当前位置: 首页 > article >正文

简易STL实现 | Deque的实现

一种 在内存中存储元素的数据结构,它支持 在两端添加和删除元素(使用循环数组实现)

1、deque的特性(分段deque实现)

1、双端操作: deque支持在前端和后端执行快速的插入和删除操作

2、随机访问: 与list不同,deque允许在常量时间内 对元素进行随机访问。这是因为 deque的内部结构采用分段数组,而不是链表
myDeque[2] 和 myDeque[4] 的访问时间是常量的,不受deque的大小的影响
这是因为std::deque 使用了分段数组的结构,每个分段都是一个固定大小的数组,因此可以直接计算索引的位置,而不需要遍历整个容器

将数据存储 在多个较小的数组(块)中,而不是 单个连续的大数组。这样做的好处是 可以更有效地扩展容量,避免频繁的内存重新分配

#include <iostream>
#include <stdexcept>
#include <vector>

template <typename T>
class Deque {
public:
    Deque() : capacity(1), size(0), frontIndex(0), backIndex(0) {
        blocks.push_back(new T[blockSize]); // 每次新分配小块数组都是固定大小(都是blockSize,就是多少个的问题)
    }

    ~Deque() {
        clear();
        for (auto block : blocks) {
            delete[] block;
        }
    }

    void push_back(const T& e) {
        if (size == capacity) {
            resize();
        }
        blocks[backIndex / blockSize][backIndex % blockSize] = e; // 核心思路
        backIndex = (backIndex + 1) % capacity;
        size++;
    }

    void push_front(const T& e) {
        if (size == capacity) {
            resize();
        }
        frontIndex = (frontIndex - 1 + capacity) % capacity; // 思路一致,在不需要分块的代码的基础上(见后面)加一个在哪个块的计算
        blocks[frontIndex / blockSize][frontIndex % blockSize] = e;
        size++;
    }

    void pop_back() {
        if (size == 0) {
            throw std::out_of_range("Deque is empty");
        }
        size--;
        backIndex = (backIndex - 1 + capacity) % capacity;
    }

    void pop_front() {
        if (size == 0) {
            throw std::out_of_range("Deque is empty");
        }
        size--;
        frontIndex = (frontIndex + 1) % capacity;
    }

    void clear() {
        while (size > 0) {
            pop_back();
        }
    }

    size_t getSize() const {
        return size;
    }

    T& operator[](size_t index) {
        if (index >= size) {
            throw std::out_of_range("index out of range");
        }
        size_t realIndex = (frontIndex + index) % capacity;
        return blocks[realIndex / blockSize][realIndex % blockSize];
    }

    void printElement() const {
        if (size == 0) {
            std::cout << "empty";
        } else {
            size_t cur = frontIndex;
            for (size_t i = 0; i < size; i++) {
                std::cout << blocks[cur / blockSize][cur % blockSize] << " ";
                cur = (cur + 1) % capacity;
            }
        }
        std::cout << std::endl;
    }

private:
    static const size_t blockSize = 64;  // 每个块的大小
    std::vector<T*> blocks;  // 存储块的指针
    size_t capacity;  // 总容量(所有块的总和)
    size_t size;  // 当前元素数量
    size_t frontIndex;  // 队首索引
    size_t backIndex;  // 队尾索引

    void resize() { // 核心思路
        size_t newCapacity = 2 * capacity;
        size_t newBlockCount = (newCapacity + blockSize - 1) / blockSize;
        blocks.resize(newBlockCount);
        for (size_t i = capacity / blockSize; i < newBlockCount; i++) {
            blocks[i] = new T[blockSize];
        }

        // 重新排列元素
        if (frontIndex > backIndex) {
            size_t shift = newCapacity - capacity;
            for (size_t i = 0; i < backIndex; i++) {
                blocks[(i + shift) / blockSize][(i + shift) % blockSize] = blocks[i / blockSize][i % blockSize];
            }
            backIndex += shift;
        }

        capacity = newCapacity;
    }
};

