Facebook Pixel

2826. Sorting Three Groups

Problem Description

You are given an integer array nums where each element is either 1, 2, or 3. Your goal is to make the array non-decreasing (each element is less than or equal to the next element) by removing some elements.

In each operation, you can remove one element from nums. You need to find the minimum number of operations (removals) required to make the remaining array non-decreasing.

For example:

  • If nums = [2, 1, 3, 2], you could remove the elements at indices 0 and 3 to get [1, 3], which is non-decreasing. This takes 2 operations.
  • A non-decreasing array means that for any two adjacent elements, the left element is less than or equal to the right element (like [1, 1, 2, 3, 3]).

The solution uses dynamic programming where f[j] represents the minimum number of operations needed to process all elements seen so far, with the last kept element being j+1. For each new element x in the array, we calculate:

  • If we keep x as value 1, we need all previous elements to be ≤ 1
  • If we keep x as value 2, we need all previous elements to be ≤ 2
  • If we keep x as value 3, we need all previous elements to be ≤ 3
  • If the current element doesn't match the target value, we add 1 to the operation count (meaning we remove it)

The final answer is the minimum value among all possible ending states.

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

Intuition

The key insight is that since we can only remove elements (not rearrange them), we're essentially choosing which elements to keep from the original array while maintaining their relative order. For the kept elements to form a non-decreasing sequence, each kept element must be greater than or equal to all previously kept elements.

Since each element can only be 1, 2, or 3, we can think about this problem differently: at any position in the array, the "last kept value" can only be one of these three numbers. This limited state space makes dynamic programming feasible.

The crucial observation is that if we've processed some prefix of the array and the last element we kept was value k, then for the remaining array to be non-decreasing, we can only keep future elements that are ≥ k.

This leads us to track three states as we process each element:

  • The minimum operations needed if the last kept element is 1
  • The minimum operations needed if the last kept element is 2
  • The minimum operations needed if the last kept element is 3

For each new element x, we have a choice: either keep it or remove it. If we keep it, it becomes our new "last kept element". If we remove it, we add 1 to our operation count and maintain the previous "last kept element".

The transition works as follows: if we want the current element to be our new last kept value j, we need the previous last kept value to be ≤ j. So we look at all valid previous states (values ≤ j) and take the minimum operations from those states. If the current element x doesn't equal j, we add 1 (meaning we remove the current element).

By processing the entire array this way and tracking the minimum operations for each possible ending value, we can find the overall minimum operations needed.

Learn more about Binary Search and Dynamic Programming patterns.

Solution Approach

The solution uses dynamic programming with state compression to optimize space complexity. Let's walk through the implementation step by step.

We maintain an array f of size 3, where f[j] represents the minimum number of operations needed to process all elements seen so far, with the last kept element being j+1.

Initially, f = [0, 0, 0] since we haven't processed any elements yet.

For each element x in nums, we create a new state array g to store the updated minimum operations:

Case 1: When x == 1

  • g[0] = f[0]: If we want the last kept element to be 1, and the current element is 1, we keep it without any additional operations
  • g[1] = min(f[:2]) + 1: If we want the last kept element to be 2, but current is 1, we must remove the current element (add 1 operation)
  • g[2] = min(f) + 1: If we want the last kept element to be 3, but current is 1, we must remove the current element

Case 2: When x == 2

  • g[0] = f[0] + 1: If we want the last kept element to be 1, but current is 2, we must remove it (2 > 1 violates non-decreasing)
  • g[1] = min(f[:2]): If we want the last kept element to be 2, and current is 2, we can keep it. Previous state can be 1 or 2
  • g[2] = min(f) + 1: If we want the last kept element to be 3, but current is 2, we must remove it

Case 3: When x == 3

  • g[0] = f[0] + 1: If we want the last kept element to be 1, but current is 3, we must remove it
  • g[1] = min(f[:2]) + 1: If we want the last kept element to be 2, but current is 3, we must remove it
  • g[2] = min(f): If we want the last kept element to be 3, and current is 3, we can keep it. Previous state can be 1, 2, or 3

After processing each element, we update f = g to carry forward the state.

The key pattern here is:

  • When the current element equals our target last value, we don't add to the operation count
  • When they differ, we add 1 (removing the current element)
  • We use min(f[:k]) to find the best previous state that maintains the non-decreasing property

Finally, min(f) gives us the minimum operations needed across all possible ending values.

The time complexity is O(n) where n is the length of the array, and space complexity is O(1) due to the rolling array optimization.

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's trace through the algorithm with nums = [2, 1, 3, 2].

