Facebook Pixel

354. Russian Doll Envelopes

Problem Description

You have a collection of envelopes, each with a width and height. The goal is to find the maximum number of envelopes you can nest inside each other, like Russian dolls.

An envelope [w1, h1] can fit inside another envelope [w2, h2] only if both conditions are met:

  • w1 < w2 (the first envelope's width is strictly less than the second's)
  • h1 < h2 (the first envelope's height is strictly less than the second's)

You need to determine the longest chain of envelopes where each envelope in the sequence can fit inside the next one. Envelopes cannot be rotated.

For example, if you have envelopes [[5,4], [6,4], [6,7], [2,3]], the maximum number you can nest is 3: [2,3] → [5,4] → [6,7].

The solution uses a clever approach by:

  1. Sorting envelopes by width (ascending) and height (descending for same width)
  2. Finding the longest increasing subsequence (LIS) based on heights only

The sorting strategy ensures that envelopes with the same width cannot be nested (due to descending height order), while the LIS on heights gives us the maximum nesting sequence. The algorithm uses binary search (bisect_left) to efficiently maintain the LIS, achieving O(n log n) time complexity.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is recognizing this problem as a 2D version of the Longest Increasing Subsequence (LIS) problem. In standard LIS, we find the longest sequence where each element is greater than the previous one. Here, we need both dimensions (width and height) to be increasing.

The challenge is handling two dimensions simultaneously. We can't simply apply LIS on pairs because we need both width AND height to increase together. This is where the sorting strategy becomes crucial.

By sorting envelopes by width first, we ensure that as we traverse the array, we only need to worry about one dimension - the height. Once sorted by width, any valid nesting sequence must have increasing heights as well.

But there's a subtle issue: what if multiple envelopes have the same width? For example, [3,4] and [3,5]. They can't nest inside each other since their widths are equal, but if we only consider heights after sorting, we might incorrectly count both in our sequence.

The clever trick is to sort envelopes with the same width in descending order by height. Why? This prevents us from selecting multiple envelopes with the same width in our LIS. Since we're looking for an increasing sequence of heights, and envelopes with the same width are arranged in decreasing height order, we can only pick at most one envelope from each width group.

After this special sorting (x[0], -x[1]), the problem reduces to finding LIS on the heights alone. The binary search optimization using bisect_left maintains a list d where d[i] represents the smallest ending height of all increasing subsequences of length i+1, allowing us to efficiently build the longest sequence.

Solution Approach

The implementation follows these steps:

Step 1: Sort the envelopes

envelopes.sort(key=lambda x: (x[0], -x[1]))

This sorts envelopes by width in ascending order. When widths are equal, it sorts by height in descending order (note the negative sign). This ensures envelopes with the same width won't both be selected in our subsequence.

Step 2: Initialize the LIS tracker

d = [envelopes[0][1]]

We create a list d that will maintain the smallest ending heights for subsequences of different lengths. Initially, it contains just the height of the first envelope.

Step 3: Process remaining envelopes

for _, h in envelopes[1:]:

We iterate through the remaining envelopes, only caring about their heights (width is already handled by sorting).

Step 4: Update the LIS tracker For each height h:

  • If h > d[-1]: The current height is larger than all heights in d, so we can extend our longest subsequence by appending h.

    d.append(h)
  • Otherwise: We find the leftmost position in d where we can place h to maintain the sorted order.

    idx = bisect_left(d, h)
    d[idx] = h

    This replacement ensures d[idx] stores the smallest possible height for subsequences of length idx+1.

Step 5: Return the result

return len(d)

The length of d represents the maximum number of envelopes that can be nested.

The algorithm maintains the invariant that d[i] is the minimum ending height among all increasing subsequences of length i+1. By using binary search (bisect_left), we achieve O(n log n) time complexity instead of the O(n²) dynamic programming approach.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through the solution with envelopes [[5,4], [6,4], [6,7], [2,3]].

Step 1: Sort the envelopes

  • Original: [[5,4], [6,4], [6,7], [2,3]]

  • After sorting by (width ascending, height descending for same width):

    • [2,3] (width=2)
    • [5,4] (width=5)
    • [6,7] (width=6, height=7)
    • [6,4] (width=6, height=4)

    Note: The two envelopes with width=6 are sorted by height in descending order (7 before 4).

Sorted array: [[2,3], [5,4], [6,7], [6,4]]

Step 2: Extract heights and find LIS Heights sequence: [3, 4, 7, 4]

Now we find the longest increasing subsequence on these heights:

  • Initialize d = [3] (first height)

  • Process height 4:

    • 4 > 3 (last element in d)
    • Append 4: d = [3, 4]
  • Process height 7:

    • 7 > 4 (last element in d)
    • Append 7: d = [3, 4, 7]
  • Process height 4:

    • 4 < 7 (last element in d)
    • Find position using binary search: bisect_left([3, 4, 7], 4) = 1
    • Replace d[1] with 4: d = [3, 4, 7] (no change since d[1] was already 4)

