词典中最长的单词 图解题解
这道题到底在问什么
- 输入
- words = ["w","wo","wor","worl","world"]
- 输出
- "world"
- 输入
- words = ["a","banana","app","appl","ap","apply","apple"]
- 输出
- "apple"
先想最直接的笨办法
准备一个集合 S,专门装「已经确认能逐字构建出来」的词,现在它是空的。答案 ans 也先设成空串。接下来从最短的词开始,一个一个判断、一个一个往 S 里收。(动画第 6 步)
最优解:为什么这么做
一句话答案:LeetCode 720 词典中最长的单词:找每个前缀都在词典里的最长词,把所有词插进 Trie 字典树、逐词检查沿路每一步是不是词尾,等长取字典序最小,时间与空间都是 O(L)。
词典里找能逐字母长出来的最长词,返回什么
给一个字符串数组 words。要找一个最长的词 w:它去掉末尾若干字母后,每一个前缀都还在 words 里。多个最长的取字典序最小,一个都没有就返回空串。题面例子 words=["w","wo","wor","worl","world"] 返回 "world";words=["a","banana","app","appl","ap","apply","apple"] 返回 "apple"。
词在不在词典,和能不能拼出来是两码事
把词典塞进哈希集合、看目标词在不在里面,答的却是错的问题。"world" 在词典里,不代表它能逐字母拼出来:只要 "wor" 缺席,就没法从 "wo" 走到 "worl","world" 照样拼不出。真正要查的是它每一个前缀 w、wo、wor、worl 都在词典里,缺一步整条链就断了。
一个词合法,就是沿路每个前缀都是词典里的词
把要求说死:w 合法,当且仅当从空串起,一个字母一个字母往后添,每添一步得到的前缀都恰好是词典里的某个词,一步都不能落空。
能存下所有词、又能顺着字母快速查前缀——这个逐前缀往下探的结构就是 Trie 字典树,也就是把有公共前缀的词合并成一棵树、沿树往下每一步添一个字母。把所有词插进去,每个词末字母的节点打上"是词尾"的标记。查 w 时顺它的字母从根往下走,每落到一个节点就看有没有词尾标记,只要有一步没有,这层前缀就不是词典里的词,w 当场判死。
逐词走一遍 Trie,长度和字典序一起挑答案
建完树,拿每个词走一遍:全程每个落脚节点都带词尾标记,才算能构建。通过的词和当前答案 ans 比长短,比 ans 长(len(ans) < len(w))就换成它;一样长则留字典序小的,即 ans > w 时换成 w。不依赖排序,扫完所有词,ans 就是最长、同长里字典序最小的那个。
拿题面的 apple 那组词沿字典树逐词往下探
先把七个词都插进 Trie,再逐个查。"a" 走一步落到带词尾标记的节点,通过,ans="a"。"banana" 第一步的 "b" 节点没有词尾标记,判死。"app" 走 a、ap、app 三步全带标记,比 "a" 长,ans="app";"appl" 再多一步也带标记,ans="appl";"ap" 能过但只有 2 长,不换;"apply" 五步全带标记,长 5,ans="apply"。
最后是 "apple",五步也全带标记,长度同样是 5。比字典序:前四位 appl 相同,第五位 e < y,"apple" < "apply",ans > w 成立,答案换成 "apple"。返回 "apple",长度 5。
为什么是 O(L),三个边界先想清
设 L 是所有词的总字符数。建 Trie 时每个字母插一次,逐词查询又把每个字母走一遍,两趟都是 O(L);节点数最多和总字符数同阶,空间也是 O(L)。等长比字典序、词尾标记别漏是两处易踩的坑。全是单字母词时都能构建,答案取里面字典序最小的。没有单字母词打底,谁都拼不出第一步,ans 始终是空串。单字母词去掉末位是空串,空前缀天然合法,永远通过。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记住这句:「排序让前缀先到,集合判前缀可达」。下面每一帧都在套它。本题演示数据答案是 car。
- 4先看原始词典:cat、c、car、dog、ca,顺序是乱的。这样扫的时候,可能先碰到 car 却还没确认 ca、c,没法判断它能不能构建。所以第一件事是排序。
- 5按「长度从短到长、同长度按字典序」排好后变成 c、ca、car、cat、dog。短的排前面,保证了一个关键性质:任何一个词的前缀,长度都更短,一定已经排在它前面、先被处理过。
- 6准备一个集合 S,专门装「已经确认能逐字构建出来」的词,现在它是空的。答案 ans 也先设成空串。接下来从最短的词开始,一个一个判断、一个一个往 S 里收。
- 7轮到第 0 个词 "c",它只有一个字母。长度为 1 的词去掉末位就是空串。
- 8空前缀天然算在集合里,所以单字母词永远能构建,"c" 直接通过,准备收进集合。
- 9把 "c" 收进集合 S(绿色),它比当前答案更长,于是答案更新成 "c"。
- 10轮到第 1 个词 "ca"。把末尾的 "a" 去掉,得到前缀 "c"。只要 "c" 已经在集合 S 里,就说明 "ca" 是在一个可构建词的基础上加一个字母得到的,那它也能构建。
- 11去集合里找前缀 "c",找到了!高亮的就是它。这说明 "ca" 的每一步前缀都在词典里、都能拼出来,所以 "ca" 也能构建。
- 12把 "ca" 收进集合 S(绿色),它比当前答案更长,于是答案更新成 "ca"。
- 13轮到第 2 个词 "car"。把末尾的 "r" 去掉,得到前缀 "ca"。只要 "ca" 已经在集合 S 里,就说明 "car" 是在一个可构建词的基础上加一个字母得到的,那它也能构建。
- 14去集合里找前缀 "ca",找到了!高亮的就是它。这说明 "car" 的每一步前缀都在词典里、都能拼出来,所以 "car" 也能构建。
- 15把 "car" 收进集合 S(绿色),它比当前答案更长,于是答案更新成 "car"。
- 16轮到第 3 个词 "cat"。把末尾的 "t" 去掉,得到前缀 "ca"。只要 "ca" 已经在集合 S 里,就说明 "cat" 是在一个可构建词的基础上加一个字母得到的,那它也能构建。
- 17去集合里找前缀 "ca",找到了!高亮的就是它。这说明 "cat" 的每一步前缀都在词典里、都能拼出来,所以 "cat" 也能构建。
- 18把 "cat" 收进集合 S。它和当前答案 "car" 一样长,但字典序不比 "car" 更小,所以答案保持 "car" 不变。这正是「同长取字典序最小」的体现。
- 19轮到第 4 个词 "dog"。把末尾的 "g" 去掉,得到前缀 "do"。只要 "do" 已经在集合 S 里,就说明 "dog" 是在一个可构建词的基础上加一个字母得到的,那它也能构建。
- 20去集合里找前缀 "do",没有这一行。也就是说 "do" 自己都拼不出来,那建立在它之上的 "dog" 自然也拼不出来。"dog" 标红,判定不可构建。
- 21"dog" 不可构建,直接跳过,不放进集合,答案也不动。它变灰,表示彻底出局。继续看下一个词。
- 22扫完一遍。能构建的词是 c、ca、car、cat 四个,dog 因为缺前缀出局。最长的是长度 3 的 car 和 cat 两个,到了「同长取字典序最小」的关键一步。
- 23car 和 cat 都长 3,比字典序:前两位都是 ca,第三位 r 比 t 小,所以 car 更小。因为我们排序时 car 排在 cat 前面、先被收下当答案,后来的 cat 同长又不更小就没顶替它。最终答案是 car。
⚠️ 容易写错的地方
✗ 错:不排序直接扫,碰到长词时前缀还没确认
✓ 对:先按长度升序排序,保证前缀先处理
排序后任何词的前缀都更短、一定排在它前面
✗ 错:同长度多个答案时随便返回一个
✓ 对:同长取字典序最小的
题目明确要求字典序最小,排序后先到的同长词正好最小
✗ 错:只看「前缀整体在不在词典」而漏判中间前缀
✓ 对:要求每一个前缀都在词典里
逐字符构建意味着 w 的每个长度的前缀都必须存在
完整代码(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 Trie:
def __init__(self):
self.children: List[Optional[Trie]] = [None] * 26
self.is_end = False
def insert(self, w: str):
node = self
for c in w:
idx = ord(c) - ord("a")
if node.children[idx] is None:
node.children[idx] = Trie()
node = node.children[idx]
node.is_end = True
def search(self, w: str) -> bool:
node = self
for c in w:
idx = ord(c) - ord("a")
if node.children[idx] is None:
return False
node = node.children[idx]
if not node.is_end:
return False
return True
class Solution:
def longestWord(self, words: List[str]) -> str:
trie = Trie()
for w in words:
trie.insert(w)
ans = ""
for w in words:
if trie.search(w) and (
len(ans) < len(w) or (len(ans) == len(w) and ans > w)
):
ans = w
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 Trie {
public:
Trie* children[26] = {nullptr};
bool isEnd = false;
void insert(const string& w) {
Trie* node = this;
for (char c : w) {
int idx = c - 'a';
if (node->children[idx] == nullptr) {
node->children[idx] = new Trie();
}
node = node->children[idx];
}
node->isEnd = true;
}
bool search(const string& w) {
Trie* node = this;
for (char c : w) {
int idx = c - 'a';
if (node->children[idx] == nullptr || !node->children[idx]->isEnd) {
return false;
}
node = node->children[idx];
}
return true;
}
};
class Solution {
public:
string longestWord(vector<string>& words) {
Trie trie;
for (const string& w : words) {
trie.insert(w);
}
string ans = "";
for (const string& w : words) {
if (trie.search(w) && (ans.length() < w.length() || (ans.length() == w.length() && w < ans))) {
ans = w;
}
}
return ans;
}
};Java
import java.util.*;
class Trie {
private Trie[] children = new Trie[26];
private boolean isEnd = false;
public void insert(String w) {
Trie node = this;
for (char c : w.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) {
node.children[idx] = new Trie();
}
node = node.children[idx];
}
node.isEnd = true;
}
public boolean search(String w) {
Trie node = this;
for (char c : w.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null || !node.children[idx].isEnd) {
return false;
}
node = node.children[idx];
}
return true;
}
}
class Solution {
public String longestWord(String[] words) {
Trie trie = new Trie();
for (String w : words) {
trie.insert(w);
}
String ans = "";
for (String w : words) {
if (trie.search(w)
&& (ans.length() < w.length()
|| (ans.length() == w.length() && w.compareTo(ans) < 0))) {
ans = w;
}
}
return ans;
}
}复杂度
时间
O(L)
L 为所有词总字符数:Trie 建树与逐词查询各扫一遍字符
空间
O(L)
Trie 节点数最多与总字符数同阶
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 词典中最长的单词 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
不建 Trie,有更好写的等价思路吗?+
有。把所有词按长度从短到长排序、同长按字典序,准备一个集合装"已确认能构建"的词。逐个处理时,把当前词去掉末位得到前缀,只要前缀在集合里就说明它能构建,收进集合、按更长或同长更小的规则更新答案。排序保证了任何词的前缀更短、一定排在它前面、先被处理,所以查前缀时它必已入集合。这条路和 Trie 等价,多一步排序、时间是 O(L log n) 级别,但更直观好写。
判断时只查"去掉末位的那一层前缀",中间更短的前缀不会漏吗?+
不会。你查的那个前缀,如果它能构建,是因为它自己早先也被同样地查过、收进了集合——而它入集合时,又查过它自己去掉末位的前缀,一层一层往回递推,最短一直递到单字母词的空前缀。所以只查一层前缀在不在,背后已经把所有更短前缀都担保过了。
为什么单字母词总能通过?+
一个词能构建的条件是它每个前缀都在词典里。单字母词去掉末位就是空串,而空串、也就是长度为 0 的前缀,不需要任何词来支撑、天然算合法。所以只要词典里有某个单字母词,它就一定能构建,也正是所有更长构建链的起点——没有任何单字母词打底,整个词典就一个都构建不出来,只能返回空串。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 词典中最长的单词 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。