Facebook Pixel

1202. Smallest String With Swaps

Problem Description

You have a string s and an array called pairs containing index pairs. Each pair pairs[i] = [a, b] represents two positions (0-indexed) in the string where you can swap the characters.

The key rules are:

  • You can perform swaps using any of the given pairs
  • You can use each pair as many times as you want (including zero times)
  • Your goal is to find the lexicographically smallest string possible after performing these swaps

For example, if you have the string "dcab" and pairs [[0,3],[1,2]], you can:

  • Swap characters at positions 0 and 3 to get "bcad"
  • Swap characters at positions 1 and 2 to get "bacd"
  • Continue swapping to eventually reach "abcd" which is the lexicographically smallest

The important insight is that if position a can swap with b, and b can swap with c, then effectively a, b, and c form a group where characters can be rearranged freely among these positions. This transitivity means that within each connected group of indices, you can arrange the characters in any order.

The solution uses Union-Find (Disjoint Set Union) to:

  1. Group all indices that are connected through the swap pairs
  2. Collect all characters that belong to each group
  3. Sort the characters in each group in ascending order
  4. Place the smallest available character from each group at its corresponding position

The code accomplishes this by:

  • Using find() function with path compression to identify the root of each connected component
  • Grouping characters by their component root in dictionary d
  • Sorting characters in reverse order (so we can use pop() to get the smallest)
  • Building the result string by taking the smallest character from each position's component

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • Yes: The problem involves pairs of indices that can be swapped, which forms a graph where indices are nodes and pairs represent edges between nodes that can exchange characters.

Is it a tree?

  • No: The graph formed by the swap pairs is not necessarily a tree. It can have cycles (e.g., if pairs allow swaps like 0↔1, 1↔2, and 2↔0, forming a cycle).

Is the problem related to directed acyclic graphs?

  • No: The graph is undirected (swaps are bidirectional) and may contain cycles.

Is the problem related to shortest paths?

  • No: We're not looking for shortest paths between nodes. Instead, we need to find which indices can reach each other through a series of swaps.

Does the problem involve connectivity?

  • Yes: The core insight is that indices form connected components - if index a can swap with b, and b can swap with c, then a, b, and c are all connected and their characters can be freely rearranged among these positions.

Disjoint Set Union (DSU)

  • Yes: The flowchart leads us to DSU/Union-Find, which is perfect for finding connected components in the graph.

Conclusion: While the flowchart suggests DSU (which is indeed used in the solution), DFS could also be used as an alternative approach to find connected components. Both DFS and DSU can identify which indices belong to the same group, allowing us to sort characters within each group to achieve the lexicographically smallest string.

The DFS pattern would work by:

  1. Starting from each unvisited index
  2. Exploring all reachable indices through the swap pairs
  3. Collecting all indices in the same connected component
  4. Sorting the characters in that component and reassigning them
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight comes from recognizing that swapping creates a transitive relationship. If we can swap positions a and b, and also swap b and c, then we can effectively move any character between positions a, b, and c through a series of swaps.

Think of it this way: imagine you have three cups labeled 0, 1, and 2, containing letters 'd', 'c', and 'a'. If you can swap cups 0↔1 and 1↔2, you can rearrange these letters in any order across the three cups. To get 'a' to position 0, you could swap 1↔2 (getting 'd', 'a', 'c'), then swap 0↔1 (getting 'a', 'd', 'c').

This means all indices that are connected through swap pairs form a group where characters can be freely rearranged. The problem then becomes:

  1. Find all groups of connected indices
  2. Within each group, we have complete freedom to arrange characters
  3. To get the lexicographically smallest string, we should place the smallest available character at the earliest position

For example, if indices [0, 2, 4] form one group containing characters ['d', 'b', 'a'], we should arrange them as ['a', 'b', 'd'] to minimize the resulting string.

