字符的最短距离 图解题解
这道题到底在问什么
- 输入
- s="loveleetcode", c="e"
- 输出
- [3,2,1,0,1,0,0,1,2,2,1,0]
- 输入
- s="aaab", c="b"
- 输出
- [3,2,1,0] (b 在末尾,越往左越远)
最优解:为什么这么做
一句话答案:LeetCode 821 字符的最短距离用两遍扫描:从左扫一遍记每位到最近 c 的左距、从右扫一遍记右距,每位取两者的 min 就是答案,时间 O(n)、空间 O(1)。
answer[i] 要算的是什么距离
给字符串 s 和字符 c(题目保证 c 在 s 里至少出现一次),要为每个下标 i 求它到最近那个 c 的距离,即下标差的绝对值 abs(i-j)。题面 s="loveleetcode"、c="e",e 在下标 3、5、6、11,输出 [3,2,1,0,1,0,0,1,2,2,1,0]:是 e 的位置为 0,越远数越大。
每个位置都向两边找一遍,慢在哪
O(n²) 是这么来的:站在每位往左右各走一趟找最近的 c、取较近那段,这是最容易想到的暴力。s 有 n 个字符,每个位置最坏要扫遍整串才碰到 c,n 个位置各扫 n 步就是 O(n²)。串长上万就明显拖不动。
一个位置的最近 c,非左即右
对某个位置来说,离它最近的 c 要么在左边、要么在右边,取近的就是答案。可从左走时右边的 c 还没扫到,一遍只知一侧。于是分两遍:第一遍从左到右,求每位到左边最近 c 的距离;第二遍从右到左,求到右边最近 c 的距离;同一位置两个方向取 min,就是真正的最近距离。
pre、suf 两个变量怎么一路更新
先把答案数组每格填成 n(串长,是距离的上界);pre 初始设成负无穷,表示左边还没出现过 c。第一遍从左往右:遇到 c 就把 pre 更新成当前下标,再让 ans[i] 取「原值」和「i 减 pre」里更小的。左边一直没 c 时 i 减 pre 是无穷大,那格保持上界,留给第二遍补。第二遍从右往左对称:遇到 c 更新 suf,ans[i] 再和「suf 减 i」取一次 min。两遍都用减法、方向保证非负,天然就是绝对值距离,不用再套 abs。
两遍扫过 loveleetcode,各得什么
第一遍从左,pre 记左边最近的 e:下标 0、1、2(lov)左边没 e,停在上界;下标 3 遇 e,pre=3、ans=0;下标 4 得 1;下标 5、6 遇 e,pre 更新为 5、6、两格 0;下标 7 到 10 依次 1、2、3、4;下标 11 遇 e,ans=0。
第二遍从右,suf 记右边最近的 e,取一次 min:下标 10、9 右距 1、2 比原来的 4、3 近,更新成 1、2;下标 8、7 右距 3、4 比原来的 2、1 远,保持 2、1;下标 6、5 遇 e 更新 suf;下标 4 得 1(与原相等);下标 3 遇 e;下标 2、1、0 得 1、2、3,补上前三格。最终得到 [3,2,1,0,1,0,0,1,2,2,1,0]。
c 挤在串的一头,就全靠另一遍兜底
两遍都是从头到尾的线性遍历,时间 O(n);除答案数组外只多用 pre、suf 两个变量,空间 O(1)。两个边界说明为什么少一遍就错:c 全挤在最右端(如 s="aaab"、c="b"),第一遍从左走时前面每个位置左边都没 b,左距全停在上界,答案全靠第二遍从右补出;c 在最左端就换第一遍兜底。另一个易错点:pre 别填 0,那等于假装下标 0 处有个 c,会把开头本该由另一遍补的位置压成错误小距离——填负无穷,才能把「还没遇到 c」和「距离真是 0」分开。
▶ 动画逐步走查(共 28 步)——想跟着动画一帧帧对照就展开
- 3核心就八个字:左右各扫一遍,取 min。下面每一帧都在套它。
- 4先把目标字符 e 全找出来:绿色这四个位置(下标 3、5、6、11)本身到 e 的距离都是 0。其余位置的距离,就看它离最近的绿色有多远。
- 5走到下标 0(字符 l),它左边一个 e 都还没出现,到左边 e 的距离先记成无穷大 ∞,等第二遍从右边补。
- 6走到下标 1(字符 o),它左边一个 e 都还没出现,到左边 e 的距离先记成无穷大 ∞,等第二遍从右边补。
- 7走到下标 2(字符 v),它左边一个 e 都还没出现,到左边 e 的距离先记成无穷大 ∞,等第二遍从右边补。
- 8走到下标 3,正好是 e。它本身距离为 0,同时把 pre 记成 3,往后的位置就拿它当「左边最近的 e」。
- 9走到下标 4(字符 l),左边最近的 e 在 3,距离就是 4 减 3 等于 1。
- 10走到下标 5,正好是 e。它本身距离为 0,同时把 pre 记成 5,往后的位置就拿它当「左边最近的 e」。
- 11走到下标 6,正好是 e。它本身距离为 0,同时把 pre 记成 6,往后的位置就拿它当「左边最近的 e」。
- 12走到下标 7(字符 t),左边最近的 e 在 6,距离就是 7 减 6 等于 1。
- 13走到下标 8(字符 c),左边最近的 e 在 6,距离就是 8 减 6 等于 2。
- 14走到下标 9(字符 o),左边最近的 e 在 6,距离就是 9 减 6 等于 3。
- 15走到下标 10(字符 d),左边最近的 e 在 6,距离就是 10 减 6 等于 4。
- 16走到下标 11,正好是 e。它本身距离为 0,同时把 pre 记成 11,往后的位置就拿它当「左边最近的 e」。
- 17第一遍扫完了。每个位置都拿到了「到左边最近 e」的距离。注意开头 lov 三个位置还是 ∞,因为它们左边根本没有 e。这正是要扫第二遍的原因。
- 18从右往左走到下标 11,又是 e,把 suf 记成 11,它本身答案就是 0。
- 19走到下标 10,右边最近的 e 在 11,右距 1。和第一遍的左距 4 比,取小的:答案 = 1。右边更近,更新成它。
- 20走到下标 9,右边最近的 e 在 11,右距 2。和第一遍的左距 3 比,取小的:答案 = 2。右边更近,更新成它。
- 21走到下标 8,右边最近的 e 在 11,右距 3。和第一遍的左距 2 比,取小的:答案 = 2。左边本来就更近,保持不变。
- 22走到下标 7,右边最近的 e 在 11,右距 4。和第一遍的左距 1 比,取小的:答案 = 1。左边本来就更近,保持不变。
- 23从右往左走到下标 6,又是 e,把 suf 记成 6,它本身答案就是 0。
- 24从右往左走到下标 5,又是 e,把 suf 记成 5,它本身答案就是 0。
- 25走到下标 4,右边最近的 e 在 5,右距 1。和第一遍的左距 1 比,取小的:答案 = 1。左右一样近,取最小还是 1,答案保持不变。
- 26从右往左走到下标 3,又是 e,把 suf 记成 3,它本身答案就是 0。
- 27走到下标 2,右边最近的 e 在 3,右距 1。和第一遍的左距 ∞ 比,取小的:答案 = 1。右边更近,更新成它。
- 28走到下标 1,右边最近的 e 在 3,右距 2。和第一遍的左距 ∞ 比,取小的:答案 = 2。右边更近,更新成它。
- 29走到下标 0,右边最近的 e 在 3,右距 3。和第一遍的左距 ∞ 比,取小的:答案 = 3。右边更近,更新成它。
- 30两遍合起来,每个位置都拿到了「左边最近」和「右边最近」里更小的那个,这就是到最近 e 的真实距离。最终答案 [3, 2, 1, 0, 1, 0, 0, 1, 2, 2, 1, 0],和题目给的输出完全一致。
⚠️ 容易写错的地方
✗ 错:只扫一遍就交答案
✓ 对:必须左右各扫一遍取 min
一遍只能拿到一侧最近的 c,开头或结尾那些位置会算错
✗ 错:第一遍 pre 没遇到 c 时用 0
✓ 对:应记成无穷大 ∞
左边没有 c 就不该有左距,记 0 会把答案压成错的 0
✗ 错:把距离写成带正负的差
✓ 对:距离是 abs,恒为非负
两遍分别用 i 减 pre、suf 减 i,方向保证了都是非负,不要再带符号
完整代码(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 TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def shortestToChar(self, s: str, c: str) -> List[int]:
n = len(s)
ans = [n] * n
pre = -inf
for i, ch in enumerate(s):
if ch == c:
pre = i
ans[i] = min(ans[i], i - pre)
suf = inf
for i in range(n - 1, -1, -1):
if s[i] == c:
suf = i
ans[i] = min(ans[i], suf - i)
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 <optional>
#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;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
vector<int> shortestToChar(string s, char c) {
int n = s.size();
const int inf = 1 << 30;
vector<int> ans(n, inf);
for (int i = 0, pre = -inf; i < n; ++i) {
if (s[i] == c) {
pre = i;
}
ans[i] = min(ans[i], i - pre);
}
for (int i = n - 1, suf = inf; ~i; --i) {
if (s[i] == c) {
suf = i;
}
ans[i] = min(ans[i], suf - i);
}
return ans;
}
};Java
import java.util.*;
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val, TreeNode left, TreeNode right){this.val=val;this.left=left;this.right=right;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
public int[] shortestToChar(String s, char c) {
int n = s.length();
int[] ans = new int[n];
final int inf = 1 << 30;
Arrays.fill(ans, inf);
for (int i = 0, pre = -inf; i < n; ++i) {
if (s.charAt(i) == c) {
pre = i;
}
ans[i] = Math.min(ans[i], i - pre);
}
for (int i = n - 1, suf = inf; i >= 0; --i) {
if (s.charAt(i) == c) {
suf = i;
}
ans[i] = Math.min(ans[i], suf - i);
}
return ans;
}
}复杂度
时间
O(n)
从左扫一遍 + 从右扫一遍,共两遍线性
空间
O(1)
除答案数组外,只用 pre、suf 两个变量(答案数组是必须的输出)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 字符的最短距离 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么一遍扫描不够,非得来回两遍?+
一遍扫描(比如从左到右)只能知道每个位置左边最近的 c 在哪,右边的 c 还没走到、无从得知。可最近的 c 完全可能在右边——尤其开头那几个位置,左边压根没有 c,左距只能先记成无穷大。所以必须再从右扫一遍拿到右距,两个方向取 min 才是真正的最近距离。想压成一遍也有别的思路:先收集所有 c 的下标,再对每个位置二分查左右最近的 c,但那是 O(n log m),反而比两遍 O(n) 慢,不划算。
答案数组为什么初始成串长 n,而不是 0 或某个大常数?+
n 是这题里任何距离的天然上界:串里任意两个下标最远也就差 n 减 1,距离到不了 n。把每格先填成 n,相当于放一个「肯定会被真实距离比下去」的占位值,之后两遍扫描每次取 min 都能安全地把它换掉。填 0 会出错——0 是最小距离,后面任何真实距离都比不过它,那一格就永远改不动。填一个更大的常数当然也行,但 n 已经够用又不担心溢出,最省心。
如果字符 c 在字符串里一次都没出现,会怎样?+
本题保证 c 至少出现一次,正常不用管这种情况。但要是题目放开这个前提,两遍扫描里 pre、suf 都不会被更新,答案数组会原样停在初始的上界 n,既不是 0 也不是任何真实距离。真遇到这种约束,应先和面试官对一下口径:是返回空数组、还是把每位都当作无穷远,按约定处理,别默认代码算出来的 n 就是想要的答案。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 字符的最短距离 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。