统计只差一个字符的子串数目 图解题解
这道题到底在问什么
- 输入
- s = "cat", t = "cbt"
- 输出
- 10(如 "ca" 对 "cb"、"cat" 对 "cbt"、"t" 对 "c" 等,各算一对)
- 输入
- s = "a", t = "b"
- 输出
- 1(只有 "a" 对 "b" 这一对,恰好 1 个字符不同)
先想最直接的笨办法
记牢这句话:固定一对起点、沿对角线一起扩、边扩边数不同字符,只要不同字符恰好是 1 个就记一对,数到第 2 个不同就收手。下面每一帧都在套这个思路,起点对一个一个换。(动画第 3 步)
最优解:为什么这么做
一句话答案:LeetCode 1638 统计只差一个字符的子串数目:在 s、t 里各截一段等长子串,唯一的不同字符即中心,向左、向右数连续相等长度 l、r,(l+1)×(r+1) 累加即答案。时间 O(m·n·min(m,n))、空间 O(1)。
只差一个字符的子串对,到底数的是什么
给两个只含小写字母的字符串 s 和 t。在 s 里截一段非空连续子串、在 t 里截一段等长的,逐位比较,恰好 1 个位置字符不同就算一对,问共有多少对。s = 「cat」、t = 「cbt」时答案是 10。留意数的是「子串对」不是「不同位置」:「cat」配 「cbt」差在 a 与 b 记一对,更短的 「ca」配 「cb」又是一对,长短各算。
把每对起点扩出的窗口全比一遍,慢在哪
两串各长 m、n,起点有 m×n 种搭配;起点定了、两串同步往右延长,延出长短不同的窗口,逐对截出来逐位数:起点 m×n 个、延伸最多 min(m, n) 格、数不同又走一遍窗口,叠起来 O(m·n·min²(m,n))(大 O 记号,描述操作数随规模怎么涨)。病根是同一次比较在长短窗口里反复做:起点 (1, 1) 扩出的 「a」/「b」、「at」/「bt」,a 与 b 那一比数了两遍。按中心增量扩的数法省掉重数这一层,降到 O(m·n·min(m,n))。
那个唯一的不同字符,为什么能拿来当中心
合法窗口只有 1 个位置不同,这唯一的不同字符落在某个 s[i] 与 t[j] 上、它俩不相等(i、j 分别是 s、t 的下标,都从 0 数起)。于是不枚举起点,改枚举「不同的中心」:走遍所有 s[i] 与 t[j],只在两者不等时动手。
锁定中心后,左右各扩的长度为什么要相乘
锁定中心 (i, j) 后,合法窗口除了这处不同、左右必须全相等。从中心往左一格格比出左连续 l(s、t 两串同步各退一格对比出的向左连续相等位数),向右同样比出右连续 r。这个中心的合法窗口,左边可多带 0 到 l 个相等字符、右边 0 到 r 个,左 l+1 种带法、右 r+1 种,乘起来 (l+1)×(r+1) 对。相乘而非相加:每种左带法配每种右带法各成一个不同窗口。
拿 s=「cat」、t=「cbt」把这 10 对亲手数出来
逐个中心数。9 种 (i, j) 先划掉相等的 (0, 0) c 对 c 和 (2, 2) t 对 t。剩下 7 个中心里,6 个两侧都扩不动:如 (0, 1) c 对 b,左边到头 l=0、右边 s[1]=a 与 t[2]=t 不等 r=0,贡献 (0+1)×(0+1)=1,其余 5 个同理各贡献 1。最肥的是 (1, 1) a 对 b:向左 s[0]=c 与 t[0]=c 相等 l=1、向右 s[2]=t 与 t[2]=t 相等 r=1,贡献 (1+1)×(1+1)=4。七个相加 1×6+4=10。
把「恰好 1 个不同」松成「至少 1 个」,答案为什么会暴涨
判定必须卡死「恰好 1 个不同」:松成「至少 1 个」,2 处、3 处不同的也混进来,答案远大于 10;写成「不超过 1 个」,0 处不同的两段也算进来。中心 m×n 个、每个向两侧最多扩 min(m, n) 格,时间 O(m·n·min(m,n));只用 l、r、ans 几个计数器,空间 O(1)。边界:s、t 各 1 个字符且不同就是 1 对;两串由同一个重复字符组成(如都是 「aaa」)才为 0,两串相同但含不同字符,错位子串照样能恰差 1 个字符。
▶ 动画逐步走查(共 25 步)——想跟着动画一帧帧对照就展开
- 3记牢这句话:固定一对起点、沿对角线一起扩、边扩边数不同字符,只要不同字符恰好是 1 个就记一对,数到第 2 个不同就收手。下面每一帧都在套这个思路,起点对一个一个换。
- 4换一对新起点。让 s 的指针停在下标 0 的 "c",t 的指针停在下标 0 的 "c"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 5指针走到 s[0] = "c" 和 t[0] = "c",两个字符相同,窗口里的不同个数不变,还是 0。 现在 s 的窗口是 "c",t 的窗口是 "c"。一个不同都没有,两段完全一样,不符合「恰好 1 个不同」,不记。
- 6指针走到 s[1] = "a" 和 t[1] = "b",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "ca",t 的窗口是 "cb"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 1。
- 7指针走到 s[2] = "t" 和 t[2] = "t",两个字符相同,窗口里的不同个数不变,还是 1。 现在 s 的窗口是 "cat",t 的窗口是 "cbt"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 2。
- 8换一对新起点。让 s 的指针停在下标 0 的 "c",t 的指针停在下标 1 的 "b"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 9指针走到 s[0] = "c" 和 t[1] = "b",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "c",t 的窗口是 "b"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 3。
- 10指针走到 s[1] = "a" 和 t[2] = "t",两个字符不同,窗口里的不同个数加一,变成 2。 现在 s 的窗口是 "ca",t 的窗口是 "bt"。不同已经攒到 2 个了,再往右扩只会更多,不可能回到 1 个,这一对到此为止,指针收手换下一对起点。
- 11换一对新起点。让 s 的指针停在下标 0 的 "c",t 的指针停在下标 2 的 "t"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 12指针走到 s[0] = "c" 和 t[2] = "t",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "c",t 的窗口是 "t"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 4。
- 13换一对新起点。让 s 的指针停在下标 1 的 "a",t 的指针停在下标 0 的 "c"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 14指针走到 s[1] = "a" 和 t[0] = "c",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "a",t 的窗口是 "c"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 5。
- 15指针走到 s[2] = "t" 和 t[1] = "b",两个字符不同,窗口里的不同个数加一,变成 2。 现在 s 的窗口是 "at",t 的窗口是 "cb"。不同已经攒到 2 个了,再往右扩只会更多,不可能回到 1 个,这一对到此为止,指针收手换下一对起点。
- 16换一对新起点。让 s 的指针停在下标 1 的 "a",t 的指针停在下标 1 的 "b"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 17指针走到 s[1] = "a" 和 t[1] = "b",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "a",t 的窗口是 "b"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 6。
- 18指针走到 s[2] = "t" 和 t[2] = "t",两个字符相同,窗口里的不同个数不变,还是 1。 现在 s 的窗口是 "at",t 的窗口是 "bt"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 7。
- 19换一对新起点。让 s 的指针停在下标 1 的 "a",t 的指针停在下标 2 的 "t"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 20指针走到 s[1] = "a" 和 t[2] = "t",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "a",t 的窗口是 "t"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 8。
- 21换一对新起点。让 s 的指针停在下标 2 的 "t",t 的指针停在下标 0 的 "c"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 22指针走到 s[2] = "t" 和 t[0] = "c",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "t",t 的窗口是 "c"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 9。
- 23换一对新起点。让 s 的指针停在下标 2 的 "t",t 的指针停在下标 1 的 "b"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 24指针走到 s[2] = "t" 和 t[1] = "b",两个字符不同,窗口里的不同个数加一,变成 1。 现在 s 的窗口是 "t",t 的窗口是 "b"。不同的字符恰好 1 个,这正是题目要的,把这一对记下来,答案变成 10。
- 25换一对新起点。让 s 的指针停在下标 2 的 "t",t 的指针停在下标 2 的 "t"。接下来这两个指针会手拉手沿对角线一起往右走,每走一格就把对上的两个字符比一比,数窗口里有几个不同。现在窗口里还一个字符都没比,先看好起跑线。
- 26指针走到 s[2] = "t" 和 t[2] = "t",两个字符相同,窗口里的不同个数不变,还是 0。 现在 s 的窗口是 "t",t 的窗口是 "t"。一个不同都没有,两段完全一样,不符合「恰好 1 个不同」,不记。
- 27九对起点都沿对角线扫过了。回放一下:贡献最多的是起点对 (0, 0) 和 (1, 1),它们各扩出了 2 对;其余起点对有的记一对、有的因为完全相同或太快撞上第 2 个不同而记不到。把每对起点记下的对数全加起来,正好是 10。全程只做了对齐、比较、计数三种常数操作。
⚠️ 容易写错的地方
✗ 错:把「子串对的数目」和「位置不同的数目」搞混,以为答案就是有几处字符不同
✓ 对:答案数的是「子串对」:同一个不同字符,左右各延伸不同长度会组成好多对,都要算
题目要的是满足条件的子串对个数。一个不同字符当中心,左右各能带一段相等,组合出 (l+1)(r+1) 对,远不止 1 个,只数「有几处不同」会严重少算
✗ 错:窗口里不同字符已经到 2 个还继续往右扩
✓ 对:不同个数一旦升到 2 就立刻停,这一对起点的扩窗结束
往右扩只会让窗口更长、不同字符只增不减,到了 2 就再也回不到恰好 1,继续扩纯属浪费,还可能误把更长的窗口算进来
✗ 错:把条件写成「至少 1 个不同」或「不超过 1 个不同」
✓ 对:必须是「恰好 1 个不同」:0 个不同(两段完全相同)不算,2 个及以上也不算
完全相同的两段是 0 个不同,题目明确不计;有 2 个以上不同更不行。只有正好 1 个不同的窗口才记一对,边界要卡死在「等于 1」
完整代码(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 Solution:
def countSubstrings(self, s: str, t: str) -> int:
ans = 0
m, n = len(s), len(t)
for i, a in enumerate(s):
for j, b in enumerate(t):
if a != b:
l = r = 0
while i > l and j > l and s[i - l - 1] == t[j - l - 1]:
l += 1
while (
i + r + 1 < m and j + r + 1 < n and s[i + r + 1] == t[j + r + 1]
):
r += 1
ans += (l + 1) * (r + 1)
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#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;
class Solution {
public:
int countSubstrings(string s, string t) {
int ans = 0;
int m = s.size(), n = t.size();
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (s[i] != t[j]) {
int l = 0, r = 0;
while (i - l > 0 && j - l > 0 && s[i - l - 1] == t[j - l - 1]) {
++l;
}
while (i + r + 1 < m && j + r + 1 < n && s[i + r + 1] == t[j + r + 1]) {
++r;
}
ans += (l + 1) * (r + 1);
}
}
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int countSubstrings(String s, String t) {
int ans = 0;
int m = s.length(), n = t.length();
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (s.charAt(i) != t.charAt(j)) {
int l = 0, r = 0;
while (i - l > 0 && j - l > 0 && s.charAt(i - l - 1) == t.charAt(j - l - 1)) {
++l;
}
while (i + r + 1 < m && j + r + 1 < n
&& s.charAt(i + r + 1) == t.charAt(j + r + 1)) {
++r;
}
ans += (l + 1) * (r + 1);
}
}
}
return ans;
}
}复杂度
时间
O(m·n·min(m, n))
m、n 是两个字符串长度。动画里起点对有 m·n 个,每对沿对角线最多扩 min(m, n) 格;参考代码枚举 m·n 个中心、每个向两边扩也是 min(m, n) 量级。两种数法都是这个上界,最坏同阶
空间
O(1)
从头到尾只用了几个下标和计数器(起点 i、j,偏移 k,不同个数 mis,或参考代码的 l、r、ans),不随字符串变长而增加,峰值是常数
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 统计只差一个字符的子串数目 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么合法窗口那个不同字符一定唯一,能拿来当中心?+
因为题目要求恰好 1 个位置不同,所以合法窗口里不相等的位置有且只有一处。这处不相等的 (i, j) 就唯一确定了这个窗口的「身份」;不同窗口哪怕共享同一处不同点,也只是这个中心向左、向右扩的长度不一样。正因唯一,枚举它当中心既不会把同一个窗口数两遍,也不会漏——每个合法窗口恰好被它那唯一的不同中心统计一次。
按中心枚举,凭什么比逐个起点暴力扩要快?+
逐个起点要枚举 m×n 个起点、每个把两串同步往右延长着扩,扩的过程里同一次字符比较会在长短不同的窗口里被重复做。按中心枚举同样是 m×n 个中心,但每个中心只向左、向右各扫一遍数出连续相等长度,再用一次乘法 (l+1)×(r+1) 就把该中心的所有合法窗口一并算出,省掉了逐窗口重复比较的那层常数。两者最坏都是 O(m·n·min(m,n)),但中心法的常数小很多。
有没有比 O(m·n·min(m,n)) 更快的做法?+
有 O(m·n) 的前后缀预处理。先算出每个位置向左、向右的最长连续相等长度,存成前缀、后缀两张表;枚举不同的中心 (i, j) 时直接查表拿到 l 和 r,无需再逐格扫,把每个中心那趟 min(m, n) 的扩窗降成 O(1)。这类「子串恰好 k 处不同」的题,常见做法就是固定一个锚点向两边扩,或用滑动窗口维护不同字符的计数。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 统计只差一个字符的子串数目 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。