This naturally leads us to think about graph connectivity - we need to find which indices are "reachable" from each other through the swap relationships. Once we identify these connected components, the solution becomes straightforward: sort the characters in each component and assign them to positions in ascending order.

The Union-Find (DSU) data structure is perfect for this because it efficiently groups elements that are connected, while DFS could also traverse each component to achieve the same grouping effect.

Learn more about Depth-First Search, Breadth-First Search, Union Find and Sorting patterns.

Solution Approach

The solution uses Union-Find (Disjoint Set Union) to identify connected components of indices that can swap characters with each other.

Step 1: Initialize Union-Find Structure

p = list(range(n))

We create a parent array where initially each index is its own parent, meaning each position starts as its own component.

Step 2: Build Connected Components

for a, b in pairs:
    p[find(a)] = find(b)

For each swap pair [a, b], we union the two indices by making them share the same root parent. The find() function with path compression ensures efficient lookups:

def find(x: int) -> int:
    if p[x] != x:
        p[x] = find(p[x])  # Path compression
    return p[x]

Step 3: Group Characters by Component

d = defaultdict(list)
for i, c in enumerate(s):
    d[find(i)].append(c)

We create a dictionary where each key is a component's root, and the value is a list of all characters belonging to that component. For example, if indices 0, 2, 4 are connected and contain 'd', 'b', 'a', they'll all be grouped under their common root.

Step 4: Sort Characters in Each Component

for i in d.keys():
    d[i].sort(reverse=True)

We sort characters in reverse order (largest first) so we can use pop() to efficiently get the smallest character. This is a performance optimization - pop() from the end of a list is O(1).

Step 5: Build the Result String

return "".join(d[find(i)].pop() for i in range(n))

For each position i from 0 to n-1:

  • Find which component it belongs to using find(i)
  • Pop the smallest character from that component's sorted list
  • Add it to the result string

The algorithm ensures that within each connected component, characters are distributed to achieve the lexicographically smallest arrangement, while maintaining the constraint that characters can only be placed at indices within their component.

Time complexity: O(n log n + m * Ξ±(n)) where n is the string length, m is the number of pairs, and Ξ± is the inverse Ackermann function (practically constant). Space complexity: O(n) for the Union-Find structure and character groupings.

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 a concrete example:

Input: s = "dcab", pairs = [[0,3],[1,2]]

Step 1: Initialize Union-Find We create a parent array p = [0, 1, 2, 3] where each index points to itself.

Step 2: Process Pairs to Build Components

  • Process pair [0,3]:

    • find(0) = 0, find(3) = 3
    • Set p[0] = 3, so indices 0 and 3 are now connected
    • Parent array: p = [3, 1, 2, 3]
  • Process pair [1,2]:

    • find(1) = 1, find(2) = 2
    • Set p[1] = 2, so indices 1 and 2 are now connected
    • Parent array: p = [3, 2, 2, 3]

Step 3: Group Characters by Component Now we iterate through each character and group them by their component root:

  • Index 0 ('d'): find(0) = 3 β†’ Add 'd' to component 3
  • Index 1 ('c'): find(1) = 2 β†’ Add 'c' to component 2
  • Index 2 ('a'): find(2) = 2 β†’ Add 'a' to component 2
  • Index 3 ('b'): find(3) = 3 β†’ Add 'b' to component 3

Our groups:

  • Component 3: ['d', 'b'] (indices 0 and 3)
  • Component 2: ['c', 'a'] (indices 1 and 2)

Step 4: Sort Characters in Each Component Sort each component's characters in reverse order:

  • Component 3: ['d', 'b'] β†’ ['d', 'b'] (already in reverse order)
  • Component 2: ['c', 'a'] β†’ ['c', 'a'] (already in reverse order)

Step 5: Build Result String For each position, pop the smallest character from its component:

  • Position 0: find(0) = 3, pop from component 3 β†’ 'b' (list becomes ['d'])
  • Position 1: find(1) = 2, pop from component 2 β†’ 'a' (list becomes ['c'])
  • Position 2: find(2) = 2, pop from component 2 β†’ 'c' (list becomes [])
  • Position 3: find(3) = 3, pop from component 3 β†’ 'd' (list becomes [])

