【C++进阶】万字长文详解STL set:从基本用法到红黑树底层原理

【C++进阶】万字长文详解STL set:从基本用法到红黑树底层原理

摘要/前言:

在C++开发中,当我们面临“去重”和“排序”的双重需求时,std::set 无疑是首选利器。但你是否想过,为什么它的查找效率是 O(logN)?为什么迭代器遍历是有序的?底层红黑树到底是如何维持平衡的?本文将从基础用法出发,深入剖析 set 的底层红黑树机制,并对比 unordered_set,助你在面试和实战中游刃有余。

目录

  1. 什么是 std::set?
  2. set 的全解
  3. 进阶:自定义类型的排序(仿函数)
  4. 核心:底层数据结构——红黑树解析
  5. 性能对比:set vs unordered_set
  6. 面试高频问答

1. 什么是 std::set?

std::set 是 C++ 标准模板库(STL)中的一种关联式容器
它的核心特性可以用三个词概括:

  • 有序:元素默认按照升序排列。
  • 唯一:容器中不允许存在重复元素(Key 就是 Value)。
  • 高效:插入、删除、查找的时间复杂度均为 O(log⁡N)O(\log N)O(logN)。

头文件:

#include<set>

2. set 的全解

2.1 构造与插入

insert 函数的返回值是一个 pair,包含一个迭代器和一个布尔值,用于判断插入是否成功。

#include<iostream>#include<set>usingnamespace std;intmain(){ set<int> s;// 1. 插入元素 s.insert(10); s.insert(20); s.insert(30); s.insert(10);// 重复插入,无效// 2. 检测插入结果 pair<set<int>::iterator,bool> ret = s.insert(20);if(ret.second){ cout <<"插入成功"<< endl;}else{ cout <<"插入失败,元素已存在"<< endl;// 输出这个}return0;}

2.2 查找与统计

千万不要用全局的 std::find 去查找 set,效率会退化为 O(N)O(N)O(N)。一定要用 set 自带的 find 成员函数

// 查找auto it = s.find(20);if(it != s.end()){ cout <<"找到了: "<<*it << endl;}// 统计(对于set,结果只能是0或1)if(s.count(30)){ cout <<"30存在"<< endl;}

2.3 删除

s.erase(20);// 删除指定值的元素// s.erase(it); // 删除迭代器指向的元素

3. 进阶:自定义类型的排序

这是新手最容易卡壳的地方。如果 set 中存放的是结构体或类,必须告诉 set 如何比较大小。

方式一:重载 operator<

