【问题标题】:Why is the c# generic List implemented as an append-only array? [duplicate]为什么 c# 泛型 List 实现为仅附加数组? [复制]
【发布时间】:2013-09-03 17:45:42
【问题描述】:

C# 泛型列表System.Collections.Generic.List<T> 是使用可增长数组作为后备存储实现的,其方式类似于更多基于数组列表的实现。很明显,这在执行随机(索引)访问时提供了巨大的好处,例如实现为链表的列表。

我想知道为什么没有选择将其实现为循环数组。对于索引随机访问和附加到列表末尾,这样的实现将具有相同的 O(1) 性能。但会提供额外的好处,例如允许 O(1) 前置操作(即在列表的前面插入新元素)以及平均将随机插入和删除所需的时间减半。

到目前为止的一些答案的总结

正如@Hachet 所指出的,为了使循环数组实现具有与System.Collections.Generic.List<T> 相似的索引性能,它需要始终增长到2 的幂的容量(所以可以执行廉价的模运算)。这意味着不可能像当前可能在构建列表实例时那样将其大小调整为用户提供的确切初始容量。所以这是一个明确的权衡问题。

正如一些快速而肮脏的性能测试所示,对于循环数组实现,索引可能会慢 2-5 倍。

由于索引是一个明显的优先事项,我可以想象与预置操作的更好性能和随机插入/删除的稍微更好的性能相比,这将是太大的惩罚。

这是带有一些补充答案信息的副本

这个问题确实与Why typical Array List implementations aren't double-ended? 有关,我在发布我的问题之前没有发现。我猜它没有以完全令人满意的方式回答:

我没有做过任何基准测试,但在我看来,还有其他瓶颈(例如非本地加载/存储)会大大超过这个。如果我没有听到更有说服力的东西,我可能会接受这个,谢谢。 – Mehrdad 2011 年 5 月 27 日在 4:18

有关此问题的答案提供了有关如何使循环列表的索引表现得相当好的附加信息,包括代码示例和一些使权衡决策更加清晰的量化数字。因此,它们提供的信息与另一个问题中存在的信息相辅相成。所以我同意这个问题的意图是非常相同的,因此我同意它应该被视为重复。但是,如果现在随附此信息的新信息丢失,那将是一种耻辱。

此外,我仍然对可能尚未出现在任何一个问题的答案中的实施选择的潜在其他原因感兴趣。

【问题讨论】:

  • 这里有像 Eric Lippert 和 Jon Skeet 这样的人真是太好了。
  • 不确定您打算如何同时添加和前置而不需要重新分配列表(即追加-前置-追加序列)?已经有QueueLinkeList(可能还有Stack)可能更适合需要访问列表两端的用例。
  • @AlexeiLevenkov 你环绕到分配数组的末尾(即你维护一个“头”和“尾”索引,或“头”和“计数”索引)。追加时减少头部并增加计数,追加时仅增加计数。

标签: c# .net list data-structures arraylist


【解决方案1】:

你的问题让我很好奇 List 和循环版本之间的运行时差异的大小。因此,我在一个强制大小为 2 的幂(避免模运算)的框架上拼凑了一个快速框架,即最佳案例实现。我忽略了增长,因为我只是想比较索引器属性的时间差异。还不错。 circularList[x] 大约是 list[x] 的两倍,而且两者都相当快。我也没有真正调试过这个,因为我的时间有限。这也可能缺少 List 正在执行的一些验证代码,如果它也有它,这会使循环列表相对慢一些。

一般来说,我会说这种行为只是分散了 List 的主要目的,实际上很少使用。因此,您在很多用途上强制降低性能,以使极少数用途受益。我认为他们做出了一个很好的决定,没有将其放入 List。

using System;

public class CircularList<T> {
    private int start, end, count, mask;
    private T[] items;
    public CircularList() : this(8) { }

    public CircularList(int capacity) {
        int size = IsPowerOf2(capacity) ? capacity : PowerOf2Ceiling(capacity);
        this.items = new T[size];
        this.start = this.end = this.count = 0;
        this.mask = size - 1;
    }

    public void Add(T item) {
        if (this.count == 0) {
            this.items[0] = item;
            this.start = this.end = 0;
        } else {
            this.items[++this.end] = item;
        }
        this.count++;
    }

    public void Prepend(T item) {
        if (this.count == 0) {
            this.items[0] = item;
            this.start = this.end = 0;
        } else {
            this.start--;
            if (this.start < 0) this.start = this.items.Length - 1;
            this.items[this.start] = item;
        }
        this.count++;
    }

