关于这个问题,我想指出几点。首先,让我们直接了解一些关于性能和 C# 的总体情况,因为在仍然存在误解的情况下很难解释这些内容。
接下来,我将在此处将所有内容应用于特定问题。
一般的 C# 性能
大 O 表示法
在大学里,你会了解到 O(n) 总是优于 O(n^2) 以及 O(n) 总是优于 O(n log n)。但是,对此的基本假设是每个操作将花费大致相同的时间。
现在,当我在 1986 年第一次开始在 1802 RISC 处理器上编程时,情况非常普遍:内存操作是 1 个时钟滴答,加法、减法等也是如此。换句话说,Big-O在那里工作得很好。
在现代计算机中,这更困难:
- 数据缓存在不同级别(速度范围从 15 GB/s 到 1000 GB/s);
- 操作在 0.5 个时钟节拍和几十个时钟节拍之间变化;
- 数据通常以突发方式获取 - 因此随机访问比顺序访问要糟糕得多;
- 向量化可以一次处理多达 8 个整数的对齐数据;
- 分支错误预测可能会使一切失去平衡,因为您必须刷新批次。
我观察到同一算法的不同实现的性能差异可能高达 1000 倍(!)
Big-O 仍然具有优点,但你应该正确看待事情。例如,假设你有 N=10000,然后是 2log N ~ 13——如果这意味着你可以从所有这些东西中受益,这也可能意味着一个“愚蠢”的 O(n log n) 算法可能会胜过你的平均 O(n) 算法。
据此,您还应该推断出 O(n^2) 永远不会胜过 O(n) 算法。所以,Big-O 仍然有它的用途;你只需要正确看待事物。
关于 C# 的一些特点
关于 C# 的一个神话是它大约与 C++ 一样快(这是我的“尽可能快”的黄金标准)。在熟练的开发人员手中,事情并非如此简单。对于简单的排序,C++ 的速度大约是 2 倍 - 但如果您有更复杂的场景,您可以真正从“低级东西”中受益,差异可能会变得非常大。我通常估计性能差异是 10 倍。但是,编写正确的高性能 C++ 代码具有挑战性(使用轻描淡写的话),因此您可能希望坚持使用 C# 并决定将性能损失视为理所当然。
有趣的一点是 C# 编译器和 JIT 编译速度非常快。在某种程度上,这是因为它们按函数编译所有内容(因此,没有内联等)。此外,C# 通常不会对内容进行矢量化。不要相信我的话,在 Visual Studio 中使用 ctrl-alt-d 并自己检查汇编程序输出。
如果我们看一下上面的列表,我们可以粗略地说 (1)、(2) 和 (3) 不受我们使用 C# 的影响; (4) 肯定受到影响,(5) 依赖。
至于(5),考虑这个简单的例子:
void set(int[] array, int index)
{
array[index] = 0;
}
请记住,在 C# 中,方法是按方法编译的。这意味着编译器不能假定index 不会越界。换句话说:它必须添加两项检查 - 其中一项必须加载内存:
if (index < 0 || index >= array.Length)
{
throw new IndexOutOfRangeException();
}
排序项目
OP 的问题是关于维护大小为m 的排序列表。排序是一个众所周知的操作,最多将花费O(log m) 您插入的每个项目。由于您正在处理n“随机”项目,因此您将获得O(n log m) 的最佳速度。
二进制堆(基于数组)可能会为您提供该性能数字,但我现在不想写下堆,并认为这种替代方案的速度大致相同(如果不是更快的话):)
您的问题
既然我们已经确定了我们要讨论的内容,那么让我们来看看手头的事实吧。我将在每一步中解释这一点。
首先,在处理性能方面的问题时,我养成了删除using System.Linq 的习惯,因此我们知道我们只是在处理具有预期特征的本机数据结构。
让我们从树形结构开始
另一个简单的解决方案是使用红黑树。我们在 .NET 中有一个供我们使用,称为 SortedSet。它使用引用、算术等——这基本上是我在 (1)、(2) 和 (3) 中警告过的所有讨厌的东西。现在,这里的实现中存在错误(对于重复),但速度几乎是您所期望的:
static void Main(string[] args)
{
Random rnd = new Random(12839);
SortedSet<int> list = new SortedSet<int>();
for (int i = 0; i < 5000; ++i)
{
list.Add(rnd.Next());
}
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < 10000; ++i)
{
for (int j = 0; j < 5000; ++j)
{
list.Add(rnd.Next());
}
int n = 0;
list.RemoveWhere((a) => n++ < 5000);
}
Console.WriteLine(sw.ElapsedMilliseconds);
Console.ReadLine();
}
与此处的几乎所有算法一样,此算法在 O(n log m) 中执行。
我对 AVL 树的大致期望:86220 毫秒。
简单实现
通常我不会为红黑树而烦恼。不过,由于您在 AVL 树中投入了大量工作,我觉得有必要进行此测量。
当我对算法进行性能优化时,我总是从最容易实现的算法开始,该算法具有大约正确的 Big-O,并且总是更喜欢具有最简单数据结构的算法(读取:数组)。在这种情况下,它是一个与标准排序相结合的列表,它将为每个排序提供O(m log m),执行m/n 次和O(n) 数据操作。结果是O(n + n log m)。
那么,为什么要选择您可能会问的最简单的实现呢?答案很简单:简单的实现也很容易编译和优化,因为它们通常没有很多分支,不使用大量随机内存访问等。
最幼稚的实现(我已经在评论中建议过)是简单地将东西放入一个数组中,对其进行排序,然后删除它的下半部分。
基本上可以在最小的测试用例中这样实现:
static void Main(string[] args)
{
Random rnd = new Random(12839);
List<int> list = new List<int>();
for (int i = 0; i < 5000; ++i)
{
list.Add(rnd.Next());
}
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < 10000; ++i)
{
for (int j = 0; j < 5000; ++j)
{
list.Add(rnd.Next());
}
list.Sort((a, b) => a.CompareTo(b)); // #1
list.RemoveRange(0, 5000);
}
Console.WriteLine(sw.ElapsedMilliseconds);
Console.ReadLine();
}
基准性能:10047 毫秒。
优化一:移除方法调用和分支
方法调用需要时间。分支机构也是如此。因此,如果我们不需要分支,我们不妨将其消除。换句话说:这大约是 (5)。
想到的一件事是将#1替换为:
list.Sort((a, b) => a - b);
在大多数(!)场景中,这给出了预期的结果,我直言不讳地假设这种场景也不例外。
测量:8768 毫秒。(是的,人,这是 15% 的变化!)
为了好玩,我们还对 (2) 做了一个简单的测试:
list.Sort((a, b) => (int)((float)a - (float)b));
它与运算符的大小完全相同(32 位),它是完全相同的数据,并且会给出相同的结果——我们只是比较了所有具有不同 CPU 操作的东西并添加了一些强制转换。测量:10902 毫秒。如果每个操作都只是一个时钟滴答,这将超出您的预期。
优化2:数组还是列表?
我也可以关心列表本身;我们对它使用了很多调用,所以我们可以用它代替一个数组。如果我们反转排序顺序,我们甚至可以消除RemoveRange。那么我为什么不专注于这个呢?好吧,实际上我可以,但我可以告诉你这不会有太大的不同,因为相对而言,它并不经常被调用。不过,测试没有坏处,对吧?:
static void Main(string[] args)
{
Random rnd = new Random(12839);
int[] list = new int[10000];
for (int i = 0; i < 5000; ++i)
{
list[i] = rnd.Next();
}
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < 10000; ++i)
{
for (int j = 0; j < 5000; ++j)
{
list[j + 5000] = rnd.Next();
}
Array.Sort(list, (a, b) => b - a);
}
Console.WriteLine(sw.ElapsedMilliseconds);
Console.ReadLine();
}
现在,这里有两个测量值:
- 将列表更改为数组只是将其更改为 +/- 8700 毫秒 - 没有太大区别
- 颠倒顺序将结果更改为 7456 毫秒。
这并没有真正产生影响的原因是List 的底层数据结构是一个数组,所以如果我们正在排序,我们只是在做同样的事情。这就是我们的时间所在。
这里要记住的不是数组和List 一样快。事实是:我发现如果它们是,它们实际上在很多情况下更快。但是,在这种情况下,我们不是在讨论内部循环中的优化,我们不会过度分配过多的内存(可能所有内容都保存在缓存中)并且所有内存访问都是对齐的。总而言之,因此我们可以预期差异会非常小。
优化3:移除更多方法调用
现在,您应该注意到这里还有一个替代方案:方法调用会花费时间,而这里调用最多的是比较器。所以让我们回到List 的解决方案并删除比较操作。当我们这样做时,我们仍然必须复制。你期待什么?
将行改为:
list.Sort();
...我们有一个新的时间:4123 毫秒。
现在,公平地说,实际上我们在这里所做的是将内联委托更改为Comparer<int>.Default,这是整数比较器的默认实现。委托将被包装在一个比较器中,创建 2 个虚拟调用 - 这只是 1 个调用。这意味着我们也可以通过实现我们自己的比较器类来反转顺序,这将是一个更快的解决方案。
优化4:Merge-join
如果我们只需要对一半的数据进行排序,为什么还要对所有内容进行排序?这没有道理,对吧?
再次,我选择最简单的算法来完成任务。我们按顺序遍历列表,并按顺序存储新项目,c.f. (1) 和 (3)。没有交换,请记住我们更喜欢顺序数据访问模式。然后,我们只需删除所有不再需要的东西。
我们需要的算法是一个合并连接,它的工作原理是这样的:
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < 10000; ++i)
{
for (int j = 0; j < 5000; ++j)
{
list.Add(rnd.Next());
}
// Sort the second half:
list.Sort(5000, 5000, Comparer<int>.Default);
// Both the lower and upper half are sorted. Merge-join:
int lhs = 0;
int rhs = 5000;
while (list.Count < 15000)
{
int l = list[lhs];
int r = list[rhs];
if (l < r)
{
list.Add(l);
++lhs;
}
else if (l > r)
{
list.Add(r);
++rhs;
}
else
{
while (list.Count < 15000 && list[lhs] == l)
{
list.Add(l);
++lhs;
}
while (list.Count < 15000 && list[rhs] == r)
{
list.Add(r);
++rhs;
}
}
}
list.RemoveRange(0, 10000);
}
我们有一个新的测量值,它是 3563 毫秒。
优化 5:RemoveRange #2
请记住,突发处理数据非常快。最后一段代码是展示这一点的绝佳机会。我们在这里使用RemoveRange,它以突发方式处理数据。我们还可以使用两个缓冲区并交换它们。基本上,我们在合并连接期间写入第二个list2,并将RemoveRange 替换为:
list.Clear();
var tmp = list;
list = list2;
list2 = tmp;
我们现在有了一个新的计时:3542 毫秒。完全一样!
从最后两个您应该得出结论,执行突发操作花费的时间非常少,您通常甚至不应该费心。
结论
我从一棵在 86220 毫秒内执行所有操作的树开始,最终得到一个耗时 3542 毫秒的算法。直截了当,这意味着最后一个实现在第一次尝试的 4% 的时间内执行。
现在,我在这个冗长的答案中确实使用了不同的算法 - 但重点是向您展示如何进行所有这些优化以及优化的真正效果。