数据结构是为实现对计算机数据有效使用的各种数据组织形式,旨在降低各种算法计算的时间与空间复杂度
常见的数据结构可分为 线性数据结构 与 非线性数据结构 ,具体包括:数组 、 链表 、 栈 、 队列 、 树 、 图 、 散列表 、 堆
不要对数据结构的使用浅尝辄止,而要深挖起内部原理
# 数组
数组是将相同类型的元素存储于 连续内存空间 的数据结构,其长度不可变。构建数组时需要在初始化时给定长度,例如:
int array[5]; | |
int array[] = {2, 3, 1, 0, 2}; |
可变数组 (标准库类型 vector
)是经常使用的数据结构,其基于数组和扩容机制实现,相比普通数组更加灵活。常用操作有:访问元素(下标运算符、范围 for
、迭代器)、添加元素(成员函数 push_back
)、删除元素(成员函数 erase
和 remove
),例如:
vector<int> array = {2,3,1,0,2}; | |
array.push_back(2); // 添加元素 2 到末尾 | |
vector<int>::iterator it; // 迭代器 | |
it = array.begin() + 4; //it 指向第 5 个元素 | |
array.erase(it); // 删除 it 指向的元素 |
详情可见于
C++:标准库类型 vector
vector 删除元素的方法
# 链表
链表以节点为单位,每个元素都是一个独立对象,在内存空间的存储是 非连续 的
每一个节点由两部分组成,一个是数据域,另一个是指针域
链表的入口节点称为头节点,即, head
# 链表的类型
# 单链表
单链表中,每个节点的指针域存放的是指向下一个节点的指针,最后一个节点的指针域指向 null
单链表的节点对象具有两个成员变量:值 val
、 后继节点指针 next
单链表中的节点只能指向节点的下一个节点
# 双链表
双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点
双链表既可以向前查询,又可以向后查询
# 循环链表
链表首尾相连
循环链表可以用来解决约瑟夫环问题
# 链表的存储方式
链表在内存空间中的分布并不是连续的,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理
链表通过指针域的指针来链接在内存中的各个节点
# 链表的定义
以单链表为例,定义链表的节点:
// 单链表 | |
struct ListNode { | |
int val; // 节点值 | |
ListNode *next; // 后继节点指针 | |
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数 | |
}; | |
// // Definition for singly-linked list. | |
// struct ListNode { | |
// int val; | |
// ListNode *next; | |
// ListNode() : val(0), next(nullptr) {} | |
// ListNode(int x) : val(x), next(nullptr) {} | |
// ListNode(int x, ListNode *next) : val(x), next(next) {} | |
// }; |
C++ 默认会生成一个构造函数,但是这个构造函数不会初始化任何成员变量
如果不自行定义构造函数,而仅仅使用默认构造函数,那么在初始化的时候就不能直接给变量赋值
// 自行定义构造函数时节点的初始化操作 | |
ListNode* head = new ListNode(5); | |
// 使用默认构造函数时节点的初始化操作 | |
ListNode* head = new ListNode(); | |
head->val = 5; |
建立链表需要实例化每个节点,并构建各节点的引用指向
注:需要用箭头运算符( ->
),其含义为 解引用 + 成员访问
# 链表的操作
# 删除节点
以删除 D 节点为例:只要将 C 节点的 next
指针 指向 E 节点即可
注意,D 节点依然留在内存中,只不过是没有在这个链表里而已。可以再手动释放这个 D 节点的这块内存
# 插入节点
以插入 F 节点为例:将 C 节点的 next
指针指向 F 节点,并将 F 节点的 next
指针指向 D 节点
可以看出链表的增添和删除都是 操作,也不会影响到其他节点
但是要注意,若要删除第五个节点,需要从头节点查找到第四个节点通过 next
指针进行删除操作,查找的时间复杂度是
# 性能分析
链表与数组的特性对比:
插入 / 删除操作的时间复杂度 | 查询操作的时间复杂度 | 适用场景 | |
---|---|---|---|
数组 | 数据量固定,频繁查询,较少增删 | ||
链表 | 数据量不固定,频繁增删,较少查询 |
数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组
链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景
# STL
STL 是 Standard Template Library 的简称,即,标准模板库
STL 可分为容器(containers)、迭代器(iterators)、空间配置器(allocator)、配接器(adapters)、算法(algorithms)、仿函数(functors)六个部分
在 C++ 标准中,STL 被组织为下面的 13 个头文件: <algorithm>
、 <deque>
、 <functional>
、 <iterator>
、 <vector>
、 <list>
、 <map>
、 <memory>
、 <numeric>
、 <queue>
、 <set>
、 <stack>
和 <utility>
STL 的版本很多,其中,三个最为普遍的 STL 版本:
-
HP STL 是 C++ STL 的第一个实现版本,而且开放源代码。其他版本的 C++ STL 一般是以 HP STL 为蓝本实现出来的。不过,现在已经很少直接使用此版本的 STL 了
-
PJ STL(全称为 P.J. Plauger STL)由 P.J.Plauger 参照 HP STL 实现出来的,是 HP STL 的一个继承版本。PJ STL 被 Visual C++ 编译器所采用,但不是开源的
-
SGI STL 也是 HP STL 的一个继承版本,和 HP STL 一样,SGI STL 也是开源的,其源代码的可读性可非常好。被 Linux 下的 C++ 编译器 GCC 所采用
接下来介绍的 栈 和 队列 也是 SGI STL 里面的数据结构
# 栈
栈(stack)是一种具有 后进先出(last in first out)特点的抽象数据结构,使用前需要引入 stack
头文件
栈不提供迭代器,也不允许遍历
栈依赖于底层容器完成所有工作,对外提供统一的接口。其中,底层容器是可插拔的,即,我们可以控制使用何种容器来实现栈的功能
因此,在 STL 中,栈往往不被归类为 容器 ,而被归类为 容器适配器(container adapter)
栈的底层实现可以是 vector
, deque
, list
,主要使用 数组 和 链表 的底层实现。对于 SGI STL ,如果不指定,则默认使用 deque
作为底层容器
deque
是一个双向队列,,只要封住一端、开通另一端,即可实现栈的逻辑
我们可以指定 vector 为栈的底层实现,其初始化语句为:
std::stack<int, std::vector<int>> stk; // 使用 vector 为底层容器的栈
栈常用的成员函数:
-
push()
:在最顶层加入元素 -
pop()
:移除顶层元素 -
top()
:返回最顶层数据的值,但不移除它 -
empty()
:判断栈是否为空 -
size()
:返回栈的大小
须注意:
-
pop()
没有返回值 -
top()
具有返回值,并且,可以通过top()
修改栈顶元素值,即,top()
可以作为左值
如下图所示,通过 入栈 push()
,出栈 pop()
,展示了栈的先入后出特性
stack<int> stk; | |
stk.push(1); // 元素 1 入栈 | |
stk.push(2); // 元素 2 入栈 | |
stk.pop(); // 元素 2 出栈 | |
stk.pop(); // 元素 1 出栈 |
# 队列
队列(queue)是一种具有 先进先出(first in first out)特点的抽象数据结构,使用前需先引入 queue
头文件
队列不提供迭代器,不允许有遍历行为
STL 队列 也不被归类为 容器 ,而被归类为 容器适配器(container adapter)
队列的底层实现可以是 deque
和 list
。对于 SGI STL ,如果不指定,则默认使用 deque
作为底层容器
可以指定 list
为栈的底层实现,其初始化语句为:
std::queue<int, std::list<int>> que; // 使用 list 为底层容器
队列常用的成员函数:
-
push()
:在队尾插入元素 -
pop()
:移除队首元素 -
front()
:返回队首元素 -
back()
:返回队尾元素 -
empty()
:判断队列是否为空 -
size()
:返回队列中元素的数量
注意:
-
pop()
没有返回值 -
front()
具有返回值,并且,可以通过front()
修改队首元素值,即,front()
可以作为左值
如下图所示,通过常用操作 入队 push()
,出队 pop()
,展示了队列的先入先出特性
queue<int> que; | |
que.push(1); // 元素 1 入队 | |
que.push(2); // 元素 2 入队 | |
que.pop(); // 出队 -> 元素 1 | |
que.pop(); // 出队 -> 元素 2 |
此外, queue
还提供了一些运算符。较为常用的是:使用赋值运算符 =
为 queue
赋值
例如
queue<int> q1, q2; | |
q1.push(1); | |
q2 = q1; | |
cout << q2.front() << endl; |
# 双端队列
# 循环队列
可参考 OI Wiki:队列
# 树
树是一种非线性数据结构,根据子节点数量可分为 二叉树 和 多叉树 ,最顶层的节点称为 根节点 root
以二叉树为例,每个节点包含三个成员变量:值 val
、左子节点 left
、右子节点 right
二叉树节点的定义:
struct TreeNode { | |
int val; // 节点值 | |
TreeNode *left; // 左子节点 | |
TreeNode *right; // 右子节点 | |
TreeNode(int x) : val(x), left(NULL), right(NULL) {} | |
}; |
访问成员变量时需要用箭头运算符( ->
),其含义为 解引用 + 成员访问
建立二叉树需要实例化每个节点,并构建各节点的子节点指针
例如:
// 初始化节点 | |
TreeNode *n1 = new TreeNode(3); // 根节点 root | |
TreeNode *n2 = new TreeNode(4); | |
TreeNode *n3 = new TreeNode(5); | |
// 构建子节点指针 | |
n1->left = n2; | |
n1->right = n3; |
详情可见 二叉树
# 堆
堆是一种基于 完全二叉树 的数据结构,可使用数组实现
以堆为原理的排序算法称为 堆排序 ,基于堆实现的数据结构为 优先级队列
优先级队列 :结点之间的关系是由结点的优先级决定的,而不是由入队的先后次序决定。优先级高的先出队,优先级低的后出队
堆分为 最小化堆 和 最大化堆
- 最大化堆 :任意节点的值小于等于其父节点的值(根节点最大)
- 最小化堆 :任意节点的值大于等于其父节点的值(根节点最小)
通过使用 优先级队列 的 压入 push()
和 弹出 pop()
操作,即可完成 堆排序
// 初始化最小化堆 | |
priority_queue<int, vector<int>, greater<int>> heap; | |
// 元素入堆 | |
heap.push(1); | |
heap.push(4); | |
heap.push(2); | |
heap.push(6); | |
heap.push(8); | |
// 元素出堆(从小到大) | |
heap.pop(); // -> 1 | |
heap.pop(); // -> 2 | |
heap.pop(); // -> 4 | |
heap.pop(); // -> 6 | |
heap.pop(); // -> 8 |
详情可见 优先级队列
# 哈希表
哈希表(Hash table ,也被称为散列表)是根据关键码的值而直接进行访问的数据结构
哈希表可以近似理解成数组,哈希表中的关键码就是数组的索引下标,通过下标可以直接访问数组的元素
一般哈希表都是用来快速判断一个元素是否出现集合当中
# 哈希函数
哈希函数(hash function)以关键码的值为参数,将关键码映射为哈希表的索引,即,函数的值即为存储元素的下标
# 哈希碰撞
哈希碰撞是指:不同的关键码映射到同一个地址
两种解决方案:
- 线性探测法:当散列发生冲突时,探测下一个单元,直到发现一个空单元,于是元素将存储在该空单元
- 拉链法:将碰撞的节点组成一个链表
# 常见的哈希结构
三种哈希结构
- 数组
- set (集合)
- map (映射)
详情可见 哈希表
# 图
图是一种非线性结构,由 顶点 vertex
和 边 edge
组成
根据边是否区分方向,图可分为 有向图 和 无向图 , 这里以无向图为例进行介绍
如下图所示,此无向图的 顶点 和 边 集合分别为
-
顶点集合:
vertices = {1, 2, 3, 4, 5}
-
边集合:
edges = {(1, 2), (1, 3), (1, 4), (1, 5), (2, 4), (3, 5), (4, 5)}
表示图的方法通常有两种:
-
邻接矩阵 :使用数组
vertices
存储顶点,邻接矩阵edges
存储边。其中,edges[i][j]
表示节点vertices[i]
和节点vertices[j]
之间是否有边int vertices[5] = {1, 2, 3, 4, 5};
int edges[5][5] = <!--swig0-->;
-
邻接表 :使用数组
vertices
存储顶点,邻接表edges
存储边。其中,edges
是一个二维容器,第一维的 i 代表顶点vertices[i]
,第二维edges[i]
存储顶点vertices[i]
对应的边集合,例如,edges[0] = [1,2,3,4]
表示vertices[0]
的边集合为[1,2,3,4]
int vertices[5] = {1, 2, 3, 4, 5};
vector<vector<int>> edges;
vector<int> edge_1 = {1, 2, 3, 4};
vector<int> edge_2 = {0, 3};
vector<int> edge_3 = {0, 4};
vector<int> edge_4 = {0, 1, 4};
vector<int> edge_5 = {0, 2, 3};
edges.push_back(edge_1);
edges.push_back(edge_2);
edges.push_back(edge_3);
edges.push_back(edge_4);
edges.push_back(edge_5);
邻接矩阵的大小只与节点数量有关,即 ,其中 为节点数量
当边数量明显少于节点数量时,使用邻接矩阵存储图会造成较大的内存浪费(此时邻接矩阵为稀疏矩阵)
因此,邻接表 适合存储 顶点较多、边较少 的 稀疏图 ,邻接矩阵 适合存储 顶点较少、边较多 的 稠密图
详情可见 图
参考资料: