noi_outline/senior/数据结构.md
2024-10-23 13:08:58 +08:00

17 KiB
Raw Blame History

线性结构

双端栈

双端栈(Deque StackDouble-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():访问右端的元素。

实现

双端栈通常是通过**双端队列dequedouble-ended queue**实现的。C++ 中的标准库容器 std::deque 就是一个可以在两端高效进行插入和删除操作的数据结构,非常适合用来实现双端栈。

应用

双端栈适合用于一些需要在两端频繁操作的场景,比如:

  • 滑动窗口问题:在滑动窗口中,你可能需要同时在窗口的两端添加或移除元素。
  • 双向搜索算法:在某些搜索算法中,可以从两端同时进行搜索。

双端队列

双端队列DequeDouble-Ended Queue)是一种特殊的队列数据结构,它允许在两端进行插入和删除操作。与普通队列(只能从一端入队、另一端出队)不同,双端队列可以从队首和队尾进行入队和出队操作,因此具有更大的灵活性。

双端队列的主要操作:

  1. 从队首插入元素push_front:在队列的前端插入一个元素。
  2. 从队尾插入元素push_back:在队列的末尾插入一个元素。
  3. 从队首删除元素pop_front:移除队列前端的元素。
  4. 从队尾删除元素pop_back:移除队列末尾的元素。
  5. 访问队首元素front:查看队列前端的元素。
  6. 访问队尾元素back:查看队列末端的元素。

双端队列的分类:

  • 输入受限双端队列:只允许从队首删除元素,但只能从一端插入。
  • 输出受限双端队列:只允许从队尾插入元素,但可以从两端删除元素。

双端队列的常见应用:

  • 滑动窗口问题:通过双端队列可以高效地维护一组窗口内的最大或最小值,常用于动态数据流的问题。
  • 任务调度:在一些调度问题中,可以根据任务的优先级从两端进行任务插入和删除。

实现方式:

双端队列可以使用双向链表动态数组实现。使用双向链表可以在常数时间内从两端插入和删除元素,而使用动态数组在平均情况下可以实现较高的访问效率,但插入和删除操作的效率可能会有所下降,尤其是在需要调整数组大小的时候。

C++中的双端队列:

C++ 标准库中提供了deque容器,它支持双端队列的所有基本操作。例如:

#include <deque>
#include <iostream>

int main() {
    std::deque<int> 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。还需要跟踪队列的大小。

操作逻辑

  • 插入操作:插入时需要更新相应端的指针,同时要处理队列为空时的特殊情况。
  • 删除操作:从队列的两端删除元素后,更新指针,处理删除后队列为空的情况。
  • 访问操作:队首和队尾的元素可以直接通过指针访问。

双向链表节点的定义

struct Node {
    int data;
    Node* prev;  // 指向前一个节点
    Node* next;  // 指向后一个节点
    Node(int val) : data(val), prev(nullptr), next(nullptr) {}
};

双端队列类的定义

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_frontpush_back 分别用于在队首和队尾插入新元素。
  • pop_frontpop_back 分别用于从队首和队尾删除元素。
  • getFrontgetBack 用于访问队首和队尾元素。
  • printDeque 打印队列中的所有元素。
  • 析构函数负责在对象销毁时释放所有节点,防止内存泄漏。

示例

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. 块表mapdeque 使用一个指针数组来管理多个小块chunk/block。每个指针指向一个内存块每个内存块保存一定数量的元素。
  2. 小块block:每个小块内存大小固定,deque 元素分布在不同的小块中,但不同的小块在物理内存中不一定连续。
  3. 分段存储:当你从队首或队尾插入元素时,deque 会动态调整块表和小块的数量,不需要像数组那样整体移动数据。

关键点:

  • 插入和删除:从队首和队尾插入删除元素时,deque 只需在块表的头部或尾部插入新块或者移除块表的头尾块。因此插入删除的开销是常数时间O(1)),和链表类似。

  • 随机访问:尽管不同小块的内存位置不连续,但由于使用了块表和固定大小的小块,随机访问时,deque 通过两步操作来计算元素位置:

    • 首先确定元素在块表中的位置:通过除法 index / block_size,确定该元素属于哪个块。
    • 再定位块内偏移:通过取模 index % block_size,确定该元素在该块内的偏移量。

    这两步操作的时间复杂度都是 O(1),因此 deque 可以像数组一样提供 O(1) 的随机访问。

dequevector 的对比

  • vectorvector 是一个连续的动态数组所有元素存储在一块连续的内存区域中。它提供了高效的随机访问O(1)),但是插入和删除操作(尤其是队首操作)在最坏情况下需要移动大量元素,开销较大。

  • dequedeque 是一种分段的存储方式不需要保证整个数据在内存中的连续性。这使得它可以同时支持高效的双端插入删除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++ 实现代码

#include <iostream>
#include <deque>
#include <vector>

using namespace std;

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    deque<int> dq;  // 单调队列,存储的是数组元素的下标
    vector<int> 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<int> nums = {1, 3, -1, -3, 5, 3, 6, 7};
    int k = 3;
    vector<int> 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)

常见图

稀疏图

偶图(二分图)

欧拉图

有向无环图

连通图与强连通图

双连通图

哈希表

数值哈希函数构造

字符串哈希函数构造

哈希冲突的常用处理方法