We maintain f[j] where f[0] represents min operations with last kept element = 1, f[1] for last kept = 2, and f[2] for last kept = 3.

Initial State: f = [0, 0, 0]

Processing element 2:

  • Current element x = 2
  • Calculate new state g:
    • g[0] (want last kept = 1, but current = 2): Must remove 2, so f[0] + 1 = 0 + 1 = 1
    • g[1] (want last kept = 2, current = 2): Can keep it! Take min(f[0], f[1]) = min(0, 0) = 0
    • g[2] (want last kept = 3, but current = 2): Must remove 2, so min(f[0], f[1], f[2]) + 1 = 0 + 1 = 1
  • Update: f = [1, 0, 1]

Processing element 1:

  • Current element x = 1
  • Calculate new state g:
    • g[0] (want last kept = 1, current = 1): Can keep it! f[0] = 1
    • g[1] (want last kept = 2, but current = 1): Must remove 1, so min(f[0], f[1]) + 1 = min(1, 0) + 1 = 1
    • g[2] (want last kept = 3, but current = 1): Must remove 1, so min(f[0], f[1], f[2]) + 1 = min(1, 0, 1) + 1 = 1
  • Update: f = [1, 1, 1]

Processing element 3:

  • Current element x = 3
  • Calculate new state g:
    • g[0] (want last kept = 1, but current = 3): Must remove 3, so f[0] + 1 = 1 + 1 = 2
    • g[1] (want last kept = 2, but current = 3): Must remove 3, so min(f[0], f[1]) + 1 = min(1, 1) + 1 = 2
    • g[2] (want last kept = 3, current = 3): Can keep it! min(f[0], f[1], f[2]) = min(1, 1, 1) = 1
  • Update: f = [2, 2, 1]

Processing element 2:

  • Current element x = 2
  • Calculate new state g:
    • g[0] (want last kept = 1, but current = 2): Must remove 2, so f[0] + 1 = 2 + 1 = 3
    • g[1] (want last kept = 2, current = 2): Can keep it! min(f[0], f[1]) = min(2, 2) = 2
    • g[2] (want last kept = 3, but current = 2): Must remove 2, so min(f[0], f[1], f[2]) + 1 = min(2, 2, 1) + 1 = 2
  • Update: f = [3, 2, 2]

Final Answer: min(f) = min(3, 2, 2) = 2

