LeetCode 833. 字符串中的查找与替换
题目描述
题意分析
给定原字符串
s和三个等长数组indexes、sources、targets,第 $i$ 条替换规则的含义是:如果原串从下标indexes[i]开始的那一段恰好等于sources[i],就把这一段替换成targets[i];否则这条规则不生效,原样保留。返回执行完所有替换后的字符串。「所有替换同时发生」是这道题的核心设定,它有两层含义。第一,每条规则的匹配判定都是在原字符串上进行的,不受其它替换的影响——你不能先做一次替换再拿改动后的串去匹配下一条。第二,每条规则给出的
indexes[i]也是原字符串上的下标,前面的替换即使改变了长度,也不能挪动后面规则的位置。这两点合起来,意味着任何「按顺序在结果串上就地修改」的实现都是错的。「不生效就原样保留」也要读细:匹配失败时,不仅
targets[i]不写入,sources[i]覆盖的那一段原文也要照常输出,不能被跳过。
约束里有两条关键保证: indexes中的值互不相同,且题目保证所有替换操作互不重叠。这排除了「两条规则抢同一段字符」的歧义,让每个原串位置最多被一条规则占用,实现可以放心地线性推进。另外 $1 \les \le 1000$、$1 \le k \le 100$,规模极小,$O( s \cdot k)$ 也能过——题目考的是语义正确性而非效率。 但
indexes不保证有序——这是最容易踩的地方。规则可能以任意顺序给出,而输出必须按原串从左到右拼接,所以要么先排序,要么建立「下标 → 规则」的映射。边界方面:某条规则的
sources[i]可能比从该下标到串尾的剩余长度还长,此时必然匹配失败,取子串时不能越界;indexes可能覆盖不到串的开头或结尾,未被任何规则触及的字符要原样输出。
解法:按索引排序 + 扫描拼接
核心思路
先看两个错误的方向,它们能解释这道题的坑在哪。第一个是「按给定顺序逐条在字符串上做替换」:只要有一条规则生效并改变了长度,后面所有规则的
indexes就全部失准。第二个是「按indexes从大到小倒着替换」:这样长度变化只影响已处理的右侧,看似可行,但匹配判定仍然可能出错——如果某条规则的 target 恰好在原串中制造出另一条规则的 source 模式,倒序做也会误判。稳妥的做法是完全不修改原串,只做一次从左到右的扫描并把结果写进新缓冲区。于是解法的骨架确定为:用一个指针 $p$ 在原串上单调右移,边走边决定当前位置该往输出缓冲区里写什么。 每一步只有两种可能——要么这里有一条生效的规则,写入 target 并把 $p$ 跳过整个 source 的长度;要么没有,写入 $s[p]$ 这一个字符并让 $p$ 前进一格。
要让这个扫描能在 $O(1)$ 时间判断「当前位置有没有规则」,就需要把规则按
indexes升序排好。做法是建立一个下标序列 $order$,按indexes的值排序,然后用第二个指针 $t$ 指向「下一条尚未处理的规则」。因为 $p$ 单调右移、规则也按位置递增,$t$ 同样只需单调右移,两个指针各走一遍,是典型的双指针推进。显式写下不变量:输出缓冲区里已经是原串前 $p$ 个字符经过全部相关替换后的结果;$order[t]$ 是所有起始位置不小于 $p$ 的规则中位置最靠前的那一条(若 $t$ 已越界则说明规则已用尽)。
每一轮的判断分三层:
- 当前位置没有规则($t$ 越界,或 $indexes[order[t]] \ne p$):写入 $s[p]$,$p$ 加一。
- 当前位置有规则且匹配成功:写入对应的 target,$p$ 前进 source 的长度,$t$ 加一。
- 当前位置有规则但匹配失败:写入 $s[p]$ 这一个字符,$p$ 加一,$t$ 也要加一。
第三种情形有两处细节值得强调。其一,匹配失败时 $p$ 只前进一格而不是 source 的长度——因为这条规则不生效,被它「盯上」的那段原文要完整输出,只能一格一格地走。其二,$t$ 仍然要加一——这条规则已经被判定过了,不能让它在下一轮重新触发;由于题目保证替换互不重叠,同一个位置不会有第二条规则,$t$ 前进不会漏掉任何东西。
匹配判定本身要先做长度检查再比内容:只有 $p + source \le s $ 时截取才合法。把长度检查放在前面,靠短路求值挡住越界,是这里的标准写法。 由于始终只读原串、只写新缓冲区,「同时发生」的语义天然成立——任何一条规则的判定都不会看到别的规则的产物。
解题步骤
- 第一步,建立规则下标序列 $order = [0, 1, \dots, k-1]$,按
indexes的值升序排序。 为什么排序的是下标而不是直接排序三个数组:三个数组是按位置一一对应的,直接排任何一个都会破坏对应关系。排一个「间接下标」是处理平行数组的标准手法。为什么必须排序:题目不保证indexes有序,而输出必须按原串位置从左到右生成。- 第二步,准备输出缓冲区、原串指针 $p = 0$、规则指针 $t = 0$。 为什么要用缓冲区而不是在原串上就地改:就地修改会让后续规则的
indexes失准,也会让匹配判定看到别的替换的结果,直接违反「同时发生」。- 第三步,当 $p$ 未到串尾时循环。 为什么循环条件用 $p$ 而不是 $t$:规则可能覆盖不到串尾,剩余字符必须继续输出;用 $t$ 作条件会在规则用尽时提前退出,丢掉后缀。
- 第四步,若 $t$ 未越界且 $indexes[order[t]] = p$,说明当前位置有一条待判定的规则。 为什么用相等判断而不是「小于等于」:$p$ 与 $t$ 都单调右移,且每条规则在其起始位置恰好被访问一次,所以只会精确相等。若出现 $indexes[order[t]] < p$,说明前面某步跳过了规则的起点,那是实现有误。
**第五步,匹配判定:先查 $p + source \le s $,再比 $s$ 从 $p$ 开始的这一段是否等于 source。** 为什么长度检查必须在前:source 可能比剩余长度还长,先截取会越界。靠 &&的短路求值天然实现这个顺序。
**第六步,匹配成功则写入 target,并令 $p$ 前进 $ source $、$t$ 加一。** 为什么 $p$ 跳的是 source 的长度而不是 target 的长度:$p$ 走在原串上,被消费掉的是 source 这么多个原字符;target 只是写进缓冲区,与原串的推进无关。这是本题最容易写反的一行。 - 第七步,匹配失败则写入 $s[p]$ 单个字符,$p$ 加一、$t$ 加一。 为什么 $t$ 也要加一:这条规则已判定完毕且不生效,若不推进会在下一轮以 $p+1$ 与它的起始位置比较,虽然不再相等而落入普通分支,但 $t$ 会一直卡在这条废规则上,导致后面所有规则都无法被触发。
- 第八步,否则(当前位置无规则)写入 $s[p]$,$p$ 加一。
- 第九步,循环结束后把缓冲区转成字符串返回。
以
s = "abcd"、indexes = [0, 2]、sources = ["a", "cd"]、targets = ["eee", "ffff"]走一遍(期望结果"eeebffff")。排序:
indexes已是升序,$order = [0, 1]$。初始 $p = 0$、$t = 0$、缓冲区为空。
$p = 0$:$t = 0$ 未越界,$indexes[order[0]] = 0 = p$,有规则。source 是 "a",长度检查 $0 + 1 \le 4$ 通过,比较 $s$ 从 0 开始的 1 个字符"a"与 source 相等,匹配成功。写入 target"eee",缓冲区变成"eee";$p$ 前进 $source = 1$ 变成 1(不是 前进 target 的长度 3);$t$ 变成 1。 $p = 1$:$t = 1$ 未越界,但 $indexes[order[1]] = 2 \ne 1$,无规则。写入 $s[1] = $
'b',缓冲区变成"eeeb";$p$ 变成 2。$p = 2$:$indexes[order[1]] = 2 = p$,有规则。source 是
"cd",长度检查 $2 + 2 \le 4$ 通过,比较 $s$ 从 2 开始的 2 个字符"cd"与 source 相等,匹配成功。写入"ffff",缓冲区变成"eeebffff";$p$ 前进 2 变成 4;$t$ 变成 2。$p = 4$:等于串长,循环结束。返回
"eeebffff"。这一步的关键观察是:第二条规则的
indexes是 2,指的是原串中"cd"的位置;此刻缓冲区已经有 4 个字符了,如果按输出位置去对齐规则,就会完全错位。始终用 $p$ 在原串上定位,是「同时发生」语义的实现方式。再看匹配失败的情形:
s = "abcd"、indexes = [0, 2]、sources = ["ab", "ec"]、targets = ["eee", "ffff"](期望结果"eeecd")。$p = 0$:规则 0 的 source 是
"ab",$s$ 从 0 开始的 2 个字符是"ab",匹配成功。写入"eee",$p$ 前进 2 变成 2,$t$ 变成 1。
$p = 2$:$indexes[order[1]] = 2 = p$,有规则。source 是 "ec",长度检查 $2 + 2 \le 4$ 通过,但 $s$ 从 2 开始的 2 个字符是"cd",与"ec"不等,匹配失败。此时只写入 $s[2] = $'c'这一个字符,缓冲区变成"eeec";$p$ 只前进一格变成 3;$t$ 变成 2。这里如果误让 $p$ 前进 $source = 2$,字符 'd'就会被吞掉,结果变成"eeec",少了一位。$p = 3$:$t = 2$ 已越界,无规则。写入 $s[3] = $
'd',缓冲区变成"eeecd";$p$ 变成 4,循环结束。返回
"eeecd"。这个例子同时验证了「失败时只走一格」和「规则用尽后仍要输出剩余字符」两条。
代码实现
class Solution {
public String findReplaceString(String s, int[] indexes, String[] sources, String[] targets) {
int n = indexes.length;
Integer[] order = new Integer[n];
for (int i = 0; i < n; i++) {
order[i] = i;
}
Arrays.sort(order, (a, b) -> indexes[a] - indexes[b]);
StringBuilder sb = new StringBuilder();
int p = 0;
int t = 0;
while (p < s.length()) {
if (t < n && p == indexes[order[t]]) {
int k = order[t];
String src = sources[k];
if (p + src.length() <= s.length() && s.startsWith(src, p)) {
sb.append(targets[k]);
p += src.length();
} else {
sb.append(s.charAt(p));
p++;
}
t++;
} else {
sb.append(s.charAt(p));
p++;
}
}
return sb.toString();
}
}
func findReplaceString(s string, indexes []int, sources []string, targets []string) string {
n := len(indexes)
order := make([]int, n)
for i := 0; i < n; i++ {
order[i] = i
}
sort.Slice(order, func(i, j int) bool {
return indexes[order[i]] < indexes[order[j]]
})
p := 0
t := 0
buf := make([]byte, 0, len(s))
for p < len(s) {
if t < n && p == indexes[order[t]] {
k := order[t]
src := sources[k]
if p+len(src) <= len(s) && s[p:p+len(src)] == src {
buf = append(buf, targets[k]...)
p += len(src)
} else {
buf = append(buf, s[p])
p++
}
t++
} else {
buf = append(buf, s[p])
p++
}
}
return string(buf)
}
复杂度分析
时间复杂度:$O(k \log k + s + \sum source_i + \sum target_i )$。排序规则下标是 $O(k \log k)$;主扫描中 $p$ 单调走遍原串是 $O( s )$,其中每次匹配判定要逐字符比较 source(总计不超过所有 source 长度之和),每次成功写入要拷贝 target。在 $ s \le 1000$、$k \le 100$、单个模式串不超过 50 的约束下,总量在万级。
空间复杂度:$O(k + s + \sum target_i )$。$order$ 数组占 $O(k)$,输出缓冲区的长度是原串长度减去被替换掉的部分再加上所有 target 的长度。这里不能省掉缓冲区——「同时发生」的语义要求原串在整个过程中保持只读。
关键点总结
- 「同时发生」的语义等于「只读原串、只写新缓冲区」。任何在结果上就地修改的写法都会让后续规则看到前面的产物、或者让下标失准。识别出这类语义后,实现方式几乎是唯一的:单调扫描原串、拼接到别处。
- 平行数组要排序时,排的是间接下标。
indexes、sources、targets三者按位置一一对应,直接排其中之一会破坏对应关系。建一个 $order$ 数组存原下标、按关键字排它,是处理这类结构的通用手法。- 双指针的推进步长要各自对应各自的坐标系。$p$ 走在原串上,所以匹配成功时跳的是 source 的长度;target 只影响输出缓冲区,与 $p$ 无关。混淆两个坐标系是本题最高频的错误。
- 判定失败也要消费掉这条规则。$t$ 必须无条件前进,否则一条废规则会永久卡住指针,后面所有规则都触发不了。写「判定 + 推进」型循环时,要确保每个分支都让至少一个指针前进。
- 越界检查靠短路求值前置。
p + len(src) <= len(s) && 内容相等这个顺序不是风格问题:反过来写会先截取子串而越界。凡是「先保证操作合法、再执行操作」的场景,都该用短路把守卫放在前面。- 面试视角:这题代码不难,面试官考的是你有没有读懂「同时发生」。开口先说「所有匹配都在原串上判定、所有下标都是原串下标,所以我不修改原串,而是扫描一遍拼出新串」,基本就过关了。常见追问有三个:一是「
indexes无序怎么办」,答排序间接下标;二是「匹配失败时指针怎么走」,答只走一格但要消费掉这条规则;三是「如果题目不保证替换互不重叠呢」,正确回答是需要额外定义冲突时的优先级(比如取下标最小或规则序号最小的那条),并在跳过一段时同步跳过所有起点落在这段内的规则。能主动指出「$p$ 跳 source 长度而非 target 长度」,说明你在心里跑过一遍循环。
易错点总结
- 错误写法:按给定顺序逐条在字符串上执行替换。以
s = "abcd"、indexes = [0,2]、sources = ["a","cd"]、targets = ["eee","ffff"]为例,第一条替换后串变成"eeebcd",第二条规则的下标 2 此时指向的是'e'而不是'c',匹配失败,返回"eeebcd",而正确答案是"eeebffff"。所有下标都是原串下标。- 错误写法:匹配成功后 $p$ 前进 target 的长度。以上例为例,第一条规则的 target 长度是 3,$p$ 会从 0 跳到 3,直接跳过了
'b'和'c',结果变成"eeed"。$p$ 走在原串上,消费的是 source 那么多个字符。- 错误写法:匹配失败后 $p$ 前进 source 的长度。以
s = "abcd"、indexes = [0,2]、sources = ["ab","ec"]、targets = ["eee","ffff"]为例,第二条规则匹配失败时若跳 2 格,字符'd'被吞掉,返回"eeec",而正确答案是"eeecd"。不生效的规则不消费任何原文,只能一格一格走。- 错误写法:匹配失败后不推进 $t$。以同一组数据为例,$t$ 永远停在第二条规则上;虽然后续 $p$ 与它的起始位置不再相等而落入普通分支,但如果还有第三条规则,它永远等不到 $t$ 指向自己,全部失效。每个分支都要保证指针有推进。
- 错误写法:不排序,直接按给定顺序用 $t$ 扫描规则。以
s = "abcd"、indexes = [2, 0]、sources = ["cd", "a"]、targets = ["ffff", "eee"]为例,$p = 0$ 时 $t$ 指向的规则起始位置是 2,不相等,于是原样输出'a';$p = 2$ 时终于匹配上第一条,但第二条(下标 0)再也没机会触发,返回"abffff",而正确答案是"eeebffff"。- 错误写法:直接对
indexes数组排序。以任意输入为例,排序后indexes[i]与sources[i]、targets[i]的对应关系被打乱,每条规则都会拿到别人的模式串和替换串,结果完全错乱。平行数组必须排间接下标。- 错误写法:先截取子串再判长度。以
s = "ab"、indexes = [1]、sources = ["bcd"]为例,从下标 1 截取 3 个字符会越界,Java 抛StringIndexOutOfBoundsException,Go 的切片操作直接 panic。长度检查必须靠短路求值排在内容比较之前。- 错误写法:循环条件写成
t < n。以s = "abcd"、indexes = [0]、sources = ["a"]、targets = ["eee"]为例,规则用尽后循环立刻退出,返回"eee",而正确答案是"eeebcd"。未被规则覆盖的后缀必须继续输出,循环条件应由 $p$ 控制。- 错误写法:从右往左倒序执行替换以规避下标失准。以某条 target 恰好在结果串中拼出另一条 source 模式的输入为例,倒序虽然保住了左侧的下标,但被修改过的右侧内容仍可能被左侧规则的匹配判定读到,违反「所有判定都基于原串」。只读原串是唯一稳妥的做法。
- 错误写法:用
indexes[order[t]] <= p作为触发条件。以任意输入为例,这会让某条规则在它的起点被跳过之后仍然触发(比如前一条替换把 $p$ 跳过了它的起点),在本题「保证不重叠」的约束下不会发生,但一旦迁移到允许重叠的变体,就会在错误的位置插入 target。触发条件应是精确相等,跳过规则要靠显式推进 $t$。- 错误写法:Java 里用
int[] order配合Arrays.sort(order, comparator)。基本类型数组的排序不接受比较器,编译不通过;必须用Integer[](如本代码)或改写成手动排序 / 先打包成二维数组。这是 Java 里排序自定义键的经典绊脚石。- 错误写法:Go 里排序写成
sort.Slice(order, func(i, j int) bool { return indexes[i] < indexes[j] })。比较函数收到的 $i$、$j$ 是 $order$ 切片内部的位置,必须用indexes[order[i]] < indexes[order[j]]才是按规则的起始位置比较。写成indexes[i]排的是一个与 $order$ 内容无关的固定序列,结果不可预测。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 56. 合并区间 | 中等 | 同样先按起始位置排序再线性扫描,但要合并相交区间而非独立处理 |
| 28. 找出字符串中第一个匹配项的下标 | 简单 | 纯子串匹配,本题的匹配判定是它的定点版本(只需在指定下标处比一次) |
| 443. 压缩字符串 | 中等 | 读指针与写指针分别推进的原地改写,训练「两个坐标系各自推进」的手感 |
| 71. 简化路径 | 中等 | 边扫描边决定往输出里写什么,重点在分段解析与栈的配合 |
| 68. 文本左右对齐 | 困难 | 大量边界条件的字符串拼接题,考察的是把规则逐条落实为分支的耐心 |
| 937. 重新排列日志文件 | 中等 | 自定义比较器排序并保持原有相对顺序,与本题排间接下标是同一类平行数据处理 |
| 6. Z 字形变换 | 中等 | 输出位置与原串位置的映射关系复杂,同样要求「不在原串上改,另开缓冲区拼」 |
| 763. 划分字母区间 | 中等 | 先预处理每个字符的最远位置再单调扫描切分,是「预处理 + 一次扫描」的另一形态 |