块数组的引入:
使用 std::vector<T*> blocks 来存储每个块的指针。每个块的大小由 blockSize 决定。每次新分配小块数组都是固定大小(都是blockSize,就是多少个的问题)

push_back 和 push_front:(pop跟不是块数组一致)
当插入新元素时,确定插入位置是否需要跨块,如果需要,则计算正确的块索引和块内的偏移量(思路一致,在不需要分块的代码的基础上(见后面)加一个在哪个块的计算)

resize:
当当前容量不足时,resize 函数会将容量扩展到原来的两倍,并重新分配块
如果 frontIndex 在 backIndex 后面(即队列在数组中是环形排列的),则需要将后半部分元素移至新位置

void resize() { // 核心思路
        size_t newCapacity = 2 * capacity;
        size_t newBlockCount = (newCapacity + blockSize - 1) / blockSize;
        blocks.resize(newBlockCount);
        for (size_t i = capacity / blockSize; i < newBlockCount; i++) {
            blocks[i] = new T[blockSize];
        }

        // 重新排列元素
        if (frontIndex > backIndex) {
            size_t shift = newCapacity - capacity;
            for (size_t i = 0; i < backIndex; i++) {
                blocks[(i + shift) / blockSize][(i + shift) % blockSize] = blocks[i / blockSize][i % blockSize];
            }
            backIndex += shift;
        }

        capacity = newCapacity;
    }

分段数组:Deque 内部的元素存储在多个小块数组(即“blocks”)中,而不是单一的大数组中。每个块的大小是固定的(由 blockSize 定义)。
动态扩展:当 Deque 需要存储更多元素时,resize() 函数会动态增加存储空间
size_t newCapacity = 2 * capacity;:动态扩展时,通常会将容量加倍以减少未来的扩展次数
size_t newBlockCount = (newCapacity + blockSize - 1) / blockSize;:计算扩展后的块数量 newBlockCount。每个块的大小是 blockSize,因此总块数为 newCapacity 除以 blockSize。(newCapacity + blockSize - 1) 用来确保向上取整,这样可以容纳所有的新元素
blocks.resize(newBlockCount);:blocks 是存储块指针的 std::vector,通过 resize 将 blocks 的大小调整为新的块数 newBlockCount

for (size_t i = capacity / blockSize; i < newBlockCount; i++) {
    blocks[i] = new T[blockSize];
}

为新增的块分配内存。循环遍历 blocks 中的新块区域,为每个新块分配大小为 blockSize 的数组

if (frontIndex > backIndex) {
    size_t shift = newCapacity - capacity;
    for (size_t i = 0; i < backIndex; i++) {
        blocks[(i + shift) / blockSize][(i + shift) % blockSize] = blocks[i / blockSize][i % blockSize];
    }
    backIndex += shift;
}

检测队列是否环绕:frontIndex > backIndex 表示当前的队列元素在数组中是环绕排列的(即头部索引在尾部索引后面)
计算偏移量 shift:新旧容量之间的差值 shift 是新元素需要移动的步数
元素重新排列:将当前 blocks 中 backIndex 前的元素依次复制到新的位置。新的位置是在旧的位置上加上 shift 偏移后的索引。通过这个操作,确保环绕的部分正确地移动到新的空间中(将环绕的部分(即 backIndex 之前的部分)移动到新的存储空间的后半部分,以使队列不再环绕),保持 Deque 的正确顺序
更新 backIndex:将 backIndex 也相应地向前移动 shift 个位置,以反映它在新扩展后的块数组中的位置

访问和打印:
operator[] 访问时,通过计算得到正确的块索引和块内偏移量
printElement 函数遍历元素,并以正确的顺序打印出来

3、动态扩展: deque的大小可以动态调整,无需 事先分配固定大小的内存。这使得 deque 适用于需要动态增长和缩小的情况

