Coding Interview Patterns (Desktop / Print Edition) Switch to Mobile PDF View → Word (.docx) ← Course Dashboard

Coding Interview Patterns

120 Algorithmic Blueprints
Alex Xu

Coding Interview Patterns

120 Algorithmic Blueprints
By Alex Xu

Table of Contents

CHAPTER 01

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

for i in range(n):
    for j in range(i + 1, n):
        compare(nums[i], nums[j])
for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
        compare(nums[i], nums[j]);
    }
}
for (int i = 0; i < n; i++) {
    for (int j = i + 1; j < n; j++) {
        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) 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.

CHAPTER 02

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:

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

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

Example 2:

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

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 []
function pairSumSortedBruteForce(nums, target) {
    const n = nums.length;
    for (let i = 0; i < n; i++) {
        for (let j = i + 1; j < n; j++) {
            if (nums[i] + nums[j] === target) {
                return [i, j];
            }
        }
    }
    return [];
}
public int[] pairSumSortedBruteForce(int[] nums, int target) {
    int n = nums.length;
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (nums[i] + nums[j] == target) {
                return new int[]{i, j};
            }
        }
    }
    return new int[]{};
}

This approach has a time complexity of O(n2)O(n^2), where nn 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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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 []
function pairSumSorted(nums, target) {
    let left = 0, right = nums.length - 1;
    while (left < right) {
        const 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++;
        // If the sum is larger, decrement the right pointer, aiming to decrease the
        // sum towards the target value.
        } else if (sum > target) {
            right--;
        // If the target pair is found, return its indexes.
        } else {
            return [left, right];
        }
    }
    return [];
}
public int[] pairSumSorted(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left < right) {
        int 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++;
        // If the sum is larger, decrement the right pointer, aiming to decrease the
        // sum towards the target value.
        } else if (sum > target) {
            right--;
        // If the target pair is found, return its indexes.
        } else {
            return new int[]{left, right};
        }
    }
    return new int[]{};
}

Complexity Analysis

Time complexity: The time complexity of pair_sum_sorted is O(n)O(n) because we perform approximately nn 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).

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.

CHAPTER 03

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:

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

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]
function tripletSumBruteForce(nums) {
    const n = nums.length;
    // Use a hash set to ensure we don't add duplicate triplets.
    const triplets = new Set();
    // Iterate through the indexes of all triplets.
    for (let i = 0; i < n; i++) {
        for (let j = i + 1; j < n; j++) {
            for (let k = j + 1; k < n; k++) {
                if (nums[i] + nums[j] + nums[k] === 0) {
                    // Sort the triplet before including it in the hash set.
                    const triplet = [nums[i], nums[j], nums[k]].sort((a, b) => a - b);
                    triplets.add(JSON.stringify(triplet));
                }
            }
        }
    }
    return Array.from(triplets, item => JSON.parse(item));
}
public List<List<Integer>> tripletSumBruteForce(int[] nums) {
    int n = nums.length;
    // Use a hash set to ensure we don't add duplicate triplets.
    Set<List<Integer>> triplets = new HashSet<>();
    // Iterate through the indexes of all triplets.
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            for (int k = j + 1; k < n; k++) {
                if (nums[i] + nums[j] + nums[k] == 0) {
                    // Sort the triplet before including it in the hash set.
                    List<Integer> triplet = Arrays.asList(nums[i], nums[j], nums[k]);
                    Collections.sort(triplet);
                    triplets.add(triplet);
                }
            }
        }
    }
    return new ArrayList<>(triplets);
}

This solution is quite inefficient with a time complexity of O(n3)O(n^3), where nn 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}}

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:

# 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 ...
// To prevent duplicate triplets, ensure 'a' is not a repeat of the previous element
// in the sorted array.
if (i > 0 && nums[i] === nums[i - 1]) {
    continue;
}
// ... Find triplets ...
// To prevent duplicate triplets, ensure 'a' is not a repeat of the previous element
// in the sorted array.
if (i > 0 && nums[i] == nums[i - 1]) {
    continue;
}
// ... Find triplets ...

Case 2: duplicate ‘b’ values‾\underline{\text{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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

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.

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
function tripletSum(nums) {
    const triplets = [];
    nums.sort((a, b) => a - b);
    for (let i = 0; i < nums.length; i++) {
        // 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 && nums[i] === nums[i - 1]) {
            continue;
        }
        // Find all pairs that sum to a target of '-a' ('-nums[i]').
        const pairs = pairSumSortedAllPairs(nums, i + 1, -nums[i]);
        for (const pair of pairs) {
            triplets.push([nums[i], ...pair]);
        }
    }
    return triplets;
}

function pairSumSortedAllPairs(nums, start, target) {
    const pairs = [];
    let left = start, right = nums.length - 1;
    while (left < right) {
        const sum = nums[left] + nums[right];
        if (sum === target) {
            pairs.push([nums[left], nums[right]]);
            left++; // To avoid duplicate '[b, c]' pairs, skip 'b' if it’s the same as the previous number.
            while (left < right && nums[left] === nums[left - 1]) {
                left++;
            }
        } else if (sum < target) {
            left++;
        } else {
            right--;
        }
    }
    return pairs;
}
public List<List<Integer>> tripletSum(int[] nums) {
    List<List<Integer>> triplets = new ArrayList<>();
    Arrays.sort(nums);
    for (int i = 0; i < nums.length; i++) {
        // 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 && nums[i] == nums[i - 1]) {
            continue;
        }
        // Find all pairs that sum to a target of '-a' ('-nums[i]').
        List<List<Integer>> pairs = pairSumSortedAllPairs(nums, i + 1, -nums[i]);
        for (List<Integer> pair : pairs) {
            triplets.add(Arrays.asList(nums[i], pair.get(0), pair.get(1)));
        }
    }
    return triplets;
}

public List<List<Integer>> pairSumSortedAllPairs(int[] nums, int start, int target) {
    List<List<Integer>> pairs = new ArrayList<>();
    int left = start, right = nums.length - 1;
    while (left < right) {
        int sum = nums[left] + nums[right];
        if (sum == target) {
            pairs.add(Arrays.asList(nums[left], nums[right]));
            left++; // To avoid duplicate '[b, c]' pairs, skip 'b' if it’s the same as the previous number.
            while (left < right && nums[left] == nums[left - 1]) {
                left++;
            }
        } else if (sum < target) {
            left++;
        } else {
            right--;
        }
    }
    return pairs;
}

Complexity Analysis

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

  • We first sort the array, which takes O(nlog⁡(n))O(n\log(n)) time.
  • Then, for each of the nn elements in the array, we call pair_sum_sorted_all_pairs at most once, which runs in 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).

Space complexity: The space complexity is 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). This is because the pair_sum_sorted_all_pairs function, in the worst case, can add approximately nn pairs to the output. Since this function is called approximately nn times, the overall space complexity is O(n2)O(n^2).

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

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:

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

Example 2:

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

Example
while left < right:
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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

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
function isPalindromeValid(s) {
    const isAlnum = (ch) => /[a-zA-Z0-9]/.test(ch);
    let left = 0, right = s.length - 1;
    while (left < right) {
        // Skip non-alphanumeric characters from the left.
        while (left < right && !isAlnum(s[left])) {
            left++;
        }
        // Skip non-alphanumeric characters from the right.
        while (left < right && !isAlnum(s[right])) {
            right--;
        }
        // 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++;
        right--;
    }
    return true;
}
public boolean isPalindromeValid(String s) {
    int left = 0, right = s.length() - 1;
    while (left < right) {
        // Skip non-alphanumeric characters from the left.
        while (left < right && !Character.isLetterOrDigit(s.charAt(left))) {
            left++;
        }
        // Skip non-alphanumeric characters from the right.
        while (left < right && !Character.isLetterOrDigit(s.charAt(right))) {
            right--;
        }
        // If the characters at the left and right pointers don’t match, the string is
        // not a palindrome.
        if (s.charAt(left) != s.charAt(right)) {
            return false;
        }
        left++;
        right--;
    }
    return true;
}

Complexity Analysis

Time complexity: The time complexity of is_palindrome_valid is O(n)O(n), where nn denotes the length of the string. This is because we perform approximately nn 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).

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.

CHAPTER 05

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

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
function largestContainerBruteForce(heights) {
    const n = heights.length;
    let maxWater = 0;
    // Find the maximum amount of water stored between all pairs of lines.
    for (let i = 0; i < n; i++) {
        for (let j = i + 1; j < n; j++) {
            const water = Math.min(heights[i], heights[j]) * (j - i);
            maxWater = Math.max(maxWater, water);
        }
    }
    return maxWater;
}
public int largestContainerBruteForce(int[] heights) {
    int n = heights.length;
    int maxWater = 0;
    // Find the maximum amount of water stored between all pairs of lines.
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            int water = Math.min(heights[i], heights[j]) * (j - i);
            maxWater = Math.max(maxWater, water);
        }
    }
    return maxWater;
}

Searching through all possible pairs of values takes O(n2)O(n^2) time, where nn 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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function largestContainer(heights) {
    let maxWater = 0;
    let left = 0, right = heights.length - 1;
    while (left < right) {
        // Calculate the water contained between the current pair of lines.
        const water = Math.min(heights[left], heights[right]) * (right - left);
        maxWater = Math.max(maxWater, 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++;
        } else if (heights[left] > heights[right]) {
            right--;
        } else {
            left++;
            right--;
        }
    }
    return maxWater;
}
public int largestContainer(int[] heights) {
    int maxWater = 0;
    int left = 0, right = heights.length - 1;
    while (left < right) {
        // Calculate the water contained between the current pair of lines.
        int water = Math.min(heights[left], heights[right]) * (right - left);
        maxWater = Math.max(maxWater, 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++;
        } else if (heights[left] > heights[right]) {
            right--;
        } else {
            left++;
            right--;
        }
    }
    return maxWater;
}

Complexity Analysis

Time complexity: The time complexity of largest_container is O(n)O(n) because we perform approximately nn 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).

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

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:

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

Intuition

This problem has three main requirements:

  1. Move all zeros to the end of the array.
  2. Maintain the relative order of the non-zero elements.
  3. Perform the modification in place.

A naive approach to this problem is to build the output using a separate array (temp). We can add all non-zero elements from the left of nums to this temporary array and leave the rest of it as zeros. Then, we just set the input array equal to temp.

By identifying and moving the non-zero elements from the left side of the array first, we ensure their order is preserved when we add them to the output:

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]
function shiftZerosToTheEndNaive(nums) {
    const temp = new Array(nums.length).fill(0);
    let i = 0;
    // Add all non-zero elements to the left of 'temp'.
    for (const num of nums) {
        if (num !== 0) {
            temp[i] = num;
            i++;
        }
    }
    // Set 'nums' to 'temp'.
    for (let j = 0; j < nums.length; j++) {
        nums[j] = temp[j];
    }
}
public void shiftZerosToTheEndNaive(int[] nums) {
    int[] temp = new int[nums.length];
    int i = 0;
    // Add all non-zero elements to the left of 'temp'.
    for (int num : nums) {
        if (num != 0) {
            temp[i] = num;
            i++;
        }
    }
    // Set 'nums' to 'temp'.
    for (int j = 0; j < nums.length; j++) {
        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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

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.

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
function shiftZerosToTheEnd(nums) {
    // The 'left' pointer is used to position non-zero elements.
    let left = 0;
    // Iterate through the array using a 'right' pointer to locate non-zero
    // elements.
    for (let right = 0; right < nums.length; right++) {
        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++;
        }
    }
}
public void shiftZerosToTheEnd(int[] nums) {
    // The 'left' pointer is used to position non-zero elements.
    int left = 0;
    // Iterate through the array using a 'right' pointer to locate non-zero
    // elements.
    for (int right = 0; right < nums.length; right++) {
        if (nums[right] != 0) {
            if (right != left) {
                int temp = nums[left];
                nums[left] = nums[right];
                nums[right] = temp;
            }
            // Increment 'left' since it now points to a position already occupied
            // by a non-zero element.
            left++;
        }
    }
}

Complexity Analysis

Time complexity: The time complexity of shift_zeros_to_the_end is O(n)O(n), where nn 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) 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.
CHAPTER 07

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:

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

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

Example 2:

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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)
function nextLexicographicalSequence(s) {
    const letters = s.split('');
    // Locate the pivot, which is the first character from the right that breaks
    // non-increasing order. Start searching from the second-to-last position.
    let pivot = letters.length - 2;
    while (pivot >= 0 && letters[pivot] >= letters[pivot + 1]) {
        pivot--;
    }
    // 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 letters.reverse().join('');
    }
    // Find the rightmost successor to the pivot.
    let rightmostSuccessor = letters.length - 1;
    while (letters[rightmostSuccessor] <= letters[pivot]) {
        rightmostSuccessor--;
    }
    // Swap the rightmost successor with the pivot to increase the lexicographical
    // order of the suffix.
    [letters[pivot], letters[rightmostSuccessor]] = [letters[rightmostSuccessor], letters[pivot]];
    // Reverse the suffix after the pivot to minimize its permutation.
    let left = pivot + 1, right = letters.length - 1;
    while (left < right) {
        [letters[left], letters[right]] = [letters[right], letters[left]];
        left++;
        right--;
    }
    return letters.join('');
}
public String nextLexicographicalSequence(String s) {
    char[] letters = s.toCharArray();
    // Locate the pivot, which is the first character from the right that breaks
    // non-increasing order. Start searching from the second-to-last position.
    int pivot = letters.length - 2;
    while (pivot >= 0 && letters[pivot] >= letters[pivot + 1]) {
        pivot--;
    }
    // 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 new StringBuilder(new String(letters)).reverse().toString();
    }
    // Find the rightmost successor to the pivot.
    int rightmostSuccessor = letters.length - 1;
    while (letters[rightmostSuccessor] <= letters[pivot]) {
        rightmostSuccessor--;
    }
    // Swap the rightmost successor with the pivot to increase the lexicographical
    // order of the suffix.
    char temp = letters[pivot];
    letters[pivot] = letters[rightmostSuccessor];
    letters[rightmostSuccessor] = temp;
    // Reverse the suffix after the pivot to minimize its permutation.
    int left = pivot + 1, right = letters.length - 1;
    while (left < right) {
        char t = letters[left];
        letters[left] = letters[right];
        letters[right] = t;
        left++;
        right--;
    }
    return new String(letters);
}

Complexity Analysis

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

Space complexity: The space complexity is 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 08

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

CHAPTER 09

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:

Example
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), where nn 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)) 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) 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) 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:

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 []
function pairSumUnsortedTwoPass(nums, target) {
    const numMap = new Map();
    // First pass: Populate the hash map with each number and its index.
    for (let i = 0; i < nums.length; i++) {
        numMap.set(nums[i], i);
    }
    // Second pass: Check for each number's complement in the hash map.
    for (let i = 0; i < nums.length; i++) {
        const complement = target - nums[i];
        if (numMap.has(complement) && numMap.get(complement) !== i) {
            return [i, numMap.get(complement)];
        }
    }
    return [];
}
public int[] pairSumUnsortedTwoPass(int[] nums, int target) {
    Map<Integer, Integer> numMap = new HashMap<>();
    // First pass: Populate the hash map with each number and its index.
    for (int i = 0; i < nums.length; i++) {
        numMap.put(nums[i], i);
    }
    // Second pass: Check for each number's complement in the hash map.
    for (int i = 0; i < nums.length; i++) {
        int complement = target - nums[i];
        if (numMap.containsKey(complement) && numMap.get(complement) != i) {
            return new int[]{i, numMap.get(complement)};
        }
    }
    return new int[]{};
}

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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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 []
function pairSumUnsorted(nums, target) {
    const hashmap = new Map();
    for (let i = 0; i < nums.length; i++) {
        const x = nums[i];
        if (hashmap.has(target - x)) {
            return [hashmap.get(target - x), i];
        }
        hashmap.set(x, i);
    }
    return [];
}
public int[] pairSumUnsorted(int[] nums, int target) {
    Map<Integer, Integer> hashmap = new HashMap<>();
    for (int i = 0; i < nums.length; i++) {
        int x = nums[i];
        if (hashmap.containsKey(target - x)) {
            return new int[]{hashmap.get(target - x), i};
        }
        hashmap.put(x, i);
    }
    return new int[]{};
}

Complexity Analysis

Time complexity: The time complexity of pair_sum_unsorted is 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) since the hash map can grow up to nn 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.

CHAPTER 10

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function verifySudokuBoard(board) {
    // Create hash sets for each row, column, and subgrid to keep track of numbers
    // previously seen on any given row, column, or subgrid.
    const rowSets = Array.from({ length: 9 }, () => new Set());
    const columnSets = Array.from({ length: 9 }, () => new Set());
    const subgridSets = Array.from({ length: 3 }, () => Array.from({ length: 3 }, () => new Set()));
    for (let r = 0; r < 9; r++) {
        for (let c = 0; c < 9; c++) {
            const num = board[r][c];
            if (num === 0) {
                continue;
            }
            // Check if 'num' has been seen in the current row, column, or subgrid.
            if (rowSets[r].has(num)) {
                return false;
            }
            if (columnSets[c].has(num)) {
                return false;
            }
            if (subgridSets[Math.floor(r / 3)][Math.floor(c / 3)].has(num)) {
                return false;
            }
            // If we passed the above checks, mark this value as seen by adding it to
            // its corresponding hash sets.
            rowSets[r].add(num);
            columnSets[c].add(num);
            subgridSets[Math.floor(r / 3)][Math.floor(c / 3)].add(num);
        }
    }
    return true;
}
public boolean verifySudokuBoard(int[][] board) {
    // Create hash sets for each row, column, and subgrid to keep track of numbers
    // previously seen on any given row, column, or subgrid.
    List<Set<Integer>> rowSets = new ArrayList<>();
    List<Set<Integer>> columnSets = new ArrayList<>();
    List<List<Set<Integer>>> subgridSets = new ArrayList<>();
    for (int i = 0; i < 9; i++) {
        rowSets.add(new HashSet<>());
        columnSets.add(new HashSet<>());
    }
    for (int i = 0; i < 3; i++) {
        List<Set<Integer>> row = new ArrayList<>();
        for (int j = 0; j < 3; j++) {
            row.add(new HashSet<>());
        }
        subgridSets.add(row);
    }
    for (int r = 0; r < 9; r++) {
        for (int c = 0; c < 9; c++) {
            int num = board[r][c];
            if (num == 0) {
                continue;
            }
            // Check if 'num' has been seen in the current row, column, or subgrid.
            if (rowSets.get(r).contains(num)) {
                return false;
            }
            if (columnSets.get(c).contains(num)) {
                return false;
            }
            if (subgridSets.get(r / 3).get(c / 3).contains(num)) {
                return false;
            }
            // If we passed the above checks, mark this value as seen by adding it to
            // its corresponding hash sets.
            rowSets.get(r).add(num);
            columnSets.get(c).add(num);
            subgridSets.get(r / 3).get(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). However, to better understand the efficiency of our algorithm in a broader context, let's use nn 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) 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) due to the row_sets, column_sets, and subgrid_sets arrays. Each array contains nn hash sets, and each hash set is capable of growing to a size of nn.

CHAPTER 11

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)), where m⋅nm \cdot n denotes the number of 0s, and (m+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) 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) 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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Hash Sets

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
function zeroStripingHashSets(matrix) {
    if (!matrix || matrix.length === 0 || matrix[0].length === 0) {
        return;
    }
    const m = matrix.length, n = matrix[0].length;
    const zeroRows = new Set(), zeroCols = new 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 (let r = 0; r < m; r++) {
        for (let c = 0; c < n; c++) {
            if (matrix[r][c] === 0) {
                zeroRows.add(r);
                zeroCols.add(c);
            }
        }
    }
    // Pass 2: Set any cell in the matrix to zero if its row index is in 'zeroRows'
    // or its column index is in 'zeroCols'.
    for (let r = 0; r < m; r++) {
        for (let c = 0; c < n; c++) {
            if (zeroRows.has(r) || zeroCols.has(c)) {
                matrix[r][c] = 0;
            }
        }
    }
}
public void zeroStripingHashSets(int[][] matrix) {
    if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
        return;
    }
    int m = matrix.length, n = matrix[0].length;
    Set<Integer> zeroRows = new HashSet<>();
    Set<Integer> zeroCols = new HashSet<>();
    // Pass 1: Traverse through the matrix to identify the rows and columns
    // containing zeros and store their indexes in the appropriate hash sets.
    for (int r = 0; r < m; r++) {
        for (int c = 0; c < n; c++) {
            if (matrix[r][c] == 0) {
                zeroRows.add(r);
                zeroCols.add(c);
            }
        }
    }
    // Pass 2: Set any cell in the matrix to zero if its row index is in 'zeroRows'
    // or its column index is in 'zeroCols'.
    for (int r = 0; r < m; r++) {
        for (int c = 0; c < n; c++) {
            if (zeroRows.contains(r) || zeroCols.contains(c)) {
                matrix[r][c] = 0;
            }
        }
    }
}

Complexity Analysis

Time complexity: The time complexity of zero_striping_hash_sets is O(m⋅n)O(m\cdot 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) 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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - In-place Zero Tracking

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
function zeroStriping(matrix) {
    if (!matrix || matrix.length === 0 || matrix[0].length === 0) {
        return;
    }
    const m = matrix.length, n = matrix[0].length;
    // Check if the first row initially contains a zero.
    let firstRowHasZero = false;
    for (let c = 0; c < n; c++) {
        if (matrix[0][c] === 0) {
            firstRowHasZero = true;
            break;
        }
    }
    // Check if the first column initially contains a zero.
    let firstColHasZero = false;
    for (let r = 0; r < m; r++) {
        if (matrix[r][0] === 0) {
            firstColHasZero = 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 (let r = 1; r < m; r++) {
        for (let c = 1; c < n; c++) {
            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 (let r = 1; r < m; r++) {
        for (let c = 1; c < n; c++) {
            if (matrix[0][c] === 0 || 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 (firstRowHasZero) {
        for (let c = 0; c < n; c++) {
            matrix[0][c] = 0;
        }
    }
    // If the first column had a zero initially, set all elements in the first column
    // to zero.
    if (firstColHasZero) {
        for (let r = 0; r < m; r++) {
            matrix[r][0] = 0;
        }
    }
}
public void zeroStriping(int[][] matrix) {
    if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
        return;
    }
    int m = matrix.length, n = matrix[0].length;
    // Check if the first row initially contains a zero.
    boolean firstRowHasZero = false;
    for (int c = 0; c < n; c++) {
        if (matrix[0][c] == 0) {
            firstRowHasZero = true;
            break;
        }
    }
    // Check if the first column initially contains a zero.
    boolean firstColHasZero = false;
    for (int r = 0; r < m; r++) {
        if (matrix[r][0] == 0) {
            firstColHasZero = 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 (int r = 1; r < m; r++) {
        for (int c = 1; c < n; c++) {
            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 (int r = 1; r < m; r++) {
        for (int c = 1; c < n; c++) {
            if (matrix[0][c] == 0 || 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 (firstRowHasZero) {
        for (int c = 0; c < n; c++) {
            matrix[0][c] = 0;
        }
    }
    // If the first column had a zero initially, set all elements in the first column
    // to zero.
    if (firstColHasZero) {
        for (int r = 0; r < m; r++) {
            matrix[r][0] = 0;
        }
    }
}

Complexity Analysis

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

  • Checking the first row for zeros takes O(m)O(m) time, and checking the first column takes 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) time.
  • Finally, we iterate through the first row and first column up to once each, which takes O(m)O(m) and 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).

Space complexity: The space complexity is 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.

CHAPTER 12

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:

Example
Input: nums = [1, 6, 2, 5, 8, 7, 10, 3]
Output: 4

Explanation: The longest chain of consecutive numbers is 5, 6, 7, 8.

Intuition

A naive approach to this problem is to sort the array. When all numbers are arranged in ascending order, consecutive numbers will be placed next to each other. This allows us to 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)) time, where nn 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:

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
function longestChainOfConsecutiveNumbersBruteForce(nums) {
    if (!nums || nums.length === 0) {
        return 0;
    }
    let longestChain = 0;
    // Look for chains of consecutive numbers that start from each number.
    for (const num of nums) {
        let currentNum = num;
        let currentChain = 1;
        // Continue to find the next consecutive numbers in the chain.
        while (nums.includes(currentNum + 1)) {
            currentNum += 1;
            currentChain += 1;
        }
        longestChain = Math.max(longestChain, currentChain);
    }
    return longestChain;
}
public int longestChainOfConsecutiveNumbersBruteForce(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }
    int longestChain = 0;
    // Look for chains of consecutive numbers that start from each number.
    for (int num : nums) {
        int currentNum = num;
        int currentChain = 1;
        // Continue to find the next consecutive numbers in the chain.
        while (contains(nums, currentNum + 1)) {
            currentNum += 1;
            currentChain += 1;
        }
        longestChain = Math.max(longestChain, currentChain);
    }
    return longestChain;
}

private boolean contains(int[] nums, int target) {
    for (int x : nums) {
        if (x == target) {
            return true;
        }
    }
    return false;
}

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

  • The outer for-loop iterates through each element, which takes 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) 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) to O(n2)O(n^2).

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) to O(n)O(n), as now every chain is searched through only once. This is explained in more detail in the complexity analysis.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function longestChainOfConsecutiveNumbers(nums) {
    if (!nums || nums.length === 0) {
        return 0;
    }
    const numSet = new Set(nums);
    let longestChain = 0;
    for (const num of numSet) {
        // If the current number is the smallest number in its chain, search for
        // the length of its chain.
        if (!numSet.has(num - 1)) {
            let currentNum = num;
            let currentChain = 1;
            // Continue to find the next consecutive numbers in the chain.
            while (numSet.has(currentNum + 1)) {
                currentNum += 1;
                currentChain += 1;
            }
            longestChain = Math.max(longestChain, currentChain);
        }
    }
    return longestChain;
}
public int longestChainOfConsecutiveNumbers(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }
    Set<Integer> numSet = new HashSet<>();
    for (int num : nums) {
        numSet.add(num);
    }
    int longestChain = 0;
    for (int num : numSet) {
        // If the current number is the smallest number in its chain, search for
        // the length of its chain.
        if (!numSet.contains(num - 1)) {
            int currentNum = num;
            int currentChain = 1;
            // Continue to find the next consecutive numbers in the chain.
            while (numSet.contains(currentNum + 1)) {
                currentNum += 1;
                currentChain += 1;
            }
            longestChain = Math.max(longestChain, currentChain);
        }
    }
    return longestChain;
}

Complexity Analysis

Time complexity: The time complexity of longest_chain_of_consecutive_numbers is 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): the outer for-loop runs nn times, and the inner while-loop runs a total of nn times across all iterations, resulting in a combined time complexity of O(n+n)=O(n)O(n+n)=O(n).

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

CHAPTER 13

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}} (i.e., [1, 1⋅2‾\underline{\text{2}} = 2, 2⋅2‾\underline{\text{2}} = 4]).
  • (5, 15, 45): This is a geometric sequence with a ratio of 3‾\underline{\text{3}} (i.e., [5, 5⋅3‾\underline{\text{3}} = 15, 15⋅3‾\underline{\text{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).
Example
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), where nn 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) 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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function geometricSequenceTriplets(nums, r) {
    // 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.
    const leftMap = new Map();
    const rightMap = new Map();
    let count = 0;
    // Populate 'rightMap' with the frequency of each element in the array.
    for (const x of nums) {
        rightMap.set(x, (rightMap.get(x) || 0) + 1);
    }
    // Search for geometric triplets that have x as the center.
    for (const x of nums) {
        // Decrement the frequency of x in 'rightMap' since x is now being
        // processed and is no longer to the right.
        rightMap.set(x, rightMap.get(x) - 1);
        if (x % r === 0) {
            const leftCount = leftMap.get(x / r) || 0;
            const rightCount = rightMap.get(x * r) || 0;
            count += leftCount * rightCount;
        }
        // Increment the frequency of x in 'leftMap' since it'll be a part of the
        // left side of the array once we iterate to the next value of `x`.
        leftMap.set(x, (leftMap.get(x) || 0) + 1);
    }
    return count;
}
public long geometricSequenceTriplets(int[] nums, int r) {
    // Track frequencies of elements to the left and right of the center element.
    Map<Long, Long> leftMap = new HashMap<>();
    Map<Long, Long> rightMap = new HashMap<>();
    long count = 0;
    // Populate 'rightMap' with the frequency of each element in the array.
    for (int x : nums) {
        rightMap.put((long) x, rightMap.getOrDefault((long) x, 0L) + 1L);
    }
    // Search for geometric triplets that have x as the center.
    for (int x : nums) {
        // Decrement the frequency of x in 'rightMap' since x is now being
        // processed and is no longer to the right.
        rightMap.put((long) x, rightMap.get((long) x) - 1L);
        if (x % r == 0) {
            long leftCount = leftMap.getOrDefault((long) x / r, 0L);
            long rightCount = rightMap.getOrDefault((long) x * r, 0L);
            count += leftCount * rightCount;
        }
        // Increment the frequency of x in 'leftMap' since it'll be a part of the
        // left side of the array once we iterate to the next value of `x`.
        leftMap.put((long) x, leftMap.getOrDefault((long) x, 0L) + 1L);
    }
    return count;
}

Complexity Analysis

Time complexity: The time complexity of geometric_sequence_triplets is 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) because the hash maps can grow up to nn in size.

CHAPTER 14

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:

class ListNode:
   def __init__(self, val: int, next: ListNode):
       self.val = val
       self.next = next
class ListNode {
    constructor(val = 0, next = null) {
        this.val = val;
        this.next = next;
    }
}
public class ListNode {
    int val;
    ListNode next;

    ListNode(int val) {
        this.val = val;
        this.next = null;
    }

    ListNode(int val, ListNode next) {
        this.val = val;
        this.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).
CHAPTER 15

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Iterative

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
function linkedListReversal(head) {
    let currNode = head, prevNode = null;
    // Reverse the direction of each node's pointer until 'currNode' is null.
    while (currNode) {
        const nextNode = currNode.next;
        currNode.next = prevNode;
        prevNode = currNode;
        currNode = nextNode;
    }
    // 'prevNode' will be pointing at the head of the reversed linked list.
    return prevNode;
}
public ListNode linkedListReversal(ListNode head) {
    ListNode currNode = head, prevNode = null;
    // Reverse the direction of each node's pointer until 'currNode' is null.
    while (currNode != null) {
        ListNode nextNode = currNode.next;
        currNode.next = prevNode;
        prevNode = currNode;
        currNode = nextNode;
    }
    // 'prevNode' will be pointing at the head of the reversed linked list.
    return prevNode;
}

Complexity Analysis

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

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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Recursive

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
function linkedListReversalRecursive(head) {
    // Base cases.
    if (!head || !head.next) {
        return head;
    }
    // Recursively reverse the sublist starting at the next node.
    const newHead = linkedListReversalRecursive(head.next);
    // Connect the reversed sublist to the head node to fully reverse the entire linked list.
    head.next.next = head;
    head.next = null;
    return newHead;
}
public ListNode linkedListReversalRecursive(ListNode head) {
    // Base cases.
    if (head == null || head.next == null) {
        return head;
    }
    // Recursively reverse the sublist starting at the next node.
    ListNode newHead = linkedListReversalRecursive(head.next);
    // Connect the reversed sublist to the head node to fully reverse the entire linked list.
    head.next.next = head;
    head.next = null;
    return newHead;
}

Complexity Analysis

Time complexity: The time complexity of linked_list_reversal_recursive is 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) due to the stack space taken up by the recursive call stack, which grows up to nn levels deep because nn 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.

CHAPTER 16

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function removeKthLastNode(head, k) {
    // A dummy node to ensure there's a node before 'head' in case we need to remove
    // the head node.
    const dummy = new ListNode(-1);
    dummy.next = head;
    let trailer = dummy, leader = dummy;
    // Advance 'leader' k steps ahead.
    for (let i = 0; i < k; i++) {
        leader = leader.next;
        // If k is larger than the length of the linked list, no node needs to be
        // removed.
        if (!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;
}
public ListNode removeKthLastNode(ListNode head, int k) {
    // A dummy node to ensure there's a node before 'head' in case we need to remove
    // the head node.
    ListNode dummy = new ListNode(-1);
    dummy.next = head;
    ListNode trailer = dummy, leader = dummy;
    // Advance 'leader' k steps ahead.
    for (int i = 0; i < k; i++) {
        leader = leader.next;
        // If k is larger than the length of the linked list, no node needs to be
        // removed.
        if (leader == null) {
            return head;
        }
    }
    // Move 'leader' to the end of the linked list, keeping 'trailer' k nodes behind.
    while (leader.next != null) {
        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). This is because the algorithm first traverses at most nn 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).

CHAPTER 17

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function linkedListIntersection(headA, headB) {
    let ptrA = headA, ptrB = headB;
    // Traverse through list A with 'ptrA' and list B with 'ptrB' until they meet.
    while (ptrA !== ptrB) {
        // Traverse list A -> list B by first traversing 'ptrA' and then, upon
        // reaching the end of list A, continue the traversal from the head of list B.
        ptrA = ptrA ? ptrA.next : headB;
        // Simultaneously, traverse list B -> list A.
        ptrB = ptrB ? ptrB.next : headA;
    }
    // At this point, 'ptrA' and 'ptrB' either point to the intersection node or both
    // are null if the lists do not intersect. Return either pointer.
    return ptrA;
}
public ListNode linkedListIntersection(ListNode headA, ListNode headB) {
    ListNode ptrA = headA, ptrB = headB;
    // Traverse through list A with 'ptrA' and list B with 'ptrB' until they meet.
    while (ptrA != ptrB) {
        // Traverse list A -> list B by first traversing 'ptrA' and then, upon
        // reaching the end of list A, continue the traversal from the head of list B.
        ptrA = (ptrA != null) ? ptrA.next : headB;
        // Simultaneously, traverse list B -> list A.
        ptrB = (ptrB != null) ? ptrB.next : headA;
    }
    // At this point, 'ptrA' and 'ptrB' either point to the intersection node or both
    // are null if the lists do not intersect. Return either pointer.
    return ptrA;
}

Complexity Analysis

Time complexity: The time complexity of linked_list_intersection is O(n+m)O(n+m), where nn and mm 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).

CHAPTER 18

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:

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

Example
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}}

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

Adding or removing a node from the head of a linked list takes 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) 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) 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}}

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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

class DoublyLinkedListNode:
   def __init__(self, key: int, val: int):
       self.key = key
       self.val = val
       self.next = self.prev = None
class DoublyLinkedListNode {
    constructor(key, val) {
        this.key = key;
        this.val = val;
        this.next = null;
        this.prev = null;
    }
}
public class DoublyLinkedListNode {
    int key;
    int val;
    DoublyLinkedListNode next;
    DoublyLinkedListNode prev;

    public DoublyLinkedListNode(int key, int val) {
        this.key = key;
        this.val = val;
        this.next = null;
        this.prev = null;
    }
}

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:

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
addToTail(node) {
    const prevNode = this.tail.prev;
    node.prev = prevNode;
    node.next = this.tail;
    prevNode.next = node;
    this.tail.prev = node;
}
public void addToTail(DoublyLinkedListNode node) {
    DoublyLinkedListNode prevNode = this.tail.prev;
    node.prev = prevNode;
    node.next = this.tail;
    prevNode.next = node;
    this.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:

def remove_node(self, node: DoublyLinkedListNode) -> None:
    node.prev.next = node.next
    node.next.prev = node.prev
removeNode(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
}
public void removeNode(DoublyLinkedListNode node) {
    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

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
class DoublyLinkedListNode {
    constructor(key, val) {
        this.key = key;
        this.val = val;
        this.next = null;
        this.prev = null;
    }
}

class LRUCache {
    constructor(capacity) {
        this.capacity = capacity;
        // A hash map that maps keys to nodes.
        this.hashmap = new Map();
        // Initialize the head and tail dummy nodes and connect them to 
        // each other to establish a basic two-node doubly linked list.
        this.head = new DoublyLinkedListNode(-1, -1);
        this.tail = new DoublyLinkedListNode(-1, -1);
        this.head.next = this.tail;
        this.tail.prev = this.head;
    }

    get(key) {
        if (!this.hashmap.has(key)) {
            return -1;
        }
        // To make this key the most recently used, remove its node and
        // re-add it to the tail of the linked list.
        const node = this.hashmap.get(key);
        this.removeNode(node);
        this.addToTail(node);
        return node.val;
    }

    put(key, value) {
        // If a node with this key already exists, remove it from the
        // linked list.
        if (this.hashmap.has(key)) {
            this.removeNode(this.hashmap.get(key));
        }
        const node = new DoublyLinkedListNode(key, value);
        this.hashmap.set(key, node);
        // Remove the least recently used node from the cache if adding
        // this new node will result in an overflow.
        if (this.hashmap.size > this.capacity) {
            const lruNode = this.head.next;
            this.hashmap.delete(lruNode.key);
            this.removeNode(lruNode);
        }
        this.addToTail(node);
    }

    addToTail(node) {
        const prevNode = this.tail.prev;
        node.prev = prevNode;
        node.next = this.tail;
        prevNode.next = node;
        this.tail.prev = node;
    }

    removeNode(node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
}
class DoublyLinkedListNode {
    int key;
    int val;
    DoublyLinkedListNode next;
    DoublyLinkedListNode prev;

    public DoublyLinkedListNode(int key, int val) {
        this.key = key;
        this.val = val;
        this.next = null;
        this.prev = null;
    }
}

public class LRUCache {
    private int capacity;
    // A hash map that maps keys to nodes.
    private Map<Integer, DoublyLinkedListNode> hashmap;
    private DoublyLinkedListNode head;
    private DoublyLinkedListNode tail;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.hashmap = new HashMap<>();
        // Initialize the head and tail dummy nodes and connect them to 
        // each other to establish a basic two-node doubly linked list.
        this.head = new DoublyLinkedListNode(-1, -1);
        this.tail = new DoublyLinkedListNode(-1, -1);
        this.head.next = this.tail;
        this.tail.prev = this.head;
    }

    public int get(int key) {
        if (!hashmap.containsKey(key)) {
            return -1;
        }
        // To make this key the most recently used, remove its node and
        // re-add it to the tail of the linked list.
        DoublyLinkedListNode node = hashmap.get(key);
        removeNode(node);
        addToTail(node);
        return node.val;
    }

    public void put(int key, int value) {
        // If a node with this key already exists, remove it from the
        // linked list.
        if (hashmap.containsKey(key)) {
            removeNode(hashmap.get(key));
        }
        DoublyLinkedListNode node = new DoublyLinkedListNode(key, value);
        hashmap.put(key, node);
        // Remove the least recently used node from the cache if adding
        // this new node will result in an overflow.
        if (hashmap.size() > capacity) {
            DoublyLinkedListNode lruNode = head.next;
            hashmap.remove(lruNode.key);
            removeNode(lruNode);
        }
        addToTail(node);
    }

    public void addToTail(DoublyLinkedListNode node) {
        DoublyLinkedListNode prevNode = this.tail.prev;
        node.prev = prevNode;
        node.next = this.tail;
        prevNode.next = node;
        this.tail.prev = node;
    }

    public void removeNode(DoublyLinkedListNode node) {
        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) 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) time complexity.

Space complexity: The overall space complexity of this solution is O(n)O(n), where nn is the capacity of the cache. This is because both the doubly linked list and hash map can each occupy 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.

CHAPTER 19

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.
Example
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.
Example
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) 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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function palindromicLinkedList(head) {
    // Find the middle of the linked list and then reverse the second half of the
    // linked list starting at this midpoint.
    const mid = findMiddle(head);
    const secondHead = reverseList(mid);
    // Compare the first half and the reversed second half of the list
    let ptr1 = head, ptr2 = secondHead;
    let res = true;
    while (ptr2) {
        if (ptr1.val !== ptr2.val) {
            res = false;
        }
        ptr1 = ptr1.next;
        ptr2 = ptr2.next;
    }
    return res;
}

// From the 'Reverse Linked List' problem.
function reverseList(head) {
    let prevNode = null, currNode = head;
    while (currNode) {
        const nextNode = currNode.next;
        currNode.next = prevNode;
        prevNode = currNode;
        currNode = nextNode;
    }
    return prevNode;
}

// From the 'Linked List Midpoint' problem.
function findMiddle(head) {
    let slow = head, fast = head;
    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}
public boolean palindromicLinkedList(ListNode head) {
    // Find the middle of the linked list and then reverse the second half of the
    // linked list starting at this midpoint.
    ListNode mid = findMiddle(head);
    ListNode secondHead = reverseList(mid);
    // Compare the first half and the reversed second half of the list
    ListNode ptr1 = head, ptr2 = secondHead;
    boolean res = true;
    while (ptr2 != null) {
        if (ptr1.val != ptr2.val) {
            res = false;
        }
        ptr1 = ptr1.next;
        ptr2 = ptr2.next;
    }
    return res;
}

// From the 'Reverse Linked List' problem.
public ListNode reverseList(ListNode head) {
    ListNode prevNode = null, currNode = head;
    while (currNode != null) {
        ListNode nextNode = currNode.next;
        currNode.next = prevNode;
        prevNode = currNode;
        currNode = nextNode;
    }
    return prevNode;
}

// From the 'Linked List Midpoint' problem.
public ListNode findMiddle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        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), where nn 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).

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.

CHAPTER 20

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

The definition of the MultiLevelListNode class is provided below:

class MultiLevelListNode:
    def __init__(self, val, next, child):
        self.val = val
        self.next = next
        self.child = child
class MultiLevelListNode {
    constructor(val = 0, next = null, child = null) {
        this.val = val;
        this.next = next;
        this.child = child;
    }
}
public class MultiLevelListNode {
    int val;
    MultiLevelListNode next;
    MultiLevelListNode child;

    public MultiLevelListNode(int val) {
        this.val = val;
        this.next = null;
        this.child = null;
    }

    public MultiLevelListNode(int val, MultiLevelListNode next, MultiLevelListNode child) {
        this.val = val;
        this.next = next;
        this.child = child;
    }
}
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
function flattenMultiLevelList(head) {
    if (!head) {
        return null;
    }
    let tail = head;
    // Find the tail of the linked list at the first level.
    while (tail.next) {
        tail = tail.next;
    }
    let 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 = null;
            while (tail.next) {
                tail = tail.next;
            }
        }
        curr = curr.next;
    }
    return head;
}
public MultiLevelListNode flattenMultiLevelList(MultiLevelListNode head) {
    if (head == null) {
        return null;
    }
    MultiLevelListNode tail = head;
    // Find the tail of the linked list at the first level.
    while (tail.next != null) {
        tail = tail.next;
    }
    MultiLevelListNode 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 != null) {
        if (curr.child != null) {
            tail.next = curr.child;
            curr.child = null;
            while (tail.next != null) {
                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), where nn 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).

CHAPTER 21

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

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

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
function linkedListLoopNaive(head) {
    const visited = new Set();
    let curr = head;
    while (curr) {
        // Cycle detected if the current node has already been visited.
        if (visited.has(curr)) {
            return true;
        }
        visited.add(curr);
        curr = curr.next;
    }
    return false;
}
public boolean linkedListLoopNaive(ListNode head) {
    Set<ListNode> visited = new HashSet<>();
    ListNode curr = head;
    while (curr != null) {
        // Cycle detected if the current node has already been visited.
        if (visited.contains(curr)) {
            return true;
        }
        visited.add(curr);
        curr = curr.next;
    }
    return false;
}

This solution takes O(n)O(n) time, where nn 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) 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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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

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
function linkedListLoop(head) {
    let slow = head, fast = head;
    // Check both 'fast' and 'fast.next' to avoid null pointer exceptions when we
    // perform 'fast.next' and 'fast.next.next'.
    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;
        if (fast === slow) {
            return true;
        }
    }
    return false;
}
public boolean linkedListLoop(ListNode head) {
    ListNode slow = head, fast = head;
    // Check both 'fast' and 'fast.next' to avoid null pointer exceptions when we
    // perform 'fast.next' and 'fast.next.next'.
    while (fast != null && fast.next != null) {
        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) 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).

CHAPTER 23

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function linkedListMidpoint(head) {
    let slow = head, 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 && fast.next) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}
public ListNode linkedListMidpoint(ListNode head) {
    ListNode slow = head, 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 != null && fast.next != null) {
        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) because we traverse the linked list linearly using two pointers.

Space complexity: The space complexity is 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.

CHAPTER 24

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:

Example
Input: n = 23
Output: True

Explanation: 222^2 + 323^2 = 13 ⇒ 121^2 + 323^2 = 10 ⇒ 121^2 + 020^2 = 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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function happyNumber(n) {
    let slow = n, fast = n;
    while (true) {
        slow = getNextNum(slow);
        fast = getNextNum(getNextNum(fast));
        if (fast === 1) {
            return true;
        // If the fast and slow pointers meet, a cycle is detected. Hence, 'n' is not
        // a happy number.
        } else if (fast === slow) {
            return false;
        }
    }
}

function getNextNum(x) {
    let nextNum = 0;
    while (x > 0) {
        // Extract the last digit of 'x'.
        const digit = x % 10;
        // Truncate (remove) the last digit from 'x' using floor division.
        x = Math.floor(x / 10);
        // Add the square of the extracted digit to the sum.
        nextNum += digit * digit;
    }
    return nextNum;
}
public boolean happyNumber(int n) {
    int slow = n, fast = n;
    while (true) {
        slow = getNextNum(slow);
        fast = getNextNum(getNextNum(fast));
        if (fast == 1) {
            return true;
        // If the fast and slow pointers meet, a cycle is detected. Hence, 'n' is not
        // a happy number.
        } else if (fast == slow) {
            return false;
        }
    }
}

public int getNextNum(int x) {
    int nextNum = 0;
    while (x > 0) {
        // Extract the last digit of 'x'.
        int 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.
        nextNum += digit * digit;
    }
    return nextNum;
}

Complexity Analysis

Time complexity: The time complexity of happy_number is 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).

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 nn as the result obtained by summing the squares of the digits of nn.

1) Upper limit for the next number For any number nn 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 nn has 1 or 2 digits, it’s possible for the next number to be larger than nn (e.g., the next number of 99 is 162, which is one digit longer).

  • If a number nn has 3 or more digits, the next number is always smaller than the original value of nn. 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 nn larger than 243 will have the next number smaller than nn. 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).

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

Let's call nn’s next number n2n^2. The next number after n2n^2 (n3n^3) will take approximately log⁡(n2)\log(n^2) steps to calculate. The next number after n3n^3 (n4n^4) will take approximately log⁡(n3)\log(n^3) 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)+…). Since we've established that n>n2>…>nkn>n^2>…>n^k where nkn^k is the last number greater than 243, the dominant component of this time complexity is O(log⁡(n))O(\log(n)). So, the time complexity for numbers greater than 243 is O(log⁡(n))O(\log(n)).

Conclusion When nn is less than 243, the time complexity is O(1), and when nn is greater than 243, the time complexity is O(log⁡(n))O(\log(n)). Therefore, the overall time complexity of the algorithm is 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 25

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) 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) time, in comparison. But before discussing how they work, let’s establish some terminology:

  • To expand the window is to advance the right pointer:

    Image represents a visual explanation of an array expansion operation.  The left side shows an initial state with a light-blue rounded rectangle representing an array containing several black dots (elements).  Above this array are two gray rectangular boxes labeled 'left' and 'right,' each with a downward arrow pointing to the corresponding end of the array.  A gap with additional dots indicates the array's continuation beyond the visible portion. A thick black arrow labeled 'expand' connects the initial array to a modified array on the right. This expanded array is also a light-blue rounded rectangle with more black dots than the initial array, indicating the addition of elements.  The 'left' box remains in a similar position above the expanded array, pointing to its left end. However, the 'right' box is now orange, and its arrow points to a location within the expanded array, suggesting the insertion or addition of elements near the right end.  A dotted line connects the orange 'right' box to the newly added elements, visually highlighting the expansion at the right side of the array.  The expanded array also shows a continuation gap with additional dots, similar to the initial array.
  • To shrink the window is to advance the left pointer:

    Image represents a visual depiction of a 'shrink' operation within a coding pattern context.  The diagram shows two stages. The first stage displays two rectangular boxes labeled 'left' (grey) and 'right' (grey), each pointing downwards with an arrow to a light-blue, horizontally-elongated oval shape containing several black dots representing data elements.  The second stage shows a similar structure, but with the 'left' box now colored orange, indicating a change.  The light-blue oval in the second stage is smaller, containing fewer dots. A thick black arrow labeled 'shrink' connects the two stages, illustrating the transformation. A dashed orange curved arrow connects the orange 'left' box to the leftmost dot in the smaller light-blue oval, highlighting that the left side is the primary focus of the shrinking operation.  The overall arrangement demonstrates how data elements are reduced or removed, specifically from the left side, resulting in a smaller dataset.
  • To slide the window is to advance both the left and right pointers:

    Image represents a visual depiction of a 'slide' operation in a data structure, likely an array or list.  The left side shows an initial state with a light-blue, rounded-rectangle representing the data structure containing several black dots representing elements.  Above this structure are two gray rectangular boxes labeled 'left' and 'right,' indicating the source of data insertion.  Solid arrows point downwards from 'left' and 'right' to the leftmost and rightmost elements of the data structure, respectively.  A horizontal arrow labeled 'slide' points to the right, separating the before and after states. The right side shows the data structure after the 'slide' operation.  The light-blue rounded-rectangle again contains black dots, but now the 'left' and 'right' boxes are orange.  Dashed orange arrows indicate that the 'left' and 'right' elements have been moved to the leftmost and rightmost positions, respectively, causing a shift in the other elements.  The brackets '[' and ']' on both sides visually enclose the data structure, emphasizing its array-like nature.  The overall diagram illustrates how elements are shifted within the data structure when a 'slide' operation is performed, with new elements potentially added at the beginning and end.

    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.

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
let left = 0, 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 === fixedWindowSize) {
        // Process the current window.
        result = processCurrentWindow();
        left++;
    }
    right++;
}
int left = 0, 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 == fixedWindowSize) {
        // Process the current window.
        result = processCurrentWindow();
        left++;
    }
    right++;
}

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:

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
let left = 0, right = 0;
while (right < n) {
    // While the condition is violated, the window is invalid, so shrink the window by
    // advancing the left pointer.
    while (conditionIsViolated) {
        left++;
    }
    // Once the window is valid, process it and then expand the window by advancing the
    // right pointer.
    result = processCurrentWindow();
    right++;
}
int left = 0, right = 0;
while (right < n) {
    // While the condition is violated, the window is invalid, so shrink the window by
    // advancing the left pointer.
    while (conditionIsViolated) {
        left++;
    }
    // Once the window is valid, process it and then expand the window by advancing the
    // right pointer.
    result = processCurrentWindow();
    right++;
}

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

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:

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

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.

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
function substringAnagrams(s, t) {
    const lenS = s.length, lenT = t.length;
    if (lenT > lenS) {
        return 0;
    }
    let count = 0;
    const expectedFreqs = new Array(26).fill(0);
    const windowFreqs = new Array(26).fill(0);
    const aCharCode = 'a'.charCodeAt(0);
    // Populate 'expectedFreqs' with the characters in string 't'.
    for (let i = 0; i < lenT; i++) {
        expectedFreqs[t.charCodeAt(i) - aCharCode]++;
    }
    const arraysMatch = (arr1, arr2) => arr1.every((val, idx) => val === arr2[idx]);
    let left = 0, right = 0;
    while (right < lenS) {
        // Add the character at the right pointer to 'windowFreqs' before sliding the
        // window.
        windowFreqs[s.charCodeAt(right) - aCharCode]++;
        // 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 === lenT) {
            if (arraysMatch(windowFreqs, expectedFreqs)) {
                count++;
            }
            // Remove the character at the left pointer from 'windowFreqs' before
            // advancing the left pointer.
            windowFreqs[s.charCodeAt(left) - aCharCode]--;
            left++;
        }
        right++;
    }
    return count;
}
public int substringAnagrams(String s, String t) {
    int lenS = s.length(), lenT = t.length();
    if (lenT > lenS) {
        return 0;
    }
    int count = 0;
    int[] expectedFreqs = new int[26];
    int[] windowFreqs = new int[26];
    // Populate 'expectedFreqs' with the characters in string 't'.
    for (char c : t.toCharArray()) {
        expectedFreqs[c - 'a']++;
    }
    int left = 0, right = 0;
    while (right < lenS) {
        // Add the character at the right pointer to 'windowFreqs' before sliding the
        // window.
        windowFreqs[s.charAt(right) - 'a']++;
        // 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 == lenT) {
            if (Arrays.equals(windowFreqs, expectedFreqs)) {
                count++;
            }
            // Remove the character at the left pointer from 'windowFreqs' before
            // advancing the left pointer.
            windowFreqs[s.charAt(left) - 'a']--;
            left++;
        }
        right++;
    }
    return count;
}

Complexity Analysis

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

  • Populating the expected_freqs array takes O(m)O(m) time, where m denotes the length of t. Since mm is guaranteed to be less than or equal to nn 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) time.
  • Note that at each iteration, the comparison performed between the two frequency arrays (expected_freqs and window_freqs) takes O(1)O(1) time because each array contains only 26 elements.

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

CHAPTER 27

Longest Substring With Unique Characters

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

Example:

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

This means the brute force approach would take O(n3)O(n^3) 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.

    The image represents a sequence of characters 'a', 'b', 'c', 'b', 'a' enclosed within a light-blue rectangular box.  A gray line underlines the sequence from 'a' to 'b', indicating a selection or subset of the data. A downward-pointing arrow extends from the middle of the underline, visually connecting the highlighted portion to the text below. This text, ' 'b' is a duplicate', explains that the character 'b' appears more than once within the selected sequence. The overall diagram illustrates the concept of identifying duplicate elements within a data set, specifically highlighting the repeated occurrence of 'b' within the sequence 'a', 'b', 'c', 'b', 'a'.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function longestSubstringWithUniqueChars(s) {
    let maxLen = 0;
    const hashSet = new Set();
    let left = 0, right = 0;
    while (right < s.length) {
        // If we encounter a duplicate character in the window, shrink the window until
        // it’s no longer a duplicate.
        while (hashSet.has(s[right])) {
            hashSet.delete(s[left]);
            left++;
        }
        // Once there are no more duplicates in the window, update 'maxLen' if the
        // current window is larger.
        maxLen = Math.max(maxLen, right - left + 1);
        hashSet.add(s[right]);
        // Expand the window.
        right++;
    }
    return maxLen;
}
public int longestSubstringWithUniqueChars(String s) {
    int maxLen = 0;
    Set<Character> hashSet = new HashSet<>();
    int left = 0, right = 0;
    while (right < s.length()) {
        // If we encounter a duplicate character in the window, shrink the window until
        // it’s no longer a duplicate.
        while (hashSet.contains(s.charAt(right))) {
            hashSet.remove(s.charAt(left));
            left++;
        }
        // Once there are no more duplicates in the window, update 'maxLen' if the
        // current window is larger.
        maxLen = Math.max(maxLen, right - left + 1);
        hashSet.add(s.charAt(right));
        // Expand the window.
        right++;
    }
    return maxLen;
}

Complexity Analysis

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

Space complexity: The space complexity is 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).
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Optimized Approach

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
function longestSubstringWithUniqueCharsOptimized(s) {
    let maxLen = 0;
    const prevIndexes = new Map();
    let left = 0, right = 0;
    while (right < s.length) {
        // If a previous index of the current character is present in the current
        // window, it's a duplicate character in the window.
        if (prevIndexes.has(s[right]) && prevIndexes.get(s[right]) >= left) {
            // Shrink the window to exclude the previous occurrence of this character.
            left = prevIndexes.get(s[right]) + 1;
        }
        // Update 'maxLen' if the current window is larger.
        maxLen = Math.max(maxLen, right - left + 1);
        prevIndexes.set(s[right], right);
        // Expand the window.
        right++;
    }
    return maxLen;
}
public int longestSubstringWithUniqueCharsOptimized(String s) {
    int maxLen = 0;
    Map<Character, Integer> prevIndexes = new HashMap<>();
    int left = 0, right = 0;
    while (right < s.length()) {
        // If a previous index of the current character is present in the current
        // window, it's a duplicate character in the window.
        if (prevIndexes.containsKey(s.charAt(right)) && prevIndexes.get(s.charAt(right)) >= left) {
            // Shrink the window to exclude the previous occurrence of this character.
            left = prevIndexes.get(s.charAt(right)) + 1;
        }
        // Update 'maxLen' if the current window is larger.
        maxLen = Math.max(maxLen, right - left + 1);
        prevIndexes.put(s.charAt(right), right);
        // Expand the window.
        right++;
    }
    return maxLen;
}

Complexity Analysis

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

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

CHAPTER 28

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function longestUniformSubstringAfterReplacements(s, k) {
    const freqs = new Map();
    let highestFreq = 0, maxLen = 0;
    let left = 0, right = 0;
    while (right < s.length) {
        // Update the frequency of the character at the right pointer and the highest
        // frequency for the current window.
        freqs.set(s[right], (freqs.get(s[right]) || 0) + 1);
        highestFreq = Math.max(highestFreq, freqs.get(s[right]));
        // Calculate replacements needed for the current window.
        const numCharsToReplace = (right - left + 1) - highestFreq;
        // 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 (numCharsToReplace > k) {
            // Remove the character at the left pointer from the hash map before
            // advancing the left pointer.
            freqs.set(s[left], freqs.get(s[left]) - 1);
            left++;
        }
        // Since the length of the current window increases or stays the same, assign
        // the length of the current window to 'maxLen'.
        maxLen = right - left + 1;
        // Expand the window.
        right++;
    }
    return maxLen;
}
public int longestUniformSubstringAfterReplacements(String s, int k) {
    Map<Character, Integer> freqs = new HashMap<>();
    int highestFreq = 0, maxLen = 0;
    int left = 0, right = 0;
    while (right < s.length()) {
        // Update the frequency of the character at the right pointer and the highest
        // frequency for the current window.
        char rightChar = s.charAt(right);
        freqs.put(rightChar, freqs.getOrDefault(rightChar, 0) + 1);
        highestFreq = Math.max(highestFreq, freqs.get(rightChar));
        // Calculate replacements needed for the current window.
        int numCharsToReplace = (right - left + 1) - highestFreq;
        // 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 (numCharsToReplace > k) {
            // Remove the character at the left pointer from the hash map before
            // advancing the left pointer.
            char leftChar = s.charAt(left);
            freqs.put(leftChar, freqs.get(leftChar) - 1);
            left++;
        }
        // Since the length of the current window increases or stays the same, assign
        // the length of the current window to 'maxLen'.
        maxLen = right - left + 1;
        // Expand the window.
        right++;
    }
    return maxLen;
}

Complexity Analysis

Time complexity: The time complexity of longest_uniform_substring_after_replacements is O(n)O(n), where nn 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), where mm is the number of unique characters in the string stored in the hash map freqs.

CHAPTER 29

Introduction to Binary Search

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
    Image represents a visual depiction of a coding pattern, likely illustrating a divide-and-conquer algorithm or a similar approach to processing data.  The image is divided into two parts. The left part shows three rectangular boxes labeled 'left' (orange), 'mid' (light blue), and 'right' (orange), each pointing downwards with an arrow to a representation of a data array shown as a sequence of black dots within square brackets. The 'left' box points to the beginning of the array, 'mid' to the middle, and 'right' to the end. The right part of the image shows a similar arrangement of 'left', 'mid', and 'right' boxes, but this time the 'left' and 'mid' boxes are connected with arrows to a smaller, processed array (represented by fewer black dots and grey lines connecting them). A solid black arrow connects the initial array (from the left part) to the processed array (in the right part), with the label 'left = mid' written above it.  A dashed orange line connects the 'left' box in the right part to the beginning of the processed array, suggesting a potential recursive call or iterative step.  The 'mid' box in the right part points directly to a point within the processed array, indicating the new midpoint. The 'right' box in the right part points to the end of the processed array. The overall diagram illustrates a process where an array is divided into sub-arrays, processed, and then recombined, possibly to find a specific element or perform a similar operation.
    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)), where nn 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. ↩

CHAPTER 30

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:

Example
Input: nums = [1, 2, 4, 5, 7, 8, 9], target = 4
Output: 2

Example 2:

Example
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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function findTheInsertionIndex(nums, target) {
    let left = 0, right = nums.length;
    while (left < right) {
        const mid = Math.floor((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;
}
public class Solution {
    public int findTheInsertionIndex(int[] nums, int target) {
        int left = 0, right = nums.length;
        while (left < right) {
            int mid = left + (right - left) / 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)) because it performs a binary search over a search space of size n+1n+1.

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

CHAPTER 31

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:

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function firstAndLastOccurrencesOfANumber(nums, target) {
    const lowerBound = lowerBoundBinarySearch(nums, target);
    const upperBound = upperBoundBinarySearch(nums, target);
    return [lowerBound, upperBound];
}

function lowerBoundBinarySearch(nums, target) {
    let left = 0, right = nums.length - 1;
    while (left < right) {
        const mid = Math.floor((left + right) / 2);
        if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return (nums.length > 0 && nums[left] === target) ? left : -1;
}

function upperBoundBinarySearch(nums, target) {
    let left = 0, right = nums.length - 1;
    while (left < right) {
        // In upper-bound binary search, bias the midpoint to the right.
        const mid = Math.floor((left + right) / 2) + 1;
        if (nums[mid] > target) {
            right = mid - 1;
        } else if (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 (nums.length > 0 && nums[right] === target) ? right : -1;
}
public class Solution {
    public int[] firstAndLastOccurrencesOfANumber(int[] nums, int target) {
        int lowerBound = lowerBoundBinarySearch(nums, target);
        int upperBound = upperBoundBinarySearch(nums, target);
        return new int[]{lowerBound, upperBound};
    }

    private int lowerBoundBinarySearch(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] > target) {
                right = mid - 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return (nums.length > 0 && nums[left] == target) ? left : -1;
    }

    private int upperBoundBinarySearch(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            // In upper-bound binary search, bias the midpoint to the right.
            int mid = left + (right - left) / 2 + 1;
            if (nums[mid] > target) {
                right = mid - 1;
            } else if (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 (nums.length > 0 && nums[right] == target) ? right : -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)), where nn 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)) because it calls each helper function once.

Space complexity: The space complexity is 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.

CHAPTER 32

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.
Example
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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function cuttingWood(heights, k) {
    let left = 0, right = Math.max(...heights);
    while (left < right) {
        // Bias the midpoint to the right during the upper-bound binary search.
        const mid = Math.floor((left + right) / 2) + 1;
        if (cutsEnoughWood(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.
function cutsEnoughWood(H, k, heights) {
    let woodCollected = 0;
    for (const height of heights) {
        if (height > H) {
            woodCollected += (height - H);
        }
    }
    return woodCollected >= k;
}
public class Solution {
    public int cuttingWood(int[] heights, int k) {
        int maxVal = 0;
        for (int h : heights) {
            maxVal = Math.max(maxVal, h);
        }
        int left = 0, right = maxVal;
        while (left < right) {
            // Bias the midpoint to the right during the upper-bound binary search.
            int mid = left + (right - left) / 2 + 1;
            if (cutsEnoughWood(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.
    private boolean cutsEnoughWood(int H, int k, int[] heights) {
        long woodCollected = 0;
        for (int height : heights) {
            if (height > H) {
                woodCollected += (height - H);
            }
        }
        return woodCollected >= k;
    }
}

Complexity Analysis

Time complexity: The time complexity of cutting_wood is O(nlog⁡(m))O(n\log(m)), where mm is the maximum height of the trees. This is because we perform a binary search over the range [0, mm]. Each iteration of the binary search calls the cuts_enough_wood function, which runs in O(n)O(n) time, where nn 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)).

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

CHAPTER 33

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:

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function findTheTargetInARotatedSortedArray(nums, target) {
    let left = 0, right = nums.length - 1;
    while (left < right) {
        const mid = Math.floor((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.
        } else if (nums[left] <= nums[mid]) {
            if (nums[left] <= target && 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 && 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 (nums.length > 0 && nums[left] === target) ? left : -1;
}
public class Solution {
    public int findTheTargetInARotatedSortedArray(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = left + (right - left) / 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.
            } else if (nums[left] <= nums[mid]) {
                if (nums[left] <= target && 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 && 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 (nums.length > 0 && nums[left] == target) ? left : -1;
    }
}

Complexity Analysis

Time complexity: The time complexity of find_the_target_in_a_rotated_sorted_array is O(log⁡(n))O(\log(n)) because we're performing a binary search over an array of length nn.

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

CHAPTER 34

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:

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

Example
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)) time where mm and nn denote the lengths of each array, respectively. This complexity is primarily due to the cost of sorting the merged array of length m+nm+n. This approach can be improved to 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:

    Image represents a visual depiction of a coding pattern, possibly related to array manipulation or indexing.  Three rectangular boxes labeled 'left,' 'L1_index,' and 'right' point downwards to an array [0, 2, 5, 6, 8].  The number 5 is highlighted in light blue and labeled 'L1,' indicating a left index or pointer.  Below this array, a sub-array [0, 3, 7] is shown, with 3 highlighted in light orange and labeled 'R2,' suggesting a right index or pointer.  A dashed rectangular box contains the equation 'L1 > R2 → right = L1_index - 1,' illustrating a conditional statement: if the value at the left index (L1) is greater than the value at the right index (R2), then the 'right' variable is updated to be one less than the value of 'L1_index.'  The diagram visually demonstrates the relationship between array indices, pointers, and a conditional update based on their values.
  • 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:

    Image represents a diagram illustrating a coding pattern, possibly related to array manipulation or searching.  The diagram shows two arrays, labeled L1 and L2, represented as `[0 2 5 6 8]` and `[0 3 7 +∞]` respectively.  The number 2 in L1 and 7 in L2 are highlighted in peach and light blue respectively, and labeled R1 and R2.  Three rectangular boxes above L1 contain the labels 'left', 'L1_index', and 'right'. Arrows point from 'left' and 'L1_index' to the beginning of L1, indicating these variables might be used to index or access elements within L1.  A dashed-line box displays the equation 'L2 > R1 → left = L1_index + 1', suggesting a conditional operation: if an element in L2 (specifically R2) is greater than an element in L1 (specifically R1), then the 'left' variable is updated to the value of 'L1_index' plus 1. This implies an iterative process where the 'left' index is adjusted based on comparisons between elements of L1 and L2.  The '+∞' in L2 suggests a sentinel value or a boundary condition.
  • If L1 ≤ R2, and L2 ≤ R1, the correct slice has been located:

    Image represents a diagram illustrating a coding pattern related to array slicing.  At the top, a rectangular box labeled 'left L1_index right' points downwards with arrows to two sets of data.  The first set is an array represented as `[0 2 5 6 8]`, where '2' is highlighted in light blue and labeled 'L1', and '5' is highlighted in light orange and labeled 'R1'.  Below this, a second array `[0 3 7]` is shown, with '3' highlighted in light blue and labeled 'L2', and '7' highlighted in light orange and labeled 'R2'.  A dashed rectangular box on the right states the condition 'L1 ≤ R2 and L2 ≤ R1' which, if true (indicated by a right-pointing arrow), results in a 'correct slice'.  The diagram visually demonstrates how the indices 'L1', 'R1', 'L2', and 'R2' from two arrays are compared to determine if a slice is valid based on the condition.

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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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)
function findTheMedianFromTwoSortedArrays(nums1, nums2) {
    // Optimization: ensure 'nums1' is the smaller array.
    if (nums2.length < nums1.length) {
        [nums1, nums2] = [nums2, nums1];
    }
    const m = nums1.length, n = nums2.length;
    const halfTotalLen = Math.floor((m + n) / 2);
    let left = 0, right = m - 1;
    // A median always exists in a non-empty array, so continue binary search until
    // it's found.
    while (true) {
        const L1Index = Math.floor((left + right) / 2);
        const L2Index = halfTotalLen - (L1Index + 1) - 1;
        // Set to -infinity or +infinity if out of bounds.
        const L1 = L1Index < 0 ? -Infinity : nums1[L1Index];
        const R1 = L1Index >= m - 1 ? Infinity : nums1[L1Index + 1];
        const L2 = L2Index < 0 ? -Infinity : nums2[L2Index];
        const R2 = L2Index >= n - 1 ? Infinity : nums2[L2Index + 1];
        // If 'L1 > R2', then 'L1' is too far to the right. Narrow the search space
        // toward the left.
        if (L1 > R2) {
            right = L1Index - 1;
        // If 'L2 > R1', then 'L1' is too far to the left. Narrow the search space
        // toward the right.
        } else if (L2 > R1) {
            left = L1Index + 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 (Math.max(L1, L2) + Math.min(R1, R2)) / 2.0;
            } else {
                return Math.min(R1, R2);
            }
        }
    }
}
public class Solution {
    public double findTheMedianFromTwoSortedArrays(int[] nums1, int[] nums2) {
        // Optimization: ensure 'nums1' is the smaller array.
        if (nums2.length < nums1.length) {
            int[] temp = nums1;
            nums1 = nums2;
            nums2 = temp;
        }
        int m = nums1.length, n = nums2.length;
        int halfTotalLen = (m + n) / 2;
        int left = 0, right = m - 1;
        // A median always exists in a non-empty array, so continue binary search until
        // it's found.
        while (true) {
            int L1Index = Math.floorDiv(left + right, 2);
            int L2Index = halfTotalLen - (L1Index + 1) - 1;
            // Set to -infinity or +infinity if out of bounds.
            double L1 = L1Index < 0 ? Double.NEGATIVE_INFINITY : nums1[L1Index];
            double R1 = L1Index >= m - 1 ? Double.POSITIVE_INFINITY : nums1[L1Index + 1];
            double L2 = L2Index < 0 ? Double.NEGATIVE_INFINITY : nums2[L2Index];
            double R2 = L2Index >= n - 1 ? Double.POSITIVE_INFINITY : nums2[L2Index + 1];
            // If 'L1 > R2', then 'L1' is too far to the right. Narrow the search space
            // toward the left.
            if (L1 > R2) {
                right = L1Index - 1;
            // If 'L2 > R1', then 'L1' is too far to the left. Narrow the search space
            // toward the right.
            } else if (L2 > R1) {
                left = L1Index + 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 (Math.max(L1, L2) + Math.min(R1, R2)) / 2.0;
                } else {
                    return Math.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))) because we perform binary search over the smaller of the two input arrays.

Space complexity: The space complexity is 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.

CHAPTER 35

Matrix Search

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.
Example
Output: True

Intuition

A naive solution to this problem is to linearly scan the matrix until we encounter the target value. However, this isn’t taking advantage of the sorted properties of the matrix.

A key observation is that all values in a given row are greater than or equal to all values in the previous row. This indicates the entire matrix can be considered as a single, continuous, sorted sequence of values:

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) time, and also takes O(m⋅n)O(m\cdot n) space, where mm and nn 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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function matrixSearch(matrix, target) {
    const m = matrix.length, n = matrix[0].length;
    let left = 0, right = m * n - 1;
    // Perform binary search to find the target.
    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        const r = Math.floor(mid / n), c = mid % n;
        if (matrix[r][c] === target) {
            return true;
        } else if (matrix[r][c] > target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return false;
}
public class Solution {
    public boolean matrixSearch(int[][] matrix, int target) {
        int m = matrix.length, n = matrix[0].length;
        int left = 0, right = m * n - 1;
        // Perform binary search to find the target.
        while (left <= right) {
            int mid = left + (right - left) / 2;
            int r = mid / n, c = mid % n;
            if (matrix[r][c] == target) {
                return true;
            } else if (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)) because it performs a binary search over a search space of size m⋅nm\cdot n.

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

CHAPTER 36

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.
Example
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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function localMaximaInArray(nums) {
    let left = 0, right = nums.length - 1;
    while (left < right) {
        const mid = Math.floor((left + right) / 2);
        if (nums[mid] > nums[mid + 1]) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    return left;
}
public class Solution {
    public int localMaximaInArray(int[] nums) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = left + (right - left) / 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)), where nn 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).

CHAPTER 37

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:

Example
Input: weights = [3, 1, 2, 4]

Explanation:
sum(weights) = 10
3 has a 3/10 probability of being selected.
1 has a 1/10 probability of being selected.
2 has a 2/10 probability of being selected.
4 has a 4/10 probability of being selected.
For example, we expect index 0 to be returned 30% of the time.

Constraints:

  • The weights array contains at least one element.

Intuition

A completely uniform random selection implies every index has an equal chance of being selected. A weighted random selection means some items are more likely to be picked than others. If we repeatedly perform a random selection many times, the frequency of each index being picked will match their expected probabilities.

The challenge with this problem is determining a method to randomly select an index based on its probability.

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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
class WeightedRandomSelection {
    constructor(weights) {
        this.prefixSums = [weights[0]];
        for (let i = 1; i < weights.length; i++) {
            this.prefixSums.push(this.prefixSums[this.prefixSums.length - 1] + weights[i]);
        }
    }

    select() {
        // Pick a random target between 1 and the largest endpoint on the number
        // line.
        const total = this.prefixSums[this.prefixSums.length - 1];
        const target = Math.floor(Math.random() * total) + 1;
        let left = 0, right = this.prefixSums.length - 1;
        // Perform lower-bound binary search to find which endpoint (i.e., prefix
        // sum value) corresponds to the target.
        while (left < right) {
            const mid = Math.floor((left + right) / 2);
            if (this.prefixSums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return left;
    }
}
import java.util.Random;

public class WeightedRandomSelection {
    private int[] prefixSums;
    private Random random;

    public WeightedRandomSelection(int[] weights) {
        this.random = new Random();
        this.prefixSums = new int[weights.length];
        this.prefixSums[0] = weights[0];
        for (int i = 1; i < weights.length; i++) {
            this.prefixSums[i] = this.prefixSums[i - 1] + weights[i];
        }
    }

    public int select() {
        // Pick a random target between 1 and the largest endpoint on the number
        // line.
        int target = random.nextInt(this.prefixSums[this.prefixSums.length - 1]) + 1;
        int left = 0, right = this.prefixSums.length - 1;
        // Perform lower-bound binary search to find which endpoint (i.e., prefix
        // sum value) corresponds to the target.
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (this.prefixSums[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) because we iterate through each weight in the weights array once. The time complexity of select is 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) due to the prefix_sums array. The space complexity of select is O(1)O(1).

CHAPTER 38

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.

CHAPTER 39

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:

Example
Input: s = '([]{})'
Output: True

Example 2:

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function validParenthesisExpression(s) {
    const parenthesesMap = { '(': ')', '{': '}', '[': ']' };
    const stack = [];
    for (const c of s) {
        // If the current character is an opening parenthesis, push it onto the stack.
        if (c in parenthesesMap) {
            stack.push(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.length > 0 && parenthesesMap[stack[stack.length - 1]] === c) {
                stack.pop();
            } else {
                return false;
            }
        }
    }
    // If the stack is empty, all opening parentheses were successfully closed.
    return stack.length === 0;
}
import java.util.*;

public class Solution {
    public boolean validParenthesisExpression(String s) {
        Map<Character, Character> parenthesesMap = new HashMap<>();
        parenthesesMap.put('(', ')');
        parenthesesMap.put('{', '}');
        parenthesesMap.put('[', ']');
        Deque<Character> stack = new ArrayDeque<>();
        for (char c : s.toCharArray()) {
            // If the current character is an opening parenthesis, push it onto the stack.
            if (parenthesesMap.containsKey(c)) {
                stack.push(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.isEmpty() && parenthesesMap.get(stack.peek()) == c) {
                    stack.pop();
                } else {
                    return false;
                }
            }
        }
        // If the stack is empty, all opening parentheses were successfully closed.
        return stack.isEmpty();
    }
}

Complexity Analysis

Time complexity: The time complexity of valid_parenthesis_expression is 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) because the stack stores at most nn characters, and the hash map takes up O(1)O(1) space.

CHAPTER 40

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.
Example
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) time, where nn 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.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function nextLargestNumberToTheRight(nums) {
    const res = new Array(nums.length).fill(0);
    const stack = [];
    // Find the next largest number of each element, starting with the rightmost
    // element.
    for (let i = nums.length - 1; i >= 0; i--) {
        // Pop values from the top of the stack until the current value's next largest
        // number is at the top.
        while (stack.length > 0 && stack[stack.length - 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.length > 0 ? stack[stack.length - 1] : -1;
        stack.push(nums[i]);
    }
    return res;
}
import java.util.*;

public class Solution {
    public int[] nextLargestNumberToTheRight(int[] nums) {
        int[] res = new int[nums.length];
        Deque<Integer> stack = new ArrayDeque<>();
        // Find the next largest number of each element, starting with the rightmost
        // element.
        for (int i = nums.length - 1; i >= 0; i--) {
            // Pop values from the top of the stack until the current value's next largest
            // number is at the top.
            while (!stack.isEmpty() && stack.peek() <= 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.isEmpty() ? stack.peek() : -1;
            stack.push(nums[i]);
        }
        return res;
    }
}

Complexity Analysis

Time complexity: The time complexity of next_largest_number_to_the_right is 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) because the stack can potentially store all nn values.

CHAPTER 41

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:

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function evaluateExpression(s) {
    const stack = [];
    let currNum = 0, sign = 1, res = 0;
    for (const c of s) {
        if (c >= '0' && c <= '9') {
            currNum = currNum * 10 + parseInt(c, 10);
        // If the current character is an operator, add 'curr_num' to the result
        // after multiplying it by its sign.
        } else if (c === '+' || c === '-') {
            res += currNum * sign;
            // Update the sign and reset 'curr_num'.
            sign = c === '-' ? -1 : 1;
            currNum = 0;
        // If the current character is an opening parenthesis, a new nested expression
        // is starting.
        } else if (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.push(res);
            stack.push(sign);
            res = 0;
            sign = 1;
        // If the current character is a closing parenthesis, a nested expression has
        // ended.
        } else if (c === ')') {
            // Finalize the result of the current nested expression.
            res += sign * currNum;
            // 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();
            currNum = 0;
        }
    }
    // Finalize the result of the overall expression.
    return res + currNum * sign;
}
import java.util.*;

public class Solution {
    public int evaluateExpression(String s) {
        Deque<Integer> stack = new ArrayDeque<>();
        int currNum = 0, sign = 1, res = 0;
        for (char c : s.toCharArray()) {
            if (Character.isDigit(c)) {
                currNum = currNum * 10 + (c - '0');
            // If the current character is an operator, add 'curr_num' to the result
            // after multiplying it by its sign.
            } else if (c == '+' || c == '-') {
                res += currNum * sign;
                // Update the sign and reset 'curr_num'.
                sign = c == '-' ? -1 : 1;
                currNum = 0;
            // If the current character is an opening parenthesis, a new nested expression
            // is starting.
            } else if (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.push(res);
                stack.push(sign);
                res = 0;
                sign = 1;
            // If the current character is a closing parenthesis, a nested expression has
            // ended.
            } else if (c == ')') {
                // Finalize the result of the current nested expression.
                res += sign * currNum;
                // 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();
                currNum = 0;
            }
        }
        // Finalize the result of the overall expression.
        return res + currNum * sign;
    }
}

Complexity Analysis

Time complexity: The time complexity of evaluate_expression is 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) time.

Space complexity: The space complexity is O(n)O(n) because the stack can grow proportionally to the length of the expression.

CHAPTER 42

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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)
function repeatedRemovalOfAdjacentDuplicates(s) {
    const stack = [];
    for (const c of 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.length > 0 && c === stack[stack.length - 1]) {
            stack.pop();
        // Otherwise, push the current character onto the stack.
        } else {
            stack.push(c);
        }
    }
    // Return the remaining characters as a string.
    return stack.join('');
}
public class Solution {
    public String repeatedRemovalOfAdjacentDuplicates(String s) {
        StringBuilder sb = new StringBuilder();
        for (char c : s.toCharArray()) {
            // 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 (sb.length() > 0 && c == sb.charAt(sb.length() - 1)) {
                sb.deleteCharAt(sb.length() - 1);
            // Otherwise, push the current character onto the stack.
            } else {
                sb.append(c);
            }
        }
        // Return the remaining characters as a string.
        return sb.toString();
    }
}

Complexity Analysis

Time complexity: The time complexity of the repeated_removal_of_adjacent_duplicates function is O(n)O(n) where nn denotes the length of the string. This is because we traverse the entire string, and we perform a join operation of up to nn characters in the stack. The stack push and pop operations contribute O(1)O(1) time.

Space complexity: The space complexity is O(n)O(n) because the stack can store at most nn characters.

CHAPTER 43

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

Example
Input: [enqueue(1), enqueue(2), dequeue(), enqueue(3), peek()]
Output: [1, 2]

Constraints:

  • The dequeue and peek operations will only be called on a non-empty queue.

Intuition

A queue is a first-in-first-out (FIFO) data structure, whereas stacks are a first-in-last-out (FILO) data structure:

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

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.

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
class Queue {
    constructor() {
        this.enqueueStack = [];
        this.dequeueStack = [];
    }

    enqueue(x) {
        this.enqueueStack.push(x);
    }

    transferEnqueueToDequeue() {
        // 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 (this.dequeueStack.length === 0) {
            while (this.enqueueStack.length > 0) {
                this.dequeueStack.push(this.enqueueStack.pop());
            }
        }
    }

    dequeue() {
        this.transferEnqueueToDequeue();
        // Pop and return the value at the top of the dequeue stack.
        return this.dequeueStack.length > 0 ? this.dequeueStack.pop() : null;
    }

    peek() {
        this.transferEnqueueToDequeue();
        return this.dequeueStack.length > 0 ? this.dequeueStack[this.dequeueStack.length - 1] : null;
    }
}
import java.util.*;

public class Queue {
    private Deque<Integer> enqueueStack;
    private Deque<Integer> dequeueStack;

    public Queue() {
        enqueueStack = new ArrayDeque<>();
        dequeueStack = new ArrayDeque<>();
    }

    public void enqueue(int x) {
        enqueueStack.push(x);
    }

    public void transferEnqueueToDequeue() {
        // 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 (dequeueStack.isEmpty()) {
            while (!enqueueStack.isEmpty()) {
                dequeueStack.push(enqueueStack.pop());
            }
        }
    }

    public Integer dequeue() {
        transferEnqueueToDequeue();
        // Pop and return the value at the top of the dequeue stack.
        return !dequeueStack.isEmpty() ? dequeueStack.pop() : null;
    }

    public Integer peek() {
        transferEnqueueToDequeue();
        return !dequeueStack.isEmpty() ? dequeueStack.peek() : null;
    }
}

Complexity Analysis

Time complexity: The time complexity of:

  • enqueue is O(1)O(1) because we add one element to the enqueue stack in constant time.

  • dequeue is amortized 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) time, where nn denotes the number of elements in the enqueue queue.
    • However, each element is only ever moved once during its lifetime. So, over nn dequeue calls, at most nn elements are moved between stacks, averaging the cost to O(1)O(1) time per dequeue operation.
  • peek is amortized O(1)O(1) for the same reasons as dequeue.

Space complexity: The space complexity is O(n)O(n) since we maintain two stacks that collectively store all elements of the queue at any given time.

CHAPTER 44

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.
Example
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) time because we traverse kk elements for up to nn 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) 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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function maximumsOfSlidingWindow(nums, k) {
    const res = [];
    const dq = []; // Stores [val, index]
    let left = 0, right = 0;
    while (right < nums.length) {
        // 1) Ensure the values of the deque maintain a monotonic decreasing order
        // by removing candidates <= the current candidate.
        while (dq.length > 0 && dq[dq.length - 1][0] <= nums[right]) {
            dq.pop();
        }
        // 2) Add the current candidate.
        dq.push([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.length > 0 && dq[0][1] < left) {
                dq.shift();
            }
            // The maximum value of this window is the leftmost value in the
            // deque.
            res.push(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;
}
import java.util.*;

public class Solution {
    public int[] maximumsOfSlidingWindow(int[] nums, int k) {
        if (nums.length == 0 || k <= 0) return new int[0];
        List<Integer> res = new ArrayList<>();
        // Stores int[]{val, index}
        Deque<int[]> dq = new ArrayDeque<>();
        int left = 0, right = 0;
        while (right < nums.length) {
            // 1) Ensure the values of the deque maintain a monotonic decreasing order
            // by removing candidates <= the current candidate.
            while (!dq.isEmpty() && dq.peekLast()[0] <= nums[right]) {
                dq.pollLast();
            }
            // 2) Add the current candidate.
            dq.offerLast(new int[]{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.isEmpty() && dq.peekFirst()[1] < left) {
                    dq.pollFirst();
                }
                // The maximum value of this window is the leftmost value in the
                // deque.
                res.add(dq.peekFirst()[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;
        }
        int[] result = new int[res.size()];
        for (int i = 0; i < res.size(); i++) {
            result[i] = res.get(i);
        }
        return result;
    }
}

Complexity Analysis

Time complexity: The time complexity of maximums_of_sliding_window is 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) because the deque can store up to kk 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 45

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.  The left side depicts a min-heap, visualized as a trapezoidal structure with a purple border, containing the numbers 7, 4, 5, and 12 arranged in a hierarchical manner.  Atop this structure, a separate purple rectangle labeled 'min:' displays the minimum value, 2, which is not part of the main heap structure itself.  The right side shows a max-heap, similarly structured but with a cyan border, containing the numbers 2, 7, 5, and 4.  A separate cyan rectangle labeled 'max:' above this structure displays the maximum value, 12, also separate from the main heap.  Both heaps are labeled at their base with 'min_heap' and 'max_heap' respectively, clearly indicating the type of heap each structure represents.  The arrangement of numbers within each heap demonstrates the property of each heap type: the minimum element (2) is at the top of the min-heap, and the maximum element (12) is at the top of the max-heap.

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, labeled as such at the top.  The heap is depicted as a binary tree with nodes encircled in violet. The root node, at the top, contains the value '2'.  This node connects to two child nodes: a left child node with the value '4', and a right child node with the value '12'. The left child node ('4') further branches into two child nodes: a left child with the value '7' and a right child with the value '5'.  All connections between nodes are represented by simple black lines indicating a parent-child relationship within the tree structure. The arrangement of nodes ensures that the value of each node is less than or equal to the values of its children, fulfilling the min-heap property where the smallest element is at the root.

Here’s a time complexity breakdown of common heap operations:

OperationTime complexityDescription
InsertO(log⁡(n))O(\log(n))Adds an element to the heap, ensuring the binary tree remains correctly ordered
DeletionO(log⁡(n))O(\log(n))Removes the element at the top of the heap, then restructures the heap to replace the top element
PeekO(1)O(1)Retrieves the top element of the heap without removing it
HeapifyO(n)O(n)Transforms an unsorted list of values into a heap [1]

 

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 (e.g., prioritizing strings with a higher number of vowels).

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. A heap is ideal for this because it allows the system to quickly access the highest-priority task and efficiently re-arrange the priorities as new tasks are added or existing tasks are completed.

Chapter Outline

Image represents a hierarchical diagram illustrating the applications of Heaps data structure in coding.  A rounded rectangle at the top labeled 'Heaps' is connected via dotted lines to two rectangular boxes below. The left box is titled 'Sorting' and lists two sub-problems: 'Combine Sorted Linked Lists' and 'Sort a K-Sorted Array'. The right box is titled 'Finding Values in Sorted Order' and lists two further sub-problems: 'K Most Frequent Strings' and 'Median of an Integer Stream'.  The dotted lines indicate that the problems listed under 'Sorting' and 'Finding Values in Sorted Order' are solved using the 'Heaps' data structure, showing how this data structure is applied to different sorting and searching problems.
CHAPTER 46

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:

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

For now, let's concentrate on identifying the most frequent strings and address lexicographical ordering afterward.

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:

Image represents a data transformation process where a list of strings is converted into a frequency table.  The input is a list `[go coding byte byte go interview go]` containing the strings 'go', 'coding', 'byte', 'byte', 'go', 'interview', and 'go'. This list is shown to the left of a right-pointing arrow. The arrow indicates the flow of data from the input list to the output table. The output is a frequency table labeled 'freqs' with two columns: 'str' representing the unique strings and 'freq' representing their corresponding frequencies. The table lists 'go' with a frequency of 3, 'coding' with 1, 'byte' with 2, and 'interview' with 1.  The table visually summarizes the number of occurrences of each unique string from the input list.

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.

Image represents a data processing flow demonstrating the selection of the *k* most frequent elements from a dataset.  The process begins with a table labeled 'freqs' containing two columns: 'str' (string) and 'freq' (frequency).  The 'str' column lists strings: 'go', 'coding', 'byte', and 'interview', while the 'freq' column shows their corresponding frequencies: 3, 1, 2, and 1, respectively.  An arrow labeled 'sort' indicates a sorting operation transforming this table into a second 'freqs' table.  In this sorted table, the strings are reordered based on their frequencies in descending order: 'go', 'byte', 'coding', and 'interview' with frequencies 3, 2, 1, and 1.  A rectangular orange box highlights the top two rows ('go' and 'byte') of the sorted table. Finally, an arrow points from this highlighted section to the text 'k most frequent (k = 2)', signifying that the top *k* = 2 most frequent strings ('go' and 'byte') have been successfully extracted.

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
Let's find the k most frequent strings from the previous input, this time using a max-heap. First, populate the heap with each string along with their frequencies.

Image represents a data structure transformation illustrating the 'push all pairs' coding pattern.  The diagram shows two states: an initial state and a final state, both featuring a `max_heap` data structure. The initial state depicts a `max_heap` with an empty top rectangular section labeled `max:` indicating an empty maximum value container. The final state shows the same `max_heap` structure, but its top rectangular section now contains `max:` followed by the pair `(go, 3)`.  Below this, within the trapezoidal `max_heap` section, three additional pairs are listed: `(byte, 2)`, `(coding, 1)`, and `(interview, 1)`. A horizontal arrow labeled 'push all pairs' connects the initial and final states, indicating that all pairs from some unspecified source have been inserted into the `max_heap`, resulting in the final state where the pairs are ordered according to their second element (the numerical value), with the pair having the largest numerical value at the top.  The `max_heap` structure implies that the pairs are ordered such that the pair with the highest numerical value is at the top.

One way to populate the heap is to push all n strings into it one by one, which will take O(nlog⁡(n))O(n\log(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)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:

Image represents a step-by-step visualization of a top-k frequent element algorithm with k=2.  The process begins with a max-heap containing three elements: (go, 3), (byte, 2), and (coding, 1).  The top element, (go, 3), is identified by a `max:` label and an arrow indicates it's popped from the heap and added to a result list `res`, which initially is empty.  This results in a new max-heap containing (byte, 2), (coding, 1), and (interview, 1), and `res` now equals `[go]`.  The next iteration identifies (byte, 2) as the maximum, pops it, and adds it to `res`, yielding a max-heap with (coding, 1) and (interview, 1), and `res` becoming `[go, byte]`.  The algorithm stops here because k=2, and the final `res` list `[go, byte]` is returned, as indicated by an arrow labeled 'return'.  Each max-heap is visually represented as a trapezoid, clearly showing its elements.

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 that prioritizes strings lexicographically when their frequencies match, as demonstrated in the implementation below.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Max-Heap

We create a Pair class for string-frequency pairs, enabling us to customize priority using a custom comparator.

from typing import List
from collections import Counter
import heapq
    
class Pair:
   def __init__(self, str, freq):
       self.str = str
       self.freq = freq
    
   # Define a custom comparator.
   def __lt__(self, other):
       # Prioritize lexicographical order for strings with equal frequencies.
       if self.freq == other.freq:
           return self.str < other.str
       # Otherwise, prioritize strings with higher frequencies.
       return self.freq > other.freq
    
def k_most_frequent_strings_max_heap(strs: List[str], k: int) -> List[str]:
   # We use 'Counter' to create a hash map that counts the frequency of each string.
   freqs = Counter(strs)
   # Create the max heap by performing heapify on all string-frequency pairs.
   max_heap = [Pair(str, freq) for str, freq in freqs.items()]
   heapq.heapify(max_heap)
   # Pop the most frequent string off the heap 'k' times and return these 'k' most
   # frequent strings.
   return [heapq.heappop(max_heap).str for _ in range(k)]
class Pair {
    constructor(str, freq) {
        this.str = str;
        this.freq = freq;
    }
}

function kMostFrequentStringsMaxHeap(strs, k) {
    // We use a Map to count the frequency of each string.
    const freqs = new Map();
    for (const str of strs) {
        freqs.set(str, (freqs.get(str) || 0) + 1);
    }
    // Create the max heap pairs from all string-frequency pairs.
    const pairs = [];
    for (const [str, freq] of freqs.entries()) {
        pairs.push(new Pair(str, freq));
    }
    // Prioritize lexicographical order for strings with equal frequencies.
    // Otherwise, prioritize strings with higher frequencies.
    pairs.sort((a, b) => {
        if (a.freq === b.freq) {
            return a.str.localeCompare(b.str);
        }
        return b.freq - a.freq;
    });
    // Pop the most frequent string off the heap 'k' times and return these 'k' most
    // frequent strings.
    return pairs.slice(0, k).map(pair => pair.str);
}
import java.util.*;

class Pair {
    String str;
    int freq;

    public Pair(String str, int freq) {
        this.str = str;
        this.freq = freq;
    }
}

public class Solution {
    public List<String> kMostFrequentStringsMaxHeap(String[] strs, int k) {
        // We use HashMap to count the frequency of each string.
        Map<String, Integer> freqs = new HashMap<>();
        for (String str : strs) {
            freqs.put(str, freqs.getOrDefault(str, 0) + 1);
        }
        // Define a custom comparator:
        // Prioritize lexicographical order for strings with equal frequencies.
        // Otherwise, prioritize strings with higher frequencies.
        PriorityQueue<Pair> maxHeap = new PriorityQueue<>((a, b) -> {
            if (a.freq == b.freq) {
                return a.str.compareTo(b.str);
            }
            return Integer.compare(b.freq, a.freq);
        });
        for (Map.Entry<String, Integer> entry : freqs.entrySet()) {
            maxHeap.offer(new Pair(entry.getKey(), entry.getValue()));
        }
        // Pop the most frequent string off the heap 'k' times and return these 'k' most
        // frequent strings.
        List<String> res = new ArrayList<>();
        for (int i = 0; i < k; i++) {
            res.add(maxHeap.poll().str);
        }
        return res;
    }
}

Complexity Analysis

Time complexity: The time complexity of k_most_frequent_strings_max_heap is O(n+klog⁡(n))O(n+k\log(n)).

  • It takes O(n)O(n) time to count the frequency of each string using Counter, and to build the max_heap.
  • We also pop off the top of the heap kk times, with each pop operation taking O(log⁡(n))O(\log(n)) time.

Therefore, the overall time complexity is O(n)+kO(log⁡(n))=O(n+klog⁡(n))O(n)+kO(\log(n))=O(n+k\log(n)).

Space complexity: The space complexity is O(n)O(n) because the hash map and heap store at most nn pairs. Note that the output array is not considered in the space complexity.

Intuition - Min-Heap

As a follow up, your interviewer may ask you to modify your solution to reduce the space used by the heap.

In the previous approach, we ended up storing up to n items in the heap. However, since we only need the k most frequent characters, is there a way to maintain a heap with a space complexity of O(k)O(k)?

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.


Let's observe how this works over an example:

Image represents a data structure manipulation process.  On the left, a table labeled 'freqs' displays word frequencies.  The table has two columns: 'str' (string) and 'freq' (frequency).  The 'str' column lists words: 'go,' 'coding,' 'byte,' and 'interview,' while the 'freq' column shows their corresponding frequencies: 3, 1, 2, and 1, respectively. The 'go' row is highlighted in peach. An arrow labeled 'push to heap' points from the 'go' row's frequency (3) to the right. On the right, a data structure labeled 'min_heap' is depicted as a truncated triangular shape, representing a min-heap.  Inside the min-heap, a rectangle labeled `min:` contains the tuple `(go, 3)`, indicating that the word 'go' with a frequency of 3 has been pushed into the min-heap.  The overall image illustrates the process of pushing an element (a word and its frequency) from a frequency table into a min-heap data structure.
Image represents a data structure manipulation process.  On the left, a table labeled 'freqs' displays word frequencies;  'str' denotes the word and 'freq' its count.  The table lists 'go' (frequency 3), 'coding' (frequency 1), 'byte' (frequency 2), and 'interview' (frequency 1).  A horizontal arrow labeled 'push to heap' points from the 'coding' row (highlighted in peach) to the right.  On the right, a min-heap data structure is depicted using a trapezoidal shape labeled 'min_heap'.  Inside the heap, the pair '(go, 3)' is visible, indicating the word 'go' and its frequency.  Above the heap, a rectangular box with a violet border shows '(coding, 1)' and is labeled with `min:` in violet text, suggesting this is the minimum element currently in the heap. The arrow indicates that the (coding, 1) pair is being added to the min-heap, implying that the process involves inserting word-frequency pairs from the table into the min-heap, maintaining the min-heap property.
Image represents a data structure manipulation process. On the left, a table labeled 'freqs' shows a list of strings ('go', 'coding', 'byte', 'interview') and their corresponding frequencies (3, 1, 2, 1).  A peach-colored row highlights 'byte' and its frequency 2. An arrow labeled 'push to heap' points from this row to the right side. The right side depicts a min-heap data structure, represented by a trapezoidal shape labeled 'min_heap,' containing elements in the format '(string, frequency)'.  Initially, the heap contains '(byte, 2)' and '(go, 3)'. A purple rectangle above the heap shows '(coding, 1)' being pushed into the heap.  An arrow labeled 'heap size > k: pop' indicates that if the heap size exceeds a value 'k' (not specified), elements are popped from the heap.  The entire diagram illustrates the process of pushing string-frequency pairs from a frequency table into a min-heap, potentially followed by popping elements if the heap size exceeds a threshold.
Image represents a data structure manipulation process involving a frequency table and a min-heap.  On the left, a table labeled 'freqs' shows the frequency of strings: 'go' (3), 'coding' (1), 'byte' (2), and 'interview' (1).  The table has columns labeled 'str' and 'freq'.  A horizontal arrow labeled 'push to heap' points from the 'interview' row (highlighted in peach) to the right. This arrow indicates that the ('interview', 1) pair is being added to a min-heap data structure shown on the right. The min-heap, labeled 'min_heap', is depicted as a truncated triangular structure containing the pairs ('byte', 2) and ('go', 3).  A smaller rectangle, highlighted in peach and labeled `min: (interview, 1)`, sits above the min-heap, representing the newly added element. A rightward arrow connects this rectangle to the min-heap, indicating the insertion.  Finally, a rightward arrow extends from the min-heap, labeled 'heap size > k: pop', suggesting that if the heap size exceeds a threshold 'k', an element will be removed (popped) from the heap.  The entire diagram illustrates the process of building a min-heap from a frequency table, potentially as part of a frequency-based algorithm.

In the end, the strings remaining in the heap are our top k frequent strings:

Image represents a visual depiction of a min-heap data structure.  The diagram shows a trapezoidal shape labeled 'min_heap' at the bottom, representing the heap itself. Inside the trapezoid, the tuple '(go, 3)' is displayed, indicating an element within the heap with a key 'go' and a value of 3.  Above the trapezoid, a rectangular box with rounded corners is positioned; this box is labeled 'min:' in a smaller, separate box to its left. Inside the main rectangular box, the tuple '(byte, 2)' is shown. This represents another element, with key 'byte' and value 2, which is seemingly outside the main heap structure but associated with it, possibly representing a node that could be added or is being considered for addition to the min-heap. The overall arrangement suggests a representation of a min-heap where elements are ordered such that the parent node always has a smaller value than its children, with '(byte, 2)' potentially being the next element to be inserted or the smallest element currently outside the heap. The purple color scheme unifies the visual elements.

To retrieve these strings, pop them from the heap until it's empty. Because we're using a min-heap, we're popping off the less frequent strings first. So, we need to reverse the order of the retrieved strings before returning the result:

Image represents a step-by-step visualization of a coding pattern, likely involving a min-heap data structure.  The diagram shows three stages.  Each stage features a purple, trapezoid-shaped min-heap, labeled 'min_heap,' containing pairs of elements.  Above each min-heap is a purple rectangle labeled 'min:' followed by the minimum element currently at the top of the heap.  In the first stage, the min-heap contains ('byte', 2) and ('go', 3).  A horizontal arrow points from ('byte', 2) to a dashed-line rectangle below labeled 'res = [byte]', indicating that the minimum element ('byte', 2) is popped from the heap and added to a result list 'res'.  The second stage shows the min-heap after this operation, containing only ('go', 3).  The minimum element ('go', 3) is then popped and added to 'res', resulting in 'res = [byte, go]' in the second dashed-line rectangle. The third stage shows an empty min-heap and the final 'res = [byte, go]' list. A green arrow points from this final 'res' list to the text 'return reverse(res)', indicating that the algorithm concludes by returning the reversed version of the 'res' list.
from typing import List
from collections import Counter
import heapq
    
class Pair:
    def __init__(self, str, freq):
        self.str = str
        self.freq = freq
    # Since this is a min-heap comparator, we can use the same comparator as the one
    # used in the max-heap, but reversing the inequality signs to invert the priority.
    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 heap size exceeds 'k', pop the lowest frequency string to ensure the heap
        # only contains the 'k' most frequent words so far.
        if len(min_heap) > k:
            heapq.heappop(min_heap)
    # Return the 'k' most frequent strings by popping the remaining 'k' strings from
    # the heap. Since we're using a min-heap, we need to reverse the result after
    # popping the elements to ensure the most frequent strings are listed first.
    res = [heapq.heappop(min_heap).str for _ in range(k)]
    res.reverse()
    return res
class Pair {
    constructor(str, freq) {
        this.str = str;
        this.freq = freq;
    }
}

function kMostFrequentStringsMinHeap(strs, k) {
    const freqs = new Map();
    for (const str of strs) {
        freqs.set(str, (freqs.get(str) || 0) + 1);
    }
    // Since this is a min-heap comparator, we can use the same comparator as the one
    // used in the max-heap, but reversing the inequality signs to invert the priority.
    const compare = (a, b) => {
        if (a.freq === b.freq) {
            return b.str.localeCompare(a.str);
        }
        return a.freq - b.freq;
    };
    const minHeap = [];
    for (const [str, freq] of freqs.entries()) {
        minHeap.push(new Pair(str, freq));
        minHeap.sort(compare);
        // If heap size exceeds 'k', pop the lowest frequency string to ensure the heap
        // only contains the 'k' most frequent words so far.
        if (minHeap.length > k) {
            minHeap.shift();
        }
    }
    // Return the 'k' most frequent strings by popping the remaining 'k' strings from
    // the heap. Since we're using a min-heap, we need to reverse the result after
    // popping the elements to ensure the most frequent strings are listed first.
    const res = minHeap.map(pair => pair.str);
    res.reverse();
    return res;
}
import java.util.*;

class Pair {
    String str;
    int freq;

    public Pair(String str, int freq) {
        this.str = str;
        this.freq = freq;
    }
}

public class Solution {
    public List<String> kMostFrequentStringsMinHeap(String[] strs, int k) {
        Map<String, Integer> freqs = new HashMap<>();
        for (String str : strs) {
            freqs.put(str, freqs.getOrDefault(str, 0) + 1);
        }
        // Since this is a min-heap comparator, we can use the same comparator as the one
        // used in the max-heap, but reversing the inequality signs to invert the priority.
        PriorityQueue<Pair> minHeap = new PriorityQueue<>((a, b) -> {
            if (a.freq == b.freq) {
                return b.str.compareTo(a.str);
            }
            return Integer.compare(a.freq, b.freq);
        });
        for (Map.Entry<String, Integer> entry : freqs.entrySet()) {
            minHeap.offer(new Pair(entry.getKey(), entry.getValue()));
            // If heap size exceeds 'k', pop the lowest frequency string to ensure the heap
            // only contains the 'k' most frequent words so far.
            if (minHeap.size() > k) {
                minHeap.poll();
            }
        }
        // Return the 'k' most frequent strings by popping the remaining 'k' strings from
        // the heap. Since we're using a min-heap, we need to reverse the result after
        // popping the elements to ensure the most frequent strings are listed first.
        List<String> res = new ArrayList<>();
        while (!minHeap.isEmpty()) {
            res.add(minHeap.poll().str);
        }
        Collections.reverse(res);
        return res;
    }
}

Complexity Analysis

Time complexity: The time complexity of k_most_frequent_strings_min_heap is O(nlog⁡(k))O(n\log(k)).

  • It takes O(n)O(n) time to count the frequency of each string using Counter.
  • To populate the heap, we push nn words onto it, with each push and pop operation taking O(log⁡(k))O(\log(k)) time. This takes O(nlog⁡(k))O(n\log(k)) time.
  • Then, we extract kk strings from the heap by performing the pop operation kk times. This takes O(klog⁡(k))O(k\log(k)) time.
  • Finally, we reverse the output array, which takes O(k)O(k) time.

Therefore, the overall time complexity is O(n)+O(nlog⁡(k))+O(klog⁡(k))+O(k)=O(nlog⁡(k))O(n)+O(n\log(k))+O(k\log(k))+O(k)=O(n\log(k)).

Space complexity: The space complexity is O(n)O(n) because the hash map stores at most nn pairs, whereas the heap only takes up O(k)O(k) space. The res array is not considered in the space complexity.

CHAPTER 47

Combine Sorted Linked Lists

Given k singly linked lists, each sorted in ascending order, combine them into one sorted linked list.

Example:

Image represents a directed acyclic graph illustrating a coding pattern, possibly related to data flow or function chaining.  The graph consists of several nodes, each represented by a circle containing a single digit (1, 3, 4, 6, 7).  Arrows indicate the direction of data flow or execution sequence between these nodes.  Three separate input sequences are shown above the label 'output:'. The first sequence shows node 1 connecting to node 6. The second shows node 1 connecting to node 4, which then connects to node 6. The third shows node 3 connecting to node 7. Below the label 'output:', a longer sequence is depicted, starting with node 1, proceeding through nodes 1, 3, 4, 6, 6, and finally ending at node 7. This longer sequence represents the combined or resultant output from the three input sequences, suggesting a pattern of data processing or function composition where the individual sequences contribute to a final result.

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. This results in a combined sorted linked list:

Image represents a diagram illustrating a data flow or processing pipeline.  Two rectangular boxes labeled 'ptr_1' and 'ptr_2' act as input sources, each pointing downwards with an arrow to a circular node.  'ptr_1' points to an orange-bordered circle containing the number '1', which is then connected via a rightward arrow to a standard black-bordered circle containing the number '3'. Similarly, 'ptr_2' points to a standard black-bordered circle containing the number '2', which is connected via a rightward arrow to another standard black-bordered circle containing the number '4'. Finally, a dashed-line rectangular box labeled 'output:' contains an orange-bordered circle with the number '1', suggesting that the value '1' is the final output of the process.  The diagram visually depicts a data flow where the initial values pointed to by 'ptr_1' and 'ptr_2' are processed, but only the value from 'ptr_1' appears in the output, implying a selective or filtering operation.

Image represents a diagram illustrating data flow and pointers.  The diagram shows two separate data flow paths. The first path begins with a light gray circle labeled '1', which points to a dark gray circle labeled '3'. Above '3', a rectangular box labeled 'ptr_1' indicates a pointer directing the flow to node '3'. The second path starts with an orange circle labeled '2', which points to a dark gray circle labeled '4'.  Above '2', a rectangular box labeled 'ptr_2' shows a pointer directing the flow to node '2'.  These two paths are independent.  To the right, a dashed-line rectangle labeled 'output:' contains a simplified representation of the data flow, showing a dark gray circle labeled '1' pointing to an orange circle labeled '2', mirroring the colors and order of the original paths' destinations, suggesting an output based on the pointer directions.

The image represents a data flow diagram illustrating a coding pattern.  Two rectangular boxes labeled 'ptr_1' and 'ptr_2' point downwards, respectively, to orange-outlined circle 3 and a black-outlined circle 4.  A light gray circle numbered '1' has a light gray arrow pointing to circle 3. Similarly, a light gray circle numbered '2' has a light gray arrow pointing to circle 4. A dashed-line rectangle labeled 'output:' contains three circles: a black-outlined circle '1', followed by a black-outlined circle '2', and finally an orange-outlined circle '3'.  Arrows connect these circles within the 'output:' rectangle, indicating a sequential flow from '1' to '2' and then to '3'. The diagram visually demonstrates how pointers ('ptr_1' and 'ptr_2') might direct the flow of data, resulting in a specific output sequence (1, 2, 3) even though the initial data points (1 and 2) are processed independently.  The orange highlighting of circle 3 in both sections suggests a shared or final destination for the data flow.

Image represents a comparison of two linked lists.  On the left, a grey linked list shows nodes numbered 1, 3, and 2, connected sequentially with arrows indicating the flow of data.  Above this list, two rectangular boxes labeled 'ptr_1' and 'ptr_2' represent pointers.  'ptr_1' points down to node 3, while 'ptr_2' points down to a separate, orange-bordered node numbered 4.  This node 4 is also connected to node 2 via an arrow. On the right, a dashed-line box labeled 'output:' contains a second linked list, also showing nodes numbered 1, 2, 3, and 4, sequentially connected with arrows.  Node 4 in this list is also highlighted with an orange border, mirroring the node 4 on the left. The diagram illustrates how pointers can alter the order of elements in a linked list, resulting in a different sequence as shown in the 'output' linked list.

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. Wouldn't it be nice to have an efficient way to access the smallest-valued node at any given point? A min-heap is perfect for this.

We can essentially do the same thing as in our initial approach, but instead of using pointers to determine the smallest node, we use a min-heap. Let’s see how this works over the three sorted linked lists below:

Image represents a directed acyclic graph illustrating a simplified coding pattern, possibly depicting data flow or function calls.  Three distinct paths are shown, each originating from a numbered node representing an input or starting point.  The first path starts at node '1' and directly connects to node '6'. The second path also begins at node '1', proceeds to node '4', and then finally reaches node '6'. The third path originates from node '3' and leads to node '7'.  Each node is a circle containing a single digit (1, 3, 4, 6, 7), representing a distinct stage or component in the process.  Arrows indicate the unidirectional flow of information or execution sequence between these nodes.  The absence of cycles suggests a linear or branching, but ultimately non-recursive, pattern.  No URLs or parameters are visible within the diagram.

To start, populate the heap with the head nodes of all the linked lists, so they're ready for comparison:

Image represents a visualization of a heapsort algorithm's execution.  A gray-shaded rectangular box labeled 'heap' contains three circles representing the heap's elements: 1, 1, and 3, with 'min:' preceding them to indicate the minimum value.  Arrows emanate from each of these circles, representing the extraction of the minimum element from the heap in each step. The first arrow from the top '1' points to a circle containing '6'. The second arrow from the next '1' points to a circle containing '4', which in turn has an arrow pointing to a circle containing '6'. Finally, the third arrow from the '3' points to a circle containing '7'.  To the right, a section labeled 'output:' shows a light gray circle containing 'D', representing the output sequence which is incomplete in this snapshot of the algorithm's execution. The diagram illustrates the process of repeatedly removing the minimum element from the heap and adding it to the output sequence.

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 (denoted as ‘node D’ in the above diagram).

After a node is popped off, the subsequent node from its linked list is added to the heap.


Now, let’s go through the example. First, we pop off the smallest-valued node from the heap and connect it to the tail of the output list:

Image represents a visualization of a min-heap data structure and its interaction with a pop operation.  A rectangular box labeled 'heap' contains three circular nodes representing elements within the heap: a top node with '1' (highlighted in orange and crossed out, indicating it's being removed), a middle node with '1', and a bottom node with '3'.  The label 'min:' is placed to the left of the heap, indicating that the top element is the minimum.  An arrow labeled 'pop' points from the top node (1) to a circular node '6' outside the heap, showing the removal of the minimum element.  Further arrows connect the remaining heap elements: the '1' node connects to a '4' node, and the '3' node connects to a '7' node.  The '4' node then connects to a '6' node.  On the right side, a section labeled 'output:' shows a greyed-out circular node 'D' (likely representing a temporary storage or placeholder) connected via an arrow to a final orange-highlighted circular node containing '1', representing the popped minimum value.  The overall diagram illustrates the process of extracting the minimum element from a min-heap, showing the data flow and the resulting output.

Then, add the subsequent node from the same linked list to the heap:

Image represents a visual depiction of a push operation onto a min-heap data structure.  The diagram shows a rectangular box labeled 'heap' containing two nodes with values '1' and '3,'  with 'min:' labeling the left side, indicating it's a minimum heap (smallest element at the top).  A rightward arrow connects '1' to '4' and '3' to '7,' representing the heap's initial structure.  A separate node with the value '6' is shown outside the heap. A curved arrow labeled 'push' points from '6' towards the top of the heap, illustrating the process of adding '6' to the heap.  The arrangement suggests that after the push operation, the heap would need to be re-organized to maintain the min-heap property, although the resulting structure after the re-organization is not shown.  The overall diagram illustrates the concept of adding a new element to a min-heap.

Continue this until we’ve added each node from all k linked lists to the output linked list:

Image represents a visualization of a heap data structure and its use in extracting the minimum element.  The left side shows a min-heap represented as a gray rectangle labeled 'heap,' containing three nodes with values 6, 1 (highlighted in orange with a strikethrough), and 3.  The node with value 1 is labeled 'min:' indicating it's the minimum element.  An arrow points from the minimum element (1) to a node with value 4, which is then connected to a node with value 6.  Another arrow connects the node with value 3 to a node with value 7.  The right side shows the 'output:' sequence. It starts with a light gray circle labeled 'D' (likely representing a dummy or default value), followed by an arrow to a node with value 1 (the extracted minimum), and finally an arrow to another orange-highlighted node with value 1 (indicating the minimum element has been extracted and potentially stored elsewhere). The arrows illustrate the flow of data, showing how the minimum element is removed from the heap and added to the output sequence.

Image represents a visualization of a heap data structure and its interaction with an output sequence.  On the left, a heap is depicted as a gray rectangle labeled 'heap,' containing three circular nodes with values 6, 4, and a crossed-out 3 (highlighted in orange), indicating the minimum element.  The label 'min:' precedes the minimum value. Arrows point from the 4 and the crossed-out 3 in the heap to two separate circular nodes with values 6 and 7 respectively, suggesting the removal of the minimum element from the heap. On the right, labeled 'output:', a sequence of circular nodes connected by arrows shows the output: a light gray node labeled 'D' (likely representing a dummy or initial value) is followed by nodes with values 1, 1, and finally 3 (highlighted in orange), indicating the order in which minimum elements are extracted and processed. The overall image illustrates a process where the minimum element is repeatedly removed from a heap, generating an ordered output sequence.

Image represents a visualization of a heap data structure and its interaction with an output sequence.  On the left, a heap is depicted as a gray rectangle labeled 'heap,' containing three circular nodes with numbers 6, 4 (crossed out and highlighted in orange), and 7, stacked vertically.  A label 'min:' precedes the heap. A directed arrow originates from the crossed-out 4 in the heap and points to a separate circular node containing the number 6. On the right, a sequence labeled 'output:' shows a series of connected circular nodes. The first node is light gray and contains the letter 'D', representing the start of the output. This node is connected by a directed arrow to a node with '1', which is connected to another node with '1', then to a node with '3', and finally to a node with '4' (highlighted in orange). The arrows indicate the flow of data, showing the extraction of the minimum element (4) from the heap and its subsequent placement in the output sequence, followed by other elements.

The image represents a visualization of a heap data structure and its use in sorting.  On the left, a 'heap' is depicted as a vertical array with three elements: 6 (highlighted in orange with a diagonal line through it, indicating it's being removed), 6, and 7.  The label 'min:' precedes the heap, suggesting it's a min-heap. To the right, labeled 'output:', is a sequence of circles connected by arrows, representing the sorted output. The sequence begins with a light gray circle containing 'D', likely representing the initial empty output. This is followed by a series of circles containing the numbers 1, 1, 3, 4, and finally 6 (in orange, matching the removed element from the heap). The arrows indicate the flow of data, showing the elements being extracted from the heap (in ascending order) and added to the output sequence.  The overall diagram illustrates the process of extracting the minimum element from a min-heap repeatedly to achieve a sorted sequence.

Image represents a visualization of a heap data structure and its output sequence. On the left, a rectangular box labeled 'heap' contains two circular nodes; the top node, encircled in orange and marked with a diagonal line, contains the number '6', labeled 'min:'. The bottom node contains the number '7'.  To the right, labeled 'output:', is a sequence of circular nodes connected by arrows, representing the output stream. The sequence begins with a light gray node containing 'D', followed by nodes with '1', '1', '3', '4', '6', and finally, an orange-outlined node containing '6'. The arrows indicate the flow of data from the heap to the output, suggesting a process where the minimum element ('6') is extracted from the heap, then the next minimum, and so on. The orange highlighting on the '6' in the heap and the final output node emphasizes the extracted minimum value.

Image represents a data flow diagram illustrating a heap sort algorithm.  On the left, a labeled 'heap' is depicted as a square containing a single, orange-outlined circle with the number '7' inside, struck through with a diagonal line.  The label 'min:' precedes the heap, suggesting this is a min-heap. To the right, labeled 'output:', a sequence of circles connected by rightward-pointing arrows displays the sorted output. The sequence begins with a light gray circle containing 'D', representing the initial state. This is followed by circles containing the numbers '1', '1', '3', '4', '6', '6', and finally, an orange-outlined circle containing '7', mirroring the element from the heap. The arrows indicate the flow of data from the heap to the output, showing the extraction and ordering of elements during the sorting process.  The orange highlighting on the '7' in both the heap and the output suggests it's the currently processed or last processed element.

Once the heap is empty, we can return dummy.next, which is the head of the combined linked list.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

Note that in the implementation below, we modify the ListNode class globally to simplify the solution. It's important to confirm with your interviewer that global variables are acceptable.

from typing import List
from ds import ListNode
import heapq
    
def combine_sorted_linked_lists(lists: List[ListNode]) -> ListNode:
    # Define a custom comparator for 'ListNode', enabling the min-heap to prioritize
    # nodes with smaller values.
    ListNode.__lt__ = lambda self, other: self.val < other.val
    heap = []
    # Push the head of each linked list into the heap.
    for head in lists:
        if head:
            heapq.heappush(heap, head)
    # Set a dummy node to point to the head of the output linked list.
    dummy = ListNode(-1)
    # Create a pointer to iterate through the combined linked list as we add nodes to
    # it.
    curr = dummy
    while heap:
        # Pop the node with the smallest value from the heap and add it to the output
        # linked list.
        smallest_node = heapq.heappop(heap)
        curr.next = smallest_node
        curr = curr.next
        # Push the popped node's subsequent node to the heap.
        if smallest_node.next:
            heapq.heappush(heap, smallest_node.next)
    return dummy.next
function combineSortedLinkedLists(lists) {
    const heap = [];
    // Push the head of each linked list into the heap.
    for (const head of lists) {
        if (head) {
            heap.push(head);
        }
    }
    // Set a dummy node to point to the head of the output linked list.
    const dummy = new ListNode(-1);
    // Create a pointer to iterate through the combined linked list as we add nodes to
    // it.
    let curr = dummy;
    while (heap.length > 0) {
        // Pop the node with the smallest value from the heap and add it to the output
        // linked list.
        heap.sort((a, b) => a.val - b.val);
        const smallestNode = heap.shift();
        curr.next = smallestNode;
        curr = curr.next;
        // Push the popped node's subsequent node to the heap.
        if (smallestNode.next) {
            heap.push(smallestNode.next);
        }
    }
    return dummy.next;
}
import java.util.*;

public class Solution {
    public ListNode combineSortedLinkedLists(ListNode[] lists) {
        // Define a custom comparator for 'ListNode', enabling the min-heap to prioritize
        // nodes with smaller values.
        PriorityQueue<ListNode> heap = new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));
        // Push the head of each linked list into the heap.
        for (ListNode head : lists) {
            if (head != null) {
                heap.offer(head);
            }
        }
        // Set a dummy node to point to the head of the output linked list.
        ListNode dummy = new ListNode(-1);
        // Create a pointer to iterate through the combined linked list as we add nodes to
        // it.
        ListNode curr = dummy;
        while (!heap.isEmpty()) {
            // Pop the node with the smallest value from the heap and add it to the output
            // linked list.
            ListNode smallestNode = heap.poll();
            curr.next = smallestNode;
            curr = curr.next;
            // Push the popped node's subsequent node to the heap.
            if (smallestNode.next != null) {
                heap.offer(smallestNode.next);
            }
        }
        return dummy.next;
    }
}

Complexity Analysis

Time complexity: The time complexity of combine_sorted_linked_lists is O(nlog⁡(k))O(n\log(k)), where nn denotes the total number of nodes across the linked lists. Here’s why:

  • It takes O(klog⁡(k))O(k\log(k)) time to create the heap initially because we insert kk nodes into the heap.
  • Then, for all nn nodes, we perform a push and pop operation on the heap, each taking O(log⁡(k))O(\log(k)) time.

This results in a total time complexity of O(klog⁡(k))+n⋅O(log⁡(k))=O(nlog⁡(k))O(k\log(k))+n\cdot O(\log(k))=O(n\log(k)).

Space complexity: The space complexity is O(k)O(k) because the heap stores up to one node from each of the kk linked lists at any time.

CHAPTER 48

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:

Example
Input: [add(3), add(6), get_median(), add(1), get_median()]
Output: [4.5, 3.0]

Explanation:

Example
add(3)        # data structure contains [3] when sorted
add(6)        # data structure contains [3, 6] when sorted
get_median()  # median is (3 + 6) / 2 = 4.5
add(1)        # data structure contains [1, 3, 6] when sorted
get_median()  # median is 3.0

Constraints:

  • At least one value will have been added before get_median is called.

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, since the values don’t necessarily arrive in sorted order.

A useful point to recognize is that we don’t necessarily care if all the values are sorted. What really matters is that the median values are in their sorted positions. But is it possible to position these values in the middle without maintaining a fully sorted list of values? If it is, we’d need to find a way to differentiate the median values from the rest.

Consider the elements below, which contain an even number of values arranged in sorted order. The two values used to calculate the median are highlighted in the middle:

Image represents a sequence of numbers: 0, 1, 2, 2, 3, 4, 5, 7, 8, 9.  A dashed orange box highlights the numbers 3 and 4. A downward-pointing orange arrow extends from the bottom of the box to the text 'median values' written in orange below the number sequence. This visually indicates that the numbers 3 and 4 within the box are identified as the median values of the entire dataset.

When a list contains two median values, we can make the following observations:

  • The first median is the largest value in the left half of the sorted integers.
  • The second median is the smallest value in the right half of the sorted integers.
Image represents a visual explanation of a coding pattern, likely related to sorting or searching algorithms.  A sequence of numbers (0, 1, 2, 2, 3, 4, 5, 7, 8, 9) is displayed, divided into two halves by a dashed orange line separating '3' and '4'. The left half (0, 1, 2, 2, 3) is highlighted in light purple, and the right half (4, 5, 7, 8, 9) is in light blue. A purple arrow points from the left half to the number '3', labeled 'largest of left half,' indicating that '3' is the largest element in the left subsequence.  Similarly, a light blue arrow points from the right half to the number '4', labeled 'smallest of right half,' showing '4' as the smallest element in the right subsequence.  The arrangement visually demonstrates the concept of finding the largest and smallest elements within partitioned sections of a dataset.

If we had a way to split the data into two halves, with one half containing the smaller values and the other half containing the larger values, we would just need an efficient method to identify the largest value in the smaller half, and the smallest value in the larger half. This is where heaps come in.

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. In this case, we just need to use one of the heaps to store it. Let's designate the max-heap to store this median:

The image represents a visual depiction of finding the median of a dataset.  Two rectangular boxes are shown, horizontally adjacent. The left box, colored light purple, contains the numbers 0, 1, 2, 2, and 3. The number 3 is enclosed within a dashed orange box, indicating it's the focus. The right box, colored light blue, contains the numbers 4, 5, 7, and 8. A downward-pointing orange arrow extends from the dashed box around the number 3 in the left box to the word 'median' written in orange below the boxes. This illustrates that the number 3 is identified as the median of the complete dataset (0, 1, 2, 2, 3, 4, 5, 7, 8) because it's the middle value when the dataset is sorted in ascending order.

Populating the heaps
Before identifying how we populate each heap, it’s useful to understand the behavior they must follow. Here are a couple of observations we can make:

  • All values in the left half must be less than or equal to any value in the right half.

  • The two halves should contain an equal number of values, except when the total number of values is odd, in which case the left half has one more value, as specified earlier.

These observations help us define 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), ensuring all values in the left half are less than or equal to those in the right half.

  2. The heaps should be of equal size, but the max-heap can have one more element than the min-heap.

Let's figure out how to maintain these rules in an example in which we try to add number 3 to the heaps. Note, the heaps before adding this number meet the above rules.

Image represents a visual depiction of two data structures, labeled 'left_half' and 'right_half,' likely illustrating a sorting or merging algorithm.  The 'left_half' structure is a trapezoidal shape outlined in cyan, containing the numbers 0, 1, 2, 2, and 4 arranged in a roughly triangular fashion.  Above this trapezoid, a cyan rectangle displays 'max: 4,' indicating the maximum value within the structure.  Below the trapezoid, 'size = 5' specifies the number of elements.  The 'right_half' structure mirrors this, but is outlined in purple and contains the numbers 5, 7, 8, and 9, arranged similarly.  A purple rectangle above this trapezoid shows 'min: 5,' indicating the minimum value.  Below the trapezoid, 'size = 4' indicates the number of elements.  The two structures are positioned side-by-side, suggesting they are separate parts of a larger data set, possibly before or after a merging operation in a merge sort algorithm.

Since 3 is less than the maximum value of left_half (4), it belongs in the left_half heap. So, let’s add 3 to this heap:

Image represents a visual depiction of data splitting into two halves, labeled 'left_half' and 'right_half'.  The 'left_half' is enclosed in a light-blue trapezoidal shape containing the numbers 0, 2, 2, 1, and a highlighted 3, suggesting a possible median or pivot point. Above this trapezoid, a light-blue rectangle displays 'max: 4', indicating the maximum value within the left half.  Below the trapezoid, 'size = 6' specifies the number of elements. The 'right_half' is similarly structured, using a purple trapezoid containing the numbers 7, 8, and 9. A purple rectangle above it shows 'min: 5', representing the minimum value in this half.  Below the trapezoid, 'size = 4' indicates the number of elements. The two halves are presented side-by-side, illustrating a potential data partitioning step in an algorithm, possibly related to sorting or searching, where the data is divided based on a pivot value.

After adding 3, we notice rule 2 has been violated, since the size of the left_half heap is more than one element larger than the right_half heap. We can fix this by moving the max value of left_half to right_half:

Image represents a visual depiction of a data transformation process, likely within a sorting or merging algorithm.  The image shows two stages. The initial stage displays two trapezoidal data structures labeled 'left_half' and 'right_half,' outlined in cyan and violet respectively.  The 'left_half' structure contains the numbers 0, 1, 2, 2, 3, with a 'size = 6' label indicating its element count, and a separate box labeled 'max: 4' showing the maximum value (though 4 is not present in the structure itself, suggesting a prior step).  Similarly, 'right_half' contains 7, 8, 9, with a 'size = 4' label and a 'min: 5' box indicating its minimum value. A dashed arrow connects the 'max' box of 'left_half' to the 'min' box of 'right_half,' suggesting a comparison or data transfer between these maximum and minimum values. The second stage, shown after a thick black arrow, depicts the transformed data structures.  The 'left_half' now contains 0, 1, 2, 2, 3, with 'size = 5' and 'max: 3,' while 'right_half' contains 5, 7, 8, 9, with 'size = 5' and 'min: 4.'  The transformation suggests a redistribution of elements based on the initial maximum and minimum comparisons, potentially indicating a step in a merge sort or similar algorithm.

So, to ensure the sizes of the heaps don’t violate rule 2, we need to rebalance the heaps after adding a value:

  • If the left_half heap's size exceeds the right_half heap’s size by more than one, rebalance the heaps by transferring left_half‘s top value to right_half:
Image represents a visual explanation of balancing a data structure, likely a binary tree or similar.  The left side shows an 'imbalanced' state depicted by a tall, light-blue rectangle divided into four equal sub-rectangles, labeled 'size(left_half)' at the bottom, representing a larger left subtree. A small, dark-filled square is present at the top of this rectangle. A curved arrow originates from this square and points to a smaller, light-purple rectangle divided into two equal sub-rectangles, labeled 'size(right_half)' at the bottom, representing a smaller right subtree. This illustrates an uneven distribution of data. A large black arrow points to the right, indicating a transformation. The right side shows a 'balanced' state with two rectangles of approximately equal height; one light-blue rectangle, labeled 'size(left_half)', and one light-purple rectangle, labeled 'size(right_half)'.  The dashed lines around the purple rectangle on the left suggest that this portion is being moved or redistributed to achieve balance. The overall image demonstrates the process of rebalancing a data structure to improve efficiency or performance.
  • If the right_half heap’s size exceeds the left_half heap’s size, rebalance the heaps by transferring its top value to the left_half:
Image represents a visual depiction of balancing a data structure, likely a binary tree or similar.  The left side, labeled 'imbalanced:', shows two vertically stacked rectangles representing data partitions. The left rectangle, light blue with a cyan border, is labeled 'size(left_half)' and is smaller than the right rectangle, light purple with a violet border, labeled 'size(right_half)'. A black arrow points from the left rectangle's top to a point near the top of the right rectangle, indicating a transfer or redistribution of data. A large black arrow points from the 'imbalanced:' section to the 'balanced:' section. The right side, labeled 'balanced:', shows the result of the data redistribution.  Both rectangles are now roughly equal in size; the left rectangle (light blue with a cyan border and labeled 'size(left_half)') and the right rectangle (light purple with a violet border and labeled 'size(right_half)') are visually similar in height, suggesting a more balanced distribution of data.  The overall diagram illustrates a before-and-after scenario of a balancing operation, highlighting the improvement in data structure distribution.

Returning the median
With the median values at the top of the heaps, returning the median boils down to two cases:

  • If the total number of elements is even, both median values can be found at the top of each heap. So, we return their sum divided by 2.

  • If the total number of elements is odd, the median will be at the top of the left_half heap.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

Note that in Python, heaps are min-heaps by default. To mimic the functionality of a max-heap, we can insert numbers as negatives in the left_half heap. This way, the largest original value becomes the smallest when negated, positioning it at the top of the heap. When we retrieve a value from this heap, we multiply it by -1 to get its original value.

import heapq
    
class MedianOfAnIntegerStream:
    def __init__(self):
        # Max-heap for the values belonging to the left half.
        self.left_half = []
        # Min-heap for the values belonging to the right half.
        self.right_half = []
        
    def add(self, num: int) -> None:
        # If 'num' is less than or equal to the max of 'left_half', it belongs to the
        # left half.
        if not self.left_half or num <= -self.left_half[0]:
            heapq.heappush(self.left_half, -num)
            # Rebalance the heaps if the size of the 'left_half' exceeds the size of
            # the 'right_half' by more than one.
            if len(self.left_half) > len(self.right_half) + 1:
                heapq.heappush(self.right_half, -heapq.heappop(self.left_half))
        # Otherwise, it belongs to the right half.
        else:
            heapq.heappush(self.right_half, num)
            # Rebalance the heaps If 'right_half' is larger than 'left_half'.
            if len(self.left_half) < len(self.right_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.0
        return -self.left_half[0]
class MedianOfAnIntegerStream {
    constructor() {
        // Max-heap for the values belonging to the left half.
        this.leftHalf = [];
        // Min-heap for the values belonging to the right half.
        this.rightHalf = [];
    }

    add(num) {
        // If 'num' is less than or equal to the max of 'left_half', it belongs to the
        // left half.
        if (this.leftHalf.length === 0 || num <= this.leftHalf[0]) {
            this.leftHalf.push(num);
            this.leftHalf.sort((a, b) => b - a);
            // Rebalance the heaps if the size of the 'left_half' exceeds the size of
            // the 'right_half' by more than one.
            if (this.leftHalf.length > this.rightHalf.length + 1) {
                const val = this.leftHalf.shift();
                this.rightHalf.push(val);
                this.rightHalf.sort((a, b) => a - b);
            }
        // Otherwise, it belongs to the right half.
        } else {
            this.rightHalf.push(num);
            this.rightHalf.sort((a, b) => a - b);
            // Rebalance the heaps If 'right_half' is larger than 'left_half'.
            if (this.leftHalf.length < this.rightHalf.length) {
                const val = this.rightHalf.shift();
                this.leftHalf.push(val);
                this.leftHalf.sort((a, b) => b - a);
            }
        }
    }

    getMedian() {
        if (this.leftHalf.length === this.rightHalf.length) {
            return (this.leftHalf[0] + this.rightHalf[0]) / 2.0;
        }
        return this.leftHalf[0];
    }
}
import java.util.*;

public class MedianOfAnIntegerStream {
    // Max-heap for the values belonging to the left half.
    private PriorityQueue<Integer> leftHalf;
    // Min-heap for the values belonging to the right half.
    private PriorityQueue<Integer> rightHalf;

    public MedianOfAnIntegerStream() {
        leftHalf = new PriorityQueue<>(Collections.reverseOrder());
        rightHalf = new PriorityQueue<>();
    }

    public void add(int num) {
        // If 'num' is less than or equal to the max of 'left_half', it belongs to the
        // left half.
        if (leftHalf.isEmpty() || num <= leftHalf.peek()) {
            leftHalf.offer(num);
            // Rebalance the heaps if the size of the 'left_half' exceeds the size of
            // the 'right_half' by more than one.
            if (leftHalf.size() > rightHalf.size() + 1) {
                rightHalf.offer(leftHalf.poll());
            }
        // Otherwise, it belongs to the right half.
        } else {
            rightHalf.offer(num);
            // Rebalance the heaps If 'right_half' is larger than 'left_half'.
            if (leftHalf.size() < rightHalf.size()) {
                leftHalf.offer(rightHalf.poll());
            }
        }
    }

    public double getMedian() {
        if (leftHalf.size() == rightHalf.size()) {
            return (leftHalf.peek() + rightHalf.peek()) / 2.0;
        }
        return leftHalf.peek();
    }
}

Complexity Analysis

Time complexity:

  • The time complexity of add is O(log⁡(n))O(\log(n)), where nn denotes the number of values added to the data structure. This is because we first push a number to one of the heaps, which takes O(log⁡(n))O(\log(n)) time. Then, if rebalancing is required, we also pop from one heap and push to the other, where both operations also take O(log⁡(n))O(\log(n)) time.
  • The time complexity of get_median is O(1)O(1) because accessing the top element of a heap takes O(1)O(1) time.

Space complexity: The space complexity is O(n)O(n) because the two heaps together store nn elements.

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.

CHAPTER 49

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:

Example
Input: nums = [5, 1, 9, 4, 7, 10], k = 2
Output: [1, 4, 5, 7, 9, 10]

Intuition

In a k-sorted array, each element is at most k indexes away from where it would be in a fully sorted array. We can visualize this with the following example where k = 2, and no number is more than k indexes away from its sorted position:

Image represents a visual depiction of sorting a k-sorted array where k=2.  The top row shows the unsorted input array `[5, 1, 9, 4, 7, 10]` with indices 0 through 5 displayed below each element.  The bottom row displays the sorted array `[1, 4, 5, 7, 9, 10]` with corresponding indices.  Curved arrows connect elements from the unsorted array to their final positions in the sorted array.  The arrows illustrate the movement of elements during the sorting process.  Specifically, element 5 at index 0 moves to index 2, element 1 at index 1 moves to index 0, element 9 at index 2 moves to index 4, element 4 at index 3 moves to index 1, element 7 at index 4 moves to index 3, and element 10 at index 5 remains at index 5. The gray arrows indicate that the elements are not necessarily directly adjacent in the sorted array, reflecting the k-sorted property (elements are at most k positions away from their sorted position).  The text 'k-sorted array:' and 'sorted:' labels the input and output arrays respectively.  The value `k = 2` indicates that each element is at most 2 positions away from its correct sorted position in the input 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.

We can think about this problem backward. For any index i, the element that belongs at index i in the sorted array is located within the range [i - k, i + k]. Below, we visualize how number 7, which is meant to be at index 3 when sorted, correctly falls within the range [3 - k, 3 + k] in the k-sorted array:

Image represents a visual explanation of a sorting algorithm, likely illustrating a concept related to k-sorted arrays.  The diagram shows a k-sorted array `[5, 1, 9, 4, 7, 10]` represented as a trapezoid with a light-blue fill.  A horizontal cyan line segment spans the top of the trapezoid, labeled `3 - k` on the left and `3 + k` on the right, indicating a window of size 2k+1 centered around the element at index 3 (assuming 0-based indexing). The number 7 is circled within this window, highlighting its position.  The trapezoid's bottom edge connects to a fully sorted array `[1, 4, 5, 7, 9, 10]` below, demonstrating the result of a sorting operation. The number 3 is placed below the sorted array, possibly indicating the index of the middle element in the sorted array or the length of the sorted subarray. The overall structure visually demonstrates how a portion of a k-sorted array (the window) is sorted to obtain a fully sorted array.

This is a good start, but we can reduce this range even further. Consider index 0 from the above array. We know the number which belongs at index 0 when sorted is somewhere in the range [0, 0 + k]:

Image represents a visual explanation of sorting a k-sorted array.  The top row shows a 'k-sorted array' [5, 1, 9, 4, 7, 10], labeled as such.  A light-blue trapezoid highlights a section of this array, specifically the elements [5, 1, 9].  A horizontal, light-blue arrow above this section points from index θ (zero) to index θ + k, indicating a window of size k elements. The number '1' within the highlighted section is circled, emphasizing its position. The bottom row displays the 'sorted' array [1, 4, 5, 7, 9, 10], resulting from sorting the k-sorted array.  A vertical light-blue line connects the beginning of both arrays (index θ), and a diagonal line connects the end of the highlighted section in the k-sorted array to the end of the corresponding sorted section in the sorted array, visually demonstrating the transformation.  The diagram illustrates how a portion of a k-sorted array (where each element is at most k positions away from its sorted position) can be sorted to obtain a fully sorted array.

Note that the sorted array in the diagrams is purely provided as a reference point. We don't yet know which number in the range [0, 0 + k] belongs at index 0. However, one fact remains consistent: in a sorted array, index 0 always holds the smallest number. This means the value needed at index 0 is also the smallest number within the range [0, 0 + k] of the k-sorted array, which is 1 in this example.

So, let's swap 1 with the number at index 0 to position 1 as the first value in the sorted array:

Image represents a step-by-step illustration of a sorting algorithm applied to a k-sorted array.  The top line shows an initial k-sorted array `[5 1 9 4 7 10]`, where a light-blue highlighted sub-array `[5 1 9]` represents a window of size `k` starting at index `0`. A light-blue line extends from index `0` to `0 + k`, indicating the window's boundaries.  An arrow points down from a small box containing 'i', suggesting this is the 'i'th iteration of the algorithm. Below, a second line displays the array after a swap operation: `[1 5 9 4 7 10]`.  The word 'swapped' and curved arrows indicate that elements 1 and 5 have been swapped. To the right, a dashed box describes the operation performed: 'swap nums[i] with min([5 1 9])', highlighting that the element at index `i` (5) was swapped with the minimum element (1) within the k-sized window. The number 1 is highlighted in peach within the `min()` function to emphasize its selection as the minimum value.  The diagram visually demonstrates a single iteration of a sorting algorithm that iteratively swaps elements within a k-sized window to achieve a sorted array.

Now let's find the number that belongs at index 1: the second smallest number. Since index 0 currently contains the smallest value in the array, we won't need to consider index 0 in our search. Therefore, we can find the value that belongs at index 1 in the range [1, 1 + k]. The smallest value in this range will be the second smallest value overall, which is 4 in this case.

So, let's swap 4 with the number at index 1 to position 4 as the second value in the sorted array:

Image represents a step-by-step illustration of a sorting algorithm applied to a k-sorted array.  The top section shows the initial k-sorted array `[1 5 9 4 7 10]`, labeled as 'k-sorted array:'. A light-blue rectangle highlights the sub-array [5 9 4] from index 1 (labeled '1' below it) to index 1+k (labeled '1+k' below it). An arrow points down from a box labeled 'i', indicating the index being processed. A dashed box to the right explains the operation: 'swap nums[i] with min([5 9 4])', highlighting '4' as the minimum value within the highlighted sub-array. The bottom section shows the array after the swap, `[1 4 9 5 7 10]`, labeled 'k-sorted array:', with a curved arrow indicating that the elements at indices 1 and 3 have been swapped.  The entire diagram visually demonstrates a single iteration of a sorting algorithm (likely a variation of selection sort) on a k-sorted array, focusing on the process of finding and swapping the minimum element within a window of size k.

If we continue this process for the rest of the array, we'll successfully sort the k-sorted array.

The main inefficiency with this approach is finding the minimum number in the range [i, i + k] at each index i. Linearly searching for it will take O(k)O(k) time at each index.

To improve this approach, we'd need a way to efficiently access the minimum value at each of these ranges. A min-heap would be perfect for this.

Min-heap
For a min-heap to determine the minimum value within each range [i, i + k], it will always need to be populated with the values in these ranges as we iterate through the array. Let's see how this works over the same example.


Before we can determine which value belongs at index 0, we'll need to populate the heap with all the values in the range [0, k], which are the first k + 1 values (where k = 2 in this example):

Image represents a step-by-step illustration of a coding pattern, likely involving a min-heap data structure.  The top-left shows an integer variable `k` initialized to 2 and an index variable `i` represented by a box. A downward arrow from `i` points to an array `nums` containing the elements [5, 1, 9, 4, 7, 10], with indices 0 through 5 displayed below each element.  To the right, a trapezoidal shape labeled 'min_heap' represents a min-heap data structure with a separate box above it labeled 'min:' indicating the minimum element. A light-gray, dashed-bordered box to the right of the min-heap shows the code snippet `min_heap.push(nums[i])` and `i++`, indicating that the element at index `i` of the `nums` array is being pushed onto the min-heap, and then the index `i` is incremented.  The overall diagram visualizes the process of iterating through the `nums` array and adding each element to a min-heap, likely as part of a larger algorithm (e.g., finding the kth smallest element).
Image represents a step-by-step illustration of building a min-heap from an array.  The array `[5, 1, 9, 4, 7, 10]` is shown at the bottom, with indices 0 through 5 labeled below each element.  A square box labeled 'i' indicates an index pointer, currently pointing via a downward arrow to the element '1' at index 1. A dashed arrow indicates that the element at index 'i' is being processed. To the right, a trapezoidal shape labeled 'min_heap' represents the min-heap data structure under construction.  Currently, it only contains the element '5', labeled with 'min: 5' to its left, indicating it's the minimum value in the heap. A dashed-line box to the right of the min-heap shows the code snippet `min_heap.push(nums[i])` and `i++`, indicating that the element at index `i` (currently 1, the value 1) is being pushed onto the min-heap, and then the index `i` is incremented.  The overall diagram visualizes the iterative process of constructing a min-heap by sequentially adding elements from the input array.
Image represents a snapshot of a step in an algorithm, likely involving a min-heap data structure.  The bottom left shows an array `nums` = [5, 1, 9, 4, 7, 10] with indices 0 through 5 displayed below each element.  A box labeled 'i' points via a dashed arrow to the element 9 at index 2, indicating the current iteration of a loop.  To the right, a purple trapezoid labeled 'min_heap' visually represents a min-heap containing the value 5 at its root and 1 at the top. A separate purple box labeled 'min:' displays the value 1, representing the minimum element currently in the min-heap. A dashed-line box to the right of the min-heap shows the code snippet `min_heap.push(nums[i])` followed by `i++`, indicating that the algorithm is pushing the element at index `i` (which is 9) onto the min-heap and then incrementing `i` for the next iteration.  The overall diagram illustrates the process of building a min-heap from an array by iteratively pushing elements onto the heap.
Image represents a visual depiction of a step in a sorting algorithm, likely using a min-heap data structure.  The left side shows an array `[5, 1, 9, 4, 7, 10]` indexed from 0 to 5, with a pointer `i` currently at index 3, indicating the element '4'. A dashed arrow points from `i` to '4', suggesting that element '4' is being considered or processed.  Below the array is a labeled axis 'k + 1', implying 'k' represents a variable related to the algorithm's progress. The right side displays a min-heap, a tree-based data structure where the smallest element is at the root. The min-heap contains the elements 1, 5, and 9, with '1' at the top, labeled 'min: 1', indicating it's the minimum value in the heap. The overall image suggests that the algorithm is building or updating the min-heap by incorporating elements from the array, with the current focus on element '4' at index 3.

An alternative way to create a heap of the first k + 1 elements is to heapify a list of the first k + 1 elements.


Now, let's begin inserting the smallest elements from the heap into the array, using the insert_index pointer. The value that belongs at index 0 in sorted order is the value currently at the top of the heap, which is 1:

The image represents a step in a sorting algorithm, likely using a min-heap data structure.  On the left, a numbered array `[5, 1, 9, 4, 7, 10]` is shown, with indices 0 through 5 displayed below.  A box labeled 'insert_index' points down to this array, indicating a position where a value will be inserted.  Separately, a box labeled 'i' points to the same array, likely representing an iterator or index variable.  To the right, a min-heap is depicted as a triangular structure containing the values 5 and 9.  Above the min-heap, a purple box labeled 'min:' contains the value '1,' representing the minimum element currently in the heap.  An arrow connects this '1' to a dashed box containing the code snippet `nums[insert_index] = min_heap.pop()`. This code signifies that the minimum value (1) from the `min_heap` is being popped (removed) and assigned to the array element at the index specified by `insert_index`.  The overall diagram illustrates the process of inserting a value into a sorted array using a min-heap to efficiently find the smallest element.

Once we insert 1 at index 0, push the value at index i to the heap before incrementing both pointers:

Image represents a step-by-step illustration of building a min-heap from an array.  The top half shows the initial state: an array `[1, 1, 9, 4, 7, 10]` is depicted with indices 0-5 below each element.  A variable `insert_index` points to the first element (index 0), and a variable `i` points to the element at index 3 (value 4). A partially-filled min-heap is shown to the right, containing only the value 9, with a label `min: 5` indicating the minimum value (5) which is not yet in the heap. A dashed box next to the min-heap displays the code snippet `min_heap.push(nums[i])`, `insert_index++`, `i++`, indicating that the element at index `i` (4) is being pushed onto the min-heap, `insert_index` is incremented, and `i` is incremented. The bottom half shows the next step: `insert_index` now points to the element at index 3 (value 4), and `i` points to the element at index 4 (value 7). The min-heap now contains 4, 5, and 9, with `min: 4` indicating the current minimum.  Dashed arrows indicate the movement of `insert_index` and `i` through the array.  The overall diagram visualizes the iterative process of constructing a min-heap by sequentially adding elements from an array.

Let's continue this process for the remaining numbers:

Image represents a step-by-step illustration of a sorting algorithm, likely using a min-heap data structure.  The top half shows an initial state: an array `nums` containing `[1, 1, 9, 4, 7, 10]` is indexed by `insert_index` (initially pointing to the first element, highlighted in green) and `i` (pointing to the second element). A min-heap, labeled `min_heap`, contains the values `5` and `9`.  An arrow indicates that the minimum value from the min-heap (4, highlighted in purple) is assigned to `nums[insert_index]`, replacing the initial value. The bottom half depicts the next step:  `insert_index` has advanced to the next position. The value at `nums[i]` (4) is pushed onto the `min_heap`, updating it to contain `5` and `9`.  A separate box describes the actions performed:  popping the minimum element from the min-heap and assigning it to the array element at `insert_index`, then pushing the next array element onto the min-heap, incrementing both `insert_index` and `i`.  The array and min-heap are visually represented, showing the changes in their contents after each step.

Image represents a visual explanation of a coding pattern, likely involving a min-heap data structure and an array.  The top half shows an array `nums` represented as `[1, 4, 9, 4, 7, 10]` with indices 0-5, and a min-heap `min_heap` containing the values 7 and 9.  A box labeled 'insert_index' points to the element '9' in the array.  A purple box labeled 'min: 5' indicates the minimum value (5) is being extracted from the min-heap. An arrow connects this to a dashed box showing the operation `nums[insert_index] = min_heap.pop()`, which replaces the element at `insert_index` (9) with the minimum value from the heap (5).  The bottom half shows the result of this operation: the array is now `[1, 4, 5, 4, 7, 10]`, and the min-heap contains only 9.  A box labeled 'i' points to the next element (7) in the array. A dashed box shows the operation `min_heap.push(nums[i])`, `insert_index++`, `i++`, indicating that the next element (7) is pushed onto the min-heap, and the `insert_index` and `i` are incremented.  The overall diagram illustrates a step-by-step process of replacing array elements with values from a min-heap, likely part of a sorting or selection algorithm.

Image represents a visual depiction of inserting an element into a min-heap data structure.  The left side shows an array `[1, 4, 5, 4, 7, 10]` with indices 0 through 5 displayed below.  A box labeled 'insert_index' points via a dashed arrow to the element '4' at index 3, indicating where a new element is to be inserted. On the right, a min-heap is shown, represented as a trapezoidal structure with elements 7 (labeled 'min: 7' in a box), 9, and 10. A box labeled 'i' points via a dashed arrow to the element '10' in the array, suggesting the index from which the element is being inserted into the heap. The array and the min-heap are visually separate but conceptually linked, illustrating the process of adding an element to the heap, which would involve comparing and potentially swapping elements to maintain the min-heap property.

Once there are no more elements to push into the heap, the rest of the array can be sorted by inserting the remaining values from the heap:

Image represents a step in a sorting algorithm, likely using a min-heap data structure.  The algorithm involves an array `nums` represented by `[1, 4, 5, 4, 7, 10]` with indices shown below. A separate min-heap, `min_heap`, is depicted as a trapezoidal structure containing the values 9 and 10. A variable `insert_index` points to the next available position in `nums` (index 3 in this case). A purple box labeled `min: 7` represents the minimum value currently in the `min_heap`. A rightward arrow connects this box to a dashed box containing the code snippet `nums[insert_index] = min_heap.pop(); insert_index++;`. This code indicates that the minimum element (7) from the `min_heap` is being popped and assigned to the element at `nums[insert_index]`, after which `insert_index` is incremented.  The overall process illustrates how the algorithm uses the min-heap to efficiently find and place the next smallest element into its correct sorted position within the `nums` array.
Image represents a step in a sorting algorithm, likely heapsort, illustrating the process of inserting elements from a list into a min-heap.  A numbered list `[1, 4, 5, 7, 7, 10]` is shown, with indices 0-5 displayed below.  A variable `insert_index` points to the next available position (index 3) in the list. A purple min-heap is depicted, currently containing only the value 10.  A separate purple box labeled `min: 9` represents the minimum value extracted from the min-heap. A dashed-line box contains the code snippet `nums[insert_index] = min_heap.pop(); insert_index++;`, indicating that the minimum element (9) from the `min_heap` is being assigned to the element at `insert_index` in the `nums` array, and then `insert_index` is incremented.  An arrow connects the `min: 9` box to the code snippet, showing the data flow.  The algorithm appears to be iteratively removing the minimum element from the min-heap and placing it into its correct sorted position within the `nums` array.
Image represents a step in a sorting algorithm, likely using a min-heap data structure.  The left side shows an array `nums` with elements [1, 4, 5, 7, 9, 10] indexed from 0 to 5.  A box labeled 'insert_index' points to the element '10' at index 5, indicating the current position for insertion.  A purple box labeled 'min: 10' represents the minimum element (10) extracted from a min-heap data structure depicted as a purple trapezoid labeled 'min_heap'. A right-pointing arrow connects the 'min: 10' box to a grey box containing the code snippet `nums[insert_index] = min_heap.pop(); insert_index++;`. This code signifies that the minimum value (10) from the min_heap is assigned to the array element at `insert_index`, and then `insert_index` is incremented, preparing for the next element insertion.  The overall process illustrates how a sorted array is constructed by repeatedly extracting the minimum element from a min-heap and placing it into its correct position in the array.
Image represents a visual depiction of a min-heap data structure.  On the left, a green array `[1, 4, 5, 7, 9, 10]` is shown, with indices 0 through 5 displayed below each element. To the right, a trapezoidal shape labeled 'min_heap' represents the heap structure itself. Above the heap, a small purple rectangle labeled 'min:' suggests this area displays the minimum value within the heap (which would be 1 in this case, based on the array).  There's no explicit connection drawn between the array and the heap, but the implication is that the array's elements are stored in the min-heap according to the min-heap property (where the parent node is always less than or equal to its children).

Once the heap is empty, the array is sorted.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
import heapq
        
def sort_a_k_sorted_array(nums: List[int], k: int) -> List[int]:
    # Populate a min-heap with the first k + 1 values in 'nums'.
    min_heap = nums[:k+1]
    heapq.heapify(min_heap)
    # Replace elements in the array with the minimum from the heap at each
    # iteration.
    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])
    # Pop the remaining elements from the heap to finish sorting the array.
    while min_heap:
        nums[insert_index] = heapq.heappop(min_heap)
        insert_index += 1
    return nums
function sortAKSortedArray(nums, k) {
    // Populate a min-heap with the first k + 1 values in 'nums'.
    const minHeap = nums.slice(0, k + 1);
    minHeap.sort((a, b) => a - b);
    // Replace elements in the array with the minimum from the heap at each
    // iteration.
    let insertIndex = 0;
    for (let i = k + 1; i < nums.length; i++) {
        nums[insertIndex] = minHeap.shift();
        insertIndex += 1;
        // Insert nums[i] maintaining sorted order
        let low = 0, high = minHeap.length;
        while (low < high) {
            const mid = (low + high) >> 1;
            if (minHeap[mid] < nums[i]) low = mid + 1;
            else high = mid;
        }
        minHeap.splice(low, 0, nums[i]);
    }
    // Pop the remaining elements from the heap to finish sorting the array.
    while (minHeap.length > 0) {
        nums[insertIndex] = minHeap.shift();
        insertIndex += 1;
    }
    return nums;
}
import java.util.*;

public class Solution {
    public int[] sortAKSortedArray(int[] nums, int k) {
        // Populate a min-heap with the first k + 1 values in 'nums'.
        PriorityQueue<Integer> minHeap = new PriorityQueue<>();
        for (int i = 0; i <= Math.min(k, nums.length - 1); i++) {
            minHeap.offer(nums[i]);
        }
        // Replace elements in the array with the minimum from the heap at each
        // iteration.
        int insertIndex = 0;
        for (int i = k + 1; i < nums.length; i++) {
            nums[insertIndex] = minHeap.poll();
            insertIndex += 1;
            minHeap.offer(nums[i]);
        }
        // Pop the remaining elements from the heap to finish sorting the array.
        while (!minHeap.isEmpty()) {
            nums[insertIndex] = minHeap.poll();
            insertIndex += 1;
        }
        return nums;
    }
}

Complexity Analysis

Time complexity: The time complexity of sort_a_k_sorted_array is O(nlog⁡(k))O(n\log(k)), where nn denotes the length of the array. Here's why:

  • We perform heapify on a min_heap of size k+1k+1 which takes O(k)O(k) time. Note that kk is upper-bounded by nn in this operation since the heap won't have more than nn values.

  • Then, we perform push and pop operations on approximately n−kn-k values using the heap. Since the heap can grow up to a size of k+1k+1, each push and pop operations takes O(log⁡(k))O(\log(k)) time. Therefore, this loop takes O(nlog⁡(k))O(n\log(k)) time in the worst case.

  • The final while-loop runs in O(klog⁡(k))O(k\log(k)) time since we pop k+1k+1 values from the heap. Note that kk here is also upper-bounded by nn.

Therefore, the overall time complexity is O(k)+O(nlog⁡(k))+O(klog⁡(k))=O(nlog⁡(k))O(k)+O(n\log(k))+O(k\log(k))=O(n\log(k)).

Space complexity: The space complexity is O(k)O(k) since the heap can grow up to k+1k+1 in size.

CHAPTER 50

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.  The diagram consists of a thick, black horizontal line extending from left to right.  At the leftmost end of the line is a thick, vertical black bar labeled 'start,' indicating the beginning of the process.  At the rightmost end of the line is a similar thick, vertical black bar labeled 'end,' signifying the completion of the process. The length of the horizontal line visually represents the duration or extent of the process between the start and end points. No other elements, such as intermediate points or annotations, are present on the line itself.

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 and a thick, black line connecting them.  The endpoints are labeled with the numbers '3' and '8', vertically aligned below their respective positions on the line.  The line segment visually represents a range or interval from 3 to 8, implying a continuous sequence of values within that range. No other information, such as URLs or parameters, is present in the image. The arrangement is simple and linear, with the numbers clearly indicating the start and end points of the represented range.
  • Open intervals: The start and end points are not included in the interval.
The image represents a simple line graph or diagram illustrating a relationship between two data points.  A thick, horizontal, black line connects two filled circles, which act as markers for the data points.  The leftmost circle is vertically aligned with the number '3', positioned below it, indicating a value of 3 for that data point. Similarly, the rightmost circle is vertically aligned with the number '8', positioned below it, representing a value of 8 for the second data point. The line connecting the circles visually represents the relationship or connection between these two data points (3 and 8), suggesting a direct link or transition between them.  No other labels, text, URLs, or parameters are present in the image.
  • Half-open intervals: Either the start or the end point is included, while the other is not.
Image represents two identical line segments, each visually depicting a range.  Both segments are thick, black lines horizontally oriented.  Each line starts with a thick, black vertical bar on the left, labeled '3' vertically below it, and ends with a similar vertical bar on the right, labeled '8' vertically below it. The numbers '3' and '8' likely represent the start and end points of a numerical range, respectively. The two line segments are placed side-by-side with a small gap between them, suggesting a comparison or parallel representation of the same range. No other elements, such as arrows, labels, or text beyond the '3' and '8' markers, are present in the image.

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.  A light green rectangle labeled 'overlap' is positioned between points 4 and 6 on the horizontal axis.  A thick black line extends from point 2 to the left edge of the rectangle at point 4, representing the first interval.  Another thick black line extends from the right edge of the rectangle at point 6 to point 7, representing the second interval.  The numbers 2, 4, 6, and 7 are marked on the axis, indicating the start and end points of the intervals. The overlap region, visually represented by the green rectangle, shows the shared portion between the two intervals, highlighting the concept of overlapping ranges.

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.  The top line defines a list named `intervals` containing five sub-lists, each representing an interval. These sub-lists are `[1, 6]`, `[2, 4]`, `[5, 9]`, `[8, 10]`, and `[10, 12]`. The second line shows a list called `start points` containing the start values of each interval, extracted from the `intervals` list: `[1, 2, 5, 8, 10]`.  These values are highlighted in cyan. The third line displays a list named `end points`, which contains the corresponding end values of each interval from the `intervals` list: `[4, 6, 9, 10, 12]`. These values are highlighted in orange.  The arrangement visually demonstrates how the `start points` and `end points` lists are derived from the `intervals` list, showing a clear relationship between the intervals and their constituent start and end points.

Interval class definition
For the problems in this chapter, intervals are represented using the class below.

class Interval:
   def __init__(self, start, end):
       self.start = start
       self.end = end
class Interval {
    constructor(start, end) {
        this.start = start;
        this.end = end;
    }
}
public class Interval {
    public int start;
    public int end;

    public Interval(int start, int end) {
        this.start = start;
        this.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.  A rounded rectangle at the top, labeled 'Intervals,' acts as the parent node, connected via dashed lines to two child nodes represented as rounded rectangles with dashed borders. The left child node is titled 'Managing Overlaps' and lists two sub-tasks: 'Merge Overlapping Intervals' and 'Identify All Interval Overlaps.' The right child node is titled 'Finding Values in Sorted Order' and contains a single sub-task: 'Largest Overlap of Intervals.'  The arrangement shows that 'Intervals' is a broader category encompassing two distinct approaches: one focused on managing overlapping intervals (merging and identifying them), and the other on finding specific values within sorted intervals (identifying the largest overlap).  The dashed lines indicate a hierarchical relationship, showing that the two lower-level nodes are specific applications or sub-problems within the broader context of 'Intervals.'
CHAPTER 51

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.  A horizontal time axis is numbered from 1 to 8, representing time units. Three dark-grey horizontal bars represent different tasks. The topmost bar spans from 2.5 to 3.5. The middle bar extends from 1.5 to 4.5. The third bar runs from 6.5 to 7.5. A longer, bright-orange bar sits at the bottom, starting at 1 and ending at 5, indicating a longer-duration task. Another shorter orange bar starts at 6 and ends at 8, representing another task.  The grey bars represent shorter tasks that are potentially concurrent with the longer orange tasks, with some overlap and some separation in time.  The dotted vertical lines at points 1, 5, and 8 on the time axis may indicate milestones or significant points in the schedule.
Example
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 number line is labeled with integers from 1 to 8.  A thick black line labeled 'A' spans from approximately position 2 to 5 on the number line, with 'A.end' explicitly marking its right endpoint at 5.  Another thick black line labeled 'B' extends from approximately position 6 to 8, with 'B.start' clearly indicating its left endpoint at 6. A dashed light-blue line connects 'A.end' to 'B.start', visually representing the separation between the two intervals. The text 'Non-overlapping:' precedes the diagram, clarifying the relationship between the intervals A and B, emphasizing that they do not share any common points.

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 the 'Overlapping' coding pattern.  A horizontal number line, ranging from approximately 1 to 8, serves as the timeline. Two segments, labeled 'A' and 'B,' are depicted on this timeline. Segment 'A' extends from approximately point 2 to point 5, with 'A.end' explicitly marked at point 5. Segment 'B' starts at point 5 ('B.start') and ends at approximately point 7.  A dashed orange vertical line connects the end of segment A ('A.end') and the start of segment B ('B.start'), visually highlighting the point of overlap between the two segments.  The segments are represented by thick black horizontal lines with perpendicular lines at their start and end points. The overall arrangement demonstrates how the end of one process (A) coincides with the beginning of another (B), indicating an overlapping execution or dependency.
Image represents a diagram illustrating overlapping intervals.  The diagram uses a horizontal number line ranging from approximately 1 to 8. Two intervals, labeled 'A' and 'B', are depicted. Interval 'A' is represented by a thick black line extending from approximately 2 to 5 on the number line, with labels 'A' and 'A.end' at its midpoint and right endpoint respectively. Interval 'B' is shown as a thick black line above 'A', starting at approximately 4 ('B.start' label) and ending at approximately 6. A dashed orange line connects the start of 'B' to the end of 'A', visually indicating an overlap between the two intervals. The text 'Overlapping:' is placed to the left of the intervals, providing context for the diagram's purpose.  The number line provides a visual scale to represent the start and end points of each interval.

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, each enclosed in square brackets `[]` and containing two integer elements separated by a comma `,`.  The arrays are arranged horizontally, adjacent to one another without any intervening spaces.  The first array contains the integers 3 and 4; the second, 7 and 8; the third, 2 and 5; the fourth, 6 and 7; and the fifth, 1 and 4.  No information flows between the arrays; they are presented as independent data structures.  There are no URLs or parameters present.

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.  The top row shows five sub-lists, each containing two numbers: [3, 4], [7, 8], [2, 5], [6, 7], and [1, 4].  A downward-pointing arrow labeled 'sort' connects the top and bottom rows. The bottom row displays the same five sub-lists, but now each sub-list is sorted in ascending order: [1, 4], [2, 5], [3, 4], [6, 7], and [7, 8].  The diagram visually demonstrates the application of a sorting algorithm to each individual sub-list, leaving the order of the sub-lists themselves unchanged.

To aid the explanation, let's represent the intervals visually:

Image represents a visualization of intervals on a number line.  The label 'intervals:' is positioned to the left of the diagram. A horizontal grey number line is shown, ranging from 1 to 8, marked with integer values. Above the number line are four horizontal grey line segments representing intervals. The first interval spans from approximately 1.2 to 4, the second from approximately 2 to 5, the third from approximately 3 to 3.5, and the fourth from approximately 6.5 to 7.5. Each interval is depicted as a line segment with small perpendicular lines at both ends, indicating the start and end points of the interval on the number line.  The intervals are not explicitly labeled but are visually distinct from each other and their positions relative to the numbered line clearly show their range.

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.  The image displays a number line ranging from 1 to 8. Above the number line, labeled 'intervals:', are several horizontal gray line segments representing individual intervals.  These intervals are: a short segment spanning approximately 1.5 to 2.5, a longer segment from approximately 2 to 5, a shorter segment around 6 to 7, and a very short segment near 7.5 to 8. Below the number line, labeled 'merged:', is a single, longer orange line segment that spans from approximately 1 to 4. This orange segment represents the result of merging all overlapping gray intervals. Dotted vertical lines connect the endpoints of the longest gray interval (from 2 to 5) to the corresponding points on the merged orange interval, visually demonstrating the merging process. The gray intervals are shown to illustrate the input, while the orange interval shows the output after merging overlapping intervals.

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. This makes sense since the interval in the merged array (A) starts before or at the same time as B.

We notice B starts before A ends, indicating an overlap. So, let's merge them:

Image represents a visual explanation of interval merging in a coding pattern.  The image displays a number line spanning from 1 to 8. Above the number line, labeled 'intervals:', are two grey horizontal line segments representing intervals. The top segment, labeled 'B.start' and 'B', extends from approximately 2.5 to 4.5. The second segment extends from approximately 6 to 7. Below the number line, labeled 'merged:', is an orange horizontal line segment labeled 'A' and 'A.end', extending from approximately 1 to 5. To the right, a dashed-line box contains the condition 'A.end ≥ B.start (overlap)' and an arrow pointing right indicating the action '→ merge A and B'. This illustrates that if the end of interval A is greater than or equal to the start of interval B (indicating an overlap), then intervals A and B should be merged.  The visual representation clearly shows the relationship between the intervals and the merging logic.

When merging A and B, we use the leftmost start value and the rightmost end value between them. Since A will always start before or at the same time as B, we always use A.start as the start point. This means we just need to identify the end point, which is the largest value between the end points of A and B:

Image represents a visual explanation of merging overlapping intervals.  The top section shows a number line from 1 to 8 with three gray horizontal line segments representing intervals.  One segment spans approximately from 1.5 to 4, another from 6.5 to 7.5, and a third, labeled 'B', spans from approximately 2 to 5.  Below this, a light-orange segment labeled 'A' is shown, spanning from approximately 1 to 4.  Dashed light-blue arrows connect the end of interval A and the end of interval B to a final, merged orange interval below. This final interval starts at the beginning of interval A (labeled 'A.start') and ends at the maximum of the ends of intervals A and B (labeled 'max(A.end, B.end) = B.end'), visually demonstrating how the merging process combines overlapping intervals into a single, larger interval.  The text 'intervals:' and 'merged:' labels the different sections of the diagram.

We can apply the same logic to the next interval:

Image represents a visual explanation of merging overlapping intervals.  The top section, labeled 'intervals:', shows a number line from 1 to 8 with two sets of gray intervals.  The first interval, labeled 'B.start' at the top, extends from approximately 2 to 4.  A second gray interval runs from approximately 1 to 4. A third interval extends from approximately 6 to 7, and a fourth from approximately 7 to 8. The bottom section, labeled 'merged:', displays a single orange interval labeled 'A' that spans from approximately 1 to 5, representing 'A.end'.  To the right, a dashed-line box explains the merging logic: 'A.end ≥ B.start (overlap)' indicates that if the end of interval A is greater than or equal to the start of interval B, then the intervals overlap ('→ merge A and B').  This implies that the merging process combines overlapping intervals into a single, larger interval.
Image represents a visual explanation of merging overlapping intervals.  The top section, labeled 'intervals:', shows two sets of gray intervals on a number line ranging from 1 to 8.  One interval extends from approximately 1.5 to 4, and another from 4 to 5. A third interval runs from 6.5 to 7.5, and a fourth from approximately 7 to 8. A separate, shorter interval labeled 'B' is centered at 4, overlapping with the first two intervals. The middle section, labeled 'merged:', shows a peach-colored interval labeled 'A' extending from approximately 1.5 to 5, representing a merging of the first two intervals.  Dashed light-blue lines indicate the merging process, connecting the endpoints of the original intervals to the new merged interval. The bottom section, also labeled 'merged:', displays a solid orange interval representing the final merged result. This interval starts at the beginning of the first interval (labeled 'A.start') and ends at the maximum value between the end of interval A (5) and the end of interval B (4), which is 5 (indicated by 'max(A.end, B.end) = A.end').  The orange interval visually demonstrates the final merged interval after considering the overlap.

When we reach the fourth interval, we notice B starts after A ends, indicating there is no overlap.

Image represents a visual explanation of a merging algorithm for intervals.  The top section, labeled 'intervals:', shows two horizontal grey lines representing intervals on a numbered axis (1 to 8). The first interval extends from approximately 1.5 to 4, and the second, labeled 'B', starts at 6 and ends at 8.  A smaller grey rectangle labeled 'B' is centered on the second interval to highlight it. The bottom section, labeled 'merged:', displays a single orange line labeled 'A' extending from approximately 1 to 5, representing a merged interval.  'A.end' is labeled at the right end of this orange line. A dashed-line box to the right explains the condition for merging: 'A.end < B.start (no overlap)' which indicates that if the end of interval A is less than the start of interval B (meaning no overlap), then B is added to the merged list.  The arrow within the box points to the right, indicating the direction of the data flow (adding B to the merged list).

So, we just add it as a new interval to the merged array:

Image represents a visual illustration of merging intervals.  The top section, labeled 'intervals:', shows three horizontal gray lines representing intervals on a number line ranging from 1 to 8. The first interval extends from approximately 2 to 5, the second from approximately 1 to 4, and the third from approximately 7 to 8. Below, a number line is shown with numerical markers from 1 to 8. A black horizontal line labeled 'B' is positioned above the number line, spanning from approximately 6 to 7.  Dashed light-blue lines descend from the endpoints of interval 'B' to the number line below. The bottom section, labeled 'merged:', displays a single, longer orange horizontal line labeled 'A,' extending from approximately 1 to 8, representing the merged intervals. This line visually demonstrates the result of combining the intervals shown above, indicating that the merging process combines overlapping or adjacent intervals into a single, larger interval.

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 a visual explanation of merging overlapping intervals.  The top section displays three gray horizontal line segments labeled 'intervals:' representing three separate intervals on a numbered axis ranging from 1 to 8. The first interval spans approximately from 1.5 to 4.5, the second from 2.5 to 5, and the third from 6 to 7.  Below, labeled 'merged:', a single longer orange line segment shows the result of merging these intervals, extending from approximately 1.5 to 7.  To the right, a smaller gray line segment labeled 'B.start' and 'B' is shown, partially overlapping with the rightmost gray interval from the top section. A dashed-line box contains the condition 'A.end ≥ B.start (overlap)' and an arrow indicating 'merge A and B,' explaining the merging logic: if the end of interval A is greater than or equal to the start of interval B, then intervals A and B are merged.  The labels 'A.end' and 'A' are used to denote the end point and the interval A respectively in the merged interval and the condition.

So, let's merge A with B:

Image represents a visual explanation of merging overlapping intervals.  The top section, labeled 'intervals:', shows three gray horizontal line segments representing intervals on a number line ranging from 1 to 8.  The first segment spans approximately from 1.5 to 4.5, the second from 1.8 to 5, and the third from 6.2 to 7.2. Below, labeled 'merged:', a light-orange segment appears, representing the merging of the first two intervals, spanning approximately from 1.5 to 5.  Further below, labeled 'merged:', a final orange segment illustrates the complete merging of all three intervals, extending from approximately 1.5 to 8.  Dashed cyan lines connect the right end of the third gray interval (labeled 'B') to the right end of the final orange interval, indicating its influence on the final merged interval's endpoint.  Similarly, dashed cyan and gray lines connect the interval labeled 'A' (the merged first two intervals) to the final merged interval, showing how its start and end points contribute to the final merged interval's boundaries. The text 'A.start max(A.end, B.end) = B.end' under the final merged interval clarifies that the final interval's end point is determined by the maximum of the end points of intervals A and B.

After processing the last interval, we've successfully merged all intervals.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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 and B don't overlap, add B to the merged list.
        if A.end < B.start:
            merged.append(B)
        # If they do overlap, merge A with B.
        else:
            merged[-1] = Interval(A.start, max(A.end, B.end))
    return merged
function mergeOverlappingIntervals(intervals) {
    if (!intervals || intervals.length === 0) return [];
    intervals.sort((a, b) => a.start - b.start);
    const merged = [intervals[0]];
    for (let i = 1; i < intervals.length; i++) {
        const B = intervals[i];
        const A = merged[merged.length - 1];
        // If A and B don't overlap, add B to the merged list.
        if (A.end < B.start) {
            merged.push(B);
        // If they do overlap, merge A with B.
        } else {
            merged[merged.length - 1] = new Interval(A.start, Math.max(A.end, B.end));
        }
    }
    return merged;
}
import java.util.*;

public class Solution {
    public List<Interval> mergeOverlappingIntervals(List<Interval> intervals) {
        if (intervals == null || intervals.isEmpty()) return new ArrayList<>();
        intervals.sort((a, b) -> Integer.compare(a.start, b.start));
        List<Interval> merged = new ArrayList<>();
        merged.add(intervals.get(0));
        for (int i = 1; i < intervals.size(); i++) {
            Interval B = intervals.get(i);
            Interval A = merged.get(merged.size() - 1);
            // If A and B don't overlap, add B to the merged list.
            if (A.end < B.start) {
                merged.add(B);
            // If they do overlap, merge A with B.
            } else {
                merged.set(merged.size() - 1, new Interval(A.start, Math.max(A.end, B.end)));
            }
        }
        return merged;
    }
}

Complexity Analysis

Time complexity: The time complexity of merge_overlapping_intervals is O(nlog(n))O(nlog(n)), where nn denotes the number of intervals. This is due to the sorting algorithm. The process of merging overlapping intervals itself takes O(n)O(n) time because we iterate over every interval.

Space complexity: The space complexity depends on the space used by the sorting algorithm. In Python, the built-in sorting algorithm, Tim sort, uses O(n)O(n) space. Note that the merged array is not considered in the space complexity calculation because we're only concerned with extra space used, not space taken up by the output.

Interview Tip

Tip: Visualize intervals to uncover logic and edge cases.
Managing intervals and handling edge cases is much easier when visualizing example inputs. Drawing examples also helps your interviewer follow along with your reasoning and understand your thought process.

CHAPTER 52

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.  A horizontal number line spans from 1 to 10, marked with integer values. Above the number line, two sets of intervals are shown, labeled 'intervals1:' and 'intervals2:'.  'intervals1:' contains two gray line segments; the first extends from approximately 1.5 to 4.5, and the second from approximately 5.5 to 6.5. 'intervals2:' shows three gray line segments; the first from approximately 2 to 7, the second from approximately 8 to 9.5, and the third from approximately 9 to 9.5. Below the number line, labeled 'intersections:', are three orange line segments representing the intersections of the intervals above. The first orange segment spans from approximately 2 to 4.5, corresponding to the overlap between the first segment of 'intervals1' and the first segment of 'intervals2'. The second orange segment extends from approximately 5.5 to 6.5, showing the overlap between the second segment of 'intervals1' and the first segment of 'intervals2'. The third orange segment is a short line at approximately 9, representing the intersection of the second segment of 'intervals2' and the third segment of 'intervals2'.  Dashed vertical lines connect the endpoints of the intervals to the number line for clarity.
Example
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.

Let’s start by learning how to identify an overlap between two overlapping intervals.

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 we know A starts before B. Let’s have a look at a couple of examples which each contain two overlapping intervals that match this condition:

Image represents two Gantt chart-like diagrams illustrating the temporal relationship between two processes, A and B.  Both diagrams use a horizontal axis numbered 1 through 7, representing time units.  The first diagram shows process A spanning from approximately time unit 1.5 to 4, labeled 'A.end' at its right endpoint. Process B is represented by a thicker line spanning from approximately time unit 2 to 7, labeled 'B.start' at its left endpoint and 'B' mid-line.  The second diagram shows a similar setup, but with process B (labeled 'B' mid-line and 'B.start' at its left endpoint) now significantly shorter, spanning only from approximately time unit 2.5 to 3.5. Process A (labeled 'A' mid-line and 'A.end' at its right endpoint) remains largely unchanged in its duration and position, spanning from approximately time unit 1.5 to 4.  The diagrams visually compare scenarios where process B's duration changes, while process A's duration and position remain relatively constant.

To extract the overlap between these two overlapping intervals, we’ll 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 a coding pattern, likely related to scheduling or resource allocation.  Each diagram shows a number line spanning from 1 to 7.  On each, a black line segment labeled 'A' represents a task or resource with a defined start and end point. A second black line segment labeled 'B' overlaps partially with 'A', also indicating a start and end point.  In the left diagram, 'B' extends beyond 'A's end, while in the right diagram, 'B' is entirely contained within 'A'.  In both, an orange line segment labeled 'B.start' marks the beginning of 'B'.  Its end is determined by `min(A.end, B.end)`, calculated separately below each diagram.  This calculation shows that 'B's end is truncated to match 'A's end in the left diagram (`= A.end`), and 'B's end remains unchanged in the right diagram (`= B.end`). The dashed orange lines highlight the boundaries used in the calculation, visually demonstrating how the end of 'B' is adjusted based on the relative positions of 'A' and 'B'.

Therefore, when two intervals overlap, their overlap is defined by the range [B.start, min(A.end, B.end)]. Remember that in all these cases, interval A always starts first.

Identifying all overlaps
Now, let’s return to the two arrays of intervals. Consider this example:

Image represents a visualization of two sets of intervals, labeled 'intervals1' and 'intervals2,' displayed on a common numerical scale ranging from 1 to 10.  'intervals1' consists of three distinct intervals: the first spans from approximately 2 to 4, the second from approximately 5 to 6, and the third from approximately 9 to 10. Each interval is represented by a thick horizontal line segment with perpendicular short lines at its endpoints indicating the start and end points. 'intervals2' shows two intervals: a longer one extending from approximately 2 to 7, and a shorter one from approximately 8 to 9, also represented by thick horizontal line segments with perpendicular short lines at their endpoints. A light gray horizontal arrow indicates the shared numerical scale beneath both sets of intervals, with numbers 1 through 10 marked along its length.  The arrangement allows for a visual comparison of the overlapping and non-overlapping portions of the intervals in both sets.

Let’s start by considering the first interval from each array:

Image represents a visualization of two sets of intervals, labeled 'intervals1' and 'intervals2,' displayed on a number line ranging from 1 to 10.  'intervals1' shows a thick black line segment extending from approximately 2 to 4, with a smaller orange square labeled 'i' pointing down to its right endpoint (4). 'intervals2' displays a thick black line segment spanning roughly from 2 to 7, with a smaller orange square labeled 'j' pointing down to its right endpoint (7).  Both sets also include shorter, lighter gray line segments representing additional intervals: one in 'intervals1' from approximately 5 to 6, and two in 'intervals2,' one from approximately 7 to 8 and another from approximately 9 to 10. The arrangement suggests a comparison or interaction between the two interval sets, possibly illustrating an algorithm or process involving iterators 'i' and 'j' traversing through these intervals.

To check if these intervals overlap, we’ll need to identify which interval between intervals1[i] and intervals2[j] starts first, so we can assign that interval as interval A and the other as interval B. The code snippet for this is provided below:

Example
# Set A to the interval that starts first and B to the other interval.
if intervals1[i].start <= intervals2[j].start:
    A, B = intervals1[i], intervals2[j]
else:
    A, B = intervals2[j], intervals1[i]

In this example, intervals1[i] starts first:

Image represents a visualization of two sets of intervals, labeled 'intervals1' and 'intervals2,' displayed on a shared numerical scale ranging from 1 to 10.  'intervals1' shows a thick black line segment, labeled 'A,' extending from approximately 2.5 to 4.  Two thinner, grey line segments are also present in 'intervals1,' one starting around 4.5 and ending around 5.5, and another starting around 8.5 and ending around 9.5. 'intervals2' displays a thick black line segment, labeled 'B,' spanning from approximately 2 to 7.  Two shorter, grey line segments are also shown in 'intervals2,' one starting around 7.5 and ending around 8.5, and another starting around 9 and ending around 10.  The numerical scale at the bottom provides a reference for the positions of these intervals.  The intervals are represented visually as line segments, with their endpoints indicating the start and end points of each interval. The thicker black lines represent the primary intervals of interest, while the thinner grey lines represent secondary or additional intervals.

A and B overlap when A.end ≥ B.start, which is true here. Since they overlap, let’s record their overlap: [B.start, min(A.end, B.end)]:

Image represents a visualization of interval intersection.  A horizontal number line spans from 1 to 10. Two intervals, labeled 'intervals1' and 'intervals2,' are depicted above the number line. 'intervals1' is represented by a thick black line segment labeled 'A' extending from approximately 1.5 to 4 on the number line. 'intervals2' is shown as a thick black line segment labeled 'B' extending from approximately 2 to 7.  Both intervals have small perpendicular lines at their start and end points.  Below the number line, in orange, is the 'intersections' section showing the overlapping portion of the two intervals. This intersection is a thick orange line segment starting at 2 (labeled 'B.start') and ending at 4 (labeled as the result of the formula 'min(A.end, B.end) = A.end'). Dashed orange vertical lines connect the start and end points of the intersection to the corresponding points on the intervals above.  Additional short grey line segments are present above the number line, representing other intervals not directly involved in the intersection calculation.

Now that we’ve identified the overlap between those two intervals, let’s move on to the next pair by advancing the pointer at one of the interval arrays. Since intervals1[i] ends before intervals2[j], we know that intervals1[i] won’t overlap with any more intervals from the intervals2 array, so let’s increment the intervals1 pointer (i) to move to the next interval in this array:

Image represents a visualization of two sets of intervals, labeled 'intervals1' and 'intervals2,' displayed on a shared numerical axis ranging from 1 to 10.  'intervals1' shows a dark gray line segment extending from approximately 2 to 4, and another shorter, black segment from 5 to 6. 'intervals2' is represented by a dark gray line segment spanning from approximately 2 to 7, and a lighter gray segment from 8 to 10.  Orange boxes labeled 'i' and 'j' are positioned above the intervals.  A dashed orange line connects 'j' to a point near the end of the first 'intervals1' segment (around 4), and a solid orange arrow points from 'i' down to the beginning of the second 'intervals1' segment (at 5).  Another solid orange arrow points downwards from 'j' to the point where the second 'intervals1' segment begins (at 5). This suggests a relationship or interaction between the intervals, possibly indicating an algorithm comparing or merging them, where 'i' and 'j' represent iterators or pointers within the algorithm.

Note, we use intervals1[i] and intervals2[j] instead of A and B since we don’t know which interval array A or B belongs to.


We’ve now identified a process that allows us to identify and record all overlaps while traversing the arrays of intervals. For the pair of intervals being considered at i and j:

  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 to apply these steps until either i or j have passed the end of their array. Once this happens, we know there won’t be any more overlapping intervals.


Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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):
       # Set A to the interval that starts first and B to the other interval.
       if intervals1[i].start <= intervals2[j].start:
           A, B = intervals1[i], intervals2[j]
       else:
           A, B = intervals2[j], intervals1[i]
       # If there's an overlap, add the overlap.
       if A.end >= B.start:
           overlaps.append(Interval(B.start, min(A.end, B.end)))
       # Advance the pointer associated with the interval that ends first.
       if intervals1[i].end < intervals2[j].end:
           i += 1
       else:
           j += 1
   return overlaps
function identifyAllIntervalOverlaps(intervals1, intervals2) {
    const overlaps = [];
    let i = 0, j = 0;
    while (i < intervals1.length && j < intervals2.length) {
        // Set A to the interval that starts first and B to the other interval.
        let A, B;
        if (intervals1[i].start <= intervals2[j].start) {
            A = intervals1[i];
            B = intervals2[j];
        } else {
            A = intervals2[j];
            B = intervals1[i];
        }
        // If there's an overlap, add the overlap.
        if (A.end >= B.start) {
            overlaps.push(new Interval(B.start, Math.min(A.end, B.end)));
        }
        // Advance the pointer associated with the interval that ends first.
        if (intervals1[i].end < intervals2[j].end) {
            i += 1;
        } else {
            j += 1;
        }
    }
    return overlaps;
}
import java.util.*;

public class Solution {
    public List<Interval> identifyAllIntervalOverlaps(List<Interval> intervals1, List<Interval> intervals2) {
        List<Interval> overlaps = new ArrayList<>();
        int i = 0, j = 0;
        while (i < intervals1.size() && j < intervals2.size()) {
            // Set A to the interval that starts first and B to the other interval.
            Interval A, B;
            if (intervals1.get(i).start <= intervals2.get(j).start) {
                A = intervals1.get(i);
                B = intervals2.get(j);
            } else {
                A = intervals2.get(j);
                B = intervals1.get(i);
            }
            // If there's an overlap, add the overlap.
            if (A.end >= B.start) {
                overlaps.add(new Interval(B.start, Math.min(A.end, B.end)));
            }
            // Advance the pointer associated with the interval that ends first.
            if (intervals1.get(i).end < intervals2.get(j).end) {
                i += 1;
            } else {
                j += 1;
            }
        }
        return overlaps;
    }
}

Complexity Analysis

Time complexity: The time complexity of identify_all_interval_overlaps is O(n+m)O(n+m) where nn and mm are the lengths of intervals1 and intervals2, respectively. This is because we traverse each interval in both arrays exactly once.

Space complexity: The space complexity is O(1)O(1). Note that the overlaps array is not considered because space complexity is only concerned with extra space used and not space taken up by the output.

CHAPTER 53

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 ranging from 1 to 8, illustrating three overlapping intervals.  Four horizontal line segments, representing the intervals, are drawn above the number line.  The left endpoints of the segments are marked with solid circles, and the right endpoints with open circles. The first segment spans approximately from 1 to 3. The second segment starts around 2 and ends around 6. The third segment begins around 4 and ends around 7. The fourth segment starts at approximately 6 and ends at 7. A dashed orange rectangle highlights the area where three intervals (segments 2, 3, and 4) overlap, located roughly between 5 and 6 on the number line. An orange curved arrow points to this rectangle with the text '3 overlapping intervals' above it.  The number line provides a visual representation of the intervals' positions and their degree of overlap.
Example
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 (intervals that started at or before this time, and haven’t yet ended):

Image represents a horizontal timeline ranging from 1 to 8, depicting four horizontal line segments representing intervals.  Each segment starts with a filled circle and ends with an unfilled circle, indicating a start and end point respectively. Three of these segments overlap within a region highlighted by a dashed orange rectangle around the 5 mark on the timeline. A curved orange arrow points from this overlapping region to a grey box labeled '3 active intervals,' further specifying that these three intervals 'have started' and 'haven't yet ended.' The fourth segment lies entirely to the left of the orange dashed rectangle, indicating it has already ended.  The lengths of the segments vary, representing different durations of the intervals.

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 of an interval indicates the start of a new active interval, whereas an end point represents an active interval finishing. This suggests an approach which looks at start and end points individually could be useful. Let’s explore this idea further.

Processing start points and end points
Let’s step through each point in the array of intervals in chronological order, and see if we can determine the number of active intervals at each point.


The first point is a start point, indicating the start of the first active interval. So, let’s increment our counter:

Image represents a diagram illustrating a coding pattern, possibly related to interval management or event tracking.  The diagram features a numbered horizontal axis ranging from 1 to 8.  Above the axis are several horizontal line segments representing intervals; the lowest segment, highlighted in light blue at its left end, starts at point 1 and extends to point 3.  Above this are three more intervals: one starting at point 4 and ending at point 5, another starting at point 2 and ending at point 7, and a final one starting at point 6 and ending at point 7. A square box labeled 'i' is positioned above the axis and a downward arrow connects it to the beginning of the lowest interval, suggesting an input or initialization point. To the right, a dashed-line box contains the code snippet 'start point -> active_intervals += 1 = 1', indicating that when a 'start point' is encountered (presumably the beginning of an interval), a variable named 'active_intervals' is incremented by 1, resulting in a value of 1.  The overall diagram visually depicts how intervals are identified and counted, possibly within a larger algorithm.

The next point is another start point. This is the start of the second active interval, so let’s increment our counter again. Now, the number of active intervals is 2, which correctly corresponds with the number of overlapping intervals at this point:

Image represents a visualization of an algorithm, possibly related to interval scheduling or similar.  The diagram shows a number line spanning from 1 to 8, with several horizontal line segments representing intervals. A light-blue circle at point 2 on the number line indicates a starting point, marked by a downward-pointing arrow originating from a square box labeled 'i'.  Three intervals are depicted: one extending from 1 to 3, another from 4 to 7, and a shorter one from 6 to 7.  A separate, dashed-line box to the right displays a code snippet: 'start point → active_intervals += 1 = 2', indicating that upon reaching the start point (2), the variable `active_intervals` is incremented by 1, resulting in a value of 2. This suggests the algorithm counts or tracks active intervals based on the starting point and the overlapping intervals on the number line.

The next point is an end point, which means an active interval has just finished. So, let’s decrement our counter. Now, the number of active intervals is 1, which also corresponds with the current number of overlapping intervals:

Image represents a diagram illustrating a coding pattern, likely related to interval management or event processing.  The diagram's core is a horizontal number line spanning from 1 to 8, representing a time or sequence axis.  On this line, several horizontal line segments are drawn, each representing an interval.  A thicker, peach-colored circle at point 3 marks a specific event or point of interest.  A square labeled 'i' above the number line points downwards with an arrow to this circle, suggesting an input or trigger at this point.  Three distinct intervals are shown: one starting at approximately 1 and ending at 3 (peach circle), another starting at 2 and ending at 7, and a third starting at 6 and ending at 7.  The intervals are represented by thick black lines with endpoints marked by small circles. To the right, a light gray box with dashed borders contains a code snippet showing an operation: 'end point → active_intervals -= 1' followed by '= 1', indicating that when an interval's endpoint is reached, a variable named 'active_intervals' is decremented by 1, and the result is 1.  This suggests the diagram visualizes how the number of active intervals changes over time based on interval endpoints.

We’ve now rationalized what we need to do whenever we encounter a start point or end point:

  • If we encounter a start point: increment active_intervals.
  • If we encounter an end point: decrement active_intervals.

Processing the remaining points allows us to attain the number of active intervals at each point. The final answer is obtained by recording the largest value of active_intervals.

Edge case: processing concurrent start and end points
An edge case to consider is when a start and end point occur simultaneously:

Image represents a horizontal line graph illustrating the concept of overlapping intervals.  The x-axis is numbered from 1 to 8, representing a numerical scale. Four horizontal line segments of varying lengths are plotted above the x-axis.  The left endpoint of each segment indicates a start point, and the right endpoint indicates an end point, representing an interval.  The first segment spans from approximately 1 to 3. The second segment extends from approximately 4.2 to 7. The third segment runs from approximately 4.8 to 8. The fourth and shortest segment is positioned from approximately 5 to 5.5.  A dashed box highlights the intersection of the second and fourth segments, specifically around the x-axis value of 6. Within this box, a light peach-colored circle represents the endpoint of the fourth segment, and a light blue circle represents the start point of the second segment, indicating an overlap between these two intervals. The circles at the endpoints of the line segments are open, suggesting that the endpoints are not included in the intervals.

Which point should we process first? Keep in mind the value of active_intervals is 3 right before we reach time = 6 in the above example.

At time 6, if we process the start point first and increment active_intervals, we would update it to 4 first, which is incorrect as there are never 4 active intervals at this moment. This is an issue because our final answer is the largest value of active_intervals encountered, which means we’ll incorrectly record 4 as the answer.

Image represents a flowchart illustrating a process where an initial value, `active_intervals = 3`, is processed.  A light-blue arrow labeled 'process start point' indicates the flow from the initial value to an intermediate value of `4`, enclosed in a red box.  An orange arrow labeled 'process end point' shows the flow from the intermediate value of `4` to a final value of `3`. A curved red arrow points from the intermediate value `4` to the text 'wrong: too high,' indicating that the intermediate result is incorrect because it exceeds the expected final value.  The diagram visually demonstrates a process where an input value is transformed, resulting in an output that is evaluated for correctness based on a predefined constraint.

If we process the end point first, we won’t encounter this issue:

Image represents a diagram illustrating a process with two stages.  The diagram begins with the text 'active_intervals = 3', indicating an initial state where the number of active intervals is 3.  An orange arrow then extends from this initial state, labeled above with 'process' in orange text and below with 'end point' in orange text, pointing to the number '2'. This signifies a process that reduces the number of active intervals from 3 to 2.  Following this, a light-blue arrow starts from '2' and points to '3', labeled above with 'process' in light-blue text and below with 'start point' in light-blue text. This represents a second process that increases the number of active intervals back to 3. The entire diagram visually depicts a cyclical process affecting the number of active intervals, starting and ending with a count of 3, with an intermediate state of 2.

Therefore, for start and end points that occur simultaneously, we should process end points before start points.

Iterating over interval points in order
We need a way to iterate through the start and end points in the order they should be processed. To do this, we can combine all start and end points into a single array and sort it. For start and end points of the same value, we ensure end points are prioritized before start points while the points are being sorted.

Let’s use ‘S’ and ‘E’ to differentiate between start and end points, respectively:

Image represents a coding pattern illustration demonstrating the transformation of a list of intervals into a sorted list of points.  The top line shows a Python-like list called `intervals` containing five sub-lists, each representing an interval with a start and end point: `[1, 3]`, `[2, 6]`, `[4, 8]`, `[6, 7]`, and `[5, 7]`. A right arrow indicates a transformation to the `points` list below, which contains all the start and end points from the `intervals` list: `[1 2 3 4 5 6 6 7 7 8]`. Underneath each point in the `points` list, 'S' (in light blue) denotes a start point of an interval and 'E' (in orange) denotes an end point.  A small downward-pointing arrow underlines the 'E' and 'S' representing an end point appearing before a start point, highlighting a condition where the end point of one interval is sorted before the start point of another. The text 'end point sorted before start point' explains this specific arrangement and the implication of the sorting process.

The algorithm we used to solve this problem is known as a ‘sweeping line algorithm.’ It works by processing the start and end points of intervals in order, as if a vertical line was sweeping across them. This method efficiently handles the dynamic nature of interval overlaps by specifically focusing on start and end points, rather than individual intervals.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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'))
   # Sort in chronological order. If multiple points occur at the same time, ensure
   # end points are prioritized before start points in the sorting order.
   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
function largestOverlapOfIntervals(intervals) {
    const points = [];
    for (const interval of intervals) {
        points.push([interval.start, 'S']);
        points.push([interval.end, 'E']);
    }
    // Sort in chronological order. If multiple points occur at the same time, ensure
    // end points are prioritized before start points in the sorting order.
    points.sort((a, b) => {
        if (a[0] !== b[0]) {
            return a[0] - b[0];
        }
        return a[1].localeCompare(b[1]);
    });
    let activeIntervals = 0;
    let maxOverlaps = 0;
    for (const [time, pointType] of points) {
        if (pointType === 'S') {
            activeIntervals += 1;
        } else {
            activeIntervals -= 1;
        }
        maxOverlaps = Math.max(maxOverlaps, activeIntervals);
    }
    return maxOverlaps;
}
import java.util.*;

public class Solution {
    static class Point {
        int time;
        char type;

        Point(int time, char type) {
            this.time = time;
            this.type = type;
        }
    }

    public int largestOverlapOfIntervals(List<Interval> intervals) {
        List<Point> points = new ArrayList<>();
        for (Interval interval : intervals) {
            points.add(new Point(interval.start, 'S'));
            points.add(new Point(interval.end, 'E'));
        }
        // Sort in chronological order. If multiple points occur at the same time, ensure
        // end points are prioritized before start points in the sorting order.
        points.sort((a, b) -> {
            if (a.time != b.time) {
                return Integer.compare(a.time, b.time);
            }
            return Character.compare(a.type, b.type);
        });
        int activeIntervals = 0;
        int maxOverlaps = 0;
        for (Point p : points) {
            if (p.type == 'S') {
                activeIntervals += 1;
            } else {
                activeIntervals -= 1;
            }
            maxOverlaps = Math.max(maxOverlaps, activeIntervals);
        }
        return maxOverlaps;
    }
}

Complexity Analysis

Time complexity: The time complexity of largest_overlap_of_intervals is O(nlog⁡(n))O(n\log(n)), where nn denotes the number of intervals. This is because we sort the points array of size 2n2n before iterating over it in O(n)O(n) time.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the points array.

CHAPTER 54

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 a Python-like list assignment.  The variable `spendings` is assigned a list containing five integer values: `10`, `15`, `20`, `10`, and `5`. These values are enclosed within square brackets `[]`, indicating a list data structure. Below the list, the days of the week—Monday (Mon), Tuesday (Tue), Wednesday (Wed), Thursday (Thu), and Friday (Fri)—are aligned with the corresponding numerical values in the list, implying that each number represents the spending for that particular day.  The overall structure shows a simple data representation where daily spending is stored in a list, with the list's index implicitly representing the day of the week.

Let's say you want to know the total spent on takeout food up until a particular day. For example, you might like to know that the total you've spent up until Wednesday is $45 ($10 + $15 + $20). This is information which a prefix sum array can store. For an array of integers, a prefix sum array maintains the running sum of values up to each index in the array.

Image represents a comparison of two arrays:  'spendings' and 'prefix_sums'. The 'spendings' array, shown as `spendings = [10 15 20 10 5]`, lists daily spending amounts for Monday through Friday, respectively.  Each spending value is aligned vertically with its corresponding day label (Mon, Tue, Wed, Thu, Fri) in a lighter gray font. Below this, the 'prefix_sums' array, displayed as `prefix_sums = [10 25 45 55 60]`, shows the cumulative sum of spending up to and including each day.  For example, the first element (10) is the spending for Monday, the second element (25) is the sum of Monday's and Tuesday's spending (10 + 15), the third (45) is the sum of Monday, Tuesday, and Wednesday's spending (10 + 15 + 20), and so on.  The arrangement clearly demonstrates the relationship between daily spending and its running total, illustrating the concept of prefix sums.

To obtain the prefix sum at each index, we just add the current number from the input array to the prefix sum from the previous index.

Image represents a step-by-step calculation of prefix sums for an array.  The top row shows the input array `[10, 15, 20, 10, 5]`.  Each element in this array is represented by a vertical orange line pointing downwards.  The label 'prefix_sums = [10' indicates the initialization of a new array to store the prefix sums.  Subsequent rows show the iterative calculation.  Each orange line represents the addition of the current element to the running sum.  Curved peach arrows indicate the addition operation (+), connecting the previous prefix sum to the current element.  The resulting prefix sums are shown in each row within square brackets: `[10]`, `[10, 25]`, `[10, 25, 45]`, `[10, 25, 45, 55]`, and finally `[10, 25, 45, 55, 60]`.  The process demonstrates how each element in the prefix sums array is the cumulative sum of all preceding elements in the input array.

In code, the above process looks like this:

def compute_prefix_sums(nums):
    # Start by adding the first number to the prefix sums array.
    prefix_sum = [nums[0]]
    # For all remaining indexes, add 'nums[i]' to the cumulative sum from the previous
    # index.
    for i in range(1, len(nums)):
        prefix_sum.append(prefix_sum[-1] + nums[i])
function computePrefixSums(nums) {
    // Start by adding the first number to the prefix sums array.
    const prefixSum = [nums[0]];
    // For all remaining indexes, add 'nums[i]' to the cumulative sum from the previous
    // index.
    for (let i = 1; i < nums.length; i++) {
        prefixSum.push(prefixSum[prefixSum.length - 1] + nums[i]);
    }
    return prefixSum;
}
public class Solution {
    public int[] computePrefixSums(int[] nums) {
        // Start by adding the first number to the prefix sums array.
        int[] prefixSum = new int[nums.length];
        prefixSum[0] = nums[0];
        // For all remaining indexes, add 'nums[i]' to the cumulative sum from the previous
        // index.
        for (int i = 1; i < nums.length; i++) {
            prefixSum[i] = prefixSum[i - 1] + nums[i];
        }
        return prefixSum;
    }
}

As you can see, building a prefix sum array takes O(n)O(n) time and O(n)O(n) space, where nn denotes the length of the array.

Applications of prefix sums
Aside from allowing us to have constant-time access to running sums at any index within an array, prefix sums are commonly used to efficiently determine the sum of subarrays. This application is examined in depth in the problems in this chapter.

Another interesting variant of prefix sums is prefix products, which populates an array with a running product instead of a running sum. Similar to prefix sums, prefix products provide an efficient way to determine the product of subarrays.

Real-world Example

Financial analysis: As hinted at earlier, a real-world use of prefix sums is for financial analysis, particularly in calculating cumulative earnings or expenses over time.

For instance, consider a company's daily revenue over a month. A prefix sum array can be used to quickly calculate the total revenue for any given period within that month. By precomputing the prefix sums, the company can instantly determine the revenue from day 5 to day 20 without having to sum each day's revenue individually. This is especially useful for generating financial reports, where quick calculations over various periods are necessary to analyze trends.

Chapter Outline

Image represents a hierarchical diagram illustrating applications of the 'Prefix Sums' coding pattern.  A central, rounded-rectangle box labeled 'Prefix Sums' is connected via dashed lines to two subordinate, rounded-rectangle boxes. The left subordinate box is labeled 'Subarray Sums' and contains two bullet points: '- Sum Between Range' and '- K-Sum Subarrays,' indicating that prefix sums are used to efficiently calculate sums within specified ranges of an array and to find subarrays that sum to a target value (K). The right subordinate box is labeled 'Product Sums' and contains one bullet point: '- Product Array Without Current Element,' showing that prefix sums can also be adapted to compute the product of array elements excluding the current element.  The arrows indicate a top-down relationship, showing how the general 'Prefix Sums' technique branches into these specific applications.
CHAPTER 55

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 a `sum_range` function operating on a numerical array `[3, -7, 6, 0, -2, 5]`.  Each example shows the array with indices 0-5 displayed below each element.  The function `sum_range(start_index, end_index)` calculates the sum of elements within a specified range (inclusive). The first example, `sum_range(0, 3)`, highlights elements at indices 0, 1, 2, and 3 (3, -7, 6, 0) in a light-blue box, with a blue arrow above indicating their sum, `sum = 2`. The second example, `sum_range(2, 4)`, highlights elements at indices 2, 3, and 4 (6, 0, -2), showing `sum = 4` above the highlighted section. Finally, `sum_range(2, 2)` highlights only the element at index 2 (6), resulting in `sum = 6`.  In all cases, the function's parameters (start and end indices) are clearly visible, along with the resulting sum displayed above the highlighted portion of the array.
Example
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), where i and j are the indexes defining the boundaries of the range to be summed up.

A naive solution is to iteratively sum the array values from index i to j, which takes linear time for each call to sum_range. Since we have access to the input array before any calls to sum_range are made, we should consider if any preprocessing can be done to improve the efficiency of sum_range.

This problem deals with subarray sums, so it might be useful to think about how prefix sums can be applied to solve it. Consider the integer array below and its prefix sums:

Image represents a code snippet illustrating the concept of prefix sum.  The top line declares a Python list named `nums` containing the integer values [3, -7, 6, 0, -2, 5].  Below this,  faint gray numbers [0, 1, 2, 3, 4, 5] are shown as indices for the `nums` list. The second line declares another list named `prefix_sum`. This list contains the prefix sums of the `nums` list.  `prefix_sum`'s values are calculated cumulatively: the first element is the same as the first element in `nums` (3), the second is the sum of the first two elements of `nums` (3 + (-7) = -4), the third is the sum of the first three (3 + (-7) + 6 = 2), and so on.  Similarly, faint gray numbers [0, 1, 2, 3, 4, 5] are shown as indices for the `prefix_sum` list, aligning with the corresponding elements in `nums`.  The arrangement visually demonstrates the relationship between an original list and its prefix sum, showing how each element in `prefix_sum` is the cumulative sum of the preceding elements in `nums`.

We already notice that the prefix sum array has some use: the prefix sum up to any index j essentially gives the answer to sum_range(0, j). For example, the sum of the range [0, 3] is just the prefix sum up to index 3:

Image represents a visual explanation of prefix sum calculation.  The top line shows the equation `sum_range(0, 3) = prefix_sum[3]`, indicating that the sum of elements from index 0 to 3 in an array is equal to the value at index 3 in the prefix sum array. Below, an array named `nums` is shown with values [3, -7, 6, 0, -2, 5], with indices 0 through 5 displayed beneath each element. A light-blue background highlights elements at indices 0, 1, 2, and 3. A cyan-colored line segment above the highlighted portion indicates the range of summation, labeled 'sum = 2,' representing the sum of the highlighted elements (3 + (-7) + 6 + 0 = 2). A downward arrow points from the highlighted section of `nums` to an array named `prefix_sum`.  `prefix_sum` contains the cumulative sums of `nums`; its values are [3, -4, 2, 2, 0, 5], with indices 0 through 5 displayed beneath each element. The value at index 3 in `prefix_sum` (2) matches the calculated sum of `nums` from indices 0 to 3, visually demonstrating the prefix sum concept.

Therefore, when i == 0:

sum_range(0, j) = prefix_sum[j]

What about when the requested range doesn’t start at 0? Let's say we want to find the sum in the range [2, 4]:

Image represents a visual depiction of a sub-array within a larger array.  The larger array is shown partially, with elements `[3, -7]` on the left and `[5]` on the right.  These elements are vertically stacked with their indices shown in grey below: `0` and `1` under `[3, -7]` and `5` under `[5]`. The central portion of the image highlights a sub-array `[6, 0, -2]` in a light blue background.  This sub-array's indices are also shown in grey below, indicating positions `2`, `3`, and `4` respectively. The arrangement visually demonstrates how a sub-array is a contiguous section of a larger array, selected by its starting and ending indices.  The indices are used to access specific elements within the array.

Is there a way to get this using only prefix sums? All prefix sum values are sums for ranges that start at index 0. So, let’s see how we could make use of these ranges. Consider the sum of the range [0, 4], which corresponds to prefix_sum[4]:

Image represents a visual depiction of a sub-array within a larger array.  A light-blue rectangular area displays a numerical array [3, -7, 6, 0, -2], with indices [0, 1, 2, 3, 4] shown faintly below each element.  Above this array, a bright-blue line segment extends horizontally, marked with small perpendicular lines at its ends. A small triangle points downwards from the line segment, positioned above the element '0' at index 3.  The text 'sum[0 : 4]' is written in blue above the line, indicating that a sum is being calculated from index 0 up to (but not including) index 4.  The number '5' is shown outside the light-blue rectangle to the right, likely representing an element beyond the sub-array being considered. The overall image illustrates a sub-array selection and a potential summation operation within a larger array context.

The key observation here is that the sum of the range [2, 4] can be obtained by subtracting the sum of the range [0, 1] from the sum above. This can be visualized:

Image represents a visual depiction of array summation.  A numerical array `[3, -7, 6, 0, -2, 5]` is shown, partitioned into sub-arrays. The first two elements, `[3, -7]`, are highlighted in light gray with red diagonal lines, and a red line segment below indicates their summation, labeled `sum[0: 1]`.  The next three elements, `[6, 0, -2]`, are highlighted in light blue, with a light blue line segment below indicating their summation, labeled `sum[2: 4]`.  A light blue line segment above encompasses the entire array from index 0 to 4, representing the total sum of the array, labeled `sum[0: 4]`. The element at index 5, `5`, is shown separately, not included in any summation.  The indices of each element are subtly indicated below the numbers within the array.

Since the sums of ranges [0, 4] and [0, 1] are both values in our prefix sum array, we can obtain the sum of the range [2, 4] from the following expression: prefix_sum[4] - prefix_sum[1].

Therefore, when i > 0:

sum_range(i, j) = prefix_sum[j] - prefix_sum[i - 1]

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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]
class SumBetweenRange {
    constructor(nums) {
        this.prefixSum = [nums[0]];
        for (let i = 1; i < nums.length; i++) {
            this.prefixSum.push(this.prefixSum[this.prefixSum.length - 1] + nums[i]);
        }
    }

    sumRange(i, j) {
        if (i === 0) {
            return this.prefixSum[j];
        }
        return this.prefixSum[j] - this.prefixSum[i - 1];
    }
}
public class SumBetweenRange {
    private int[] prefixSum;

    public SumBetweenRange(int[] nums) {
        prefixSum = new int[nums.length];
        prefixSum[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            prefixSum[i] = prefixSum[i - 1] + nums[i];
        }
    }

    public int sumRange(int i, int j) {
        if (i == 0) {
            return prefixSum[j];
        }
        return prefixSum[j] - prefixSum[i - 1];
    }
}

Complexity Analysis

Time complexity: The time complexity of the constructor is O(n)O(n), where nn denotes the length of the array. This is because we populate a prefix_sum array of length nn. The time complexity of sum_range is O(1)O(1).

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the prefix_sum array.

CHAPTER 56

K-Sum Subarrays

Find the number of subarrays in an integer array that sum to k.

Example:

Image represents three identical arrays, each enclosed in square brackets `[]`, containing the integer sequence `[1, 2, -1, 1, 2]`.  Each array is displayed horizontally, with the elements numbered underneath from 0 to 4, representing their indices.  A peach-colored rectangular highlight spans the first three elements (1, 2, -1) in each array. Above each array, the text 'sum = 3' is displayed in orange, indicating that the sum of the highlighted elements (1 + 2 + (-1) = 3) within the peach-colored region equals 3.  The arrangement visually emphasizes the consistent sub-array and its sum across the three repetitions.
Example
Input: nums = [1, 2, -1, 1, 2], k = 3
Output: 3

Intuition

The brute force solution to this problem involves iterating through every possible subarray and checking if their sum equals k. It takes O(n2)O(n^2) time to iterate over all subarrays, and finding the sum of each subarray takes O(n)O(n) time, resulting in an overall time complexity of O(n3)O(n^3), where nn denotes the length of the array. This solution is quite inefficient, so let’s think of something better.

Since we’re working with subarray sums, it’s worth considering how prefix sums can be used to solve this problem.

Prefix sums
As described in the Sum Between Range problem in this chapter, the sum of a subarray between two indexes, i and j, can be calculated with the following formula:

Image represents a visual explanation of calculating a subarray sum using prefix sums.  The image shows three arrays. The first array `[1 2 -1 1 2]` is the input array.  Above it, in orange text, is labeled `sum[i:j]`, representing the sum of a subarray from index `i` to `j`. A peach-colored rectangle highlights the subarray `[-1 1]` which is the sum of elements from index `i` to `j`.  Arrows point from small boxes labeled `i` and `j` upwards to the elements at those indices within the subarray. The second array `[1 2 -1 1 2]` is labeled `prefix_sum[j]` in light blue text, representing the prefix sum array up to index `j`. A light blue rectangle highlights the prefix sum up to `j`, which is `[1 2 -1 1]`. An arrow points from a box labeled `j` to the last element of this prefix sum. The third array `[1 2 -1 1 2]` is labeled `prefix_sum[i - 1]` in red text, representing the prefix sum up to index `i - 1`. A light pink rectangle highlights the prefix sum up to `i-1`, which is `[1 2]`. An arrow points from a box labeled `i - 1` to the last element of this prefix sum. The equation `[1 2 -1 1 2] = [1 2 -1 1 2] - [1 2]` demonstrates that the subarray sum `sum[i:j]` can be calculated by subtracting the prefix sum up to `i - 1` from the prefix sum up to `j`.

For subarrays which start at the beginning of the array (i.e., when i = 0), the formula is just:

Image represents a visual explanation of prefix sum calculation.  The image shows two arrays: a peach-colored array labeled `sum[0: j]` containing the elements [1, 2, -1, 1, 2], and a light-blue array labeled `prefix_sum[j]` containing [1, 2, -1, 1, 2].  Both arrays contain the same numerical values.  An upward arrow connects the index `j` (represented by a small gray square) to both arrays, indicating that `j` is the index up to which the sum is calculated. The `sum[0: j]` array represents a sub-array from index 0 up to index `j` of the original array, while the `prefix_sum[j]` array represents the cumulative sum of the elements from index 0 up to index `j`. The equality sign (=) between the arrays visually demonstrates that the `prefix_sum[j]` array is the result of calculating the cumulative sum of the `sum[0: j]` array.  The subscript `0` in `sum[0: j]` indicates that the summation starts from the beginning of the array.

In this problem, we already know the sum we’re looking for (k), meaning our goal is to find:

  • All pairs of i and j such that prefix_sum[j] - prefix_sum[i - 1] == k when i > 0.
  • All values of j such that prefix_sum[j] == k when i == 0.

We can unify both cases by recognizing that the formula prefix_sum[j] == k is the same as the formula prefix_sum[j] - prefix_sum[i - 1] == k when prefix_sum[i - 1] equals 0 (i.e., prefix_sum[j] - 0 == k).

One issue with this is when i == 0, index i - 1 is invalid. To make this unification possible while avoiding the out-of-bounds issue, we can prepend '[0]' to the prefix sums array, making it possible for prefix_sum[i - 1] to equal 0 when i - 1 == 0.

Image represents a visual explanation of prefix sums.  The top line shows an array named `nums` containing the integer values [1, 2, -1, 1, 2]. Below it, an array named `prefix_sums` is shown, containing the cumulative sums of `nums`.  `prefix_sums` starts with 1 (the first element of `nums`), then adds the second element (2) to get 3, then adds the third element (-1) to get 2, and so on, resulting in the array [1, 3, 2, 3, 5].  An orange arrow labeled 'start prefix sums with 0' points downwards from above to the beginning of a third array, visually demonstrating that a prefix sum calculation often begins with 0. This third array is identical to `prefix_sums` except it includes a leading 0, representing the cumulative sum before considering any elements from `nums`.  The indices of each array are shown below the arrays for clarity.

Keep in mind that we should iterate over the array from index 1 because we added this 0 to the start of the prefix sum array.

Here’s the code snippet for this approach:

from typing import List
    
def k_sum_subarrays(nums: List[int], k: int) -> int:
    n = len(nums)
    count = 0
    # Populate the prefix sum array, setting its first element to 0.
    prefix_sum = [0]
    for i in range(0, n):
        prefix_sum.append(prefix_sum[-1] + nums[i])
    # Loop through all valid pairs of prefix sum values to find all subarrays that sum
    # to 'k'.
    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
function kSumSubarrays(nums, k) {
    const n = nums.length;
    let count = 0;
    // Populate the prefix sum array, setting its first element to 0.
    const prefixSum = [0];
    for (let i = 0; i < n; i++) {
        prefixSum.push(prefixSum[prefixSum.length - 1] + nums[i]);
    }
    // Loop through all valid pairs of prefix sum values to find all subarrays that sum
    // to 'k'.
    for (let j = 1; j <= n; j++) {
        for (let i = 1; i <= j; i++) {
            if (prefixSum[j] - prefixSum[i - 1] === k) {
                count += 1;
            }
        }
    }
    return count;
}
public class Solution {
    public int kSumSubarrays(int[] nums, int k) {
        int n = nums.length;
        int count = 0;
        // Populate the prefix sum array, setting its first element to 0.
        int[] prefixSum = new int[n + 1];
        prefixSum[0] = 0;
        for (int i = 0; i < n; i++) {
            prefixSum[i + 1] = prefixSum[i] + nums[i];
        }
        // Loop through all valid pairs of prefix sum values to find all subarrays that sum
        // to 'k'.
        for (int j = 1; j <= n; j++) {
            for (int i = 1; i <= j; i++) {
                if (prefixSum[j] - prefixSum[i - 1] == k) {
                    count += 1;
                }
            }
        }
        return count;
    }
}

This is an improvement on the brute force solution, which reduces the time complexity to O(n2)O(n^2). Can we optimize this solution further?

Optimization - hash map
An important point is that we don't need to treat both prefix_sum[j] and prefix_sum[i - 1] as unknowns in the formula. If we know the value of prefix_sum[j], we can find prefix_sum[i - 1] using prefix_sum[i - 1] = prefix_sum[j] - k.

Therefore, for each prefix sum (curr_prefix_sum), we need to find the number of times curr_prefix_sum - k previously appeared as a prefix sum before.

This is similar to the problem presented in Pair Sum - Unsorted in the Hash Maps and Sets chapter, where we learn a hash map is useful for implementing the above idea efficiently. In this context, if we store encountered prefix sum values in a hash map, we can check if curr_prefix_sum - k was encountered before in constant time.

Note, it's also important to track the frequency of each prefix sum we encounter using the hash map, as the same prefix sum may appear multiple times.


Let’s try using a hash map (prefix_sum_map) on the example below with k = 3. Initialize prefix_sum_map with one zero for the same reason we prepended 0 to the prefix sum array in the O(n2)O(n^2) solution discussed earlier:

Image represents a visual depiction of a prefix sum calculation and its mapping.  The image shows an integer array `[1, 2, -1, 1, 2]` labeled with indices 0 through 4 beneath each element.  Above the array, `k = 3` is specified, indicating a parameter likely related to a window or sub-array size. To the right, a table labeled 'prefix_sum_map' is presented. This table has two columns: 'sum' and 'freq'.  The 'sum' column contains the value `0`, representing the initial prefix sum. The 'freq' column contains the value `1`, indicating the frequency of this sum.  The arrangement suggests that the array is processed to calculate prefix sums, and the `prefix_sum_map` stores the unique prefix sums encountered and their frequencies.  The value of `k` likely influences how these prefix sums are calculated or used in a larger algorithm, possibly defining a sliding window of size 3 across the input array.

We’re using a hash map to keep track of prefix sums, we no longer need a separate array to store each individual prefix sum.

Initially, the prefix sum (curr_prefix_sum) is equal to 1. Its complement, -2, is not in the hash map as illustrated below. So, we continue:

Image represents a step-by-step illustration of a coding pattern, likely related to prefix sums or subarray sums.  The top left shows `k = 3` indicating a parameter value.  Below, `curr_prefix_sum = 1` represents the current prefix sum, which is an arrow pointing down to an array `[1, 2, -1, 1, 2]` indexed from 0 to 4.  To the right, a `prefix_sum_map` is shown as a table with two columns labeled `sum` and `freq`, currently containing only one row with `sum = 0` and `freq = 1`. A dashed box to the right shows a conditional statement: `curr_prefix_sum - k = -2 (not in hash map) => continue`. This indicates that the difference between the current prefix sum (1) and k (3) is -2, which is not found as a `sum` in the `prefix_sum_map`, resulting in the continuation of the algorithm.  The overall diagram depicts a single iteration of an algorithm that likely involves tracking prefix sums and checking for specific sum values within a hash map (or similar data structure) to solve a problem, possibly related to finding subarrays with a given sum.

Store the (curr_prefix_sum, freq) pair (1, 1) in the hash map before moving to the next prefix sum.


The next curr_prefix_sum value is 3 (1 + 2). Its complement, 0, exists in the hash map with a frequency of 1. This means we found 1 subarray of sum k. So, we add 1 to our count:

Image represents a visual explanation of a coding pattern, likely related to prefix sums and hash maps.  The top-left shows 'k = 3' indicating a parameter value. Below, 'curr_prefix_sum = 3' suggests a running prefix sum. An array `[1, 2, -1, 1, 2]` is displayed with indices 0-4 beneath, illustrating the data being processed. A downward arrow connects 'curr_prefix_sum = 3' to the array, implying the sum is derived from the array elements. To the right, a 'prefix_sum_map' is depicted as a table with two columns labeled 'sum' and 'freq'.  This table contains two rows: one with 'sum' = 0 and 'freq' = 1, and another with 'sum' = 1 and 'freq' = 1, representing prefix sums and their frequencies. A light-grey box on the far right details a calculation:  'curr_prefix_sum - k = 0 (in hash map)' which implies checking if a specific prefix sum exists in the map.  The subsequent line 'count += prefix_sum_map[0] += 1' shows an increment to a 'count' variable based on the frequency found in the 'prefix_sum_map' at index 0.  The arrow connecting the 'prefix_sum_map' and the calculation box indicates data flow from the map to the calculation. The entire diagram illustrates a step-by-step process of using prefix sums and a hash map to efficiently solve a problem, likely counting subarray sums equal to a target value (k).

Store the (curr_prefix_sum, freq) pair (3, 1) in the hash map before moving on to the next value.


We now have a strategy for processing each value in the array:

  1. Update curr_prefix_sum by adding the current value of the array to it

  2. If curr_prefix_sum - k exists in the hash map, add its frequency (prefix_sum_map[curr_prefix_sum - k]) to count

  3. Add (curr_prefix_sum, freq) to the hash map. If the key is already present, increase its frequency; if not, set it to 1.

Repeat this process for the rest of the array:

Image represents a visualization of a coding pattern, likely for finding subarrays with a specific sum.  The top-left shows 'k = 3', indicating a target sum.  Next to it, 'curr_prefix_sum = 2' represents the current prefix sum calculated so far. An array [1, 2, -1, 1, 2] is shown below, with indices 0 to 4 displayed beneath each element. A downward arrow connects 'curr_prefix_sum = 2' to the '-1' element in the array, indicating the current prefix sum calculation stage. To the right is a table labeled 'prefix_sum_map', showing prefix sums (3, 1, 0) in the left column and their frequencies (1, 1, 1) in the right column.  Finally, a light-gray box on the far right displays 'curr_prefix_sum - k = -1 (not in hash map)' and an arrow pointing right labeled 'continue', suggesting that if the difference between the current prefix sum and the target sum (k) is not found in the 'prefix_sum_map', the algorithm continues processing the array.
Image represents a visual explanation of a coding pattern, likely for finding subarrays with a sum equal to a target value (k=3 in this case).  The left side shows an input array `[1, 2, -1, 1, 2]` with indices displayed below.  Above it, `curr_prefix_sum = 3` indicates a running prefix sum at a particular point. A downward arrow points to the element '1' at index 3, suggesting this is the current prefix sum being considered. To the right, a table labeled `prefix_sum_map` stores prefix sums (in the 'sum' column) and their frequencies (in the 'freq' column).  For example, a prefix sum of 0 has a frequency of 1, a prefix sum of 1 has a frequency of 1, a prefix sum of 2 has a frequency of 1, and a prefix sum of 3 has a frequency of 1. A light peach-colored cell highlights the prefix sum of 0 and its frequency of 1. A dashed-line box on the far right describes the algorithm's logic: if `curr_prefix_sum - k == 0`, meaning the current prefix sum minus the target sum is zero, then increment a `count` variable by the frequency of that prefix sum found in `prefix_sum_map` at index 0 (which is 1 in this case).  The `+= 1` indicates that the count is incremented by 1.  The entire diagram illustrates how a prefix sum map is used to efficiently count subarrays with a specific sum.
Image represents a visual explanation of a coding pattern, likely for finding subarrays with a sum equal to a target value (k=3 in this case).  The left side shows an input array `[1, 2, -1, 1, 2]` with indices displayed below. Above it, `curr_prefix_sum = 5` indicates the cumulative sum of the array. A downward arrow points to the array, suggesting this sum is being processed.  The center displays a `prefix_sum_map`, a table with two columns: 'sum' and 'freq' (frequency).  This table stores prefix sums and their counts.  The cell (2,1) is highlighted in peach, indicating a prefix sum of 2 has occurred once. The right side shows a calculation step: `curr_prefix_sum - k = 2` (where k=3), implying a search for subarrays summing to 3.  The result, 2, is used to access the `prefix_sum_map[2]`, which is 1 (the frequency of prefix sum 2). This value (1) is then added to a `count` variable (`count += 1`), suggesting the algorithm is counting occurrences of subarrays with the target sum.  The entire diagram illustrates how a prefix sum map is used to efficiently count subarrays with a given sum.

Once we’ve processed all the prefix sum values, we return count, which stores the number of subarrays that sum to k.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def k_sum_subarrays_optimized(nums: List[int], k: int) -> int:
    count = 0
    # Initialize the map with 0 to handle subarrays that sum to 'k' from the start of
    # the array.
    prefix_sum_map = {0: 1}
    curr_prefix_sum = 0
    for num in nums:
        # Update the running prefix sum by adding the current number
        curr_prefix_sum += num
        # If a subarray with sum 'k' exists, increment 'count' by the number of times
        # it has been found.
        if curr_prefix_sum - k in prefix_sum_map:
            count += prefix_sum_map[curr_prefix_sum - k]
        # Store the 'curr_prefix_sum' value in the hash map.
        prefix_sum_map[curr_prefix_sum] = prefix_sum_map.get(curr_prefix_sum, 0) + 1
    return count
function kSumSubarraysOptimized(nums, k) {
    let count = 0;
    // Initialize the map with 0 to handle subarrays that sum to 'k' from the start of
    // the array.
    const prefixSumMap = new Map();
    prefixSumMap.set(0, 1);
    let currPrefixSum = 0;
    for (const num of nums) {
        // Update the running prefix sum by adding the current number
        currPrefixSum += num;
        // If a subarray with sum 'k' exists, increment 'count' by the number of times
        // it has been found.
        if (prefixSumMap.has(currPrefixSum - k)) {
            count += prefixSumMap.get(currPrefixSum - k);
        }
        // Store the 'curr_prefix_sum' value in the hash map.
        prefixSumMap.set(currPrefixSum, (prefixSumMap.get(currPrefixSum) || 0) + 1);
    }
    return count;
}
import java.util.*;

public class Solution {
    public int kSumSubarraysOptimized(int[] nums, int k) {
        int count = 0;
        // Initialize the map with 0 to handle subarrays that sum to 'k' from the start of
        // the array.
        Map<Integer, Integer> prefixSumMap = new HashMap<>();
        prefixSumMap.put(0, 1);
        int currPrefixSum = 0;
        for (int num : nums) {
            // Update the running prefix sum by adding the current number
            currPrefixSum += num;
            // If a subarray with sum 'k' exists, increment 'count' by the number of times
            // it has been found.
            if (prefixSumMap.containsKey(currPrefixSum - k)) {
                count += prefixSumMap.get(currPrefixSum - k);
            }
            // Store the 'curr_prefix_sum' value in the hash map.
            prefixSumMap.put(currPrefixSum, prefixSumMap.getOrDefault(currPrefixSum, 0) + 1);
        }
        return count;
    }
}

Complexity Analysis

Time complexity: The time complexity of k_sum_subarrays_optimized is O(n)O(n) because we iterate through each value in the nums array.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the hash map.

CHAPTER 57

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 the elements of the input array except nums[i] itself.

Example:

Example
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). The same logic applies to the rest of the output.

Intuition

The straightforward solution to this problem is to find the total product of the array and divide it by each of the values in nums individually to get the output array:

Image represents a calculation demonstrating a coding pattern.  The left side shows an array `[2, 3, 1, 4, 5]` with each element visually connected via orange curved lines to a central node labeled 'product = 120'. This indicates that the product of all elements in the array is 120. An arrow points to the right, leading to a calculation where 120 is divided by each element of the original array: `res = [120/2, 120/3, 120/1, 120/4, 120/5]`.  Below this, the results of these divisions are shown as a new array: `res = [60, 40, 120, 30, 24]`.  The diagram visually illustrates the process of calculating the result of dividing a product by each of its factors.

This approach allows us to solve the problem in linear time and constant space. However, a potential follow-up question by an interviewer is: what if we can’t use division? Let’s explore a solution to this.

Avoiding division
A brute force approach involves calculating the output value for each index one by one. This would take O(n)O(n) time per index, leading to an overall time complexity of O(n2)O(n^2), where nn denotes the length of the array. This is inefficient, so let's look at other approaches.

An important insight is that the output for any given index can be determined by multiplying two things:

  1. The product of all numbers to the left of the index.
  2. The product of all numbers to the right of the index.
Image represents a diagram illustrating a coding pattern, likely related to array processing or a similar data structure.  A top-level element labeled 'i' points downwards to an array-like structure visually divided into two parts. The left part, highlighted in light blue, contains the numbers '2' and '3', with a calculation 'product = 6' indicating their multiplication. The right part, highlighted in light purple, contains '4' and '5', with a corresponding calculation 'product = 20'.  A gray brace connects the results of these sub-calculations ('6' and '20'), leading to a final calculation 'res[i] = 6 * 20 = 120', showing the multiplication of the two sub-products and assigning the result (120) to an array element 'res[i]', where 'i' likely represents an index.  The diagram visually demonstrates a process where sub-arrays are processed independently to produce intermediate results, which are then combined to generate a final result.

Why is this helpful? If we have precomputed the products of all values to the left and right of each index, we can quickly calculate the output for each index. More specifically, we would need two arrays that contain the left and right products of each index, respectively:

  • left_products: an array where left_products[i] is the product of all values to the left of i.

  • right_products: an array where right_products[i] is the product of all values to the right of i.

To obtain the left_products array, we need to keep track of a cumulative product of all elements we encounter as we move from left to right. The value of this product at a specific index should represent the product of all values to its left. The same is true of the right_products array, but the cumulative products start from the right. Once we have these arrays, multiplying the left and right product values at each index gives us the output value of that index.

Image represents a step-by-step calculation of the product of all elements in an array except the element at the current index.  The top row shows an input array `nums` containing the integers [2, 3, 1, 4, 5]. Below this, `left_products` array stores the cumulative product of elements to the left of each index in `nums`, starting with 1 (the product of zero elements).  Each element in `left_products` is calculated by multiplying the previous element by the corresponding element in `nums`. Similarly, `right_products` calculates the cumulative product of elements to the right of each index, starting from the rightmost element and moving left.  Asterisks (*) visually represent the multiplication operations between consecutive elements in `nums` to obtain `left_products`.  Double vertical lines (||) represent the multiplication between corresponding elements of `left_products` and `right_products` to produce the final `res` array, which contains the product of all elements in `nums` except the element at the current index.  For example, `res[0]` (60) is calculated as `left_products[0]` (1) * `right_products[0]` (60).

Since the left and right product arrays are formed through cumulative multiplication, this leads us to the concept of prefix products.

Prefix products
Prefix products are created in the same way as a prefix sum array, with two key differences:

  1. Instead of cumulative addition, we use cumulative multiplication.
  2. We initialize the prefix product array with 1 instead of 0, to avoid multiplying the cumulative products by 0.

Let’s try creating the left_products array, initializing it with 1 at index 0:

Image represents a code snippet showcasing an array named `nums` initialized with the integer values [2, 3, 1, 4, 5].  Below it, another array named `left_products` is partially shown, initialized with the value `[1` suggesting it's an array intended to store the product of numbers to the left of each element in the `nums` array.  The arrangement is vertical, with `nums` above `left_products`, implying a sequential relationship where `left_products` is likely calculated based on the values in `nums`.  No explicit connections or information flow are visually depicted beyond the implied relationship between the two arrays; the image only shows the initial state of the arrays before any computation is performed.  There are no URLs or parameters present.

For each subsequent index in the left_products array, we calculate its value by multiplying the running product by the previous value in the nums array:

Image represents a visual depiction of a calculation step within a coding pattern, likely related to prefix or suffix product calculation.  The top line shows a list named 'nums' containing the integer array [2, 3, 1, 4, 5].  Below, a list named 'left_products' is initialized as an array.  Gray arrows indicate that the initial values of 'nums' (2 and then 3) are being processed.  A central asterisk (*) symbolizes a calculation operation.  An orange arrow shows the result of this operation (2, likely the product of 2 and 1) being appended to 'left_products'.  The diagram suggests an iterative process where elements from 'nums' are sequentially multiplied and accumulated into 'left_products', representing a running product from the left side of the 'nums' array.  The incomplete 'left_products' array and the continuation of 'nums' imply that the calculation is ongoing.
Image represents a calculation step involving two arrays labeled `nums` and `left_products`. The array `nums` is displayed as `[ 2 3 1 4 5 ]`, and the array `left_products` is displayed as `[ 1 2 6 ]`. The diagram illustrates how the value '6' in the `left_products` array is derived. Two grey arrows point towards a red asterisk symbol located between the two arrays. One arrow originates from the number '3' in the `nums` array (specifically the second element, index 1). The other arrow originates from the number '2' in the `left_products` array (the second element, index 1). A curved orange arrow originates from the red asterisk and points towards the number '6' in the `left_products` array (the third element, index 2). The number '6' is highlighted in orange. This visual representation indicates that the value '6' in `left_products` is the result of multiplying the value '3' from `nums` by the value '2' from `left_products`, demonstrating a step in calculating prefix products where `left_products[i] = nums[i-1] * left_products[i-1]`. Specifically, it shows `left_products[2] = nums[1] * left_products[1]`, or 6 = 3 * 2.
Image represents a visual depiction of a calculation step within a coding pattern, likely related to prefix or suffix product calculation in an array.  The top line shows an array named 'nums' containing the integer values [2, 3, 1, 4, 5]. Below this, another array named 'left_products' is shown, initialized with values [1, 2, 6, 6, 1].  Grey arrows indicate that the values 1, 3, and 1 from the 'nums' array are being multiplied cumulatively to generate the corresponding values in the 'left_products' array (1*1=1, 1*2=2, 1*2*3=6). An orange curved arrow points from the value '4' in the 'nums' array to the value '6' in the 'left_products' array, indicating that the next calculation step involves multiplying the current cumulative product (6) by 4 to update the next element in 'left_products'.  A '*' symbol is placed near the point of calculation to highlight the multiplication operation. The final value in 'left_products' (1) is not explicitly shown to be calculated in the image.
Image represents a visual depiction of a calculation involving two arrays.  The top array, labeled 'nums = [2 3 1 4 5]', contains five numerical elements: 2, 3, 1, 4, and 5, enclosed within square brackets. The bottom array, labeled 'left_products = [1 2 6 6 24]', also contains five elements: 1, 2, 6, 6, and 24, similarly enclosed in square brackets.  Grey arrows point from the elements 4 and 6 in the 'nums' array to the asterisk (*) symbol, indicating a multiplication operation. An orange curved arrow originates from the asterisk and points to the final element, 24, in the 'left_products' array, signifying that 24 is the result of multiplying 4 and 6.  The arrangement visually demonstrates a cumulative product calculation from left to right within the 'nums' array, resulting in the corresponding values in the 'left_products' array.  The final element in 'left_products' (24) is highlighted in orange to emphasize it as the result of the calculation.

The same can be done for the right_products array, but starting on the right and moving leftward:

Image represents a snippet of code illustrating a computation.  The top line declares a variable named `nums` and assigns it an array containing the integer values [2, 3, 1, 4, 5]. Below this, a second line declares a variable named `right_products` and initializes it as an array, currently empty except for a single closing bracket `]`.  The arrangement suggests that the `right_products` array will be populated based on calculations involving the `nums` array.  There is no explicit connection shown between the two arrays, only their declaration and initial values.  The image implies a process where the `right_products` array will be filled with values derived from the `nums` array, likely involving a right-to-left product calculation. The number `1` at the end of `right_products` suggests an initial value or a placeholder for the result of the computation.
Image represents a visual depiction of a coding pattern, likely related to array manipulation.  The top line shows an array named 'nums' initialized with the integer values [2, 3, 1, 4, 5]. Below this, a partially filled array named 'right_products' is shown as an empty array `[]`.  An orange curved arrow points from the number 5 in the 'nums' array to an asterisk (*), indicating a calculation is performed on this element.  Gray arrows point from both the 5 and the 1 (the last two elements of the 'nums' array) to the asterisk, suggesting a multiplication operation. The result of this multiplication (5 * 1 = 5) is then shown as the first element (5) in the 'right_products' array.  The diagram illustrates a step-by-step process of calculating the product of elements to the right of each element in the 'nums' array and storing the results in the 'right_products' array.  The diagram only shows the calculation for the last element, implying a similar process would be repeated for the preceding elements.
Image represents a visual depiction of a coding pattern, likely illustrating the calculation of cumulative products from right to left within an array.  The top line shows an array named 'nums' containing the integer values [2, 3, 1, 4, 5]. Below, an array named 'right_products' is initialized as empty.  The core of the diagram shows the calculation process:  grey arrows point from the elements of the 'nums' array (4 and 5) to a multiplication symbol (*), indicating that these elements are being multiplied. The result of 4 * 5 = 20 is shown in orange, and an orange curved arrow points to this result from the multiplication symbol, highlighting the flow of calculation.  This 20 is the first element of the 'right_products' array. The next element of 'right_products' (5) is calculated by multiplying the next element to the left in 'nums' (1) with the previous result (20/5 = 4, which is not explicitly shown but implied). The final element of 'right_products' (1) is simply the last element of 'nums'. The diagram visually demonstrates a right-to-left cumulative product calculation.
Image represents a diagram illustrating a coding pattern, likely related to calculating cumulative products from right to left within an array.  The top line shows an array named 'nums' containing the integer values [2, 3, 1, 4, 5]. Below this, an array named 'right_products' is partially shown, initialized as an empty array.  Gray arrows point from the 'nums' array to the 'right_products' array, indicating a calculation flow.  A multiplication symbol (*) is positioned centrally, suggesting that the calculation involves multiplication.  An orange curved arrow points from the last element (5) of 'nums' to the first element (20) of 'right_products', indicating that the first element of 'right_products' is the cumulative product of the elements in 'nums' from right to left (5 * 4 * 1 * 3 * 2 = 120, but the image shows 20, which is likely a mistake or a simplified example). The subsequent elements in 'right_products' (20, 5, 1) are also derived from this right-to-left cumulative product calculation, with each element representing the product of the remaining elements to its right in the 'nums' array.  The diagram visually demonstrates the process of generating a new array containing cumulative products from the right side of the input array.
Image represents a diagram illustrating a calculation of right products.  The top row shows a list labeled 'nums = [2 3 1 4 5]', representing an array named 'nums' containing the integer values 2, 3, 1, 4, and 5.  Below this, labeled 'right_products =', is a second array showing the result of a calculation.  This array contains the values [60 20 20 5 1].  Gray arrows point from the 'nums' array to the 'right_products' array, indicating a dependency.  A prominent orange curved arrow points from the first element of the 'right_products' array (60) to the asterisk (*) symbol, which is positioned between the two arrays, suggesting that the calculation starts from the rightmost element of the 'nums' array and proceeds leftward, accumulating products.  The asterisk likely represents the multiplication operation used in the calculation.

Once both arrays are populated, we can compute each value of the output array, where res[i] is equal to the product of left_products[i] and right_products[i], as previously demonstrated.

Reducing space
We have successfully found a solution that doesn't involve division and runs in linear time. However, this solution takes up linear space due to the left and right product arrays. Can we compute the output array in place without taking up extra space?

An important thing to realize is that we don’t necessarily need to create the left and right product arrays to populate the output array. Instead, we can directly compute and store the left and right products in the output array as we calculate them.

This can be done in two steps:

1. First, populate the output array (res) the same way we populated left_products. This prepares the output array to be multiplied by the right products:

Image represents a step-by-step calculation of the product of elements to the left of each element in an array.  The top line shows an array named `nums` containing the integer values [2, 3, 1, 4, 5]. Below it, `left_products` is an array initialized with the cumulative product of elements to the left of each corresponding element in `nums`, shown in gray.  The first element is 1 (as there are no elements to its left), the second is 2 (the product of the first element in `nums`), the third is 6 (2*3), the fourth is 6 (2*3*1), and the fifth is 24 (2*3*1*4). Finally, `res` is an array that appears to be a copy of `left_products`, displayed in black, with a gray arrow pointing to the right from the end of `res`, indicating the final result of the calculation.  The values in `res` are identical to those in `left_products`, suggesting that the calculation of the cumulative left products is complete and stored in `res`.

2. Then, instead of populating a right_products array, we directly multiply the running product from the right (right_product) into the output array:

Image represents a step-by-step illustration of a coding pattern, likely for calculating the product of array elements to the right of each element.  Two instances are shown, each depicting an array `nums` = [2, 3, 1, 4, 5] and an array `res` which is being populated.  The `res` array initially contains [1, 2, 6, 6, 24] in the first instance and [1, 2, 6, 30, 24] in the second.  A variable `right_product` is used; it starts at 1.  Each instance shows a dashed box containing calculations.  In the first instance, the calculation `res[i] = res[i] * right_product` updates `res[3]` (initially 6) to 24 by multiplying it with `right_product` (1). Then, `right_product` is updated to 5 (1 * 5).  The second instance shows a similar calculation, but now `res[3]` (initially 6) is updated to 30 (6 * 5), and `right_product` is updated to 20 (5 * 4).  Arrows indicate the flow of information:  the updated `right_product` value from one step feeds into the next step's calculation, and the updated `res[i]` value reflects the result of the calculation.  The `(i)` symbols above the `nums` arrays indicate the iteration index.
Image represents a step-by-step illustration of an array manipulation algorithm.  The left side shows two arrays: `nums` containing the integer values [2, 3, 1, 4, 5], and `res` initialized to [1, 2,  ,  ,  ]. An arrow points from a small 'i' symbol to the element '1' in `nums`, indicating the current index being processed. The third element of `res` is highlighted in peach and shows the value 120, resulting from a calculation. The right side displays the calculation details: `res[i] = res[i] * right_product`, where `res[i]` represents the current element in `res` (initially 6), and `right_product` is a variable. The calculation shows `6 * 20 = 120`, updating `res[i]`.  Below, the update of `right_product` is shown as `right_product = right_product * nums[i]`, which calculates `20 * 1 = 20`, implying that `right_product` is iteratively updated based on the elements in `nums`.  The overall diagram visualizes a process where an array (`res`) is populated by multiplying its elements with a dynamically updated `right_product`, derived from the `nums` array.
Image represents a step-by-step illustration of a coding pattern, likely related to array manipulation.  The left side shows an array named `nums` containing the integer values [2, 3, 1, 4, 5], and another array `res` initialized as [1,  _, _, _, _].  A downward arrow points from a labeled box 'i' to the element '3' in `nums`, indicating an iteration index. The second element of `res` is highlighted with a peach-colored circle containing the value 40. The right side displays a calculation demonstrating how the value 40 is derived: `res[i] = res[i] * right_product`, where `res[i]` (the second element of `res`) is calculated as 2 (the first element of `nums`) multiplied by 20 (the `right_product`).  Further calculations show that `right_product` is updated iteratively: initially it's 20, then it becomes 60 (20 * 3), implying a right-to-left product calculation across the `nums` array.  The overall diagram visualizes a single iteration of an algorithm, likely calculating the product of all elements to the right of each element in `nums` and storing the result in `res`.
Image represents a step-by-step illustration of a coding pattern, likely related to array manipulation.  The left side shows an input array `nums` containing the integer values [2, 3, 1, 4, 5], and an output array `res` initialized with [60, 40, 120, 30, 24].  An arrow labeled 'i' points from a small square to the `nums` array, indicating an iterative process. The highlighted value '60' in `res` corresponds to the first element. The right side displays a calculation box detailing the computation for the first element of `res`.  The formula `res[i] = res[i] * right_product` is shown, with `res[i]` initially set to 1.  The `right_product` variable is updated iteratively; in this example, it starts at 60 and is then updated to 120 (60 * 2) for the next iteration, implying a right-to-left calculation involving the product of elements in `nums`.  The calculation shows the intermediate steps: 1 * 60 = 60, and then the update of `right_product` to 120 for the next iteration.  The overall pattern suggests a cumulative product calculation from the right side of the `nums` array, influencing the values in the `res` array.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def product_array_without_current_element(nums: List[int]) -> List[int]:
    n = len(nums)
    res = [1] * n
    # Populate the output with the running left product.
    for i in range(1, n):
        res[i] = res[i - 1] * nums[i - 1]
    # Multiply the output with the running right product, from right to left.
    right_product = 1
    for i in range(n - 1, -1, -1):
        res[i] *= right_product
        right_product *= nums[i]
    return res
function productArrayWithoutCurrentElement(nums) {
    const n = nums.length;
    const res = new Array(n).fill(1);
    // Populate the output with the running left product.
    for (let i = 1; i < n; i++) {
        res[i] = res[i - 1] * nums[i - 1];
    }
    // Multiply the output with the running right product, from right to left.
    let rightProduct = 1;
    for (let i = n - 1; i >= 0; i--) {
        res[i] *= rightProduct;
        rightProduct *= nums[i];
    }
    return res;
}
public class Solution {
    public int[] productArrayWithoutCurrentElement(int[] nums) {
        int n = nums.length;
        int[] res = new int[n];
        for (int i = 0; i < n; i++) {
            res[i] = 1;
        }
        // Populate the output with the running left product.
        for (int i = 1; i < n; i++) {
            res[i] = res[i - 1] * nums[i - 1];
        }
        // Multiply the output with the running right product, from right to left.
        int rightProduct = 1;
        for (int i = n - 1; i >= 0; i--) {
            res[i] *= rightProduct;
            rightProduct *= nums[i];
        }
        return res;
    }
}

Complexity Analysis

Time complexity: The time complexity of product_array_without_current_element is O(n)O(n) because we iterate over the nums array twice.

Space complexity: The space complexity is O(1)O(1). The res array is not included in the space complexity analysis.

CHAPTER 58

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:

class TreeNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None
class TreeNode {
    constructor(val) {
        this.val = val;
        this.left = null;
        this.right = null;
    }
}
public class TreeNode {
    public int val;
    public TreeNode left;
    public TreeNode right;

    public TreeNode(int val) {
        this.val = val;
        this.left = null;
        this.right = null;
    }
}

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.  The left side shows a tree with root node 4,  a peach-colored fill highlighting the path from node 2 to node 6.  Dashed light-blue lines indicate the depth of each level (depth 0 for node 4, depth 1 for nodes 2 and 5, and depth 2 for nodes 1, 6, and 7).  The text 'height = 3' indicates the tree's height. The right side displays a similar tree with root node 4, but this time, the focus is on identifying the root node's left and right subtrees.  The left subtree, rooted at node 2, and the right subtree, rooted at node 5, are highlighted with peach-colored fills.  Nodes 1, 6, and 7 are labeled as 'leaf nodes' with a dashed light-blue line encompassing them.  A small tree at the top shows a parent node (1) connected to two child nodes (2 and 3) with a solid line representing the 'edge' between them.  The 'parent' and 'edge' labels are in light-blue, while 'child nodes', 'root', 'intermediate nodes', 'root node's left subtree', and 'root node's right subtree' are in orange.  The node numbers are all within circles, with the root nodes (4) in gray and leaf nodes (1, 6, 7) in peach.

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.  The tree consists of a root node labeled '4', with two child nodes labeled '2' and '5'. Node '2' further branches into nodes '1' and '3', while node '5' branches into nodes '6' and '7'.  The arrows depict the traversal order:  orange arrows indicate the forward progression of the DFS algorithm, showing the path taken down the tree, while grey arrows represent the backtracking steps when a branch is fully explored.  The algorithm starts at node '4', proceeds down the left subtree to '2', then to '1', backtracks to '2', proceeds to '3', backtracks to '2', then backtracks to '4', proceeds to the right subtree to '5', then to '6', backtracks to '5', proceeds to '7', and finally backtracks to '5' and then to '4', completing the traversal.  The label 'DFS:' precedes the diagram, clearly indicating the algorithm being visualized.

DFS is typically implemented recursively, following a structure similar to the code snippet below:

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.
function dfs(node) {
    if (node === null) {
        return;
    }
    process(node);       // Process the current node.
    dfs(node.left);      // Traverse the left subtree.
    dfs(node.right);     // Traverse the right subtree.
}
public class Solution {
    public void dfs(TreeNode node) {
        if (node == null) {
            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.  The graph consists of seven nodes, numbered 1 through 7, connected by edges. Node 4 sits at the top, acting as the root.  Node 4 connects to nodes 2 and 5. Node 2 connects to nodes 1 and 3, while node 5 connects to nodes 6 and 7.  The orange arrows indicate the order of traversal in a BFS algorithm, starting from node 4 and proceeding level by level.  The traversal sequence is 4, then 2 and 5, followed by 1, 3, 6, and finally 7.  Each node is represented by a circle containing its numerical label. The text 'BFS:' precedes the graph, clearly indicating the algorithm being illustrated.

BFS is typically implemented iteratively using a queue, and the reason for this will become clear as we explore the problems in this chapter. The basic structure of BFS is reflected in the following code snippet:

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.
function bfs(root) {
    if (root === null) {
        return;
    }
    const queue = [root];
    while (queue.length > 0) {
        const node = queue.shift();
        process(node);  // Process the current node.
        if (node.left) {
            queue.push(node.left);  // Add the left child to the queue.
        }
        if (node.right) {
            queue.push(node.right);  // Add the right child to the queue.
        }
    }
}
import java.util.*;

public class Solution {
    public void bfs(TreeNode root) {
        if (root == null) {
            return;
        }
        Queue<TreeNode> queue = new LinkedList<>();
        queue.offer(root);
        while (!queue.isEmpty()) {
            TreeNode node = queue.poll();
            process(node);  // Process the current node.
            if (node.left != null) {
                queue.offer(node.left);  // Add the left child to the queue.
            }
            if (node.right != null) {
                queue.offer(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, nn denotes the number of nodes in the tree, and hh denotes the height of the tree.

OperationTimeSpaceDescription
DFSO(n)O(n)O(h)O(h)DFS visits each node, resulting in an O(n)O(n) time complexity. The space complexity is determined by the maximum depth of the recursive call stack, which can be as deep as the height of the tree, hh. In the worst case, the height of the tree is nn. In a balanced tree whose height is minimized, the height of the tree is approximately log⁡(n)\log(n).
BFSO(n)O(n)O(n)O(n)BFS visits each node, resulting in an O(n)O(n) time complexity. The space complexity is determined by the maximum number of nodes stored in the queue at any time. In the worst case, the queue can store the entire bottom level of the tree, which could contain around n/2n/2 nodes.

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.  A rounded rectangle at the top labeled 'Trees' acts as the root, branching down via dotted lines to two child rectangles. The left child rectangle is labeled 'DFS' (Depth-First Search) and lists several tree-related problems solvable using DFS: 'Invert Binary Tree,' 'Balanced Binary Tree Validation,' 'Binary Search Tree Validation,' 'Lowest Common Ancestor,' 'Build Binary Tree From Preorder and Inorder Traversals,' 'Maximum Sum of a Continuous Path in a Binary Tree,' 'Binary Tree Symmetry,' and 'Kth Smallest Number in a Binary Search Tree,' and 'Serialize and Deserialize a Binary Tree.' The right child rectangle is labeled 'BFS' (Breadth-First Search) and contains problems suitable for BFS: 'Rightmost Nodes of a Binary Tree,' 'Widest Binary Tree Level,' and 'Binary Tree Columns.'  Each problem within the DFS and BFS rectangles is presented as a bulleted list item, indicating a collection of tasks associated with each search strategy.  The overall structure shows how different tree traversal algorithms (DFS and BFS) can be applied to solve various coding problems involving 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. ↩

CHAPTER 59

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.  The left side shows a tree with a root node labeled '5'. This root node has two children: a left child labeled '1' and a right child labeled '8'. The node labeled '1' has two children: a left child labeled '7' and a right child labeled '6'. The node labeled '8' has a single child labeled '4'. A thick arrow points to the right, indicating a transformation. The right side shows the transformed tree, also with a root node labeled '5'.  Its left child is now '8', and its right child is '1'. The node labeled '8' has a single child labeled '4', and the node labeled '1' has children labeled '6' and '7'.  The transformation essentially swaps the left and right subtrees of the root node, while maintaining the structure and values of the subtrees themselves.

Intuition - Recursive

To invert a binary tree is to essentially "flip" the tree along a vertical axis, as visualized below:

Image represents a step-by-step illustration of a binary tree's mirroring or flipping operation.  The initial tree (leftmost) is a complete binary tree with nodes numbered 1 through 15, where node 1 is the root, and each node has two children except for the leaf nodes. A dashed vertical line bisects the tree.  The word 'FLIP' with a curved arrow indicates a mirroring operation about this line. The middle tree shows an intermediate stage where the left and right subtrees of the root are being swapped, with nodes 2 and 3 exchanging positions.  The rightmost tree depicts the final result: a mirrored version of the original tree, where the left and right subtrees of each node are swapped, resulting in a tree with the same structure but with nodes 2 and 3, 4 and 5, 6 and 7, etc., reflecting across the original dashed line.  Thick arrows indicate the transformation steps between the three tree structures.  The numbers within parentheses in the middle tree represent the nodes' positions before they are swapped in the final mirrored tree.

To understand how we invert a binary tree algorithmically, let’s focus on how the positions of nodes change after the inversion. For example, pay attention to nodes 8 and 9 in the above tree. After the tree is inverted, we see that they’ve swapped places relative to their parent node:

Image represents two binary trees before and after a node swap operation.  The left tree shows a root node labeled '1', branching into nodes '2' and '3'. Node '2' further branches into '4' and '5', while '3' branches into '6' and '7'. Node '4' has child nodes '8' (cyan border) and '9' (magenta border). Nodes '5', '6', and '7' each have two child nodes labeled '10' through '15' in ascending order from left to right. A thick black arrow points from the left tree to the right tree. The right tree is identical to the left, except that nodes '8' and '9' (originally children of node '4') have swapped positions.  All nodes are circular, with numbers centrally placed.  The color and thickness of the lines connecting the nodes are consistent within each tree, with the exception of the thicker black lines connecting node '4' to its children in both trees.  The overall transformation highlights a specific node swap within a larger tree structure.

The key observation is that this swap of the left and right child happens to each node in the binary tree during inversion.

It’s also important to note that when a left child node moves to the right, all nodes under it move as well, and the same happens when a right child moves to the left. In other words, we swap the left subtree and the right subtree of each node. This indicates we’re not just swapping the left and right node values, but the nodes themselves.

The image represents a visual depiction of a node swapping operation within a binary tree structure.  The diagram shows two states of a binary tree.  The initial state displays a root node (represented by a circle) connected to two triangular subtrees labeled 'left subtree' (light blue) and 'right subtree' (light purple).  A rectangular box labeled 'node' points to the root node with an arrow, indicating the node being considered.  The second state shows the result of swapping the left and right subtrees.  The 'left subtree' from the initial state is now the 'right subtree,' and vice-versa.  A horizontal arrow connects the two states, labeled 'swap node.left and node.right,' explicitly describing the operation performed.  Both states maintain the same root node, illustrating the in-place nature of the subtree swap.

Therefore, to invert a binary tree, we swap the left and right children of every node. Now, let’s explore a tree traversal algorithm that allows us to do this.

Depth-first search
Our strategy is to visit every node in the binary tree and swap its left and right children. There’s no particular order in which we need to visit the nodes. This means we can employ any tree traversal algorithm, as long as every node is visited.

Let’s tackle this problem recursively using DFS. After swapping the left and right children of the root node, we recursively call our invert function on the left and right children to invert their subtrees as well.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Recursive

from ds import TreeNode
    
def invert_binary_tree_recursive(root: TreeNode) -> TreeNode:
    # Base case: If the node is null, there's nothing to invert.
    if not root:
        return None
    # Swap the left and right subtrees of the current node.
    root.left, root.right = root.right, root.left
    # Recursively invert the left and right subtrees.
    invert_binary_tree_recursive(root.left)
    invert_binary_tree_recursive(root.right)
    return root
function invertBinaryTreeRecursive(root) {
    // Base case: If the node is null, there's nothing to invert.
    if (!root) {
        return null;
    }
    // Swap the left and right subtrees of the current node.
    [root.left, root.right] = [root.right, root.left];
    // Recursively invert the left and right subtrees.
    invertBinaryTreeRecursive(root.left);
    invertBinaryTreeRecursive(root.right);
    return root;
}
public class Solution {
    public TreeNode invertBinaryTreeRecursive(TreeNode root) {
        // Base case: If the node is null, there's nothing to invert.
        if (root == null) {
            return null;
        }
        // Swap the left and right subtrees of the current node.
        TreeNode temp = root.left;
        root.left = root.right;
        root.right = temp;
        // Recursively invert the left and right subtrees.
        invertBinaryTreeRecursive(root.left);
        invertBinaryTreeRecursive(root.right);
        return root;
    }
}

Complexity Analysis

Time complexity: The time complexity of invert_binary_tree_recursive is O(n)O(n), where nn denotes the number of nodes in the tree. This is because the algorithm traverses each node of the binary tree exactly once.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

Intuition - Iterative

As a starting point, we can try developing an iterative DFS solution by using the recursive DFS solution as a reference. In the recursive solution, each time a call is made to the left or right child, it’s added to a recursive call stack.

Ultimately, the recursive call stack is a stack, which means we can use a stack to mimic the recursive approach. Let's try using a stack on the following tree. Start by adding the root node to the stack:

Image represents a binary tree traversal algorithm visualized using a tree structure and a stack.  The tree's root node, highlighted with an orange border, contains the value '5'. This node branches into two child nodes: a left child node with the value '1' and a right child node with the value '8'. The node with value '1' further branches into two leaf nodes with values '7' and '6'. Similarly, the node with value '8' branches into a leaf node with the value '4'.  To the right of the tree, a stack is depicted as a container labeled 'stack,' currently holding only one element—a node with an orange border containing the value '5'. This suggests a depth-first search (DFS) traversal, likely a pre-order traversal, where the root node ('5') is processed first and then pushed onto the stack. The diagram illustrates a snapshot during the traversal process, showing the current state of the tree and the stack.

The top of the stack contains the root node, node 5. Let’s pop it off so we can swap its left and right subtrees:

Image represents a binary tree undergoing a swap operation, visualized alongside a stack data structure.  A binary tree with root node 5 (highlighted in orange) has left subtree (1, 7, 6) and right subtree (8, 4). A curved, gray arrow labeled 'swap' indicates an intended exchange between nodes 1 and 8. To the right, a stack is depicted, containing only node 5 (also orange) at this stage. A red arrow points from the stack to the text 'node = stack.pop', indicating that node 5 is popped from the stack.  A gray, dashed-line box below contains the instruction 'swap node.left and node.right', describing the operation to be performed on the popped node (5) within the tree, implying that its left and right subtrees will be exchanged.  A separate orange box labeled 'node' points to the root node 5, showing the initial assignment of the node variable.

After the swap, let’s add node 5’s left and right children to the stack so their subtrees can be inverted in future iterations:

Image represents a binary tree traversal algorithm using a stack.  The left side shows a binary tree with node '5' (highlighted in orange) as the root.  Node '5' connects to nodes '8' and '1'. Node '8' further connects to nodes '4' and '7', while node '1' connects to nodes '7' and '6'.  All nodes contain numerical values.  The right side depicts a stack labeled 'stack,' initially empty.  Two cyan arrows indicate the flow of information: one labeled 'push node.right' points from the right child of the currently processed node (initially '5') to the stack, pushing the value '1' onto the stack; the other labeled 'push node.left' points from the left child ('8') to the stack, pushing '8' onto the stack.  The stack's top element is '1', and below it is '8', illustrating a depth-first traversal (likely post-order) where right subtrees are processed before left subtrees.  A rectangular box labeled 'node' with an orange border points to the root node '5', indicating the starting point of the traversal.

The node at the top of the stack is now node 1, which we should pop off to swap its left and right subtrees and add its children to the stack:

Image represents a binary tree undergoing a transformation, likely part of a tree traversal or restructuring algorithm.  The left side shows a binary tree with nodes labeled 5, 8, 1, 4, 7, and 6. Node 1 is highlighted in orange, indicating it's the current focus. A horizontal orange arrow labeled 'node' points from node 8 to node 1, suggesting a data flow or operation involving this node.  The right side depicts a stack, a data structure, with nodes 8 and 1 inside.  A red horizontal line crosses node 1 in the stack, indicating it's being accessed. A red arrow points from the stack's top (node 1) to the text 'node = stack.pop', showing that node 1 is being popped from the stack and assigned to the variable 'node'. A gray dashed box contains the instruction 'swap node.left and node.right', indicating that the left and right children of the node (obtained from the stack) will be swapped.  A small curved arrow under nodes 7 and 6 on the left indicates a swap operation has occurred or will occur between these nodes. The overall diagram illustrates a step-by-step process involving a stack and a binary tree, where nodes are popped from the stack and their children are swapped.
Image represents a visualization of a tree traversal algorithm, likely a depth-first search (DFS) using a stack.  The left side shows a binary tree with nodes labeled 5, 8, 1, 4, 6, and 7.  Node 1 is highlighted in orange, indicating the current node being processed.  A directed orange arrow points from node 8 (labeled 'node') to node 1, showing the traversal direction. Node 5 is the root node, connecting to nodes 8 and 1. Node 8 connects to nodes 4 and 1. Node 1 connects to nodes 6 and 7. The right side depicts a stack labeled 'stack,' containing nodes 7, 6, and 8.  Light blue arrows labeled 'push node.right' and 'push node.left' indicate that the right and left children of the current node (node 1 in this case) are being pushed onto the stack.  The order of nodes in the stack suggests that the algorithm is processing the right subtree before the left subtree, a common characteristic of DFS.

We repeat the above process of iteratively swapping left and right subtrees until the stack is empty, indicating that all nodes have been processed.

from ds import TreeNode
    
def invert_binary_tree_iterative(root: TreeNode) -> TreeNode:
    if not root:
        return None
    stack = [root]
    while stack:
        node = stack.pop()
        # Swap the left and right subtrees of the current node.
        node.left, node.right = node.right, node.left
        # Push the left and right subtrees onto the stack.
        if node.left:
            stack.append(node.left)
        if node.right:
            stack.append(node.right)
    return root
function invertBinaryTreeIterative(root) {
    if (!root) {
        return null;
    }
    const stack = [root];
    while (stack.length > 0) {
        const node = stack.pop();
        // Swap the left and right subtrees of the current node.
        [node.left, node.right] = [node.right, node.left];
        // Push the left and right subtrees onto the stack.
        if (node.left) {
            stack.push(node.left);
        }
        if (node.right) {
            stack.push(node.right);
        }
    }
    return root;
}
import java.util.*;

public class Solution {
    public TreeNode invertBinaryTreeIterative(TreeNode root) {
        if (root == null) {
            return null;
        }
        Deque<TreeNode> stack = new ArrayDeque<>();
        stack.push(root);
        while (!stack.isEmpty()) {
            TreeNode node = stack.pop();
            // Swap the left and right subtrees of the current node.
            TreeNode temp = node.left;
            node.left = node.right;
            node.right = temp;
            // Push the left and right subtrees onto the stack.
            if (node.left != null) {
                stack.push(node.left);
            }
            if (node.right != null) {
                stack.push(node.right);
            }
        }
        return root;
    }
}

Complexity Analysis

Time complexity: The time complexity of invert_binary_tree_iterative is O(n)O(n) because it processes each node in the binary tree exactly once.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

Interview Tip

Tip: Use a stack to convert a recursive solution to an iterative one.
A common follow-up an interviewer may ask is to solve a problem iteratively rather than recursively. This approach is particularly useful when the tree’s height can be large, and we want to avoid potential stack overflow errors. For example, this could happen in Python, where the maximum recursion depth is set to 1000 by default. It's useful to understand that a stack can often be used to transform recursive solutions into iterative ones.

CHAPTER 60

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.

Example:

Image represents a binary tree structure illustrating an imbalance condition. The main tree consists of nodes labeled 5, 2, 7, 1, 4, 9, 3, and 6, connected by branches to represent parent-child relationships. Node 5 is the root, with node 2 as its left child and node 7 as its right child. Node 2 has children 1 (left) and 4 (right), while node 4 has a child 3. Node 7 has children 9 (right) and 6 (left).  A shaded rectangular area highlights the subtree rooted at node 7. A horizontal arrow points from this highlighted subtree to the text 'imbalanced:', followed by descriptions of the subtree's height imbalance: 'left subtree height = 0' and 'right subtree height = 2', indicating a height difference of 2 between the left and right subtrees of node 7, thus signifying an imbalance in the tree.
Example
Output: False

Intuition

For a binary tree to be balanced, all its subtrees would need to be balanced too. This implies that the height difference between the left and right subtrees of each node should be at most 1. A difference greater than 1 indicates a height imbalance.

This suggests we need a way to determine the heights of the left and right subtrees at each node in order to evaluate if the subtree rooting from that node is balanced or not.

A key insight is that the height of a tree is equal to the depth of its deepest subtree, plus 1, to include the tree’s root node.

Image represents a binary tree structure with a root node and three levels.  A rectangular box labeled 'node' points down to the root node, which is a circle connected to two child nodes. One of these child nodes is further connected to two leaf nodes.  Orange brackets labeled '1' and '2' indicate the height of the right and left subtrees respectively.  To the right, a light gray box with dashed borders shows a calculation of the tree's height. The calculation uses the formula `height = 1 + max(left_height, right_height)`, substituting `left_height` as 2 and `right_height` as 1, resulting in a final height of 3.  The formula demonstrates how the height of a binary tree is determined recursively based on the maximum height of its subtrees plus one for the current node.

The above formula reveals a recursive relationship, where we can recursively determine the heights of the left and right subtrees to calculate the height of the current subtree. The base case of the recursion would be returning 0 upon encountering a null node, since they have a height of 0.

The diagram below displays the heights returned from the left and right children of each node, and shows how we determine if a subtree is imbalanced. This highlights the recursive process, where values bubble up from the bottom and make their way up to the root node. At each node, we also evaluate whether that node represents a height-balanced subtree. This reveals an imbalance at node 7:

Image represents a binary tree where nodes are labeled with numbers.  The root node is '5'.  Node '5' has a left child '2' and a right child '7'. Node '2' has a left child '1' and a right child '4'. Node '4' has a left child '3'. Node '7' has a right child '9', which in turn has a right child '6'.  Orange dashed lines and numbers indicate the height of each subtree, with the numbers representing the height.  For example, the subtree rooted at node '2' has a height of 2, and the subtree rooted at node '7' has a height of 2. Node '7' is highlighted in red.  To the right of the tree, the text 'left_height = 0, right_height = 2' indicates the heights of the left and right subtrees of node '5'.  The text 'height difference = 2 > 1 → imbalanced' shows the calculation of the height difference (2 - 0 = 2) and concludes that the tree is imbalanced because the difference is greater than 1. A red arrow points from the height difference calculation to the word 'imbalanced', emphasizing the conclusion.

However, there's a flaw in only returning the subtree’s height at each node: upon detecting node 7 is imbalanced, all we did about this was return its height to its parent node. This consequently means its parent node (node 5) could be mistakenly considered balanced.

An important thing to remember is that if one subtree is imbalanced, the entire tree is considered imbalanced. This means node 5 should also be marked as imbalanced. We can fix this by returning -1 upon encountering an imbalanced node, essentially informing parent nodes of this imbalance:

Image represents a binary tree illustrating an imbalance.  The tree's nodes contain numerical values (1, 2, 3, 4, 5, 6, 7, 9). Nodes 5 and 7 are highlighted in pink, indicating they are key nodes in the imbalance analysis.  Solid lines represent the parent-child relationships within the tree's structure. Dashed orange lines with orange numerical values (1, 2, 3) represent the balance factor of each node, showing the difference in height between the left and right subtrees.  A red arrow points from the node 7 to the word 'imbalanced' (in red text), explicitly labeling the imbalance. The balance factors indicate that the right subtree rooted at node 5 is significantly deeper than its left subtree, causing the imbalance.  Specifically, node 5 has a balance factor of -1 (right subtree is one level deeper), node 2 has a balance factor of 2 (left subtree is two levels deeper), and node 7 has a balance factor of -2 (right subtree is two levels deeper), contributing to the overall imbalance highlighted by the diagram.

To finalize our answer, we return false if the root node of the binary tree returns -1, and true otherwise.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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:
    # Base case: if the node is null, its height is 0.
    if not node:
        return 0
    # Recursively get the height of the left and right subtrees. If either subtree
    # is imbalanced, propagate -1 up the tree.
    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 the current node's subtree is imbalanced (height difference > 1), return -1.
    if abs(left_height - right_height) > 1:
        return -1
    # Return the height of the current subtree.
    return 1 + max(left_height, right_height)
function balancedBinaryTreeValidation(root) {
    return getHeightImbalance(root) !== -1;
}

function getHeightImbalance(node) {
    // Base case: if the node is null, its height is 0.
    if (!node) {
        return 0;
    }
    // Recursively get the height of the left and right subtrees. If either subtree
    // is imbalanced, propagate -1 up the tree.
    const leftHeight = getHeightImbalance(node.left);
    const rightHeight = getHeightImbalance(node.right);
    if (leftHeight === -1 || rightHeight === -1) {
        return -1;
    }
    // If the current node's subtree is imbalanced (height difference > 1), return -1.
    if (Math.abs(leftHeight - rightHeight) > 1) {
        return -1;
    }
    // Return the height of the current subtree.
    return 1 + Math.max(leftHeight, rightHeight);
}
public class Solution {
    public boolean balancedBinaryTreeValidation(TreeNode root) {
        return getHeightImbalance(root) != -1;
    }

    private int getHeightImbalance(TreeNode node) {
        // Base case: if the node is null, its height is 0.
        if (node == null) {
            return 0;
        }
        // Recursively get the height of the left and right subtrees. If either subtree
        // is imbalanced, propagate -1 up the tree.
        int leftHeight = getHeightImbalance(node.left);
        int rightHeight = getHeightImbalance(node.right);
        if (leftHeight == -1 || rightHeight == -1) {
            return -1;
        }
        // If the current node's subtree is imbalanced (height difference > 1), return -1.
        if (Math.abs(leftHeight - rightHeight) > 1) {
            return -1;
        }
        // Return the height of the current subtree.
        return 1 + Math.max(leftHeight, rightHeight);
    }
}

Complexity Analysis

Time complexity: The time complexity of balanced_binary_tree_validation is O(n)O(n), where nn denotes the number of nodes in the tree. This is because it recursively traverses each node of the tree once.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

CHAPTER 61

Rightmost Nodes of a Binary Tree

Return an array containing the values of the rightmost nodes at each level of a binary tree.

Example:

Image represents a binary tree data structure where nodes are represented by circles containing numerical values.  The root node, labeled '1' and highlighted in orange, connects to two child nodes: '2' and '3', also highlighted in orange. Node '2' further branches into nodes '4' and '5', while node '3' connects to node '6', also orange. Node '4' has child nodes '8' and '9'. Node '5' connects to node '11', which is also highlighted in orange.  Arrows pointing to nodes '1', '3', '6', and '11' indicate an external input or operation targeting these specific nodes, suggesting a possible traversal or update process focused on these particular elements within the tree.  The nodes are numbered sequentially, but the orange highlighting suggests a specific subset of nodes are of particular interest or are involved in a separate process from the rest of the tree.
Example
Output: [1, 3, 6, 11]

Intuition

At first glance, the solution to this problem might seem as simple as traversing the rightmost branch of the tree until we reach a leaf node. But this doesn’t work. Why not? Consider the tree below. We see that traversing just the rightmost branch results in missing the rightmost node at the fourth level of the tree:

Image represents a binary tree with nodes labeled numerically (1, 2, 3, 4, 5, 6, 8, 9, 11).  Nodes 1, 3, and 6 are shaded gray and enclosed within a dashed oval, indicating a subset of the tree.  Arrows indicate a traversal path from node 1 to node 3, and then to node 6.  Node 11 is circled in red and a horizontal arrow points to it from the text 'missing,' highlighting its absence from the 'output' list.  To the right, the 'output' is listed as `[1 3 6]`, while the 'expected output' is `[1 3 6 11]`, demonstrating that the algorithm represented by the tree traversal only produced a subset of the expected values, specifically omitting node 11.  The tree structure shows a hierarchical relationship between the nodes, with node 1 as the root, and nodes 2 and 3 as its children. Node 2 has children 4 and 5, and node 4 has children 8 and 9. Node 3 has a child node 6, and node 5 has a child node 11.

This means we need to consider the entire tree to attain the correct output, and not just a single branch. What would be useful is a way to traverse the tree level by level, allowing us to identify and retrieve the last (i.e., rightmost) node at each level.

We know BFS traverses nodes level by level. However, standard BFS doesn't provide explicit markers for when one level ends and another begins. In contrast, there is a type of BFS traversal that allows us to process one level at a time. This algorithm is called level-order traversal.

Level-order traversal
The core idea of level-order traversal is that at any level of the tree, the children of the nodes at that level comprise the next level. This means the children of level 1’s nodes make up level 2, and likewise, the children of level 2’s nodes make up level 3, and so on. To see how this works, consider the binary tree below, with the BFS queue initialized with the tree’s root node:

Image represents a tree-like data structure, specifically a binary tree, on the left, and a queue data structure on the right. The binary tree is composed of circular nodes, each containing a single integer from 1 to 11. Node 1 is the root node, branching into nodes 2 and 3. Node 2 further branches into nodes 4 and 5, while node 3 connects to node 6. Node 4 branches into nodes 8 and 9, and node 5 connects to node 11.  The connections between nodes represent parent-child relationships.  On the right, a queue is depicted as `queue = [1]`, indicating that the queue currently contains only the integer 1, enclosed in square brackets.  There is no visible connection or information flow between the tree and the queue; they are presented as separate structures.

We know level 1 consists of only the root node. So, the children of the root make up the nodes of the second level. Let’s pop the root node off and then add its children to the queue:

Image represents a visual explanation of Breadth-First Search (BFS) algorithm applied to a binary tree.  The left side shows a binary tree with a root node labeled '1' (highlighted in peach), a left child node '2', and a right child node '3'.  The nodes '2' and '3' have dashed lines indicating they might have further children, but these are not shown.  A label 'Level 1:' precedes the root node. An arrow points from a rectangular box labeled 'node' to the root node '1', suggesting data flow into the algorithm. The right side demonstrates the algorithm's process. A queue, initially containing only node '1' (in a solid circle), is shown.  A red arrow and the text 'node = queue.pop' indicate that node '1' is dequeued.  Two blue arrows, labeled 'push node.left' and 'push node.right', show the left and right children ('2' and '3', respectively, in dashed circles) being enqueued into the queue, expanding the queue to '[2, 3]'.  This illustrates the step-by-step process of BFS: visiting nodes level by level, adding children to the queue as they are encountered.

Since we've removed the only level 1 node from the queue and added its children, the queue now only contains the nodes of level 2. Therefore, the size of the queue currently corresponds to the number of nodes in level 2.

Image represents a visual explanation of a breadth-first search (BFS) algorithm using a tree structure and a queue.  A tree is shown with a root node labeled '1', connected to two child nodes labeled '2' and '3' at level 2.  These child nodes are highlighted with a light peach-colored background, and the label 'Level 2:' is placed to their left. Dashed lines extend from nodes '2' and '3', suggesting further child nodes at subsequent levels, not explicitly shown.  To the right, a queue is depicted as `queue = [2 3]`, indicating that nodes '2' and '3' are currently in the queue for processing. A light gray, dashed-line box below states 'size of queue = size of level 2', illustrating the relationship between the number of nodes in the queue and the number of nodes at the current level being processed in the BFS traversal.

The current queue size is 2, indicating the second level has 2 nodes. So, let’s pop off the next 2 nodes in the queue and add their children to the queue:

Image represents a visual explanation of a Breadth-First Search (BFS) algorithm applied to a binary tree.  The left side shows a binary tree with nodes numbered 1 through 6. Node 1 is the root, with nodes 2 and 3 as its children. Nodes 2 and 3 each have two children, resulting in a tree structure with three levels.  A light peach-colored rectangle highlights nodes 2 and 3, indicating the current level being processed.  A label 'Level 2:' is placed to the left of this rectangle.  An arrow points from a box labeled 'node' to node 2, suggesting that node 2 is currently being processed. The middle section depicts a queue data structure, initially containing only node 2. A red arrow points downwards from node 2 within the queue, labeled 'node = queue.pop,' illustrating the removal of node 2 from the queue. The right side shows the queue's evolution.  Initially, the queue contains only node 2 (shown in a solid circle). After processing node 2, its left child (node 4) and right child (node 5) are added to the queue (represented by dashed circles), indicated by blue arrows labeled 'push node.left' and 'push node.right' respectively.  The final queue contains nodes 4 and 5, enclosed in square brackets.  The overall diagram demonstrates the step-by-step process of BFS, showing how nodes are visited level by level and added to the queue.
Image represents a visual explanation of a Breadth-First Search (BFS) algorithm traversing a binary tree.  The left side shows a binary tree with nodes numbered 1 through 6. Node 1 is the root, with children nodes 2 and 3. Node 2 has children 4 and 5, and node 3 has child 6.  A light peach-colored rectangle highlights node 2 and an arrow pointing to node 3, indicating the traversal order.  The text 'Level 1 2:' labels the second level of the tree.  The middle section depicts a queue data structure, initially containing only node 3 (represented by a circle with a thick border). A red arrow and the text 'node = queue.pop' show node 3 being dequeued.  The right side illustrates the queue's evolution. Initially, it contains only node 3.  After dequeuing 3, nodes 4 and 5 are added (from node 2's children), and then node 6 (from node 3's child) is added, as indicated by a dashed-line circle and the text 'push node.left' with a blue arrow pointing to the addition of node 6 to the queue.  The final queue contains nodes 4, 5, and 6.  The overall diagram demonstrates how BFS uses a queue to systematically explore the tree level by level.

After the two nodes on the second level have been popped off the queue, the remaining nodes in the queue represent the third level.

Image represents a tree structure with a root node labeled '1', branching into two nodes labeled '2' and '3'.  Nodes '2' and '3' further branch into nodes '4', '5', and '6' at level 3, which are highlighted with a light peach background.  Dashed lines extend downwards from nodes '4', '5', and '6', suggesting further levels in the tree that are not explicitly shown. To the right, a queue is depicted as `queue = [4`, with nodes '5' and '6' shown separately, implying they would be added to the queue next. A light gray, dashed-bordered box states 'size of queue = size of level 3', indicating a relationship between the number of elements in the queue and the number of nodes at level 3 of the tree.  The text 'Level 3:' is placed to the left of the level 3 nodes to clearly label that level.

The size of the queue is 3, meaning that to process the third level, we must process the next 3 nodes.


To summarize this process, we start by placing the root node in the queue, where the root node represents the first level. Then, begin BFS by entering a while-loop that continues until the queue is empty, meaning all nodes in the tree have been visited. For level-order traversal:

  1. Determine the level size: Collect the current size of the queue (level_size) to find the number of nodes in the current level. Initially, the queue will only contain the root node, indicating the first level is of size 1.

  2. Process the current level: For each node at this level, pop it from the queue and add its children to the queue.

After processing all nodes of the current level, the queue contains all the nodes of the next level. Repeat steps 1 and 2 to process the next level. When the queue is empty, all levels have been processed.

Once we know how to traverse the tree level by level, the rightmost node at each level is obtained by collecting that level’s last node:

Image represents a tree-like structure illustrating a coding pattern, possibly related to tree traversal or data collection.  The tree has four levels, labeled 'Level 1,' 'Level 2,' 'Level 3,' and 'Level 4.'  Each level contains nodes represented by circles, with numbers inside.  Nodes 1, 3, 6, and 11 are highlighted with an orange border.  Downward-pointing arrows indicate a traversal path, starting from node 1, then proceeding to nodes 3, 6, and finally 11.  The nodes are connected by lines representing parent-child relationships.  To the right, a list `res = [1 3 6 11]` shows the values collected during this traversal, suggesting that the algorithm collects values from the orange-highlighted nodes.  The unhighlighted nodes (2, 4, 5, 8, 9) are part of the tree structure but are not included in the final result shown in the `res` list.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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)
        # Add all the non-null child nodes of the current level to the queue.
        for i in range(level_size):
            node = queue.popleft()
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
            # Record this level's last node to the result array.
            if i == level_size - 1:
                res.append(node.val)
    return res
function rightmostNodesOfABinaryTree(root) {
    if (!root) {
        return [];
    }
    const res = [];
    const queue = [root];
    while (queue.length > 0) {
        const levelSize = queue.length;
        // Add all the non-null child nodes of the current level to the queue.
        for (let i = 0; i < levelSize; i++) {
            const node = queue.shift();
            if (node.left) {
                queue.push(node.left);
            }
            if (node.right) {
                queue.push(node.right);
            }
            // Record this level's last node to the result array.
            if (i === levelSize - 1) {
                res.push(node.val);
            }
        }
    }
    return res;
}
import java.util.*;

public class Solution {
    public List<Integer> rightmostNodesOfABinaryTree(TreeNode root) {
        if (root == null) {
            return new ArrayList<>();
        }
        List<Integer> res = new ArrayList<>();
        Queue<TreeNode> queue = new LinkedList<>();
        queue.offer(root);
        while (!queue.isEmpty()) {
            int levelSize = queue.size();
            // Add all the non-null child nodes of the current level to the queue.
            for (int i = 0; i < levelSize; i++) {
                TreeNode node = queue.poll();
                if (node.left != null) {
                    queue.offer(node.left);
                }
                if (node.right != null) {
                    queue.offer(node.right);
                }
                // Record this level's last node to the result array.
                if (i == levelSize - 1) {
                    res.add(node.val);
                }
            }
        }
        return res;
    }
}

Complexity Analysis

Time complexity: The time complexity of rightmost_nodes_of_a_binary_tree is O(n)O(n), where nn denotes the number of nodes in the tree. This is because we process each node of the tree once during the level-order traversal.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the queue. The queue’s size will grow as large as the level with the most nodes. In the worst case, this occurs at the final level when all the last-level nodes are non-null, totaling approximately n/2n/2 nodes. Note that the res array does not contribute to the space complexity.

CHAPTER 62

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.

Example:

Image represents a binary tree data structure.  The root node, labeled '1', connects to two child nodes: '2' and '3'. Node '2' further branches into nodes '4' and '5', while node '3' connects to node '7'. Node '4' has children '8' and '9'. Node '5' has a child '11', and node '7' has a child '14'.  Nodes '5', '3', and '7' each have missing children represented by dashed circles labeled '(null)', indicating that these branches are not yet populated or have null values. An orange line at the bottom spans the width of the tree, indicating a level or a specific operation on the tree, and is labeled '7', possibly representing the width or a level of the tree.  The numbers within the circles represent the values stored in each node. The solid lines represent parent-child relationships in the tree, while the dashed lines indicate the absence of a child node.
Example
Output: 7

Intuition

Let's first understand how width is defined in a binary tree. An important distinction to make is that the width of a level is not necessarily equivalent to the number of nodes in that level.

Image represents a tree-like structure illustrating a coding pattern, likely related to tree traversal or breadth-first search.  The diagram shows a complete binary tree with four levels.  The top level contains a single node labeled '1'. The second level has two nodes labeled '2' and '3', connected to node '1'. The third level contains nodes '4', '5', and '7', with '4' and '5' connected to '2' and '7' connected to '3'.  A dashed-line connection from '3' indicates a missing node (represented as '(null)'). The bottom level shows nodes '8', '9', '11', and '14', with '8' and '9' connected to '4', and '14' connected to '7'.  Two additional '(null)' nodes are shown on this level, indicating missing nodes.  Orange horizontal lines connect nodes at each level, labeled with the 'width' (number of nodes in that level) and the total 'number of nodes' at or below that level.  The values for width are 1, 2, 4, and 7, respectively, while the number of nodes are 1, 2, 3, and 4, respectively.  The diagram visually demonstrates how the width of the tree increases exponentially with each level.

As we can see, the null nodes between the leftmost and rightmost nodes are considered in the width, as well.

Think about a data structure in which determining the width or distance between two elements is simple, such as an array, where the distance of two elements can be obtained by the difference between their indexes. If our binary tree also has indexes, it would similarly be possible to obtain the width between two nodes at a level. Let’s explore a method to assign an index to each node.

Indexing a binary tree
Below, we see how indexes can be assigned at each node, starting with index 0 for the root node:

Image represents a binary tree structure visualized across multiple horizontal layers, each representing a level of the tree.  The nodes of the tree are circles containing numbers (1, 2, 3, 4, 5, 7, 8, 9, 11, 14), with orange numbers next to them indicating a sequential node numbering. Solid lines connect parent nodes to their children, illustrating the hierarchical relationships. Dashed lines and circles labeled '[null]' represent missing nodes or branches. To the right of the tree, calculations are shown for 'width' at each level.  The formula used is `width = (largest node index - smallest node index) + 1`, with the result displayed below. For example, at the lowest level, the calculation is `width = (13 - 7) + 1 = 7`, reflecting the number of nodes (including nulls) present at that level.  The top level has a width of 1, the second level has a width of 2, the third level has a width of 4, and the bottom level has a width of 7.

These indexes enable us to calculate the width of a level using rightmost_index - leftmost_index + 1, as shown in the diagram above. Note, the indexes at the null nodes are added only for visualization.

But how can we set up something like this? The key observation is that each node's index can be determined from its parent's index. We can derive the following relationship for any node at index i:

  • Its left child will be at index 2*i + 1.
  • Its right child will be at index 2*i + 2.

Now, we just need a way to traverse each level of the tree to determine the width at individual levels, allowing us to obtain the width of the widest level in the tree. We can use level-order traversal for this.

Level-order traversal
If you're unfamiliar with the level-order traversal algorithm, study the Rightmost Nodes of a Binary Tree problem before continuing.

Remember, level-order traversal utilizes a queue to process the binary tree nodes. In this problem, whenever we push a node into this queue, we also push its respective index along with it, to know which index it’s associated with.

The width of a level can be calculated using rightmost_index - leftmost_index + 1. To perform this calculation, we need the index of the first (leftmost) and last (rightmost) nodes of that level. Here’s how we obtain these indexes:

  • Set leftmost_index to the index at the first node of the level.

  • Start rightmost_index at the same point as leftmost_index and update it as we traverse the level. This way, it will eventually be set to the last index after traversing the level.

Image represents a binary tree structure illustrating the concept of `leftmost_index` and `rightmost_index`.  The tree's root node is labeled '1'.  Node '1' connects to nodes '2' and '3' with solid lines. Node '2' further connects to nodes '4' and '5' with solid lines, while node '3' connects to node '7' with a solid line and has dashed lines indicating null children. Node '4' connects to nodes '8' and '9' with solid lines. Node '5' connects to node '11' with a solid line and has a dashed line indicating a null child. Node '7' connects to node '14' with a solid line.  Nodes '8', '9', '11', and '14' are leaf nodes.  A peach-colored rectangular background highlights nodes '4', '5', ' (null) ', and '7', representing a level in the tree.  Orange rectangular boxes labeled `leftmost_index` and `rightmost_index` point downwards with arrows to nodes '4' and '7' respectively, indicating these nodes as the leftmost and rightmost nodes at that specific level of the tree.  Dashed lines and '(null)' labels represent null or missing child nodes.

As we calculate the width at each level, we keep track of the largest width using max_width, representing the width of the widest level in the binary tree.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from ds import TreeNode
    
def widest_binary_tree_level(root: TreeNode) -> int:
    if not root:
        return 0
    max_width = 0
    queue = deque([(root, 0)])  # Stores (node, index) pairs.
    while queue:
        level_size = len(queue)
        # Set the 'leftmost_index' to the index of the first node in this level. Start
        # 'rightmost_index' at the same point as 'leftmost_index' and update it as we
        # traverse the level, eventually positioning it at the last node.
        leftmost_index = queue[0][1]
        rightmost_index = leftmost_index
        # Process all nodes at the current level.
        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
function widestBinaryTreeLevel(root) {
    if (!root) {
        return 0;
    }
    let maxWidth = 0;
    const queue = [[root, 0]];  // Stores (node, index) pairs.
    while (queue.length > 0) {
        const levelSize = queue.length;
        // Set the 'leftmost_index' to the index of the first node in this level. Start
        // 'rightmost_index' at the same point as 'leftmost_index' and update it as we
        // traverse the level, eventually positioning it at the last node.
        const leftmostIndex = queue[0][1];
        let rightmostIndex = leftmostIndex;
        // Process all nodes at the current level.
        for (let j = 0; j < levelSize; j++) {
            const [node, i] = queue.shift();
            if (node.left) {
                queue.push([node.left, 2 * i + 1]);
            }
            if (node.right) {
                queue.push([node.right, 2 * i + 2]);
            }
            rightmostIndex = i;
        }
        maxWidth = Math.max(maxWidth, rightmostIndex - leftmostIndex + 1);
    }
    return maxWidth;
}
import java.util.*;

public class Solution {
    static class Element {
        TreeNode node;
        int index;

        Element(TreeNode node, int index) {
            this.node = node;
            this.index = index;
        }
    }

    public int widestBinaryTreeLevel(TreeNode root) {
        if (root == null) {
            return 0;
        }
        int maxWidth = 0;
        Queue<Element> queue = new LinkedList<>();  // Stores (node, index) pairs.
        queue.offer(new Element(root, 0));
        while (!queue.isEmpty()) {
            int levelSize = queue.size();
            // Set the 'leftmost_index' to the index of the first node in this level. Start
            // 'rightmost_index' at the same point as 'leftmost_index' and update it as we
            // traverse the level, eventually positioning it at the last node.
            int leftmostIndex = queue.peek().index;
            int rightmostIndex = leftmostIndex;
            // Process all nodes at the current level.
            for (int j = 0; j < levelSize; j++) {
                Element elem = queue.poll();
                TreeNode node = elem.node;
                int i = elem.index;
                if (node.left != null) {
                    queue.offer(new Element(node.left, 2 * i + 1));
                }
                if (node.right != null) {
                    queue.offer(new Element(node.right, 2 * i + 2));
                }
                rightmostIndex = i;
            }
            maxWidth = Math.max(maxWidth, rightmostIndex - leftmostIndex + 1);
        }
        return maxWidth;
    }
}

Complexity Analysis

Time complexity: The time complexity of widest_binary_tree_level is O(n)O(n), where nn denotes the number of nodes in the tree. This is because we process each node once during level-order traversal.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the queue. The queue’s size will grow as large as the level with the most nodes. In the worst case, this occurs at the last level when all the last-level nodes are non-null, totaling approximately n/2n/2 nodes.

CHAPTER 63

Binary Search Tree Validation

Verify whether a binary tree is a valid binary search tree (BST). A BST is a binary tree where each node meets the following criteria:

  • A node's left subtree contains only nodes of lower values than the node's value.

  • A node's right subtree contains only nodes of greater values than the node's value.

Example:

Image represents a binary tree structure where nodes are represented by circles containing numerical values.  The root node, at the top, contains the number '5'.  This root node connects to two child nodes: '2' on the left and '7' on the right. Node '2' further branches into two leaf nodes: '1' on the left and '6' on the right. Similarly, node '7' branches into leaf nodes '7' on the left and '9' on the right.  Crucially, curved red arrows labeled 'invalid' point from node '2' to node '6' and from node '7' to the other node '7', indicating that these connections or the data within these nodes are considered invalid within the context of the tree's structure or the algorithm it represents.  The overall structure visually depicts a hierarchical data organization with highlighted invalid elements.
Example
Output: False

Explanation: This tree has two violations of the BST criteria:

  • Node 5's left subtree contains node 6, and node 6's value is greater than 5.
  • Node 7 has a left child with the same value of 7.

Intuition

A BST maintains a logically sorted order of values by complying with the criteria specified in the problem description. That is, the left subtree of a node with value x must consist of values less than x, and its right subtree must consist of values strictly greater than x.

Image represents a binary search tree structure illustrating the property of a node's value ('x', enclosed in a circle) in relation to its subtrees.  A central node 'x' is connected by two downward-pointing branches to two triangular shapes representing the left and right subtrees. The left subtree, colored light blue, is labeled 'left subtree' and text outside the triangle states 'all left subtree values'.  The right subtree, colored light purple, is labeled 'right subtree' with accompanying text 'all right subtree values'.  The branches connecting 'x' to the subtrees are labeled with '<', indicating that all values within the left subtree are less than 'x', and all values within the right subtree are greater than 'x'.  The overall arrangement visually demonstrates the ordering principle inherent in a binary search tree.

In addition, all nodes in the left and right subtrees of the above diagram must follow these criteria, too.

Since evaluating subtrees is important in determining if a tree is a BST, let’s try a recursive DFS approach to solve this problem.

Consider this example:

The image represents a binary tree data structure.  A rectangular box labeled 'node' with a light gray fill points to a central circular node containing the number '5'. This node '5' acts as the root of the tree.  From node '5', two branches extend downwards, each connecting to another circular node. The left branch connects to node '2', which further branches into nodes '1' and '6'. The right branch from node '5' connects to node '7', which in turn branches into nodes '7' and '9'.  All nodes are represented by circles containing a single integer value. The lines connecting the nodes represent parent-child relationships within the tree structure, illustrating a hierarchical arrangement of data.  No URLs or parameters are present.

There’s nothing to assess at the root node because it can contain any value. So, let’s have a look at its children. Here’s how we assess these nodes based on the BST rules:

  • All nodes to the left of node 5 should be less than 5. So, when we make a recursive call to its left child, we pass in an upper bound of 5.

  • All nodes to the right of node 5 should be greater than 5. So, when we make a recursive call to its right child, we pass in a lower bound of 5.

Image represents a binary search tree with a root node labeled '5'.  The root node has two child nodes: a left child node labeled '2' and a right child node labeled '7'.  The node labeled '2' further branches into two leaf nodes labeled '1' and '6'. Similarly, the node labeled '7' branches into two leaf nodes labeled '7' and '9'.  Dashed grey arrows connect the root node to its left and right subtrees, indicating a function call `is_within_bounds`.  To the left of the tree, the function call `is_within_bounds(node.left, lower_bound = -∞, upper_bound = 5)` is shown, specifying that the left subtree is checked for values within the range of negative infinity to 5.  Conversely, to the right of the tree, the function call `is_within_bounds(node.right, lower_bound = 5, upper_bound = +∞)` indicates that the right subtree is checked for values within the range of 5 to positive infinity.  The entire diagram illustrates a binary search tree structure and the application of a bounds-checking function on its subtrees.

Let’s now consider node 2. It satisfies its specified lower and upper bounds of - and 5, respectively. So, let’s verify its subtrees. Applying the same logic as before, node 2’s left child should have an upper bound of 2, while its right child should have a lower bound of 2.

Image represents a binary tree structure illustrating a 'is_within_bounds' function.  The tree has a root node labeled '5' in light gray, connected by light gray lines to two child nodes: '2' (dark gray) and '7' (light gray). Node '2' is further connected by dark gray lines to child nodes '1' and '6'. Node '7' connects to child nodes '7' and '9' (both light gray).  Dashed lines connect node '2' to nodes '1' and '6', suggesting a conditional or iterative process.  Textual descriptions accompany the connections to node '2', specifying the function `is_within_bounds` with parameters `node.left` (for the connection to '1'), `lower_bound = -∞`, and `upper_bound = 2`; and `node.right` (for the connection to '6'), `lower_bound = 2`, and `upper_bound = 5`.  This suggests the function checks if a node's value falls within a specified range, with different ranges applied to the left and right subtrees of node '2'. The light gray nodes and connections likely represent a different part of the algorithm or data structure, possibly a separate branch or a result of the function's execution.

Above, we see that node 6 violates its expected upper bound of 5 (since 6 > 5). This violation indicates we’re dealing with an invalid binary tree. Note, node 6 has an upper bound of 5 because it’s a left descendant of node 5.

We now have a strategy for our DFS function, is_within_bounds(node, lower_bound, upper_bound):

  1. Check if the node.val falls between lower_bound and upper_bound. If it does, continue to the next step. If not, the tree is an invalid BST: return false.

  2. Call is_within_bounds on the left child with an updated upper bound set to the current node’s value: is_within_bounds(node.left, lower_bound, node.val).

  3. Call is_within_bounds on the right child with an updated lower bound set to the current node’s value: is_within_bounds(node.right, node.val, upper_bound).

  4. If both recursive calls return true, then the current node’s subtree is a valid BST: return true.

A minor optimization to make here is to check if the value we get from the recursive call at step 2 is false. If it’s false, we don’t have to perform step 3 and can just return false straight away, as we already know this tree isn’t a BST.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from ds import TreeNode
    
def binary_search_tree_validation(root: TreeNode) -> bool:
    # Start validation at the root node. The root node can contain any value, so set
    # the initial lower and upper bounds to -infinity and +infinity, respectively.
    return is_within_bounds(root, float('-inf'), float('inf'))
    
def is_within_bounds(node: TreeNode, lower_bound: int, upper_bound: int) -> bool:
    # Base case: if the node is null, it satisfies the BST condition.
    if not node:
        return True
    # If the current node's value is not within the valid bounds, this tree is not a
    # valid BST.
    if not lower_bound < node.val < upper_bound:
        return False
    # If the left subtree isn't a BST, this tree isn't a BST.
    if not is_within_bounds(node.left, lower_bound, node.val):
        return False
    # Otherwise, return true if the right subtree is also a BST.
    return is_within_bounds(node.right, node.val, upper_bound)
function binarySearchTreeValidation(root) {
    // Start validation at the root node. The root node can contain any value, so set
    // the initial lower and upper bounds to -infinity and +infinity, respectively.
    return isWithinBounds(root, -Infinity, Infinity);
}

function isWithinBounds(node, lowerBound, upperBound) {
    // Base case: if the node is null, it satisfies the BST condition.
    if (!node) {
        return true;
    }
    // If the current node's value is not within the valid bounds, this tree is not a
    // valid BST.
    if (!(lowerBound < node.val && node.val < upperBound)) {
        return false;
    }
    // If the left subtree isn't a BST, this tree isn't a BST.
    if (!isWithinBounds(node.left, lowerBound, node.val)) {
        return false;
    }
    // Otherwise, return true if the right subtree is also a BST.
    return isWithinBounds(node.right, node.val, upperBound);
}
public class Solution {
    public boolean binarySearchTreeValidation(TreeNode root) {
        // Start validation at the root node. The root node can contain any value, so set
        // the initial lower and upper bounds to -infinity and +infinity, respectively.
        return isWithinBounds(root, Long.MIN_VALUE, Long.MAX_VALUE);
    }

    private boolean isWithinBounds(TreeNode node, long lowerBound, long upperBound) {
        // Base case: if the node is null, it satisfies the BST condition.
        if (node == null) {
            return true;
        }
        // If the current node's value is not within the valid bounds, this tree is not a
        // valid BST.
        if (!(lowerBound < node.val && node.val < upperBound)) {
            return false;
        }
        // If the left subtree isn't a BST, this tree isn't a BST.
        if (!isWithinBounds(node.left, lowerBound, node.val)) {
            return false;
        }
        // Otherwise, return true if the right subtree is also a BST.
        return isWithinBounds(node.right, node.val, upperBound);
    }
}

Complexity Analysis

Time complexity: The time complexity of binary_search_tree_validation is O(n)O(n), where nn denotes the number of nodes in the tree. This is because we process each node recursively at most once.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

Detailed Recursive Demonstration

You may be curious why recursion works for problems like this. When we make a recursive DFS call in the middle of the code, how is the program able to return to this code and continue running the rest of it, after this recursive call finishes? This problem provides an excellent opportunity to demonstrate how this is possible.

The answer is that recursion makes use of a recursive call stack to manage each instance of a recursive function call. To illustrate this, we'll revisit the binary tree example discussed earlier, stepping through the recursive calls for the first few nodes. We'll also display the recursive call stack to help track the state of each call more clearly.


The first DFS call is made to the root node, which is given a lower and upper bound of -∞ and +∞, respectively:

Image represents a binary tree on the left and a depiction of a recursive call stack on the right. The tree's root node is labeled '5', with left child '2' and right child '7'. Node '2' has children '1' and '6', while node '7' has children '7' and '9'.  A gray box labeled 'node' points to the root node '5' indicating the starting point of a process. An orange arrow points from the tree to a beige rectangle representing the recursive call stack, which contains the function call `is_within_bounds(5, -∞, +∞)`. This function call takes the value '5' (from the root node) and positive and negative infinity as upper and lower bounds respectively. Below the call stack, a dashed-line box shows the condition `lower_bound < 5 < upper_bound`, with `-∞` and `+∞` representing the lower and upper bounds.  The diagram illustrates how a recursive function might process a tree structure, with each node's value being passed to the `is_within_bounds` function during the recursive calls.

We see that node 5 satisfies its specified lower and upper bounds.


At each node, after we confirm that its value falls within its expected lower and upper bounds, we make a recursive call to its left child before any right children are processed:

Image represents a binary tree on the left and a representation of a recursive call stack on the right. The binary tree consists of nodes numbered 1, 2, 5, 6, 7, 7, and 9. Node 5 is the root, with node 2 as its left child and node 7 as its right child. Node 2 has children 1 and 6, while node 7 has children 7 and 9. A dashed grey arrow indicates a traversal from node 5 to node 2. A rectangular box next to node 2 labels it as 'node'.  An orange arrow points from the tree to a rounded-corner rectangle labeled 'recursive call stack,' which contains two lines. The first line shows `is_within_bounds(2, -∞, 5)`, indicating a function call with the node value 2, a lower bound of negative infinity, and an upper bound of 5. The second line shows `is_within_bounds(5, -∞, +∞)`, representing another function call with node value 5, negative infinity as the lower bound, and positive infinity as the upper bound. Below the stack, a dashed-line box displays the condition `lower_bound < 2 < upper_bound`, with (-∞) and (5) representing the lower and upper bounds respectively, illustrating the bounds check within the recursive function.
Image represents a binary tree on the left and a recursive call stack with a condition on the right. The binary tree has nodes labeled 1, 2, 5, 6, 7, 7, and 9. Node 5 is the root, with node 2 as its left child and node 7 as its right child. Node 2 has children 1 and 6, while node 7 has children 7 and 9. A dashed grey arrow points from a box labeled 'node' to node 1, indicating a potential traversal or selection of this node.  On the right, a rounded rectangle represents the recursive call stack, showing three function calls to `is_within_bounds`. Each call includes a node number (1, 2, and 5 respectively) and lower and upper bounds (-∞, 2), (-∞, 5), and (-∞, +∞) respectively.  A separate dashed rectangle displays the condition `lower_bound < 1 < upper_bound`, with (-∞) and (2) representing the initial lower and upper bounds, suggesting a range check within the function. An orange arrow points from the tree to the call stack, implying that the tree traversal triggers the recursive calls.

After processing node 1, which has no children, recursion naturally progresses back to the recursive call to node 2, which is now at the top of the stack:

Image represents a binary tree on the left and a recursive call stack on the right, illustrating a depth-first traversal. The tree's root node is labeled '5', branching into nodes '2' and '7'. Node '2' further branches into nodes '1' and '6'. Node '7' branches into nodes '7' and '9'. A dashed arrow indicates the traversal starts at node '1'. A solid arrow points from a box labeled 'node' to node '2', suggesting data flow.  The right side depicts a recursive call stack showing function calls of `is_within_bounds`. Each call shows the node being processed (circled numbers 1, 2, and 5) and the lower and upper bounds being checked (∞ represents infinity). The stack shows the order of function calls during the traversal, with the most recent call (`is_within_bounds(2, -∞, 5)`) at the top, indicating a depth-first approach.  The topmost call in the stack is greyed out, suggesting it's the next function call to be processed.

Continuing from where we left off at this instance, it now processes its right child by making a recursive call to it:

Image represents a binary tree on the left and a recursive call stack with a validation check on the right. The binary tree has a root node labeled '5'.  Node '5' has two children: '2' and '7'. Node '2' has children '1' and '6', with node '6' shaded gray, indicating it's currently being processed. Node '7' has children '7' and '9'. A dashed arrow and a box labeled 'node' point to node '6', highlighting its selection.  An orange arrow points from the tree to a rectangular box labeled 'recursive call stack,' which lists three function calls: `is_within_bounds(6, 2, 5)`, `is_within_bounds(2, -∞, 5)`, and `is_within_bounds(5, -∞, +∞)`.  The numbers in parentheses represent the node value being checked, the lower bound, and the upper bound, respectively. Below the call stack is another box showing a validation check: `lower_bound < 6 < upper_bound`, with '(2)' under `lower_bound`, '6' in the middle, and '(5)' under `upper_bound`. The inequality `6 < 5` is marked as 'invalid' in red, indicating that the value 6 is outside the specified bounds (2 and 5).  The overall diagram illustrates a recursive function (`is_within_bounds`) traversing a binary tree and checking if node values fall within predefined bounds.

Once we reach node 6, the algorithm notices that its value violates its expected upper bound. As such, it returns false.


In this DFS solution, the recursion starts at the root node, but values start being returned from the leaf nodes and bubble upwards to their parent nodes. Below is a diagram showing how the boolean result at each node moves up from the leaf nodes to the root:

Image represents a binary decision tree illustrating a search algorithm, likely for a value within a range.  The root node contains the value '5' and the condition '-∞<5<+∞', indicating a check for whether the target value is within the entire range.  From the root, two branches extend. The left branch leads to node '2' with the condition '-∞<2<5', and the right branch leads to node '7' with the condition '5<7<+∞'.  Each node represents a comparison;  green dashed arrows indicate a 'True' outcome (the condition is met), and red dashed arrows indicate a 'False' outcome.  Nodes '1' ('-∞<1<2') and '6' ('2<6|5', labeled 'invalid') are children of node '2', while nodes '7' ('5<7|7', labeled 'invalid') and '9' ('7<9<+∞') are children of node '7.  The 'invalid' labels suggest that the algorithm handles these conditions as errors or out-of-range values.  The final red dashed arrow from node '5' points to the text 'return False', indicating that if neither the left nor right branch yields a 'True' result, the algorithm returns 'False', signifying that the target value was not found within the defined ranges.

Note that nodes 5, 2, and 7 return false because, while their values fall within their bounds, at least one node in their subtrees does not.

CHAPTER 64

Lowest Common Ancestor

Return the lowest common ancestor (LCA) of two nodes, p and q, in a binary tree. The LCA is defined as the lowest node that has both p and q as descendants. A node can be considered an ancestor of itself.

Example:

Image represents a binary tree structure where nodes are represented by circles containing numerical labels (1 through 9). Node 1 is the root node, branching into nodes 2 and 3. Node 2 further branches into nodes 4 and 5. Node 3 branches into nodes 6 and 7. Node 6 further branches into nodes 8 and 9.  Node 3 is highlighted in orange and labeled 'LCA' (Least Common Ancestor) in orange text above it, indicating its significance in the context of finding the LCA within the tree.  Additionally, node 8 is labeled with a 'p' in cyan, and node 7 is labeled with a 'q' in cyan, suggesting these nodes ('p' and 'q') are the target nodes for which the LCA (node 3) is being identified.  The lines connecting the nodes represent parent-child relationships within the tree hierarchy.

Constraints:

  • The tree contains at least two nodes.

  • All node values are unique.

  • p and q represent different nodes in the tree.

Intuition

One strategy to solve this problem is to traverse each node in the tree and evaluate whether the current node at any point is the LCA of p and q. To make this evaluation, it’s crucial to know the conditions a node needs to meet to be the LCA.

Let’s start by discussing where p or q would need to be for the current node to be at least an ancestor of both.

Identifying when the current node is an ancestor of p and q
Consider the following binary tree. Let's explore where p and q would need to be for node 3 to be an ancestor.

Image represents a binary tree structure where nodes are represented by circles containing numerical labels (1 through 9).  Node 1 is the root, branching into node 2 on the left and node 3 (highlighted in orange) on the right. Node 2 further branches into nodes 4 and 5. Node 3 branches into nodes 6 and 7. Node 6 further branches into nodes 8 and 9. A rectangular box labeled 'node' points to node 3 with an arrow, indicating a focus on this node.  The text 'ancestor of p and q?' is positioned near node 3, suggesting a question about finding the common ancestor of two nodes (p and q, not explicitly shown in the diagram) within the tree structure.  The overall structure illustrates a hierarchical relationship between the nodes, with each node (except the leaves) having zero, one, or two child nodes.

Node 3 is only an ancestor of p and q when both nodes are in node 3’s subtree. If they were anywhere else, they would not be descendants of node 3.

Image represents a binary tree structure illustrating an ancestor concept. The tree's root node is labeled '1'.  Node '1' branches into node '2' on the left and node '3' on the right. Node '2' further branches into nodes '4' and '5'. Node '3' branches into nodes '6' and '7'. Node '6' further branches into nodes '8' and '9'.  A light peach-colored fill highlights the subtree rooted at node '3'. A rectangular box labeled 'node' points with an arrow to node '3'.  Accompanying text explains that node '3' is considered an ancestor if both nodes 'p' and 'q' (not explicitly shown in the tree but implied to be within the tree) reside within the highlighted subtree (the subtree rooted at node 3).

Now, let’s examine the conditions in which a node is the lowest common ancestor of p and q.

Identifying when the current node is the LCA of p and q
Let’s identify where in the subtree p and q should be to qualify node 3 as the lowest ancestor of them both.

Image represents three binary tree diagrams illustrating the possible locations of nodes 'p' and 'q' given that node '3' is their lowest common ancestor (LCA).  Each diagram shows a root node '3' labeled 'LCA' in orange text, branching into nodes '6' and '7'. Node '6' further branches into nodes '8' and '9'.  The first diagram highlights nodes '8' and '9' with peach-colored fill, indicating 'p' is in the right subtree of '3' and 'q' is in the left subtree. The second diagram highlights node '8' with peach-colored fill and node '3' with peach-colored fill, showing 'p' in the left subtree of '3' and 'q' at node '3'. The third diagram highlights node '7' with peach-colored fill and node '9' with peach-colored fill, indicating 'p' at node '3' and 'q' in the right subtree of '3'.  Above the diagrams, text describes each scenario:  'If node 3 is the LCA, where are p and q?'.  The text to the right of each diagram specifies the location of 'p' and 'q' for that particular scenario.

As we can see above, there are three possible cases where node 3 is the LCA. This is because in each case, there isn’t a node lower than node 3 that contains both p and q as descendants, making node 3 the LCA.

Note that if both p and q were in just one of node 3’s subtrees (e.g., the left subtree), node 3 would not be the LCA because there would be a node lower in the left subtree that’s an ancestor of both p and q:

Image represents a binary tree illustrating the concept of the Lowest Common Ancestor (LCA).  The tree shows node 3 as the root, with a left subtree containing nodes 6, 8, and 9, and a right subtree containing node 7.  Nodes 8 and 9 are labeled 'p' and 'q' respectively in cyan, indicating these are the nodes for which the LCA is being sought. The subtree containing nodes 6, 8, and 9 is highlighted with a peach-colored background, explicitly identifying it as the relevant portion for finding the LCA of 'p' and 'q'.  The text 'LCA' in orange is positioned above the highlighted subtree to emphasize its role. A curved arrow points from node 6 to node 3, with the text 'NOT the LCA' indicating that node 6 is not the lowest common ancestor of nodes 8 and 9.  The diagram visually demonstrates that node 6 is a common ancestor, but node 3 is the lowest common ancestor of nodes 8 and 9 in this specific tree structure.

What can we derive from these observations? In the three cases where the current node is the LCA, p and q were found in exactly two of the following three locations:

  • The current node itself.
  • The current node’s left subtree.
  • The current node’s right subtree.

This indicates that by traversing the tree and checking if p and q are present in two of these locations for each node, we can effectively identify the LCA.

Depth-first search
Recursive DFS is well-suited for traversal in this problem, as it enables us to recursively check each node’s left and right subtrees to determine if they contain p or q.

In the implementation, we can assess the existence of p and q in the three locations previously mentioned, by attaining the following three boolean variables at each node:

  • node_is_p_or_q = (node == p or node == q)
  • left_contains_p_or_q = dfs(node.left)
  • right_contains_p_or_q = dfs(node.right)

Again, two of these variables need to be true for the current node to be the LCA.

Return statement
Whenever we make a recursive DFS call, such as dfs(node.left), we expect that call to return true if the subtree rooting from node.left contains p or q. This means the return statement of our DFS function should return true if either p or q exists anywhere in the current subtree.

To do this, we return true if any of the above three variables are true, as this would indicate either p or q is somewhere in the current subtree.

The diagram below shows how the boolean values returned from each node's left and right subtrees help to identify the LCA:

Image represents a binary tree structure illustrating a coding pattern, possibly related to finding the Lowest Common Ancestor (LCA).  The tree's root node is labeled '1'.  A solid black line connects node '1' to node '2' (left branch) and node '3' (right branch). Node '2' has children '4' and '5', while node '3' has children '6' and '7'. Node '6' further branches into nodes '8' and '9'.  Dashed lines with directional arrows indicate boolean values (True/False) associated with each branch. Red dashed lines represent 'False', and green dashed lines represent 'True'.  The values are positioned to suggest a traversal or comparison process. Node '3' is highlighted in orange, and an orange arrow points to it, labeled 'LCA', suggesting it's identified as the lowest common ancestor. Node '7' is highlighted in red and labeled 'q' in cyan, possibly indicating a specific node of interest.  Node '8' is labeled 'p' in cyan, similarly suggesting a specific node. The overall structure and labeling suggest a process of traversing the tree based on boolean conditions to identify a specific node or relationship between nodes.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

Note, this implementation uses a global variable because it leads to a more readable solution. However, it's important to confirm with your interviewer that global variables are acceptable. If not, you may need to adjust the solution to avoid using them, such as passing the variable as an argument, or finding an alternative approach.

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
    # Base case: a null node is neither p nor q.
    if not node:
        return False
    node_is_p_or_q = node == p or node == q
    # Recursively determine if the left and right subtrees contain 'p' or 'q'.
    left_contains_p_or_q = dfs(node.left, p, q)
    right_contains_p_or_q = dfs(node.right, p, q)
    # If two of the above three variables are true, the current node is the LCA.
    if node_is_p_or_q + left_contains_p_or_q + right_contains_p_or_q == 2:
        lca = node
    # Return true if the current subtree contains 'p' or 'q'.
    return node_is_p_or_q or left_contains_p_or_q or right_contains_p_or_q
function lowestCommonAncestor(root, p, q) {
    let lca = null;

    function dfs(node) {
        // Base case: a null node is neither p nor q.
        if (!node) {
            return false;
        }
        const nodeIsPOrQ = node === p || node === q;
        // Recursively determine if the left and right subtrees contain 'p' or 'q'.
        const leftContainsPOrQ = dfs(node.left);
        const rightContainsPOrQ = dfs(node.right);
        // If two of the above three variables are true, the current node is the LCA.
        if ((nodeIsPOrQ ? 1 : 0) + (leftContainsPOrQ ? 1 : 0) + (rightContainsPOrQ ? 1 : 0) >= 2) {
            lca = node;
        }
        // Return true if the current subtree contains 'p' or 'q'.
        return nodeIsPOrQ || leftContainsPOrQ || rightContainsPOrQ;
    }

    dfs(root);
    return lca;
}
public class Solution {
    private TreeNode lca = null;

    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        dfs(root, p, q);
        return lca;
    }

    private boolean dfs(TreeNode node, TreeNode p, TreeNode q) {
        // Base case: a null node is neither p nor q.
        if (node == null) {
            return false;
        }
        boolean nodeIsPOrQ = (node == p || node == q);
        // Recursively determine if the left and right subtrees contain 'p' or 'q'.
        boolean leftContainsPOrQ = dfs(node.left, p, q);
        boolean rightContainsPOrQ = dfs(node.right, p, q);
        // If two of the above three variables are true, the current node is the LCA.
        int count = (nodeIsPOrQ ? 1 : 0) + (leftContainsPOrQ ? 1 : 0) + (rightContainsPOrQ ? 1 : 0);
        if (count >= 2) {
            lca = node;
        }
        // Return true if the current subtree contains 'p' or 'q'.
        return nodeIsPOrQ || leftContainsPOrQ || rightContainsPOrQ;
    }
}

Complexity Analysis

Time complexity: The time complexity of lowest_common_ancestor is O(n)O(n), where nn denotes the number of nodes in the tree. This is because the algorithm traverses each node of the tree once.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

CHAPTER 65

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.

Example:

Image represents a binary tree data structure.  The tree is rooted at node 5, which branches into two child nodes: 9 on the left and 3 on the right.  Node 9 further branches into a single child node, 2. Node 3 branches into two child nodes: 4 on the left and 7 on the right.  Each node contains a single integer value. The connections between nodes represent parent-child relationships, indicating a hierarchical structure where each node (except the root) has exactly one parent node, and each node can have zero, one, or two child nodes.  There is no information flow explicitly depicted; the diagram solely illustrates the structural organization of the data within the tree.
Example
Input: preorder = [5, 9, 2, 3, 4, 7], inorder = [2, 9, 5, 4, 3, 7]

Constraints:

  • The tree consists of unique values.

Intuition

The first question that might come to mind is why both the preorder and inorder arrays are needed to solve this problem. Can’t we just use one of them? The reason is that each individual traversal order could represent multiple possible trees. For example, below are some trees that correspond to the following inorder traversal:

Image represents three different binary trees, each visually depicted with nodes containing numerical values connected by branches, alongside an array labeled 'inorder = [2 9 5 4 3 7]'.  The array represents an inorder traversal of all three trees. The first tree shows a root node '5' with a left subtree containing nodes '9' (parent) and '2' (child) and a right subtree with nodes '3' (parent), '4' (child), and '7' (child). The second tree has a root node '2', with a right subtree containing nodes '4' (parent), '5' (child), and '3' (child), where '3' has a right child '7' and '5' has a left child '9'. The third tree shows a root node '4', with a left subtree containing nodes '5' (parent), '2' (child), and '9' (child) and a right subtree with nodes '7' (parent) and '3' (child).  All three trees contain the same set of nodes (2, 3, 4, 5, 7, 9), but their structures differ, demonstrating that different tree structures can yield the same inorder traversal.

If one traversal array isn’t enough, it might be possible to build the tree using an additional traversal array as a reference point, to help identify how each node should be placed.

The root node
Whatever our approach, we initially need a way to access the root node since the root node is necessary to construct the rest of the tree.

To identify where the root node is, let’s first remind ourselves how nodes are processed during each traversal algorithm.

Preorder traversalInorder traversal
1. Process the current node1. Process the left subtree
2. Process the left subtree2. Process the current node
3. Process the right subtree3. Process the right subtree

 

Notice that during preorder traversal, the current node is processed before its subtrees. From this, we can infer that preorder traversal processes the root node first, meaning the first value in the preorder array is the value of the root node.

With this established, let’s find a method to build the rest of the tree.

Building the tree
Consider the preorder and inorder traversal arrays below and the corresponding binary tree:

Image represents a binary tree alongside its preorder and inorder traversal sequences.  The right side shows a binary tree with root node '5'.  Node '5' connects to a left child node '9' and a right child node '3'. Node '9' has a left child node '2', and node '3' has a left child node '4' and a right child node '7'. The left side displays two arrays: 'preorder' which lists the nodes in the order they are visited during a preorder traversal ([5, 9, 2, 3, 4, 7]), and 'inorder' which lists the nodes in the order they are visited during an inorder traversal ([2, 9, 5, 4, 3, 7]).  The preorder traversal starts at the root and recursively visits the left subtree before the right subtree, while the inorder traversal recursively visits the left subtree, then the root, and finally the right subtree.  The image demonstrates the relationship between a binary tree's structure and its preorder and inorder traversal sequences.

As mentioned above, we know the first value of the preorder array, 5, is the value of the root node.

Image represents a visual depiction of a preorder traversal in a tree structure.  A rectangular box labeled 'root' in orange is positioned above an orange arrow pointing downwards towards a preorder array representation. The array, labeled 'preorder = [ 5  9  2  3  4  7 ]', shows a sequence of numbers.  The number 5, the first element in the array, is highlighted by being enclosed in an orange circle below the array, indicating it's the root node of the tree. The arrow visually connects the 'root' label to the first element (5) of the preorder array, signifying that the value 5 is the root node of the tree whose preorder traversal is represented by the array.  The remaining numbers in the array (9, 2, 3, 4, 7) represent the other nodes in the tree, ordered according to the preorder traversal algorithm (root, left subtree, right subtree).

Now the question is, what node should be placed next? The preorder array alone isn’t enough to determine this. So, we should consult the inorder array.

Inorder traversal first visits a node’s left subtree before processing the node itself, so we can deduce that all values in the inorder array to the left of 5 are part of its left subtree. Similarly, all values to the right of 5 in the inorder array belong to its right subtree:

Image represents a binary search tree with the root node labeled '5', highlighted in peach.  The node '5' has a left subtree, shaded light blue, containing nodes '9' and '2', with '9' as the parent node and '2' as its child.  A light orange arrow points from the inorder traversal array element '5' to the root node '5' in the tree.  Similarly, the node '5' has a right subtree, shaded light purple, consisting of nodes '3', '4', and '7', where '3' is the parent node, and '4' and '7' are its left and right children respectively. A violet arrow points from the inorder traversal array element '3' to the node '3' in the tree.  Above the tree, an inorder traversal array `[2, 9, 5, 4, 3, 7]` is shown, with the elements '2' and '9' representing the inorder traversal of the left subtree, '5' representing the root, and '4', '3', and '7' representing the inorder traversal of the right subtree.  The text 'node 5's left subtree' and 'node 5's right subtree' clearly labels the respective subtrees.  The arrows visually connect the inorder traversal array elements to their corresponding nodes in the tree, illustrating the relationship between the inorder traversal and the tree structure.

Now what? With the root node placed, we have two subtrees to build: node 5’s left and right subtrees.

If we build the tree starting with the already-created root node, followed by the left subtree and then the right subtree, we’re effectively building the tree using preorder traversal. This is useful because we happen to have an array of values from a preorder traversal, which means we know the exact order in which the nodes should be created.

As we’re building the tree using preorder traversal, the next node to be created at any point will be the next value in the preorder array. This means we can iterate through the preorder array to place each new node.

Based on this, we know the next node to be placed will have a value of 9. But how do we know if node 9 is a left child or a right child of 5?

The image represents two possible binary tree structures derived from a preorder traversal sequence.  At the top, a preorder traversal array is shown as `preorder = [5 9 2 3 4 7]`. A downward-pointing orange arrow highlights the number `9` within this array. Below the array, two small binary trees are presented.  Both trees have a root node labeled `5` (in a black circle).  The left tree shows a dashed line connecting the root node `5` to a child node `9` (in an orange circle), while the right tree shows the same root node `5` connected via a dashed line to a child node `9` (also in an orange circle). The word 'OR' is placed between the two trees, indicating that both structures are valid interpretations of the preorder traversal sequence given the ambiguity inherent in preorder traversals without additional information.  The difference lies in which node is considered the child of the root node `5`.

This is where the inorder array comes in, as it helps us determine the structure of the tree. Consider the left subtree:

  • When we want to build the left subtree of a node, we look at the part of the inorder array that corresponds to the left subtree. In our example, this is the subarray [2, 9].

  • If this subarray is not empty, we proceed to build the left subtree, starting with node 9.

  • If this subarray is empty, it means there is no left subtree, so the current node’s left child is null.

Image represents a diagram illustrating the construction of a binary tree from an inorder traversal array.  The top line shows the inorder traversal array `inorder = [2 9 5 4 3 7]` split into two subarrays: `[2 9 5]` (highlighted in light blue) and `[4 3 7]` (highlighted in light purple). A light blue curved arrow connects the first subarray to the text 'left subarray not empty → left subtree exists,' indicating that the non-empty left subarray implies the existence of a left subtree in the corresponding binary tree. Similarly, a light purple curved arrow connects the second subarray to the text 'right subarray not empty → right subtree exists,' showing that the non-empty right subarray implies the existence of a right subtree.  The diagram visually demonstrates how the presence of non-empty subarrays in an inorder traversal directly relates to the existence of left and right subtrees during binary tree construction.

The same logic applies to the right subtree: we only build it if its corresponding inorder subarray isn’t empty.

Now, let’s devise a strategy for our algorithm. To utilize the two traversal arrays, we can assign a pointer to each:

  • A preorder_index points to the value of the current subtree’s root node. Once this node is created, increment preorder_index so it points to the value of the next node to be created.

  • An inorder_index is used to determine the position of the same value in the inorder array. We can find the index of this value by searching through the inorder array.

Image represents a visual explanation of constructing a binary tree from its preorder and inorder traversal sequences.  The top section shows a labeled box 'preorder_index' pointing down to a preorder traversal sequence represented as `preorder = [5 9 2 3 4 7]`. Below this, another labeled box 'inorder_index' points to an inorder traversal sequence `inorder = [2 9 5 4 3 7]`, with the numbers 2 and 9 highlighted in light blue, and 4, 3, and 7 highlighted in light purple.  To the right, a binary tree is depicted with the root node labeled '5'.  The left subtree, represented by `[2,9]` in cyan, and the right subtree, represented by `[4,3,7]` in violet, branch from the root node. The diagram illustrates how the preorder and inorder sequences, along with their indices, are used to reconstruct the structure of the binary tree.

At each recursive call, once these indexes are obtained, we:

  1. Create the current node using the value pointed at by preorder_index.

  2. Increment preorder_index so it points to the value of the next node to be created.

  3. Make a recursive call to build the current node’s left and right subtrees:

    • Pass in the subarray inorder[0, inorder_index - 1] to build the left subtree.
    • Pass in the subarray inorder[inorder_index + 1, n - 1] to build the right subtree.

Optimization - left and right pointers
One inefficiency of the above approach is that we extract entire subarrays out of the inorder array whenever we make a recursive call, which takes O(n)O(n) time each time we do this, where nn denotes the length of each input array. A more efficient approach is to define the range of each inorder subarray using left and right pointers. Specifically:

  • The left subtree contains the values in the range [left, inorder_index - 1].

  • The right subtree contains the values in the range [inorder_index + 1, right].

Image represents a diagram illustrating a recursive algorithm for constructing a binary tree from its preorder and inorder traversal sequences.  At the top, a box labeled 'preorder_index' points down to a preorder traversal sequence represented as `preorder = [5 9 2 3 4 7]`. Below this, boxes labeled 'left' and 'right' branch from the preorder sequence.  The 'left' box points to a portion of the inorder traversal sequence, `inorder = [2 9]`, highlighted in light blue, representing the left subtree.  The 'right' box points to `inorder = [4 3 7]`, highlighted in lavender, representing the right subtree.  Gray curved arrows connect the highlighted inorder subsequences to further subdivisions of the inorder sequence, indicated by `inorder = [2 9 5 4 3 7]` and `inorder = [2 9 5 4 3 7]`, respectively, with orange dashed lines showing the recursive partitioning of the inorder sequence to identify left and right subtrees.  The numbers within the inorder sequences are used to locate the root of each subtree based on its position in the preorder sequence, recursively building the binary tree.  The labels 'left' and 'right' consistently indicate the partitioning of the inorder sequence to define the left and right subtrees at each recursive step.

This allows us to define subarrays by just moving pointers, as opposed to creating completely new subarrays.

Optimization - hash map
At each node, it’s necessary to set inorder_index to the position of the same value pointed at by preorder_index. Performing a linear search for this value would take O(n)O(n) time for each node. Instead, we can use a hash map to store the inorder array values and their indexes, allowing us to retrieve any value's index from the inorder array in O(1)O(1) time.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
    # Populate the hash map with the inorder values and their indexes.
    for i, val in enumerate(inorder):
        inorder_indexes_map[val] = i
    # Build the tree and return its root node.
    return build_subtree(0, len(inorder) - 1, preorder, inorder)
    
def build_subtree(left, right, preorder, inorder):
    global preorder_index, inorder_indexes_map
    # Base case: if no elements are in this range, return None.
    if left > right:
        return None
    val = preorder[preorder_index]
    # Set 'inorder_index' to the index of the same value pointed at by
    # 'preorder_index'.
    inorder_index = inorder_indexes_map[val]
    node = TreeNode(val)
    # Advance 'preorder_index' so it points to the value of the next node to be
    # created.
    preorder_index += 1
    # Build the left and right subtrees and connect them to the current node.
    node.left = build_subtree(left, inorder_index - 1, preorder, inorder)
    node.right = build_subtree(inorder_index + 1, right, preorder, inorder)
    return node
function buildBinaryTree(preorder, inorder) {
    let preorderIndex = 0;
    const inorderIndexesMap = new Map();
    // Populate the hash map with the inorder values and their indexes.
    for (let i = 0; i < inorder.length; i++) {
        inorderIndexesMap.set(inorder[i], i);
    }

    function buildSubtree(left, right) {
        // Base case: if no elements are in this range, return null.
        if (left > right) {
            return null;
        }
        const val = preorder[preorderIndex];
        // Set 'inorder_index' to the index of the same value pointed at by
        // 'preorder_index'.
        const inorderIndex = inorderIndexesMap.get(val);
        const node = new TreeNode(val);
        // Advance 'preorder_index' so it points to the value of the next node to be
        // created.
        preorderIndex += 1;
        // Build the left and right subtrees and connect them to the current node.
        node.left = buildSubtree(left, inorderIndex - 1);
        node.right = buildSubtree(inorderIndex + 1, right);
        return node;
    }

    // Build the tree and return its root node.
    return buildSubtree(0, inorder.length - 1);
}
import java.util.*;

public class Solution {
    private int preorderIndex = 0;
    private Map<Integer, Integer> inorderIndexesMap = new HashMap<>();

    public TreeNode buildBinaryTree(int[] preorder, int[] inorder) {
        preorderIndex = 0;
        inorderIndexesMap.clear();
        // Populate the hash map with the inorder values and their indexes.
        for (int i = 0; i < inorder.length; i++) {
            inorderIndexesMap.put(inorder[i], i);
        }
        // Build the tree and return its root node.
        return buildSubtree(0, inorder.length - 1, preorder, inorder);
    }

    private TreeNode buildSubtree(int left, int right, int[] preorder, int[] inorder) {
        // Base case: if no elements are in this range, return null.
        if (left > right) {
            return null;
        }
        int val = preorder[preorderIndex];
        // Set 'inorder_index' to the index of the same value pointed at by
        // 'preorder_index'.
        int inorderIndex = inorderIndexesMap.get(val);
        TreeNode node = new TreeNode(val);
        // Advance 'preorder_index' so it points to the value of the next node to be
        // created.
        preorderIndex += 1;
        // Build the left and right subtrees and connect them to the current node.
        node.left = buildSubtree(left, inorderIndex - 1, preorder, inorder);
        node.right = buildSubtree(inorderIndex + 1, right, preorder, inorder);
        return node;
    }
}

Complexity Analysis

Time complexity: The time complexity of build_binary_tree is O(n)O(n), as it makes one call to the build_subtree function, which recursively traverses each element in the preorder and inorder arrays once, resulting in an O(n)O(n) runtime.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn. The hash map inorder_indexes_map also takes up O(n)O(n) space.

CHAPTER 66

Maximum Sum of a Continuous Path in a Binary Tree

Return the maximum sum of a continuous path in a binary tree. A path is defined by the following characteristics:

  • Consists of a sequence of nodes that can begin and end at any node in the tree.
  • Each consecutive pair of nodes in the sequence is connected by an edge.
  • The path must be a single continuous sequence of nodes that doesn't split into multiple paths.

Example:

Image represents a binary tree structure where nodes contain numerical values.  The root node, at the top, is labeled '5'.  This root node branches into two child nodes: '-10' on the left and '8' on the right. The '-10' node further branches into '1' and '-7', which in turn branch into '11' and '-1' respectively. The '8' node branches into '9' and '7'. The '7' node further branches into '6' and '-3'.  A peach-colored highlight encompasses the nodes '8', '9', '7', '6', and '-3'. To the right of this highlighted section, a calculation is shown: 'sum = 9 + 8 + 7 + 6 = 30', indicating a summation of the values within the highlighted subtree.  The entire diagram illustrates a tree data structure and a specific calculation performed on a subset of its nodes.
Example
Output: 30

Constraints:

  • The tree contains at least one node.

Intuition

Let’s first understand what a path is in a binary tree. An important thing to note is that all paths have a root node. Consider the following binary tree:

Image represents a binary tree data structure.  The root node, at the top, contains the value '5'.  This root node connects to two child nodes: a left child node with the value '-10' and a right child node with the value '8'.  The left child node ('-10') further branches into two child nodes: a left child with the value '1' and a right child with the value '-7'. The '1' node has a single left child node with the value '11'. The '-7' node has a single left child node with the value '-1'. The right child node ('8') also branches into two child nodes: a left child with the value '9' and a right child with the value '7'. The '7' node has two child nodes: a left child with the value '6' and a right child with the value '-3'.  All nodes are represented as circles containing their respective integer values. The connections between nodes represent parent-child relationships within the tree structure, indicating hierarchical organization of the data.

Every path that exists in this tree has a corresponding root node, as shown in the three examples below:

Image represents four binary trees, each illustrating a different stage in identifying a root-to-leaf path.  The top-left tree is a complete binary tree with root node '5', branching into '-10' and '8'.  '-10' further branches into '1' and '-7', with '1' having a child node '11' and '-7' having '-1'. '8' branches into '9' and '7', with '7' having children '6' and '-3'.  A peach-colored highlight indicates a path from the root to a leaf node, starting at node '8' in this instance, with an orange arrow labeled 'root of path' pointing to it. The bottom-left tree shows the same structure, but the highlighted path originates at '-10'. The top-right and bottom-right trees are identical to the left-side trees, but the highlighted paths are different, illustrating different possible root-to-leaf paths within the same tree structure.  Each highlighted path visually emphasizes a specific sequence of nodes from the root to a leaf node in the binary tree.

Inversely, this means that every node in the tree is the root of some path(s). For example, node 7 in the tree below is the root of four paths. The largest sum rooting from node 7 is 13:

Image represents two binary trees illustrating a tree traversal algorithm, likely for finding a subset of nodes that sums to a target value.  Both trees have a root node labeled '5'.  Each root node branches to two child nodes:  the left branch connects to a node labeled '-10', and the right branch connects to a node labeled '8'.  The '-10' node further branches to nodes '1' and '-7', with '1' branching to '11' and '-7' branching to '-1'. The '8' node branches to '9' and '7'.  In the left tree, the '7' node is highlighted in peach and branches to '6' and '-3', with the text 'sum = 7' indicating the sum of these three nodes (7 + 6 + -3).  The right tree is identical to the left, except the '7', '6', and '-3' nodes are highlighted in peach, and a rectangular box labeled 'sum = 13' is placed next to them, indicating the sum of these three nodes (7 + 6 + 0). The difference between the two trees shows a step-by-step process of finding a subset of nodes that sums to a target value, possibly using a recursive approach.
Image represents two binary trees, both rooted at node '5'.  Each tree has three levels. The root node '5' connects to two child nodes: '-10' and '8'.  Node '-10' further branches into '1' and '-7', with '1' connecting to '11' and '-7' connecting to '-1'. Node '8' branches into '9' and '7'. In the left tree, node '7' connects to '6' and '-3', which are highlighted in a peach-colored fill, with the text 'sum = 4' in orange nearby. In the right tree, nodes '6' and '-3' are also connected to '7', and this entire subtree (nodes '7', '6', and '-3') is highlighted in a peach-colored fill, with the text 'sum = 10' in orange nearby.  Both trees are identical except for the subtree rooted at node '7'. The highlighted subtrees illustrate different sums of the nodes within them.

So, to find the maximum path sum, we calculate the maximum sum rooting from each node and return the largest of these sums.

Calculating the maximum path sum at any node
Let’s try adopting a recursive strategy for this. Consider the root node of our example. The maximum sum of a path rooting from node 5 involves the maximum gain we can attain from its left subtree and its right subtree.

Image represents a binary tree illustrating the concept of finding the maximum path sum starting from a root node (node 5).  The tree is composed of nodes containing integer values. Node 5 is the root, branching to a left subtree (highlighted in light blue) and a right subtree (highlighted in light purple). The left subtree consists of nodes -10, 1, -7, 11, and -1, while the right subtree contains nodes 8, 9, 7, 6, and -3.  Each node is connected to its parent and children nodes via lines.  The formula '5 + (max from left) + (max from right)' is displayed above the tree, indicating that the maximum path sum from node 5 is calculated by adding the value of node 5 to the maximum path sum from its left subtree and the maximum path sum from its right subtree.  The subtrees are labeled 'left' and 'right' for clarity. A small box labeled 'node' points to node 5, indicating that the calculation starts from this node.

Which sums do we expect node 5’s left and right subtrees to return? Consider what happens when the maximum path sums of these subtrees are returned to node 5:

Image represents a binary tree with a root node labeled '5'.  A light-blue shaded area highlights the left subtree, showing a path from node '-10' to node '1' and finally to node '11', with the text 'maximum path of left subtree (sum = 2)' indicating the maximum path sum within this subtree.  Similarly, a light-purple shaded area highlights the right subtree, showing a path from node '8' to node '7', then to nodes '6' and '-3', with the text 'maximum path of right subtree (sum = 30)' indicating its maximum path sum.  A dashed cyan arrow points from a box labeled 'node' to the root node '5', indicating the starting point of the algorithm.  Dashed magenta arrows connect the root node '5' to the maximum paths of both subtrees.  Above the root node, the text 'maximum path at node 5 (sum = 5 + 2 + 30 = 37)' shows the calculation of the maximum path sum through the root node, incorporating the sums from both subtrees and the root node's value. A downward-pointing black arrow at the bottom indicates the final result or output of the algorithm.
Image represents a tree-like structure, possibly illustrating a graph data structure or a similar concept.  The topmost node is a pink circle connected to two other pink circles below it.  These two lower pink circles each have further connections to other circles. One pink circle connects to another pink circle and then to a light grey circle. The other pink circle connects to a light grey circle and then to another light grey circle. One of the bottom-most pink circles is shaded a darker pink.  A curved red arrow points to a path formed by several connected pink circles, with the text 'not a path' in red next to the arrow, indicating that this highlighted sequence of nodes does not represent a valid path according to some definition within the context of the course. The lines connecting the circles represent edges or relationships between nodes. The color-coding (pink and light grey) might signify different node types or states, but this is not explicitly labeled.

As we can see, when we received the maximum path sum from a recursive call made to node 8, we received the sum of a path with multiple branches. This results in an invalid path formed at node 5.

Below, we can see what we actually want. The sums of the two paths returned here correctly give us the maximum path sum rooting from node 5:

Image represents a binary tree illustrating the concept of finding the maximum path sum.  The root node, labeled '5', connects to two child nodes: '-10' on the left and '8' on the right.  A light-blue shaded area highlights a path from the root down to the node '11', with a label '(sum = 2)' indicating the sum of the nodes along this path (5 + (-10) + 1 + 11 = 7, but the image shows 2, which is incorrect). A light-purple shaded area highlights a path from the root down to the node '-3', with a label '(sum = 21)' indicating the sum of the nodes along this path (5 + 8 + 7 + (-3) = 17, but the image shows 21, which is also incorrect). Dashed cyan lines show a path from the root to the node '-10', and dashed magenta lines show a path from the root to the node '7'.  A small rectangle labeled 'node' points to the root node '5' with an arrow. Above the tree, text states 'maximum path at node (sum = 5 + 2 + 21 = 28)', indicating the maximum path sum calculated, although the calculation is incorrect based on the values shown in the tree. A downward-pointing arrow at the bottom suggests a further processing step or result.  The numbers within each circle represent the value of that node in the tree.
Image represents a tree-like graph structure illustrating a coding pattern, likely related to pathfinding or traversal algorithms.  The graph consists of nodes represented as circles, connected by edges represented as lines.  The topmost node is connected to two nodes below it, which in turn connect to further nodes, creating a branching structure.  A subset of the nodes and connecting edges are highlighted with a light green fill, forming a path from the topmost node down to a leaf node on the right. This highlighted path is labeled with the text 'valid path' in green, indicating a specific sequence of nodes that satisfies some condition within the algorithm.  The unhighlighted nodes and edges are rendered in light gray, suggesting they are not part of the currently identified valid path. The overall structure resembles a binary tree, although not all nodes have two children.

The difference between the valid and invalid paths above lies in the type of path returned by the recursive call to node 8. We can see this difference clearly in the diagram below.

  • In the left diagram below, an invalid path is formed at node 5 because at node 8, we return node 8’s maximum path sum, which is from a path with two branches.

  • In the right diagram, a valid path is formed at node 5 because we return the largest sum of a path with a single branch.

Image represents two nearly identical binary trees illustrating a coding pattern, likely related to path sums in a tree.  Both trees have a root node labeled '5', connected to a node labeled '8'. Node '8' branches to nodes '9' and '7'. Node '7' further branches to nodes '6' and '-3'.  The left tree highlights a path (nodes 8, 7, 6) with a sum of 30, indicated by a dashed grey arrow pointing back to node 5 and the text 'returning node 8's maximum path sum: return = 30X'. This path is visually emphasized by a peach-colored fill. The right tree shows a different path (nodes 8, 9) summing to 21, represented by a dashed grey arrow to node 5 and the text 'returning the correct path sum with only one branch return = 21✓'.  The title 'at node 8:' indicates that the diagrams illustrate the calculations performed at node 8 to determine the maximum path sum, contrasting a correct single-branch calculation with an incorrect multi-branch calculation.

This observation highlights that we can’t just return the maximum path sum of a node. So, let’s have a closer look at which path sum we should return, instead.

Identifying the value returned during recursion
Consider node 8 from the above example. The maximum path sum rooting from node 8 is 30. We know from the discussion above that we can’t just return this maximum path sum value:

Image represents a binary tree structure with nodes containing numerical values and a calculation demonstrating a maximum sum computation.  A light-grey node labeled '5' is connected via a dashed line to a peach-colored node labeled '8'. A rectangular box labeled 'node' indicates a data flow from the box to node '8'. Node '8' is connected to a peach-colored node '9' and a peach-colored node '7'. Node '7' is further connected to a peach-colored node '6' and a light-grey node '-3'.  A calculation to the right shows `max_sum = node.val + left_sum + right_sum`, with `node.val` being 8, `left_sum` being 9, and `right_sum` being 13, resulting in a `max_sum` of 30.  However, a red 'X' marks the `return max_sum` statement, suggesting an error or an incorrect approach in the calculation or algorithm. The dashed line from node '5' to node '8' implies a potential recursive call or traversal within a tree-processing algorithm.

We know we need to make sure we return a single, continuous path from node 8. This would mean returning a path with node 8 as an end point, which leaves us with two main choices for values we could return:

Image represents a binary tree illustrating a coding pattern, specifically option 1 of a recursive tree traversal algorithm.  The tree consists of nodes containing numerical values (5, 8, 9, 7, 6, -3).  Nodes 8 and 9 are highlighted with a peach-colored background.  A light gray arrow points from a rectangular box labeled 'node' to node 8, indicating the current node being processed.  A thicker black line connects node 8 and node 9, representing a parent-child relationship in the tree.  Light gray lines connect other nodes to represent the tree structure.  A dashed orange line and arrow point from node 8 back to node 5, visually representing a recursive call or data flow.  Above the orange line, the text 'return node.val + left_sum = 17' indicates the calculation being performed at node 8, summing its value (8) with the sum of its left subtree (9), resulting in 17, which is then returned up the tree.  A dashed gray line extends from the left of node 5, suggesting a continuation of the tree beyond the visible portion.
Image represents a binary tree illustrating a coding pattern, specifically option 2 of a recursive tree traversal algorithm.  The tree consists of nodes, circles containing numerical values.  A light-grey node labeled '5' is connected to a darker-grey node labeled '9' and a peach-colored node labeled '8'.  The node '8' is further connected to a peach-colored node '7' and a peach-colored node '6'.  A light-grey node labeled '-3' is connected to node '7'. A rectangular box labeled 'node' shows a directional arrow pointing to node '8', indicating the current node being processed.  A dashed curved arrow originates from node '8' and points to node '5', suggesting a return value.  The text 'return node.val + right_sum = 21' indicates the calculation being performed, where 'node.val' refers to the value of the current node (8) and 'right_sum' represents the sum of values in the right subtree (7 + 6 = 13), resulting in a total of 21. The peach-colored nodes and their connections are highlighted to emphasize the part of the tree currently being processed in this recursive step.  A dashed line extends from the left of node '5', suggesting a continuation of the tree beyond the visible portion.

Between the above two paths, we just return whichever of their sums is larger. Therefore, our return statement is:

return node.val + max(left_sum, right_sum)

Now that we’re getting single continuous path sums from the left and right subtrees, the maximum path sum rooting from a node can be calculated using node.val + left_sum + right_sum. This is done separately from the return statement.

The maximum path sum of the entire tree is found by keeping track of the largest path sum formed at every node.

Handling negative path sums
One final thing to note is that we shouldn’t include the values of left_sum or right_sum if either is negative, as they wouldn’t contribute to a maximum sum. We can do this by setting their values to 0 if they’re negative, which is the same as excluding the left or right path from the sum.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
   # Base case: null nodes have no path sum.
   if not node:
       return 0
   # Collect the maximum gain we can attain from the left and right subtrees, setting
   # them to 0 if they’re negative.
   left_sum = max(max_path_sum_helper(node.left), 0)
   right_sum = max(max_path_sum_helper(node.right), 0)
   # Update the overall maximum path sum if the current path sum is larger.
   max_sum = max(max_sum, node.val + left_sum + right_sum)
   # Return the maximum sum of a single, continuous path with the current node as an
   # endpoint.
   return node.val + max(left_sum, right_sum)
function maxPathSum(root) {
    let maxSum = -Infinity;

    function maxPathSumHelper(node) {
        // Base case: null nodes have no path sum.
        if (!node) {
            return 0;
        }
        // Collect the maximum gain we can attain from the left and right subtrees, setting
        // them to 0 if they’re negative.
        const leftSum = Math.max(maxPathSumHelper(node.left), 0);
        const rightSum = Math.max(maxPathSumHelper(node.right), 0);
        // Update the overall maximum path sum if the current path sum is larger.
        maxSum = Math.max(maxSum, node.val + leftSum + rightSum);
        // Return the maximum sum of a single, continuous path with the current node as an
        // endpoint.
        return node.val + Math.max(leftSum, rightSum);
    }

    maxPathSumHelper(root);
    return maxSum;
}
public class Solution {
    private int maxSum = Integer.MIN_VALUE;

    public int maxPathSum(TreeNode root) {
        maxSum = Integer.MIN_VALUE;
        maxPathSumHelper(root);
        return maxSum;
    }

    private int maxPathSumHelper(TreeNode node) {
        // Base case: null nodes have no path sum.
        if (node == null) {
            return 0;
        }
        // Collect the maximum gain we can attain from the left and right subtrees, setting
        // them to 0 if they’re negative.
        int leftSum = Math.max(maxPathSumHelper(node.left), 0);
        int rightSum = Math.max(maxPathSumHelper(node.right), 0);
        // Update the overall maximum path sum if the current path sum is larger.
        maxSum = Math.max(maxSum, node.val + leftSum + rightSum);
        // Return the maximum sum of a single, continuous path with the current node as an
        // endpoint.
        return node.val + Math.max(leftSum, rightSum);
    }
}

Complexity Analysis

Time complexity: The time complexity of max_path_sum is O(n)O(n), where nn denotes the number of nodes in the tree. This is because it traverses each node of the tree once.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

CHAPTER 67

Binary Tree Symmetry

Determine if a binary tree is vertically symmetric. That is, the left subtree of the root node is a mirror of the right subtree.

Example:

Image represents a binary tree data structure.  The tree is composed of nodes, represented by circles, each containing a numerical value. The root node, at the top, contains the value '5'.  This root node connects to two child nodes, each containing the value '2'. Each of these '2' nodes further branches into two child nodes: one with the value '1' and another with the value '4'. The '4' nodes then connect to a child node with the value '3'.  The entire structure is mirrored across a dashed vertical line, creating a symmetrical tree.  No URLs or parameters are present. The numbers within the nodes do not appear to represent any specific data other than their position within the tree structure. The dashed line serves as a visual aid to highlight the symmetry of the tree.
Example
Output: True

Intuition

To check a binary tree's symmetry, we need to assess its left and right subtrees. The first thing to note is that the root node itself doesn’t affect the symmetry of the tree. Therefore, we don't need to consider the root node. We now have the task of comparing two subtrees to check if one vertically mirrors the other.

Consider the root node’s left and right subtrees in the following example:

Image represents a binary tree data structure.  The topmost node, colored light gray, contains the value '5' and serves as the root of the tree.  From the root, two branches extend downwards, each connecting to a node with the value '2'.  Each of these '2' nodes further branches into two child nodes.  The left '2' node connects to a node with the value '1' and another with the value '4'. The '4' node then connects to a child node with the value '3'.  Similarly, the right '2' node connects to a node with the value '4' and another with the value '1'. The '4' node on the right also connects to a child node with the value '3'. All connections between nodes are represented by lines, with the lines connecting the root node to its children being light gray and the remaining connections being black.  The numbers within each circle represent the data stored in each node of the tree. The structure visually demonstrates a hierarchical relationship between the data elements.

The key observation here is that the right subtree is an inverted version of the left subtree.

We’ve learned from the problem Invert Binary Tree that an inversion is performed by swapping the left and right child of every node. This suggests the value of each node's left child in the left subtree should match the value of the right child of the corresponding node in the right subtree, and vice versa.

We can start by using DFS to traverse both subtrees. During this traversal, we compare the left and right children of each node in the left subtree with the right and left children of its corresponding node in the right subtree, respectively.

  • If the values of any two nodes being compared are not the same, the tree is not symmetric.

  • If, at any point, one of the child nodes being compared is null while the other isn’t, the tree is also not symmetric.

Initially, we see the values of the root nodes of the left and right subtrees are equal:

Image represents a comparison of two tree structures labeled 'node1' and 'node2', each rooted at a node with value '2'.  'node1' branches into a left child with value '1' and a right child with value '4', which further branches into a child with value '3'. Similarly, 'node2' branches into a left child with value '4' (which further branches into a child with value '3') and a right child with value '1'.  Arrows point from the labels 'node1' and 'node2' to their respective root nodes, indicating the input to a function 'dfs(node1, node2)'.  A separate, light-grey, dashed-bordered box to the right describes the function's first step: '1. check that node1.val == node2.val', indicating a comparison of the root node values of the two input trees.

Since they’re equal, proceed by comparing their children through recursive DFS calls. Specifically, make two recursive DFS calls to compare the left child of one node with the right child of the other. This checks that node2’s children contain the same values as node1’s children, but inverted.

Image represents two binary trees labeled 'node1' and 'node2', each rooted at a node with value '2'.  In 'node1', the left child of the root is '1' and the right child is '4'.  Node '4' has a child '3'. In 'node2', the left child of the root is '4' and the right child is '1'. Node '4' has a child '3'.  Cyan arrows indicate the 'left' and 'right' branches in both trees. Magenta arrows highlight the comparison between the left subtree of 'node1' and the right subtree of 'node2', and vice-versa. Dashed cyan lines connect nodes '1' and '3' from 'node1' to nodes '1' and '3' from 'node2', respectively, labeled 'compare'.  To the right, a gray box describes a check:  '2. check that dfs(node1.left, node2.right) and dfs(node1.right, node2.left) are True,' indicating a depth-first search comparison between the specified subtrees to verify their structural equality.

If either DFS call returns false, the subtrees are not symmetric. If both DFS calls return true, the subtrees are symmetric. This process is repeated for the entire tree.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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:
    # Base case: if both nodes are null, they're symmetric.
    if not node1 and not node2:
        return True
    # If one node is null and the other isn't, they aren't symmetric.
    if not node1 or not node2:
        return False
    # If the values of the current nodes don't match, trees aren't symmetric.
    if node1.val != node2.val:
        return False
    # Compare the 'node1's left subtree with 'node2's right subtree. If these
    # aren't symmetric, the whole tree is not symmetric.
    if not compare_trees(node1.left, node2.right):
        return False
    # Compare the 'node1's right subtree with 'node2's left subtree.
    return compare_trees(node1.right, node2.left)
function binaryTreeSymmetry(root) {
    if (!root) {
        return true;
    }
    return compareTrees(root.left, root.right);
}

function compareTrees(node1, node2) {
    // Base case: if both nodes are null, they're symmetric.
    if (!node1 && !node2) {
        return true;
    }
    // If one node is null and the other isn't, they aren't symmetric.
    if (!node1 || !node2) {
        return false;
    }
    // If the values of the current nodes don't match, trees aren't symmetric.
    if (node1.val !== node2.val) {
        return false;
    }
    // Compare the 'node1's left subtree with 'node2's right subtree. If these
    // aren't symmetric, the whole tree is not symmetric.
    if (!compareTrees(node1.left, node2.right)) {
        return false;
    }
    // Compare the 'node1's right subtree with 'node2's left subtree.
    return compareTrees(node1.right, node2.left);
}
public class Solution {
    public boolean binaryTreeSymmetry(TreeNode root) {
        if (root == null) {
            return true;
        }
        return compareTrees(root.left, root.right);
    }

    private boolean compareTrees(TreeNode node1, TreeNode node2) {
        // Base case: if both nodes are null, they're symmetric.
        if (node1 == null && node2 == null) {
            return true;
        }
        // If one node is null and the other isn't, they aren't symmetric.
        if (node1 == null || node2 == null) {
            return false;
        }
        // If the values of the current nodes don't match, trees aren't symmetric.
        if (node1.val != node2.val) {
            return false;
        }
        // Compare the 'node1's left subtree with 'node2's right subtree. If these
        // aren't symmetric, the whole tree is not symmetric.
        if (!compareTrees(node1.left, node2.right)) {
            return false;
        }
        // Compare the 'node1's right subtree with 'node2's left subtree.
        return compareTrees(node1.right, node2.left);
    }
}

Complexity Analysis

Time complexity: The time complexity of binary_tree_symmetry is O(n)O(n), where nn denotes the number of nodes in the tree. This is because we process each node recursively at most once.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

Interview Tip

Tip: Cover null cases. Always check for null or empty inputs before using their attributes in a function. In this problem, the main binary_tree_symmetry function itself accesses the left and right attributes of the input node, necessitating an initial null check.

CHAPTER 68

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. Nodes in the same column should be ordered from top to bottom. Nodes in the same row and column should be ordered from left to right.

Example:

Example
Output: [[2], [9], [5, 1, 4], [3], [7]]

Intuition

First and foremost, to get the columns of a binary tree, we'll need a way to identify what column each node is in.

Column ids
One way to distinguish between different columns is to represent each column by a distinct numerical value: an id. Initially, we don't know how many columns there are to the left or to the right of the root node, but we at least know the column which contains the root node itself. Let's give this column an id of 0. This lets us set positive column ids for nodes to the right of the root and negative ids to the left:

Image represents a binary tree structure visualized across columns representing levels or time steps.  The topmost node, labeled '5', resides in column '0'.  This node connects to two child nodes: '9' in column '-1' and '3' in column '1'. Node '9' further branches into nodes '2' in column '-2' and '1' in column '0'. Node '3' connects to node '4' in column '0' and node '7' in column '2'.  Each column is delineated by a vertically oriented orange bar at the top, with column labels '-2', '-1', '0', '1', and '2' indicating the level or time step. The area between the orange bars is shaded lightly peach, providing visual separation between the columns. The nodes are represented as circles containing numerical values, and the connections between them represent parent-child relationships within the tree structure.

How can we identify what column id a node is associated with? A handy observation is that every time we move to the right, the column id increases by 1, and every time we move to the left, it decreases by 1. This allows us to assign ids as we traverse the tree: for any node, the column ids of node.left and node.right are column - 1 and column + 1, respectively:

Image represents a directed acyclic graph illustrating a simple coding pattern, possibly related to tree traversal or a decision-making process within a program.  The graph consists of four nodes, each represented by a circle containing a numerical value (5, 9, 3, and 1) and labeled with a 'column' value (0, -1, 1, and 0 respectively).  Directed edges connect the nodes, indicating the flow of information or execution. Node 5, with column value 0, branches to node 9 (column -1) via an edge labeled 'column - 1' and to node 3 (column 1) via an edge labeled 'column + 1'. Node 9, with column value -1, connects to node 1 (column 0) via an edge labeled 'column + 1'.  The labels on the edges suggest that the value of a 'column' variable is modified as the program traverses the graph, potentially representing an index or counter within a data structure.  The overall structure suggests a recursive or iterative process where the value of the 'column' variable determines the path taken through the graph.

Tracking node values by their column id
Now that we have a way to determine a node’s column, we’ll need a way to keep track of which node values belong to which columns. This can be done using a hash map where keys are column ids and the value of each column id is a list of the node values at that column.

Image represents a visualization of a data structure, likely a tree or graph, alongside a mapping table.  A binary tree is shown with nodes containing numerical values (2, 5, 9, 1, 4, 3, 7).  The nodes are connected by edges, illustrating parent-child relationships.  The tree is positioned above a series of vertically aligned, peach-colored columns labeled -2, -1, 0, 1, and 2 at the top.  Each column visually aligns with a subset of the tree's nodes, suggesting a spatial organization or partitioning. To the right, a table labeled 'column_map' maps column IDs (-2 to 2) to lists of node values.  For example, column 0 contains nodes with values 5, 1, and 4, as reflected in the table's entry for column ID 0.  The table's labels 'id' and 'node values' clearly define the meaning of its columns.  The overall diagram illustrates a relationship between the spatial arrangement of nodes in the tree and their association with specific columns, as defined by the `column_map` table.

Now, let’s consider what traversal algorithm we should use to populate this hash map.

Breadth-first search
In general, we can employ any traversal method to assign column ids to each node, as long as we increment the column id whenever we move right and decrement it whenever we move left. However, we need to be cautious about the following two requirements:

  1. Nodes in the same column should be ordered from top to bottom.

  2. Nodes in the same row and column should be ordered from left to right.

So, we’ll need an algorithm that traverses the tree from top to bottom and then from left to right. This calls for BFS.

BFS processes nodes level by level, starting from the root and moving horizontally across the tree at each level. This method ensures nodes are visited from top to bottom, and for nodes in the same row (level), they are visited from left to right.

Image represents a tree-like data structure, possibly illustrating a coding pattern like a binary tree or a similar hierarchical structure.  The structure consists of nodes containing numerical values (5, 9, 3, 2, 1, 4, 7) connected by branches. Node 5 sits at the top, acting as the root.  It branches down to nodes 9 and 3. Node 9 further branches to nodes 2 and 1, while node 3 branches to nodes 4 and 7.  Horizontal light-blue lines with right-pointing cyan arrows traverse the diagram at three levels, indicating a flow of information or processing from left to right across each level. The leftmost vertical cyan arrow suggests an input or starting point for the process, while the rightmost horizontal arrow at the bottom level suggests an output or final result.  The overall arrangement suggests a layered processing model where data is processed level by level, potentially representing a parallel or pipelined algorithm.

Traversing the tree this way ensures node values are added to the hash map in the desired order, complying with the two requirements above.

We can see how the hash map is populated level by level below:

Image represents a visualization of a coding pattern, likely related to tree traversal or data structure manipulation.  The image shows three instances of a binary tree, each with a root node labeled '5'.  Each node in the tree contains a numerical value.  To the right of each tree is a table labeled 'column_map,' which acts as a mapping between a unique identifier ('id') and the values of nodes at a specific level or column within the tree.  Light blue arrows connect nodes horizontally across each tree, indicating a traversal or processing step.  Orange numbers next to the nodes in the trees represent some kind of associated value or index, possibly indicating the order of processing or a relative position.  The first tree shows a single column (id 0) with the root node 5 and its children. The second tree shows a horizontal connection between nodes 9 and 3, with the column_map updated to include ids -1, 0, and 1, reflecting the nodes' positions after this step. The third tree shows a horizontal connection spanning all leaf nodes, with the column_map further updated to include ids -2, -1, 0, 1, and 2, reflecting the final state after processing all nodes.  The overall pattern suggests a process that iteratively traverses the tree horizontally, updating the column_map to track the processed nodes and their values.

Once BFS concludes, the hash map is populated with lists of values for each column id. However, the hash map itself is not the expected output. So, let’s discuss what to do next.

Creating the output
This problem expects us to return the column ids from left to right. As we know, a hash map does not inherently maintain the order of its keys (column ids), which means we’ll need to find a way to attain the output in the desired order.

To retrieve the column ids in the desired order, it would be useful to know the leftmost column id and the rightmost column id. This allows us to increment through all ids in the range [leftmost_column, rightmost_column], ensuring we can build the output in the desired order.

Image represents a binary tree structure visualized within a columnar arrangement.  At the top, two rectangular boxes labeled 'leftmost_column' and 'rightmost_column' are positioned above their respective columns.  Arrows descend from these boxes, pointing to horizontal orange bars that span the width of their respective columns.  These bars are segmented into sections labeled with integers -2, -1, 0, 1, and 2, representing column indices. The main body of the image shows a binary tree; node 5 is the root, branching down to nodes 9 and 3. Node 9 further branches to nodes 2 and 1, while node 3 branches to nodes 4 and 7. Each node contains a single integer value. The tree is positioned such that nodes are vertically aligned within their respective columns based on the index labels, with the root node (5) centered in column 0.  The peach-colored background columns visually separate the nodes based on their column index.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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:
            # Add the current node's value to its corresponding list in the hash
            # map.
            column_map[column].append(node.val)
            leftmost_column = min(leftmost_column, column)
            rightmost_column = max(rightmost_column, column)
            # Add the current node's children to the queue with their respective
            # column ids.
            queue.append((node.left, column - 1))
            queue.append((node.right, column + 1))
    # Construct the output list by collecting values from each column in the hash
    # map in the correct order.
    return [column_map[i] for i in range(leftmost_column, rightmost_column + 1)]
function binaryTreeColumns(root) {
    if (!root) {
        return [];
    }
    const columnMap = new Map();
    let leftmostColumn = 0, rightmostColumn = 0;
    const queue = [[root, 0]];
    while (queue.length > 0) {
        const [node, column] = queue.shift();
        if (node) {
            // Add the current node's value to its corresponding list in the hash
            // map.
            if (!columnMap.has(column)) {
                columnMap.set(column, []);
            }
            columnMap.get(column).push(node.val);
            leftmostColumn = Math.min(leftmostColumn, column);
            rightmostColumn = Math.max(rightmostColumn, column);
            // Add the current node's children to the queue with their respective
            // column ids.
            queue.push([node.left, column - 1]);
            queue.push([node.right, column + 1]);
        }
    }
    // Construct the output list by collecting values from each column in the hash
    // map in the correct order.
    const res = [];
    for (let i = leftmostColumn; i <= rightmostColumn; i++) {
        if (columnMap.has(i)) {
            res.push(columnMap.get(i));
        }
    }
    return res;
}
import java.util.*;

public class Solution {
    static class Pair {
        TreeNode node;
        int column;

        Pair(TreeNode node, int column) {
            this.node = node;
            this.column = column;
        }
    }

    public List<List<Integer>> binaryTreeColumns(TreeNode root) {
        if (root == null) {
            return new ArrayList<>();
        }
        Map<Integer, List<Integer>> columnMap = new HashMap<>();
        int leftmostColumn = 0, rightmostColumn = 0;
        Queue<Pair> queue = new LinkedList<>();
        queue.offer(new Pair(root, 0));
        while (!queue.isEmpty()) {
            Pair pair = queue.poll();
            TreeNode node = pair.node;
            int column = pair.column;
            if (node != null) {
                // Add the current node's value to its corresponding list in the hash
                // map.
                columnMap.computeIfAbsent(column, k -> new ArrayList<>()).add(node.val);
                leftmostColumn = Math.min(leftmostColumn, column);
                rightmostColumn = Math.max(rightmostColumn, column);
                // Add the current node's children to the queue with their respective
                // column ids.
                queue.offer(new Pair(node.left, column - 1));
                queue.offer(new Pair(node.right, column + 1));
            }
        }
        // Construct the output list by collecting values from each column in the hash
        // map in the correct order.
        List<List<Integer>> res = new ArrayList<>();
        for (int i = leftmostColumn; i <= rightmostColumn; i++) {
            if (columnMap.containsKey(i)) {
                res.add(columnMap.get(i));
            }
        }
        return res;
    }
}

Complexity Analysis

Time complexity: The time complexity of binary_tree_columns is O(n)O(n) where nn denotes the number of nodes in the tree. This is because we process each node of the tree once during the level‐order traversal.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the queue. The queue’s size will grow as large as the level with the most nodes. In the worst case, this occurs at the final level when all the last‐level nodes are non‐null, totaling approximately n/2n/2 nodes. Note that the output array created at the return statement does not contribute to the space complexity.

CHAPTER 69

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.

Example:

Example
Output: 6

Constraints:

  • n ≥ 1, where n denotes the number of nodes in the tree.
  • 1 ≤ k ≤ n

Intuition - Recursive

A naive approach to this problem is to traverse the tree and store all the nodes in an array, sort the array, and return the kth element. This approach, however, does not take advantage of the fact that we're dealing with a BST.

Consider the below BST:

Image represents a binary tree data structure.  The tree is rooted at node 5, which connects to two child nodes: node 2 on the left and node 7 on the right. Node 2 further branches into two leaf nodes: node 1 on the left and node 4 on the right. Similarly, node 7 branches into two leaf nodes: node 6 on the left and node 9 on the right.  Each node is represented by a circle containing a numerical value (1 through 9).  The connections between nodes are represented by lines, indicating a parent-child relationship within the tree structure.  No URLs or parameters are present in the image; the only information conveyed is the numerical labels and their hierarchical arrangement within the binary tree.

We know that in a BST, each node’s value is larger than all the nodes to its left and smaller than all the nodes to its right. This structure means that BSTs inherently possess a sorted order. Given this, it should be possible to construct a sorted array of the tree's values by traversing the tree, without the need for additional sorting.

We now need a method to traverse the binary tree that allows us to encounter the nodes in their sorted order.

What are our options? We can immediately rule out any traversal algorithms that process the root node first, such as breadth-first search and preorder traversal, since the root node is not guaranteed to have the smallest value in a BST. This also indicates that we need an algorithm that starts with the leftmost node since this is always the smallest node in a BST. Additionally, the algorithm should end at the rightmost node since this would be the largest node.

This leads us to an ideal traversal algorithm: inorder traversal, where for each node, the left subtree is processed first, followed by the current node, and then the right subtree.

Image represents a binary search tree structure illustrating an inorder traversal.  A central node labeled 'X' is depicted, connected by lines to two triangular shapes representing subtrees.  A left-pointing arrow on the line connecting 'X' to the left subtree indicates values less than 'X' are contained within the light-blue, left subtree labeled 'left subtree' and the text 'less than x'.  Similarly, a right-pointing arrow on the line connecting 'X' to the right subtree indicates values greater than 'X' are contained within the purple, right subtree labeled 'right subtree' and the text 'greater than x'. To the right, the text 'inorder traversal:' describes the traversal order as 'left subtree → x → right subtree', indicating that in an inorder traversal, the left subtree is visited first, followed by the root node 'X', and finally the right subtree.

To build the sorted list of values using inorder traversal, we can design a recursive function. When called on the root node, it returns a sorted list of all the values in the BST.

When the function is called for any node during the recursive process, it constructs a sorted list of the values in the subtree rooting from that node. This is achieved by first obtaining the sorted values from its left subtree, then adding the current node's value, and finally appending the sorted values from its right subtree. This process is carried out through recursive calls to the left and right children.

Once we have the full list of sorted values, we can simply return the value at the (k - 1)th index to get the kth smallest value.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Recursive

from ds import TreeNode
    
def kth_smallest_number_in_BST_recursive(root: TreeNode, k: int) -> int:
    sorted_list = inorder(root)
    return sorted_list[k - 1]
    
# Inorder traversal function to attain a sorted list of nodes from the BST.
def inorder(node: TreeNode) -> List[int]:
    if not node:
        return []
    return inorder(node.left) + [node.val] + inorder(node.right)
function kthSmallestNumberInBSTRecursive(root, k) {
  const sortedList = inorder(root);
  return sortedList[k - 1];
}

// Inorder traversal function to attain a sorted list of nodes from the BST.
function inorder(node) {
  if (!node) {
    return [];
  }
  return [...inorder(node.left), node.val, ...inorder(node.right)];
}
public class Solution {
    public int kthSmallestNumberInBSTRecursive(TreeNode root, int k) {
        List<Integer> sortedList = inorder(root);
        return sortedList.get(k - 1);
    }

    // Inorder traversal function to attain a sorted list of nodes from the BST.
    private List<Integer> inorder(TreeNode node) {
        if (node == null) {
            return new ArrayList<>();
        }
        List<Integer> result = new ArrayList<>();
        result.addAll(inorder(node.left));
        result.add(node.val);
        result.addAll(inorder(node.right));
        return result;
    }
}

Complexity Analysis

Time complexity: The time complexity of kth_smallest_number_in_BST_recursive is O(n)O(n), where nn denotes the number of nodes in the tree. This is because we need to traverse through all nn nodes of the tree to attain the sorted list.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by sorted_list, as well as the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is nn.

Intuition - Iterative

Since we only need the kth smallest value, storing all n values in a list might not be necessary. Ideally, we’d like to find a way to traverse through k nodes instead of n. How can we modify our approach to achieve this?

If we had a way to stop inorder traversal once we've reached the kth node in the traversal, we would land on our answer. An iterative approach would allow for this since we’d be able to exit traversal once we’ve reached the kth node — something that’s quite difficult to achieve using recursion.

We know inorder traversal is a DFS algorithm and that DFS algorithms can be implemented iteratively using a stack. Let’s explore this idea further.


Consider what happened during recursive inorder traversal in the previous approach:

  • Make a recursive call to the left subtree.
  • Process the current node.
  • Make a recursive call to the right subtree.

Let’s replicate the above steps iteratively using a stack.

  1. Move as far left as possible, adding each node to the stack as we move left.
  • We do this because, at the start of each recursive call in the recursive approach, a new call is made to the current node’s left subtree, continuing until the base case (a null node) is reached. This implies that to mimic this process iteratively, we’ll need to move as far left as possible.

  • The reason we push nodes onto the stack as we go is so they can be processed later.

    The code snippet for this can be seen below:

    while node:
        stack.append(node)
        node = node.left
    while (node) {
      stack.push(node);
      node = node.left;
    }
    while (node != null) {
        stack.push(node);
        node = node.left;
    }
  1. Once we can no longer move left, we pop the node off the top of the stack. Let's call it the current node. Initially, this node will represent the smallest node. After this, the current node will subsequently represent the next smallest node, and so on until we reach the kth smallest node.

    • Decrement k, indicating that we now have one less node to visit until we reach the kth smallest node.

    • Once k == 0, we found the kth smallest node. Return the value of this node.

  2. Move to the current node’s right child.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Iterative

def kth_smallest_number_in_BST_iterative(root: TreeNode, k: int) -> int:
    stack = []
    node = root
    while stack or node:
        # Move to the leftmost node and add nodes to the stack as we go so they
        # can be processed in future iterations.
        while node:
            stack.append(node)
            node = node.left
        # Pop the top node from the stack to process it, and decrement 'k'.
        node = stack.pop()
        k -= 1
        # If we have processed 'k' nodes, return the value of the 'k'th smallest
        # node.
        if k == 0:
            return node.val
        # Move to the right subtree.
        node = node.right
function kthSmallestNumberInBSTIterative(root, k) {
  const stack = [];
  let node = root;
  while (stack.length > 0 || node) {
    // Move to the leftmost node and add nodes to the stack as we go so they
    // can be processed in future iterations.
    while (node) {
      stack.push(node);
      node = node.left;
    }
    // Pop the top node from the stack to process it, and decrement 'k'.
    node = stack.pop();
    k--;
    // If we have processed 'k' nodes, return the value of the 'k'th smallest
    // node.
    if (k === 0) {
      return node.val;
    }
    // Move to the right subtree.
    node = node.right;
  }
}
public class Solution {
    public int kthSmallestNumberInBSTIterative(TreeNode root, int k) {
        Deque<TreeNode> stack = new ArrayDeque<>();
        TreeNode node = root;
        while (!stack.isEmpty() || node != null) {
            // Move to the leftmost node and add nodes to the stack as we go so they
            // can be processed in future iterations.
            while (node != null) {
                stack.push(node);
                node = node.left;
            }
            // Pop the top node from the stack to process it, and decrement 'k'.
            node = stack.pop();
            k--;
            // If we have processed 'k' nodes, return the value of the 'k'th smallest
            // node.
            if (k == 0) {
                return node.val;
            }
            // Move to the right subtree.
            node = node.right;
        }
        return -1;
    }
}

Complexity Analysis

Time complexity: The time complexity of kth_smallest_number_in_BST_iterative is O(k+h)O(k+h), where hh denotes the height of the tree. Here’s why:

  • Iterative inorder traversal ensures we traverse kk nodes, which takes at least O(k)O(k) time.

  • Additionally, traversing to the leftmost node takes up to O(h)O(h) time, which should be considered separately since it’s possible that hh > kk. In the worst case, the height of the tree is nn, resulting in a time complexity of O(n)O(n), where nn denotes the number of nodes in the tree.

Space complexity: The space complexity is O(h)O(h) since the stack can store up to O(h)O(h) during the traversal to the leftmost node. In the worst case, the height of the tree is nn, resulting in a space complexity of O(n)O(n).

CHAPTER 70

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.

Image represents a serialization and deserialization process of a tree data structure.  The image shows two identical binary trees, each with a root node labeled '5'.  The left subtree of the root contains nodes '9', '2', and '11', where '2' is a child of '9', and '11' is a child of '2'. The right subtree contains nodes '3', '4', '7', and '6', where '4' and '7' are children of '3', and '6' is a child of '4'.  An arrow labeled `serialize` points from the first tree to the string `serialized_string`, representing the process of converting the tree into a string representation. Another arrow labeled `deserialize` points from `serialized_string` to the second tree, illustrating the reconstruction of the tree from its string representation.  Both trees are structurally identical, demonstrating the successful serialization and deserialization of the tree data.

Intuition

The primary challenge of this problem lies in how we serialize the tree into a string since this will determine if it’s possible to reconstruct the tree using this string alone.

Let's first decide on a traversal strategy because the method we use to serialize the tree will impact how we deserialize the string. Two options:

  • Use BFS to serialize the tree level by level.

  • Use DFS. In this case, we’d need to choose between inorder, preorder, and postorder traversal.

There’s flexibility in choosing the traversal algorithm because serializing with a specific traversal method allows us to rebuild (deserialize) the tree using the same traversal algorithm.

Serialization
An important piece of information needed in our serialized string is the node values. In addition, we’ll need to ensure we can identify the root node's value, since this is the first node to create when we deserialize the string.

As such, a traversal algorithm like preorder traversal is a good choice because it processes the root node first, then the left subtree, and finally the right subtree. This ensures the first value in our serialized string is the root node's value.

So, let’s try serializing the following binary tree using preorder traversal, separating each node with a comma:

Image represents a binary tree on the left, undergoing a preorder serialization process, resulting in a serialized string on the right. The tree's root node, labeled '5', connects to two child nodes: '9' on the left and '3' on the right. Node '9' further connects to '2', which in turn connects to '11'. Node '3' connects to '4' and '7'. Node '4' connects to '6'.  A horizontal arrow labeled 'preorder serialization' points from the tree to a string '5,9,2,11,3,4,6,7', representing the preorder traversal of the tree (root, left subtree, right subtree).  Each comma-separated number in the string corresponds to a node visited in the preorder traversal sequence.

The issue with this serialization is that it doesn't guarantee we can reconstruct the exact original tree from its serialized string representation. This is because the string could represent multiple different trees, which means preorder deserialization could result in the creation of an invalid tree:

Image represents three different binary search trees, each rooted at the number 5, generated from the input sequence '5,9,2,11,3,4,6,7'.  A curved gray arrow points from the input sequence to each of the three root nodes (5), each marked with a red 'X'. Each tree visually depicts a different possible structure resulting from variations in the order of insertion of the numbers into the tree.  The numbers in the sequence are arranged as nodes in the trees, with each node having at most two children (left and right).  The left child is always smaller than the parent node, and the right child is always larger.  The differences between the three trees illustrate the non-uniqueness of binary search trees constructed from the same input sequence, depending on the insertion order.

This is because the string is missing crucial information: null child nodes. For instance, after placing the root node with a value of 5 in the above example, we don't yet know where to place the node of value 9, which is the next value in the string. That is, we can't determine whether node 9 should be the left or right child of node 5:

Image represents two simple tree structures separated by the word 'OR'.  Both trees have a root node containing the number '5'. The left tree has a single child node connected to the root with a solid black line; this child node contains the number '9'. The right tree also has a single child node containing '9', connected to the root with a solid black line.  Between the two trees, and below the root nodes, are two dashed-line circles, each containing the text 'null' in light gray.  The left tree's root node is connected to one of the 'null' nodes with a light gray line, and the right tree's root node is connected to the other 'null' node with a light gray line. The overall image likely illustrates a concept of optional or alternative branches in a data structure, where the 'null' nodes represent the absence of a connection or a specific value.

If the string indicates where the null child nodes are, we can correctly deserialize the tree because we have a complete representation of the tree’s structure. Let's use the character '#' to represent a null node:

Image represents a binary tree undergoing preorder serialization.  The tree's root node, labeled '5', connects to two child nodes: '9' on the left and '3' on the right. Node '9' further connects to '2' (left) and a null node (right), while '2' connects to '11' (left) and another null node (right).  '11' has two null children. Node '3' connects to '4' (left) and '7' (right). Node '4' connects to '6' (right) and a null node (left), and '6' has two null children. Node '7' has two null children. All null nodes are represented by circles with the text 'null' inside, displayed in light gray. A solid arrow labeled 'preorder serialization' points from the tree to a string representation: '5,9,2,11,#,#,#,#,3,4,#,6,#,#,7,#,#'. This string represents the tree's preorder traversal, where each node's value is followed by '#' representing null children.  The order reflects the preorder traversal algorithm: root, left subtree, right subtree, recursively.

Deserialization
To deserialize a string created with preorder traversal, we also need to use preorder traversal to reconstruct the tree.

The first step is to split the string using the comma delimiter, so each node value and ‘#’ is in a list:

Image represents a data transformation process illustrating the 'split' operation.  At the top, a string '5,9,2,11,#,#,#,#,3,4,#,6,#,#,7,#,#' is shown.  A downward-pointing arrow labeled 'split' connects this string to a list-like structure below. This list contains the same elements as the string, but arranged in a vertical array within square brackets `[]`. Each element from the original string is now on a separate line within the list.  The '#' characters, representing null or placeholder values, maintain their positions relative to the numbers.  The process visually demonstrates how a string of comma-separated values is split into individual elements, effectively transforming the string into a list or array data structure.

The first value in the list is the root node of the tree:

Image represents a simple diagram illustrating a data structure, possibly an array or list.  The word 'root' in orange text is positioned above a downward-pointing arrow, indicating the origin or starting point of the data structure. The arrow points to a bracketed sequence of numbers and hash symbols. The sequence begins with the numbers 5, 9, 2, and 11, followed by several hash symbols (#) representing null or placeholder values, then the numbers 3, 4, 6, and 7, with more hash symbols filling the remaining spaces within the brackets.  The numbers are presented in a linear fashion, suggesting a sequential arrangement.  There are no explicit connections between individual elements beyond their sequential order within the brackets.  The overall structure suggests a root node pointing to a linear data structure with some empty or unused slots.

Starting from this root value, recursively construct the tree node by node using preorder traversal. Each new node will be created with the next value in the list of preorder values. Whenever we encounter a ‘#’, we return null. The code snippet for this:

# Helper function to construct the tree using preorder traversal.
def build_tree(values: List[str]) -> TreeNode:
    val = next(values)
    # Base case: '#' indicates a null node.
    if val == '#':
        return None
    # Use preorder traversal to create the current node first, then the left and
    # right subtrees.
    node = TreeNode(int(val))
    node.left = build_tree(values)
    node.right = build_tree(values)
    return node
// Helper function to construct the tree using preorder traversal.
function buildTree(values) {
  const val = values.next().value;
  // Base case: '#' indicates a null node.
  if (val === '#') {
    return null;
  }
  // Use preorder traversal to create the current node first, then the left and
  // right subtrees.
  const node = new TreeNode(parseInt(val, 10));
  node.left = buildTree(values);
  node.right = buildTree(values);
  return node;
}
// Helper function to construct the tree using preorder traversal.
public TreeNode buildTree(Iterator<String> values) {
    String val = values.next();
    // Base case: '#' indicates a null node.
    if (val.equals("#")) {
        return null;
    }
    // Use preorder traversal to create the current node first, then the left and
    // right subtrees.
    TreeNode node = new TreeNode(Integer.parseInt(val));
    node.left = buildTree(values);
    node.right = buildTree(values);
    return node;
}

Follow-up: what if you must use a different traversal algorithm?
It’s possible to serialize and deserialize a binary tree using other traversal algorithms than preorder traversal. Let’s explore some alternatives.

Postorder Traversal:‾\underline{\text{Postorder Traversal:}}

When we serialize the tree using postorder traversal, we get the following string (ignoring the null nodes in this discussion to focus on the node values and their order):

Image represents a binary tree on the left, undergoing a postorder serialization process, resulting in a serialized string on the right. The tree's root node, labeled '5', connects to two child nodes: '9' on the left and '3' on the right. Node '9' further branches into '2' and '11', while node '3' connects to '4' and '7'. Node '4' has a child node '6', and node '7' has no children.  A horizontal arrow labeled 'postorder serialization' points from the tree to the resulting string '11,2,9,4,6,7,3,5'.  The string represents the postorder traversal of the tree (left subtree, right subtree, root), with each node's value separated by commas. An orange arrow points to the '5' in the string, labeled 'root', indicating its position as the root node in the original tree.

As we see, one big difference is that the root node will be the final value in the string, since postorder traversal processes the left subtree, then the right subtree, and finally the root node.

During deserialization, we’d build the tree by iterating through the node values from right to left instead of left to right, since the root value is at the right. In addition, we’d need to create each node’s right subtree before we create its left subtree, as we go through the string in reverse.

Inorder traversal:‾\underline{\text{Inorder traversal:}}

A lot more care needs to be taken when serializing a tree using inorder traversal. The main reason is that it’s unclear where the root node of the tree is in the string, and where the root node of each subtree is, as we can see below:

Image represents a binary tree on the left undergoing inorder serialization, resulting in a string representation on the right. The tree's root node, labeled '5', connects to two child nodes: '9' on the left and '3' on the right.  Node '9' further branches into '2' and '11', while node '3' connects to '4' and '7'. Node '4' has a child node '6'.  A horizontal arrow labeled 'inorder serialization' points from the tree to a string '11,2,9,5,3,4,6,7'. This string represents the inorder traversal of the tree, meaning it lists the nodes in the order: left subtree, root, right subtree recursively.  A curved orange arrow points to the '5' within the string, labeled 'root', indicating its position as the root node in the original tree structure.

This doesn’t make it impossible to use inorder traversal. It just means significantly more information needs to be provided in the serialized string to make deserialization possible. In particular, we need to include details about which value serves as the root node for each subtree as we iterate through them.

Breadth-first search (BFS):‾\underline{\text{Breadth-first search (BFS):}}

BFS starts processing each node from the root of the tree, and traverses through it level by level, and from left to right. Serializing the tree using BFS gives the following order of values:

Image represents a tree data structure undergoing Breadth-First Search (BFS) serialization.  The left side shows a tree with node 5 as the root. Node 5 connects to nodes 9 and 3. Node 9 connects to node 2, which further connects to node 11. Node 3 connects to nodes 4 and 7, with node 4 connecting to node 6.  A horizontal arrow labeled 'BFS serialization' points from the tree to a string '5,9,3,2,4,7,11,6'. This string represents the serialized form of the tree, generated by traversing the tree level by level from left to right using BFS.  The number 5, highlighted in a peach-colored box, is explicitly labeled as the 'root' with an orange arrow pointing to it within the serialized string.  The entire process demonstrates how a tree structure can be converted into a linear representation using BFS.

Similarly to preorder traversal, we just need to follow the exact traversal order for reconstructing the tree when deserializing the string.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

In the following implementation, we opt for preorder traversal for serialization and deserialization.

from ds import TreeNode
    
def serialize(root: TreeNode) -> str:
    # Perform a preorder traversal to add node values to a list, then convert the
    # list to a string.
    serialized_list = []
    preorder_serialize(root, serialized_list)
    # Convert the list to a string and separate each value using a comma
    # delimiter.
    return ','.join(serialized_list)
    
# Helper function to perform serialization through preorder traversal.
def preorder_serialize(node, serialized_list) -> None:
    # Base case: mark null nodes as '#'.
    if node is None:
        serialized_list.append('#')
        return
    # Preorder traversal processes the current node first, then the left and right
    # children.
    serialized_list.append(str(node.val))
    preorder_serialize(node.left, serialized_list)
    preorder_serialize(node.right, serialized_list)
    
def deserialize(data: str) -> TreeNode:
    # Obtain the node values by splitting the string using the comma delimiter.
    node_values = iter(data.split(','))
    return build_tree(node_values)
    
# Helper function to construct the tree using preorder traversal.
def build_tree(values: List[str]) -> TreeNode:
    val = next(values)
    # Base case: '#' indicates a null node.
    if val == '#':
        return None
    # Use preorder traversal processes the current node first, then the left and
    # right children.
    node = TreeNode(int(val))
    node.left = build_tree(values)
    node.right = build_tree(values)
    return node
function serialize(root) {
  // Perform a preorder traversal to add node values to a list, then convert the
  // list to a string.
  const serializedList = [];
  preorderSerialize(root, serializedList);
  // Convert the list to a string and separate each value using a comma
  // delimiter.
  return serializedList.join(',');
}

// Helper function to perform serialization through preorder traversal.
function preorderSerialize(node, serializedList) {
  // Base case: mark null nodes as '#'.
  if (node === null) {
    serializedList.push('#');
    return;
  }
  // Preorder traversal processes the current node first, then the left and right
  // children.
  serializedList.push(String(node.val));
  preorderSerialize(node.left, serializedList);
  preorderSerialize(node.right, serializedList);
}

function deserialize(data) {
  // Obtain the node values by splitting the string using the comma delimiter.
  const nodeValues = data.split(',')[Symbol.iterator]();
  return buildTree(nodeValues);
}

// Helper function to construct the tree using preorder traversal.
function buildTree(values) {
  const val = values.next().value;
  // Base case: '#' indicates a null node.
  if (val === '#') {
    return null;
  }
  // Use preorder traversal processes the current node first, then the left and
  // right children.
  const node = new TreeNode(parseInt(val, 10));
  node.left = buildTree(values);
  node.right = buildTree(values);
  return node;
}
public class Codec {
    public String serialize(TreeNode root) {
        // Perform a preorder traversal to add node values to a list, then convert the
        // list to a string.
        List<String> serializedList = new ArrayList<>();
        preorderSerialize(root, serializedList);
        // Convert the list to a string and separate each value using a comma
        // delimiter.
        return String.join(",", serializedList);
    }

    // Helper function to perform serialization through preorder traversal.
    private void preorderSerialize(TreeNode node, List<String> serializedList) {
        // Base case: mark null nodes as '#'.
        if (node == null) {
            serializedList.add("#");
            return;
        }
        // Preorder traversal processes the current node first, then the left and right
        // children.
        serializedList.add(String.valueOf(node.val));
        preorderSerialize(node.left, serializedList);
        preorderSerialize(node.right, serializedList);
    }

    public TreeNode deserialize(String data) {
        // Obtain the node values by splitting the string using the comma delimiter.
        Iterator<String> nodeValues = Arrays.asList(data.split(",")).iterator();
        return buildTree(nodeValues);
    }

    // Helper function to construct the tree using preorder traversal.
    private TreeNode buildTree(Iterator<String> values) {
        String val = values.next();
        // Base case: '#' indicates a null node.
        if (val.equals("#")) {
            return null;
        }
        // Use preorder traversal processes the current node first, then the left and
        // right children.
        TreeNode node = new TreeNode(Integer.parseInt(val));
        node.left = buildTree(values);
        node.right = buildTree(values);
        return node;
    }
}

Complexity Analysis

Time complexity: The time complexity of both serialize and deserialize is O(n)O(n), where nn denotes the number of nodes in the tree. This is because we visit each of the nn nodes in the binary tree exactly once during preorder traversal. The serialize function additionally converts the serialized list to a string, which also takes O(n)O(n) time.

Space complexity: The space complexity of both serialize and deserialize is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as the height of the binary tree. The largest possible height of a binary tree is 𝑛.

CHAPTER 71

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.

To understand how a trie works, consider the string "internet". We can store this string as a sequence of connected nodes, where each node represents a character in the string:

Image represents a directed graph illustrating a sequence of data transformations or processing steps.  The graph consists of eight circular nodes, each containing a single lowercase letter: 'i', 'n', 't', 'e', 'r', 'n', 'e', and 't'.  These nodes are connected by directed edges (arrows) indicating the flow of data. The data flows unidirectionally from 'i' to 'n', then to 't', then to 'e', then to 'r', then back to 'n', then to 'e', and finally to 't'.  Each letter likely represents a stage in a process, with the arrows showing the order of execution or data transformation from one stage to the next.  The repetition of 'n' and 'e' suggests potential loops or iterative steps within the overall process. The 'i' likely represents the initial input, and 't' the final output.

Now, let's say we also want to store the word "interface". Instead of creating a separate sequence of nodes, we take advantage of their common prefix "inter". Both words can share the nodes for "inter", and the remainder of "interface" can branch out from the node 'r':

Image represents a directed acyclic graph illustrating a sequence of data processing or workflow.  The graph consists of several circular nodes, each containing a single lowercase letter, representing stages or steps in the process.  Arrows indicate the direction of data flow between these stages. The sequence begins with node 'i' which flows into 'n', then 't', and finally 'e'.  From 'e', the flow diverges. One branch connects to node 'r', which then splits into two further branches. The upper branch leads to 'n', then 'e', and finally 't'. The lower branch from 'r' connects to 'f', then 'a', 'c', and finally 'e'.  Therefore, the graph shows two distinct parallel processing paths originating from 'r', both ultimately contributing to the final 'e' node, while the initial sequence 'i', 'n', 't', 'e' acts as a precursor to the branching point at 'r'.

This is, in essence, how a trie works: by allowing words to reuse existing nodes based on shared prefixes, it efficiently stores strings in a way that minimizes redundancy.

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.

Image represents a tree-like data structure visualization alongside a tabular representation of its children.  The left side shows a tree with a central, empty, circular node connected to three child nodes labeled 'a', 'b', and 'c' respectively, each depicted as a filled, orange-toned circle. An arrow points from a rectangular box labeled 'node' to the central node, suggesting that this box represents the parent node. The right side displays a table titled 'node.children,' with two columns: 'char' listing characters 'a', 'b', and 'c', and 'child node' visually mirroring the child nodes from the tree diagram, using the same orange-toned circles containing the corresponding characters.  The table illustrates the relationship between the parent node and its children, showing that the parent node's children are represented by the characters 'a', 'b', and 'c', each associated with a corresponding child node.

Sometimes, an array is used instead of a hash map if the possible characters of words in the trie are limited and known in advance. For example, if they only contain lowercase English letters, an array of size 26 can be used, instead.

2. End of word indicator: Each TrieNode needs an attribute to indicate whether it marks the end of a word. This can be done in two ways:

  • 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. This is usually used if we also want to know the specific word that ends at this node.

Trie structure
This is an example of a trie that’s been populated by inserting the words "internet", "interface", "byte", "bytes", "dog", and "dig". Every word represented by the trie branches out of a root TrieNode, which does not represent any character.

Image represents a Trie data structure, a tree-like data structure used for storing a dynamic set of strings.  The topmost node is labeled 'root,' and from it branch three main paths. Each path consists of nodes, each node containing a single character.  The paths represent different strings.  For example, one path spells 'inter,' another 'bytes,' and a third contains 'dog' and 'dig.' Dashed lines connect certain leaf nodes (terminal nodes) to labels indicating 'is_word = True,' signifying that the string formed by the path to that node is a valid word in the Trie's vocabulary.  The arrangement shows how the Trie efficiently stores and searches for words by sharing prefixes.  The characters in the nodes are arranged sequentially along each path to form words, and the 'is_word' label indicates whether the word represented by the path is present in the dataset.

Below is the time complexity breakdown for trie operations involving a word of length kk:

OperationComplexityDescription
InsertO(k)O(k)Insert a word into the trie.
SearchO(k)O(k)Search for a word in the trie.
Search prefixO(k)O(k)Check if any word in the trie starts with a given prefix.
DeleteO(k)O(k)Delete a word from the trie

 

The implementation details of most of these functions are discussed in detail in the Design a Trie problem.

When to use tries
The primary use of a trie is to handle efficient prefix searches. If an interview problem asks you to find all strings that share a common prefix, a trie is likely the ideal data structure. Tries are also useful for word validation, allowing us to quickly verify the existence of words within a set of strings. They also help optimize data storage by reducing redundancy through shared prefixes among strings.

Real-world Example

Autocomplete: When you start typing a word, some systems use a trie to quickly suggest possible completions. Each node in the trie represents a character, and as you type, the system traverses the trie based on the input characters. This allows the system to efficiently retrieve all possible words or phrases that start with the entered prefix, enabling fast autocomplete suggestions.

Chapter Outline

Image represents a hierarchical diagram illustrating the concept of Tries in coding patterns.  At the top, a rounded rectangle labeled 'Tries' acts as the parent node.  From this node, two dashed lines descend, each pointing to a separate child node represented by rounded rectangles with a light gray fill. The left child node is labeled 'Implementation' and contains two bullet points: 'Design a Trie' and 'Insert and Search Words with Wildcards'. The right child node is labeled 'Traversal' and contains a single bullet point: 'Find All Words on a Board'.  The diagram visually depicts that the concept of Tries branches into two main aspects:  the implementation details (designing and using a Trie data structure for insertion and search, including handling wildcards) and the traversal aspect (specifically, using Tries to find all words on a board, suggesting a word search application).
CHAPTER 72

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.

Example:

Example
Input: [
  insert('top'),
  insert('bye'),
  has_prefix('to'),
  search('to'),
  insert('to'),
  search('to')
]
Output: [True, False, True]

Explanation:

Example
insert("top")    # trie has: "top"
insert("bye")    # trie has: "top" and "bye"
has_prefix("to") # prefix "to" exists in the string "top": return True
search("to")     # trie does not contain the word "to": return False
insert("to")     # trie has: "top", "bye", and "to"
search("to")     # trie contains the word "to": return True

Constraints:

  • The words and prefixes consist only of lowercase English letters.

  • The length of each word and prefix is at least one character.

Intuition

Let’s define a TrieNode using the same definition introduced in the introduction. In this implementation, the is_word attribute will be used to indicate whether a TrieNode marks the end of a word.

Initializing the trie
To initialize the Trie, we define the root TrieNode in the constructor. All words inserted into the trie will branch out from this root node.

Inserting a word into the trie
The insert function builds the trie word by word. What makes a trie useful is that it reduces redundancy by reusing existing nodes when possible. For example, if we insert “byte” when “bye” already exists in the trie, these two words should share the nodes that make up the prefix “by” to save space. This is an important point that will shape our implementation of this function.


To understand the insertion logic, let's walk through an example. Consider inserting the word "byte" into the trie below.

Image represents a Trie data structure, a tree-like data structure used for storing strings.  The topmost node, colored light gray, is labeled 'root' and serves as the root of the Trie.  From the root, two branches extend downwards. The left branch leads to a node containing the letter 't', connected to a node with 'o', which is further connected to a node with 'p'.  The right branch leads to a node containing the letter 'b', connected to a node with 'y', which is further connected to a node with 'e'.  Each node representing a letter is a black circle. Dashed lines extend from the 'o', 'p', and 'e' nodes, each labeled 'is_word = True,' indicating that 'top' and 'bye' are valid words stored in this Trie. The connections between nodes represent the sequential arrangement of letters within words.  The structure visually demonstrates how the Trie efficiently stores and searches for words by traversing paths from the root.

We first check if 'b', the first letter of the string, exists as a child of the root node by querying the hash map containing its children. In this case, it does. So, move to node 'b':

Image represents a Trie data structure illustrating the insertion of the word 'byte'.  The diagram shows a root node labeled 'root' (light gray) connected to two child nodes: 't' (black) and 'b' (orange).  The 't' node branches down to 'o' and then 'p', each labeled with 'is_word = True', indicating these letter combinations are valid words. The 'b' node connects to 'y' and then 'e', also labeled 'is_word = True'.  A dashed box to the right explains the insertion process:  ' 'b' is a child of the current node' and the code snippet `node = node.children['b']` shows how the current node is updated to point to the 'b' node during insertion.  A rectangular box labeled 'node' points to the root node initially, indicating the starting point of the insertion process.  The lines connecting the nodes represent parent-child relationships within the Trie.

Now consider the second letter, ‘y’. Similarly, node 'y' exists as a child of node 'b', so let's move to node 'y':

Image represents a tree-like data structure illustrating a coding pattern.  The topmost node, labeled 'root' in light gray, branches into two nodes: 't' and 'b'.  The 't' node connects downwards to 'o', which further connects to 'p'.  Dashed lines connect 'o' and 'p' to the text 'is_word = True', indicating a property associated with these nodes.  Similarly, the 'b' node connects to a node 'y' highlighted in orange, which then connects to 'e'.  'e' also has a dashed line connecting it to 'is_word = True'. A rectangular box to the right shows the code snippet `'y' is a child of the current node  => node = node.children['y']`, explaining how to access the child node 'y' from a parent node (represented by the variable 'node').  A smaller rectangular box labeled 'node' points to the 'b' node, illustrating the current node in the example.  The overall structure depicts a Trie or prefix tree, commonly used in data structures and algorithms, particularly for efficient string searching.

Now consider the next letter, ‘t’. Since node 't' doesn't exist as a child of node 'y', we need to create it and add it to node 'y’s children hash map. Then, we can move to this newly created node 't':

Image represents a Trie data structure visualization alongside code snippets illustrating its modification.  The Trie is depicted as a tree with a light-grey root node labeled 'root'.  Two branches extend from the root, leading to nodes labeled 't' and 'b' respectively. The 't' node connects to an 'o' node, which in turn connects to a 'p' node.  Both 'o' and 'p' nodes have dashed lines indicating a boolean attribute 'is_word = True'. The 'b' node connects to a 'y' node, which further connects to an 'e' node. The 'e' node also has a dashed line indicating 'is_word = True'. A dashed line connects the 'y' node to a new, orange-colored 't' node, representing a node being added. A rectangular box to the right displays code comments explaining that 't' is not a child of the current node ('y'), followed by two lines of code: `node.children['t'] = TrieNode()` which creates a new TrieNode for 't', and `node = node.children['t']` which updates the current node pointer to the newly created 't' node.  A separate rectangular box labeled 'node' points to the 'y' node, indicating the current node before the addition of the 't' node.

The last letter is ‘e’, which doesn’t exist as a child of node ‘t’. So, let’s create node ‘e’ and add it to node ‘t’s children:

Image represents a Trie data structure illustrating the insertion of a new node.  The Trie is visualized as a tree with a light gray root node labeled 'root'.  From the root, two branches extend to nodes labeled 't' and 'b'. The 't' node connects to an 'o' node, which in turn connects to a 'p' node.  Both 'o' and 'p' nodes have a dashed line and label 'is_word = True', indicating they represent complete words. The 'b' node connects to a 'y' node, which further branches to 'e' and 't' nodes. The 'e' node has 'is_word = True'. A dashed line connects the 't' node to a new, orange-colored 'e' node, labeled 'node', signifying the addition of this node. A separate box explains the process:  ''e' is not a child of the current node' followed by two lines of code: '→ node.children['e'] = TrieNode()' and '→ <font color='orange'>node</font> = node.children['e']', showing how a new TrieNode is created and assigned to the 'e' child of the current node.  The arrows indicate the flow of information and the creation of a new node within the Trie.

Now that we've reached the end of the word, we should set the is_word attribute of node 'e' to true, indicating that it marks the end of a word.

Image represents a Trie data structure, a tree-like data structure used for storing strings.  The topmost node is labeled 'root' in gray, and branches down to two nodes, 't' and 'b'.  The 't' node connects to 'o', which connects to 'p'.  Each of 'o' and 'p' has a dashed box next to it indicating `is_word = True`, signifying that 'top' is a valid word in the Trie. Similarly, 'b' connects to 'y', which further branches to 'e' and 't'.  Node 'e' has `is_word = True` indicating 'bye' is a valid word.  Node 't' connects to another 'e' node highlighted in orange, also marked with `is_word = True`, suggesting 'byte' is a valid word. A separate box displays `node.is_word = True`, illustrating a general condition for a node representing a complete word. Finally, a gray box labeled 'node' points to the orange 'e' node, demonstrating a potential traversal or search operation within the Trie.

Searching for a word
Searching for a word involves the same strategy as insertion, where we move node by node down the trie. The two main differences are:

  1. If a node corresponding to the current character in the word isn't found at any point, we return false because this would indicate the word doesn't exist in the trie.

  2. After traversing all characters of the search term, we return true only if the final node's is_word attribute is true.

Image represents a Trie data structure illustrating a search operation for the word 'to'.  The Trie is a tree-like structure where each node represents a character.  The root node, labeled 'root', is connected to a 't' node. This 't' node connects to an 'o' node.  Both 'o' and 'p' nodes have a property `is_word = True` indicating they represent complete words.  A dashed orange line highlights the path traversed during the search for 'to'.  An arrow labeled 'node' points to the 'o' node, showing the starting point of the search.  The 'o' node's `is_word` property is also shown as `True`.  A separate branch from the root node leads to 'b', 'y', 'e', and another 't' node, which further connects to an 'e' node; this 'e' node also has `is_word = True`. A gray box with dashed lines displays the code snippet `return node.is_word`, indicating that the search function returns the boolean value of the `is_word` property of the final node reached.

Searching for a prefix
The logic for finding a prefix is nearly identical to the logic discussed above for the search function. The only difference is after successfully traversing all characters in our search term, we can just return true without checking the final node's is_word attribute, as a prefix doesn’t need to end at the end of a word.

Image represents a Trie data structure illustrating a `has_prefix('by')` function.  The Trie is a tree-like structure where nodes represent characters and edges represent transitions between characters. The root node is labeled 'root' and is enclosed in a dashed orange oval.  A gray line connects the root to a node labeled 't,' which connects to a node labeled 'o,' which in turn connects to a node labeled 'p.'  Dashed lines and the text 'is_word = True' indicate that 'op' and 'p' are valid words in the Trie.  Separately, a gray line connects the root to a node labeled 'b,' which connects to a node labeled 'y.' This 'by' node is also enclosed in a dashed orange oval.  A right-pointing arrow labeled 'node' points to this 'by' node.  From 'y,' two branches extend, one to a node 'e' and another to a node 't,' which further connects to a node 'e.'  The nodes 'e' and 'e' also have dashed lines and 'is_word = True' indicating they are valid words.  A dashed-line box to the upper right displays 'return True,' indicating that the prefix 'by' exists within the Trie.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_word = False
class TrieNode {
  constructor() {
    this.children = {};
    this.isWord = false;
  }
}
class TrieNode {
    Map<Character, TrieNode> children;
    boolean isWord;

    public TrieNode() {
        children = new HashMap<>();
        isWord = false;
    }
}
class Trie:
   def __init__(self):
       self.root = TrieNode()
    
   def insert(self, word: str) -> None:
       node = self.root
       for c in word:
           # For each character in the word, if it’s not a child of the current node,
           # create a new TrieNode for that character.
           if c not in node.children:
               node.children[c] = TrieNode()
           node = node.children[c]
       # Mark the last node as the end of a word.
       node.is_word = True
    
   def search(self, word: str) -> bool:
       node = self.root
       for c in word:
           # For each character in the word, if it’s not a child of the current node,
           # the word doesn't exist in the Trie.
           if c not in node.children:
               return False
           node = node.children[c]
       # Return whether the current node is marked as the end of the word.
       return node.is_word
    
   def has_prefix(self, prefix: str) -> bool:
       node = self.root
       for c in prefix:
           if c not in node.children:
               return False
           node = node.children[c]
       # Once we’ve traversed the nodes corresponding to each character in the
       # prefix, return True.
       return True
class Trie {
  constructor() {
    this.root = new TrieNode();
  }

  insert(word) {
    let node = this.root;
    for (const c of word) {
      // For each character in the word, if it’s not a child of the current node,
      // create a new TrieNode for that character.
      if (!node.children[c]) {
        node.children[c] = new TrieNode();
      }
      node = node.children[c];
    }
    // Mark the last node as the end of a word.
    node.isWord = true;
  }

  search(word) {
    let node = this.root;
    for (const c of word) {
      // For each character in the word, if it’s not a child of the current node,
      // the word doesn't exist in the Trie.
      if (!node.children[c]) {
        return false;
      }
      node = node.children[c];
    }
    // Return whether the current node is marked as the end of the word.
    return node.isWord;
  }

  hasPrefix(prefix) {
    let node = this.root;
    for (const c of prefix) {
      if (!node.children[c]) {
        return false;
      }
      node = node.children[c];
    }
    // Once we’ve traversed the nodes corresponding to each character in the
    // prefix, return True.
    return true;
  }
}
class Trie {
    private TrieNode root;

    public Trie() {
        this.root = new TrieNode();
    }

    public void insert(String word) {
        TrieNode node = this.root;
        for (char c : word.toCharArray()) {
            // For each character in the word, if it’s not a child of the current node,
            // create a new TrieNode for that character.
            if (!node.children.containsKey(c)) {
                node.children.put(c, new TrieNode());
            }
            node = node.children.get(c);
        }
        // Mark the last node as the end of a word.
        node.isWord = true;
    }

    public boolean search(String word) {
        TrieNode node = this.root;
        for (char c : word.toCharArray()) {
            // For each character in the word, if it’s not a child of the current node,
            // the word doesn't exist in the Trie.
            if (!node.children.containsKey(c)) {
                return false;
            }
            node = node.children.get(c);
        }
        // Return whether the current node is marked as the end of the word.
        return node.isWord;
    }

    public boolean hasPrefix(String prefix) {
        TrieNode node = this.root;
        for (char c : prefix.toCharArray()) {
            if (!node.children.containsKey(c)) {
                return false;
            }
            node = node.children.get(c);
        }
        // Once we’ve traversed the nodes corresponding to each character in the
        // prefix, return True.
        return true;
    }
}

Complexity Analysis

Time complexity:

  • The time complexity of insert is O(k)O(k), where kk is the length of the word being inserted. This is because we traverse through or insert up to kk nodes into the trie in each iteration.
  • The time complexity of search and has_prefix is O(k)O(k) because we search through at most kk characters in the trie.

Space complexity:

  • The space complexity of insert is O(k)O(k) because in the worst case, the inserted word doesn’t share any prefix with words already in the trie. In this case, kk new nodes are created.
  • The space complexity of search and has_prefix is O(1)O(1) because no additional space is used to traverse the search term in the trie.
CHAPTER 73

Insert and Search Words with Wildcards

Design and implement a data structure that supports the following 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.

Example:

Example
Input: [
  insert('band'),
  insert('rat'),
  search('ra.'),
  search('b..'),
  insert('ran'),
  search('.an')
]
Output: [True, False, True]

Explanation:

Example
insert("band") # data structure has: "band"
insert("rat")   # data structure has: "band" and "rat"
search("ra.")   # "ra." matches "rat": return True
search("b..")   # no three-letter word starting with ‘b' in the
               # data structure: return False
insert("ran")  # data structure has: "band", "rat", and "ran"
search(".an")  # ".an" matches "ran": return True

Constraints:

  • Words will only contain lowercase English letters and '.' characters.

Intuition

The requirements of this data structure closely resemble those of a trie, as it needs to facilitate the insertion and search of words. What makes this problem unique is the requirement to support wildcards ('.') in searches. Let’s learn how we would need to modify our trie functions to support wildcards.

Inserting a word into the trie
The requirements for insertion in this problem match the requirements in a traditional trie. So, let’s use the same implementation of insert as in the Design a Trie problem.

Searching with wildcards
What does it mean to encounter a wildcard? Consider the following trie:

Image represents a Trie data structure, a tree-like data structure used for storing strings.  The topmost node is labeled 'root' in light gray, representing the root of the Trie.  From the root, two branches extend downwards. The left branch connects to a node labeled 'b', which connects to 'a', which further connects to 'n', and finally to 'd'.  A dashed line and the text 'is_word = True' are associated with the 'd' node, indicating that 'band' is a valid word in the Trie. The right branch from the root connects to a node labeled 'r', which connects to 'a'.  From 'a', two branches extend to nodes labeled 't' and 'n'.  Both 't' and 'n' have dashed lines and the text 'is_word = True' associated with them, indicating that 'rant' and 'ran' are valid words.  The connections between nodes represent prefixes of words, with each node representing a character.  The 'is_word' flag indicates whether a path from the root to that node forms a complete valid word.

If we perform a search for "ra.", we know we can traverse down nodes 'r' and 'a' until we reach the wildcard character. Since the last letter is a wildcard, it could represent any letter. So, as long as there exists a node branching out from 'a' that represents the end of a word (i.e., has is_word == True), the word "ra." exists in the trie. In this case, both nodes 't' and 'n' meet the requirements of this wildcard:

Image represents a Trie data structure illustrating a search operation for the string 'ra.'.  The topmost node is labeled 'root' (in gray) and serves as the starting point.  A gray arrow points from the root to a green node labeled 'r', indicating the search begins with 'r'.  From 'r', a downward gray arrow points to another green node 'a', showing the search progresses to 'a'.  The node 'a' has two children: green nodes 't' and 'n'.  Dashed lines connect 't' and 'n' to labels 'is_word = True', indicating that both 't' and 'n' are valid words in the Trie.  Separately, a branch from the root node leads to a chain of nodes labeled 'b', 'a', 'n', and 'd'.  A dashed line connects 'd' to the label 'is_word = True', signifying 'd' as a valid word. The search for 'ra.' stops at the 'a' node because there is no subsequent '.' node.  The overall structure shows how the Trie efficiently searches for prefixes and words.

Now, let’s say we perform a search for “.an”. Since the first character is a wildcard, we need to cover all branches starting from every node representing the first character in the trie. This means starting a search from each child node of the root. We can do this by recursively calling the search function on these nodes, passing in the substring “an” because this substring contains the remaining characters to be searched for.

Image represents a Trie data structure visualizing a search for the string '.an'.  A grey circle labeled 'root' sits at the top, branching down with two grey arrows to green circles labeled 'b' and 'r'.  The 'b' branch further descends with a downward arrow to a green 'a', then to a yellow 'n', and finally to a black 'd'.  The 'r' branch leads to a green 'a', which then branches to a red 't' (marked with an 'x' indicating failure) and a green 'n'.  Dashed lines connect the leaf nodes ('d', 't', and 'n') to text indicating whether they represent complete words: 'd' shows 'is_word = True', 't' shows 'is_word = True', and 'n' shows 'is_word = True' for the rightmost 'n' and 'is_word = False' for the 'n' under 'a' and 'b'.  The arrows depict the traversal path during the search for '.an', showing the algorithm's progression through the Trie to find matching words.

Notice the nodes forming the string "ban" will not satisfy the search term because node 'n' does not mark the end of a word.


So, at any point in the search, we need to handle two scenarios:

  1. When we encounter a letter, we proceed to the child of the current node that corresponds with this letter in the trie.

  2. When we encounter a wildcard, we explore all child nodes, as the '.' may match any character. We can perform a recursive call for each child node to search for the remainder of the word.

This strategy for handling wildcards allows us to search every possible branch for a word that matches the search term. As soon as we find one branch that represents a word matching the search term, we return true.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

In this implementation, we use a helper function (search_helper) for searching because we need to pass in two extra parameters at each recursive call:

  1. An index that defines the start of the remaining substring that needs to be searched. We pass in an index instead because passing in the substring itself would necessitate creating that substring, which would take linear time for each recursive call.

  2. The TrieNode we’re starting the search from. This ensures we don’t restart each recursive call from the root node.

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:
       # Start searching from the root of the trie.
       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 a wildcard character is encountered, recursively search for the rest
           # of the word from each child node.
           if c == '.':
               for child in node.children.values():
                   # If a match is found, return true.
                   if self.search_helper(i + 1, word, child):
                       return True
               return False
           elif c in node.children:
               node = node.children[c]
           else:
               return False
       # After processing the last character, return true if we've reached the end
       # of a word.
       return node.is_word
class InsertAndSearchWordsWithWildcards {
  constructor() {
    this.root = new TrieNode();
  }

  insert(word) {
    let node = this.root;
    for (const c of word) {
      if (!node.children[c]) {
        node.children[c] = new TrieNode();
      }
      node = node.children[c];
    }
    node.isWord = true;
  }

  search(word) {
    // Start searching from the root of the trie.
    return this.searchHelper(0, word, this.root);
  }

  searchHelper(wordIndex, word, node) {
    for (let i = wordIndex; i < word.length; i++) {
      const c = word[i];
      // If a wildcard character is encountered, recursively search for the rest
      // of the word from each child node.
      if (c === '.') {
        for (const child of Object.values(node.children)) {
          // If a match is found, return true.
          if (this.searchHelper(i + 1, word, child)) {
            return true;
          }
        }
        return false;
      } else if (node.children[c]) {
        node = node.children[c];
      } else {
        return false;
      }
    }
    // After processing the last character, return true if we've reached the end
    // of a word.
    return node.isWord;
  }
}
class InsertAndSearchWordsWithWildcards {
    private TrieNode root;

    public InsertAndSearchWordsWithWildcards() {
        this.root = new TrieNode();
    }

    public void insert(String word) {
        TrieNode node = this.root;
        for (char c : word.toCharArray()) {
            if (!node.children.containsKey(c)) {
                node.children.put(c, new TrieNode());
            }
            node = node.children.get(c);
        }
        node.isWord = true;
    }

    public boolean search(String word) {
        // Start searching from the root of the trie.
        return searchHelper(0, word, this.root);
    }

    private boolean searchHelper(int wordIndex, String word, TrieNode node) {
        for (int i = wordIndex; i < word.length(); i++) {
            char c = word.charAt(i);
            // If a wildcard character is encountered, recursively search for the rest
            // of the word from each child node.
            if (c == '.') {
                for (TrieNode child : node.children.values()) {
                    // If a match is found, return true.
                    if (searchHelper(i + 1, word, child)) {
                        return true;
                    }
                }
                return false;
            } else if (node.children.containsKey(c)) {
                node = node.children.get(c);
            } else {
                return false;
            }
        }
        // After processing the last character, return true if we've reached the end
        // of a word.
        return node.isWord;
    }
}

Complexity Analysis

Time complexity:

  • The time complexity of insert is O(k)O(k), where kk denotes the length of the word being inserted. This is because we traverse through or insert up to kk nodes into the trie in each iteration.
  • The time complexity of search is:
    • O(k)O(k) when word contains no wildcards because we search through at most kk characters in the trie.
    • O(26k)O(26k) in the worst case, when word contains only wildcards. For each wildcard, we potentially need to explore up to 26 different characters (one for each lowercase English letter). With kk wildcards, approximately 26k26k recursive calls are made.

Space complexity:

  • The space complexity of insert is O(k)O(k) because in the worst case, the inserted word doesn’t share a prefix with words already in the trie. In this case, kk new nodes are created.
  • The space complexity of search is:
    • O(1)O(1) when word contains no wildcards.
    • O(k)O(k) in the worst case when word contains only wildcards due to the space taken up by the recursive call stack, which can grow up to kk in size.
CHAPTER 74

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. Adjacent cells are those which horizontally or vertically neighbor each other. We can't use the same cell more than once for a single word.

Example:

Image represents a step-by-step visualization of a pathfinding algorithm, possibly within a 2D array or grid.  Four 3x3 grids are shown, each representing a stage in the algorithm. Each grid contains letters ('b', 'y', 's', 'r', 't', 'e', 'a', 'i', 'n') arranged in a matrix.  A peach-colored background highlights a subset of cells in each grid. A black dot indicates the starting position, and thick black arrows illustrate the path taken by the algorithm. The path progresses through the grids, changing direction (right, up, down, left) in each step, traversing the highlighted cells. The final grid shows the algorithm completing its path, ending on the letter 'n'.  The algorithm appears to be searching for a specific sequence or pattern within the grid, moving cell by cell based on some underlying logic not explicitly shown in the image.
Example
Input: board = [['b', 'y', 's'], ['r', 't', 'e'], ['a', 'i', 'n']],
       words = ['byte', 'bytes', 'rat', 'rain', 'trait', 'train']
Output: ['byte', 'bytes', 'rain', 'train']

Intuition

There are many layers to this problem, so let's start by considering a simpler version where we’re only required to search for one word on the board.

Simplified problem: words array contains one word
With only one word to find, a straightforward approach is to iterate through each cell of the board. If any cell contains the first letter of the word, perform a DFS from that cell in all four directions (left, right, up, down) to find the rest of the word. This process involves backtracking from a cell when we cannot find the next letter of the word in any of its adjacent cells. The process continues until the word is found, or we can no longer find any more letters on the board.

Image represents a 3x4 grid containing the letters 's', 't', 'a', 'r', 'r', 'a', 'v', 'i', 'e', 'm', 'z', and 'f' arranged row-wise.  The top row, 's', 't', 'a', 'r', is highlighted in peach. A thick black arrow originates from a filled black circle on the letter 's' and points to the letter 'r', indicating a horizontal traversal across the highlighted row.  To the right of the grid, a dashed-line box contains the text 'no 'e' in adjacent cells,' suggesting that the algorithm or process represented is searching for the letter 'e' in horizontally or vertically adjacent cells to the highlighted sequence 'stare,' and in this case, it has not found any. The text 'word = 'stare'' above the grid indicates that the word 'stare' is being processed or searched for within the grid.
Image represents a visual depiction of a backtracking algorithm.  A 3x4 grid displays letters; 's', 't', 'a', 'r' in the top row, 'r', 'a', 'v', 'i' in the second, and 'e', 'm', 'z', 'f' in the bottom.  A dark filled circle marks the starting point 's' in the top-left cell. A thick black arrow points right from 's' to 't' and then to 'a', highlighting the path taken by the algorithm. The cells containing 's', 't', and 'a' are shaded light peach, indicating the current path.  To the right of the grid, a dashed-line box displays the text 'no 'r' in adjacent cells,' explaining the reason for backtracking; the algorithm has reached 'a' and cannot find an adjacent 'r' to continue the search. The arrow labeled 'backtrack:' points to the grid, indicating the direction of the algorithm's progression and the reason for the backtracking step.
Image represents a visual depiction of a backtracking algorithm.  A 4x4 grid displays characters ('s', 't', 'a', 'r', 'r', 'a', 'v', 'i', 'e', 'm', 'z', 'f'). A peach-colored area highlights the cells 's', 't', 'a', 'e'. A black filled circle marks the starting point ('s') in the top-left corner of the highlighted area. A thick black line traces a path from 's' to the right to 't', then down to 'a', and finally down to 'e'.  An arrow at the end of the path points downwards, indicating the direction of traversal. To the right of the grid, a dashed-line rectangle displays the text 'found it!', suggesting that the backtracking algorithm has successfully found a target sequence or pattern within the grid, likely represented by the highlighted path 'star'. The text 'backtrack:' precedes the grid, indicating the algorithm's name.

If you're not familiar with backtracking, it might be useful to review the Backtracking chapter before continuing with this problem.

Original problem - words array contains multiple words
The above approach works well for one word, but repeating this process for every word in the array is quite expensive. Let’s devise a way to make our search more efficient. Consider the following board:

Image represents a 3x3 grid, enclosed by a thick black border.  The grid is composed of nine equally sized cells, each containing a single lowercase letter.  Reading from left to right and top to bottom, the letters are arranged as follows: 'b', 'y', 's'; 'r', 't', 'e'; 'a', 'i', 'n'. There are no connections or arrows depicted between the cells, and no additional information such as URLs or parameters are present. The letters themselves are the only components within the grid, and their arrangement appears arbitrary, without any apparent pattern or relationship between them beyond their spatial positioning within the grid structure.

Let’s say the words array contains the words “byte” and “bytes”. Once we've found "byte", we'd ideally like to extend the search by just one more cell to also find the word "bytes":

Image represents a 3x3 grid, possibly illustrating a pathfinding or state transition algorithm.  The grid is filled with single-letter labels: 'b' in the top-left cell, 'r' below it, 'a' below that; 'y' in the top-middle cell, 't' below it, 'i' below that; and 's' in the top-right cell, 'e' below it, 'n' below that.  The top-right and middle-right cells are shaded light peach. A black filled circle representing a starting point is located at 'b'. A thick black line traces a path from 'b' horizontally to the right, then down two cells, and finally horizontally to the right again, ending just before the 'e' cell.  A thick grey arrow points upwards from 'e' to 's', suggesting a final transition or movement towards 's', which could be a target or end state. The overall structure suggests a visual representation of a sequence of steps or transitions within a defined space, possibly demonstrating a specific coding pattern or algorithm.

However, with our initial algorithm, we’d need to restart the search entirely to find "bytes." This is quite inefficient. What we want is a data structure that allows us to efficiently search words with shared prefixes, allowing us to find multiple words without restarting the search for each of them. This is where the trie data structure comes into play, as it is excellent for managing prefixes.

Let's begin creating the trie by inserting each word from the provided words array:

Image represents a Trie data structure visualizing the storage of the words ['byte', 'rat', 'rain', 'bytes', 'trait', 'train'].  A root node labeled 'root' branches into three main paths. The leftmost path, using dark-colored nodes, spells out 'bytes,' with each node representing a letter (b, y, t, e, s). Dashed lines connect the end nodes to labels indicating the complete words formed ('byte' and 'bytes'). The central path, also using dark-colored nodes, initially spells 'rat' (r, a, t), with a dashed line and label indicating the complete word. This path further extends to 'rain' (r, a, i, n) using a lighter-colored node for 'i' and 'n', again with a dashed line and label. The rightmost path, using dark-colored nodes, spells 'train' (t, r, a, i, n), with the final 'n' node having a dashed line and label indicating the complete word.  The lighter-colored nodes in the 'rain' and 'train' paths indicate shared prefixes with other words in the Trie.  The top line shows the Python list containing all the words stored in the Trie.

In this chapter's introduction, we discussed two options to mark the end of a word in a TrieNode. Here, we use the word attribute instead of is_word to determine if a TrieNode represents the end of a word, and to know which specific word has ended. This will be important later.

Let’s now use this trie to search for words over the board. We do this by seeing if any paths in the trie correspond with any paths on the board:

Image represents a Trie data structure illustrating the insertion of words.  A 3x3 grid on the left shows a path (indicated by a black arrow) tracing the letters 'b', 'y', 't', and 'e' from a starting point. An orange arrow connects this path to a vertical chain of nodes ('b', 'y', 't', 'e', 's') forming a branch of a larger Trie. This branch is highlighted in peach, and the node 's' is labeled with `word = 'bytes'`. The main Trie, rooted at 'root' (light gray), branches into three main paths: one leading to 'r', 'a', 'i', 'n' (`word = 'rain'`), another to 't' (`word = 'trait'`), and a third to 't', 'r', 'a', 'i', 'n' (`word = 'train'`).  The 'rain' branch has a node 'n' with a dashed line indicating the word 'rain'. Similarly, dashed lines connect the nodes 't' and 'n' in the 'trait' and 'train' branches, respectively, to indicate the corresponding words.  The structure demonstrates how prefixes are shared efficiently in a Trie, with nodes representing common letter sequences.
Image represents a Trie data structure illustrating word completion.  A 3x3 grid on the left shows a path highlighted from 'b' to 'e', representing the prefix of a word.  A vertical arrow connects this grid to a linear Trie branch on the right, where nodes 'b', 'y', 't', 'e', and 's' are sequentially connected, forming the word 'bytes'.  This branch is highlighted in peach, and the label 'word = 'bytes'' is placed below it.  The main Trie is a tree structure rooted at 'root' (grey node), branching into three main sub-trees. One sub-tree contains the sequence 'r', 'a', 'i', 'n', with a dashed line connecting the 'n' node to the label 'word = 'rain''. Another sub-tree is 'r', 'a', 'i', further branching into 't' (labeled 'word = 'trait'') and 'n' (labeled 'word = 'train''). The third sub-tree is a single branch 't'.  The linear 'bytes' branch is shown as a subset of the main Trie, demonstrating how a word is represented within the structure.

Similarly to how we used backtracking to search through the board when looking for a single word, we can also use backtracking here. Let’s have a closer look at how this works.

Backtracking using a trie
The first step is similar to what was discussed earlier: we go through the board until we find a cell whose character matches any of the root node's children in the trie, representing the first letter of a word.

As soon as we start going through the board, we notice that the top-left of the board, cell (0, 0), contains the character 'b', which is a child of the trie's root node.

The image represents a visual comparison of a data structure in two different representations. On the left, a 3x3 grid displays the letters 'b', 'y', 's', 'r', 't', 'e', 'a', 'i', 'n'.  The top-left cell containing 'b' is highlighted in a light peach color. To the right, a tree structure is shown.  The root node, labeled 'root' in light gray, connects to three child nodes: 'b' (highlighted in orange), 'r', and 't', all represented as circles.  The 'b' node corresponds to the highlighted 'b' in the grid.  Dashed lines extend downwards from the 'b', 'r', and 't' nodes, suggesting further levels or children that are not explicitly shown. The overall image illustrates how a data set (the grid of letters) can be represented as a tree structure, with the root node potentially representing the starting point or a key element, and the child nodes representing subsequent elements or branches within the data.

We can initiate DFS starting from this cell to see if the board contains the words formed by any of the paths branching from node ‘b’.


Checking through the adjacent cells of cell ‘b’, we notice that one contains ‘y’, which corresponds to a child of node ‘b’. So, let’s make a DFS call to this cell to continue looking for the rest of this trie path on the board.

Image represents a visual explanation of a coding pattern, likely related to tree structures or data traversal.  On the left, a 3x3 matrix is shown, containing the letters 'b', 'r', 'a', 'y', 't', 'i', 's', 'e', 'n'.  A thick arrow points from 'b' to 'y', highlighting these two letters.  To the right, a tree structure is depicted. The top node is labeled 'root', connected to three child nodes: 'b', 'r', and 't'. The 'b' node is further connected to an orange-highlighted node 'y', which is connected to 't', 'e', and finally 's'.  The 'r' and 't' nodes have dashed lines extending downwards, suggesting incomplete branches.  Below the 'e' and 's' nodes, the text 'byte' and 'bytes' respectively are present, possibly indicating data associated with these nodes. The overall diagram illustrates how a sequence of characters (from the matrix) might be represented and traversed within a tree-like data structure.

We continue this process until no more trie nodes can be found at an adjacent cell on the board. When this happens, we backtrack to the previous cell on the board to explore a different path.

If we ever reach a node that represents the end of the word (i.e., contains a non-null word attribute), we can record that word in our output:

Image represents a visualization of a Trie data structure traversal algorithm.  The image is divided into three sections, each showing a step in the process.  Each section contains a 3x3 grid representing a portion of the input data ('bytes'), with letters 'b', 'y', 's', 'r', 't', 'e', 'a', 'i', 'n' arranged in a matrix.  Below each grid is a tree structure. The top node of the tree is labeled 'root'.  The first section highlights the letter 't' in the grid with a downward arrow, indicating the traversal starts at 't'. The corresponding tree shows a path from the root to 't', with 'b' and 'y' nodes above it, colored black. The second section shows a rightward arrow from 't' to 'e' in the grid, indicating the traversal moves to 'e'. The tree now extends to 'e', which is also colored black. A dashed arrow points from 'e' to a peach-colored box labeled 'add to 'res'', indicating that 'e' is added to a result string.  The third section shows an upward arrow from 't' to 's' in the grid, indicating the traversal moves up to 's'. The tree now extends to 's', which is colored orange. A dashed arrow points from 's' to a peach-colored box labeled 'add to 'res'', indicating that 's' is added to the result string.  In all three sections, the nodes 'r' and 't' are light gray, indicating they are not part of the current traversal path.  The labels 'byte' and 'bytes' are associated with the nodes 'e' and 's' respectively, suggesting these letters represent parts of a byte or bytes.

As shown, we found two words on the board from the DFS call that started at cell (0, 0). Now, we restart this process for any other cells on the board that match the character of one of the root node’s children.


One important aspect of this backtracking approach is keeping track of visited cells as we explore the board. Without this, we might revisit a cell unintentionally. For example, when exploring both children of node ‘i’, we could end up revisiting cell ‘t’:

Image represents a visualization of a tree traversal algorithm, likely depth-first search (DFS), alongside a matrix representation illustrating the path.  The left side shows a tree with a root node labeled 'root' and child nodes 'b', 'r', and 't'.  The 't' node branches down to 'r', 'a', and finally 'i', which has children 't' ('trait') and 'n' ('train'). Dashed lines indicate potential further traversal paths not explicitly shown.  The central and right sections depict a 4x3 matrix where each cell contains a character.  An orange arrow points to the 'i' node in the tree, indicating the current position in the traversal.  The matrix sections show the traversal path; a red-highlighted area in the matrix represents the currently visited nodes.  A black dot and a black arrow within the matrix highlight the current character being processed ('t' and 'i' respectively in the first matrix).  A red arrow curves from the 't' in the first matrix to the text ' 't' was previously visited,' indicating a backtracking step due to a previously visited node.  The text 'visit 't'' and 'visit 'n'' indicate the next potential traversal steps based on the tree structure.  The final matrix shows the traversal continuing to 'n'.

The remedy for this is to either keep track of visited cells using a hash set, or keep track of them in place by changing the visited cell to a special character (like '#') as we traverse:

Image represents a visualization of a Trie data structure and its traversal.  A tree on the left shows nodes labeled 'root', 'b', 'r', 't', 'r', 'a', 'i', 't', and 'n', connected by edges representing hierarchical relationships.  The 'b' and 'r' nodes branching from the 'root' have dashed lines indicating they are leaf nodes. The 't' node from the 'root' connects to 'r', 'a', 'i', which further branches into 't' and 'n'.  These latter two nodes have dashed lines connected to labels 'trait' and 'train' respectively, suggesting these are terminal nodes representing words. An orange arrow points to the 'i' node. To the right, two 3x3 matrices are shown. The first matrix has header labels 'b', 'y', and 's' and contains '#' symbols in the bottom two rows and 'e' and 'n' in the top and bottom right cells respectively. The bottom-middle cell contains '# → i', indicating a transition or pointer to the 'i' node in the Trie. A horizontal arrow labeled 'visit 'n'' points from the first matrix to the second. The second matrix is identical to the first, except the bottom-right cell now shows '# → n', indicating a transition to the 'n' node, signifying a traversal of the Trie based on the input 'n'.  The overall image illustrates how a Trie is traversed, with the matrices representing a possible state transition during a search or pattern matching operation.

This shouldn’t be a permanent change to the board. So, we should undo this change at the end of each recursive call, as demonstrated in the following code snippet:

def dfs(r, c, board, node):
    temp = board[r][c]
    board[r][c] = '#'  # Mark as visited.
    for next_r, next_c in adjacent_cells:
        if board[next_r][next_c] in node.children:
            dfs(next_r, next_c, board, node.children[board[next_r][next_c]])
    board[r][c] = temp  # Mark as unvisited.
function dfs(r, c, board, node) {
  const temp = board[r][c];
  board[r][c] = '#'; // Mark as visited.
  for (const [nextR, nextC] of adjacentCells) {
    if (node.children[board[nextR][nextC]]) {
      dfs(nextR, nextC, board, node.children[board[nextR][nextC]]);
    }
  }
  board[r][c] = temp; // Mark as unvisited.
}
void dfs(int r, int c, char[][] board, TrieNode node) {
    char temp = board[r][c];
    board[r][c] = '#';  // Mark as visited.
    for (int[] nextCell : adjacentCells) {
        int nextR = nextCell[0], nextC = nextCell[1];
        if (node.children.containsKey(board[nextR][nextC])) {
            dfs(nextR, nextC, board, node.children.get(board[nextR][nextC]));
        }
    }
    board[r][c] = temp;  // Mark as unvisited.
}

Now, let’s walk through this process in detail.

For each cell on the board that matches a character of one of the root node’s children, make a recursive DFS call to that cell, passing in the corresponding node. At each of these DFS calls:

  1. Check if the current node represents the end of a word. If it does, add that word to the output.

  2. Mark the current cell as visited by setting the cell to ‘#’.

  3. Recursively explore all adjacent cells that correspond with a child of the current TrieNode.

  4. Backtrack by reverting the cell back to its original character (i.e., marking it as unvisited).

Handling multiple occurrences of the same word on the board
We need to be aware of the risk of adding duplicate words to the output, as the board may contain the same word in multiple locations. Remember that we use the word attribute on each TrieNode to check if it represents the end of a word. After recording a word in our output, we can set that node’s word attribute to null, ensuring we cannot record the same word again.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

class TrieNode:
    def __init__(self):
        self.children = {}
        self.word = None
class TrieNode {
  constructor() {
    this.children = {};
    this.word = null;
  }
}
class TrieNode {
    Map<Character, TrieNode> children;
    String word;

    public TrieNode() {
        children = new HashMap<>();
        word = null;
    }
}
from typing import List
    
def find_all_words_on_a_board(board: List[List[str]], words: List[str]) -> List[str]:
    root = TrieNode()
    # Insert every word into the trie.
    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 = []
    # Start a DFS call from each cell of the board that contains a child of the root
    # node, which represents the first letter of a word in the trie.
    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: List[List[str]], r: int, c: int, node: TrieNode, res: List[str]) -> None:
    # If the current node represents the end of a word, add the word to the result.
    if node.word:
        res.append(node.word)
        # Ensure the current word is only added once.
        node.word = None
    temp = board[r][c]
    # Mark the current cell as visited.
    board[r][c] = '#'
    # Explore all adjacent cells that correspond with a child of the current TrieNode.
    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, board) and board[next_r][next_c] in node.children:
            dfs(board, next_r, next_c, node.children[board[next_r][next_c]], res)
    # Backtrack by reverting the cell back to its original character.
    board[r][c] = temp
    
def is_within_bounds(r: int, c: int, board: List[str]) -> bool:
    return 0 <= r < len(board) and 0 <= c < len(board[0])
function findAllWordsOnABoard(board, words) {
  const root = new TrieNode();
  // Insert every word into the trie.
  for (const word of words) {
    let node = root;
    for (const char of word) {
      if (!node.children[char]) {
        node.children[char] = new TrieNode();
      }
      node = node.children[char];
    }
    node.word = word;
  }
  const res = [];
  // Start a DFS call from each cell of the board that contains a child of the root
  // node, which represents the first letter of a word in the trie.
  for (let r = 0; r < board.length; r++) {
    for (let c = 0; c < board[0].length; c++) {
      if (root.children[board[r][c]]) {
        dfs(board, r, c, root.children[board[r][c]], res);
      }
    }
  }
  return res;
}

function dfs(board, r, c, node, res) {
  // If the current node represents the end of a word, add the word to the result.
  if (node.word) {
    res.push(node.word);
    // Ensure the current word is only added once.
    node.word = null;
  }
  const temp = board[r][c];
  // Mark the current cell as visited.
  board[r][c] = '#';
  // Explore all adjacent cells that correspond with a child of the current TrieNode.
  const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
  for (const [dr, dc] of dirs) {
    const nextR = r + dr;
    const nextC = c + dc;
    if (isWithinBounds(nextR, nextC, board) && node.children[board[nextR][nextC]]) {
      dfs(board, nextR, nextC, node.children[board[nextR][nextC]], res);
    }
  }
  // Backtrack by reverting the cell back to its original character.
  board[r][c] = temp;
}

function isWithinBounds(r, c, board) {
  return 0 <= r && r < board.length && 0 <= c && c < board[0].length;
}
public class Solution {
    public List<String> findAllWordsOnABoard(char[][] board, String[] words) {
        TrieNode root = new TrieNode();
        // Insert every word into the trie.
        for (String word : words) {
            TrieNode node = root;
            for (char ch : word.toCharArray()) {
                if (!node.children.containsKey(ch)) {
                    node.children.put(ch, new TrieNode());
                }
                node = node.children.get(ch);
            }
            node.word = word;
        }
        List<String> res = new ArrayList<>();
        // Start a DFS call from each cell of the board that contains a child of the root
        // node, which represents the first letter of a word in the trie.
        for (int r = 0; r < board.length; r++) {
            for (int c = 0; c < board[0].length; c++) {
                if (root.children.containsKey(board[r][c])) {
                    dfs(board, r, c, root.children.get(board[r][c]), res);
                }
            }
        }
        return res;
    }

    private void dfs(char[][] board, int r, int c, TrieNode node, List<String> res) {
        // If the current node represents the end of a word, add the word to the result.
        if (node.word != null) {
            res.add(node.word);
            // Ensure the current word is only added once.
            node.word = null;
        }
        char temp = board[r][c];
        // Mark the current cell as visited.
        board[r][c] = '#';
        // Explore all adjacent cells that correspond with a child of the current TrieNode.
        int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        for (int[] d : dirs) {
            int nextR = r + d[0], nextC = c + d[1];
            if (isWithinBounds(nextR, nextC, board) && node.children.containsKey(board[nextR][nextC])) {
                dfs(board, nextR, nextC, node.children.get(board[nextR][nextC]), res);
            }
        }
        // Backtrack by reverting the cell back to its original character.
        board[r][c] = temp;
    }

    private boolean isWithinBounds(int r, int c, char[][] board) {
        return 0 <= r && r < board.length && 0 <= c && c < board[0].length;
    }
}

Complexity Analysis

Time complexity: The time complexity of find_all_words_on_a_board is O(L+m⋅n⋅3L)O(L+m\cdot n\cdot 3L) where NN denotes the number of words in the words array, LL denotes the length of the longest word, and m⋅nm\cdot n denotes the size of the board. Here’s why:

  • To build the trie, we insert each word from the input array into it, with each word containing a maximum of LL characters. This takes O(N⋅L)O(N\cdot L) time.

  • Then, in the main search process, we perform a DFS for each of the mn cells on the board. Each DFS call takes O(3L)O(3L) time because, at each point in the DFS, we make up to 3 recursive calls: one for each of the 3 adjacent cells (this excludes the cell we came from). This is repeated for, at most, the length of the longest word, LL.

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

Space complexity: The space complexity is O(N⋅L)O(N\cdot L). Here’s why:

  • The trie has a space complexity of O(N⋅L)O(N\cdot L). In the worst case, if all words have unique prefixes, we store every character of every word in the trie. Each word attribute stored at the end of a path in the trie takes O(L)O(L) space, and with NN words. This contributes an additional O(N⋅L)O(N\cdot L) space.

  • The maximum depth of the recursive call stack is LL.

Therefore, the overall space complexity is O(N⋅L)+O(L)=O(N⋅L)O(N\cdot L)+O(L)=O(N\cdot L).

CHAPTER 75

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:

class GraphNode:
    def __init__(self, val):
        self.val = val
        self.neighbors = []
class GraphNode {
  constructor(val) {
    this.val = val;
    this.neighbors = [];
  }
}
class GraphNode {
    public int val;
    public List<GraphNode> neighbors;

    public GraphNode(int val) {
        this.val = val;
        this.neighbors = new ArrayList<>();
    }
}

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.
Image represents a graph illustrating graph traversal concepts.  The left side shows a graph with nodes labeled 0, 1, 2, 3, and 4. Node 0 is connected to nodes 1, 2, and 3 with solid black lines. Node 2 is connected to node 4 with a grey line.  Dashed blue lines encircle nodes 1, 2, and 3, and are labeled 'neighbors of node 0,' indicating these nodes are directly connected to node 0.  The text 'degree = 3' next to node 0 specifies its degree (number of connections). The right side shows the same graph, but with a peach-colored area highlighting a path from node 3 to node 4, traversing through node 2.  This highlighted path visually emphasizes a specific traversal sequence within the graph.

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.

Image represents three different graph structures illustrating variations in graph properties.  The first graph, labeled 'directed:', shows a directed acyclic graph (DAG) with nodes numbered 0 through 4.  Node 0 has directed edges pointing to nodes 1 and 2. Node 3 has a directed edge to node 2, and node 2 has a directed edge to node 4. The second graph, labeled 'weighted:', depicts a weighted undirected graph with the same nodes (0-4) but with numerical weights (6, 12, 10, 4, 3) assigned to each edge, indicating the connection strength or cost between nodes.  The third graph, labeled 'cyclic:', displays a cyclic undirected graph with nodes 0 through 4.  This graph contains a cycle, highlighted by thicker peach-colored edges, between nodes 0, 2, and 3; the word 'cycle' is written in orange within this cycle.  All three graphs use circles to represent nodes and lines to represent edges, with arrows indicating direction in the first graph and numbers indicating weights in the second.

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. Adjacency lists can be implemented using a hash map, where the key represents the node, and its corresponding value represents the list of that node’s neighbors.

In an adjacency matrix, the graph is represented as a 2D matrix where matrix[i][j] indicates an edge between nodes i and j.

Image represents a graph data structure illustrated in three different ways:  First, a visual representation shows five nodes (0, 1, 2, 3, 4) connected by edges, forming a tree-like structure. Node 0 is the root, connected to nodes 1 and 3. Node 3 is also connected to node 2, and node 2 is connected to node 4. Second, an adjacency list represents the same graph.  It's a table with a 'node' column (0-4) and a 'neighbors' column. Each row shows a node and a list of its directly connected neighbors. For example, node 0's neighbors are 1, 2, and 3. Third, an adjacency matrix is shown, a 5x5 grid representing the graph's connections. Rows and columns are labeled 0-4, corresponding to the nodes. A '1' at position (i, j) indicates a connection between node i and node j; a '0' indicates no direct connection.  For instance, the '1' at (0,1) shows a connection between node 0 and node 1, mirroring the visual and adjacency list representations.  The matrix is symmetric because the graph is undirected.

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, both of which have similar use cases when they’re used on trees.

When traversing a graph, it might also be necessary to keep track of visited nodes using a data structure such as a hash set, to ensure each node is only visited once.

DFS is typically implemented recursively, as demonstrated in the code snippet below:

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)
function dfs(node, visited) {
  visited.add(node);
  process(node);
  for (const neighbor of node.neighbors) {
    if (!visited.has(neighbor)) {
      dfs(neighbor, visited);
    }
  }
}
public void dfs(GraphNode node, Set<GraphNode> visited) {
    visited.add(node);
    process(node);
    for (GraphNode neighbor : node.neighbors) {
        if (!visited.contains(neighbor)) {
            dfs(neighbor, visited);
        }
    }
}

BFS is typically implemented iteratively, as demonstrated in the code snippet below:

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)
function bfs(node) {
  const visited = new Set();
  const queue = [node];
  while (queue.length > 0) {
    const currNode = queue.shift();
    if (!visited.has(currNode)) {
      visited.add(currNode);
      process(currNode);
      for (const neighbor of currNode.neighbors) {
        queue.push(neighbor);
      }
    }
  }
}
public void bfs(GraphNode node) {
    Set<GraphNode> visited = new HashSet<>();
    Queue<GraphNode> queue = new LinkedList<>();
    queue.offer(node);
    while (!queue.isEmpty()) {
        GraphNode currNode = queue.poll();
        if (!visited.contains(currNode)) {
            visited.add(currNode);
            process(currNode);
            for (GraphNode neighbor : currNode.neighbors) {
                queue.offer(neighbor);
            }
        }
    }
}

Both DFS and BFS have a time complexity of O(n+e)O(n+e), where nn denotes the number of nodes and ee denotes the number of edges. This is because during traversal, each node is visited once, and each edge is explored once.

They both also share a space complexity of O(n)O(n). For DFS, this is due to the space taken up by the recursive call stack, and for BFS, it’s due to the space taken up by the queue.

Real-world Example

Social networks: Users of social media sites like LinkedIn are typically represented as nodes, and connections or friendships between users are represented as edges. The graph structure allows platforms to analyze relationships, suggest new connections, and identify groups or communities within their networks.

Chapter Outline

Image represents a hierarchical diagram illustrating different coding patterns related to graphs.  A top-level rounded rectangle labeled 'Graphs' points downwards to two main branches: 'DFS' (Depth-First Search) and 'BFS' (Breadth-First Search).  Each of these branches contains a rounded rectangle listing associated graph problems: DFS includes 'Graph Deep Copy,' 'Count Islands,' 'Bipartite Graph Validation,' and 'Longest Increasing Path'; BFS includes 'Matrix Infection' and 'Shortest Transformation Sequence.'  Further down, dashed lines connect these main branches to two additional rounded rectangles: 'Dijkstra's Algorithm' (connected to DFS, listing 'Shortest Path') and 'Topological Sort' (connected to BFS, listing 'Prerequisites'). Finally, a central dashed line from both DFS and BFS converges to a bottom rounded rectangle labeled 'Union-Find,' which lists 'Merging Communities' and 'Connect the Dots' as associated problems.  The overall structure shows how various graph algorithms and problems are categorized under DFS, BFS, and the Union-Find pattern, with Dijkstra's Algorithm and Topological Sort being specific applications within those broader categories.
CHAPTER 76

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. This means you need to make new nodes for the copied graph instead of reusing any nodes from the original graph.

Example:

Image represents a comparison between an 'Original Graph' and its 'Cloned Graph.'  The original graph is depicted as a simple undirected graph with four nodes labeled 0, 1, 2, and 3. Node 0 is connected to nodes 1 and 2, node 1 is connected to node 0 and 2, node 2 is connected to nodes 0, 1, and 3, and node 3 is connected to node 2. The cloned graph mirrors this structure, with identical node labels and connections; however, each node in the cloned graph is represented by a dashed orange circle with a pale peach fill, visually distinguishing it from the solid-line, white-filled circles of the original graph.  A textual description to the right clarifies that all nodes in the cloned graph are 'deep copies,' implying that they are independent copies and do not share any references or pointers with the nodes in the original 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, effectively cloning each node while we traverse. Any traversal method will suffice for this strategy. In this explanation, we’ll use DFS.

Traversing the graph
Start by defining exactly what we want our DFS function to do. When we call DFS on the input node, we expect it to create a deep copy of that node and all its neighbors. Let’s break down this process.

The first thing our function will do is create a copy of its input node:

Image represents a diagram illustrating a Depth-First Search (DFS) algorithm, specifically showcasing node cloning.  The left side depicts a graph with four nodes (0, 1, 2, 3) interconnected. Node 0 is connected to nodes 1 and 2, node 1 is connected to node 2, and node 2 is connected to node 3.  A rectangular box labeled 'node' points a downward arrow to node 0, indicating the starting point of the DFS traversal. The text 'dfs(θ):' above signifies that a depth-first search is being performed starting at node 0 (represented by θ). On the right side, a rectangular box labeled 'cloned_node' points to a dashed-line circle containing θ, representing a cloned version of the starting node. This illustrates the creation of a copy of the starting node during the DFS process, potentially for purposes like maintaining a visited node list or exploring different paths in the graph.  The difference in the style of the node (solid vs. dashed line) highlights the distinction between the original and cloned nodes.

Next, we want to ensure this cloned node is connected to a clone of all its neighbors, mirroring the original node’s neighbors. To achieve this, we'll call the DFS function on each of the original node's neighbors:

Image represents a visual comparison of a graph traversal algorithm, likely Depth-First Search (DFS), on an original graph and its cloned version.  On the left, a simple undirected graph is shown with four nodes labeled 0, 1, 2, and 3.  Node 0 is connected to nodes 1 and 2, node 1 is connected to nodes 0 and 3, and node 2 is connected to nodes 0 and 3.  A rectangular box labeled 'node' points to node 0, indicating a starting point for traversal. On the right, a cloned version of node 0 is depicted within a dashed orange circle, representing a starting point for DFS on the cloned graph.  From this cloned node 0, dashed orange lines indicate recursive calls to the DFS function.  One dashed line connects to node 1, labeled 'dfs((1))', signifying a DFS call on node 1. Another dashed line connects to a circle labeled 'dfs((2))', representing a DFS call on node 2. The arrangement visually demonstrates how a cloned node initiates separate DFS traversals, potentially for parallel processing or independent exploration of the graph's structure.

Each of these DFS instances will also do the same thing by creating a clone of their input node and returning it when it has been connected to its neighbors:

Image represents a visual explanation of a Depth-First Search (DFS) algorithm on a graph.  The left side shows a simple undirected graph with four nodes labeled 0, 1, 2, and 3.  Node 0 is connected to nodes 1 and 2, node 1 is connected to node 2, and node 2 is connected to node 3.  A box labeled 'node' points to node 0, indicating the starting point of the DFS. The right side depicts the DFS process. A box labeled 'cloned_node' points to a dashed-line circled node 0, representing a copy of the starting node in the DFS traversal.  A dashed orange line connects this cloned node 0 to a function call `dfs((1))`, indicating a recursive call to DFS on node 1.  The result of `dfs((1))`, which is 1, is shown flowing via a right-angle arrow to a dashed-line circled node labeled '1', representing the return value.  Similarly, a dashed orange line connects the cloned node 0 to `dfs((2))`, representing a recursive call to DFS on node 2. The result of `dfs((2))`, which is 2, is shown flowing via a right-angle arrow to a dashed-line circled node labeled '2', representing the return value of the second recursive call. The dashed-line circles around nodes 0, 1, and 2 on the right side visually distinguish them as nodes visited during the DFS traversal.

In pseudocode, this is what the process looks like:

Example
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

One more thing we should be mindful of is the possibility of cloning nodes that have already been cloned.

Handling previously-cloned nodes
Consider node 2 in the following graph and cloned graphs. Its neighbors are nodes 0, 1, and 3. Let’s say nodes 0 and 1 have already been cloned, but not node 3:

Image represents a graph transformation illustrating a cloning pattern.  The left side shows an initial graph with four nodes labeled 0, 1, 2, and 3.  Node 0 is connected to node 2, node 1 is connected to nodes 0 and 2, and node 2 is connected to node 3.  A rectangular box labeled 'node' points to node 2 with an arrow, indicating that node 2 is the target of a cloning operation. The right side depicts the graph after the cloning operation.  Nodes 0, 1, and 2 are now outlined with a dashed orange border, indicating they are cloned.  The structure of the connections between nodes 0, 1, and 2 remains identical to the original graph, but node 2 is now a cloned node. A rectangular box labeled 'cloned_node' points to the cloned node 2 with an arrow, signifying the result of the cloning process.  The original node 3 and its connection to node 2 are not affected by the cloning.

To link the cloned node 2 to its neighbors, we perform a DFS call to node 0, node 1, and node 3:

Image represents a graph traversal algorithm, likely Depth-First Search (DFS), illustrated through two diagrams. The left diagram shows a simple undirected graph with four nodes (0, 1, 2, 3) and edges connecting them: node 0 connects to nodes 1 and 2; node 1 connects to nodes 0 and 2; node 2 connects to nodes 0, 1, and 3; and node 3 connects to node 2.  A rectangular box labeled 'node' points to node 2, indicating a starting point. The right diagram depicts the DFS traversal process.  A rectangular box labeled 'cloned_node' points to a lightly shaded, dashed-line circled node 2, representing a copy of the starting node in the traversal.  From this cloned node 2, dashed orange lines connect to nodes labeled 'dfs(0)', 'dfs(1)', and 'dfs(3)', indicating that the DFS function is recursively called on these nodes.  The arrangement shows the progression of the DFS algorithm, starting from node 2 and exploring its neighbors recursively.

Since cloned copies of nodes 0 and 1 already exist, our DFS function should return these previously created nodes, instead of creating new ones:

Image represents a diagram illustrating a graph traversal algorithm, likely Depth-First Search (DFS), and its cloning mechanism.  The left side shows a simple undirected graph with four nodes (0, 1, 2, 3) where node 0 is connected to node 2, node 1 is connected to nodes 2 and 3, and node 2 is connected to node 3.  A labeled arrow 'node' points to node 2, indicating a starting point. The right side depicts the DFS process.  A rectangular box labeled 'previously created nodes' points to dashed-line circled nodes 0 and 1, representing nodes already visited. A rectangular box labeled 'cloned_node' points to a dashed-line circled node 2, a clone of node 2 from the original graph.  Solid arrows indicate the traversal path within the DFS, with 'dfs(x)' indicating a DFS call on node x. Dashed orange lines connect the cloned node 2 to nodes 0 and 3, showing the exploration of its neighbors.  The 'returns(x)' labels indicate the return of the DFS function after exploring node x. Finally, a curved arrow from a rectangular box labeled 'new node' points to the dashed-line circled node 3, suggesting the creation of a new node during the cloning process.  The overall diagram visualizes how a graph is traversed and cloned using a DFS-like algorithm.

We can manage this by using a hash map where each original node is a key, and the corresponding cloned node is the value. This way, whenever we perform a DFS call on a node, we first check if it already has a clone in our hash map. If it does, we just return the existing clone. If it doesn't, we create a new clone and add it to the hash map.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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 this node was already cloned, then return this previously cloned node.
    if node in clone_map:
        return clone_map[node]
    # Clone the current node.
    cloned_node = GraphNode(node.val)
    # Store the current clone to ensure it doesn't need to be created again in future
    # DFS calls.
    clone_map[node] = cloned_node
    # Iterate through the neighbors of the current node to connect their clones to the
    # current cloned node.
    for neighbor in node.neighbors:
        cloned_neighbor = dfs(neighbor, clone_map)
        cloned_node.neighbors.append(cloned_neighbor)
    return cloned_node
function graphDeepCopy(node) {
  if (!node) {
    return null;
  }
  return dfs(node, new Map());
}

function dfs(node, cloneMap = new Map()) {
  // If this node was already cloned, then return this previously cloned node.
  if (cloneMap.has(node)) {
    return cloneMap.get(node);
  }
  // Clone the current node.
  const clonedNode = new GraphNode(node.val);
  // Store the current clone to ensure it doesn't need to be created again in future
  // DFS calls.
  cloneMap.set(node, clonedNode);
  // Iterate through the neighbors of the current node to connect their clones to the
  // current cloned node.
  for (const neighbor of node.neighbors) {
    const clonedNeighbor = dfs(neighbor, cloneMap);
    clonedNode.neighbors.push(clonedNeighbor);
  }
  return clonedNode;
}
public class Solution {
    public GraphNode graphDeepCopy(GraphNode node) {
        if (node == null) {
            return null;
        }
        return dfs(node, new HashMap<>());
    }

    private GraphNode dfs(GraphNode node, Map<GraphNode, GraphNode> cloneMap) {
        // If this node was already cloned, then return this previously cloned node.
        if (cloneMap.containsKey(node)) {
            return cloneMap.get(node);
        }
        // Clone the current node.
        GraphNode clonedNode = new GraphNode(node.val);
        // Store the current clone to ensure it doesn't need to be created again in future
        // DFS calls.
        cloneMap.put(node, clonedNode);
        // Iterate through the neighbors of the current node to connect their clones to the
        // current cloned node.
        for (GraphNode neighbor : node.neighbors) {
            GraphNode clonedNeighbor = dfs(neighbor, cloneMap);
            clonedNode.neighbors.add(clonedNeighbor);
        }
        return clonedNode;
    }
}

Complexity Analysis

Time complexity: The time complexity of graph_deep_copy is O(n+e)O(n+e), where nn is the number of nodes and ee is the number of edges of the graph. This is because we traverse through and create a clone of all nn nodes of the original graph, and traverse across ee edges during DFS.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as nn. In addition, the clone_map hash map stores a key-value pair for each of the nn nodes.

CHAPTER 77

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:

Image represents a 4x4 matrix, visually depicting a concept likely related to identifying 'islands' within a grid.  The matrix cells contain either a '1' or a '0'.  Two distinct groups of adjacent cells containing '1's are highlighted in a peach/light orange color. These groups are labeled 'island' with orange curved arrows pointing to each group.  The '1's within each highlighted group are directly adjacent (horizontally or vertically, not diagonally), forming two distinct, unconnected clusters. The remaining cells contain '0's, representing areas outside the islands. The overall arrangement demonstrates a visual representation of a data structure where connected components (islands of '1's) are identified within a larger matrix.
Example
Output: 2

Intuition

Before determining how to find all the islands in a matrix, let’s consider an input that contains only one island.

Matrix with just one island
Consider the following matrix containing one island:

Image represents a 4x4 matrix, visually depicted as a grid with four rows and four columns, each cell capable of holding a numerical value.  The rows and columns are labeled from 0 to 3.  The matrix cells contain either a '0' (represented in light gray) or a '1' (represented in orange). The '1' values are clustered towards the center of the matrix, specifically at positions (1,0), (1,1), (1,2), and (2,1), forming a roughly diagonal pattern.  The remaining cells contain '0' values. The entire matrix is enclosed within a bold black border.  No URLs or parameters are present.

When we iterate through the matrix starting from the top left, the first land cell we encounter is cell (0, 2). From here, we’d like to find the rest of the island.

The key observation is that we should be able to access every land cell on the same island by moving horizontally or vertically through neighboring land cells. This means that all 1s forming an island are connected either directly or indirectly through adjacent 1s. Conceptually, this is similar to a graph, where each cell is a node, and each connection to an adjacent cell is an edge:

Image represents a visual comparison of two representations of the same data structure.  On the left, a 4x4 adjacency matrix is shown, with rows and columns labeled 0 through 3.  The matrix cells contain either a '0' (light gray) or a '1' (orange).  The '1' values are located at matrix positions (0,2), (1,0), (1,1), (1,2), and (2,1), indicating connections between nodes.  A large, gray curly brace separates the matrix from a graph representation on the right. This graph consists of five nodes, each represented by an orange circle containing the number '1'.  These nodes are connected by black lines, mirroring the connections shown in the adjacency matrix. Specifically, the graph shows a node connected to three other nodes, one of which is connected to a fourth node, and another of which is connected to a fifth node. The arrangement of '1's in the matrix directly corresponds to the connections in the graph; each '1' in the matrix represents an edge connecting two nodes in the graph.

This demonstrates that we can identify the rest of the island by performing a graph traversal algorithm. Most traversal algorithms will suit this purpose. In this explanation, we'll use DFS.

Depth-first search
For each cell we visit during the traversal, we need to mark that cell as visited to ensure it’s not visited again. There are two ways to do this:

  1. Use a separate data structure, such as a hash set, to keep track of the coordinates of visited cells.

  2. Modify the matrix by changing the value of a visited cell from 1 to -1, ensuring it doesn’t get revisited.

We’ll proceed with the second option because it doesn’t require the use of extra space.


Now, let’s begin DFS traversal. Mark the first cell, (0, 2), as visited by modifying its value to -1. Then, continue exploring by calling DFS on any neighboring land cells. Here, the only neighboring land cell is cell (1, 2):

Image represents a visual comparison of a matrix and a graph.  On the left, a 4x4 matrix is shown with row and column indices labeled 0-3.  Most cells contain '0' (in light gray), but some cells contain '1' (in orange) and one cell contains '-1' (in dark gray). A downward-pointing arrow indicates the '-1' cell as a point of focus.  On the right, a graph is depicted with four nodes, each containing the number '1' (in black text) within an orange circle.  These nodes are connected by black lines forming a T-shape.  A fifth node, colored light gray and containing '-1', is positioned above one of the '1' nodes in the T-shape, with an orange arrow pointing downwards to indicate a connection or flow from the '-1' node to the adjacent '1' node in the graph. The overall image likely illustrates a data structure transformation or a representation of the same underlying data in two different formats, possibly demonstrating a concept in graph theory or matrix operations.

From cell (1, 2), we similarly mark it as visited and explore its neighboring land cells. Again, there's only one neighboring land cell: (1, 1). So, let's make a recursive DFS call to it:

Image represents a comparison of a matrix representation and a graph representation of the same data.  On the left, a 4x4 matrix is shown with numerical values.  The rows and columns are labeled 0 through 3.  Most cells contain '0' in light gray text.  However, cells (1,0), (1,1), and (2,1) contain '1' in orange text, while cell (1,2) contains '-1' in dark gray text.  A left-pointing arrow is positioned between the '1' at (1,1) and the '-1' at (1,2), suggesting a relationship or flow of information. On the right, a directed graph is depicted.  Three nodes circled in orange contain '1', while two nodes circled in gray contain '-1'.  Lines connect the nodes, representing relationships.  An orange arrow points from a gray '-1' node to an orange '1' node, indicating a directional connection. The graph visually mirrors the matrix, with the orange '1' nodes corresponding to the orange '1's in the matrix, and the gray '-1' nodes corresponding to the '-1' in the matrix. The arrow in the matrix and the arrow in the graph both highlight the same interaction between a '1' and a '-1'.

From cell (1, 1), mark it as visited and explore both of its neighboring land cells. Let’s continue exploring from cell (1, 0) first:

Image represents a comparison between a matrix representation and a graph representation of data.  On the left, a 4x4 matrix is shown with numerical values.  Rows and columns are labeled 0-3.  Most cells contain '0' (in light gray), but some contain '1' (in orange) and '-1' (in dark gray). Specifically, a '1' is located at matrix[1][0] and matrix[2][1].  A '-1' is present at matrix[0][2], matrix[1][1], and matrix[1][2]. An arrow points to the '1' at matrix[1][0], indicating a potential focus or starting point. On the right, a directed acyclic graph is displayed.  Nodes represent numerical values; orange circles represent '1', and gray circles represent '-1'.  Edges connect the nodes, showing relationships. An orange arrow points from a '1' node to a '-1' node, indicating a directional flow. The graph visually represents the connections and relationships between the non-zero elements in the matrix, with the orange '1' nodes corresponding to the '1's in the matrix and the gray '-1' nodes corresponding to the '-1's in the matrix. The structure suggests a possible flow or dependency between the data points.

At (1, 0), there's no neighboring land. So, the recursive process naturally goes back to cell (1, 1) to continue exploring any other neighboring land cells:

Image represents a visual explanation of a coding pattern, likely related to graph traversal or matrix manipulation.  The image is divided into two main sections. The top section shows a 4x4 matrix with numerical values.  Most cells contain '0', except for a '-1' at position (0,2) and a sequence of '-1's at positions (1,0), (1,1), (1,2).  A highlighted '1' is present at position (2,1). A right-pointing arrow connects the '-1' at (1,0) to the '-1' at (1,1), indicating a possible flow or transition. The bottom section displays a nearly identical 4x4 matrix, but the arrow now points downwards from the '-1' at (1,1) to the highlighted '1' at (2,1). To the right of the matrices is a simple graph. This graph consists of four nodes, three of which are labeled '-1' and are connected linearly. The fourth node, circled in orange, is labeled '1' and is connected to the last node in the linear sequence. An orange downward-pointing arrow connects the second '-1' node in the linear sequence to the '1' node, mirroring the arrow in the bottom matrix.  The overall image suggests a demonstration of how a specific algorithm or pattern processes data within a matrix, potentially identifying or highlighting a specific value ('1') based on the relationships between other values ('-1').

We've now finished exploring this island:

Image represents a comparison between two representations of the same data structure, likely an adjacency matrix and its corresponding graph.  On the left, a 4x4 matrix is shown, with rows and columns indexed from 0 to 3.  The matrix cells contain numerical values; most are 0, while several are -1.  Specifically, the -1 values are located at (0,2), (1,0), (1,1), (1,2), (2,1). The numbers are displayed in a dark gray font within the cells of the matrix. The matrix is enclosed in a bold black border. On the right, a graph is depicted with five nodes, each represented by a light gray circle containing the number -1.  These nodes are connected by edges, forming a structure where one node is connected to three others, and one of those three is connected to a fourth node. The connections between the nodes visually represent the non-zero entries (-1) in the adjacency matrix on the left. The arrangement of the nodes and edges in the graph directly corresponds to the positions of the -1 values in the matrix.  For example, the -1 at matrix position (1,0) corresponds to an edge between node 1 and node 0 in the graph.

With this island completely explored, let’s increment a variable count to indicate that one new island has been found. Now, let’s consider the main problem where there could be multiple islands in the matrix.

Matrix with multiple islands
We can identify all islands using the following steps:

  1. Search through the matrix, starting from cell (0, 0), until we find a land cell.

  2. Upon encountering a land cell, explore its island using DFS, marking each land cell we encounter as visited (-1) to avoid visiting them again.

  3. Increment count by 1, indicating the discovery of the island we just explored.

  4. Keep searching the matrix for any unvisited land cells. When we find one, repeat steps 2 to 4.

Image represents a step-by-step visualization of an algorithm, likely for identifying and counting 'islands' in a 2D grid.  The process begins with a 4x4 grid where cells containing '1' (in orange) represent land and '0' (in gray) represents water.  Above the first grid, text reads 'explore the island, count = 1,' indicating the algorithm's initial state. A dashed orange arrow points to a '1' cell, signifying the starting point of the exploration.  A rightward arrow then transitions to the second grid, where the initial '1' island has been replaced with '-1' (in gray), signifying it's been visited.  Above this grid, 'explore the island, count = 2' appears, along with a dashed orange arrow tracing the path to another '1' island.  Another rightward arrow leads to the final grid, where all '1' cells have been replaced by '-1', indicating all islands have been explored.  The text 'all islands explored' confirms the algorithm's completion.  The algorithm appears to use Depth-First Search or Breadth-First Search to traverse and mark visited cells, effectively counting the number of distinct islands.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

To traverse the matrix 4-directionally, we can use an array of direction vectors:

dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]

Each pair in the array represents the changes needed to move one step in a specific direction:

Image represents a visual explanation of how to move in four directions (Up, Down, Left, Right) on a 2D grid using array indexing.  The left side shows four equations, each representing a directional movement.  Each equation takes a coordinate pair (r, c) representing the row and column of a cell and adds it to an element from a `dirs` array.  `dirs[0]` corresponds to moving Up, resulting in (r - 1, c); `dirs[1]` corresponds to moving Down, resulting in (r + 1, c); `dirs[2]` corresponds to moving Left, resulting in (r, c - 1); and `dirs[3]` corresponds to moving Right, resulting in (r, c + 1). The right side displays a 3x3 grid visually illustrating these movements. The central cell is highlighted in light gray and labeled (r, c), representing the starting point. The surrounding cells are labeled with their respective coordinates, showing the result of applying the directional equations.  The grid demonstrates the effect of adding the `dirs` array elements to the initial coordinate (r, c) to obtain the new coordinates after moving in a specific direction.
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 a land cell is found, perform DFS to explore the full island, and
            # include this island in our count.
            if matrix[r][c] == 1:
                dfs(r, c, matrix)
                count += 1
    return count
    
def dfs(r: int, c: int, matrix: List[List[int]]) -> None:
    # Mark the current land cell as visited.
    matrix[r][c] = -1
    # Define direction vectors for up, down, left, and right.
    dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    # Recursively call DFS on each neighboring land cell to continue exploring this
    # island.
    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)
    
def is_within_bounds(r: int, c: int, matrix: List[List[int]]) -> bool:
    return 0 <= r < len(matrix) and 0 <= c < len(matrix[0])
function countIslands(matrix) {
  if (!matrix || matrix.length === 0) {
    return 0;
  }
  let count = 0;
  for (let r = 0; r < matrix.length; r++) {
    for (let c = 0; c < matrix[0].length; c++) {
      // If a land cell is found, perform DFS to explore the full island, and
      // include this island in our count.
      if (matrix[r][c] === 1) {
        dfs(r, c, matrix);
        count++;
      }
    }
  }
  return count;
}

function dfs(r, c, matrix) {
  // Mark the current land cell as visited.
  matrix[r][c] = -1;
  // Define direction vectors for up, down, left, and right.
  const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
  // Recursively call DFS on each neighboring land cell to continue exploring this
  // island.
  for (const [dr, dc] of dirs) {
    const nextR = r + dr;
    const nextC = c + dc;
    if (isWithinBounds(nextR, nextC, matrix) && matrix[nextR][nextC] === 1) {
      dfs(nextR, nextC, matrix);
    }
  }
}

function isWithinBounds(r, c, matrix) {
  return 0 <= r && r < matrix.length && 0 <= c && c < matrix[0].length;
}
public class Solution {
    public int countIslands(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            return 0;
        }
        int count = 0;
        for (int r = 0; r < matrix.length; r++) {
            for (int c = 0; c < matrix[0].length; c++) {
                // If a land cell is found, perform DFS to explore the full island, and
                // include this island in our count.
                if (matrix[r][c] == 1) {
                    dfs(r, c, matrix);
                    count++;
                }
            }
        }
        return count;
    }

    private void dfs(int r, int c, int[][] matrix) {
        // Mark the current land cell as visited.
        matrix[r][c] = -1;
        // Define direction vectors for up, down, left, and right.
        int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        // Recursively call DFS on each neighboring land cell to continue exploring this
        // island.
        for (int[] d : dirs) {
            int nextR = r + d[0], nextC = c + d[1];
            if (isWithinBounds(nextR, nextC, matrix) && matrix[nextR][nextC] == 1) {
                dfs(nextR, nextC, matrix);
            }
        }
    }

    private boolean isWithinBounds(int r, int c, int[][] matrix) {
        return 0 <= r && r < matrix.length && 0 <= c && c < matrix[0].length;
    }
}

Complexity Analysis

Time complexity: The time complexity of count_islands is O(m⋅n)O(m\cdot n), where mm denotes the number of rows and nn denotes the number of columns. This is because each cell of the matrix is visited at most twice: once when searching for land cells in the count_islands function, and up to one more time during DFS.

Space complexity: The space complexity is O(m⋅n)O(m\cdot n) mostly due to the recursive call stack during DFS, which can grow up to m⋅nm\cdot n in size.

Interview Tip

Tip: Check with an interviewer if modifications to the input are acceptable.
During DFS, we marked cells as visited by modifying the input directly. However, in some situations, input modification may not be desirable. As such, it’s worth confirming this with the interviewer before making in-place modifications to the input.

CHAPTER 78

Matrix Infection

You are given a matrix where each cell is either:

0: Empty
1: Uninfected
2: Infected

With each passing second, every infected cell (2) infects its uninfected neighboring cells (1) that are 4-directionally adjacent. Determine the number of seconds required for all uninfected cells to become infected. If this is impossible, return ‐1.

Example:

Image represents a four-stage visualization of a data transformation process, possibly illustrating a coding pattern like a wave propagation or diffusion algorithm.  Each stage is labeled 'second 0,' 'second 1,' 'second 2,' and 'second 3,' suggesting a time sequence.  Each stage displays a 3x4 grid of cells.  Initially (second 0), a single cell in the center-right contains the value '2', while all other cells contain '0' or '1'.  Subsequent stages show the '2' value propagating to adjacent cells.  Arrows in 'second 1' indicate the direction of this propagation (up and down). In 'second 2' and 'second 3', the '2' value continues to spread horizontally, with '2 ← 2' indicating the merging or interaction of two '2' values.  The color of the cells changes to a light peach color as they acquire the '2' value, visually highlighting the propagation. The overall arrangement demonstrates a step-by-step evolution of the data within the grid over time, illustrating a specific pattern of data movement and transformation.
Example
Input: matrix = [[1, 1, 1, 0], [0, 0, 2, 1], [0, 1, 1, 0]]
Output: 3

Intuition

Let’s begin tackling this problem by considering a simple case where the initial matrix contains only one infected cell.

Matrix with one infected cell
Consider the following matrix, containing just one 2:

Image represents a 3x4 grid, enclosed by a bold black border, containing numerical values.  The grid is divided into twelve cells, each displaying either a '0' (greyed out) or a '1' (in bold black font), except for one cell in the center-right which contains a '2' (in bold black font) and is highlighted with a light peach/beige background.  The arrangement of the numbers appears somewhat random, with no immediately apparent pattern or sequence.  There are no connections or arrows depicted between the cells, and no additional information such as labels, URLs, or parameters are present within the image itself. The numbers within the grid are simply presented as data points within the cells of the matrix.

An observation is that each infected (2) and uninfected (1) cell can be considered as nodes in a graph, where edges exist between cells that are 4-directionally adjacent. Therefore, we can visualize these cells as a connected graph:

Image represents a visual comparison of two data representations.  On the left, a 3x3 matrix is shown, containing numerical values.  The top row displays '1', '1', and '1', while the second row shows '0', '0', and a highlighted '2' in a peach-colored cell. The bottom row contains '0', '1', and '1'.  The '0' values are displayed in a lighter gray font.  A curly brace connects this matrix to a graph on the right. This graph is composed of several nodes, each represented by a circle containing a numerical value.  Three nodes labeled '1' are connected horizontally in a row at the top.  Below this, a central node labeled '2' is highlighted in orange, connected to the rightmost '1' of the top row, and to a '1' node to its right and two '1' nodes below it, forming a T-shape.  The connections between the nodes are represented by lines, indicating relationships or flows between the data points. The overall image illustrates a possible transformation or mapping between a matrix representation and a graph representation of the same underlying data, with the highlighted '2' in the matrix corresponding to the central highlighted '2' node in the graph.

This helps us think about this problem as a graph traversal problem. Which traversal algorithm will allow us to simulate the infection process? To find out, let’s observe how cells get infected each second.


After the first second, the adjacent uninfected neighbors of the first infected cell become infected. These cells are a distance of 1 away from the initially infected cell:

Image represents a visual comparison of a 2D array and a graph.  The left side shows a 3x3 array where cells contain the numbers 1, 2, and 0. A 2x2 sub-array in the rightmost column and middle rows is highlighted in peach, containing the numbers 2. Arrows within this sub-array indicate movement: an upward arrow from the central '2' to the '2' above it, and a downward arrow from the central '2' to the '2' below it, and a rightward arrow from the central '2' to another '2' to its right. The right side depicts a directed graph.  Three nodes labeled '1' are connected to three nodes labeled '2', all of which are colored peach.  The '2' nodes are arranged vertically.  The connections between '1' and '2' nodes are represented by lines, and each connection is labeled 'dist = 1,' indicating a distance of 1 between connected nodes.  The peach-colored '2' nodes are also connected to each other with arrows, mirroring the movement shown in the array's peach sub-array, with the rightmost '2' node highlighted with a thicker orange border.  The graph visually represents the movement pattern shown in the array's highlighted section.

One second later, the neighbors of the most recently infected cells get infected. These cells are a distance of 2 from the initial infected cell:

Image represents a comparison between a matrix representation and a graph representation of a data structure, likely illustrating a concept in graph algorithms or shortest path finding.  The left side shows a 3x4 matrix.  The top-left cell contains '1', while several cells contain '2' and others contain '0'.  The '2' values are shaded a light peach color, and arrows indicate a potential flow or relationship between two adjacent '2' values. The right side displays a graph with nodes representing values and edges representing connections.  The top graph section shows a node labeled '1' connected to a node labeled '2' (highlighted in orange), which is further connected to another node labeled '2' (not highlighted).  The bottom section mirrors this structure, with a node labeled '2' (highlighted in orange) connected to another node labeled '2' (not highlighted).  The text 'dist = 2' appears above and below the graph sections, suggesting that the distance or cost associated with traversing the edges is 2.  The overall image suggests a visualization of how a matrix can be interpreted as a graph, potentially to solve problems involving shortest paths or similar graph-based computations.

As we can see, the outward expansion from the initially infected cell is similar to how level-order traversal works in a tree, where each level represents nodes that are at a specific distance from the initially infected node.

So, let’s perform a level-order traversal to infect cells, starting at the infected cell. Each level that gets traversed corresponds to 1-second passing in the infection process.

Image represents a comparison of two data structures representing the same information.  On the left, a 3x4 matrix is shown, labeled 'second 0' at the top.  The matrix contains numerical values; most cells contain '0' or '1', but one cell in the center is highlighted in a light peach color and contains the value '2'.  On the right, a graph is presented, also labeled 'second 0' near a node. This graph consists of seven circular nodes, each containing the number '1' except for one central node highlighted in orange, which contains the number '2'.  The nodes are connected by lines representing edges. Three nodes form a horizontal line, connected to the central '2' node, which is further connected to another node containing '1' and a pair of nodes at the bottom. The arrangement of the '1's in the graph mirrors the arrangement of '1's in the matrix, with the central '2' in both structures corresponding to the highlighted '2' in the matrix.  The overall image likely illustrates a data transformation or representation of the same data in two different formats, possibly demonstrating a matrix-to-graph conversion or a similar concept within the context of coding patterns.
Image represents a comparison of two visualizations of data transformation, likely within a coding pattern context.  On the left, a 3x3 matrix displays numerical values (1, 2, and 0). A central 2x2 sub-matrix, highlighted in peach, shows the focus of the transformation. Arrows within this sub-matrix indicate movement of the value '2': upward, then rightward, then downward. The entire matrix is labeled 'second 1' at the top, suggesting a time step or iteration. On the right, a directed graph illustrates the same transformation.  Three nodes labeled '1' are connected linearly, followed by three nodes labeled '2' (highlighted in peach/orange) arranged vertically.  Arrows connect these '2' nodes, mirroring the movement in the matrix.  Horizontal arrows connect the '2' nodes, and the entire graph is annotated with 'second 1' labels next to the vertical and horizontal arrows, again indicating a time step or iteration. The graph visually represents the flow of the value '2' as depicted by the arrows in the matrix, providing an alternative representation of the same data transformation.
Image represents a comparison of two data structure representations, both showing the same data at 'second 2'.  On the left, a 3x4 matrix is displayed.  The matrix contains numerical values; '1' in the top-left corner, '0's in several cells, and '2's concentrated in the central-right area.  Two pairs of '2's are highlighted in a peach color, with left-pointing arrows indicating a potential data flow or transformation within the matrix. The top-right and bottom-right corners contain '0's. Above the matrix, 'second 2' indicates a time step or iteration. On the right, a graph is shown, also labeled 'second 2' at the top and bottom. This graph consists of several interconnected nodes, each containing the number '2', except for one node labeled '1' at the far left.  The '1' node is connected to a peach-colored '2' node, which receives data from another peach-colored '2' node.  A central peach-colored '2' node connects to another peach-colored '2' node at the bottom and a light peach-colored '2' node to the right.  The peach-colored nodes suggest a highlighted or active state, possibly indicating a current processing step or data flow direction within the graph.  The overall image likely illustrates different ways to represent and manipulate the same data, possibly within an algorithm or data structure related to a coding pattern.
Image represents a comparison of two data structure representations at 'second 3'.  On the left, a 3x3 matrix is shown, with some cells containing the number '2' and others containing '0'.  The shading of the cells indicates a gradient, with cells containing '2' having a peach color, the intensity of which varies slightly across the cells.  A left-pointing arrow is present between two '2's in the top row, indicating a data movement or operation. On the right, a graph is displayed, consisting of six nodes, each containing the number '2', connected by lines representing edges.  The nodes are arranged in a roughly square shape with a central node connected to four others.  Similar to the matrix, a left-pointing arrow connects two of the nodes, indicating data flow or a specific operation within the graph structure. Both representations show the same data at a specific time point ('second 3'), illustrating two different ways to visualize and potentially manipulate the same underlying data.

Regarding the implementation of this traversal, we know that level-order traversal is a modified version of BFS. So, we use a queue to implement this traversal. If you're unfamiliar with how this works, review the Rightmost Nodes of a Binary Tree problem from the Tree chapter, which implements a level-order traversal on a binary tree.

Now, let’s consider how we would handle a matrix which initially contains multiple infected cells.

Matrix with multiple infected cells
Consider the following example and its corresponding graph visualization:

Image represents a visual comparison of two data structure representations.  On the left, a 3x3 matrix is shown, containing numerical values.  Specifically, the matrix displays the numbers 1, 2, and 0, with the '2' values highlighted in a light peach color. The matrix is enclosed in a bold black border. To the right of the matrix, a large grey curly brace indicates a transformation or mapping.  Following the brace are two graphs, each composed of nodes connected by edges.  The nodes are circles containing the numbers 1 and 2; nodes with the number '2' are highlighted with an orange border. The first graph is a vertical chain of three nodes connected sequentially, with the top and bottom nodes connected to a '2' node each, forming a 'U' shape. The second graph is a 'T' shape, with a central node connected to a node above and two nodes below, one of which is a '2' node.  The graphs visually represent alternative ways to structure or interpret the data presented in the matrix, potentially illustrating a concept like adjacency matrices or graph representations of data.

In this example, multiple cells are initially infected, meaning there are multiple cells at level 0 of the traversal.

To handle this, we use a pattern known as multi-source BFS. Instead of adding just one cell to the queue before performing level-order traversal, we add every initially infected cell to the queue. This way, the traversal starts with all initially infected cells as level 0, allowing the infection process to begin simultaneously from multiple starting points:

Image represents a 3x5 matrix where each cell contains a numerical value.  The rows are labeled 0, 1, and 2, and the columns are labeled 0, 1, 2, 3, and 4.  The matrix cells contain the numbers 0, 1, and 2, with some cells highlighted in a light peach color.  These highlighted cells represent elements that are part of a queue, which is defined to the right of the matrix as `queue = [(0, 1) (2, 1) (2, 4)]`.  This queue notation indicates that the coordinates (row, column) of the highlighted cells are (0,1), (2,1), and (2,4).  The numbers within the matrix cells themselves do not appear to directly relate to the queue's structure; rather, the queue identifies specific cell locations within the matrix.

When we start with multiple cells in the queue, we can see what the level order process looks like:

Image represents a comparison of two data structure representations.  On the left, a 3x4 matrix is shown, with cells containing numerical values.  The top-left cell contains '1', followed by '2' in the adjacent cell, then '1', and finally '0' in the last cell of the top row. The second row contains '1', '0', '0', and '1'. The bottom row shows '1', '2', '0', and '2'.  Cells containing '2' are highlighted in a light peach color. The entire matrix is labeled 'second 0' at the top. On the right, three separate graphs are depicted, each consisting of interconnected nodes.  The nodes are circles containing the numbers '1' or '2'.  Nodes containing '2' are highlighted in orange. The first graph shows a vertical chain of three '1' nodes, with the bottom '1' node connected to an orange '2' node.  The top '1' node is connected to another '1' node, which is connected to an orange '2' node. The second graph is a simple chain of a '1' node connected to a '1' node, which is connected to an orange '2' node. The third graph is a vertical chain of a '1' node connected to a '1' node, which is connected to an orange '2' node. Each of these graphs is also labeled 'second 0' near the orange '2' node. The image likely illustrates different ways to represent the same underlying data, possibly showcasing a transformation from a matrix representation to a graph representation, potentially highlighting specific elements or patterns within the data.
Image represents a comparison of data representations.  The leftmost component is a 3x4 grid.  The top row shows the numbers 2, 2, 2, and 0, with arrows indicating left-to-right flow between the first three 2s. The second row contains 1, 0, 0, and 1. The bottom row shows 2, 2, 0, and 0, with an arrow indicating left-to-right flow between the two 2s.  Cells containing 2 are shaded peach. The label 'second 1' appears above the grid. The middle section displays a directed graph with nodes labeled 1 and 2.  Orange-bordered nodes represent the number 2, while unbordered nodes represent 1.  Arrows indicate the direction of flow. The top row shows a 2 pointing to a 2, which points to another 2. The bottom row shows a 2 pointing to a 2. The label 'second 1' appears above and below this graph. The rightmost section shows another directed graph. It consists of three nodes: a top node labeled 1, a middle node labeled 2 (orange-bordered), and a bottom node labeled 2 (orange-bordered). An arrow points from the bottom 2 to the middle 2, and a line connects the middle 2 to the top 1. The label 'second 1' appears to the right of this graph.  The image likely illustrates different ways to represent the same data, possibly showcasing a transition from a tabular representation to a graph representation, emphasizing data flow or relationships.
Image represents a comparison of data representation and flow using a matrix and graph structures.  The leftmost component is a 3x4 matrix showing numerical data;  peach-colored cells contain the value '2', while white cells contain '0'. Arrows within the matrix indicate data movement: a downward arrow in the leftmost column, an upward arrow in the rightmost column, and a leftward arrow in the central row. The top of the matrix is labeled 'second 2'.  To the right, two graph representations are shown. The first is a 2x2 grid of interconnected nodes, each node containing the value '2'.  The central nodes are highlighted in orange, and arrows indicate data flow between nodes. The text 'second 2' appears next to the central orange node. The second graph is a vertical arrangement of three nodes, with the central node in orange, and the value '2' in each node. Arrows indicate data flow from the bottom node to the central node and from the central node to the top node.  The text 'second 2' appears next to the central and top orange nodes.  The overall image demonstrates different ways to visualize and represent the same data, highlighting the movement or flow of the value '2' within each structure.

Unreachable uninfected cells
It's important to keep in mind that it's not always possible to infect all uninfected cells. We could encounter situations where it's impossible to reach an uninfected cell, such as in the following example:

The image represents a visual explanation of a coding pattern, likely related to a 2D array or matrix manipulation.  A 3x3 matrix is shown, with each cell containing a numerical value. The top-middle cell (row 1, column 2) contains the number '2' and is highlighted with a light peach/beige color, suggesting a focus or initial state. The other cells in the first row contain '1' and '1', while the remaining cells contain '0', except for the bottom-left cell (row 3, column 1), which contains '1'. A horizontal arrow points from the text 'this will never be infected' to the bottom-left cell containing '1'. This illustrates that despite the initial state of the matrix, the cell represented by the '1' in the bottom-left corner will remain unchanged, or 'never be infected,' implying a specific algorithm or condition prevents its modification. The overall structure suggests a demonstration of data immutability or a specific algorithm's behavior within a matrix context.

One way to account for this is to search through the matrix after level-order traversal and check if any 1s remain. However, there’s a cleaner way to accomplish this. First, in the loop where we search for all the level 0 infected cells, we can also count how many 1s there are:

Image represents a 3x3 matrix alongside a queue and a count. The matrix is indexed with row and column numbers from 0 to 2.  The matrix cells contain integers; a '2' is highlighted in a peach/light-orange color at row 0, column 1.  Other cells contain '1's and '0's. To the right of the matrix, a queue is defined as `queue = [(0, 1)]`, representing a coordinate pair (row, column) indicating the position of the highlighted '2'.  Finally, a count `ones = 3` indicates the total number of '1's present within the matrix.  The matrix, queue, and count likely represent an intermediate state in an algorithm, possibly related to searching or processing a 2D array.

Then, as we perform the level-order traversal, we can decrement this count for each uninfected cell we infect. This way, the value of this count after the traversal will represent the number of cells that remained uninfected. We return -1 if this count is greater than 0:

Image represents a 3x3 matrix with numerical values and arrows, alongside a decision box.  The matrix is labeled with row and column indices from 0 to 2. The top row (index 0) contains the values 2, 2, and 2, highlighted in a light peach color, with bidirectional arrows indicating movement or comparison between the adjacent '2's. The second row (index 1) contains only zeros (0), and the third row (index 2) contains a '1' in the first column and zeros in the remaining columns.  To the right of the matrix is a dashed-line box containing a condition: 'ones = 1 > 0 → return -1'. This box indicates a decision-making process; if the number of 'ones' in the matrix is greater than 0, the function returns a value of -1.  The matrix likely represents data or a state within a program, and the decision box shows a conditional return statement based on the data within the matrix.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
   # Count the total number of uninfected cells and add each infected cell to the
   # queue to represent level 0 of the level-order traversal.
   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))
   # Use level-order traversal to determine how long it takes to infect the
   # uninfected cells.
   while queue and ones > 0:
       # 1 second passes with each level of the matrix that's explored.
       seconds += 1
       for _ in range(len(queue)):
           r, c = queue.popleft()
           # Infect any neighboring 1s and add them to the queue to be processed in
           # the next level.
           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:
                   matrix[next_r][next_c] = 2
                   ones -= 1
                   queue.append((next_r, next_c))
   # If there are still uninfected cells left, return -1. Otherwise, return the time
   # passed.
   return seconds if ones == 0 else -1
    
def is_within_bounds(r: int, c: int, matrix: List[List[int]]) -> bool:
    return 0 <= r < len(matrix) and 0 <= c < len(matrix[0])
function matrixInfection(matrix) {
  const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
  const queue = [];
  let ones = 0;
  let seconds = 0;
  // Count the total number of uninfected cells and add each infected cell to the
  // queue to represent level 0 of the level-order traversal.
  for (let r = 0; r < matrix.length; r++) {
    for (let c = 0; c < matrix[0].length; c++) {
      if (matrix[r][c] === 1) {
        ones++;
      } else if (matrix[r][c] === 2) {
        queue.push([r, c]);
      }
    }
  }
  let head = 0;
  // Use level-order traversal to determine how long it takes to infect the
  // uninfected cells.
  while (head < queue.length && ones > 0) {
    // 1 second passes with each level of the matrix that's explored.
    seconds++;
    const levelSize = queue.length - head;
    for (let i = 0; i < levelSize; i++) {
      const [r, c] = queue[head++];
      // Infect any neighboring 1s and add them to the queue to be processed in
      // the next level.
      for (const [dr, dc] of dirs) {
        const nextR = r + dr;
        const nextC = c + dc;
        if (isWithinBounds(nextR, nextC, matrix) && matrix[nextR][nextC] === 1) {
          matrix[nextR][nextC] = 2;
          ones--;
          queue.push([nextR, nextC]);
        }
      }
    }
  }
  // If there are still uninfected cells left, return -1. Otherwise, return the time
  // passed.
  return ones === 0 ? seconds : -1;
}

function isWithinBounds(r, c, matrix) {
  return 0 <= r && r < matrix.length && 0 <= c && c < matrix[0].length;
}
public class Solution {
    public int matrixInfection(int[][] matrix) {
        int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        Queue<int[]> queue = new LinkedList<>();
        int ones = 0;
        int seconds = 0;
        // Count the total number of uninfected cells and add each infected cell to the
        // queue to represent level 0 of the level-order traversal.
        for (int r = 0; r < matrix.length; r++) {
            for (int c = 0; c < matrix[0].length; c++) {
                if (matrix[r][c] == 1) {
                    ones++;
                } else if (matrix[r][c] == 2) {
                    queue.offer(new int[]{r, c});
                }
            }
        }
        // Use level-order traversal to determine how long it takes to infect the
        // uninfected cells.
        while (!queue.isEmpty() && ones > 0) {
            // 1 second passes with each level of the matrix that's explored.
            seconds++;
            int size = queue.size();
            for (int i = 0; i < size; i++) {
                int[] curr = queue.poll();
                int r = curr[0], c = curr[1];
                // Infect any neighboring 1s and add them to the queue to be processed in
                // the next level.
                for (int[] d : dirs) {
                    int nextR = r + d[0], nextC = c + d[1];
                    if (isWithinBounds(nextR, nextC, matrix) && matrix[nextR][nextC] == 1) {
                        matrix[nextR][nextC] = 2;
                        ones--;
                        queue.offer(new int[]{nextR, nextC});
                    }
                }
            }
        }
        // If there are still uninfected cells left, return -1. Otherwise, return the time
        // passed.
        return ones == 0 ? seconds : -1;
    }

    private boolean isWithinBounds(int r, int c, int[][] matrix) {
        return 0 <= r && r < matrix.length && 0 <= c && c < matrix[0].length;
    }
}

Complexity Analysis

Time complexity: The time complexity of matrix_infection is O(m⋅n)O(m\cdot n), where mm denotes the number of rows, and nn denotes the number of columns. This is because in the worst case, every cell in the matrix is explored during level-order traversal.

Space complexity: The space complexity is O(m⋅n)O(m\cdot n), primarily due to the queue, which can store up to m⋅nm\cdot n cells.

CHAPTER 79

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.

The input is presented as an adjacency list, where graph[i] is a list of all nodes adjacent to node i.

Example:

Image represents a graph, specifically a cycle graph of five nodes (vertices) arranged in a circular pattern.  Nodes 0, 2, and 3 are depicted with light-blue circles and have a light-blue outline, while nodes 1 and 4 are shown with light-orange circles and an orange outline.  Each node contains a numerical label (0 through 4).  Undirected edges (lines) connect node 0 to node 1, node 1 to node 2, node 2 to node 3, node 3 to node 4, and node 4 back to node 0, forming a closed loop.  No other connections exist between the nodes. The color-coding of the nodes might represent a classification or grouping, with nodes 1 and 4 belonging to one group and nodes 0, 2, and 3 to another, although the specific meaning of this color distinction is not explicitly provided in the image itself.
Example
Input: graph = [[1, 4], [0, 2], [1], [4], [0, 3]]
Output: True

Intuition

Before diving into a solution, let’s first understand what makes a graph bipartite. If the nodes of a graph can be divided into two distinct sets, with the edges only running between nodes from different sets, then the graph is bipartite. We can visually rearrange the nodes of the following graph to demonstrate this:

Image represents a graph transformation illustrating a partitioning or grouping of nodes.  The left side shows a single, connected graph with five nodes (0, 1, 2, 3, 4) arranged in a pentagon shape, with each node connected to its adjacent neighbors. A thick arrow points to the right, indicating a transformation. The right side displays the same five nodes partitioned into two sets, labeled 'set 1:' and 'set 2:'.  Nodes 0, 2, and 3 are in 'set 1:' and are depicted with light-blue circles. Nodes 1 and 4 are in 'set 2:' and are shown as light-orange circles.  Connections between nodes are maintained, but now edges connect nodes across the two sets. Specifically, node 0 connects to node 1, node 0 connects to node 4, node 2 connects to node 1, and node 3 connects to node 4.  The transformation demonstrates how a single graph can be divided into subsets while preserving some connectivity between the subsets.

With a graph that isn’t bipartite, it’s impossible to arrange the nodes this way without an edge existing between two nodes of the same set:

Image represents a diagram illustrating a graph-partitioning problem.  The leftmost element shows a pentagon-shaped graph with nodes labeled 0 through 4, connected in a cyclical manner.  Two larger sections on the right depict attempts to partition this pentagon's nodes into two sets, labeled 'set 1' and 'set 2'.  Nodes in 'set 1' are light blue, and nodes in 'set 2' are light orange.  Thick black arrows point from the pentagon to these two bipartite graph attempts. The top right bipartite graph shows a connection between nodes 2 (set 1) and 3 (set 1), indicated by a red line, highlighting an edge between two nodes of the same set. The bottom right bipartite graph similarly shows a connection between nodes 4 (set 2) and 3 (set 2), also indicated by a red line, again demonstrating an edge between two nodes of the same set.  A dashed red curve connects these problematic edges, with accompanying red text stating, 'no way to avoid an edge between two nodes of the same set,' indicating the impossibility of perfectly partitioning this specific graph into two sets without violating the constraint of no intra-set edges.

To determine if a graph is bipartite, we can use graph coloring, where we attempt to color one set of nodes with one color, and the other set of nodes with another color, while ensuring no adjacent nodes share the same color. Let’s explore this idea.

Graph coloring
Let's use blue and orange in our coloring process. One potential strategy is: for each node we color blue, color all of its neighbors orange, and vice versa. Most traversal algorithms allow us to color neighboring nodes in this way. In this explanation, we use DFS.

Let’s try applying this coloring process to the example below and see how it works:

Image represents a graph, specifically a cycle graph or ring graph, consisting of five nodes (vertices) numbered 0 through 4 arranged in a circular pattern.  Each node is represented by a circle containing its numerical label.  Straight lines connect each node to its immediate neighbors, forming a closed loop.  Node 0 is connected to node 1, node 1 is connected to node 2, node 2 is connected to node 3, node 3 is connected to node 4, and node 4 is connected back to node 0, completing the cycle.  There are no other connections or directional arrows present; the connections are undirected, implying a symmetrical relationship between connected nodes.  No additional information, such as URLs or parameters, is included within the image.

Start by coloring node 0 blue:

Image represents a graph data structure, specifically a simple undirected graph, depicted as a pentagon.  Five nodes, labeled numerically as 0, 1, 2, 3, and 4, are arranged in a circular pattern, connected by straight lines representing edges.  Node 0 is highlighted with a light blue outline, and a small rectangular box labeled 'node' points to it with an arrow, indicating that the circles represent nodes in the graph.  The edges connect node 0 to node 1, node 1 to node 2, node 2 to node 3, node 3 to node 4, and node 4 back to node 0, forming a closed loop. No direction is indicated on the edges, signifying an undirected relationship between the connected nodes.  No additional information, such as weights or labels on the edges, is present.

Using DFS, we explore the neighbors of node 0, starting with node 1. All of its neighbors need to be colored orange, so let’s make a DFS call to node 1 to color it orange:

Image represents a simple graph, specifically a cycle graph with five nodes.  The nodes are represented by circles, each containing a numerical label (0, 1, 2, 3, and 4). Node 0 is highlighted with a light blue border, and node 1 is highlighted with a light orange border.  The nodes are connected by straight lines representing edges, forming a closed loop.  The connections are: node 0 connects to node 1; node 1 connects to node 2; node 2 connects to node 3; node 3 connects to node 4; and node 4 connects to node 0.  A rectangular box labeled 'node' points with an arrow to node 1, indicating that the circles are referred to as nodes within the context of the diagram. No other information, such as URLs or parameters, is present in the image.

Let’s continue doing this for the next few nodes in the DFS process:

Image represents a step-by-step process demonstrating a graph coloring algorithm.  The initial state shows a pentagon-shaped graph with five nodes (0, 1, 2, 3, 4) connected by edges. Nodes 0 and 2 are colored light blue, node 1 and 3 are colored light orange, and node 4 is uncolored (black). An arrow points from a gray box labeled 'node' to node 2, indicating a selection or assignment. The next stage shows the same graph, but now node 2 is colored light blue, and node 3 is colored light orange, with an arrow pointing from a 'node' box to node 3.  The final stage highlights nodes 0 and 4, both colored light blue, within a dashed gray oval, with a red curved arrow pointing to the oval and labeled 'same color,' indicating that these nodes share the same color.  The overall process illustrates a coloring algorithm iteratively assigning colors to nodes, aiming for nodes connected by edges to have different colors.

Above, we encountered an issue. We needed to color node 4 blue, but one of node 4’s neighbors, node 0, is also colored blue. This means the graph cannot be colored using our graph coloring strategy. In other words, the graph is not bipartite.


We now have a strategy to confirm if a graph is bipartite using the two-coloring technique. But how can we be sure that it always works? We’ve been coloring adjacent nodes in alternating colors from the beginning of the DFS. This means if we encounter a situation where two adjacent nodes are the same color, it means there’s no way to color the graph differently, since we’ve been following the rule of using different colors for neighboring nodes all along.

Handling multiple components
Keep in mind the input isn't necessarily a graph that's fully connected. It could be a graph with multiple components, such as this:

Image represents two separate graphs, each composed of nodes and edges. The first graph is a square, with four nodes labeled 0, 1, 2, and 3.  Node 0 is connected to node 1 via a single edge, node 1 is connected to node 3, node 3 is connected to node 2, and node 2 is connected to node 0, forming a closed loop.  The second graph is a simple line graph with two nodes, labeled 4 and 5, connected by a single edge.  Both graphs are visually distinct and unconnected to each other; they are presented side-by-side without any implied relationship or data flow between them.  The nodes are represented as circles containing numerical labels, and the edges are represented as straight lines connecting the nodes.

As such, we need to ensure we color all components of the graph by calling DFS on every uncolored node:

Image represents two separate depictions of Depth-First Search (DFS) traversal on two different graphs.  The left side, labeled 'dfs(0):', shows a graph with four nodes (0, 1, 2, 3) connected to form a square. Node 0 (light blue circle) is connected to node 1 (light orange circle), node 1 is connected to node 3 (light blue circle), node 3 is connected to node 2 (light orange circle), and node 2 is connected to node 0.  The right side, labeled 'dfs(4):', shows a simpler graph with two nodes (4 and 5), both light blue and light orange respectively, connected by a single edge.  The labels 'dfs(0):' and 'dfs(4):' indicate that the DFS algorithm is starting at node 0 in the first graph and node 4 in the second graph.  The color-coding (light blue and light orange) likely represents a distinction in node visitation order or other properties within the DFS algorithm, though the specific meaning isn't explicitly defined in the image itself.

If we can confirm that all components can be colored using two colors, the graph is bipartite. However, if any of these components cannot be colored this way, the graph is not bipartite.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

In this implementation, we color the nodes blue and orange using the numbers 1 and -1 to represent these colors, respectively. To keep track of each node's color, we use an array called colors, initialized with all 0s, where 0 represents an unvisited node. As we explore the graph using DFS, we update the colors array by setting each node to either 1 (blue) or -1 (orange).

def bipartite_graph_validation(graph: List[List[int]]) -> bool:
    colors = [0] * len(graph)
    # Determine if each graph component is bipartite.
    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 the current neighbor has the same color as the current node, the graph is
        # not bipartite.
        if colors[neighbor] == color:
            return False
        # If the current neighbor is not colored, color it with the other color and
        # continue the DFS.
        if colors[neighbor] == 0 and not dfs(neighbor, -color, graph, colors):
            return False
    return True
function bipartiteGraphValidation(graph) {
  const colors = new Array(graph.length).fill(0);
  // Determine if each graph component is bipartite.
  for (let i = 0; i < graph.length; i++) {
    if (colors[i] === 0 && !dfs(i, 1, graph, colors)) {
      return false;
    }
  }
  return true;
}

function dfs(node, color, graph, colors) {
  colors[node] = color;
  for (const neighbor of graph[node]) {
    // If the current neighbor has the same color as the current node, the graph is
    // not bipartite.
    if (colors[neighbor] === color) {
      return false;
    }
    // If the current neighbor is not colored, color it with the other color and
    // continue the DFS.
    if (colors[neighbor] === 0 && !dfs(neighbor, -color, graph, colors)) {
      return false;
    }
  }
  return true;
}
public class Solution {
    public boolean bipartiteGraphValidation(int[][] graph) {
        int[] colors = new int[graph.length];
        // Determine if each graph component is bipartite.
        for (int i = 0; i < graph.length; i++) {
            if (colors[i] == 0 && !dfs(i, 1, graph, colors)) {
                return false;
            }
        }
        return true;
    }

    private boolean dfs(int node, int color, int[][] graph, int[] colors) {
        colors[node] = color;
        for (int neighbor : graph[node]) {
            // If the current neighbor has the same color as the current node, the graph is
            // not bipartite.
            if (colors[neighbor] == color) {
                return false;
            }
            // If the current neighbor is not colored, color it with the other color and
            // continue the DFS.
            if (colors[neighbor] == 0 && !dfs(neighbor, -color, graph, colors)) {
                return false;
            }
        }
        return true;
    }
}

Complexity Analysis

Time complexity: The time complexity of bipartite_graph_validation is O(n+e)O(n+e) where nn denotes the number of nodes and ee denotes the number of edges. This is because we explore all nodes in the graph and traverse across ee edges during DFS.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the recursive call stack, which can grow as large as nn. In addition, the colors array also contributes O(n)O(n) space.

CHAPTER 80

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 (up, down, left, or right) to the previous one.

Example:

Image represents a 3x3 grid, visually similar to a Sudoku grid, with numerical values (1, 3, 4, 5, 7, 8, 9, 2, 4) placed within its cells.  The grid is labeled with row and column indices (0, 1, 2) along its top and left side, respectively.  A path is highlighted, visually represented by a peach-colored fill encompassing specific cells (containing 1, 3, 4, 5, 8), and orange arrows indicate the direction of movement along this path. The path starts at cell (0,0) with the value 1, moves down to cell (1,0) with the value 3, then right to cell (1,1) with the value 4, then up to cell (0,1) with the value 5, and finally right to cell (0,2) with the value 8. The remaining cells (containing 7, 9, 2, 4) are not part of the highlighted path.
Example
Output: 5

Intuition

In this problem, we’re tasked with finding the longest strictly increasing path. Note, “strictly” means the path cannot include any two cells of equal value.

From any cell, we can move to a neighboring cell if that neighbor has a larger value than the current cell. We can map this relationship between cells as nodes in a graph, with edges representing moves from one cell to a higher-value neighboring cell:

Image represents a comparison of two data structures representing the same underlying data.  On the left is a 3x3 matrix, indexed from 0 to 2 on both axes.  The matrix cells contain the integers 1, 5, 8, 3, 4, 4, 7, 9, and 2, arranged row-wise from top to bottom and left to right.  On the right is a directed acyclic graph (DAG) where each node is a circle containing one of the integers from the matrix.  Arrows indicate the direction of flow between nodes. The flow follows a pattern mirroring the matrix's row-major order: node 1 connects to 5, then 5 to 8; node 1 also connects to 3, then 3 to 7; node 3 connects to 4, then 4 to 9; and finally, node 4 connects to 2, and node 2 connects to 8.  Essentially, the DAG visually represents the sequential relationships between the numbers as they appear in the matrix, showing a possible traversal path through the data.

Upon closer inspection, we notice this is a directed acyclic graph (DAG). Let’s break down what this means:

  1. The graph is directed because we can go from a smaller cell to a larger one, but not the other way around.

  2. The graph is acyclic because it’s not possible to return to a smaller, previous value in the path because the path only extends to larger cells.

This highlights that finding the longest increasing path in a matrix is effectively the same as finding the longest path in a DAG.

Image represents a comparison of two visualizations of the same data flow.  The left side shows a 3x3 grid containing the numbers 1, 3, 5, 4, 8, 7, 9, 2, and 4 arranged in a matrix.  A highlighted path, colored peach, traces a sequence through the grid: starting at 1, moving down to 3, then right to 4, up to 5, and finally right to 8.  Orange arrows indicate the direction of flow along this path.  The right side displays a directed graph where each node is a circle containing one of the numbers from the grid.  The nodes are interconnected by directed edges (arrows) mirroring the path shown in the grid.  The same peach color highlights the corresponding path in the graph, showing the flow from node 1 to 5 to 8, then down to 3, and then to 4.  The numbers in both representations are identical, and the connections between them are consistent, demonstrating two different ways to visualize a sequential data flow or process.

With this in mind, let’s figure out how to traverse the matrix.

Traversing the matrix
One strategy is to find the length of the longest path that starts at each cell and return the maximum of these lengths. To do this, we need a way to explore all paths which extend from each cell, and return the longest one. Many traversal algorithms allow us to do this. In this explanation, we use DFS since it has a slightly simpler implementation for finding the longest path.


Let’s see how this works over an example. Start by performing DFS at the first cell, (0, 0):

Image represents a 3x3 matrix, labeled as `dfs(0,0):`,  with rows and columns indexed from 0 to 2.  The matrix contains numerical values; specifically, the top-left cell (row 0, column 0) contains the number 1, encircled in a light pink or peach circle.  The remaining cells contain the numbers 5, 8, 3, 4, 4, 7, 9, and 2, arranged sequentially within the grid.  The matrix's structure suggests a data representation, possibly for a graph or a 2D array, with the `dfs(0,0)` label potentially indicating a depth-first search algorithm starting at the coordinates (0,0), which corresponds to the cell containing the circled '1'.  No URLs or parameters are visible. The overall visual presentation is simple and clear, focusing on the numerical data within the grid structure.

To determine the longest path starting from this cell, we need to explore its higher-value neighboring cells. By making a DFS call to each of these larger neighbors, we can find the lengths of the paths that start from them:

Image represents a 3x3 matrix illustrating Depth-First Search (DFS) traversal of a 2D array.  The matrix contains numerical values (1, 5, 8, 3, 4, 4, 7, 9, 2) arranged in rows and columns, with row and column indices labeled 0, 1, and 2 respectively.  Two cells are highlighted in peach: the cell at (0,0) containing the value '1', and the cell at (1,0) containing the value '3'.  An orange arrow originates from the cell (0,0) pointing right to the cell (0,1) containing the value '5', indicating a traversal step. Another orange arrow points downwards from the cell (0,0) to the cell (1,0), showing another traversal step.  The text 'dfs(0,1)' is positioned above the matrix, likely indicating the starting point of the DFS algorithm at row 0, column 1. Similarly, 'dfs(1,0)' is placed to the left, suggesting another potential starting point at row 1, column 0. The overall image demonstrates a visual representation of the DFS algorithm's path through the matrix, highlighting the order of cell visits.

The largest path starting at cell (0, 0) will be equal to whichever of these DFS calls returns a larger path, plus 1 to include cell (0, 0) itself.

Note that since this matrix resembles a DAG, we don’t need to mark cells as visited, since it’s not possible to cycle back to previously visited cells.


The pseudocode for finding the length of the longest path starting at a given cell is provided below:

def dfs(cell):
    max_path = 1
    for each neighbor of the current cell:
        if value of neighbor > value of cell:
            max_path = max(max_path, dfs(neighbor) + 1)
    return max_path
function dfs(cell) {
  let maxPath = 1;
  for (const neighbor of getNeighbors(cell)) {
    if (neighbor.value > cell.value) {
      maxPath = Math.max(maxPath, dfs(neighbor) + 1);
    }
  }
  return maxPath;
}
int dfs(Cell cell) {
    int maxPath = 1;
    for (Cell neighbor : cell.neighbors) {
        if (neighbor.val > cell.val) {
            maxPath = Math.max(maxPath, dfs(neighbor) + 1);
        }
    }
    return maxPath;
}

To get the final result, we just need to call DFS for every cell in the matrix, ensuring we find the lengths of the longest paths starting from each cell. The maximum of these lengths is equal to the length of the longest increasing path.


An important thing to notice is that it’s possible for DFS to be called on the same cell multiple times. We can see this below, where we call DFS on cell (2, 1) on three different occasions:

Image represents three 3x3 matrices illustrating a Depth-First Search (DFS) algorithm's traversal. Each matrix is labeled with `dfs(row, column):`, indicating the starting point of the search.  The first matrix, `dfs(2,0)`, shows a 3x3 grid with numbers; the cell at row 2, column 0 (containing '7') is highlighted in peach, and an orange arrow points to the adjacent cell at row 2, column 1 (containing '9'), which is highlighted in light blue. The second matrix, `dfs(1,1)`, shows the same grid, but the highlighted cell is at row 1, column 1 (containing '4'), with an orange arrow pointing down to the light blue cell at row 2, column 1 (containing '9').  Finally, the third matrix, `dfs(2,2)`, shows the same grid with the highlighted cell at row 2, column 2 (containing '2'), and an orange arrow pointing left to the light blue cell at row 2, column 1 (containing '9').  The arrows indicate the direction of traversal during the DFS algorithm, highlighting the path taken from the starting cell.  The light blue cell represents the currently visited cell during the search.

After we first calculate the longest path starting at a certain cell, we don’t need to calculate it again for that cell. This suggests we should use memoization: by storing the DFS result of each cell, we can just return the saved result whenever a DFS call is made to that cell again.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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)]
    # Find the longest increasing path starting at each cell. The maximum of these is
    # equal to the overall longest increasing path.
    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)]
    # The longest path starting at the current cell is equal to the longest path of
    # its larger neighboring cells, plus 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] > matrix[r][c]:
            max_path = max(max_path, 1 + dfs(next_r, next_c, matrix, memo))
    memo[r][c] = max_path
    return max_path
    
def is_within_bounds(r: int, c: int, matrix: List[List[int]]) -> bool:
    return 0 <= r < len(matrix) and 0 <= c < len(matrix[0])
function longestIncreasingPath(matrix) {
  if (!matrix || matrix.length === 0) {
    return 0;
  }
  let res = 0;
  const m = matrix.length;
  const n = matrix[0].length;
  const memo = Array.from({ length: m }, () => new Array(n).fill(0));
  // Find the longest increasing path starting at each cell. The maximum of these is
  // equal to the overall longest increasing path.
  for (let r = 0; r < m; r++) {
    for (let c = 0; c < n; c++) {
      res = Math.max(res, dfs(r, c, matrix, memo));
    }
  }
  return res;
}

function dfs(r, c, matrix, memo) {
  if (memo[r][c] !== 0) {
    return memo[r][c];
  }
  let maxPath = 1;
  const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
  // The longest path starting at the current cell is equal to the longest path of
  // its larger neighboring cells, plus 1.
  for (const [dr, dc] of dirs) {
    const nextR = r + dr;
    const nextC = c + dc;
    if (isWithinBounds(nextR, nextC, matrix) && matrix[nextR][nextC] > matrix[r][c]) {
      maxPath = Math.max(maxPath, 1 + dfs(nextR, nextC, matrix, memo));
    }
  }
  memo[r][c] = maxPath;
  return maxPath;
}

function isWithinBounds(r, c, matrix) {
  return 0 <= r && r < matrix.length && 0 <= c && c < matrix[0].length;
}
public class Solution {
    public int longestIncreasingPath(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            return 0;
        }
        int res = 0;
        int m = matrix.length, n = matrix[0].length;
        int[][] memo = new int[m][n];
        // Find the longest increasing path starting at each cell. The maximum of these is
        // equal to the overall longest increasing path.
        for (int r = 0; r < m; r++) {
            for (int c = 0; c < n; c++) {
                res = Math.max(res, dfs(r, c, matrix, memo));
            }
        }
        return res;
    }

    private int dfs(int r, int c, int[][] matrix, int[][] memo) {
        if (memo[r][c] != 0) {
            return memo[r][c];
        }
        int maxPath = 1;
        int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        // The longest path starting at the current cell is equal to the longest path of
        // its larger neighboring cells, plus 1.
        for (int[] d : dirs) {
            int nextR = r + d[0], nextC = c + d[1];
            if (isWithinBounds(nextR, nextC, matrix) && matrix[nextR][nextC] > matrix[r][c]) {
                maxPath = Math.max(maxPath, 1 + dfs(nextR, nextC, matrix, memo));
            }
        }
        memo[r][c] = maxPath;
        return maxPath;
    }

    private boolean isWithinBounds(int r, int c, int[][] matrix) {
        return 0 <= r && r < matrix.length && 0 <= c && c < matrix[0].length;
    }
}

Complexity Analysis

Time complexity: The time complexity of longest_increasing_path is O(m⋅n)O(m\cdot n) where mm denotes the number of rows, and nn denotes the number of columns. This is because each cell of the matrix is visited at most twice: once in the longest_increasing_path function, and during DFS where each cell is visited at most once due to memoization.

Space complexity: The space complexity is O(m⋅n)O(m\cdot n) primarily due to the recursive call stack during DFS, and the memoization table, both of which can grow to m⋅nm\cdot n in size.

CHAPTER 81

Shortest Transformation Sequence

Given two words, start and end, and a dictionary containing an array of words, return the length of the shortest transformation sequence to transform start to end. A transformation sequence is a series of words in which:

  • Each word differs from the preceding word by exactly one letter.
  • Each word in the sequence exists in the dictionary.

If no such transformation sequence exists, return 0.

Example:

Example
Input: start = 'red', end = 'hit',
       dictionary = [
            'red', 'bed', 'hat', 'rod', 'rad', 'rat', 'hit', 'bad', 'bat'
       ]
Output: 5

Constraints:

  • All words are the same length.
  • All words contain only lowercase English letters.
  • The dictionary contains no duplicate words.

Intuition

In a transformation sequence, each transformation involves changing one letter of a word to get another word. Consider the following example:

Image represents a simple data structure used in a coding pattern, likely related to string manipulation or search algorithms.  The image displays two lines of code-like text. The top line defines two variables: `start` which is assigned the string value 'red', and `end` which is assigned the string value 'hit'. The bottom line declares a variable named `dictionary` and assigns it a list or array containing a series of strings: 'red', 'bed', 'hat', 'rod', 'rad', 'rat', 'hit', 'bad', and 'bat'.  There are no visible connections or information flow between the variables; they are simply defined and initialized with their respective values. The arrangement suggests that the `start` and `end` variables might represent the beginning and ending points of a search within the `dictionary`, although this is not explicitly shown.

We know our transformation sequence should start with the word “red”, but how can we identify subsequent words in the sequence? The words that could immediately follow “red” are “bed”, “rad”, or “rod”, since each differs from “red” by one letter:

Image represents a tree-like data structure, likely illustrating a concept in graph theory or a specific coding pattern.  The structure consists of four circular nodes connected by lines. A central node labeled 'red' is connected to three subordinate nodes: 'bed' on the left, 'rad' below, and 'rod' on the right.  The lines represent connections or relationships between the nodes, suggesting a hierarchical or parent-child relationship where 'red' is the parent node and 'bed,' 'rad,' and 'rod' are its children. No other information, such as URLs or parameters, is present within the image. The arrangement visually demonstrates a simple tree structure with a single root node ('red') and three leaf nodes ('bed,' 'rad,' and 'rod').

Similarly, we can make a direct connection between each word and its one-letter-off neighbors:

Image represents a directed acyclic graph (DAG) where nodes represent words and edges represent relationships between them.  The topmost node is labeled 'red,' connected to three nodes below: 'bed,' 'rad,' and 'rod.'  'Bed' connects to 'bad,' which in turn connects to 'bat.' 'Rad' connects to both 'bad' and 'rat.' 'Rod' has no outgoing connections. 'Rat' connects to both 'bat' and 'hat,' and 'hat' connects to 'hit.'  The graph visually depicts a hierarchical or tree-like structure, although not strictly a tree due to the connection between 'rad' and 'rat.'  No URLs or parameters are present; the only information conveyed is the textual labels on the nodes and the connections between them, suggesting a potential relationship between the words, perhaps based on phonetic similarity or other linguistic criteria.

This structure resembles a graph, indicating that finding the shortest transformation sequence involves finding the shortest path in this graph from the start node (“red”) to the end node (“hit”):

The image represents a directed graph illustrating a word transformation problem.  The graph consists of nodes representing words ('red,' 'bed,' 'bad,' 'bat,' 'rad,' 'rod,' 'rat,' 'hat,' 'hit') and edges representing single-letter changes between words.  Nodes are depicted as circles, with 'red' and 'hit' in light blue, 'rad,' 'rat,' and 'hat' in orange, and the rest in black.  Edges are shown as arrows; grey arrows connect words with one letter difference, while orange arrows highlight the shortest transformation path from 'red' to 'hit'. The path follows the sequence: 'red' → 'rad' → 'rat' → 'hat' → 'hit'.  To the right of the graph, the text 'shortest path → shortest transformation sequence' explains that finding the shortest path in the graph corresponds to finding the shortest sequence of word transformations.

Finding the shortest path
When tasked with a graph problem that involves finding the shortest path between two nodes, BFS should come to mind. From the start node, BFS works by exploring all neighbors at a distance of 1 from the start node, followed by exploring nodes at a distance of 2, and so on. This means that once we find the end node, we’ve reached it via the shortest possible distance.

So, let’s use level-order traversal, a variant of BFS, to find the shortest path in this graph, where each level we traverse represents nodes that are a specific distance from the start node:

Image represents a three-stage visualization of a breadth-first search (BFS) algorithm on a graph.  The graph consists of nodes representing words ('red,' 'bed,' 'rad,' 'rod,' 'bad,' 'rat,' 'bat,' 'hat,' 'hit') connected by edges. Stage 1 shows the initial state with the node 'red' (highlighted in orange) at distance 0 from itself.  Stages 2 and 3 depict subsequent iterations of the BFS.  In each stage, nodes are colored orange as they are visited, with an associated 'dist' value indicating their distance from the starting node 'red'.  Grey edges represent existing connections in the graph, while orange arrows show the traversal path during the BFS algorithm.  Stage 2 shows nodes 'bed' and 'rad' (orange) at distance 1 from 'red,' connected by orange arrows labeled 'dist=1'. Stage 3 shows nodes 'bad' and 'rat' (orange) at distance 2 from 'red,' connected by orange arrows labeled 'dist=2'.  The algorithm progresses layer by layer, exploring nodes at increasing distances from the source node 'red'.
Image represents two directed acyclic graphs illustrating a shortest path algorithm.  Both graphs share a common upper structure consisting of three levels. The top level contains a single node labeled 'red,' connected to three nodes ('bed,' 'rad,' 'rod') on the second level. The second level nodes are connected to nodes on the third level ('bad,' 'rat').  The difference lies in the lower structure. In graph 4, 'bad' connects to an orange-highlighted node 'bat,' and 'rat' connects to an orange-highlighted node 'hat.'  'bat' and 'hat' are connected to a black-highlighted node 'hit.'  The edges connecting 'bad' to 'bat' and 'rat' to 'hat' are labeled 'dist=3,' indicating a distance of 3. In graph 5, the upper structure remains identical, but the lower structure shows 'bad' and 'rat' connecting to 'bat' and 'hat' respectively, with additional connections between 'bad' and 'bat,' and 'bat' and 'hat.'  'hat' connects to 'hit,' and the edge is labeled 'dist=4,' indicating a distance of 4.  The orange highlighting suggests a path, and the black highlighting indicates a target node. The numbers '4' and '5' above each graph likely represent steps or iterations in the algorithm.

As we can see, we managed to find the end node via the shortest path. To return the length of this path, we return dist, plus 1, to include the start node. If you’re unfamiliar with how level-order traversal works, review the Matrix Infection problem in this chapter.

Note that during traversal, we need to ensure we don’t revisit previously traversed strings. By storing the visited strings in a hash set, we can quickly check if a string was already visited.

Now, let’s figure out how to build the above graph.

Building the graph
We can represent the graph as an adjacency list, where each word has a list of all of its neighboring words:

Image represents a graph data structure depicted in a textual format.  The text is labeled 'graph:' and presents a series of key-value pairs. Each key is a string enclosed in double quotes (e.g., 'red'), representing a node in the graph.  The value associated with each key is a list of strings, also enclosed in square brackets, representing the nodes directly connected to the key node (its neighbors). For instance, 'red': [ 'bed', 'rad', 'rod'] indicates that the node 'red' is connected to nodes 'bed', 'rad', and 'rod'.  Similarly, 'bed' connects to 'red' and 'bad', 'hat' connects to 'rat', 'bat', and 'hit', and 'hit' connects only to 'hat'.  The ellipsis (...) suggests that there might be more key-value pairs not shown in the image, implying a larger graph.  The overall structure represents an adjacency list representation of a directed or undirected graph, depending on whether the connections are considered bidirectional.

The challenge here is finding each word’s neighbors. Consider the word “red”. One way we can identify all words that are one letter different from “red” is to generate all possible words by changing each letter in “red” to every other letter in the alphabet. For each of these generated words, we check if it exists in the dictionary. If it does, it’s a neighbor of “red”:

Image represents a tree-like data structure, possibly illustrating a coding pattern related to string manipulation or data partitioning.  The root node is labeled 'red'.  Three branches emanate from the root, labeled '_ed', 'r_d', and 're_'. Each branch further subdivides into a series of nodes, indicated by right-pointing arrows.  The '_ed' branch contains nodes 'aed', 'bed', 'ced', and 'zed', suggesting an alphabetical sequence. Similarly, 'r_d' branch shows 'rad', 'rod', and 'rzd', and the 're_' branch displays 'rea', 'reb', 'rec', and 'rez'.  The ellipses (...) indicate potential continuation of the sequence in each branch, implying scalability.  The color coding (orange for some labels) might highlight specific elements or operations within the pattern, though the exact meaning is not explicitly defined in the image. The overall structure suggests a hierarchical breakdown of the root string 'red' into substrings based on a defined pattern.

To make checking the dictionary more efficient, we can store the words from the dictionary in a hash set, enabling us to verify the existence of a word in constant time.

Note that before we build the graph, it’s important to check if start or end exists in the dictionary. If either is missing, we can immediately return 0, since each word in the sequence needs to be in the dictionary.

Space optimization
The purpose of the above adjacency list is to facilitate graph traversal. However, we can avoid building it altogether by leveraging the fact that each word in the dictionary is only visited once during the BFS traversal, indicating we only ever need access to the neighbors of each word once.

If we had a way to generate each word’s neighbors when needed during BFS traversal, we could avoid creating the adjacency list, and save some space that would otherwise be taken up by it.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
    # Use level-order traversal to find the shortest path from the start word to the
    # end word.
    while queue:
        for _ in range(len(queue)):
            curr_word = queue.popleft()
            # If we found the end word, we've reached it via the shortest path.
            if curr_word == end:
                return dist + 1
            # Generate all possible words that have a one-letter difference to the
            # current word.
            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' exists in the dictionary, it's a neighbor of the
                    # current word. If it's unvisited, add it to the queue to be
                    # processed in the next level.
                    if next_word in dictionary_set and next_word not in visited:
                        visited.add(next_word)
                        queue.append(next_word)
        dist += 1
    # If there is no way to reach the end node, then no path exists.
    return 0
function shortestTransformationSequence(start, end, dictionary) {
  const dictionarySet = new Set(dictionary);
  if (!dictionarySet.has(start) || !dictionarySet.has(end)) {
    return 0;
  }
  if (start === end) {
    return 1;
  }
  const lowerCaseAlphabet = 'abcdefghijklmnopqrstuvwxyz';
  const queue = [start];
  const visited = new Set([start]);
  let dist = 0;
  let head = 0;
  // Use level-order traversal to find the shortest path from the start word to the
  // end word.
  while (head < queue.length) {
    const levelSize = queue.length - head;
    for (let j = 0; j < levelSize; j++) {
      const currWord = queue[head++];
      // If we found the end word, we've reached it via the shortest path.
      if (currWord === end) {
        return dist + 1;
      }
      // Generate all possible words that have a one-letter difference to the
      // current word.
      for (let i = 0; i < currWord.length; i++) {
        for (const c of lowerCaseAlphabet) {
          const nextWord = currWord.slice(0, i) + c + currWord.slice(i + 1);
          // If 'next_word' exists in the dictionary, it's a neighbor of the
          // current word. If it's unvisited, add it to the queue to be
          // processed in the next level.
          if (dictionarySet.has(nextWord) && !visited.has(nextWord)) {
            visited.add(nextWord);
            queue.push(nextWord);
          }
        }
      }
    }
    dist++;
  }
  // If there is no way to reach the end node, then no path exists.
  return 0;
}
public class Solution {
    public int shortestTransformationSequence(String start, String end, List<String> dictionary) {
        Set<String> dictionarySet = new HashSet<>(dictionary);
        if (!dictionarySet.contains(start) || !dictionarySet.contains(end)) {
            return 0;
        }
        if (start.equals(end)) {
            return 1;
        }
        Queue<String> queue = new LinkedList<>();
        Set<String> visited = new HashSet<>();
        queue.offer(start);
        visited.add(start);
        int dist = 0;
        // Use level-order traversal to find the shortest path from the start word to the
        // end word.
        while (!queue.isEmpty()) {
            int levelSize = queue.size();
            for (int k = 0; k < levelSize; k++) {
                String currWord = queue.poll();
                // If we found the end word, we've reached it via the shortest path.
                if (currWord.equals(end)) {
                    return dist + 1;
                }
                // Generate all possible words that have a one-letter difference to the
                // current word.
                char[] chars = currWord.toCharArray();
                for (int i = 0; i < chars.length; i++) {
                    char originalChar = chars[i];
                    for (char c = 'a'; c <= 'z'; c++) {
                        chars[i] = c;
                        String nextWord = new String(chars);
                        // If 'next_word' exists in the dictionary, it's a neighbor of the
                        // current word. If it's unvisited, add it to the queue to be
                        // processed in the next level.
                        if (dictionarySet.contains(nextWord) && !visited.contains(nextWord)) {
                            visited.add(nextWord);
                            queue.offer(nextWord);
                        }
                    }
                    chars[i] = originalChar;
                }
            }
            dist++;
        }
        // If there is no way to reach the end node, then no path exists.
        return 0;
    }
}

Complexity Analysis

Time complexity: The time complexity of shortest_transformation_sequence is O(n⋅L2)O(n\cdot L^2), where nn denotes the number of words in the dictionary, and LL denotes the length of a word. Here’s why:

  • Creating a hash set containing all the words in the dictionary takes O(n⋅L)O(n\cdot L) time, because hashing each of the nn words takes O(L)O(L) time.

  • Level-order traversal processes at most nn words from the dictionary. At each of these words, we generate up to 26L26L transformations, and it takes O(L)O(L) time to check if a transformation exists in the visited and dictionary_set hash sets, and to enqueue it. This means level-order traversal takes approximately O(n⋅26L⋅L)=O(n⋅L2)O(n\cdot 26L\cdot L)=O(n\cdot L^2) time.

Therefore, the overall time complexity is O(n⋅L)+O(n⋅L2)=O(n⋅L2)O(n\cdot L)+O(n\cdot L^2)=O(n\cdot L^2).

Space complexity: The space complexity is O(n⋅L)O(n\cdot L), taken up by the dictionary_set hash set, the visited hash set, and the queue.

Optimization - Bidirectional Traversal

An important observation is that we don’t necessarily need to begin level-order traversal at the start word: we can also start a search at the end word. In fact, we can combine these by performing them simultaneously to find the shortest path. This is known as bidirectional BFS, or in this case, bidirectional level-order traversal.

When we perform two searches, one from start and one from end, the idea is that they will meet in the middle if a path between these two words exists. If a path doesn’t exist, the searches will never meet, indicating that a transformation sequence does not exist.

This optimization allows us to identify the shortest distance more quickly. We can see in the example below that regular level-order traversal ends up traversing through more nodes than in the bidirectional traversal, while also requiring 4 iterations instead of 2.

Image represents a comparison of two graph traversal methods: level-order traversal and bidirectional level-order traversal.  The left side depicts a level-order traversal, showing a graph structured in levels (1 through 4), with nodes connected by black lines.  An orange curved line highlights the path taken during the traversal, starting from a node labeled 'start' (orange fill) and ending at a node labeled 'end' (light gray fill).  Level numbers are indicated in orange text next to the corresponding level's nodes. The right side illustrates a bidirectional level-order traversal, using the same graph structure but with two traversal paths highlighted: one orange, starting from a 'start' node (light gray fill), and another light blue, starting from the 'end' node (light blue fill). Both paths progress through the levels, with level numbers (in black text) indicating the level of each node.  The traversal paths converge in the middle of the graph, suggesting a meeting point during the bidirectional search.  Both sides include additional gray nodes extending beyond the main graph structure, representing potential further levels or branches not included in the primary traversal paths.

To simulate this process, we alternate between the two level-order traversals and progress through each search one level at a time:

  • Start traversal: progress one level in the traversal that started from the start node
  • End traversal: progress one level in the traversal that started from the end node

We alternate between these two steps until a node visited in one search has already been visited in the other search, indicating that the traversals have met. Here’s what this alternating process looks like:

Image represents two diagrams illustrating traversal methods across a layered graph structure.  The left diagram, titled 'start traversal,' shows a graph with a designated 'start' node (orange) and an 'end' node (light blue).  Nodes are arranged in layers, connected by lines. A thick orange curved line visually separates the nodes, indicating a traversal path. Above the diagram, a box displays 'level_start += 1,' indicating an incrementing level counter during traversal.  Below, 'level_start = 1' shows the initial value of this counter. The right diagram, titled 'end traversal,' mirrors the structure but uses a light gray 'start' node and a light blue 'end' node. A thick light blue curved line represents the traversal path in this case.  Similarly, a box above displays 'level_end += 1,' and 'level_end = 1' is shown below, indicating a level counter incrementing from 1 during this traversal. Both diagrams use the same underlying graph structure, but the traversal paths and associated counters differ, highlighting different approaches to traversing the graph.

Image represents a visual explanation of a graph traversal algorithm.  The image is divided into two parts, 'start traversal' and 'end traversal,' each showing a directed acyclic graph.  Both graphs have a 'start' node (colored orange in the left graph, gray in the right) and an 'end' node (colored cyan).  The 'start traversal' section depicts a traversal from the start node, indicated by an orange curved line incrementing `level_start` from 1 to 2.  Simultaneously, a light-gray line shows a traversal from the end node, incrementing `level_end` from an implied 0 to 1.  The 'end traversal' section shows the continuation of these traversals, with the orange line representing `level_start` (now equal to 2) and the cyan line representing `level_end` (now equal to 2).  These lines intersect, signifying the meeting of the traversals.  A box in the 'end traversal' section states that when traversals meet, the algorithm returns `level_start + level_end + 1`.  Both graphs are structured similarly, with nodes connected to form a hexagonal pattern, and the traversals are visually represented by colored curved lines that pass through multiple nodes.

To check if a node has already been visited by the other traversal, we can query the visited hash set used by the other traversal. If a word exists in the visited hash set of the other traversal, we know the searches have met.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Bidirectional Traversal

from typing import List
from collections import deque
    
def shortest_transformation_sequence_optimized(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
    start_queue = deque([start])
    start_visited = {start}
    end_queue = deque([end])
    end_visited = {end}
    level_start = level_end = 0
    # Perform a level-order traversal from the start word and another from the end
    # word.
    while start_queue and end_queue:
        # Explore the next level of the traversal that starts from the start word. If
        # it meets the other traversal, the shortest path between 'start' and 'end'
        # has been found.
        level_start += 1
        if explore_level(start_queue, start_visited, end_visited, dictionary_set):
            return level_start + level_end + 1
        # Explore the next level of the traversal that starts from the end word.
        level_end += 1
        if explore_level(end_queue, end_visited, start_visited, dictionary_set):
            return level_start + level_end + 1
    # If the traversals never met, then no path exists.
    return 0
    
# This function explores the next level in the level-order traversal and checks if
# two searches meet.
def explore_level(queue, visited, other_visited, dictionary_set) -> bool:
    lower_case_alphabet = 'abcdefghijklmnopqrstuvwxyz'
    for _ in range(len(queue)):
        current_word = queue.popleft()
        for i in range(len(current_word)):
            for c in lower_case_alphabet:
                next_word = current_word[:i] + c + current_word[i+1:]
                # If 'next_word' has been visited during the other traversal, it means
                # both searches have met.
                if next_word in other_visited:
                    return True
                if next_word in dictionary_set and next_word not in visited:
                    visited.add(next_word)
                    queue.append(next_word)
    # If no word has been visited by the other traversal, the searches have not met
    # yet.
    return False
function shortestTransformationSequenceOptimized(start, end, dictionary) {
  const dictionarySet = new Set(dictionary);
  if (!dictionarySet.has(start) || !dictionarySet.has(end)) {
    return 0;
  }
  if (start === end) {
    return 1;
  }
  const startQueue = [start];
  const startVisited = new Set([start]);
  const endQueue = [end];
  const endVisited = new Set([end]);
  let levelStart = 0;
  let levelEnd = 0;
  // Perform a level-order traversal from the start word and another from the end
  // word.
  while (startQueue.length > 0 && endQueue.length > 0) {
    // Explore the next level of the traversal that starts from the start word. If
    // it meets the other traversal, the shortest path between 'start' and 'end'
    // has been found.
    levelStart++;
    if (exploreLevel(startQueue, startVisited, endVisited, dictionarySet)) {
      return levelStart + levelEnd + 1;
    }
    // Explore the next level of the traversal that starts from the end word.
    levelEnd++;
    if (exploreLevel(endQueue, endVisited, startVisited, dictionarySet)) {
      return levelStart + levelEnd + 1;
    }
  }
  // If the traversals never met, then no path exists.
  return 0;
}

// This function explores the next level in the level-order traversal and checks if
// two searches meet.
function exploreLevel(queue, visited, otherVisited, dictionarySet) {
  const lowerCaseAlphabet = 'abcdefghijklmnopqrstuvwxyz';
  const levelSize = queue.length;
  for (let k = 0; k < levelSize; k++) {
    const currentWord = queue.shift();
    for (let i = 0; i < currentWord.length; i++) {
      for (const c of lowerCaseAlphabet) {
        const nextWord = currentWord.slice(0, i) + c + currentWord.slice(i + 1);
        // If 'next_word' has been visited during the other traversal, it means
        // both searches have met.
        if (otherVisited.has(nextWord)) {
          return true;
        }
        if (dictionarySet.has(nextWord) && !visited.has(nextWord)) {
          visited.add(nextWord);
          queue.push(nextWord);
        }
      }
    }
  }
  // If no word has been visited by the other traversal, the searches have not met
  // yet.
  return false;
}
public class Solution {
    public int shortestTransformationSequenceOptimized(String start, String end, List<String> dictionary) {
        Set<String> dictionarySet = new HashSet<>(dictionary);
        if (!dictionarySet.contains(start) || !dictionarySet.contains(end)) {
            return 0;
        }
        if (start.equals(end)) {
            return 1;
        }
        Queue<String> startQueue = new LinkedList<>();
        Set<String> startVisited = new HashSet<>();
        Queue<String> endQueue = new LinkedList<>();
        Set<String> endVisited = new HashSet<>();

        startQueue.offer(start);
        startVisited.add(start);
        endQueue.offer(end);
        endVisited.add(end);

        int levelStart = 0;
        int levelEnd = 0;
        // Perform a level-order traversal from the start word and another from the end
        // word.
        while (!startQueue.isEmpty() && !endQueue.isEmpty()) {
            // Explore the next level of the traversal that starts from the start word. If
            // it meets the other traversal, the shortest path between 'start' and 'end'
            // has been found.
            levelStart++;
            if (exploreLevel(startQueue, startVisited, endVisited, dictionarySet)) {
                return levelStart + levelEnd + 1;
            }
            // Explore the next level of the traversal that starts from the end word.
            levelEnd++;
            if (exploreLevel(endQueue, endVisited, startVisited, dictionarySet)) {
                return levelStart + levelEnd + 1;
            }
        }
        // If the traversals never met, then no path exists.
        return 0;
    }

    // This function explores the next level in the level-order traversal and checks if
    // two searches meet.
    private boolean exploreLevel(Queue<String> queue, Set<String> visited, Set<String> otherVisited, Set<String> dictionarySet) {
        int levelSize = queue.size();
        for (int k = 0; k < levelSize; k++) {
            String currentWord = queue.poll();
            char[] chars = currentWord.toCharArray();
            for (int i = 0; i < chars.length; i++) {
                char originalChar = chars[i];
                for (char c = 'a'; c <= 'z'; c++) {
                    chars[i] = c;
                    String nextWord = new String(chars);
                    // If 'next_word' has been visited during the other traversal, it means
                    // both searches have met.
                    if (otherVisited.contains(nextWord)) {
                        return true;
                    }
                    if (dictionarySet.contains(nextWord) && !visited.contains(nextWord)) {
                        visited.add(nextWord);
                        queue.offer(nextWord);
                    }
                }
                chars[i] = originalChar;
            }
        }
        // If no word has been visited by the other traversal, the searches have not met
        // yet.
        return false;
    }
}

Complexity Analysis

Time complexity: The time complexity of shortest_transformation_sequence_optimized is O(n⋅L2)O(n\cdot L^2), since we’re performing two level-order traversals. Note that this is more efficient in practice since there are potentially fewer nodes to traverse when using bidirectional traversal.

Space complexity: The space complexity is O(n⋅L)O(n\cdot L), taken up by the dictionary_set hash set, the visited hash sets, and both queues.

CHAPTER 82

Merging Communities

There are n people numbered from 0 to n - 1 , with each person initially belonging to a separate community. When two people from different communities connect, their communities merge into a single community.

Your goal is to write two functions:

  • connect(x: int, y: int) -> None: Connects person x with person y and merges their communities.
  • get_community_size(x: int) -> int: Returns the size of the community which person x belongs to.

Example:

Image represents a visualization of a graph's evolution and community size calculations.  The image is divided into five sections. The first two sections show the `connect(x, y)` function calls, visually represented by numbered nodes (0-4) within circles.  Lines connecting the nodes indicate the creation of edges between them.  `connect(0, 1)` shows a line connecting node 0 and node 1. `connect(1, 2)` adds a line connecting node 1 and node 2. The third section displays the result of `get_community_size(3)`, which returns 1, indicating node 3 is in a community of size one. The fourth section shows the result of `get_community_size(0)`, returning 3, indicating node 0 belongs to a community of size three (nodes 0, 1, and 2). The fifth section shows `connect(3, 4)`, adding a connection between node 3 and node 4. Finally, `get_community_size(4)` returns 2, showing that nodes 3 and 4 now form a community of size two.  The arrangement demonstrates how adding connections (`connect`) affects the community sizes (`get_community_size`), illustrating a fundamental concept in graph theory and network analysis.
Example
Input: n = 5,
       [
         connect(0, 1),
         connect(1, 2),
         get_community_size(3),
         get_community_size(0),
         connect(3, 4),
         get_community_size(4),
       ]
Output: [1, 3, 2]

Intuition

In this problem, we start with n individuals, each in their own separate community. As we connect pairs of individuals, their respective communities merge. The challenge is to efficiently manage these connections and quickly determine the size of the community for any given individual.

This is where the Union-Find data structure, also known as the Disjoint Set Union (DSU) data structure, comes in. Union-Find consists of two operations:

  • Union: takes two elements from different sets and makes them part of the same set.
  • Find: determines what set an element belongs to.

In the context of this problem, the union operation can be used to merge the communities of two people, while the find operation can be used to determine which community a person belongs to. To make this work, we need a way to represent each community. For example, let’s say there are 5 people, with persons 0 and 1 in one community, and persons 2, 3, and 4 in another:

Image represents two distinct graphs.  The left graph is a linear graph, depicted with a light blue background, containing two nodes labeled '0' and '1'.  Node '0' is positioned above node '1', and an implied connection exists between them, suggesting a sequential or directional relationship. The right graph is a triangular graph, filled with a light purple background, consisting of three nodes at the vertices of the triangle labeled '2', '3', and '4'. Node '4' is positioned at the base of the triangle, while nodes '2' and '3' are at the top corners.  Implied connections exist between all three nodes, indicating a mutual or interconnected relationship. The two graphs are presented side-by-side for comparison, highlighting the difference in their structure and the potential implications for data flow or processing within a coding context.

One way to distinguish between them is to designate a representative for each community. Let's assign person 0 as the representative of the left community, and person 2 as the representative of the right community. We can represent these communities as a graph, where each community is a connected component, and each person in a community points to their representative:

Image represents two distinct directed acyclic graphs (DAGs).  The left DAG, highlighted in light blue, is a simple linear graph with two nodes labeled '0' and '1'.  Node '1' has a directed edge pointing towards node '0', indicating a dependency where '0' depends on '1'.  Each node is encircled and numbered with a small circle containing the same number as the node itself. The right DAG, highlighted in light purple, is a triangular graph with three nodes labeled '2', '3', and '4'. Node '2' receives directed edges from both nodes '3' and '4', signifying that '2' depends on both '3' and '4'.  Node '3' has a directed edge pointing to node '2', and node '4' also has a directed edge pointing to node '2'.  Similar to the left DAG, each node is encircled and numbered.  The two DAGs are presented side-by-side for comparison, illustrating different dependency structures within a system.

This can be reflected using a parent array, where parent[i] stores the parent of person i. This parent array is discussed later.

Now that we have a way to represent communities, let’s discuss how the union and find functions would work in more detail.

Union
Consider the previous example of 5 people. Let’s say we want to connect persons 1 and 4 (i.e., union(1, 4)):

Image represents a directed graph illustrating a union operation within a data structure, likely a disjoint-set data structure or similar.  The graph contains five nodes, each represented by a circle containing a numerical label (0, 1, 2, 3, and 4).  Arrows indicate the direction of connections between nodes. Node 3 points to node 2, and node 1 points to node 0.  Node 4 points to node 2.  Nodes 1 and 4 are enclosed within a dashed orange oval, labeled with the text 'union(1, 4):' in orange, indicating that a union operation is being performed on these two nodes. This operation likely merges the sets containing nodes 1 and 4, resulting in a change to the overall structure of the data structure, potentially affecting the parent-child relationships between nodes.  The resulting structure after the union operation is not explicitly shown, but it would involve a change in the connections between nodes 1, 2, and 4.

We first need to identify their representatives so we know which communities these two people belong to. We can use the find function for this, which will be discussed later:

Image represents a diagram illustrating a find operation within a data structure, likely a disjoint-set data structure or similar.  The diagram shows two separate components. The left component features a node labeled '1' connected by an upward-pointing arrow to a node labeled '0' which is highlighted in orange.  Next to this connection, the text 'rep_x = find(1) = 0' indicates that a find operation on element '1' returns the representative element '0'. The right component shows a node labeled '4' connected by an upward-pointing arrow to a node labeled '2', also highlighted in orange.  A rightward arrow connects node '2' to node '3'.  The text 'rep_y = find(4) = 2' indicates that a find operation on element '4' returns the representative element '2'.  The arrows represent the path taken during the find operation, ultimately leading to the representative element for each set.  The orange highlighting suggests that nodes '0' and '2' are the root or representative elements of their respective sets.

Before merging these communities, we need to pick one of the two current representatives as the representative of the merged community. For now, let’s just pick person 0.

From here, one strategy is to connect everyone in the second community directly to person 0:

Image represents a directed acyclic graph (DAG) illustrating a data flow or dependency structure.  The graph features a central node labeled 'θ' (theta), depicted as a larger circle with an orange border and a light beige fill, representing a final result or aggregated output. Four smaller, circular nodes labeled '1', '2', '3', and '4' are connected to the central node 'θ' via directed edges (arrows).  These arrows point towards 'θ', indicating that the data or results from nodes '1', '2', '3', and '4' are inputs or contributing factors to the computation or aggregation performed at node 'θ'.  The arrangement suggests that 'θ' depends on the completion or output of '1', '2', '3', and '4', implying a parallel or concurrent processing model where 'θ' is calculated only after all its dependencies are satisfied.

This would require individually setting the parent of all these people to person 0, which can be quite expensive if the community has many people. A more efficient strategy is to just connect the representative of this community, person 2, to person 0.

In code, this is done by setting the parent of rep_y to be rep_x (parent[rep_y] = rep_x):

Image represents a directed acyclic graph (DAG) illustrating a data flow or dependency structure, possibly within a coding pattern context.  The graph consists of five nodes, numbered 0 through 4, represented as circles. Node 0, highlighted with an orange outline and labeled '0', is designated with the text 'rep_x' above it, suggesting it represents a repeated x-value or a replicated data source.  Node 1 points to node 0 via a directed edge (arrow), indicating a data dependency. Node 2 receives data from node 3 via a directed edge and, in turn, sends data to node 0.  Node 4 points to node 2, showing a data dependency. Node 3 has no outgoing edges, suggesting it's a source or terminal node. The overall structure suggests a pipeline or branching process where data flows from nodes 1, 3, and 4, converging at node 2 before potentially being replicated or used in node 0, labeled 'rep_x' to indicate replication or repetition of the x-component.  The label 'rep_y' above node 2 suggests a possible connection to a y-component or a second replication process.

This way, everyone originally in the second community is indirectly connected to person 0, making person 0 the representative of all nodes.

Note that if rep_x and rep_y are the same person, then they belong to the same community, in which case we don’t need to merge anything.

Find
The find function should return the representative of the community a person is in:

Image represents two disjoint sets of nodes, each representing a disjoint-set data structure, visually illustrating the `find` operation.  The left set, depicted in cyan, shows node 0 pointing to itself (indicated by a self-looping cyan dashed arrow), and node 1 pointing to node 0 (a solid black arrow).  The text `find(0) = 0` and `find(1) = 0` indicates that finding the root of 0 returns 0, and finding the root of 1 returns 0 (because 1 points to 0). The right set, depicted in orange, shows node 2 pointing to itself (self-looping orange dashed arrow), node 4 pointing to node 2 (solid black arrow), and node 3 pointing to node 2 (solid black arrow). The text `find(2) = 2`, `find(3) = 2`, and `find(4) = 2` indicates that the root of 2, 3, and 4 is 2.  The dashed arrows represent the path followed during the find operation, showing how each node eventually leads to its respective root node.  The solid arrows represent the parent-child relationships within each set.

At this point, it's important to note the difference between a parent and a representative:

  • A representative is the person who represents the community. This is effectively the “root” node of the community.

  • A parent of a person is the node that person is pointing to.

For example, in the community below, we see that person 0's parent is person 1, whereas person 0’s representative is person 2:

Image represents a directed graph illustrating a chain of relationships.  Three nodes, numbered 0, 1, and 2, are depicted as circles. Node 0 is connected to node 1 by a solid arrow, indicating a unidirectional relationship.  Above this connection, the text 'parent of person θ' is written in gray, suggesting node 0 is the parent of the person represented by node 1. Node 1 is further connected to node 2 by another solid arrow.  Node 2 is colored orange and labeled 'representative' in orange text, indicating its role.  Dashed arrows form a loop connecting node 0 back to itself and node 1 to node 2, suggesting a cyclical or feedback relationship, though the nature of this feedback is not explicitly defined.  The overall structure shows a linear progression from parent (0) to person (1) to representative (2), with an implied cyclical feedback mechanism.

We can implement the find(x) function by traversing x’s parent chain until we reach the representative. The representative is the person whose parent is themselves, so we can identify them by checking if the current person is their own parent. The code snippet for this is provided below.

def find(x: int) -> int:
    # If x is equal to its parent, we found the representative.
    if x == parent[x]:
        return x
    # Otherwise, continue traversing through x's parent chain.
    return find(parent[x])
function find(x) {
  // If x is equal to its parent, we found the representative.
  if (x === parent[x]) {
    return x;
  }
  // Otherwise, continue traversing through x's parent chain.
  return find(parent[x]);
}
int find(int x) {
    // If x is equal to its parent, we found the representative.
    if (x == parent[x]) {
        return x;
    }
    // Otherwise, continue traversing through x's parent chain.
    return find(parent[x]);
}

Now we’ve discussed both the union and the find function, let’s discuss what optimizations can be made to them.

Union by size
Recall that before merging two communities, we need to pick someone to represent the merged community. Our choice is between rep_x and rep_y. Is one a better choice than the other? Consider the following two communities:

Image represents two distinct directed acyclic graphs.  The left graph, labeled 'rep_x', depicts a tree structure where a central node labeled '0' receives input from five nodes numbered '1' through '5'.  Arrows indicate the direction of information flow, showing that nodes 1 through 5 send data to node 0.  The right graph, labeled 'rep_y', is simpler, showing a single upward arrow from node '7' to node '6', indicating that node 7 sends data to node 6.  Both graphs illustrate data flow, possibly representing different representations (x and y) of a similar underlying structure or process, with the numbered nodes potentially representing individual data points or features, and the nodes labeled '0' and '6' representing aggregated or processed results.

Let's see what the difference is when we pick rep_x to be the representative versus picking rep_y:

Image represents two directed acyclic graphs illustrating a concept likely related to choosing a representative node.  The left graph, labeled 'choose rep_x:', shows nodes numbered 1 through 7, with arrows pointing upwards towards a central node labeled '0' (highlighted in orange), indicating that node 0 is chosen as the representative ('rep_x') for nodes 1-7. Node 6 has an additional incoming arrow from node 7. The right graph, labeled 'choose rep_y:', similarly shows nodes 1 through 7, with arrows pointing upwards towards a central node labeled '0', which in turn points upwards to node 6 (highlighted in orange), indicating that node 6 is chosen as the representative ('rep_y') for nodes 0, 1, 2, 3, 4, 5, and 7.  The arrangement visually demonstrates two different representative selection processes, possibly highlighting different algorithms or strategies for choosing a representative node within a dataset or graph structure.

As we can see, when we choose a representative from one of two communities, the people in the other community have a longer distance to their new representative, with the distance increased by 1. Therefore, it’s better to pick the representative from the larger community, so that only the people from the smaller community are impacted by this adjusted distance.

This optimization requires a way to determine the size of a community. We can do this using a size array, where size[i] is the size of the community represented by person i. The code for this can be seen below:

def union(x: int, y: int) -> None:
   rep_x, rep_y = find(x), find(y)
   if rep_x != rep_y:
       # If 'rep_x' represents a larger community, connect 'rep_y's community to it.
       if size[rep_x] > size[rep_y]:
           parent[rep_y] = rep_x
           size[rep_x] += size[rep_y]
       # If 'rep_y' represents a larger community, or both communities are of the same
       # size, connect 'rep_x's community to it.
       else:
           parent[rep_x] = rep_y
           size[rep_y] += size[rep_x]
function union(x, y) {
  const repX = find(x);
  const repY = find(y);
  if (repX !== repY) {
    // If 'rep_x' represents a larger community, connect 'rep_y's community to it.
    if (size[repX] > size[repY]) {
      parent[repY] = repX;
      size[repX] += size[repY];
    }
    // If 'rep_y' represents a larger community, or both communities are of the same
    // size, connect 'rep_x's community to it.
    else {
      parent[repX] = repY;
      size[repY] += size[repX];
    }
  }
}
void union(int x, int y) {
    int repX = find(x);
    int repY = find(y);
    if (repX != repY) {
        // If 'rep_x' represents a larger community, connect 'rep_y's community to it.
        if (size[repX] > size[repY]) {
            parent[repY] = repX;
            size[repX] += size[repY];
        }
        // If 'rep_y' represents a larger community, or both communities are of the same
        // size, connect 'rep_x's community to it.
        else {
            parent[repX] = repY;
            size[repY] += size[repX];
        }
    }
}

A similar optimization to union by size is union by rank, which optimizes the union function in a very similar way [1].

Note that the size array makes the implementation of get_community_size(x) quite straightforward: we just return the size of x’s representative (i.e., return size[find(x)]).

Path compression
There’s another major optimization that can be made. Consider the following community, with person 0 as the representative. The further down we go in this diagram, the further away the people are from the representative. We can see this for persons 3 and 6, for example:

Image represents a tree-like data structure illustrating a 'find' operation within a disjoint-set data structure, likely used for representing sets and their relationships.  The central node, labeled '0' and highlighted in orange, is the representative element ('rep') of the entire set.  Nodes 1 through 6 represent other elements within the set. Solid black arrows indicate the parent-child relationships within the structure; for example, node 2 is a child of node 1, and node 1 is a child of node 0. Dashed gray arrows represent the path a 'find(parent[i])' operation would take to locate the representative element for a given node 'i'.  For instance, `find(parent[3])` would traverse from node 3 to node 2, then to node 1, and finally to node 0 (the representative).  Each leaf node (3 and 6) has a label indicating a `find(i)` operation, suggesting a direct path to find the element itself.  The overall structure shows how the `find` operation navigates the tree to identify the representative element of a given node's set.

What’s important to realize is that performing find(3) and find(6) will not only return the representative to persons 3 and 6, but it will also cause it to be returned by all the other nodes in their parent chains because this is a recursive function:

Image represents a directed graph illustrating a coding pattern, possibly related to recursion or a state machine.  The graph features seven nodes (circles) numbered 0 through 6. Node 0, colored orange and labeled 'rep,' sits at the top and represents a potentially recursive function's main call or a central state.  Nodes 1, 2, 4, 5, and 6 are connected to each other and to node 0 with solid black arrows indicating the primary flow of execution or state transitions.  Node 0 has solid black arrows pointing to nodes 1 and 4, representing function calls or state changes. Nodes 1 and 4 are connected to nodes 2 and 5 respectively, with similar solid black arrows. Nodes 2 and 5 are connected to nodes 3 and 6 respectively, again with solid black arrows.  Dashed orange arrows connect nodes 1 and 4 back to node 0, and nodes 2 and 5 back to nodes 1 and 4 respectively, and nodes 3 and 6 back to nodes 2 and 5 respectively, labeled 'return 0,' suggesting return values or state transitions back to previous states.  Dashed gray arrows form loops around nodes 1, 2, 4, 5, and 6, possibly indicating alternative paths or error handling. The overall structure suggests a hierarchical or tree-like pattern with a central node (0) and branching sub-processes or states.

This means every person in their parent chains also knows their representative is person 0. So, if we update the parent of each of these people to person 0 in the find function, all the nodes in this chain will connect to the representative. This technique is known as path compression, and it allows us to flatten the graph:

Image represents a directed graph illustrating a 'rep' pattern, likely within a coding context.  The graph features a central node labeled '0' and colored orange, representing a central repository or aggregation point.  Six other nodes, numbered 1 through 6, are connected to the central node with unidirectional arrows pointing towards node '0'. This indicates that data or information flows from nodes 1 through 6 into the central node '0'.  The arrangement suggests a pattern where multiple sources (1-6) contribute to or update a single representative element (0), possibly representing data aggregation, merging, or a similar process in a software system. The label 'rep' above node 0 reinforces this interpretation, signifying a representative or aggregated data structure.

The code for this optimization is provided below:

def find(x: int) -> int:
    if x == parent[x]:
        return x
    # Path compression: updates the representative of x, flattening the structure of
    # the community as we go.
    parent[x] = find(parent[x])
    return parent[x]
function find(x) {
  if (x === parent[x]) {
    return x;
  }
  // Path compression: updates the representative of x, flattening the structure of
  // the community as we go.
  parent[x] = find(parent[x]);
  return parent[x];
}
int find(int x) {
    if (x == parent[x]) {
        return x;
    }
    // Path compression: updates the representative of x, flattening the structure of
    // the community as we go.
    parent[x] = find(parent[x]);
    return parent[x];
}

This path compression optimization allows us to restructure the graph at every find call, effectively reducing the distance between people and their representative, and making future find calls more efficient.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

Below is the implementation of the Union-Find data structure.

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 'rep_x' represents a larger community, connect 'rep_y's community to
            # it.
            if self.size[rep_x] > self.size[rep_y]:
                self.parent[rep_y] = rep_x
                self.size[rep_x] += self.size[rep_y]
            # Otherwise, connect 'rep_x's community to '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]) # Path compression.
        return self.parent[x]
   
    def get_size(self, x: int) -> int:
        return self.size[self.find(x)]
class UnionFind {
  constructor(size) {
    this.parent = Array.from({ length: size }, (_, i) => i);
    this.size = new Array(size).fill(1);
  }

  union(x, y) {
    const repX = this.find(x);
    const repY = this.find(y);
    if (repX !== repY) {
      // If 'rep_x' represents a larger community, connect 'rep_y's community to
      // it.
      if (this.size[repX] > this.size[repY]) {
        this.parent[repY] = repX;
        this.size[repX] += this.size[repY];
      }
      // Otherwise, connect 'rep_x's community to 'rep_y'.
      else {
        this.parent[repX] = repY;
        this.size[repY] += this.size[repX];
      }
    }
  }

  find(x) {
    if (x === this.parent[x]) {
      return x;
    }
    this.parent[x] = this.find(this.parent[x]); // Path compression.
    return this.parent[x];
  }

  getSize(x) {
    return this.size[this.find(x)];
  }
}
class UnionFind {
    private int[] parent;
    private int[] size;

    public UnionFind(int size) {
        this.parent = new int[size];
        this.size = new int[size];
        for (int i = 0; i < size; i++) {
            this.parent[i] = i;
            this.size[i] = 1;
        }
    }

    public void union(int x, int y) {
        int repX = find(x);
        int repY = find(y);
        if (repX != repY) {
            // If 'rep_x' represents a larger community, connect 'rep_y's community to
            // it.
            if (this.size[repX] > this.size[repY]) {
                this.parent[repY] = repX;
                this.size[repX] += this.size[repY];
            }
            // Otherwise, connect 'rep_x's community to 'rep_y'.
            else {
                this.parent[repX] = repY;
                this.size[repY] += this.size[repX];
            }
        }
    }

    public int find(int x) {
        if (x == this.parent[x]) {
            return x;
        }
        this.parent[x] = this.find(this.parent[x]); // Path compression.
        return this.parent[x];
    }

    public int getSize(int x) {
        return this.size[this.find(x)];
    }
}

Below is the implementation of the main problem using the above Union-Find data structure:

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)
class MergingCommunities {
  constructor(n) {
    this.uf = new UnionFind(n);
  }

  connect(x, y) {
    this.uf.union(x, y);
  }

  getCommunitySize(x) {
    return this.uf.getSize(x);
  }
}
class MergingCommunities {
    private UnionFind uf;

    public MergingCommunities(int n) {
        this.uf = new UnionFind(n);
    }

    public void connect(int x, int y) {
        this.uf.union(x, y);
    }

    public int getCommunitySize(int x) {
        return this.uf.getSize(x);
    }
}

Complexity Analysis

Time complexity: Let’s break down the time complexity of the Union-Find functions:

  • With the path compression and union by size optimizations, find has a time complexity of amortized O(1)O(1)1 because the branches of the graph become very short over time, making the function effectively constant-time in most cases.

  • Since union just uses the find function twice, it also has a time complexity of amortized O(1)O(1).

  • Since get_size just uses the find function once, it also has a time complexity of amortized O(1)O(1).

Therefore, the time complexities of connect and get_community_size are both amortized O(1)O(1). The time complexity of the constructor is O(n)O(n) because we initialize two arrays of size nn when creating the UnionFind object.

Space complexity: The space complexity is O(n)O(n) because the Union-Find data structure has two arrays of size nn: parent and size. The space taken up by the recursive call stack is amortized O(1)O(1) since the branches of the graph become very short over time, resulting in fewer recursive calls made to the find function.

Footnotes

  1. We can also write the time complexity here as O(α(n))O(α(n)), where α(n)α(n) is the inverse Ackermann function, which grows extremely slowly but is not constant. ↩

CHAPTER 83

Prerequisites

Given an integer n representing the number of courses labeled from 0 to n - 1, and an array of prerequisite pairs, determine if it's possible to enroll in all courses.

Each prerequisite is represented as a pair [a, b], indicating that course a must be taken before course b.

Example:

Image represents a directed graph illustrating a simple coding pattern, possibly related to data flow or state transitions.  The graph contains three nodes represented as circles.  One node is labeled 'θ' (theta), suggesting an initial state or input. Another node is labeled '1', and the third is labeled '2'.  A directed edge (arrow) connects the 'θ' node to the '1' node, indicating a unidirectional flow of information or a transition from 'θ' to '1'.  Two curved, bidirectional edges connect nodes '1' and '2', showing a reciprocal relationship or iterative interaction between these two states; information or control flows in both directions between '1' and '2'.  The overall structure suggests a sequence where an initial input ('θ') triggers a process ('1') that interacts iteratively with another component ('2') before potentially completing or transitioning to another state (not shown).
Example
Input: n = 3, prerequisites = [[0, 1], [1, 2], [2, 1]]
Output: False

Explanation: Course 1 cannot be taken without first completing course 2 and, and vice versa.

Constraints:

  • For any prerequisite [a, b], a will not equal b.

Intuition

Our goal is to check if enrollment into all courses is possible. Let’s start by identifying which situations make enrollment impossible.

Consider a simple case where there are just two courses. A scenario where enrollment into all courses is impossible occurs when each course is a prerequisite to the other, as graphically represented below:

Image represents a directed graph illustrating prerequisites, where `prerequisites = [[0, 1], [1, 0]]` defines the dependencies.  The graph consists of two nodes, represented by circles labeled '0' and '1'.  A directed edge, indicated by an arrow, connects node '0' to node '1', signifying that node '1' requires node '0' as a prerequisite.  Conversely, another directed edge connects node '1' to node '0', indicating a reciprocal dependency; node '0' also requires node '1'. The `prerequisites` array is a matrix where each inner array represents a dependency; `[0, 1]` means 1 depends on 0, and `[1, 0]` means 0 depends on 1.  This visual representation shows a cyclical dependency between the two nodes.

Here are a couple of impossible enrollment scenarios that could occur with prerequisites for three courses:

Image represents two directed graphs illustrating different prerequisite relationships.  Each graph contains three nodes labeled '0', '1', and '2', representing tasks or modules.  Arrows indicate dependencies; an arrow from node A to node B signifies that B requires A as a prerequisite. The left graph shows node 0 pointing to node 1, node 1 pointing to node 2, and node 2 pointing back to node 1, representing a cyclical dependency.  Above this graph, the text 'prerequisites = [[0,1] [1,2] [2,1]]' lists these dependencies as ordered pairs within a list of lists. The right graph shows node 0 pointing to node 1, node 1 pointing to node 2, and node 2 pointing to node 0, forming a different cyclical dependency.  Above this graph, the text 'prerequisites = [[2,0] [0,1] [1,2]]' similarly lists the dependencies as ordered pairs in a list of lists, reflecting the connections in the graph.  Both graphs visually represent different sets of prerequisites, highlighting how different dependency structures can be represented using directed graphs and their corresponding list representations.

What do we notice about these cases and their graphical representations? They both have a circular relationship: a cycle. This highlights that it’s impossible to enroll in all courses if there exists a circular dependency between courses. In other words, there must not be a cycle in the graphical representation of the courses for complete enrollment to be possible.

Another thing to note is that the first courses which can be completed are those without prerequisites. In the graphical representation, such a course would have no directed arrows pointing at them. Let’s refer to the number of directed edges incoming to a node as the in-degree of that node.


In the example below, each node's in-degree is displayed at the top right:

Image represents a directed graph illustrating prerequisites.  The graph consists of six nodes, numbered 0 through 5, represented as circles containing their respective numbers.  Arrows indicate directed edges showing dependencies; a directed edge from node *x* to node *y* means *y* depends on *x*.  The edges are labeled with small orange numbers, possibly representing weights or costs associated with the dependencies.  Specifically, node 0 has outgoing edges to nodes 1 and 2; node 1 has an outgoing edge to node 4; node 3 has an outgoing edge to node 2; node 2 has an outgoing edge to node 4; and node 4 has an outgoing edge to node 5. Above the graph, a Python list named `prerequisites` is defined, containing nested lists. Each nested list represents a dependency pair; for example, `[0,1]` indicates that node 1 depends on node 0.  The list explicitly defines all the dependencies shown in the graph.

Courses 0 and 3 have an in-degree of 0. So, let’s complete them first and remove them from the graph. By doing this, we reduce the number of prerequisites for courses 1 and 2. Course 1's indegree then decreases by 1, and course 2's indegree decreases by 2:

Image represents a directed graph illustrating a coding pattern transformation.  The left side shows an initial graph with two source nodes, labeled '0' (crossed out in red, indicating inactivity or removal) and '3' (also crossed out in red), each connected via grey, directed edges with weights (small orange numbers) to nodes '1' (weight 1) and '2' (weight 2) respectively. Node '1' and '2' are then connected to node '4' (weights 1 and 2 respectively), which in turn connects to node '5' (weight 1).  A dashed grey arrow separates the left and right graphs, indicating a transformation. The right side shows the transformed graph where nodes '0' and '3' are removed. Nodes '1' and '2' now directly connect to node '4' (weights 1 and 0 respectively), and node '4' still connects to node '5' (weight 1).  The weights on the edges represent some form of cost or value associated with the connection between nodes. The overall diagram depicts a simplification or optimization of the graph structure, potentially representing a refactoring step in a coding pattern.

Now, courses 1 and 2 have an in-degree of 0, which means we can enroll in them and remove them from the graph. Observe what happens when we continue the process of removing courses with an in-degree of 0 from the graph:

Image represents a directed graph illustrating a coding pattern, possibly related to dependency management or task execution.  The initial state shows three nodes: nodes '1' and '2' (grey, with a red strikethrough, indicating inactive or completed states) and node '4' (black, active).  Orange numbers (2 and 1) above the arrows indicate weights or priorities associated with the connections. Node '1' connects to node '4' with a weight of 2, and node '2' connects to node '4' with a weight of 0. Node '4' connects to node '5' (black, active) with a weight of 1.  The graph then transitions. Node '4' becomes inactive (grey with a red strikethrough), and a dashed arrow indicates a non-deterministic or asynchronous transition.  Node '5' remains active (black), but eventually also becomes inactive (grey with a red strikethrough) after a further transition indicated by a dashed arrow.  The overall diagram depicts a sequence of states, showing how dependencies are resolved and tasks are completed, potentially highlighting a scenario where a task (node 4) is completed before its dependent task (node 5) is executed, and then node 5 is completed.

By the end of this process, no courses remain, indicating it’s possible to enroll in all courses.


Now, consider an example with a cyclic dependency:

Image represents a directed graph illustrating prerequisites.  At the top, a Python list named `prerequisites` is defined, containing pairs of numbers representing dependencies: `[[0,1] [0,2] [3,2] [1,4] [2,4] [4,5] [5,2]]`.  The graph visually depicts these dependencies.  The graph consists of six nodes, numbered 0 through 5, represented as circles containing their respective numbers.  Directed edges, represented by arrows, connect the nodes, indicating the flow of dependencies.  For example, the edge from node 0 to node 1 (labeled with an orange '1' above the arrow) signifies that node 1 requires node 0 as a prerequisite. Similarly, node 2 requires node 0 (labeled '0'), node 2 requires node 3 (labeled '3'), node 4 requires node 1 (labeled '2'), node 4 requires node 2 (labeled '1'), node 5 requires node 4 (labeled '1'), and node 2 requires node 5 (a curved edge).  The numbers on the arrows correspond to the order of dependencies as listed in the `prerequisites` list.

Let's follow the same steps of removing courses with an in-degree of 0:

Image represents a directed graph illustrating a coding pattern, possibly related to graph transformations or optimization.  The initial graph shows nodes labeled 0 (greyed out and crossed with a red line, indicating it's inactive or removed), 1, 2, 3 (also greyed out and crossed), 4, and 5.  Orange numbers next to the edges represent weights or costs associated with each connection. Node 0 connects to node 1 with weight 1, and to node 2 with weight 0. Node 1 connects to node 4 with weight 2, and node 3 connects to node 2 with weight 3 and to node 4 with weight 1. Node 4 connects to node 5 with weight 1, and node 5 connects back to node 2 with weight 1.  The graph then undergoes a transformation, indicated by a dashed arrow.  In the transformed graph, nodes 0 and 3 are absent. The connections and weights between nodes 1, 2, 4, and 5 are rearranged. Node 1 is removed, and node 2 now connects to node 4 with weight 1. Node 4 connects to node 5 with weight 1, and node 5 connects back to node 2 with weight 1. A further transformation is shown, resulting in a final graph with the same nodes (1,2,4,5) but with slightly different connections and weights.  The transformations suggest a process of simplifying or optimizing the graph by removing nodes and adjusting edge weights.

Here, there is no way to progress because there aren't any courses with an in-degree of 0. When this happens, and there are still unvisited courses, a cyclic dependency exists, meaning enrolment is impossible.

Now, let’s identify a way to simulate the above process algorithmically.

Topological sort
The process we described above is essentially topological sorting, where vertices of a graph are sorted in such a way that for every directed edge u → v, node u comes before node v in the ordering of the topological sort.

An algorithm designed to perform topological sort is Kahn’s algorithm. Let’s see how we can use it to solve this problem.


The first step of Kahn's algorithm is to determine the in-degree of each course. This can be achieved by counting the number of times each course appears as a dependent in the prerequisite pairs: for a pair [a, b], course b depends on course a, so course b’s in-degree is incremented by one:

Image represents a directed acyclic graph (DAG) illustrating a topological sort problem, along with its prerequisites and in-degrees.  The upper part shows the DAG with six nodes (0, 1, 2, 3, 4, 5) represented as circles, connected by directed edges indicated by arrows.  The numbers on the arrows represent edge weights (not explicitly defined but implied). Node 0 points to node 1 and node 2; node 3 points to node 2; node 1 points to node 4; and node 4 points to node 5.  Below the DAG, a list named 'prerequisites' shows the dependencies as pairs of node indices: `[[0,1] [0,2] [3,2] [1,4] [2,4] [4,5]]`, indicating that node 1 requires node 0, node 2 requires node 0, node 2 requires node 3, node 4 requires node 1, node 4 requires node 2, and node 5 requires node 4.  Finally, a list named 'in_degrees' shows the number of incoming edges for each node: `[0 1 2 0 2 1]`, indicating that node 0 has 0 incoming edges, node 1 has 1, node 2 has 2, and so on.  Orange curved arrows connect the prerequisites pairs to their corresponding in-degree values in the `in_degrees` list, visually linking the dependencies to the count of incoming edges for each node.  The numbers below the `in_degrees` list (0, 1, 2, 3, 4, 5) correspond to the node indices.

Now, we want to process all the courses with an in-degree of 0 first. We can add these courses to a queue to be processed:

Image represents a directed acyclic graph (DAG) with five nodes (0, 1, 2, 3, 4) and edges connecting them, along with an accompanying illustration of a topological sort algorithm's execution.  The nodes are numbered 0 through 4 and are depicted as circles containing their respective numbers.  Edges are represented by arrows indicating direction; for example, node 0 points to node 1, and node 3 points to node 2.  Small orange numbers next to each edge indicate a weight or some associated value. The algorithm uses an `in_degrees` array, shown as `[0 1 2 0 2 1]`, representing the number of incoming edges for each node (node 0 has 0 incoming edges, node 1 has 1, and so on).  A `queue` array, initialized as `[0 3]`, stores nodes with zero incoming edges.  A light green highlight visually emphasizes the current state of the `in_degrees` array, showing that nodes 0 and 3 are initially in the queue because they have no incoming edges.  The graph's structure and the `in_degrees` and `queue` arrays together illustrate the data structures used in a topological sorting algorithm.

To begin processing, pop the first course from the queue: course 0. Then, for each course that has course 0 as a prerequisite (i.e., courses pointed to by course 0), reduce their in-degree by one:

Image represents a directed acyclic graph (DAG) illustrating a topological sort algorithm.  The DAG consists of five nodes (circles) numbered 1 through 5, with directed edges (arrows) indicating dependencies. Node 0 is shown in gray and crossed out, indicating it's a source node that has been processed.  A directed edge from node *i* to node *j* implies that node *j* depends on node *i*.  The edges are labeled with small numbers representing weights or counts (e.g., 1→0, 2→1).  To the right, an array named `in_degrees` is shown, representing the in-degree of each node (number of incoming edges).  Initially, `in_degrees` is [0, 1, 2, 0, 2, 1], with node 0's in-degree crossed out because it's processed.  Another array, `queue`, is shown as [0, 3], indicating nodes with an in-degree of 0 (ready to be processed).  The orange numbers on the nodes and edges likely represent the order of processing during the topological sort.  The graph visually demonstrates the flow of information and dependencies, with the arrays tracking the algorithm's progress.

If any of the courses have an in-degree of 0 after being decremented, add them to the queue. Course 1 now has an in-degree of 0, so we add it to the queue:

Image represents a directed acyclic graph (DAG) illustrating a topological sorting algorithm.  The DAG consists of five nodes (1, 2, 3, 4, 5) represented as circles containing their respective node numbers.  Directed edges, represented by arrows, connect nodes indicating the flow of information; the numbers next to the arrows represent edge weights. Node 3 points to node 2 (weight 1), node 1 points to node 4 (weight 2), and node 2 points to node 4 (weight 2). Finally, node 4 points to node 5 (weight 1). To the right, an array labeled 'in_degrees' shows the in-degree of each node (number of incoming edges): node 1 has 0, node 2 has 1, node 3 has 0, node 4 has 2, and node 5 has 1.  A highlighted element '0' in the 'in_degrees' array indicates a node with an in-degree of 0, ready for processing.  Below, an array labeled 'queue' shows the queue of nodes ready for processing, initially containing nodes 3 and 1.  The arrangement visually demonstrates the algorithm's steps: identifying nodes with zero in-degrees, adding them to a queue, processing them, and updating in-degrees accordingly.

We can continue the above process until the queue is empty, indicating that there aren't any more courses with an in-degree of 0.

  • If we've processed all n courses, enrollment to all courses is possible.
  • If we couldn’t process all n courses, a cycle was found, indicating enrollment to all courses is impossible.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
    # Represent the graph as an adjacency list and record the in-degree of each
    # course.
    for prerequisite, course in prerequisites:
        graph[prerequisite].append(course)
        in_degrees[course] += 1
    queue = deque()
    # Add all courses with an in-degree of 0 to the queue.
    for i in range(n):
        if in_degrees[i] == 0:
            queue.append(i)
    enrolled_courses = 0
    # Perform topological sort.
    while queue:
        node = queue.popleft()
        enrolled_courses += 1
        for neighbor in graph[node]:
            in_degrees[neighbor] -= 1
            # If the in-degree of a neighboring course becomes 0, add it to the queue.
            if in_degrees[neighbor] == 0:
                queue.append(neighbor)
    # Return true if we've successfully enrolled in all courses.
    return enrolled_courses == n
function prerequisites(n, prerequisites) {
  const graph = Array.from({ length: n }, () => []);
  const inDegrees = new Array(n).fill(0);
  // Represent the graph as an adjacency list and record the in-degree of each
  // course.
  for (const [prerequisite, course] of prerequisites) {
    graph[prerequisite].push(course);
    inDegrees[course]++;
  }
  const queue = [];
  // Add all courses with an in-degree of 0 to the queue.
  for (let i = 0; i < n; i++) {
    if (inDegrees[i] === 0) {
      queue.push(i);
    }
  }
  let enrolledCourses = 0;
  let head = 0;
  // Perform topological sort.
  while (head < queue.length) {
    const node = queue[head++];
    enrolledCourses++;
    for (const neighbor of graph[node]) {
      inDegrees[neighbor]--;
      // If the in-degree of a neighboring course becomes 0, add it to the queue.
      if (inDegrees[neighbor] === 0) {
        queue.push(neighbor);
      }
    }
  }
  // Return true if we've successfully enrolled in all courses.
  return enrolledCourses === n;
}
public class Solution {
    public boolean prerequisites(int n, int[][] prerequisites) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            graph.add(new ArrayList<>());
        }
        int[] inDegrees = new int[n];
        // Represent the graph as an adjacency list and record the in-degree of each
        // course.
        for (int[] p : prerequisites) {
            int prerequisite = p[0], course = p[1];
            graph.get(prerequisite).add(course);
            inDegrees[course]++;
        }
        Queue<Integer> queue = new LinkedList<>();
        // Add all courses with an in-degree of 0 to the queue.
        for (int i = 0; i < n; i++) {
            if (inDegrees[i] == 0) {
                queue.offer(i);
            }
        }
        int enrolledCourses = 0;
        // Perform topological sort.
        while (!queue.isEmpty()) {
            int node = queue.poll();
            enrolledCourses++;
            for (int neighbor : graph.get(node)) {
                inDegrees[neighbor]--;
                // If the in-degree of a neighboring course becomes 0, add it to the queue.
                if (inDegrees[neighbor] == 0) {
                    queue.offer(neighbor);
                }
            }
        }
        // Return true if we've successfully enrolled in all courses.
        return enrolledCourses == n;
    }
}

Complexity Analysis

Time complexity: The time complexity of prerequisites is O(n+e)O(n+e) where ee denotes the number of edges derived from the prerequisites array. Here’s why:

  • Creating the adjacency list and recording the in-degrees takes O(e)O(e) time because we iterate through each prerequisite once.

  • Adding all courses with in-degree 0 to the queue takes O(n)O(n) time because we check the in-degree of each course once.

  • Performing Kahn’s algorithm takes O(n+e)O(n+e) time because each course and prerequisite is processed at most once during the traversal.

Space complexity: The space complexity is O(n+e)O(n+e), since the adjacency list takes up O(n+e)O(n+e) space, while the in_degrees array and queue each take up O(n)O(n) space.

CHAPTER 84

Shortest Path

Given an integer n representing nodes labeled from 0 to n - 1 in an undirected graph, and an array of non-negative weighted edges, return an array where each index i contains the shortest path length from a specified start node to node i. If a node is unreachable, set its distance to -1.

Each edge is represented by a triplet of positive integers: the start node, the end node, and the weight of the edge.

Example:

Image represents a weighted directed graph, possibly illustrating a shortest path problem or a similar graph traversal algorithm.  The graph consists of six nodes, numbered 0 through 5. Node 0, labeled 'start' in orange, is the designated starting node and is highlighted with an orange border.  The remaining nodes (1, 2, 3, 4, and 5) are represented as simple circles containing their respective numerical labels.  Edges connect the nodes, with each edge labeled with a numerical weight representing the cost or distance between the connected nodes.  Specifically, node 0 connects to node 1 (weight 5) and node 2 (weight 3). Node 1 connects to node 2 (weight 1) and node 3 (weight 4). Node 2 connects to node 4 (weight 5) and node 3 (weight 4). Node 3 connects to node 1 (weight 4). Node 5 is an isolated node, not connected to any other node in the graph.  The arrangement suggests a flow of information or traversal from the starting node (0) towards the other nodes, with the edge weights influencing the path selection in an algorithm.
Example
Input: n = 6,
       edges = [
         [0, 1, 5],
         [0, 2, 3],
         [1, 2, 1],
         [1, 3, 4],
         [2, 3, 4],
         [2, 4, 5],
       ],
       start = 0
Output: [0, 4, 3, 7, 8, -1]

Intuition

There are a few algorithms that can be employed to find the shortest path in a graph. Let’s consider some of our options:

  • BFS works well for finding the shortest path when the graph has edges with no weight, or uniform weight across the edges, as BFS doesn’t take weight into account in its traversal strategy.

  • Dijkstra’s algorithm works well for graphs with non-negative weights, as it efficiently finds the shortest path from a single source to all other nodes.

  • The Bellman-Ford algorithm works well for graphs with edges that may have negative weights [1].

  • The Floyd-Warshall algorithm works well when we need to find the shortest paths between all pairs of nodes in a graph [2].

Among these options, Dijkstra’s algorithm suits this problem the most since we’re dealing with a graph with non-negative weighted edges, and we need to find the shortest path from a start node to all other nodes. This algorithm uses a greedy strategy, which we’ll explore during this explanation.

Consider the undirected weighted graph below, with node 0 as the starting node.

Image represents a weighted graph, a network of nodes (circles) connected by edges (lines).  The nodes are labeled with numbers 0 through 5. Node 0 is connected to node 1 with an edge labeled '5' and to node 2 with an edge labeled '3'. Node 1 is connected to node 2 with an edge labeled '1' and to node 3 with an edge labeled '4'. Node 2 is connected to node 3 with an edge labeled '4' and to node 4 with an edge labeled '5'. Node 3 is connected to node 4 with an edge labeled '4'. Node 5 is an isolated node, not connected to any other node in the graph.  The numbers on the edges represent weights or costs associated with traversing that connection.  The arrangement shows a somewhat clustered structure with nodes 0, 1, 2, 3, and 4 forming a connected subgraph, while node 5 remains isolated.

Initially, since we don't know any of the distances between node 0 and the other nodes, we'll set them to infinity. The only distance we do know is from the start node to itself, which is just 0:

Image represents a weighted, undirected graph with six nodes (labeled 0 through 5) and several edges connecting them.  The edges are labeled with their corresponding weights. Node 0 connects to node 1 (weight 5) and node 2 (weight 3). Node 1 connects to node 2 (weight 1), node 3 (weight 4), and node 0 (weight 5). Node 2 connects to node 4 (weight 5) and node 3 (weight 4). Node 3 connects to node 1 (weight 4). Node 4 and node 5 are isolated nodes, not connected to any other nodes in the graph. To the right of the graph, a list named 'distances' is shown, initialized as `[0, ∞, ∞, ∞, ∞, ∞]`.  The numbers below the list (0, 1, 2, 3, 4, 5) correspond to the indices of the nodes in the graph, indicating that the list represents the distances from a starting node (likely node 0, given the initial 0 in the list) to each of the other nodes in the graph.  The symbol ∞ represents infinity, signifying that initially, the distances to nodes other than the starting node are considered unreachable.

Let’s begin with the start node. Consider its immediate neighbors, nodes 1 and 2. The distances from node 0 to these nodes are 5 and 3, respectively. We don't know if these are the shortest distances from 0 to them, but they’re definitely shorter than infinity. So, let's update the distances to those nodes from node 0:

Image represents a weighted graph illustrating a shortest path algorithm, possibly Dijkstra's algorithm.  The graph consists of six nodes (0-5), with node 0 labeled 'curr' and colored orange, indicating a starting node.  Nodes 1 and 2 are light blue, suggesting they've been visited.  The remaining nodes (3, 4, 5) are unvisited.  Edges connecting the nodes are labeled with their respective weights (1, 3, 4, 5). For instance, node 0 connects to node 1 with a weight of 5 and to node 2 with a weight of 3. Node 1 connects to node 2 with a weight of 1 and to node 3 with a weight of 4. Node 2 connects to node 4 with a weight of 5. Node 3 connects to node 1 with a weight of 4.  To the right, a distances array `[∞ ∞ ∞ ∞ ∞ ∞]` is shown, representing the initial distances from the source node (0) to all other nodes, where ∞ denotes infinity.  The array is partially updated, showing distances 5 and 3, calculated from the source node to nodes 1 and 2 respectively, indicated by light blue lines connecting the array to the corresponding nodes in the graph.  The array's index corresponds to the node number.

Right now, the closest node to the start node is node 2, with a distance of 3. This confirms that the shortest distance to node 2 from node 0 is 3, as the alternative route through node 1 has a longer distance. Thus, we can be certain that node 2 is reached through the shortest possible path, so let’s move to it:

Image represents a graph illustrating a shortest path algorithm, possibly Dijkstra's algorithm.  The left side shows a weighted, undirected graph with five nodes (numbered 0-4). Node 0 is greyed out, suggesting it's the starting node.  Edges connecting the nodes are labeled with their weights (e.g., the edge between nodes 1 and 2 has a weight of 1). Node 2 is highlighted in orange and labeled 'curr,' indicating it's the currently processed node. The right side displays a distances array `distances = [∞, 5, 3, ∞, ∞, ∞]`, representing the shortest distances from the starting node (0) to each node.  The array element corresponding to node 2 (index 2) shows a value of 3, highlighted in peach, indicating the shortest distance from node 0 to node 2 is 3. An upward-pointing arrow next to the array points to this value, explicitly labeling it as the 'shortest distance from start'.  The infinity symbols (∞) represent initially unknown distances, which are updated during the algorithm's execution.

The current node is now node 2. Keep in mind that, so far, we’ve traversed a distance of distance[2] = 3 from node 0 to reach node 2.

The immediate unvisited neighbors of the current node are nodes 1, 3, and 4. The distances to them from the start node are 1 + 3, 4 + 3, and 5 + 3 (where the +3 accounts for the distance traveled so far). Let’s update the distance array with these distances since they are smaller than the distances currently set for them:

Image represents a weighted graph illustrating a shortest path algorithm, possibly Dijkstra's algorithm.  The graph consists of five nodes (0, 1, 2, 3, 4, 5), represented by circles. Node 2 is highlighted in orange and labeled 'curr,' indicating the current node being processed.  Edges connect nodes, with weights (distances) shown on the edges. For example, node 1 connects to node 2 with a weight of 1, and node 2 connects to node 4 with a weight of 5. Node 0 is connected to node 2 with a weight of 3.  A light blue edge indicates a path being considered.  To the right, a 'distances' array is shown, initialized with infinity (∞) for all nodes except the starting node (0), which is 0.  The array is updated iteratively; the image shows the calculation of distances from the current node (2) to nodes 1 and 3, adding the distance from node 2 to the existing distance from node 0 to nodes 1 and 3 (1+3 and 4+3 respectively).  Node 4's distance is also updated (5+3).  The array represents the shortest distances from node 0 to all other nodes at a given step in the algorithm.

Right now, among the unvisited nodes, the node with the shortest distance from the start node is node 1, with a distance of 4. This means the shortest distance to node 1 from the start node is 4, since all other paths to node 1 involve traversing through distances larger than 4. So, let’s move to node 4:

Image represents a graph traversal algorithm visualization, likely Dijkstra's algorithm.  A weighted graph is shown with nodes numbered 0 through 5, where node 1 is highlighted in orange and labeled 'curr,' indicating the current node being processed.  Edges connecting nodes represent weighted paths; for example, node 1 connects to node 3 with a weight of 4.  The weights are displayed on the edges.  Nodes 0, 2, 3, and 4 are connected, forming a subgraph, while node 5 is isolated.  To the right, an array named 'distances' is shown, representing the shortest distances from the starting node (likely node 0, though not explicitly stated).  The array initially contains values, with the second element (index 1) highlighted in peach, showing the shortest distance from the start node to node 1 (value 1). An upward arrow points to this element, labeled 'shortest distance from start,' indicating that the algorithm is updating the shortest distance to the current node (node 1).  The infinity symbol (∞) represents unreachable nodes.

The greedy choice made is now becoming clearer:

At each step, we move to the unvisited node with the shortest known distance from the start node.


Applying this greedy choice for the rest of the graph completes Dijkstra’s algorithm, giving us an array populated with the shortest path lengths from the start node to each node:

Image represents a weighted graph displayed alongside a distance array. The graph consists of six nodes, numbered 0 through 5, connected by weighted edges.  Node 0 connects to node 1 (weight 5) and node 2 (weight 3). Node 1 connects to node 2 (weight 1), node 3 (weight 4), and node 0 (weight 5). Node 2 connects to node 4 (weight 5) and node 3 (weight 4). Node 3 connects to node 1 (weight 4). Node 4 and node 5 are not connected to any other nodes in the graph.  To the right of the graph, a Python-style array named `distances` is shown, containing values [0, 4, 3, 7, 8, ∞].  The numbers below the array (0, 1, 2, 3, 4, 5) correspond to the node indices, indicating that the distance from node 0 to node 0 is 0, to node 1 is 4, to node 2 is 3, to node 3 is 7, to node 4 is 8, and to node 5 is infinity (∞).  The array represents the shortest path distances from node 0 to all other nodes in the graph.

The final step is to convert all infinity values in this array to -1, indicating we weren’t able to reach that node:

Image represents a Python list assignment. The variable `distances` is assigned a list containing six numerical values: `[0, 4, 3, 7, 8, -1]`.  These values are enclosed within square brackets `[]`, indicating a list data structure.  Below the list, smaller numbers `0, 1, 2, 3, 4, 5` are displayed, acting as indices representing the position of each element within the `distances` list.  The index 0 corresponds to the value 0, index 1 to the value 4, and so on, with index 5 corresponding to the value -1.  There is no explicit flow of information depicted; the image simply shows the assignment of values to the list variable.

To implement the above strategy, we’d like an efficient way to access the unvisited node with the shortest known distance at any point in the process. We can use a min-heap for this, allowing us logarithmic access to the node with the minimum distance.

Using the min-heap
To understand how we use the min-heap in Dijkstra’s algorithm, consider the following example with the start node, node 0, initially in the min-heap with its corresponding distance of 0:

Image represents a visualization of Dijkstra's algorithm's initial state.  On the left, a weighted directed graph is shown with three nodes labeled 0, 1, and 2. Node 0 is marked as 'start'.  Edges connect node 0 to node 1 with weight 6, and node 0 to node 2 with weight 3. Node 1 connects to node 2 with weight 1. Below the graph, a distances array is shown, initialized as [0, ∞, ∞], representing the initial distances from the start node (0) to each node (0, 1, and 2 respectively, where ∞ signifies infinity). On the right, a min-heap data structure is depicted, currently containing only node 0 with a value of 0, indicated by 'min: 0:0'.  The min-heap is used to efficiently select the node with the smallest distance from the start node during the algorithm's execution.  The overall image illustrates the setup before the algorithm begins iterating and updating distances.

Begin by popping the start node from the top of the heap and setting it to the current node. Then, add the current node’s neighbors to the min-heap with their corresponding distances from the start node, and update the distances of these neighbors:

Image represents a step-by-step illustration of Dijkstra's algorithm using a min-heap.  The left side shows the initial state: a graph with three nodes (0, 1, 2), where node 0 (labeled 'curr' and colored orange) is the current node with a distance of 0.  Edges are weighted (6, 1, 3).  A min-heap is shown alongside, containing the initial distances: node 0 at distance 0, and nodes 1 and 2 at infinity (∞).  An arrow indicates that node 0 (with distance 0) is extracted from the min-heap, updating `curr_dist` to 0 and `curr_node` to 0. The right side depicts the state after updating the distances of node 0's neighbors. Node 1 and 2 are now colored cyan, reflecting their updated status. The distances to nodes 1 and 2 are updated to 6 and 3 respectively, reflected in the updated min-heap (6:1 and 3:2) and the `distances` array [0, 6, 3].  The arrow between the left and right sides signifies the transition between these states.

Repeat these two steps for each node at the top of the heap until the heap is empty:

Image represents a step-by-step illustration of Dijkstra's algorithm, focusing on updating distances in a graph.  The left side shows the initial state: a graph with nodes 0, 1, and 2, where node 2 (colored orange and labeled 'curr') is the current node being processed.  Edges are labeled with their weights (6, 1, 3).  A min-heap data structure is shown, containing node 1 with a distance of 6 and node 2 with a distance of 3 (indicated as 'min: 3:2').  The current distance to node 2 ('curr_dist') is 3, and an array 'distances' = [0, 6, 3] represents the shortest distances from node 0 to nodes 0, 1, and 2 respectively. An arrow points from the min-heap's '3:2' element to 'curr_dist = 3' and 'curr_node = 2', indicating the extraction of the current node and its distance from the heap. The right side depicts the state after updating the distances of node 2's neighbors. Node 1 is now colored light blue, reflecting its updated status. The min-heap now contains node 1 with a distance of 4 (1+3, shown as 'min: 1+3:1') and node 2 with a distance of 6. The 'distances' array is updated to [0, 4, 3], showing the new shortest distance to node 1.  A large arrow separates the before and after states, illustrating the transition.

Image represents a step-by-step illustration of Dijkstra's algorithm.  The left side shows the initial state: a graph with three nodes (0, 1, 2) connected by weighted edges (6, 1, 3), with node 1 highlighted as 'curr' (current node) in orange.  Node 1 has a 'curr_dist' of 4.  A min-heap data structure is shown, containing the element '6:1', indicating a distance of 6 to node 1.  Below, a 'distances' array [0, 4, 3] represents the shortest distances found so far from a source node (not shown) to nodes 0, 1, and 2 respectively. A large arrow points to the right, indicating a transition. The right side depicts the state after processing node 1: the min-heap now contains '6:1', reflecting the updated distances. The 'distances' array is updated to [0, 4, 3], showing that the distances to nodes 0, 1, and 2 have been updated.  The text ' (no unvisited neighbors)' indicates that all neighbors of the current node have been processed.  The overall image demonstrates how Dijkstra's algorithm iteratively selects the node with the minimum distance from the min-heap, updates the distances of its neighbors, and repeats until all nodes are processed.

Image represents a step in a graph traversal algorithm, likely Dijkstra's algorithm.  The left side shows a weighted graph with three nodes (0, 1, and 2) and their connecting edges labeled with weights (6, 1, and 3). Node 1 is highlighted in orange and labeled 'curr,' indicating it's the currently selected node. A red curved arrow points to node 1, labeled 'previously visited,' suggesting a process of iteratively visiting nodes.  Below the graph, 'curr_dist = 6' shows the current distance from the source node to node 1, and 'distances = [0 4 3]' represents an array storing the shortest distances from the source to each node (0, 1, and 2 respectively). The right side depicts a min-heap data structure labeled 'min_heap,' containing the element '6:1' (distance:node), signifying that node 1 with a distance of 6 is the element with the minimum distance currently in the heap. An orange arrow points from this element in the min-heap to 'curr_dist = 6' and 'curr_node = 1,' indicating that the node and its distance are extracted from the min-heap for processing.  The overall image illustrates the process of selecting the node with the minimum distance from a min-heap and updating relevant variables.

As we can see, we encounter an issue: we’re revisiting node 1. The only difference is that this time, we’re visiting it at a larger distance (curr_dist = 6) from the start than the recorded distance (4). We can avoid this situation with the following if-statement:

if curr_dist > distances[curr_node]: continue

This check allows us to avoid using an additional data structure to keep track of visited nodes.


Why does the greedy approach work?
Before reading this section, it’s worth reviewing the Greedy chapter if you aren’t familiar with greedy algorithms.

Dijkstra's algorithm is considered greedy because, at each step, it selects the unvisited node with the shortest known distance from the start node, based on the assumption that this distance is the shortest possible path to that node. This choice is made as a local optimum, with the belief that it will lead to the global optima: the shortest path to all nodes. Can we always guarantee that this assumption is true? Consider the following graph, where the current node is node 0:

Image represents a weighted graph illustrating a shortest path algorithm.  The graph consists of five nodes, numbered 0 through 4. Node 0, highlighted in orange and labeled 'curr,' represents the current node being processed. Nodes 1, 2, 3, and 4 are connected to node 0 with weighted edges; the weight of each edge is indicated by a number next to the connecting line (10, 3, 6, and 7 respectively). Dashed lines connect nodes 1, 2, 3, and 4 to points above them, suggesting further connections not explicitly shown.  To the right, a list labeled 'dist' shows a partial array, likely representing the shortest distances from node 0 to other nodes in the graph. The array starts with 0 (the distance from 0 to itself), followed by 10, 3, 6, and 7, corresponding to the weights of the edges connecting node 0 to nodes 1, 2, 3, and 4 respectively.  The ellipsis (...) indicates that the array continues beyond the displayed elements.  The numbers below the 'dist' array (0, 1, 2, 3, 4) likely represent the indices of the distances within the array, corresponding to the node numbers.

The node with the shortest known distance from the start node is node 2, with a distance of 3. We assume this is the shortest distance to node 2, and choose node 2 as the local optimum. Here’s a question we could ask here regarding the validity of this choice: is it possible to find a path with a distance less than 3 to another neighboring node, making node 2 no longer the neighbor with the shortest distance from the start node?

The answer is no. This is because we have to pass through one of these neighboring nodes to find any other paths, which would require us to add a distance of at least 3 to our total distance traversed from the start node. To reduce this traversed distance, we would need to encounter negatively weighted edges in the graph, which we know isn’t possible in this graph.

This analysis also demonstrates that Dijkstra’s algorithm is only applicable when the graph has no edges with negative weights.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
    # Represent the graph as an adjacency list.
    for u, v, w in edges:
        graph[u].append((v, w))
        graph[v].append((u, w))
    min_heap = [(0, start)]  # (distance, node)
    # Use Dijkstra's algorithm to find the shortest path between the start node
    # and all other nodes.
    while min_heap:
        curr_dist, curr_node = heapq.heappop(min_heap)
        # If the current distance to this node is greater than the recorded
        # distance, we've already found the shortest distance to this node.
        if curr_dist > distances[curr_node]:
            continue
        # Update the distances of the neighboring nodes.
        for neighbor, weight in graph[curr_node]:
            neighbor_dist = curr_dist + weight
            # Only update the distance if we find a shorter path to this
            # neighbor.
            if neighbor_dist < distances[neighbor]:
                distances[neighbor] = neighbor_dist
                heapq.heappush(min_heap, (neighbor_dist, neighbor))
    # Convert all infinity values to -1, representing unreachable nodes.
    return [-1 if dist == float('inf') else dist for dist in distances]
function shortestPath(n, edges, start) {
  const graph = Array.from({ length: n }, () => []);
  const distances = new Array(n).fill(Infinity);
  distances[start] = 0;
  // Represent the graph as an adjacency list.
  for (const [u, v, w] of edges) {
    graph[u].push([v, w]);
    graph[v].push([u, w]);
  }
  // Min-priority queue: entries are [dist, node]
  const minHeap = new MinPriorityQueue({ priority: (item) => item[0] });
  minHeap.enqueue([0, start]);
  // Use Dijkstra's algorithm to find the shortest path between the start node
  // and all other nodes.
  while (!minHeap.isEmpty()) {
    const [currDist, currNode] = minHeap.dequeue().element;
    // If the current distance to this node is greater than the recorded
    // distance, we've already found the shortest distance to this node.
    if (currDist > distances[currNode]) {
      continue;
    }
    // Update the distances of the neighboring nodes.
    for (const [neighbor, weight] of graph[currNode]) {
      const neighborDist = currDist + weight;
      // Only update the distance if we find a shorter path to this
      // neighbor.
      if (neighborDist < distances[neighbor]) {
        distances[neighbor] = neighborDist;
        minHeap.enqueue([neighborDist, neighbor]);
      }
    }
  }
  // Convert all infinity values to -1, representing unreachable nodes.
  return distances.map((dist) => (dist === Infinity ? -1 : dist));
}
public class Solution {
    public int[] shortestPath(int n, int[][] edges, int start) {
        List<List<int[]>> graph = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            graph.add(new ArrayList<>());
        }
        int[] distances = new int[n];
        Arrays.fill(distances, Integer.MAX_VALUE);
        distances[start] = 0;
        // Represent the graph as an adjacency list.
        for (int[] edge : edges) {
            int u = edge[0], v = edge[1], weight = edge[2];
            graph.get(u).add(new int[]{v, weight});
            graph.get(v).add(new int[]{u, weight});
        }
        PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
        minHeap.offer(new int[]{0, start}); // {distance, node}
        // Use Dijkstra's algorithm to find the shortest path between the start node
        // and all other nodes.
        while (!minHeap.isEmpty()) {
            int[] curr = minHeap.poll();
            int currDist = curr[0], currNode = curr[1];
            // If the current distance to this node is greater than the recorded
            // distance, we've already found the shortest distance to this node.
            if (currDist > distances[currNode]) {
                continue;
            }
            // Update the distances of the neighboring nodes.
            for (int[] neighborEdge : graph.get(currNode)) {
                int neighbor = neighborEdge[0], weight = neighborEdge[1];
                int neighborDist = currDist + weight;
                // Only update the distance if we find a shorter path to this
                // neighbor.
                if (neighborDist < distances[neighbor]) {
                    distances[neighbor] = neighborDist;
                    minHeap.offer(new int[]{neighborDist, neighbor});
                }
            }
        }
        // Convert all infinity values to -1, representing unreachable nodes.
        int[] result = new int[n];
        for (int i = 0; i < n; i++) {
            result[i] = distances[i] == Integer.MAX_VALUE ? -1 : distances[i];
        }
        return result;
    }
}

Complexity Analysis

Time complexity: The time complexity of the shortest_path is O((n+e)log⁡(n))O((n+e)\log(n)), where ee represents the number of edges. Here’s why:

  • Creating the adjacency list takes O(e)O(e) time.

  • Dijkstra's algorithm traverses up to all nn nodes and explores each edge of the graph. To access each node, we pop it from the heap, and for each edge, up to one node is pushed to the heap (when we process each node’s neighbors). Since each push and pop operation takes O(log⁡(n))O(\log(n)) time, the time complexity of Dijkstra’s algorithm is O((n+e)log⁡(n))O((n+e)\log(n)).

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

Space complexity: The space complexity is O(n+e)O(n+e), since the adjacency list takes up O(n+e)O(n+e) space, whereas the distances array and min_heap take up O(n)O(n) space.

CHAPTER 85

Connect the Dots

Given a set of points on a plane, determine the minimum cost to connect all these points.

The cost of connecting two points is equal to the Manhattan distance between them, which is calculated as |x1 - x2| + |y1 - y2| for two points (x1, y1) and (x2, y2).

Example:

Image represents a transformation of a set of points in a Cartesian coordinate system.  The left side shows a scatter plot with five points located at approximately (1,1), (3,2), (4,3), (2,6), and (7,1).  These points are unconnected. A large, bold arrow points to the right, indicating a transformation. The right side displays the same five points, but now connected to a central point located at approximately (3,2).  The lengths of the lines connecting the central point to each of the other points are labeled with numerical values: 3, 5, 2, and 5.  The x and y axes are labeled on both sides, ranging from 0 to 7. The transformation visually demonstrates a change from an unorganized set of points to a structured arrangement radiating from a central node, with the connecting lines representing distances or weights between the points.
Example
Input: points = [[1, 1], [2, 6], [3, 2], [4, 3], [7, 1]]
Output: 15

Constraints:

  • There will be at least 2 points on the plane.

Intuition

Let's treat this problem as a graph problem, imagining each point as a node, and the cost of connecting any two points as the weight of an edge between those nodes.

The goal is to connect all nodes (points) in such a way that the total cost is minimized. This is essentially the minimum spanning tree (MST) problem:

The MST of a weighted graph is a way to connect all points in the graph, ensuring each point is reachable from any other point while minimizing the total weight of the connections.

There are two main algorithms that are used to find the MST of a graph:

  • Kruskal's algorithm
  • Prim's algorithm

In this explanation, we'll break down Kruskal's algorithm since it uses a data structure we've already discussed in this chapter: Union-Find.

Kruskal's algorithm
Kruskal's algorithm is a greedy method for finding the MST. It essentially builds the MST by connecting nodes with the lowest-weighted edges first while skipping any edges that could cause a cycle.

To do this, we first need to identify all the possible edges and sort them by their Manhattan distance:

Image represents a weighted, undirected graph visualized on a Cartesian coordinate system with x and y axes ranging from 0 to 7.  Four nodes are plotted at coordinates (1,1), (2,6), (3,2), and (7,1).  A fifth node is located at approximately (3.5, 2.5).  Edges connect these nodes, with each edge labeled with a numerical weight representing the distance or cost between the connected nodes. For instance, the edge between (1,1) and (2,6) has a weight of 6.  The edge between (1,1) and (3,2) has a weight of 3. The edge between (3,2) and (7,1) has a weight of 5.  The edge between (1,1) and (7,1) has a weight of 6. The edge between (2,6) and (7,1) has a weight of 10.  To the right of the graph, a list of node pairs is presented, each pair connected by an arrow indicating the direction of traversal, and followed by the weight of the edge connecting those nodes.  This list essentially reiterates the edge weights and connections shown graphically in the main diagram.

Now, let's implement Kruskal's algorithm. Start by including the lowest weighted edge in the MST, which is the edge from (3, 2) to (4, 3):

Image represents a Cartesian coordinate system with x and y axes ranging from 0 to 7.  Several points are plotted on this plane: (1,1), (1,6), (3,2), (4,3), and (7,1). A green line connects points (3,2) and (4,3).  To the right of the graph, a list of coordinate pairs is presented, each pair representing a starting and ending point.  An arrow points from each pair to a numerical value, seemingly representing a calculated distance or weight between the corresponding points on the graph. For example, the pair (3,2) to (4,3) maps to the value '2', which might correspond to the length of the green line segment.  Other pairs, such as (1,1) to (3,2), map to different numerical values (in this case, '3'), suggesting a different calculation method or metric is used for each pair.  The numerical values appear to be associated with the distances or relationships between the points, possibly representing edge weights in a graph data structure.

Next, add the next lowest weighted edge, which is the edge from (1, 1) to (3, 2):

Image represents a Cartesian coordinate system with x and y axes ranging from 0 to 7.  Several points are plotted on this plane: (1,1), (2,6), (3,2), (4,3), and (7,1).  Two line segments are drawn: a green line connecting (1,1) and (3,2), labeled '3', and a grey line connecting (3,2) and (4,3), labeled '2'.  To the right of the graph, a list of point pairs is presented, each pair representing a connection between two points on the graph.  An arrow follows each pair, pointing to a numerical value.  These values seem to represent a calculated distance or weight associated with the connection between the respective points.  For instance, the pair (1,1) to (3,2) is followed by an arrow pointing to '3', corresponding to the green line's label.  The point (1,1) is highlighted with a light green circle.  The numerical values following the arrows appear to be the result of a calculation or function applied to the coordinates of the connected points.

Notice that adding the next edge from (1, 1) to (4, 3) will cause a cycle. Edges that cause cycles are avoided in an MST because doing so implies we're connecting two points that are already connected. So, let's skip this edge:

Image represents a Cartesian coordinate system with x and y axes ranging from 0 to 7.  Several points are plotted on this plane: (1,1), (3,2), (4,3), (2,6), and (7,1).  These points are connected by grey lines forming a polygon.  Additionally, a dashed red line connects (1,1) and (4,3), with the value '5' written in red along this line.  To the right of the graph, a list shows pairs of points from the graph, indicating the coordinates of the starting and ending points of each line segment.  An arrow points from each pair to a numerical value representing the distance or weight associated with that segment.  The value '5' is highlighted in pink and labeled as 'forms a cycle,' suggesting a closed loop or cycle is formed by some of the connections.  The numbers next to the points on the graph (1, 2, 3) seem to be labels or annotations, not coordinates.

Continuing this process until all points are connected gives us the MST:

Image represents a graph overlaid on a Cartesian coordinate system with x and y axes ranging from 0 to 7.  Four points are plotted: (1,1), (3,2), (4,3), and (7,1).  Lines connect these points, forming a network.  Each line is labeled with a numerical weight representing the distance or cost between the connected points. For instance, the line between (1,1) and (3,2) is labeled '3'.  To the right of the graph, a list details the connections between the points using coordinate pairs, indicating the 'from' and 'to' points of each connection.  An arrow follows each pair, pointing to the corresponding weight from the graph.  The weights are represented by numbers, with some connections having darker arrows (indicating stronger or more significant connections) and others lighter (suggesting weaker or less significant connections).  The coordinate pairs and their associated weights illustrate a weighted graph, likely representing a distance matrix or a cost function between nodes in a network.

We'll know that all points are connected once we've added a total of n - 1 edges to the MST, because this is the least number of edges needed to connect n points without any cycles. To attain the cost of this MST, we just need to keep track of the Manhattan cost of each edge we add to the MST.

Now that we have a strategy to find the MST of a set of points, we just need a way to determine when connecting two points leads to a cycle.

Avoiding cycles
A cycle is formed when we add an edge to nodes that are already connected in some way. Consider the following set of points, where the group of points to the left are connected, and the group of points to the right are connected:

Image represents a Cartesian coordinate system graph displaying two distinct, unconnected line segments.  The x-axis ranges from 0 to approximately 8, and the y-axis ranges from 0 to 6. The first line segment connects the points (1, 5), (2, 2), and (3, 1). The second line segment connects the points (7.5, 1), (8.5, 2), and (9.5, 5). Both segments are composed of straight lines connecting the specified points, represented by solid black dots.  The x and y axes are labeled, indicating the independent and dependent variables, respectively.  No other labels, text, URLs, or parameters are present within the image.

Connecting any two points in the same group causes a cycle, and connecting two points from two separate groups will result in both groups merging into one:

Image represents two Cartesian coordinate graphs side-by-side.  Both graphs share the same x-axis ranging from 0 to 10 and a y-axis ranging from 0 to 6.  The left graph displays a series of connected black points forming a jagged line, primarily gray, with coordinates approximately (1,5), (2,2), (3,1), (8,1), and (9,2), (10,5). A dashed red line connects (1,5) to (2,2), labeled 'connect (1,5) to (2,2) → cycle' indicating a cyclical connection between these two points. The right graph shows a similar set of black points connected by a gray line with coordinates approximately (1,5), (2,2), (3,1), (8,1), and (9,2), (10,5). However, in this graph, a solid green line connects (3,1) to (8,1), labeled 'connect (3,1) to (8,1),' illustrating a direct connection between these points.  Both graphs visually represent different ways to connect points, highlighting the concepts of cycles and direct connections within a data structure or network.

What would be useful here is a way to determine if two points belong to the same group, and a way to merge two groups together. The Union-Find data structure is perfect for this.

We can use the union function to connect two points together.

  • If the two points belong to two separate groups, union should merge those groups and return true.

  • Otherwise, if they belong to the same group, union should return false.

This way, we can use this boolean return value as a way to determine if attempting to connect two points causes a cycle.

If you're not familiar with Union-Find, study the solution to the Merging Communities problem.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

In this implementation, we'll identify each point using their index in the points array. This means that for n points, each point is represented by an index from 0 to n - 1. By doing this, we can set up a Union-Find data structure with a capacity of n so that there are n groups initially, with each group containing one of the n points.

from typing import List
    
def connect_the_dots(points: List[List[int]]) -> int:
    n = len(points)
    # Create and populate a list of all possible edges.
    edges = []
    for i in range(n):
        for j in range(i + 1, n):
            # Manhattan distance.
            cost = (abs(points[i][0] - points[j][0]) +
                    abs(points[i][1] - points[j][1]))
            edges.append((cost, i, j))
    # Sort the edges by their cost in ascending order.
    edges.sort()
    uf = UnionFind(n)
    total_cost = edges_added = 0
    # Use Kruskal's algorithm to create the MST and identify its minimum cost.
    for cost, p1, p2 in edges:
        # If the points are not already connected (i.e. their representatives are
        # not the same), connect them, and add the cost to the total cost.
        if uf.union(p1, p2):
            total_cost += cost
            edges_added += 1
            # If n - 1 edges have been added to the MST, the MST is complete.
            if edges_added == n - 1:
                return total_cost
function connectTheDots(points) {
  const n = points.length;
  // Create and populate a list of all possible edges.
  const edges = [];
  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      // Manhattan distance.
      const cost = Math.abs(points[i][0] - points[j][0]) + Math.abs(points[i][1] - points[j][1]);
      edges.push([cost, i, j]);
    }
  }
  // Sort the edges by their cost in ascending order.
  edges.sort((a, b) => a[0] - b[0]);
  const uf = new UnionFind(n);
  let totalCost = 0;
  let edgesAdded = 0;
  // Use Kruskal's algorithm to create the MST and identify its minimum cost.
  for (const [cost, p1, p2] of edges) {
    // If the points are not already connected (i.e. their representatives are
    // not the same), connect them, and add the cost to the total cost.
    if (uf.union(p1, p2)) {
      totalCost += cost;
      edgesAdded++;
      // If n - 1 edges have been added to the MST, the MST is complete.
      if (edgesAdded === n - 1) {
        return totalCost;
      }
    }
  }
}
public class Solution {
    public int connectTheDots(int[][] points) {
        int n = points.length;
        // Create and populate a list of all possible edges.
        List<int[]> edges = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                // Manhattan distance.
                int cost = Math.abs(points[i][0] - points[j][0]) +
                           Math.abs(points[i][1] - points[j][1]);
                edges.add(new int[]{cost, i, j});
            }
        }
        // Sort the edges by their cost in ascending order.
        edges.sort((a, b) -> Integer.compare(a[0], b[0]));
        UnionFind uf = new UnionFind(n);
        int totalCost = 0;
        int edgesAdded = 0;
        // Use Kruskal's algorithm to create the MST and identify its minimum cost.
        for (int[] edge : edges) {
            int cost = edge[0], p1 = edge[1], p2 = edge[2];
            // If the points are not already connected (i.e. their representatives are
            // not the same), connect them, and add the cost to the total cost.
            if (uf.union(p1, p2)) {
                totalCost += cost;
                edgesAdded++;
                // If n - 1 edges have been added to the MST, the MST is complete.
                if (edgesAdded == n - 1) {
                    return totalCost;
                }
            }
        }
        return totalCost;
    }
}

The Union-Find data structure remains the same as the implementation provided in Merging Communities, with a slight modification made to the union function, adding a boolean return value to it.

class UnionFind:
    def __init__(self, size):
        self.parent = [i for i in range(size)]
        self.size = [1] * size

    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 if both groups were merged.
            return True
        # Return False if the points belong to the same group.
        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]
class UnionFind {
  constructor(size) {
    this.parent = Array.from({ length: size }, (_, i) => i);
    this.size = new Array(size).fill(1);
  }

  union(x, y) {
    const repX = this.find(x);
    const repY = this.find(y);
    if (repX !== repY) {
      if (this.size[repX] > this.size[repY]) {
        this.parent[repY] = repX;
        this.size[repX] += this.size[repY];
      } else {
        this.parent[repX] = repY;
        this.size[repY] += this.size[repX];
      }
      // Return True if both groups were merged.
      return true;
    }
    // Return False if the points belong to the same group.
    return false;
  }

  find(x) {
    if (x === this.parent[x]) {
      return x;
    }
    this.parent[x] = this.find(this.parent[x]);
    return this.parent[x];
  }
}
class UnionFind {
    private int[] parent;
    private int[] size;

    public UnionFind(int size) {
        this.parent = new int[size];
        this.size = new int[size];
        for (int i = 0; i < size; i++) {
            this.parent[i] = i;
            this.size[i] = 1;
        }
    }

    public boolean union(int x, int y) {
        int repX = find(x);
        int repY = find(y);
        if (repX != repY) {
            if (this.size[repX] > this.size[repY]) {
                this.parent[repY] = repX;
                this.size[repX] += this.size[repY];
            } else {
                this.parent[repX] = repY;
                this.size[repY] += this.size[repX];
            }
            // Return True if both groups were merged.
            return true;
        }
        // Return False if the points belong to the same group.
        return false;
    }

    public int find(int x) {
        if (x == this.parent[x]) {
            return x;
        }
        this.parent[x] = this.find(this.parent[x]);
        return this.parent[x];
    }
}

Complexity Analysis

Time complexity: The time complexity of the connect_the_dots is O(n2log⁡(n))O(n^2\log(n)), where nn denotes the length of the points array, and n2n^2 is the number of edges in the edges array, since we consider all possible pairs of points to form edges. Here's why:

  • Sorting n2n^2 edges takes O(n2log⁡(n2))O(n^2\log(n^2)) time, which can be simplified to O(n2log⁡(n))O(n^2\log(n)).

  • We perform up to one union operation for each edge, with each union taking amortized O(1)O(1) time, resulting in the union operations contributing amortized O(n2)O(n^2) time.

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

Space complexity: The space complexity is O(n2)O(n^2) due to the space taken up by the edges list. In addition, the UnionFind data structure takes up O(n)O(n) space.

CHAPTER 86

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:

Image represents a flowchart illustrating a coding pattern, possibly related to decision-making or control flow.  The central element is a small, light gray circle labeled 'YOU,' representing a decision point.  Three orange arrows emanate from this circle: one labeled 'A' pointing left, one labeled 'C' pointing right, and one labeled 'B' pointing downwards. These arrows represent different possible paths or code branches.  The paths extend into a maze-like structure of black lines, forming distinct routes for each branch.  A curved black arrow connects the downward path 'B' to a point on the right labeled 'intersection 1,' suggesting a merging or joining of paths later in the process. The overall structure resembles a simplified representation of code execution, where 'YOU' is the decision point, A, B, and C are different code blocks or functions, and the lines represent the flow of execution, potentially highlighting the concept of conditional statements and their impact on the program's path.

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:

Image represents a flowchart depicting a control flow through a series of labeled points (A, B, C, D, E) connected by lines indicating the direction of flow.  A central intersection is highlighted, labeled 'intersection 2,' where a small, light gray circle represents a decision point.  From this circle, a curved arrow points to the right, indicating a possible path.  A thick, green arrow points left from point A to the intersection, while a light gray arrow points right from the intersection to point C.  A light gray arrow points downwards from the intersection to point B.  From the circle, a thick, orange arrow points downwards to point E, and another thick, orange arrow points left to point D.  The lines form a complex path, with some segments forming rectangular shapes, suggesting different code blocks or processes. The overall structure resembles a maze, illustrating the branching and merging of control flow within a program.

You try option D, but it leads to a dead end. So, you backtrack to the second intersection point:

The image represents a visual explanation of a backtracking algorithm, likely within the context of pathfinding or maze traversal.  It shows two nearly identical diagrams depicting a maze-like structure with labeled nodes (A, B, C, D, E) connected by pathways.  The left diagram illustrates a forward traversal, showing a green arrow moving from D to A, then a grey arrow from A to C, and a grey arrow downwards to B.  The right diagram shows the same initial path but highlights a backtracking step.  A light-blue shaded L-shaped section indicates the path taken before backtracking. A bright-blue arrow shows the backtracking movement from a point near B back to a point near E. An orange arrow then points downwards from this point to node E, representing the next step in the algorithm after backtracking.  The overall structure is a series of interconnected rectangular boxes representing different states or locations within the algorithm's search space.  The arrows indicate the flow of the algorithm, with green arrows representing the initial forward movement and blue and orange arrows representing the backtracking and subsequent steps.

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:

Image represents a visual depiction of a backtracking algorithm navigating a maze-like structure.  The diagram shows two instances of a path-finding process. The top half illustrates a forward traversal: a light-blue path starts from the left, moves downwards labeled 'E', then upwards and right, reaching a junction labeled 'A', 'B', and 'C'. A green arrow indicates a choice is made to proceed from 'A' to 'C'. The bottom half shows the backtracking process.  A light-blue path again starts from the left, but this time, after reaching a junction similar to the top half, it proceeds along a path labeled 'backtrack' (in light-blue text) using a light-blue upward arrow. This backtrack path leads to a point where a different path is explored, indicated by an orange arrow moving towards 'C'.  Both halves feature a similar maze structure with paths represented by lines, junctions, and a small circle representing the current position of the algorithm.  The labels 'A', 'B', and 'C' denote decision points within the maze, while 'E' marks a starting point in the top half. The 'backtrack' labels highlight the reversal of the path taken during the backtracking phase.

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:

Image represents a maze-like structure illustrating a pathfinding algorithm.  A light-blue, L-shaped area represents the explored portion of the maze, with thicker black lines defining the maze walls.  A green circle marks a decision point within the maze. From this point, a light-green arrow points right, labeled 'C', indicating a possible path. A thicker, downward-pointing green arrow leads to a point labeled 'B', representing a choice made by the algorithm.  From point B, another light-green arrow points right towards the maze exit, labeled 'Found a way out!', indicating a successful path discovery. The overall arrangement shows the algorithm's progression through the maze, starting from an implied entry point (the top of the L-shape) and ending at the exit, highlighting the path taken and the exploration of alternative routes (represented by the 'C' path).

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:

Image represents a tree-like diagram illustrating a coding pattern, possibly related to decision-making or pathfinding.  The top node is labeled 'intersection 1,' from which three branches extend downwards. The leftmost branch, labeled 'A,' points to a node labeled 'intersection 2.'  'Intersection 2' has two further branches, labeled 'D' and 'E,' both leading to terminal nodes labeled 'dead end.' The central branch from 'intersection 1,' labeled 'B,' points directly to a terminal node labeled 'exit.' The rightmost branch from 'intersection 1,' labeled 'C,' also points to a terminal node labeled 'dead end.'  The arrows on the branches indicate the direction of flow, suggesting a sequential process where decisions are made at each intersection point, leading to either a successful exit or a dead end.

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:

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.
function dfs(state) {
  // Termination condition.
  if (meetsTerminationCondition(state)) {
    processSolution(state);
    return;
  }
  // Explore each possible decision that can be made at the current state.
  for (const decision of possibleDecisions(state)) {
    makeDecision(state, decision);
    dfs(state);
    undoDecision(state, decision); // Backtrack.
  }
}
void dfs(State state) {
    // Termination condition.
    if (meetsTerminationCondition(state)) {
        processSolution(state);
        return;
    }
    // Explore each possible decision that can be made at the current state.
    for (Decision decision : possibleDecisions(state)) {
        makeDecision(state, decision);
        dfs(state);
        undoDecision(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)O(b\cdot d), where bb denotes the branching factor and dd denotes the depth. This is because in the worst case, every node at each level of the tree needs to be explored during a typical backtracking algorithm.

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

Image represents a hierarchical diagram illustrating the application of the 'Backtracking' coding pattern.  At the top, a rounded rectangle labeled 'Backtracking' serves as the root node.  Three dashed-line rounded rectangles branch down from it. The leftmost rectangle is labeled 'Permutations' and lists two sub-problems: 'Find All Permutations' and 'N Queens.' The rightmost rectangle is labeled 'Subsets' and contains the sub-problem 'Find All Subsets.'  A third rectangle, positioned below 'Backtracking' and connected to it by a dashed line, is labeled 'Combinations' and lists 'Combinations of Sum' and 'Phone Keypad Combination' as its sub-problems.  The arrangement shows that 'Permutations,' 'Subsets,' and 'Combinations' are all problem types that can be solved using the backtracking algorithm, with the diagram visually representing this relationship.
CHAPTER 87

Find All Permutations

Return all possible permutations of a given array of unique integers. They can be returned in any order.

Example:

Example
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. For the second position, let's pick a different number. We can keep adding numbers like this until all the numbers from the array are used. To avoid reusing numbers, let's also keep track of the used numbers using a hash set.

Image represents a tree-like diagram illustrating a recursive algorithm, likely for generating permutations.  The top row shows three initial states, each represented as '[], used = {}', indicating an empty permutation list and an empty set of used numbers.  The input `nums = [4 5 6]` is defined at the top.  From each initial state, a number is chosen (indicated by 'choose 4', 'choose 5', 'choose 6' in peach-colored boxes), represented by a downward-pointing arrow.  This chosen number is appended to the permutation list, and added to the `used` set.  The next level shows the updated state, for example '[4], used = {4}'. This process repeats recursively; each node represents a state with a partially built permutation and a set of used numbers.  The arrows depict the flow of the algorithm, branching out as each number is chosen.  The final state, '[4,5,6], used = {4,5,6}', is reached in the bottom-right branch, resulting in the message 'one permutation found!' in green, indicating a successful permutation generation.  The diagram visually demonstrates the backtracking nature of the algorithm, as each branch explores a different permutation possibility.

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

Image represents a directed acyclic graph illustrating a backtracking algorithm, likely for a problem involving selecting elements from a set.  The graph shows a sequence of states, each represented by a node labeled with a list `[]` indicating the selected elements and a set `{ }` labeled `used` showing the elements already considered.  The top node `[], used = {}` represents the initial state with no elements selected.  An arrow points downwards to `[4], used = {4}`, indicating that element 4 has been selected.  Another downward arrow connects this to `[4,5], used = {4,5}`, showing that element 5 has subsequently been selected.  A final downward arrow leads to `[4,5,6]`, suggesting element 6 was added.  A light-blue box labeled 'backtrack: remove 6' and an upward arrow connects `[4,5,6]` to `[4,5]`, demonstrating a backtracking step where element 6 is removed to explore alternative solutions.  The graph visually depicts the exploration of different combinations by adding and removing elements, a common characteristic of backtracking algorithms.

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

Image represents a state diagram illustrating a backtracking algorithm, likely for a problem involving selecting elements from a set.  The diagram shows a directed acyclic graph with nodes representing states and edges representing transitions. Each node is labeled with a list (representing a subset of elements selected so far) and a set named 'used' indicating which elements are already included in the subset.  The top node, '[ ], used = {}', represents the initial state with no elements selected.  A downward arrow connects this to '[4], used = {4}', indicating the selection of element 4.  Another downward arrow leads to '[4, 5]', showing the subsequent selection of element 5. A further downward arrow connects to '[4, 5, 6]', suggesting the addition of element 6.  Crucially, a light-blue box labeled 'backtrack: remove 5' and an upward arrow connects '[4, 5]' to '[4], used = {4}', demonstrating a backtracking step where element 5 is removed to explore alternative selections.  The arrows indicate the flow of the algorithm, showing how it explores different combinations by adding and removing elements.

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:

Image represents a state-transition diagram illustrating a backtracking algorithm, likely for finding combinations or permutations.  The diagram shows a tree-like structure where each node represents a state defined by a list (in square brackets) representing a partially built combination and a set (within curly braces) labeled 'used' tracking the elements already included. The root node is `[], used = {}`, indicating an empty initial combination and no used elements.  A downward arrow signifies the addition of an element to the combination.  From the root, a black arrow points to `[4], used = {4}`, showing the selection of element 4.  From this node, two downward arrows branch to `[4,5]` and `[4,6], used = {4,6}`, representing the addition of 5 and 6 respectively, updating the 'used' set. A light-blue upward arrow connects `[4,5]` to `[4,5,6]`, suggesting a further addition of 6 (though this branch isn't fully explored).  A rectangular box labeled 'choose 6' highlights the decision point where 6 is considered for addition to the combination. The overall structure demonstrates the exploration of different combinations by adding elements and backtracking when necessary, as indicated by the upward arrows.

The only number we can use at this point is 5, so let’s add it to [4, 6], giving us another permutation:

Image represents a tree-like diagram illustrating a backtracking algorithm for generating permutations.  The root node is `[], used = {}`, indicating an empty initial set and an empty set of used numbers.  A downward-pointing arrow connects this to the node `[4], used = {4}`, showing the selection of 4 as the first element.  From this node, two branches extend downwards, representing the choices for the second element: `[4,5]` and `[4,6]`, with `used = {4,6}` indicating that 4 and 6 have been used.  A light-blue upward-pointing arrow connects `[4,5]` to `[4,5,6]`, showing the addition of 6 as the third element.  Similarly, a downward-pointing arrow from `[4,6]` leads to `[4,6,5]`.  The text 'choose 5' highlights the selection of 5 in the lower branch.  Finally, `[4,5,6]` and `[4,6,5]` represent complete permutations, with `used = {4,6,5}` indicating all used numbers, and the text 'found another permutation!' confirms the successful generation of a permutation.  The diagram visually demonstrates the recursive exploration of possibilities and the backtracking process when a choice leads to a dead end.

Following this backtracking process until we’ve explored all branches allows us to generate all permutations:

Image represents a tree-like structure illustrating the permutations of the set {4, 5, 6}. The root node, at the top, is an empty set represented by `[]`.  From this root, three branches descend, each representing a single element from the set: [4], [5], and [6]. Each of these nodes then branches further, adding one more element from the remaining set. For example, [4] branches into [4, 5] and [4, 6], representing the two possible ways to add one element from {5, 6} to the set {4}. This pattern continues until the bottom level, where each leaf node represents a complete permutation of the original set {4, 5, 6}, shown as ordered lists within square brackets: [4, 5, 6], [4, 6, 5], [5, 4, 6], [5, 6, 4], [6, 4, 5], and [6, 5, 4].  Arrows indicate the flow of information, showing how each permutation is built up sequentially from the root node.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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)
function findAllPermutations(nums) {
  const res = [];
  backtrack(nums, [], new Set(), res);
  return res;
}

function backtrack(nums, candidate, used, res) {
  // If the current candidate is a complete permutation, add it to the result.
  if (candidate.length === nums.length) {
    res.push([...candidate]);
    return;
  }
  for (const num of nums) {
    if (!used.has(num)) {
      // Add 'num' to the current permutation and mark it as used.
      candidate.push(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.delete(num);
    }
  }
}
public class Solution {
    public List<List<Integer>> findAllPermutations(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        backtrack(nums, new ArrayList<>(), new HashSet<>(), res);
        return res;
    }

    private void backtrack(int[] nums, List<Integer> candidate, Set<Integer> used, List<List<Integer>> res) {
        // If the current candidate is a complete permutation, add it to the result.
        if (candidate.size() == nums.length) {
            res.add(new ArrayList<>(candidate));
            return;
        }
        for (int num : nums) {
            if (!used.contains(num)) {
                // Add 'num' to the current permutation and mark it as used.
                candidate.add(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.remove(candidate.size() - 1);
                used.remove(num);
            }
        }
    }
}

Complexity Analysis

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

  • Starting from the root, we recursively explore nn candidates.
  • For each of these nn candidates, we explore n−1n - 1 more candidates, then n−2n - 2 more candidates, etc, until we have explored all permutations. This results in a total of n⋅(n−1)⋅(n−2)…1=n!n\cdot (n-1)\cdot (n-2)…1=n! permutations.
  • For each of the n!n! permutations, we make a copy of it and add it to the output, which takes O(n)O(n) time.

This results in a total time complexity of O(n!)⋅O(n)=O(n⋅n!)O(n!)\cdot O(n)=O(n\cdot n!).

Space complexity: The space complexity is O(n)O(n) because the maximum depth of the recursion tree is nn. The algorithm also maintains the candidate and used data structures, both of which also contribute O(n)O(n) space. Note, the res array does not contribute to space complexity.

CHAPTER 88

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:

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

Let’s have a look at what the state space tree looks like when making this decision for every element.

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:

Image represents a simplified diagram, possibly illustrating a coding pattern or data structure.  It shows a rectangular white space, representing the core data or process, enclosed by a thick black border. This border is thicker at the top and bottom, creating a visual emphasis on these areas.  The top and bottom borders extend slightly beyond the sides of the central rectangle, forming small, outward-pointing projections.  There are no internal components, labels, text, URLs, or parameters visible within the white rectangle or the black border. The overall simplicity suggests a high-level representation focusing on the structure rather than specific details of the pattern or data it depicts.  The lack of internal elements implies an abstract representation, where the internal workings are not explicitly shown.

To figure out how we 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:

Image represents a diagram illustrating an inclusive and exclusive range operation.  A central node, labeled `[]`, represents the input range.  From this central node, two lines extend to the left and right. The left line, labeled `incl. 4` in cyan, points to a node labeled `[4]`, indicating that the number 4 is included in the resulting range.  Conversely, the right line, labeled `excl. 4` in orange, points to a node labeled `[]`, signifying that the number 4 is excluded from the resulting range on that side.  The arrows indicate the direction of the range operation, showing how the input range is processed to produce two distinct output ranges based on inclusion or exclusion of the value 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:

Image represents a binary tree structure illustrating a coding pattern.  The root node, at the top, is represented by `[]`, indicating an empty array or list. This root node branches into two child nodes, also represented by `[]`. Each of these child nodes further branches into two more nodes. The left branches from the root and its child node are labeled `incl.5` in cyan, signifying the inclusion of the number 5.  The right branches are labeled `excl.5` in orange, indicating the exclusion of 5. The leaf nodes at the bottom represent the resulting arrays after applying these inclusion/exclusion operations.  Specifically, the leftmost leaf node shows `[4, 5]`, resulting from including 5 in the initial array `[4]`. The next leaf node is `[4]`, showing the result of excluding 5 from `[4]`. Similarly, on the right side, `[5]` represents including 5 in an initial empty array `[]`, and the final leaf node `[]` shows the result of excluding 5 from an empty array.  The arrows indicate the flow of data and the operations performed at each node.

Finally, for the third element, we continue branching out for each existing subset based on whether we include or exclude this element:

Image represents a binary tree illustrating a recursive algorithm, likely demonstrating subset generation or a similar combinatorial problem.  The root node is an empty array `[]`.  This root branches into two nodes: `[4]` and `[]`. Each subsequent node represents a subset.  Each node with a numerical array splits into two child nodes: one where the number 6 is included (`incl. 6` in cyan), and one where it's excluded (`excl. 6` in orange).  This pattern repeats down the tree.  The leaf nodes show the final subsets generated: `[4,5,6]`, `[4,5]`, `[4,6]`, `[4]`, `[5,6]`, `[5]`, `[6]`, and `[]`. The arrows indicate the flow of the algorithm, showing how each subset is derived from its parent by either including or excluding the number 6.

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:

Image represents a recursive tree structure illustrating a coding pattern, likely for generating subsets of a given array.  The left side shows an iterative process, with boxes labeled 'i = 0', 'i = 1', 'i = 2', and 'i = 3' representing iterations, each accompanied by the array `nums` which is initially `[4, 5, 6]` and shrinks with each iteration.  Arrows indicate the progression through the iterations. The main part of the image is a binary tree. Each node represents a subset of `nums`. The top node is an empty array `[]`. Each subsequent level branches into two nodes: one labeled 'incl nums[i]' (in cyan) representing the inclusion of the current element `nums[i]` in the subset, and the other labeled 'excl nums[i]' (in orange) representing its exclusion.  The branches connect nodes, showing how subsets are built recursively by either including or excluding each element.  The leaf nodes at the bottom show all possible subsets of the original array `[4, 5, 6]`, ranging from the empty set `[]` to the full set `[4, 5, 6]`.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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)
function findAllSubsets(nums) {
  const res = [];
  backtrack(0, [], nums, res);
  return res;
}

function backtrack(i, currSubset, nums, res) {
  // Base case: if all elements have been considered, add the current subset to the
  // output.
  if (i === nums.length) {
    res.push([...currSubset]);
    return;
  }
  // Include the current element and recursively explore all paths that branch from
  // this subset.
  currSubset.push(nums[i]);
  backtrack(i + 1, currSubset, nums, res);
  // Exclude the current element and recursively explore all paths that branch from
  // this subset.
  currSubset.pop();
  backtrack(i + 1, currSubset, nums, res);
}
public class Solution {
    public List<List<Integer>> findAllSubsets(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        backtrack(0, new ArrayList<>(), nums, res);
        return res;
    }

    private void backtrack(int i, List<Integer> currSubset, int[] nums, List<List<Integer>> res) {
        // Base case: if all elements have been considered, add the current subset to the
        // output.
        if (i == nums.length) {
            res.add(new ArrayList<>(currSubset));
            return;
        }
        // Include the current element and recursively explore all paths that branch from
        // this subset.
        currSubset.add(nums[i]);
        backtrack(i + 1, currSubset, nums, res);
        // Exclude the current element and recursively explore all paths that branch from
        // this subset.
        currSubset.remove(currSubset.size() - 1);
        backtrack(i + 1, currSubset, nums, res);
    }
}

Complexity Analysis

Time complexity: The time complexity of find_all_subsets is O(n2⋅n)O(n^2\cdot n). This is because the state space tree has a depth of nn and a branching factor of 2 since there are two decisions we make at each state. For each of the 2n2n subsets created, we make a copy of them and add the copy to the output, which takes O(n)O(n) time. This results in a total time complexity of O(n2⋅n)O(n^2\cdot n).

Space complexity: The space complexity is O(n)O(n) because the maximum depth of the recursion tree is nn. The algorithm also maintains the curr_subset data structure, which also contributes O(n)O(n) space. Note, the res array does not contribute to space complexity.

CHAPTER 89

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:

Image represents a before-and-after visualization of a simple puzzle or algorithm, likely illustrating a sorting or rearrangement process.  The image consists of two identical 3x3 grids, each cell shaded alternately light and dark gray.  Each grid contains four identical black crown symbols, each with a horizontal line beneath it. The left grid shows the crowns arranged in a seemingly random pattern: one in the top left, one in the bottom left, one in the middle right, and one in the bottom right. The right grid shows the same four crowns, but now they are arranged in a different, more organized pattern: one in the top right, one in the middle left, one in the bottom left, and one in the bottom right. The grids are side-by-side, clearly indicating a transformation from the initial state (left) to a final state (right).  No text, URLs, or parameters are present; the visual representation alone conveys the change in arrangement.
Example
Input: n = 4
Output: 2

Intuition

Queens can move vertically, horizontally, and diagonally:

Image represents a central black crown icon surrounded by eight arrows pointing outwards in various directions.  Four arrows point vertically (two up, two down), two arrows point horizontally (one left, one right), and two arrows point diagonally (one up-right, one down-left, one up-left, and one down-right). To the right of the crown and arrows, a list describes the arrow directions as: '- vertically', '- horizontally', and '- diagonally'.  The arrangement visually depicts data flow or connections emanating from a central point in multiple directions, illustrating the concept of multi-directional data access or propagation.

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.

Based on this, let’s identify a method for placing the queens.

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. In this case, we backtrack by changing the position of the previous queen and trying again.

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. If a queen cannot be placed anywhere on this new row, we backtrack, reposition the previous row’s queen, and then try again:

Image represents a step-by-step illustration of the backtracking algorithm applied to the N-Queens problem.  The process begins with an attempt to place a black queen (represented by ♛) on a row of a chessboard (represented by a grid).  Initially, the algorithm tries placing the queen in the first column of the row, indicated by a red line connecting the queen to a red circle with a minus sign (-) within the grid, signifying an attacked square.  This placement results in all squares on that row being attacked, as shown by the red shading and minus signs.  A blue arrow labeled 'backtrack' then indicates a move to the next step.  The next frame shows the queen removed from its initial position. A dashed arrow then shows the algorithm moving to the next position. The final frame shows the algorithm successfully placing the queen in a different column of the same row, highlighted by a green square, because this position doesn't attack any previously placed queens, as indicated by the green arrow and text 'can place queen here'.  The red lines emanating from the queens represent the squares they attack, both horizontally, vertically, and diagonally. The text 'All squares are attacked. So, backtrack and reposition the previous queen' explains the reason for backtracking.

A partial state space tree for this backtracking process is visualized below for n = 4:

Image represents a tree-like structure illustrating a search algorithm, possibly for solving a puzzle or game involving placing crowns on a grid.  The topmost node shows an empty 4x4 grid.  This root node branches into four child nodes, each representing a possible placement of a single black crown on the grid.  These nodes further branch, with each subsequent level representing additional crown placements.  Grey arrows indicate valid moves, while black arrows show the progression of the algorithm. Red lines connect crowns indicating attacks or conflicts.  Red circles next to some leaf nodes signify invalid configurations (where crowns attack each other).  The algorithm continues until a solution is found, indicated by a green box around a leaf node containing a valid configuration of crowns without any conflicts, marked with a green checkmark.  The algorithm appears to be exploring all possible placements systematically, pruning branches that lead to invalid states.

We’re still left with some questions. In particular, how can we tell if a square is being attacked, and how exactly do we “place” or “remove” a queen?

Detecting opposing queens
One challenge in this problem is determining if a square is attacked by another queen. We could do a linear search across the row, column, and diagonals every time we want to place a new queen, but this is quite inefficient. A key observation is that we don't necessarily need to know the exact positions of all the queens. We only need to know if there exists a queen in any given square's row, column, or diagonals. 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.

What about diagonals? How can we determine which diagonal we're on? Since there are two types of diagonals, let’s refer to the diagonal that goes from top-left to bottom-right as the "diagonal", and the one that goes from top-right to bottom-left as the "anti-diagonal". Consider the following diagrams:

Image represents a comparison of two 4x4 matrices illustrating diagonal and anti-diagonal patterns.  The left matrix, titled 'Diagonals:', displays light-blue shaded diagonals where each element is calculated as `r - c`, with 'r' representing the row number (0-3) and 'c' representing the column number (0-3).  The top row shows column labels (0, 1, 2, 3), and the leftmost column shows row labels (0, 1, 2, 3).  The matrix elements are the result of subtracting the column number from the row number. The right matrix, titled 'Anti-diagonals:', shows lavender-shaded anti-diagonals, where each element is calculated as `r + c`, using the same row and column numbering system.  The elements in this matrix are the sum of the row and column numbers.  Both matrices use bold black outlines and clearly labeled axes for rows ('r') and columns ('c'), with the calculation formula displayed below each matrix in a corresponding color.

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. Similarly to how we keep track of column ids, we can use a diagonal and an anti-diagonal hash set to keep track of diagonal and anti-diagonal ids, respectively.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

Note, this implementation uses a global variable as it leads to a more readable solution. However, it's important to confirm with your interviewer whether global variables are acceptable.

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 current column, diagonal, and anti-diagonal from
        # the hash sets.
        cols_set.remove(c)
        diagonals_set.remove(curr_diagonal)
        anti_diagonals_set.remove(curr_anti_diagonal)
let res = 0;

function nQueens(n) {
  res = 0;
  dfs(0, new Set(), new Set(), new Set(), n);
  return res;
}

function dfs(r, diagonalsSet, antiDiagonalsSet, colsSet, n) {
  // Termination condition: If we have reached the end of the rows, we've placed all
  // 'n' queens.
  if (r === n) {
    res++;
    return;
  }
  for (let c = 0; c < n; c++) {
    const currDiagonal = r - c;
    const currAntiDiagonal = r + c;
    // If there are queens on the current column, diagonal or anti-diagonal, skip
    // this square.
    if (
      colsSet.has(c) ||
      diagonalsSet.has(currDiagonal) ||
      antiDiagonalsSet.has(currAntiDiagonal)
    ) {
      continue;
    }
    // Place the queen by marking the current column, diagonal, and anti-diagonal
    // as occupied.
    colsSet.add(c);
    diagonalsSet.add(currDiagonal);
    antiDiagonalsSet.add(currAntiDiagonal);
    // Recursively move to the next row to continue placing queens.
    dfs(r + 1, diagonalsSet, antiDiagonalsSet, colsSet, n);
    // Backtrack by removing the current column, diagonal, and anti-diagonal from
    // the hash sets.
    colsSet.delete(c);
    diagonalsSet.delete(currDiagonal);
    antiDiagonalsSet.delete(currAntiDiagonal);
  }
}
public class Solution {
    private int res = 0;

    public int nQueens(int n) {
        res = 0;
        dfs(0, new HashSet<>(), new HashSet<>(), new HashSet<>(), n);
        return res;
    }

    private void dfs(int r, Set<Integer> diagonalsSet, Set<Integer> antiDiagonalsSet, Set<Integer> colsSet, int n) {
        // Termination condition: If we have reached the end of the rows, we've placed all
        // 'n' queens.
        if (r == n) {
            res++;
            return;
        }
        for (int c = 0; c < n; c++) {
            int currDiagonal = r - c;
            int currAntiDiagonal = r + c;
            // If there are queens on the current column, diagonal or anti-diagonal, skip
            // this square.
            if (colsSet.contains(c) || diagonalsSet.contains(currDiagonal) || antiDiagonalsSet.contains(currAntiDiagonal)) {
                continue;
            }
            // Place the queen by marking the current column, diagonal, and anti-diagonal
            // as occupied.
            colsSet.add(c);
            diagonalsSet.add(currDiagonal);
            antiDiagonalsSet.add(currAntiDiagonal);
            // Recursively move to the next row to continue placing queens.
            dfs(r + 1, diagonalsSet, antiDiagonalsSet, colsSet, n);
            // Backtrack by removing the current column, diagonal, and anti-diagonal from
            // the hash sets.
            colsSet.remove(c);
            diagonalsSet.remove(currDiagonal);
            antiDiagonalsSet.remove(currAntiDiagonal);
        }
    }
}

Complexity Analysis

Time complexity: The time complexity of n_queens is O(n!)O(n!). Here’s why:

  • For the first queen, there are nn choices for its position.
  • For the second queen, there are n−an-a choices for its position, where aa denotes the number of squares on the second row attacked by the first queen.
  • The third queen has n−bn-b choices, where bb denotes the number of squares on the third row attacked by the previous two queens, and b<ab<a.
  • This process continues for subsequent queens, resulting in a total of n⋅(n−a)⋅(n−b)…1n\cdot (n-a)\cdot (n-b)…1 choices. Even though this doesn’t exactly equate to n!n! (n⋅(n−1)⋅(n−2)…1n\cdot (n-1)\cdot (n-2)…1 ), this trend approximately results in a factorial growth of the search space.

Space complexity: The space complexity is O(n)O(n) because the maximum depth of the recursion tree is nn. The hash sets also contribute to this space complexity because they each store up to nn values.

CHAPTER 90

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:

Example
Input: nums = [1, 2, 3], target = 4
Output: [[1, 1, 1, 1], [1, 1, 2], [1, 3], [2, 2]]

Constraints:

  • All integers in nums are positive and unique.
  • The target value is positive.
  • The output must not contain duplicate combinations. For example, [1, 1, 2] and [1, 2, 1] are considered the same combination.

Intuition

Since we can use each integer in the input array as many times as we like, we can create an infinite number of combinations. We certainly cannot explore combinations infinitely. So, to manage this, we need to narrow our search.

An important point that will help us with this is that all values in the integer array are positive integers. This means that as we add more values to a combination, its sum will increase. Therefore, we should stop building a combination once its sum is equal to or exceeds the target value.

Another thing we should be mindful of is duplicate combinations. Consider the input array [1, 2, 3]. The combinations [1, 3, 2, 1] and [2, 1, 3, 1] represent the same combination. To ensure a universal representation, we can represent this combination as [1, 1, 2, 3], where the integers appear in the same order as in the original array. To ensure every combination has only one version, we need to build the combinations so that they are all ordered this way.

With those two things in mind, let's think about how we find all combinations that sum to the target value. Backtracking is ideal for exploring all possible combinations, so let's start by considering the state space tree for this problem.

State space tree
The purpose of a state space tree is to show combinations getting built one number at a time. Consider the input array [1, 2, 3] and a target of 4. Let's start with the root node of the tree, which is an empty combination:

Image represents a pair of large, black, square brackets, oriented vertically with a significant space between them.  Each bracket is composed of three distinct, thick, parallel lines creating a solid, shadowed appearance suggesting depth. The brackets are positioned symmetrically, with the left bracket mirroring the right bracket in size, shape, and shadowing.  No text, URLs, or parameters are present within or around the brackets; the image solely depicts the brackets themselves, implying a visual representation of a code block or a structural element within a programming context, likely used to denote the beginning and end of a code section or a data structure. The empty space between the brackets suggests the content that would typically be enclosed within them in a programming language.

To figure out how to branch out from here, let's identify what decisions we can make. Each element can be included in a combination an unlimited number of times. So, this means we can make three decisions for an array of length 3: include each element from the array (remember that a branch in the state space tree represents a decision):

Image represents a decision tree illustrating a choice between three options.  A central node, labeled '[]' (representing the decision point), branches out to three leaf nodes labeled '[1]', '[2]', and '[3]'.  Each branch is labeled with the text 'choose' in orange, followed by a number (1, 2, or 3) indicating the selected option.  Arrows point from the central node to each leaf node, signifying the flow of control based on the choice made.  The numbers 1, 2, and 3 represent the three possible outcomes or paths resulting from the decision at the central node.  The square brackets around the leaf node labels might indicate an array or list structure in a programming context.

Let's make the same decisions for each of these combinations as well to continue extending the state space tree. Remember that if any combination has a sum equal to 4 or exceeding 4, we stop extending those combinations. These two conditions are effectively our termination conditions:

Image represents a tree-like structure illustrating a recursive algorithm, possibly for finding combinations. The root node is an empty array `[]`.  From the root, three branches extend, labeled with orange numbers 1, 2, and 3, respectively, pointing to nodes `[1]`, `[2]`, and `[3]`. Each of these nodes further branches into three sub-nodes, again labeled 1, 2, and 3, resulting in leaf nodes representing two-element arrays like `[1,1]`, `[1,2]`, `[1,3]`, etc.  Each leaf node displays the sum of its elements.  A green checkmark indicates that the sum equals 4, while a red 'X' signifies a sum greater than 4. Dashed lines connect the leaf nodes to unseen underlying elements, suggesting further recursion or exploration of possibilities. The orange numbers on the branches seem to represent the values being added to the array at each level.

One issue with this approach is that it resulted in duplicate combinations in our tree:

Image represents a tree-like structure illustrating a coding pattern, possibly related to recursion or tree traversal.  The root node is labeled `[]`, representing an empty array or the starting point.  From the root, three branches descend, each labeled `[1]`, `[2]`, and `[3]`, respectively. Each of these nodes further branches into three child nodes, representing arrays with two elements. For example, `[1]` branches into `[1,1]`, `[1,2]`, and `[1,3]`.  The nodes `[2,1]`, `[3,1]`, and `[3,2]` are highlighted in a peach/light orange color. Curved arrows connect certain pairs of these leaf nodes, labeled 'duplicate' in a corresponding color (peach/light orange for the peach/light orange nodes, and gold for the other nodes). These arrows indicate that the connected nodes represent duplicate or redundant data structures within the overall pattern.  The structure visually demonstrates how different combinations of elements can arise from a recursive or iterative process, and highlights the identification and handling of duplicate results.

To avoid these duplicates, we should keep in mind our universal representation: each combination should be constructed such that its elements are listed in the same order as in the input array. We can enforce this by specifying an index 'start_index' for each combination we create. This start_index points to a value in the input array and ensures that we can only add elements from this value onward. This way, we maintain the correct order and avoid duplicates in our combinations:

Image represents a tree-like structure illustrating a recursive algorithm, possibly for finding subsets of an array `nums = [1, 2, 3]` that sum to 4.  The root node is `[], start_index = 0`, representing an empty subset and a starting index of 0.  From the root, three branches extend, each representing a subset starting with one of the elements from `nums`.  Each node shows the current subset (e.g., `[1]`, `[2]`, `[3]`) and its `start_index`.  These nodes further branch, recursively adding subsequent elements from `nums` (starting from the `start_index`).  The leaf nodes display the final subsets (e.g., `[1, 1]`, `[1, 2]`, `[1, 3]`, etc.).  Each leaf node is labeled with the sum of its elements; those summing to 4 are marked in green (`sum == 4`), while those exceeding 4 are marked in red (`sum > 4`).  The arrows indicate the flow of the algorithm, showing how subsets are built recursively.  The gray arrows represent paths that are not explored further because they exceed the target sum.

Initially, for the root combination, start_index is set to 0. As we recursively build each combination, start_index is updated to the index of the current element being added. By doing this, we ensure that in the next recursive call, we only consider elements from the updated index onward in the input array.

This maintains the required order and prevents duplicates because we never revisit previous elements. Since each combination is built by only adding elements that come after the current element in the input array, we avoid generating the same combination in a different order.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

In our algorithm, the termination condition requires us to know the sum of the current combination. While we could use a separate variable to track the sum of each combination, this isn't necessary. Instead, we can repurpose our target value. When we choose a number to a combination, we reduce the target value by that number. This way, the target value dynamically tracks the remaining sum needed to reach the original target. We see how this works below, where the target gets reduced by the value we add to the combination:

Image represents a decision tree illustrating a recursive process, possibly for finding a subset of numbers that sums to a target value.  The root node shows an empty array `[]` with a `target` value of 4.  Three branches emanate from this root, each labeled with 'choose' followed by a number (1, 2, or 3), representing the selection of an element (implicitly from a larger set not shown). Each branch leads to a child node.  These child nodes display an array containing the chosen element (e.g., `[1]`, `[2]`, `[3]`) and an updated `target` value, calculated by subtracting the chosen element from the previous target (e.g., 4-1=3, 4-2=2, 4-3=1).  The updated `target` value suggests a recursive continuation of the process, where the algorithm would continue to choose elements until the target becomes zero, implying a solution has been found, or becomes negative, indicating no solution exists with the chosen path.  The structure visually depicts the exploration of different solution paths through recursive choices.

This means that when we reach a target of 0, we've found a valid combination. If the target becomes negative, we can terminate the current branch of the search:

Image represents a decision tree illustrating a recursive algorithm, likely for finding combinations that sum to a target value.  The root node is `[]`, target = 4, representing an empty initial set and a target sum of 4.  From this root, three branches descend, labeled 1, 2, and 3, representing the addition of those numbers to the set. Each subsequent node shows the current set (e.g., `[1,1]`) and the remaining target value (e.g., `target = 3-1 = 2`), calculated by subtracting the added number from the previous target.  The tree continues recursively until the target becomes 0 (e.g., `[1,3]`, `[2,2]`), at which point a green arrow indicates that the combination is added to the result and the branch terminates.  If the target becomes negative (e.g., `[2,3]`, `[3,3]`), a red arrow points to 'stop,' signifying that this branch does not yield a solution.  The orange numbers on the branches indicate the number being added at each step.  The algorithm explores all possible combinations by systematically adding numbers from 1 to 3 until either the target is reached or it becomes negative.
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 by adding it to the current combination.
    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 by removing the number we just added.
        combination.pop()
function combinationsOfSumK(nums, target) {
  const res = [];
  dfs([], 0, nums, target, res);
  return res;
}

function dfs(combination, startIndex, nums, target, res) {
  // Termination condition: If the target is equal to 0, we found a combination
  // that sums to 'k'.
  if (target === 0) {
    res.push([...combination]);
    return;
  }
  // Termination condition: If the target is less than 0, no more valid
  // combinations can be created by adding it to the current combination.
  if (target < 0) {
    return;
  }
  // Starting from start_index, explore all combinations after adding nums[i].
  for (let i = startIndex; i < nums.length; i++) {
    // Add the current number to create a new combination.
    combination.push(nums[i]);
    // Recursively explore all paths that branch from this new combination.
    dfs(combination, i, nums, target - nums[i], res);
    // Backtrack by removing the number we just added.
    combination.pop();
  }
}
public class Solution {
    public List<List<Integer>> combinationsOfSumK(int[] nums, int target) {
        List<List<Integer>> res = new ArrayList<>();
        dfs(new ArrayList<>(), 0, nums, target, res);
        return res;
    }

    private void dfs(List<Integer> combination, int startIndex, int[] nums, int target,
                     List<List<Integer>> res) {
        // Termination condition: If the target is equal to 0, we found a combination
        // that sums to 'k'.
        if (target == 0) {
            res.add(new ArrayList<>(combination));
            return;
        }
        // Termination condition: If the target is less than 0, no more valid
        // combinations can be created by adding it to the current combination.
        if (target < 0) {
            return;
        }
        // Starting from start_index, explore all combinations after adding nums[i].
        for (int i = startIndex; i < nums.length; i++) {
            // Add the current number to create a new combination.
            combination.add(nums[i]);
            // Recursively explore all paths that branch from this new combination.
            dfs(combination, i, nums, target - nums[i], res);
            // Backtrack by removing the number we just added.
            combination.remove(combination.size() - 1);
        }
    }
}

Complexity Analysis

Time complexity: The time complexity of combinations_of_sum_k is O(ntarget/m)O(n^{target/m}), where nn denotes the length of the array, and mm denotes the smallest candidate. This is because, in the worst case, we always add the smallest candidate mm to our combination. The recursion tree will branch down until the sum of the smallest candidates reaches or exceeds the target. This results in a tree depth of target/mtarget/m. Since the function makes a recursive call for up to nn candidates at each level of the recursion, the branching factor is nn, giving us the time complexity of O(ntarget/m)O(n^{target/m}).

Space complexity: The space complexity is O(target/m)O(target/m), which includes:

  • The recursive call stack depth, which is at most target/mtarget/m in depth.

  • The combination list can also require at most O(target/m)O(target/m) space since the longest combination would consist of the smallest element mm repeated target/mtarget/m times.

CHAPTER 91

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:

1

2 abc

3 def

4 ghi

5 jkl

6 mno

7 pqrs

8 tuv

9 wxyz

Return all possible letter combinations the input digits could represent.

Example:

Example
Input: digits = '69'
Output: ['mw', 'mx', 'my', 'mz', 'nw', 'nx', 'ny', 'nz', 'ow', 'ox', 'oy', 'oz']

Intuition

At each digit in the string, we have a decision to make: which letter will this digit represent? Based on this decision, let's illustrate the state space tree that represents the choices at each digit of the input string.

State space tree
Consider the input string "69". Let's start with the root node of the tree, which is an empty string:

Image represents a simplified diagram showing four vertical, rectangular blocks arranged horizontally with small gaps between them.  There are no labels, URLs, parameters, or other textual information present within or around the blocks.  The blocks are all identical in appearance, being solid black rectangles of seemingly equal size and shape.  The arrangement suggests a linear or sequential process, with information potentially flowing from left to right, although no arrows or other indicators of direction or data flow are visible. The image lacks any further detail or context to specify the nature of the blocks or their interaction.

At the first digit, 6, we have the choice of starting our combination with 'm', 'n', or 'o':

Image represents a decision tree illustrating a choice process.  A central node labeled '''' (empty string) branches into three directions.  The top-most branch, labeled 'choose m' in orange text, points left to a terminal node labeled `'m'` (representing the character 'm'). The bottom branch, labeled 'choose n' in orange text, points downwards to a terminal node labeled `'n'` (representing the character 'n'). The right-most branch, labeled 'choose o' in orange text, points right to a terminal node labeled `'o'` (representing the character 'o').  The leftmost node is further labeled 'digit 6: 'm'' indicating that this diagram might be part of a larger process related to digit 6, where 'm' is a possible outcome.  The arrows indicate the flow of information or the decision path taken, with each branch representing a choice between different characters ('m', 'n', or 'o').

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? These choices are illustrated below:

Image represents a tree-like structure illustrating a coding pattern.  The root node is labeled with double quotes (' '), from which three branches extend, each labeled with a single character: 'm' (in orange), 'n' (in orange), and 'o' (in orange). Each of these nodes then branches further into four child nodes, each labeled with a two-character string.  The first character in each child node's label is inherited from its parent node ('m', 'n', or 'o'), while the second character is one of 'w', 'x', 'y', or 'z' (all in orange), resulting in combinations like 'mw', 'mx', 'my', 'mz', 'nw', 'nx', 'ny', 'nz', 'ow', 'ox', 'oy', and 'oz'.  The left side of the image is labeled 'digit 6:' and the bottom left 'digit 9:', suggesting a relationship between the tree structure and these digits, possibly representing a coding scheme where digit 6 maps to the three parent nodes ('m', 'n', 'o') and digit 9 maps to the twelve leaf nodes (the two-character strings).  The arrows indicate the flow of information from parent nodes to their respective children.

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:

Image represents a tree-like structure illustrating a coding pattern, possibly related to string manipulation or code generation.  The diagram's top shows a root node labeled  “'' representing an initial state. From this root, three branches descend, each labeled with a single character: 'm', 'n', and 'o'. Each of these nodes further branches into four child nodes, each representing a two-character string.  For example, the 'm' node branches into 'mw', 'mx', 'my', and 'mz'. Similarly, 'n' branches into 'nw', 'nx', 'ny', and 'nz', and 'o' branches into 'ow', 'ox', 'oy', and 'oz'.  To the left of the main tree, three separate boxes are shown, each labeled 'i = 0', 'i = 1', and 'i = 2' respectively, with a downward arrow pointing to 'digit = '69'' in each case. This suggests an iterative process where the value of 'i' increments, and the 'digit' remains constant at '69' throughout the iterations. The overall structure suggests a recursive or combinatorial generation of strings based on an initial input ('69') and an iterative counter ('i').

The final level of this decision tree (i.e., when i == n, where n denotes the length of the input string) represents all possible combinations that can be created from the provided string. Similar to our approach in Find All Subsets, let's use backtracking to obtain these keypad combinations.

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. In the hash map, digits are the keys, and the associated sets of letters are their values. This allows us to access the letters in constant time:

Image represents a table titled 'keypad_map' that maps digits to corresponding letters on a telephone keypad.  The table is divided into two columns. The left column, labeled 'digit' at the bottom, lists the digits '2' through '9', each enclosed in single quotes. The right column, labeled 'letters' at the bottom, shows the corresponding letters associated with each digit on a standard telephone keypad, also enclosed in double quotes.  For example, the digit '2' maps to the letters 'abc', '3' maps to 'def', and so on, up to '9' mapping to 'wxyz'.  There is no explicit information flow depicted; the table simply presents a static mapping between digits and letter combinations.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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()
function phoneKeypadCombinations(digits) {
  if (!digits || digits.length === 0) {
    return [];
  }
  const keypadMap = {
    '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
    '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
  };
  const res = [];
  backtrack(0, [], digits, keypadMap, res);
  return res;
}

function backtrack(i, currCombination, digits, keypadMap, res) {
  // Termination condition: if all digits have been considered, add the
  // current combination to the output list.
  if (currCombination.length === digits.length) {
    res.push(currCombination.join(''));
    return;
  }
  for (const letter of keypadMap[digits[i]]) {
    // Add the current letter.
    currCombination.push(letter);
    // Recursively explore all paths that branch from this combination.
    backtrack(i + 1, currCombination, digits, keypadMap, res);
    // Backtrack by removing the letter we just added.
    currCombination.pop();
  }
}
public class Solution {
    public List<String> phoneKeypadCombinations(String digits) {
        if (digits == null || digits.isEmpty()) {
            return new ArrayList<>();
        }
        Map<Character, String> keypadMap = Map.of(
            '2', "abc", '3', "def", '4', "ghi", '5', "jkl",
            '6', "mno", '7', "pqrs", '8', "tuv", '9', "wxyz"
        );
        List<String> res = new ArrayList<>();
        backtrack(0, new StringBuilder(), digits, keypadMap, res);
        return res;
    }

    private void backtrack(int i, StringBuilder currCombination, String digits,
                          Map<Character, String> keypadMap, List<String> res) {
        // Termination condition: if all digits have been considered, add the
        // current combination to the output list.
        if (currCombination.length() == digits.length()) {
            res.add(currCombination.toString());
            return;
        }
        String letters = keypadMap.get(digits.charAt(i));
        for (char letter : letters.toCharArray()) {
            // Add the current letter.
            currCombination.append(letter);
            // Recursively explore all paths that branch from this combination.
            backtrack(i + 1, currCombination, digits, keypadMap, res);
            // Backtrack by removing the letter we just added.
            currCombination.deleteCharAt(currCombination.length() - 1);
        }
    }
}

Complexity Analysis

Time complexity: The time complexity of phone_keypad_combinations is O(n⋅4n)O(n\cdot 4^n). This is because the state space tree will branch down until a decision is made for all nn elements. This results in a tree of height nn with a branching factor of 4 since there are up to 4 decisions we can make at each digit. For each of the 4n4^n combinations created, we convert it into a string and add it to the output list, which takes O(n)O(n) time per combination. This results in a total time complexity of O(n⋅4n)O(n\cdot 4^n).

Space complexity: The space complexity is O(n)O(n) due to the recursive call stack, which can grow up to a maximum depth of nn. The keypad_map only takes constant space since there are only 8 key-value pairs.

Interview Tip

Tip: Check if you can skip trivial implementations.

During an interview, it's crucial to manage your time effectively. If you encounter a trivial and time-consuming task, such as creating the keypad_map in this problem, it's possible the interviewer may allow you to skip it or implement it later if there's time left in the interview. Ensure you at least briefly mention how the implementation you're skipping would work before requesting to move on to the core logic of the problem.

CHAPTER 92

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. This isn’t a foreign concept to us. 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.

Image represents a tree-like diagram illustrating a recursive problem-solving approach.  The top node is labeled `f(main_problem)`, representing the main problem to be solved. This node branches into two subproblems: `f(subproblem_a)` and `f(subproblem_b)`.  `f(subproblem_a)` further breaks down into `f(subproblem_b)` and `f(subproblem_c)`.  Each `f(subproblem_x)` represents a function call to solve a subproblem.  The `f(subproblem_b)` on the right branch is highlighted with a peach-colored background and a curved orange arrow points to it from the text 'solved before,' indicating that this specific subproblem has been solved previously.  Solid lines represent direct function calls, while dashed lines indicate further, unspecified sub-problems stemming from the leaf nodes `f(subproblem_b)` and `f(subproblem_c)`. The diagram visually demonstrates how a complex problem is broken down into smaller, potentially reusable subproblems, highlighting the concept of memoization or dynamic programming where previously solved subproblems are reused to improve efficiency.

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. This can greatly increase the performance of an algorithm.

The DP process
DP is often perceived as challenging, but it follows a pretty consistent problem-solving process, best demonstrated with an example. First, we'll briefly explain some key DP terms, and then dive into the Climbing Stairs problem to learn how to identify a DP problem and develop a DP solution.

  • 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. These definitions may seem abstract now, but keep them in mind as they will become clearer in the context of a problem.

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. For example, given a query without spaces (like “bestrestaurants”), DP checks all possible ways to insert spaces (“best restaurants,” “best rest aunts”) by solving each segment (subproblem) separately, and storing their solutions to avoid recalculation.

Chapter Outline

Image represents a hierarchical diagram illustrating the application of Dynamic Programming.  At the top, a rounded rectangle labeled 'Dynamic Programming' serves as the root node.  A downward-pointing arrow connects it to two rectangular boxes with dashed borders, representing subcategories. The left box is labeled '1D-DP' and lists four problems solved using one-dimensional dynamic programming: 'Climbing Stairs,' 'Minimum Coin Combination,' 'Neighborhood Burglary,' and 'Maximum Subarray Sum.' The right box is labeled '2D-DP' and lists five problems solved using two-dimensional dynamic programming: 'Matrix Pathways,' 'Longest Palindrome in a String,' 'Longest Common Subsequence,' '0/1 Knapsack,' and 'Largest Square in an Array.'  Dashed lines connect the root node to each subcategory box, indicating a parent-child relationship, showing how these specific problems fall under the broader umbrella of Dynamic Programming techniques.

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

CHAPTER 93

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:

Image represents five distinct diagrams illustrating a potential coding pattern, likely related to data flow or algorithm execution. Each diagram consists of a series of connected black squares forming a staircase-like structure.  Orange and grey arrows, labeled with the numbers '1' and '2', indicate the flow of data or steps in a process.  The arrows point downwards, suggesting a sequential or cascading operation. The first diagram shows a simple staircase with three steps, each receiving a '1' input. The second diagram adds a '2' input to the second step. The third diagram introduces a grey '1' input, demonstrating a potential alternative path or data source. The fourth and fifth diagrams show variations on the previous structures, with different combinations of '1' and '2' inputs affecting the staircase's progression.  The overall arrangement suggests a comparison of different scenarios or variations within a single algorithmic pattern, possibly highlighting different input combinations and their effects on the output or process flow.
Example
Input: n = 4
Output: 5

Intuition - Top-Down

A brute force solution to this problem is to go through all possible combinations of moving 1 or 2 steps up the stairs until reaching the top. How would we do this? Think about how to get to stair i:

Image represents a simplified flowchart illustrating a sequential process or a step-by-step procedure.  The process begins at the top with a square box containing the letter 'i', possibly representing an input or initial state. A downward-pointing arrow indicates the flow of information or execution from this initial state.  The process then proceeds downwards through a series of four vertically stacked, right-angled steps, suggesting a linear progression of stages. Each step is connected to the next by a continuous line, implying a direct and ordered transition between them.  The bottom of the staircase is marked by a dashed line, indicating either an interruption, an incomplete process, or a transition to another, unseen part of the process. The overall structure visually communicates a clear, sequential workflow where each step depends on the successful completion of the previous one.

One thing we know for sure is that to reach step i, we need to reach it from either step i - 1, or step i - 2 since we can only climb 1 or 2 steps at a time:

Image represents two diagrams illustrating a coding pattern, likely related to iteration or recursion.  Each diagram shows a staircase-like structure formed by black lines, representing a sequence or process.  At the top of each staircase is a square box labeled 'i,' indicating an input or initial value.  A downward-pointing arrow from 'i' shows data flow.  In the left diagram, an orange line connects 'i' to a point on the staircase labeled 'i - 1,' suggesting a step where the input 'i' is processed to produce 'i - 1.'  Similarly, in the right diagram, an orange line connects 'i' to a point on the staircase labeled 'i - 2,' indicating a different processing step resulting in 'i - 2.'  Both diagrams end with dashed lines, implying continuation or further steps in the sequence. The diagrams visually compare two different ways of processing the input 'i' within a larger iterative or recursive process, highlighting the potential for varying step sizes or processing logic.

This is all the information we need. If we want to know all the different ways we can get to step i, we just need to know:

  1. The number of ways to get to step i - 1 (climbing_stairs(i - 1)).
  2. The number of ways to get to step i - 2 (climbing_stairs(i - 2)).

This highlights that this problem has an optimal substructure where, in order to solve climbing_stairs(n), we need the answers to two of its subproblems. We can translate this to a recurrence relation:

climbing_stairs(n) = climbing_stairs(n - 1) + climbing_stairs(n - 2)

Let’s first implement this using recursion.

To do this, we’ll need to identify the base cases, which handle the simplest subproblems. The simplest versions of this problem occur when the number of steps is 1 or 2. If n equals 1, we return 1 since the only way to reach step 1 is to climb 1 step. If n equals 2, return 2 since there are two ways to reach step 2.

If we apply this recursive logic to a staircase of 6 steps, this is what the recursion tree would look like:

Image represents a recursion tree illustrating the function `climbing_stairs()`, abbreviated as `cs()`.  The root node is `cs(6)`, representing the initial call to the function with an input of 6.  This node branches into two child nodes, `cs(5)` and `cs(4)`, indicating recursive calls with inputs reduced by 1 and 2 respectively. This pattern continues recursively; each node `cs(n)` (where n is a positive integer) branches into `cs(n-1)` and `cs(n-2)`, until the base cases `cs(1)` and `cs(2)` are reached, which are labeled as '(base case)' and represent the termination condition of the recursion.  The arrows depict the flow of execution, showing how the function calls break down into smaller subproblems until the base cases are hit, after which the results are combined back up the tree to compute the final result for `cs(6)`.

This solution is considered a top-down solution as it starts from the main problem, and recursively breaks it down into smaller subproblems as it progresses down the recursive tree.

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 the existence of overlapping subproblems. This isn't a big issue for short staircases, but for a taller one with more steps, it can result in a lot of repeated calculations of subproblems we’ve already solved. This is where memoization comes into play.

Memoization
Storing the result of each subproblem the first time we solve it, then reusing these stored results when needed, is a technique known as memoization. For example, after we calculate the subproblem of n = 3 (climbing_stairs(3)) for the first time, we don’t need to calculate it again; we can just fetch the already-calculated result for n = 3. The same applies to n = 4. This greatly reduces the size of the recursion tree:

Image represents a recursion tree illustrating the `climbing_stairs()` function, where `cs(n)` denotes the number of ways to climb `n` stairs.  The root node is `cs(6)`, representing the initial problem.  Solid black arrows indicate the recursive breakdown of the problem: `cs(6)` breaks down into `cs(5)` and `cs(4)`.  Similarly, `cs(5)` breaks down into `cs(4)` and `cs(3)`, and `cs(4)` into `cs(3)` and `cs(2)`.  `cs(3)` further breaks down into `cs(2)` and `cs(1)`.  Nodes labeled `cs(2)` and `cs(1)` are marked as 'base cases,' representing the simplest solvable instances.  Dashed green arrows show the flow of information back up the tree, indicating that the solutions to the base cases (`cs(1)` and `cs(2)`) are used to solve `cs(3)`, then `cs(4)`, and finally `cs(6)`.  Nodes `cs(3)` and `cs(4)` are labeled '(already solved)' to highlight that their solutions are reused in the calculation of higher-level nodes, demonstrating the efficiency of dynamic programming or memoization in solving this recursive problem.

We use a hash map for memoization to store the results of subproblems for constant-time access. For example, after calculating the result for the subproblem n = 3, we store the result in the hash map as a value, where the key is 3.

As we can see, we’ve successfully implemented a DP solution using top-down memoization. We identified the subproblems, used them to create the recurrence relation, specified our base cases, and applied memoization to ensure each subproblem is solved only once.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Top-Down

memo = {}
        
def climbing_stairs_top_down(n: int) -> int:
    # Base cases: With a 1-step staircase, there’s only one way to climb it.
    # With a 2-step staircase, there are two ways to climb it.
    if n <= 2:
        return n
    if n in memo:
        return memo[n]
    # The number of ways to climb to the n-th step is equal to the sum of the number
    # of ways to climb to step n - 1 and to n - 2.
    memo[n] = climbing_stairs_top_down(n - 1) + climbing_stairs_top_down(n - 2)
    return memo[n]
const memo = {};

function climbingStairsTopDown(n) {
  // Base cases: With a 1-step staircase, there’s only one way to climb it.
  // With a 2-step staircase, there are two ways to climb it.
  if (n <= 2) {
    return n;
  }
  if (n in memo) {
    return memo[n];
  }
  // The number of ways to climb to the n-th step is equal to the sum of the number
  // of ways to climb to step n - 1 and to n - 2.
  memo[n] = climbingStairsTopDown(n - 1) + climbingStairsTopDown(n - 2);
  return memo[n];
}
public class Solution {
    private Map<Integer, Integer> memo = new HashMap<>();

    public int climbingStairsTopDown(int n) {
        // Base cases: With a 1-step staircase, there’s only one way to climb it.
        // With a 2-step staircase, there are two ways to climb it.
        if (n <= 2) {
            return n;
        }
        if (memo.containsKey(n)) {
            return memo.get(n);
        }
        // The number of ways to climb to the n-th step is equal to the sum of the number
        // of ways to climb to step n - 1 and to n - 2.
        memo.put(n, climbingStairsTopDown(n - 1) + climbingStairsTopDown(n - 2));
        return memo.get(n);
    }
}

Complexity Analysis

Time complexity:

  • Without memoization, the time complexity of climbing_stairs_top_down is O(2n)O(2^n) because the depth of the recursion tree is n, and its branching factor is 2 since we make 2 recursive calls at each point in the tree.
  • With memoization, we ensure each subproblem is solved only once. Since there are nn possible subproblems (one for each step from step 1 to step nn), the time complexity is O(n)O(n).

Space complexity: The space complexity is O(n)O(n) due to the recursive call stack, which grows to a height of nn. The memoization array also contributes to the space occupied by storing nn key-value pairs.

Intuition - Bottom-Up

Generally, any problem that can be solved using top-down memoization can also be solved using a bottom-up DP approach, where we translate the memoization array to a DP array. Let's explore how this works.

Translating the memoization array to a DP array
Think about what each value in the DP array represents. We want this array to store the answers to our subproblems (i.e., dp[i] should store the number of ways we can reach step i). Now, remember that our memoization array stores the same thing. In other words, dp[i] and memo[i] store the same result.

In our top-down implementation, the memoization stores results like so:
memo[n] = climbing_stairs(n - 1) + climbing_stairs(n - 2)

However, if the results of climbing_stairs(n - 1) and climbing_stairs(n - 2) were already calculated and memoized, this is what would actually be going on:
memo[n] = memo[n - 1] + memo[n - 2]

Now that we have this tabular relationship for the memoization array, simply change “memo” to “dp” to get the DP relationship:
dp[n] = dp[n - 1] + dp[n - 2]

We call this a bottom-up solution because we need to calculate the solutions to smaller subproblems before we can solve the larger ones. In other words, we "build up" to the main solution as opposed to the top-down solution, where we start with the main problem, n, and work our way down.

Base cases
Our base cases stay the same: the answers to dp[1] and dp[2] are 1 and 2, respectively.

Return statement
In our top-down solution, we return memo[n]. Since there’s a one-to-one relationship between the memoization array and the DP array, we can just return dp[n] in our bottom-up solution.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Bottom-Up

def climbing_stairs_bottom_up(n: int) -> int:
    if n <= 2:
        return n
    dp = [0] * (n + 1)
    # Base cases.
    dp[1], dp[2] = 1, 2
    # Starting from step 3, calculate the number of ways to reach each step until the
    # n-th step.
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]
function climbingStairsBottomUp(n) {
  if (n <= 2) {
    return n;
  }
  const dp = new Array(n + 1).fill(0);
  // Base cases.
  dp[1] = 1;
  dp[2] = 2;
  // Starting from step 3, calculate the number of ways to reach each step until the
  // n-th step.
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}
public class Solution {
    public int climbingStairsBottomUp(int n) {
        if (n <= 2) {
            return n;
        }
        int[] dp = new int[n + 1];
        // Base cases.
        dp[1] = 1;
        dp[2] = 2;
        // Starting from step 3, calculate the number of ways to reach each step until the
        // n-th step.
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}

Complexity Analysis

Time complexity: The time complexity of climbing_stairs_bottom_up is O(n)O(n) as we iterate through nn elements of the DP array.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the DP array, which contains n+1n+1 elements.

Optimization - Bottom Up

An important thing to notice is that in the DP solution, we only ever need to access the previous two values of the DP array (at i - 1 and i - 2) to calculate the current value (at i). This means we don’t need to store the entire DP array.

Instead, we can use two variables to keep track of the previous two values:

  • one_step_before: to store the value of dp[i - 1].
  • two_steps_before: to store the value of dp[i - 2].

As we iterate through the steps, we update these two variables to always hold the values for the previous two steps. This approach retains the time complexity of O(n)O(n), while reducing space complexity to O(1)O(1). The adjusted implementation is below:

def climbing_stairs_bottom_up_optimized(n: int) -> int:
    if n <= 2:
        return n
    # Initialize 'one_step_before' and 'two_steps_before' with the base cases.
    one_step_before, two_steps_before = 2, 1
    for i in range(3, n + 1):
        # Calculate the number of ways to reach the current step.
        current = one_step_before + two_steps_before
        # Update the values for the next iteration.
        two_steps_before = one_step_before
        one_step_before = current
    return one_step_before
function climbingStairsBottomUpOptimized(n) {
  if (n <= 2) {
    return n;
  }
  // Initialize 'one_step_before' and 'two_steps_before' with the base cases.
  let oneStepBefore = 2;
  let twoStepsBefore = 1;
  for (let i = 3; i <= n; i++) {
    // Calculate the number of ways to reach the current step.
    const current = oneStepBefore + twoStepsBefore;
    // Update the values for the next iteration.
    twoStepsBefore = oneStepBefore;
    oneStepBefore = current;
  }
  return oneStepBefore;
}
public class Solution {
    public int climbingStairsBottomUpOptimized(int n) {
        if (n <= 2) {
            return n;
        }
        // Initialize 'one_step_before' and 'two_steps_before' with the base cases.
        int oneStepBefore = 2;
        int twoStepsBefore = 1;
        for (int i = 3; i <= n; i++) {
            // Calculate the number of ways to reach the current step.
            int current = oneStepBefore + twoStepsBefore;
            // Update the values for the next iteration.
            twoStepsBefore = oneStepBefore;
            oneStepBefore = current;
        }
        return oneStepBefore;
    }
}

Interview Tip

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. The bottom-up solution, on the other hand, requires considering both steps at the same time. In addition, a bottom-up solution starts by solving subproblems first, which can be less intuitive, whereas a top-down solution starts with the main problem before working downward.

Once you have a working top-down solution, you can translate it into a bottom-up solution as described in the intuition above. Over time, you'll get better at mapping a recurrence relation directly to a bottom-up tabular relation, allowing you to skip the top-down approach.

CHAPTER 94

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:

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

Example
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 that tries every possible coin combination impossible, due to the infinite number of possible combinations. This indicates the need for a more efficient method.

Consider the example below:

Image represents a simple input for a coding problem, likely related to searching or summing within an array.  The input consists of two parts presented linearly. The first part is an integer array represented by `[1 2 3]`, enclosed in square brackets, with the integers 1, 2, and 3 separated by spaces. This array is followed by a comma and a space, separating it from the second part. The second part specifies a target value, denoted by `target = 5`, indicating that the algorithm should search for, or use, the value 5 in relation to the array. There are no URLs or parameters present; the image solely displays the input data.  The arrangement suggests that the array `[1 2 3]` and the target value `5` are independent inputs to a function or algorithm.

If we use a 3-dollar coin from the array, then we’ll only need 2 dollars more to make 5. This gives us a new target: find the fewest number of coins needed to make 2 dollars:

Image represents a simple data flow diagram illustrating a change in a parameter.  The diagram begins with the word 'use' positioned above a downward-pointing arrow, indicating the input or application of a parameter.  The arrow points to the input data: an array `[1 2 3]` and a parameter labeled `target = 5`. A solid arrow then connects this input to a new state, where the array remains the same, `[1 2 3]`, but the `target` parameter has changed to `target = 2`.  The diagram visually depicts a process where the array remains constant, while the `target` parameter is modified from 5 to 2, suggesting a change in the input or a transformation within a system.

This indicates we’ve identified subproblems within the main problem, where each subproblem requires finding the fewest number of coins needed to make a smaller target.

Each coin we use creates a new subproblem. For example, using a 1-dollar coin changes our target from 5 to 4 dollars. Let's visualize how these smaller targets, representing new subproblems, are created after using each coin:

Image represents a tree-like diagram illustrating a decomposition process.  At the top is a central node labeled 'target = 5,' representing an initial target value.  Three downward-pointing lines, labeled 'use 1,' 'use 2,' and 'use 3' in orange text, connect this central node to three leaf nodes at the bottom. Each leaf node represents a sub-target value resulting from the decomposition: 'target = 4' on the left, 'target = 3' in the center (directly below the central node), and 'target = 2' on the right. The arrows indicate the flow of information, showing how the initial target value of 5 is broken down into three smaller target values (4, 3, and 2) through three different uses or processes.

In extension, each of these subproblems can be solved by breaking them down into further subproblems:

Image represents a decision tree illustrating a recursive algorithm, possibly for finding a solution where a target value is reached by repeatedly subtracting values. The root node displays 'target = 5,' indicating the initial target value.  From this root, three branches descend, labeled 'use 1,' 'use 2,' and 'use 3,' representing three possible subtractions or actions. Each branch leads to a node showing the updated target value after the corresponding action (e.g., 'target = 4' after 'use 1' from the root). This branching pattern continues recursively down to the leaf nodes.  Leaf nodes display either a green checkmark (✓) indicating a successful solution (target value reaches 0), a red cross (✗) indicating failure (target value becomes negative), or dashed upward-pointing arrows (↑↑↑↑) representing further recursive calls that haven't been fully visualized in the diagram.  The numbers next to 'use' likely represent the values subtracted at each step. The overall structure visualizes the exploration of different solution paths in a recursive search.

A path that ends with a target of 0 means the coins used in that path add up to 5. If the target becomes negative, it means the path is invalid, so we should stop extending the path.

We’ve observed how new subproblems are created, but haven’t yet addressed how to attain the solutions to them. Remember, each subproblem needs to return the minimum number of coins needed to reach its target.

Consider the main problem with a target of 5. To solve this, we first need to find the minimum number of coins needed to reach each of its three subproblems. The solution to the main problem is the smallest result among these subproblems, plus 1, to account for the coin used to create the subproblem. This highlights an optimal substructure in the problem, allowing us to define the following recurrence relation:

min_coin_combination(target) = 1 + min(min_coin_combination(target - coin_i) | coin_i ∈ coins)

Base case
Naturally, we need a base case for this formula. The base case occurs when the target equals 0, which is the simplest version of this problem, as no coins are needed to meet the target. In this case, we return 0.

Memoization
An important thing to notice is that we might end up solving the same subproblem multiple times. For instance, we calculate the subproblem target = 3 two times in the previous example:

Image represents a decision tree illustrating a search algorithm, possibly for finding a specific target value.  The root node displays 'target = 5'.  Three branches descend from it, labeled 'use 1,' 'use 2,' and 'use 3,' each leading to a node representing a reduced target value (4, 3, and 2 respectively). This process recursively continues; each node with a target value greater than zero branches into three sub-nodes, each representing a further reduction of the target value using one of the three 'use' options.  The leaf nodes show the final target value reached after applying the 'use' operations.  These leaf nodes are marked with a green checkmark if the target value reaches zero, indicating success, and a red cross if the target value becomes negative, indicating failure.  Dashed lines point upwards from the leaf nodes to their parent nodes, suggesting a backtracking or exploration mechanism.  The light blue background highlights nodes where the target value is 3, possibly indicating a significant step or intermediate result in the algorithm.

This highlights the existence of overlapping subproblems. Memoization improves our solution by storing the solutions to subproblems as they are computed, ensuring each subproblem is solved only once. This eliminates redundant calculations, and can significantly reduce the size of the recursion tree:

Image represents a tree-like diagram illustrating a recursive problem-solving approach.  The root node at the top displays 'target = 5'. From this root, three branches extend downwards, each labeled with 'use 1', 'use 2', and 'use 3' in orange, representing three different ways to reduce the target value.  The 'use 1' branch leads to a node 'target = 4', which further branches into 'target = 3', 'target = 2', and 'target = 1' using 'use 1', 'use 2', and 'use 3' respectively. The 'use 2' branch from the root leads to 'target = 3', annotated as '(already solved)' in green, indicating a base case. Similarly, the 'use 3' branch from the root leads to 'target = 2', also marked '(already solved)' in green.  Green curved lines connect the solved base cases ('target = 3' and 'target = 2') to the lower-level nodes ('target = 3', 'target = 2', and 'target = 1'), suggesting that solutions to these base cases are used to solve the higher-level targets. Dashed lines point upwards from 'target = 3', 'target = 2', and 'target = 1' to indicate that these subproblems are solved recursively. The diagram visually demonstrates how a larger problem ('target = 5') is broken down into smaller, recursively solvable subproblems.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Top-Down**

def min_coin_combination_top_down(coins: List[int], target: int) -> int:
    res = top_down_dp(coins, target, {})
    return -1 if res == float('inf') else res
    
def top_down_dp(coins: List[int], target: int, memo: Dict[int, int]) -> int:
    # Base case: if the target is 0, then 0 coins are needed to reach it.
    if target == 0:
        return 0
    if target in memo:
        return memo[target]
    # Initialize 'min_coins' to a large number.
    min_coins = float('inf')
    for coin in coins:
        # Avoid negative targets.
        if coin <= target:
            # Calculate the minimum number of coins needed if we use the current coin.
            min_coins = min(min_coins, 1 + top_down_dp(coins, target - coin, memo))
    memo[target] = min_coins
    return memo[target]
function minCoinCombinationTopDown(coins, target) {
  const res = topDownDp(coins, target, {});
  return res === Infinity ? -1 : res;
}

function topDownDp(coins, target, memo) {
  // Base case: if the target is 0, then 0 coins are needed to reach it.
  if (target === 0) {
    return 0;
  }
  if (target in memo) {
    return memo[target];
  }
  // Initialize 'min_coins' to a large number.
  let minCoins = Infinity;
  for (const coin of coins) {
    // Avoid negative targets.
    if (coin <= target) {
      // Calculate the minimum number of coins needed if we use the current coin.
      minCoins = Math.min(minCoins, 1 + topDownDp(coins, target - coin, memo));
    }
  }
  memo[target] = minCoins;
  return memo[target];
}
public class Solution {
    public int minCoinCombinationTopDown(int[] coins, int target) {
        int res = topDownDp(coins, target, new HashMap<>());
        return res == Integer.MAX_VALUE ? -1 : res;
    }

    private int topDownDp(int[] coins, int target, Map<Integer, Integer> memo) {
        // Base case: if the target is 0, then 0 coins are needed to reach it.
        if (target == 0) {
            return 0;
        }
        if (memo.containsKey(target)) {
            return memo.get(target);
        }
        // Initialize 'min_coins' to a large number.
        int minCoins = Integer.MAX_VALUE;
        for (int coin : coins) {
            // Avoid negative targets.
            if (coin <= target) {
                int subResult = topDownDp(coins, target - coin, memo);
                if (subResult != Integer.MAX_VALUE) {
                    // Calculate the minimum number of coins needed if we use the current coin.
                    minCoins = Math.min(minCoins, 1 + subResult);
                }
            }
        }
        memo.put(target, minCoins);
        return memo.get(target);
    }
}

Complexity Analysis

Time complexity:

  • Without memoization, the time complexity of min_coin_combination_top_down would be O(ntarget/m)O(n^{target/m}), where nn denotes the number of coins, and mm denotes the smallest coin value. The recursion tree has a branch factor of nn because we make a recursive call for up to nn coins. The depth of the tree is target/mtarget/m because in the worst case, we continually reduce the target value by the smallest coin.

  • With memoization, each subproblem is solved only once. Since there are at most targettarget subproblems, and we iterate through all nn coins for each subproblem, the time complexity is O(target⋅n)O(target\cdot n).

Space complexity: The space complexity is O(target)O(target) because, while the maximum depth of the recursive call stack is only target/mtarget/m, the memoization array stores up to targettarget key-value pairs.

Intuition - Bottom-Up

Using the same technique discussed in the Climbing Stairs problem, we can convert our top-down solution to a bottom-up one by translating the memoization array to a DP array.

First, let’s look at the value our memoization array stores, as shown in the following code snippet of the top-down implementation:

for coin in coins:
    if coin <= target:
        min_coins = min(min_coins, 1 + top_down_dp(coins, target - coin, memo))
memo[target] = min_coins
for (const coin of coins) {
  if (coin <= target) {
    minCoins = Math.min(minCoins, 1 + topDownDp(coins, target - coin, memo));
  }
}
memo[target] = minCoins;
for (int coin : coins) {
    if (coin <= target) {
        int subResult = topDownDp(coins, target - coin, memo);
        if (subResult != Integer.MAX_VALUE) {
            minCoins = Math.min(minCoins, 1 + subResult);
        }
    }
}
memo.put(target, minCoins);

Translating this to a DP array provides the following code:

for coin in coins:
    if coin <= target:
        dp[target] = min(dp[target], 1 + dp[target - coin])
for (const coin of coins) {
  if (coin <= target) {
    dp[target] = Math.min(dp[target], 1 + dp[target - coin]);
  }
}
for (int coin : coins) {
    if (coin <= target) {
        dp[target] = Math.min(dp[target], 1 + dp[target - coin]);
    }
}

This code snippet only includes the calculation for one target value. In our top-down solution, this calculation is repeated for every target value from the initial target, down to the base case (target == 0).

In the bottom-up solution, we need to reverse this order by starting with the base case and working our way up to the initial target value (hence the name “bottom-up”). This is necessary because our DP array calculation depends on the DP values of smaller targets. So, we need to calculate the answers for smaller targets first. This can be done using a for-loop from 1 to the target (starting at 1 since the base case of 0 is already set):

for t in range(1, target + 1):
    for coin in coins:
        if coin <= t:
            dp[t] = min(dp[t], 1 + dp[t - coin])
for (let t = 1; t <= target; t++) {
  for (const coin of coins) {
    if (coin <= t) {
      dp[t] = Math.min(dp[t], 1 + dp[t - coin]);
    }
  }
}
for (int t = 1; t <= target; t++) {
    for (int coin : coins) {
        if (coin <= t) {
            dp[t] = Math.min(dp[t], 1 + dp[t - coin]);
        }
    }
}

Once this is done, the answer to the problem will be stored in dp[target].

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Bottom-Up**

def min_coin_combination_bottom_up(coins: List[int], target: int) -> int:
    # The DP array will store the minimum number of coins needed for each amount. Set
    # each element to a large number initially.
    dp = [float('inf')] * (target + 1)
    # Base case: if the target is 0, then 0 coins are needed.
    dp[0] = 0
    # Update the DP array for all target amounts greater than 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
function minCoinCombinationBottomUp(coins, target) {
  // The DP array will store the minimum number of coins needed for each amount. Set
  // each element to Infinity initially.
  const dp = new Array(target + 1).fill(Infinity);
  // Base case: if the target is 0, then 0 coins are needed.
  dp[0] = 0;
  // Update the DP array for all target amounts greater than 0.
  for (let t = 1; t <= target; t++) {
    for (const coin of coins) {
      if (coin <= t) {
        dp[t] = Math.min(dp[t], 1 + dp[t - coin]);
      }
    }
  }
  return dp[target] !== Infinity ? dp[target] : -1;
}
public int minCoinCombinationBottomUp(int[] coins, int target) {
    // The DP array will store the minimum number of coins needed for each amount. Set
    // each element to a large number initially.
    int[] dp = new int[target + 1];
    Arrays.fill(dp, target + 1);
    // Base case: if the target is 0, then 0 coins are needed.
    dp[0] = 0;
    // Update the DP array for all target amounts greater than 0.
    for (int t = 1; t <= target; t++) {
        for (int coin : coins) {
            if (coin <= t) {
                dp[t] = Math.min(dp[t], 1 + dp[t - coin]);
            }
        }
    }
    return dp[target] > target ? -1 : dp[target];
}

Complexity Analysis

Time complexity: The time complexity of min_coin_combination_bottom_up is O(targetn)O(targetn) because we loop through all nn coins for each value between 1 and targettarget.

Space complexity: The space complexity is O(target)O(target) due to the space occupied by the DP array, which is of size target+1target+1.

Interview Tip

Tip: When a problem asks for the minimum or maximum of something, it might be a DP problem.
If you spot keywords like "minimum", "maximum", "longest", or "shortest", in the problem description, consider whether a DP approach might be appropriate, as many DP problems involve optimizing a certain value, such as finding the minimum cost, or longest sequence.

CHAPTER 95

Matrix Pathways

You are positioned at the top-left corner of a m × n matrix, and can only move downward or rightward through the matrix. Determine the number of unique pathways you can take to reach the bottom-right corner of the matrix.

Example:

Image represents six distinct diagrams, each enclosed within a black square and overlaid on a 3x3 grid.  Each diagram depicts a path formed by a thick, orange line connecting two orange circles; one circle is located at the top-left corner of the grid in every diagram, representing a starting point, while the other circle, representing an endpoint, is positioned at various locations within the grid in each diagram. The orange line connecting these circles follows a path constrained by the grid lines, forming different right-angled routes across the grid in each of the six diagrams.  The paths vary in length and direction, demonstrating different possible routes from the top-left starting point to various endpoints within the 3x3 grid.  The endpoint circles have a slightly larger, lighter orange ring around them, visually distinguishing them from the starting point circles.
Example
Input: m = 3, n = 3
Output: 6

Constraints:

  • m, n ≥ 1

Intuition

At each cell, we can either move right or move down. No matter which direction we choose at any point, it will always move us closer to the destination. This means we just need to keep moving either right or down until we can no longer do so, at which point we’ve reached the bottom-right corner.

Let's think about this problem backward. Assume we have already reached the bottom-right corner. How did we get here? We know for certain we came from either the cell directly above, or the cell directly to the left of the current position.

Image represents a 3x3 grid, enclosed within a bold black border, with each cell delineated by thin black lines.  A light-blue circle with a slightly darker-blue inner circle is positioned in the bottom-right cell. An orange arrow points horizontally from the cell to the left of the circle, into the circle itself.  Simultaneously, another orange arrow points vertically downwards from the cell above the circle, also into the circle.  No text, URLs, or parameters are present within the grid or on the arrows; the image solely depicts the directional flow of information into a central point within a structured grid system.

This is equally true for any cell on the matrix, which means a generalization can be made: the number of paths to any cell is equal to the sum of the number of paths to the cell above it and the cell to its left.

Image represents a diagram illustrating a recursive approach to calculating the number of pathways in a matrix.  The diagram uses a grid to visually represent matrix cells, with coordinates (r, c) denoting row 'r' and column 'c'.  An orange arrow points from cell (r-1, c) vertically downwards to cell (r, c), and another orange arrow points from cell (r, c-1) horizontally to the right towards cell (r, c). This visually demonstrates that the number of pathways to reach cell (r, c) is the sum of the pathways to reach cell (r-1, c) above it and cell (r, c-1) to its left.  Below the diagram, the equation `matrix_pathways(r, c) = matrix_pathways(r - 1, c) + matrix_pathways(r, c - 1)` explicitly defines this recursive relationship, stating that the total number of pathways to reach a cell (r, c) is equal to the sum of pathways to reach the cell above it and the cell to its left.

This demonstrates the existence of subproblems, and that this problem has an optimal substructure, where we need to solve two subproblems in order to solve the main problem. This makes this problem well-suited for DP. So, let’s translate the above recurrence relation to a DP formula:

dp[r][c] = dp[r - 1][c] + dp[r][c - 1], where dp[r][c] represents the total number of paths that lead to cell (r, c).

Before populating the DP table, we need to know our base cases.

Base cases
We know dp[0][0] should be 1 because there’s only one path leading to cell (0, 0).

What else do we know for certain? Since we can only move right or down, once we leave a row or column, we can never return to it because we can't move left or up. This means, for any cell in row 0 or column 0, there’s only one path to those cells:

Image represents two grid diagrams illustrating different data processing approaches.  The left diagram shows a single row of a grid highlighted in peach, containing a light-blue filled circle at the far left and three consecutive orange right-pointing arrows extending from it across the row.  This suggests a horizontal, sequential processing of data.  A light-blue outlined circle is present at the bottom-right corner of the grid, implying a final result or output. The right diagram displays a peach-highlighted column, with a light-blue filled circle at the top and three consecutive orange downward-pointing arrows leading to a light-blue outlined circle at the bottom-right. This depicts a vertical, sequential processing of data, contrasting with the horizontal flow in the left diagram. Both diagrams use the same grid structure, suggesting a comparison between horizontal and vertical data processing methods within a similar context.  The small circles likely represent data points, while the arrows indicate the direction and flow of processing steps.

Therefore, we can set all cells in row 0 and column 0 to 1 as the base cases.

Problem-solving tip: another way to identify row 0 and column 0 as base cases is by examining the DP formula. Since we need the values from row r - 1 and column c - 1 to populate dp[r][c], all values at r = 0 and c = 0 must be pre-populated before using the formula to avoid index out-of-bound errors.

Populating the DP table
Once the base cases are set, we can populate the remaining DP table, starting from cell (1, 1), using our DP formula (dp[r][c] = dp[r-1][c] + dp[r][c-1]):

Image represents a transformation of a 4x4 matrix.  The left matrix is initialized with the value '1' in the first column and row, and all other cells are empty.  The top row and leftmost column are labeled with indices 0, 1, 2, and 3.  A thick black arrow points from the left matrix to the right matrix. The right matrix shows the result of a cumulative sum operation.  Specifically, each cell (i, j) in the right matrix contains the sum of all cells (x, y) in the left matrix where x ≤ i and y ≤ j.  For example, the cell at (1,1) in the right matrix is 2 (1+1 from the left matrix), the cell at (2,3) is 10 (1+1+1+1+1+1+1+1+1+1), and the cell at (3,3) is 20, representing the cumulative sum of all elements in the left matrix.  The values in the right matrix are calculated by iteratively summing the values from the left matrix, demonstrating a cumulative sum pattern.

After we fill in the DP table, we can return dp[m - 1][n - 1], which contains the number of paths to the bottom-right corner.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

def matrix_pathways(m: int, n: int) -> int:
    # Base cases: Set all cells in row 0 and column 0 to 1. We can do this by
    # initializing all cells in the DP table to 1.
    dp = [[1] * n for _ in range(m)]
    # Fill in the rest of the DP table.
    for r in range(1, m):
        for c in range(1, n):
            # Paths to current cell = paths from above + paths from left.
            dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
    return dp[m - 1][n - 1]
function matrixPathways(m, n) {
  // Base cases: Set all cells in row 0 and column 0 to 1. We can do this by
  // initializing all cells in the DP table to 1.
  const dp = Array.from({ length: m }, () => new Array(n).fill(1));
  // Fill in the rest of the DP table.
  for (let r = 1; r < m; r++) {
    for (let c = 1; c < n; c++) {
      // Paths to current cell = paths from above + paths from left.
      dp[r][c] = dp[r - 1][c] + dp[r][c - 1];
    }
  }
  return dp[m - 1][n - 1];
}
public int matrixPathways(int m, int n) {
    // Base cases: Set all cells in row 0 and column 0 to 1. We can do this by
    // initializing all cells in the DP table to 1.
    int[][] dp = new int[m][n];
    for (int r = 0; r < m; r++) Arrays.fill(dp[r], 1);
    // Fill in the rest of the DP table.
    for (int r = 1; r < m; r++) {
        for (int c = 1; c < n; c++) {
            // Paths to current cell = paths from above + paths from left.
            dp[r][c] = dp[r - 1][c] + dp[r][c - 1];
        }
    }
    return dp[m - 1][n - 1];
}

Complexity Analysis

Time complexity: The time complexity of matrix_pathways is O(m⋅n)O(m\cdot n) because each cell in the DP table is populated once.

Space complexity: The space complexity is O(m⋅n)O(m\cdot n) due to the DP table, which contains m⋅nm\cdot n elements.

Optimization

We can optimize our solution by understanding that, for each cell in the DP table, we only need to access the cells directly above it and to its left.

Image represents a 2x2 grid illustrating a dynamic programming approach, likely for a problem involving a 2D array or matrix.  The grid is divided into four quadrants, each representing a cell in the DP table. The top-right quadrant is labeled `dp[r-1][c]`, representing the value at row `r-1` and column `c`. The bottom-left quadrant is labeled `dp[r][c-1]`, representing the value at row `r` and column `c-1`. The bottom-right quadrant is labeled `dp[r][c]`, representing the value at row `r` and column `c`. A downward-pointing arrow connects `dp[r-1][c]` to `dp[r][c]`, indicating a dependency; the value of `dp[r][c]` is calculated using the value of `dp[r-1][c]`. A rightward-pointing arrow connects `dp[r][c-1]` to `dp[r][c]`, indicating another dependency; the value of `dp[r][c]` also depends on the value of `dp[r][c-1]`.  This suggests a recursive or iterative approach where the value of each cell is computed based on the values of its top and left neighbors.
  • To get the cell above it (dp[r-1][c]), we only need access to the previous row.
  • To get the cell to its left (dp[r][c-1]), we just need to look at the cell to the left of the current cell, which is in the same row we're currently populating.

Therefore, we only need to maintain two rows:

  • prev_row: the previous row.
  • curr_row: the current row being populated.
Image represents a 4x4 grid illustrating a coding pattern, possibly related to dynamic programming or matrix calculations.  The grid is labeled with row and column indices (0-3 for both).  The grid's cells contain numerical values; the top two rows (rows 0 and 1) show light gray numbers (1, 1, 1, 1; 1, 2, 3, 4 respectively). Row 2 displays bold black numbers (1, 3, 6, 10). Row 3 begins with a bold black '1'.  Downward-pointing gray arrows connect each element in row 2 to the corresponding element below in row 3, indicating a calculation dependency.  A horizontal gray arrow connects the initial '1' in row 3 to the next cell, suggesting a cumulative or iterative process.  To the right, an explanatory text states: 'to calculate row 3, the only other row we need is row 2,' clarifying the dependency between rows 2 and 3 in the calculation.  The labels 'prev_row: 2' and 'curr_row: 3' explicitly identify rows 2 and 3 as the previous and current rows in the computation, respectively.

This effectively reduces the space complexity to O(n)O(n) because we now only need to maintain two arrays of size nn. After populating the DP values for the current row, we’ll need to make sure to update prev_row with the values from curr_row to prepare for the next iteration since the next row’s previous row is the current row. Below is the optimized code:

def matrix_pathways_optimized(m: int, n: int) -> int:
    # Initialize 'prev_row' as the DP values of row 0, which are all 1s.
    prev_row = [1] * n
    # Iterate through the matrix starting from row 1.
    for r in range(1, m):
        # Set the first cell of 'curr_row' to 1. This is done by
        # setting the entire row to 1.
        curr_row = [1] * n
        for c in range(1, n):
            # The number of unique paths to the current cell is the sum
            # of the paths from the cell above it ('prev_row[c]') and
            # the cell to the left ('curr_row[c - 1]').
            curr_row[c] = prev_row[c] + curr_row[c - 1]
        # Update 'prev_row' with 'curr_row' values for the next
        # iteration.
        prev_row = curr_row
    # The last element in 'prev_row' stores the result for the
    # bottom-right cell.
    return prev_row[n - 1]
function matrixPathwaysOptimized(m, n) {
  // Initialize 'prevRow' as the DP values of row 0, which are all 1s.
  let prevRow = new Array(n).fill(1);
  // Iterate through the matrix starting from row 1.
  for (let r = 1; r < m; r++) {
    // Set the first cell of 'currRow' to 1. This is done by
    // setting the entire row to 1.
    const currRow = new Array(n).fill(1);
    for (let c = 1; c < n; c++) {
      // The number of unique paths to the current cell is the sum
      // of the paths from the cell above it ('prevRow[c]') and
      // the cell to the left ('currRow[c - 1]').
      currRow[c] = prevRow[c] + currRow[c - 1];
    }
    // Update 'prevRow' with 'currRow' values for the next iteration.
    prevRow = currRow;
  }
  // The last element in 'prevRow' stores the result for the bottom-right cell.
  return prevRow[n - 1];
}
public int matrixPathwaysOptimized(int m, int n) {
    // Initialize 'prevRow' as the DP values of row 0, which are all 1s.
    int[] prevRow = new int[n];
    Arrays.fill(prevRow, 1);
    // Iterate through the matrix starting from row 1.
    for (int r = 1; r < m; r++) {
        // Set the first cell of 'currRow' to 1. This is done by
        // setting the entire row to 1.
        int[] currRow = new int[n];
        Arrays.fill(currRow, 1);
        for (int c = 1; c < n; c++) {
            // The number of unique paths to the current cell is the sum
            // of the paths from the cell above it ('prevRow[c]') and
            // the cell to the left ('currRow[c - 1]').
            currRow[c] = prevRow[c] + currRow[c - 1];
        }
        // Update 'prevRow' with 'currRow' values for the next iteration.
        prevRow = currRow;
    }
    // The last element in 'prevRow' stores the result for the bottom-right cell.
    return prevRow[n - 1];
}
CHAPTER 96

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:

Image represents a horizontal arrangement of four stylized houses from left to right. The houses each consist of a rectangular body and a triangular roof. The first and third houses from the left have light orange bodies, while the second and fourth houses have light grey bodies. Each house body contains a black dollar value label: the first house has $200, the second has $300, the third has $200, and the fourth has $50. Below the first house, slightly to the right of its center, is a black dot. Below the third house, also slightly to the right of its center, is another black dot. A curved black line originates from the dot below the first house and curves upwards and then downwards to connect to the dot below the third house, forming an arc that spans beneath the second house. Text labels are placed below the dots: +$200 is positioned below the dot for the first house, and another +$200 is positioned below the dot for the third house. The overall diagram appears to depict a process or interaction where the first and third houses, both valued at $200, are selected or involved in an operation, possibly adding or transferring a value of $200 at each point.
Example
Input: houses = [200, 300, 200, 50]
Output: 400

Explanation: Stealing from the houses at indexes 0 and 2 yields 200 + 200 = 400 dollars.

Intuition

Ideally, we would want to rob every house, and collect the total sum of all cash contained therein. However, with the alarm system in place, we need to be more strategic about which houses to rob and which to skip.

A simple, greedy approach of always robbing the house with the most money fails because it overlooks the long-term consequences of its choices, and doesn’t always yield the highest total profit. This is visualized in the example below.

Image represents a horizontal arrangement of four stylized houses enclosed within a bracket formed by a horizontal line below and vertical lines on the left and right. Above the bracketed houses, the text 'option 1: rob the house with the most money first' is displayed. The four houses are arranged from left to right. The first house has a light grey body and roof, with the label '$200' inside its body and 'skip' below the house. The second house has a light orange body and roof, with the label '$300' inside its body and 'rob' below the house. The third house has a light grey body and roof, with the label '$200' inside its body and 'skip' below the house. The fourth house has a light orange body and roof, with the label '$50' inside its body and 'rob' below the house. To the right of the bracket, the text 'profit = $350' is shown. The diagram illustrates a specific strategy or option for selecting houses to 'rob' from a sequence, indicating which houses are chosen ('rob') and which are not ('skip'), and presenting the total calculated profit for this strategy.
Image represents a horizontal arrangement of four stylized houses enclosed within a bracket formed by a horizontal line below and vertical lines on the left and right. Above the bracketed houses, the text 'option 2: optimal solution' is displayed. The four houses are arranged from left to right. The first house has a light orange body and roof, with the label '$200' inside its body and 'rob' below the house. The second house has a light grey body and roof, with the label '$300' inside its body and 'skip' below the house. The third house has a light orange body and roof, with the label '$200' inside its body and 'rob' below the house. The fourth house has a light grey body and roof, with the label '$50' inside its body and 'skip' below the house. To the right of the bracket, the text 'profit = $400' is shown. The diagram illustrates a specific strategy or option for selecting houses to 'rob' from a sequence, indicating which houses are chosen ('rob') and which are not ('skip'), and presenting the total calculated profit for this optimal strategy, which is $400 (derived from robbing the first house for $200 and the third house for $200).

Let’s approach this problem from a different angle. Imagine breaking into houses all along a street, and eventually reaching the last house, denoted as i below. How much money has been stolen up to this point?

Image represents a diagram illustrating a coding pattern, possibly related to recursion or iteration.  The diagram shows five simple house-like shapes arranged horizontally within a rectangular box.  The first house is followed by three dots indicating an ellipsis, suggesting a repetition of the house pattern.  The houses are identical in design, each consisting of a square base topped with a triangular roof.  A small square box labeled 'i' is positioned above the last house, with a downward-pointing arrow connecting it to the last house, implying input or an index 'i' into the pattern. Below the houses, the text 'profit(i) = ?' is written, posing a question about the function or value associated with the index 'i' within the context of the repeated house pattern. The overall arrangement suggests a sequence or series where the function `profit(i)` needs to be determined based on the position or index 'i' of the house within the sequence.

To answer this question, we need to consider the two choices that can be made at this last house: do we skip it or rob it?

  • If we skip it, we end our burglary with the total amount stolen up to the house at i - 1:

    Image represents a diagram illustrating a dynamic programming approach, possibly for a house-robbery problem.  The main section shows a sequence of four house-shaped icons within a rectangular box, representing houses.  Three houses are outlined, while the fourth is labeled '(i - 1),' indicating the i-th house minus one.  An ellipsis (...) between the first and second houses signifies that there could be more houses before the shown sequence. Below the houses, the equation `profit(i) = total_stolen(i - 1)` is displayed, suggesting that the profit from robbing the i-th house is equal to the total stolen from the previous (i-1) houses.  To the right, a separate, shaded house-shaped icon labeled 'skip' is shown with an arrow pointing down from a box containing 'i,' indicating that the i-th house might be skipped, implying a choice between robbing the current house or not.  The overall diagram visually explains a recursive relationship where the optimal solution for robbing the i-th house depends on the optimal solution for robbing the previous (i-1) houses.
  • If we rob it, we couldn't have robbed the previous house at i - 1, so we end the burglary with the money stolen from this final house, plus the total amount stolen up to the house at i - 2, which is two houses back:

    Image represents a diagram illustrating a dynamic programming approach to solving the 'House Robber II' problem.  A rectangular box contains a sequence of house icons representing houses in a circular street; an ellipsis (...) indicates that there are more houses not explicitly shown.  Three houses are visible within the box, labeled (i-2), (i-1), and implicitly (i) which is the current house being considered.  Outside the box, two additional houses are shown. The house labeled (i-1) is light gray, labeled 'skip,' indicating that this house is not robbed in the current iteration. The house to its right is peach-colored, labeled 'rob,' representing the current house being robbed.  An arrow descends from a small square containing an 'i' symbol, pointing to the 'rob' house, suggesting an iterative process. The equation `profit(i) = houses[i] + total_stolen(i - 2)` below the diagram shows the recursive relationship: the profit from robbing house `i` is the value of house `i` plus the total stolen from houses up to (but not including) house `i-1`.  The diagram visually demonstrates the decision-making process at each step, choosing between robbing the current house (`rob`) or skipping it (`skip`) based on maximizing the total profit, considering the constraint of not robbing adjacent houses.

The optimal choice is whichever of these two options yields the largest amount of money.

Notice this discussion highlights the existence of subproblems, and an optimal substructure where we need to know the total amount stolen up to the two previous houses, before determining the total amount we can steal up to the current house.

This means we should try using DP to solve this problem. Let’s say dp[i] represents the maximum amount we’re able to steal by the time we reach house i. Based on our previous discussion, we know that:

dp[i] = max(profit if we skip house i, profit if we rob house i) = max(dp[i - 1], houses[i] + dp[i - 2])

Once the DP array is populated using the above formula, we can just return dp[n - 1], which represents the maximum amount that can be stolen once we reach the end of the street. Now, let’s think about what our base cases should be.

Base cases
For starters, let’s consider what to do if there’s just one house. This is the simplest possible subproblem, where the total amount stolen is just the money in that house. So, one of our base cases is dp[0] = houses[0].

Keep in mind that to use our DP formula (dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])), we need to access values at indexes i - 1 and i - 2. This means we must set initial values for index 0 and index 1. With these base cases, we can safely start using the formula from index 2 onward without causing any index out-of-bound errors. So, what’s the most money that can be stolen at i = 1 (i.e., when there are just two adjacent houses)? We can only steal from one of these houses, so dp[1] = max(houses[0], houses[1]).

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
   
def neighborhood_burglary(houses: List[int]) -> int:
    # Handle the cases when the array is less than the size of 2 to avoid out-of-
    # bounds errors when assigning the base case values.
    if not houses:
        return 0
    if len(houses) == 1:
        return houses[0]
    dp = [0] * len(houses)
    # Base case: when there's only one house, rob that house.
    dp[0] = houses[0]
    # Base case: when there are two houses, rob the one with the most money.
    dp[1] = max(houses[0], houses[1])
    # Fill in the rest of the DP array.
    for i in range(2, len(houses)):
        # 'dp[i]' = max(profit if we skip house 'i', profit if we rob house 'i').
        dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])
    return dp[len(houses) - 1]
function neighborhoodBurglary(houses) {
  // Handle the cases when the array is less than the size of 2 to avoid out-of-
  // bounds errors when assigning the base case values.
  if (!houses || houses.length === 0) return 0;
  if (houses.length === 1) return houses[0];
  const dp = new Array(houses.length).fill(0);
  // Base case: when there's only one house, rob that house.
  dp[0] = houses[0];
  // Base case: when there are two houses, rob the one with the most money.
  dp[1] = Math.max(houses[0], houses[1]);
  // Fill in the rest of the DP array.
  for (let i = 2; i < houses.length; i++) {
    // 'dp[i]' = max(profit if we skip house 'i', profit if we rob house 'i').
    dp[i] = Math.max(dp[i - 1], houses[i] + dp[i - 2]);
  }
  return dp[houses.length - 1];
}
public int neighborhoodBurglary(int[] houses) {
    // Handle the cases when the array is less than the size of 2 to avoid out-of-
    // bounds errors when assigning the base case values.
    if (houses == null || houses.length == 0) return 0;
    if (houses.length == 1) return houses[0];
    int[] dp = new int[houses.length];
    // Base case: when there's only one house, rob that house.
    dp[0] = houses[0];
    // Base case: when there are two houses, rob the one with the most money.
    dp[1] = Math.max(houses[0], houses[1]);
    // Fill in the rest of the DP array.
    for (int i = 2; i < houses.length; i++) {
        // 'dp[i]' = max(profit if we skip house 'i', profit if we rob house 'i').
        dp[i] = Math.max(dp[i - 1], houses[i] + dp[i - 2]);
    }
    return dp[houses.length - 1];
}

Complexity Analysis

Time complexity: The time complexity of neighborhood_burglary is O(n)O(n), where nn denotes the number of houses. This is because each index of the DP array is populated at most once.

Space complexity: The space complexity is O(n)O(n) since we're maintaining a DP array that has nn elements.

Optimization

From the DP array formula dp[i] = max(dp[i - 1], houses[i] + dp[i - 2]), an important observation is that we only need to access the previous two values of the DP array, index i - 1 and index i - 2, to calculate the current value at index i. This means we don’t need to store the entire DP array.

Instead, we can use two variables to keep track of the previous two values:

  • prev_max_profit: stores the value of dp[i - 1]
  • prev_prev_max_profit: stores the value of dp[i - 2]

This optimization reduces the space complexity to O(1)O(1) since we’re no longer maintaining any auxiliary data structures. The adjusted implementation can be seen below:

from typing import List

def neighborhood_burglary_optimized(houses: List[int]) -> int:
    if not houses:
        return 0
    if len(houses) == 1:
        return houses[0]
    # Initialize the variables with the base cases.
    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)
        # Update the values for the next iteration.
        prev_prev_max_profit = prev_max_profit
        prev_max_profit = curr_max_profit
    return prev_max_profit
function neighborhoodBurglaryOptimized(houses) {
  if (!houses || houses.length === 0) return 0;
  if (houses.length === 1) return houses[0];
  // Initialize the variables with the base cases.
  let prevMaxProfit = Math.max(houses[0], houses[1]);
  let prevPrevMaxProfit = houses[0];
  for (let i = 2; i < houses.length; i++) {
    const currMaxProfit = Math.max(prevMaxProfit, houses[i] + prevPrevMaxProfit);
    // Update the values for the next iteration.
    prevPrevMaxProfit = prevMaxProfit;
    prevMaxProfit = currMaxProfit;
  }
  return prevMaxProfit;
}
public int neighborhoodBurglaryOptimized(int[] houses) {
    if (houses == null || houses.length == 0) return 0;
    if (houses.length == 1) return houses[0];
    // Initialize the variables with the base cases.
    int prevMaxProfit = Math.max(houses[0], houses[1]);
    int prevPrevMaxProfit = houses[0];
    for (int i = 2; i < houses.length; i++) {
        int currMaxProfit = Math.max(prevMaxProfit, houses[i] + prevPrevMaxProfit);
        // Update the values for the next iteration.
        prevPrevMaxProfit = prevMaxProfit;
        prevMaxProfit = currMaxProfit;
    }
    return prevMaxProfit;
}
CHAPTER 97

Longest Common Subsequence

Given two strings, find the length of their longest common subsequence (LCS). A subsequence is a sequence of characters that can be derived from a string by deleting zero or more elements, without changing the order of the remaining elements.

Example:

Image represents three pairs of small, peach-colored, circular nodes, each containing a single letter ('a', 'b', 'c', or 'e'), connected by lines to illustrate different graph structures.  The first pair shows a simple linear connection: a node labeled 'a' connects to another node labeled 'a' below it; to the right, a node labeled 'b' connects to a node labeled 'a' and another labeled 'b'.  The second pair, separated by the word 'OR' in gray text, shows a different connection: a node labeled 'a' connects to nodes labeled 'b' and 'a' below it; to the right, a node labeled 'a' connects to nodes labeled 'a' and 'b' below it. The third pair, also separated by 'OR', mirrors the structure of the first pair, with a linear connection of 'a' to 'a' on the left and 'b' connecting to 'a' and 'b' on the right.  Each node is labeled with a single letter, and the connections between nodes are represented by simple lines.  The overall image seems to illustrate alternative graph structures or data flow patterns, possibly within a coding context.
Example
Input: s1 = 'acabac', s2 = 'aebab'
Output: 3

Intuition

A naive approach to this problem is to generate every possible subsequence for both strings and identify the LCS among them. This is extremely inefficient, so we need to think of something better.

One way to think about this problem is to realize that for any character from either string, we have a choice to either include it in the LCS, or exclude it. This will help us figure out the next steps in finding the length of the LCS.

Let’s start by considering the first character of each string and whether we should include or exclude them. There are two primary cases to discuss:

  • Case 1: the characters are the same.
  • Case 2: the characters are different.

Case 1: equal characters
Consider the following two strings, where we’re trying to find the length of their LCS, starting from index 0 of each string (LCS(0, 0)):

Image represents a diagram illustrating the concept of Longest Common Subsequence (LCS).  The diagram shows two sequences of characters arranged in a rectangular box. The top row contains the sequence 'a c a b a c', and the bottom row contains the sequence 'a e b a b'.  These sequences are presented as input to an LCS function, denoted by 'LCS(θ, θ)' where θ likely represents the empty sequence or null input. The question mark following the function call indicates that the length of the LCS between these two sequences is unknown and needs to be calculated.  The arrangement of the characters in rows implies that the LCS algorithm would compare elements in corresponding positions to find the longest common subsequence.  No explicit connections or information flow are visually depicted beyond the presentation of the two input sequences and the LCS function call.

The first characters of these two strings are equal. What should we do about them? We should include these characters in the LCS as they form the beginning of a common subsequence. Including them also means our LCS will have a length of at least 1. But how do we find the length of the rest of the LCS? We can do this by computing the LCS of the remainder of both strings. That is, the LCS of their substrings starting at index 1 (LCS(1, 1)):

Image represents a visual depiction of a step in the Longest Common Subsequence (LCS) algorithm.  A light-green vertical rectangle on the left contains the sequence 'aa', representing one input sequence.  A horizontal rectangle to its right displays a second input sequence: 'cabcac'. Below the horizontal rectangle, the word 'equal' is written in green, indicating a match.  A right-pointing arrow connects the 'aa' sequence to the word 'equal', signifying that the algorithm is currently comparing the first elements of both sequences. To the far right, the equation 'LCS(0, 0) = 1 + LCS(1, 1)' is shown, representing a recursive step in the LCS calculation.  The 'LCS(0, 0)' term refers to the LCS length of the entire sequences, while 'LCS(1, 1)' represents the LCS length of the subsequences starting from the second element of each input sequence. The equation shows that if the first elements match (as they do here with 'a'), the overall LCS length is incremented by 1, and the calculation recursively continues with the remaining subsequences.

We’ve just identified that this case can be solved by solving a subproblem that also computes the LCS of two strings, indicating this problem has an optimal substructure.

Therefore, we can generalize a recurrence relation for this case. Below, index i and j represent the start of the substring of s1 and s2, respectively.

if s1[i] == s2[j]: LCS(i, j) = 1 + LCS(i + 1, j + 1)


Case 2: different characters
Now, let’s say the first characters of the two strings are different:

Image represents a visual depiction of a Longest Common Subsequence (LCS) problem.  A rectangular box contains two rows of characters. The top row displays the sequence 'a c a b a c,' and the bottom row shows 'k e b a b.'  A red rectangular highlight encloses the first column, containing 'a' and 'k'. A red arrow points downwards from this highlighted column to the word 'different' indicating that the characters 'a' and 'k' are not the same. To the right of the box, the notation 'LCS(θ, θ) = ?' is written, posing the question of finding the longest common subsequence between two sequences (represented by θ, which likely stands for an empty sequence in this context, implying the initial step of the LCS algorithm).  The overall diagram illustrates the initial comparison step in an LCS algorithm, highlighting a difference between the first elements of the two input sequences.

This means the LCS cannot include both of these characters. It could include one of them, but certainly not both. Therefore, we have two choices to find the LCS:

  1. Exclude ‘a’ from the first string to find the LCS between the two strings after this exclusion:

    Image represents a rectangular box divided into two rows. The top row contains the characters 'a', 'c', 'a', 'b', 'a', and 'c', while the bottom row contains 'k', 'e', 'b', 'a', and 'b'.  The characters in the top row are slightly offset to the right compared to those in the bottom row.  The leftmost character 'a' in the top row is visually separated from the rest, suggesting a possible distinction.  To the right of the box, the equation 'LCS(θ, θ) = LCS(1, θ)' is displayed, where 'LCS' likely stands for 'Longest Common Subsequence,' θ represents an empty set or zero, and the numbers 1 and 0 are parameters.  The equation suggests a recursive relationship or base case within the LCS algorithm, possibly illustrating how the calculation proceeds when one of the input sequences is empty.
  2. Exclude ‘k’ from the second string to find the LCS between the two strings after this exclusion:

    Image represents a diagram illustrating a base case in the Longest Common Subsequence (LCS) algorithm.  The diagram shows two sequences of characters arranged in a rectangular box. The top row contains the sequence 'a c a b a c', while the bottom row contains the sequence 'k e b a b'.  The character 'k' in the bottom row is displayed in a lighter gray, suggesting it's potentially treated differently or is outside the main comparison. The box visually represents the input to the LCS function. To the right of the box, the equation 'LCS(θ, θ) = LCS(θ, 1)' is shown, indicating a base case where the LCS of an empty sequence (θ) and another empty sequence (θ) is equal to the LCS of an empty sequence (θ) and a sequence of length 1.  This equation likely represents a recursive step or a condition within the LCS algorithm's implementation, demonstrating how the algorithm handles the scenario where one of the input sequences is empty.

The length of the LCS will be the larger length between these two options. Again, here we see that we’re dealing with a problem of optimal substructure. The recurrence relation for this case is:

if s1[i] != s2[j]: LCS(i, j) = max(LCS(i + 1, j), LCS(i, j + 1)) (i.e. max(LCS excluding s1[i], LCS excluding s2[j]))


Dynamic programming
Since we’re dealing with overlapping subproblems, where the solutions to a subproblem can be used multiple times, we can convert our recurrence relation to a DP formula. Let’s say dp[i][j] represents LCS(i, j). Based on our previous discussion, we know that:

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

Now, we need to think about what the base cases should be.

Base cases
The simplest version of our problem is when one or both strings are empty. In this case, their LCS has a length of 0. But which values of the DP table should we populate for these base cases?

We know that when i = len(s1) - 1, only one character of s1 is being considered:

Image represents a diagram illustrating the calculation of an index within a string.  A rectangular box at the top contains the assignment statement `i = len(s1) - 1`, indicating that the variable `i` is being assigned the value of the length of a string `s1` minus 1. A downward arrow connects this box to a sequence of characters 'a c a b a c' displayed below. The arrow points to the last character 'c' in the sequence, visually representing that the calculated index `i` points to the last element of the string `s1`.  The characters 'a c a b a' are shown in a lighter gray, suggesting they are part of the string but not the immediate focus of the calculation. The overall diagram demonstrates how the formula `len(s1) - 1` determines the index of the last element in a string.

This implies that when i = len(s1), the substring of s1 contains no characters. The equivalent is true for s2 when j = len(s2). Therefore, we can populate the DP table with the base case values like so:

  • dp[len(s1)][j] = 0 for all j
  • dp[i][len(s2)] = 0 for all i

Let’s draw the DP table with just these base cases to get a better idea of what this looks like:

Image represents a 6x6 matrix, labeled as `s1` on the left and `s2` at the top.  The rows of the matrix are indexed 0 through 5, and labeled with characters 'a', 'c', 'a', 'b', 'a', and '''. The columns are indexed 0 through 5, and labeled with characters 'a', 'e', 'b', 'a', 'b', and '''.  The matrix itself is mostly empty, containing only zeros ('0') in the bottom row and the rightmost column.  A right-pointing arrow extends from the bottom-right corner of the matrix, pointing to the text 'base case,' indicating that the bottom row represents a base case in some algorithm or process. The arrangement suggests a tabular representation of data or a state space, possibly related to dynamic programming or a similar computational technique where the matrix entries might represent intermediate results or values.

As we can see, the last row and last column are set to 0 for our base cases.

Populating the DP table
We populate the DP table starting from the smallest subproblems (excluding the base cases). Specifically, we begin by populating dp[len(s1) - 1][len(s2) - 1], which considers the LCS of only the last character of each string. From there, we iteratively populate the DP table in reverse order, moving backward through the table until we reach cell (0, 0).

Image represents a table visualizing a string matching algorithm, possibly demonstrating the operation of a finite automaton or similar pattern-matching technique.  The table is organized with rows labeled `s1` (representing a pattern string) and numbered 0-6, and columns labeled `s2` (representing a text string) and numbered 0-5, corresponding to characters 'a', 'e', 'b', 'a', 'b', and '''.  Each row in `s1` contains a character ('a', 'c', 'a', 'b', 'a', 'c', ''') representing the pattern.  Horizontal orange arrows connect cells in `s1` to cells in `s2`. These arrows indicate a match between a character in the pattern (`s1`) and a character in the text (`s2`). The arrows point from right to left, suggesting a left-to-right scan of the text string. The rightmost cell of the top row in `s1` is labeled 'end', indicating the end of a successful match. The rightmost cell of the bottom row in `s1` is labeled 'start', indicating the beginning of the pattern matching process.  The rightmost column contains zeros, possibly representing a state or a flag indicating whether a match has been found at that point.  The numbers in the top row and leftmost column represent indices for the characters in the strings.

Once the DP table is populated, we return dp[0][0], which stores the length of the LCS between the entire first string and the entire second string.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

def longest_common_subsequence(s1: str, s2: str) -> int:
    # Base case: Set the last row and last column to 0 by initializing the entire DP
    # table with 0s.
    dp = [[0] * (len(s2) + 1) for _ in range(len(s1) + 1)]
    # Populate the DP table.
    for i in range(len(s1) - 1, -1, -1):
        for j in range(len(s2) - 1, -1, -1):
            # If the characters match, the length of the LCS at 'dp[i][j]' is
            # 1 + the LCS length of the remaining substrings.
            if s1[i] == s2[j]:
                dp[i][j] = 1 + dp[i + 1][j + 1]
            # If the characters don't match, the LCS length at 'dp[i][j]' can be found
            # by either:
            # 1. Excluding the current character of s1.
            # 2. Excluding the current character of s2.
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j + 1])
    return dp[0][0]
function longestCommonSubsequence(s1, s2) {
  // Base case: Set the last row and last column to 0 by initializing the entire DP
  // table with 0s.
  const dp = Array.from({ length: s1.length + 1 }, () => new Array(s2.length + 1).fill(0));
  // Populate the DP table.
  for (let i = s1.length - 1; i >= 0; i--) {
    for (let j = s2.length - 1; j >= 0; j--) {
      // If the characters match, the length of the LCS at 'dp[i][j]' is
      // 1 + the LCS length of the remaining substrings.
      if (s1[i] === s2[j]) {
        dp[i][j] = 1 + dp[i + 1][j + 1];
      } else {
        // If the characters don't match, the LCS length at 'dp[i][j]' can be found
        // by either excluding the current character of s1 or s2.
        dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
      }
    }
  }
  return dp[0][0];
}
public int longestCommonSubsequence(String s1, String s2) {
    // Base case: Set the last row and last column to 0 by initializing the entire DP
    // table with 0s.
    int[][] dp = new int[s1.length() + 1][s2.length() + 1];
    // Populate the DP table.
    for (int i = s1.length() - 1; i >= 0; i--) {
        for (int j = s2.length() - 1; j >= 0; j--) {
            // If the characters match, the length of the LCS at 'dp[i][j]' is
            // 1 + the LCS length of the remaining substrings.
            if (s1.charAt(i) == s2.charAt(j)) {
                dp[i][j] = 1 + dp[i + 1][j + 1];
            } else {
                // If the characters don't match, the LCS length at 'dp[i][j]' can be found
                // by either excluding the current character of s1 or s2.
                dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
            }
        }
    }
    return dp[0][0];
}

Complexity Analysis

Time complexity: The time complexity of longest_common_subsequence is O(m⋅n)O(m\cdot n), where mm and nn denote the lengths of s1 and s2, respectively. This is because each cell in the DP table is populated once.

Space complexity: The space complexity is O(m⋅n)O(m\cdot n) since we're maintaining a 2D DP table that has (m+1)(n+1)(m+1)(n+1) elements.

Optimization

We can optimize our solution by noticing that for each cell in the DP table, we only need to access the cell below it, the cell to its right, and the bottom-right diagonal cell.

Image represents a 2x2 grid illustrating a dynamic programming (DP) state transition.  The grid is divided into four cells, each representing a subproblem's solution denoted by `dp[i][j]`, `dp[i][j+1]`, `dp[i+1][j]`, and `dp[i+1][j+1]`, where `i` and `j` are indices.  The shaded cells, `dp[i+1][j]` and `dp[i+1][j+1]`, represent solutions to subproblems with incremented indices.  Grey arrows indicate the flow of information, showing that the solution to the current subproblem `dp[i][j]` is derived from the solutions of the subproblems `dp[i][j+1]` and `dp[i+1][j]`.  Specifically, `dp[i][j]` receives input from `dp[i][j+1]` and `dp[i+1][j]`, suggesting a recursive relationship where the optimal solution for `dp[i][j]` depends on the optimal solutions of its neighboring subproblems to the right (`dp[i][j+1]`) and below (`dp[i+1][j]`).  The arrow from `dp[i+1][j+1]` to `dp[i][j]` is less prominent, suggesting a less direct or conditional dependency.
  • To get the cell below it, we only need access to the row below.
  • To get the cell to its right, we just need to look at the cell to the right of the current cell.
  • To get the bottom-right diagonal cell, we also only need access to the row below.

Therefore, we only need to maintain two rows:

  • curr_row: the current row being populated.
  • prev_row: the row below the current row.
Image represents a table illustrating a dynamic programming approach, likely for a sequence alignment or similar problem.  The table, labeled 's2' at the top, is a 6x6 matrix with rows labeled 0 through 6 and columns labeled 'a', 'e', 'b', 'a', 'b', and '''.  The rows 1 through 6 represent previous calculations (s1 is mentioned to the left, suggesting a sequence of calculations).  The leftmost column labels rows as 'curr_row' (row 0) and 'prev_row' (row 1), indicating the current and previous rows used in the calculation.  Numerical values populate the cells, representing intermediate results.  Arrows within the table show the flow of information, with upward arrows pointing from row 1 to row 0, indicating that values in row 1 are used to compute values in row 0.  A horizontal arrow points from the last cell of row 0 to the right, with accompanying text explaining that to calculate row 0, only row 1 is needed.  The overall structure suggests a bottom-up computation where each row depends on the previous row, building up to the final result (likely in row 0).

This effectively reduces the space complexity to O(n)O(n). Below is the optimized code:

def longest_common_subsequence_optimized(s1: str, s2: str) -> int:
    # Initialize 'prev_row' as the DP values of the last row.
    prev_row = [0] * (len(s2) + 1)
    for i in range(len(s1) - 1, -1, -1):
        # Set the last cell of 'curr_row' to 0 to set the base case for
        # this row. This is done by initializing the entire row to 0.
        curr_row = [0] * (len(s2) + 1)
        for j in range(len(s2) - 1, -1, -1):
            # If the characters match, the length of the LCS at
            # 'curr_row[j]' is 1 + the LCS length of the remaining
            # substrings ('prev_row[j + 1]').
            if s1[i] == s2[j]:
                curr_row[j] = 1 + prev_row[j + 1]
            # If the characters don't match, the LCS length at
            # 'curr_row[j]' can be found by either:
            # 1. Excluding the current character of s1 ('prev_row[j]').
            # 2. Excluding the current character of s2
            # ('curr_row[j + 1]').
            else:
                curr_row[j] = max(prev_row[j], curr_row[j + 1])
            # Update 'prev_row' with 'curr_row' values for the next
            # iteration.
        prev_row = curr_row
    return prev_row[0]
function longestCommonSubsequenceOptimized(s1, s2) {
  // Initialize 'prevRow' as the DP values of the last row.
  let prevRow = new Array(s2.length + 1).fill(0);
  for (let i = s1.length - 1; i >= 0; i--) {
    // Set the last cell of 'currRow' to 0 to set the base case for this row.
    const currRow = new Array(s2.length + 1).fill(0);
    for (let j = s2.length - 1; j >= 0; j--) {
      if (s1[i] === s2[j]) {
        currRow[j] = 1 + prevRow[j + 1];
      } else {
        currRow[j] = Math.max(prevRow[j], currRow[j + 1]);
      }
    }
    prevRow = currRow;
  }
  return prevRow[0];
}
public int longestCommonSubsequenceOptimized(String s1, String s2) {
    // Initialize 'prevRow' as the DP values of the last row.
    int[] prevRow = new int[s2.length() + 1];
    for (int i = s1.length() - 1; i >= 0; i--) {
        int[] currRow = new int[s2.length() + 1];
        for (int j = s2.length() - 1; j >= 0; j--) {
            if (s1.charAt(i) == s2.charAt(j)) {
                currRow[j] = 1 + prevRow[j + 1];
            } else {
                currRow[j] = Math.max(prevRow[j], currRow[j + 1]);
            }
        }
        prevRow = currRow;
    }
    return prevRow[0];
}
CHAPTER 98

Longest Palindrome in a String

Return the longest palindromic substring within a given string.

Example:

Example
Input: s = 'abccbaba'
Output: 'abccba'

Intuition

A naive solution to this problem is to check every possible substring and save the longest palindrome found. It takes approximately O(n2)O(n^2) time to generate all substrings for a string of length nn, and for each of these substrings, it takes O(n)O(n) time to check if it’s a palindrome. This results in an overall time complexity of O(n3)O(n^3), which is expensive. So, we should consider a more efficient approach.

Determining if a substring is a palindrome
An important observation is that palindromes contain shorter palindromes within them. We can observe this in the string “rotator”, for example:

Image represents a visual depiction of a palindrome check.  The top row displays the input string 'rotor', with the letters 'r', 'o', 't', 'a', 't', 'o', and 'r' arranged sequentially from left to right.  The letters 'o', 't', 'a', 't', and 'o' are shown in light blue, suggesting they are being processed or are part of a subset. A horizontal gray line underlines the light blue letters, visually separating them from the outer 'r's. A downward-pointing gray arrow extends from the center of this line, pointing to the word 'palindrome' written below in a gray font. This arrangement illustrates that the input string 'rotor' is being checked for palindromic properties, with the central portion ('otato') being the focus of the check, and the result of the check (that it is a palindrome) is indicated below.

This highlights a subproblem: to identify if a string is a palindrome, we can check if its inner substring is also a palindrome.

More specifically, a substring from index i to j is a palindrome given two conditions:

  1. Its first and last characters are the same (s[i] == s[j]).

  2. The substring from index i + 1 to j - 1 (s[i + 1 : j - 1]) is also a palindrome.

Image represents a visual explanation of a palindrome-checking algorithm.  The core element is a string 'rotor' displayed in light blue, representing the input string 's'.  Two orange boxes labeled 'i' and 'j' act as pointers, initially pointing to the first ('r') and last ('r') characters of the string, respectively.  Dashed grey arrows indicate the flow of comparison:  'i' points downwards to the 'r' at the beginning, and 'j' points downwards to the 'r' at the end.  Above the string, 's[i] == s[j]' in orange text signifies the comparison of characters at indices 'i' and 'j'.  If the characters are equal (as they are in this case), the algorithm proceeds.  Below the string, 's[i + 1 : j - 1]' in orange and black text indicates the substring remaining after the initial comparison, which is 'ota to'.  This substring is declared to be a palindrome, implying that the algorithm recursively checks this substring for the same condition until either a mismatch is found or the substring becomes empty, confirming the original string's palindrome status.

The only situation where this isn’t true is when the substring is of length 0, 1, or 2, in which case there is no inner substring. We’ll discuss these cases later.

This problem has an optimal substructure since we solve a subproblem to obtain the solution to the main problem. This indicates that DP is well-suited for solving this problem. Let’s say that dp[i][j] tells us if the substring s[i : j] is a palindrome. Based on our earlier observations, we can say that:

dp[i][j] = True if s[i] == s[j] and dp[i + 1][j - 1]

Naturally, we need to specify the base cases for this formula.

Base cases
We mentioned earlier that substrings of length 1 and 2 have no inner substrings. In other words, there are no further subproblems to solve when determining if they are palindromes. As such, these substrings define our base cases:

  • Base case: All substrings of length 1 are palindromes. So, set dp[i][i] to true for all values of i.

    Image represents a diagram illustrating a step in a palindrome-finding algorithm.  The left side shows the input string 'a b c c b a b a', with each character underlined, indicating individual substrings of length one. A grey arrow points to the right, signifying a transformation or operation. The right side describes this operation: 'mark all length 1 substrings as palindromes'.  This indicates that the algorithm's current step involves identifying all single-character substrings (each letter in this case) within the input string and classifying them as palindromes because a single character is inherently a palindrome.  There are no URLs or parameters present; the diagram solely focuses on the conceptual step of the algorithm.
  • Base case: Substrings of length 2 are palindromes if both its characters are the same. So, set dp[i][i + 1] to true if s[i] == s[i + 1].

    Image represents a string manipulation process.  The input is the string 'abccbba', displayed with light gray brackets under each character, visually dividing it into individual characters.  A thicker, black bracket highlights the substring 'cc'. A rightward-pointing arrow separates the input string from a textual description of the operation. This description states the goal: to 'mark all length 2 substrings where both characters are the same as palindromes'.  The highlighted 'cc' substring exemplifies a length-2 substring fulfilling this condition, as both characters are identical, making it a palindrome.  The image demonstrates a step in identifying palindromic substrings within a larger string.

With the base cases set up, let’s discuss how to populate the rest of the DP table.

Populating the DP table
Determining if longer substrings are palindromes depends on the DP values of shorter substrings. Therefore, we should populate the DP table for the shortest substrings first, starting with checking all substrings of length 3 and working our way up to length n, where n denotes the length of the input string.

Keeping track of the longest palindromic substring
As we populate the DP table, we also need to keep track of the longest palindromic substring encountered. We use two variables for this: start_index and max_len.

  • start_index: stores the starting index of the longest palindromic substring found so far
  • max_len: stores the length of the longest palindromic substring found so far

When we find a new, longer palindromic substring, we update these two variables. By the end, start_index and max_len will indicate the position and length of the longest palindrome.

Finally, return the substring using s[start_index : start_index + max_len].

This solution is a classic example of “interval DP,” which is used to solve optimization problems involving subproblems over intervals of data. In this case, the “intervals” are effectively the substrings between indexes i and j.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

def longest_palindrome_in_a_string(s: str) -> str:
    n = len(s)
    if n == 0:
        return ""
    dp = [[False] * n for _ in range(n)]
    max_len = 1
    start_index = 0
    # Base case: a single character is always a palindrome.
    for i in range(n):
        dp[i][i] = True
    # Base case: a substring of length two is a palindrome if both characters are the
    # same.
    for i in range(n - 1):
        if s[i] == s[i + 1]:
            dp[i][i + 1] = True
            max_len = 2
            start_index = i
    # Find palindromic substrings of length 3 or greater.
    for substring_len in range(3, n + 1):
        # Iterate through each substring of length 'substring_len'.
        for i in range(n - substring_len + 1):
            j = i + substring_len - 1
            # If the first and last characters are the same, and the inner substring
            # is a palindrome, then the current substring is a palindrome.
            if s[i] == s[j] and dp[i + 1][j - 1]:
                dp[i][j] = True
                max_len = substring_len
                start_index = i
    return s[start_index : start_index + max_len]
function longestPalindromeInAString(s) {
  const n = s.length;
  if (n === 0) return "";
  const dp = Array.from({ length: n }, () => new Array(n).fill(false));
  let maxLen = 1;
  let startIndex = 0;
  // Base case: a single character is always a palindrome.
  for (let i = 0; i < n; i++) {
    dp[i][i] = true;
  }
  // Base case: a substring of length two is a palindrome if both characters are the same.
  for (let i = 0; i < n - 1; i++) {
    if (s[i] === s[i + 1]) {
      dp[i][i + 1] = true;
      maxLen = 2;
      startIndex = i;
    }
  }
  // Find palindromic substrings of length 3 or greater.
  for (let substringLen = 3; substringLen <= n; substringLen++) {
    for (let i = 0; i <= n - substringLen; i++) {
      const j = i + substringLen - 1;
      if (s[i] === s[j] && dp[i + 1][j - 1]) {
        dp[i][j] = true;
        maxLen = substringLen;
        startIndex = i;
      }
    }
  }
  return s.substring(startIndex, startIndex + maxLen);
}
public String longestPalindromeInAString(String s) {
    int n = s.length();
    if (n == 0) return "";
    boolean[][] dp = new boolean[n][n];
    int maxLen = 1;
    int startIndex = 0;
    // Base case: a single character is always a palindrome.
    for (int i = 0; i < n; i++) {
        dp[i][i] = true;
    }
    // Base case: a substring of length two is a palindrome if both characters are the same.
    for (int i = 0; i < n - 1; i++) {
        if (s.charAt(i) == s.charAt(i + 1)) {
            dp[i][i + 1] = true;
            maxLen = 2;
            startIndex = i;
        }
    }
    // Find palindromic substrings of length 3 or greater.
    for (int substringLen = 3; substringLen <= n; substringLen++) {
        for (int i = 0; i <= n - substringLen; i++) {
            int j = i + substringLen - 1;
            if (s.charAt(i) == s.charAt(j) && dp[i + 1][j - 1]) {
                dp[i][j] = true;
                maxLen = substringLen;
                startIndex = i;
            }
        }
    }
    return s.substring(startIndex, startIndex + maxLen);
}

Complexity Analysis

Time complexity: The time complexity of longest_palindrome_in_a_string is O(n2)O(n^2) because each cell of the n⋅nn\cdot n DP table is populated once.

Space complexity: The space complexity is O(n2)O(n^2) because we're maintaining a DP table that has n2n^2 elements. Note, the output string is not considered in the space complexity.

Optimized Approach

An important observation of the previous approach is that the base cases represent the centers of palindromes. Understanding this, another possible strategy is to expand outward from each base case to find the longest palindrome.

There are two types of base cases: single-character substrings and two-character substrings. We can treat each as the center of potential palindromes, and expand outward from each of them to find these palindromes.

We can do this by setting left and right pointers at the center, expanding them outward – as long as the characters at both pointers match – and stopping once they can no longer form a larger palindrome. Here are two examples of this:

The image represents a visual explanation of a recursive algorithm, likely for string manipulation or palindrome checking.  The left side shows an input string 'a b c c b a b a' with a single character 'a' highlighted in light blue. Two orange boxes labeled 'left' and 'right' point downwards to this central 'a', indicating the initial state of pointers or indices. A curved grey arrow points from the 'a' upwards and is labeled 'single-character base case', suggesting this is the termination condition of the recursion.  A rightward arrow separates this initial state from the right side, which depicts the result of a recursive step. The right side shows the same string, but now the characters 'b a b' are highlighted in light blue, representing a sub-string processed by the algorithm.  Two orange boxes labeled 'left' and 'right' point to the ends of this highlighted substring. A light blue double-headed arrow connects the left and right ends of the highlighted substring, visually representing the comparison or processing of this sub-string. The overall diagram illustrates the progression of a recursive algorithm, starting from a base case of a single character and recursively processing larger substrings until a condition is met.
Image represents a visual explanation of a coding pattern, likely related to string manipulation or palindrome checking.  The left side shows an initial string 'a b c c b a b a' with two central characters 'c c' highlighted in a light-blue rectangle.  Orange rectangular boxes labeled 'left' and 'right' point downwards, indicating pointers or indices focusing on these central characters. A curved grey arrow points from the rectangle upwards, labeled 'two-character base case,' suggesting this is a starting point for an algorithm. A large right arrow separates the before and after states. The right side shows the same string, but now the entire string 'a b c c b a b a' is highlighted in a single light-blue rectangle, implying a merging or expansion process. Light-blue arrows underneath indicate a bidirectional traversal or comparison across the merged string.  The 'left' and 'right' pointers from the left side are now positioned above the respective ends of the merged string, suggesting the algorithm's progression. The overall diagram illustrates a step-by-step process where a central portion of a string is initially identified and then expanded outwards until a condition (likely related to palindrome checking or string merging) is met.

All we need to do is keep track of the start index and length of the longest palindromic we find, just as in the previous approach.

This approach makes use of some of the information from the previous DP approach, while solving the problem using constant space.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Optimized Approach

from typing import Tuple
    
def longest_palindrome_in_a_string_expanding(s: str) -> str:
    n = len(s)
    start, max_len = 0, 0
    for center in range(n):
        # Check for odd-length palindromes.
        odd_start, odd_length = expand_palindrome(center, center, s)
        if odd_length > max_len:
            start = odd_start
            max_len = odd_length
        # Check for even-length palindromes.
        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]
    
# Expands outward from the center of a base case to identify the start index and
# length of the longest palindrome that extends from this base case.
def expand_palindrome(left: int, right: int, s: str) -> Tuple[int, int]:
    while left > 0 and right < len(s) - 1 and s[left - 1] == s[right + 1]:
        left -= 1
        right += 1
    return left, right - left + 1
function longestPalindromeInAStringExpanding(s) {
  const n = s.length;
  let start = 0, maxLen = 0;
  for (let center = 0; center < n; center++) {
    // Check for odd-length palindromes.
    const [oddStart, oddLength] = expandPalindrome(center, center, s);
    if (oddLength > maxLen) {
      start = oddStart;
      maxLen = oddLength;
    }
    // Check for even-length palindromes.
    if (center < n - 1 && s[center] === s[center + 1]) {
      const [evenStart, evenLength] = expandPalindrome(center, center + 1, s);
      if (evenLength > maxLen) {
        start = evenStart;
        maxLen = evenLength;
      }
    }
  }
  return s.substring(start, start + maxLen);
}

// Expands outward from the center of a base case to identify the start index and
// length of the longest palindrome that extends from this base case.
function expandPalindrome(left, right, s) {
  while (left > 0 && right < s.length - 1 && s[left - 1] === s[right + 1]) {
    left--;
    right++;
  }
  return [left, right - left + 1];
}
public String longestPalindromeInAStringExpanding(String s) {
    int n = s.length();
    int start = 0, maxLen = 0;
    for (int center = 0; center < n; center++) {
        // Check for odd-length palindromes.
        int[] odd = expandPalindrome(center, center, s);
        if (odd[1] > maxLen) {
            start = odd[0];
            maxLen = odd[1];
        }
        // Check for even-length palindromes.
        if (center < n - 1 && s.charAt(center) == s.charAt(center + 1)) {
            int[] even = expandPalindrome(center, center + 1, s);
            if (even[1] > maxLen) {
                start = even[0];
                maxLen = even[1];
            }
        }
    }
    return s.substring(start, start + maxLen);
}

// Expands outward from the center of a base case to identify the start index and
// length of the longest palindrome that extends from this base case.
private int[] expandPalindrome(int left, int right, String s) {
    while (left > 0 && right < s.length() - 1 && s.charAt(left - 1) == s.charAt(right + 1)) {
        left--;
        right++;
    }
    return new int[]{left, right - left + 1};
}

Complexity Analysis

Time complexity: The time complexity of longest_palindrome_in_a_string_expanding is O(n2)O(n^2) because expanding from the center of a base case takes up to O(n)O(n) time. Doing this for each base case takes O(n2)O(n^2) time.

Space complexity: The space complexity is O(1)O(1) since we aren’t maintaining any auxiliary data structures. The output string is not considered in the space complexity.

Manacher’s Algorithm

Aside from the above quadratic-time solutions, there's a more efficient algorithm for finding the longest palindromic substring: Manacher's Algorithm. This algorithm runs in O(n)O(n) time.

However, Manacher's Algorithm's specialized nature makes it less common in coding interviews. Most interviewers want to see solutions that show you deeply understand basic concepts and have strong problem-solving skills. They are less interested in tricky solutions that would be unlikely for candidates to come up with during interviews.

For those interested in learning more about Manacher's Algorithm, check out [1] and [2].

CHAPTER 99

Maximum Subarray Sum

Given an array of integers, return the sum of the subarray with the largest sum.

Example:

Example
Input: nums = [3, 1, -6, 2, -1, 4, -9]
Output: 5

Explanation: subarray [2, -1, 4] has the largest sum of 5.

Constraints:

  • The input array contains at least one element.

Intuition

Brute force approaches to this problem involve calculating the sum of every possible subarray. This would take at least O(n2)O(n^2) time, where nn denotes the length of the array. So, let’s consider alternative methods.

The challenge with this problem lies in the presence of negative numbers. If the array consisted entirely of non-negative numbers, the answer would simply be the sum of the entire array.

To find the maximum sum given the presence of negative numbers, let’s try keeping track of the sum of a contiguous subarray, starting at index 0.

As this subarray expands and we add each number to the running sum, we’ll need to decide whether to continue with the current subarray’s sum, or start a new subarray beginning with the current element. To understand how we might make such a decision, let’s dive into an example.

Consider the following input array:

Image represents a one-dimensional array or list of integers.  The array is enclosed in square brackets `[` and `]`.  The integers within the array are `3`, `1`, `-6`, `2`, `-1`, `4`, and `-9`, each separated by a space.  Below each integer, in a lighter gray font, are the corresponding indices of the array, starting from 0 and incrementing sequentially: `0`, `1`, `2`, `3`, `4`, `5`, and `6`.  There are no visible connections or information flow between the elements other than their sequential arrangement within the array structure, implicitly indicating that each integer is accessible via its index.

The first two values of the array are positive, so we can continue expanding the current subarray by adding these to our sum (curr_sum), initialized at 0:

Image represents a visual depiction of an array and a variable update.  An array `num` is shown, containing the integer elements [3, 1, -6, 2, -1, 4, -9], with each element's index displayed below it (0 through 6).  A downward-pointing orange arrow labeled 'num' points to the first element of the array, the number 3, which is highlighted in a peach-colored circle.  To the right, a dashed-line box shows the variable `curr_sum` being updated. The expression `curr_sum += 3` indicates that 3 is added to the current value of `curr_sum`, resulting in a new value of 3, as shown by `curr_sum = 3`.  The diagram illustrates the process of accessing and using a specific element (the first element) from an array within a calculation.
Image represents a visual depiction of an array and an operation performed on it.  A numerical array `[3, 1, -6, 2, -1, 4, -9]` is shown, with each element indexed from 0 to 6 below it.  An orange arrow labeled 'num' points downwards to the element at index 1, which is the number 1, highlighting it as the current element being processed.  To the right, a dashed-line box shows the operation being performed: `curr_sum += 1`, indicating that 1 is being added to a variable named `curr_sum`. Below this, `= 4` shows the resulting value of `curr_sum` after the addition.  The diagram illustrates a step in an iterative process, likely summing the elements of the array, with the highlighted element contributing to the running sum.

When we reach index 2, we land on the first negative number (-6). Adding it to the current sum gives us a negative sum of -2.

What should we do now? If we restart the subarray at this point, the new subarray will start with a sum of -6, which is less than the current sum of -2.

Image represents a visual depiction of a step in an algorithm, likely involving array processing and sum calculation.  A peach-colored highlighted section of an array `[3 1 -6 2 -1 4 -9]` is shown, with the element `-6` at index 2 indicated by an orange downward-pointing arrow labeled 'num'. This suggests that the algorithm is currently processing the element at index 2. To the right, a light gray box represents a variable named `curr_sum`.  Inside this box, the calculation `curr_sum += -6` is shown, indicating that -6 is being added to the current value of `curr_sum`. Below this, `= -2` shows the resulting value of `curr_sum` after the addition. A rightward-pointing arrow labeled 'larger' connects to the `-2`, suggesting that this value is being compared or used to determine a 'larger' value in a subsequent step of the algorithm.  The array indices are displayed below the array elements, ranging from 0 to 6.
Image represents a step-by-step illustration of a coding pattern, likely related to finding the minimum value in an array.  The top shows the word 'num' in orange, with a downward arrow pointing to a peach-colored circle containing '-6'. This circle is positioned within a larger array representation: `[3, 1, -6, 2, -1, 4, -9]`, with each element's index (0-6) displayed below it. The '-6' in the circle is highlighted, indicating the current element being processed. To the right, a dashed-line box displays 'curr_sum = -6', representing the current minimum sum found so far. An arrow labeled 'smaller' points from the right to the 'curr_sum' box, indicating that the value '-6' is being compared and potentially updated as the minimum.  The overall diagram visually demonstrates the iterative process of comparing each element of the array to the current minimum, updating the minimum if a smaller value is encountered.

Therefore, it is better to continue with the current subarray for now.


At index 3, we reach another important decision point:

  • If we include 2 in the current subarray, its sum increases to 0:
Image represents a visual depiction of a coding pattern, likely illustrating a step within an algorithm.  A peach-colored rectangular segment highlights a portion of a numerical array: `[3 1 -6 2 -1 4 -9]`, with indices 0 through 6 displayed below each element. An orange arrow labeled 'num' points downwards onto the highlighted segment, indicating that the variable 'num' is referencing or operating on this section of the array (specifically elements at indices 0, 1, 2, and 3).  To the right, a dashed-line box represents a variable named 'curr_sum'. Inside this box, the expression 'curr_sum += 2' indicates that 2 is being added to the current value of 'curr_sum'. Below this, '= 0' shows the current value of 'curr_sum' is 0. A grey arrow points from the right to the 'curr_sum' box, labeled 'smaller', suggesting that the value of 'smaller' (not shown) is influencing or updating 'curr_sum'. The overall diagram suggests a process where a section of an array is being processed, potentially to find a minimum or maximum value, and a running sum ('curr_sum') is being updated based on this processing.
  • If we restart the subarray at this index, we begin a new subarray of sum 2:
Image represents a snapshot of an algorithm's execution, likely demonstrating a coding pattern related to array traversal and sum calculation.  A numerical array `[3, 1, -6, 2, -1, 4, -9]` is displayed, with each element indexed from 0 to 6.  A light peach-colored circle highlights the element '2' at index 3, with an orange arrow labeled 'num' pointing to it, indicating the current element being processed.  To the right, a dashed-line box displays 'curr_sum = 2', representing the current sum calculated so far.  A rightward arrow connects this box to the word 'larger', suggesting that the algorithm is comparing the current sum with a larger value (not explicitly shown) as part of a larger process, possibly finding the largest contiguous subarray sum.  The overall arrangement visually depicts a step-by-step process where the algorithm iterates through the array, updating the `curr_sum` and comparing it to find a larger value.

It’s evident here that the better choice is to start tracking the sum of a new subarray, beginning at index 3.

As observed, for each number in the array during this process, there are two choices:

  1. Continue: Add the current number to the ongoing subarray sum (curr_sum + num).
  2. Restart: Begin keeping track of a new subarray starting with the current number, with an initial sum of num.

The best choice is the larger of the two: max(curr_sum + num, num).

Let’s apply this logic to the rest of the array:

Image represents a step-by-step illustration of a coding pattern, likely for finding the maximum contiguous subarray sum.  The left side shows an array `[3, 1, -6, 2, -1, 4, -9]` with indices 0 through 6 displayed below each element.  An orange arrow labeled 'num' points to the element '2' at index 3 and '-1' at index 4, indicating the current element being processed. A peach-colored rectangle highlights these two elements. The right side shows a calculation box detailing the algorithm's logic.  A curved arrow points from the text '(previous curr_sum value)' to the calculation box. Inside the box, the equation `curr_sum = max(curr_sum + num, num)` is shown, representing the update of the maximum sum so far.  Below, a substitution with `curr_sum = 2` and `num = -1` is shown, resulting in `max(2 + (-1), -1)`, which simplifies to `max(1, -1) = 1`. This demonstrates how the algorithm updates the maximum sum based on the current element and the previous maximum sum.
Image represents a visual explanation of a maximum subarray sum calculation.  A numerical array `[3, 1, -6, 2, -1, 4, -9]` is shown, with indices 0 through 6 displayed below each element.  A peach-colored rectangle highlights the element '2', '-1', and '4' within the array. An orange arrow labeled 'num' points downwards, indicating that the value '4' is currently being processed. To the right, a light gray box details the calculation: `curr_sum = max(curr_sum + num, num)`, where `curr_sum` represents the current maximum sum and `num` represents the current element (4). The calculation is broken down in subsequent lines: `= max(1 + 4, 4)` showing the substitution of values, and finally `= 5`, indicating that the current maximum sum is updated to 5.  The diagram illustrates a step in an algorithm that iterates through the array, updating the maximum sum based on the current element and the running sum.
Image represents a visual explanation of a maximum subarray sum calculation.  A numerical array `[3, 1, -6, 2, -1, 4, -9]` is shown, with each element indexed from 0 to 6.  A peach-colored rectangle highlights the element at index 3, the number `2`, which is labeled with an orange arrow pointing to it as 'num'.  To the right, a calculation is displayed showing the running maximum sum (`curr_sum`). The formula `curr_sum = max(curr_sum + num, num)` is presented, demonstrating how the current maximum sum is updated by taking the maximum between the current sum plus the current element (`num`) and the current element itself.  The calculation is then shown step-by-step:  `max(5 + (-9), -9)` which simplifies to `-4`, indicating that at this step, the maximum subarray sum is -4.  The indices below the array visually correspond to the array elements.

Now that we have a strategy to linearly track subarray sums, the only other thing to do is keep track of the largest value of curr_sum encountered. To do this, we can update a variable max_sum whenever we encounter a larger curr_sum value. Then, max_sum will be the answer to the problem.

Kadane’s algorithm
The algorithm described above is formally known as "Kadane's algorithm". Although it may not seem like it, Kadane's algorithm is actually a DP algorithm. What’s interesting is that we didn’t explicitly detect and solve subproblems to come up with this algorithm, like we typically do in DP. Instead, we solved it by linearly keeping track of a subarray sum and making decisions along the way.

So, to fully understand why this is a DP problem, let's explore how we would solve it using the traditional DP approach of breaking the problem into smaller subproblems, and solving them step-by-step. This is demonstrated in the next section of this explanation.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def maximum_subarray_sum(nums: List[int]) -> int:
    if not nums:
        return 0
    # Set the sum variables to negative infinity to ensure negative sums can be
    # considered.
    max_sum = current_sum = float('-inf')
    # Iterate through the array to find the maximum subarray sum.
    for num in nums:
        # Either add the current number to the existing running sum, or start a new
        # subarray at the current number.
        current_sum = max(current_sum + num, num)
        max_sum = max(max_sum, current_sum)
    return max_sum
function maximumSubarraySum(nums) {
  if (!nums || nums.length === 0) return 0;
  // Set the sum variables to negative infinity to ensure negative sums can be considered.
  let maxSum = -Infinity;
  let currentSum = -Infinity;
  // Iterate through the array to find the maximum subarray sum.
  for (const num of nums) {
    // Either add the current number to the existing running sum, or start a new
    // subarray at the current number.
    currentSum = Math.max(currentSum + num, num);
    maxSum = Math.max(maxSum, currentSum);
  }
  return maxSum;
}
public int maximumSubarraySum(int[] nums) {
    if (nums == null || nums.length == 0) return 0;
    int maxSum = nums[0];
    int currentSum = nums[0];
    // Iterate through the array to find the maximum subarray sum.
    for (int i = 1; i < nums.length; i++) {
        currentSum = Math.max(currentSum + nums[i], nums[i]);
        maxSum = Math.max(maxSum, currentSum);
    }
    return maxSum;
}

Complexity Analysis

Time complexity: The time complexity of maximum_subarray_sum is O(n)O(n) because we iterate through each element of the input array once.

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

Intuition - DP

Let’s discuss how we would approach this problem as we did with other DP problems in this chapter.

An important observation is that every possible subarray ends at a certain index. This inversely means each index signifies the end of several subarrays, one of which will have the maximum subarray sum (shortened to “max subarray” moving forward) ending at that index.

For example, we can see the max subarray that ends at index 3 below by considering all subarrays ending at index 3:

Image represents a numerical array depicted as `[3 1 -6 2 -1 4 -9]`, enclosed in square brackets.  A peach-colored rectangle highlights the first four elements (3, 1, -6, 2) of this array.  Beneath the array, numbers 0 through 6 are displayed, acting as indices corresponding to each element's position within the array.  To the right of the array, the text 'sum = 0' is shown in orange, indicating that the sum of all elements within the array equals zero.  There are no URLs or parameters present in the image; the image solely illustrates an array and its sum.
Image represents a numerical array depicted within square brackets `[]`, showing a sequence of numbers: 3, 1, -6, 2, -1, 4, -9.  A light peach-colored rectangle highlights the sub-array [1, -6, 2].  Beneath the array, numbers 0 through 6 are displayed, acting as indices corresponding to each element's position within the array. To the right of the array, the text 'sum = -3' is shown in orange, indicating the sum of all the numbers in the array is -3.  There is no explicit connection shown between the highlighted sub-array and the sum calculation; the highlighting simply draws attention to a specific portion of the array.
Image represents a numerical array depicted within square brackets `[]`, containing the elements 3, 1, -6, 2, -1, 4, and -9.  Each element is positioned sequentially, with indices 0 through 6 displayed below them in gray.  A peach-colored oval highlights the elements -6 and 2, suggesting a focus or operation on these specific values. To the right of the array, the text 'Sum = -4' is displayed in orange, indicating the sum of all elements within the array is -4.  The arrangement visually demonstrates the calculation of the sum of the elements in the array, with the highlighted section possibly representing a sub-calculation or a specific part of the summation process.
Image represents a visual depiction of a maximum subarray sum algorithm.  A numerical array `[3, 1, -6, 2, -1, 4, -9]` is shown, with each element's index displayed below it (0 through 6).  The element at index 3, the number '2', is highlighted with a peach-colored circle.  To the right, a green rectangular box displays the result 'sum = 2', indicating that the maximum contiguous subarray sum within the given array is 2. The word 'max' is written in green above the result box, clarifying that the displayed value represents the maximum sum.  The highlighted '2' visually suggests that this element is part of, or possibly the maximum subarray itself, although the algorithm's exact steps aren't explicitly shown. The diagram focuses on presenting the input array and the final result of the maximum subarray sum calculation.

So, how can we find the max subarray that ends at each index? Consider the last index of the array:

Image represents a visual depiction of a subproblem within a dynamic programming approach, likely to find the maximum subarray sum.  A rectangular box contains a numerical array `[3, 1, -6, 2, -1, 4, -9]` with indices 0 through 6 displayed below each element.  A small square labeled 'i' is positioned above the array, with a downward arrow indicating input to the array. To the right of the array, the text 'max_subarray(i) = ?' is displayed, posing a question about the maximum subarray sum up to index 'i'. The overall structure suggests an illustration of a single step in an algorithm where the input 'i' (likely representing an index) determines a subarray, and the question mark indicates the algorithm's goal of finding the maximum sum within that subarray.

One thing we know for sure is the max subarray ending at this index will definitely include the value at this index. We just need to determine if there are any elements to the left that also contribute to this max subarray. In other words, we need to find the max subarray that ends right before the last index:

Image represents a visual depiction of a step in a dynamic programming algorithm, likely for finding the maximum sum subarray.  A rectangular box displays an array of integers: [3, 1, -6, 2, -1, 4, -9], with indices 0 through 6 displayed below each element.  A small square labeled 'i' points down to the element -9 at index 6, indicating the current index being processed. To the right, the expression `max_subarray(i - 1) + (-9)` is shown, illustrating the recursive or iterative step of the algorithm. This formula suggests that the maximum subarray sum ending at index `i` is calculated by adding the maximum subarray sum ending at the previous index (`i - 1`) to the value at the current index (`-9`).  The arrow from 'i' to -9 visually connects the index to the element used in the calculation, demonstrating the flow of information within the algorithm. The peach-colored highlighting of -9 emphasizes its role in the current calculation step.

Another thing to consider is the possibility that max_subarray(i - 1) is negative. This would mean the max subarray should only consist of -9, as a further negative contribution will only decrease the sum. Therefore, the formula becomes:

max_subarray(i) = max(max_subarray(i - 1) + nums[i], nums[i])

As we see, this is a recurrence relation that takes advantage of an optimal substructure, where the max subarray at the current index depends on the max subarray at the previous index.

This indicates we can solve this problem using DP. Translating the above recurrence relation to a DP formula gives us:

dp[i] = max(dp[i - 1] + nums[i], nums[i])

Now, let’s consider what the base case for this problem is.

Base case
The simplest subproblem occurs when we consider only the first element of the array (i.e., when i = 0). When there’s only one element, there’s only one subarray. Therefore, we can set dp[0] to nums[0] as our base case.

Populating the DP array
With the base case established, we can populate the rest of the DP array. Starting from index 1 and going up to index n - 1, we calculate the maximum subarray sum ending at each index using the aforementioned recurrence relation.

As we populate the DP array, we need to keep track of the maximum value in the DP array, max_sum, representing the largest sum of any subarray within the entire array. By the time we finish populating the DP array, we can just return max_sum.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - DP

from typing import List
    
def maximum_subarray_sum_dp(nums: List[int]) -> int:
    n = len(nums)
    if n == 0:
        return 0
    dp = [0] * n
    # Base case: the maximum subarray sum of an array with just one element is that
    # element.
    dp[0] = nums[0]
    max_sum = dp[0]
    # Populate the rest of the DP array.
    for i in range(1, n):
        # Determine the maximum subarray sum ending at the current index.
        dp[i] = max(dp[i - 1] + nums[i], nums[i])
        max_sum = max(max_sum, dp[i])
    return max_sum
function maximumSubarraySumDp(nums) {
  const n = nums.length;
  if (n === 0) return 0;
  const dp = new Array(n).fill(0);
  // Base case: the maximum subarray sum of an array with just one element is that element.
  dp[0] = nums[0];
  let maxSum = dp[0];
  // Populate the rest of the DP array.
  for (let i = 1; i < n; i++) {
    // Determine the maximum subarray sum ending at the current index.
    dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
    maxSum = Math.max(maxSum, dp[i]);
  }
  return maxSum;
}
public int maximumSubarraySumDp(int[] nums) {
    int n = nums.length;
    if (n == 0) return 0;
    int[] dp = new int[n];
    // Base case: the maximum subarray sum of an array with just one element is that element.
    dp[0] = nums[0];
    int maxSum = dp[0];
    // Populate the rest of the DP array.
    for (int i = 1; i < n; i++) {
        // Determine the maximum subarray sum ending at the current index.
        dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
        maxSum = Math.max(maxSum, dp[i]);
    }
    return maxSum;
}

Complexity Analysis

Time complexity: The time complexity of maximum_subarray_sum_dp is O(n)O(n) since we iterate through nn elements of the DP array.

Space complexity: The space complexity is O(n)O(n) because we’re maintaining a DP array that contains nn elements.

Optimization

An important thing to note is that in the DP solution, we only ever need to access the previous value of the DP array (at i - 1) to calculate the current value (at i). This means we don’t need to store the entire DP array.

Instead, we can use a single variable to keep track of the current subarray sum and update this value to calculate the next subarray sum.

This approach reduces the space complexity to O(1)O(1). The adjusted implementation of this can be seen below:

from typing import List
    
def maximum_subarray_sum_dp_optimized(nums: List[int]) -> int:
    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
function maximumSubarraySumDpOptimized(nums) {
  const n = nums.length;
  if (n === 0) return 0;
  let currentSum = nums[0];
  let maxSum = nums[0];
  for (let i = 1; i < n; i++) {
    currentSum = Math.max(nums[i], currentSum + nums[i]);
    maxSum = Math.max(maxSum, currentSum);
  }
  return maxSum;
}
public int maximumSubarraySumDpOptimized(int[] nums) {
    int n = nums.length;
    if (n == 0) return 0;
    int currentSum = nums[0];
    int maxSum = nums[0];
    for (int i = 1; i < n; i++) {
        currentSum = Math.max(nums[i], currentSum + nums[i]);
        maxSum = Math.max(maxSum, currentSum);
    }
    return maxSum;
}

As we can see, we’ve ended up with an optimized DP solution that’s nearly identical to the solution we came up with in the first approach: Kadane’s algorithm.

CHAPTER 100

0/1 Knapsack

You are a thief planning to rob a store. However, you can only carry a knapsack with a maximum capacity of cap units. Each item (i) in the store has a weight (weights[i]) and a value (values[i]).

Find the maximum total value of items you can carry in your knapsack.

Example:

Image represents a visual depiction of a knapsack problem, a classic optimization problem in computer science.  Four items are shown, each labeled 'item 0,' 'item 1,' 'item 2,' and 'item 3,' respectively. Each item has an associated value (price) represented by a dollar amount ($70, $50, $40, $0) displayed in a gray rectangle, and a weight (w) indicated numerically (5, 3, 4, 1). A larger, brown rectangular box labeled 'cap = 7' represents the knapsack with a weight capacity of 7. Inside the knapsack are items 1 and 2, indicating that these items have been selected.  The arrangement shows that items 1 and 2, with a combined weight of 7 (3 + 4), fill the knapsack to its capacity.  The other items are not included, suggesting they were not selected due to weight constraints or to optimize the value within the capacity limit.  The image illustrates a solution to the knapsack problem where the goal is to maximize the total value of items within the knapsack's weight constraint.
Example
Input: cap = 7, weights = [5, 3, 4, 1], values = [70, 50, 40, 10]
Output: 90

Explanation: The most valuable combination of items that can fit in the knapsack together are items 1 and 2 . These items have a combined value of 50 + 40 = 90 and a total weight of 3 + 4 = 7 , which fits within the knapsack's capacity.

Intuition

For each item, we have two choices: include it in the knapsack, or exclude it. This binary decision is why this classic problem is called “0/1 Knapsack.”

The brute force approach involves making this decision for every item. Since two choices can be made for each item, this results in 2⋅2⋅2…2=2n2\cdot 2\cdot 2…2=2n possible combinations of choices. As we can see, generating all possible combinations is inefficient.

A greedy solution that involves picking the most valuable items first isn’t a good choice either, as it doesn’t always lead to the optimal outcome, which we can see in the example below:

Image represents four rectangular boxes arranged horizontally, each representing an item with an associated price and weight.  The boxes are labeled 'item 0,' 'item 1,' 'item 2,' and 'item 3' above each respective box. Inside each box is a dollar amount representing the price: $70, $50, $40, and $10 respectively. Below each box, the weight (w) of the item is specified: w = 5, w = 3, w = 4, and w = 1 respectively.  There are no connections or information flow visually depicted between the boxes; they are presented independently as individual data points.
Image represents two knapsack problem solutions.  The top section displays 'option 1: take the most valuable item first' and 'option 2: optimal solution' as headings for two distinct approaches.  Each option is visually represented by a rounded-rectangle container labeled 'cap = 7,' indicating a knapsack capacity of 7 units. Inside each container, multiple smaller rectangles represent items. Each item rectangle shows an 'item' number, its value (in dollars), and its weight ('w'). Option 1 (brown container) shows item 3 ($10, w=1) and item 0 ($70, w=5), resulting in a total value of $80. Option 2 (green container), labeled as the optimal solution, contains item 1 ($50, w=3) and item 2 ($40, w=4), achieving a total value of $90.  The arrangement highlights the difference between a greedy approach (Option 1) and an optimal solution (Option 2) for the same knapsack problem.

So, let's approach this problem from a different angle. Consider the first item from the above item list (i = 0). What’s the most value we can attain with a knapsack of capacity 7, if we include this item? What about if we exclude this item? Let’s define the function knapsack(i, cap) to represent the maximum value achievable with items starting from index i and a knapsack capacity of cap:

Image represents a visual depiction of the 0/1 knapsack problem.  The top section shows four items, labeled `item 0`, `item 1`, `item 2`, and `item 3`, each with an associated value (in dollars: $70, $50, $40, $10 respectively) and weight (`w`: 5, 3, 4, 1 respectively).  An arrow points from a labeled box containing 'i' downwards to `item 0`, suggesting an index or iterator variable. Below this, a brown rectangular shape represents a knapsack with a capacity (`cap`) of 7. To the right, the text `knapsack(i = 0, cap = 7) = ?` indicates the problem being posed: finding the maximum value that can be put into the knapsack of capacity 7, starting with item 0.  The arrangement implies that the algorithm will iterate through the items, considering their values and weights to determine the optimal combination fitting within the knapsack's capacity.

We explore the implications of including or excluding this item, separately.

Including item i
Picking the first item gives a value of 70. This item weighs 5, so our knapsack now has a remaining capacity of 7 - 5 = 2. With an updated capacity of 2, what’s the optimal combination possible with the remaining items in our selection?

This question leads us to realize that if we determine the maximum value that can be obtained from the remaining items (starting from index 1) with a knapsack capacity of 2, we can find the solution:

Image represents a visual depiction of a knapsack problem's recursive solution.  At the top, a rectangular box displays four items labeled 'item 0,' 'item 1,' 'item 2,' and 'item 3,' each with an associated value (in dollars) and weight (w).  Item 0 has a value of $70 and weight 5; item 1 has $50 and weight 3; item 2 has $40 and weight 4; and item 3 has $10 and weight 1.  Below, a brown rectangular box represents the knapsack with a 'cap' of 7 (representing the maximum weight capacity).  Inside this knapsack, item 0 ($70, w=5) is already included, indicated by the green 'include' label and an arrow pointing from item 0 to the knapsack. A grey curved arrow originates from the knapsack and points to item 1, indicating consideration of this item.  Another grey curved arrow connects the knapsack to a rounded rectangle containing the expression '70 + knapsack(i = 1, cap = 7 - 5 = 2),' showing the recursive call to the knapsack function with the remaining capacity (2) after including item 0, and starting from item 1 (i=1).  A small square labeled 'i' above the items suggests an input or index.  The overall diagram illustrates the step-by-step process of the recursive algorithm, showing how the algorithm considers each item and its impact on the knapsack's capacity and total value.

We’ve identified that this case can be solved by solving a subproblem, which means we’re dealing with a problem that has an optimal substructure.

Therefore, we can generalize a recurrence relation for this case. Below, c denotes the remaining knapsack capacity we want to solve for (we give it a different name because it can be different to the initial value of cap):

If we include item i, the most value we can get is values[i] + knapsack(i + 1, c - weights[i])


Excluding item i
Now, let’s say we exclude item 0. This means our knapsack will maintain a capacity of 7. Here, the most value we can get is just from the maximum value from the rest of the items, with a knapsack of capacity 7:

Image represents a visual depiction of a knapsack problem's recursive step.  A rectangular box contains four items, each labeled 'item 0,' 'item 1,' 'item 2,' and 'item 3,' respectively.  Each item displays its value (in dollars: $70, $50, $40, $10) and weight (w: 5, 3, 4, 1). Item 0 is marked 'exclude' in red, indicating it's not considered in this step.  A brown rectangular shape representing a knapsack with a 'cap = 7' label signifies a weight capacity of 7 units. A grey rounded rectangle labeled 'knapsack(i = 1, cap = 7)' represents a recursive call to the knapsack function, starting from item 1 (index i=1) with the same capacity. A grey arrow points from this function call to item 2, indicating that the algorithm is currently evaluating whether to include item 2 in the knapsack given the remaining capacity.  Above the items, a downward-pointing arrow from a small square labeled 'i' points to item 0, suggesting an iterative approach where items are processed sequentially.

Again, this case can be solved by solving a subproblem:

If we exclude item i, the most value we can get is knapsack(i + 1, c).


Now that we’ve established the recurrence relations for both cases (including and excluding item i), we can combine them: the maximum value we can get from any selection of items is the larger value obtained from these two cases:

knapsack(i, c) = max(include item i, exclude item i) = max(values[i] + knapsack(i + 1, c - weights[i]), knapsack(i + 1, c))

One case we haven’t covered yet is the possibility the item does not fit in the knapsack. In this case, we have no choice but to exclude the item, resulting in a maximum value of knapsack(i + 1, c), as discussed earlier.

Dynamic programming
Given this problem has an optimal substructure, we can translate our recurrence relations directly into DP formulas:

if weights[i] <= c: \# If item i fits in a knapsack of capacity c.
    dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
else: \# If item i doesn't fit.
    dp[i][c] = dp[i + 1][c]
if (weights[i] <= c) { // If item i fits in a knapsack of capacity c.
  dp[i][c] = Math.max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c]);
} else { // If item i doesn't fit.
  dp[i][c] = dp[i + 1][c];
}
if (weights[i] <= c) { // If item i fits in a knapsack of capacity c.
    dp[i][c] = Math.max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c]);
} else { // If item i doesn't fit.
    dp[i][c] = dp[i + 1][c];
}

For clarity, there are two dimensions in our DP table:

  • One for the current item, i, represented by the rows of the DP table.
  • One for the current knapsack capacity, c, represented by the columns on the DP table.

With this in mind, let’s think about what the base cases should be.

Base cases
The simplest version of this problem is when there are no items in our selection, meaning the maximum value we can attain is 0. But which cells of the DP table represent this base case?

We know that when i = n - 1, only one item from the selection is being considered (where n denotes the total number of items):

Image represents a diagram illustrating a data structure, possibly an array or list, containing four items with associated values and weights.  Each item is labeled 'item 0,' 'item 1,' 'item 2,' and 'item 3,' respectively, positioned horizontally.  Each item displays a monetary value ($70, $50, $40, $10) within a light gray rectangular box. Below each monetary value, a weight ('w') is assigned (5, 3, 4, 1). A separate box above the structure shows the equation 'i = n - 1,' indicating a relationship between an index 'i' and the total number of items 'n'.  A downward arrow connects this box to 'item 3,' suggesting that 'i' points to the last item in the structure when 'n' represents the total number of items. The overall arrangement suggests a sequential access pattern, possibly related to an algorithm processing the items from the end.

This implies that when i = n, no items are being considered. Therefore, we can populate the DP table using dp[n][c] = 0 for all c. The reason i = 0 isn’t the base case is because i = 0 encapsulates all items starting from index 0.

Another subproblem we know the answer to is c = 0, since no items can fit in a knapsack of capacity 0. For this, we can populate the DP table using dp[i][0] = 0 for all i.

Let’s draw the DP table with just the base case values, to get a better idea of what this looks like:

Image represents a table or matrix labeled 'C', with rows indexed by 'i' (0 to 4) and columns indexed by 'c' (0 to 7).  The matrix is primarily empty, containing only zeros ('0') in the first column for rows 0 through 3, and in the last row (row 4) from column 0 to 7.  A thick black border surrounds the matrix. An arrow points from the label '(n) 4' to the right, indicating data flow. Another arrow points from the label 'base cases' upwards to the bottom row of the matrix (row 4), suggesting that the values in this row are initialized or provided as base cases for some computation.  The overall structure suggests a dynamic programming approach where the matrix is populated iteratively, starting from the base cases in the last row and potentially filling the rest of the matrix based on calculations involving previous rows and columns.

As shown, the first column and last row are set to 0 for the base cases.

Populating the DP table
We populate the DP table starting from the smallest subproblems (excluding base cases). Specifically, this means starting from row i = n - 1, where only the last item is considered, and ending at i = 0, where we consider all items. For each of these rows, we iterate through each possible knapsack capacity from c = 1 to c = cap.

Image represents a grid illustrating a coding pattern, possibly related to array manipulation or memory allocation.  The grid is labeled with 'i' along the vertical axis (rows 0-4) and 'c' along the horizontal axis (columns 0-7). Each cell contains a numerical value, predominantly '0'.  Three horizontal orange arrows, originating from cells (3,1), (2,1), and (1,1) respectively, extend across the grid to the 'end' label in column 7.  The arrow starting at (3,1) is additionally labeled 'start'.  The arrangement suggests a sequential process where data, represented by the arrows, flows from a 'start' point across the 'c' axis to an 'end' point. The '0' values in the cells might represent an initial state or default values before the process begins. The bottom row (i=4) shows only '0' values, suggesting it's either unused or represents a different state.

Once the DP table is populated, we return dp[0][cap], which stores the maximum value after all items and knapsack capacities are considered.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def knapsack(cap: int, weights: List[int], values: List[int]) -> int:
    n = len(values)
    # Base case: Set the first column and last row to 0 by initializing the entire DP
    # table to 0.
    dp = [[0 for x in range(cap + 1)] for x in range(n + 1)]
    # Populate the DP table.
    for i in range(n - 1, -1, -1):
        for c in range(1, cap + 1):
           # If the item 'i' fits in the current knapsack capacity, the maximum
           # value at 'dp[i][c]' is the largest of either:
            # 1. The maximum value if we include item 'i'.
            # 2. The maximum value if we exclude item 'i'.
            if weights[i] <= c:
                dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
            # If it doesn't fit, we have to exclude it.
            else:
                dp[i][c] = dp[i + 1][c]
    return dp[0][cap]
function knapsack(cap, weights, values) {
  const n = values.length;
  // Base case: Set the first column and last row to 0 by initializing the entire DP
  // table to 0.
  const dp = Array.from({ length: n + 1 }, () => new Array(cap + 1).fill(0));
  // Populate the DP table.
  for (let i = n - 1; i >= 0; i--) {
    for (let c = 1; c <= cap; c++) {
      // If the item 'i' fits in the current knapsack capacity, the maximum
      // value at 'dp[i][c]' is the largest of either including or excluding it.
      if (weights[i] <= c) {
        dp[i][c] = Math.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];
}
public int knapsack(int cap, int[] weights, int[] values) {
    int n = values.length;
    // Base case: Set the first column and last row to 0 by initializing the entire DP
    // table to 0.
    int[][] dp = new int[n + 1][cap + 1];
    // Populate the DP table.
    for (int i = n - 1; i >= 0; i--) {
        for (int c = 1; c <= cap; c++) {
            if (weights[i] <= c) {
                dp[i][c] = Math.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 complexity: The time complexity of knapsack is O(n⋅cap)O(n\cdot cap) because each cell of the DP table is populated once.

Space complexity: The space complexity is O(n⋅cap)O(n\cdot cap) because we're maintaining a DP table that stores (n+1)⋅(cap+1)(n+1)\cdot (cap+1) elements.

Optimization

We can optimize the solution by recognizing that, for each cell in the DP table, we only need access cells from the row below it.

Therefore, we only need to maintain two rows:

  • curr_row: the current row being populated.
  • prev_row: the row below the current row.

This effectively reduces the space complexity to O(cap)O(cap). Below is the optimized code:

from typing import List
    
def knapsack_optimized(cap: int, weights: List[int], values: List[int]) -> int:
    n = len(values)
    # Initialize 'prev_row' as the DP values of the row below the
    # current row.
    prev_row = [0] * (cap + 1)
    for i in range(n - 1, -1, -1):
        # Set the first cell of the 'curr_row' to 0 to set the base
        # case for this row. This is done by initializing the entire
        # row to 0.
        curr_row = [0] * (cap + 1)
        for c in range(1, cap + 1):
            # If item 'i' fits in the current knapsack capacity, the
            # maximum value at 'curr_row[c]' is the largest of either:
            # 1. The maximum value if we include item 'i'.
            # 2. The maximum value if we exclude item 'i'.
            if weights[i] <= c:
                curr_row[c] = max(values[i] + prev_row[c - weights[i]],prev_row[c])
            # If item 'i' doesn't fit, we exclude it.
            else:
                curr_row[c] = prev_row[c]
        # Set 'prev_row' to 'curr_row' values for the next iteration.
        prev_row = curr_row
    return prev_row[cap]
function knapsackOptimized(cap, weights, values) {
  const n = values.length;
  // Initialize 'prevRow' as the DP values of the row below the current row.
  let prevRow = new Array(cap + 1).fill(0);
  for (let i = n - 1; i >= 0; i--) {
    const currRow = new Array(cap + 1).fill(0);
    for (let c = 1; c <= cap; c++) {
      if (weights[i] <= c) {
        currRow[c] = Math.max(values[i] + prevRow[c - weights[i]], prevRow[c]);
      } else {
        currRow[c] = prevRow[c];
      }
    }
    prevRow = currRow;
  }
  return prevRow[cap];
}
public int knapsackOptimized(int cap, int[] weights, int[] values) {
    int n = values.length;
    int[] prevRow = new int[cap + 1];
    for (int i = n - 1; i >= 0; i--) {
        int[] currRow = new int[cap + 1];
        for (int c = 1; c <= cap; c++) {
            if (weights[i] <= c) {
                currRow[c] = Math.max(values[i] + prevRow[c - weights[i]], prevRow[c]);
            } else {
                currRow[c] = prevRow[c];
            }
        }
        prevRow = currRow;
    }
    return prevRow[cap];
}
CHAPTER 101

Largest Square in a Matrix

Determine the area of the largest square of 1's in a binary matrix.

Example:

Image represents a 5x5 matrix, visually depicted as a grid of cells, each containing a binary digit (either '0' or '1').  The matrix is enclosed within a bold black border.  The cells are arranged in five rows and five columns. A rectangular region encompassing the bottom three rows and the first three columns is highlighted with a light peach or orange fill.  No arrows or other explicit connections between cells are shown; the information flow is implicitly represented by the spatial arrangement of the binary digits within the matrix.  The numerical values within each cell are simply data points, with no further labels, URLs, or parameters associated with them. The overall structure suggests a representation of data, possibly used as input or output for a coding pattern algorithm, where the highlighted region might indicate a specific area of interest or a subset of the data.
Example
Output: 9

Intuition

The brute force solution to this problem involves examining every possible submatrix within the given matrix to determine if it forms a square of 1s. This can be done by treating each cell as a potential top-left corner of a square, and checking all possible squares that extend from that cell. For each of these squares, we'll need to verify that all cells within the square are 1's. Repeating this process for each cell allows us to find the largest square. This process is quite inefficient, so let's explore alternatives.

One important thing to understand is that squares contain smaller squares inside them. This indicates that subproblems might exist in this problem. Let's see if we can find what the subproblems are and how we can use them. Consider the following 6x6 matrix containing a 4×4 square of 1s:

Image represents a 6x6 matrix, or a two-dimensional array, visually depicted as a grid.  The rows and columns are numbered from 0 to 5. Each cell within the grid contains either a '0' or a '1'. The arrangement shows a pattern where the outer columns and rows (0 and 5) contain only '0's, while the inner columns (1 to 4) contain '1's in rows 1 to 4.  No information flows between the cells; the matrix simply presents a static data structure with a clear visual representation of the data's organization and values.  There are no URLs or parameters present.

Let's say we're at cell (4, 4). We know just by looking at the matrix that a square of length 4 with all 1s ends at this cell, but what information would we need algorithmically to determine that this 4x4 exists? A key observation here is that there are three 3×3 squares around this cell:

  • one that ends directly to the left of the current cell (4, 3).
  • one that ends at the top-left diagonal of the current cell (3, 3).
  • one that ends directly above the current cell (3, 4).
Image represents a step-by-step visualization of a 2D array processing algorithm, likely demonstrating a pattern related to matrix traversal or manipulation.  Three 6x6 matrices are shown, each representing a stage in the process.  The matrices contain values of '0' and '1', with a subset of '1's highlighted in different colored boxes (light blue, light orange, and light purple) at each stage.  Arrows connect the matrices, indicating the flow of the algorithm. The first matrix shows a 3x3 block of '1's highlighted in light blue, with a single '1' to its right highlighted in light orange. The second matrix shifts the light blue highlighted block one position to the right, with the light orange highlight now showing a different '1' to the right. The third matrix shifts the light blue highlighted block down one position, with the light orange highlight now showing a different '1' below.  The final matrix (to the right) shows the cumulative effect of the process, with a larger rectangular area (4x3) highlighted in magenta, encompassing the areas highlighted in the previous steps.  This suggests an accumulation or aggregation process where the highlighted regions are combined or processed sequentially.

This more clearly highlights the existence of subproblems: the length of a square that ends at the current cell depends on the lengths of the squares that end at its left, top, and top-left neighboring cells.

Let's consider a slightly different scenario, where this time, the input matrix contains one less 1, meaning there's no longer a 4×4 square of 1s:

Image represents a 6x6 matrix, visually depicted as a grid with rows and columns numbered 0 through 5.  Each cell within the grid contains either a '0' or a '1'. The arrangement shows a pattern where the central area (excluding the outermost rows and columns) is filled with '1's, while the outermost rows and columns contain '0's.  A single cell at row 4, column 1, is highlighted with a light gray background, also containing a '0'.  No information flows between cells; the image simply presents a static data structure representing a 2-dimensional array or matrix.

Let's see if our strategy of checking the three neighboring squares around the current cell (4, 4) changes at all here. Keep in mind that this time, the square that ends directly to the left of the current cell only has a length of 2. This means the square that ends at the current cell can, at most, have a length of 3, with 1 unit from the current cell and 2 units from the smallest neighboring square:

Image represents a step-by-step visualization of a 2D array processing algorithm, likely demonstrating a sliding window or kernel operation.  Three 6x6 matrices are shown, each representing a stage in the process.  The matrices contain only 0s and 1s.  In the first matrix, a 2x2 region (cells [3,2] to [4,3]) is highlighted with a light blue border.  An arrow points from the cell [3,3] to [4,3], suggesting data movement or processing.  The second matrix shows the light blue region shifted one position to the right, now encompassing cells [3,3] to [4,4], with a new light blue region.  A similar arrow points from [3,4] to [4,4]. The third matrix shows a larger, 3x3 region (cells [1,2] to [3,4]) highlighted with a purple border, encompassing the previous light blue regions, suggesting an accumulation or aggregation of results.  Arrows connect the matrices, indicating the sequential nature of the algorithm.  The row and column indices (0-5) are labeled along the top and left side of each matrix, providing spatial context.  The overall diagram illustrates a pattern of iterative processing across a 2D array, possibly for tasks like image filtering or feature extraction.

This indicates that the length of the current square is restricted by the smallest of its three neighboring squares. We can express this as a recursive formula where matrix[i][j] represents the value of the current cell, (i, j):

if matrix[i][j] == 1: max_square(i, j) = 1 + min(max_square(i - 1, j), max_square(i - 1, j - 1), max_square(i, j - 1))

We now have all the information we need. Given this problem has an optimal substructure, we can translate the above recurrence relation directly into a DP formula.

if matrix[i][j] == 1: dp[i][j] = 1 + min(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1])

Now, let's think about what the base cases should be.

Base cases
We know dp[0][0] should be 1 if matrix[0][0] is 1 since the top-left cell can only have a square of length 1.

What other base cases are there? Consider row 0 and column 0 of the matrix. These are special because the length of a square ending at any of these cells is at most 1.

So, for the base cases, we can set all cells in row 0 and column 0 to 1 in our DP table, provided those cells in the original matrix are also 1:

Image represents a dynamic programming (DP) table, labeled 'DP table:', used to store solutions to subproblems.  The table is a 5x5 grid, with rows and columns indexed from 0 to 4.  The top row and leftmost column represent indices for accessing the table's entries.  The text 'base cases' with an arrow pointing to the top-left cell (0,0) indicates that the initial values are populated from base cases.  The first row and column are partially filled; cell (0,0) contains '1', (0,1) contains '0', (0,2) contains '1', (0,3) contains '1', and (0,4) contains '0'.  Similarly, (1,0) contains '0', (2,0) contains '1', (3,0) contains '1', and (4,0) contains '1'. The remaining cells are empty, implying that these subproblems haven't been solved yet.  The arrows indicate the direction of data flow, showing how base cases populate the table's initial values.

Populating the DP table
We populate the DP table starting from the smallest subproblems (excluding the base cases). Specifically, with row 0 and column 0 populated, we begin by populating cell (1, 1) and work our way down to the last cell (m - 1, n - 1) row by row, where m and n are the dimensions of the matrix:

Image represents a 5x5 grid, visually resembling a matrix or table, with numerical values (0s and 1s) populating its cells.  The rows and columns are numbered 0 through 4.  The grid's first column contains the values 1, 0, 1, 1, 1 from top to bottom.  The remaining columns contain 0, 1, 1, 0 respectively in the first row, and are otherwise empty.  Orange horizontal arrows extend from the second column, labeled 'start' in the second row, to the last column, labeled 'end' in the last row.  These arrows traverse rows 1, 2, 3, and 4, indicating a path or flow through the grid.  The arrangement suggests a representation of a simple algorithm or data structure, possibly illustrating a pathfinding or state transition process where the 1s represent permissible steps and the arrows show the chosen path from a starting point to an ending point.

The largest value in the DP table represents the length of the largest square in our matrix. Therefore, we just need to track the maximum DP value (max_len) as we populate the table. Once done, we just return max_len^2, which represents the area of the largest square.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

In this implementation, it's possible to merge the base case handling with the code which populates the DP table. However, in an interview setting, code is often easier to understand when the base cases are defined separately, which is why they are implemented separately here.

def largest_square_in_a_matrix(matrix: List[List[int]]) -> int:
    if not matrix:
        return 0
    m, n = len(matrix), len(matrix[0])
    dp = [[0] * n for _ in range(m)]
    max_len = 0
    # Base case: If a cell in row 0 is 1, the largest square ending there has a
    # length of 1.
    for j in range(n):
        if matrix[0][j] == 1:
            dp[0][j] = 1
            max_len = 1
    # Base case: If a cell in column 0 is 1, the largest square ending there has
    # a length of 1.
    for i in range(m):
        if matrix[i][0] == 1:
            dp[i][0] = 1
            max_len = 1
    # Populate the rest of the DP table.
    for i in range(1, m):
        for j in range(1, n):
            if matrix[i][j] == 1:
                # The length of the largest square ending here is determined by
                # the smallest square ending at the neighboring cells (left,
                # top-left, top), plus 1 to include this cell.
                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
function largestSquareInAMatrix(matrix) {
  if (!matrix || matrix.length === 0) return 0;
  const m = matrix.length, n = matrix[0].length;
  const dp = Array.from({ length: m }, () => new Array(n).fill(0));
  let maxLen = 0;
  // Base case: If a cell in row 0 is 1, the largest square ending there has a length of 1.
  for (let j = 0; j < n; j++) {
    if (matrix[0][j] === 1) {
      dp[0][j] = 1;
      maxLen = 1;
    }
  }
  // Base case: If a cell in column 0 is 1, the largest square ending there has a length of 1.
  for (let i = 0; i < m; i++) {
    if (matrix[i][0] === 1) {
      dp[i][0] = 1;
      maxLen = 1;
    }
  }
  // Populate the rest of the DP table.
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      if (matrix[i][j] === 1) {
        dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1]);
      }
      maxLen = Math.max(maxLen, dp[i][j]);
    }
  }
  return maxLen ** 2;
}
public int largestSquareInAMatrix(int[][] matrix) {
    if (matrix == null || matrix.length == 0) return 0;
    int m = matrix.length, n = matrix[0].length;
    int[][] dp = new int[m][n];
    int maxLen = 0;
    for (int j = 0; j < n; j++) {
        if (matrix[0][j] == 1) {
            dp[0][j] = 1;
            maxLen = 1;
        }
    }
    for (int i = 0; i < m; i++) {
        if (matrix[i][0] == 1) {
            dp[i][0] = 1;
            maxLen = 1;
        }
    }
    for (int i = 1; i < m; i++) {
        for (int j = 1; j < n; j++) {
            if (matrix[i][j] == 1) {
                dp[i][j] = 1 + Math.min(dp[i - 1][j], Math.min(dp[i - 1][j - 1], dp[i][j - 1]));
            }
            maxLen = Math.max(maxLen, dp[i][j]);
        }
    }
    return maxLen * maxLen;
}

Complexity Analysis

Time complexity: The time complexity of largest_square_in_a_matrix is O(m⋅n)O(m\cdot n) because each cell of the DP table is populated once.

Space complexity: The space complexity is O(m⋅n)O(m\cdot n) since we're maintaining a 2D DP table that has m⋅nm\cdot n elements.

Optimization

We can optimize our solution by realizing that, for each cell in the DP table, we only need to access the cell directly above it, the cell to its left, and the top-left diagonal cell.

  • To get the cell above it or the top-left diagonal cell, we only need access to the previous row.

  • To get the cell to its left, we just need to look at the cell to the left of the current cell in the same row we're populating.

Therefore, we only need to maintain two rows:

  • prev_row: the previous row.
  • curr_row: the current row being populated.
Image represents a 5x5 matrix illustrating a coding pattern, possibly related to dynamic programming or a similar algorithm.  The matrix is labeled with row and column indices (0-4) along its top and left side.  The matrix cells contain numerical values (0, 1, 2). The bottom two rows are shaded light gray, labeled 'prev_row: 3' and 'curr_row: 4,' respectively.  Gray arrows indicate data flow:  elements from 'prev_row: 3' (1, 2, 2, 0, 1) are used to calculate the elements in 'curr_row: 4' (starting with 1), with each arrow pointing from a 'prev_row' element to its corresponding influence on a 'curr_row' element.  A text annotation to the right explains that to compute 'curr_row: 4,' only 'prev_row: 3' is needed, highlighting a dependency relationship between rows in the matrix, suggesting an iterative or recursive calculation process.

This effectively reduces the space complexity to O(m)O(m). Below is the optimized code:

def largest_square_in_a_matrix_optimized(matrix: List[List[int]]) -> int:
    if not matrix:
        return 0
    m, n = len(matrix), len(matrix[0])
    prev_row = [0] * n
    max_len = 0
    # Iterate through the matrix.
    for i in range(m):
        curr_row = [0] * n
        for j in range(n):
            # Base cases: if we're in row 0 or column 0, the largest square ending
            # here has a length of 1. This can be set by using the value in the
            # input matrix.
            if i == 0 or j == 0:
                curr_row[j] = matrix[i][j]
            else:
                if matrix[i][j] == 1:
                      # curr_row[j] = 1 + min(left, top-left, top)
                    curr_row[j] = 1 + min(curr_row[j - 1], prev_row[j - 1], prev_row[j])
            max_len = max(max_len, curr_row[j])
        # Update 'prev_row' with 'curr_row' values for the next iteration.
        prev_row, curr_row = curr_row, [0] * n
    return max_len ** 2
function largestSquareInAMatrixOptimized(matrix) {
  if (!matrix || matrix.length === 0) return 0;
  const m = matrix.length, n = matrix[0].length;
  let prevRow = new Array(n).fill(0);
  let maxLen = 0;
  for (let i = 0; i < m; i++) {
    let currRow = new Array(n).fill(0);
    for (let j = 0; j < n; j++) {
      if (i === 0 || j === 0) {
        currRow[j] = matrix[i][j];
      } else if (matrix[i][j] === 1) {
        currRow[j] = 1 + Math.min(currRow[j - 1], prevRow[j - 1], prevRow[j]);
      }
      maxLen = Math.max(maxLen, currRow[j]);
    }
    prevRow = currRow;
  }
  return maxLen ** 2;
}
public int largestSquareInAMatrixOptimized(int[][] matrix) {
    if (matrix == null || matrix.length == 0) return 0;
    int m = matrix.length, n = matrix[0].length;
    int[] prevRow = new int[n];
    int maxLen = 0;
    for (int i = 0; i < m; i++) {
        int[] currRow = new int[n];
        for (int j = 0; j < n; j++) {
            if (i == 0 || j == 0) {
                currRow[j] = matrix[i][j];
            } else if (matrix[i][j] == 1) {
                currRow[j] = 1 + Math.min(currRow[j - 1], Math.min(prevRow[j - 1], prevRow[j]));
            }
            maxLen = Math.max(maxLen, currRow[j]);
        }
        prevRow = currRow;
    }
    return maxLen * maxLen;
}
CHAPTER 102

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. To understand this, let’s dive into an analogy.

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:

Image represents a weighted graph, a network diagram showing five nodes (A, B, C, D, E) labeled in orange, interconnected by edges representing travel times in minutes.  Node A connects to node B with three edges showing travel times of 40, 30, and 20 minutes respectively, and another edge to B with a travel time of 30 minutes. Node B connects to node C with edges of 15 and 20 minutes. Node C connects to node D with edges of 30 and 50 minutes. Node D connects to node E with edges of 30 and 35 minutes.  Node D also has an edge to node C with a travel time of 25 minutes.  All edges are unidirectional, implying travel time may vary in the opposite direction (not shown).  The numbers on the edges represent the time taken to traverse that specific connection between the nodes.

You want the fastest route for this journey, so you aim to optimize the route. One option is to check every possible route to see which one takes the least amount of time to drive through, but this approach is quite time consuming.

Instead, you decide to take the route with the shortest duration at each leg of the trip, understanding that it will result in the quickest journey:

Image represents a weighted directed graph illustrating a network of nodes (A, B, C, D, E) connected by edges representing time durations.  Each node is labeled with a capital letter in orange.  The edges are depicted as lines; thicker black lines indicate the shortest path between nodes, while thinner light-grey lines represent alternative, longer paths.  Numerical values along the edges denote the time (in minutes) it takes to traverse that connection. For example, the shortest path from A to B takes 20 minutes, while an alternative path takes 30 minutes.  The shortest path from A to E is A-B-C-D-E, with individual leg times of 20, 15, 25, and 30 minutes respectively.  Other paths exist between nodes with varying durations, such as the 35-minute connection between B and E, or the 50-minute connection between C and D. The overall diagram visualizes different routes and their associated travel times between the five locations.

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. This means we check if the problem has an optimal substructure which can be broken down and solved by smaller subproblems.

The key difference between DP and greedy approaches is their decision-making strategies. Greedy algorithms follow the greedy choice property, while DP considers all possible solutions to find the global optimum. It’s generally the case that all greedy problems can be solved using DP, but not all DP problems can be solved using a greedy approach.

Real-world Example

Huffman coding in data compression: Huffman coding is an algorithm that assigns variable-length codes to input characters based on their frequencies, with the most frequent characters getting the shortest codes. The goal is to minimize the overall size of the encoded data. The greedy approach works by always combining the two least frequent characters first (local optimum), ensuring that each step reduces the size of the overall encoding (global optimum). This method is widely used in file compression formats like ZIP, and media compression standards like JPEG.

Chapter Outline

Image represents a hierarchical diagram illustrating applications of the 'Greedy' coding pattern.  A rounded rectangle at the top, labeled 'Greedy,' acts as the root node.  From this node, two dashed lines descend, each pointing to a separate rounded rectangle representing a specific application. The left rectangle is labeled 'Reaching a Destination' and lists two sub-points: 'Jump to the End' and 'Gas Stations,' suggesting examples of problems solved using a greedy approach in this context. The right rectangle is labeled 'Resource Allocation' and lists 'Candies' as an example problem.  The overall structure shows that 'Reaching a Destination' and 'Resource Allocation' are both considered applications or examples of the broader 'Greedy' algorithmic pattern. The dashed lines indicate a hierarchical relationship, showing that the lower-level boxes are specific instances of the upper-level concept.

It’s impractical to provide a one-size-fits-all framework for solving greedy problems because each one is unique. Instead, this chapter explores a variety of unique situations in which a problem can be solved using the greedy choice property to provide a general understanding of how this property works.

CHAPTER 103

Jump to the End

You are given an integer array in which you're originally positioned at index 0. Each number in the array represents the maximum jump distance from the current index. Determine if it's possible to reach the end of the array.

Example 1:

Image represents a sequence of numbers, [3, 2, 0, 2, 5], depicted within square brackets.  A green curved arrow originates from the number 3 at the beginning of the sequence and points to the number 2 near the middle.  Another smaller green curved arrow originates from the same initial 2 and points to the number 5 at the end of the sequence. These arrows suggest a connection or flow of information, possibly indicating a relationship or operation between the numbers 3 and 2, and separately between the number 2 and 5. The arrangement implies a linear data structure, possibly an array or list, with the arrows highlighting specific elements and their interactions within the context of a coding pattern, likely illustrating a selection or traversal process.
Example
Input: nums = [3, 2, 0, 2, 5]
Output: True

Example 2:

Image represents two diagrams illustrating different scenarios of data flow or program execution. Each diagram shows a sequence of numbers, [2, 1, 0, 3], enclosed in square brackets.  In both diagrams, a grey curved arrow points from the '0' to the text 'dead end,' indicating a termination point or a path that doesn't continue further.  The key difference lies in the red arrows. In the first diagram, a single red curved arrow points from the '2' to the '0', suggesting a direct transition or flow from '2' to '0'. In the second diagram, two red curved arrows originate from the '2', one pointing to the '1' and the other to the '0', illustrating a branching or conditional flow where '2' can lead to either '1' or '0' before potentially reaching the 'dead end'.  The diagrams likely depict different control flows or execution paths within a program, possibly highlighting the concept of a single path versus multiple paths, or a decision point in the algorithm.
Example
Input: nums = [2, 1, 0, 3]
Output: False

Constraints:

  • There is at least one element in nums.
  • All integers in nums are non-negative integers.

Intuition

From any index i in the array, we can jump up to nums[i] positions to the right. This means the furthest index we can reach from any given index i, is i + nums[i]:

Image represents a visual explanation of array traversal.  A small square labeled 'i' points downwards with an arrow to an array `[3, 2, 0, 2, 5]`.  Beneath the array, indices 0 through 4 are displayed.  A curved orange arrow originates from the element at index 1 (value 2) and points to the element at index 3 (value 2). This arrow illustrates a jump in the array. The text below the array explains that this jump represents the 'furthest index we can jump to from i = i + nums[i]', indicating that the value at index `i` (in this case, 2) is added to `i` to determine the next index to visit.  The formula `i + nums[i]` shows how the jump is calculated.

If the array consisted entirely of positive numbers, jumping from index 0 to the last index would be straightforward, as there would always be a way to progress forward toward the last index. The challenge arises when we encounter a 0 in the array, as a 0 is effectively a dead end, since landing on it disallows any further movement.

Consider the example below:

Image represents a two-row array of numbers. The top row, enclosed in square brackets `[ ]`, displays the numbers 3, 2, 0, 2, and 5 in bold, black font, representing the main data set.  Below this, a second row shows the indices 0, 1, 2, 3, and 4 in a lighter gray font, indicating the position or index of each corresponding element in the top row.  The arrangement is linear, with each number in the top row directly above its corresponding index in the bottom row. No explicit connections or information flow is depicted beyond the implied positional relationship between the top and bottom rows.

Let’s think about this problem backward. Our destination is the last index, index 4, but let’s say we’ve already made it there. How did we reach this index? In this example, it’s possible to make it to index 4 from index 3:

The image represents a visual depiction of an array or list data structure.  The main component is a numerical array `[3, 2, 0, 2, 5]` displayed horizontally.  Beneath each element of the array, its index is shown in gray: 0, 1, 2, 3, and 4 respectively.  A small, light peach-colored circle containing the number '4' is positioned below the last element, visually highlighting the array's length or the index of the last element.  A curved orange arrow originates from above the element '2' at index 3 and points to the element '5' at index 4, indicating a potential operation or data flow between these two specific elements within the array.  This arrow likely represents an action or modification performed on the array, possibly involving the element at index 3 affecting the element at index 4.

This means that if we find a way to reach index 3, we know for sure we can make it to index 4. The key observation here is that if we can reach the last index from any earlier index, this earlier index becomes our new destination.

With this in mind, let’s go through this example in full, starting with the last index as our initial destination:

Image represents a visual depiction of accessing an element within an array.  A square box containing an 'i' symbol points downwards to an array represented as `[3 2 0 2 5]`, with indices 0 through 4 displayed below each element.  A small orange circle highlights the element at index 4, labeled 'destination' in orange text below.  A separate, light-grey, dashed-line box to the right displays 'destination = 4', indicating that the value at the designated index (4) within the array is 5.  The arrow from the 'i' symbol suggests an instruction or pointer to locate the element at the specified index, which is then retrieved and displayed as the value of the 'destination' variable.

To find earlier indexes that can reach the destination, let’s move backward through the array, starting at index 3. As we do this for each index, we check if we can reach the current destination from this index. If we can, this index becomes the new destination. We do this by checking if it's possible to jump to the destination from this index:

if i + nums[i] ≥ destination, we can jump to destination from index i.

Image represents a visual depiction of an algorithm's step, likely within a larger coding pattern example.  A numerical array `[3, 2, 0, 2, 5]` is shown, with indices 0 through 4 displayed below each element.  A black square labeled 'i' points via a downward arrow to the element '2' at index 3, indicating a current iteration. A dashed orange line then arcs from this '2' to the element '5' at index 4.  A separate orange circle labeled '4' (presumably representing the index) is connected by a solid orange line to the word 'destination' written in orange text.  A light gray box to the right details the calculation: `i + nums[i] = 3 + 2 = 5 ≥ destination`, concluding with `destination = i = 3`. This suggests that the algorithm is comparing the sum of the current index (i=3) and the element at that index (nums[i]=2) to a 'destination' value, and updating the 'destination' variable based on the comparison.  The overall diagram illustrates a step-by-step process of array traversal and conditional logic, possibly part of a search or comparison algorithm.

Image represents a visual depiction of an array and an index.  The top row shows an array represented by `[3 2 0 2 5]`, enclosed in square brackets, with each number representing an element.  Below this, a second row displays indices `0 1 2 3 4` in grey, indicating the position of each element in the array.  A circled '3' in orange is positioned below the array, connected by a vertical orange line to the word 'destination' written in orange below it. This visually highlights that the index '3' points to the element at that position within the array, which is the '2' in the top row.  The greyed-out elements and indices suggest a focus on the element at index 3, making it the 'destination' of the visual representation.

With the destination at index 3, let’s continue moving backward through the array.


Now, we’re at index 2. Below, we see we cannot reach the destination from index 2, so the destination is not updated.

Image represents a visual explanation of a coding scenario, likely involving array traversal.  A square box labeled 'i' points downwards to an array `[3, 2, 0, 2, 5]` with indices 0 through 4 displayed below each element.  A circled '3' highlights the element '2' at index 3, labeled 'destination' in orange text below.  A dashed-line box to the right shows a calculation: `i + nums[i] = 2 + 0`, indicating that `i` is 2 (the index of the current element being examined).  The next line shows `= 2 < destination`, comparing the result of the calculation (2) to the destination value (also 2). The final line, 'cannot reach current destination,' indicates that the condition `2 < destination` is false, implying the algorithm's inability to reach the target index based on the current logic.  The overall diagram illustrates a step in an algorithm where the current index `i` and the value at that index in the `nums` array are used to determine if a destination index can be reached, and in this case, it cannot.

Continue with this logic for the remaining numbers in the array:

Image represents a visual depiction of an iterative process, likely within a loop in a program.  A numbered array `[3, 2, 0, 2, 5]` is shown, with indices 0 through 4 displayed below each element.  A square box labeled 'i' points downwards with a solid arrow to the element '2' at index 1, indicating the current iteration's index. A dashed orange arrow arcs from this '2' to the element '2' at index 3, suggesting a potential search or comparison.  A circled '3' below the second '2' is labeled 'destination', indicating a target value or position. A separate light gray box details the calculation: `i + nums[i] = 1 + 2 = 3 ≥ destination`, implying a comparison between the calculated sum (3) and the 'destination' value. The final line in the gray box, `destination = i = 1`, shows the result of the comparison, assigning the value of 'i' (1) to 'destination'.  The overall diagram illustrates a step in an algorithm where an index 'i' is used to access and process elements in an array, potentially searching for a specific value or condition.
Image represents a visual depiction of a coding pattern, likely illustrating an algorithm's step.  A square box labeled 'i' points downwards with an arrow to a bracketed array `[3, 2, 0, 2, 5]`, indicating an index 'i' is being used to access elements within the array.  A dashed orange arrow curves from the element '3' (at index 0) to the element '2' (at index 1), suggesting a potential iteration or comparison.  Below the array, a circled '1' labeled 'destination' is connected to the array via a solid orange line, implying a variable 'destination' is being assigned a value from the array.  To the right, a grey box details the algorithm's logic: `i + nums[i] = 0 + 3`, `= 3 ≥ destination`, and `destination = i = 0`. This shows a calculation where the value at index 'i' (initially 0) is added to 'i', the result is compared to 'destination', and 'destination' and 'i' are updated based on the comparison.  The overall diagram illustrates a step-by-step process of accessing and manipulating array elements based on a condition involving an index and a destination variable.
The image represents a visual depiction of a data structure, possibly illustrating a concept within graph theory or routing algorithms.  At the top, a bracketed sequence `[3 2 0 2 5]` is displayed, potentially representing a list of values or weights. Below this, a second row shows a sequence of numbers `1 2 3 4`, seemingly representing indices or positions.  A prominent orange circle containing a '0' is positioned to the left of the second row, labeled 'destination' in orange text below it, suggesting this '0' represents a target node or destination index. A vertical orange line connects this circled '0' to the second row, visually indicating a connection or association between the destination and the row of indices. The numbers in the top row appear to be associated with the numbers in the second row, possibly representing data associated with each index, with some values (0 and 2) appearing in both rows, suggesting a relationship or mapping between the two sequences. The greyed-out numbers in the top and second rows might indicate inactive or irrelevant data points within the context of the illustrated concept.

Finally, we see that once we’ve finished iterating through each index, the destination is set to index 0. This means we've successfully found a way to jump to the end from index 0.

Therefore, we return true when destination == 0. Otherwise, we cannot reach the destination from index 0, so we return false.


An interesting aspect of this approach is that as soon as we find an index i from where we can reach the destination, we update the destination to that index and assume that this is the correct decision:

Image represents a visual depiction of a coding pattern, likely illustrating a data manipulation or algorithm.  The image shows two arrays of integers:  `[9 6 7 0 4 2 0 5]` and `[9 6 7 0 4 2 0 5]`.  Each element in the arrays is labeled with its index (0-7) below it in gray.  A small square labeled 'i' is shown above each array, indicating an iterator or index pointer.  A downward arrow from each 'i' points to the element at index 5 in the respective array (the number 2).  A curved, light-blue arrow connects the number 2 in the first array to the number 5 in the same array, suggesting a movement or operation involving these elements.  Below the number 5 in each array, an orange circle labeled 'destination' is connected to the number 5 with a vertical orange line, highlighting the destination of the operation.  A gray arrow points from the first array to the second, implying a transformation or result of the operation. The overall diagram suggests a process where an element at a specific index is identified and potentially moved or modified, resulting in a change in the array's structure.

The thing is, there can sometimes be multiple indexes which can reach the destination. So, how do we know that choosing the first valid index we encounter from the right is the best choice?

Image represents a visual depiction of data flowing into a single destination point.  A horizontal array displays the numbers [9, 6, 7, 0, 4, 2, 0, 5], each number positioned above its index (0 through 7).  Curved, light-blue arrows emanate from each of these numbers, converging at a single point labeled 'destination' in orange text, which is also numerically indicated by a circled '7' below the array.  The arrows visually represent the flow of data from each element in the array to the final destination, suggesting a process of aggregation or accumulation where multiple data points contribute to a single result.

The key to understanding why is realizing that all the other indexes which can reach the destination can also reach this first valid index:

Image represents a visual depiction of data flow or a transformation process.  The image shows a sequence of numbers [9, 6, 7, 0, 4, 2, 0, 5] arranged horizontally, with each number positioned above an index (0-7).  Four bright cyan curved arrows originate from the numbers 9, 6, 7, and 0, respectively, all converging onto the number 2.  A lighter gray curved arrow connects the number 0 to the number 5.  A small orange circle labeled '5' is positioned below the number 2 and is labeled 'destination' in orange text, suggesting that the number 2 is the target or result of the data flow represented by the cyan arrows. The overall structure suggests a process where multiple data points (9, 6, 7, and 0) contribute to or are processed to produce a final result (2), with an additional, separate process indicated by the gray arrow.

Therefore, by choosing the first valid index, we effectively simplify our problem without missing any potential solutions.

This is indicative of a greedy solution; since the greedy choice property is satisfied, we make the best immediate choice at each step as we move backward through the array (local optimums), hoping it leads to the overall solution (global optimum).

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def jump_to_the_end(nums: List[int]) -> bool:
    # Set the initial destination to the last index in the array.
    destination = len(nums) - 1
    # Traverse the array in reverse to see if the destination can be reached by
    # earlier indexes.
    for i in range(len(nums) - 1, -1, -1):
        # If we can reach the destination from the current index, set this index as
        # the new destination.
        if i + nums[i] >= destination:
            destination = i
    # If the destination is index 0, we can jump to the end from index 0.
    return destination == 0
function jumpToTheEnd(nums) {
  // Set the initial destination to the last index in the array.
  let destination = nums.length - 1;
  // Traverse the array in reverse to see if the destination can be reached by earlier indexes.
  for (let i = nums.length - 1; i >= 0; i--) {
    // If we can reach the destination from the current index, set this index as the new destination.
    if (i + nums[i] >= destination) {
      destination = i;
    }
  }
  // If the destination is index 0, we can jump to the end from index 0.
  return destination === 0;
}
public boolean jumpToTheEnd(int[] nums) {
    // Set the initial destination to the last index in the array.
    int destination = nums.length - 1;
    // Traverse the array in reverse to see if the destination can be reached by earlier indexes.
    for (int i = nums.length - 1; i >= 0; i--) {
        // If we can reach the destination from the current index, set this index as the new destination.
        if (i + nums[i] >= destination) {
            destination = i;
        }
    }
    // If the destination is index 0, we can jump to the end from index 0.
    return destination == 0;
}

Complexity Analysis

Time complexity: The time complexity of jump_to_the_end is O(n)O(n), where nn denotes the length of the array. This is because we iterate through each element of nums in reverse order.

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

CHAPTER 104

Gas Stations

There's a circular route which contains gas stations. At each station, you can fill your car with a certain amount of gas, and moving from that station to the next one consumes some fuel.

Find the index of the gas station you would need to start at, in order to complete the circuit without running out of gas. Assume your car starts with an empty tank. If it's not possible to complete the circuit, return -1. If it's possible, assume only one solution exists.

Example:

Example
Input: gas = [2, 5, 1, 3], cost = [3, 2, 1, 4]
Output: 1

Explanation:

Start at station 1: gain 5 gas (tank = 5), costs 2 gas to go to station 2 (tank = 3).
At station 2: gain 1 gas (tank = 4), costs 1 gas to go to station 2 (tank = 3).
At station 3: gain 3 gas (tank = 6), costs 4 gas to go to station 3 (tank = 2).
At station 0: gain 2 gas (tank = 4), costs 3 gas to go to station 1 (tank = 1).
We started and finished the circuit at station 1 without running out of gas.

Intuition

Before deciding which gas station to start with, let's first determine if it's even possible to complete the circuit with the total amount of gas available.

Total gas vs total cost
Case 1: sum(gas) < sum(cost):
The first thing to realize is if the total gas is less than the total cost, it's impossible to complete the circuit. No matter where we start, we'll run out of gas before completing the circuit. So, in this situation, we should return -1.

Case 2: sum(gas) ≥ sum(cost):
Now, let's consider the more interesting case where the total travel cost is less than or equal to the total amount of gas available.

Here's a potential hypothesis:

Since there's enough total gas to cover the total cost of travel, there must be a start point in the circuit that allows us to complete it without ever running out of gas.

It's tough to confirm this hypothesis without examining an example, so let's dive into one.

Finding a start point
Consider the following example where sum(gas) > sum(cost):

Image represents two horizontal, one-dimensional arrays of numerical data.  The top array is labeled 'gas' and contains the integer values [3, 2, 1, 3, 3, 2, 3, 4] enclosed within square brackets.  The bottom array is labeled 'cost' and contains the integer values [2, 1, 4, 1, 2, 6, 0, 3], also enclosed within square brackets. Both arrays have the same length (8 elements).  The arrays are positioned vertically one above the other, implying a potential relationship or correspondence between the elements at the same index in each array.  There are no explicit connections or arrows drawn between the arrays; the relationship is implied by their parallel structure and identical lengths.

We don't necessarily need to consider the gas and cost separately. At any station i, we collect gas[i] and consume cost[i] to move to the next station. We can consider both at the same time by getting the difference between these values, which provides the net gas gained or lost at each station:

Image represents a simple calculation demonstrating element-wise subtraction between two arrays.  The top row shows an array labeled 'gas' containing the integer values [3, 2, 1, 3, 3, 2, 3, 4]. The second row displays an array labeled 'cost' with the integer values [2, 1, 4, 1, 2, 6, 0, 3].  Vertical grey arrows connect each corresponding element in the 'gas' and 'cost' arrays, indicating a subtraction operation. The bottom row, labeled 'net gas:', shows the results of this element-wise subtraction.  Each element in this resulting array is the difference between the corresponding elements in the 'gas' and 'cost' arrays; for example, 3-2=1, 2-1=1, 1-4=-3, and so on. The resulting 'net gas' array contains the values [1, 1, -3, 2, 1, -4, 3, 1], with positive values shown in green and negative values in red.

Let's start at station 0 with an empty gas tank and see how far we can go. The net gas at this station is positive (1), which means we have enough gas to reach the next station. Let's add 1 to our tank:

The image represents a visual depiction of a simple iterative process, possibly illustrating a coding pattern.  A small square containing the letter 'i' (likely indicating information or an instruction) has a downward arrow pointing to a sequence of numbers: 1, 1, -3, 2, 1, -4, 3, 1.  These numbers are indexed from 0 to 7 beneath them, labeled 'start' in orange text to the left, suggesting an initial state or starting point.  To the right, a dashed-line box shows a variable named 'tank' being updated. The expression 'tank += 1' indicates that 1 is added to the current value of 'tank,' and '= 1' shows the resulting value of 'tank' after the addition.  The implication is that each number in the sequence is processed sequentially, and in this specific example, the 'tank' variable is incremented by 1 for each iteration, regardless of the value in the sequence.

Note that index i refers to the current gas station, whereas start refers to the gas station we started from.


At station 1, we encounter the same situation:

Image represents a diagram illustrating a coding pattern, possibly related to array processing or accumulation.  The diagram shows a sequence of numbers: 1, 1, -3, 2, 1, -4, 3, 1, each positioned above a corresponding index (0 through 7) indicating its position within the sequence.  The word 'start' is written in orange below the index 0, suggesting the beginning of the process. A downward-pointing arrow originates from a small square containing the letter 'i', likely representing an iterator or index variable, pointing to the first element of the sequence (the number 1). To the right, a dashed-line box displays the operation 'tank += 1' and below it '= 2', indicating a variable named 'tank' is being incremented by 1, resulting in a value of 2.  The overall implication is that the sequence of numbers is being processed sequentially, and each number is potentially contributing to the accumulation within the 'tank' variable.

At station 2, our tank falls below 0, indicating we don't have enough gas to make it to the next station:

Image represents a flowchart illustrating a coding pattern, possibly related to fuel consumption or resource management.  A square box labeled 'i' at the top points downwards with an arrow to a sequence of numbers: 1, 1, -3, 2, 1, -4, 3, 1.  These numbers are indexed from 0 to 7, with 'start' labeled in orange below index 0.  To the right, a light gray, dashed-line box contains a code snippet: `tank += (-3)`, followed by `= -1 < 0`, and a right-pointing arrow leading to the text 'cannot make it to the next station'.  The diagram shows a process where a variable 'tank' is decremented by 3 in each step, and the condition `-1 < 0` evaluates to true, resulting in the message indicating an inability to proceed to the next stage.  The numbers likely represent changes in the 'tank' value over time or across different stages.

This means we cannot start our journey at station 0. Should we go back and try station 1? The key observation here is that if we didn't have enough gas to get from station 0 to station 3, we also wouldn't have enough if we started at any other station before station 3:

Image represents a visual depiction of an iterative process, possibly demonstrating a coding pattern like accumulating values.  A sequence of numbers (1, 1, -3, 2, 1, -4, 3, 1) is displayed, each number positioned above a corresponding index (0, 1, 2, 3, 4, 5, 6, 7) indicating its position.  The word 'start' is written in orange below the index 3, suggesting the starting point of the iteration. To the right, a gray box with a dashed border shows a variable named 'tank' initialized to 0.  The implication is that the numbers in the sequence are sequentially processed, and their values are added to or subtracted from the 'tank' variable.  The arrangement suggests a step-by-step accumulation where the final value of 'tank' would be the sum of all the numbers in the sequence.

This is a general rule:

If we cannot make it to station b from station a, we cannot make it to station b from any of the stations in between, either:

Image represents two analogous illustrations explaining a coding pattern concept.  Both illustrations feature a horizontal orange line representing a journey from point 'a' to point 'b', marked by labels at each end.  A small orange car icon is positioned at the start of the line, at point 'a'.  The line terminates at point 'b' with a red 'X' indicating failure.  The first illustration uses the analogy of driving from 'a' to 'b' without running out of gas, stating 'if we cannot drive from a to b without running out of gas,' above the diagram.  The second illustration expands this, showing that if the journey from 'a' to 'b' fails, then starting anywhere between 'a' and 'b' will also fail, stating 'we cannot start anywhere between a and b without running out of gas.'  Both diagrams include dots along the line to represent intermediate points between 'a' and 'b', visually reinforcing the concept that failure at the end implies failure at any intermediate starting point.

Let's try to understand why. If we only just ran out of gas right before reaching station b, this means our tank maintained a non-negative amount of gas until station b:

The image represents a graphical depiction of fuel consumption.  The upper portion shows a line graph where the vertical axis labeled 'tank' represents the fuel level, ranging from zero at the bottom to a maximum at the top. The horizontal axis represents the distance traveled, labeled 'station' at the far right.  A green line plots the fuel level over distance, starting at point 'a' with a full tank and decreasing gradually, showing fluctuations in fuel consumption.  The line reaches a minimum at point 'b', where it sharply drops to zero, indicated by a red line segment.  The lower portion of the image shows a horizontal orange arrow representing the car's journey from point 'a' to point 'b', marked by a red 'x' indicating the point of fuel depletion.  Small dots along the orange arrow represent intermediate points along the journey.  The labels 'a' and 'b' on both the upper and lower parts of the image correspond to the same locations on the journey and fuel level.  The car icon at the beginning of the orange arrow visually reinforces the representation of the car's travel.

Consequently, starting anywhere else before station b will result in us missing a non-negative amount of gas from the previous stations. Therefore, starting at any of these in-between stations doesn't allow us to progress to station b.


Back to our example. Let's now try resetting our tank to 0 and restarting at station 3 (at i + 1), since we just discussed how starting at stations 0 to i doesn't work:

Image represents a diagram illustrating a coding pattern, possibly related to array processing or a similar concept.  A small square labeled 'i' with an arrow pointing downwards indicates an input or starting point.  Below this, a sequence of numbers (1, 1, -3, 2, 1, -4, 3, 1) is presented, each number positioned above a greyed-out index (0, 1, 2, 3, 4, 5, 6, 7) indicating their position within a data structure. The word 'start' is written in orange below the index numbers. To the right, a dashed-line box displays 'tank = 0', suggesting an initial value or state of a variable named 'tank'. The arrow from 'i' implies that the sequence of numbers is processed sequentially, potentially accumulating values into the 'tank' variable.  The negative numbers suggest a subtraction operation might be involved in the processing.

We continue until we reach a point where we cannot proceed to the next station:

Image represents a diagram illustrating a coding pattern, possibly related to array manipulation or a simple accumulator.  A small square containing the letter 'i' (likely indicating information or input) points downwards with an arrow towards a sequence of numbers: 1, 1, -3, 2, 1, -4, 3, 1. These numbers are indexed from 0 to 7, shown in light gray below the numbers. The word 'start' is written in orange below the numbers, suggesting this is the starting point of the process.  To the right, a dashed-line box shows a variable named 'tank' being updated. The equation 'tank += 2' indicates that 2 is added to the current value of 'tank,' resulting in a new value of '2' (shown as ' = 2').  The overall diagram suggests a process where the numbers in the sequence are processed sequentially, and the 'tank' variable accumulates a value based on this processing, in this case, starting with an initial value of 0 and adding 2.

Image represents a diagram illustrating a coding pattern, possibly related to array processing or iterative summation.  The diagram shows a sequence of numbers: 1, 1, -3, 2, 1, -4, 3, 1, indexed from 0 to 7.  These numbers are displayed horizontally, with indices shown in light gray below each number.  A downward-pointing arrow originates from a small square containing the letter 'i', suggesting an iterator or index variable. This arrow points to the number 1 at index 4, indicating a starting point. The word 'start' is written in orange below the numbers, reinforcing this starting position. To the right, a dashed-line box displays the equation 'tank += 1' and ' = 3', suggesting that a variable named 'tank' is being incremented by 1, resulting in a final value of 3.  The overall structure implies that the numbers are being processed sequentially, and the 'i' likely represents the index used to iterate through the sequence, with the 'tank' variable accumulating values from the sequence.

Image represents a sequence of numbers (1, 1, -3, 2, 1, -4, 3, 1) indexed from 0 to 7, with the number at index 3 labeled 'start' in orange.  A downward-pointing arrow from a small square containing the letter 'i' points to the number -4 in the sequence. To the right, a dashed-line box displays the code snippet 'tank += (-4)', indicating that -4 is added to a variable named 'tank'. Below this, ' = -1 < 0' shows the result of the operation, which is less than 0. Finally, an arrow points to the text 'cannot make it to the next station', indicating a conditional outcome based on the value of 'tank' being less than 0.  The overall diagram illustrates a scenario where a process (indicated by the 'i' and arrow) leads to a condition check (tank < 0) resulting in a specific outcome.

As we can see, we ran out of gas at station 5, which means we can't start from stations 3 to 5, either. So, let's try restarting at station 6.


After resetting the tank to 0, let's continue traveling through the stations:

Image represents a diagram illustrating a simple coding pattern, possibly related to variable assignment and accumulation.  A sequence of numbers (1, 1, -3, 2, 1, -4) is shown in light gray, indexed from 0 to 5, representing an input array or data stream.  A downward-pointing arrow from a small square containing the letter 'i' points to the number '3', indicating an initial value or input trigger.  This '3' is positioned at index 6, suggesting a starting point for processing.  Next to the '3' at index 6 is the number '1', which might represent the next element in the sequence to be processed. To the right, a dashed-line box displays an assignment operation: 'tank += 3', followed by ' = 3', indicating that a variable named 'tank' is initialized to 0 and then incremented by 3, resulting in a final value of 3. The word 'start' is written in orange below the number 3 at index 6, clearly marking the beginning of the operation. The overall diagram suggests a process where an initial value is used, and subsequent operations (not explicitly shown) might involve iterating through the input sequence and updating the 'tank' variable.

Image represents a visual depiction of a coding pattern, likely illustrating a cumulative sum or accumulator.  A sequence of numbers (1, 1, -3, 2, 1, -4, 3) is shown, each number indexed from 0 to 6.  These numbers are presented in light gray except for the number 3 at index 6, which is in black and has a downward-pointing arrow originating from a small square containing the letter 'i', suggesting an input or initial value. This '3' at index 6 is then added to a variable named 'tank', which initially holds a value of 1 (as indicated by 'tank += 1'). The result of this addition, 4, is displayed next to an equals sign ('= 4') within a dashed-line box representing the 'tank' variable's updated state. The word 'start' is written in orange below the number sequence, indicating the starting point of the process.  The overall diagram demonstrates how a running total is calculated by iteratively adding each element of the number sequence to an accumulator variable.

We've reached the end of the array. Should we go back to the start of the array to check if starting from station 6 allows us to complete the circuit? Or is reaching the end from station 6 enough to finish the circuit? Let's look into this.

Proving we have enough gas to complete the circuit after reaching the end of the array
We can determine this via a proof by contradiction. If the gas we have by the end of the array is not enough, that means no solution exists: no station at which we could start in order to complete the circuit. This implies that no matter where we start, we will hit a deficit (i.e., a point where our tank falls below 0) before completing the circuit.

Consider a segment of the circuit where we run into a deficit:

Image represents a diagram illustrating a traversal or journey.  A small orange car icon is positioned at the left, labeled implicitly as point 'a' with ellipses suggesting further points preceding it.  An orange arrow originates from a short perpendicular line extending downwards from the car, indicating the starting point of the journey. This arrow stretches horizontally across the diagram, passing over several evenly spaced black dots representing intermediate points along a path. The arrow terminates at a red 'x' mark, implicitly labeled as point 'b' with ellipses suggesting further points following it. The overall visual suggests a linear progression from point 'a' to point 'b', with the 'x' possibly indicating an endpoint or a point of failure or interruption in the journey.

We learned earlier that we cannot start at any station between a and b (inclusive) without running into a deficit. So, we can characterize this entire segment as having less total gas than the total cost required to travel through it.

After concluding that we cannot start anywhere from stations a to b, we decide to restart at the next station after b, which represents the start of the next segment. Keep in mind that since there is no solution in this proof, every segment in the circuit will end with a deficit:

Image represents a circular diagram illustrating a cyclical process or pattern.  A large, solid black circle forms the central element, representing the core of the cycle.  Surrounding this circle is a smaller, orange circle formed by a series of interconnected orange arrows, indicating a continuous flow or progression.  Four red crosses are positioned equidistantly along the outer orange circle, each connected to a small orange dot.  The arrows point in a clockwise direction, suggesting a sequential process where each red cross represents a stage or step within the cycle. The absence of labels or text within the diagram leaves the specific nature of the cycle undefined, allowing for broad interpretation within the context of coding patterns.

This means each of these segments has less total gas than total cost. Therefore, for there to be no starting point, sum(gas) would have to be less than sum(cost).

However, we know that sum(gas) ≥ sum(cost), confirming there must be a valid start point that allows us to complete the circuit.

Therefore, in our example, we can confirm that station 6 is the answer for the following reasons.

  • sum(gas) ≥ sum(cost) implies that a solution must exist.

  • We confirmed that starting anywhere before station 6 will result in us running into a deficit.

  • We didn't encounter any deficit from station 6 to the last station in the array.

So, we just need to return start, which is station 6 in our example.


This is considered a greedy solution because we assume the first station we encounter that doesn't run into a deficit by the end of our array, is the start point that allows us to complete the circuit without testing every possible station in the array as the start point. The locally optimal choices (moving forward when possible, resetting when encountering a deficit) lead to the globally optimal solution (finding the correct starting point).

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def gas_stations(gas: List[int], cost: List[int]) -> int:
    # If the total gas is less than the total cost, completing the circuit is
    # impossible.
    if sum(gas) < sum(cost):
        return -1
    start = tank = 0
    for i in range(len(gas)):
        tank += gas[i] - cost[i]
        # If our tank has negative gas, we cannot continue through the circuit from
        # the current start point, nor from any station before or including the
        # current station 'i'.
        if tank < 0:
            # Set the next station as the new start point and reset the tank.
            start, tank = i + 1, 0
    return start
function gasStations(gas, cost) {
  const sumGas = gas.reduce((a, b) => a + b, 0);
  const sumCost = cost.reduce((a, b) => a + b, 0);
  if (sumGas < sumCost) return -1;
  let start = 0, tank = 0;
  for (let i = 0; i < gas.length; i++) {
    tank += gas[i] - cost[i];
    if (tank < 0) {
      start = i + 1;
      tank = 0;
    }
  }
  return start;
}
public int gasStations(int[] gas, int[] cost) {
    int totalGas = 0, totalCost = 0;
    for (int g : gas) totalGas += g;
    for (int c : cost) totalCost += c;
    if (totalGas < totalCost) return -1;
    int start = 0, tank = 0;
    for (int i = 0; i < gas.length; i++) {
        tank += gas[i] - cost[i];
        if (tank < 0) {
            start = i + 1;
            tank = 0;
        }
    }
    return start;
}

Complexity Analysis

Time complexity: The time complexity of gas_stations is O(n)O(n), where nn denotes the length of the input arrays. This is because we iterate through each element in the gas and cost arrays.

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

Interview Tip

Tip: Demonstrate your greedy solution with examples if proving it formally is too difficult
In some problems, such as this one, proving that a greedy solution works might be complicated, especially in an interview setting. If you and the interviewer are on the same page about this, a good compromise is to demonstrate the solution's correctness with a few diverse examples. This approach allows both you and the interviewer to have confidence in your solution in the absence of a thorough proof.

CHAPTER 105

Candies

You teach a class of children sitting in a row, each of whom has a rating based on their performance. You want to distribute candies to the children while abiding by the following rules:

  • Each child must receive at least one candy.
  • If two children sit next to each other, the child with the higher rating must receive more candies.

Determine the minimum number of candies you need to distribute to satisfy these conditions.

Example 1:

Example
Input: ratings = [4, 3, 2, 4, 5, 1]
Output: 12

Explanation: You can distribute candies to each child as follows: [3, 2, 1, 2, 3, 1].

Example 2:

Example
Input: ratings = [1, 3, 3]
Output: 4

Explanation: You can distribute candies to each child as follows: [1, 2, 1].

Intuition

For starters, we know we need to give at least 1 candy to each of the n children to satisfy the first requirement of this problem. As for the other requirement, let's start off by considering a few specific cases.

Uniform ratings
Consider the situation where all children have the same rating. Since no child has a higher rating than another, we can give each child one candy to meet both requirements.

Image represents a simple data structure example showing two parallel arrays, likely in a programming context.  The top array, labeled 'ratings,' contains five elements, each with a value of '2,' separated by the '==' operator, suggesting comparison or equality checks.  The bottom array, labeled 'candies,' also contains five elements, each with a value of '1' (displayed in orange).  The arrays are arranged vertically, with 'ratings' above 'candies,' implying a potential relationship between the two datasets.  The numbers within the square brackets indicate array elements, and the overall structure suggests a one-to-one correspondence between the elements of the 'ratings' and 'candies' arrays, where each rating of 2 is associated with one candy.

Increasing ratings
Now, consider the case where the children’s ratings are in strictly increasing order. Here, each child should receive one more candy than their left-side neighbor.

Image represents two horizontal, one-dimensional arrays of numerical data. The top array, labeled 'ratings = [1 < 3 < 4 < 6 < 8]', contains the numbers 1, 3, 4, 6, and 8, arranged sequentially within square brackets.  The numbers are separated by the 'less than' symbol (<), indicating a sorted ascending order. The bottom array, labeled 'candies = [1 2 3 4 5]', displays the numbers 1, 2, 3, 4, and 5, also within square brackets, but without any separating symbols, suggesting a simple sequence.  The numbers in the 'candies' array are rendered in orange, while those in the 'ratings' array are in black, except for the '<' symbols which are in a lighter orange.  There is no explicit connection or information flow visually depicted between the two arrays; they are presented independently, implying a potential relationship or comparison between the two datasets that is not directly shown in the image itself.

The first child gets one candy because they have no left-side neighbor, and a smaller rating than their right-side neighbor. Each subsequent child has a higher rating than their left-side neighbor, so gets one more candy than that neighbor.

Image represents a diagram illustrating an algorithm or code snippet.  At the top, a Python-like list named `ratings` is defined and initialized with the integer values [1, 3, 4, 6, 8]. Below it, another list named `candies` is initialized with [1, 2, 3, 4, 5]. A horizontal arrow connects the second element of `candies` to its last element, indicating an iterative process. Below the arrow, a condition `rating[i] > ratings[i - 1]` is shown, suggesting that the algorithm iterates through the `ratings` list, comparing each element to its preceding element. If the condition is true (the current rating is greater than the previous rating), the subsequent line `candies[i] = candies[i - 1] + 1` indicates that the corresponding element in the `candies` list is incremented by 1.  This implies that the number of candies increases only when the rating increases compared to the previous rating. The overall diagram visualizes a conditional update of the `candies` list based on the values in the `ratings` list.

Decreasing ratings
Here, each child needs to receive one more candy than their right-side neighbor.

Image represents two horizontal arrays of numerical data, presented as Python-style lists. The top array, labeled 'ratings = ', contains the integer sequence [7, 6, 4, 3, 2], with each number separated by a right-pointing arrow symbol ('>'), suggesting a descending order or a comparison between consecutive elements.  The bottom array, labeled 'candies = ', displays the integer sequence [5, 4, 3, 2, 1], also in descending order, with the numbers rendered in orange, while the numbers in the 'ratings' array are in black, except for the orange arrows.  The two arrays are vertically aligned, implying a potential relationship or correlation between the corresponding elements in each list, possibly illustrating a data structure or a pattern in a coding context.

This is the same as the previous case, but in reverse. To populate the candies array in reverse, we start with the child furthest to the right, giving them one candy. Moving leftward, we give each child one more candy than their right-side neighbor:

The image represents a visual explanation of an algorithm or code snippet that manipulates two arrays, `ratings` and `candies`.  The `ratings` array, displayed at the top, contains the integer values [7, 6, 4, 3, 2]. Below it is the `candies` array, initially holding the values [5, 4, 3, 2, 1]. A light-orange horizontal arrow connects the `candies` array, indicating a flow of information or processing from right to left.  Below the arrow, the condition `rating[i] > ratings[i + 1]` is shown in orange text, signifying that the algorithm compares consecutive elements in the `ratings` array. If this condition is true (meaning a rating is greater than the next one), the subsequent line, `candies[i] = candies[i + 1] + 1`, in orange text, shows that the corresponding element in the `candies` array is updated by adding 1 to the value of the next element in the `candies` array.  This suggests a process where the number of candies assigned is dependent on the relative ranking of ratings, with higher ratings potentially receiving more candies than their lower-rated neighbors.

Non-uniform ratings
Unlike previous cases, in a non-uniform distribution of ratings, children can have higher ratings than their left-side neighbor, right-side neighbor, both neighbors, or neither. This makes handling non-uniform ratings more complex.

What if we can handle these cases separately? Specifically, we could use:

  • One pass to ensure children with a higher rating than their left-side neighbor get more candy (handle increasing ratings).

  • A second pass to ensure children with a higher rating than their right-side neighbor get more candy (handle decreasing ratings).

This allows us to apply the logic we used for increasing and decreasing ratings in two separate passes. Let’s see if this strategy works over the following example, starting with an initial distribution of 1 candy for each child to ensure we meet the first requirement of this problem:

Image represents a line graph illustrating a relationship between 'ratings' and 'candies'. The horizontal axis (x-axis) is labeled 'ratings' and displays a sequence of numbers within square brackets: [4, 3, 2, 4, 5, 1], representing different rating values. The vertical axis (y-axis) is labeled 'candies' and shows a scale from 0 to 3, indicating the quantity of candies.  A horizontal line at 'candies = 1' connects six orange data points, each corresponding to a rating value on the x-axis.  This visual representation suggests a constant number of candies (one) regardless of the rating value.  The graph's structure is simple, with clear axis labels and a straightforward linear relationship between the variables, showing no correlation between the number of candies and the ratings.

First pass: handle increasing ratings
Iterate through the ratings array and, for each child, check if they have a higher rating than their left-side neighbor (if ratings[i] > ratings[i - 1]). Start from index 1 since the child at index 0 doesn’t have a left-side neighbor.

  • If a child's rating is higher than their left-side neighbor's rating, make sure they have at least one more candy than their left-side neighbor (candies[i] = candies[i - 1] + 1).

  • Otherwise, just continue to the next child’s rating.

We can see how this process updates the candy distribution below:

Image represents a Cartesian coordinate system illustrating a simple data relationship. The horizontal axis, labeled 'ratings,' displays a sequence of numerical ratings: 4, 3, 2, 4, 5, 1,  represented along the x-axis with corresponding numerical markers (0, 1, 2, 3, 4, 5) below. The vertical axis, labeled 'candies,' represents a quantity, with values 0, 1, 2, and 3 marked along the y-axis.  A horizontal line at y=1 connects six orange dots, each positioned above a rating value on the x-axis.  This visually indicates that regardless of the rating (4, 3, 2, 4, 5, or 1), the number of candies remains constant at one. A small black square containing an 'i' is positioned above the data line, with a downward-pointing arrow suggesting it provides additional information or context about the data presented.  The x-axis values are enclosed in square brackets, '[ ]', and a right-pointing arrow symbol '>' is placed between the first two rating values (4 and 3).
Image represents a line graph illustrating a data set. The horizontal axis is labeled 'ratings' and shows a sequence of numbers from 4 to 1, representing rating values.  The vertical axis is labeled 'candies' and shows a scale from 0 to 3, likely representing the quantity or count of candies. A horizontal line at 'candies = 1' connects six data points, each corresponding to a rating value. The data points are represented by orange circles.  The x-coordinates of these points are [4, 3, 2, 4, 5, 1] respectively, and their y-coordinate is consistently 1. A small black square containing the letter 'i' is positioned above the data point at rating 2, possibly indicating an information point or annotation related to that specific data point.  The x-axis values are presented within square brackets `[ ]`, suggesting a list or array of rating values.  A small right-pointing arrow is placed between the rating values 3 and 2, possibly indicating the direction of data flow or sequence.
Image represents a line graph illustrating a relationship between 'ratings' and 'candies'. The horizontal axis displays 'ratings', showing values from 4 to 1,  represented as [4, 3, 2, 4, 5, 1] within square brackets. The vertical axis represents 'candies', ranging from 0 to 3.  An orange line connects data points, showing the number of candies corresponding to each rating. The line starts at (4,1), remains at a value of 1 for ratings 3 and 2, peaks at (4,2) for the third rating, drops to (5,1) for the fourth rating, and remains at 1 for the final rating of 1. A small black square labeled 'i' is positioned above the peak of the line, with a downward-pointing arrow indicating a possible annotation or additional information related to the highest point on the graph (rating 4, candies 2).
Image represents a line graph illustrating the relationship between 'ratings' and 'candies'. The horizontal axis (x-axis) is labeled 'ratings' and displays numerical values [4, 3, 2, 4, 5, 1] enclosed in square brackets.  The vertical axis (y-axis) is labeled 'candies' and shows values from 0 to 3.  An orange line connects data points representing the number of candies corresponding to each rating. The points show that the number of candies starts at 1 for a rating of 4, remains at 1 for ratings of 3 and 2, increases to 2 for a rating of 4, peaks at 3 for a rating of 5, and finally drops to 1 for a rating of 1. A small square box labeled 'i' is positioned above the highest point on the graph, with an arrow pointing downwards to that point, possibly indicating additional information or a key data point.
Image represents a line graph illustrating the relationship between 'ratings' (x-axis) and 'candies' (y-axis).  The x-axis shows ratings values ranging from 4 to 1, with corresponding numerical indices [0, 1, 2, 3, 4, 5] displayed below. The y-axis represents the number of candies, ranging from 0 to 3.  An orange line connects data points, showing the number of candies associated with each rating. The points indicate that at ratings 4 (index 0) and 3 (index 1), there is 1 candy; at rating 2 (index 2), there is also 1 candy; at rating 4 (index 3), there are 2 candies; at rating 5 (index 4), there are 3 candies; and finally, at rating 1 (index 5), there is 1 candy. A small black square with an 'i' inside is positioned to the upper right, with a downward-pointing arrow connecting it to the last data point on the graph, suggesting additional information or context related to that specific data point might be available.

Second pass: handle decreasing ratings
Iterate through the ratings array in reverse and, for each child, check if they have a higher rating than their right-side neighbor (if ratings[i] > ratings[i + 1]). Start at index 4 since the child at index 5 (the last child) doesn’t have a right-side neighbor.

  • If a child's rating is higher than their right-side neighbor's rating, make sure they have one more candy than that neighbor. Note that because we already did one pass through the candies array, they might already have more candies than their right-side neighbor, in which case the current amount of candy they have is sufficient.

  • Otherwise, continue to the next child’s rating.

We can see how this process updates the candy distribution below:

Image represents a line graph illustrating the relationship between 'ratings' and 'candies'. The horizontal axis (x-axis) represents 'ratings', with values ranging from 0 to 5, shown as [4 3 2 4 5 > 1]. The vertical axis (y-axis) represents 'candies', with values ranging from 0 to 3.  An orange line connects data points, showing the number of candies corresponding to each rating. The points are (0,1), (1,1), (2,1), (3,2), (4,3), and (5,1).  A small black square labeled 'i' is positioned above the graph, with an arrow pointing downwards towards the data point at (4,3), possibly indicating additional information or a highlight. The graph visually depicts a non-linear relationship, where the number of candies increases initially with ratings, peaks at a rating of 4, and then decreases.
Image represents a line graph illustrating a relationship between 'ratings' and 'candies'. The horizontal axis (x-axis) displays 'ratings', with values ranging from 4 to 1, represented numerically and with corresponding indices [4<sub>0</sub>, 3<sub>1</sub>, 2<sub>2</sub>, 4<sub>3</sub>, 5<sub>4</sub>, 1<sub>5</sub>] suggesting a possible array or list structure. The vertical axis (y-axis) represents 'candies', with values ranging from 0 to 3.  An orange line connects data points, showing the number of candies corresponding to each rating. The points show that the number of candies remains constant at 1 for ratings 4, 3, and 2, then increases to 2 for rating 4, peaks at 3 for rating 5, and finally drops to 1 for rating 1. A small information icon ('i' in a square) points to the data point (4<sub>3</sub>, 2), possibly highlighting a specific data point of interest or an anomaly.
Image represents a line graph illustrating a relationship between 'ratings' and 'candies'. The horizontal axis (x-axis) displays 'ratings', with values ranging from 4 to 1, represented numerically and indexed below the axis as [4<sub>0</sub> 3<sub>1</sub> 2<sub>2</sub> < 4<sub>3</sub> 5<sub>4</sub> 1<sub>5</sub>]. The vertical axis (y-axis) represents 'candies', with values from 0 to 3.  An orange line connects data points, showing the number of candies corresponding to each rating. The line starts at (4,1), remains at a constant value of 1 until (2,1), then increases sharply to (4,2), peaks at (5,3), and finally drops to (1,1). A small black square labeled 'i' points down to the point (2,1) on the graph, possibly indicating a specific data point of interest or a change in trend.
Image represents a line graph illustrating the relationship between candy ratings and the number of candies.  The horizontal axis, labeled 'ratings,' displays a sequence of ratings represented as a list `[4, 3, 2, 4, 5, 1]`, with each number corresponding to a data point. The vertical axis, labeled 'candies,' represents the quantity of candies, ranging from 0 to 3.  An orange line connects data points, showing the number of candies associated with each rating.  The points are (4,1), (3,2), (2,1), (4,2), (5,3), and (1,1). A small downward-pointing arrow with an 'i' inside a box is positioned above the point (3,2), possibly indicating additional information or a key data point. The graph visually demonstrates a non-linear relationship between ratings and the number of candies, with the number of candies fluctuating across different rating values.
Image represents a line graph illustrating a relationship between 'ratings' and 'candies'.  The horizontal axis displays 'ratings', with values [4, 3, 2, 4, 5, 1] marked below, each value having a corresponding subscript index [0, 1, 2, 3, 4, 5] indicating the position in the sequence. The vertical axis represents 'candies', ranging from 0 to 3. An orange line connects data points, showing the number of candies corresponding to each rating. The points are (0,3), (1,2), (2,1), (3,2), (4,3), and (5,1). A small square with an 'i' inside is positioned above the first data point (0,3), suggesting additional information might be available.  The overall graph depicts a non-linear relationship, where the number of candies fluctuates with changes in ratings.

At the end of the second pass, each child should have the correct number of candies. Now, we just need to return the sum of all the candies in the candies array.


The solution we came up with is a greedy solution because it satisfies the greedy choice property: we make a locally optimal choice for the current child by only considering the ratings of their immediate neighbors, without worrying about the ratings of other children.

How can we be sure this strategy works? Over our two passes, we ensure both the left-side and right-side neighbors of each child are considered when distributing candies. This guarantees that the solution meets the problem's requirements.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def candies(ratings: List[int]) -> int:
    n = len(ratings)
    # Ensure each child starts with 1 candy.
    candies = [1] * n
    # First pass: for each child, ensure the child has more candies than their
    # left-side neighbor if the current child's rating is higher.
    for i in range(1, n):
        if ratings[i] > ratings[i - 1]:
            candies[i] = candies[i - 1] + 1
    # Second pass: for each child, ensure the child has more candies than their
    # right-side neighbor if the current child's rating is higher.
    for i in range(n - 2, -1, -1):
        if ratings[i] > ratings[i + 1]:
            # If the current child already has more candies than their right-side
            # neighbor, keep the higher amount.
            candies[i] = max(candies[i], candies[i + 1] + 1)
    return sum(candies)
function candies(ratings) {
  const n = ratings.length;
  // Ensure each child starts with 1 candy.
  const candies = new Array(n).fill(1);
  // First pass: for each child, ensure the child has more candies than their
  // left-side neighbor if the current child's rating is higher.
  for (let i = 1; i < n; i++) {
    if (ratings[i] > ratings[i - 1]) {
      candies[i] = candies[i - 1] + 1;
    }
  }
  // Second pass: for each child, ensure the child has more candies than their
  // right-side neighbor if the current child's rating is higher.
  for (let i = n - 2; i >= 0; i--) {
    if (ratings[i] > ratings[i + 1]) {
      candies[i] = Math.max(candies[i], candies[i + 1] + 1);
    }
  }
  return candies.reduce((sum, c) => sum + c, 0);
}
public int candies(int[] ratings) {
    int n = ratings.length;
    // Ensure each child starts with 1 candy.
    int[] candies = new int[n];
    Arrays.fill(candies, 1);
    // First pass: for each child, ensure the child has more candies than their
    // left-side neighbor if the current child's rating is higher.
    for (int i = 1; i < n; i++) {
        if (ratings[i] > ratings[i - 1]) {
            candies[i] = candies[i - 1] + 1;
        }
    }
    // Second pass: for each child, ensure the child has more candies than their
    // right-side neighbor if the current child's rating is higher.
    for (int i = n - 2; i >= 0; i--) {
        if (ratings[i] > ratings[i + 1]) {
            candies[i] = Math.max(candies[i], candies[i + 1] + 1);
        }
    }
    int total = 0;
    for (int c : candies) total += c;
    return total;
}

Complexity Analysis

Time complexity: The time complexity of candies is O(n)O(n) because we perform two passes over the nums array.

Space complexity: The space complexity is O(n)O(n) due to the space taken up by the candies array.

CHAPTER 106

Introduction to Sort and Search

Intuition

When sorting and searching items in data structures, efficiency is key. This chapter covers common sorting methods and their roles in efficient searching.

First, we present a comparison of the time and space complexities for various sorting algorithms. Below, nn denotes the number of elements in the data structure:

AlgorithmTime complexitySpace complexity
Best caseAverage caseWorst case
Insertion sort [1]O(n)O(n)O(n2)O(n^2)O(n2)O(n^2)O(1)O(1)
Selection sort [2]O(n2)O(n^2)O(n2)O(n^2)O(n2)O(n^2)O(1)O(1)
Bubble sort [3]O(n)O(n)O(n2)O(n^2)O(n2)O(n^2)O(1)O(1)
Merge sortO(nlog⁡(n))O(n\log(n))O(nlog⁡(n))O(n\log(n))O(nlog⁡(n))O(n\log(n))O(n)O(n)
QuicksortO(nlog⁡(n))O(n\log(n))O(nlog⁡(n))O(n\log(n))O(n2)O(n^2)O(log⁡(n))O(\log(n)) (average) O(n)O(n) (worst)
Heapsort [4]O(nlog⁡(n))O(n\log(n))O(nlog⁡(n))O(n\log(n))O(nlog⁡(n))O(n\log(n))O(1)O(1)
Counting sort1O(n+k)O(n+k)O(n+k)O(n+k)O(n+k)O(n+k)O(n+k)O(n+k)
Bucket sort2 [5]O(n+k)O(n+k)O(n+k)O(n+k)O(n2)O(n^2)O(n+k)O(n+k)
Radix sort3 [6]O(d(n+k))O(d(n+k))O(d(n+k))O(d(n+k))O(d(n+k))O(d(n+k))O(n+k)O(n+k)

 

This chapter focuses on merge sort, quicksort, and counting sort. There is additional information on the other algorithms in the references provided, as well as a tool for visualizing how these algorithms work [7].

Fundamental concepts for sorting algorithms
Here’s a list of fundamental attributes of sorting algorithms you should be familiar with:

  • Stability: A sorting algorithm is considered stable if it preserves the relative order of equal elements in the sorted output. If two elements have equal values, their order in the sorted output is the same as in the input.

  • In-place sorting: An in-place sorting algorithm transforms the input using a constant amount of extra storage space. It involves sorting the elements within the original data structure.

  • Comparison-based sorting: Comparison-based sorting algorithms sort elements by comparing them pairwise. These algorithms typically have a lower bound of O(nlog⁡(n))O(n\log(n)), whereas non-comparison-based sorting algorithms can achieve linear time complexity, but require specific assumptions about the input data.

These concepts are referenced throughout the problems in this chapter.

Real-world Example

Sorting products by category: When users search for products, the platform often sorts the results based on various criteria such as lowest to highest price, highest to lowest rating, or even relevance to the search query. Efficient sorting algorithms ensure large datasets of products can be quickly arranged according to user preferences.

Chapter Outline

Image represents a hierarchical diagram illustrating the subcategories within the broader topic of 'Sort and Search' algorithms.  A rounded rectangle at the top, labeled 'Sort and Search,' 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 labeled 'Sort' and contains a bulleted list of three specific sorting problems: 'Sort Linked List,' 'Sort Array,' and 'Dutch National Flag.' The right box is labeled 'Search' and lists a single search problem: 'Kth Largest Integer.'  The dashed lines visually represent the hierarchical relationship, showing that 'Sort' and 'Search' are both sub-categories under the umbrella of 'Sort and Search.'  No URLs or parameters are present in the diagram.

Footnotes

  1. In counting sort, k represents the range of the values in the input array. ↩

  2. In bucket sort, k represents the number of buckets used. ↩

  3. In radix sort, k represents the range of the inputs and d represents the number of digits in the maximum element. ↩

CHAPTER 107

Sort Linked List

Given the head of a singly linked list, sort the linked list in ascending order.

Example:

Image represents two linear sequences of numbered nodes (3, 2, 4, 5, 1) and (1, 2, 3, 4, 5) connected by unidirectional arrows indicating a flow from left to right.  The top sequence uses black-bordered circles containing numbers 3, 2, 4, 5, and 1 respectively, linked sequentially by black arrows.  The bottom sequence uses orange-bordered circles with the same numbers (1, 2, 3, 4, 5) arranged sequentially and connected by black arrows. A downward-pointing grey arrow connects the node '4' in the top sequence to the node '3' in the bottom sequence, suggesting a transformation or transition between the two sequences.  The overall arrangement visually depicts a before-and-after scenario, or a process that reorders the sequence of nodes.

Intuition

Let’s start by finding a sorting algorithm that allows us to sort a linked list.

Choosing a sorting algorithm
We're tasked with sorting a linked list, not an array. This distinction is crucial because algorithms like quicksort rely on random access through indexing, which linked lists don't support. Merge sort is a great O(nlog⁡(n))O(n\log(n)) time option, where nn denotes the length of the linked list, because it does not require random access and works well with linked lists, as we’ll see in this explanation.

Merge sort
The merge sort algorithm uses a divide and conquer strategy. At a high level, it can be broken down into three steps:

  1. Split the linked list into two halves:
Image represents a directed graph illustrating a coding pattern, possibly related to state transitions or workflow.  The upper path shows a sequence of nodes (circles) numbered 3, 2, 4, 5, and 1, connected by solid black arrows indicating a directional flow from one node to the next.  A lower path mirrors part of the upper path, starting with nodes 3, 2, and 4, also connected by solid black arrows.  The key difference is that node 4 in the upper path has two gray curved arrows emanating from it, one pointing downwards to node 2 in the lower path, and another pointing downwards to node 5 in the lower path, which is then followed by node 1. This suggests a branching or conditional logic where node 4's processing can lead to two different subsequent sequences, both ultimately ending at node 1, but via different intermediate nodes (2 and 4 versus 5).  The numbers within the nodes likely represent states or steps in a process.
  1. Recursively sort both halves:
Image represents a visual depiction of the merge sort algorithm's operation on two input arrays.  The top half shows the initial input array `(3, 2, 4)` represented as three interconnected circles, each containing a number, with arrows indicating the flow of data.  The numbers are connected sequentially from left to right. Below this, a second row shows the result of an intermediate step in the merge sort, `(2, 3, 4)`, also represented by interconnected circles with arrows showing data flow.  A grey arrow connects the initial input to the intermediate result, suggesting a transformation.  The right side mirrors this structure, showing the initial input array `(5, 1)` in the top row and the intermediate result `(1, 5)` in the bottom row, again with arrows indicating data flow and a grey arrow connecting the input to the intermediate result.  The text 'merge_sort(' precedes each input array, indicating the function being applied.  The parentheses enclose the input arrays, and the closing parenthesis follows the last element of each array.
  1. Merge the halves back together in a sorted manner to form a single sorted list:
Image represents a diagram illustrating a merge operation in a coding pattern.  The upper portion shows two separate sequences: one sequence (in black circles) progresses from node '2' to '3' and then to '4'; the other sequence (also in black circles) goes from node '1' to '5'.  The lower portion depicts a merged sequence (in orange circles) resulting from the combination of the upper sequences.  A curved grey arrow labeled 'merge' connects nodes '3' and '5' from the upper sequences to node '3' in the lower sequence, indicating the merging point. The merged sequence in the lower portion flows from node '1' to '2', then to '3', '4', and finally '5'.  The numbers within the circles represent data elements or nodes in the sequences, and the arrows indicate the flow or order of processing.

We can see what the entire process looks like in the diagram below:

Image represents a directed graph illustrating a coding pattern, possibly related to state transitions or workflow.  The graph consists of nodes, represented by circles containing numbers (1 through 5), and directed edges, represented by grey arrows connecting the nodes.  Several distinct paths are visible, with some nodes appearing multiple times in different paths.  The numbers within the nodes likely represent different states or stages in a process.  Some nodes are colored light blue, others are colored orange, suggesting a possible distinction between different types of states or stages.  The paths show the sequence of transitions between these states.  For example, one path shows a sequence of 3 → 2 → 4 → 5 → 1, while another shows 3 → 2 → 4, and yet another shows a loop involving nodes 2 and 3. The overall structure suggests a complex system with multiple possible state transitions and potentially parallel or branching workflows.

This is what this process looks like as pseudocode:

def merge_sort(head):
    # Split the linked list into two halves.
    second_head = split_list(head)
    # Recursively sort both halves.
    first_half_sorted = merge_sort(head)
    second_half_sorted = merge_sort(second_head)
    # Merge the sorted sublists.
    return merge(first_half_sorted, second_half_sorted)
function mergeSort(head) {
  // Split the linked list into two halves.
  const secondHead = splitList(head);
  // Recursively sort both halves.
  const firstHalfSorted = mergeSort(head);
  const secondHalfSorted = mergeSort(secondHead);
  // Merge the sorted sublists.
  return merge(firstHalfSorted, secondHalfSorted);
}
public ListNode mergeSort(ListNode head) {
    // Split the linked list into two halves.
    ListNode secondHead = splitList(head);
    // Recursively sort both halves.
    ListNode firstHalfSorted = mergeSort(head);
    ListNode secondHalfSorted = mergeSort(secondHead);
    // Merge the sorted sublists.
    return merge(firstHalfSorted, secondHalfSorted);
}

Let’s discuss in more detail how to split a linked list, and how to merge two sorted linked lists.

Splitting the linked list in half
To split a linked list in half, we need access to its middle node because the node next to the middle node can represent the head of the second linked list:

The image represents a linked list divided into two halves.  The linked list consists of nodes containing the numbers 3, 2, 4, 5, and 1, connected sequentially by arrows indicating the next element in the sequence.  A gray box labeled 'mid' points down to node 4, indicating this node as the midpoint of the list.  The nodes 3, 2, and 4 are enclosed under a gray line labeled 'first half,' while nodes 5 and 1 are enclosed under an orange line labeled 'second half.'  An orange box labeled 'mid.next' points down to node 5, highlighting the node immediately following the midpoint.  The arrows visually represent the flow of data through the linked list, showing the connections between consecutive nodes.

We can retrieve the middle node using the fast and slow pointer technique, as described in the Linked List Midpoint problem:

Image represents a linked list traversal visualization illustrating a 'fast' and 'slow' pointer technique.  The linked list is depicted as a sequence of numbered nodes (3, 2, 4, 5, 1) connected by solid arrows indicating the direction of traversal. A light-blue rectangular box labeled 'slow' points to node 4, and an orange rectangular box labeled 'fast' points to node 1.  Dashed arrows show the movement of the pointers; the slow pointer moves one node at a time (3→2→4), while the fast pointer moves two nodes at a time (1→5→1). A grey dashed-line box to the right displays the condition 'fast.next == null → break,' indicating that the algorithm terminates when the fast pointer reaches the end of the list (where the next node is null).  The overall diagram demonstrates a common pattern used in algorithms like cycle detection in linked lists, where the fast pointer's faster traversal allows for efficient detection of loops or the end of the list.
Image represents a linked list data structure illustrating a pointer manipulation technique.  The diagram shows a singly linked list with nodes numbered 3, 2, 4, 5, and 1.  Arrows indicate the direction of the `next` pointers connecting each node. A rectangular box labeled 'head' points to node 3, indicating it's the head of the list. Another rectangular box labeled 'slow' points to node 4. A third rectangular box, 'second_head', points to node 5.  A dashed-line rectangle to the right displays the assignment statement 'second_head = slow.next', showing that the `second_head` pointer is being assigned the value of the `next` pointer of the `slow` pointer (which points to node 5).  The overall diagram depicts a step in an algorithm, likely involving two pointers traversing the list at different speeds, where `second_head` is being updated based on the position of the `slow` pointer.

Then, we just need to disconnect the two halves by setting slow.next to null:

Image represents a linked list data structure with two pointers, 'slow' and 'second_head', traversing it.  A grey rectangular box labeled 'head' points to a circular node containing the number '3'. This node is connected via a right-pointing arrow to a node containing '2', which in turn connects to a node containing '4'. A light-blue rectangular box labeled 'slow' points to the node containing '4'.  Another circular node containing '5' is connected to a node containing '1' via a right-pointing arrow. A rectangular box labeled 'second_head' points to the node containing '5'. Finally, a dashed-line grey rectangle displays the condition 'slow.next = null', indicating that the next pointer of the node pointed to by 'slow' is null, signifying the end of a linked list segment.  The arrows represent the connections between nodes, showing the flow of traversal through the list.

Note that when the linked list is of even length, there are two middle nodes. We want the slow pointer to stop at the first middle node so we can get the head of the second half more easily. As mentioned in Linked List Midpoint, we can achieve this by stopping the fast pointer when fast.next.next is null:

Image represents a linked list traversal visualization illustrating a fast and slow pointer approach.  The diagram shows a singly linked list with nodes numbered 3, 2, 4, and 5.  A rectangular box labeled 'slow' in light blue points to node 2 with a downward-pointing arrow, indicating the slow pointer's initial position.  Similarly, a rectangular box labeled 'fast' in orange points to node 4, showing the fast pointer's starting position. Solid arrows indicate the forward traversal of the list: node 3 points to node 2, node 2 to node 4, and node 4 to node 5.  Dashed orange and light blue curved arrows from node 3 to node 2 and from node 4 to node 2 represent the iterative movement of the fast and slow pointers, respectively.  A separate light gray box with dashed borders displays a conditional statement: 'fast.next.next == null → break,' indicating that the traversal terminates when the pointer two steps ahead of the fast pointer (fast.next.next) reaches the end of the list (null).
Image represents a linked list data structure illustrating a pointer manipulation technique.  Three rectangular boxes labeled 'head', 'slow', and 'second_head' point to nodes within the linked list.  The linked list consists of four circular nodes containing the numerical values 3, 2, 4, and 5 respectively.  Arrows indicate the connections between nodes, showing the sequence of the list.  'head' points to node 3, 'slow' points to node 2, and 'second_head' points to node 4. A dashed rectangular box to the right displays the assignment statement 'second_head = slow.next', indicating that the pointer 'second_head' is being assigned the value of the 'next' pointer of the node pointed to by 'slow'. This demonstrates how 'second_head' is advanced one step ahead of 'slow' in the linked list.

Merging two sorted linked lists
First, let's consider merging two linked lists, each containing a single node, meaning they’re both inherently sorted. We merge them by placing the smaller node first, followed by the other node:

Image represents a diagram illustrating a data flow or transformation process.  Two circles labeled '5' and '1' are positioned at the top, representing input data points or sources.  These are connected via curved, grey arrows to a lower level containing two orange-bordered circles labeled '1' and '5'. The grey arrows indicate a merging or combining of the initial data.  A solid, black arrow connects the lower-left orange circle ('1') to the lower-right orange circle ('5'), suggesting a unidirectional transformation or processing step where data from '1' is processed to produce '5'. The arrangement visually depicts a scenario where two distinct inputs ('5' and '1') are combined and then undergo a transformation resulting in a new output ('5'), potentially implying a pattern of data aggregation and subsequent modification.

With that established, how would we merge two longer linked lists? To do this, we can set two pointers, one at the start of each linked list, then perform the following steps:

  1. Compare the nodes at each pointer and add the node with the smaller value to the merged linked list.

  2. Advance the pointer at that node with the smaller value to the next node in its linked list.

  3. Repeat the above two steps until we can no longer advance either pointer.

Before discussing the final step, observe how these steps are applied to the following two sorted linked lists. We can use a dummy node to point to the head of the merged linked list:

Image represents two linked lists being merged.  The top list consists of nodes labeled 2, 3, and 4, with a node labeled 11 pointing to node 2. The bottom list consists of nodes labeled 1 and 5, with a node labeled 12 pointing to node 1.  Arrows indicate the direction of links between nodes. A rectangular box describes the merging process:  if the value of node 11 is greater than the value of node 12, a new node (12) is added to the merged list, and the 'next' pointer of node 12 is set to point to the next node in the merged list (which is node 1 in this case).  To the right of the lists, a visual representation of this step shows a grey node labeled 'D' (likely representing a dummy node or pointer) pointing to an orange node labeled '1', illustrating the insertion of node 12 (implied, not explicitly shown) before node 1 in the merged list.
Image represents a visual explanation of a merging operation in a linked list data structure.  The top section shows two linked lists. The first list, in black, consists of nodes containing the values 2, 3, and 4, with a node labeled '11' pointing to the node with value 2. The second list, in gray, contains nodes with values 1 and 5, with a node labeled '12' pointing to the node with value 5.  Arrows indicate the direction of links between nodes. A rectangular box explains the logic:  '11.val < 12.val → add node 11 to the merged list' and '11 = 11.next,' indicating that because the value of node 11 is less than the value of node 12, node 11 is added to the merged list, and its 'next' pointer is updated. The bottom section depicts the resulting merged list, where a new gray node 'D' points to an orange node '1', which points to an orange node '2'.  The orange nodes represent the merged list, showing the integration of node 11 (implicitly) into the sorted structure.
Image represents a visual explanation of a merging operation in linked lists.  The left side shows two linked lists: one with nodes containing values '2', '3', and '4', and another with nodes containing '1' and '5'.  Above each list, a square box indicates a node with value '11' and '12' respectively, representing nodes to be merged. Arrows depict the connections between nodes within each list.  The right side displays the result of merging the '11' node into a new list, which starts with a 'D' node (likely representing a dummy head node).  A rectangular box explains the merging logic: '11.val < 12.val → add node 11 to the merged list — 11 = 11.next', indicating that the node with value '11' is added to the merged list because its value is less than the value of the next node ('12'), and its 'next' pointer is set to point to the next node in the merged list. The merged list visually shows nodes '1', '2', and '3' in orange, indicating that node '11' has been successfully integrated into the sorted merged list.
Image represents a visual explanation of a merging operation in a linked list data structure.  The top-left shows two linked lists; one in light gray with nodes containing values '2', '3', and a node '4' (in black, indicating it's a focus point), and another below it, also in light gray, with nodes '1' and '5' (also in black).  Above the node '4' is a gray box labeled '11', representing a node with value 11 to be inserted. Above the node '5' is a gray box labeled '12', representing a node with value 12. A downward arrow connects '11' to '4' and '12' to '5', indicating the insertion point. A large light-gray box in the center explains the logic: '11.val < 12.val → add node 11 to the merged list — 11 = 11.next'. This describes the condition for merging (value 11 is less than value 12) and the action (inserting node 11) along with the pointer update (11.next will point to the next node). The bottom-right shows the resulting merged linked list, with nodes 'D', '1', '2', '3', and '4' (all in orange to highlight the merged list), connected by arrows indicating the sequence.  The 'D' node likely represents a dummy head node. The overall diagram illustrates the step-by-step process of merging two sorted linked lists.

If one of the linked lists has been entirely added to the merged list, we can just add the rest of the other linked list to the merged list:

Image represents a visual explanation of a merging algorithm, likely for linked lists.  The top section shows two linked lists; one light gray list with nodes labeled '2', '3', and '4', and another light gray list with a single node labeled '1' connected to a node labeled '5' (in bold, indicating a potential end point). A gray box labeled '12' points down to node '5'.  A gray box labeled '11' points down to node '4'.  A dashed-line box contains the condition '11 == null → add the rest of the second list to the merged list.'  The bottom section depicts the result of the merge.  A light gray node labeled 'D' (possibly representing a dummy node or the beginning of the merged list) connects to a linked list with nodes labeled '1', '2', '3', '4', and '5', all highlighted in orange, indicating they are part of the merged list. The arrows show the direction of connections within each list and the flow of data during the merge operation.  The condition in the dashed box suggests that if the pointer '11' reaches the end of the first list (null), the remaining elements of the second list are appended to the merged list.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def sort_linked_list(head: ListNode) -> ListNode:
    # If the linked list is empty or has only one element, it's already sorted.
    if not head or not head.next:
        return head
    # Split the linked list into halves using the fast and slow pointer technique.
    second_head = split_list(head)
    # Recursively sort both halves.
    first_half_sorted = sort_linked_list(head)
    second_half_sorted = sort_linked_list(second_head)
    # Merge the sorted sublists.
    return merge(first_half_sorted, second_half_sorted)
    
def split_list(head: ListNode) -> ListNode:
    slow = fast = head
    while fast.next and fast.next.next:
        slow = slow.next
        fast = fast.next.next
    second_head = slow.next
    slow.next = None
    return second_head
    
def merge(l1: ListNode, l2: ListNode) -> ListNode:
    dummy = ListNode(0)
    # This pointer will be used to append nodes to the tail of the merged linked list.
    tail = dummy
    # Continually append the node with the smaller value from each linked list to the
    # merged linked list until one of the linked lists has no more nodes to merge.
    while l1 and l2:
        if l1.val < l2.val:
            tail.next = l1
            l1 = l1.next
        else:
            tail.next = l2
            l2 = l2.next
        tail = tail.next
    # One of the two linked lists could still have nodes remaining. Attach those nodes
    # to the end of the merged linked list.
    tail.next = l1 or l2
    return dummy.next
function sortLinkedList(head) {
  // If the linked list is empty or has only one element, it's already sorted.
  if (!head || !head.next) {
    return head;
  }
  // Split the linked list into halves using the fast and slow pointer technique.
  const secondHead = splitList(head);
  // Recursively sort both halves.
  const firstHalfSorted = sortLinkedList(head);
  const secondHalfSorted = sortLinkedList(secondHead);
  // Merge the sorted sublists.
  return merge(firstHalfSorted, secondHalfSorted);
}

function splitList(head) {
  let slow = head, fast = head;
  while (fast.next && fast.next.next) {
    slow = slow.next;
    fast = fast.next.next;
  }
  const secondHead = slow.next;
  slow.next = null;
  return secondHead;
}

function merge(l1, l2) {
  const dummy = new ListNode(0);
  let tail = dummy;
  while (l1 && l2) {
    if (l1.val < l2.val) {
      tail.next = l1;
      l1 = l1.next;
    } else {
      tail.next = l2;
      l2 = l2.next;
    }
    tail = tail.next;
  }
  tail.next = l1 || l2;
  return dummy.next;
}
public ListNode sortLinkedList(ListNode head) {
    // If the linked list is empty or has only one element, it's already sorted.
    if (head == null || head.next == null) {
        return head;
    }
    // Split the linked list into halves using the fast and slow pointer technique.
    ListNode secondHead = splitList(head);
    // Recursively sort both halves.
    ListNode firstHalfSorted = sortLinkedList(head);
    ListNode secondHalfSorted = sortLinkedList(secondHead);
    // Merge the sorted sublists.
    return merge(firstHalfSorted, secondHalfSorted);
}

private ListNode splitList(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    ListNode secondHead = slow.next;
    slow.next = null;
    return secondHead;
}

private ListNode merge(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(0);
    ListNode tail = dummy;
    while (l1 != null && l2 != null) {
        if (l1.val < l2.val) {
            tail.next = l1;
            l1 = l1.next;
        } else {
            tail.next = l2;
            l2 = l2.next;
        }
        tail = tail.next;
    }
    tail.next = (l1 != null) ? l1 : l2;
    return dummy.next;
}

Complexity Analysis

Time complexity: The time complexity of sort_linked_list is O(nlog⁡(n))O(n\log(n)) because it uses merge sort. Here’s the breakdown:

  • The linked list is recursively split until each sublist contains only one node. This splitting process happens about log⁡2(n)\log_2(n) times because each split reduces the size of the linked list by half.

  • At each level, we merge the split linked lists. Merging all elements at one level takes about nn operations.

  • Since there are log⁡2(n)\log_2(n) levels of splitting and merging, and there are nn operations at each level, the time complexity is O(nlog⁡(n))O(n\log(n)).

Space complexity: The space complexity is O(log⁡(n))O(\log(n)) due to the recursive call stack, which can grow up to log⁡2(n)\log_2(n) in height.

Stable Sorting

Merge sort is a stable sorting algorithm. This is important in scenarios where the original order of equal elements must be preserved. For example, if we are sorting a list of nodes that share the same value, but have an additional attribute storing the time they were created, then it’s important for the relative order of these nodes to stay the same. Stability can be important for database systems; for instance, where stable sorting is required to maintain data integrity and consistency across multiple operations.

CHAPTER 108

Sort Array

Given an integer array, sort the array in ascending order.

Example:

Example
Input: nums = [6, 8, 4, 2, 7, 3, 1, 5]
Output: [1, 2, 3, 4, 5, 6, 7, 8]

Intuition

This problem is quite open-ended because there are many sorting algorithms we could use to sort an array, each with its own pros and cons. In this explanation, we focus on the quicksort algorithm, which runs in O(nlog⁡(n))O(n\log(n)) time on average, where nn denotes the length of the array.

Quicksort
Conceptually, the goal of quicksort is to sort the array by placing each number in its sorted position one at a time. To correctly position a number, we move all numbers smaller than it to its left, and all numbers larger than it to its right. At each step, we call this number the pivot. We discuss how a pivot is selected later in the explanation.

Image represents a visual depiction of a partitioning step within a sorting algorithm, likely Quicksort.  The top row shows an unsorted array [6, 8, 4, 2, 7, 3, 1, 5], with the element '5' highlighted in an orange box and labeled 'pivot'. A downward arrow indicates the partitioning process. The bottom row displays the array after partitioning.  The numbers less than the pivot (5) are grouped in a light-blue box labeled 'less than 5' as [4, 2, 3, 1]. The pivot (5) is shown in an orange box, positioned between the two groups, and labeled 'in the correct position'. The numbers greater than the pivot are grouped in a light-purple box labeled 'greater than 5' as [6, 8, 7]. This illustrates how the pivot element is placed in its final sorted position, with all smaller elements to its left and all larger elements to its right, a key step in the Quicksort algorithm.

This process is called partitioning because we’re dividing the array into two parts around the pivot:

  1. The left part with elements smaller than the pivot.
  2. The right part with elements larger than the pivot.

Note that neither the left nor the right part of the pivot value need to be sorted. All that matters is that the pivot is in the correct position.

After partitioning, we just need to sort the left and right parts. We can do this by recursively calling quicksort on both of them. This makes quicksort a divide and conquer strategy. The pseudocode for this is provided below:

def quicksort(nums, left, right):
    # Partition the array and obtain the index of the pivot.
    pivot_index = partition(nums, left, right)
    # Sort the left and right parts.
    quicksort(nums, left, pivot_index - 1)
    quicksort(nums, pivot_index + 1, right)
function quicksort(nums, left, right) {
  // Partition the array and obtain the index of the pivot.
  const pivotIndex = partition(nums, left, right);
  // Sort the left and right parts.
  quicksort(nums, left, pivotIndex - 1);
  quicksort(nums, pivotIndex + 1, right);
}
public void quicksort(int[] nums, int left, int right) {
    // Partition the array and obtain the index of the pivot.
    int pivotIndex = partition(nums, left, right);
    // Sort the left and right parts.
    quicksort(nums, left, pivotIndex - 1);
    quicksort(nums, pivotIndex + 1, right);
}

Let’s discuss the partitioning process in more detail.

Partitioning
There are two primary steps to partitioning:

  1. Selecting the pivot.
  2. Rearranging the elements so elements smaller than the pivot are on its left, and elements larger than the pivot are on its right.

Selecting the pivot
We can actually choose any number as the pivot. The method of selecting a pivot can be optimized, which is discussed later, but for simplicity, we can choose the rightmost number as the pivot:

Image represents a sequence of numbers, [6, 8, 4, 2, 7, 3, 1, 5], displayed horizontally.  Each number is positioned above its index, indicated in grey text below (0 through 7). The number 5, located at index 7, is highlighted within an orange rectangular box, labeled 'pivot' in orange text to its upper right.  The arrangement visually depicts a data array, with the highlighted '5' signifying a pivot element within the context of a sorting algorithm or similar data structure operation.  No URLs or parameters are present; the image solely illustrates the positional relationship between the numbers and the designated pivot.

Rearranging elements around the pivot
Let’s see if we can do this in linear time without using extra space.

If we can ensure all numbers less than the pivot are placed to the left, then numbers greater than or equal to the pivot will consequently be placed to the right. This can be done using two pointers:

  • One pointer at the left of the array (lo) to position the numbers less than the pivot.

  • One pointer to iterate through the array (i), looking for numbers less than the pivot.

The main idea here is that whenever we encounter a number less than the pivot, swap it with nums[lo].


Let's look at the example below to understand how this works. First, keep advancing i until we find a number less than the pivot:

Image represents a visual depiction of a portion of a sorting algorithm, likely Quicksort.  The diagram shows an array `[6, 8, 4, 2, 7, 3, 1, 5]` with indices 0 through 7 displayed below each element.  Above the array, two boxes labeled '10' (light gray) and 'i' (black) indicate variables. Arrows point from both '10' and 'i' downwards, suggesting these variables are used to iterate through the array.  To the right, a separate box labeled 'pivot' contains the value '5', indicating the pivot element for this iteration. A dashed-line box depicts a conditional statement: 'nums[i] > pivot'.  An arrow extends from this condition to the word 'continue,' illustrating that if the condition `nums[i] > pivot` is true, the algorithm continues to the next iteration.  The overall diagram illustrates a single step in the partitioning phase of a sorting algorithm, where elements are compared to the pivot to determine their position in the sorted array.
Image represents a snapshot of a sorting algorithm, likely Quicksort, illustrating a single iteration.  A numerical array `[6, 8, 4, 2, 7, 3, 1, 5]` is shown with indices 0 through 7 displayed below each element.  A grey box labeled 'lo' (likely representing the lower index of a partition) points to the element '6' at index 0, and a black box labeled 'i' (likely representing the current index) points to the element '8' at index 1. A dashed arrow connects 'lo' to 'i', suggesting a comparison or traversal between these indices.  A separate grey box labeled 'pivot' contains the value '5', indicating the pivot element selected for this partition.  A decision box with dashed borders shows a condition: `nums[i] > pivot`.  If this condition is true (as it is for '8'), a solid arrow points to the word 'continue', implying the algorithm proceeds to the next iteration, potentially incrementing 'i' to compare the next element.  The overall diagram visualizes the state of the algorithm during a comparison step within the partitioning phase of a sorting algorithm.
Image represents a visual depiction of a step in a sorting algorithm, likely Quicksort.  A numerical array `[6, 8, 4, 2, 7, 3, 1]` is shown, with indices 0 through 6 displayed below each element.  Above the array, two boxes labeled '10' (grey) and 'i' (black) indicate variables. A downward arrow from '10' points to the element '6' at index 0, suggesting '10' might represent the array's length or a related parameter.  A downward arrow from 'i' points to the element '4' at index 2. A dashed curved arrow connects 'i' to '8' at index 1, indicating a comparison or swap operation.  Finally, a separate box labeled 'pivot' contains the value '5', suggesting '5' is the pivot element selected for this partitioning step in the algorithm.  The arrangement visually demonstrates the algorithm's progress in partitioning the array around the pivot.

Once a number less than the pivot is found, move it to the left by swapping it with nums[lo]. Then, increment lo so it points to where the next number less than the pivot should be placed:

Image represents a visual depiction of a step within a sorting algorithm, likely Quicksort.  The left side shows an array `[6, 8, 4, 2, 7, 3, 1, 5]` with indices 0 through 7 displayed below each element.  Arrows point downwards from labeled boxes 'lo' (initially pointing to index 0) and 'i' (initially pointing to index 2) indicating their current positions within the array. The number 5 is labeled as 'pivot'. The right side shows a decision box with dashed borders.  This box contains a condition `nums[i] < pivot`, which checks if the element at index `i` (currently 4) is less than the pivot (5). If true, two actions are performed:  `swap(nums[i], nums[lo])` swaps the element at index `i` with the element at index `lo`, and `lo += 1` increments the `lo` index.  The arrows from the decision box represent the flow of control based on the condition's outcome.  The overall diagram illustrates a single iteration of the partitioning phase in a sorting algorithm, where elements smaller than the pivot are moved to the left side of the array.
Image represents a step in a sorting algorithm, likely Quicksort, illustrating a swap operation.  The array `[4, 8, 6, 2, 7, 3, 1]` is shown with indices 0 through 6 displayed below each element in gray.  A light-blue box labeled 'lo' points down to the element at index 0 (value 4), and a black box labeled 'i' points down to the element at index 2 (value 6). A curved light-blue arrow labeled 'swapped' connects the element at index 0 (4) to the element at index 2 (6), indicating these two elements have been exchanged.  To the right, a gray box labeled 'pivot' contains the value 5, which is likely the pivot element around which the partition is being built.  The arrangement visually depicts the state of the array during the partitioning phase of the algorithm, where elements smaller than the pivot are moved to the left and larger elements to the right.
Image represents a visual depiction of a step in a sorting algorithm, likely Quicksort.  An array [4, 8, 6, 2, 7, 3, 1] is shown with indices 0 through 6 displayed below each element. A light-blue box labeled 'lo' (likely representing the low index) points to the element '8' at index 1 via a dashed arrow indicating a potential swap or comparison. A black box labeled 'i' (likely representing the iterator or current index) points to the element '6' at index 2 with a solid arrow.  A grey box labeled 'pivot' contains the value '5' at index 7, indicating this element is the pivot around which the partition is being formed. The arrangement shows the algorithm's progress in partitioning the array based on the pivot value.  The dashed arrow from 'lo' suggests a comparison or movement of the '8' element relative to the pivot. The solid arrow from 'i' indicates the iterator's position in the array during the partitioning process.

Continue this process until all numbers less than the pivot are on the left of the array:

The image represents a visual depiction of a pivot element in an array during a sorting algorithm, likely Quicksort.  A numerical array `[4, 2, 3, 1, 8, 6, 7, 5]` is shown, with indices 0 through 7 displayed below each element.  Elements 4, 2, and 3 are colored light blue, indicating they might be processed or sorted already.  The number 1 is also light blue, suggesting it's part of a sorted or processed section. The number 8, 6, and 7 are in black, implying they are yet to be processed.  A light gray box labeled 'pivot' contains the number 5, located at index 7, signifying it's the chosen pivot element for this iteration.  A light blue box above the array contains the number '10,' which might represent the total number of elements or a related parameter. A downward-pointing arrow connects the '10' box to the number 8 in the array, possibly indicating the pivot selection process or the current position of the algorithm's pointer.

The last step is to move the pivot to the correct position by swapping the pivot with nums[lo]:

Image represents a visual depiction of a single step in a sorting algorithm, likely Quicksort.  The image shows an array [4, 2, 3, 1, 5, 6, 7, 8] with indices 0 through 7 displayed below each element. The number 5 is highlighted in a light beige box with an orange border and labeled 'pivot'.  A curved orange arrow connects the element 1 (at index 3) and the element 8 (at index 7), labeled 'swapped,' indicating that these two elements have been exchanged during the partitioning phase of the algorithm.  The arrangement visually demonstrates the in-place swapping of elements relative to the pivot element during the partitioning process of a sorting algorithm.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

from typing import List
    
def sort_array(nums: List[int]) -> List[int]:
    quicksort(nums, 0, len(nums) - 1)
    return nums
    
def quicksort(nums: List[int], left: int, right: int) -> None:
    # Base case: if the subarray has 0 or 1 element, it's already sorted.
    if left >= right:
        return
    # Partition the array and retrieve the pivot index.
    pivot_index = partition(nums, left, right)
    # Call quicksort on the left and right parts to recursively sort them.
    quicksort(nums, left, pivot_index - 1)
    quicksort(nums, pivot_index + 1, right)
    
def partition(nums: List[int], left: int, right: int) -> int:
    pivot = nums[right]
    lo = left
    # Move all numbers less than the pivot to the left, which consequently positions
    # all numbers greater than or equal to the pivot to the right.
    for i in range(left, right):
        if nums[i] < pivot:
            nums[lo], nums[i] = nums[i], nums[lo]
            lo += 1
    # After partitioning, 'lo' will be positioned where the pivot should be. So, swap
    # the pivot number with the number at the 'lo' pointer.
    nums[lo], nums[right] = nums[right], nums[lo]
    return lo
function sortArray(nums) {
  quicksort(nums, 0, nums.length - 1);
  return nums;
}

function quicksort(nums, left, right) {
  // Base case: if the subarray has 0 or 1 element, it's already sorted.
  if (left >= right) return;
  // Partition the array and retrieve the pivot index.
  const pivotIndex = partition(nums, left, right);
  // Call quicksort on the left and right parts to recursively sort them.
  quicksort(nums, left, pivotIndex - 1);
  quicksort(nums, pivotIndex + 1, right);
}

function partition(nums, left, right) {
  const pivot = nums[right];
  let lo = left;
  for (let i = left; i < right; i++) {
    if (nums[i] < pivot) {
      [nums[lo], nums[i]] = [nums[i], nums[lo]];
      lo++;
    }
  }
  [nums[lo], nums[right]] = [nums[right], nums[lo]];
  return lo;
}
public int[] sortArray(int[] nums) {
    quicksort(nums, 0, nums.length - 1);
    return nums;
}

private void quicksort(int[] nums, int left, int right) {
    // Base case: if the subarray has 0 or 1 element, it's already sorted.
    if (left >= right) return;
    int pivotIndex = partition(nums, left, right);
    quicksort(nums, left, pivotIndex - 1);
    quicksort(nums, pivotIndex + 1, right);
}

private int partition(int[] nums, int left, int right) {
    int pivot = nums[right];
    int lo = left;
    for (int i = left; i < right; i++) {
        if (nums[i] < pivot) {
            int temp = nums[lo];
            nums[lo] = nums[i];
            nums[i] = temp;
            lo++;
        }
    }
    int temp = nums[lo];
    nums[lo] = nums[right];
    nums[right] = temp;
    return lo;
}

Complexity Analysis

Time complexity: The time complexity of sort_array can be analyzed in terms of the average and worst cases:

  • Average case: O(nlog⁡(n))O(n\log(n)). In the average case, quicksort effectively divides the array into two roughly equal parts after each partition, leading to a recursive tree with a depth of approximately log⁡2(n)\log_2(n). For each of these levels, we perform a partition which takes O(n)O(n) time, resulting in a total time complexity of O(nlog⁡(n))O(n\log(n)).

  • Worst case: O(n2)O(n^2). The worst-case scenario occurs when the pivot selection consistently results in extremely unbalanced partitions, such as when the smallest or largest element is always chosen as a pivot, which is explained in more detail in the optimization. Uneven partitioning can result in a recursive depth as deep as nn. For each of these nn levels of recursion, we perform a partition which takes O(n)O(n) time, resulting in a total time complexity of O(n2)O(n^2).

Space complexity: The space complexity can also be analyzed in terms of the average and worst cases:

  • Average case: O(log⁡(n))O(\log(n)). In the average case, the depth of the recursive call stack is approximately log⁡2(n)\log_2(n).

  • Worst case: O(n)O(n). In the worst case, the depth of the recursive call stack can be as deep as nn.

Optimization

As mentioned in the above complexity analysis, it’s possible for a worst-case time complexity of O(n2)O(n^2) to occur. Let’s dive into when this can happen. Consider the following array, which is already sorted:

Image represents a simple array or list data structure depicted using square brackets `[]` to enclose five integer elements: 1, 2, 3, 4, and 5.  These elements are arranged sequentially within the brackets, separated by spaces, indicating their order within the data structure. No connections or information flow is explicitly shown beyond the inherent sequential arrangement of the elements within the array.  The image solely illustrates the basic structure of a list containing five integers, with no further metadata, labels, or external connections depicted.

If we perform quicksort on this array, we choose the rightmost element as the pivot and partition the other elements around this pivot. However, since this pivot is the largest element, there will be n-1 elements less than the pivot and 0 elements greater than or equal to it:

Image represents a visual depiction of a partitioning step within a sorting algorithm, likely Quicksort.  The illustration shows an array represented as `[1 2 3 4 5]`. The number 5 is highlighted in an orange box and labeled 'pivot'.  A light-blue rounded rectangle encloses the numbers 1, 2, 3, and 4, labeled 'left part' in light-blue text. A purple arrow originates from the right side of the pivot (5) and points to the right, indicating the absence of a 'right part' in this specific step.  The overall arrangement demonstrates the partitioning process where elements smaller than the pivot are placed to its left, and in this case, no elements are larger than the pivot, resulting in an empty right partition.

This creates quite an uneven partition. When we call quicksort on the left part, the same imbalance will occur, but with a left part consisting of n-2 elements:

Image represents a visual depiction of a partitioning step in a sorting algorithm, likely Quicksort.  The image shows a numerical array `[1, 2, 3, 4]` enclosed in square brackets. The first three elements, `[1, 2, 3]`, are grouped together in a light-blue rounded rectangle and labeled in light-blue text as 'left part'. The last element, `4`, is separately enclosed in an orange rounded rectangle and labeled above in orange text as 'pivot'. A purple arrow originates from the bottom of the 'pivot' rectangle and points to the right, indicating the direction of data flow, and is labeled in purple text as 'no right part'. This illustrates a scenario where the pivot element is already in its correct sorted position, resulting in an empty right partition after the partitioning step.  The arrangement visually separates the array into a 'left part' and an implicitly empty 'right part' based on the pivot's position.

Continuing this until the quicksort process is complete results in a recursion depth of n.

An uneven partition occurs when we choose an extreme pivot: one that’s larger or smaller than most other elements. Consistently picking an extreme pivot can occur when the array is sorted in increasing or decreasing order, or when there are many duplicates in the array.

To reduce the likelihood of choosing an extreme pivot, we can modify quicksort to choose a random pivot instead. There still remains a small chance of consistently picking an extreme pivot, but this outcome is no longer dependent on the order of the input array. A more detailed answer is discussed in the provided reference [1].

We can integrate this change into our current solution by randomly selecting an index and swapping its element with the rightmost element, before performing the partition. This way, we don't have to modify the partition function, as it uses the rightmost element:

def quicksort_optimized(nums: List[int], left: int, right: int) -> None:
    if left >= right:
        return
    # Choose a pivot at a random index.
    random_index = random.randint(left, right)
    # Swap the randomly chosen pivot with the rightmost element to position the pivot
    # at the rightmost index.
    nums[random_index], nums[right] = nums[right], nums[random_index]
    pivot_index = partition(nums, left, right)
    quicksort_optimized(nums, left, pivot_index - 1)
    quicksort_optimized(nums, pivot_index + 1, right)
function quicksortOptimized(nums, left, right) {
  if (left >= right) return;
  // Choose a pivot at a random index.
  const randomIndex = left + Math.floor(Math.random() * (right - left + 1));
  // Swap the randomly chosen pivot with the rightmost element.
  [nums[randomIndex], nums[right]] = [nums[right], nums[randomIndex]];
  const pivotIndex = partition(nums, left, right);
  quicksortOptimized(nums, left, pivotIndex - 1);
  quicksortOptimized(nums, pivotIndex + 1, right);
}
private Random rand = new Random();

public void quicksortOptimized(int[] nums, int left, int right) {
    if (left >= right) return;
    int randomIndex = left + rand.nextInt(right - left + 1);
    int temp = nums[randomIndex];
    nums[randomIndex] = nums[right];
    nums[right] = temp;
    int pivotIndex = partition(nums, left, right);
    quicksortOptimized(nums, left, pivotIndex - 1);
    quicksortOptimized(nums, pivotIndex + 1, right);
}

Interview Follow-Up

Let’s say the interviewer introduces the following constraints to the initial sorting problem:

  • The input array does not contain negative values.
  • All values in the input array are less than or equal to 10310^3.

Does our approach to the problem change, and should we still use quicksort? Considering that all values in our array now fall within the limited range of [0, 10310^3], a counting sort approach becomes appropriate.

Counting sort
Counting sort is a non-comparison-based sorting algorithm that works by counting the number of occurrences of each element in the array, then using these counts to place each element in its correct sorted position:

Image represents a visual explanation of a counting sort algorithm.  At the top, a list named `nums` is shown, containing the integer values [2, 1, 0, 0, 2, 4, 2]. A gray line underlines this list. Below, a list called `counts` is displayed, representing the frequency of each element in `nums`.  `counts` contains [2, 1, 3, 0, 1], indicating two 0s, one 1, three 2s, zero 3s, and one 4.  Small gray numbers (0, 1, 2, 3, 4) are placed above `counts` to clarify the index corresponding to each element's count. Orange curved arrows connect each element in `counts` to the corresponding number of elements in the final `res` list.  `res` is the sorted output list, shown at the bottom as [0, 0, 1, 2, 2, 2, 4], where each element's position reflects its sorted order based on the counts in the `counts` list.  The arrows visually demonstrate how the frequency information in `counts` is used to populate the sorted `res` list.

We can do this in two steps:

  1. Count occurrences: create a counts array, where each of its indexes represents an element from the original array. Increment the value at each index based on how many times the corresponding element appears in the original array.

  2. Build sorted array (res): iterate through each index of the counts array and add that index (i) to the sorted array as many times as its value (counts[i]) indicates.

Counting sort is efficient here because we know the largest possible number in the array is at most 10310^3, which means our counts array will have a maximum size of 10310^3 + 1. However, if this problem constraint is not specified and the maximum value in the array may be very large, then a counting sort solution might not be appropriate, due to the potentially large size of the counts array.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

Note that there’s another common method for implementing counting sort, which is detailed in the reference provided [2].

from typing import List
    
def sort_array_counting_sort(nums: List[int]) -> List[int]:
    if not nums:
        return []
    res = []
    # Count occurrences of each element in 'nums'.
    counts = [0] * (max(nums) + 1)
    for num in nums:
        counts[num] += 1
    # Build the sorted array by appending each index 'i' to it a total of 'counts[i]'
    # times.
    for i, count in enumerate(counts):
        res.extend([i] * count)
    return res
function sortArrayCountingSort(nums) {
  if (!nums || nums.length === 0) return [];
  const res = [];
  const maxVal = Math.max(...nums);
  const counts = new Array(maxVal + 1).fill(0);
  for (const num of nums) {
    counts[num]++;
  }
  for (let i = 0; i < counts.length; i++) {
    for (let c = 0; c < counts[i]; c++) {
      res.push(i);
    }
  }
  return res;
}
public int[] sortArrayCountingSort(int[] nums) {
    if (nums == null || nums.length == 0) return new int[]{};
    int maxVal = 0;
    for (int num : nums) maxVal = Math.max(maxVal, num);
    int[] counts = new int[maxVal + 1];
    for (int num : nums) counts[num]++;
    int[] res = new int[nums.length];
    int idx = 0;
    for (int i = 0; i < counts.length; i++) {
        for (int c = 0; c < counts[i]; c++) {
            res[idx++] = i;
        }
    }
    return res;
}

Complexity Analysis

Time complexity: The time complexity of sort_array_counting_sort is O(n+k)O(n+k), where kk denotes the maximum value of nums. This is because it takes O(n)O(n) time to count the occurrences of each element and O(k)O(k) time to build the sorted array.

Space complexity: The space complexity is O(n+k)O(n+k), since the res array occupies O(n)O(n) space, and the counts array takes up O(k)O(k) space. Note that res is considered in the space complexity, as counting sort is not an in-place sorting algorithm requiring an additional array to store the sorted result.

Interview Tip

Tip: Quicksort is useful for in-place sorting.
While quicksort isn’t a stable sorting algorithm, it is an in-place sorting algorithm, meaning it requires less additional space compared to an algorithm such as merge sort, which takes O(n)O(n) space when used to sort an array.

CHAPTER 109

Kth Largest Integer

Return the kth largest integer in an array.

Example:

Example
Input: nums = [5, 2, 4, 3, 1, 6], k = 3
Output: 4

Constraints:

  • The array contains no duplicates.
  • The array contains at least one element.
  • 1 ≤ k ≤ n, where n denotes the length of the array.

Intuition - Min-Heap

A straightforward solution to this problem is to sort the array in reverse order and return the number at the (k - 1)th index:

Image represents a visual explanation of finding the k<sup>th</sup> largest element in an array.  The diagram begins with an unsorted array `[5 2 4 3 1 6]` where `k` is defined as 3.  The array's elements are indexed from 0 to 5, shown below each element. A rightward arrow labeled 'sort' indicates a sorting operation transforming the initial array into a sorted array `[6 5 4 3 2 1]`, maintaining the same indexing.  Above the sorted array, an orange arrow points down to the element at index 2 (the third largest element), which is 4, highlighting that the 3<sup>rd</sup> largest element (k=3) is 4.  The element '4' is circled in a peach color to emphasize its selection as the k<sup>th</sup> largest element.

This solution takes O(nlog⁡(n))O(n\log(n)) time, but as we only need the kth largest integer, sorting all the elements in the input array might not be necessary. Let’s explore some solutions which take this into account.

An interesting thing to realize is that if we know what the top k largest integers are, we’d know that the smallest of these would be the kth largest integer:

Image represents a visual depiction of finding the top k largest elements from a list.  The input is shown as a list `[5 2 4 3 1 6]` enclosed in square brackets, with each number circled in orange.  Arrows in orange indicate the selection of the three largest numbers (5, 4, and 6). These three numbers are then collected into a new list `[5 4 6]`, labeled 'top k largest:', with the number 4 highlighted in a light green circle. A green downward-pointing arrow from the light green circle points to the word 'min', suggesting a subsequent step might involve finding the minimum element within the top k largest elements.  The overall diagram illustrates a process of selecting the top k elements and potentially further processing them.

This leads to the idea that instead of sorting the entire array, we can keep track of the top k largest integers in the array. Is there a way to maintain the top k largest integers, while having access to the smallest of these integers?

A min-heap seems like it should work well for this purpose because it provides efficient access to the smallest element. Let’s explore how to use it to maintain the top k integers in the array.


Consider the below example with k = 3. Let’s begin by pushing the first k integers to the heap:

Image represents a visual depiction of a min-heap data structure being populated.  A downward-pointing arrow originates from a small square containing the letter 'i', suggesting an input source. This arrow points to a bracketed array `[5 2 4 3 1 6]`, where each number represents an element, and the subscripts (0, 1, 2, 3, 4, 5) indicate their indices within the array.  To the right, a trapezoidal shape labeled 'min_heap' visually represents the min-heap structure.  The number '5' is shown at the top of the min-heap, indicating that it's the root node (the smallest element). The image illustrates the initial step of building a min-heap, where the first element from the input array (5) is placed at the root of the min-heap.  The remaining elements (2, 4, 3, 1, 6) are implicitly implied to be added subsequently to maintain the min-heap property (where the value of each node is less than or equal to the values of its children).

Image represents a visual depiction of a min-heap data structure being built.  A downward-pointing arrow labeled 'i' points to an array `[5, 2, 4, 3, 1, 6]`, where each element's index (0-5) is shown below it in gray.  This array represents the input data. To the right, a min-heap is shown, visually represented as a trapezoidal structure with a purple border and labeled 'min_heap'. The top node contains the value '2', and the bottom node contains the value '5'.  The diagram illustrates an intermediate step in the process of constructing a min-heap from the input array; the '2' at the top of the min-heap suggests that the algorithm has already processed at least the first few elements of the array, placing the smallest element found so far at the root of the heap.  The remaining elements in the array are yet to be incorporated into the min-heap structure.

Image represents a visual depiction of a data structure transformation.  A small square labeled 'i' points downwards to an array `[5, 2, 4, 3, 1, 6]`, where each element is indexed from 0 to 5 below it.  This array is being transformed into a min-heap data structure, shown as a trapezoidal shape with a purple outline labeled 'min_heap'. The min-heap contains the numbers 2, 4, and 5, arranged such that 2 is at the top, 4 is below and to the left, and 5 is below and to the right.  The arrangement suggests that the value of each node is less than or equal to the values of its children, a defining characteristic of a min-heap.  The arrow from 'i' implies that the array is the input to the process of creating the min-heap.

We shouldn’t immediately push the next element, 3, in the heap because the heap already contains k integers. This gives us a choice:

  • Skip 3, or:
  • Remove an integer from the heap and push 3 in.

The smallest integer in the heap is 2. Integer 3 is more likely to belong in the top k integers than 2 is, so let’s pop 2 from the heap and push 3 in:

Image represents a step-by-step illustration of a coding pattern involving a min-heap data structure.  The top left shows an array `nums` with elements [5, 2, 4, 3, 1, 6], indexed from 0 to 5, with an arrow pointing to the element at index 3 (value 3).  To the right, a min-heap is depicted, initially containing elements 4 and 5, with a 2 (crossed out in red) at the top, indicating it's about to be removed. A dashed box next to the heap describes the algorithm: if `nums[i]` (the current element being processed from the array) is greater than the top element of the min-heap, then the top element is popped (in this case, 2), and `nums[i]` (which is 3) is pushed onto the min-heap. The bottom part shows the min-heap after this operation, now containing 3, 4, and 5.  The arrow connecting the top and bottom min-heap diagrams visually represents the transformation resulting from the algorithm described in the dashed box.

The next integer is 1, which is smaller than the smallest integer in the heap. So, we know it’s not among the top k integers:

Image represents a step in an algorithm, likely involving a min-heap data structure.  A numbered array `[5 2 4 3 1 6]` is shown, with an index `i` (represented by a box labeled 'i') pointing to the element '1' at index 4.  A min-heap is depicted visually as a tree structure with elements 3 (at the top), 4, and 5. The label 'min_heap' is below the heap. A downward arrow from 'i' points to the array element '1', indicating the current element being processed. A dashed box contains the condition `nums[i] < min_heap.top`, which checks if the current array element (1) is less than the top element of the min-heap (3).  A right-pointing arrow from this condition indicates that if the condition is true, the algorithm continues.  The overall diagram illustrates a comparison between an element from an input array and the top element of a min-heap, a common operation in heap-based algorithms like heapsort or priority queue management.

The final integer is 6. It’s larger than the smallest integer in the heap. So, let’s pop off the top of the heap and push in 6:

Image represents a step-by-step illustration of a coding pattern involving a min-heap data structure.  The top section shows an array `nums` with elements [5, 2, 4, 3, 1, 6] indexed from 0 to 5, and a min-heap represented as a purple triangle containing the values 4 and 5.  A downward arrow points from an index `i` (represented by a small square labeled 'i') to the min-heap, indicating that the element at index `i` (which is 1 in this case) is being processed. A separate box describes the condition `nums[i] > min_heap.top` and the actions taken if the condition is true: popping the top element (3) from the min-heap and pushing `nums[i]` (1) onto the min-heap. The bottom section shows the resulting min-heap after these operations, now containing 4, 5, and 6, illustrating the updated state of the min-heap after processing the element at index `i`.  A horizontal arrow connects the before and after states of the min-heap.

At this point, the k integers in the heap represent the top k largest integers from the array, with the smallest of these integers located at the top of the heap. This smallest value is the kth largest integer in the entire array, so we can just return this integer from the top of the heap:

The image represents a data structure visualization illustrating the retrieval of the minimum element from a min-heap.  A rectangular box labeled '4' in light purple, representing the top element of the min-heap, is shown with a green arrow pointing right towards the text 'return min_heap.top' written in green. This arrow indicates the action of returning the top element. Below the top element, a trapezoidal shape labeled 'min_heap' in purple contains the numbers '5' and '6', visually representing the remaining elements of the min-heap. The arrangement shows that the min-heap is structured such that the smallest element (4) is at the top, and the subsequent elements (5 and 6) are arranged below, implicitly suggesting a hierarchical structure where the parent node (4) is smaller than its children (5 and 6). The overall diagram demonstrates the process of accessing the minimum value from a min-heap data structure, where `min_heap.top` refers to accessing the top (minimum) element.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Min-Heap

from typing import List
import heapq
    
def kth_largest_integer_min_heap(nums: List[int], k: int) -> int:
    min_heap = []
    heapq.heapify(min_heap)
    for num in nums:
        # Ensure the heap has at least 'k' integers.
        if len(min_heap) < k:
            heapq.heappush(min_heap, num)
        # If 'num' is greater than the smallest integer in the heap, pop off this
        # smallest integer from the heap and push in 'num'.
        elif num > min_heap[0]:
            heapq.heappop(min_heap)
            heapq.heappush(min_heap, num)
    return min_heap[0]
// In JavaScript, MinPriorityQueue from datastructures-js or a simple sorted array/binary heap can be used:
class MinHeap {
  constructor() { this.heap = []; }
  push(val) {
    this.heap.push(val);
    this._up(this.heap.length - 1);
  }
  pop() {
    const top = this.heap[0];
    const bottom = this.heap.pop();
    if (this.heap.length > 0) {
      this.heap[0] = bottom;
      this._down(0);
    }
    return top;
  }
  peek() { return this.heap[0]; }
  size() { return this.heap.length; }
  _up(i) {
    while (i > 0) {
      const p = Math.floor((i - 1) / 2);
      if (this.heap[p] <= this.heap[i]) break;
      [this.heap[p], this.heap[i]] = [this.heap[i], this.heap[p]];
      i = p;
    }
  }
  _down(i) {
    const len = this.heap.length;
    while (2 * i + 1 < len) {
      let left = 2 * i + 1, right = 2 * i + 2, best = left;
      if (right < len && this.heap[right] < this.heap[left]) best = right;
      if (this.heap[i] <= this.heap[best]) break;
      [this.heap[i], this.heap[best]] = [this.heap[best], this.heap[i]];
      i = best;
    }
  }
}

function kthLargestIntegerMinHeap(nums, k) {
  const minHeap = new MinHeap();
  for (const num of nums) {
    if (minHeap.size() < k) {
      minHeap.push(num);
    } else if (num > minHeap.peek()) {
      minHeap.pop();
      minHeap.push(num);
    }
  }
  return minHeap.peek();
}
public int kthLargestIntegerMinHeap(int[] nums, int k) {
    PriorityQueue<Integer> minHeap = new PriorityQueue<>();
    for (int num : nums) {
        if (minHeap.size() < k) {
            minHeap.offer(num);
        } else if (num > minHeap.peek()) {
            minHeap.poll();
            minHeap.offer(num);
        }
    }
    return minHeap.peek();
}

Complexity Analysis

Time complexity: The time complexity of kth_largest_integer_min_heap is O(nlog⁡(k))O(n\log(k)) because for each integer, we perform at most one push and pop operation on the min-heap, which has a size no larger than kk. Each heap operation takes O(log⁡(k))O(\log(k)) time.

Space complexity: The space complexity is O(k)O(k) because the heap can grow to a size of kk.

Intuition - Quickselect

The previous approach involved keeping track of the k largest integers to identify the kth largest integer. This effectively required us to keep these k values partially sorted through the use of a heap. But what if there’s a way to position only the kth largest value in its expected sorted position, without sorting the other numbers?

Image represents a comparison of three different approaches to sorting an array.  The input array `[5 2 4 3 1 6]` is shown at the left.  Three arrows emanate from this array, each representing a different sorting method. The top arrow leads to a fully sorted array `[1 2 3 4 5 6]`, labeled 'sort the whole array:', with a time complexity of O(nlog(n)). The middle arrow points to a partially sorted array `[... 4 5 6]`, labeled 'sort the k largest integers:', showing only the k largest integers sorted, with a time complexity of O(nlog(k)).  The bottom arrow shows a final array `[... 4 ...]`, labeled 'only position the k<sup>th</sup> largest integer in its sorted index:', where only the k<sup>th</sup> largest integer (in this case, 4) is placed in its correct sorted position. This last method is marked with '(faster?)', suggesting it's potentially more efficient than the previous two.  Each array displays indices below its elements for clarity.  The highlighted '4' in each output array emphasizes the focus on the k<sup>th</sup> largest element.

Quickselect can be used to achieve this.

Quickselect is an algorithm that leverages the partition step of quicksort, which positions a value in its sorted position. Quickselect is generally used to find the kth smallest element, whereas, in this problem, we're asked to find the kth largest element. To utilize quickselect for this purpose, we can instead find the (n - k)th smallest integer, which is equivalent to finding the kth largest element:

Image represents a visual explanation of finding the kth largest element in an array using the `quickselect` algorithm.  The diagram shows an initial unsorted array `[5 2 4 3 1 6]` with indices 0 through 5 displayed below each element.  Above the array, the equation 'k<sup>th</sup> largest == (n - k)<sup>th</sup> smallest' highlights the relationship between the kth largest and (n-k)th smallest elements, where 'n' is the array's size. A horizontal arrow connects the initial array to a partially sorted array `[2 1 3 4 5 6]`. This arrow is labeled `quickselect(n - k)`, indicating that the `quickselect` function is applied to find the (n-k)th smallest element.  The (n-k)th smallest element (which is also the kth largest) is highlighted in light green within the resulting array, specifically at index `n - k`, which is explicitly labeled with a green upward-pointing arrow.  The diagram visually demonstrates how finding the (n-k)th smallest element using `quickselect` effectively identifies the kth largest element.

Now, let’s dive into how quickselect works. To understand quickselect, we recommend you study the quicksort solution provided in the Sort Array problem.

Quickselect works identically to quicksort in that we:

1. Choose a pivot.
2. Partition the array into two parts by moving elements around the pivot so that:

  • Numbers less than the pivot are moved to its left.
  • Numbers greater than the pivot are moved to its right.

The primary difference between these two algorithms lies in the recursion step:

  • In quicksort, we recursively process both the left and right parts.
  • In quickselect, we only need to recursively process one of these parts.

Let’s explore why only one part is chosen.

Deciding which part of a partition to process
Suppose we pick a random number as a pivot. After performing a partition, this pivot will be positioned correctly. From here, there are three possibilities for the position of the (n - k)th smallest integer:

  • If the pivot is positioned before index n - k, it means the (n - k)th smallest integer must be somewhere to the right of the pivot. So, perform quickselect on the right part:

    Image represents a segment of a Quickselect algorithm visualization.  At the top, a conditional statement 'if pivot_index < n - k:' is shown, indicating a decision point in the algorithm. Below this, a visual representation of an array is presented.  The array is shown as `[ . . pivot . . . . ]`, where `pivot` is a gray square representing the pivot element, and the dots represent other elements. A peach-colored rectangle highlights a portion of the array to the right of the pivot. A green upward-pointing arrow labeled 'index n - k' connects to the right side of this peach rectangle, indicating that this section of the array contains `n - k` elements, where `n` is the array's size and `k` is a target rank. Finally, a light gray, dashed-bordered box to the right states 'perform quickselect on the right part,' indicating that if the condition `pivot_index < n - k` is true, the Quickselect algorithm recursively operates on the right sub-array (the peach-colored section) to find the k-th smallest element.
  • If the pivot is positioned after index n - k, perform quickselect on the left part:

    Image represents a segment of a Quickselect algorithm flowchart.  The top shows a conditional statement: `if pivot_index > n - k:`. Below this, a peach-colored rectangle represents an array, depicted with ellipses (...) indicating multiple elements, enclosed in square brackets `[ ... ]`.  A single element within the array is highlighted as `pivot` within a grey square. A green upward-pointing arrow labeled `index n - k` connects the array to the `pivot` element, indicating its position within the array. To the right, a dashed-line rectangle contains the text 'perform quickselect on the left part', describing the action taken if the conditional statement is true.  The overall structure illustrates a decision point in the algorithm: if the pivot's index is greater than `n - k`, then the Quickselect algorithm is recursively applied to the left portion of the array.
  • If the pivot is positioned exactly at index n - k, the pivot itself is the (n - k)th smallest integer. So, we just return the pivot:

    Image represents a code snippet illustrating a conditional return statement within a function.  The top line shows a conditional statement: `if pivot_index == n - k:`. This checks if the variable `pivot_index` is equal to the result of `n - k`, where 'n' and 'k' are presumably pre-defined variables. Below this, a visual representation of an array `[...]` is shown, with ellipses (...) indicating unspecified elements. A light-green square labeled 'pivot' highlights a specific element within the array. An upward arrow points from the text 'index n - k' to the 'pivot' square, indicating that `pivot_index` represents the index `n - k` within the array `nums`. To the right, a dashed-line box contains the code `return nums[pivot_index]`, signifying that if the condition in the first line is true, the function returns the element at the `pivot_index` within the `nums` array.  The overall diagram visually explains how a pivot element's index is calculated and used to return a value from an array based on a specific condition.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Quickselect

Note that the partition function in this implementation is identical to the partition function in Sort Array. Also, this implementation selects a random pivot, as per the optimization discussed in Sort Array.

import random
from typing import List
    
def kth_largest_integer_quickselect(nums: List[int], k: int) -> int:
    return quickselect(nums, 0, len(nums) - 1, k)
    
def quickselect(nums: List[int], left: int, right: int, k: int) -> None:
    n = len(nums)
    if left >= right:
        return nums[left]
    random_index = random.randint(left, right)
    nums[random_index], nums[right] = nums[right], nums[random_index]
    pivot_index = partition(nums, left, right)
    # If the pivot comes before 'n - k', the ('n - k')th smallest integer is somewhere
    # to its right. Perform quickselect on the right part.
    if pivot_index < n - k:
        return quickselect(nums, pivot_index + 1, right, k)
    # If the pivot comes after 'n - k', the ('n - k')th smallest integer is somewhere
    # to its left. Perform quickselect on the left part.
    elif pivot_index > n - k:
        return quickselect(nums, left, pivot_index - 1, k)
    # If the pivot is at index 'n - k', it's the ('n - k')th smallest integer.
    else:
        return nums[pivot_index]
    
def partition(nums: List[int], left: int, right: int) -> int:
    pivot = nums[right]
    lo = left
    for i in range(left, right):
        if nums[i] < pivot:
            nums[lo], nums[i] = nums[i], nums[lo]
            lo += 1
    nums[lo], nums[right] = nums[right], nums[lo]
    return lo
function kthLargestIntegerQuickselect(nums, k) {
  return quickselect(nums, 0, nums.length - 1, k);
}

function quickselect(nums, left, right, k) {
  const n = nums.length;
  if (left >= right) return nums[left];
  const randomIndex = left + Math.floor(Math.random() * (right - left + 1));
  [nums[randomIndex], nums[right]] = [nums[right], nums[randomIndex]];
  const pivotIndex = partition(nums, left, right);
  if (pivotIndex < n - k) {
    return quickselect(nums, pivotIndex + 1, right, k);
  } else if (pivotIndex > n - k) {
    return quickselect(nums, left, pivotIndex - 1, k);
  } else {
    return nums[pivotIndex];
  }
}

function partition(nums, left, right) {
  const pivot = nums[right];
  let lo = left;
  for (let i = left; i < right; i++) {
    if (nums[i] < pivot) {
      [nums[lo], nums[i]] = [nums[i], nums[lo]];
      lo++;
    }
  }
  [nums[lo], nums[right]] = [nums[right], nums[lo]];
  return lo;
}
private Random rand = new Random();

public int kthLargestIntegerQuickselect(int[] nums, int k) {
    return quickselect(nums, 0, nums.length - 1, k);
}

private int quickselect(int[] nums, int left, int right, int k) {
    int n = nums.length;
    if (left >= right) return nums[left];
    int randomIndex = left + rand.nextInt(right - left + 1);
    int temp = nums[randomIndex];
    nums[randomIndex] = nums[right];
    nums[right] = temp;
    int pivotIndex = partition(nums, left, right);
    if (pivotIndex < n - k) {
        return quickselect(nums, pivotIndex + 1, right, k);
    } else if (pivotIndex > n - k) {
        return quickselect(nums, left, pivotIndex - 1, k);
    } else {
        return nums[pivotIndex];
    }
}

private int partition(int[] nums, int left, int right) {
    int pivot = nums[right];
    int lo = left;
    for (int i = left; i < right; i++) {
        if (nums[i] < pivot) {
            int temp = nums[lo];
            nums[lo] = nums[i];
            nums[i] = temp;
            lo++;
        }
    }
    int temp = nums[lo];
    nums[lo] = nums[right];
    nums[right] = temp;
    return lo;
}

Complexity Analysis

Time complexity: The time complexity of kth_largest_integer_quickselect can be analyzed in terms of the average and worst cases:

  • Average case: O(n)O(n). In the average case, quickselect partitions the array and reduces the problem size by approximately half each time by performing a recursive call on only one part of each partition. A linear partition is performed during each of these recursive calls. This results in a total time complexity of O(n)+O(n/2)+O(n/4)+O(n/8)+…O(n) + O(n/2) + O(n/4) + O(n/8) + \dots, which is a geometric series that sums to O(n)O(n).

  • Worst case: O(n2)O(n^2). The worst-case scenario occurs when the pivot selection consistently results in extremely unbalanced partitions. This can result in the problem size only being reduced by one element after each partition, leading to a total time complexity of O(n)+O(n−1)+O(n−2)+…O(n)+O(n-1)+O(n-2)+…, which can be simplified to an O(n2)O(n^2) time complexity.

Space complexity: The space complexity can also be analyzed in terms of the average and worst cases:

  • Average Case: O(log⁡(n))O(\log(n)). In the average case, the depth of the recursive call stack is approximately log⁡2(n)\log_2(n).

  • Worst Case: O(n)O(n). In the worst case, the depth of the recursive call stack can be as deep as nn.

CHAPTER 110

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:

Example
Input: nums = [0, 1, 2, 0, 1, 2, 0]
Output: [0, 0, 0, 1, 1, 2, 2]

Intuition

This problem is just asking us to sort three numbers in ascending order. A straightforward solution would be to use an in-built sorting function. However, this is an O(nlog⁡(n))O(n\log(n)) approach, where nn 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.  The diagram shows an unsorted array `[2 0 1 2 0 0 1]` where numbers 0 are colored red, 1s are black, and 2s are cyan. A thick black arrow points to a sorted array `[0 0 0 1 1 2 2]`.  Underneath the sorted array, red and cyan horizontal brackets highlight the groups of 0s and 2s respectively, labeled 'sort 0s' and 'sort 2s' in their respective colors. Above the sorted array, a black bracket highlights the group of 1s, with the text '1s will naturally get sorted' explaining their position. The overall arrangement demonstrates how, without explicit sorting of 1s, the algorithm implicitly places them in their correct sorted position between the 0s and 2s.

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 or list depicted using square brackets `[]`.  The array contains seven integer elements: 2, 0, 1, 2, 0, 0, and 1. These elements are displayed in bold, black font and are arranged sequentially within the brackets. Below the array, a sequence of numbers from 0 to 6 is shown in a lighter gray font; these numbers represent the indices of the corresponding elements in the array, starting from index 0 for the first element (2) and ending at index 6 for the last element (1).  There is no explicit information flow or connections between the elements other than their sequential arrangement within the array structure indicated by the brackets and the implicit index mapping provided by the numbers below.

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:

Image represents a visual depiction of a sorting algorithm's step, likely part of a quicksort or similar algorithm.  Two labeled boxes, 'left' and 'i' (likely representing an index), point downward with arrows to an array `[2 0 1 2 0 0 1]`, indicating that the algorithm is focusing on the element at index `i` (which is 2 in this case).  A separate box labeled 'right' points to the last element of the array (1). A dashed-bordered box to the right details the algorithm's logic:  `nums[i] == 2` shows the value at index `i`; `→ swap(nums[i], nums[right])` indicates that the algorithm will swap the element at index `i` (value 2) with the element at the 'right' index (value 1); and `→ right--` shows that the 'right' index will be decremented after the swap.  The numbered indices beneath the array clarify the positions of each element within the array.  The overall diagram illustrates a single iteration of a sorting algorithm's partitioning step, focusing on the comparison and potential swap of elements based on their values relative to a pivot (implicitly defined by the 'right' index).
Image represents a visual depiction of a sorting algorithm, likely a variation of quicksort or similar, showing a single swap operation.  A numerical array `[1 0 1 2 0 0 2]` is displayed, with indices 0 through 6 shown below each element.  Two rectangular boxes labeled 'left' (in gray) and 'right' (in light blue) point downwards with arrows to the array's first and last elements, respectively.  A smaller gray box labeled 'i' is positioned near the 'left' box, suggesting an index or pointer.  A curved, light-blue arrow labeled 'swapped' connects the first and last elements (1 and 2), indicating that these elements have been exchanged during the sorting process.  The arrows from 'left' and 'right' highlight the elements selected for comparison and subsequent swapping.
Image represents a visual depiction of a two-pointer approach to array manipulation.  Two labeled boxes, 'left' (gray) and 'right' (light blue), point downwards towards an array `[1 0 1 2 0 0 2]`.  The array's indices are displayed below it, ranging from 0 to 6. The 'left' pointer initially targets the first element (index 0), while the 'right' pointer points to the last element (index 6).  A small, dark gray box labeled 'i' is positioned next to the 'left' box, suggesting an index variable.  A dashed, light blue arrow from the 'right' pointer indicates that it might move leftward during the algorithm's execution.  The downward arrows from 'left' and 'right' visually represent the pointers' initial positions within the array. The overall diagram illustrates the starting configuration of a common algorithm that likely involves processing the array from both ends, possibly for sorting, searching, or other operations requiring bidirectional traversal.

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:

Image represents a visual depiction of a code snippet likely involving array traversal and conditional logic.  The diagram shows a numerical array `[1 0 1 2 0 0 2]` with indices displayed below each element (0 through 6).  A rectangular box labeled 'left' points to the array's first element (at index 0), and another box labeled 'i' (likely representing an index variable) points to the same element.  A downward-pointing arrow from 'i' indicates that the variable `i` is currently pointing to the first element of the array.  A separate box labeled 'right' points to the array's element at index 5. A dashed-line box to the right describes a conditional statement: `nums[i] == 1`, which checks if the element at the index `i` is equal to 1.  If this condition is true, an action `i++` (incrementing the index `i`) is performed, implying that the code iterates through the array, checking each element for the value 1 and moving to the next element if found.
Image represents a visual depiction of a two-pointer approach to array traversal.  A numerical array `[1, 0, 1, 2, 0, 0, 2]` is shown, with each element's index displayed below it (0 through 6).  Two rectangular boxes labeled 'left' and 'right' point downwards, indicating pointers to the array's beginning and end, respectively. A square box labeled 'i' points downwards to the second element (value 0) of the array. A dashed arrow connects the 'left' pointer (pointing to the first element, value 1) to the 'i' pointer, suggesting an interaction or comparison between these two elements. The 'right' pointer points to the second-to-last element (value 0). The arrangement visually demonstrates the concept of using two pointers, 'left' and 'right,' to traverse and potentially manipulate an array, with the 'i' pointer possibly representing an intermediate index or a point of comparison within the array.  The grayscale variation in the array's element indices suggests a possible focus on a specific sub-section of the array during the traversal.

Now, pointer i is pointing at a 0, so let’s swap it with nums[left]:

Image represents a visual depiction of a step within a sorting algorithm, likely a variation of quicksort.  The top shows two labeled boxes, 'left' (in red) and 'i' (in black), pointing down with arrows to the array `[1 0 1 2 0 0 2]`.  The 'left' arrow points to the element '1' at index 0, and the 'i' arrow points to the element '0' at index 1.  A third box labeled 'right' (in grey) points to the element '0' at index 4.  A dashed-line box to the right contains a conditional statement 'nums[i] == 0' followed by an action '→ swap(nums[i], nums[left])'. This indicates that if the element at index `i` (which is 0) is equal to 0, then the algorithm swaps the values at indices `i` and `left`.  The indices are shown below the array elements for reference.  The diagram illustrates a single iteration where the algorithm checks a specific element and performs a swap based on a condition.
Image represents a visual depiction of a sorting algorithm, likely a variation of quicksort or similar, showing a single swap operation.  A red box labeled 'left' points down to the first element (0) of an array [0, 1, 1, 2, 0, 0, 2]. A gray box labeled 'i' points down to the second element (1) of the same array.  Below the array, index numbers (0, 1, 2, 3, 4, 5, 6) are shown for reference. A red curved arrow labeled 'swapped' indicates that the elements at indices 0 and 1 have been exchanged. A gray box labeled 'right' points down to the array's element at index 4 (the first 0 after the initial elements).  The visual emphasizes the interaction between the 'left' and 'i' pointers during a single swap step within a larger sorting process, highlighting the movement and exchange of elements based on their values and the pointers' positions.

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.

Image represents a visual depiction of a coding pattern, likely illustrating array manipulation.  The diagram shows a numerical array `[0 1 1 2 0 0 2]` with indices displayed below (0 to 6).  Above the array, two labeled boxes, 'left' (in red) and 'i' (in gray), point downwards with arrows to the array's elements at indices 0 and 1 respectively.  Another labeled box, 'right' (in gray), points to the element at index 5.  To the right, a dashed-line box indicates the resulting actions:  two arrows point to 'left++' and 'i++', suggesting that the variables 'left' and 'i' are incremented after accessing their respective array elements.  The overall pattern suggests an iterative process involving two pointers ('left' and 'i') moving through the array, potentially for comparison or manipulation, with the 'right' pointer possibly indicating a boundary or target.
Image represents a visual depiction of a data structure manipulation, possibly within an algorithm.  A numerical array `[0, 1, 1, 2, 0, 0, 2]` is shown, with each element indexed below (0-6).  Two labeled boxes, 'left' (red) and 'right' (gray), indicate operations on the array. A solid black arrow descends from 'left' to the element '1' at index 1, signifying a direct modification or access. A solid black arrow descends from 'i' (a gray box likely representing an index or iterator) to the element '1' at index 2, indicating another direct modification or access.  A dashed red arrow connects 'left' to the element '0' at index 0, suggesting a potential indirect influence or conditional operation. A solid gray arrow descends from 'right' to the element '0' at index 5, showing a direct modification or access at that location. The overall image illustrates how different parts of an array are accessed and potentially modified from different points of reference ('left' and 'right'), possibly within a sorting or merging algorithm.

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:

Image represents a diagram illustrating a coding pattern, possibly related to array traversal or searching.  Three rectangular boxes labeled 'left,' 'right,' and 'i' are positioned above a numerical array represented as `[0 0 0 1 1 2 2]`, with indices 0 through 6 displayed below the array elements.  Arrows descend from 'left,' 'right,' and 'i' pointing to specific elements within the array: 'left' points to '1' at index 3, 'right' points to '1' at index 4, and 'i' points to '2' at index 5.  The array elements at indices 0, 1, and 2 are colored red, suggesting they might be excluded or processed differently. The element at index 4 is colored light blue, suggesting a different state. A separate, dashed-line box to the right displays the condition 'i > right → stop,' indicating that the algorithm terminates when the value of 'i' exceeds the value of 'right'.  The overall diagram depicts a process where two pointers ('left' and 'right') and an index ('i') traverse an array, potentially searching or manipulating its elements based on the condition specified in the final box.

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.


Why do we advance both i and left pointers when we encounter a 0?
A question we might have regarding the above process is why we advance the i pointer along with the left pointer when nums[i] == 0.

The reason becomes clear when we consider the following example:

The image represents a visual depiction of a merge operation, likely within a merge sort algorithm.  Two labeled sources, 'left' (in red) and 'right' (in gray), each point downwards via arrows to an array-like structure.  The 'left' source points to the first element, '0', of the array [0, 1, 2], with the index '0' shown below it.  The 'i' box, in black and white, also points to the same array's first element, suggesting an index or iterator. The 'right' source points to the element '1' in the array [1], with the index '3' shown below it.  The arrays are presented side-by-side, implying that the merge operation combines elements from two sorted sub-arrays ('left' and 'right') into a single sorted array. The arrows visually represent the flow of data from the source arrays into the merged array, illustrating the merging process.

Here, nums[i] == 0, so the first thing we do is swap nums[i] and nums[left], which doesn’t change anything in this case since left and i point to the same element. Now, observe what happens if we only advance the left pointer:

Image represents a visual depiction of a step in a sorting algorithm, likely a partitioning step within quicksort.  The left side shows an array `[0, 1, 2, 1]` with indices 0, 1, 2, and 3 displayed below.  A red box labeled 'left' points to the element at index 0 (value 0), and a gray box labeled 'i' points to the element at index 1 (value 1).  A red arrow indicates a connection from 'left' to the element at index 0, suggesting a pointer or index variable. A black arrow from 'i' also points to the element at index 1. A gray box labeled 'right' points to the element at index 3 (value 1). The right side shows a pseudocode block describing the algorithm's logic.  It checks if `nums[i]` (the element pointed to by 'i') is equal to 0. If true, it executes `swap(nums[i], nums[left])`, swapping the elements at indices `i` and `left`. Finally, it increments `left` using `left++`, moving the 'left' pointer one position to the right.  The overall diagram illustrates the process of partitioning an array based on a pivot value (likely 0 in this case) by comparing elements and swapping them to place elements smaller than the pivot to the left.
Image represents a visual depiction of an iterative process, likely within a loop in a program.  A gray box labeled 'i' represents a loop counter variable.  A red box labeled 'left' and a gray box labeled 'right' represent index pointers or variables.  Arrows indicate the flow of control.  A downward arrow from 'i' points to the element at index 0 in the array `[0, 1, 2, 1]`. A downward red arrow from 'left' points to the element at index 1. A dashed red arrow connects the element at index 0 to the element at index 1, suggesting a comparison or operation between these elements. A downward gray arrow from 'right' points to the element at index 3.  The array `[0, 1, 2, 1]` is indexed from 0 to 3, shown below the array.  The condition `(i < left)` is displayed, indicating a loop continuation condition where the loop continues as long as the value of `i` is less than the value of `left`.  The overall diagram illustrates a step in an algorithm, possibly a search or sorting algorithm, that involves comparing and manipulating elements within an array using index pointers.

As we can see, the left pointer will surpass the i pointer, which shouldn’t happen since i needs to stay between left and right throughout the algorithm. To avoid this, we advance both the i and left pointers:

Image represents a visual depiction of a step in a sorting algorithm, likely a variation of quicksort or similar.  The left side shows an array `[0, 1, 2, 1]` with indices 0, 1, 2, and 3 displayed below.  A red box labeled 'left' points to the element at index 0 (value 0), and a gray box labeled 'i' points to the element at index 0 (value 0) as well. A gray box labeled 'right' points to the element at index 3 (value 1). The right side shows a pseudocode block describing the algorithm's actions.  The first line checks if `nums[i]` (the element at index `i`) is equal to 0. If true, the next line executes `swap(nums[i], nums[left])`, swapping the element at index `i` with the element at index `left`. The following lines increment `left` (`left++`) and `i` (`i++`), moving to the next elements in the array.  The arrows visually connect the code block to the array, illustrating the flow of data and control during the algorithm's execution.  The red arrow from 'left' to the array element 0 highlights the current position of the 'left' pointer.
Image represents a visual depiction of an array manipulation process, likely within a sorting or searching algorithm.  A numerical array `[0, 1, 2, 1]` is shown, with indices 0, 1, 2, and 3 displayed below each element.  A red box labeled 'left' points with a solid red arrow to the element '1' at index 1, indicating a selection or operation on this element.  A dashed red arrow from 'left' curves to the element '0' at index 0, suggesting a comparison or potential swap. A gray box labeled 'i' points with a solid black arrow to the same element '1' at index 1, possibly representing an iterator or index variable. Finally, a gray box labeled 'right' points with a solid gray arrow to the element '1' at index 3, potentially indicating another element involved in the comparison or operation. The overall diagram illustrates the interaction between different parts of an algorithm, focusing on the element at index 1 and its relationship to other elements within the array, possibly during a left-to-right traversal or comparison.
Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function dutchNationalFlag(nums) {
  let i = 0, left = 0, right = nums.length - 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++;
      i++;
    // Swap 2s with the element at the right pointer.
    } else if (nums[i] === 2) {
      [nums[i], nums[right]] = [nums[right], nums[i]];
      right--;
    } else {
      i++;
    }
  }
}
public void dutchNationalFlag(int[] nums) {
    int i = 0, left = 0, right = nums.length - 1;
    while (i <= right) {
        // Swap 0s with the element at the left pointer.
        if (nums[i] == 0) {
            int temp = nums[i];
            nums[i] = nums[left];
            nums[left] = temp;
            left++;
            i++;
        // Swap 2s with the element at the right pointer.
        } else if (nums[i] == 2) {
            int temp = nums[i];
            nums[i] = nums[right];
            nums[right] = temp;
            right--;
        } else {
            i++;
        }
    }
}

Complexity Analysis

Time complexity: The time complexity of dutch_national_flag is O(n)O(n) because we iterate through each element of nums once.

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

CHAPTER 111

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.  The image shows a sequence of vertical arrows, each labeled at the top with either 'T' (representing True) or 'F' (representing False), and at the bottom with a binary digit (0 or 1) and a corresponding decimal number.  The arrows indicate a downward flow of information.  The decimal numbers (31, 30, 29, ..., 1, 0) decrease sequentially from left to right, representing a countdown.  The binary digits (0 and 1) alternate, with 'T' consistently mapping to '1' and 'F' to '0'.  An ellipsis (...) indicates the omission of intermediate steps in the sequence, implying the counter continues this pattern until it reaches zero.  The gray arrows and numbers suggest a less significant or inactive state compared to the black arrows and numbers, which represent the active counting process.

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.  The left side depicts the NOT gate, showing a truth table with a single input 'a' and an output '~a'.  The table lists input values of 0 and 1 for 'a', resulting in corresponding output values of 1 and 0 for '~a', respectively.  Below the table, an example is provided:  applying the NOT operation to the input '1' yields an output of '0', and applying it to '0' yields '1'. The right side illustrates the AND gate, presenting a truth table with two inputs, 'a' and 'b', and an output 'a & b'.  The table shows all possible combinations of 0 and 1 inputs for 'a' and 'b', with the output 'a & b' being 1 only when both 'a' and 'b' are 1; otherwise, it's 0.  An example calculation is shown below, where '1 AND 0' results in '0'.  Both sections clearly demonstrate the functionality of each gate through both tabular representation and numerical examples.
Image represents a comparison of two logical operations, OR and XOR, using truth tables and examples.  The left side depicts the OR operation.  A truth table shows the output (a | b) for all combinations of binary inputs 'a' and 'b' (00, 01, 10, 11), resulting in 0, 1, 1, and 1 respectively.  Next to it, an example shows the OR operation applied to the binary inputs 1 and 0, resulting in an output of 1. The right side similarly illustrates the XOR operation. Its truth table displays the output (a ^ b) for the same input combinations, yielding 0, 1, 1, and 0 respectively. An example demonstrates the XOR operation on inputs 1 and 0, producing an output of 1.  Both sections clearly label the inputs ('a', 'b'), the operation ('|', '^'), and the output column.  The examples use the same input values (1 and 0) to highlight the difference in the output of the two operations.

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.

    Image represents a visual example of a left bit shift operation.  The top row shows a binary sequence '1 0 0 1' enclosed in a peach-colored rectangle, labeled as the input.  To the right of this sequence, '<<' denotes the left bit shift operator, followed by the number '2', indicating a shift of two positions. The bottom row displays the result of the operation: '1 0 0 1 0 0', also within a peach-colored rectangle.  Four orange arrows connect the top row's digits to their corresponding shifted positions in the bottom row, visually demonstrating how each bit is moved two places to the left.  The '=', symbol to the left of the bottom row signifies that it represents the outcome of the bit shift operation on the input sequence.
  • 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).

    Image represents a visual example of a coding pattern, likely related to data manipulation or bit shifting.  The top row shows a sequence of four digits: 1, 0, 0, and 1, enclosed in a light peach-colored rectangle.  This sequence is labeled 'Example:'.  Two orange arrows emanate from the first and last '1's in the top row, pointing to a second light peach-colored rectangle below. This lower rectangle contains a sequence of two digits: 1 and 0.  The digits '0' and '0' are written to the left of the lower rectangle, indicating a possible result of some operation.  To the right of the top rectangle, '>> 2' is written, suggesting a right bit shift operation by two positions. The overall diagram illustrates a transformation of the top sequence into the bottom sequence through a right bit shift, resulting in the bottom sequence of '0 0 1 0' being reduced to '1 0' after the shift.

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: 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 is set: if x & (1 << i) != 0, the ith bit is set

  • Checking if a number x is even or odd: if x & 1 == 0, x is even

  • Checking if a number is a power of 2: if x > 0 and x & (x - 1) == 0, x is a power of 2

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'.  A rounded rectangle at the top, labeled 'Bit Manipulation', acts as the root node.  Three rectangular boxes, connected to the root node via dashed downward-pointing arrows, represent subcategories: 'Counting Bits' (containing the description '- Hamming Weights of Integer'), 'XOR' (containing '- Lonely Integer'), and 'Masks' (containing '- Swap Odd and Even Bits').  The 'Masks' box is connected to the root node by a dashed arrow, indicating a direct relationship.  The arrangement visually depicts how 'Counting Bits' and 'XOR' are distinct approaches under the umbrella of 'Bit Manipulation,' while 'Masks' is another technique, possibly building upon or related to the core concept.  The dashed lines suggest a conceptual connection rather than a strict sequential flow.

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.

CHAPTER 112

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:

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

Image represents a mathematical equation showing the conversion of a base-10 number to its base-2 (binary) equivalent.  The top line displays 'x = 25 (base 10)', indicating that the variable 'x' holds the decimal value 25.  Below this, an equals sign connects the base-10 representation to its binary equivalent. The binary representation '1 1 0 0 1 (base 2)' is shown as a sequence of five digits (1, 1, 0, 0, and 1), each representing a bit, explicitly labeled as being in base 2.  The arrangement clearly shows the direct equivalence between the decimal number 25 and its binary counterpart, 11001.  The parentheses around 'base 10' and 'base 2' clarify the number systems used.

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. Let’s see how this works.

For starters, we can determine the least significant bit (LSB) of x by performing x & 1, which masks all bits of x except the LSB: if x & 1 == 1, the LSB is 1. Otherwise, it’s 0. We can see this below for x = 25:

Image represents a binary bitwise AND operation.  Two five-bit binary numbers are shown, one labeled (x) with the values 11001 and the other labeled (1) with the values 00001.  These numbers are arranged vertically, with the (x) number above the (1) number. An ampersand (&) symbol precedes the second binary number, indicating the AND operation. A horizontal line separates the input numbers from the result. The result of the bitwise AND operation is displayed below the line, showing the resulting binary number 00001, also labeled (1), which is obtained by performing a logical AND operation between corresponding bits of the two input numbers.  Each bit in the result is 1 only if both corresponding bits in the input numbers are 1; otherwise, it's 0.  The grayed-out (1) in the result emphasizes that it's identical to the second input number in this specific example.

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:

Image represents a visual depiction of a binary operation, possibly a bitwise shift or a similar operation on binary data.  The top row shows a sequence of binary digits: 1, 1, 0, a peach-colored box containing '0', and another '1'.  These digits are connected via grey arrows to the bottom row, which shows the result of the operation: 0, 1, 1, 0, and another peach-colored box containing '0'.  Each digit in the top row is individually connected to a digit in the bottom row; the first '1' connects to '0', the second '1' connects to '1', the '0' connects to '1', and the peach-colored '0' connects to '0'. The final '1' in the top row is not connected to the bottom row.  The peach-colored boxes in both rows are labeled 'new LSB' in orange text, indicating that these '0's represent a new least significant bit (LSB) in the operation.  The double right arrow ('>>') suggests a right bit shift, where the digits are shifted to the right, and the LSB is added.  The '=' symbol indicates that the bottom row is the result of the operation performed on the top row.

We now have a process we can repeat to count the number of set bits in a number:

  1. If x & 1 == 1, increment our count. Otherwise, don’t.
  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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Count Bits For Each Number

from typing import List
    
def hamming_weights_of_integers(n: int) -> List[int]:
    return [count_set_bits(x) for x in range(n + 1)]
    
def count_set_bits(x: int) -> int:
    count = 0
    # Count each set bit of 'x' until 'x' equals 0.
    while x > 0:
        # Increment the count if the LSB is 1.
        count += x & 1
        # Right shift 'x' to shift the next bit to the LSB position.
        x >>= 1
    return count
function hammingWeightsOfIntegers(n) {
  const result = [];
  for (let x = 0; x <= n; x++) {
    result.push(countSetBits(x));
  }
  return result;
}

function countSetBits(x) {
  let count = 0;
  while (x > 0) {
    count += x & 1;
    x >>= 1;
  }
  return count;
}
public int[] hammingWeightsOfIntegers(int n) {
    int[] result = new int[n + 1];
    for (int x = 0; x <= n; x++) {
        result[x] = countSetBits(x);
    }
    return result;
}

private int countSetBits(int x) {
    int count = 0;
    while (x > 0) {
        count += x & 1;
        x >>= 1;
    }
    return count;
}

Complexity Analysis

Time complexity: The time complexity of hamming_weights_of_integers is O(nlog⁡(n))O(n\log(n)) because for each integer xx from 0 to nn, counting the number of set bits takes logarithmic time, as there are approximately log⁡2(x)\log_2(x) bits in that number. If we assume all integers have 32 bits, the time complexity simplifies to just O(n)O(n), since counting the set bits for a number will take at most 32 steps, which we do for n+1n+1 numbers.

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

    Image represents a diagram illustrating a bitwise right shift operation.  The top row shows an initial value `x` represented by two encircled '1's, followed by three '0's.  A blue arrow labeled 'LSB' points downwards, indicating the least significant bit is being processed.  Grey arrows connect each bit in the top row to the corresponding bit in the bottom row.  The top row represents the initial value of `x`, and the bottom row shows the result of the right shift operation (`x >> 1`).  The encircled '1's in the top row shift one position to the right, resulting in the encircled '1's in the bottom row. The remaining bits are filled with '0's.  To the right, `dp[x] = 2` indicates the initial count of '1's in `x`, and `dp[x >> 1] = 2` shows the count of '1's after the right shift.  The diagram visually demonstrates how the right shift operation moves bits to the right, filling empty positions with zeros, and how this affects the count of '1's.
  • If the LSB of x is 1, the difference in the number of bits between x and x >> 1 is 1:

    Image represents a diagram illustrating a bit manipulation operation, likely within the context of a coding pattern related to bitwise operations or data structures.  The diagram shows a binary number, `x`, represented by three bits: `110` (where the rightmost bit is the least significant bit, or LSB, as indicated by a blue arrow pointing to the rightmost '1').  These bits are enclosed in orange circles.  A blue circle contains the LSB '1'.  Grey arrows show the right bit-shift operation (`>> 1`), resulting in a new binary number `011`. These shifted bits are also enclosed in orange circles.  The text `x = 1 1 0` labels the original binary number, while `x >> 1 = 0 1 1` labels the result of the right bit shift.  The expressions `dp[x] = 3` and `dp[x >> 1] = 2` suggest that `dp` is an array or function that counts the number of '1's in the binary representation of `x` and `x >> 1`, respectively.  The values 3 and 2 reflect the number of '1's in the original and shifted binary numbers.  The zeros in the diagram represent bits with a value of zero.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation - Dynamic Programming

from typing import List
    
def hamming_weights_of_integers_dp(n: int) -> List[int]:
    # Base case: the number of set bits in 0 is just 0. We set dp[0] to 0 by
    # initializing the entire DP array to 0.
    dp = [0] * (n + 1)
    for x in range(1, n + 1):
        # 'dp[x]' is obtained using the result of 'dp[x >> 1]', plus the LSB of 'x'.
        dp[x] = dp[x >> 1] + (x & 1)
    return dp
function hammingWeightsOfIntegersDp(n) {
  // Base case: the number of set bits in 0 is just 0.
  const dp = new Array(n + 1).fill(0);
  for (let x = 1; x <= n; x++) {
    // 'dp[x]' is obtained using the result of 'dp[x >> 1]', plus the LSB of 'x'.
    dp[x] = dp[x >> 1] + (x & 1);
  }
  return dp;
}
public int[] hammingWeightsOfIntegersDp(int n) {
    // Base case: the number of set bits in 0 is just 0.
    int[] dp = new int[n + 1];
    for (int x = 1; x <= n; x++) {
        // 'dp[x]' is obtained using the result of 'dp[x >> 1]', plus the LSB of 'x'.
        dp[x] = dp[x >> 1] + (x & 1);
    }
    return dp;
}

Complexity Analysis

Time complexity: The time complexity of hamming_weights_of_integers_dp is O(n)O(n) since we populate each element of the DP array once.

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

CHAPTER 113

Lonely Integer

Given an integer array where each number occurs twice except for one of them, find the unique number.

Example:

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

Image represents a data structure visualization illustrating the concept of finding a 'lonely integer.'  The left side shows an integer array `nums = [1, 3, 3, 2, 1]` where each number's frequency needs to be determined.  A peach-colored circle highlights the number '2' within the array, emphasizing its role in the process.  To the right, a hashmap is depicted, labeled 'hashmap,' with two columns: 'num' and 'freq,' representing the number and its frequency, respectively. The hashmap contains entries (1,2), (2,1), (3,2).  The number '2' and its frequency '1' are highlighted in peach within the hashmap. An orange arrow points from the highlighted '2' in the hashmap to the text 'lonely integer,' indicating that the number 2, appearing only once, is identified as the lonely integer based on its frequency count within the input array.

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)O(n) time, but comes at the cost of O(n)O(n) space, where nn 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.

Image represents a diagram illustrating a coding pattern for finding a 'lonely integer.'  The diagram begins with an unsorted integer array, `nums = [1, 3, 3, 2, 1]`, where the number 2 is highlighted in a peach-colored circle to emphasize its potential as a lonely integer (an integer without adjacent duplicates). A grey arrow labeled 'sort' indicates a sorting operation, transforming the array into `[1, 1, 2, 3, 3]`.  The number 2 remains highlighted in a peach-colored circle in the sorted array.  Curved orange arrows beneath the sorted array point towards the 2, with accompanying orange text reading 'no duplicates next to 2: lonely integer,' explicitly stating the condition for identifying the lonely integer—the presence of a number without adjacent identical numbers.  The diagram visually demonstrates that after sorting, checking for adjacent duplicates efficiently identifies the lonely integer.

This method takes O(nlog⁡(n))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:

Image represents a step-by-step calculation demonstrating the bitwise XOR operation on a list of numbers.  The top line shows a Python-like list assignment, `nums = [1 3 3 2 1]`, where the number 2 is highlighted in a peach-colored circle. The next line describes the operation: 'XOR all elements:', followed by the elements of the list separated by the XOR operator (`^`). The subsequent lines show the calculation's progression: the XOR operation is performed pairwise, starting with the first two elements (1 ^ 1), then the result is XORed with the next element (0 ^ 3), and so on.  Each step is shown explicitly, with parentheses used to indicate the order of operations. The final result, 2, is obtained after all XOR operations are completed.  The example illustrates how the bitwise XOR operation can be applied to a sequence of numbers, resulting in a final value.

This allows us to identify the lonely integer in linear time without using extra space.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function lonelyInteger(nums) {
  let res = 0;
  // XOR each element of the array so that duplicate values will cancel each other out (x ^ x == 0).
  for (const num of nums) {
    res ^= num;
  }
  // 'res' will store the lonely integer because it would not have been canceled out by any duplicate.
  return res;
}
public int lonelyInteger(int[] nums) {
    int res = 0;
    // XOR each element of the array so that duplicate values will cancel each other out (x ^ x == 0).
    for (int num : 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)O(n) because we perform a constant-time XOR operation on each element in nums.

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

CHAPTER 114

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:

Image represents a diagram illustrating a binary addition process.  The diagram shows two rows of binary numbers, representing the addends. The top row displays the binary number 10101 (which is 21 in decimal), and the bottom row displays 010110 (which is 22 in decimal). To the right of each row is the decimal equivalent in parentheses: (41) and (22) respectively.  A horizontal arrow points to the beginning of the bottom row, indicating the start of the addition.  Between the two rows, orange curved arrows connect pairs of digits, visually representing the carry-over operation in binary addition.  Each arrow connects a pair of digits where a carry is generated (1 + 1 = 10 in binary, resulting in a 0 in the current position and a carry of 1 to the next position).  The arrangement and the arrows clearly show the step-by-step process of binary addition, highlighting the carry propagation.
Example
Input: n = 41
Output: 22

Example 2:

Image represents a diagram illustrating a data transformation or processing step, possibly within a coding pattern related to binary data or bit manipulation.  The diagram shows two rows of binary digits (0s and 1s), labeled (23) and (43) respectively, representing input data. A horizontal arrow pointing to the right precedes the bottom row, indicating the direction of data flow.  Three pairs of vertically stacked binary digits are shown, each pair connected by an orange curved arrow pointing upwards from the bottom digit to the top digit.  This suggests a transformation or operation is applied to each pair, potentially a bitwise operation or a simple mapping.  The top row represents the output after this transformation, with the pairs of digits seemingly undergoing the same operation. The numbers (23) and (43) likely represent identifiers or labels for the input and output data sets, possibly indicating their source or destination within a larger system.
Example
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.
Image represents a visual depiction of a bitwise XOR operation across multiple positions.  The top row displays 'position:' followed by numbers 5 through 0, indicating the positional index of the bits. Below this, two rows of binary digits (0s and 1s) are presented. Each column represents a bit at a specific position.  Within each column, a pair of numbers is shown; the top number represents the input bit A, and the bottom number represents the input bit B.  Orange and light-blue arrows connect these input bits, visually demonstrating the XOR operation: an orange arrow points from the top bit to the bottom bit if the top bit is 1, and a light-blue arrow points from the bottom bit to the top bit if the bottom bit is 1. The result of the XOR operation (A XOR B) is implicitly shown by the arrangement of the arrows, where the presence of an arrow indicates a '1' result and its absence indicates a '0' result.  The pattern repeats across all positions, illustrating the consistent application of the XOR operation on each bit pair.

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.

Image represents a flowchart illustrating a bit manipulation algorithm.  At the top, a sequence of bits '1 0 1 0 0 1' is shown.  This sequence is split into two branches. The top-left branch, labeled 'extract even bits' in cyan, uses a left shift operation, indicated by cyan arrows, resulting in the subsequence '0 0 1'. The top-right branch, labeled 'extract odd bits' in orange, uses a right shift operation, indicated by orange arrows, resulting in the subsequence '1 0 1'.  Each shift operation involves moving the bits to the left or right, with empty spaces filled by zeros.  The subsequences '0 0 1' and '1 0 1' are then merged at the bottom using a 'merge' operation, resulting in the final sequence '0 1 0 1 1 0'.  The intermediate steps clearly show the left and right shifts applied to the extracted even and odd bits respectively, before the final merging step.  The circles represent individual bits (0 or 1), with light blue circles representing the even bits and light orange circles representing the odd bits after the extraction.  Grey circles represent empty spaces or zeros introduced during the shift operations.

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:

Image represents a visual depiction of an 'even mask' applied to a sequence of numbers.  The top row displays a sequence of integers from 31 down to 0.  A horizontal gray line separates this sequence from a second row labeled 'even_mask ='.  The bottom row shows a corresponding sequence of 0s and 1s representing the even mask.  Each number in the top row is associated with a 0 or 1 in the bottom row;  a '1' indicates that the corresponding number in the top row is even, and a '0' indicates that it is odd.  The ellipsis ('...') in the bottom row signifies the continuation of this alternating pattern of 0s and 1s for the omitted numbers (27 to 6).  The arrangement clearly shows a one-to-one mapping between the input sequence (top row) and the resulting even mask (bottom row), illustrating a simple bitmasking operation.

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:

Image represents a bitwise AND operation demonstrating how to extract even-indexed bits from a binary sequence.  The top row shows a binary sequence of length 'n', represented by alternating 0s and 1s, with ellipses (...) indicating continuation of the pattern.  Below it, labeled 'AND', is a second row, 'even_mask', which is a binary sequence of alternating 1s and 0s, starting with 0. This acts as a mask.  A horizontal line separates these two rows from the result below, labeled 'even_bits'. This bottom row shows the result of a bitwise AND operation between the top and middle rows.  Each bit in the 'even_bits' row is the result of the AND operation between the corresponding bits in the top and middle rows.  Specifically, the even-indexed bits (0, 2, 4, etc.) from the top row are preserved in the result because they are ANDed with 1s from the 'even_mask' row (resulting in the original bit), while the odd-indexed bits are all 0s because they are ANDed with 0s from the 'even_mask' row (resulting in 0). The light-blue circles highlight the resulting even bits in the bottom row.

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:

Image represents a visual depiction of an odd mask applied to a sequence of numbers.  The top row displays a sequence of integers from 31 down to 0, arranged from left to right.  These numbers are written in a light gray color.  A horizontal gray line separates this top row from the bottom row. The bottom row shows the result of applying an 'odd mask' to the top row.  The label 'odd_mask =' precedes this bottom row.  The bottom row contains a sequence of 1s and 0s, where 1 represents an odd number from the top row and 0 represents an even number.  The sequence of 1s and 0s directly corresponds to the top row, with each 1 or 0 positioned below its corresponding number above.  The ellipsis ('...') in the bottom row indicates the continuation of the alternating 1 and 0 pattern for the omitted numbers between 28 and 5 in the top row.  The pattern clearly shows that the odd mask identifies odd numbers with a value of 1 and even numbers with a value of 0.

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:

Image represents a bitwise AND operation to extract odd bits from a binary sequence.  The top row shows a binary sequence of length 'n', represented by a series of 0s and 1s, with ellipses (...) indicating continuation of the pattern.  Below this, labeled 'AND', is another binary sequence, 'odd_mask', which consists of alternating 1s and 0s, starting with 1.  A horizontal line separates these two rows from the result below, labeled 'odd_bits'. This bottom row displays the result of the bitwise AND operation between the top and middle rows. Each bit in the 'odd_bits' row is the result of the AND operation between the corresponding bits in the top and middle rows.  Specifically, a 1 appears in the 'odd_bits' row only when both corresponding bits in the top and middle rows are 1; otherwise, a 0 is produced.  The parentheses next to each row label '(n)', '(odd_mask)', and '(odd_bits)' indicate the description of the respective rows.  The peach-colored circles highlight the resulting bits in the 'odd_bits' row, visually emphasizing the outcome of the bitwise AND operation.

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:

Image represents two parallel rows of data transformations.  The top row shows a sequence of numbers, primarily zeros, with some light-blue circled zeros and a single light-blue circled '1'.  Light-blue arrows point downwards from the light-blue circled numbers to the row below, indicating a transformation. The bottom row shows the result of this transformation, which is mostly zeros, with a single light-blue circled '1' in the result. To the far right, '<< 1' indicates a condition where the input contains a '1' at a specific position. The bottom row demonstrates a similar process, but with peach-colored circles containing zeros and a single peach-colored circled '1'.  Orange arrows point downwards from the peach-colored circled numbers to the row below, representing another transformation. The resulting bottom row contains mostly zeros, with a single peach-colored circled '1'.  To the far right, '>> 1' indicates a condition where the input contains a '1' at a specific position.  Ellipses (...) are used to represent the continuation of the sequences.  The equal signs (=) indicate the result of the transformations.  The overall diagram illustrates two different data transformation processes based on the presence or absence of a '1' in the input sequence.

Then, to merge these together, we can use the bitwise-OR operator because it combines the two sets of bits into the final result.

Image represents a bitwise OR operation illustrated using two rows of bits and a result row.  The top row, labeled '(shifted even_bits),' displays a sequence of bits represented by light-blue circles, containing mostly 0s with a single 1 near the end. The second row, labeled '(shifted odd_bits),' shows a sequence of bits in peach-colored circles, also primarily 0s but with a 1 in the middle.  Each bit in the top and bottom rows is vertically aligned. The label 'OR' is placed to the left of a horizontal line separating the input rows from the result row. The result row below the line shows the bitwise OR of the corresponding bits from the top two rows; each bit in this row is a light-blue circle if the corresponding bit in the top row is 1, otherwise it's a peach-colored circle if the corresponding bit in the bottom row is 1, and 0 otherwise.  The result demonstrates that the bitwise OR operation combines the bits from both input rows, resulting in a 1 if either of the corresponding bits is 1, and 0 only if both corresponding bits are 0.  Ellipses (...) are used to indicate that the sequences of bits continue beyond the displayed portion.

Now, the odd-positioned bits are in the even positions and vice versa.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

def swap_odd_and_even_bits(n: int) -> int:
    even_mask = 0x55555555   # 01010101010101010101010101010101
    odd_mask = 0xAAAAAAAA  # 10101010101010101010101010101010
    even_bits = n & even_mask
    odd_bits = n & odd_mask
    # Shift the even bits to the left, the odd bits to the right, and merge these
    # shifted values together.
    return (even_bits << 1) | (odd_bits >> 1)
function swapOddAndEvenBits(n) {
  const evenMask = 0x55555555; // 01010101010101010101010101010101
  const oddMask = 0xAAAAAAAA;  // 10101010101010101010101010101010
  const evenBits = n & evenMask;
  const oddBits = n & oddMask;
  // Shift even bits to the left, odd bits to the right, and merge together.
  return (evenBits << 1) | (oddBits >>> 1);
}
public int swapOddAndEvenBits(int n) {
    int evenMask = 0x55555555; // 01010101010101010101010101010101
    int oddMask = 0xAAAAAAAA;  // 10101010101010101010101010101010
    int evenBits = n & evenMask;
    int oddBits = n & oddMask;
    // Shift even bits to the left, odd bits to the right, and merge together.
    return (evenBits << 1) | (oddBits >>> 1);
}

Complexity Analysis

Time complexity: The time complexity of swap_odd_and_even_bits is O(1)O(1).

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

CHAPTER 115

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

Image represents a hierarchical diagram illustrating categories within 'Math and Geometry.'  A rounded rectangle at the top, labeled 'Math and Geometry,' acts as the root node.  From this root, a dashed horizontal line extends, branching into two subordinate, rounded-rectangle boxes. The left box is labeled 'Math' and lists three sub-items: 'Reverse 32-bit Integer,' 'The Josephus Problem,' and 'Triangle Numbers.' The right box is labeled 'Geometry' and contains two sub-items: 'Spiral Traversal' and 'Maximum Collinear Points.'  All text within the boxes is in a monospace font.  The dashed lines indicate a hierarchical relationship, showing that 'Math' and 'Geometry' are subcategories of 'Math and Geometry,' and the listed items are specific problems or concepts within those subcategories.
CHAPTER 116

Spiral Traversal

Return the elements of a matrix in clockwise spiral order.

Example:

Image represents a grid-based diagram illustrating a linear data structure traversal pattern, possibly demonstrating a specific algorithm or data flow.  The diagram shows a sequence of numbered nodes (0-19) arranged in a snake-like path across a 5x4 grid.  Each node is represented by a number within a peach-colored rectangle.  Orange arrows connect adjacent nodes, indicating the direction of traversal. The path starts at node 0, proceeds rightward to node 4, then downwards, traversing nodes 9, 14, and 19.  From node 4, the path turns downwards to node 9, then downwards again to node 14, and finally to node 19.  The path then reverses direction at node 19, moving leftward to node 18, and continues leftward and upward, forming a loop that eventually connects back to node 5, then to node 10, and finally to node 15.  The overall structure resembles a snake winding through the grid, demonstrating a non-standard linear traversal order.
Example
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:

Image represents three 4x4 matrices illustrating different traversal patterns. Each matrix contains numbers from 0 to 19 arranged sequentially.  The first matrix shows a rightward traversal, indicated by orange right-pointing arrows connecting the numbers 0 through 4 in the first row, then continuing to the next row and so on. Above this matrix, text reads 'move right until we reach the rightmost column:'. The second matrix demonstrates a downward traversal, with orange downward-pointing arrows connecting the numbers 4, 9, 14, and 19, positioned in the last column.  The text above it reads 'move down until we reach the bottom row:'. The third matrix depicts a leftward traversal, using orange left-pointing arrows connecting the numbers 19, 18, 17, and 15 in the last row, then continuing to the previous row and so on.  The text above this matrix reads 'move left until we reach the leftmost column:'.  All three matrices share the same numerical arrangement, but the arrows highlight different paths through the data.  Row and column indices (0-3) are displayed along the top and left side of each matrix.

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:

Image represents a 4x5 grid illustrating a coding pattern.  The grid is labeled with row numbers (0, 1, 2, 3) on the left and column numbers (0, 1, 2, 3, 4) at the top. Each cell contains a number from 0 to 19, sequentially arranged.  The top-left cell (0,0) is highlighted in light red and labeled as the 'revisited cell'.  A red arrow points from the text 'revisited cell' to this cell (0,0).  Within the grid, upward-pointing grey arrows are shown originating from cells (1,0), (2,0), and (3,0), indicating a movement upwards towards the top row. The text above the grid, 'move up until we reach the top row:', describes the process depicted by the arrows, suggesting an upward traversal of the grid from a specific cell until the top row is reached.

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)O(m\cdot n) space, where mm and nn are the dimensions of the matrix. This is because we need to store every cell of the matrix in the hash set. 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
Image represents a 4x5 matrix of numbers from 0 to 19, visually illustrating a 2-dimensional array.  The matrix is labeled with numbers 0-4 across the top and 0-3 down the left side, representing row and column indices respectively.  The numbers within the matrix are arranged sequentially, increasing from left to right and top to bottom.  Four rectangular boxes labeled 'left,' 'right,' 'top,' and 'bottom' are positioned outside the matrix. Arrows indicate data flow: 'left' points down to the top-left corner (0,0) of the matrix, 'right' points down to the top-right corner (0,4), 'top' points right to the leftmost column (0), and 'bottom' points right to the bottom-most column (3). This arrangement suggests a visual representation of how data might be accessed or traversed within a 2D array using different starting points or directions.

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:

Image represents a comparison of two data structures, both 4x4 matrices.  The left matrix visually demonstrates data traversal.  It contains numbers 0-19 arranged in a grid, with the top row (0-4) highlighted in peach circles and connected by rightward arrows, indicating a sequential traversal from left to right.  Labels 'left' and 'right' (orange boxes) point to the starting and ending points of this traversal, respectively.  Labels 'top' and 'bottom' (grey boxes) indicate the entry and exit points for the data flow into and out of the matrix. The right matrix shows the same numerical data but without the highlighted traversal.  A dotted arrow connects the 'top' label of the left matrix to the 'top' label of the right matrix, suggesting a transformation or mapping between the two structures.  The right matrix's top row (0-4) is shaded light beige, possibly indicating a different state or highlighting a specific aspect of the data.  Both matrices are labeled with row and column indices (0-3 and 0-4 respectively), providing a clear coordinate system for data access.

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

Image represents a comparison of two 5x5 matrices before and after a rightward shift operation.  The left matrix shows a 5x5 grid with numbers from 0 to 24 arranged sequentially, row by row.  Orange rectangular boxes labeled 'top' and 'bottom' indicate data input from the top and bottom, respectively.  Grey rectangular boxes labeled 'left' and 'right' indicate the column indices.  Within the matrix, the numbers 9, 14, and 19 are highlighted in peach circles.  An orange arrow points from the 'top' box to the first row of the matrix, and another from the 'bottom' box to the third row.  A downward orange arrow connects the highlighted numbers 9, 14, and 19, indicating a vertical flow of data. The right matrix depicts the result after a rightward shift. The numbers 9, 14, and 19 have moved to the rightmost column, with the rightmost column's original values (9, 14, 19) now appearing as light peach-colored numbers. A grey arrow points from the 'top' box to the first row of the right matrix, and another from the 'bottom' box to the third row. A dashed grey arrow points from the right matrix to the 'right' label, indicating the output of the shift operation.  The 'left' label remains unchanged, indicating that the leftmost column remains unaffected.

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

Image represents two 5x5 matrices, each labeled with row and column indices from 0 to 4.  The first matrix contains numbers 0-19 arranged sequentially, row by row.  The second matrix is initially identical.  Above each matrix are orange rectangular boxes labeled 'left' and 'right,' indicating directional input.  To the left of both matrices are gray rectangular boxes labeled 'top' and 'bottom,' representing input sources.  Arrows show data flow: 'top' feeds into row 1 of both matrices, 'bottom' feeds into row 3 of both matrices, 'left' feeds into column 0, and 'right' feeds into column 3.  In the first matrix, the numbers 15, 16, 17, and 18 in the bottom row are highlighted in peach.  In the second matrix, these same numbers are highlighted and connected by bidirectional arrows, illustrating a data transformation or interaction specifically within the bottom row, possibly representing a data flow or manipulation within a specific section of the matrix.

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

Image represents a visual depiction of data transformation or flow within a coding pattern, possibly related to matrix manipulation or data indexing.  The image is divided into two main sections, each showing a 4x5 matrix with numerical values (0-19) arranged sequentially.  The left section displays the initial matrix, with values 5 and 10 highlighted in peach circles at coordinates (1,0) and (2,0) respectively.  Above this matrix, labels 'left' and 'right' indicate column headers, and numbers 0-4 represent row indices.  Orange rectangular boxes labeled 'top' and 'bottom' show data input sources, with arrows indicating data flow into the matrix at row indices 1 and 2 respectively, targeting the highlighted cells. The right section shows the transformed matrix after the data flow.  The 'top' input (from the left section) is shown flowing into row 1, and the 'bottom' input flows into row 2.  A dashed arrow points from the 'left' label in the left section to the 'left' label in the right section, suggesting a potential data transformation or mapping process affecting the column headers.  The numbers in the right matrix are identical to the left except for the highlighted cells, which remain unchanged, illustrating a selective update operation.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function spiralMatrix(matrix) {
  if (!matrix || matrix.length === 0) return [];
  const result = [];
  let top = 0, bottom = matrix.length - 1;
  let left = 0, right = matrix[0].length - 1;
  while (top <= bottom && left <= right) {
    for (let i = left; i <= right; i++) {
      result.push(matrix[top][i]);
    }
    top++;
    for (let i = top; i <= bottom; i++) {
      result.push(matrix[i][right]);
    }
    right--;
    if (top <= bottom) {
      for (let i = right; i >= left; i--) {
        result.push(matrix[bottom][i]);
      }
      bottom--;
    }
    if (left <= right) {
      for (let i = bottom; i >= top; i--) {
        result.push(matrix[i][left]);
      }
      left++;
    }
  }
  return result;
}
public List<Integer> spiralMatrix(int[][] matrix) {
    List<Integer> result = new ArrayList<>();
    if (matrix == null || matrix.length == 0) return result;
    int top = 0, bottom = matrix.length - 1;
    int left = 0, right = matrix[0].length - 1;
    while (top <= bottom && left <= right) {
        for (int i = left; i <= right; i++) {
            result.add(matrix[top][i]);
        }
        top++;
        for (int i = top; i <= bottom; i++) {
            result.add(matrix[i][right]);
        }
        right--;
        if (top <= bottom) {
            for (int i = right; i >= left; i--) {
                result.add(matrix[bottom][i]);
            }
            bottom--;
        }
        if (left <= right) {
            for (int i = bottom; i >= top; i--) {
                result.add(matrix[i][left]);
            }
            left++;
        }
    }
    return result;
}

Complexity Analysis

Time complexity: The time complexity of spiral_matrix is O(m⋅n)O(m\cdot n) because we traverse each cell of the matrix once.

Space complexity: The space complexity is O(1)O(1). The res array is not included in the space complexity.

CHAPTER 117

Reverse 32-Bit Integer

Reverse the digits of a signed 32-bit integer. If the reversed integer overflows (i.e., is outside the range [−231-2^{31}, 231−12^{31} - 1]), return 0. Assume the environment only allows you to store integers within the signed 32-bit integer range.

Example 1:

Example
Input: n = 420
Output: 24

Example 2:

Example
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):

Image represents a simple illustration demonstrating a reversal operation.  The left side shows an input sequence 'n = 1 2 3', indicating a variable 'n' assigned a sequence of three integers: 1, 2, and 3. A comma follows this sequence. The right side displays the result of a reversal operation, labeled 'reversed n = θ', showing the variable 'n' after reversal is assigned an empty sequence represented by the symbol 'θ'.  There is no explicit connection drawn between the input and output; the implication is that the operation transforms the input sequence into an empty sequence.  The text 'reversed' acts as a label describing the transformation applied to the input sequence 'n'.

One way to do this is by starting at the last digit of n and appending each digit to reversed_n:

Image represents a visual depiction of a recursive function reversing a sequence of numbers.  Three horizontal rows illustrate the function's iterative steps. Each row shows an input sequence 'm' (on the left) and the corresponding reversed output sequence 'reversed_n' (on the right).  Orange boxes contain the numbers in each sequence.  Orange arrows connect the input 'm' to the output 'reversed_n' in each row.  The first row shows the input sequence 'm = 1 2 3' and the output 'reversed_n = 3'. The second row shows the input 'm = 1 2' and the output 'reversed_n = 3 2', building upon the previous step.  The third row shows the input 'm = 1' and the final reversed output 'reversed_n = 3 2 1', completing the reversal of the initial sequence.  The diagram visually demonstrates how the function recursively processes the input sequence, adding the next element to the beginning of the reversed sequence in each iteration.

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:

Image represents a code snippet demonstrating how to extract the last digit from an integer.  The top line declares an integer variable 'n' and assigns it the value 123. The next line shows a calculation to extract the last digit:  'digit = n % 10'. This uses the modulo operator (%) which returns the remainder after division.  In this case, 123 modulo 10 equals 3, representing the remainder when 123 is divided by 10. The final line shows the result of this calculation: 'digit = 3', indicating that the last digit of 123 is 3. The arrangement is vertical, with each line representing a step in the calculation, flowing from the variable declaration to the calculation and finally to the result.

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:

Image represents a mathematical equation demonstrating integer division. The equation is structured as follows:  'n = n // 10'.  This indicates that a variable 'n' is being assigned a new value. The new value is calculated by performing integer division (represented by '//') of the original value of 'n' by 10.  Below this, a second line shows ' = 12', indicating that after the integer division operation, the variable 'n' now holds the value 12.  The equation implies that the original value of 'n' before the division was 120 (since 120 // 10 = 12).  There are no URLs or parameters present; the image solely depicts a simple arithmetic operation within a coding context, likely illustrating a specific step in an algorithm or calculation.

Once that’s done, let’s add the last digit extracted to our reversed number:

Image represents a line of code snippet, likely from a program designed to reverse an integer.  The snippet shows an assignment statement where a variable named `reversed_n` is being updated.  The operation is an addition (`+=`) of a variable named `digit`, which currently holds the value 3. This suggests that the algorithm is iteratively building the reversed integer by appending digits one at a time. The `reversed_n` variable accumulates the reversed number, with `digit` representing the next digit to be added to the reversed number. The overall structure implies a loop where `digit` would take on different values in successive iterations, each contributing to the construction of `reversed_n`.

Below, we see the current states of n and reversed_n:

Image represents a simple illustration of a coding pattern, specifically demonstrating the concept of reversing a sequence.  The image consists of a linear arrangement of text elements.  It begins with 'n = 1 2,', indicating an initial sequence represented by the variable 'n' containing the values 1 and 2.  This is followed by the word 'reversed', signifying the operation being performed. Finally, 'n = 3' shows the reversed sequence, implying that the original sequence '1 2' has been reversed to become '2 1' (although only the result '3' is shown, suggesting a simplified or incomplete representation of the reversal process, possibly due to context missing from the image).  There are no visual connections or arrows; the relationship between the initial and reversed sequences is implied solely through the textual description and the change in the value of 'n'.

To process the next digit, let’s extract it from n using the modulus operation, then remove it by dividing n by 10:

Image represents a code snippet illustrating an algorithm to extract digits from an integer.  The top line shows an assignment statement: `digit = n % 10`, where `n` represents an integer input and the modulo operator (`%`) calculates the remainder when `n` is divided by 10, effectively extracting the last digit. This is then assigned to the variable `digit`. Below, a sample calculation is shown, where the result of `n % 10` is `2`. The next line shows another assignment statement: `n = n // 10`, using floor division (`//`) to remove the last digit from `n` by integer division.  A sample calculation follows, showing the result of `n // 10` as `1`.  The arrangement vertically displays the sequential steps of the algorithm: first extracting the last digit, then updating the integer by removing that digit.  The algorithm implicitly iterates to extract all digits sequentially.

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:

Image represents a step-by-step calculation illustrating a coding pattern, likely for reversing an integer.  The top line shows an equation: `reversed_n = 10 * reversed_n + digit`. This suggests an iterative process where `reversed_n` accumulates the reversed digits of a number, and `digit` represents the next digit to be added.  The next line shows a specific instance of this equation: `= 30 + 2`, implying that at this step, the current reversed number (`reversed_n`) is 30, and the next digit (`digit`) is 2. The final line shows the result of this addition: `= 32`, indicating the updated value of `reversed_n` after incorporating the digit 2.  The overall structure demonstrates how a reversed integer is constructed digit by digit through repeated multiplication and addition.

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:

Image represents a step-by-step calculation demonstrating a coding pattern for reversing a negative integer.  The top line initializes two variables: `n` to -15 and `reversed_n` to 0.  Below, the calculation proceeds in three steps. First, `digit` is calculated as `n % 10`, resulting in -5 (the modulo operator finds the remainder after division). Second, `n` is updated to `n // 10` using floor division, resulting in -1 (integer division discarding the remainder). Finally, `reversed_n` is updated using the formula `10 * reversed_n + digit`, which incorporates the extracted digit into the reversed number, resulting in -5.  The calculations are shown with both the formula and the resulting value for each step, illustrating the iterative process of extracting digits and building the reversed integer.
Image represents a step-by-step calculation demonstrating a single iteration of an algorithm to reverse a negative integer.  The top line initializes two variables: `n` with a value of -1 and `reversed_n` with a value of -5.  Below, the algorithm extracts the last digit of `n` using the modulo operator (`%`), assigning the result (-1) to the variable `digit`. Next, integer division (`//`) is used to remove the last digit from `n`, updating `n` to 0. Finally, the reversed number is updated by multiplying the current `reversed_n` by 10, adding `digit`, and assigning the result (-51) back to `reversed_n`.  The calculations are clearly shown with the formulas and their results displayed sequentially, illustrating the flow of data between the variables.

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 231−12^{31} - 1, it will overflow, and we should return 0. Let’s call this maximum value INT_MAX.

Image represents an equation showing the maximum value for a 32-bit signed integer.  The left side of the equation is labeled 'INT_MAX ='.  The right side begins with '2<sup>31</sup> - 1 =', indicating that the maximum integer value is calculated by subtracting 1 from 2 raised to the power of 31. The result of this calculation, 2147483647, is displayed to the far right of the equation, representing the maximum positive value that can be stored in a 32-bit signed integer variable.  The equation demonstrates a common calculation in computer science to determine the upper limit of integer data types based on their bit size.

Initially, it might seem sufficient to reverse the number completely, check if it exceeds 231−12^{31} - 1, and return 0 if it does. However, in an environment where integers larger than 231−12^{31} - 1 cannot be stored, attempting to reverse such an integer would cause an overflow:

Image represents a sequence of operations and their outcome.  It begins with the integer 2199999999.  A gray arrow labeled 'reverse' points to the right, indicating a reversal of the digits, resulting in the integer 9999999912.  A second gray arrow, pointing to the right, shows a comparison:  '9999999912 > INT_MAX'. This signifies that the reversed number is greater than the maximum value allowed for an integer data type (INT_MAX). Finally, a red arrow points to the right from the comparison, leading to the word 'overflow' in red text, indicating that the operation has resulted in an integer overflow error due to exceeding the maximum representable integer value.

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 214748364**7** == INT_MAX):

Image represents a step-by-step calculation demonstrating the appending of a digit to a reversed number.  Initially, the variable `reversed_n` holds the value 21478364, and the variable `digit` holds the value 7. The process, labeled 'append digit:', involves the calculation `reversed_n = reversed_n * 10 + digit`. This results in a new value for `reversed_n`, which is 214783647.  A comparison is then made, showing that this new value is equal to `INT_MAX` (presumably representing the maximum value for an integer data type), and an arrow indicates that this equality leads to 'no overflow,' suggesting the calculation successfully appended the digit without exceeding the integer's maximum capacity.
Image represents a step-by-step calculation demonstrating integer overflow.  The top line initializes a variable `reversed_n` to the value 21478364 and a variable `digit` to 8. The second line shows the calculation for appending `digit` to `reversed_n`:  `reversed_n` is multiplied by 10 and `digit` is added to the result, updating `reversed_n`. The third line displays the outcome of this calculation: `reversed_n` becomes 214783648.  A comparison follows, indicating that this new value (214783648) exceeds `INT_MAX` (the maximum value for an integer data type), resulting in an integer overflow, which is highlighted in red as 'overflow'.  The gray text 'append digit:' acts as a label describing the operation performed in the second line.

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, as shown below:

Image represents two examples illustrating a coding pattern related to integer reversal and overflow.  Each example shows a process where a number (`reversed_n`) is reversed, and the result (`n`) is compared against the maximum integer value (`INT_MAX`). The first example begins with `reversed_n = 2147483641`.  Above it, in orange text, is `INT_MAX // 10`, indicating a potential division operation. A grey arrow labeled 'implies' connects `reversed_n` to the reversed number `n = 1463847412`. The second example follows the same structure, starting with `reversed_n = 2147483642`, also with `INT_MAX // 10` above it.  The reversed number is `n = 2463847412`.  However, this result is greater than `INT_MAX`, as indicated by `n = 2463847412 > INT_MAX`, leading to a red 'impossible input' label, highlighting a potential overflow error.  Both examples use the same initial operation (`INT_MAX // 10`) suggesting a common step in the algorithm before the reversal.

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

Try it yourself
Write your solution to the problem before checking the reference implementation.

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.

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
function reverse32BitInteger(n) {
  const INT_MAX = 2147483647;
  const INT_MIN = -2147483648;
  let reversedN = 0;
  while (n !== 0) {
    const digit = n % 10;
    n = Math.trunc(n / 10);
    if (reversedN > Math.trunc(INT_MAX / 10) || reversedN < Math.trunc(INT_MIN / 10)) {
      return 0;
    }
    reversedN = reversedN * 10 + digit;
  }
  return reversedN;
}
public int reverse32BitInteger(int n) {
    int reversedN = 0;
    while (n != 0) {
        int digit = n % 10;
        n /= 10;
        if (reversedN > Integer.MAX_VALUE / 10 || (reversedN == Integer.MAX_VALUE / 10 && digit > 7)) {
            return 0;
        }
        if (reversedN < Integer.MIN_VALUE / 10 || (reversedN == Integer.MIN_VALUE / 10 && digit < -8)) {
            return 0;
        }
        reversedN = reversedN * 10 + digit;
    }
    return reversedN;
}

Complexity Analysis

Time complexity: The time complexity of reverse_32_bit_integer is O(log⁡(n))O(\log(n)) because we loop through each digit of nn, of which there are roughly log⁡(n)\log(n) digits. As this environment only supports 32-bit integers, the time complexity can also be considered O(1)O(1).

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

CHAPTER 118

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:

Image represents a Cartesian coordinate system with a labeled x-axis and y-axis ranging from 0 to 4.  The graph displays several black points plotted at coordinates (1,1), (1,3), (2,2), (3,1), (3,3), and (4,4).  A prominent orange line connects the points (1,1), (2,2), (3,3), and (4,4), representing a linear relationship with a positive slope. The other points are not on this line, suggesting they are outliers or data points not fitting the linear trend shown by the orange line.  The axes are clearly marked with numerical values, indicating the scale of the graph. The overall image suggests a visualization of data points and a best-fit line, possibly illustrating a concept related to linear regression or data fitting within the context of a coding patterns course.
Example
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:

Image represents a Cartesian coordinate system with x and y axes ranging from 0 to 4.  Several black dots are plotted on the graph representing data points: approximately (1,1), (2,2), (3,3), (4,4), (1,3), and (3,1). A dark gray line connects the points (1,1), (2,2), (3,3), and (4,4). This line is highlighted by a light peach-colored band. A curved gray arrow points from the band to the text 'all pairs of points have a slope of 1,' indicating that the slope between any two points on the highlighted line is 1.  The remaining points (1,3) and (3,1) lie outside this highlighted band, illustrating that they do not share the same slope relationship with the points on the line.

Let’s remind ourselves how the slope of a line is calculated given two points (xax_a, yay_a) and (xbx_b, yby_b):

slope=rise/run=(yb−ya)/(xb−xa)slope = rise / run = (y_b-y_a) / (x_b-x_a)

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:

Image represents a Cartesian coordinate system with x and y axes ranging from 0 to 4.  Two lines are plotted on this system.  The first line, colored orange, connects the points (1, 3) and (2, 4).  Next to this line, the text 'slope = 1' is written in orange. The second line, colored cyan, connects the points (2, 2) and (3, 3).  Similarly, the text 'slope = 1' is written next to this line in cyan. Both lines visually demonstrate a slope of 1, indicating a positive linear relationship where for every unit increase in x, there is a corresponding unit increase in y.  The points on both lines are represented by solid black dots.

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.

Image represents a Cartesian coordinate system with x and y axes ranging from 0 to 4.  Two lines are plotted: an orange line with a positive slope passing through points (1,1), (2,2), and (3,3), and a cyan line with a negative slope intersecting the orange line at (2,2) and passing through points (1,3) and (3,1).  Numerical values representing the slopes (1 and -1) are labeled along each line. A downward-pointing arrow labeled 'focal_point' indicates the intersection point (2,2) of the two lines.  To the right, a table labeled 'slope_map' is shown, containing two columns: 'slope' and 'freq'. The 'slope' column lists the slopes 1 and -1, while the 'freq' column shows their corresponding frequencies, 3 and 2 respectively.  This suggests the diagram illustrates a concept where lines with different slopes are analyzed, with the intersection point highlighted as a focal point, and the frequency of each slope recorded in the table.

This allows us to find the maximum number of points collinear with this focal point:

Image represents a Cartesian coordinate system with a line passing through several points.  The x-axis and y-axis are labeled 'x' and 'y' respectively, ranging from 0 to 4. A gray line connects points (1,1), (2,2), and (4,4), with the value '1' indicated as the vertical and horizontal distance between consecutive points. A larger, lighter-shaded area highlights this line segment.  The point (2,2) is specifically labeled 'focal_point' with a downward-pointing arrow indicating its significance.  Several other black dots are plotted at coordinates (1.5, 3), (3,1), and are not directly connected to the main line. Separately, a table labeled 'slope_map' is shown, containing two columns titled 'slope' and 'freq'.  The 'slope' column lists values '1' and '-1', while the 'freq' column lists '3' and '2', suggesting a frequency count associated with each slope.  The overall image likely illustrates a concept related to line fitting, slope analysis, or data distribution within a coding pattern context.

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 (i.e., run=xb−xa=0run = x_b - x_a = 0). This is problematic because dividing by 0 is undefined:

Image represents a Cartesian coordinate system with a vertical line segment plotted on it. The x-axis is labeled 'x' and ranges from 0 to 4, while the y-axis is labeled 'y' and ranges from 0 to 4.  Two points, (2, 1) and (2, 4), are marked on the graph with black dots. A vertical orange line segment connects these two points. To the right of the line segment, the calculation of the slope is shown:  `slope = (4 - 1) / (2 - 2) = 3/0`. This demonstrates that the slope of a vertical line is undefined because division by zero is not possible.

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:

Image represents two calculations of slopes, `slope1` and `slope2`, each defined as the ratio of `rise` to `run`.  `slope1` is calculated as 499999999/500000000, which, due to floating-point precision limitations, results in 0.999999998.  `slope2` is calculated as 499999998/499999999, also resulting in 0.999999998 after floating-point precision is applied.  The diagram visually shows these calculations with rectangular boxes representing the fraction calculations (e.g., 499999999/500000000 and 499999998/499999999) and rounded rectangles representing the final floating-point results (0.999999998). Arrows connect the fraction calculations to their respective floating-point results, labeled 'float precision'.  A vertical arrow and the label 'not equal' connect the two fraction calculations, indicating that despite their near-identical results after floating-point conversion, the original fractions are not mathematically equal.  Similarly, a vertical arrow and the label 'equal' connect the two floating-point results, highlighting that the final results are considered equal due to the limitations of floating-point precision.

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:

Image represents three fractions, ¹⁄₂, ²⁄₄, and ¹³/₂₆, each positioned at the top and slightly separated from each other.  Curved grey lines connect each fraction individually to a single point below. This point is labeled 'same value, different fractional representations,' indicating that despite their different numerators and denominators, all three fractions are equivalent and represent the same numerical value (0.5). The arrangement visually demonstrates the concept of equivalent fractions, showcasing how a single value can have multiple fractional representations.

If we reduce fractions to their simplest forms, we can consistently represent equal fractions that have different initial representations.

Image represents a diagram illustrating a pattern in fraction simplification.  The diagram shows three columns, each representing a separate fraction simplification example.  The top of each column displays an initial fraction:  ¹⁄₂ in the first column, ²⁄₄ in the second, and ¹³/₂₆ in the third.  A downward-pointing grey arrow connects each initial fraction to a simplified equivalent fraction at the bottom of the column: ¹⁄₂ in the first column, ¹⁄₂ in the second, and ¹⁄₂ in the third.  The arrangement visually demonstrates that despite starting with different numerical representations, each fraction simplifies to the same result, ¹⁄₂, highlighting a pattern of fraction reduction.

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:

Image represents a step-by-step calculation demonstrating the simplification of a ratio using the greatest common divisor (GCD).  It begins with an ordered pair (rise, run) = (13, 26), representing a slope or ratio.  A grey arrow indicates a transformation where both 'rise' and 'run' are divided by 13 (highlighted in orange), resulting in a simplified ratio (rise/13, run/13) = (1, 2).  Below, a separate calculation shows that the greatest common divisor of 13 and 26 is 13 (also highlighted in orange).  The arrangement visually connects the initial ratio, its simplification, and the GCD calculation, illustrating how finding the GCD allows for the simplification of the ratio.  The arrows show the flow of information, indicating the dependency of the simplified ratio on the GCD calculation.

This ensures all equal fractions are represented in the same way.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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 the maximum count to include the focal point itself.
    return max_points + 1
    
def get_slope(p1: List[int], p2: List[int]) -> Tuple[int, int]:
    rise = p2[1] - p1[1]
    run = p2[0] - p1[0]
    # Handle vertical lines separately to avoid dividing by 0.
    if run == 0:
        return (1, 0)
    # Simplify the slope to its reduced form.
    gcd_val = gcd(rise, run)
    return (rise // gcd_val, run // gcd_val)
function maximumCollinearPoints(points) {
  let res = 0;
  for (let i = 0; i < points.length; i++) {
    res = Math.max(res, maxPointsFromFocalPoint(i, points));
  }
  return res;
}

function maxPointsFromFocalPoint(focalPointIndex, points) {
  const slopesMap = new Map();
  let maxPoints = 0;
  for (let j = 0; j < points.length; j++) {
    if (j !== focalPointIndex) {
      const slope = getSlope(points[focalPointIndex], points[j]);
      const key = `${slope[0]},${slope[1]}`;
      const count = (slopesMap.get(key) || 0) + 1;
      slopesMap.set(key, count);
      maxPoints = Math.max(maxPoints, count);
    }
  }
  return maxPoints + 1;
}

function getSlope(p1, p2) {
  let rise = p2[1] - p1[1];
  let run = p2[0] - p1[0];
  if (run === 0) return [1, 0];
  const gcdVal = gcd(rise, run);
  return [Math.floor(rise / gcdVal), Math.floor(run / gcdVal)];
}
public int maximumCollinearPoints(int[][] points) {
    int res = 0;
    for (int i = 0; i < points.length; i++) {
        res = Math.max(res, maxPointsFromFocalPoint(i, points));
    }
    return res;
}

private int maxPointsFromFocalPoint(int focalPointIndex, int[][] points) {
    Map<String, Integer> slopesMap = new HashMap<>();
    int maxPoints = 0;
    for (int j = 0; j < points.length; j++) {
        if (j != focalPointIndex) {
            int[] slope = getSlope(points[focalPointIndex], points[j]);
            String key = slope[0] + "," + slope[1];
            slopesMap.put(key, slopesMap.getOrDefault(key, 0) + 1);
            maxPoints = Math.max(maxPoints, slopesMap.get(key));
        }
    }
    return maxPoints + 1;
}

private int[] getSlope(int[] p1, int[] p2) {
    int rise = p2[1] - p1[1];
    int run = p2[0] - p1[0];
    if (run == 0) return new int[]{1, 0};
    int gcdVal = gcd(rise, run);
    return new int[]{rise / gcdVal, run / gcdVal};
}

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:

# The Euclidean algorithm.
def gcd(a, b):
    while b != 0:
        a, b = b, a % b
    return a
// The Euclidean algorithm.
function gcd(a, b) {
  while (b !== 0) {
    const temp = b;
    b = a % b;
    a = temp;
  }
  return a;
}
// The Euclidean algorithm.
public int gcd(int a, int b) {
    while (b != 0) {
        int temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}

Complexity Analysis

Time complexity: The time complexity of maximum_collinear_points is O(n2log⁡(m))O(n^2\log(m)), where nn denotes the number of points, and mm denotes the largest value among the coordinates. Here’s why:

  • The time complexity of the gcd(rise, run) function is O(log⁡(min(rise,run)))O(\log(min(rise, run))), which is approximately equal to O(log⁡(m))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(nlog⁡(m))O(n\log(m)).

  • The max_points_from_focal_point function is called a total of nn times, resulting in an overall time complexity of O(n2log⁡(m))O(n^2\log(m)).

Space complexity: The space complexity is O(n)O(n) due to the hash map, which in the worst case, stores n−1n-1 key-value pairs: one for each point excluding the focal point.

CHAPTER 119

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:

Image represents a step-by-step process demonstrating a coding pattern, possibly related to data structure manipulation or algorithm execution.  The process begins with a set of five nodes (circles) labeled 0, 1, 2, 3, and 4, arranged somewhat arbitrarily.  Small numbers (1 and 2) are positioned near some nodes, potentially representing weights, priorities, or indices.  A node labeled '1' is crossed out with a gray 'X', indicating its removal or inactivation.  A rightward arrow indicates a transition to the next step.  In subsequent steps, the crossed-out node is removed, and other nodes (initially 3 and 2) become grayed out, suggesting they are also processed or removed in later stages.  The process continues with further graying out and removal of nodes, showing a sequence of transformations on the initial node arrangement.  The final state shows only nodes 0 and 2 remaining, with the other nodes grayed out and crossed out, implying a filtering or reduction process.  The numbers near the nodes likely track changes or maintain some form of ordering or identification throughout the process.
Example
Input: n = 5, k = 2
Output: 2

Constraints:

  • There will be at least one person in the circle
  • k will at least be equal to 1.

Intuition

The naive approach to solving this problem is to simulate the removal of people step by step. We can create a circular linked list with n nodes. Starting from node 0, we iterate through the linked list, removing every kth person. The last node remaining after all removals will represent the last remaining person.

This approach takes O(n⋅k)O(n\cdot k) time because, to remove a node, we must iterate through kk nodes in the linked list. Therefore, for each of the nn nodes, we perform kk 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.

Image represents a circular arrangement of twelve numbered nodes (0 through 11) representing 'n people', with a central text label 'n people'.  An orange arrow labeled 'start' points downwards to node 0. A red curved arrow originates from node 0, passes over nodes 1 and 2, and terminates at node 3, which is marked with a red 'X' indicating its removal.  The red arrow is labeled 'k = 4', suggesting a process where every fourth person (starting from 0) is eliminated.  The numbers within the circles represent the positions of individuals in a circular arrangement, and the process depicted simulates a 'Josephus problem' type elimination.

Image represents a circular arrangement of twelve numbered circles (0 through 11) representing individuals, positioned around a central area.  The numbers are sequentially placed around the circle, starting with 0 at the top and proceeding clockwise.  The central area contains the text 'n - 1 people', indicating the number of people involved in a process, excluding one. An orange arrow points downwards from the text 'new start' to the circle labeled '4', signifying that the process begins anew with the individual represented by the number 4.  This suggests a cyclical or iterative process where the starting point shifts after each iteration, potentially involving the elimination or selection of individuals in a circular fashion.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation


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
function josephus(n, k) {
  // 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;
}
public int josephus(int n, int k) {
    // 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)O(n) because we make a total of nn recursive calls to this function until we reach the base case.

Space complexity: The space complexity is O(n)O(n) due to the recursive call stack, which grows up to a depth of nn.

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.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function josephusOptimized(n, k) {
  let res = 0;
  for (let i = 2; i <= n; i++) {
    // res[i] = (res[i - 1] + k) % i.
    res = (res + k) % i;
  }
  return res;
}
public int josephusOptimized(int n, int k) {
    int res = 0;
    for (int i = 2; i <= n; i++) {
        // 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)O(n) because we iterate through nn subproblems.

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

CHAPTER 120

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:

Image represents a triangular arrangement of numbers, resembling Pascal's triangle.  The triangle is outlined in orange and filled with a light beige color.  Four rows are shown, with each row representing a level in the triangle.  The first row contains only the number '1'. The second row contains '1 1 1'. The third row contains '1 2 3 2 1', and the fourth row contains '1 3 6 7 6 3 1'.  To the left, four horizontal grey arrows point towards the triangle, labeled 'row 1', 'row 2', 'row 3', and 'row 4' respectively, indicating the input or source of each row.  Within the triangle, small grey arrows and '+' symbols show how the numbers in row 3 are added to generate the numbers in row 4. The number '6' in row 4 is highlighted in a peach-colored circle. A curved arrow points from this circled '6' to a caption below the triangle that reads: `1<sup>st</sup> even number in row 4 is at position 3.`, indicating the position of the first even number in the fourth row.
Example
Input: n = 4
Output: 3

Constraints:

  • n will be at least 3.

Intuition

A naive solution to this problem is to generate the entire triangle and all of its values up to the nth row. Then, we can iterate through the nth row until we encounter the first even number. However, this approach is inefficient because it results in an excessive use of time and memory to build the entire triangle. To find a more optimal solution, let’s consider how we can simplify the representation of our triangle.

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:

Image represents a numerical triangle, resembling Pascal's triangle but with a modified pattern.  The triangle is arranged with a single '1' at the apex, followed by rows expanding symmetrically. Each number in a row is the sum of the two numbers directly above it (except for the edge '1's).  The numbers increase towards the center of each row and then decrease symmetrically.  Vertically oriented red lines are drawn through the triangle, seemingly highlighting a specific path or subset of numbers within the structure.  These lines do not appear to follow a consistent pattern based on the visible portion of the triangle, suggesting a selection rather than a mathematical relationship.  No URLs or parameters are present.

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:

Image represents a 5x5 matrix, or a two-dimensional array, visually depicting a pattern.  The top row is labeled 'position' with numbers 1 through 5 representing column indices. The leftmost column is labeled 'row' with numbers 1 through 5 representing row indices.  The matrix itself contains numerical values; the first row contains a single '1', the second row contains '1' and '1', the third row contains '1', '2', and '3', the fourth row contains '1', '3', '6', and '7', and the fifth row contains '1', '4', '10', '16', and '19'.  The arrangement shows a pattern where each number is the sum of the number directly above it and the number to its left (except for the first element in each row and column, which are all '1').  There are no URLs or parameters present.

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:

Image represents a 5x5 matrix, labeled 'position' at the top, showing the arrangement of data points.  The rows are numbered 1 through 5 on the left-hand side, labeled 'row,' and the columns are numbered 1 through 5 at the top, labeled 'position.' Each cell within the matrix contains either a '1' or a '0.' The arrangement of '1's and '0's is not random; it appears to represent a pattern, possibly related to a specific coding pattern or algorithm.  The '1's are concentrated along the main diagonal and adjacent cells, while the '0's fill the remaining cells.  No other information, such as URLs or parameters, is present in the image. The matrix visually displays the positional relationship between the data points represented by the '1's and '0's.

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:

Image represents a 5x5 matrix illustrating a coding pattern.  The top row is labeled 'position' with numbers 1 through 5 representing column indices. The leftmost column is labeled 'row,' with rows 1 and 2 showing only light gray '1's. Rows 3 and 5 are labeled '(odd)' and row 4 is labeled '(even),' indicating a pattern based on row parity.  Each cell within the matrix contains either a '1' or a peach-colored '0'.  The '0's are strategically placed at positions (3,2), (4,3), and (5,2), suggesting a pattern related to the interaction between row parity and column position.  The '1's fill the remaining cells, forming a pattern that could be described algorithmically based on the row number and column position.  No URLs or parameters are present.

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:

Image represents a 7x7 matrix visually depicting a pattern.  The matrix is labeled 'position' along the top row, with numbers 1 through 7 representing column indices. The leftmost column is labeled 'row,' with row indices 1 through 7, with odd-numbered rows additionally labeled '(odd)'.  The matrix cells contain either the number '1' or a peach-colored circle representing '0'. The '0' values are arranged diagonally across the matrix, starting from row 3, column 2 and continuing down and to the right, with one '0' per row. All other cells contain the number '1'.  The arrangement suggests a pattern where the '0' values indicate a specific condition or state within the data structure represented by the matrix.

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:

Image represents a 10x10 matrix illustrating a coding pattern, specifically focusing on odd-numbered rows.  The matrix is labeled with 'position' across the top (numbers 1-10) and 'row' down the side (numbers 1-10, with '(odd)' annotations beside odd-numbered rows).  The matrix cells contain either a '0' or a '1'.  Within each odd-numbered row (3, 5, 7, 9), a single cell contains a peach-colored '0', visually highlighted.  This peach-colored '0' is positioned such that it moves one position to the right in each subsequent odd-numbered row.  The remaining cells in the odd-numbered rows contain '1's, while the even-numbered rows are partially filled with '1's, forming a pattern that mirrors the arrangement of '1's in the odd rows, but without the highlighted '0'. The overall pattern suggests a relationship between row number and the position of the highlighted '0', potentially representing an algorithm or data structure where the highlighted '0' indicates a specific index or element within each odd-numbered row.

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:

Image represents a 4x4 matrix, visually depicted as a table with rows labeled 3, 4, 5, and 6, and implicitly numbered columns.  Each cell contains a binary value (0 or 1).  The matrix is bordered by a light-blue rectangle.  Within the matrix, specific cells contain a light-peach colored circle encompassing the digit '0'. These circled zeros appear at positions (3,2), (4,3), (5,2), and (6,4), indicating a possible selection or highlighting of these specific elements.  The remaining cells contain either a '1' or a '0' without any special formatting.  No URLs or parameters are visible; the image solely presents the matrix data and the visual emphasis on certain '0' values.

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:

Image represents four identical numerical arrays, each consisting of a 7x1 arrangement of numbers.  Each array displays the numbers 1, 2, 1, 2, 1 across the second row, and 1, 3, 6, 7, 6, 3, 1 across the third row.  The top row of each array shows a single '1' in grey, and the fourth row shows a single '1' in grey.  The first and last numbers in the second and third rows are also in grey.  Within each array, orange arrows point downwards from the '1' and '2' values in the second row to the numbers below, indicating a summation operation.  Specifically, the '1' in the second row is added to the '3' below it, and the '2' is added to the '6' below it, resulting in the '3' and '6' in the third row being the sum of the numbers above them.  The '7' in the third row is the sum of the '1' and '2' above it.  This pattern of summation is consistent across all four arrays.

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.
Image represents three 10x4 matrices, each depicting a different pattern of 0s and 1s.  Above each matrix is a peach-colored annotation describing the pattern's rule. The first matrix, labeled 'all odd rows: position 2,' shows a 0 in the second position of every odd-numbered row (rows 3, 5, 7, and 9). The second matrix, labeled 'all rows that are a multiple of 4: position 3,' shows a 0 in the third position of every fourth row (row 4 and row 8). The third matrix, labeled 'all other rows: position 4,' shows a 0 in the fourth position of all rows not covered by the first two rules (rows 3, 5, 6, 7, 9, and 10).  Each matrix has rows numbered 3 through 10, and each row contains four values (1 or 0).  A downward-pointing arrow connects each annotation to its corresponding matrix, visually indicating the application of the rule.  The highlighted 0s in each matrix clearly show the positions affected by each rule.

This problem demonstrates how recognizing patterns and simplifying the problem can turn a time-consuming solution into a quick, constant-time one.

Try it yourself
Write your solution to the problem before checking the reference implementation.

Implementation

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
function triangleNumbers(n) {
  // 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.
  } else if (n % 4 === 0) {
    return 3;
  }
  // For all other rows, the first even number always starts at position 4.
  return 4;
}
public int triangleNumbers(int n) {
    // 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.
    } else if (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)O(1).

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