如何高效地处理多个元素插入到数组中?

9

我有一个动态分配的整数数组,我想在任意位置插入多个整数,这些整数可能超过250万。

目前我的代码如下:

type
  TIntegerArr = array of Integer;

var
  FCount: Integer;
  FSortedList: TIntegerArr;

procedure Insert(_Value: Integer; _InsertPos: integer);
var
  OldList: TIntegerArr;
begin
  OldList := FSortedList;
  if Length(FSortedList) < FCount + 1 then begin
    OldList := FSortedList;
    FSortedList := nil;
    SetLength(FSortedList, FCount + 100);
    CopyMemory(@FSortedList[0], @OldList[0], SizeOf(Integer) * _InsertPos);
  end;
  MoveMemory(@FSortedList[_InsertPos + 1], @OldList[_InsertPos], SizeOf(Integer) * (FCount - _InsertPos));
  FSortedList[_InsertPos] := _Value;
  Inc(FCount);
end;

(真实代码是一个类的方法,该类具有FSortedList和FCount作为字段。)
使用临时列表,并使用Move而不是for循环来移动数据已经显著提高了性能,因为它可以防止在数组需要增长时将其复制两次(一次在现有数组的SetLength中,另一次是使用Move)。
但最坏情况下的Insert(SomeValue, 0)仍然总是移动所有现有值。
到目前为止,我的想法是引入一个偏移量,使数组的起始处只需在偏移量达到0时才移动所有现有值。例如:
// simple case: inserting at Position 0:
if FOffset = 0 then begin
  // [...] reallocate a new array as above
  Move(@FSortedList[100], @OldList, SizeOf(Integer) * _InsertPos);
  FOffset := 100;
end;
Dec(FOffset);
FSortedList[FOffset] := _NewValue;

(此代码未经测试,可能存在错误)当然,可以扩展此代码以检查插入点是更靠近开头还是结尾,并根据此将第一个或最后一个值移动一个位置,以便平均只移动1/4的条目,而不是目前的1/2。
另一种选择是实现稀疏数组。我记得在20世纪90年代看到过这样的实现,但不记得是哪个商业库(TurboPower?)。
该过程是某些排序和索引代码的核心,该代码适用于不同大小的数组,从只有几十个条目到上述数百万个条目。
当前程序运行约2小时(在我的优化之前,它接近5小时),我已经知道数组中的条目数至少会翻倍。由于插入性能随着数组大小的增加而变差,因此我怀疑在条目数量翻倍的情况下,运行时间至少会增加4倍。
我希望得到一些建议来提高性能。目前内存消耗并不是很大的问题,但运行时间绝对是。
(这是Delphi 2007,但除非更新的Delphi版本已经针对以上操作进行了优化,否则这不会有太大区别。Classes.TList没有进行优化。)
编辑1:刚刚找到我提到的稀疏数组实现:它是TurboPower SysTools中的StColl。
编辑2:好的,一些背景信息:我的程序读取一个具有240万条目的DBase表,并从这些条目生成几个新表。新表被规范化并在创建后进行索引(出于性能原因,我不会在插入数据之前生成索引,请相信我,我先尝试了。)。该数组是代码的核心部分,为生成的表提供内部排序。新记录仅附加到表中,但其RecNo按排序顺序插入到数组中。

2
请参考 @RunnerImproved Sliced Array implementation,如果需要任何有关如何改进排序的输入。 - LU RD
@LURD:谢谢。我在他写这篇博客文章时就已经读过了(那页上的第一条评论是我的),但我已经忘记了。 - dummzeuch
请告诉我们关于“可插入数组”使用案例的更多信息。可能的解决方案取决于它们。 - MBo
1
在您进行Edit2之后,我仍然不确定您是否真正需要一个数组,或者另一个容器会是更好的选择... - MBo
1
你可以尝试使用我的NLDSparseList - NGLN
显示剩余3条评论
2个回答

4

在查看您的程序后,我发现了一些缺陷。为了了解进展情况,我首先测量了您现有程序在最坏情况下(始终将数字添加到0位置)的速度。

n:=500000;
for i:=0 to n-1
 do Insert(i, 0);

测量结果:n=500000 47.6毫秒

A) 简单性

我从你的过程中删除了一些不必要的行(OldList是完全不必要的,SetLength会保留内存)。

改进A:

procedure Insert(_Value: Integer; _InsertPos: integer);
begin
 if Length(FSortedList) < FCount + 1
    then SetLength(FSortedList, FCount + 100);
  Move(FSortedList[_InsertPos], FSortedList[_InsertPos+1], SizeOf(Integer) * (FCount - _InsertPos));
  FSortedList[_InsertPos] := _Value;
  Inc(FCount);