4、内存局部性: deque的内部结构利用了多个缓冲区,有助于提高内存局部性,从而在某些情况下提供更好的性能(指程序在执行过程中访问的内存地址 具有一定的规律性,即在时间和空间上相邻的内存地址被频繁访问)
与单一的大数组相比,deque 的分段数组结构 使得每个缓冲区的大小固定且较小。这样,即使需要扩展,也不会频繁地移动大量内存数据,只需操作当前使用的缓冲区即可,这降低了缓存未命中的可能性
分段数组结构 使得最近访问的缓冲区和其中的元素很可能会被缓存,从而在访问这些数据时 具有较高的缓存命中率,这在需要频繁操作队列的前后端时尤其明显

2、deque 工作原理

backIndex指向的位置是当前末尾元素的下一个位置, 也就是还没有有效的数据(如果容量满了, 其会指向frontIndex的位置)
在这里插入图片描述

3、实现 deque(不分段)

#include <iostream>
#include <stdexcept>
#include <string>
#include <sstream>

template <typename T>
class Deque {
public:
    Deque() : elements(nullptr), capacity(0), size(0), frontIndex(0), backIndex(0) {}

    ~Deque() {
        clear();
        delete[] elements;
    }

    void push_back(const T& e) {
        if (size == capacity) {
            resize();
        }
        elements[backIndex] = e;
        backIndex = (backIndex + 1) % capacity;
        size++;
    }

    void push_front(const T& e) {
        if (size == capacity) {
            resize();
        }
        size_t newFrontIndex = (frontIndex - 1 + capacity) % capacity; // 为了防止数组索引为负, 使其指向尾端
        frontIndex = newFrontIndex;
        elements[frontIndex] = e;
        size++;
    }

    void pop_back() { // 不需要负责内存的释放,内存的释放由析构函数解决
        if (size == 0) {
            throw std::out_of_range("Deque is empty");
        }
        size--;
        backIndex = (backIndex - 1 + capacity) % capacity;
    }

    void pop_front() {
        if (size == 0) {
            throw std::out_of_range("Deque is empty");
        }
        size--;
        frontIndex = (frontIndex + 1) % capacity;
    }

    void clear() {
        while (size > 0) {
            pop_back(); // 可以复用
        }
    }

    size_t getSize() {
        return size;
    }

    T& operator[](int index) { // 参数必须是int,不然就有可能越界(size_t转换负值为未知正整数)
        if (index < 0 || index >= size) {
            throw std::out_of_range("index out of range");
        }
        return elements[(frontIndex + index) % capacity]; // 注意下标不是直接是index
    }

    void printElement() {
        if (size == 0) {
            std::cout << "empty";
        }
        size_t cur = frontIndex;
        for (size_t i = 0; i < size; i++) {
            std::cout << elements[cur] << " ";
            cur = (cur + 1) % capacity;
        }
        std::cout << std::endl;

    }
private:
    T* elements;
    size_t capacity;
    size_t size;
    size_t frontIndex;
    size_t backIndex;

    void resize() {
        size_t newCapacity = (capacity == 0) ? 1 : 2 * capacity;
        T* newElements = new T[newCapacity];
        size_t cur = frontIndex;
        for (size_t i = 0; i < size; i++) {
            newElements[i] = elements[cur];
            cur = (cur + 1) % capacity;
        }
        delete[] elements;
        elements = newElements;
        frontIndex = 0;
        backIndex = size;
        capacity = newCapacity;
    }


}; // 类定义后别忘了;

