正文
1. 数组
1.1 数组的定义与初始化
数组是一种线性的数据结构,它可以存储多个相同类型的数据元素。在C++中,定义数组的方式如下:
int arr[5]; // 定义一个包含5个整型元素的数组,此时数组元素未初始化
int arr2[3] = {1, 2, 3}; // 定义并初始化一个整型数组
可以通过下标来访问数组中的元素,下标从0开始,例如:
int num = arr2[1]; // 获取数组arr2中下标为1的元素,即2
1.2 数组的遍历
常见的遍历方式有使用for
循环,依次访问数组中的每个元素。示例如下:
for (int i = 0; i < 5; i++) {
cout << arr[i] << " ";
}
也可以使用基于范围的for
循环(C++11及之后支持),代码更简洁,例如:
for (int element : arr2) {
cout << element << " ";
}
1.3 数组的应用场景
常用于存储一组具有相同性质的数据,比如存储学生的成绩、一组商品的价格等。在很多算法中,数组也是基础的数据存储结构,像前面提到的前缀和、差分等算法操作的对象常常就是数组。
2. 链表
2.1 链表的基本概念
链表是由一系列节点组成的数据结构,每个节点包含数据域和指针域(指向下一个节点)。常见的有单向链表、双向链表和循环链表。
- 单向链表:节点只包含指向下一个节点的指针,例如:
// 定义单向链表节点结构体
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(NULL) {}
};
- 双向链表:节点除了包含指向下一个节点的指针,还包含指向上一个节点的指针,结构如下:
struct DoublyListNode {
int val;
DoublyListNode* prev;
DoublyListNode* next;
DoublyListNode(int x) : val(x), prev(NULL), next(NULL) {}
};
- 循环链表:单向链表或双向链表的最后一个节点的指针指向头节点(单向循环链表指向头节点,双向循环链表的尾节点的
next
指针指向头节点,头节点的prev
指针指向尾节点)。
2.2 链表的基本操作
- 插入节点:以单向链表为例,在指定节点后插入新节点的代码如下:
void insertAfter(ListNode* prevNode, int newVal) {
ListNode* newNode = new ListNode(newVal);
newNode->next = prevNode->next;
prevNode->next = newNode;
}
- 删除节点:删除单向链表中指定节点(假设不是头节点)的代码示例:
void deleteNode(ListNode* nodeToDelete) {
ListNode* nextNode = nodeToDelete->next;
nodeToDelete->val = nextNode->val;
nodeToDelete->next = nextNode->next;
delete nextNode;
}
- 遍历链表:通过不断访问节点的
next
指针来遍历单向链表,示例代码:
void traverse(ListNode* head) {
ListNode* cur = head;
while (cur!= NULL) {
cout << cur->val << " ";
cur = cur->next;
}
}
2.3 链表的应用场景
适合动态地插入和删除元素的场景,比如操作系统中进程的调度链表,用于管理等待执行、正在执行等不同状态的进程;在一些需要频繁增删元素的数据管理场景中比数组更具优势,因为数组在中间插入或删除元素往往需要移动大量其他元素。
3. 栈
3.1 栈的定义与特点
栈是一种后进先出(Last In First Out,LIFO)的数据结构,就像一个只能在一端进行操作(进栈和出栈)的容器。可以用数组或者链表来实现栈,下面是用数组实现简单栈结构的示例代码(只包含基本的入栈、出栈操作示意):
class Stack {
private:
int topIndex;
int arr[100]; // 假设栈最大容量为100,实际可根据需求调整
public:
Stack() {
topIndex = -1;
}
void push(int element) {
if (topIndex < 99) { // 检查栈是否已满
topIndex++;
arr[topIndex] = element;
}
}
int pop() {
if (topIndex >= 0) { // 检查栈是否为空
int element = arr[topIndex];
topIndex--;
return element;
}
return -1; // 表示栈空,可根据实际情况处理错误
}
};
3.2 栈的应用场景
- 函数调用栈:在程序执行函数调用时,系统会使用栈来保存函数的调用信息,包括局部变量、返回地址等,当函数执行完毕,按照后进先出的顺序依次返回。
- 表达式求值:例如计算后缀表达式(逆波兰表达式)的值,利用栈来存储操作数,根据运算符进行相应的计算,遵循栈的后进先出特性来保证计算顺序的正确性。
- 括号匹配检查:对于给定的括号序列(如
()[]{}
等),可以用栈来判断括号是否匹配,将左括号依次入栈,遇到右括号时与栈顶的左括号进行匹配并出栈。
4. 队列
4.1 队列的定义与特点
队列是一种先进先出(First In First Out,FIFO)的数据结构,有队头和队尾两个端点,元素从队尾进入队列,从队头离开队列。同样可以用数组或者链表来实现队列,以下是用数组实现简单队列的代码示例(包含基本的入队、出队操作示意):
class Queue {
private:
int front;
int rear;
int arr[100]; // 假设队列最大容量为100,可按需调整
public:
Queue() {
front = 0;
rear = -1;
}
void enqueue(int element) {
if (rear < 99) { // 检查队列是否已满
rear++;
arr[rear] = element;
}
}
int dequeue() {
if (front <= rear) { // 检查队列是否为空
int element = arr[front];
front++;
return element;
}
return -1; // 表示队空,可按实际情况处理错误
}
};
4.2 队列的应用场景
- 任务调度:在操作系统中,多个任务等待执行时,可以按照先来先服务的原则将任务放入队列,然后依次从队列头取出任务执行。
- 消息队列:在分布式系统或者多线程编程等场景中,消息队列用于存储消息,生产者将消息放入队列尾,消费者从队列头获取消息进行处理,实现解耦和异步处理。
- 广度优先搜索(BFS):在图算法的广度优先搜索中,利用队列来存储待访问的节点,保证按照距离起始节点由近到远的顺序进行遍历。
5. 树
5.1 树的基本概念
树是一种非线性的数据结构,由节点和边组成,有一个根节点,每个节点可以有零个或多个子节点,除根节点外每个节点有且仅有一个父节点。常见的树结构有二叉树、二叉搜索树、平衡二叉树(如AVL树、红黑树)等。
- 二叉树:每个节点最多有两个子节点,分别称为左子节点和右子节点,示例二叉树节点结构体如下:
struct TreeNode {
int val;
TreeNode* left;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
- 二叉搜索树(BST):是一种特殊的二叉树,它满足左子树上所有节点的值都小于根节点的值,右子树上所有节点的值都大于根节点的值,并且左右子树也分别是二叉搜索树。
5.2 树的遍历
- 前序遍历:先访问根节点,再访问左子树,最后访问右子树。递归实现代码如下:
void preorderTraversal(TreeNode* root) {
if (root == NULL) return;
cout << root->val << " ";
preorderTraversal(root->left);
preorderTraversal(root->right);
}
- 中序遍历:先访问左子树,再访问根节点,最后访问右子树。递归实现示例:
void inorderTraversal(TreeNode* root) {
if (root == NULL) return;
inorderTraversal(root->left);
cout << root->val << " ";
inorderTraversal(root->right);
}
- 后序遍历:先访问左子树,再访问右子树,最后访问根节点。递归代码如下:
void postorderTraversal(TreeNode* root) {
if (root == NULL) return;
postorderTraversal(root->left);
postorderTraversal(root->right);
cout << root->val << " ";
}
- 层序遍历:按照从上到下、从左到右的顺序依次访问树的各个节点,通常借助队列来实现,示例代码:
#include <iostream>
#include <queue>
using namespace std;
void levelOrderTraversal(TreeNode* root) {
if (root == NULL) return;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* node = q.front();
q.pop();
cout << node->val << " ";
if (node->left!= NULL) q.push(node->left);
if (node->right!= NULL) q.push(node->right);
}
}
5.3 树的应用场景
- 文件系统的目录结构:可以用树来表示,根节点是整个文件系统的根目录,子节点是各级子目录和文件,方便文件的查找、管理等操作。
- 数据库索引结构:如B树、B+树等树结构常被用于数据库的索引,提高数据查询的效率。
- 语法树:在编译原理中,将程序的语法结构用语法树表示,便于进行语法分析、语义分析等编译阶段的处理。
6. 图
6.1 图的基本概念
图由顶点(节点)和边组成,边可以连接两个顶点,表示它们之间的某种关系。图分为有向图(边有方向)和无向图(边没有方向),还可以根据边是否有权重分为加权图和无权图。图的表示方式常见的有邻接矩阵和邻接表。
- 邻接矩阵:用一个二维数组来表示图中顶点之间的连接关系,例如对于一个有
n
个顶点的图,adjMatrix[i][j]
表示顶点i
和顶点j
之间是否有边(无权图中用0或1表示,加权图中可以存储边的权重)。 - 邻接表:用链表(或者数组+链表等形式)来存储每个顶点的邻接顶点,即与它有边相连的顶点,这样对于稀疏图(边的数量相对顶点数量较少)可以节省空间。
6.2 图的遍历
- 深度优先搜索(DFS):类似于树的先序遍历,从一个起始顶点开始,沿着一条路径尽可能深地访问顶点,直到不能继续,然后回溯,再选择其他未访问的路径继续访问。递归实现示例代码如下(以邻接表表示的无向图为例):
#include <iostream>
#include <vector>
using namespace std;
void dfs(vector<vector<int>>& adjList, int vertex, vector<bool>& visited) {
visited[vertex] = true;
cout << vertex << " ";
for (int neighbor : adjList[vertex]) {
if (!visited[neighbor]) {
dfs(adjList, neighbor, visited);
}
}
}
- 广度优先搜索(BFS):前面在队列应用场景中提到过,从起始顶点开始,先访问离它最近的一层顶点,再依次访问更远层的顶点,借助队列实现。示例代码(同样以邻接表表示的无向图为例):
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
void bfs(vector<vector<int>>& adjList, int vertex) {
vector<bool> visited(adjList.size(), false);
queue<int> q;
visited[vertex] = true;
q.push(vertex);
while (!q.empty()) {
int curVertex = q.front();
q.pop();
cout << curVertex << " ";
for (int neighbor : adjList[curVertex]) {
if (!visited[neighbor]) {
visited[neighbor] = true;
q.push(neighbor);
}
}
}
}
6.3 图的应用场景
- 社交网络分析:用图来表示用户之间的好友关系,顶点是用户,边表示好友关系,可以分析用户的社交圈子、信息传播路径等。
- 交通网络规划:城市的道路网络、公交线路等可以用图表示,用于规划最优路线、分析交通流量等。
- 电路分析:电路中的元件、节点等可以构成图,帮助分析电流的流向、电路的连通性等情况。
7. 哈希表
7.1 哈希表的基本原理
哈希表(Hash Table)也叫散列表,它是根据关键码值(Key)而直接进行访问的数据结构。通过一个哈希函数,将关键码值映射到表中一个位置来访问记录,以加快查找的速度。理想情况下,哈希函数能将不同的键均匀地分布到哈希表的各个位置,这个位置通常称为桶(Bucket)。
例如,假设有一个简单的哈希函数是将整数键值对 key
取模(%
)一个固定的数(比如表的大小 m
)来确定其在哈希表中的位置,即 hash(key) = key % m
。但实际中可能会出现不同的键经过哈希函数计算后得到相同位置的情况,这就是哈希冲突(Hash Collision)。
7.2 解决哈希冲突的方法
- 开放定址法:当发生冲突时,按照某种探测规则去寻找下一个空闲的位置来存放元素。常见的探测方式有线性探测(依次往后找空闲位置)、二次探测(按照与冲突位置的二次函数关系来探测空闲位置)等。例如线性探测,若
hash(key1)
位置已经被占用,那就尝试hash(key1) + 1
、hash(key1) + 2
等位置,直到找到空闲位置。 - 链地址法:把具有相同哈希值的元素放在同一个链表中,哈希表的每个位置实际是一个链表的头指针。比如
key1
和key2
经过哈希函数计算后得到相同位置,那就将它们对应的节点插入到该位置对应的链表中,这样查找时先通过哈希函数定位到链表头,再在链表中查找具体元素。
7.3 哈希表的实现与操作(以C++中 unordered_map
为例,它底层基于哈希表实现)
#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
// 创建一个unordered_map,键为string类型,值为int类型
unordered_map<string, int> myMap;
// 插入元素
myMap["apple"] = 5;
myMap["banana"] = 3;
// 查找元素
if (myMap.find("apple")!= myMap.end()) {
cout << "找到了键为apple的值,值为: " << myMap["apple"] << endl;
}
// 修改元素的值
myMap["apple"] = 8;
// 遍历哈希表
for (auto it = myMap.begin(); it!= myMap.end(); it++) {
cout << it->first << ": " << it->second << endl;
}
return 0;
}
7.4 哈希表的应用场景
- 快速查找:在很多需要快速判断元素是否存在或者获取对应元素值的场景中广泛应用,比如在一个海量数据的集合中查找某个特定元素,使用哈希表可以在接近常数时间复杂度内完成查找,像编译器中的符号表管理(查找变量名对应的各种属性等)。
- 数据去重:可以利用哈希表记录元素出现的次数,将重复的元素快速筛选出来,例如对一个包含大量重复整数的数组进行去重操作,把整数作为键,出现次数作为值插入哈希表,只保留出现次数为1的元素对应的键即可实现去重。
8. 堆
8.1 堆的定义与性质
堆是一种特殊的完全二叉树结构,分为大顶堆和小顶堆。大顶堆满足每个节点的值都大于或等于它的子节点的值(根节点的值最大);小顶堆则满足每个节点的值都小于或等于它的子节点的值(根节点的值最小)。
堆通常用数组来实现,对于一个节点下标为 i
(下标从0开始)的堆,它的左子节点下标为 2 * i + 1
,右子节点下标为 2 * i + 2
,父节点下标为 (i - 1) / 2
(向下取整)。
8.2 堆的基本操作
- 插入元素:以大顶堆为例,插入元素时先将元素添加到堆的末尾,然后不断与它的父节点比较并交换(如果值大于父节点的值),直到满足大顶堆的性质为止。代码示例如下(这里简单实现一个基于数组的大顶堆插入操作示意,假设堆数组为
heap
,当前堆大小为heapSize
):
void insert(int element, int heap[], int& heapSize) {
heapSize++;
heap[heapSize - 1] = element;
int curIndex = heapSize - 1;
while (curIndex > 0) {
int parentIndex = (curIndex - 1) / 2;
if (heap[curIndex] > heap[parentIndex]) {
swap(heap[curIndex], heap[parentIndex]);
curIndex = parentIndex;
} else {
break;
}
}
}
- 删除堆顶元素:删除大顶堆的堆顶元素(也就是最大值)时,先将堆顶元素和堆的末尾元素交换,然后减少堆的大小,再从堆顶开始向下调整,让它满足大顶堆的性质,即比较它与子节点的值,若小于子节点的值则与较大的子节点交换,重复这个过程。示例代码(同样基于前面的数组表示的堆,
heapSize
为当前堆大小):
int deleteMax(int heap[], int& heapSize) {
int maxElement = heap[0];
heap[0] = heap[heapSize - 1];
heapSize--;
int curIndex = 0;
while (true) {
int leftIndex = 2 * curIndex + 1;
int rightIndex = 2 * curIndex + 2;
int maxChildIndex = curIndex;
if (leftIndex < heapSize && heap[leftIndex] > heap[maxChildIndex]) {
maxChildIndex = leftIndex;
}
if (rightIndex < heapSize && heap[rightIndex] > heap[maxChildIndex]) {
maxChildIndex = rightIndex;
}
if (maxChildIndex!= curIndex) {
swap(heap[curIndex], heap[maxChildIndex]);
curIndex = maxChildIndex;
} else {
break;
}
}
return maxElement;
}
8.3 堆的应用场景
- 优先排序:例如在任务调度中,不同任务有不同的优先级,将任务按照优先级构建成一个大顶堆(优先级高的在堆顶),每次选择优先级最高的任务执行,就像操作系统中对进程的优先级调度。
- 求前
k
大(小)元素:对于一个海量数据集合,要找出其中前k
大(小)的元素,可以先构建一个大小为k
的小(大)顶堆,然后遍历数据,若元素大于(小于)堆顶元素则替换堆顶元素并调整堆,最后堆中的元素就是所求的前k
大(小)元素,这样避免了对全部数据进行排序,提高了效率。
9. 并查集
9.1 并查集的基本概念
并查集是处理不相交集合的合并与查询问题的数据结构,主要支持两个操作:合并(Union)和查找(Find)。它常用来解决动态连通性问题,比如判断两个节点是否在同一个连通分量中,或者将两个连通分量合并为一个。
通常用树的结构来表示集合,每个集合的代表元素作为树的根节点,每个节点有一个指针指向它的父节点(初始时每个节点的父节点就是它自己)。
9.2 并查集的基本操作及实现
- 查找操作(Find):用于查找一个元素所在的集合的代表元素(也就是根节点)。可以通过不断沿着父节点指针向上查找,直到找到根节点。为了优化查找效率,通常会采用路径压缩的方法,即在查找过程中,将查找路径上的节点直接指向根节点,减少后续查找的时间。示例代码如下(假设用数组
parent
来存储每个节点的父节点,节点编号从0开始):
int find(int x, int parent[]) {
if (parent[x] == x) {
return x;
}
return parent[x] = find(parent[x], parent); // 路径压缩
}
- 合并操作(Union):将两个集合合并为一个集合,通常是找到两个集合的代表元素(根节点),然后将其中一个根节点的父节点设置为另一个根节点,实现集合的合并。示例代码(基于前面的
parent
数组表示):
void unionSet(int x, int y, int parent[]) {
int rootX = find(x, parent);
int rootY = find(y, parent);
if (rootX!= rootY) {
parent[rootX] = rootY;
}
}
9.3 并查集的应用场景
- 社交网络中的连通性判断:比如判断两个人是否在同一个社交圈子(连通分量)内,随着新的好友关系建立(合并操作),可以动态地更新并查集结构,快速判断连通情况。
- 游戏中的组队判定:在一些多人在线游戏中,玩家可以组队形成不同的队伍,每个队伍就是一个连通集合,通过并查集可以方便地处理玩家加入或离开队伍(合并或拆分集合)等操作,以及判断两个玩家是否在同一队伍中。
10. 字典树(Trie树)
10.1 字典树的基本概念
字典树又称单词查找树、Trie树,是一种树形结构,用于高效地存储和检索字符串集合中的字符串。它利用字符串的公共前缀来减少查询时间,每个节点可以有多个子节点,对应不同的字符,从根节点到某一节点的路径上经过的字符连接起来就是一个字符串的前缀。
例如,存储单词 “apple”、“app”、“banana”等,根节点没有字符,它的子节点可能有 a
、b
等字符对应的节点,从根节点到存储 a
的节点再到存储 p
的节点这一路径就代表了前缀 “ap”。
10.2 字典树的基本结构与操作
- 节点结构:通常节点结构体包含一个数组(或哈希表等结构)来存储子节点指针,以及一个标记位用于表示从根节点到该节点的路径是否构成一个完整的单词。示例节点结构体如下(这里假设只处理小写字母,用长度为26的数组存储子节点指针):
struct TrieNode {
TrieNode* children[26];
bool isEnd;
TrieNode() {
isEnd = false;
for (int i = 0; i < 26; i++) {
children[i] = NULL;
}
}
};
- 插入操作:将一个字符串插入到字典树中,就是沿着字符串的字符依次查找或创建节点,最后将最后一个节点的标记位设置为
true
表示是一个完整的单词。代码示例如下(以插入单词 “apple” 为例,root
为字典树的根节点):
void insert(TrieNode* root, string word) {
TrieNode* cur = root;
for (char c : word) {
int index = c - 'a';
if (cur->children[index] == NULL) {
cur->children[index] = new TrieNode();
}
cur = cur->children[index];
}
cur->isEnd = true;
}
- 查找操作:在字典树中查找一个字符串,同样沿着字符串的字符依次查找对应的节点,如果能顺利找到最后一个节点且其标记位为
true
,则表示字符串存在于字典树中。示例代码:
bool search(TrieNode* root, string word) {
TrieNode* cur = root;
for (char c : word) {
int index = c - 'a';
if (cur->children[index] == NULL) {
return false;
}
cur = cur->children[index];
}
return cur->isEnd;
}
10.3 字典树的应用场景
- 自动补全功能:在搜索引擎的输入框、代码编辑器的代码补全功能等场景中,根据用户已输入的前缀快速查找可能的完整单词,字典树可以快速定位到以该前缀开头的所有单词。
- 单词拼写检查:将大量正确的单词构建成字典树,对于待检查的单词,可以在字典树中查找是否存在,若不存在则可能拼写有误,并且可以基于字典树给出一些相似的正确单词建议。
结语
感谢您的阅读!期待您的一键三连!欢迎指正!