Result: "bacd"

This makes sense because:

  • Positions 0 and 3 can swap, so we put the smaller character 'b' at position 0 and 'd' at position 3
  • Positions 1 and 2 can swap, so we put the smaller character 'a' at position 1 and 'c' at position 2

The resulting string "bacd" is the lexicographically smallest possible string we can achieve through the allowed swaps.

Solution Implementation

1from collections import defaultdict
2from typing import List
3
4class Solution:
5    def smallestStringWithSwaps(self, s: str, pairs: List[List[int]]) -> str:
6        # Find the root parent of a node using path compression
7        def find_root(node: int) -> int:
8            if parent[node] != node:
9                # Path compression: directly connect node to its root
10                parent[node] = find_root(parent[node])
11            return parent[node]
12      
13        n = len(s)
14        # Initialize parent array where each node is its own parent
15        parent = list(range(n))
16      
17        # Union operation: connect all indices that can be swapped
18        for index_a, index_b in pairs:
19            # Union by connecting roots of both indices
20            parent[find_root(index_a)] = find_root(index_b)
21      
22        # Group characters by their connected component (root parent)
23        component_chars = defaultdict(list)
24        for index, char in enumerate(s):
25            root = find_root(index)
26            component_chars[root].append(char)
27      
28        # Sort characters in each component in descending order
29        # This allows us to pop from the end (smallest character) efficiently
30        for root in component_chars.keys():
31            component_chars[root].sort(reverse=True)
32      
33        # Build result by taking smallest available character from each component
34        result = []
35        for index in range(n):
36            root = find_root(index)
37            # Pop the smallest character (last element after reverse sort)
38            result.append(component_chars[root].pop())
39      
40        return "".join(result)
41
1class Solution {
2    // Parent array for Union-Find (Disjoint Set Union)
3    private int[] parent;
4
5    public String smallestStringWithSwaps(String s, List<List<Integer>> pairs) {
6        int n = s.length();
7      
8        // Initialize parent array for Union-Find
9        parent = new int[n];
10      
11        // Create array of lists to store characters belonging to each connected component
12        List<Character>[] componentCharacters = new List[n];
13      
14        // Initialize each index as its own parent and create empty list for each component
15        for (int i = 0; i < n; ++i) {
16            parent[i] = i;
17            componentCharacters[i] = new ArrayList<>();
18        }
19      
20        // Union operation: Connect all pairs in the same component
21        for (List<Integer> pair : pairs) {
22            int indexA = pair.get(0);
23            int indexB = pair.get(1);
24            // Union the two indices by connecting their roots
25            parent[find(indexA)] = find(indexB);
26        }
27      
28        // Convert string to character array for manipulation
29        char[] charArray = s.toCharArray();
30      
31        // Group characters by their connected component
32        for (int i = 0; i < n; ++i) {
33            int root = find(i);
34            componentCharacters[root].add(charArray[i]);
35        }
36      
37        // Sort characters in each component in descending order
38        // (will pop from end for ascending order result)
39        for (List<Character> component : componentCharacters) {
40            component.sort((a, b) -> b - a);
41        }
42      
43        // Build result by taking smallest available character for each position
44        for (int i = 0; i < n; ++i) {
45            int root = find(i);
46            List<Character> component = componentCharacters[root];
47            // Remove and assign the smallest character (last element after descending sort)
48            charArray[i] = component.remove(component.size() - 1);
49        }
50      
51        return String.valueOf(charArray);
52    }
53
54    // Find operation with path compression for Union-Find
55    private int find(int x) {
56        if (parent[x] != x) {
57            // Path compression: make every node point directly to root
58            parent[x] = find(parent[x]);
59        }
60        return parent[x];
61    }
62}
63
1class Solution {
2public:
3    string smallestStringWithSwaps(string s, vector<vector<int>>& pairs) {
4        int n = s.size();
5      
6        // Parent array for Union-Find data structure
7        vector<int> parent(n);
8        // Initialize each element as its own parent (self-loop)
9        iota(parent.begin(), parent.end(), 0);
10      
11        // Array of vectors to store characters belonging to each connected component
12        vector<vector<char>> componentsChars(n);
13      
14        // Find function with path compression for Union-Find
15        function<int(int)> findRoot = [&](int x) -> int {
16            if (parent[x] != x) {
17                parent[x] = findRoot(parent[x]); // Path compression
18            }
19            return parent[x];
20        };
21      
22        // Union operation: connect all pairs of indices that can be swapped
23        for (const auto& pair : pairs) {
24            int indexA = pair[0];
25            int indexB = pair[1];
26            // Union by connecting roots
27            parent[findRoot(indexA)] = findRoot(indexB);
28        }
29      
30        // Group characters by their connected component's root
31        for (int i = 0; i < n; ++i) {
32            int root = findRoot(i);
33            componentsChars[root].push_back(s[i]);
34        }
35      
36        // Sort each component's characters in descending order
37        // (we'll pop from back later, so largest char will be at back after sorting)
38        for (auto& componentChars : componentsChars) {
39            sort(componentChars.rbegin(), componentChars.rend());
40        }
41      
42        // Reconstruct the string by assigning the smallest available character
43        // from each position's component
44        for (int i = 0; i < n; ++i) {
45            int root = findRoot(i);
46            vector<char>& componentChars = componentsChars[root];
47            // Get the smallest character (at the back after reverse sorting)
48            s[i] = componentChars.back();
49            componentChars.pop_back();
50        }
51      
52        return s;
53    }
54};
55
1/**
2 * Returns the lexicographically smallest string after applying swaps
3 * @param s - The input string to swap characters in
4 * @param pairs - Array of index pairs that can be swapped
5 * @returns The lexicographically smallest string possible
6 */
7function smallestStringWithSwaps(s: string, pairs: number[][]): string {
8    const stringLength: number = s.length;
9  
10    // Initialize parent array for Union-Find data structure
11    // Each index initially points to itself as its parent
12    const parent: number[] = new Array(stringLength).fill(0).map((_, index) => index);
13  
14    /**
15     * Find the root parent of a node with path compression
16     * @param node - The node to find the root parent for
17     * @returns The root parent of the node
18     */
19    const findRoot = (node: number): number => {
20        if (parent[node] !== node) {
21            // Path compression: directly connect node to root
22            parent[node] = findRoot(parent[node]);
23        }
24        return parent[node];
25    };
26  
27    // Array to store characters grouped by their connected components
28    const connectedGroups: string[][] = new Array(stringLength).fill(0).map(() => []);
29  
30    // Union operation: connect all pairs by setting same root parent
31    for (const [indexA, indexB] of pairs) {
32        parent[findRoot(indexA)] = findRoot(indexB);
33    }
34  
35    // Group characters by their connected component root
36    for (let i = 0; i < stringLength; i++) {
37        connectedGroups[findRoot(i)].push(s[i]);
38    }
39  
40    // Sort each connected component in descending order
41    // This allows us to pop the smallest character efficiently
42    for (const group of connectedGroups) {
43        group.sort((charA: string, charB: string) => 
44            charB.charCodeAt(0) - charA.charCodeAt(0)
45        );
46    }
47  
48    // Build the result string by taking the smallest available character
49    // from each position's connected component
50    const resultArray: string[] = [];
51    for (let i = 0; i < stringLength; i++) {
52        // Pop returns the smallest character due to descending sort
53        resultArray.push(connectedGroups[findRoot(i)].pop()!);
54    }
55  
56    return resultArray.join('');
57}
58

