典型数据结构概括
一、顺序表
以下是对C++中顺序表(顺序存储结构)的详细解析,结合核心实现原理与代码示例:
1、顺序表的基本概念
顺序表是线性表的顺序存储结构,其特点为:
-
逻辑相邻性:逻辑上相邻的元素在物理存储地址上也相邻。
-
随机访问:通过首地址和元素索引可在O(1)时间内访问任意元素。
-
存储密度高:仅存储数据元素,无额外指针开销。
-
动态扩展性:动态分配方式支持容量调整(需手动或自动扩容)
2、顺序表的结构定义
1. 静态分配
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE]; // 静态数组
int length; // 当前元素个数
} SqList;
- 缺点:容量固定,无法动态扩展
2. 动态分配
typedef struct {
int *elems; // 动态数组基地址
int length; // 当前元素个数
int size; // 总容量
} SqList;
- 优点:支持动态扩容(如
realloc或new重新分配)
3、顺序表的核心操作
1. 初始化与销毁
// 初始化动态顺序表
bool initList(SqList &L, int initSize) {
L.elems = new int[initSize];
if (!L.elems) return false; // 内存分配失败
L.length = 0;
L.size = initSize;
return true;
}
// 销毁顺序表
void destroyList(SqList &L) {
if (L.elems) delete[] L.elems;
L.length = L.size = 0;
}
- 关键点:需检查内存分配是否成功
2. 插入元素
bool insertList(SqList &L, int index, int value) {
if (index < 0 || index > L.length) return false; // 索引越界
if (L.length == L.size) { // 容量不足时扩容
int newSize = L.size * 2;
int *newElems = new int[newSize];
memcpy(newElems, L.elems, L.length * sizeof(int));
delete[] L.elems;
L.elems = newElems;
L.size = newSize;
}
for (int i = L.length; i > index; i--) { // 元素后移
L.elems[i] = L.elems[i-1];
}
L.elems[index] = value;
L.length++;
return true;
}
- 时间复杂度:平均O(n),最坏O(n)(需移动所有元素)
3. 删除元素
bool deleteList(SqList &L, int index) {
if (index < 0 || index >= L.length) return false;
for (int i = index; i < L.length - 1; i++) { // 元素前移
L.elems[i] = L.elems[i+1];
}
L.length--;
return true;
}
- 时间复杂度:平均O(n)
4. 查找元素
// 按值查找
int findValue(SqList &L, int value) {
for (int i = 0; i < L.length; i++) {
if (L.elems[i] == value) return i;
}
return -1;
}
// 按索引访问
int getValue(SqList &L, int index) {
if (index >= 0 && index < L.length)
return L.elems[index];
else
throw out_of_range("Invalid index");
}
-
按值查找:时间复杂度O(n)
-
按索引访问:时间复杂度O(1)
4、顺序表的优缺点
优点:
-
高效随机访问:适合频繁查询的场景。
-
内存紧凑:无额外指针开销,存储密度高
缺点:
-
插入/删除效率低:需移动大量元素。
-
扩容成本高:动态分配需复制全部元素
5、C++标准库中的顺序表(vector)
-
自动扩容:容量不足时按1.5或2倍扩容(不同编译器实现不同)
-
接口丰富:支持
push_back、insert、erase等操作。 -
示例代码:
#include <vector>
using namespace std;
vector<int> vec; // 初始化
vec.push_back(10); // 尾部插入
vec.insert(vec.begin(), 5); // 头部插入
vec.erase(vec.begin() + 1); // 删除第二个元素
6、应用场景
-
适合场景:数据量稳定、查询频繁(如缓存、静态数据集)。
-
不适用场景:频繁插入/删除(此时链表更优)
总结
顺序表是基础数据结构,理解其实现原理对优化程序性能至关重要。在实际开发中,优先使用标准库vector以简化内存管理。手写顺序表时需注意索引合法性检查和动态扩容策略,避免内存泄漏与越界访问。
二、链表
链表是计算机科学中一种基础且重要的动态数据结构,与顺序表(数组)形成互补,适用于需要频繁插入/删除的场景。以下是链表的核心要点及与顺序表的对比:
1、链表的基本结构与类型
-
基本结构 链表由节点(Node)构成,每个节点包含两部分:
-
数据域:存储数据元素(如整型、对象等)。
-
指针域:指向其他节点的地址(单链表仅含一个指针,双向链表含两个指针)
-
-
主要类型
-
单链表:每个节点包含指向后继的指针(
next),仅支持单向遍历。struct Node { int data; Node* next; }; -
双向链表:节点包含前驱(
prev)和后继(next)指针,支持双向遍历。struct DoublyNode { int data; DoublyNode *prev, *next; }; -
循环链表:尾节点指针指向头节点,形成闭环。分单向和双向循环链表
-
带头链表:含一个不存储数据的头节点,简化插入/删除逻辑
-
2、链表的核心操作
-
基本操作
-
插入:在任意位置插入节点仅需调整相邻节点的指针,时间复杂度O(1)。例如,单链表插入步骤:
-
创建新节点。
-
新节点的
next指向插入位置的后继节点。 -
前驱节点的
next指向新节点
-
-
删除:直接修改指针跳过待删节点,时间复杂度O(1)。
-
遍历:从头节点开始顺序访问,时间复杂度O(n),无法随机访问
-
-
动态内存管理
-
节点通过
malloc(C)或new(C++)动态分配内存,需手动释放避免内存泄漏。例如:Node* newNode = new Node{value, nullptr}; // 动态分配 delete node; // 释放内存 -
内存泄漏风险:若未释放已删除的节点,会导致内存浪费
-
3、链表的优缺点
-
优势
-
动态扩展:无需预先分配固定内存,按需申请节点,避免空间浪费。
-
高效增删:插入/删除仅需调整指针,时间效率高,适合频繁修改的场景(如事件队列、内存管理)
-
-
劣势
-
访问效率低:查找需遍历,时间复杂度O(n)。
-
空间开销大:每个节点需额外存储指针,存储密度低于顺序表。
-
缓存命中率低:节点物理地址分散,无法利用CPU缓存预加载机制
-
4、链表的应用场景
-
动态数据集合 适用于元素数量不固定且频繁增删的场景(如实时日志记录、任务调度)
-
复杂数据结构的基础
-
内存管理:操作系统用链表管理空闲内存块。
-
图结构:邻接表表示图的连接关系。
-
LRU缓存淘汰算法:通过链表维护访问顺序
-
-
特定算法需求
-
大整数运算:链表可存储超长整数。
-
深度优先搜索(DFS):递归栈的替代实现
-
5、链表与顺序表的对比
| 特性 | 顺序表 | 链表 |
|---|---|---|
| 存储方式 | 连续内存 | 离散内存 |
| 随机访问 | 支持(O(1)) | 不支持(O(n)) |
| 插入/删除效率 | 需移动元素(O(n)) | 仅调整指针(O(1)) |
| 空间利用率 | 高(无额外指针) | 低(指针占用额外空间) |
| 扩容成本 | 需重新分配内存 | 按需动态分配 |
| 缓存命中率 | 高(连续存储) | 低(地址分散) |
| 适用场景 | 查询频繁、数据量稳定 | 增删频繁、数据动态变化 |
6、C++标准库中的链表实现
-
std::list:双向链表的实现,支持高效插入/删除,提供迭代器操作。例如:#include <list> std::list<int> myList = {1, 2, 3}; myList.push_back(4); // 尾部插入 myList.erase(it); // 删除指定位置元素 -
std::forward_list:单链表的轻量级实现,内存开销更小
总结
链表通过动态内存管理和指针操作实现了灵活的数据存储,适合频繁修改数据的场景,但需权衡其访问效率与空间开销。在实际开发中,可根据需求选择顺序表或链表,或结合两者优势(如动态数组std::vector与链表std::list的互补使用)。理解链表的底层原理对优化算法(如反转链表、合并有序链表)和解决复杂问题(如环形链表检测)至关重要
三、跳跃表
1、核心结构:
| 层级 | 节点元素序列(值, 层级) |
|---|---|
| 0 | —> {4 , 3} —> {5 , 0} —> {16,0} —> {27,5} —> {34,0} —> {47,2} —> {50,0} |
| 1 | —> {4 , 3} --------------------------------> {27,5} ------------------> {47,2} |
| 2 | —> {4 , 3} --------------------------------> {27,5} ------------------> {47,2} |
| 3 | —> {4 , 3} --------------------------------> {27,5} |
| 4 | -----------------------------------------------> {27,5} |
| 5 | -----------------------------------------------> {27,5} |
2、处理逻辑:
插入:
从最高层级开始向下遍历层级所对应的下一级数据,如果插入数据比当前数据小,则跳过当前数据,前往下一层;如果当前数据比插入数据小,则将当前数据的next域更新为此数据,直到不再有比插入数据小的数据存在。依次执行此逻辑直到遍历完跳跃表的所有层级。
例:欲插入数据:23
则处理逻辑为:创建一个新的update节点,先查看第六层的下一级,是{27,5},因为27>23,所以不做操作转向下一层。第五层同理,转到第四层。第四层的下一级为{4 , 3},因为23>4,则update[3]更新为当前{4 , 3}节点,而后继续跟下一级比较,是{27,5},故不操作转下一层。第三层和第二层同理,并且是从{4 , 3}节点开始向下一级比较。最后一层处理逻辑相同,最终update[0]将指向{16,0}节点。
然后更新跳跃表,创建一个节点s存放23数据,随机选取一个层级,然后根据temp[0~5]的位置插入23数据。
每一层:
s->forward[i] = update[i]->forward[i]; // 将s的下一级更新为update[i]的下一级
update[i]->forward[i] = s; // 将update[i]的下一级更新为s
此时(设23的层级为4)跳跃表结构变为:
| 层级 | 节点元素序列(值, 层级) |
|---|---|
| 0 | —> {4 , 3} —> {5 , 0} —> {16,0} —> {23,4} --> {27,5} —> {34,0} —> {47,2} —> {50,0} |
| 1 | —> {4 , 3} --------------------------------> {23,4} --> {27,5} ------------------> {47,2} |
| 2 | —> {4 , 3} --------------------------------> {23,4} --> {27,5} ------------------> {47,2} |
| 3 | —> {4 , 3} --------------------------------> {23,4} --> {27,5} |
| 4 | -----------------------------------------------> {23,4} --> {27,5} |
| 5 | -------------------------------------------------------------> {27,5} |
3、核心构建:
插入:
bool InsertNode(SkipList* plist, KeyType key, int value) {//插入
assert(plist != nullptr);
bool res = false;
SkipListNode* update[MAX_LEVEL + 1] = {0};//创建一个包含所有层级的指针数组
//用第0号位表next域
//利用这个数组记录新的值在多层级内的位置
//具体操作参见资源文件InsertNode.png
SkipListNode* p = plist->head;
for (int i = plist->level; i >= 0; --i) {//外循环表层级的改变
while (nullptr != p->forward[i] && p->forward[i]->key < key) {//内循环表位置的变换
p = p->forward[i];
}
update[i] = p;
//记录新的值在所有层级的位置
//每个层级记录的都是上一个比新值小的值所在位置
}
p = p->forward[0];
//在最底层退出时p指向了前一个比新值小的值
//此时向后一位就是新值应该处于的地址
//当想要插入的值是表内最大值时,指针指向nullptr
if (nullptr == p || p->key != key) {
res = true;
int newlevel = randomlevel();//为新值随机一个新层高
if (newlevel > plist->level) {//如果新层高是跳跃表中的最高层(但是比MAX_LEVEL小)
for (int i = plist->level + 1; i <= newlevel; ++i) {
update[i] = plist->head;//?
}
plist->level = newlevel;
}
SkipListNode* s = BuyNode(newlevel);
s->key = key;
s->value = value;
s->level = newlevel;
for (int i = 0; i <= newlevel; ++i) {
//assert(s->forward[i] != nullptr && update[i] != nullptr);
s->forward[i] = update[i]->forward[i];
update[i]->forward[i] = s;
}
}
return res;
}
查询:
SkipListNode* SearchKey(const SkipList* plist, KeyType key) {//搜索
assert(plist != nullptr);
SkipListNode* p = plist->head;
for (int i = plist->level; i >= 0; --i) {
//i初始化为最高层,从最高层开始查找
while (nullptr != p->forward[0] && p->forward[i]->key < key) {
p = p->forward[i];
/*
* 如果i没有到最底层(0层)则在高层之间快速跳跃
* 如果i到达底层则变为顺序查找,直到找到当前值不小于目标值时退出
* 如果当前值更大,就跳转到下一个多层(或底层,如果i为0)结点
*/
}
}
p = p->forward[0];
if (nullptr != p && p->key != key) {
p = nullptr;//如果没有找到
//注意这个写法,并不是找到后返回p,很俏皮
}
return p;
}
移除:
bool RemoveNode(SkipList* plist, KeyType key) {
assert(plist != nullptr);
SkipListNode* p = plist->head;
SkipListNode* update[MAX_LEVEL + 1] = { 0 };
//用来更新层级指针的中间位
//0位表示next域
for (int i = plist->level; i >= 0; --i) {//外循环表示层级的改变
while (nullptr != p->forward[i] && p->forward[i]->key < key) {//内循环表示位置的变换
p = p->forward[i];
}
update[i] = p;
//记录查找值在所有层级的位置
//每个层级记录的都是上一个比查找值小的值所在位置
}
p = p->forward[0];
//在最底层退出时p指向了前一个比查找值小的值
//此时向后一位就是查找值应该处于的地址
//然后开始删除↓
if (nullptr != p && p->key == key) {
for (int i = 0; i <= plist->level; ++i) {
if (update[i]->forward[i] != p) {
break;
}
update[i]->forward[i] = p->forward[i];
}
//for(int i = 0; i <= p->level; ++i) {
// update[i]->forward[i]=p->forward[i];
//}
FreeNode(p);
--plist->cursize;
while (plist->level > 0 && plist->head->forward[plist->level] == nullptr) {
--plist->level;
//如果删除了当前元素导致跳跃表最高层减少
//则我们把跳跃表的最高层减少
}
return true;
}
return false;
}
四、栈
1、栈的定义与特性
栈(Stack)是一种先进后出(LIFO)的线性数据结构,仅允许在栈顶(Top)进行插入(Push)和删除(Pop)操作。其核心特性包括:
-
受限访问:只能通过栈顶操作元素,无法直接访问中间或底部元素。
-
动态或静态实现:可用数组(静态栈)或链表(动态栈)实现,前者固定大小,后者灵活扩容
-
操作复杂度:入栈(Push)和出栈(Pop)的时间复杂度均为 O(1),查询中间元素需遍历,复杂度为 O(n)
2、栈的实现方式
1. 静态栈(数组实现)
-
原理:使用数组存储元素,栈顶指针
top初始化为 -1,标识空栈。 -
关键代码示例:
class Stack { int maxTop, top; double* values; public: Stack(int size) { maxTop = size - 1; values = new double[size]; top = -1; } void Push(double x) { if (IsFull()) { /* 处理栈满 */ } values[++top] = x; } double Pop() { return values[top--]; } }; -
特点:实现简单,但容量固定,需预分配空间
2. 动态栈(链表实现)
-
原理:使用单向链表的头插法,头节点作为栈顶,无需预先分配空间。
-
关键代码示例:
template <class T> class LinkedStack { struct Node { T data; Node* next; }; Node* head; int cnt; public: void Push(T val) { Node* newNode = new Node{val, head->next}; head->next = newNode; cnt++; } T Pop() { Node* temp = head->next; T val = temp->data; head->next = temp->next; delete temp; return val; } }; -
特点:动态扩容,适合元素数量不确定的场景
3、栈的核心操作
-
入栈(Push):将元素添加到栈顶。
-
出栈(Pop):移除栈顶元素并返回其值。
-
查看栈顶(Top):获取但不移除栈顶元素。
-
判空(Empty):检查栈是否为空。
-
容量查询(Size):返回当前元素数量
4、C++标准库中的栈(std::stack)
C++标准库提供 std::stack 容器适配器,默认底层容器为 deque,也可指定 vector 或 list:
-
常用方法:
stack<int> s; s.push(10); // 入栈 s.pop(); // 出栈 int top = s.top();// 获取栈顶 bool isEmpty = s.empty(); -
底层容器选择:
-
deque(默认):头尾操作高效,适合通用场景。
-
vector:尾部操作快,但需连续内存。
-
list:任意位置插入/删除高效
-
5、栈的应用场景
-
逆序输出:利用LIFO特性反转数据顺序,如字符串逆序
-
括号匹配:通过栈检查嵌套括号是否正确闭合
-
函数调用栈:记录函数调用顺序及局部变量
-
撤销操作(Undo):记录操作历史以便回退
-
表达式求值:处理后缀表达式(逆波兰表示法)
6、性能优化与注意事项
-
栈溢出:静态栈需处理满栈异常;动态栈需避免内存泄漏。
-
模板类设计:通过模板支持泛型数据类型,提升复用性
-
标准库优化:使用
emplace替代push可避免拷贝开销(C++11+)
7、代码示例(链式栈模板类)
#include <iostream>
template <typename T>
class Stack {
struct Node { T data; Node* next; };
Node* topNode; int size;
public:
Stack() : topNode(nullptr), size(0) {}
void push(T val) {
Node* newNode = new Node{val, topNode};
topNode = newNode;
size++;
}
T pop() {
if (topNode == nullptr) throw "Empty Stack";
T val = topNode->data;
Node* temp = topNode;
topNode = topNode->next;
delete temp;
size--;
return val;
}
T top() const { return topNode->data; }
bool empty() const { return topNode == nullptr; }
};
总结
栈作为基础数据结构,在C++中可通过数组或链表灵活实现,标准库 std::stack 提供了高效封装。理解其LIFO特性及适用场景(如逆序处理、状态回溯)是算法设计与系统开发的关键。实际应用中需根据场景选择实现方式,并注意异常处理与性能优化。
五、队列
1、队列的基本概念与特性
队列(Queue)是一种先进先出(FIFO, First In First Out)的线性数据结构,其操作限制为只能在队尾(rear)插入元素(入队),在队头(front)删除元素(出队)。其核心特性包括:
-
入队(Enqueue):在队尾添加新元素,时间复杂度一般为O(1)
-
出队(Dequeue):在队头移除元素,时间复杂度一般为O(1)
-
只允许访问队头和队尾元素,中间元素不可直接访问
2、队列的底层实现方式
队列的底层可通过不同数据结构实现,具体选择影响操作的复杂度:
-
数组实现(顺序队列):
-
使用动态数组存储元素,入队时在数组末尾添加元素,出队时删除数组首元素(需移动后续元素,时间复杂度O(n))
-
循环队列:通过模运算将数组视为环形结构,避免数据移动。当
rear或front指针到达数组末尾时,重置为0,适用于固定大小队列 -
动态数组优化:入队时若空间不足自动扩容(均摊复杂度O(1)),出队时可能缩容
-
-
链表实现(链式队列):
-
使用头指针(front)指向队首节点,尾指针(rear)指向队尾节点。入队操作在链表尾部插入节点,出队操作删除头节点,时间复杂度均为O(1)
-
优点:无扩容限制,内存利用率高。
-
3、C++标准库中的std::queue
std::queue是C++提供的容器适配器,基于底层容器(如deque或list)封装队列操作,头文件为<queue>
-
模板参数:
template <class T, class Container = deque<T>> class queue;-
T:元素类型。 -
Container:底层容器类型,需支持push_back()、pop_front()等操作,默认使用deque
-
-
核心成员函数:
-
访问元素:
-
front():返回队头元素的引用(若队列为空,行为未定义) -
back():返回队尾元素的引用
-
-
容量操作:
-
empty():判断队列是否为空 -
size():返回队列中元素个数
-
-
修改操作:
-
push(const T& val):在队尾插入元素 -
pop():删除队头元素(若队列为空,行为未定义) -
emplace(args...):直接在队尾构造元素(避免拷贝,C++11起支持)
-
-
-
底层容器选择的影响:
-
deque(默认):支持随机访问,内存分段管理,扩容效率高,适合频繁的入队和出队 -
list:避免内存碎片化,但每次操作涉及动态内存分配,性能略低
-
4、队列的应用场景
-
广度优先搜索(BFS):遍历图或树时按层访问节点,队列存储待处理节点
-
消息队列:解耦生产者和消费者,实现异步任务处理
-
缓存管理:使用队列淘汰最早进入的元素(如LRU缓存策略)
-
多线程同步:任务调度中维护待执行任务的顺序
5、代码示例
-
使用
std::queue实现队列操作:#include <queue> #include <iostream> int main() { std::queue<int> q; q.push(10); // 入队 q.push(20); std::cout << "队头元素:" << q.front() << "\n"; // 输出10 q.pop(); // 出队 std::cout << "新队头元素:" << q.front() << "\n"; // 输出20 return 0; } -
自定义循环队列(数组实现):
class CircularQueue { private: int *data; int front, rear, capacity; public: CircularQueue(int size) : capacity(size + 1), front(0), rear(0) { data = new int[capacity]; } bool enqueue(int val) { if ((rear + 1) % capacity == front) return false; // 队满 data[rear] = val; rear = (rear + 1) % capacity; return true; } bool dequeue() { if (front == rear) return false; // 队空 front = (front + 1) % capacity; return true; } };
6、复杂度与注意事项
-
时间复杂度:标准库实现的
std::queue入队和出队均为O(1),但若底层容器为动态数组,扩容时存在均摊复杂度 -
线程安全:
std::queue非线程安全,多线程环境下需加锁 -
异常处理:自定义队列需处理队空时出队、队满时入队的边界条件
队列作为基础数据结构,其高效性和简单性使其在算法和系统设计中广泛应用。理解其实现原理及标准库的适配器特性,有助于在开发中灵活选择最优方案。
六、二叉树
1、BST树
(1)BST的基本概念与特性
二叉搜索树(Binary Search Tree, BST) 是一种特殊的二叉树,其核心特性为:
-
有序性:任意节点的左子树所有节点值均小于该节点,右子树所有节点值均大于该节点
-
递归结构:每个节点的左、右子树本身也是BST
-
中序遍历有序:中序遍历BST会得到一个递增序列,这是其核心性质之一
重要性质:
-
最小值位于最左叶子节点,最大值位于最右叶子节点
-
若树保持平衡(如AVL树、红黑树),增删查操作的时间复杂度为 O(logn);若退化为链表(不平衡),时间复杂度为 O(n)
(2)BST的核心操作
1. 查找操作
-
原理:从根节点开始,根据目标值大小递归向左/右子树搜索
-
代码示例(递归与非递归):
// 递归查找
TreeNode* searchBST(TreeNode* root, int target) {
if (!root) return nullptr;
if (target < root->val) return searchBST(root->left, target);
else if (target > root->val) return searchBST(root->right, target);
return root;
}
// 非递归查找
TreeNode* searchBST(TreeNode* root, int target) {
while (root) {
if (root->val == target) break;
root = (target < root->val) ? root->left : root->right;
}
return root;
}
2. 插入操作
-
原理:找到合适位置(叶子节点)插入新节点,保持BST的有序性
-
代码示例:
TreeNode* insert(TreeNode* root, int val) {
if (!root) return new TreeNode(val);
if (val < root->val) root->left = insert(root->left, val);
else if (val > root->val) root->right = insert(root->right, val);
return root;
}
3. 删除操作
删除操作需分三种情况处理:
-
叶子节点:直接删除。
-
单子节点:用子节点替换被删节点。
-
双子节点:用左子树最大值或右子树最小值替换被删节点,并递归删除替换节点
代码示例:
TreeNode* deleteNode(TreeNode* root, int key) {
if (!root) return nullptr;
if (key < root->val) root->left = deleteNode(root->left, key);
else if (key > root->val) root->right = deleteNode(root->right, key);
else {
// 找到要删除的节点
if (!root->left) return root->right; // 只有右子树
if (!root->right) return root->left; // 只有左子树
// 找到右子树最小节点替换
TreeNode* minNode = getMin(root->right);
root->val = minNode->val;
root->right = deleteNode(root->right, minNode->val);
}
return root;
}
解释:
-
如果要删除的节点没有左子树,则直接返回其右子树。
-
如果要删除的节点没有右子树,则直接返回其左子树。
-
如果要删除的节点既有左子树又有右子树,则找到右子树中的最小节点(即右子树的最左节点),用其值替换要删除的节点的值,然后递归删除右子树中的这个最小节点。
(3)BST的应用场景
-
高效查找与排序:BST支持 O(logn) 的查找效率,常用于数据库索引、符号表等
-
动态集合管理:支持动态数据的快速插入、删除和查询
-
范围查询:利用中序遍历特性,快速获取有序数据区间
-
算法基础:作为平衡树(如AVL、红黑树)的基础结构,用于优化更复杂的数据操作
(4)复杂度与注意事项
-
时间复杂度:
- 查找、插入、删除:平均 O(logn),最坏 O(n)(树不平衡时)
-
注意事项:
-
验证BST:需确保所有子树满足左小右大,不能仅判断父子节点关系,可通过中序遍历或传递最大/最小值验证
-
平衡优化:实际应用中需结合平衡树技术避免性能退化
-
(5)C++代码实现示例
以下为BST的完整C++实现框架:
class BST {
private:
struct Node {
int val;
Node *left, *right;
Node(int v) : val(v), left(nullptr), right(nullptr) {}
};
Node* root;
Node* insert(Node* node, int val) {
if (!node) return new Node(val);
if (val < node->val) node->left = insert(node->left, val);
else if (val > node->val) node->right = insert(node->right, val);
return node;
}
Node* deleteNode(Node* node, int val) { /* 同上 */ }
public:
BST() : root(nullptr) {}
void insert(int val) { root = insert(root, val); }
void remove(int val) { root = deleteNode(root, val); }
bool contains(int val) { return search(root, val) != nullptr; }
};
(6)扩展思考
-
平衡BST:通过旋转操作保持树高平衡,如AVL树(严格平衡)和红黑树(近似平衡)
-
工程应用:数据库索引(如MySQL的B+树索引)和内存缓存(如C++ STL的
std::map基于红黑树实现)
2、AVL树
(1)基本概念与特性
AVL树(Adelson-Velsky-Landis树)是首个提出的自平衡二叉搜索树,其核心特性是通过动态调整树结构,保持任意节点的左右子树高度差(平衡因子)绝对值不超过1。这种平衡性使得AVL树在最坏情况下的时间复杂度仍为 O(logn),适用于需要高效查询的场景
核心特性:
-
平衡条件:每个节点的左右子树高度差(平衡因子)必须为 -1、0 或 1,即 ∣hleft−hright∣≤1。
-
递归性质:所有子树本身也是AVL树。
-
中序遍历有序:与普通二叉搜索树相同,中序遍历结果为递增序列
(2)平衡因子与节点结构
平衡因子(Balance Factor) 定义为一个节点左子树高度减去右子树高度的值。若平衡因子超出 [-1, 1] 范围,则需通过旋转调整树结构
节点结构(C++示例) AVL树节点通常包含三叉链(父、左、右指针)、键值对及平衡因子:
template<class K, class V>
struct AVLNode {
AVLNode<K, V>* _left, *_right, *_parent;
pair<K, V> _kv; // 键值对
int _bf; // 平衡因子(或直接记录高度)
// 构造函数初始化...
};
通过三叉链可回溯父节点,便于旋转操作中调整节点关系
(3)旋转操作:AVL树的自平衡核心
当插入或删除节点导致平衡因子超出范围时,通过四种旋转操作恢复平衡:
1. 单旋操作
-
左单旋(RR型) 适用场景:新节点插入到右子树的右子树(右右失衡)。操作步骤:
-
将失衡节点的右子节点提升为新父节点。
-
原右子节点的左子树挂到失衡节点的右侧。
-
更新父指针和平衡因子
-
-
右单旋(LL型) 适用场景:新节点插入到左子树的左子树(左左失衡)。操作步骤与左单旋对称

2. 双旋操作
- 左右双旋(LR型) 适用场景:新节点插入到左子树的右子树(左右失衡)。操作步骤:
-
先对左子节点执行左旋(转换为LL型)。
-
再对失衡节点执行右旋
-

- 右左双旋(RL型) 适用场景:新节点插入到右子树的左子树(右左失衡)。操作步骤与左右双旋对称
旋转后处理:更新所有相关节点的平衡因子和父指针,确保树结构合法
(4)插入与删除操作
1. 插入操作
-
步骤:
-
按二叉搜索树规则插入新节点。
-
向上回溯更新父节点的平衡因子。
-
若某节点平衡因子变为 ±2,触发旋转调整
-
-
平衡因子更新规则:
-
插入左子树:父节点平衡因子减1(
_bf--)。 -
插入右子树:父节点平衡因子加1(
_bf++)
-
2. 删除操作
-
步骤:
- 删除节点后,向上回溯调整平衡因子。
- 若出现失衡,执行旋转操作(可能需多次旋转)。
-
复杂度:删除操作可能导致从被删节点到根节点的路径上多个节点失衡,需逐层调整
(5)复杂度与性能分析
-
时间复杂度:
-
查找、插入、删除均需 O(logn),得益于严格的平衡性。
-
旋转操作本身为 O(1),但插入/删除后可能触发多次旋转
-
-
空间复杂度:O(n),每个节点需额外存储平衡因子和父指针
对比普通二叉搜索树:AVL树牺牲部分插入/删除性能(频繁旋转)换取查询效率,适用于读多写少的场景(如数据库索引)
(6)应用场景
-
数据库索引:如MySQL的InnoDB引擎使用B+树,但内存数据库可能选用AVL树优化查询。
-
实时系统:需要稳定查询响应的场景(如游戏引擎中的空间划分)。
-
高频查询场景:如编译器的符号表管理
(7)代码实现要点(C++)
- 插入与旋转示例:
// 右单旋示例(LL型)
Node* rightRotate(Node* y) {
Node* x = y->_left;
y->_left = x->_right;
if (x->_right) x->_right->_parent = y;
x->_right = y;
x->_parent = y->_parent;
y->_parent = x;
// 更新平衡因子...
return x;
}
- 平衡因子更新:插入后需沿路径更新父节点直至根节点或平衡因子为0
(8)验证AVL树的平衡性
通过递归检查每个节点的平衡因子是否在 [-1, 1] 范围内,并验证子树高度差:
bool isBalanced(Node* root) {
if (!root) return true;
int leftH = height(root->left);
int rightH = height(root->right);
if (abs(leftH - rightH) > 1) return false;
return isBalanced(root->left) && isBalanced(root->right);
}
需同时确保中序遍历有序性
(9)扩展与局限性
-
局限性:频繁插入/删除时旋转代价较高,此时红黑树(近似平衡)可能更优
-
改进方向:结合其他平衡策略(如替罪羊树的惰性删除)减少旋转次数
3、红黑树
对于一个AVL树,为了让它重新维持在一个平衡状态,就需要对其进行旋转处理, 那么创建一颗平衡二叉树的成本其实不小. 这个时候就有人开始思考,并且提出了红黑树的理论,红黑树在业界应用很广泛,比如 Java 中的 TreeMap,JDK 1.8 中的 HashMap、C++ STL 中的 map 均是基于红黑树结构实现的。那么红黑树到底比AVL树好在哪里?
(1)红黑树简介
红黑树是一种自平衡的二叉查找树,是一种高效的查找树。它是由 Rudolf Bayer 于1978年发明,在当时被称为平衡二叉 B 树(symmetric binary B-trees)。后来,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的红黑树。红黑树具有良好的效率,它可在 O(logN) 时间内完成查找、增加、删除等操作。
(2)为什么需要红黑树?
对于二叉搜索树,如果插入的数据是随机的,那么它就是接近平衡的二叉树,平衡的二叉树,它的操作效率(查询,插入,删除)效率较高,时间复杂度是O(logN)。
但是可能会出现一种极端的情况,那就是插入的数据是有序的(递增或者递减),那么所有的节点都会在根节点的右侧或左侧,此时,二叉搜索树就变为了一个链表,它的操作效率就降低了,时间复杂度为O(N),所以可以认为二叉搜索树的时间复杂度介于O(logN)和O(N)之间,视情况而定。
那么为了应对这种极端情况,红黑树就出现了,它是具备了某些特性的二叉搜索树,能解决非平衡树问题,红黑树是一种接近平衡的二叉树(说它是接近平衡因为它并没有像AVL树的平衡因子的概念,它只是靠着满足红黑节点的5条性质来维持一种接近平衡的结构,进而提升整体的性能,并没有严格的卡定某个平衡因子来维持绝对平衡)。
(3)红黑树的特性
红黑树通过以下规则确保平衡性:
-
颜色特性:每个节点非红即黑。
-
根节点:根必须是黑色。
-
叶子节点:所有空节点(NIL节点)视为黑色。
-
红色节点约束:红色节点的子节点必须为黑色(不可连续红节点)。
-
黑高一致性:从任意节点到其所有叶子节点的路径包含相同数量的黑色节点(称为黑高)
平衡性保证:最长路径长度不超过最短路径的两倍(因最长路径为红黑交替,最短路径为全黑节点)
(4)红黑树的操作原理
-
插入操作
-
新节点默认为红色,插入后若破坏性质则调整:
-
颜色反转:若父节点和叔节点均为红色,将父、叔变黑,祖父变红,递归向上调整
-
旋转调整:若父红而叔黑,通过左旋/右旋修复(如LL/RR型需单旋,LR/RL型需双旋)
-
-
示例:插入导致连续红节点时,通过旋转将红节点上移并调整颜色
-
-
删除操作
-
删除后若破坏黑高,需通过借兄弟节点或合并节点调整:
-
兄弟节点为红色:旋转父节点并重新着色。
-
兄弟节点为黑色且其子节点有红色:通过旋转和变色修复
-
-
复杂度:删除调整最多涉及3次旋转
-
-
旋转操作
-
左旋:将节点的右子节点提升为父节点,原节点变为左子节点。
-
右旋:将节点的左子节点提升为父节点,原节点变为右子节点
-
目的:减少树高差,恢复红黑树性质
-
颜色标记的本质——减少结构调整频率
红黑树通过红黑交替规则(红色节点不能相邻)和黑高平衡规则(所有路径黑色节点数相同),以颜色为信号动态调整平衡。这种设计允许红黑树在插入或删除时:
-
优先通过颜色翻转修复冲突:例如插入时若父节点和叔叔都是红色,只需将父、叔变黑,祖父变红即可(无需旋转)。这种操作代价低,仅需局部调整。
-
仅在必要时触发旋转:若颜色翻转无法解决(如父红叔黑),才通过左旋/右旋调整结构,旋转次数最多2次(插入)或3次(删除),远低于严格平衡的AVL树
示例:插入新节点时,若父节点为红色且叔叔为红色,仅需“父叔变黑、祖父变红”即可(颜色翻转)。这种局部调整避免了全局结构变化。
(5)红黑树与AVL树的对比
| 特性 | 红黑树 | AVL树 |
|---|---|---|
| 平衡标准 | 近似平衡(最长路径≤2倍最短) | 严格平衡(高度差≤1) |
| 插入/删除效率 | 调整次数少,适合频繁修改 | 调整次数多,适合查询为主 |
| 查询效率 | 略低于AVL(因树高稍高) | 最优(严格平衡) |
| 应用场景 | 数据库、文件系统、Java集合 | 内存敏感型场景(如缓存) |
总结:红黑树以略微牺牲查询效率为代价,换取了更高的插入/删除性能,适合动态数据集
(6)红黑树的应用场景
-
数据库索引:如MySQL的InnoDB引擎使用红黑树优化范围查询。
-
内存管理:Linux内核用红黑树管理进程控制块和内存块
-
编程语言库:Java的
TreeMap、C++ STL的map、JDK 1.8后的HashMap链表树化 -
文件系统:如NTFS的文件块管理
(7)红黑树的局限性
-
线程不安全:并发修改需依赖锁机制(如读写锁、分段锁)
-
内存占用:非叶子节点需存储颜色标记,略高于普通二叉查找树
4、B树
B树(Balanced Tree)是一种多路平衡搜索树,专为磁盘或其他直接存储设备设计的高效数据结构,广泛应用于数据库、文件系统等场景。与红黑树不同,B树通过多分支结构减少树的高度,从而降低磁盘I/O次数,提升大规模数据访问效率。
(1)B树的核心特性
-
多路平衡:
- 每个节点最多包含
m个子节点(m为B树的阶数,通常m ≥ 3)。 - 除根节点外,所有非叶子节点至少有
⌈m/2⌉个子节点。 - 所有叶子节点位于同一层(高度平衡)。
- 每个节点最多包含
-
键值分布规则:
- 每个节点包含
k个有序键值(⌈m/2⌉−1 ≤ k ≤ m−1)。 - 节点中的键值按升序排列,且子节点的键值范围由其父节点键值分割。
- 每个节点包含
-
高效磁盘访问:
- 节点大小通常设置为磁盘页(如4KB),单次I/O读取整个节点数据。
- 通过降低树的高度,减少磁盘访问次数。
(2)B树的节点结构
B树节点包含以下核心字段:
- 键值数组:存储有序的键值(如
K keys[m-1])。 - 子节点指针数组:指向子节点的指针(如
Node* children[m])。 - 当前键值数量:记录节点中实际存储的键值数(如
int num_keys)。 - 是否为叶子节点:标识节点类型。
template <class K, int M> // M为B树的阶
struct BTreeNode {
K keys[M-1]; // 键值数组(最多M-1个键)
BTreeNode* children[M]; // 子节点指针数组(最多M个子节点)
int num_keys; // 当前键值数量
bool is_leaf; // 是否为叶子节点
};
(3)B树的查找操作
- 流程:
- 从根节点开始,逐层向下查找。
- 在每个节点中,通过二分法找到第一个不小于目标键的位置。
- 若当前节点包含目标键,返回结果;否则进入对应子节点。
- 时间复杂度:O(logₘN),其中
m为阶数,N为总键值数。
(4)B树的插入操作
插入操作需保证节点键值数量不超过m-1,否则需分裂节点。
1. 插入步骤
- 查找插入位置:找到目标叶子节点并插入键值。
- 节点溢出检查:
- 若键值数超过
m-1,将中间键值提升到父节点,分裂当前节点为两个子节点。
- 若键值数超过
- 递归调整父节点:若父节点溢出,继续分裂并向上传递中间键值。
2. 分裂示例(阶数m=3)
- 原节点:键值
[10, 20, 30](溢出)。 - 分裂后:
- 父节点新增中间键
20。 - 分裂为左子节点
[10]和右子节点[30]。
- 父节点新增中间键
(5)B树的删除操作
删除操作需保证节点键值数量不小于⌈m/2⌉−1,否则需合并节点或借用键值。
1. 删除步骤
- 查找目标键:
- 若键在叶子节点,直接删除。
- 若键在非叶子节点,用前驱或后继键替换,转化为删除叶子节点。
- 节点下溢检查:
- 借键:若兄弟节点有富余键值,从兄弟节点借一个键。
- 合并:若兄弟节点无富余键,与兄弟节点合并,并删除父节点中的分割键。
- 递归调整父节点:若父节点下溢,继续向上调整。
2. 合并示例(阶数m=3)
- 父节点键值:
[20],左子节点[10],右子节点[ ](下溢)。 - 合并后:父节点键值删除,合并左右子节点为
[10, 20]。
(6)B树与红黑树的对比
| 特性 | B树 | 红黑树 |
|---|---|---|
| 分支数 | 多路平衡(阶数m≥3) | 二叉平衡 |
| 平衡规则 | 节点键值数量约束 | 颜色标记和旋转操作 |
| 适用场景 | 磁盘存储(减少I/O次数) | 内存数据(快速动态操作) |
| 树高 | O(logₘN),更低 | O(log₂N) |
| 应用实例 | 数据库索引(如MySQL InnoDB) | C++ STL(map、set) |
(7)B树的应用场景
- 数据库索引:
- B+树(B树变种)是数据库的主流索引结构,叶子节点通过链表连接,支持高效范围查询。
- 例如:MySQL InnoDB引擎的聚簇索引和非聚簇索引。
- 文件系统:
- NTFS、ReiserFS等文件系统使用B树管理文件元数据(如目录结构)。
- 大规模数据存储:
- 分布式存储系统(如Google Bigtable)利用B树变种优化数据分布和查询。
(8)B树的变种与优化
- B+树:
- 所有数据存储在叶子节点,非叶子节点仅作索引。
- 叶子节点通过链表连接,支持高效范围扫描。
- B*树:
- 通过增加节点最小填充因子(如
2/3 * m),减少节点分裂频率。
- 通过增加节点最小填充因子(如
- 压缩B树:
- 对键值进行压缩存储,提升单节点存储密度。
总结
B树通过多路平衡设计,将树高控制在极低水平,特别适合磁盘存储场景。其核心优势在于通过单次磁盘I/O读取更多数据,减少访问延迟。与红黑树相比,B树更适用于大规模数据存储和高并发访问,而红黑树则更适合内存中的动态数据操作。理解B树的分裂、合并规则及其变种(如B+树),是掌握现代数据库和文件系统设计的基石。
5、B+树
B+树是一种多路平衡查找树,是B树的优化变体,广泛应用于数据库、文件系统等需要高效处理海量数据的场景。以下从结构特征、操作原理、优势对比及实际应用等方面进行详解:
(1)B+树的核心结构特征
-
分层节点设计
-
内部节点(非叶子节点):仅存储索引键(Key),不保存实际数据(Data),用于导航查询路径
-
叶子节点:存储全部数据记录(或数据地址),并通过双向链表连接,形成有序序列,支持高效的范围查询
-
-
节点容量规则
-
对于m阶B+树,每个节点最多包含m-1个键,且子节点数等于键数
-
所有叶子节点位于同一层级,保证查询路径长度稳定
-
-
键值分布特性
-
内部节点的键值为子树中的最大值(或最小值),确保层级间的有序性
-
叶子节点包含完整的键值集合,且数据按顺序紧密排列
-
(2)B+树的操作原理
-
查找操作
-
从根节点开始,逐层比较键值,定位到目标叶子节点后返回数据。时间复杂度为O(log n),且所有查询均需到达叶子节点,性能稳定
-
示例:查找键值为K的记录时,通过内部节点的索引快速缩小范围,最终在叶子节点链表中定位
-
-
插入操作
-
插入数据时,若叶子节点未满,直接插入并保持有序性;若节点已满,则分裂为两个节点,并将中间键提升至父节点,递归调整直至平衡
-
分裂示例:假设5阶B+树的节点最多容纳4个键,插入第5个键时分裂为两个节点,中间键(如30)上移至父节点
-
-
删除操作
- 删除数据后,若叶子节点键数低于下限(如m/2),需向兄弟节点借键或合并节点,递归调整父节点键值以维持平衡
(3)B+树与B树的本质区别
-
数据存储位置
-
B树:键和数据可能分布在所有节点中。
-
B+树:数据仅存于叶子节点,内部节点仅作索引
-
-
查询稳定性
-
B树:查询可能在内部节点提前终止,性能波动较大。
-
B+树:所有查询必须到达叶子节点,路径长度固定
-
-
范围查询支持
-
B树:需通过中序遍历实现范围查询,效率较低。
-
B+树:叶子节点的链表结构可直接遍历,时间复杂度为O(1)~O(n)
-
-
空间利用率
- B+树的内部节点仅存键值,单节点可容纳更多索引,树高更低,减少磁盘I/O次数
(4)B+树的优势与应用场景
-
优势总结
-
减少磁盘I/O:高扇出特性降低树高,单次查询访问的磁盘块更少
-
适合范围查询:叶子链表支持高效的范围扫描和排序
-
缓存友好:紧凑的索引结构提升内存利用率,局部性原理适配磁盘预读机制
-
-
典型应用
-
数据库索引(如MySQL InnoDB):B+树索引支持快速点查与范围查询,同时减少全表扫描
-
文件系统(如NTFS、ReiserFS):高效管理大文件的分块存储与检索
-
(5)B+树的局限性
-
插入/删除成本较高:节点分裂与合并可能引发连锁调整,影响写入性能
-
内存占用:非叶子节点需额外存储索引,可能占用更多内存
(6)与其他数据结构的对比
-
红黑树:虽平衡但树高较大,适合内存操作,而B+树专为磁盘设计,减少I/O次数
-
哈希表:仅适合等值查询,无法支持范围操作,B+树在综合场景下更具优势
通过上述分析可见,B+树通过独特的结构设计,在数据存储、查询效率与扩展性之间实现了平衡,成为大规模数据管理的核心数据结构之一。其设计理念对现代数据库和存储系统的影响深远,是理解高效数据检索机制的关键
6697

被折叠的 条评论
为什么被折叠?



