VList data structures in C#

时间:2022-05-04
本文章向大家介绍VList data structures in C#,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

原文链接

介绍

VLIST数据结构是由Phil Bagwell设计的,它作为在函数式编程语言单链表的替代品。它可以被认为是链接列表和动态数组(如.NET Framework的List<T>类)之间的折中,它们混合了每个列表的优点。

我为.NET创建了四种不同的VList数据类型族:

  • FVList:由Phil Bagwell描述的标准不可变VList,在前面添加了新项目(在索引0处)。
  • RVList:一个倒序的VList。与.NET Framework相比,它更好地匹配,因为新项目被添加在后面(在索引处)。
  • FWList:性能接近FVList的可变版本。
  • RWList:一个可变的RVList版本; 实际上,这是一个List<T>的直接替代。

在内部,所有这些都建立在一个混合可变、不可变的VList之上,我将在本文的过程中对其进行描述。任何这些数据类型的实例都可以在O(1)和O(log N)时间之间转换为其他任何数据类型的实例。

注意:FVList最初被命名为VList。然而,最常见的是想要使用RVList或者RWList,因为他们遵循.NET约定,项目在列表的“结尾”处添加和删除。因此,在我的Loyc项目中,我正考虑重新命名RVList到VList,这样人们每次创建RVList时就不必去想房车。但显然我不能重新命名VList为RVList,除非我先将其转发给别的东西......所以我称它为FVList。不过,在这篇文章中,我只是在重新命名VList,而不是为了避免对那些过去读过这篇文章的人造成混淆。

在这篇文章中,术语“ VList”可以同时表示FVList 和RVList ,而“WList”可以同时表示FWList 和RWList。

背景

函数式编程语言大量使用“ 永久链接列表”,这是链接列表,其项目是不可变的(从未修改过)。因为它们是不可变的,所以在两个链表之间共享链表的一部分总是非常安全。如果你不熟悉它们,请看下面的持久链表数据结构的完整实现:

public struct PList<T> : IEnumerable<T>
{
    private PList(PNode<T> n) { _node = n; }
    private PNode<T> _node;

    public PList<T> Add(T item)
    {
        _node = new PNode<T>(item, _node);
        return this;
    }
    public T Head
    {
        get { return _node.Value; }
    }
    public PList<T> Tail
    {
        get { return new PList<T>(_node.Next); }
    }
    public bool IsEmpty
    {
        get { return _node == null; }
    }
    public IEnumerator<T> GetEnumerator()
    {
        PNode<T> n = _node;
        while (n != null)
        {
            yield return n.Value;
            n = n.Next;
        }
    }
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

}
private class PNode<T> // used internally by PList<T> 在PList<T>内部使用
{
    public PNode(T value, PNode<T> next) { Value=value; Next=next; }
    public readonly T Value;
    public readonly PNode<T> Next;
}

这是他们可能教你写在高中时的一种简单的单链表实现,但它已经非常有用。你可以用Add()添加项目,Tail删除最后一个项目,并且由于它实现了IEnumerable,你可以使用foreach或者LINQ遍历它。

现在,请思考以下代码:

PList<int> A = new PList<int>();

A.Add(7);

A.Add(3);

PList<int> B = A;

A.Add(9);

A.Add(1);

B.Add(3);

B.Add(1);

在内存中,结构如下所示:

image.png

事实上,你不能修改链表中的项目意味着你可以把它们当作一个值类型来处理:如果你将一个列表传递给一个函数,你永远不用担心这个函数会修改你的列表。如果需要,该功能可以自由添加或删除列表中的项目,但这些更改不会影响你的列表副本。

但是,持久链表 PList<T>并不像你每天使用的List<T>标准那么好。上面的实现不提供读取、修改、插入或删除列表中间任意位置的项目的方法。我们当然可以添加这个功能 -- 我们可以提供在列表中任何位置更改项目的假设 -- 但是,它会比List<T>慢,因为执行任何这些操作会花费O(N)时间。例如,如果添加了索引器,那么你可以这样编写:

B 2 = 5 ;

为了完成这个任务,必须先制作一份副本:

image.png

此外,在修改之前需要O(N)时间来查找itemN。

最后,统计列表中的项目数量需要O(count)时间。

FVList

Phil Bagwell的VList使用数组的链表而不是单个项目。它旨在通过以下方式改进持久链表:

索引元素平均时间为O(1)(但列表结尾的为O(log N))。

O(log N)时间内计算元素(在我的实现中是O(1)!)。