Time and Space Complexity

Time Complexity: O(n Γ— log n + m Γ— Ξ±(m))

The time complexity consists of two main components:

  • The Union-Find operations: For each of the m pairs, we perform two find operations and one union operation. With path compression, each find operation takes O(Ξ±(m)) amortized time, where Ξ± is the inverse Ackermann function. This gives us O(m Γ— Ξ±(m)) for processing all pairs.
  • Sorting the characters: After grouping characters by their connected components, we sort each group. In the worst case, all n characters belong to the same component, requiring O(n Γ— log n) time for sorting.
  • The final string construction takes O(n) time as we pop from each sorted list once per character.

Therefore, the overall time complexity is O(n Γ— log n + m Γ— Ξ±(m)).

Space Complexity: O(n)

The space complexity includes:

  • The parent array p of size n: O(n)
  • The dictionary d storing all characters grouped by components: O(n) in total as it stores all n characters
  • The output string construction: O(n)

The overall space complexity is O(n), where n is the length of the input string.

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

Common Pitfalls

1. Incorrect Union-Find Implementation Without Path Compression

Pitfall: Implementing the find operation without path compression can lead to degraded performance, turning the practically constant Ξ±(n) factor into O(n) in worst cases.

Incorrect Implementation:

def find_root(node: int) -> int:
    while parent[node] != node:
        node = parent[node]
    return node

Problem: This creates long chains where finding the root of deep nodes requires traversing many parent links, especially after multiple union operations.

Solution: Always use path compression to flatten the tree structure:

def find_root(node: int) -> int:
    if parent[node] != node:
        parent[node] = find_root(parent[node])  # Recursively compress path
    return parent[node]

2. Forgetting to Use find() When Accessing Components

Pitfall: Directly using the parent array values instead of calling find() when grouping or accessing characters.

Incorrect Code:

# Wrong: Using parent[i] directly
component_chars = defaultdict(list)
for index, char in enumerate(s):
    component_chars[parent[index]].append(char)  # Bug!

Problem: The parent array doesn't always point directly to the root. After unions, some nodes point to intermediate parents, not the actual root. This creates multiple separate groups for what should be a single connected component.

Solution: Always use find_root() to get the actual root:

component_chars[find_root(index)].append(char)  # Correct

3. Modifying the Original String Instead of Building a New One

Pitfall: Attempting to modify the string in-place or incorrectly tracking which characters have been used.

Incorrect Approach:

# Wrong: Trying to swap characters directly in the string
s = list(s)
for a, b in pairs:
    if s[a] > s[b]:
        s[a], s[b] = s[b], s[a]
return "".join(s)

Problem: This greedy approach doesn't consider the transitivity of swaps. Just because you can swap positions a and b doesn't mean you should do it immediately - the optimal character for position a might come from position c through a chain of swaps.

Solution: Group all connected positions first, then distribute the sorted characters optimally across the entire component.

4. Not Handling Empty Pairs Array

Pitfall: Assuming pairs array always has elements without checking.

Problem: When pairs is empty, no swaps are possible, and the original string should be returned. The code should handle this gracefully.

Solution: The current implementation naturally handles this case - when pairs is empty, each character remains in its own component, effectively returning the original string. However, for clarity, you could add an early return:

if not pairs:
    return s

5. Inefficient Character Removal from Sorted Lists

Pitfall: Using pop(0) to get the smallest character instead of sorting in reverse and using pop().

Incorrect Implementation:

# Inefficient: O(n) operation for each pop
component_chars[root].sort()  # Sort in ascending order
result.append(component_chars[root].pop(0))  # Remove from front

Problem: Removing from the front of a list is O(n) operation in Python, making the overall complexity worse.

Solution: Sort in reverse order and pop from the end (O(1) operation):

component_chars[root].sort(reverse=True)
result.append(component_chars[root].pop())  # Remove from end
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

How many ways can you arrange the three letters A, B and C?


Recommended Readings

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

Load More