当我们需要存储“最后n个项目”时,使用列表是否比向量更好?

43

有很多问题表明应该始终使用向量,但在我看来,对于需要存储“最后n个项目”的情况,列表可能更好。

例如,假设我们需要存储最近看到的最后5项:

迭代0:

3,24,51,62,37,

然后在每次迭代中,索引为0的项被删除,并将新项添加到末尾:

迭代1:

24,51,62,37,8

迭代 2:

51,62,37,8,12

对于这种情况,对于向量来说,复杂度将会是O(n),因为我们需要复制n个项,但对于列表来说,应该是O(1),因为每次迭代我们总是只是从头部裁剪并添加到尾部。

我的理解正确吗?这是std::list的实际行为吗?


4
看这个...它解释了何时选择哪一个:https://dev59.com/E3E95IYBdhLWcg3wrP0n - Pushpendra
2
这个由Bjarne Stroustrup主讲的演讲非常有用:https://www.youtube.com/watch?v=YQs6IC-vgmo - Galik
10个回答

95

都不需要。你的集合大小是固定的,std::array已经足够。

你实现的数据结构叫做环形缓冲区。为了实现它,你需要创建一个数组,并跟踪当前第一个元素的偏移量。

当你添加一个将推出缓冲区的元素时 - 也就是当你删除第一个元素时 - 你会增加偏移量。

要在缓冲区中获取元素,你需要将索引和偏移量相加并对缓冲区的长度取模。


20
循环缓冲区是队列的一种完美实现方式,它具有固定的最大大小(不幸的是,目前没有标准库容器实现该功能)。 - manlio
3
如果有一个简单的实现方法,我认为这个答案会更有用,虽然我自己也能够实现。 - Rahul Iyer
1
@KaizerSozay 如果你想的话,你可以随时建议一个带有你实现的编辑。 - Maya
8
请使用boost::circular_buffer - GManNickG
1
索引在最后一行被重置,但是它可能需要更多的强调,因为它很重要:“将索引和偏移量相加并取余数,结果值不能超过缓冲区的长度”。 - user3067860
显示剩余5条评论

33

std::deque是更好的选择。如果你对其性能进行了基准测试,发现它在特定用途下表现不佳,那么你可以在固定大小的数组中实现循环缓冲区,并存储缓冲区起始位置的索引。当替换缓冲区中的元素时,您将覆盖起始索引处的元素,然后将起始索引设置为其先前值加上缓冲区大小的模数。

列表遍历非常慢,因为列表元素可能分散在内存中,而向量移位实际上非常快,因为单个内存块上的内存移动非常快,即使它是一个大块。

Taming The Performance Beast这次Meeting C++ 2015会议的演讲可能对您有兴趣。


3
参考链接:局部性原理数组(以及因此向量)具有局部性引用,而链接列表则没有。当您访问数组中的一个元素时,相邻的元素也会与其一起被提取到CPU缓存中,以便更快地访问。链接列表更容易导致缓存未命中。 - Wyzard
3
是的,std::deque 使用数组,但不止一个。引用自cppreference:"与std::vector不同,deque的元素不是连续存储的:典型实现使用一系列单独分配的固定大小数组"。这仍然比每个单独的项目在不同的位置分别分配要好得多。 - Wyzard
2
@KaizerSozay,你最好对不同的数据结构进行一些性能测试。个人认为在这种情况下向量会更胜一筹。 - Galik
1
等等,@Galik。OP的问题中没有中间插入。按照我的理解,它总是添加到末尾。 - user4581301
1
@user4581301 哦,我明白了,位置4是另一端!! - Galik
显示剩余18条评论

25
如果您可以使用Boost,请尝试boost::circular_buffer

Boost Circular Buffer

这是一种类似于 std::list 或者 std::deque 的序列。它支持随机访问迭代器,常数时间的在缓冲区开头或结尾进行插入和删除操作,并且可以与 std 算法互操作。
该序列提供了固定容量的存储:当缓冲区被填满时,新数据会从缓冲区开头开始写入并覆盖旧数据。
// Create a circular buffer with a capacity for 5 integers.
boost::circular_buffer<int> cb(5);

// Insert elements into the buffer.
cb.push_back(3);
cb.push_back(24);
cb.push_back(51);
cb.push_back(62);
cb.push_back(37);

int a = cb[0];  // a == 3
int b = cb[1];  // b == 24
int c = cb[2];  // c == 51

// The buffer is full now, so pushing subsequent
// elements will overwrite the front-most elements.
cb.push_back(8);   // overwrite 3 with 8
cb.push_back(12);  // overwrite 24 with 12

// The buffer now contains 51, 62, 37, 8, 12.