structStudent{ string name;int score;// const 修饰非常重要booloperator<(const Student& other)const{// 按分数降序排列returnthis->score > other.score;}};

方式二:仿函数(Functor)

这种方式更灵活,不需要修改结构体源码。

structComp{booloperator()(const Student& a,const Student& b)const{return a.score > b.score;}};// 使用时指定比较器 set<Student, Comp> st;

4. 核心:底层数据结构——红黑树解析

面试官问 set,其实就是在问红黑树(Red-Black Tree)

4.1 为什么是红黑树?

std::set 底层通常实现为红黑树。它是一种自平衡的二叉搜索树(BST)

  • 为什么不用普通二叉搜索树?
    如果插入的数据是有序的(如 1, 2, 3, 4, 5),普通 BST 会退化成链表,查找效率从 O(log⁡N)O(\log N)O(logN) 降为 O(N)O(N)O(N)。
  • 为什么不用 AVL 树?
    AVL 树是“绝对平衡”的,查询效率极高,但为了维持绝对平衡,每次插入删除都需要大量的旋转操作。红黑树是“弱平衡”的,在插入删除的性能和查询性能之间取得了更好的折中。

4.2 红黑树的 5 条规则

红黑树通过以下规则保证最长路径不会超过最短路径的 2 倍:

  1. 每个节点不是红色就是黑色
  2. 根节点必须是黑色
  3. 如果一个节点是红色的,则它的两个子节点必须是黑色(不能有连续的红节点)。
  4. 对于每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数目的黑色节点
  5. 每个叶子节点(NIL节点)都是黑色的。

4.3 迭代器稳定性

set 的迭代器本质上是红黑树节点的指针。

  • 重要特性: 除了被删除的那个元素的迭代器外,set 的插入和删除操作不会导致其他迭代器失效。这与 vector 扩容导致所有迭代器失效完全不同。

5. 性能对比:set vs unordered_set

这是面试中的必考题,一张表讲清楚:

特性std::setstd::unordered_set
底层实现红黑树 (RB-Tree)哈希表 (Hash Table)
是否有序有序无序
查找/插入时间O(log⁡N)O(\log N)O(logN)平均 O(1)O(1)O(1),最坏 O(N)O(N)O(N)
内存占用较低(存节点指针)较高(需维护Bucket数组)
适用场景需要数据有序、范围查找纯粹的快速查找、去重

选择建议:

  • 如果你需要遍历输出有序数据,或者进行 lower_bound/upper_bound 范围查找,必选 set
  • 如果你只是为了单纯的快速去重,不在乎顺序,unordered_set

6. 面试高频问答 (Q&A)

Q1: set 中可以通过迭代器修改元素的值吗?

A:不可以。set 中的元素是 const 的。因为修改元素值可能会破坏红黑树的有序性。如果必须修改,正确的做法是:先 erase 旧元素,再 insert 新元素。

Q2: 红黑树查找的时间复杂度是多少?

A:O(log⁡N)O(\log N)O(logN)。因为红黑树的高度近似为 2log⁡N2\log N2logN。

Q3: 为什么 map/set 的插入效率比 vector 高?

A: 虽然 vector 尾插很快,但如果在中间插入,vector 需要移动后续所有数据。而 map/set 只需要修改节点指针和进行少量的旋转变色,不需要内存拷贝。

总结

std::set 是 C++ 程序员必须掌握的容器。理解其背后的红黑树原理,不仅能帮你写出更高效的代码,更是通往高阶开发的敲门砖。

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注!如有疑问,请在评论区留言。

Read more

【C++】深入浅出“图”——最短路径算法

【C++】深入浅出“图”——最短路径算法

文章目录 * 一、Dijkstra算法 * 二、Bellman_Ford算法 * 三、Floyd_Warshall算法 一、Dijkstra算法 最短路径问题是指,从在带权的有向图中从某一顶点出发,找到通往另一顶点的最短路径,“最短”指的是沿路径各边的权值总和最小。 Dijkstra算法是单源最短路径的经典贪心算法,只能用于没有负权的图。它从起点出发,每次选当前距离最小且未确定最短路径的节点,用它去松弛(更新)所有邻接点的最短路径估计值,标记该节点为 “已确定”,重复此过程直到所有节点处理完毕,最终得到起点到图中所有节点的最短路径。 // src是选定的起点,dist记录起点到各点的最短路径,pPath记录到每个点的最短路径的前驱顶点下标voidDijkstra(const V& src, vector<W>& dist, vector<int>& pPath){ size_t srci =GetVertexIndex(

By Ne0inhk
【Linux】线程池(一)C++ 手写线程池:基于策略模式实现高性能日志模块

【Linux】线程池(一)C++ 手写线程池:基于策略模式实现高性能日志模块

文章目录 * 池化技术 * 线程池的日志模块 * 日志与策略模式 * 日志模块 * 两个核心问题 * 设计文件等级 * 刷新策略 * 获取日志时间 * logger类实现 * 内部类LogMessage实现 * 日志刷新流程图及源码 池化技术 池化技术可以减少很多的底层重复工作,例如创建进程、线程、申请内存空间时的系统调用和初始化工作,例如线程池,先预先创建好一些线程,当任务到来时直接将预先创建好的线程唤醒去处理任务,效率会远远高于任务到来时临时创建线程。例如内存池,但我们要用1mb空间时内存池会一次性申请20mb空间,效率会远远高于用多少空间申请多少空间(申请空间会调用系统调用)。 线程池是执行流级别的池化技术,STL中的空间配置器和内存池是内存块管理级别的池化技术。 线程池的日志模块 下⾯开始,我们结合我们之前所做的所有封装,进⾏⼀个线程池的设计。在写之前,我们要做如下准备。 * 准备线程的封装 * 准备锁和条件变量的封装 * 引⼊日志,对线程进⾏封装 日志与策略

By Ne0inhk
【探寻C++之旅】C++ 智能指针完全指南:从原理到实战,彻底告别内存泄漏

【探寻C++之旅】C++ 智能指针完全指南:从原理到实战,彻底告别内存泄漏

前言 作为 C++ 开发者,你是否曾因以下场景头疼不已?函数中new了数组,却因异常抛出导致后续delete没执行,排查半天定位到内存泄漏;多模块共享一块内存,不知道该由谁负责释放,最后要么重复释放崩溃,要么漏释放泄漏;用了auto_ptr后,拷贝对象导致原对象 “悬空”,访问时直接崩溃却找不到原因。 如果你有过这些经历,那智能指针一定是你必须掌握的现代 C++ 工具。它基于 RAII 思想,自动管理动态资源,让你无需手动delete,从根源上减少内存泄漏风险。今天,我们就从 “为什么需要智能指针” 到 “不同智能指针的实战场景”,带你系统掌握这一核心特性。 请君浏览 * 前言 * 一、智能指针的诞生:解决手动管理内存的 “千古难题” * 1.1 一个典型的内存泄露场景 * 1.2 智能指针的核心:RAII 思想 * 二、C++ 标准库智能指针:

By Ne0inhk
【C++模版】泛型编程:代码复用的终极利器

【C++模版】泛型编程:代码复用的终极利器

目录 一、泛型编程 1.1 为什么需要泛型编程? 1.2 模板:泛型编程的基础 二、函数模板 2.1 函数模板的定义格式 2.2 函数模板的原理 2.3 函数模板的实例化 2.3.1 隐式实例化 2.3.2 显式实例化 2.4 模板参数的匹配原则 ☃. 小彩蛋: 模板中::的二义性问题 三、类模板 3.1 类模板的定义格式 3.2 类模板的实例化 四、非类型模板参数  4.1 核心概念与语法 经典案例:实现编译期定长数组

By Ne0inhk