|
| 1 | +# [1032. 字符流](https://leetcode.cn/problems/stream-of-characters/) |
| 2 | + |
| 3 | +- 标签:设计、字典树、数组、字符串、数据流 |
| 4 | +- 难度:困难 |
| 5 | + |
| 6 | +## 题目链接 |
| 7 | + |
| 8 | +- [1032. 字符流 - 力扣](https://leetcode.cn/problems/stream-of-characters/) |
| 9 | + |
| 10 | +## 题目大意 |
| 11 | + |
| 12 | +**描述**:设计一个算法:接收一个字符流,并检查这些字符的后缀是否是字符串数组 $words$ 中的一个字符串。 |
| 13 | + |
| 14 | +**要求**: |
| 15 | + |
| 16 | +按下述要求实现 StreamChecker 类: |
| 17 | + |
| 18 | +- `StreamChecker(String[] words):` 构造函数,用字符串数组 $words$ 初始化数据结构。 |
| 19 | +- `boolean query(char letter):` 从字符流中接收一个新字符,如果字符流中的任一非空后缀能匹配 $words$ 中的某一字符串,返回 $True$;否则,返回 $False$。 |
| 20 | + |
| 21 | +**说明**: |
| 22 | + |
| 23 | +- $1 \le words.length \le 2000$。 |
| 24 | +- $1 <= words[i].length <= 200$。 |
| 25 | +- $words[i]$ 由小写英文字母组成。 |
| 26 | +- $letter$ 是一个小写英文字母。 |
| 27 | +- 最多调用查询 $4 \times 10^4$ 次。 |
| 28 | + |
| 29 | +**示例**: |
| 30 | + |
| 31 | +- 示例 1: |
| 32 | + |
| 33 | +```python |
| 34 | +输入: |
| 35 | +["StreamChecker", "query", "query", "query", "query", "query", "query", "query", "query", "query", "query", "query", "query"] |
| 36 | +[[["cd", "f", "kl"]], ["a"], ["b"], ["c"], ["d"], ["e"], ["f"], ["g"], ["h"], ["i"], ["j"], ["k"], ["l"]] |
| 37 | +输出: |
| 38 | +[null, false, false, false, true, false, true, false, false, false, false, false, true] |
| 39 | + |
| 40 | +解释: |
| 41 | +StreamChecker streamChecker = new StreamChecker(["cd", "f", "kl"]); |
| 42 | +streamChecker.query("a"); // 返回 False |
| 43 | +streamChecker.query("b"); // 返回 False |
| 44 | +streamChecker.query("c"); // 返回n False |
| 45 | +streamChecker.query("d"); // 返回 True ,因为 'cd' 在 words 中 |
| 46 | +streamChecker.query("e"); // 返回 False |
| 47 | +streamChecker.query("f"); // 返回 True ,因为 'f' 在 words 中 |
| 48 | +streamChecker.query("g"); // 返回 False |
| 49 | +streamChecker.query("h"); // 返回 False |
| 50 | +streamChecker.query("i"); // 返回 False |
| 51 | +streamChecker.query("j"); // 返回 False |
| 52 | +streamChecker.query("k"); // 返回 False |
| 53 | +streamChecker.query("l"); // 返回 True ,因为 'kl' 在 words 中 |
| 54 | +``` |
| 55 | + |
| 56 | +## 解题思路 |
| 57 | + |
| 58 | +这道题要求设计一个数据结构,能够实时检查字符流中的后缀是否匹配给定的单词集合。由于字符流是动态的,我们需要高效地处理每个新字符的查询。 |
| 59 | + |
| 60 | +### 思路 1:字典树 + 字符串反转 |
| 61 | + |
| 62 | +**问题分析**: |
| 63 | +- 需要检查字符流中的后缀是否匹配单词集合中的任意单词 |
| 64 | +- 字符流是动态添加的,直接存储所有可能的后缀会非常低效 |
| 65 | +- 字典树适合前缀匹配,但我们需要后缀匹配 |
| 66 | + |
| 67 | +**核心思想**: |
| 68 | +将后缀匹配问题转化为前缀匹配问题:将所有单词反转后插入字典树,这样检查后缀就变成了检查前缀。 |
| 69 | + |
| 70 | +**算法步骤**: |
| 71 | +1. **初始化**:将所有单词反转后插入字典树中 |
| 72 | +2. **查询处理**:每次接收到新字符时,将其添加到字符流的前面 |
| 73 | +3. **匹配检查**:在字典树中搜索当前字符流,找到匹配的单词就返回 `True` |
| 74 | + |
| 75 | +**关键优化**: |
| 76 | +- 使用反转的单词构建字典树,将后缀匹配转化为前缀匹配 |
| 77 | +- 在搜索过程中,一旦找到匹配的单词就立即返回,避免不必要的继续搜索 |
| 78 | + |
| 79 | +**示例分析**: |
| 80 | +- 单词集合:`["cd", "f", "kl"]` → 插入字典树:`["dc", "f", "lk"]` |
| 81 | +- 字符流 `"cd"` → 检查 `"dc"` 是否在字典树中 → 匹配成功,返回 `True` |
| 82 | + |
| 83 | + |
| 84 | +### 思路 1:代码 |
| 85 | + |
| 86 | +```python |
| 87 | +class Node: # 字符节点 |
| 88 | + def __init__(self): # 初始化字符节点 |
| 89 | + self.children = dict() # 初始化子节点 |
| 90 | + self.isEnd = False # isEnd 用于标记单词结束 |
| 91 | + |
| 92 | + |
| 93 | +class Trie: # 字典树 |
| 94 | + |
| 95 | + # 初始化字典树 |
| 96 | + def __init__(self): # 初始化字典树 |
| 97 | + self.root = Node() # 初始化根节点(根节点不保存字符) |
| 98 | + |
| 99 | + # 向字典树中插入一个单词 |
| 100 | + def insert(self, word: str) -> None: |
| 101 | + cur = self.root |
| 102 | + for ch in word: # 遍历单词中的字符 |
| 103 | + if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 |
| 104 | + cur.children[ch] = Node() # 建立一个节点,并将其保存到当前节点的子节点 |
| 105 | + cur = cur.children[ch] # 令当前节点指向新建立的节点,继续处理下一个字符 |
| 106 | + cur.isEnd = True # 单词处理完成时,将当前节点标记为单词结束 |
| 107 | + |
| 108 | + # 查找字典树中是否存在一个单词 |
| 109 | + def search(self, word: str) -> bool: |
| 110 | + cur = self.root |
| 111 | + for ch in word: # 遍历单词中的字符 |
| 112 | + if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 |
| 113 | + return False # 直接返回 False |
| 114 | + cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 |
| 115 | + if cur.isEnd: |
| 116 | + return True |
| 117 | + return False |
| 118 | + |
| 119 | + # 查找字典树中是否存在一个前缀 |
| 120 | + def startsWith(self, prefix: str) -> bool: |
| 121 | + cur = self.root |
| 122 | + for ch in prefix: # 遍历前缀中的字符 |
| 123 | + if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 |
| 124 | + return False # 直接返回 False |
| 125 | + cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 |
| 126 | + return cur is not None # 判断当前节点是否为空,不为空则查找成功 |
| 127 | + |
| 128 | +class StreamChecker: |
| 129 | + |
| 130 | + def __init__(self, words: List[str]): |
| 131 | + self.trie = Trie() |
| 132 | + self.stream = "" |
| 133 | + for word in words: |
| 134 | + self.trie.insert(word[::-1]) |
| 135 | + |
| 136 | + def query(self, letter: str) -> bool: |
| 137 | + self.stream = letter + self.stream |
| 138 | + size = len(letter) |
| 139 | + |
| 140 | + return self.trie.search(self.stream) |
| 141 | + |
| 142 | + |
| 143 | + |
| 144 | +# Your StreamChecker object will be instantiated and called as such: |
| 145 | +# obj = StreamChecker(words) |
| 146 | +# param_1 = obj.query(letter) |
| 147 | +``` |
| 148 | + |
| 149 | +### 思路 1:复杂度分析 |
| 150 | + |
| 151 | +- **时间复杂度**: |
| 152 | + - 初始化:$O(m \times n)$,其中 $m$ 是单词数量,$n$ 是单词的平均长度。 |
| 153 | + - 查询:$O(k)$,其中 $k$ 是当前字符流的长度。最坏情况下,每次查询都需要遍历整个字符流。 |
| 154 | +- **空间复杂度**:$O(m \times n)$,字典树的空间复杂度,其中 $m$ 是单词数量,$n$ 是单词的平均长度。 |
| 155 | + |
| 156 | +### 思路 2:AC 自动机 |
| 157 | + |
| 158 | +**问题分析**: |
| 159 | +- 需要处理多模式串匹配问题,适合使用 AC 自动机 |
| 160 | +- 字符流查询频率高,需要优化查询时间复杂度 |
| 161 | +- 不需要存储完整的字符流历史,只需要维护当前匹配状态 |
| 162 | + |
| 163 | +**核心思想**: |
| 164 | +使用 AC 自动机(Aho-Corasick Automaton)进行多模式串匹配: |
| 165 | +1. 将所有单词构建成AC自动机,利用字典树共享公共前缀 |
| 166 | +2. 为每个节点设置失配指针,实现匹配失败时的快速跳转 |
| 167 | +3. 维护当前匹配状态,每次接收新字符时更新状态并检查匹配 |
| 168 | + |
| 169 | +**算法步骤**: |
| 170 | +1. **构建AC自动机**:将所有单词插入字典树,并构建失配指针 |
| 171 | +2. **维护匹配状态**:使用变量记录当前在AC自动机中的位置 |
| 172 | +3. **字符流处理**:每次接收新字符时,沿着AC自动机进行状态转移 |
| 173 | +4. **匹配检测**:检查当前状态及其失配链上是否有单词结尾 |
| 174 | + |
| 175 | +**关键优势**: |
| 176 | +- **时间复杂度优秀**:构建 $O(m)$,查询平均 $O(1)$ |
| 177 | +- **空间效率高**:共享公共前缀,节省存储空间 |
| 178 | +- **适合流式处理**:不需要存储整个字符流历史 |
| 179 | + |
| 180 | +### 思路 2:代码 |
| 181 | + |
| 182 | +```python |
| 183 | +class TrieNode: |
| 184 | + def __init__(self): |
| 185 | + self.children = {} # 子节点,key 为字符,value 为 TrieNode |
| 186 | + self.fail = None # 失配指针,指向当前节点最长可用后缀的节点 |
| 187 | + self.is_end = False # 是否为某个模式串的结尾 |
| 188 | + self.word = "" # 如果是结尾,存储完整的单词 |
| 189 | + |
| 190 | +class AC_Automaton: |
| 191 | + def __init__(self): |
| 192 | + self.root = TrieNode() # 初始化根节点 |
| 193 | + |
| 194 | + def add_word(self, word): |
| 195 | + """ |
| 196 | + 向Trie树中插入一个模式串 |
| 197 | + """ |
| 198 | + node = self.root |
| 199 | + for char in word: |
| 200 | + if char not in node.children: |
| 201 | + node.children[char] = TrieNode() # 新建子节点 |
| 202 | + node = node.children[char] |
| 203 | + node.is_end = True # 标记单词结尾 |
| 204 | + node.word = word # 存储完整单词 |
| 205 | + |
| 206 | + def build_fail_pointers(self): |
| 207 | + """ |
| 208 | + 构建失配指针(fail指针),采用BFS广度优先遍历 |
| 209 | + """ |
| 210 | + from collections import deque |
| 211 | + queue = deque() |
| 212 | + # 1. 根节点的所有子节点的 fail 指针都指向根节点 |
| 213 | + for child in self.root.children.values(): |
| 214 | + child.fail = self.root |
| 215 | + queue.append(child) |
| 216 | + |
| 217 | + # 2. 广度优先遍历,依次为每个节点建立 fail 指针 |
| 218 | + while queue: |
| 219 | + current = queue.popleft() |
| 220 | + for char, child in current.children.items(): |
| 221 | + # 从当前节点的 fail 指针开始,向上寻找有无相同字符的子节点 |
| 222 | + fail = current.fail |
| 223 | + while fail and char not in fail.children: |
| 224 | + fail = fail.fail |
| 225 | + # 如果找到了,child的fail指针指向该节点,否则指向根节点 |
| 226 | + child.fail = fail.children[char] if fail and char in fail.children else self.root |
| 227 | + queue.append(child) |
| 228 | + |
| 229 | +class StreamChecker: |
| 230 | + def __init__(self, words): |
| 231 | + self.ac = AC_Automaton() |
| 232 | + # 将所有单词插入AC自动机 |
| 233 | + for word in words: |
| 234 | + self.ac.add_word(word) |
| 235 | + # 构建失配指针 |
| 236 | + self.ac.build_fail_pointers() |
| 237 | + # 当前匹配状态 |
| 238 | + self.current_node = self.ac.root |
| 239 | + |
| 240 | + def query(self, letter): |
| 241 | + """ |
| 242 | + 处理新字符,检查是否匹配到任何单词 |
| 243 | + """ |
| 244 | + # 如果当前节点没有该字符的子节点,则沿 fail 指针向上跳转 |
| 245 | + while self.current_node is not self.ac.root and letter not in self.current_node.children: |
| 246 | + self.current_node = self.current_node.fail |
| 247 | + |
| 248 | + # 如果有该字符的子节点,则转移到该子节点 |
| 249 | + if letter in self.current_node.children: |
| 250 | + self.current_node = self.current_node.children[letter] |
| 251 | + # 否则仍然停留在根节点 |
| 252 | + |
| 253 | + # 检查当前节点以及沿 fail 链上的所有节点是否为单词结尾 |
| 254 | + temp = self.current_node |
| 255 | + while temp is not self.ac.root: |
| 256 | + if temp.is_end: |
| 257 | + return True # 找到匹配的单词 |
| 258 | + temp = temp.fail |
| 259 | + |
| 260 | + return False # 没有找到匹配的单词 |
| 261 | + |
| 262 | + |
| 263 | +# Your StreamChecker object will be instantiated and called as such: |
| 264 | +# obj = StreamChecker(words) |
| 265 | +# param_1 = obj.query(letter) |
| 266 | +``` |
| 267 | + |
| 268 | +### 思路 2:复杂度分析 |
| 269 | + |
| 270 | +- **时间复杂度**: |
| 271 | + - 初始化:$O(m)$,其中 $m$ 是所有单词的总长度。构建字典树和失配指针都是线性时间。 |
| 272 | + - 查询:$O(1)$ 平均情况,$O(k)$ 最坏情况,其中 $k$ 是单词的最大长度。由于失配指针的存在,大部分情况下可以快速跳转。 |
| 273 | +- **空间复杂度**:$O(m)$,其中 $m$ 是所有单词的总长度。AC自动机的空间复杂度主要由字典树决定。 |
0 commit comments