ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

红黑树封装Map与Set:从底层原理到迭代器实现全解析

红黑树封装Map与Set:从底层原理到迭代器实现全解析 在C里摸爬滚打一段时间后基本都会遇到一个坎STL容器用过不少但一打开源码就懵。尤其是map和set用起来感觉挺简单一个存键值对一个存不重复元素可内部到底怎么工作、为什么能自动有序、迭代器怎么设计得这么统一很多人没深究过。这篇就围绕“封装”这个关键词把map和set从底层红黑树到上层接口的完整封装链路拆开讲透包括insert、迭代器、operator[]这几个最核心的模块。认真看完并跟着动手写一遍你对STL关联容器的理解会有一个质的提升以后再看源码不会发怵自己设计泛型容器也能少走很多弯路。这篇不打算贴一大堆STL源码然后逐行解释那样又臭又长看完就忘。我建议换个思路——用一棵通用的红黑树模板自己动手封装出map和set。这个练手项目做完你对封装、模板、迭代器、仿函数这些C进阶要点的理解都会更扎实。1. 内容整体设计与思路拆解1.1 为什么map和set能共用一棵树先说结论在底层map和set都是红黑树。这并非巧合而是标准库设计者的有意为之。红黑树是一种自平衡二叉搜索树能在O(logN)时间内完成查找、插入、删除并且保证从根到叶子的最长路径不超过最短路径的两倍不会像普通二叉搜索树那样退化成一个链表。实际场景里我们需要的是一个有序存放数据的容器set只关注键本身map则关注“键到值的映射”。无论哪种需求底层的排序、平衡、查找逻辑其实完全一样差别只在“节点里存了什么”“怎么比较大小”。以SGI STL也就是侯捷老师那本《STL源码剖析》讲的版本为代表的老牌实现里rb_tree是一个独立容器map和set都是它的封装rb_tree存的是pairconst Key, T时封装出来就是map。rb_tree存的是Key本身时封装出来就是set。也就是说map和set本身几乎没有任何数据成员只是一个壳内部持有或者说继承一棵tree。所有实际的插入、查找、删除、迭代操作最后都转发给这颗tree干。这里就有个关键问题了既然rb_tree是通用的它不能直接调用比较符去比key因为它不知道从value里怎么取出key。所以SGI STL引入了一个叫KeyOfValue的仿函数做“键值提取”。map传入的KeyOfValue提取pair.firstset传入的KeyOfValue直接返回value本身。排序用的Compare仿函数再对提取出来的key做比较。这个设计值得我们反复咀嚼。它本质上是把“行为”作为模板参数打进容器里。到了C11之后标准库提供了一组透明哈希std::unordered_map/set和树形容器功能越来越丰富但红黑树封装map/set的这套骨架级设计依然值得学习因为它是理解所有关联容器共同抽象的基础。1.2 手写容器要达到什么程度并不是要求你把整棵红黑树从零写完——虽然那样也可以但代码量太大一篇讲不完。更实际的方案是用red-black tree作为底层骨架但不追求完美实现所有的旋转细节可以先用AVL实现一个简化版再升级成红黑树。我建议的红黑树接口大致是值类型是模板参数Valuemap传pairconst Key, Tset传Key。树提供insert_unique、find、begin、end这些接口但对外不暴露节点指针。树内部维护一个header节点用来实现end()同时让begin()可以快速找到最左节点。搜一下网上的帖子“C手写红黑树”的代码通常动辄几百行但自己动手封装map和set并不需要把每行旋转代码都背下来核心目标是把容器语义正确实现。真正理解key to value的提取与compare的调用关系才是封装的重点。借助一个设计良好的简化红黑树我封装后的Map/Set对外表现和标准库保持基本一致。也就是默认构造支持insert、find、erase、size、empty。迭代器支持前、前--、解引用、箭头、!、。map额外支持operator[]。set不提供operator[]因为单个键没有关联值。至于要封装到什么程度算合格我的检验标准很简单写完这版代码能不能用自己的Map跑一段关联容器代码比如统计单词频次再跑一个有序存储的Set去重跟标准库输出一致。能就算通了。1.3 整体架构分层我实现的时候把代码分成三层第一层是红黑树节点只关心链接关系和颜色位。第二层是红黑树本体负责节点的插入、旋转、调整、查找、迭代器遍历。第三层是Map/Set外壳只暴露STL风格接口内部通过KeyOfValue和Compare去操作tree。迭代器也分两层树本身不直接暴露原始指针而是通过迭代器对象包装节点指针。Map和Set再对树的迭代器做typedef必要时再做适配。这样一层层包下来层次清晰调试时也好定位。分层设计还有个好处后面想增加任何功能比如支持逆向迭代、支持移动语义其实只改某一层就行上层基本不动。整体代码的可维护性和可扩展性比一梭子全写在容器里好得多。2. 核心细节解析与实操要点2.1 模板参数设计的门道先给出红黑树本体的模板声明骨架template class Key, class Value, class KeyOfValue, class Compare std::lessKey, class Alloc std::allocatorValue class RbTree;这里几个参数的职责要拎清楚Key逻辑上用来排序的键类型。Value节点里实际存储的数据类型。KeyOfValue一个仿函数声明大概长这样const Key operator()(const Value value) const。对map来说它返回value.first对set来说返回value本身。Compare键的比较逻辑默认小于号。如果想让Set和Map共用Tree关键就在KeyOfValue。看下面这对定义// 用同一个RbTree分别实例化成set和map的底层 using SetTree RbTreeint, int, Identityint, std::lessint; using MapTree RbTreeint, std::pairconst int, std::string, Select1ststd::pairconst int, std::string, std::lessint;注意看map的Value里pair的第一个参数是const int不是int。这一点很重要后面讲map的迭代器时会再提它从类型层面禁止了用户通过迭代器修改key。如果你写成pairint, string用户拿迭代器就可能把key改了树的有序性就崩了。标准库在这里的考虑是很精细的。再说说比较仿函数。如果Compare是std::less 比较时直接调a b。判断两个键是否相等标准库的做法不是a b而是bool equal !comp(a, b) !comp(b, a);也就是“既不大于也不小于”视作相等。这样做的好处是即使你的类型只重载了operator没有重载operator也能正常用来做关联容器。对自定义类型非常友好。在实现insert时这个判断尤其关键因为我们要“定位到第一个不小于目标键的位置”并且判断是否已经存在相同键。我犯过一个很低级的错误为了省事直接写a b判断相等结果传一个只有operator的自定义类型进来编译就报错。后来老老实实同步实现了等价判断才知道标准库这一手有多聪明。2.2 红黑树节点的内存布局红黑树节点本质上是一个带有颜色标记、三向链接的数据单元。SGI STL里面拆了半天搞出一个base节点存颜色和父、左、右指针一个子节点再挂value字段。我们不用照抄它的继承链写法但可以用它这个思路简化enum Color { RED, BLACK }; struct RbTreeNodeBase { Color color; RbTreeNodeBase* left; RbTreeNodeBase* right; RbTreeNodeBase* parent; }; template class Value struct RbTreeNode : public RbTreeNodeBase { Value value; };为什么这样设计第一红黑树的旋转和变色只看节点颜色和链接关系完全不需要知道value是什么。把它定义在base里意味着旋转代码可以只在base指针上操作不需要模板化这样能减少模板实例化带来的代码膨胀也能让旋转逻辑不依赖具体类型。第二节点本身是继承结构而迭代器内部只需要持有RbTreeNodeBase*访问value时再做向下转型static_cast到RbTreeNode *。这样迭代器本体对Value也是通用的。不过转发时还是要小心红黑树在调整平衡的过程中会频繁修改parent指针如果base和子类的内存布局没搞对或者漏了初始化color字段就会出现意向不到的崩溃。我一开始写的时候new出节点忘记把color设成RED结果无论怎么插树都变成全黑导致插入调整逻辑完全跑偏。2.3 header节点的作用红黑树遍历最大的一个问题是怎么判断“走到头了”。如果迭代器靠nullptr作为终点那么end()没法指向一个实际存在的节点而且rbegin()也没法简单表示。SGI STL的做法是在树的根部挂一个header节点它不存真实数据只是用来把“链表”首尾串起来header.parent指向真正的根节点。header.left指向树里最左最小的节点。header.right指向树里最右最大的节点。反过来根节点的parent指向header最左节点的left也指向header最右节点的right也指向header。这样一来begin()返回最左节点end()返回header树遍历到header就停止。reverse_iterator的rbegin()直接就是header.right。各种边界条件都被统一到了一个闭合环路上代码写得干净也不容易出off-by-one错误。不过要注意如果树是空的header.left、header.right、header.parent都应该指向header自己。你得在构造函数里把这几个指针都正确初始化否则空树调用begin()和end()时直接踩内存。2.4 旋转与重平衡红黑树平衡的规则可以简明概括为每个节点非红即黑。根是黑色。红色节点的两个子节点都是黑色即不能有连续红节点。任一节点到其每个叶子的路径上黑色节点数相同。这是“最长路径 2倍最短路径”的充分条件。实现中最复杂的部分是在插入后修复平衡。插入节点默认染红这样不会破坏第4条然后循环处理叔叔节点的颜色必要时做旋转。旋转分左旋和右旋核心是保持二叉搜索树的中序性质不变只改变局部链接。这里不把旋转代码完整贴出来但说几个特别容易写错的地方旋转函数必须处理好“空孩子”和“根节点parent是header”这两个边界。左旋时x的右孩子不能为空右旋时x的左孩子不能为空。旋转之后要先把新根和它父节点的连接关系更新好再更新孩子和父节点的parent指针。我见过不少网上代码在旋转里少写一行“node-parent parent”结果一跑就段错误。所以针对红黑树我强烈建议每写完一次旋转和变色立刻用“性质检查”的函数自测而不是等整个树写完再去调试。3. 实操过程与核心环节实现3.1 树的insert先按BST插入再修复红黑树的insert_unique逻辑分两大块走二叉搜索树插入流程找到位置并挂上去。调用insert_fixup根据叔叔节点颜色分情况变色或旋转。先看第一块的关键代码伪代码级别的简化template class Key, class Value, class KeyOfValue, class Compare std::pairtypename RbTreeKey, Value, KeyOfValue, Compare::iterator, bool RbTreeKey, Value, KeyOfValue, Compare::insert_unique(const Value value) { RbTreeNodeBase* x header-parent; // 根 RbTreeNodeBase* y header; // parent bool comp_val true; KeyOfValue key_of_value; Compare comp; const Key key key_of_value(value); while (x) { y x; comp_val comp(key, key_of_value(static_castRbTreeNodeValue*(x)-value)); if (comp_val) { x x-left; } else { x x-right; } } // 此时y是待插入位置的父节点 RbTreeNodeBase* parent y; RbTreeNodeValue* new_node create_node(value); new_node-color RED; if (y header || comp_val) { y-left new_node; } else { y-right new_node; } new_node-parent y; insert_fixup(new_node); // 更新header-left和header-right return {iterator(new_node), true}; }注意在循环里每个节点都做一次key提取。这一步调用很频繁所以KeyOfValue的operator()最好内联。这也是为什么标准库要求它轻量别在里面塞复杂逻辑。如果你在KeyOfValue里写了几百行计算就不要怪程序变慢。这里还有一个细节y初始化为headerx是根。当树为空时x为nullptr循环不执行y还是header所以新节点的父节点就是header。然后在if (y header || comp_val)分支里挂到header.left。之后要记得把header.parent设成new_node。这个“空树时新节点挂在header左边”的细节特别容易漏漏了的话begin()就找不对节点。真正的关键在于insert_fixup这是红黑树插入的重头戏。概括地讲分为三大类情况情况一叔叔是红色。直接变色把祖父变红父和叔叔变黑然后继续把祖父当作新节点向上走。情况二叔叔是黑色且当前节点是右孩子。对父节点左旋转换成情况三。情况三叔叔是黑色且当前节点是左孩子。对祖父右旋并交换父、祖父颜色。我在写红黑树时喜欢把fixup做成一个独立函数并在里面加循环和注释。给一个很短的伪代码版结构void insert_fixup(RbTreeNodeBase* node) { while (node ! header-parent node-parent-color RED) { if (node-parent node-parent-parent-left) { RbTreeNodeBase* uncle node-parent-parent-right; if (uncle uncle-color RED) { // 情况一 node-parent-color BLACK; uncle-color BLACK; node-parent-parent-color RED; node node-parent-parent; } else { if (node node-parent-right) { // 情况二 node node-parent; rotate_left(node); } // 情况三 node-parent-color BLACK; node-parent-parent-color RED; rotate_right(node-parent-parent); } } else { // 镜像对称逻辑 } } header-parent-color BLACK; }这段逻辑就是以“父是左孩子”为基准把镜像对称的内部逻辑换成右孩子即可。写镜像的时候最容易粗心我建议把左、右两分支分开写不要硬抽公共函数否则非节点的索引越界问题会让你很难受。3.2 迭代器实现从节点到统一抽象迭代器说穿了就是一层结构体里面存一个节点指针再重载几个运算符template class Value, class Ref, class Ptr struct RbTreeIterator { using difference_type std::ptrdiff_t; using value_type Value; using reference Ref; using pointer Ptr; using iterator_category std::bidirectional_iterator_tag; RbTreeNodeBase* node; RbTreeIterator() : node(nullptr) {} explicit RbTreeIterator(RbTreeNodeBase* p) : node(p) {} reference operator*() const { return static_castRbTreeNodeValue*(node)-value; } pointer operator-() const { return (operator*()); } RbTreeIterator operator() { node rb_tree_increment(node); return *this; } RbTreeIterator operator(int) { RbTreeIterator tmp(*this); *this; return tmp; } RbTreeIterator operator--() { node rb_tree_decrement(node); return *this; } RbTreeIterator operator--(int) { RbTreeIterator tmp(*this); --*this; return tmp; } friend bool operator(const RbTreeIterator lhs, const RbTreeIterator rhs) { return lhs.node rhs.node; } friend bool operator!(const RbTreeIterator lhs, const RbTreeIterator rhs) { return !(lhs rhs); } };和--为什么不能用普通指针移动因为树节点不是连续内存没法通过地址1走到下一个中序节点。得靠树的遍历规则increment找后继如果当前节点有右孩子就一直往左走否则向上回溯直到某个祖先节点是它父节点的左孩子那这个祖先就是后继。decrement找前驱如果当前节点有左孩子就一直往右走否则向上回溯直到某个祖先节点是它父节点的右孩子那个祖先就是前驱。这里有一类经典的大坑处理header节点。因为header既不在最左也不在最右它被设计成一个特殊哨兵。对end()做违背语义但偶尔会有人误操作时应该仍然回到begin()对begin()做--应该跳到end()等等。这些操作在释放迭代器时最容易被忽略但最好用测试用例覆盖到。3.3 Map的operator[]一行代码背后的巧妙语义Map的operator[]几乎是STL里语法糖最浓的一个接口。函数声明长这样T operator[](const Key key);它的行为是如果key存在返回对应value的引用如果不存在先用默认构造创建一个value插入然后返回它的引用。这个语义极其顺手可以一行完成“查找或插入”m[apple] 5;很多人以为这行代码就是“给键apple赋值为5”但它的真实操作是先尝试插入“apple - 默认构造的int0”发现已经存在或者刚插入拿到迭代器再对value赋值为5。如果key不存在它会改变容器大小。具体实现就一行template class Key, class T, class Compare T MapKey, T, Compare::operator[](const Key key) { return insert(std::make_pair(key, T())).first-second; }但这里有个性能以及可读性的老问题即使key已经存在上面这行也会先临时构造一个pair哪怕那T的默认构造开销很大白白浪费一次构造并拷贝。标准库后来在C11里加入了try_emplace和insert_or_assign避免了不必要的构造还能把右值语义利用起来。更值得注意的问题是operator[]无法告诉调用者“这次插入还是已有”。如果程序需要知道key是否已存在更精确的写法是直接用insert的返回值它会给你pairiterator, bool。bool就是“是否新插入成功”的标记。很多面试题会问“map的operator[]和insert有什么区别”其实就是在考察这一点。3.4 set的迭代器和键值安全Set的迭代器有一个和直觉不太一样的地方标准库的set ::iterator也基本只能读不能写。虽然它内部可能不是直接typedef const_iterator但实际效果是你无法通过set的迭代器去修改元素。换句话说set迭代器的reference类型是const T不是T。这跟map的迭代器行为形成对比map的iterator可以修改value不能修改key因为key是const。set的iterator整个元素都不许改。这个设计背后的道理不难理解set的元素本身就是排序的键如果把键改了树的有序性瞬间被破坏后续查找和插入全部出错。所以哪怕实现上允许标准库也从类型层面封死了这条路。在实现层面我建议直接把Set的iterator和const_iterator都typedef成Tree::const_iterator。这也是很多教学实现会采用的办法template class Key, class Compare class Set { public: using iterator typename TreeType::const_iterator; using const_iterator typename TreeType::const_iterator; // ... };3.5 构造、拷贝和析构封装容器时还有一个容易忽略的部分三大件。不是说可以不写而是要让它们语义正确。红黑树内部持有节点在堆上动态分配的内存所以经典的Rule of Three在这里全得用上拷贝构造需要复制整棵树包括所有节点颜色和层级关系。拷贝赋值最好是先拷贝出一棵临时树再和当前树交换copy-and-swap这样异常安全容易保证。析构递归或迭代方式释放全部节点。对于Map和Set外层因为它们只是持有Tree成员编译器默认生成的拷贝构造、拷贝赋值、析构都会自动调用Tree对应的函数所以外部壳通常不需要自己写。但Tree必须写正确。一个很实际的建议在实现时给RbTree提供一个clear()函数析构直接调它。这样调用方还可以随时清空整个容器复用不用重新创建对象。3.6 完整封装后的小实验封装完之后我自己跑了这样一段测试代码#include Map.h #include Set.h #include iostream #include string int main() { // map测试统计字符出现次数 Mapstd::string, int counter; const char* words[] {hello, world, hello, cpp}; for (auto w : words) { counter[w]; } for (Mapstd::string, int::iterator it counter.begin(); it ! counter.end(); it) { std::cout it-first - it-second std::endl; } // set测试去重 Setint s; int vals[] {3, 1, 4, 1, 5, 9, 2, 6, 5}; for (int v : vals) { s.insert(v); } for (Setint::const_iterator it s.begin(); it ! s.end(); it) { std::cout *it ; // 输出 1 2 3 4 5 6 9 } std::cout std::endl; }跑下来和std::map / std::set行为对齐这个练习就算圆满了。如果对输出顺序有疑虑可以顺便打印一下size和empty验证边界条件。4. 常见问题与排查技巧实录4.1 迭代器越界导致死循环出现这种现象最常见的原因是increment函数里没有处理“当前节点是树的最右节点”的情况。此时按规则向上回溯可能一路回溯到header如果代码没写“如果node header就返回header”就会在循环里卡死。检查方法专门写一个测试用例插入1、2、3、4、5然后从begin()循环打印每一个值并在最后一次之后检查迭代器是否等于end()。如果在第5个元素处死循环基本就是increment函数处理边界有问题。4.2 值类型写成pairKey,T而不是pairconst Key,T有时为了图方便有人直接把pairKey,T作为树节点值类型结果一切看上去都正常。问题出在后续使用时你拿到了一个iterator然后做了一个匪夷所思的操作it-first something编辑器不报错。这样就把key改了树直接坏掉。正确做法是树节点存储pairconst Key, T。如果你真的存储了pairKey, T迭代器会返回一个允许修改Key的引用这跟标准库给用户的安全保证不一致。所以我在实现map时Value一律用using Value std::pairconst Key, T;4.3 KeyOfValue写错导致map排序失效如果你写了一个独立的Select1st仿函数但返回的是pair里的first副本而不是引用那么每比较一次都会发生一次pair拷贝非常低效。更糟糕的是如果你误返回secondmap就会按关联值排序而不是按键排序。正确写法的灵魂是返回const Keytemplate class Pair struct Select1st { const typename Pair::first_type operator()(const Pair p) const { return p.first; } };对于set则写Identitytemplate class T struct Identity { const T operator()(const T x) const { return x; } };4.4 内存泄漏还是重复释放红黑树析构或clear时要遍历每个节点delete。由于节点不是线性排列递归析构最简单但可能因为递归深度过大而在极端情况下爆栈。实际测试中插入10万个节点用递归析构没问题但插入100万或千万级数据建议改成显式栈的迭代遍历释放。我在调试时遇到过“崩溃在delete节点”的场景最后定位到是节点之间还残留着相互引用的parent导致析构时把同一个节点释放了两次。后来在clear里先把根节点摘下来再递归清子树并且把父指针置空问题就没了。4.5 快速自查红黑树正确性我强烈建议给Tree加一个isValid()函数用来检查红黑树是否还满足所有性质。尤其在写完insert_fixup后跑随机插入测试每次插入后判断根节点是黑色。不存在连续的红色父子。任意节点到其叶子路径上的黑色节点数一致。中序遍历结果非降序。封装map和set之后可以跑随机测试对比std::map/std::set的输出。这个验证方式能帮你把“旋转没对称”“颜色翻转漏一个”“属性没更新”这类问题尽早揪出来。4.6 封装后对外接口不一致Map和Set的insert参数类型其实不一样Map::insert接受pairconst Key, T。Set::insert接受const Key。如果你让Map直接转发给Tree::insert_uniqueTree要求Value类型pairconst Key, T和传入参数类型一致用户直接insert(key, value)就会报错。标准库的map支持两种写法我的做法是提供两个重载std::pairiterator, bool insert(const value_type value); template class P std::pairiterator, bool insert(P value);前者是主接口后者兼容右值语义和隐式转换。封装的时候注意接口一致性不然用户用起来会和标准库对不上劝退效果一流。5. 我在这个项目里的几条实操心得这个项目做完我对C模板和容器的理解真的上了个台阶。有些感受不吐不快第一亲手写一遍红黑树封装map/set比刷十道模板题都有用。很多知识点比如仿函数做策略参数、traits思想、迭代器萃取单独学总觉得抽象但放到这个项目里一切都有了落点。第二红黑树的调试和普通业务代码完全不是一个路子。我的经验很简单不要靠肉眼盯着代码找bug一定要靠性质检查函数辅助。每次调整完树就跑一连串isValid()它报告哪条不满足就去查对应的逻辑分支十分钟就能定位到问题。靠想象去推导几十行的插入调整逻辑效率极低。第三如果觉得红黑树直接写太硬核可以先用std::map的底层结构对照来写。网上有各种教学源码别直接抄最好先把逻辑理清楚然后自己默写一遍。默写不出来的地方记下来回头对照看看差在哪这一遍下来基本不会再忘。这个后续还能扩展的方向也很多给Set加lower_bound/upper_bound给Map支持try_emplace给Tree加上逆向迭代器甚至试着把你的Map放到std::sort这类算法里用都是很好的进阶练习。哪怕只是把这篇文章里的代码全部自己敲一遍并跑通对C的提升也绝对超过看十篇纯理论博客。
返回列表