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:
- Group all indices that are connected through the swap pairs
- Collect all characters that belong to each group
- Sort the characters in each group in ascending order
- 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 withb
, andb
can swap withc
, thena
,b
, andc
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:
- Starting from each unvisited index
- Exploring all reachable indices through the swap pairs
- Collecting all indices in the same connected component
- Sorting the characters in that component and reassigning them
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:
- Find all groups of connected indices
- Within each group, we have complete freedom to arrange characters
- 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 EvaluatorExample 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 twofind
operations and one union operation. With path compression, eachfind
operation takesO(Ξ±(m))
amortized time, whereΞ±
is the inverse Ackermann function. This gives usO(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, requiringO(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 sizen
:O(n)
- The dictionary
d
storing all characters grouped by components:O(n)
in total as it stores alln
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
How many ways can you arrange the three letters A, B and C?
Recommended Readings
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
https assets algo monster cover_photos bfs svg Breadth First Search on Trees Hopefully by this time you've drunk enough DFS Kool Aid to understand its immense power and seen enough visualization to create a call stack in your mind Now let me introduce the companion spell Breadth First Search BFS
Union Find Disjoint Set Union Data Structure Introduction Prerequisite Depth First Search Review problems dfs_intro Once we have a strong grasp of recursion and Depth First Search we can now introduce Disjoint Set Union DSU This data structure is motivated by the following problem Suppose we have sets of elements
Want a Structured Path to Master System Design Too? Donβt Miss This!