【问题标题】:Why does std::list::reverse have O(n) complexity?为什么 std::list::reverse 有 O(n) 复杂度?
【发布时间】:2016-06-07 08:05:30
【问题描述】:

为什么 C++ 标准库中std::list 类的反向函数具有线性运行时?我认为对于双向链表,反向函数应该是 O(1)。

反转双向链表应该只涉及切换头指针和尾指针。

【问题讨论】:

  • 我不明白为什么人们不赞成这个问题。这是一个完全合理的问题。反转双向链表需要 O(1) 时间。
  • 不幸的是,有些人混淆了“问题很好”和“问题有一个好主意”的概念。我喜欢这样的问题,基本上“我的理解似乎与普遍接受的做法不同,请帮助解决这个冲突”,因为扩展你的想法可以帮助你解决更多的问题!似乎其他人会采取“在 99.9999% 的情况下这是浪费处理,甚至不要考虑它”的方法。如果有什么安慰的话,我被否决的票要少得多!
  • 是的,这个问题因其质量而受到过多的反对。可能与支持 Blindy 答案的人相同。公平地说,“反转双向链表应该只涉及切换头指针和尾指针”对于每个人在高中学习的标准链表 impl 或人们使用的许多实现通常都不正确。很多时候,人们对问题或答案的直接直觉反应推动了赞成/反对的决定。如果您在那句话中更清楚或省略它,我认为您会得到更少的反对票。
  • 或者让我把举证责任交给你,@Curious:我在这里创建了一个双向链表实现:ideone.com/c1HebO。您能否指出您希望Reverse 函数在 O(1) 中的实现方式?
  • @CompuChip:实际上,取决于实现,它可能不会。您不需要一个额外的布尔值来知道要使用哪个指针:只需使用一个不指向您的指针......顺便说一下,这很可能是使用异或链表自动实现的。所以是的,这取决于列表是如何实现的,并且可以澄清 OP 声明。

标签: c++ c++11 stl linked-list


【解决方案1】:

它是 O(n) 仅仅是因为它需要以相反的顺序复制列表。每个单独的项目操作都是 O(1),但整个列表中有 n 个。

当然,在为新列表设置空间以及之后更改指针等方面涉及一些常数时间操作。一旦包含一阶 n 因子,O 表示法就不会考虑单个常数。