    public T this[int index] {
        get {
            if ((index < 0) || (index >= this.count)) throw new ArgumentOutOfRangeException();
            return this.items[(index + this.start) & this.mask]; // (index + start) % length
        }
        set {
            if ((index < 0) || (index >= this.count)) throw new ArgumentOutOfRangeException();
            this.items[(index + this.start) & this.mask] = value; // (index + start) % length
        }
    }

    private bool IsPowerOf2(int value) {
        return (value > 0) && ((value & (value - 1)) == 0);
    }
    private int PowerOf2Ceiling(int value) {
        if (value < 0) return 1;
        switch (value) {
            case 0:
            case 1: return 1;
        }
        value--;
        value |= value >> 1;
        value |= value >> 2;
        value |= value >> 4;
        value |= value >> 8;
        return unchecked((value | (value >> 16)) + 1);
    }
}

【讨论】:

  • 谢谢。是的,很明显,当前的列表实现是“仅追加”写入场景的最佳选择,以及写入有限且读取次数多的场景。这些当然是常见的情况。所以也许这就是为什么以这种方式做出决定的原因。
  • 哦,我想说它所做的验证非常相似(请参阅dotnetframework.org/default.aspx/4@0/4@0/DEVDIV_TFS/Dev10/…)。看来您可以通过使用if ((uint)index &gt;= (uint)this.count) 来提高上面代码示例中验证检查的性能
  • 我刚刚使用循环列表实现进行了一些性能测试(与您的代码类似),并且在我的系统上使用 .NET 4.5 循环列表的性能几乎是和列表一样快。使用 1 亿个整数:相加 = 0.6608 对 0.735 秒(因子 1.11),对于顺序索引:0.3732 对 0.3811 秒(因子 1.02)。在这种情况下,对于更快的前置和随机插入来说,这似乎并不是一个很大的牺牲。
  • 嗯,这肯定会有所不同:D 添加性能看起来仍然不错(但对于顺序插入,它基本上与列表相同):5.732 ns/op 与 7.434 ns/op(因子1.3)。不过,顺序索引有很大的不同:0.666 与 3.261 ns/op(因子 4.9)
【解决方案2】:

实际上,循环数组可以用 O(1) 访问时间来实现。但是我不相信List&lt;T&gt; 索引器的意图是 O(1)。大 O 表示法跟踪性能,因为它与它所操作的集合的大小有关。 List&lt;T&gt; 的实现者除了需要 O(1) 之外,还可能痴迷于其他项目,例如指令数和紧密循环中的性能。它需要在相同场景中尽可能接近阵列性能才能普遍有用。访问数组的元素是一个非常简单的操作

  • 将索引乘以大小添加到数组开始
  • 尊重

循环数组中的索引虽然仍然是 O(1),但至少涉及一个分支操作。它必须根据所需的索引检查数组索引是否需要环绕。这意味着具有已知边界的循环上的紧密数组将在其中具有分支逻辑。这将是在原始数组上具有快速紧密循环的代码路径中的一个显着下降。

编辑

啊,是的,不一定需要分支。但是,指令数仍将高于数组的指令数。我相信这仍然会成为作者担忧的因素。

另外一个问题是 prepend 是否是优先操作。如果 prepend 不被认为是优先事项,那么为什么在这种情况下(索引肯定是优先事项)会受到任何性能影响?我的猜测是索引、枚举和添加到数组的末尾是被赋予最高优先级的场景。像 prepend 这样的操作可能被认为是罕见的。

【讨论】:

  • 循环数组中的索引元素可以通过执行模运算(最好在不执行除法的情况下实现)来实现而无需分支。在某些情况下,您需要 2 次 memmove 操作,但要移动的数量会少很多。
  • @Alex - List 访问速度非常快,几乎与普通数组访问一样快。添加模数将对性能产生重大影响,除非您可以保证列表大小是 2 的幂。
  • @hatchet 同意,当你成长时,你应该总是增长到 2 的幂。
  • 但是目前List上没有这样的限制。您可以使其初始大小完全符合您的要求。
  • @hatchet 是的,这将是一个权衡,可能会导致决定以它的方式实施它。另一方面,一旦它需要增长到超出您指定的初始容量,它将使用自己的“理想”容量确定。
猜你喜欢
  • 1970-01-01
  • 2013-09-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-03-08
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多