深入理解C/C++字典树:原理、实现与优化技巧

这是文章《C/C++中的字典树数据结构》的第1部分(共7部分)。

字典树(Trie)数据结构作为一个动态数组的容器。在本文章中,我们将看一下如何在C/C++中实现一个字典树(Trie)。

这是基于树数据结构的,但不一定存储键。在这里,每个节点只有一个值,该值是根据位置定义的。

所以,每个节点的值表示前缀,因为它是经过一系列匹配后从根节点出发的点。

我们把这样的匹配称为前缀匹配。因此,我们可以使用字典树(Trie)来存储字典中的词语!

例如,在上图中,根节点为空,因此每个字符串都匹配根节点。节点7匹配了一个以”to”为前缀的字符串,而节点3匹配了一个以”tea”为前缀的字符串。

在这里,键只存储在叶子节点的位置上,因为前缀匹配会停在任何叶子节点。因此,任何非叶子节点都不存储整个字符串,而只存储前缀匹配的字符。

出于这些原因,字典树也被称为前缀树。现在让我们从程序员的角度来理解如何实现这种数据结构。


在C/C++中实现字典树数据结构

让我们首先写下字典树结构。字典树节点主要有两个组成部分。

  • 它的子节点
  • 一个标记,用于指示叶子节点

然而,由于我们还将打印字典树,如果我们能够在数据部分中存储一个附加属性,那将更容易。

那么让我们来定义TrieNode的结构。在这里,由于我只会存储小写字母,我将将其实现为一棵26叉树。因此,每个节点将有指向26个子节点的指针。

// 每个节点的子节点数量
// 我们将构建一个N叉树并使其成为字典树
// 由于我们有26个英文字母,每个节点需要26个子节点
#define N 26

typedef struct TrieNode TrieNode;

struct TrieNode {
    // 字典树节点结构
    // 每个节点从根节点开始有N个子节点
    // 还有一个标记来检查它是否是叶子节点
    char data; // 仅用于打印目的
    TrieNode* children[N];
    int is_leaf;
};

既然我们已经定义了结构,让我们编写函数来初始化字典树节点,并且还要释放其指针。

TrieNode* make_trienode(char data) {
    // 为字典树节点分配内存
    TrieNode* node = (TrieNode*) calloc (1, sizeof(TrieNode));
    for (int i=0; i<N; i++)
        node->children[i] = NULL;
    node->is_leaf = 0;
    node->data = data;
    return node;
}

void free_trienode(TrieNode* node) {
    // 释放字典树节点序列
    for(int i=0; i<N; i++) {
        if (node->children[i] != NULL) {
            free_trienode(node->children[i]);
        }
        else {
            continue;
        }
    }
    free(node);
}

将一个词插入到字典树数据结构中

这是文章《C/C++中的字典树数据结构》的第2部分(共7部分)。

现在我们将编写insert_trie()函数,该函数接收根节点(最顶层节点)的指针,并将一个单词插入到字典树(Trie)中。

插入过程很简单。它逐个字符地迭代单词,并评估其相对位置。

例如,字符’b’的位置是1,所以它将成为第二个子元素。

