本期我们讲解一个比较偏门的数据结构:基数树
相关代码已经上传至作者的个人gitee:楼田莉子/CPP代码学习
目录
[基数树(Radix Tree)的设计原理与特点](#基数树(Radix Tree)的设计原理与特点)
[节点结构设计(以 C++ 实现为例)](#节点结构设计(以 C++ 实现为例))
基数树(Radix Tree)的设计原理与特点
基数树(Radix Tree),又称压缩前缀树(Compressed Trie)或 Patricia Trie,是一种针对传统字典树(Trie)的空间优化结构。它的核心思想是将单子节点路径压缩为节点上的字符串片段,从而大幅减少节点数量,提升内存效率与查找性能。下面从算法原理、数据结构设计、操作机制以及与其他结构的对比等方面进行深入剖析。
传统字典树(Trie)的瓶颈
传统字典树每个节点存储单个字符,并拥有一个指向子节点的指针数组(或映射)。这种结构在存储大量共享前缀的字符串时存在两个主要问题:
-
空间冗余 :若键之间共享前缀但后续分支较少,会产生大量仅有一个子节点的"链式"节点。例如,存储
"apple"和"apply",传统 trie 会为a→p→p→l→e和y分别创建节点,其中a、p、p、l节点均只有一个子节点,形成冗长的链条。 -
内存访问开销:查找一个键需要逐字符访问节点,每次跳转都伴随一次指针解引用,缓存局部性差。
基数树的压缩思想
基数树通过合并"无分支"的节点路径来消除冗余。每个节点存储一段字符串片段(fragment),而非单个字符。当某个节点只有一个子节点时,该节点会与子节点合并,片段拼接,从而将多条单子节点链压缩为一个节点。
节点结构设计(以 C++ 实现为例)
cpp
template <typename ValueType>
class RadixTreeNode {
public:
std::string fragment; // 节点存储的字符串片段
ValueType value; // 值(仅当 is_terminal 为 true 时有效)
bool is_terminal; // 是否为一个完整键的终点
std::map<char, std::unique_ptr<RadixTreeNode>> children; // 子节点映射(按首字符)
};
-
fragment:代表从该节点到其子节点之间的所有字符,是压缩后的路径。 -
children:由于节点可能分裂为多个分支,子节点通过其片段的首字符进行索引,采用std::map(有序)或std::unordered_map维护。 -
is_terminal:标记当前节点是否对应一个完整的插入键。若为true,则value有效。
这种设计使每个节点都可以表示一段连续的字符,从而将多字符路径合并,显著减少节点数量。
核心操作的设计原理
插入(Insert)
插入是基数树最复杂的操作,需要处理三种情况:
-
完全匹配节点片段:若待插键的前缀与当前节点片段完全一致,则递归进入子节点或在该节点结束。
-
部分匹配 :若当前节点片段与键的前缀仅有部分相同,则必须分裂当前节点,将公共前缀单独提取为一个新节点,原来的节点和新键分别作为其子节点。
-
无匹配:若键的首字符与所有子节点的首字符均不同,则直接创建新子节点。
分裂操作的细节(来自实现中的迭代算法):
-
计算当前节点片段
node->fragment与待插键的最长公共前缀长度common。 -
若
common小于node->fragment的长度,说明需要在当前节点处分裂。 -
新建一个前缀节点
prefixNode,其片段为公共前缀fragment.substr(0, common)。 -
将原节点的剩余部分
fragment.substr(common)作为其后缀子节点oldSuffixNode,并继承原节点的终端状态和子节点。 -
待插键的剩余部分
key.substr(common)作为另一个新子节点newSuffixNode(若不为空)。 -
最后用
prefixNode替换原节点在父节点中的位置。
为什么必须分裂?
分裂保证了基数树的唯一性 :每个节点片段的第一个字符在兄弟节点之间是互异的(通过 map 按首字符索引实现)。通过分裂,可以确保不同分支的键在分歧点处分离,避免歧义。
查找(Find)
查找过程简单直接:
-
从根节点开始,设剩余键为
remaining。 -
计算当前节点片段与
remaining的最长公共前缀长度。 -
若长度不等于节点片段长度,说明不匹配,返回失败。
-
若
remaining被完全消耗,则检查当前节点是否为终端,若是则返回值,否则失败。 -
否则,从
remaining中截掉已匹配的部分,取剩余部分的首字符,在子节点映射中查找对应子节点,并重复上述过程。
查找的时间复杂度为 O(L),其中 L 为键的长度。由于压缩了路径,实际访问的节点数远小于键的长度,因为每个节点可能匹配多个字符。
删除(Erase)
删除操作需要处理节点合并,以保证树结构的紧凑性。删除一个键时:
-
先定位到对应的终端节点,清除其终端标志(若节点仍有其他子节点,则不能删除节点本身)。
-
若删除后该节点不再有子节点且不是终端节点,则将其从父节点中移除。
-
若父节点因此只剩下一个子节点(且父节点自身不是终端节点),则合并父节点与子节点,将子节点的片段拼接到父节点片段后,并转移子节点的终端状态和子节点。
这种合并是分裂的逆过程,确保了树始终保持压缩状态。
遍历(Traverse)
遍历可采用深度优先搜索,累加路径上的片段,当遇到终端节点时输出完整键。实现中的 traverseRecursive 函数正是如此工作。
基数树的特性与设计权衡
优点
-
空间高效:通过路径压缩,节点数量接近键的总数,远少于传统 trie。特别适合存储大量长且共享前缀的字符串(如 URL 路径、IP 路由表)。
-
查找快速:一次比较可能匹配多个字符,减少了内存访问次数。现代 CPU 对字符串比较有优化,实际性能优越。
-
支持前缀查询:可以轻松查找具有给定前缀的所有键,只需定位到前缀对应节点后遍历子树。
-
有序性 :若使用
std::map存储子节点,则键的顺序与插入顺序无关,但可按字典序遍历所有键。
缺点与权衡
-
实现复杂度:插入和删除涉及节点分裂与合并,代码实现比普通 trie 复杂,容易出错。
-
内存碎片:节点中存储的字符串片段可能动态分配,导致内存碎片。
-
删除合并开销:为保证压缩,删除后需要检查并合并节点,可能引入额外的时间开销。
-
适应性:对于字符集较小的场景(如 DNA 序列,字符集仅 A/C/G/T),分裂/合并的收益可能不如直接使用数组索引的 trie 高。
与其他变种的对比
| 结构 | 节点存储 | 空间 | 查找 | 插入/删除 | 适用场景 |
|---|---|---|---|---|---|
| 传统 Trie | 单字符,数组/映射 | 高(链式节点多) | O(L) | O(L) | 小规模、简单实现 |
| 基数树 | 字符串片段,映射 | 低(节点数少) | O(L) | O(L) + 分裂/合并 | 大规模、长前缀共享 |
| 双数组 Trie | 数组索引 | 极低 | O(L) | 动态维护困难 | 静态数据集 |
| Patricia Trie | 二进制位 | 极低 | O(L) | 复杂 | 路由表、网络地址 |
深入设计细节
子节点索引方式
实现中采用 std::map<char, unique_ptr<Node>> 作为子节点容器。选择 map 而非 unordered_map 主要基于以下考虑:
-
有序性 :
map保证子节点按字符序排列,便于按序遍历所有键。 -
稳定性 :
map在迭代时不会因插入而失效,而unordered_map在 rehash 时可能导致迭代器失效,增加遍历复杂度。cpp#include <iostream> #include <memory> #include <map> #include <string> #include <vector> #include <utility> /** * 基数树(Radix Tree)节点。 * 每个节点存储一个字符串片段(substring),以及一个可选的值(如果该节点代表一个完整的键)。 * 子节点按照其片段的首字符存储在一个 std::map 中,以保证有序性。 */ template <typename ValueType> class RadixTreeNode { public: std::string fragment; // 节点存储的字符串片段 ValueType value; // 值(仅当 is_terminal 为 true 时有效) bool is_terminal; // 是否为一个完整键的终点 std::map<char, std::unique_ptr<RadixTreeNode>> children; // 子节点映射(按首字符) RadixTreeNode() : fragment(""), is_terminal(false) {} explicit RadixTreeNode(const std::string& frag) : fragment(frag), is_terminal(false) {} }; /** * 基数树(Radix Tree)类。 * 键类型为 std::string,值类型由模板参数指定。 */ template <typename ValueType> class RadixTree { private: using Node = RadixTreeNode<ValueType>; std::unique_ptr<Node> root; // 辅助函数:在两个字符串中查找最长公共前缀的长度 static size_t commonPrefixLength(const std::string& a, const std::string& b) { size_t len = std::min(a.size(), b.size()); size_t i = 0; while (i < len && a[i] == b[i]) ++i; return i; } // 递归插入辅助函数 void insert(Node* node, const std::string& key, const ValueType& val) { if (key.empty()) { // 键为空:直接标记当前节点为终点 node->is_terminal = true; node->value = val; return; } // 查找与当前节点片段的最长公共前缀 size_t common = commonPrefixLength(node->fragment, key); if (common == node->fragment.size()) { // 当前节点片段完全匹配键的前缀 std::string remaining = key.substr(common); if (remaining.empty()) { // 键正好在当前节点结束 node->is_terminal = true; node->value = val; return; } // 否则,检查子节点 char firstChar = remaining[0]; auto it = node->children.find(firstChar); if (it != node->children.end()) { // 递归插入到对应的子节点 insert(it->second.get(), remaining, val); } else { // 创建新的子节点 auto newNode = std::make_unique<Node>(remaining); newNode->is_terminal = true; newNode->value = val; node->children[firstChar] = std::move(newNode); } } else { // 需要分裂当前节点 // 公共前缀部分 std::string prefix = node->fragment.substr(0, common); std::string oldSuffix = node->fragment.substr(common); std::string newSuffix = key.substr(common); // 创建新节点作为当前节点的替代(前缀节点) auto newPrefixNode = std::make_unique<Node>(prefix); // 将原节点的子节点和终端状态转移给新节点(作为其后缀节点) auto oldSuffixNode = std::make_unique<Node>(oldSuffix); oldSuffixNode->is_terminal = node->is_terminal; oldSuffixNode->value = node->value; oldSuffixNode->children = std::move(node->children); // 移动子节点 // 将 oldSuffixNode 作为 newPrefixNode 的子节点 newPrefixNode->children[oldSuffix[0]] = std::move(oldSuffixNode); // 处理新插入的键 if (newSuffix.empty()) { // 键正好在公共前缀处结束 newPrefixNode->is_terminal = true; newPrefixNode->value = val; } else { // 创建新节点存储剩余部分 auto newSuffixNode = std::make_unique<Node>(newSuffix); newSuffixNode->is_terminal = true; newSuffixNode->value = val; newPrefixNode->children[newSuffix[0]] = std::move(newSuffixNode); } // 用 newPrefixNode 替换当前节点 // 由于 node 指针指向的是原节点的内容,我们需要通过父节点来更新。 // 在这里无法直接修改 node 本身,因为调用者持有的是父节点中 children 的指针。 // 因此,此辅助函数只能通过递归调用来实现,而分裂操作需要修改父节点。 // 为了避免复杂化,我们采用另一种设计:在插入时,使用非递归方法或传递父节点信息。 // 但为了简单起见,我们将分裂逻辑放在一个单独的函数中,由 insert 的调用者处理。 // 实际实现中,更好的方法是让 insert 返回一个 bool 或者使用迭代方式。 // 这里我们采用另一种策略:将分裂操作实现为单独的函数,并在递归时检测。 } } // 更完整的递归插入实现(处理节点分裂) // 由于上述逻辑在需要修改当前节点时无法直接替换,我们采用另一种递归结构: // 插入函数返回一个 std::unique_ptr<Node>,以便在需要时替换当前节点。 // 但为了保持接口简单,我们使用非递归的迭代方式实现插入。 // 这里我们提供一个迭代版的插入函数,以避免递归时替换节点的复杂性。 public: RadixTree() : root(std::make_unique<Node>()) {} // 插入键值对 void insert(const std::string& key, const ValueType& value) { if (key.empty()) { root->is_terminal = true; root->value = value; return; } Node* cur = root.get(); // 用于记录从根到当前节点的路径,以便在需要分裂时修改父节点 // 但分裂只发生在当前节点,我们可以通过遍历过程中维护父节点信息来更新。 // 使用迭代方式,同时记录当前节点及其父节点。 // 这里我们采用递归辅助函数,并传入父节点指针和当前节点在父节点中的映射键。 // 为了简化,我们实现一个递归辅助函数,它接受父节点和当前节点在父节点中的映射键。 // 但这样会使代码变得复杂。另一种方式:在节点内部实现插入逻辑,但需要节点知道自己在父节点中的位置。 // 更简单的方法:使用递归函数,返回是否进行了分裂,并让调用者处理替换。 // 我们这里采用一种更直观的递归方式:插入函数返回一个指向新节点的指针,如果当前节点被替换则返回新节点。 // 但为了不破坏原有的智能指针管理,我们使用以下递归辅助函数,它返回一个 bool 表示是否进行了替换。 // 为了方便,我们重新设计一个私有递归插入函数,它接受当前节点指针,并返回一个 bool 表示是否进行了修改。 // 但为了代码清晰,我们采用迭代方式实现插入,避免递归中的节点替换问题。 // 以下为迭代实现: std::vector<std::pair<Node*, char>> path; // 记录路径,用于回溯 while (true) { size_t common = commonPrefixLength(cur->fragment, key); if (common == cur->fragment.size()) { // 当前片段完全匹配 std::string remaining = key.substr(common); if (remaining.empty()) { // 键结束于当前节点 cur->is_terminal = true; cur->value = value; return; } // 检查子节点 char firstChar = remaining[0]; auto it = cur->children.find(firstChar); if (it != cur->children.end()) { // 继续向下 path.emplace_back(cur, firstChar); cur = it->second.get(); key = remaining; // 更新剩余键 } else { // 创建新子节点 auto newNode = std::make_unique<Node>(remaining); newNode->is_terminal = true; newNode->value = value; cur->children[firstChar] = std::move(newNode); return; } } else { // 需要分裂当前节点 // 分裂 cur 节点 std::string prefix = cur->fragment.substr(0, common); std::string oldSuffix = cur->fragment.substr(common); std::string newSuffix = key.substr(common); // 创建新的前缀节点 auto newPrefixNode = std::make_unique<Node>(prefix); // 创建旧后缀节点 auto oldSuffixNode = std::make_unique<Node>(oldSuffix); oldSuffixNode->is_terminal = cur->is_terminal; oldSuffixNode->value = cur->value; oldSuffixNode->children = std::move(cur->children); // 转移子节点 // 将旧后缀节点作为新前缀节点的子节点 newPrefixNode->children[oldSuffix[0]] = std::move(oldSuffixNode); // 处理新插入的键 if (newSuffix.empty()) { // 键在公共前缀处结束 newPrefixNode->is_terminal = true; newPrefixNode->value = value; } else { // 创建新后缀节点 auto newSuffixNode = std::make_unique<Node>(newSuffix); newSuffixNode->is_terminal = true; newSuffixNode->value = value; newPrefixNode->children[newSuffix[0]] = std::move(newSuffixNode); } // 用新前缀节点替换当前节点 // 如果当前节点是根节点,则直接替换 root if (cur == root.get()) { root = std::move(newPrefixNode); } else { // 找到父节点,替换其子节点 auto& parent = path.back().first; char parentKey = path.back().second; parent->children[parentKey] = std::move(newPrefixNode); } return; } } } // 查找键对应的值,如果存在则返回 true 并赋值给 value,否则返回 false bool find(const std::string& key, ValueType& value) const { Node* cur = root.get(); std::string remaining = key; while (cur) { size_t common = commonPrefixLength(cur->fragment, remaining); if (common != cur->fragment.size()) { // 不匹配 return false; } if (common == remaining.size()) { // 键已耗尽,检查当前节点是否为终点 if (cur->is_terminal) { value = cur->value; return true; } else { return false; } } // 继续匹配剩余部分 remaining = remaining.substr(common); char firstChar = remaining[0]; auto it = cur->children.find(firstChar); if (it == cur->children.end()) { return false; } cur = it->second.get(); } return false; } // 删除键,返回是否成功删除 bool erase(const std::string& key) { // 删除操作需要递归地删除节点,并在可能的情况下合并节点。 // 由于实现较复杂,这里给出一个简化版本,仅删除终端标记,不进行节点合并。 // 完整的删除需要处理节点合并,但为保持代码简洁,先提供基本删除。 // 实际生产环境中需要实现完整的节点合并逻辑。 return eraseRecursive(root.get(), key); } private: // 递归删除辅助函数,返回是否进行了删除 bool eraseRecursive(Node* node, const std::string& key) { if (!node) return false; // 这里需要实现完整的删除逻辑,包括合并节点。 // 为简化,只做标记删除。 // 实际应该递归查找并删除,然后检查是否可以将子节点合并到父节点。 // 限于篇幅,暂不实现完整合并。 // 用户可以自行扩展。 return false; // 占位 } public: // 遍历所有键值对,执行给定的回调函数 void traverse(std::function<void(const std::string&, const ValueType&)> callback) const { std::string currentPrefix; traverseRecursive(root.get(), currentPrefix, callback); } private: void traverseRecursive(const Node* node, std::string& currentPrefix, std::function<void(const std::string&, const ValueType&)> callback) const { if (!node) return; std::string newPrefix = currentPrefix + node->fragment; if (node->is_terminal) { callback(newPrefix, node->value); } for (const auto& [ch, child] : node->children) { traverseRecursive(child.get(), newPrefix, callback); } } }; // 示例使用 int main() { RadixTree<int> tree; tree.insert("apple", 1); tree.insert("app", 2); tree.insert("apricot", 3); tree.insert("banana", 4); tree.insert("band", 5); // 查找 int val; if (tree.find("apple", val)) { std::cout << "apple -> " << val << std::endl; } if (tree.find("app", val)) { std::cout << "app -> " << val << std::endl; } if (tree.find("apricot", val)) { std::cout << "apricot -> " << val << std::endl; } if (!tree.find("ape", val)) { std::cout << "ape not found" << std::endl; } // 遍历所有键值对 std::cout << "\nAll key-value pairs:\n"; tree.traverse([](const std::string& key, int val) { std::cout << key << " -> " << val << std::endl; }); return 0; } -
小规模 :节点分支数通常较小(平均 2-3 个),
map的 O(log n) 查找与unordered_map的 O(1) 差距不大。
若追求极致查找性能,可替换为 unordered_map,但需注意遍历时的顺序不确定性。
字符串比较的优化
在查找和插入中,频繁使用 commonPrefixLength 函数计算两个字符串的最长公共前缀。该函数内部逐字符比较,对于较长的片段可能成为瓶颈。一种优化方式是在节点中存储片段长度,并利用 SIMD 指令加速字符串比较,但在通用库中通常保持简单。
内存管理
使用 std::unique_ptr 管理子节点,确保节点在树销毁时自动释放,避免内存泄漏。移动语义(std::move)在分裂操作中转移子节点所有权,保证了异常安全。
源代码
RadixTree.h
cpp
#include <iostream>
#include <memory>
#include <map>
#include <string>
#include <vector>
#include <utility>
/**
* 基数树(Radix Tree)节点。
* 每个节点存储一个字符串片段(substring),以及一个可选的值(如果该节点代表一个完整的键)。
* 子节点按照其片段的首字符存储在一个 std::map 中,以保证有序性。
*/
template <typename ValueType>
class RadixTreeNode {
public:
std::string fragment; // 节点存储的字符串片段
ValueType value; // 值(仅当 is_terminal 为 true 时有效)
bool is_terminal; // 是否为一个完整键的终点
std::map<char, std::unique_ptr<RadixTreeNode>> children; // 子节点映射(按首字符)
RadixTreeNode() : fragment(""), is_terminal(false) {}
explicit RadixTreeNode(const std::string& frag) : fragment(frag), is_terminal(false) {}
};
/**
* 基数树(Radix Tree)类。
* 键类型为 std::string,值类型由模板参数指定。
*/
template <typename ValueType>
class RadixTree {
private:
using Node = RadixTreeNode<ValueType>;
std::unique_ptr<Node> root;
// 辅助函数:在两个字符串中查找最长公共前缀的长度
static size_t commonPrefixLength(const std::string& a, const std::string& b) {
size_t len = std::min(a.size(), b.size());
size_t i = 0;
while (i < len && a[i] == b[i]) ++i;
return i;
}
// 递归插入辅助函数
void insert(Node* node, const std::string& key, const ValueType& val) {
if (key.empty()) {
// 键为空:直接标记当前节点为终点
node->is_terminal = true;
node->value = val;
return;
}
// 查找与当前节点片段的最长公共前缀
size_t common = commonPrefixLength(node->fragment, key);
if (common == node->fragment.size()) {
// 当前节点片段完全匹配键的前缀
std::string remaining = key.substr(common);
if (remaining.empty()) {
// 键正好在当前节点结束
node->is_terminal = true;
node->value = val;
return;
}
// 否则,检查子节点
char firstChar = remaining[0];
auto it = node->children.find(firstChar);
if (it != node->children.end()) {
// 递归插入到对应的子节点
insert(it->second.get(), remaining, val);
} else {
// 创建新的子节点
auto newNode = std::make_unique<Node>(remaining);
newNode->is_terminal = true;
newNode->value = val;
node->children[firstChar] = std::move(newNode);
}
} else {
// 需要分裂当前节点
// 公共前缀部分
std::string prefix = node->fragment.substr(0, common);
std::string oldSuffix = node->fragment.substr(common);
std::string newSuffix = key.substr(common);
// 创建新节点作为当前节点的替代(前缀节点)
auto newPrefixNode = std::make_unique<Node>(prefix);
// 将原节点的子节点和终端状态转移给新节点(作为其后缀节点)
auto oldSuffixNode = std::make_unique<Node>(oldSuffix);
oldSuffixNode->is_terminal = node->is_terminal;
oldSuffixNode->value = node->value;
oldSuffixNode->children = std::move(node->children); // 移动子节点
// 将 oldSuffixNode 作为 newPrefixNode 的子节点
newPrefixNode->children[oldSuffix[0]] = std::move(oldSuffixNode);
// 处理新插入的键
if (newSuffix.empty()) {
// 键正好在公共前缀处结束
newPrefixNode->is_terminal = true;
newPrefixNode->value = val;
} else {
// 创建新节点存储剩余部分
auto newSuffixNode = std::make_unique<Node>(newSuffix);
newSuffixNode->is_terminal = true;
newSuffixNode->value = val;
newPrefixNode->children[newSuffix[0]] = std::move(newSuffixNode);
}
// 用 newPrefixNode 替换当前节点
// 由于 node 指针指向的是原节点的内容,我们需要通过父节点来更新。
// 在这里无法直接修改 node 本身,因为调用者持有的是父节点中 children 的指针。
// 因此,此辅助函数只能通过递归调用来实现,而分裂操作需要修改父节点。
// 为了避免复杂化,我们采用另一种设计:在插入时,使用非递归方法或传递父节点信息。
// 但为了简单起见,我们将分裂逻辑放在一个单独的函数中,由 insert 的调用者处理。
// 实际实现中,更好的方法是让 insert 返回一个 bool 或者使用迭代方式。
// 这里我们采用另一种策略:将分裂操作实现为单独的函数,并在递归时检测。
}
}
// 更完整的递归插入实现(处理节点分裂)
// 由于上述逻辑在需要修改当前节点时无法直接替换,我们采用另一种递归结构:
// 插入函数返回一个 std::unique_ptr<Node>,以便在需要时替换当前节点。
// 但为了保持接口简单,我们使用非递归的迭代方式实现插入。
// 这里我们提供一个迭代版的插入函数,以避免递归时替换节点的复杂性。
public:
RadixTree() : root(std::make_unique<Node>()) {}
// 插入键值对
void insert(const std::string& key, const ValueType& value) {
if (key.empty()) {
root->is_terminal = true;
root->value = value;
return;
}
Node* cur = root.get();
// 用于记录从根到当前节点的路径,以便在需要分裂时修改父节点
// 但分裂只发生在当前节点,我们可以通过遍历过程中维护父节点信息来更新。
// 使用迭代方式,同时记录当前节点及其父节点。
// 这里我们采用递归辅助函数,并传入父节点指针和当前节点在父节点中的映射键。
// 为了简化,我们实现一个递归辅助函数,它接受父节点和当前节点在父节点中的映射键。
// 但这样会使代码变得复杂。另一种方式:在节点内部实现插入逻辑,但需要节点知道自己在父节点中的位置。
// 更简单的方法:使用递归函数,返回是否进行了分裂,并让调用者处理替换。
// 我们这里采用一种更直观的递归方式:插入函数返回一个指向新节点的指针,如果当前节点被替换则返回新节点。
// 但为了不破坏原有的智能指针管理,我们使用以下递归辅助函数,它返回一个 bool 表示是否进行了替换。
// 为了方便,我们重新设计一个私有递归插入函数,它接受当前节点指针,并返回一个 bool 表示是否进行了修改。
// 但为了代码清晰,我们采用迭代方式实现插入,避免递归中的节点替换问题。
// 以下为迭代实现:
std::vector<std::pair<Node*, char>> path; // 记录路径,用于回溯
while (true) {
size_t common = commonPrefixLength(cur->fragment, key);
if (common == cur->fragment.size()) {
// 当前片段完全匹配
std::string remaining = key.substr(common);
if (remaining.empty()) {
// 键结束于当前节点
cur->is_terminal = true;
cur->value = value;
return;
}
// 检查子节点
char firstChar = remaining[0];
auto it = cur->children.find(firstChar);
if (it != cur->children.end()) {
// 继续向下
path.emplace_back(cur, firstChar);
cur = it->second.get();
key = remaining; // 更新剩余键
} else {
// 创建新子节点
auto newNode = std::make_unique<Node>(remaining);
newNode->is_terminal = true;
newNode->value = value;
cur->children[firstChar] = std::move(newNode);
return;
}
} else {
// 需要分裂当前节点
// 分裂 cur 节点
std::string prefix = cur->fragment.substr(0, common);
std::string oldSuffix = cur->fragment.substr(common);
std::string newSuffix = key.substr(common);
// 创建新的前缀节点
auto newPrefixNode = std::make_unique<Node>(prefix);
// 创建旧后缀节点
auto oldSuffixNode = std::make_unique<Node>(oldSuffix);
oldSuffixNode->is_terminal = cur->is_terminal;
oldSuffixNode->value = cur->value;
oldSuffixNode->children = std::move(cur->children); // 转移子节点
// 将旧后缀节点作为新前缀节点的子节点
newPrefixNode->children[oldSuffix[0]] = std::move(oldSuffixNode);
// 处理新插入的键
if (newSuffix.empty()) {
// 键在公共前缀处结束
newPrefixNode->is_terminal = true;
newPrefixNode->value = value;
} else {
// 创建新后缀节点
auto newSuffixNode = std::make_unique<Node>(newSuffix);
newSuffixNode->is_terminal = true;
newSuffixNode->value = value;
newPrefixNode->children[newSuffix[0]] = std::move(newSuffixNode);
}
// 用新前缀节点替换当前节点
// 如果当前节点是根节点,则直接替换 root
if (cur == root.get()) {
root = std::move(newPrefixNode);
} else {
// 找到父节点,替换其子节点
auto& parent = path.back().first;
char parentKey = path.back().second;
parent->children[parentKey] = std::move(newPrefixNode);
}
return;
}
}
}
// 查找键对应的值,如果存在则返回 true 并赋值给 value,否则返回 false
bool find(const std::string& key, ValueType& value) const {
Node* cur = root.get();
std::string remaining = key;
while (cur) {
size_t common = commonPrefixLength(cur->fragment, remaining);
if (common != cur->fragment.size()) {
// 不匹配
return false;
}
if (common == remaining.size()) {
// 键已耗尽,检查当前节点是否为终点
if (cur->is_terminal) {
value = cur->value;
return true;
} else {
return false;
}
}
// 继续匹配剩余部分
remaining = remaining.substr(common);
char firstChar = remaining[0];
auto it = cur->children.find(firstChar);
if (it == cur->children.end()) {
return false;
}
cur = it->second.get();
}
return false;
}
// 删除键,返回是否成功删除
bool erase(const std::string& key) {
// 删除操作需要递归地删除节点,并在可能的情况下合并节点。
// 由于实现较复杂,这里给出一个简化版本,仅删除终端标记,不进行节点合并。
// 完整的删除需要处理节点合并,但为保持代码简洁,先提供基本删除。
// 实际生产环境中需要实现完整的节点合并逻辑。
return eraseRecursive(root.get(), key);
}
private:
// 递归删除辅助函数,返回是否进行了删除
bool eraseRecursive(Node* node, const std::string& key) {
if (!node) return false;
// 这里需要实现完整的删除逻辑,包括合并节点。
// 为简化,只做标记删除。
// 实际应该递归查找并删除,然后检查是否可以将子节点合并到父节点。
// 限于篇幅,暂不实现完整合并。
// 用户可以自行扩展。
return false; // 占位
}
public:
// 遍历所有键值对,执行给定的回调函数
void traverse(std::function<void(const std::string&, const ValueType&)> callback) const {
std::string currentPrefix;
traverseRecursive(root.get(), currentPrefix, callback);
}
private:
void traverseRecursive(const Node* node, std::string& currentPrefix,
std::function<void(const std::string&, const ValueType&)> callback) const {
if (!node) return;
std::string newPrefix = currentPrefix + node->fragment;
if (node->is_terminal) {
callback(newPrefix, node->value);
}
for (const auto& [ch, child] : node->children) {
traverseRecursive(child.get(), newPrefix, callback);
}
}
};
测试代码
cpp
// 示例使用
int main()
{
RadixTree<int> tree;
tree.insert("apple", 1);
tree.insert("app", 2);
tree.insert("apricot", 3);
tree.insert("banana", 4);
tree.insert("band", 5);
// 查找
int val;
if (tree.find("apple", val)) {
std::cout << "apple -> " << val << std::endl;
}
if (tree.find("app", val)) {
std::cout << "app -> " << val << std::endl;
}
if (tree.find("apricot", val)) {
std::cout << "apricot -> " << val << std::endl;
}
if (!tree.find("ape", val)) {
std::cout << "ape not found" << std::endl;
}
// 遍历所有键值对
std::cout << "\nAll key-value pairs:\n";
tree.traverse([](const std::string& key, int val) {
std::cout << key << " -> " << val << std::endl;
});
return 0;
}
结果为:

本期内容到这里结束了,喜欢请点个赞谢谢
封面图自选:
