交换字符串中的元素 图解题解
这道题到底在问什么
- 输入
- s="dcab", pairs=[[0,3],[1,2]]
- 输出
- "bacd" (两组:{0,3} 和 {1,2})
- 输入
- s="dcab", pairs=[[0,3],[1,2],[0,2]]
- 输出
- "abcd" (多了 [0,2] 把两组并成一组)
最优解:为什么这么做
一句话答案:LeetCode 1202 交换字符串中的元素:能反复对调的下标经传递连成一组,用并查集分组后把每组字符排升序、填回升序下标,就得到字典序最小串,时间 O((n+m)α+n log n)。
字典序最小,可动的到底是哪些位置
给一个字符串 s 和若干个下标对 pairs,每一对里的两个位置可以无限次互相对调。要问把这些交换用到头,s 能变成的字典序最小串是什么。题面 s="dcab"、pairs=[[0,3],[1,2]],答案是 "bacd";若再补一对 [0,2],四个位置连成一片,答案能小到 "abcd"。要留意:能换的是位置,不是某个字母,相同字母落在不同位置未必能凑到一块。
一对一对手动交换,行不通在哪
pairs 只给出几对下标,真按它一对一对地换、再枚举所有交换顺序,组合数会随对数指数膨胀,根本试不完。何况一对能换无数次,还能借共同下标一环扣一环传下去:0 和 2 能换、2 和 4 能换,0 和 4 没直接配过对,也能通过 2 挪到一起。手动模拟数不清顺序,也抓不住这种间接连通。
先弄清哪些下标属于同一组
把每个下标看成一个点,每对 pairs 看成把两个点连起来。能直接或间接连到一起的下标,构成一个连通分量——里头任意两下标都能顺着对调互相走到,这些位置上的字符可以任意重排。求这种分组用并查集最省事:并查集,即把互相能换到的下标一点点收拢到同一个代表下面,合并时让一个的代表指向另一个,查代表时顺着指向找到根。m 对下标合并完,每个下标归哪组就定死了。
组内排升序,再按下标从小到大填
分组只解决了谁和谁能换,还得摆成字典序最小。对每一组:把组里所有位置上的字符抽出来排成升序,再把组里的下标也从小到大排好,然后让最小字符落最小下标、次小落次小,一一对应。字典序看的是从左往右第一个不同的位置,越靠前的下标越该放小字符,每组内部都让小字符占小位置,整串就压到最小。参考代码换了个等价写法:每组字符降序存着,填回时从尾部弹出最小的,落点完全一样。
"dcab" 配上 [[0,3],[1,2]] 怎么排
按题面走一遍。s="dcab",下标 0 到 3 上是 d、c、a、b。第一对 [0,3] 把 0、3 并一组,第二对 [1,2] 把 1、2 并一组,得两组:{0,3} 和 {1,2}。看 {0,3}:位置 0 是 d、位置 3 是 b,两个字符排升序是 b、d,下标升序是 0、3,于是 b 进 0、d 进 3。看 {1,2}:位置 1 是 c、位置 2 是 a,排升序是 a、c,下标升序是 1、2,于是 a 进 1、c 进 2。四个位置落定 b、a、c、d,读出来正是 "bacd"。若再加一对 [0,2],四位连成一整组,d、c、a、b 整体排成 a、b、c、d 填回,收紧到 "abcd"。
复杂度与两处一写就崩的地方
设串长 n、下标对数 m。m 次合并加上按下标查根,路径压缩后近似线性,是 O((n+m)α),α 增长极慢,当常数看即可;各组字符加起来总共 n 个,排序合计 O(n log n),两者相加是总时间。空间上 parent 数组和分组存放的字符都随串长走,是 O(n)。有两处一写就崩:并查集若建在字符上而不是下标上,相同字母在不同位置会被硬并成一组,答案立刻散架;字符排好序却忘了按下标升序填回,只排字符不管位置,逐位最小就守不住。
▶ 动画逐步走查(共 25 步)——想跟着动画一帧帧对照就展开
- 3记住两件事:并查集连的是索引不是字符;同一组内字符可自由重排,按升序填回升序索引就最小。下面一帧帧套它。
- 4连通分量 = 6先把 6 个位置摆成 6 个节点,标号 0 到 5,对应 s 的「e d b f a c」。一开始谁也不连谁,每个索引自己是一组,所以右边连通分量个数是 6。接下来按 pairs 一对一对地连。
- 5pairs = [[0,2],[2,4],[1,5]]这道例子的 pairs 有三对:[0,2]、[2,4]、[1,5]。也就是要把索引 0 和 2、2 和 4、1 和 5 分别连起来。索引 3 一次都没出现,等会儿它会一直孤零零。一对一对处理。
- 6准备合并索引 0 和 2看第 0 对 [0,2]:位置 0 和位置 2 可以互换。先紫色点亮这两个索引,准备把它们并成一组。
- 7代表 0 与 2合并前各自找代表元,也就是树根。现在 0 的根是 0、2 的根是 2,都还是自己,谁也没被并过。根不同,可以合。
- 8连通分量 = 5按参考解的写法,让 0 的根指向 2 的根,于是 0 挂到 2 下面,这一组成了 {0,2}。连通分量从 6 降到 5。
- 9准备合并索引 2 和 4看第 1 对 [2,4]:位置 2 和位置 4 可换。注意 2 已经带着 0 是一组了,这一步会把整组接到 4 上,传递性就体现在这。先点亮 2 和 4。
- 10代表 2 与 4找根:2 的根是 2、4 的根是 4,都是各自树根。真正要连的是这两个根。
- 11连通分量 = 4让 2 的根指向 4。现在 0 → 2 → 4 串成一棵树,根是 4,这一组变成 {0,2,4}。从没直接说 0 和 4 能换,但通过 2 这个中间人,它们被传递地连到了一起。连通分量降到 4。
- 12准备合并索引 1 和 5看第 2 对 [1,5]:位置 1 和位置 5 可换。它们和左边那棵树没关系,是另一组。点亮 1 和 5。
- 13代表 1 与 5找根:1 的根是 1、5 的根是 5,都还独立。根不同,可以合。
- 14连通分量 = 3让 1 的根指向 5,这一组成了 {1,5}。现在场上有两棵树外加孤立的 3,一共 3 个连通分量。
- 15三组就绪三对全处理完,索引分成三组:{0,2,4} 一组、{1,5} 一组、索引 3 自己一组。每组内部的字符都可以自由重排,而 3 谁也连不上,它那位字符只能原地不动。下面切到字符串,逐组排序填回。
- 16把 s 平铺成一排:索引 0 到 5 上是 e、d、b、f、a、c。现在三组的归属已经定了,我们一组一组来,先看最左边的那棵大树 {0,2,4}。
- 17绿色点亮 {0,2,4} 这一组,把它们位置上的字符抓出来看:索引 0 是 e、索引 2 是 b、索引 4 是 a,凑成 e、b、a 三个字符。因为它们连通,这三个字符能在这三个位置上任意摆放。
- 18把这三个字符按字典序排好:a ≤ b ≤ e。再把这一组的索引也从小到大列出来:0、2、4。接下来让最小的 a 进最小的索引 0,次小的 b 进 2,最大的 e 进 4。
- 19把 a 写进索引 0。这是组内最小的字符,落到组内最小的位置。此刻整串是 adbfac。
- 20把 b 写进索引 2。次小的接着放到次小的位置。此刻整串是 adbfac。
- 21把 e 写进索引 4。组内最后一个,最大的 e 落到最大的索引 4,这一组排完了。此刻整串是 adbfec。
- 22换第二组 {1,5}。蓝色那三位是已经排好的 {0,2,4},别再动它们。绿色点亮 1 和 5,取出字符:索引 1 是 d、索引 5 是 c,凑成 d、c。
- 23排一下:c ≤ d。索引从小到大是 1、5。于是 c 进索引 1、d 进索引 5。原来 1 是 d、5 是 c,正好对调一下。
- 24把 c 写进索引 1。小的 c 进小的索引 1。现在整串是 acbfec。
- 25把 d 写进索引 5。剩下的 d 进索引 5,这一组也排完了。现在整串是 acbfed。
- 26最后是孤零零的索引 3。它没和任何位置配过对,所属的组只有它自己,字符 f 没有任何挪动空间,只能原地保持。所以第 3 位还是 f。
- 27六个位置全部落定:{0,2,4} 排成 a、b、e,{1,5} 排成 c、d,f 守在第 3 位。从左到右读出来就是 acbfed。每组都让小字符占了小位置,逐位都尽量小,整串就是字典序最小。
⚠️ 容易写错的地方
✗ 错:对「字符」做并查集
✓ 对:对「索引」做并查集
能交换的是位置不是字母,相同字母在不同位置未必同组,合并对象必须是 pairs 里的索引
✗ 错:以为给定的一对只能换一次
✓ 对:同组索引可任意重排
允许任意多次交换,加上传递,同一连通组内字符可达任意排列,所以组内能自由排序
✗ 错:排序后忘了按索引升序填回
✓ 对:组内字符升序、组内索引也升序,一一对应
只排字符不排位置,或填错位置,都拿不到逐位最小,字典序最小会失守
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def smallestStringWithSwaps(self, s: str, pairs: List[List[int]]) -> str:
def find(x: int) -> int:
if p[x] != x:
p[x] = find(p[x])
return p[x]
n = len(s)
p = list(range(n))
for a, b in pairs:
p[find(a)] = find(b)
d = defaultdict(list)
for i, c in enumerate(s):
d[find(i)].append(c)
for i in d.keys():
d[i].sort(reverse=True)
return "".join(d[find(i)].pop() for i in range(n))C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
string smallestStringWithSwaps(string s, vector<vector<int>>& pairs) {
int n = s.size();
int p[n];
iota(p, p + n, 0);
vector<char> d[n];
function<int(int)> find = [&](int x) -> int {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
};
for (auto e : pairs) {
int a = e[0], b = e[1];
p[find(a)] = find(b);
}
for (int i = 0; i < n; ++i) {
d[find(i)].push_back(s[i]);
}
for (auto& e : d) {
sort(e.rbegin(), e.rend());
}
for (int i = 0; i < n; ++i) {
auto& e = d[find(i)];
s[i] = e.back();
e.pop_back();
}
return s;
}
};Java
import java.util.*;
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val, TreeNode left, TreeNode right){this.val=val;this.left=left;this.right=right;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
private int[] p;
public String smallestStringWithSwaps(String s, List<List<Integer>> pairs) {
int n = s.length();
p = new int[n];
List<Character>[] d = new List[n];
for (int i = 0; i < n; ++i) {
p[i] = i;
d[i] = new ArrayList<>();
}
for (List<Integer> pair : pairs) {
int a = pair.get(0), b = pair.get(1);
p[find(a)] = find(b);
}
char[] cs = s.toCharArray();
for (int i = 0; i < n; ++i) {
d[find(i)].add(cs[i]);
}
for (List<Character> e : d) {
e.sort((a, b) -> b - a);
}
for (int i = 0; i < n; ++i) {
List<Character> e = d[find(i)];
cs[i] = e.remove(e.size() - 1);
}
return String.valueOf(cs);
}
private int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}
}复杂度
时间
O((n+m)·α + n·log n)
n 是串长、m 是 pairs 个数;m 次合并、n 次查根近似线性,各组字符总量是 n,排序合计 O(n·log n)
空间
O(n)
parent 数组长 n,加上按组存放字符也是 n,峰值与串长同阶
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 交换字符串中的元素 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
不用并查集,换别的方法能做吗?+
能。把每个下标当成图的一个点、每对 pairs 当成一条无向边,用 DFS 或 BFS 求出各个连通分量,每个分量内部照样排序填回,思路一模一样。并查集的好处是合并和查根写起来最短、最直接,不用另外建图,所以是首选。
每组排完序往回填,会不会把顺序搞反?+
记一句话:组内字符按字典序升序,组内下标按大小升序,两列升序一一对齐,小字符配小下标。至于实现,升序字符正着填、或者像参考代码那样降序存着从尾部弹最小的,最后落到每个位置上的字符完全一样,不影响结果。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 交换字符串中的元素 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。