存储元素更加紧凑。例如,一个带有N个元素的需要16 N字节的内存(在32位PC上),但RVList<int>通常需要少于8 N字节(与内存需求大致相同)。另外,由于相邻元素往往在内存中相邻,所以VLists更容易缓存,因此,他们更快。

理想情况下,数组链表的大小呈指数增长,因此列表中的第一个数组最大。这个图表说明了这一点:

图2翻译.png

对VList的引用是一对我称之为“块”和“本地计数”(代码中的_block和_localCount)的值。例如,图中的“引用C”由一个指向块2的指针组成,其局部计数为6.因此,VList C包含12个项目(块0中2个,块1中4个,块2中6个)。同时,引用D的本地计数为8,因此它包含14个项目(其中12个与C共享)。就像链接列表一样,可以有其他引用指向VList的其他部分。该图显示了四个引用,共享三个存储块。

此外,我的 FVList<T>表现非常像PList<T>,但具有更高的性能和更多的功能。FVList和RVList提供完整的IList<T>接口,以及像Tail、Push/ Pop/Front(使用VLIST作为堆栈)、AddRange、InsertRange、RemoveRange和ToArray这些东西。

VList始终以大小为2的块开始,而且在创建新块时,它们是前一块大小的两倍。理想情况下,索引器平均耗时为O(1)(当访问随机索引时),因为该列表的50-75%位于前两个块中,并且达到最后几个元素所需要的额外耗时O(log N)对整体运行时间没有太大的影响(只要你不会比第一个元素更频繁地访问最后一个元素)。在我的实现中,每个VList块(由一个VListBlock<T>对象表示)跟踪所有先前块中元素的总数,因此该属性需要耗时为O(1)。

假设我们再次尝试相同的例子,但是用FVList而不是PList:

FVList<int> A = new FVList<int>();
A.Add(7);
A.Add(3);
FVList<int> B = A;
A.Add(9);
A.Add(1);
B.Add(3);
B.Add(1);  

比如说PList<T>、FVList<T>是值类型,用一个简单的赋值语句创建一个副本。结果如下:

image.png

现在,假设我们创建了包含{7,8,9}的第三个列表:

// A contains { 1, 9, 3, 7 }.

FVList<int> C = A.WithoutFirst(3); // Remove 3 items to get { 7 }. 移除三个项来获取7

C.Add(8).Add(9); // Add 8, then 9 to get { 9, 8, 7 }. 添加8、9以获得{9,8,7}

由于Block0 1已经在使用,所以当我们向C中添加8时,必须分配一个新块。在C中添加9之后,内存布局如下所示:

image.png

请注意,C无法知道是否有任何引用仍然存在于Block0 1中的值3。在向C添加任何项目之前,变量A和B可能已超出范围,但C不知道这一点。因此,C必须假定值3正在使用并保持独立,从而创建一个新数组而不是替换现有值。

另请注意,新的块3只有两个项而不是4个; 这是因为块大小选择为前一块中使用的大小的两倍:C仅在块0中使用1个项目,因此该大小的倍数为2.

因此,当你在与VList进行大量共享和分支时,块往往更小,表现更像链接列表。我相信这很好,因为否则就会有分配非常大的数据块的风险,在这些数据块中只有极少数数据项正在使用。

例如,假设我们这样做:

FVList<int> D = new FVList();

D.Add(1).Add(-1); // { -1, 1 }

D.RemoveAt(0); // { 1 }

D.Add(2).Add(-1); // { -1, 2, 1 }

D.RemoveAt(0); // { 2, 1 }

D.Add(3) // { 3, 2, 1 }

这种访问模式(反复加2和删除1)是最糟糕的情况。它实际上迫使VList降级到低效链表:

image.png

在这种情况下块大小不会成倍增加,或者很多空间会被浪费得非常快。

事实上,为了防止在子列表共享分支和分支时出现某些病态问题,我决定将所有块限制为最多1024个项目,而且我的Add()方法使用了一种技术(记录在VListBlockArray.Add源代码中)以避免保持小于1/3的数组完全活着(从垃圾收集器的角度来看)。

VList的“ 流利 ”接口

在开发FVList<T>结构时,我发现在使用属性时会遇到FVList<T>问题,因为它是一种值类型。考虑当您尝试向该Foo.List属性添加内容时会发生什么情况:

class Foo {

private FVList<int> v;
public  FVList<int> List { 
    get { return v; }
    set { v = value; }
}

}

Foo f = new Foo();

f.List.Add(777); // FAIL 失败

该列表属性的使用是绝对错误的,因为它在运行时没有效果。为什么?FVList<int>是一个值类型,所以该列表属性返回列表的副本。当你调用该Add方法时,777被添加到列表的副本中,之后副本立即消失。不幸的是,C#编译器没有检测到这个问题(这太糟糕了,没有Mutator属性可以让我应用于Add(),这会使编译器在这种情况下发出错误。)