// Elements can be popped from either the front or the back.
cb.pop_back();  // 12 is removed
cb.pop_front(); // 51 is removed

“circular_buffer”将其元素存储在一段连续的内存区域中,这使得元素的插入、删除和随机访问非常快速,时间复杂度为常数级别。

PS …或者像Taemyr建议的那样,直接实现循环缓冲区

《Overload Journal #50 - Aug 2002》(Pete Goodliffe撰写)对编写强健的类似STL的循环缓冲区有一个不错的介绍


2
这随后使得元素的快速常数时间插入和删除成为可能,只有在两端才能实现,否则是线性的。 - sp2danny

5
问题在于O(n)仅仅是针对当n趋近于无穷大时的渐近行为进行讨论。如果n很小,那么涉及到的常数因子就变得非常重要了。结果是对于“最后5个整数项”,如果vector没有击败list,我会感到惊讶的。我甚至认为std::vector会击败std::deque。
对于“最后500个整数项”,我仍然期望std::vector比std::list更快 - 但现在std::deque可能会获胜。对于“最后500万个慢速复制项”,std:vector将是最慢的。
基于std::array或std::vector的环形缓冲区可能仍然更快。
几乎总是如此的性能问题:
- 封装一个固定接口 - 编写能够实现该接口的最简单代码 - 如果分析显示存在问题,请进行优化(这将使代码更加复杂)。
实际上,只需使用std::deque或者如果您有一个预先构建的环形缓冲区,就足够了。(但除非分析表明您需要这样做,否则没有必要费力地编写环形缓冲区。)

3
如果您需要存储最后N个元素,那么逻辑上您正在执行某种队列或循环缓冲区操作,std::stackstd::dequeLIFOFIFO队列的实现。
您可以使用boost::circular_buffer或手动实现简单的循环缓冲区:
template<int Capcity>
class cbuffer
{
public:
    cbuffer() : sz(0), p(0){}
    void push_back(int n)
    {
        buf[p++] = n;
        if (sz < Capcity)
            sz++;
        if (p >= Capcity)
            p = 0;
    }
    int size() const
    {
        return sz;
    }
    int operator[](int n) const
    {
        assert(n < sz);
        n = p - sz + n;
        if (n < 0)
            n += Capcity;
        return buf[n];
    }
    int buf[Capcity];
    int sz, p;
};

循环缓冲区是一种可以存储固定数量元素的数据结构。以下是一个存储5个整型元素的循环缓冲区的示例使用:

int main()
{
    cbuffer<5> buf;

    // insert random 100 numbers
    for (int i = 0; i < 100; ++i)
        buf.push_back(rand());

    // output to cout contents of the circular buffer
    for (int i = 0; i < buf.size(); ++i)
        cout << buf[i] << ' ';
}

作为一条提示,请记住,当您只有5个元素时,最好的解决方案是快速实施并且能够正确运行。

3
这是一个最简循环缓冲区。我主要在这里发布它,以获取大量的评论和改进意见。
最小实现:
#include <iterator>

template<typename Container>
class CircularBuffer
{
public:
    using iterator   = typename Container::iterator;
    using value_type = typename Container::value_type;
private:
    Container _container;
    iterator  _pos;
public:
    CircularBuffer() : _pos(std::begin(_container)) {}
public:
    value_type& operator*() const { return *_pos; }
    CircularBuffer& operator++() { ++_pos ; if (_pos == std::end(_container)) _pos = std::begin(_container); return *this; }
    CircularBuffer& operator--() { if (_pos == std::begin(_container)) _pos = std::end(_container); --_pos; return *this; }
};

使用方法

#include <iostream>
#include <array>

int main()
{
    CircularBuffer<std::array<int,5>> buf;

    *buf = 1; ++buf;
    *buf = 2; ++buf;
    *buf = 3; ++buf;
    *buf = 4; ++buf;
    *buf = 5; ++buf;
    std::cout << *buf << " "; ++buf;
    std::cout << *buf << " "; ++buf;
    std::cout << *buf << " "; ++buf;
    std::cout << *buf << " "; ++buf;
    std::cout << *buf << " "; ++buf;
    std::cout << *buf << " "; ++buf;
    std::cout << *buf << " "; ++buf;
    std::cout << *buf << " "; --buf;
    std::cout << *buf << " "; --buf;
    std::cout << *buf << " "; --buf;
    std::cout << *buf << " "; --buf;
    std::cout << *buf << " "; --buf;
    std::cout << *buf << " "; --buf;

    std::cout << std::endl;
}

编译

g++ -std=c++17 -O2 -Wall -Wextra -pedantic -Werror

演示

在Coliru上:在线尝试


(注:此文为HTML代码,仅供参考)