Result: len(d) = 3

The maximum nesting sequence has 3 envelopes. One valid sequence is:

  • [2,3][5,4][6,7]

The sorting strategy prevented us from incorrectly selecting both [6,7] and [6,4] (which have the same width and cannot nest). By placing [6,7] before [6,4] in our sorted array, the LIS algorithm naturally selected [6,7] as part of the increasing sequence, while [6,4] couldn't extend it further.

Solution Implementation

1from typing import List
2from bisect import bisect_left
3
4class Solution:
5    def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
6        # Sort envelopes by width ascending, and if widths are equal, by height descending
7        # This ensures we can't use two envelopes with the same width in our sequence
8        envelopes.sort(key=lambda envelope: (envelope[0], -envelope[1]))
9      
10        # Initialize LIS (Longest Increasing Subsequence) array with the first envelope's height
11        # This array maintains the smallest tail value for each possible subsequence length
12        lis_heights = [envelopes[0][1]]
13      
14        # Process remaining envelopes
15        for width, height in envelopes[1:]:
16            if height > lis_heights[-1]:
17                # If current height is larger than the largest height in LIS,
18                # we can extend the sequence
19                lis_heights.append(height)
20            else:
21                # Find the leftmost position where we can replace an existing height
22                # with the current height to maintain the LIS property
23                insertion_index = bisect_left(lis_heights, height)
24                lis_heights[insertion_index] = height
25      
26        # The length of the LIS array represents the maximum number of envelopes
27        # that can be nested (Russian doll style)
28        return len(lis_heights)
29
1class Solution {
2    public int maxEnvelopes(int[][] envelopes) {
3        // Sort envelopes by width ascending, if width is same then by height descending
4        // This ensures we don't count multiple envelopes with same width
5        Arrays.sort(envelopes, (a, b) -> {
6            if (a[0] == b[0]) {
7                return b[1] - a[1];  // Same width: sort by height descending
8            }
9            return a[0] - b[0];  // Different width: sort by width ascending
10        });
11      
12        int n = envelopes.length;
13      
14        // dp array to store the smallest tail height for each subsequence length
15        // dp[i] represents the smallest ending height of all increasing subsequences of length i
16        int[] dp = new int[n + 1];
17        dp[1] = envelopes[0][1];  // Initialize with first envelope's height
18        int maxLength = 1;  // Current maximum length of increasing subsequence
19      
20        // Process remaining envelopes
21        for (int i = 1; i < n; i++) {
22            int currentHeight = envelopes[i][1];
23          
24            if (currentHeight > dp[maxLength]) {
25                // Current height is larger than all existing subsequence tails
26                // Extend the longest subsequence
27                maxLength++;
28                dp[maxLength] = currentHeight;
29            } else {
30                // Find the position to replace using binary search
31                // We need to find the leftmost position where dp[pos] >= currentHeight
32                int left = 1;
33                int right = maxLength;
34              
35                while (left < right) {
36                    int mid = (left + right) >> 1;  // Equivalent to (left + right) / 2
37                    if (dp[mid] >= currentHeight) {
38                        right = mid;  // Search in left half including mid
39                    } else {
40                        left = mid + 1;  // Search in right half
41                    }
42                }
43              
44                // After binary search, left is the position where dp[left] >= currentHeight
45                // Replace dp[left] with currentHeight to maintain the smallest tail property
46                int position = (dp[left] >= currentHeight) ? left : 1;
47                dp[position] = currentHeight;
48            }
49        }
50      
51        return maxLength;
52    }
53}
54
1class Solution {
2public:
3    int maxEnvelopes(vector<vector<int>>& envelopes) {
4        // Sort envelopes by width ascending, and if widths are equal, by height descending
5        // The height descending ensures we don't mistakenly nest envelopes with same width
6        sort(envelopes.begin(), envelopes.end(), [](const auto& envelope1, const auto& envelope2) {
7            return envelope1[0] < envelope2[0] || 
8                   (envelope1[0] == envelope2[0] && envelope1[1] > envelope2[1]);
9        });
10      
11        int numEnvelopes = envelopes.size();
12      
13        // LIS array to track the smallest ending height for each subsequence length
14        vector<int> lisHeights{envelopes[0][1]};
15      
16        // Process remaining envelopes to find LIS of heights
17        for (int i = 1; i < numEnvelopes; ++i) {
18            int currentHeight = envelopes[i][1];
19          
20            // If current height is larger than all previous, extend the sequence
21            if (currentHeight > lisHeights.back()) {
22                lisHeights.push_back(currentHeight);
23            }
24            else {
25                // Find the position to replace with current height using binary search
26                int replaceIndex = lower_bound(lisHeights.begin(), lisHeights.end(), currentHeight) 
27                                   - lisHeights.begin();
28              
29                // Update the height at found position
30                lisHeights[replaceIndex] = currentHeight;
31            }
32        }
33      
34        // The size of LIS array represents the maximum number of nested envelopes
35        return lisHeights.size();
36    }
37};
38
1function maxEnvelopes(envelopes: number[][]): number {
2    // Sort envelopes by width ascending, and if widths are equal, by height descending
3    // The height descending ensures we don't mistakenly nest envelopes with same width
4    envelopes.sort((envelope1, envelope2) => {
5        if (envelope1[0] !== envelope2[0]) {
6            return envelope1[0] - envelope2[0];
7        }
8        return envelope2[1] - envelope1[1];
9    });
10  
11    const numEnvelopes = envelopes.length;
12  
13    // LIS array to track the smallest ending height for each subsequence length
14    const lisHeights: number[] = [envelopes[0][1]];
15  
16    // Process remaining envelopes to find LIS of heights
17    for (let i = 1; i < numEnvelopes; i++) {
18        const currentHeight = envelopes[i][1];
19      
20        // If current height is larger than all previous, extend the sequence
21        if (currentHeight > lisHeights[lisHeights.length - 1]) {
22            lisHeights.push(currentHeight);
23        } else {
24            // Find the position to replace with current height using binary search
25            const replaceIndex = lowerBound(lisHeights, currentHeight);
26          
27            // Update the height at found position
28            lisHeights[replaceIndex] = currentHeight;
29        }
30    }
31  
32    // The size of LIS array represents the maximum number of nested envelopes
33    return lisHeights.length;
34}
35
36// Helper function to find the leftmost position where target can be inserted
37function lowerBound(arr: number[], target: number): number {
38    let left = 0;
39    let right = arr.length;
40  
41    while (left < right) {
42        const mid = Math.floor((left + right) / 2);
43        if (arr[mid] < target) {
44            left = mid + 1;
45        } else {
46            right = mid;
47        }
48    }
49  
50    return left;
51}
52

