字符串中的额外字符 图解题解
这道题到底在问什么
- 输入
- s = "leetscode", dictionary = ["leet","code","leetcode"]
- 输出
- 1
- 输入
- s = "sayhelloworld", dictionary = ["hello","world"]
- 输出
- 3
最优解:为什么这么做
一句话答案:LeetCode 2707 字符串中的额外字符用一维动态规划:f[i] 记前 i 个字符最少额外,每格在「第 i 个字符算额外的 f[i-1]+1」与「词典词在 i 收尾接 f[j]」间取最小,时间 O(n²·L)、空间 O(n+M)。
「额外字符」到底数的是哪些字符
给字符串 s 和字典 dictionary,把 s 切成若干互不重叠的子串,每段都得是字典单词;塞不进单词的字符就是额外字符,求最少剩几个。题面两例:leetscode 切出 leet、code,中间的 s 落单,额外 1 个;sayhelloworld 切出 hello、world,开头 say 落单,答案 3。
2^8 种切法试不完,贪心抓长词又错在哪
leetscode 有 8 个字符间隙,每个切或不切,光切法就 2^8=256 种,枚举不动。贪心每次抓能配的最长词也靠不住:长词可能压着两个短词的接缝,占完反塞不进更多。字符归哪个词牵动整串,只能切小问题、从前缀问起。
f[i] 为什么定成前 i 个字符的最少额外
定义 f[i]:s 前 i 个字符最少剩几个额外字符——一维动态规划(把「前 i 个字符怎么切最省」存进数组,更长前缀直接取)。f[0] 是空串、零额外,f[0]=0 是 base case(不用再往下拆的最简起点),答案 f[n]。它是单词拆分(LeetCode 139)的计费版:拆不完的字符改成记一分。
第 i 个字符只有两种命运,取最小为什么一个不落
盯住第 i 个字符(从 1 数起),它只有两种命运。要么谁也不归、自己当额外,前 i 个最优=前 i-1 个最优加一分,f[i]=f[i-1]+1,这一行兜住;要么它是某词典词的末字符,词覆盖 s[j:i](第 j 个字符后到第 i 个收尾),这段零额外,f[i] 接 f[j]。
实现先把字典塞进哈希集合(O(1) 查某段是不是单词),对每个 i 枚举起点 j 从 0 到 i-1,截 s[j:i] 查,命中且 f[j] 更小就更新——这就是转移(由已算好的小格子推出当前格)。取最小填到 f[n] 即全局最少。命中不等于采纳:几个词同在 i 收尾,挑 f[j] 最小的接。
leetscode 的 f 表,哪两格是靠单词压下来的
九个字符,f 开十格,f[0]=0。前三格没词命中,保底:f[1]=1、f[2]=2、f[3]=3。f[4] 保底给 f[3]+1=4;j 从 0 起 s[0:4] 正是 leet,接 f[0]=0,f[4] 压到 0。
第 5-8 字符是 s、c、o、d,没词收尾,接着保底:f[5]=1、f[6]=2、f[7]=3、f[8]=4。f[9] 保底 f[8]+1=5;j 从 5 起 s[5:9] 正是 code,接 f[5]=1,f[9] 压到 1。题面输出 1:中间的 s 唯一额外。
漏写保底那一行,f[9] 为什么缩成 0
参考代码把 f 初始化成全 0,每轮第一件事是 f[i]=f[i-1]+1;这行一漏,没词命中的格子全停在 0,f[9] 缩成 0。方向也别错:内层截的必须是以第 i 位收尾的 s[j:i],从 i 往后截整张表就接不上。
复杂度:外层 i、内层 j 约 n² 对,每对截子串比对,截取加哈希 O(L)(L 是子串最长长度),总时间 O(n²·L),最坏 O(n³);空间 O(n+M)。整串恰是一个词典词,命中一次 f[n] 清成 0。一个词都配不上,每格默认加一,f[n]=n。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记牢这一句:f[i] 只有两种来路,要么让第 i 个字符当额外字符,在 f[i-1] 上加一;要么发现有单词正好在这里结尾,直接接上那个单词开头前的 f[j]。两者取最小。下面从 f[0] 开始一格一格填。
- 4地基:f[0]=0先看最左边一列,列头是 0,表示只看前 0 个字符,也就是空串。空串里一个字符都没有,自然没有额外字符,所以 f[0] 直接定成 0。这一格是整张表的地基,后面每个 f 值都要从它一步步接出来。上行是 s 的五个字符 c o d e x,列头 1 到 5 对应看前几个字符。
- 5前 1 位 · 暂定 1现在算 f[1],看前 1 个字符。第一手最保守:假设第 1 个字符 c 谁都用不上,是个额外字符。那就在前 0 个字符的最优解 f[0] 等于 0 上再加一个额外字符,f[1] 先暂定为 1。这只是保底值,接下来看能不能靠单词把它压得更小。
- 6不是单词,跳过把起点放在第 0 列,取出这一段 "c"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[1] 还是 1。继续把起点往右挪,试下一段。
- 7前 2 位 · 暂定 2现在算 f[2],看前 2 个字符。第一手最保守:假设第 2 个字符 o 谁都用不上,是个额外字符。那就在前 1 个字符的最优解 f[1] 等于 1 上再加一个额外字符,f[2] 先暂定为 2。这只是保底值,接下来看能不能靠单词把它压得更小。
- 8不是单词,跳过把起点放在第 0 列,取出这一段 "co"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[2] 还是 2。继续把起点往右挪,试下一段。
- 9不是单词,跳过把起点放在第 1 列,取出这一段 "o"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[2] 还是 2。继续把起点往右挪,试下一段。
- 10前 3 位 · 暂定 3现在算 f[3],看前 3 个字符。第一手最保守:假设第 3 个字符 d 谁都用不上,是个额外字符。那就在前 2 个字符的最优解 f[2] 等于 2 上再加一个额外字符,f[3] 先暂定为 3。这只是保底值,接下来看能不能靠单词把它压得更小。
- 11不是单词,跳过把起点放在第 0 列,取出这一段 "cod"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[3] 还是 3。继续把起点往右挪,试下一段。
- 12不是单词,跳过把起点放在第 1 列,取出这一段 "od"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[3] 还是 3。继续把起点往右挪,试下一段。
- 13不是单词,跳过把起点放在第 2 列,取出这一段 "d"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[3] 还是 3。继续把起点往右挪,试下一段。
- 14前 4 位 · 暂定 4现在算 f[4],看前 4 个字符。第一手最保守:假设第 4 个字符 e 谁都用不上,是个额外字符。那就在前 3 个字符的最优解 f[3] 等于 3 上再加一个额外字符,f[4] 先暂定为 4。这只是保底值,接下来看能不能靠单词把它压得更小。
- 15命中,压到 0把起点往回挪到第 0 列,取出这一段子串 "code"。它正好是字典里的单词,这一段零额外字符。于是可以把它接在 f[0] 等于 0 后面,得到 0。这个值比刚才的暂定值更小,f[4] 就被压到 0,两端和这段字符一起染绿表示命中。
- 16命中但落选再把起点挪到第 1 列,这段 "ode" 也是字典里的单词。不过接上 f[1] 等于 1 的结果并不比当前的 f[4] 等于 0 更小,所以取最小值时它落选,f[4] 保持 0。命中不等于一定采纳,还要看谁的 f 更小。
- 17不是单词,跳过把起点放在第 2 列,取出这一段 "de"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[4] 还是 0。继续把起点往右挪,试下一段。
- 18不是单词,跳过把起点放在第 3 列,取出这一段 "e"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[4] 还是 0。继续把起点往右挪,试下一段。
- 19前 5 位 · 暂定 1现在算 f[5],看前 5 个字符。第一手最保守:假设第 5 个字符 x 谁都用不上,是个额外字符。那就在前 4 个字符的最优解 f[4] 等于 0 上再加一个额外字符,f[5] 先暂定为 1。这只是保底值,接下来看能不能靠单词把它压得更小。
- 20不是单词,跳过把起点放在第 0 列,取出这一段 "codex"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[5] 还是 1。继续把起点往右挪,试下一段。
- 21不是单词,跳过把起点放在第 1 列,取出这一段 "odex"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[5] 还是 1。继续把起点往右挪,试下一段。
- 22不是单词,跳过把起点放在第 2 列,取出这一段 "dex"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[5] 还是 1。继续把起点往右挪,试下一段。
- 23不是单词,跳过把起点放在第 3 列,取出这一段 "ex"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[5] 还是 1。继续把起点往右挪,试下一段。
- 24不是单词,跳过把起点放在第 4 列,取出这一段 "x"。它不在字典里,不能当作一个单词整段用掉,这条路走不通,直接跳过,f[5] 还是 1。继续把起点往右挪,试下一段。
- 25答案 = 1整张表填满,最后一列 f[5] 就是答案。它等于 1,意思是把 codex 最优分割后只剩 1 个额外字符。回头看这条最优路线:code 从第 0 位接到第 4 位这一段是单词、零额外,末尾那个 x 谁也覆盖不了,是唯一的额外字符,正好 1 个。
⚠️ 容易写错的地方
✗ 错:贪心地每次都切最长的单词
✓ 对:用 f[i] 对所有命中的 j 取最小
最长匹配不一定最优,可能切短单词反而给后面留出更好分法,必须让动态规划枚举所有结尾在此的单词
✗ 错:f[i] 只写命中时的更新,忘了 f[i-1]+1
✓ 对:先写 f[i]=f[i-1]+1 保底再尝试压小
某些位置根本没有单词在此结尾,若不保底,f[i] 会取不到值或被漏算,额外字符必须允许存在
✗ 错:把子串比对写成从 i 往后取
✓ 对:取的是结尾在第 i 位的子串 s[j..i-1]
f[i] 管的是前 i 个字符,只有以第 i 位结尾的单词才能接到 f[i],起点 j 在左侧滑动
✗ 错:命中单词就立刻采纳、停止枚举
✓ 对:命中也要比一比 f[j] 是否更小
同一个 i 可能有多个单词结尾,不同起点的 f[j] 不同,要在其中取最小才是最优
完整代码(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 minExtraChar(self, s: str, dictionary: List[str]) -> int:
ss = set(dictionary)
n = len(s)
f = [0] * (n + 1)
for i in range(1, n + 1):
f[i] = f[i - 1] + 1
for j in range(i):
if s[j:i] in ss and f[j] < f[i]:
f[i] = f[j]
return f[n]C++
#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 minExtraChar(string s, vector<string>& dictionary) {
unordered_set<string> ss(dictionary.begin(), dictionary.end());
int n = s.size();
int f[n + 1];
f[0] = 0;
for (int i = 1; i <= n; ++i) {
f[i] = f[i - 1] + 1;
for (int j = 0; j < i; ++j) {
if (ss.count(s.substr(j, i - j))) {
f[i] = min(f[i], f[j]);
}
}
}
return f[n];
}
};Java
import java.util.*;
class Solution {
public int minExtraChar(String s, String[] dictionary) {
Set<String> ss = new HashSet<>();
for (String w : dictionary) {
ss.add(w);
}
int n = s.length();
int[] f = new int[n + 1];
f[0] = 0;
for (int i = 1; i <= n; ++i) {
f[i] = f[i - 1] + 1;
for (int j = 0; j < i; ++j) {
if (ss.contains(s.substring(j, i))) {
f[i] = Math.min(f[i], f[j]);
}
}
}
return f[n];
}
}复杂度
时间
O(n² · L)
n 是 s 的长度。外层 i 走 n 步,内层 j 又到 n 步,一共约 n 的平方对 (i,j);每一对都要截出子串再丢进哈希集合比对,子串长度最长到 L,截取加哈希是 O(L)。所以是 O(n 的平方乘 L),最坏可看作 O(n 的三次方)。用字典树把「以 i 结尾的单词」查询优化后可降到 O(n 的平方)
空间
O(n + M)
按峰值算。f 数组占 O(n);字典哈希集合把所有单词存下来,占 O(M),M 是字典里全部单词的总字符数。两者相加,不随 n 平方增长
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 字符串中的额外字符 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这题和单词拆分(LeetCode 139)是什么关系?+
骨架同一条:都定义「前 i 个字符」的子问题,都枚举以 i 收尾的词典词往回接。139 问能不能恰好拆完,f[i] 是真假值,转移是「任一命中即真」;本题允许拆不完但要计费,f[i] 是最少额外字符数,转移换成「保底 f[i-1]+1 与所有命中的 f[j] 取最小」。139 会做,本题就是把布尔或换成取最小、再补一行保底。LeetCode 140 单词拆分 II 是同族切分,那题要求所有切法。
f 为什么要开 n+1 格,f[0]=0 少了会出什么错?+
f[0] 是空串这个 base case(最简单、不用再往下拆的起点),词从串头开始命中时全靠它记住「前面零额外」,比如 leetscode 里 leet 接的就是 f[0]=0。f 若只开 n 格,f[n] 没地方放,答案本身就丢了;f[0] 若没归零,所有从串头整段命中的清零都会跟着偏,leetscode 就算不出 1。
内层每次都截子串再哈希,太慢了能怎么优化?+
两个方向。一是剪枝:起点 j 只需要扫到 i 减去最长词长的位置,比最长词还长的子串不可能在字典里,截了也是白哈希。二是把字典建成字典树(Trie,按字符一层层存单词的树),枚举以 i 收尾的词时沿树逐字符走、走不动就停,省掉反复截串和整串哈希,枚举部分可压到 O(n²)。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 字符串中的额外字符 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。