【讨论】:

    【解决方案2】:

    它还为每个节点交换上一个和下一个指针。这就是为什么它需要线性。尽管如果使用此 LL 的函数也将有关 LL 的信息作为输入,例如它是正常访问还是反向访问,则可以在 O(1) 中完成。

    【讨论】:

      【解决方案3】:

      只是一个算法解释。 想象一下,您有一个包含元素的数组,那么您需要反转它。 基本思想是迭代每个元素,改变元素 第一个位置到最后一个位置,第二个位置的元素到倒数第二个位置,依此类推。当您到达数组的中间时,您将更改所有元素,因此在 (n/2) 次迭代中,这被认为是 O(n)。

      【讨论】:

        【解决方案4】:

        如果列表存储一个允许交换“prev”和“next”指针含义的标志,它可能O(1)每个节点都有。如果反转列表是一项频繁的操作,那么这样的添加实际上可能很有用,我不知道为什么当前标准会禁止实施它。但是,拥有这样一个标志会使列表的普通 遍历 更加昂贵(如果只是一个常数因素),因为而不是

        current = current->next;
        

        在列表迭代器的operator++ 中,你会得到

        if (reversed)
          current = current->prev;
        else
          current = current->next;
        

        这不是您决定轻松添加的内容。鉴于列表的遍历频率通常比反转列表的频率高得多,标准强制这种技术是非常不明智的。因此,允许逆运算具有线性复杂度。但是请注意,tO(1) ⇒ tO(n) 因此,如前所述,在技术上实现您的“优化”是允许的。

        如果您来自 Java 或类似背景,您可能想知道为什么迭代器每次都必须检查标志。难道我们不能有两个不同的迭代器类型,它们都派生自一个共同的基类型,并让std::list::beginstd::list::rbegin 多态地返回适当的迭代器吗?虽然可能,但这会使整个事情变得更糟,因为推进迭代器现在将是一个间接(难以内联)的函数调用。在 Java 中,无论如何,您通常都会付出这个代价,但话又说回来,这也是许多人在性能至关重要时选择 C++ 的原因之一。

        正如 cmets 中的 Benjamin Lindley 所指出的,由于不允许 reverse 使迭代器无效,标准允许的唯一方法似乎是将指针存储回迭代器内的列表,这会导致双精度- 间接内存访问。

        【讨论】:

        • @galinette: std::list::reverse 不会使迭代器无效。
        • @galinette 抱歉,我将您之前的评论误读为“每个 iterator 的标志”,而不是“每个 node 的标志”它。当然,每个 node 的标志会适得其反,因为您必须再次进行线性遍历才能将它们全部翻转。
        • @5gon12eder:您可以以非常损失的成本消除分支:将nextprev 指针存储在一个数组中,并将方向存储为01。要向前迭代,您将遵循 pointers[direction] 并反向迭代 pointers[1-direction](反之亦然)。这仍然会增加一点点开销,但可能少于一个分支。
        • 您可能无法在迭代器中存储指向列表的指针。 swap() 被指定为常数时间并且不会使任何迭代器无效。
        • @TavianBarnes 该死!那么,三重间接......(我的意思是,实际上不是三重。你必须将标志存储在动态分配的对象中,但迭代器中的指针当然可以直接指向该对象,而不是间接指向列表。)
        【解决方案5】:

        当然,因为所有支持双向迭代器的容器都有 rbegin() 和 rend() 的概念,所以这个问题没有实际意义?

        构建一个反向迭代器并通过它访问容器的代理很简单。

        这个非运算确实是 O(1)。

        如:

        #include <iostream>
        #include <list>
        #include <string>
        #include <iterator>
        
        template<class Container>
        struct reverse_proxy
        {
            reverse_proxy(Container& c)
            : _c(c)
            {}
        
            auto begin() { return std::make_reverse_iterator(std::end(_c)); }
            auto end() { return std::make_reverse_iterator(std::begin(_c)); }
        
            auto begin() const { return std::make_reverse_iterator(std::end(_c)); }
            auto end() const { return std::make_reverse_iterator(std::begin(_c)); }
        
            Container& _c;
        };
        
        template<class Container>
        auto reversed(Container& c)
        {
            return reverse_proxy<Container>(c);
        }
        
        int main()
        {
            using namespace std;
            list<string> l { "the", "cat", "sat", "on", "the", "mat" };
        
            auto r = reversed(l);
            copy(begin(r), end(r), ostream_iterator<string>(cout, "\n"));
        
            return 0;
        }
        

        预期输出:

        mat
        the
        on
        sat
        cat
        the
        

        鉴于此,在我看来,标准委员会并没有花时间强制容器的 O(1) 反向排序,因为这不是必需的,并且标准库很大程度上是建立在只强制要求的原则之上的绝对必要,同时避免重复。

        只是我的 2c。

        【讨论】:

          【解决方案6】:

          假设,reverse 可能是 O(1)。 (再次假设)可能存在一个布尔列表成员,指示链接列表的方向当前与创建列表的原始方向相同还是相反。

          不幸的是,这基本上会降低任何其他操作的性能(尽管不会更改渐近运行时)。在每个操作中,都需要参考一个布尔值来考虑是否跟随链接的“下一个”或“上一个”指针。

          由于这可能被认为是相对不频繁的操作,因此标准(不规定实现,仅规定复杂性)指定复杂性可以是线性的。这允许“下一个”指针始终明确地表示相同的方向,从而加快常见情况的操作。

          【讨论】:

          • @MooseBoys:我不同意你的类比。不同之处在于,在列表的情况下,通过使用这个布尔标志技巧,实现可以为reverse 提供O(1) 复杂度而不影响任何其他操作的大o。但是,在实践中,每个操作中的额外分支都是昂贵的,即使它在技术上是 O(1)。相比之下,您不能创建一个列表结构,其中sort 为 O(1) 并且所有其他操作的成本相同。问题的关键是,貌似你只关心大O就可以免费获得O(1)reverse,那他们为什么不这样做呢。
          • 如果您使用 XOR 链表,则反转将成为常数时间。不过,迭代器会更大,并且递增/递减它的计算成本会稍微高一些。尽管对于 any 类型的链表来说,这可能与不可避免的内存访问相形见绌。
          • @IlyaPopov:每个节点都需要这个吗?用户从不询问列表节点本身的任何问题,只询问主列表主体。因此,对于用户调用的任何方法,访问布尔值都很容易。例如,您可以制定规则,如果列表反转,则迭代器无效,和/或使用迭代器存储布尔值的副本。所以我认为它必然不会影响大O。我承认,我没有逐行通过规范。 :)
          • @Kevin:嗯,什么?无论如何,您不能直接对两个指针进行异或,您需要先将它们转换为整数(显然是std::uintptr_t 类型。然后您可以对它们进行异或。
          • @Kevin,你绝对可以在 C++ 中创建一个 XOR 链表,它实际上是这种事情的典型语言。没有说您必须使用std::uintptr_t,您可以转换为char 数组,然后对组件进行异或。它会更慢,但 100% 便携。很可能您可以在这两种实现之间进行选择,并且仅在缺少 uintptr_t 时才使用第二种作为后备。一些如果在这个答案中描述:stackoverflow.com/questions/14243971/…
          【解决方案7】:

          因为它必须遍历每个节点(ntotal)并更新它们的数据(更新步骤确实是O(1))。这使得整个操作O(n*1) = O(n)

          【讨论】:

          • 因为您也需要更新每个项目之间的链接。拿出一张纸画出来,而不是投反对票。
          • 既然如此确定,为什么还要问呢?你在浪费我们俩的时间。
          • @Curious 双链表的节点有方向感。原因来自那里。
          • @Blindy 一个好的答案应该是完整的。 “拿出一张纸,把它画出来”因此不应该是一个好的答案的必要组成部分。不好的答案会被否决。
          • @Shoe:他们必须这样做吗?请研究 XOR-linked-list 等。
          猜你喜欢
          • 1970-01-01
          • 2020-03-11
          • 2020-06-25
          • 2022-10-24
          • 2018-11-26
          • 2017-01-25
          • 1970-01-01
          • 1970-01-01
          • 2013-01-28
          相关资源
          最近更新 更多