如果FVList<T>只实现了返回void的标准Add方法,则必须使用如下代码解决问题:

Foo f = new Foo();

FVList<int> temp = f.List;

temp.Add(777);

f.List = temp;

所以,我决定通过使用void返回值更改方法来返回被修改列表的副本,从而使事情更轻松。这样,你可以简单地写:

Foo f = new Foo();

f.List = f.List.Add(777); // WORKS! 成功

但是,这种更改也会使列表更方便使用,因为您可以在一行中执行多项修改:

VList<string> Q = new VList<string>("Captain", "Joe");

Q.RemoveAt(1).Add("Kirk").Add("T").Add("James");

// Q = { "James", "T", "Kirk", "Captain" }

我没有为FWList和RWList引用相同的功能,这些是引用类型因此不会遇到同样的问题。

RVList

FVList对于普通的C#程序员来说有点奇怪,因为项目被添加在前面(索引0)而不是后面。这就是我制作RVList<T>的原因。除了Add()方法将项目添加到列表的末尾而不是开始之外,它与FVList<T>相同。您可以在O(1)时间内转换FVList<T>为RVList<T>(反之亦然)(但项目的顺序相反!),并按照您的预期从列表0开始到Count-1按顺序列举列表。

RVList<T>只不过是名单上的一个不同的“观点”。这就好像你写了一个叫做BackwardList<T>的wrapper包装器,它反转了List<T>表面元素的顺序。

枚举RVList<T>项目按照“反向”顺序进行,从索引0开始到Count,就像遍历从远端到前端的链表。我决定在一个算法的帮助下实现一个枚举,该算法通过单向链表向后搜索。理想情况下,枚举需要O(N +(log N)^ 2)= O(N)时间,但是如果列表退化为链表,则需要O(N + N ^ 2)= O(N ^ 2 )时间。相比之下,FVList<T>枚举器总是花费O(N)时间,因为链表是以自然的方式遍历的。

可以在O(N)时间内使用临时列表枚举以跟踪任何需要遍历RVList<T>的块。如果有需求,我可能会改变实现。

FWList和RWList

如上所示,如果你对其使用某些修改模式,则FVList<T>有时会浪费地使用内存,并产生长链块。此外,调用setter修改FVList<T>索引N至少需要O(N)时间 - 有时更像O(Count)时间。

为了使VList真正有用,我觉得,有必要有一个不受这些问题困扰的可变版本。我决定将它命名为WList,或“可修改的VLIST”(MList作为“可变VLIST”已经声明过了),两种变化形式FWList和RWList(正向和反向的WList)。

当RWList<T>自己使用时,它是标准的List<T>直接替代品。当以这种方式使用时,保证总会产生一系列大小呈指数级增长的块。它可以这样做,因为它可以覆盖块中的现有项目 - 它永远不必“分叉”块。

因此,RWList<T>具有与List<T>相同的big-O性能:

索引器读取和写入的平均时间为O(1)。

添加或删除列表头部的项目的时间为O(1)。

插入或删除索引K处的项目需要耗时O(K)。

搜索位于索引K处的项目需要O(K)时间,或者如果未找到将会耗时O(N)。

枚举列表需要O(N)时间。

正如你可能猜到的那样,RWList<T>是FWList<T>的逆序形式。RWList<T>通常优先于C#开发的FWList<T>,因为该Add方法在索引[<code>Count0]处添加项目而不是索引0。

当然,具有类似List<T>的big-O性能没有理由替换List<T>,尤其是因为RWList<T>可能整体速度较慢(尽管我没有进行基准测试)。FWList和RWList有用的真正的原因是,你可以即时将列表 - 甚至只是列表的一部分 - 转换成一个FVList<T>或RVList<T>。而且,一旦你完成了这个任务,你仍然可以继续修改FWList<T>或者像以前一样修改RWList<T>- 可变性错觉总是被保留下来。

在底层,FWList或RWList分为两部分或“一半”:可变部分和不可变部分。当你创建一个新的列表并添加项目时,它是100%可变的。当你调用ToVList()或者ToRVList(),它被标记为100%不变。如果你添加了项目到WList的最后,它仍然需要耗时O(1),因为你没有改变列表的不可变部分。但是,如果修改列表的一部分,使其不可变,则最多需要O(N)时间,因为复制必须由许多或所有不可变项目组成,以使其再次变为可变。