int main() {
    Deque<int> myDeque;
    int N;
    std::cin >> N;
    getchar();
    for (int i = 0; i < N; i++) {
        std::string line;
        std::getline(std::cin, line);
        std::istringstream iss(line);
        std::string command;
        iss >> command;
        if (command == "push_back") {
            int num;
            iss >> num;
            myDeque.push_back(num);
        }
        else if (command == "push_front") {
            int num;
            iss >> num;
            myDeque.push_front(num);
        }
        else if (command == "pop_back") { // pop要判断是否为空
            if (myDeque.getSize() == 0) {
                continue;
            }
            myDeque.pop_back();
        }
        else if (command == "pop_front") {
            if (myDeque.getSize() == 0) {
                continue;
            }
            myDeque.pop_front();
        }
        else if (command == "clear") {
            myDeque.clear();
        }
        else if (command == "size") {
            std::cout << myDeque.getSize() << std::endl;
        }
        else if (command == "get") {
            size_t pos;
            iss >> pos;
            std::cout << myDeque[pos] << std::endl;
        }
        else if (command == "print") {
            myDeque.printElement();
        }
    }

    return 0;
}

和 C++ STL标准库实现版本的区别:
1、标准库中的 std::deque(双端队列)通常是通过 多个连续存储区域(即一维数组)来实现的,而不是单一的连续数组, 这多个一维数组 连接起来形成了deque数组, 目前的实现采用的是循环数组, 缺点就是resize时要复制旧数组, 而官方的std::deque只需要再串联一个一维数组就可以了, 效率更高

2、内存管理: 在实际的 STL 中,内存管理通常会更加复杂和高效。STL 会使用分配器(allocator)来管理内存,而不是直接使用 new 和 delete。分配器允许用户提供自定义的内存管理策略

3、迭代器和算法支持: STL 提供了丰富的迭代器和算法支持,允许通用的方式操作容器
迭代器的支持
输入迭代器:只能进行读操作,且只能单向遍历

#include <iostream>
#include <iterator>
#include <numeric>

int main() {
    std::istream_iterator<int> input(std::cin), eof;
    int sum = std::accumulate(input, eof, 0);
    std::cout << "Sum: " << sum << std::endl;
    return 0;
}

输出迭代器:只能进行写操作,且只能单向遍历

#include <iostream>
#include <iterator>
#include <algorithm>
#include <vector>

int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    std::ostream_iterator<int> output(std::cout, " ");
    std::copy(vec.begin(), vec.end(), output);
    std::cout << std::endl;
    return 0;
}

前向迭代器:可以读写操作,并且可以多次遍历

#include <iostream>
#include <algorithm>
#include <forward_list>

int main() {
    std::forward_list<int> flist = {1, 2, 3, 2, 1};
    std::replace(flist.begin(), flist.end(), 2, 10);
    for (int n : flist) {
        std::cout << n << " ";
    }
    std::cout << std::endl;
    return 0;
}

双向迭代器:可以前后遍历,例如 list 容器的迭代器

#include <iostream>
#include <algorithm>
#include <list>

int main() {
    std::list<int> lst = {1, 2, 3, 4, 5};
    std::reverse(lst.begin(), lst.end());
    for (int n : lst) {
        std::cout << n << " ";
    }
    std::cout << std::endl;
    return 0;
}

随机访问迭代器:支持常数时间内的随机访问操作,例如 vector 和 deque 容器的迭代器

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> vec = {5, 3, 9, 1, 7};
    std::sort(vec.begin(), vec.end());
    if (std::binary_search(vec.begin(), vec.end(), 3)) {
        std::cout << "Found!" << std::endl;
    }
    return 0;
}

算法的支持
排序算法:std::sort 可以对任何随机访问迭代器(如 vector、deque)容器进行排序
搜索算法:std::find 可以在线性时间内在任何容器中查找特定元素
修改算法:std::transform 可以对容器中的每个元素应用指定的函数并存储结果
复制算法:std::copy 可以将一个容器的内容复制到另一个容器中

4、性能优化: 实际的 STL 库通常会进行更多的性能优化,包括使用更复杂的数据结构、考虑缓存友好性等

5、标准兼容性: 实际的 STL 遵循 C++ 标准,确保在不同的平台和编译器上的一致行为。它还提供了许多其他功能,如分配器的自定义、迭代器标签(iterator tags)等

4、高频面试题