Time and Space Complexity

Time Complexity: O(n log n)

The time complexity breaks down as follows:

  • Sorting the envelopes takes O(n log n) where n is the number of envelopes
  • Iterating through the sorted envelopes takes O(n) time
  • For each envelope, we either:
    • Append to the end of array d in O(1) time, or
    • Perform binary search using bisect_left in O(log k) time where k is the current size of array d, and update the element at that index in O(1) time
  • Since k ≤ n, each binary search operation is at most O(log n)
  • Overall: O(n log n) + O(n) * O(log n) = O(n log n)

Space Complexity: O(n)

The space complexity analysis:

  • The sorting operation may use O(log n) space for the recursion stack (depending on the sorting algorithm implementation)
  • Array d stores at most n elements in the worst case, requiring O(n) space
  • No other significant auxiliary space is used
  • Overall: O(n) space complexity

Common Pitfalls

Pitfall 1: Incorrect Sorting Strategy for Same Width

The Problem: A common mistake is sorting envelopes with the same width in ascending order of height instead of descending order. This would look like:

envelopes.sort(key=lambda x: (x[0], x[1]))  # WRONG!

Why This Fails: If you sort heights in ascending order for the same width, the LIS algorithm might incorrectly select multiple envelopes with the same width. For example, with envelopes [[1,2], [1,3], [1,4]], the algorithm would think all three can be nested, but they can't since they have the same width.

The Solution: Always sort by height in descending order when widths are equal:

envelopes.sort(key=lambda x: (x[0], -x[1]))  # CORRECT

This ensures that among envelopes with the same width, only one can be selected in the increasing subsequence (since heights would be decreasing).

Pitfall 2: Using Standard LIS Without Proper Sorting

The Problem: Some might try to apply LIS directly on both dimensions or only sort by one dimension:

# Only sorting by width
envelopes.sort(key=lambda x: x[0])
# Then trying to find LIS on (width, height) pairs

Why This Fails: This doesn't handle the strict inequality requirement for both dimensions. You might end up with envelopes that have equal heights or widths in your sequence.

The Solution: The two-step approach is essential:

  1. First, handle the width dimension through sorting
  2. Then, apply LIS only on the height dimension, with the special descending sort for equal widths

Pitfall 3: Misunderstanding the bisect_left Replacement

The Problem: Some might think we're building the actual sequence of envelopes in the lis_heights array and be confused why we're replacing values:

# Misconception: lis_heights contains the actual sequence of envelopes

Why This Is Wrong: The lis_heights array doesn't store the actual subsequence. Instead, lis_heights[i] stores the minimum possible ending height for all subsequences of length i+1.

The Solution: Understand that this is a space-optimized approach where we only track the minimum ending values for each possible subsequence length. The actual sequence isn't preserved, but its length is, which is all we need for this problem.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

How does merge sort divide the problem into subproblems?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More