This means we need 2 operations (removals) minimum. Indeed, if we remove elements at indices 0 and 3 (the two 2's), we get [1, 3] which is non-decreasing.

Solution Implementation

1class Solution:
2    def minimumOperations(self, nums: List[int]) -> int:
3        # dp[i] represents minimum operations to make the array non-decreasing
4        # where the last element is i+1 (i.e., dp[0] for ending with 1, dp[1] for 2, dp[2] for 3)
5        dp = [0] * 3
6      
7        for current_num in nums:
8            # new_dp will store the updated state after processing current_num
9            new_dp = [0] * 3
10          
11            if current_num == 1:
12                # If current number is 1, we can only keep it as 1
13                new_dp[0] = dp[0]  # No operation needed if previous ending was also 1
14                new_dp[1] = min(dp[:2]) + 1  # Change 1 to 2, can come from ending 1 or 2
15                new_dp[2] = min(dp) + 1  # Change 1 to 3, can come from any ending
16              
17            elif current_num == 2:
18                # If current number is 2
19                new_dp[0] = dp[0] + 1  # Change 2 to 1, only valid if previous was 1
20                new_dp[1] = min(dp[:2])  # Keep as 2, can come from ending 1 or 2
21                new_dp[2] = min(dp) + 1  # Change 2 to 3, can come from any ending
22              
23            else:  # current_num == 3
24                # If current number is 3
25                new_dp[0] = dp[0] + 1  # Change 3 to 1, only valid if previous was 1
26                new_dp[1] = min(dp[:2]) + 1  # Change 3 to 2, can come from ending 1 or 2
27                new_dp[2] = min(dp)  # Keep as 3, can come from any ending
28          
29            # Update dp state for next iteration
30            dp = new_dp
31      
32        # Return minimum operations across all possible ending values
33        return min(dp)
34
1class Solution {
2    public int minimumOperations(List<Integer> nums) {
3        // dp[i] represents minimum operations to make all processed elements <= (i+1)
4        // dp[0]: all elements <= 1
5        // dp[1]: all elements <= 2
6        // dp[2]: all elements <= 3
7        int[] dp = new int[3];
8      
9        // Process each number in the list
10        for (int currentNum : nums) {
11            // Create new dp array for current iteration
12            int[] newDp = new int[3];
13          
14            if (currentNum == 1) {
15                // If current number is 1:
16                // - To maintain <= 1: no operation needed, inherit from previous
17                newDp[0] = dp[0];
18                // - To maintain <= 2: can come from <= 1 or <= 2, need to change to 2
19                newDp[1] = Math.min(dp[0], dp[1]) + 1;
20                // - To maintain <= 3: can come from any state, need to change to 3
21                newDp[2] = Math.min(dp[0], Math.min(dp[1], dp[2])) + 1;
22            } else if (currentNum == 2) {
23                // If current number is 2:
24                // - To maintain <= 1: need to change 2 to 1
25                newDp[0] = dp[0] + 1;
26                // - To maintain <= 2: no operation needed, can come from <= 1 or <= 2
27                newDp[1] = Math.min(dp[0], dp[1]);
28                // - To maintain <= 3: can come from any state, need to change to 3
29                newDp[2] = Math.min(dp[0], Math.min(dp[1], dp[2])) + 1;
30            } else {
31                // If current number is 3:
32                // - To maintain <= 1: need to change 3 to 1
33                newDp[0] = dp[0] + 1;
34                // - To maintain <= 2: need to change 3 to 2
35                newDp[1] = Math.min(dp[0], dp[1]) + 1;
36                // - To maintain <= 3: no operation needed, can come from any state
37                newDp[2] = Math.min(dp[0], Math.min(dp[1], dp[2]));
38            }
39          
40            // Update dp array for next iteration
41            dp = newDp;
42        }
43      
44        // Return minimum operations from all possible final states
45        return Math.min(dp[0], Math.min(dp[1], dp[2]));
46    }
47}
48
1class Solution {
2public:
3    int minimumOperations(vector<int>& nums) {
4        // dp[i] represents the minimum operations needed where:
5        // dp[0]: sequence can only contain value 1
6        // dp[1]: sequence can contain values 1 and 2 (non-decreasing)
7        // dp[2]: sequence can contain values 1, 2, and 3 (non-decreasing)
8        vector<int> dp(3, 0);
9      
10        for (int num : nums) {
11            // Create new state array for current iteration
12            vector<int> newDp(3);
13          
14            if (num == 1) {
15                // If current number is 1
16                // Can keep it in state 0 (only 1s allowed)
17                newDp[0] = dp[0];
18                // Need to change it in state 1 (1,2 allowed but must be non-decreasing)
19                newDp[1] = min(dp[0], dp[1]) + 1;
20                // Need to change it in state 2 (1,2,3 allowed but must be non-decreasing)
21                newDp[2] = min({dp[0], dp[1], dp[2]}) + 1;
22            } 
23            else if (num == 2) {
24                // If current number is 2
25                // Must change it in state 0 (only 1s allowed)
26                newDp[0] = dp[0] + 1;
27                // Can keep it in state 1 (1,2 allowed)
28                newDp[1] = min(dp[0], dp[1]);
29                // Need to change it in state 2 (can't have 2 after 3)
30                newDp[2] = min({dp[0], dp[1], dp[2]}) + 1;
31            } 
32            else {
33                // If current number is 3
34                // Must change it in state 0 (only 1s allowed)
35                newDp[0] = dp[0] + 1;
36                // Must change it in state 1 (only 1,2 allowed)
37                newDp[1] = min(dp[0], dp[1]) + 1;
38                // Can keep it in state 2 (1,2,3 allowed)
39                newDp[2] = min({dp[0], dp[1], dp[2]});
40            }
41          
42            // Update dp array for next iteration
43            dp = move(newDp);
44        }
45      
46        // Return minimum operations among all final states
47        return min({dp[0], dp[1], dp[2]});
48    }
49};
50
1/**
2 * Finds the minimum number of operations to make the array non-decreasing
3 * where each element can only be 1, 2, or 3, and an operation changes one element
4 * 
5 * @param nums - The input array containing values 1, 2, or 3
6 * @returns The minimum number of operations needed
7 */
8function minimumOperations(nums: number[]): number {
9    // dp[i] represents minimum operations to make array valid ending with value i+1
10    // dp[0] -> ending with 1, dp[1] -> ending with 2, dp[2] -> ending with 3
11    let dp: number[] = new Array(3).fill(0);
12  
13    // Process each element in the array
14    for (const currentNum of nums) {
15        // Create new dp array for current iteration
16        const nextDp: number[] = new Array(3).fill(0);
17      
18        if (currentNum === 1) {
19            // If current element is 1:
20            // - Can end with 1: no change needed if previous ended with 1
21            nextDp[0] = dp[0];
22            // - Can end with 2: need to change 1 to 2, can come from 1 or 2
23            nextDp[1] = Math.min(dp[0], dp[1]) + 1;
24            // - Can end with 3: need to change 1 to 3, can come from any previous state
25            nextDp[2] = Math.min(dp[0], Math.min(dp[1], dp[2])) + 1;
26        } else if (currentNum === 2) {
27            // If current element is 2:
28            // - Can end with 1: need to change 2 to 1, only valid if previous was 1
29            nextDp[0] = dp[0] + 1;
30            // - Can end with 2: no change needed, can come from 1 or 2
31            nextDp[1] = Math.min(dp[0], dp[1]);
32            // - Can end with 3: need to change 2 to 3, can come from any previous state
33            nextDp[2] = Math.min(dp[0], Math.min(dp[1], dp[2])) + 1;
34        } else {
35            // If current element is 3:
36            // - Can end with 1: need to change 3 to 1, only valid if previous was 1
37            nextDp[0] = dp[0] + 1;
38            // - Can end with 2: need to change 3 to 2, can come from 1 or 2
39            nextDp[1] = Math.min(dp[0], dp[1]) + 1;
40            // - Can end with 3: no change needed, can come from any previous state
41            nextDp[2] = Math.min(dp[0], Math.min(dp[1], dp[2]));
42        }
43      
44        // Update dp array for next iteration
45        dp = nextDp;
46    }
47  
48    // Return minimum operations among all possible ending values
49    return Math.min(...dp);
50}
51

Time and Space Complexity

Time Complexity: O(n), where n is the length of the input array nums. The algorithm iterates through the array exactly once with a single for loop. Inside the loop, all operations (comparisons, assignments, and finding minimum values among at most 3 elements) are performed in constant time O(1). Therefore, the overall time complexity is O(n) * O(1) = O(n).

Space Complexity: O(1). The algorithm uses two fixed-size arrays f and g, each of size 3, regardless of the input size. These arrays store intermediate states for dynamic programming. Since the space used does not grow with the input size and remains constant at 6 integer values total, the space complexity is O(1).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Misunderstanding State Transitions

Pitfall: A common mistake is incorrectly calculating state transitions, particularly confusing when to add 1 (remove) versus when to keep the element. Developers often think dp[i] represents "keeping elements with value i+1" rather than "the last kept element has value i+1".

Example of Wrong Logic:

# WRONG: Thinking we need to remove when transitioning between different values
if current_num == 2:
    new_dp[0] = dp[0] + 1  # Wrong reason: thinking we remove because 2 != 1
    new_dp[1] = dp[1]      # Wrong: forgetting we can come from dp[0] too

Correct Understanding:

  • new_dp[j] represents the minimum operations where the last kept element will be j+1
  • When current_num doesn't match our target ending value j+1, we must remove it (+1 operation)
  • When they match, we keep the element (no additional operation)

2. Incorrect Minimum Calculation for Previous States

Pitfall: Forgetting that when keeping an element with value k, all previous kept elements must be ≤ k to maintain non-decreasing order.

Wrong Implementation:

if current_num == 2:
    new_dp[1] = dp[1]  # WRONG: only considering previous state ending with 2

Correct Implementation:

if current_num == 2:
    new_dp[1] = min(dp[:2])  # CORRECT: can come from states ending with 1 or 2

3. Off-by-One Errors in Index Mapping

Pitfall: Confusing the mapping between dp indices and actual values. Remember that dp[0] corresponds to value 1, dp[1] to value 2, and dp[2] to value 3.

Solution: Always use clear variable names or comments to indicate the mapping:

# dp[0] -> last kept element is 1
# dp[1] -> last kept element is 2  
# dp[2] -> last kept element is 3

4. Not Handling Edge Cases

Pitfall: The code might fail if not properly initialized or if the input array is empty.

Robust Solution:

def minimumOperations(self, nums: List[int]) -> int:
    if not nums:  # Handle empty array
        return 0
  
    dp = [0] * 3
    # ... rest of the code

5. Space Optimization Confusion

Pitfall: When optimizing from 2D DP to 1D rolling array, developers might accidentally use the updated values instead of the previous iteration's values.

Prevention: Always create a new array for the current iteration:

for current_num in nums:
    new_dp = [0] * 3  # Create fresh array for current iteration
    # ... calculate new_dp based on dp ...
    dp = new_dp  # Update only after all calculations are done

Never modify dp in-place during calculations as it would corrupt the state transitions.

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

A heap is a ...?


Recommended Readings

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

Load More