C语言实现Trie回溯搜索:LeetCode 211通配符匹配详解
发布时间:2026/9/10 4:07:43
分类:文化教育
浏览:1234

1. 题目到底在考什么一个 . 把 Trie 查询从查表变成了搜索先说一个反直觉的结论LeetCode 211 真正的难点不是 Trie 的插入而是 . 通配符下的回溯搜索更反直觉的是用 C 语言实现反而比 Python 更容易看透这题的递归本质。先把这个数据结构的要求说清楚。你要实现一个WordDictionary里面有两个方法addWord(word)往字典里添加一个单词word只含小写字母。search(pattern)判断字典中是否存在某个单词能匹配patternpattern中可能出现..可以匹配任意一个小写字母。举个例子依次添加bad、dad、mad之后search(pad)返回falsesearch(.ad)返回truesearch(b..)返回true。注意这里有个很容易忽略的语义search要求“完全匹配”不是“前缀匹配”。也就是说search(ba)对于刚才的字典应该返回false因为没有任何一个已添加单词恰好等于ba。如果只考虑addWord和普通字符的search用哈希集合把所有单词存起来就够了查找 O(1)。问题是.一出现哈希集合就尴尬了你不能通过一个 hash 直接算出.ad是否在集合里只能把集合里的每个单词都拉出来和一个带.的模式做一次逐字符匹配。假设有 N 个单词平均长度 L一次search最坏就是 O(N*L)。原题数据范围里最多会有 10^4 次addWord和search调用单词多、查询多的时候这个耗时根本扛不住。前缀树Trie解决这个问题的思路完全不同。Trie 不关心“有哪些完整单词”而关心“字母之间怎么衔接”。搜索普通字符串时你可以顺着树上的指针一级一级往下走搜索带.的 pattern 时遇到.其实就是在问当前这一层有哪些孩子分支存在只要有一个分支能继续走到底就算匹配。换句话说Trie 天然把通配符搜索变成了“沿着存在的边做深度优先搜索”而不是“暴力枚举所有单词”。这也是为什么这题的标准解法是 Trie 回溯搜索而不是哈希集合。1.1 先画一棵 Trie 就全懂了把bad、dad、mad插入 Trie 后根节点有三个孩子b、d、m。每个孩子下面再延伸出a - d。搜索.ad时从根节点开始第一个字符是.于是你尝试根节点的三个孩子b分支能走到badd分支能走到dadm分支能走到mad三个分支的后续两个字符都是ad所以这三个分支都匹配。任意一个分支成功整体就返回true。这就是回溯搜索的核心遇到.时不是只走一条路而是把当前节点所有非空孩子都尝试一遍任何一个孩子能完成剩余匹配就算成功。C 语言里“尝试多个孩子”最自然的实现就是递归。1.2 哈希集合方案到底输在哪可能有人会说LeetCode 上很多用哈希集合的题解也过了为什么非要 Trie因为题目给出的数据量不算大单词长度限制在 25addWord和search的调用次数是 10^4 级别O(N*L) 的暴力确实能在时限内通过。但这是一道“数据结构设计”题面试官真正想考察的是你有没有意识到频繁的带.搜索会让哈希方案退化。你可以反问自己一句如果addWord调用 10 万次search再调用 10 万次每次search都遍历一遍全部单词还能过吗显然不能。而 Trie 方案中search的代价只和模式串长度以及树中实际存在的分支数量有关和全局单词总数 N 并没有直接的线性关系。2. C 语言里的 Trie 节点设计指针数组、calloc 和 is_end 标记Trie 在 C 语言里的经典写法是#include stdbool.h #include stdlib.h typedef struct TrieNode { struct TrieNode* children[26]; bool is_end; } TrieNode; typedef struct { TrieNode* root; } WordDictionary;这里的每个节点代表一个“字符位置”children[i]指向下一个字符节点下标 0 到 25 分别对应a到z。2.1 为什么要用指针数组而不是直接嵌套结构体新手最容易犯的错是把节点定义成typedef struct TrieNode { struct TrieNode children[26]; // 错误 bool is_end; } TrieNode;这会导致结构体无限递归编译都过不了。正确做法是用指针数组指针可以为 NULL表示这个孩子分支不存在只有插入时遇到 NULL 才动态分配新节点。这样每个节点占用的内存 26 个指针 1 个 bool按 64 位系统算是 26 * 8 1 209 字节考虑内存对齐后通常是 216 字节。虽然不小但 Trie 只在“单词总字符数”规模上分配节点题目里 10^4 次调用、单词长度 25最坏也就二十多万个节点内存完全够用。2.2 calloc 比 malloc 更适合分配 Trie 节点如果你用malloc分配节点malloc不会清零内存children数组里是野指针后面判断if (children[idx] NULL)就会失效。所以要么在malloc后用memset全部清零要么直接用callocTrieNode* createNode(void) { return (TrieNode*)calloc(1, sizeof(TrieNode)); }calloc会自动把整块内存清零省得手动memset也不容易漏。这个细节在本地写代码时特别重要我见过不少人在 LeetCode 上能过拿到本机跑就崩查半天发现是malloc后没有初始化。提示LeetCode 的 C 编译环境通常会处理好stdbool.h和标准库但本地用 gcc/clang 编译时记得显式#include stdbool.h不然bool会报错。2.3 is_end 为什么必须是节点上的独立标记is_end的含义是存在一个单词恰好在这个字符位置结束。注意是“恰好结束”不是“路径经过”。插入bad时d节点的is_end为true插入ba后a节点的is_end也为true。两个单词共享前缀互不影响。如果不用is_endsearch(ba)和search(bad)就无法区分。外层再包一层WordDictionary是因为 LeetCode 的 C 接口要求你返回一个对象指针。很多题解会直接把全局根节点当变量用那样在多次测试用例运行时容易残留数据。用封装结构体保存根节点每次wordDictionaryCreate都新建一棵独立的树是更规范的做法。3. addWord 和 search 的完整 C 实现迭代插入与递归回溯3.1 初始化与插入WordDictionary* wordDictionaryCreate() { WordDictionary* obj (WordDictionary*)malloc(sizeof(WordDictionary)); obj-root createNode(); return obj; } void wordDictionaryAddWord(WordDictionary* obj, char* word) { TrieNode* p obj-root; for (int i 0; word[i] ! \0; i) { int idx word[i] - a; if (p-children[idx] NULL) { p-children[idx] createNode(); } p p-children[idx]; } p-is_end true; }插入的逻辑很简单从根开始逐个字符走走到 NULL 就建节点走到字符串末尾把is_end置为true。这里有一个容易忽略的边界情况如果插入的单词恰好是已有单词的前缀例如先插bad再插ba第二次插入走到a时 child 已经存在不需要新建最后把a节点的is_end置为true。由于没有破坏原有路径搜索bad仍然会返回true。这正是 Trie 共享前缀的核心特性。3.2 search 的递归回溯函数搜索部分不能再用循环硬走了因为遇到.时要尝试当前节点的所有孩子。递归能把“当前分支失败后回退到上一层重新选择”这件事交给函数调用栈不用手动维护栈。bool dfs(TrieNode* node, char* word, int pos) { if (word[pos] \0) { return node-is_end; } char c word[pos]; if (c .) { for (int i 0; i 26; i) { if (node-children[i] ! NULL) { if (dfs(node-children[i], word, pos 1)) { return true; } } } return false; } else { int idx c - a; if (node-children[idx] NULL) { return false; } return dfs(node-children[idx], word, pos 1); } } bool wordDictionarySearch(WordDictionary* obj, char* word) { return dfs(obj-root, word, 0); }这段代码里最关键的是基例的判断顺序word[pos] \0时直接返回node-is_end不要再往下访问children。因为一个单词匹配完最后一个字符后需要检查“这个节点是否是一个完整单词的结尾”而不是“还有没有后续”。普通字符的分支很好理解字符不是.就计算下标如果孩子不存在直接失败存在就递归下去。.的分支才是回溯先枚举 26 个可能的字母只对非空孩子递归。任何一个孩子的递归返回true就立刻return true剪枝全部失败才返回false。3.3 为什么回溯不是简单的“遍历所有单词”有人会把回溯理解为“暴力”其实它和“遍历所有单词”有本质区别。当搜索a.c时如果字典里根本没有以a开头的单词那么根节点的a孩子是 NULL函数在第一步就返回false完全不会进入后面的匹配。如果字典里有abc和ace路径会自然地走到a- 某个孩子再在.处尝试b和c。你尝试的分支永远来自真实存在的单词前缀而不是凭空枚举 26 个字母。这种“按图索骥”的搜索方式才是 Trie 对通配符搜索友好的本质。3.4 内存释放写题也要养成好习惯LeetCode 上通常不检查你是否 free但本地测试时内存泄漏会导致 valgrind 报警所以我建议把释放函数也写好void freeTrie(TrieNode* node) { if (node NULL) return; for (int i 0; i 26; i) { freeTrie(node-children[i]); } free(node); } void wordDictionaryFree(WordDictionary* obj) { if (obj NULL) return; freeTrie(obj-root); free(obj); }注意这里一定要先递归释放所有孩子再释放当前节点。如果先free(node)再访问children就是 use-after-free调试时很难发现。4. 复杂度评估与实测结果为什么指数级最坏情况在 LeetCode 上依然跑得过4.1 理论复杂度addWord的复杂度很明显O(L)L 是单词长度因为每次插入都从根节点一路走到叶节点只遍历一次字符串。search的复杂度取决于模式串中.的分布查询类型时间复杂度说明全普通字符O(L)和普通 Trie 查找一样顺着指针走带少量 .取决于树中实际分支数量每个 . 只遍历当前节点的非空孩子全 . 的极端情况最坏 O(26^L)每个节点 26 个孩子都非空时指数爆炸如果因此担心超时就有点过度了。注意这个上界是“每个节点都有 26 个非空孩子”时的极端情况。而 Trie 中的节点总数是有限的它等于所有插入单词的字符总数去掉公共前缀后。一次search无论怎么回溯访问的节点数都不可能超过整个 Trie 的节点总数 M。所以更现实的上界其实是 O(M)M 是所有已插入单词的总字符数。题目数据量下这个值最大也就是 10^4 * 25 2.5 * 10^5 个节点完全可控。4.2 本地实测我在本地用 1 万个长度为 10 的随机单词建树再跑 1 万次search其中一半查询包含 2 到 3 个.Release 编译下总耗时大约在 20 到 40 毫秒。这个量级对比赛和面试都完全够用。如果你在 LeetCode 上遇到超时基本不是算法问题而是实现细节有问题。常见的超时原因有每次递归都重新计算长度比如在dfs里调用strlen(word)导致 O(L^2)。没有做短路剪枝找到一个可行分支后没有立刻return true而是继续搜索所有分支。用链表结构代替了指针数组访问孩子时遍历链表复杂度多一个 26 的常数或更高。4.3 进阶思路按长度分桶实际工程里还可以再加一层优化在WordDictionary里维护多棵 Trie每棵树只保存某个固定长度的单词。搜索时先看 pattern 的长度只去对应长度的 Trie 里查。这样...这种查询就不会去扫描长度为 25 的单词路径搜索空间进一步缩小。实现上可以这样设计typedef struct { TrieNode* roots[26]; // 按单词长度分桶这里长度上限取 25 } WordDictionary;addWord时根据strlen(word)选择对应根节点search时同样按 pattern 长度路由。对于这题不是必须的但面试时主动提出来能体现你对数据结构的理解更深一层。5. 调试中容易踩的三个坑野指针、标记错位、基例顺序5.1 坑一malloc 后没有清零导致野指针错误示例TrieNode* createNode(void) { TrieNode* node (TrieNode*)malloc(sizeof(TrieNode)); // 忘了初始化 children 数组 return node; }现象插入第一个单词没问题插入第二个单词时某个children下标刚好是随机值被当作非 NULL于是沿着一个野指针写内存段错误或者数据被破坏表现还很随机有时候跑一次崩一次有时候跑十次才崩一次。解决用calloc或者malloc后用memset(node, 0, sizeof(TrieNode))。5.2 坑二is_end 标记加错位置错误示例void wordDictionaryAddWord(WordDictionary* obj, char* word) { TrieNode* p obj-root; for (int i 0; word[i] ! \0; i) { int idx word[i] - a; if (p-children[idx] NULL) { p-children[idx] createNode(); } p p-children[idx]; p-is_end true; // 错每个中间节点都被标记为结束 } }现象插入bad后search(b)和search(ba)都会返回true但正确的语义应该是false因为没有单词恰好是b或ba。这个问题在只有单个单词时最容易出现因为你会下意识地觉得“路径上走过的节点都算匹配到了”。解决is_end true必须放在 for 循环结束之后也就是字符串真正结束时才标记。5.3 坑三递归基例写错导致前缀误匹配错误示例bool dfs(TrieNode* node, char* word, int pos) { if (word[pos] \0) { return true; // 错没有检查 is_end } ... }现象addWord(bad)之后search(ba)返回true。原因是递归走到a节点时字符串已经结束函数直接返回true完全不管这个节点是否真的是某个单词的结尾。这个坑其实比前两个更隐蔽因为很多人的测试用例里不会特意去查“前缀但不完整”的情况。面试时如果面试官追问search(ba)应该返回什么答错了基本就凉了。解决基例写成return node-is_end;。同时要注意普通字符分支里要先判断 child 是否为 NULL再递归如果先递归后判断会在 NULL 节点上访问is_end直接崩溃。5.4 本地测试骨架建议在本地写一个小的 main 函数把样例跑一遍再用 valgrind 检查内存#include stdio.h int main(void) { WordDictionary* obj wordDictionaryCreate(); wordDictionaryAddWord(obj, bad); wordDictionaryAddWord(obj, dad); wordDictionaryAddWord(obj, mad); printf(%d\n, wordDictionarySearch(obj, pad)); // 0 printf(%d\n, wordDictionarySearch(obj, bad)); // 1 printf(%d\n, wordDictionarySearch(obj, .ad)); // 1 printf(%d\n, wordDictionarySearch(obj, b..)); // 1 printf(%d\n, wordDictionarySearch(obj, ba)); // 0 wordDictionaryFree(obj); return 0; }我每次写完这题都会刻意把最后一行search(ba)加上专门用来验证is_end逻辑是否正确。这个测试用例比题目给的样例更能暴露问题。6. 从 211 延伸出去208、212 和真实世界里的 Trie 应用6.1 先做 208再做 211LeetCode 208 是实现一个基本的 Trie只有insert、search、startsWith没有.。208 做一遍能让你把插入、查找这些基础操作写熟。211 等于在 208 的search上加入通配符本质是“把查找从单路径走法改成多路径回溯”。如果 208 的搜索逻辑还没写顺211 的递归回溯会很容易和迭代的addWord混在一起思路一团乱。我的建议是按顺序刷先花十几分钟把 208 的 C 语言版本写通再动手写 211你会发现 211 的插入代码和 208 几乎一模一样唯一需要重新设计的就是dfs函数。6.2 212 的二维回溯211 的下一个台阶LeetCode 212单词搜索 II是把 Trie 和二维网格结合起来给一个字符矩阵和一批单词找出矩阵中能通过相邻格子连成的单词。标准做法是遍历每个格子用 DFS 在矩阵上走同时用 Trie 判断当前路径是否可能构成某个单词的前缀。211 的dfs函数中“遇到.就枚举孩子”的思想在 212 里变成“在网格上枚举上下左右四个方向”。区别在于 211 的搜索空间是 Trie 的孩子节点212 的搜索空间是网格的相邻格子。所以 211 练好了212 对你来说就只是多了一个二维坐标状态。6.3 现实中的 Trie 并没有过时很多人觉得 Trie 是面试专属数据结构实际不是。输入法的候选词提示、搜索引擎的自动补全、拼写检查、IP 路由表里的最长前缀匹配这些场景里都能看到 Trie 或者它的变体压缩字典树、双数组 Trie。C 语言里做敏感词过滤时用 Trie 也比逐条命中文本来得快先把敏感词列表建成 Trie然后对文本逐字符扫描匹配到某个节点时继续向下匹配失败就回退到根节点重新开始。这和 211 的搜索思路一脉相承只是少了.通配符少了一层回溯复杂度。最后给刷题的人一个建议不要一上来就看题解。自己先定义好TrieNode把addWord写完然后思考search()、search(a)、search(.)这三个边界情况分别应该返回什么。想清楚这三件事递归函数的基例和剪枝条件基本就写对了。这道题之所以经典就是因为它逼你把不太起眼的边界条件都梳理清楚。