题目地址:
https://leetcode.com/problems/k-similar-strings/
给定两个异位词 s 1 s_1 s1和 s 2 s_2 s2,长度都是 n n n,允许将 s 1 s_1 s1中某两个字符交换位置,问至少交换多少次可以使得 s 1 s_1 s1变成 s 2 s_2 s2。
其实在问该(含重复元素)置换最少可以分解为多少个对换之积。遍历两个单词,同时建图,将每个字母看成一个顶点,对应位置的字母 s 1 [ i ] s_1[i] s1[i]连一条指向字母 s 2 [ i ] s_2[i] s2[i]的边,这样就建出了一个图,这个图可能有自环和平行边。
我们首先考虑一个类似大环的图(即形如 a → b → c → a a\to b\to c\to a a→b→c→a这种有向图),那么其实它对应的就是一个轮换(本质上是 s 1 = a b c , s 2 = b c a s_1=abc,s_2=bca s1=abc,s2=bca),对于一个轮换,由群论相关结论,其可以拆成若干对换的乘积,并且对换个数最少是轮换长度减 1 1 1(这个定理的证明是这样的,对于一个长 k k k的轮换,经过若干次对换要变成恒等映射,那么恒等映射相当于是 k k k个长度为 1 1 1的轮换。我们考虑对换次数。首先 k − 1 k-1 k−1次是可以做到的,因为有轮换分解 ( 123... n ) = ( 12 ) ( 13 ) ( 14 ) . . . ( 1 n ) (123...n)=(12)(13)(14)...(1n) (123...n)=(12)(13)(14)...(1n);其次我们证明少于 k − 1 k-1 k−1次就无法做到了,这是因为做一次对换,容易观察出,如果对换换的是同一个轮换内部的两个元素,则该轮换被拆成了两个轮换;如果对换换的不是同一个轮换内部的两个元素,那么那两个轮换就会合成一个轮换。即每次最多能将轮换数加 1 1 1,所以 1 1 1个轮换要变成 k k k个轮换至少得做 k − 1 k-1 k−1次对换。在图上,轮换其实就是交换了两条边的入点)。还有的一个性质是,一个长 k k k的轮换要拆成 k k k个长 1 1 1的轮换,每次只要保证是在对换某个轮换内部的两个点就行了,无论换谁,怎么换,最终都会达到相同的效果,并且次数也是一样的。
这道题的麻烦之处在于某个字母可能出现多次,即该置换是含重复元素的。所以我们需要枚举将整个图看成哪几个大环的所有方式,然后用暴搜的方式搜到对换数最小的方案。对于那些只有一个出边的点,这种点属于哪个环是毫无疑问的,所以真正需要枚举的只在某个顶点 u u u有多个出边的时候发生。由于对于某个环(轮换)来说,只要是在环内部做对换,怎么做对换都是可以的,但是由于 u u u有多条出边,怎么“看”有哪些环的方式,是不唯一的。例如对于两个字符串 s 1 = a b c b d , s 2 = b c a d b s_1=abcbd,s_2=bcadb s1=abcbd,s2=bcadb, b b b有两条出边,因此将其拆为两个点 b 1 b_1 b1和 b 2 b_2 b2,那么建图之后可以视为两个环 a → b 1 → c → a , b 2 → d → b 2 a\to b_1\to c\to a, b_2\to d\to b_2 a→b1→c→a,b2→d→b2,也可以视为是一个环 a → b 1 → d → b 2 → c → a a\to b_1\to d\to b_2\to c\to a a→b1→d→b2→c→a,那么这两种“看待”环的方式,会导致总对换次数不一样。看成第一种方式,需要 3 − 1 + 2 − 1 = 3 3-1+2-1=3 3−1+2−1=3次对换,看成后者,需要 4 4 4次对换。所以我们就需要枚举所有的“看法”。枚举看法,实际上就是在枚举出边,由于“看法”确定的情况下,次数也是确定的,所以可以直接先将多个出边的点拆出来成为自环,然后继续暴搜。比如对于第一种看法,枚举出边 b 1 → c b_1\to c b1→c,则得三个环 a → c → a , b 1 → b 1 , b 2 → d → b 2 a\to c\to a, b_1\to b_1, b_2\to d\to b_2 a→c→a,b1→b1,b2→d→b2,而若枚举出边 b 1 → d b_1\to d b1→d,则得两个环 a → d → b 2 → c → a , b 1 → b 1 a\to d\to b_2\to c\to a, b_1\to b_1 a→d→b2→c→a,b1→b1。
在题目里,暴搜的实际上是 s 1 = a b c b d , s 2 = b c a d b s_1=abcbd,s_2=bcadb s1=abcbd,s2=bcadb在第一个位置不匹配的时候,应该把哪个 b b b换过去。换哪一个,就决定了一种“看环”的看法。那么DFS搜索的深度,就是步数。由于我们要计算最少步数,所以需要用迭代加深。而本题的启发函数是很好写的,当前所得的字符串和 s 2 s_2 s2若有 x x x个字母不同的位置,由于每次对换最多消去 2 2 2个不同位置,所以最少还需要 ⌈ x / 2 ⌉ \lceil x/2\rceil ⌈x/2⌉这么多次对换,启发函数可以设为 h ( s ) = ⌈ x / 2 ⌉ h(s)=\lceil x/2\rceil h(s)=⌈x/2⌉,于是整个算法事实上是一个IDA*算法(可以参考https://blog.csdn.net/qq_46105170/article/details/115594149)。代码如下:
import java.util.Arrays;
public class Solution {
public int kSimilarity(String s1, String s2) {
char[] chs1 = s1.toCharArray(), chs2 = s2.toCharArray();
int depth = 0;
while (!dfs(depth, 0, chs1, chs2)) {
depth++;
}
return depth;
}
private boolean dfs(int depth, int pos, char[] s1, char[] s2) {
// 搜到最大深度了,就不往下搜了,直接判断是否找到了一个解
if (depth == 0) {
return Arrays.equals(s1, s2);
}
// 如果当前深度加上启发函数大于了最大深度,则本次搜索不可能有解,返回false
if (h(s1, s2) > depth) {
return false;
}
// pos前面的位置已经配对好了,直接从pos开始搜
for (int i = pos; i < s1.length; i++) {
// 略过自环,这些环不需要拆
if (s1[i] != s2[i]) {
// 枚举出边,每条出边就对应着环的一种“看法”
for (int j = i + 1; j < s1.length; j++) {
if (s1[j] == s2[i]) {
// 尝试换过去,图里对应着拆出一个自环
swap(s1, i, j);
// 继续dfs,深度加1,并且位置从i + 1开始搜,如果能搜到解则返回true
if (dfs(depth - 1, i + 1, s1, s2)) {
return true;
}
// 搜不到解则恢复现场
swap(s1, i, j);
}
}
// 对换的次序是无关的,只要拆出一个自环即可,所以找到了第一个拆环的位置搜下去,
// 如果没找到答案就可以退了,因为之后搜也不可能有答案
break;
}
}
return false;
}
// 启发函数
private int h(char[] s1, char[] s2) {
int res = 0;
for (int i = 0; i < s1.length; i++) {
if (s1[i] != s2[i]) {
res++;
}
}
return res + 1 >> 1;
}
private void swap(char[] s, int i, int j) {
char tmp = s[i];
s[i] = s[j];
s[j] = tmp;
}
}
时间复杂度指数级,但IDA*实际时间复杂度不会很大,空间 O ( n ) O(n) O(n)。