list::size() 真的是 O(n) 吗?

68

最近注意到有人提到 std::list::size() 的复杂度为线性。
根据某些资料,事实上这是与实现相关的,因为标准并没有规定复杂度应该是什么。
这篇博客文章中的评论说:

  

实际上,这取决于你使用哪个STL。Microsoft Visual Studio V6将size()实现为 {return (_Size);},而gcc(至少在3.3.2和4.1.0版本中)将其作为{ return std::distance(begin(), end()); }来实现。第一个具有恒定速度,第二个具有O(N)速度

  1. 所以我猜对于VC++的开发者群体,size()的复杂度是常数,因为自VC6以来Dinkumware可能没有改变这一点。我在这里是对的吗?
  2. gcc目前看起来是什么样子?如果它真的是O(n),为什么开发者要这么做?
8个回答

86

在C++11标准中,对于任何标准容器来说,.size()操作必须具有“常数”复杂度(O(1))。(表96 — 容器要求)。在之前的C++03标准中,.size() 应该 具有常数复杂度,但不是必需的(参见Is std::string size() a O(1) operation?)。

这个标准的变化是由n2923: Specifying the complexity of size() (Revision 1)引入的。

然而,在gcc 4.8及以下版本中,libstdc++中的.size()实现仍使用O(N)算法:

  /**  Returns the number of elements in the %list.  */
  size_type
  size() const _GLIBCXX_NOEXCEPT
  { return std::distance(begin(), end()); }

另请参阅为什么std::list在C++11中更大?以了解详细信息。

更新:在使用gcc 5.0 及以上版本的C++11模式下,std::list::size()正确的O(1)


顺便说一句,在libc++中,.size()的时间复杂度确实是O(1):

_LIBCPP_INLINE_VISIBILITY
size_type size() const _NOEXCEPT     {return base::__sz();}

...

__compressed_pair<size_type, __node_allocator> __size_alloc_;

_LIBCPP_INLINE_VISIBILITY
const size_type& __sz() const _NOEXCEPT
    {return __size_alloc_.first();}

3
这应该被接受,不幸的是人们不看旧问题。 :) - NoSenseEtAl

53

Pre-C++11答案

您是正确的,标准并未规定list::size()的复杂度,但它建议它“应该具有恒定的复杂度”(请参见表65中的注解A)。

这是Howard Hinnant撰写的一篇有趣的文章,解释了为什么有些人认为list::size()应该具有O(N)的复杂度(基本上是因为他们认为O(1)list::size()会使list::splice()具有O(N)的复杂度),以及为什么O(1)list::size()是一个好主意(在作者看来):

我认为论文中的主要观点是:

  • 很少有情况需要维护内部计数器,以便list::size()可以是O(1),从而使splice操作变成线性
  • 可能还有很多情况,某人可能不知道因调用O(N)size()而可能发生的负面影响(比如他举的一个例子,在持有锁的同时调用list::size())。
  • 相比于允许size()为O(N),出于“最小惊奇原则”的考虑,标准应该要求任何实现了size()的容器以O(1)方式实现。如果一个容器无法做到这一点,它就不应该实现size()。在这种情况下,容器的使用者将知道size()不可用,如果他们仍然想要获取容器中元素的数量,可以使用container::distance(begin(), end())来获取该值 - 但他们将完全知道这是一个O(N)的操作。
  • 我认为我大部分同意他的推理。然而,我不喜欢他对splice()重载的建议增加。必须传入一个n,它必须等于distance(first, last)才能获得正确的行为,这似乎是导致难以诊断的错误的源泉。

    我不确定未来应该或可以做些什么,因为任何更改都会对现有代码产生重大影响。但是就目前而言,我认为现有代码已经受到影响 - 行为可能因为某些应该已经定义好的东西而在一个实现和另一个实现之间存在相当大的差异。也许onebyone提到的关于有“缓存”的大小是否已知的标记可以很好地工作 - 您可以获得摊销O(1)的行为 - 只有在一些splice()操作修改列表时才会获得O(N)的行为。这种方法的好处是,实现者今天就可以完成它,而无需更改标准(除非我遗漏了某些内容)。

    据我所知,C++0x在这个领域没有做出任何改变。


    答案是正确的,但对于列表大小的推理有误。您的建议容易出现不一致的参数,并违反了让用户仅提供所有信息一次的原则。 - PierreBdR
    6
    可以尝试保持 O(1) 的切割,但将大小标记为“未知”。这样,size() 的最坏情况仍为 O(N),但最坏情况最多发生一次,每次“不友好”的切割。因此,所有操作的性能都严格优于始终为 O(N) 的 size()。警告:我没有深思熟虑过这个想法。 - Steve Jessop
    “strictly superior” - 其实这是个谎言,因为splice中有一些额外的检查来确定你处于哪种情况,并且在所有mutators中进行大小算术运算。我告诉过你我没有仔细考虑过。但是复杂度从来不会更糟,有时候会更好。 - Steve Jessop
    @PierreBdR - 如果不清楚的话,我不是这篇论文的作者,我指出它是因为我认为它有一些有趣的观点。我已经编辑了答案,使其更加清晰(并添加了一些我自己的想法,并结合了这些评论的想法)。 - Michael Burr
    15
    最新的C++0x草案要求size()具有常数时间复杂度(该更改是在N3000中对容器要求进行的)。 - James McNellis

    15

    我之前曾经研究过gcc 3.4的list::size,因此我可以说:

    1. 它使用std::distance(head, tail)
    2. std::distance有两种实现:对于满足RandomAccessIterator的类型,它使用"tail-head",对于仅满足InputIterator的类型,它使用一个O(n)算法,依赖于"iterator++",计算直到命中给定的tail。
    3. std::list不满足RandomAccessIterator,因此大小为O(n)。

    至于“为什么”,我只能说std::list适用于需要顺序访问的问题。将大小存储为类变量会在每次插入、删除等操作时引入开销,而这种浪费是STL意图所不能容忍的。如果你确实需要常数时间的size(),请使用std::deque


    13

    就我个人而言,我认为splice的时间复杂度是O(N)并不是问题,因为唯一允许size为O(N)的原因就是“你不用的部分不需要付费”(译注:这里是引用C++口号的意思)。在这种情况下,无论是否检查列表的大小,维护列表的大小都需要在每次插入/删除时进行额外的增量/减量。这是一个很小的固定开销,但仍然值得考虑。

    很少需要检查列表的大小。从头到尾迭代而不关心总大小更加常见。


    9
    显然,C++11委员会不同意你的看法。可惜。 - Mark Ransom

    5
    我会前往源代码存档)。SGI的STL页面指出允许具有线性复杂度。我相信他们遵循的设计指南是尽可能通用地实现列表,从而允许更灵活地使用列表。

    5
    SGI并不是“源代码”。它基于原始的(HP?)STL,但标准偏离了那个。SGI仅说明了他们的实现所做的事情,而不是标准规定的内容。 - James Curran
    链接现在已经失效了。 - Lightness Races in Orbit

    1
    这个错误报告:[C++0x] std::list::size complexity详细描述了GCC 4.x版本中实现是线性时间复杂度的事实,以及由于ABI兼容性问题,C++11向常数时间复杂度的转换缓慢到来(在5.0版本中可用)。
    GCC 4.9系列的man页面仍然包括以下免责声明:
    “对C++11的支持仍处于实验阶段,并且可能在未来的版本中以不兼容的方式进行更改。”
    这里引用了同一个错误报告:C++11中std::list::size应该具有常数复杂度吗?

    0
    所有上述答案都提到了C++11和GCC,但没有提到在GCC中的_GLIBCXX_USE_CXX11_ABI编译定义,这还不够。
    使用-std=c++11进行编译并不意味着在GCC中std::list::size()是O(1),它默认是O(1),但是当使用_GLIBCXX_USE_CXX11_ABI=0进行编译时,它变成了O(N)。
    GCC版本小于5.1,例如GCC4.8支持C++11,但无论你使用什么编译标志,std::list::size()都是O(N)。
    这可以在当前GCC的源代码中清楚地显示出来:size()的实现如下。
      _GLIBCXX_NODISCARD
      size_type
      size() const _GLIBCXX_NOEXCEPT
      { return _M_node_count(); }
    

    它被称为_M_node_count(),而_M_node_count的实现如下所示:
        #if _GLIBCXX_USE_CXX11_ABI
          static size_t
          _S_distance(const_iterator __first, const_iterator __last)
          { return std::distance(__first, __last); }
    
          // return the stored size
          size_t
          _M_node_count() const
          { return this->_M_get_size(); }
    #else
          // dummy implementations used when the size is not stored
          static size_t
          _S_distance(const_iterator, const_iterator)
          { return 0; }
    
          // count the number of nodes
          size_t
          _M_node_count() const
          { return std::distance(begin(), end()); }
    #endif
    

    如果_GLIBCXX_USE_CXX11_ABI设置为0,size()的复杂度为O(N),否则为O(1)。 当你使用高版本GCC编译的库并且需要链接到低版本GCC编译的库时,_GLIBCXX_USE_CXX11_ABI设置为0会发生。例如,你的系统安装了一个GCC 4.8编译的GTEST库,但是你的代码现在使用GCC 7.3和C++11,并且你需要链接到那个库,在这种情况下,你需要在你的CMakeLists.txt中添加以下内容,否则会出现链接问题。
    add_compile_definitions(_GLIBCXX_USE_CXX11_ABI=0)
    

    总之,在GCC中,
    - 当使用GCC版本小于5.1时,std::list::size()的时间复杂度为O(N)。 - 当使用GCC版本大于等于5.1时: - 当编译选项中设置了_GLIBCXX_USE_CXX11_ABI=0时,std::list::size()的时间复杂度为O(N)。 - 当编译选项中未设置_GLIBCXX_USE_CXX11_ABI或设置了_GLIBCXX_USE_CXX11_ABI=1时,std::list::size()的时间复杂度为O(1)。

    0

    如果您正确使用列表,则可能不会注意到任何差异。

    列表适用于您想要重新排列而无需复制的大型数据结构,或者对于插入后要保持有效指针的数据。

    在第一种情况下,没有区别,在第二种情况下,我更喜欢旧的(较小的)size()实现。

    无论如何,std更多关注正确性和标准行为以及“用户友好性”,而不是原始速度。


    1
    我不清楚在紧急情况下想知道列表中有多少元素,如何构成不正确使用列表的问题。 - Lightness Races in Orbit

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