2
如果你真的想要那些评论和想法,你可能想在[codereview.se]上寻求批评。请确保先阅读面向Stack Overflow用户的代码审查指南,因为那里有些事情是不同的! - Toby Speight

2

由于向量大小固定为5,任何操作都是O(5)=常数时间。 - MSalters
1
是的。但是,OP只是以n = 5作为示例,这并不意味着他/她总是会使用5个元素。 - codelyzer
1
无所谓,O(6) 也是常数时间。关键是项目数量是有限的。 - MSalters
@MSalters:这不是大O符号的工作方式。你的思路归结为“由于没有整数N是无限大的,因此O(N)= O(1)”。Codelyzer的观点是,从向量的前面删除(并将其他N-1个元素向前移动)在向量长度方面是O(N)-这绝对是100%正确的。 - Quuxplusone
你可能会反驳说:“啊,但是我的向量只有有限的长度——五!”当然。但是其他人可能有一个长度为六、一百或一万的向量;随着向量长度的增加,pop_front所需的时间也会相应增加——随着向量变得越来越长,pop_front涉及的操作数量呈线性增长。这就是我们所说的“O(N)”。 - Quuxplusone
@Quuxplusone:我知道大O符号的工作原理(以及大Omega等)。一个O(N)算法的运行时间小于c*N,当N趋近于无穷大时。从数学上讲,这被称为“极限”。基本观点仍然是有界数字不会趋近于无穷大。 - MSalters

2

以下是我一段时间前写的基于环形缓冲区的双端队列模板类的开端,主要是为了尝试使用std::allocator(因此不需要T具有默认构造函数)。请注意,它目前没有迭代器、insert/remove、复制/移动构造函数等。

#ifndef RING_DEQUEUE_H
#define RING_DEQUEUE_H

#include <memory>
#include <type_traits>
#include <limits>

template <typename T, size_t N>
class ring_dequeue {
private:
    static_assert(N <= std::numeric_limits<size_t>::max() / 2 &&
                  N <= std::numeric_limits<size_t>::max() / sizeof(T),
                  "size of ring_dequeue is too large");

    using alloc_traits = std::allocator_traits<std::allocator<T>>;

public:
    using value_type = T;
    using reference = T&;
    using const_reference = const T&;
    using difference_type = ssize_t;
    using size_type = size_t;

    ring_dequeue() = default;

    // Disable copy and move constructors for now - if iterators are
    // implemented later, then those could be delegated to the InputIterator
    // constructor below (using the std::move_iterator adaptor for the move
    // constructor case).
    ring_dequeue(const ring_dequeue&) = delete;
    ring_dequeue(ring_dequeue&&) = delete;
    ring_dequeue& operator=(const ring_dequeue&) = delete;
    ring_dequeue& operator=(ring_dequeue&&) = delete;

    template <typename InputIterator>
    ring_dequeue(InputIterator begin, InputIterator end) {
        while (m_tailIndex < N && begin != end) {
            alloc_traits::construct(m_alloc, reinterpret_cast<T*>(m_buf) + m_tailIndex,
                                    *begin);
            ++m_tailIndex;
            ++begin;
        }
        if (begin != end)
            throw std::logic_error("Input range too long");
    }

    ring_dequeue(std::initializer_list<T> il) :
        ring_dequeue(il.begin(), il.end()) { }

    ~ring_dequeue() noexcept(std::is_nothrow_destructible<T>::value) {
        while (m_headIndex < m_tailIndex) {
            alloc_traits::destroy(m_alloc, elemPtr(m_headIndex));
            m_headIndex++;
        }
    }

    size_t size() const {
        return m_tailIndex - m_headIndex;
    }
    size_t max_size() const {
        return N;
    }

    bool empty() const {
        return m_headIndex == m_tailIndex;
    }
    bool full() const {
        return m_headIndex + N == m_tailIndex;
    }

    template <typename... Args>
    void emplace_front(Args&&... args) {
        if (full())
            throw std::logic_error("ring_dequeue full");
        bool wasAtZero = (m_headIndex == 0);
        auto newHeadIndex = wasAtZero ? (N - 1) : (m_headIndex - 1);
        alloc_traits::construct(m_alloc, elemPtr(newHeadIndex),
                                std::forward<Args>(args)...);
        m_headIndex = newHeadIndex;
        if (wasAtZero)
            m_tailIndex += N;
    }
    void push_front(const T& x) {
        emplace_front(x);
    }
    void push_front(T&& x) {
        emplace_front(std::move(x));
    }