1、deque 和 vector 的主要区别是什么
内存分配:vector 使用单一连续的内存空间来存储元素,而 deque 使用多个分散的内存块。这意味着 deque 可以在两端进行高效的插入和删除操作,而 vector 只能在尾部高效地进行这些操作
插入效率:在 deque 的前端和后端插入或删除元素的操作效率很高,但在中间插入或删除元素效率较低,因为它需要移动多个内存块中的元素。而 vector 在尾部插入和删除元素高效(头部不行),但在前端或中间进行这些操作效率较低,因为可能需要移动大量元素
随机访问:vector 提供了更快的随机访问性能,因为它使用连续内存,而 deque 由于其分散的内存块,随机访问性能稍低
内存耗费:vector 在扩展时可能会有较大的内存重新分配成本,而 deque 因为是分块存储,所以通常不需要大量的内存重新分配

2、使用 deque 实现固定大小滑动窗口

#include <iostream>
#include <deque>

class SlidingWindow {
public:
    SlidingWindow(size_t windowSize) : maxSize(windowSize) {}

    void add(int value) {
        // 如果窗口已满,则删除最早的元素
        if (window.size() == maxSize) {
            window.pop_front();
        }
        // 添加新元素到窗口
        window.push_back(value);
    }

    double getAverage() const {
        if (window.empty()) return 0.0;
        
        double sum = 0;
        for (int value : window) {
            sum += value;
        }
        return sum / window.size();
    }

    void printWindow() const {
        for (int value : window) {
            std::cout << value << " ";
        }
        std::cout << std::endl;
    }

private:
    std::deque<int> window;
    size_t maxSize;
};

3、在 deque 中使用 [] 操作符和 at() 方法有何区别
operator[] 提供对元素的无检查访问,这意味着如果使用超出范围的索引,它不会抛出异常,而是导致未定义行为
at() 方法提供边界检查的访问。如果指定的索引超出了 deque 的范围,at() 方法会抛出一个 std::out_of_range 异常。因此,at() 比 operator[] 更安全,但可能会略微降低性能
使用 at() 方法可以帮助调试中发现越界错误,而 operator[] 在确定不会越界时可以提供更好的性能

4、在 deque 的前端插入一个元素时,迭代器会发生什么

  1. std::deque 的行为
    std::deque 是一个双端队列,允许在两端快速插入和删除元素。与 std::vector 不同,std::deque 是分段存储的,因此在内部的实现中有多个内存块
    1)前端插入:
    在 std::deque 的前端插入元素时,通常不会导致整个容器的重新分配。但如果前端没有足够的空间(即最前面的块已满),std::deque 可能会在前端分配一个新的块
    由于 std::deque 可能需要移动或重新分配内部的内存块,因此所有的迭代器、引用和指针可能会失效
    2)后端插入:
    在 std::deque 的尾部插入元素时,情况与前端插入类似。如果后端没有足够的空间,可能需要分配新的块,从而导致迭代器、引用和指针失效
  2. std::vector 是一个动态数组,当在尾部插入元素时,其行为如下:
    1)尾部插入:
    如果 std::vector 的当前容量足够,那么插入新元素不会导致迭代器、引用和指针失效
    但是,如果插入新元素导致 std::vector 的容量不足,它会分配更大的内存块,并将现有的元素复制到新分配的内存中。这种情况下,所有指向旧元素的迭代器、引用和指针都会失效
    2)前端或中间插入:
    在 std::vector 的前端或中间插入元素时,即使没有触发容量重新分配,由于元素需要移动以为新元素腾出空间,指向被移动元素的迭代器、引用和指针也会失效

5、 解释 deque 的内部工作机制。它是如何实现 两端插入和删除操作的高效性的
std::deque 维护了一系列定长数组(称为块或缓冲区),并且有一个中央控制器(通常是指针数组)来管理这些块。每个块可以独立地增长和收缩,这意味着当在两端添加或删除元素时,只有相关联的块受到影响