for (int i=0; word[i] != '\0'; i++) {
    // 获取字母表中的相对位置
    int idx = (int) word[i] - 'a';
    if (temp->children[idx] == NULL) {
        // 如果对应的子节点不存在,
        // 就创建该子节点!
        temp->children[idx] = make_trienode(word[i]);
    }
    else {
        // 不做任何操作。该节点已存在
    }

我们将逐个字符匹配前缀,并在必要时简单地初始化一个节点。

否则,我们只需要一直沿着链条向下移动,直到匹配所有字符。

temp = temp->children[idx];

最后,我们将插入所有不匹配的字符,并将更新后的字典树(Trie)返回。

TrieNode* insert_trie(TrieNode* root, char* word) {
    // 将单词插入到字典树中
    // 假设:单词只包含小写字母
    TrieNode* temp = root;

    for (int i=0; word[i] != '\0'; i++) {
        // 获取字母表中的相对位置
        int idx = (int) word[i] - 'a';
        if (temp->children[idx] == NULL) {
            // 如果对应的子节点不存在,
            // 就创建该子节点!
            temp->children[idx] = make_trienode(word[i]);
        }
        else {
            // 不做任何操作。该节点已存在
        }
        // 向下移动一层,到idx引用的子节点
        // 因为我们有前缀匹配
        temp = temp->children[idx];
    }
    // 在单词的末尾,将此节点标记为叶子节点
    temp->is_leaf = 1;
    return root;
}

在字典树(Trie)中搜索单词

C/C++中的字典树数据结构 – 第3部分(共7部分)

现在我们已经实现了字典树(Trie)的插入操作,接下来让我们看一下如何搜索给定的模式。

我们将尝试逐个字符匹配字符串,采用与前面相同的前缀匹配策略。

如果我们已经到达链的末尾,但仍然没有找到匹配项,这意味着字符串不存在,因为没有完成完整的前缀匹配。

为了使搜索正确返回,我们的模式必须完全匹配,并且在匹配完成后,我们必须到达一个叶节点。

int search_trie(TrieNode* root, char* word)
{
    // 在字典树中搜索单词
    TrieNode* temp = root;

    for(int i=0; word[i]!='\0'; i++)
    {
        int position = word[i] - 'a';
        if (temp->children[position] == NULL)
            return 0;
        temp = temp->children[position];
    }
    if (temp != NULL && temp->is_leaf == 1)
        return 1;
    return 0;
}

好的,我们已经完成了插入和搜索的步骤。

为了测试这些功能,我们首先编写一个print_trie()函数,用于打印每个节点的数据。

void print_trie(TrieNode* root) {
    // 打印字典树的节点
    if (!root)
        return;
    TrieNode* temp = root;
    printf("%c -> ", temp->data);
    for (int i=0; i<N; i++) {
        print_trie(temp->children[i]); 
    }
}

既然我们已经实现了这些功能,让我们运行完整的程序来检查它是否正常工作。

#include 
#include 
#include 

// 每个节点的子节点数量
// 我们将构建一个N叉树并使其成为字典树
// 由于我们有26个英文字母,每个节点需要
// 26个子节点
#define N 26

typedef struct TrieNode TrieNode;

struct TrieNode {
    // 字典树节点结构
    // 每个节点有N个子节点,从根节点开始
    // 以及一个标记是否为叶节点的标志
    char data; // 仅用于打印目的存储
    TrieNode* children[N];
    int is_leaf;
};

TrieNode* make_trienode(char data) {
    // 为字典树节点分配内存
    TrieNode* node = (TrieNode*) calloc (1, sizeof(TrieNode));
    for (int i=0; i<N; i++)
        node->children[i] = NULL;
    node->is_leaf = 0;
    node->data = data;
    return node;
}

void free_trienode(TrieNode* node) {
    // 释放字典树节点序列
    for(int i=0; i<N; i++) {
        if (node->children[i] != NULL) {
            free_trienode(node->children[i]);
        }
        else {
            continue;
        }
    }
    free(node);
}

TrieNode* insert_trie(TrieNode* root, char* word) {
    // 将单词插入到字典树中
    // 假设:单词只包含小写字符
    TrieNode* temp = root;

    for (int i=0; word[i] != '\0'; i++) {
        // 获取字母表中的相对位置
        int idx = (int) word[i] - 'a';
        if (temp->children[idx] == NULL) {
            // 如果相应的子节点不存在,
            // 就创建该子节点!
            temp->children[idx] = make_trienode(word[i]);
        }
        else {
            // 什么都不做。节点已经存在
        }
        // 向下一层,到idx引用的子节点
        // 因为我们有前缀匹配
        temp = temp->children[idx];
    }
    // 在单词的末尾,将此节点标记为叶节点
    temp->is_leaf = 1;
    return root;
}

int search_trie(TrieNode* root, char* word)
{
    // 在字典树中搜索单词
    TrieNode* temp = root;

    for(int i=0; word[i]!='\0'; i++)
    {
        int position = word[i] - 'a';
        if (temp->children[position] == NULL)
            return 0;
        temp = temp->children[position];
    }
    if (temp != NULL && temp->is_leaf == 1)
        return 1;
    return 0;
}

void print_trie(TrieNode* root) {
    // 打印字典树的节点
    if (!root)
        return;
    TrieNode* temp = root;
    printf("%c -> ", temp->data);
    for (int i=0; i<N; i++) {
        print_trie(temp->children[i]); 
    }
}

void print_search(TrieNode* root, char* word) {
    printf("搜索 %s: ", word);
    if (search_trie(root, word) == 0)
        printf("未找到\n");
    else
        printf("已找到!\n");
}

int main() {
    // 字典树数据结构操作的驱动程序
    TrieNode* root = make_trienode('\0');
    root = insert_trie(root, "hello");
    root = insert_trie(root, "hi");
    root = insert_trie(root, "teabag");
    root = insert_trie(root, "teacan");
    print_search(root, "tea");
    print_search(root, "teabag");
    print_search(root, "teacan");
    print_search(root, "hi");
    print_search(root, "hey");
    print_search(root, "hello");
    print_trie(root);
    free_trienode(root);
    return 0;
}

使用gcc编译器编译后,你将得到以下输出:

搜索 for tea: 未找到
搜索 for teabag: 已找到!
搜索 for teacan: 已找到!
搜索 for hi: 已找到!
搜索 for hey: 未找到
搜索 for hello: 已找到!
 -> h -> e -> l -> l -> o -> i -> t -> e -> a -> b -> a -> g -> c -> a -> n -> 

虽然打印方式可能不太直观,但关键在于查看print_trie()方法。因为该方法首先打印当前节点,然后打印其所有子节点,所以这个顺序确实表明了打印的顺序。

那么,现在让我们来实现删除方法吧!

从字典树数据结构中删除一个单词

这个方法比另外两个方法稍微复杂一些。

由于我们没有明确存储键和值的方式,因此不存在任何格式算法。

然而,针对本文的目的,我只会处理那些待删除的词是叶节点的情况。也就是说,它必须完全匹配前缀,直至达到一个叶节点。

那么让我们开始吧。我们删除函数的签名将是:

TrieNode* delete_trie(TrieNode* root, char* word);

在前面提到过,只有当匹配的前缀达到叶节点时,才会执行删除操作。因此,让我们编写另一个函数is_leaf_node()来为我们执行此操作。

int is_leaf_node(TrieNode* root, char* word) {
    // Checks if the prefix match of word and root
    // is a leaf node
    TrieNode* temp = root;
    for (int i=0; word[i]; i++) {
        int position = (int) word[i] - 'a';
        if (temp->children[position]) {
            temp = temp->children[position];
        }
    }
    return temp->is_leaf;
}

好的,完成了之后,让我们来看一下我们的delete_trie()方法的草稿。

TrieNode* delete_trie(TrieNode* root, char* word) {
    // Will try to delete the word sequence from the Trie only it 
    // ends up in a leaf node
    if (!root)
        return NULL;
    if (!word || word[0] == '\0')
        return root;
    // If the node corresponding to the match is not a leaf node,
    // we stop
    if (!is_leaf_node(root, word)) {
        return root;
    }
    // TODO
}

经过这个检查之后,我们现在知道我们的词将以叶节点结束。

但还有另外一种情况需要处理。如果存在另一个单词与当前字符串具有部分前缀匹配的情况怎么办?

例如,在下面的字典树中,单词”tea”和”ted”在”te”之前有部分相同的匹配。

所以,如果发生这种情况,我们不能简单地从t到a删除整个序列,因为它们是链接在一起的,那样两个链都会被删除!

因此,我们需要找到能够出现这种匹配的最深点。只有在那之后,我们才能删除剩余的链条。

这涉及到查找最长的前缀字符串,所以让我们写另一个函数。

char* longest_prefix(TrieNode* root, char* word);

这将返回字典树中的最长匹配,该匹配并非当前的单词。下面的代码在注释中解释了每个中间步骤。

char* find_longest_prefix(TrieNode* root, char* word) {
    // 查找字典树中单词的最长公共前缀子字符串
    if (!word || word[0] == '\0')
        return NULL;
    // 最长前缀的长度
    int len = strlen(word);

    // 我们最初将最长前缀设置为单词本身,
    // 并尝试从最深位置回溯到
    // 分歧点(如果存在)
    char* longest_prefix = (char*) calloc (len + 1, sizeof(char));
    for (int i=0; word[i] != '\0'; i++)
        longest_prefix[i] = word[i];
    longest_prefix[len] = '\0';

    // 如果从根节点没有分支,这意味着
    // 我们正在匹配原始字符串!
    // 这不是我们想要的!
    int branch_idx  = check_divergence(root, longest_prefix) - 1;
    if (branch_idx >= 0) {
        // 存在分支,我们必须更新位置
        // 到最长匹配处,并通过分支索引长度
        // 更新最长前缀
        longest_prefix[branch_idx] = '\0';
        longest_prefix = (char*) realloc (longest_prefix, (branch_idx + 1) * sizeof(char));
    }

    return longest_prefix;
}

这里还有一个变化!由于我们试图找到最长的匹配,算法实际上会贪婪地匹配原始字符串本身!这不是我们想要的。

我们希望找到最长的匹配项,但排除原始输入字符串。因此,我们必须使用另一种方法check_divergence()进行必要的检查。

这将检查从根节点到当前位置是否存在任何分支,并返回最佳匹配的长度,该长度不是输入的长度。

如果我们处于坏链中,对应于原始字符串,那么就不会从该点分叉!所以对于我们来说,使用check_divergence()是避免那个讨厌的点的好方法。

int check_divergence(TrieNode* root, char* word) {
    // 检查单词的最后一个字符处是否存在分支
 // 并返回单词中发生分支的最大位置
    TrieNode* temp = root;
    int len = strlen(word);
    if (len == 0)
        return 0;
    // 我们将返回发生分支的最大索引
    int last_index = 0;
    for (int i=0; i < len; i++) {
        int position = word[i] - 'a';
        if (temp->children[position]) {
            // 如果该位置存在子节点
            // 我们将检查是否存在任何其他子节点
            // 以便发生分支
            for (int j=0; j<N; j++) {
                if (j != position && temp->children[j]) {
                    // 我们找到了另一个子节点!这是一个分支。
                    // 更新分支位置
                    last_index = i + 1;
                    break;
                }
            }
            // 移动到序列中的下一个子节点
            temp = temp->children[position];
        }
    }
    return last_index;
}

最后,我们确保不会盲目地删除整个链条。所以现在让我们继续使用我们的删除方法。

TrieNode* delete_trie(TrieNode* root, char* word) {
    // 仅当单词序列最终以叶子节点结束时
    // 才尝试从字典树中删除该单词序列
    if (!root)
        return NULL;
    if (!word || word[0] == '\0')
        return root;
    // 如果对应于匹配的节点不是叶子节点,
    // 我们停止
    if (!is_leaf_node(root, word)) {
        return root;
    }
    TrieNode* temp = root;
    // 查找不是当前单词的最长前缀字符串
    char* longest_prefix = find_longest_prefix(root, word);
    //printf("Longest Prefix = %s\n", longest_prefix);
    if (longest_prefix[0] == '\0') {
        free(longest_prefix);
        return root;
    }
    // 跟踪在字典树中的位置
    int i;
    for (i=0; longest_prefix[i] != '\0'; i++) {
        int position = (int) longest_prefix[i] - 'a';
        if (temp->children[position] != NULL) {
            // 继续移动到公共前缀中的最深节点
            temp = temp->children[position];
        }
        else {
            // 没有这样的节点。直接返回。
            free(longest_prefix);
            return root;
        }
    }
    // 现在,我们已经到达两个字符串之间的最深公共节点。
    // 我们需要删除对应于单词的序列
    int len = strlen(word);
    for (; i < len; i++) {
        int position = (int) word[i] - 'a';
        if (temp->children[position]) {
            // 删除剩余的序列
            TrieNode* rm_node = temp->children[position];
            temp->children[position] = NULL;
            free_trienode(rm_node);
        }
    }
    free(longest_prefix);
    return root;
}

上述的代码简单地找到了前缀匹配的最深节点,并从字典树中删除剩余匹配的单词序列,因为这与任何其他的匹配无关。

上述过程的时间复杂度

字典树操作的时间复杂度

操作 时间复杂度
search_trie() O(n) – 其中 n 是输入字符串的长度
insert_trie() O(n) – 其中 n 是输入字符串的长度
delete_trie() O(C*n) – 其中 C 是字母表中的字母数量,
n 是输入单词的长度

几乎所有情况下,字母的数量是恒定的,因此 delete_trie() 的复杂度也被降低到 O(n)。


字典树数据结构的完整C/C++程序

最终,我们(希望如此)完成了delete_trie()函数。让我们使用我们的驱动程序来检查我们所编写的是否正确。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 每个节点的子节点数量
// 我们将构建一个N叉树并使其成为Trie
// 由于我们有26个英文字母,每个节点需要
// 26个子节点
#define N 26

typedef struct TrieNode TrieNode;

struct TrieNode {
    // Trie节点结构
    // 每个节点有N个子节点,从根节点开始
    // 以及一个标志来检查它是否是叶节点
    char data; // 仅用于打印目的而存储
    TrieNode* children[N];
    int is_leaf;
};

TrieNode* make_trienode(char data) {
    // 为TrieNode分配内存
    TrieNode* node = (TrieNode*) calloc (1, sizeof(TrieNode));
    for (int i=0; i<N; i++)
        node->children[i] = NULL;
    node->is_leaf = 0;
    node->data = data;
    return node;
}

void free_trienode(TrieNode* node) {
    // 释放trienode序列
    for(int i=0; i<N; i++) {
        if (node->children[i] != NULL) {
            free_trienode(node->children[i]);
        }
        else {
            continue;
        }
    }
    free(node);
}

TrieNode* insert_trie(TrieNode* root, char* word) {
    // 将单词插入到Trie中
    // 假设:单词只包含小写字符
    TrieNode* temp = root;

    for (int i=0; word[i] != '\0'; i++) {
        // 获取字母表中的相对位置
        int idx = (int) word[i] - 'a';
        if (temp->children[idx] == NULL) {
            // 如果相应的子节点不存在,
            // 只需创建该子节点!
            temp->children[idx] = make_trienode(word[i]);
        }
        else {
            // 什么都不做。节点已经存在
        }
        // 向下一级,到idx引用的子节点
        // 因为我们有前缀匹配
        temp = temp->children[idx];
    }
    // 在单词的末尾,将此节点标记为叶节点
    temp->is_leaf = 1;
    return root;
}

int search_trie(TrieNode* root, char* word)
{
    // 在Trie中搜索单词
    TrieNode* temp = root;

    for(int i=0; word[i]!='\0'; i++)
    {
        int position = word[i] - 'a';
        if (temp->children[position] == NULL)
            return 0;
        temp = temp->children[position];
    }
    if (temp != NULL && temp->is_leaf == 1)
        return 1;
    return 0;
}

int check_divergence(TrieNode* root, char* word) {
    // 检查单词的最后一个字符是否有分支
    // 并返回单词中出现分支的最大位置
    TrieNode* temp = root;
    int len = strlen(word);
    if (len == 0)
        return 0;
    // 我们将返回出现分支的最大索引
    int last_index = 0;
    for (int i=0; i < len; i++) {
        int position = word[i] - 'a';
        if (temp->children[position]) {
            // 如果该位置存在子节点
            // 我们将检查是否存在任何其他子节点
            // 以便发生分支
            for (int j=0; j<N; j++) {
                if (j != position && temp->children[j]) {
                    // 我们找到了另一个子节点!这是一个分支。
                    // 更新分支位置
                    last_index = i + 1;
                    break;
                }
            }
            // 转到序列中的下一个子节点
            temp = temp->children[position];
        }
    }
    return last_index;
}

char* find_longest_prefix(TrieNode* root, char* word) {
    // 在Trie中查找单词的最长公共前缀子串
    if (!word || word[0] == '\0')
        return NULL;
    // 最长前缀的长度
    int len = strlen(word);

    // 我们最初将最长前缀设置为单词本身,
    // 并尝试从最深位置回溯
    // 到一个分歧点,如果存在的话
    char* longest_prefix = (char*) calloc (len + 1, sizeof(char));
    for (int i=0; word[i] != '\0'; i++)
        longest_prefix[i] = word[i];
    longest_prefix[len] = '\0';

    // 如果从根节点没有分支,这意味着
    // 我们正在匹配原始字符串!
    // 这不是我们想要的!
    int branch_idx  = check_divergence(root, longest_prefix) - 1;
    if (branch_idx >= 0) {
        // 存在分支,我们必须更新位置
        // 到最长匹配并更新最长前缀
        // 通过分支索引长度
        longest_prefix[branch_idx] = '\0';
        longest_prefix = (char*) realloc (longest_prefix, (branch_idx + 1) * sizeof(char));
    }

    return longest_prefix;
}

int is_leaf_node(TrieNode* root, char* word) {
    // 检查单词和根节点的前缀匹配
    // 是否是叶节点
    TrieNode* temp = root;
    for (int i=0; word[i]; i++) {
        int position = (int) word[i] - 'a';
        if (temp->children[position]) {
            temp = temp->children[position];
        }
    }
    return temp->is_leaf;
}

TrieNode* delete_trie(TrieNode* root, char* word) {
    // 只有当单词序列以叶节点结束时,
    // 才会尝试从Trie中删除它
    if (!root)
        return NULL;
    if (!word || word[0] == '\0')
        return root;
    // 如果对应于匹配的节点不是叶节点,
    // 我们停止
    if (!is_leaf_node(root, word)) {
        return root;
    }
    TrieNode* temp = root;
    // 查找不是当前单词的最长前缀字符串
    char* longest_prefix = find_longest_prefix(root, word);
    //printf("Longest Prefix = %s\n", longest_prefix);
    if (longest_prefix[0] == '\0') {
        free(longest_prefix);
        return root;
    }
    // 跟踪在Trie中的位置
    int i;
    for (i=0; longest_prefix[i] != '\0'; i++) {
        int position = (int) longest_prefix[i] - 'a';
        if (temp->children[position] != NULL) {
            // 继续移动到公共前缀中的最深节点
            temp = temp->children[position];
        }
        else {
            // 没有这样的节点。只需返回。
            free(longest_prefix);
            return root;
        }
    }
    // 现在,我们已经到达两个字符串之间最深的公共节点。
    // 我们需要删除对应于单词的序列
    int len = strlen(word);
    for (; i < len; i++) {
        int position = (int) word[i] - 'a';
        if (temp->children[position]) {
            // 删除剩余的序列
            TrieNode* rm_node = temp->children[position];
            temp->children[position] = NULL;
            free_trienode(rm_node);
        }
    }
    free(longest_prefix);
    return root;
}

void print_trie(TrieNode* root) {
    // 打印trie的节点
    if (!root)
        return;
    TrieNode* temp = root;
    printf("%c -> ", temp->data);
    for (int i=0; i<N; i++) {
        print_trie(temp->children[i]); 
    }
}

void print_search(TrieNode* root, char* word) {
    printf("查找 %s: ", word);
    if (search_trie(root, word) == 0)
        printf("未找到\n");
    else
        printf("已找到!\n");
}

int main() {
    // Trie数据结构操作的驱动程序
    TrieNode* root = make_trienode('\0');
    root = insert_trie(root, "hello");
    root = insert_trie(root, "hi");
    root = insert_trie(root, "teabag");
    root = insert_trie(root, "teacan");
    print_search(root, "tea");
    print_search(root, "teabag");
    print_search(root, "teacan");
    print_search(root, "hi");
    print_search(root, "hey");
    print_search(root, "hello");
    print_trie(root);
    printf("\n");
    root = delete_trie(root, "hello");
    printf("删除 'hello' 后...\n");
    print_trie(root);
    printf("\n");
    root = delete_trie(root, "teacan");
    printf("删除 'teacan' 后...\n");
    print_trie(root);
    printf("\n");
    free_trienode(root);
    return 0;
}

程序输出

查找 tea: 未找到
查找 teabag: 已找到!
查找 teacan: 已找到!
查找 hi: 已找到!
查找 hey: 未找到
查找 hello: 已找到!
 -> h -> e -> l -> l -> o -> i -> t -> e -> a -> b -> a -> g -> c -> a -> n -> 
删除 'hello' 后...
 -> h -> i -> t -> e -> a -> b -> a -> g -> c -> a -> n -> 
删除 'teacan' 后...
 -> h -> i -> t -> e -> a -> b -> a -> g -> 

通过这个例子,我们已经完成了在C/C++中实现Trie数据结构的学习。我知道这是一篇很长的阅读,但希望你能正确理解如何应用这些方法!


下载该代码

这是文章《C/C++中的字典树数据结构》的第7部分(共7部分)。

内容片段: 你可以在我上传到Github代码片段上找到完整的代码。它可能不是最高效的代码,但我已尽力确保它能正常工作。

如果你有任何问题或建议,欢迎在下方的评论区提出!


参考资料

  • 维基百科关于字典树的文章

推荐阅读

如果你对类似的话题感兴趣,可以参考《数据结构和算法》部分,其中包括哈希表和图论等主题。


bannerAds