易变项目由单个FWList或RWList专属所有; 不变的项目可以在不同的FWLists、RWLists、FVLists和RVLists之间共享。当列表从一种形式转换为另一种形式时,列表中的所有项目都被标记为不可变。这只需通过增加被调用的ImmCount块的属性来匹配列表中项目的数量来完成。在100%可变块中,ImmCount为0; 在一个完整的100%可变块中,ImmCount等于该块的Capacity。

为了说明这是如何工作的,我们来看一个例子。假设我们创建了一个从0到8递增的FWList项目:

FWList<int> W = new FWList<int>(9);

for (int i = 0; i < 9; i++)

W[i] = i; 

此列表由三个块代表...

图3翻译.png

我们可以使用WithoutFirst(4)获取列表末尾的不可变版本,而不是调用ToFVList()(这会使整个列表不可变):

FVList<int> V = W.WithoutFirst(4); // V = { 4, 5, 6, 7, 8 }

image.png

(注:RWList<T>有WithoutLast(),而不是WithoutFirst(),它使列表可变,而不是开始的结束。)

由于前四项仍然是可变的,我们可以在O(1)时间内修改它们。例如,我们可以这样做:

W3 = 33; // fast

但是,如果我们修改列表中不可变部分的项目,WList将会修改尽可能多的块以修改不可变的项。例如,

W4 = 444;

产生以下结果:

image.png

请注意,WLists不支持可变和不可变块的交织。只有列表的头部是可变的,并且只有尾部是不可变的。如果您在索引Count-1处修改WList(或者如果在索引0处修改RWList),则整个列表将被强制变为可变。

我冒昧地在这里实现了一个优化:如果你正在将VList变成一个可变的WList,并且VList有很多小块(比如上面显示的病态链表的情况),WList将尝试将小块整合成更大的块,以指数级进展,以便WList的可变部分变得比VList创建时更有效率。但是,它不会复制更多的数据块,以避免执行您要求的即时操作,这可能会限制重组的完成量。

如果您怀疑RVList已经降级为长链,那么您可以使用这个小函数将链重组为一个高效的短链:

// Restructures an RVList if it has degraded severely. Restructuring needs O(Count) time. 如果RVList严重降级了,就重构RVList,需要O(Count)时间

void AutoOptimize<T>(ref RVList<T> v)

{

// Check if the chain length substantially exceeds Sqrt(v.Count)
int bcl = v.BlockChainLength-2;
if (bcl * (bcl - (bcl>>2)) > v.Count) {
    RWList<T> w = v.ToRWList();  // This is basically a no-op
    w[0] = w[0];                 // Restructure & make mutable
    v = w.ToVList();             // Mark immutable again
}

}

VListBlock

四种VList类型所有都使用公共块类VListBlock<T>。现在,FVList和RVList显然需要共享大多数相同的代码,并且因为C#结构不允许有基类,我把大部分管理VLists的共享逻辑放在VListBlock<T>。在VListBlock源代码中,“标准”列表是FVList; 如果一个方法需要一个列表作为参数,则FVList比RVList优先考虑。在VListBlock这里,术语“前面”是指链接表的头部,尾部块被称为“先前”块。

当我添加为可变VLists设计的新算法时,我给了它们前缀Mu以区别为不可变列表设计的算法。由于FWList和RWList是类,我给了他们一个基类WListBase<T>,它实现IList<T>并含有大量的公共代码,但最底层代码进去VListBlock(或它的两个派生类,其存在的小列表优化,如下所述)。

区块所有权

我想我应该说一些关于区块所有权的内容,因为目前的实现比需要的更复杂。跟踪谁拥有什么是重要的,因为两个WLists可以共享相同的块,但每个块最多只能拥有一个WList。VListBlock不知道它的拥有者,因为它没有提及WListBase拥有它; 相反,WListBase包含一个flag(IsOwner),它指定它是否拥有其头部块,并且每个块都包含一个隐式标志(PriorIsOwned),用于指定该块是否拥有链中的先前块。如果WList拥有头块,那么它也拥有PriorIsOwned属性为true且在行中的所有先前块。两个WLists

要求拥有一个街区这是不可能的。即使它们位于不同的线程上,也不会发生这种情况,因为MutableFlag是使用原子操作(Interlocked.CompareExchange)设置的。

MutableFlag指示,块是由拥有FWList或RWList,但它并不表示谁拥有它。两个不同的WLists当然可以共享一个拥有此标志集的块,但至少两个WLists中的一个只包含块中的不可变项。所以,WList依靠它的IsOwner标志并确定PriorIsOwned拥有多少块。正如我所提到的,PriorIsOwned是一个“隐含”的旗帜,这是一个混乱的部分。如果满足以下条件,则PriorIsOwned返回true:

前面的块是可变的(有MutableFlag)。

Prior._localCount > PriorBlock.ImmCount.

这种逻辑依赖于可变和不可变块不能交织的事实,并且事实上,直到先前块已满为止,永远不会创建新的可变块。正因为如此,它可以保证,要么在前面的项目列表是可变的,或者该名单是完全充满不可变的前面的项目。该逻辑一旦ImmCount到达块的Capacity就会PriorIsOwned返回false。这是可以的,因为如果一个块中充满了不可变的项目,那么它也不能以任何方式修改任何先前的块; 因此,谁拥有该块的问题是无关紧要的。

现在,例如,如果想要修改源代码以允许在所有先前的块已满之前分配新的可变块(例如,以支持当前不可用的可设置的Capacity属性),则该逻辑将不再是有效,并且有必要引入和管理显示标志,指示先前的块是否拥有。

线程安全

线程安全是一个问题。单个列表实例不是线程安全的,但我试图确保共享相同内存的不同列表是线程安全的。VListBlock.cs描述_immCount中的注释说明了线程安全是如何处理的,但基本上,线程安全是_immCount唯一关心的领域,因为在同一时间内VListBlock没有其他数据可以被两个不同的线程修改。

小列表优化

对于某些应用程序,通常会有大量短名单(两个或更少)。例如,抽象语法树是N元树,但许多节点具有0,1或2个子元素。出于这个原因,我优化了列表中第一个块的内存使用情况,以便不使用两个项目的数组,而是使用两个称为_1和_2的字段。第一个块由类VListBlockOfTwo表示,而所有其他块由类VListBlockArray表示(均源自于VListBlock)。VListBlockOfTwo使用大约28个字节的内存少于VListBlockArray相同尺寸的内存。作为交换尺寸较小,性能会受到一定损失,因为某些操作需要虚拟函数调用。如果FVList或RVList没有项目,它根本不使用堆空间。

结论

那么,用VList有什么好处?简而言之,它们在功能算法中是一种替代持久链表的好方法。我将在Loyc中使用它们,在我的可扩展C#/ boo编译器项目(它处于非常早期的阶段,顺便说一句,因为项目太庞大而无法独立完成!)。

我的想法是Loyc不仅可以用作编译器,还可以用于IDE来提供“智能感知”。现在,为了在您输入程序时对程序进行深入检查,Loyc会通过许多“编译器步骤”运行您的代码,以发现深层意义。例如,假设有人写了一个扩展来支持C#中的C预处理器。然后,代码就像

#define EMPTY(c) internal class c {}

EMPTY(Foo)

EMPTY(Bar)

将不得不被翻译成

internal class Foo {}

internal class Bar {}

因此IDE可以了解类Foo和类Bar的存在。Loyc将不得不经历如下一系列转变:

图7翻译.png

每次用户按下某个字母时,我都认为与其通过所有这些步骤,我希望以增量的方式来完成这项任务,以便不必每次按下字符都重新标记源文件,只有那些可能改变的令牌才会被重新解析。此外,可能会通过所有编译器阶段推出一系列“修正”(增量修改),以便在更短的时间内生成重新生成的文件。

为了这是远程可行的,有必要保留令牌列表的旧副本,并且可能是原始AST的旧副本。但是,由于整体意图在于提高效率,因此实际上花费时间制作令牌列表和AST的完整副本可能会适得其反。相反,我认为,副本应该只根据AST的一部分,当需要的话按需提供,VList有助于支持延续AST、i.e.,只要有保持对它们的引用AST那里的旧版本将会保留。

另外,由于这个编译器应该允许第三方修改AST,所以AST可能是不可变的,并且编译器阶段可以用函数式编写。这样,一个编译器扩展会不小心搞坏了另一个扩展所做的更改,调试会更简单。不过,我正在考虑一个协调的解决方案,因为完全不变性可能会损害性能。

如果你能想到FVList,RVList,FWList和RWList的其他用途,请留下你的想法评论!

关于依赖关系的说明

我希望你不介意:VList源代码包含依赖于nunit.framework.dll的单元测试。此外,Loyc.Runtime.dll也是一个小的依赖项,Loyc.Runtime.dll是一个用于通用目的的简单实用的程序小集合。最重要的依赖是这里描述的一个小类CollectionDebugView<T>,它允许VLists 在调试器中看起来与普通List<T>对象相同。其他依赖项是Localize.From,一个可插入字符串本地化资源。只需从源代码中删除字符串“Localize.From”的所有实例,即可自由删除它。