    template <typename... Args>
    void emplace_back(Args&&... args) {
        if (full())
            throw std::logic_error("ring_dequeue full");
        alloc_traits::construct(m_alloc, elemPtr(m_tailIndex),
                                std::forward<Args>(args)...);
        ++m_tailIndex;
    }
    void push_back(const T& x) {
        emplace_back(x);
    }
    void push_back(T&& x) {
        emplace_back(std::move(x));
    }

    T& front() {
        if (empty())
            throw std::logic_error("ring_dequeue empty");
        return *elemPtr(m_headIndex);
    }
    const T& front() const {
        if (empty())
            throw std::logic_error("ring_dequeue empty");
        return *elemPtr(m_headIndex);
    }
    void remove_front() {
        if (empty())
            throw std::logic_error("ring_dequeue empty");
        alloc_traits::destroy(m_alloc, elemPtr(m_headIndex));
        ++m_headIndex;
        if (m_headIndex == N) {
            m_headIndex = 0;
            m_tailIndex -= N;
        }
    }
    T pop_front() {
        T result = std::move(front());
        remove_front();
        return result;
    }

    T& back() {
        if (empty())
            throw std::logic_error("ring_dequeue empty");
        return *elemPtr(m_tailIndex - 1);
    }
    const T& back() const {
        if (empty())
            throw std::logic_error("ring_dequeue empty");
        return *elemPtr(m_tailIndex - 1);
    }
    void remove_back() {
        if (empty())
            throw std::logic_error("ring_dequeue empty");
        alloc_traits::destroy(m_alloc, elemPtr(m_tailIndex - 1));
        --m_tailIndex;
    }
    T pop_back() {
        T result = std::move(back());
        remove_back();
        return result;
    }

private:
    alignas(T) char m_buf[N * sizeof(T)];
    size_t m_headIndex = 0;
    size_t m_tailIndex = 0;
    std::allocator<T> m_alloc;

    const T* elemPtr(size_t index) const {
        if (index >= N)
            index -= N;
        return reinterpret_cast<const T*>(m_buf) + index;
    }
    T* elemPtr(size_t index) {
        if (index >= N)
            index -= N;
        return reinterpret_cast<T*>(m_buf) + index;
    }
};

#endif

任何人都可以随意将此内容纳入您的答案中——这主要是作为对Taemyr的回答和@KaizerSozay对其的评论的回应,建议添加一个示例实现。 - Daniel Schepler

1
简单来说,std::vector 更适用于非可变大小的内存。在您的情况下,如果您将所有数据向前移动或在向量中添加新数据,则必须浪费空间。正如 @David 所说,std::deque 是一个不错的选择,因为您需要使用 pop_headpush_back 等双向列表操作。关于列表的更多信息,请参考 cplus cplus reference
与其他基本标准序列容器(数组、向量和双端队列)相比,链表在任何位置插入、提取和移动已获得迭代器的容器中的元素时通常表现更佳,因此在使用这些算法的排序算法等算法中也是如此。
链表和前向列表与这些其他序列容器相比的主要缺点是它们缺乏通过其位置直接访问元素的能力。例如,要访问列表中的第六个元素,必须从已知位置(如开头或结尾)迭代到该位置,这需要花费线性时间来计算它们之间的距离。它们还会消耗一些额外的内存来保留与每个元素相关联的链接信息(对于大量小型元素的列表可能是一个重要因素)。
关于双端队列
对于涉及在开始或结束位置以外的位置频繁插入或删除元素的操作,双端队列的表现不如链表和前向列表,并且具有较不一致的迭代器和引用。

vetor

因此,与数组相比,向量在消耗更多内存的同时具备了管理存储和以高效的方式动态增长的能力。

与其他动态序列容器(双端队列、列表和前向列表)相比,向量非常高效地访问其元素(就像数组一样),并且在从其末尾添加或删除元素方面相对高效。对于涉及在位置末尾以外插入或删除元素的操作,它们表现不如其他容器,并且迭代器和引用不如列表和前向列表一样一致。


实际上,std::list 是一个双向链表,因此从前面和后面都同样好用。 - Zan Lynx
@ZanLynx 那为什么要使用双端队列呢? - Rahul Iyer
@KaizerSozay:std::deque 保存更大的块并使它们看起来既像向量又像列表。它就像是向量的列表。它的性能比列表更接近向量。 - Zan Lynx
@KaizerSozay:deque 具有随机访问,而 list 则没有。 - MSalters

-2

我认为即使使用std::deque,在某些情况下它也会有复制项目的开销,因为std::deque本质上是一个数组映射,所以使用std::list是消除复制开销的好方法。

为了增加std::list的遍历性能,您可以实现一个内存池,这样std::list将从主干分配内存,并具有缓存的空间局部性。


Map?如果说的是数据结构,那它更像是一个列表。它要映射什么呢?也就是说,它的键是什么? - luk32

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接