LeetCode 854:相似度为 K 的字符串
LeetCode 854:相似度为 K 的字符串
问题背景与定义
在字符串处理问题中,经常会遇到需要通过某种操作将一个字符串转换为另一个字符串的场景。LeetCode 854题「相似度为 K 的字符串」就是这类问题的典型代表。题目要求我们计算将一个字符串 s1
转换为另一个字符串 s2
所需的最小交换次数,其中 s1
和 s2
是互为异位词(anagram)的字符串。
示例:
-
输入:
s1 = "ab", s2 = "ba"
输出:1
解释:交换s1
中的a
和b
,得到"ba"
,只需 1 次交换。 -
输入:
s1 = "abc", s2 = "bca"
输出:2
解释:先交换a
和b
得到"bac"
,再交换a
和c
得到"bca"
,共需 2 次交换。
这类问题本质上是在寻找字符串转换的最短路径,而广度优先搜索(BFS)是解决这类问题的有效方法。
为什么选择 BFS?
广度优先搜索(BFS)是解决最短路径问题的理想选择,因为它具有以下特性:
- 逐层遍历:BFS 从初始状态开始,逐层扩展所有可能的状态,确保每一层的状态都对应相同的操作步数。
- 最短路径保证:当第一次到达目标状态时,所经过的路径一定是最短的(步数最少)。
- 避免重复计算:通过记录已访问的状态,可以避免重复处理相同的状态,提高效率。
在本题中,每个状态是一个字符串,每次操作是一次字符交换。BFS 可以确保我们在找到目标字符串时,使用的交换次数是最少的。
算法思路详解
我们的目标是通过最少的交换次数将 s1
转换为 s2
。核心思路是使用 BFS 逐层扩展所有可能的交换,并通过剪枝策略减少不必要的计算:
- 定位差异:每次找到第一个不匹配的位置
i
,即s1[i] ≠ s2[i]
。 - 筛选候选交换位置:寻找位置
j > i
,满足s1[j] == s2[i]
且s1[j] ≠ s2[j]
。 - 优先处理最优交换:如果存在位置
j
使得s1[i] == s2[j]
,则交换i
和j
可以同时修复两个位置,优先处理这种情况。 - 记录已访问状态:使用哈希集合记录已生成的字符串,避免重复处理。
代码实现与详细解释
以下是完整的 Java 代码实现:
import java.util.*;class Solution {public int kSimilarity(String a1, String a2) {// 如果两个字符串已经相同,无需交换if (a1.equals(a2)) {return 0;}// 初始化 BFS 队列和已访问集合Queue<String> queue = new LinkedList<>();Set<String> visited = new HashSet<>();queue.offer(a1);visited.add(a1);int step = 0; // 记录当前步数int n = a1.length();// BFS 主循环while (!queue.isEmpty()) {int size = queue.size();// 处理当前层的所有状态for (int k = 0; k < size; k++) {String s = queue.poll();// 如果找到目标字符串,返回当前步数if (s.equals(a2)) {return step;}// 找到第一个不匹配的位置 iint i = 0;while (i < n && s.charAt(i) == a2.charAt(i)) {i++;}if (i == n) continue; // 如果所有字符都匹配,跳过char[] arr = s.toCharArray();List<Integer> candidates = new ArrayList<>();// 寻找所有可能的交换位置 jfor (int j = i + 1; j < n; j++) {// 条件1:arr[j] 必须等于目标位置 i 的字符// 条件2:位置 j 的字符不能已经在目标位置if (arr[j] == a2.charAt(i) && arr[j] != a2.charAt(j)) {// 最优情况:如果交换 i 和 j 可以同时解决两个位置的不匹配if (arr[i] == a2.charAt(j)) {swap(arr, i, j);String t = new String(arr);if (t.equals(a2)) {return step + 1; // 提前返回,找到结果}if (visited.add(t)) {queue.offer(t); // 将新状态加入队列}swap(arr, i, j); // 回溯,恢复数组candidates.clear(); // 找到最优解,清空候选列表break;} else {candidates.add(j); // 普通候选,稍后处理}}}// 处理所有普通候选for (int j : candidates) {swap(arr, i, j);String t = new String(arr);if (visited.add(t)) {queue.offer(t); // 将新状态加入队列}swap(arr, i, j); // 回溯,恢复数组}}step++; // 进入下一层,步数加1}return -1; // 理论上不会执行到这里,因为题目保证有解}// 交换数组中两个位置的字符private void swap(char[] arr, int i, int j) {char temp = arr[i];arr[i] = arr[j];arr[j] = temp;}
}
关键步骤解析
1. 初始化与边界检查
if (a1.equals(a2)) {return 0;
}
如果两个字符串已经相同,直接返回 0,无需任何交换。
2. BFS 队列与已访问集合
Queue<String> queue = new LinkedList<>();
Set<String> visited = new HashSet<>();
queue.offer(a1);
visited.add(a1);
int step = 0;
- 队列
queue
:存储待处理的字符串状态。 - 集合
visited
:记录已访问的状态,避免重复处理。 - 步数
step
:记录当前处理的层数,即交换次数。
3. 定位第一个不匹配位置
int i = 0;
while (i < n && s.charAt(i) == a2.charAt(i)) {i++;
}
if (i == n) continue;
- 从左到右找到第一个
s.charAt(i) ≠ a2.charAt(i)
的位置i
。 - 如果所有字符都匹配(
i == n
),则跳过当前状态。
4. 筛选候选交换位置
for (int j = i + 1; j < n; j++) {if (arr[j] == a2.charAt(i) && arr[j] != a2.charAt(j)) {// ...}
}
- 条件1:
arr[j] == a2.charAt(i)
确保交换后位置i
的字符能被修复。 - 条件2:
arr[j] != a2.charAt(j)
避免交换一个已经在正确位置的字符,减少无效操作。
5. 最优交换优先处理
if (arr[i] == a2.charAt(j)) {swap(arr, i, j);String t = new String(arr);if (t.equals(a2)) {return step + 1;}if (visited.add(t)) {queue.offer(t);}swap(arr, i, j);candidates.clear();break;
}
- 当
arr[i] == a2.charAt(j)
时,交换i
和j
可以同时修复两个位置的不匹配。 - 这种情况下,直接返回
step + 1
,或加入队列继续处理。
6. 普通候选处理
for (int j : candidates) {swap(arr, i, j);String t = new String(arr);if (visited.add(t)) {queue.offer(t);}swap(arr, i, j);
}
- 对于无法同时修复两个位置的普通候选,逐个处理并加入队列。
- 使用
visited.add(t)
确保只处理未访问过的状态。
剪枝策略详解
这段代码的核心优化在于候选交换位置的筛选,通过以下策略减少不必要的状态扩展:
-
定位第一个不匹配位置:
每次只关注当前未匹配的最左侧位置,避免重复处理已匹配的部分。 -
候选位置筛选条件:
if (arr[j] == a2.charAt(i) && arr[j] != a2.charAt(j)) {// ... }
- 确保交换后位置
i
能被修复。 - 避免交换一个已经在正确位置的字符。
- 确保交换后位置
-
最优候选优先处理:
if (arr[i] == a2.charAt(j)) {// 最优情况:交换后同时修复两个位置 }
当找到一个位置
j
,使得arr[i]
恰好等于a2[j]
时,交换i
和j
可以同时解决两个不匹配问题,这种情况优先处理并提前返回。
示例演示:理解算法执行过程
让我们通过一个具体例子来理解算法的执行过程:
输入:s1 = "abac", s2 = "baca"
步骤解析:
-
初始状态:
s1 = "abac"
s2 = "baca"
- 队列初始化为
["abac"]
- 步数
step = 0
-
处理第一层:
- 取出
"abac"
,找到第一个不匹配位置i = 0
(s1[0] = 'a'
,s2[0] = 'b'
) - 寻找候选位置
j > 0
,满足arr[j] == 'b'
且arr[j] != s2[j]
- 找到
j = 1
(arr[1] = 'b'
),交换i = 0
和j = 1
- 生成新字符串
"bAAC"
,加入队列 - 步数
step = 1
- 取出
-
处理第二层:
- 取出
"bAAC"
,找到第一个不匹配位置i = 2
(s1[2] = 'A'
,s2[2] = 'c'
) - 寻找候选位置
j > 2
,满足arr[j] == 'c'
且arr[j] != s2[j]
- 找到
j = 3
(arr[3] = 'c'
),交换i = 2
和j = 3
- 生成新字符串
"baca"
,与目标匹配,返回step + 1 = 2
- 取出
复杂度分析
-
时间复杂度:最坏情况下为 O(n!),但通过剪枝策略,实际复杂度远低于此。对于长度为 n 的字符串,每个位置最多有 n-1 个候选交换位置,BFS 的层数通常较小,因此实际效率较高。
-
空间复杂度:主要由队列和访问集合决定,最坏情况下为 O(n × n!),其中 n! 是可能的状态数,n 是每个状态的字符串长度。
总结:为什么这种方法有效?
-
BFS 的逐层扩展:确保找到的路径是最短的,符合题目要求的最小交换次数。
-
候选位置的智能筛选:
- 只考虑能修复当前不匹配位置的交换
- 优先处理能同时修复两个位置的最优交换
- 避免交换已经在正确位置的字符
-
状态去重:使用哈希集合记录已访问的状态,避免重复计算,大幅提高效率。
通过这些优化,算法能够在合理时间内处理较大的输入,找到最优解。这种方法不仅适用于字符串相似度问题,还可推广到其他需要寻找最短操作序列的问题中。