From 09aeffd2e0c4e7a048c5f3ead1c3268e14cb0226 Mon Sep 17 00:00:00 2001 From: Zengtudor Date: Wed, 23 Oct 2024 13:08:58 +0800 Subject: [PATCH] update --- senior/数据结构.md | 417 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 senior/数据结构.md diff --git a/senior/数据结构.md b/senior/数据结构.md new file mode 100644 index 0000000..a4c1da4 --- /dev/null +++ b/senior/数据结构.md @@ -0,0 +1,417 @@ +# 线性结构 +## 双端栈 +双端栈(**Deque Stack** 或 **Double-ended Stack**)是一种允许在两端进行操作的栈数据结构。通常的栈只能在一端进行操作(称为**栈顶**),即**后进先出**(LIFO, Last In First Out),但双端栈允许在栈的**两端**都可以执行进栈和出栈操作。 + +>注意,双端栈和双端队列在C++中都可以用std::deque来实现,只有使用上的区别 + +### 双端栈的特点 +1. **两端操作**:双端栈可以在栈的**两端**进行进栈(push)和出栈(pop),即可以从栈的前端或后端插入和删除元素。 +2. **灵活性**:与单端栈相比,双端栈提供了更大的灵活性,因为可以根据需要选择从哪一端操作数据。 + +### 操作 +假设双端栈具有两个端点: +- **左端(front)** +- **右端(back)** + +主要操作包括: +- **`push_front()`**:在左端插入元素。 +- **`push_back()`**:在右端插入元素。 +- **`pop_front()`**:从左端弹出元素。 +- **`pop_back()`**:从右端弹出元素。 +- **`front()`**:访问左端的元素。 +- **`back()`**:访问右端的元素。 + +### 实现 +双端栈通常是通过**双端队列(deque,double-ended queue)**实现的。C++ 中的标准库容器 `std::deque` 就是一个可以在两端高效进行插入和删除操作的数据结构,非常适合用来实现双端栈。 + +### 应用 +双端栈适合用于一些需要在两端频繁操作的场景,比如: +- **滑动窗口问题**:在滑动窗口中,你可能需要同时在窗口的两端添加或移除元素。 +- **双向搜索算法**:在某些搜索算法中,可以从两端同时进行搜索。 + +## 双端队列 + +双端队列(Deque,**Double-Ended Queue**)是一种特殊的队列数据结构,它允许在**两端进行插入和删除**操作。与普通队列(只能从一端入队、另一端出队)不同,双端队列可以从**队首和队尾**进行入队和出队操作,因此具有更大的灵活性。 + +### 双端队列的主要操作: +1. **从队首插入元素(push_front)**:在队列的前端插入一个元素。 +2. **从队尾插入元素(push_back)**:在队列的末尾插入一个元素。 +3. **从队首删除元素(pop_front)**:移除队列前端的元素。 +4. **从队尾删除元素(pop_back)**:移除队列末尾的元素。 +5. **访问队首元素(front)**:查看队列前端的元素。 +6. **访问队尾元素(back)**:查看队列末端的元素。 + +### 双端队列的分类: +- **输入受限双端队列**:只允许从队首删除元素,但只能从一端插入。 +- **输出受限双端队列**:只允许从队尾插入元素,但可以从两端删除元素。 + +### 双端队列的常见应用: +- **滑动窗口问题**:通过双端队列可以高效地维护一组窗口内的最大或最小值,常用于动态数据流的问题。 +- **任务调度**:在一些调度问题中,可以根据任务的优先级从两端进行任务插入和删除。 + +### 实现方式: +双端队列可以使用**双向链表**或**动态数组**实现。使用双向链表可以在常数时间内从两端插入和删除元素,而使用动态数组在平均情况下可以实现较高的访问效率,但插入和删除操作的效率可能会有所下降,尤其是在需要调整数组大小的时候。 + +### C++中的双端队列: +C++ 标准库中提供了`deque`容器,它支持双端队列的所有基本操作。例如: + +```cpp +#include +#include + +int main() { + std::deque dq; + + dq.push_back(1); // 在队尾插入1 + dq.push_front(2); // 在队首插入2 + + std::cout << dq.front() << std::endl; // 输出队首元素(2) + std::cout << dq.back() << std::endl; // 输出队尾元素(1) + + dq.pop_back(); // 移除队尾元素 + dq.pop_front(); // 移除队首元素 + + return 0; +} +``` + +要用指针实现一个双端队列(Deque),通常使用**双向链表**作为底层数据结构。双向链表中的每个节点都包含指向前驱节点和后继节点的指针,这样我们可以在常数时间内从两端进行插入和删除操作。 + +### 设计思路 + +1. **节点结构**:双向链表的每个节点需要包含一个数据字段,以及两个指针字段:分别指向前一个节点和后一个节点。 +2. **双端队列结构**:需要维护两个指针,分别指向队列的头节点(front)和尾节点(rear)。还需要跟踪队列的大小。 + +### 操作逻辑 + +- **插入操作**:插入时需要更新相应端的指针,同时要处理队列为空时的特殊情况。 +- **删除操作**:从队列的两端删除元素后,更新指针,处理删除后队列为空的情况。 +- **访问操作**:队首和队尾的元素可以直接通过指针访问。 + +### 双向链表节点的定义 + +```cpp +struct Node { + int data; + Node* prev; // 指向前一个节点 + Node* next; // 指向后一个节点 + Node(int val) : data(val), prev(nullptr), next(nullptr) {} +}; +``` + +### 双端队列类的定义 + +```cpp +class Deque { +private: + Node* front; // 队首指针 + Node* rear; // 队尾指针 + int size; // 当前队列中的元素个数 + +public: + Deque() : front(nullptr), rear(nullptr), size(0) {} + + // 检查队列是否为空 + bool isEmpty() { + return size == 0; + } + + // 返回队列的大小 + int getSize() { + return size; + } + + // 在队首插入元素 + void push_front(int val) { + Node* newNode = new Node(val); + if (isEmpty()) { + front = rear = newNode; // 如果队列为空,队首和队尾都指向新节点 + } else { + newNode->next = front; // 新节点的next指向当前的队首 + front->prev = newNode; // 当前队首的prev指向新节点 + front = newNode; // 更新队首指针 + } + size++; + } + + // 在队尾插入元素 + void push_back(int val) { + Node* newNode = new Node(val); + if (isEmpty()) { + front = rear = newNode; // 如果队列为空,队首和队尾都指向新节点 + } else { + newNode->prev = rear; // 新节点的prev指向当前的队尾 + rear->next = newNode; // 当前队尾的next指向新节点 + rear = newNode; // 更新队尾指针 + } + size++; + } + + // 从队首删除元素 + void pop_front() { + if (isEmpty()) { + std::cout << "Deque is empty, cannot pop from front!" << std::endl; + return; + } + Node* temp = front; + front = front->next; // 更新队首指针 + if (front != nullptr) { + front->prev = nullptr; // 队首前驱指针置空 + } else { + rear = nullptr; // 如果删除后队列为空,更新队尾 + } + delete temp; // 释放节点内存 + size--; + } + + // 从队尾删除元素 + void pop_back() { + if (isEmpty()) { + std::cout << "Deque is empty, cannot pop from back!" << std::endl; + return; + } + Node* temp = rear; + rear = rear->prev; // 更新队尾指针 + if (rear != nullptr) { + rear->next = nullptr; // 队尾后继指针置空 + } else { + front = nullptr; // 如果删除后队列为空,更新队首 + } + delete temp; // 释放节点内存 + size--; + } + + // 获取队首元素 + int getFront() { + if (isEmpty()) { + std::cout << "Deque is empty, no front element!" << std::endl; + return -1; + } + return front->data; + } + + // 获取队尾元素 + int getBack() { + if (isEmpty()) { + std::cout << "Deque is empty, no back element!" << std::endl; + return -1; + } + return rear->data; + } + + // 打印双端队列 + void printDeque() { + if (isEmpty()) { + std::cout << "Deque is empty!" << std::endl; + return; + } + Node* current = front; + while (current != nullptr) { + std::cout << current->data << " "; + current = current->next; + } + std::cout << std::endl; + } + + // 析构函数,释放所有节点 + ~Deque() { + while (!isEmpty()) { + pop_front(); + } + } +}; +``` + +### 代码解释 +- **push_front** 和 **push_back** 分别用于在队首和队尾插入新元素。 +- **pop_front** 和 **pop_back** 分别用于从队首和队尾删除元素。 +- **getFront** 和 **getBack** 用于访问队首和队尾元素。 +- **printDeque** 打印队列中的所有元素。 +- 析构函数负责在对象销毁时释放所有节点,防止内存泄漏。 + +### 示例 + +```cpp +int main() { + Deque dq; + dq.push_back(10); // 队尾插入10 + dq.push_front(20); // 队首插入20 + dq.push_back(30); // 队尾插入30 + + dq.printDeque(); // 输出: 20 10 30 + + dq.pop_front(); // 队首删除 + dq.printDeque(); // 输出: 10 30 + + dq.pop_back(); // 队尾删除 + dq.printDeque(); // 输出: 10 + + std::cout << "Front: " << dq.getFront() << std::endl; // 输出队首元素: 10 + std::cout << "Back: " << dq.getBack() << std::endl; // 输出队尾元素: 10 + + return 0; +} +``` + +### 运行结果 + +``` +20 10 30 +10 30 +10 +Front: 10 +Back: 10 +``` +在 C++ 标准库中,`deque`(双端队列)既支持**双端插入和删除**,又可以进行**随机访问**,这与其底层实现方式有关。C++ 的 `deque` 并不是直接用链表实现的,而是通过一种特殊的**分段连续存储**(segmented array)来实现,这使得它可以兼具高效的双端插入删除和随机访问功能。 + +### `deque` 的底层实现原理 + +`deque` 的底层通常是一个类似**动态数组的块表结构**,由一组**连续的小块**(chunk 或者 block)组成。每一个小块大小固定,而这些小块之间不需要在内存中是连续的。为了管理这些小块,`deque` 会使用一个指针数组(类似索引表),每个指针指向一个块。这种设计使得 `deque` 既能像链表一样支持高效的双端操作,也能像数组一样支持随机访问。 + +#### 具体结构: +1. **块表(map)**:`deque` 使用一个指针数组来管理多个小块(chunk/block)。每个指针指向一个内存块,每个内存块保存一定数量的元素。 +2. **小块(block)**:每个小块内存大小固定,`deque` 元素分布在不同的小块中,但不同的小块在物理内存中不一定连续。 +3. **分段存储**:当你从队首或队尾插入元素时,`deque` 会动态调整块表和小块的数量,不需要像数组那样整体移动数据。 + +#### 关键点: +- **插入和删除**:从队首和队尾插入删除元素时,`deque` 只需在块表的头部或尾部插入新块,或者移除块表的头尾块。因此,插入删除的开销是常数时间(O(1)),和链表类似。 +- **随机访问**:尽管不同小块的内存位置不连续,但由于使用了块表和固定大小的小块,随机访问时,`deque` 通过两步操作来计算元素位置: + - **首先确定元素在块表中的位置**:通过除法 `index / block_size`,确定该元素属于哪个块。 + - **再定位块内偏移**:通过取模 `index % block_size`,确定该元素在该块内的偏移量。 + + 这两步操作的时间复杂度都是 O(1),因此 `deque` 可以像数组一样提供 O(1) 的随机访问。 + +### `deque` 和 `vector` 的对比 + +- **`vector`**:`vector` 是一个连续的动态数组,所有元素存储在一块连续的内存区域中。它提供了高效的随机访问(O(1)),但是插入和删除操作(尤其是队首操作)在最坏情况下需要移动大量元素,开销较大。 + +- **`deque`**:`deque` 是一种分段的存储方式,不需要保证整个数据在内存中的连续性。这使得它可以同时支持高效的双端插入删除(O(1)),并且还能在 O(1) 时间内随机访问元素。 + +### 结论 +C++ 中的 `deque` 之所以可以支持随机访问,是因为它的底层采用了**分段数组**的存储方式,而不是链表。虽然 `deque` 的内存布局是分散的,但通过使用块表和小块,`deque` 可以在常数时间内定位任意元素的位置,从而实现与数组类似的随机访问性能。 + +因此,`deque` 是一种非常灵活的数据结构,既可以支持高效的随机访问,又能高效地从两端插入和删除元素。 +## 单调队列 +单调队列(Monotonic Queue)是一种特别的数据结构,常用于解决一些具有滑动窗口性质的问题。在处理滑动窗口最大值、最小值或者其他具有区间性质的优化问题时,单调队列能够有效地减少时间复杂度,使一些暴力解法从 \(O(n^2)\) 优化为 \(O(n)\) 或 \(O(n \log n)\)。 + +### 单调队列的作用 +单调队列的主要作用是: +1. **在滑动窗口中维护最大值或最小值**:单调队列可以在常数时间内得到滑动窗口的最值。通过维护一个递增或递减的队列,在每次窗口滑动时,可以快速移除不符合要求的元素,并保持队列的单调性。 + +2. **优化动态区间问题**:当我们需要动态地在某个区间内找最大/最小值时,单调队列可以在 \(O(n)\) 的时间复杂度内处理问题,而不需要每次重新遍历区间。 + +### 基本原理 +单调队列之所以高效,关键在于保持队列中的元素是有序的。根据需求,可以维护递增或递减队列: +- **递增队列**:队列中的元素从头到尾是递增的,这样可以在队列头部得到最小值。 +- **递减队列**:队列中的元素从头到尾是递减的,这样可以在队列头部得到最大值。 + +每当滑动窗口移动时,我们会将新的元素插入队列,同时确保队列的单调性。队列中的元素可能会失效(比如已经不在滑动窗口的范围内),这些元素需要被及时移除。 + +### C++ 实现滑动窗口最大值问题 + +接下来,我们以经典的“滑动窗口最大值”问题为例,给出单调队列的 C++ 实现。 + +#### 题目描述 +给定一个大小为 \(n\) 的数组,和一个大小为 \(k\) 的滑动窗口。窗口从数组的左端移动到右端,每次只向右移动一位,求每个窗口中的最大值。 + +#### C++ 实现代码 + +```cpp +#include +#include +#include + +using namespace std; + +vector maxSlidingWindow(vector& nums, int k) { + deque dq; // 单调队列,存储的是数组元素的下标 + vector result; + + for (int i = 0; i < nums.size(); ++i) { + // 移除已经不在窗口范围内的元素 + if (!dq.empty() && dq.front() == i - k) { + dq.pop_front(); + } + + // 移除队列中所有小于当前元素的值,因为它们不可能成为最大值 + while (!dq.empty() && nums[dq.back()] < nums[i]) { + dq.pop_back(); + } + + // 将当前元素下标添加到队列 + dq.push_back(i); + + // 当窗口长度达到k时,记录当前窗口的最大值 + if (i >= k - 1) { + result.push_back(nums[dq.front()]); // 队列头部元素就是当前窗口的最大值 + } + } + + return result; +} + +int main() { + vector nums = {1, 3, -1, -3, 5, 3, 6, 7}; + int k = 3; + vector result = maxSlidingWindow(nums, k); + + for (int max_val : result) { + cout << max_val << " "; + } + + return 0; +} +``` + +#### 代码解释 +1. **deque**:我们使用双端队列 `dq` 来存储数组元素的下标,确保队列中的元素始终是从大到小排列的。这样队列头部的元素永远是当前窗口的最大值。 +2. **删除无效元素**:如果队列中的元素已经不在当前滑动窗口的范围内(即下标小于 \(i - k + 1\)),我们将其从队列头部移除。 +3. **保持队列单调性**:在将当前元素插入队列之前,移除所有队列中比当前元素小的元素,因为它们不可能再成为未来窗口的最大值。 +4. **记录结果**:一旦窗口大小达到 \(k\),我们将队列头部的元素(即最大值)加入结果数组。 + +### 时间复杂度 +该算法的时间复杂度为 \(O(n)\),因为每个元素最多被插入和移除队列一次。 +## 优先队列 + +## ST表 + +# 集合与森林 + +## 并查集 + +## 树的孩子兄弟表示法 + +# 特殊树 + +## 二叉堆,树状数组 + +## 线段树 + +## 字典树(Trie树) + +## 笛卡尔树 + +## 平衡树(AVL, treap, splay) + +# 常见图 +## 稀疏图 + +## 偶图(二分图) + +## 欧拉图 + +## 有向无环图 + +## 连通图与强连通图 + +## 双连通图 + +# 哈希表 + +## 数值哈希函数构造 + +## 字符串哈希函数构造 + +## 哈希冲突的常用处理方法 + +