https://leetcode-cn.com/problems/smallest-string-with-swaps/
分析
需要把这道题看成图论的题目才好做:将字符串中的索引视为图的节点,一个pair对意味着两个节点之间存在一条无向边。那么答案就可以这样来得到:对无向图中的每个连通分量所代表的字符串分别进行排序,再把字符按照索引填入原位置。
这是因为交换具有传递性,即a与b可交换,b与c可交换,那么a一定可与c交换(交换次数不受限制)。因此在连通分量中任意两个字符之间都是可交换的,要得到这部分按字典序最小的字符串,自然就是直接排序。
解法
并查集
涉及到图论中的连通分量问题,自然要写并查集,这个很重要,需要事先掌握,下面直接给出采用了路径压缩的并查集代码:
#define MAX 100007
int root[MAX];
void init(int n) {
for (int i = 0; i < n; ++i) root[i] = i;
}
int find(int x) {
if (x != root[x]) root[x] = find(root[x]);
return root[x];
}
void combine(int x, int y) {
root[find(x)] = find(y);
}
遍历图中所有的边,就可以完成并查集的构造了:
for (auto& pair : pairs) {
if (find(pair[0]) != find(pair[1])) combine(pair[0], pair[1]);
}
排序思路一
构造好并查集后,同属于一个连通分量的字符具有相同的代表元,可通过find
函数来获取。下一步就是思考如何获取各个连通分量代表的字符串,排序,再按正确位置填回去。
要把同属一个连通分量的字符连接成一个字符串,这一步必须用哈希表来建立一个映射关系,连通分量的代表元作为key,value则是存储字符的一个容器。考虑到需要排序,那么优先队列就非常适合作为这里的容器。
当我们把每个字符都放入它所属的优先队列后,再遍历字符串的索引,找到它的代表元,通过映射关系从对应的优先队列中取出一个字符填入该索引处,最终就得到了结果。注意优先队列应选用小根堆。
string smallestStringWithSwaps(string s, vector<vector<int>>& pairs) {
init(s.size());
for (auto& pair : pairs) {
if (find(pair[0]) != find(pair[1])) combine(pair[0], pair[1]);
}
unordered_map<int, priority_queue<int, vector<int>, greater<int>>> map;
for (int i = 0; i < s.size(); ++i) map[find(i)].push(s[i]);
for (int i = 0; i < s.size(); ++i) {
int root = find(i);
if (map[root].size() > 0) {
s[i] = map[root].top();
map[root].pop();
}
}
return s;
}
在这个排序思路中,由于容器本身的有序性,我们可以在容器中存放字符而不用管它原来的索引,只用保证每次从容器中取出的字符都是最小的那个即可,填入的位置索引可以由遍历得到。
排序思路二
直接使用优先队列虽然简单方便,但是速度并不快,上述代码用了396ms,而且在最后一步填入时也不是对每个连通分量来分组处理的,可能不是那么容易想到。
考虑更直观的排序方式,使用容器来存储字符的索引,然后对于每一个连通分量来进行排序和填入的处理:利用索引来拼接出一个临时字符串,对其排序,再利用索引来填入。
string smallestStringWithSwaps(string s, vector<vector<int>>& pairs) {
init(s.size());
for (auto& pair : pairs) {
if (find(pair[0]) != find(pair[1])) combine(pair[0], pair[1]);
}
unordered_map<int, vector<int>> map;
for (int i = 0; i < s.size(); ++i) map[find(i)].push_back(i);
for (auto& [root, indexes] : map) {
string str = "";
for (auto i : indexes) str += s[i];
sort(str.begin(), str.end());
for (int i = 0; i < str.size(); ++i) s[indexes[i]] = str[i];
}
return s;
}
这个方法应该比较容易想到,速度也不错,耗时272ms。
排序思路三
注意到,字符串中只含有小写英文字母,那么就可以采用线性时间复杂度的桶排序,并且由于代表元也是int
,哈希表就可以换成更简单的二维数组了:
string smallestStringWithSwaps(string s, vector<vector<int>>& pairs) {
int n = s.size();
init(n);
for (auto& pair : pairs) {
if (find(pair[0]) != find(pair[1])) combine(pair[0], pair[1]);
}
int map[n][26];
memset(map, 0, sizeof(map)); // 初始化,不能省略
for (int i = 0; i < n; ++i) map[find(i)][s[i] - 'a']++;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < 26; ++j) {
if (map[find(i)][j] > 0) {
s[i] = (char)(j + 'a');
map[find(i)][j]--;
break;
}
}
}
return s;
}
排序的思想很简单,把字符填入对应的桶内即可。要取出最小的那个字符时,只需从小到大遍历所有的桶,从第一个不为空的桶里拿一个即可。填入的方法与思路一相同,都是依次遍历索引,找到此时它所在连通分量中最小的那个字符,填入这个位置。
这个方法速度最快,仅用204ms,击败100%的c++方法,但由于采用桶排序,是以空间换时间的,因此占用的空间较高。