在前端插入:如果第一个块有空间,则在该块的开始插入新元素。如果没有空间,deque 会分配一个新块 并将其链接到现有的块序列中
在后端插入:如果最后一个块有空间,则在该块的末尾插入新元素。如果没有空间,deque 会分配一个新块 并将其链接到现有的块序列中
在前端删除:移除第一个块的第一个元素。如果该块变空,则释放该块资源 并更新中央控制器
在后端删除:移除最后一个块的最后一个元素。如果该块变空,则释放该块资源 并更新中央控制器

6、deque 的构造函数

std::deque<int> dq; // 创建一个空的 deque

std::deque<int> dq(10, 5); // 10个元素,每个都是5

std::vector<int> vec{1, 2, 3, 4, 5};
std::deque<int> dq(vec.begin(), vec.end()); // 复制给定迭代器范围内的元素

std::deque<int> dq1(10, 5);
std::deque<int> dq2(dq1); // 拷贝构造函数

std::deque<int> dq1(10, 5);
std::deque<int> dq2(std::move(dq1)); // 移动构造函数

std::deque<int> dq{1, 2, 3, 4, 5}; // 使用一个初始化列表来创建

// 带有分配器的默认构造函数:创建一个空的 deque,但是使用自定义的分配器来分配内存
std::allocator<int> alloc;
std::deque<int, std::allocator<int>> dq(alloc);

std::allocator<int> alloc;
std::deque<int, std::allocator<int>> dq(10, 5, alloc); // 10个元素,每个都是5,使用自定义分配器

std::vector<int> vec{1, 2, 3, 4, 5};
std::allocator<int> alloc;
std::deque<int, std::allocator<int>> dq(vec.begin(), vec.end(), alloc);

std::deque<int> dq1(10, 5);
std::allocator<int> alloc;
std::deque<int, std::allocator<int>> dq2(dq1, alloc); // 拷贝dq1,使用自定义分配器

std::deque<int> dq1(10, 5);
std::allocator<int> alloc;
std::deque<int, std::allocator<int>> dq2(std::move(dq1), alloc); // 移动dq1,使用自定义分配器

std::allocator<int> alloc;
std::deque<int, std::allocator<int>> dq({1, 2, 3, 4, 5}, alloc);

http://www.kler.cn/a/284809.html

相关文章:

  • NVIDIA NIM 简介
  • 将python下载的依赖包传到没网的服务器
  • sqlserver删除最近2个月的记录
  • Kubernetes的基本构建块和最小可调度单元pod-0
  • Spring资源加载模块,原来XML就这,活该被注解踩在脚下 手写Spring第六篇了
  • Spring Boot集成SQL Server快速入门Demo
  • PyMOL的开源版和商业版如何选择 PyMOL开源版安装 PyMOL商业版安装 PyMOL安装教程 远程安装PyMOL正式版 官网版
  • PDF文本指令解析与文本水印去除
  • 【IDEA】一键重启多个服务
  • 游戏出海,燃动全球,“安全”如何通关?
  • 【C++】有关vector迭代器失效问题
  • 快速了解Git服务器端基础及基本操作命令(一)
  • mysql的group by怎么用
  • disk manager操作教程 如何使用Disk Manager组件 Mac如何打开ntfs格式文件
  • Open WebUI官方库:解锁人工智能服务的官方通道
  • git常见命令行及分支规范
  • MATLAB智能优化算法-学习笔记(1)——遗传算法求解0-1背包问题【过程+代码】
  • 通过css,js html结合实现第一个页面
  • 网络安全实训六(靶机实例DC-3)
  • 迭代器模式
  • TWRP 使用帮助 第三方Recovery
  • 给鼠标一个好看的指针特效 鼠标光标如何修改形状?
  • 如何在项目中配置.gitignore文件
  • [合集]一汽大众(斯柯达、奥迪、兰博基尼、宾利等)故障代码查询合集
  • 【论文笔记】独属于CV的注意力机制CBAM-Convolutional Block Attention Module
  • Ubuntu上安装配置(jdk/tomcat/ufw防火墙/mysql)+mysql卸载