end;

速度提升 6% (44.8 毫秒)

B) 一切都计算在内

if Length(FSortedList) < FCount + 1 
   then SetLength(FSortedList, FCount + 100);

提示1:每次插入都会调用函数Length。 提示2:每次计算FCount+1。 提示3:过程参数应为const(按引用传递)。 提示4:引入FCapacity变量。 提示5:仅通过100增加长度会导致大量重新分配(在250万个数组上进行25,000次)。正如您所说,内存不是问题,那么为什么不预先分配全部或至少较大的空间呢?
procedure Insert(const _Value, _InsertPos: integer);
begin
 if FCount = FCapacity
    then begin
     Inc(FCapacity, 100000);
     SetLength(FSortedList, FCapacity);
    end;
 Move(FSortedList[_InsertPos], FSortedList[_InsertPos+1], SizeOf(Integer) * (FCount - _InsertPos));
 FSortedList[_InsertPos] := _Value;
 Inc(FCount);
end;

速度提升1% (44.3毫秒)。

提示:您可以实现一些渐进式算法,而不是递增100000。

C) 瓶颈

如果我们现在看这个过程,就什么也没剩下了,只有很多内存移动。如果我们无法改变算法,那么我们必须改进内存移动。

实际上还有快速移动挑战(fastcode.sourceforge.net)。

我准备了一个zip文件,里面只有你需要的那些文件(3个文件,源代码)。 链接 >>> http://www.dakte.org/_stackoverflow/files/delphi-fastcode.zip

  • 将fastcodeCPUID.pas和fastmove.pas添加到您的项目中!
  • 插入Uses fastmove.pas;
  • 就这样!不用再做其他更改了!

在我的计算机上速度提升接近50%(取决于您使用的CPU)。

原始过程

n         ms graph
---------------------------------
100000   1.8 *
200000   7.6 ***
300000  17.0 *******
400000  30.1 *************
500000  47.6 ********************

提高,不包括快速移动(-7%)

n         ms graph
---------------------------------
100000   1.6 *
200000   6.9 ***
300000  15.7 ******
400000  28.2 ***********
500000  44.3 ******************

改进,使用 fastmove (-46%)

n         ms graph
---------------------------------
100000   0.8 *
200000   3.8 **
300000   9.0 ****
400000  16.3 *******
500000  25.7 ***********

最新评论:

 if FCount = FCapacity
    then begin
     if FCapacity<100000
        then FCapacity:=100000  
        else FCapacity:=FCapacity*2;
     SetLength(FSortedList, FCapacity);
    end;

正如我所说,你可以增加一些渐进式的FCapacity增加。这是一种经典的Grow实现(如果需要,只需添加更多的if语句或将100000更改为更合适的值)。

D)更新2:数组作为^TArray

type
  PIntegerArr3 = ^TIntegerArr3y;
  TIntegerArr3y = array[0..1] of Integer;

var
 FCapacity3,
 FCount3: Integer;
 FSortedList3: PIntegerArr3;

procedure ResizeArr3(var aCurrentArr: PIntegerArr3; const aNewCapacity: Integer);
var lNewArr: PIntegerArr3;

begin
 GetMem(lNewArr, aNewCapacity*SizeOf(Integer));

 if FCount3>0 // copy data too
  then begin
    if aNewCapacity<FCount3
       then FCount3:=aNewCapacity; // shrink
    Move(aCurrentArr^[0], lNewArr^[0], FCount3*SizeOf(Integer));
  end;

 FreeMem(aCurrentArr, FCapacity3*SizeOf(Integer));
 FCapacity3:=aNewCapacity;
 aCurrentArr:=lNewArr;
end;

procedure FreeArr3;
begin
 if FCapacity3>0
  then begin
    FreeMem(FSortedList3, FCapacity3*SizeOf(Integer));
    FSortedList3:=nil;
  end;
end;

procedure Insert3(const _Value, _InsertPos: integer);
begin
 if FCount3 = FCapacity3
    then ResizeArr3(FSortedList3, FCapacity3 + 100000);
 Move(FSortedList3^[_InsertPos], FSortedList3^[_InsertPos+1], SizeOf(Integer) * (FCount3 - _InsertPos));
 FSortedList3^[_InsertPos] := _Value;
 Inc(FCount3);
end;

C) 从步骤C中没有获得速度增益!

结论: 使用FastMove或算法更改后,“物理”内存移动速度的极限已经达到!

我正在使用Delphi XE3,在System.pas的第5307行:

(* ***** BEGIN LICENSE BLOCK *****
 *
 * The assembly function Move is licensed under the CodeGear license terms.
 *
 * The initial developer of the original code is Fastcode
 *
 * Portions created by the initial developer are Copyright (C) 2002-2004
 * the initial developer. All Rights Reserved.
 *
 * Contributor(s): John O'Harrow
 *
 * ***** END LICENSE BLOCK ***** *)

procedure Move(const Source; var Dest; Count: NativeInt);

实际上,在Delphi中已经有一些Fastcode例程,但是包括直接从其网站下载的例程(或从我上面提供的链接)使差异最大,几乎达到了50%(奇怪)。


感谢您详尽的回答。我不明白,“改进A”中的速度提升是从哪里来的。我在OldList变量中的意图是防止在SetLength调用中复制整个现有内容(正如您所说,保留现有内容)。因此,我分配了一个新数组,并仅复制了旧数组从0到InsertPos的那部分,然后将InsertPos之后的部分移动到新数组中。这应该可以避免大约一半的数组内容被复制两次。 - dummzeuch
@dummzeuch 当涉及到大量相同的操作时,例如500,000甚至数百万次,每个函数调用都很重要。A中的速度改进是因为:1.删除了本地变量,不需要堆栈;在Delphi中,<=3个函数参数通过CPU寄存器传递,2.删除了对本地变量的赋值,3.删除了使用nil释放内存的操作,4.删除了使用setlength进行分配的操作,5.删除了CopyMemory调用(即使您调用什么也不做的函数也会占用宝贵的时间;)只需要一个SetLength,它重新分配内存,复制数据并释放旧数据,并针对其所做的优化。 - david
在你原来的过程中,你使用了SetLength(FSortedList,FCount + 100)为新内存分配了空间。SetLength还将所有内存清零,因此释放内存,分配内存,清除内存并复制已清除的内存是完全多余的。如果您调用SetLength,它会分配新的内存并复制当前内容,但仅将数组剩余部分归零(即使这个归零也是多余的),因此这只是一个两步过程,几乎没有冗余;) - david
所以,如果我理解你的意思正确的话,除了一些边角改进因为没有堆栈分配,分配给一个变量(这可能本来就是一个CPU寄存器),以及对于在位置0插入时不起作用的CopyMemory调用,速度提升基本上通过防止SetLength不必要地设置所有内存为0来实现。所以相比使用Integer数组而不是^array[0...largenumber] of integer并自己分配内存,我得到了一个速度惩罚。这正确吗? - dummzeuch
@dummzeuch 大体上是的... ;) 在程序中使用指针可以让代码运行更快,但维护起来更费力,而且会降低源代码的可读性,此外你必须释放任何分配的内存,而使用数组则不需要。然而,我是在回答你的问题,你定义了一个整数数组。使用指针会更快,因为一些多余的东西可以被删除;有关 CPU 寄存器的注释,Delphi 通过将最多三个参数直接放入寄存器(如果可能:指针、32 位 CPU 上的 int8-int32 等)来优化函数调用,而局部变量则不会这样做。 - david
@dummzeuch 我用指针进行了测试。与上一个优化版本相比没有速度提升。不能通过数组进一步改进,唯一的解决方案是使用其他类型的容器或字典。 - david

1

不想打扰,但是我的问题已经在编辑中得到了解决:

从数组切换到TurboPower's StColl后,性能不再随着大型数组的增加而下降,并且非常快速。运行时间从2小时缩短到不到半小时。更改非常简单。我希望我早点记起这个库。

我需要从SourceForge存储库中获取以下文件(我不想下载整个库):

  • StBase.pas
  • StColl.pas
  • StConst.pas
  • StList.pas
  • StDefine.inc

实际上,我很惊讶没有更多的相互依存。TurboPower的人绝对知道他们的行业。我想知道他们今天在做什么,还是在为赌场编程赌博机吗?


2
如果稀疏数组是答案,那么问题就错了。稀疏数组和普通数组是非常不同的。如果你有一个稀疏数组,那么你需要提前分配整个数组,并且永远不会进行移动操作。在现代 Delphi 中,你可以使用 TDictionary<K,V> - David Heffernan
好的,你说得对,我需要一个类似于数组的数据结构来存储整数,并且能够高效地在任意位置插入新条目。TStCollection提供了这个功能。对于这个应用程序,我不需要TStCollection具有未使用间隙的可能性。 - dummzeuch
@DavidHeffernan 那个评论(TDictionary<K,V> 用作稀疏数组以提高性能)非常有见地! - SOUser

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