List.Sort以及快速排序

本文详细探讨了使用.NET处理大量数据集合时性能与优化的问题,通过对比内置的List排序方法与自定义快速排序算法,揭示了元素访问效率对性能的影响,并提出在特定场景下实现优化版本的可能性。
经常看到有人因为使用.net中的集合类处理海量数据时性能不够理想,就武断的得出.net不行,c#也不行这样的结论。对于.net framework这样的类库来说,除了性能以外,通用性和安全性同样重要,而为了后者,有时就不得不牺牲性能。如果你的程序核心就是处理大量数据集合,并且对.net内置类库性能不满意,那么这时候就应该考虑为特定类型实现一个优化的版本了。 

      事情的由来是我需要对若干个(<10)集合进行排序,每个集合中的元素不会超过2k,老实说,所要处理的数据并不多,但我希望在1ms之内完成所有操作,这就成了挑战。也许有人觉得1ms的要求有些苛刻,但对我的应用来说,1ms已经很些奢侈了. 所要处理的数据非常简单,大部分都是一些数据集对象,每个对象都有一个uint类型的id,并以此为key进行排序:
  1. public class KeyValuePair
  2. {
  3.     public uint id;
  4.     public string s = string.Empty;
  5.     public float d;
  6.     public double c;
  7. }
复制代码
集合大小是不固定的,List自然就是最合适的容器,并且有现成的方法用于排序List.Sort()。我使用了以Comparison委托为参数的版本,并定义了如下函数:
  1. public static int Compare(KeyValuePair v1,KeyValuePair v2)
  2. {
  3.     return v1.id.CompareTo(v2.id);
  4. }
复制代码
我知道,这是一个非常不规范的Comparison定义,应该先检查对象是否为null等等。不过这里只是 测试而已,我只想看看List.Sort最快能到什么程度。测试数据为一个有5k元素的List,和5个每个有1k元素的List。通过多次测试,以减少噪音干扰,结果都在2~3ms之间,令人印象非常深刻,足以满足大部份应用的需求。可惜对我的目标来说,还是差了一点。

      于是我决定自己写一个函数。List.Sort内部使用了Array.Sort,而后者的实现则是传统快速排序(quicksort)算法。在众所周知的几种排序算法中,quicksort是平均情况下最好的,因此我将仍旧使用这一算法,只是删除List.Sort内一些不必要的检查。代码如下:
  1. public static void QuickSort(List<KeyValuePair> list, int start,int end)
  2. {
  3.     if (end <= start)
  4.         return;

  5.     int pivotIndex = FindPivot(list, start, end);
  6.     swap(list, pivotIndex, end);

  7.     int k = Partition(list, start, end, list[end].id);

  8.     swap(list, k, end);
  9.     
  10.     QuickSort(list, start, k - 1);
  11.     QuickSort(list, k + 1, end);
  12. }
  13. public static int Partition(List<KeyValuePair> list, int start, int end, uint piovtValue)
  14. {
  15.     start--;
  16.     while (true)
  17.     {

  18.         do { start++; }
  19.         while (list[start].id < piovtValue);

  20.         if (start > end)
  21.             break;

  22.         do { end--; }
  23.         while (end > 0 && list[end].id > piovtValue);

  24.         if (start > end)
  25.             break;

  26.         swap(list, start, end);
  27.     }
  28.     return start;
  29. }

  30. public static int FindPivot(List<KeyValuePair> list, int start, int end)
  31. {
  32.     int a = (int)list[start].id;
  33.     int b = (int)list[end].id;
  34.     int middle = (start+end)/2;
  35.     int c = (int)list[middle].id;

  36.     if ((a - b) * (a - c) < 0)
  37.         return start;
  38.     if ((b - a) * (b - c) < 0)
  39.         return end;

  40.     return middle;
  41. }

  42. public static void swap(List<KeyValuePair> list, int a, int b)
  43. {
  44.     KeyValuePair temp = list[a];
  45.     list[a] = list;
  46.     list = temp;
  47. }
复制代码
这里并没有使用任何特别的技巧,几乎是按数据结构书中的例子照搬过来。它的性能如何呢?以下是测试结果:

list size    list.sort    myQuickSort
5k            2ms            4ms
10k            6ms            5ms
100k          58ms          26ms
200k          132ms          58ms

    非常有趣,当列表元素小于10k时,list.sort比myQuickSort快,而随着元素的增加,myQuickSort将快2~4倍。第二种情况是意料之中的,用reflector查看list.sort的源码就能发现,我的quickSort实现显然要简洁很多。但为什么当元素不多时,会出现如此明显的反差呢?起初我尝试用DotTrace分析两个函数所执行的时间,不幸的是由于此时数据太少,排序执行的太快,DotTrace分析的结果是完全错误的: 总是显示quickSort比list.sort所用的时间少。看来只有查看IL了,于是以下代码引起了我的注意:

IL_0002:  callvirt  instance !0 class [mscorlib]System.Collections.Generic.List`1<class Console4Test.KeyValuePair>::get_Item(int32)

      这是swap函数中,访问列表元素所产生的代码。过去,我一直认为访问list元素和访问数组元素是相同的,此时,我开始有所怀疑了。居然出现了callvirt这样的指令,虽然在IL中,callvirt并不意味着一定是虚函数调用,reflector也证明List.get_item并不是一个虚方法。直觉告诉我应该对这行代码深究下去,看看JIT究竟把它编译为了什么样的指令:
  1. swap(list, k, end);
  2. 000000c7  cmp        eax,dword ptr [esi+0Ch] 
  3. 000000ca  jb          000000D1 
  4. 000000cc  call        787E9A3C 
  5. 000000d1  mov        eax,dword ptr [ebp-14h] 
  6. 000000d4  mov        edx,dword ptr [esi+4] 
  7. 000000d7  cmp        eax,dword ptr [edx+4] 
  8. 000000da  jae        0000015E 
  9. 000000e0  mov        eax,dword ptr [edx+eax*4+0Ch] 
  10. 000000e4  mov        dword ptr [ebp-20h],eax 
  11. 000000e7  cmp        edi,dword ptr [esi+0Ch] 
  12. 000000ea  jb          000000F1 
  13. 000000ec  call        787E9A3C 
  14. 000000f1  mov        eax,dword ptr [esi+4] 
  15. 000000f4  cmp        edi,dword ptr [eax+4] 
  16. 000000f7  jae        0000015E 
  17. 000000f9  mov        eax,dword ptr [eax+edi*4+0Ch] 
  18. 000000fd  mov        dword ptr [ebp-24h],eax 
  19. 00000100  mov        eax,dword ptr [ebp-14h] 
  20. 00000103  cmp        eax,dword ptr [esi+0Ch] 
  21. 00000106  jb          0000010D 
  22. 00000108  call        787E9A3C 
  23. 0000010d  mov        ecx,dword ptr [esi+4] 
  24. 00000110  push        dword ptr [ebp-24h] 
  25. 00000113  mov        edx,dword ptr [ebp-14h] 
  26. 00000116  call        78F1B384 
  27. 0000011b  inc        dword ptr [esi+10h] 
  28. 0000011e  cmp        edi,dword ptr [esi+0Ch] 
  29. 00000121  jb          00000128 
  30. 00000123  call        787E9A3C 
  31. 00000128  mov        ecx,dword ptr [esi+4] 
  32. 0000012b  push        dword ptr [ebp-20h] 
  33. 0000012e  mov        edx,edi 
  34. 00000130  call        78F1B384 
  35. 00000135  inc        dword ptr [esi+10h]
复制代码
这段代码确实让人有些惊奇。首先,swap函数被内联了,这正是我们所希望的;其次,list元素访问也被正确内联了,没有发生我们之前担心的函数调用。这里确实有几条call指令,不过这是在发生异常才会调用的地址。另外,虽然这里没有列出,但值得一提的是FindPivot中的list元素访问则没有内联,每次访问list元素都意味着执行一次函数调用以及这个函数中的16条汇编指令。 最后,2个简单的list元素交换竟然产生了30条以上的汇编代码,我想这也是所有人所料未及的。

      看来list元素访问确实是一个潜在的问题。为了证明这一点,我把quickSort中,所有list都改为了array。再次测试,果然,我自己的版本无论在任何情况下都比list.sort快,同时,也比array.sort快。这里不再列出实际的测试数据,只贴出array版本的swap汇编代码:
  1.             swap(list, k, end);
  2. 00000070  cmp        eax,dword ptr [esi+4] 
  3. 00000073  jae        000000BE 
  4. 00000075  mov        eax,dword ptr [esi+eax*4+0Ch] 
  5. 00000079  mov        dword ptr [ebp-1Ch],eax 
  6. 0000007c  mov        ecx,dword ptr [esi+edi*4+0Ch] 
  7. 00000080  mov        eax,dword ptr [ebp-10h] 
  8. 00000083  lea        edx,[esi+eax*4+0Ch] 
  9. 00000087  call        78F11F98 
  10. 0000008c  push        dword ptr [ebp-1Ch] 
  11. 0000008f  mov        edx,edi 
  12. 00000091  mov        ecx,esi 
  13. 00000093  call        78F1B384 
复制代码
可以看到,代码减少为了原来的1/3。两个call同样是.net内部的一些安全检查代码。

        好了,现在知道我的代码慢在哪里了,但这并不能解释list.sort为什么在元素少的时候比较快,难道它不受list元素访问效率的影响吗?是的,list本身并不会受到自身元素访问 机制的影响,因为他调用Array.sort时,传递的是内部储存的私有元素数组成员,而不是他自己。因此,可以猜测,当元素较少时,排序算法执行的非常快,此时,元素访问方式的不同,就成了明显的瓶颈,而当处理元素较多时,大部分时间都用在排序上,元素访问的代价则逐渐变小。


以上手写的quickSort方法还能进一步优化吗?显然是可以的:

1,当quickSort中的分组元素小于10时,改用插入排序,可以带来大约5~10%的性能提升:
  1. public static void QuickSort(List<KeyValuePair> list, int start,int end)
  2. {
  3.     if (end <= start)
  4.         return;

  5.     int pivotIndex = FindPivot(list, start, end);
  6.     swap(list, pivotIndex, end);

  7.     int k = Partition(list, start, end, list[end].id);

  8.     swap(list, k, end);

  9.     if (k - start <= 10)
  10.         InsertSort(list, start, k - 1);
  11.     else
  12.         QuickSort(list, start, k - 1);

  13.     if (end - k - 1 <= 10)
  14.         InsertSort(list, start, k - 1);
  15.     else
  16.         QuickSort(list, k + 1, end);
  17. }
复制代码
2,把FindPivot函数手动内两到QuickSort中。
3. 用栈模拟递归,本人不是太推荐这种做法。

4. 也许还能用指针优化关键操作,不过似乎c#不允许对reference type使用指针L

小结:

在极端性能要求下,需要对元素进行排序时:

当n< ~5k时,用array代替list,或者为array写一个简单的wrapper,并且自己实现sort;或者直接使用list.sort

当 n > 10k时, 实现自己的sort方法,至少能得到2~4倍的提速。
<think> 嗯,我现在需要解决用户的问题:了解C#中Array.Sort方法的使用和排序原理。首先,我需要仔细分析用户提供的引用内容,然后结合自己的知识来整理答案。 首先,用户引用了5个来源。引用1、2、3都提到Array.Sort()的简单使用,比如对一维数组排序,比冒泡排序更方便,只需要一行代码。引用2和3还展示了代码示例,包括Array.Sort和Array.Reverse的使用。引用4提到对二维数组排序的方法,用户可能尝试用Array.Sort处理二维数组。引用5则涉及Lambda表达式在Array.SortList.Sort中的应用,特别是自定义排序规则,比如按多个字段排序。 接下来,用户的问题分为两部分:使用方法和排序原理。使用方法部分需要涵盖基本的一维数组排序、自定义排序(如Lambda)、二维数组排序。排序原理部分需要解释底层算法,如内省排序(IntroSort)和可能用到的插入排序等优化。 关于使用方法: 1. 一维数组排序:直接调用Array.Sort(arr),如引用2和3所示。需要说明升序排列,结合Reverse实现降序。 2. 自定义排序:引用5中的Lambda表达式,例如对对象的多个属性排序。比如先按score降序,再按id升序。这里要注意委托的返回值规则(负数、零、正数)。 3. 二维数组排序:引用4提到对二维数组按特定列排序。这里需要说明如何通过传递自定义比较器来实现,比如比较第二列元素,然后交换整行数据。代码示例可能需要调整,因为引用4的代码是用户自己写的,可能存在错误,需参考正确写法。 关于排序原理: 根据微软文档,Array.Sort在不同情况下使用不同算法。对于原始类型,可能用内省排序(结合快排、堆排和插入排序),而引用类型使用稳定排序(如归并排序)。需要解释内省排序如何避免快排的最坏情况,以及插入排序在小数组中的应用。此外,自定义比较器会影响稳定性,如引用5中提到的,使用Lambda时可能变为不稳定排序。 还需要注意引用3中的Array.Reverse用法,它在排序后反转数组实现降序,但效率可能不如直接使用自定义比较器降序排列。 可能的问题点: - 用户提到的二维数组排序,引用4中的代码是否正确?例如,使用Array.Sort<T>(T[] array, IComparer<T> comparer)并传入自定义比较器,比较器实现按列比较。需要确认代码是否有效,比如传递二维数组和比较第二列的比较器。 - 稳定性问题:引用5指出使用Lambda时,Array.Sort对引用类型不稳定,而List.Sort稳定。需要核实文档,因为通常Array.Sort对于引用类型使用稳定排序,但自定义比较器可能影响这一点,可能用户存在误解,需进一步确认官方说明。 最后,生成相关问题需要覆盖使用场景、优化、原理比较等,如多维数组排序、稳定性影响、性能分析等。 </think>以下是关于C#中`Array.Sort`方法的使用和排序原理的详细说明: --- ### **一、使用方法** #### 1. **基本排序(一维数组)** ```csharp int[] nums = { 2, 652, 32, 1, 6, 65 }; Array.Sort(nums); // 升序排序 // 输出:1, 2, 6, 32, 65, 652 ``` #### 2. **降序排序** ```csharp Array.Sort(nums); Array.Reverse(nums); // 先升序再反转实现降序 // 或使用Lambda表达式直接降序 Array.Sort(nums, (a, b) => b.CompareTo(a)); ``` #### 3. **自定义对象排序** ```csharp class Student { public int Id { get; set; } public int Score { get; set; } } Student[] students = new Student[] { /*...*/ }; // 按Score降序,Id升序 Array.Sort(students, (a, b) => { int temp = b.Score.CompareTo(a.Score); return temp != 0 ? temp : a.Id.CompareTo(b.Id); }); ``` #### 4. **二维数组按列排序** ```csharp int[,] matrix = { {3, 9}, {1, 4}, {5, 2} }; // 转换为交错数组后排序 int[][] jagged = matrix.Cast<int>() .Select((x, i) => new { x, index = i / 2 }) .GroupBy(x => x.index) .Select(g => g.Select(x => x.x).ToArray()) .ToArray(); Array.Sort(jagged, (a, b) => a[1].CompareTo(b[1])); // 按第二列升序 ``` --- ### **二、排序原理** 1. **算法选择**: - 对于**原始类型**(如`int`, `double`),采用**内省排序(IntroSort)**[^1],结合: - **快速排序**(分区处理) - **堆排序**(递归深度过大时避免最坏情况) - **插入排序**(小数组优化,元素数≤16时) - 对于**引用类型**,使用**稳定排序**(如归并排序)[^5]。 2. **性能特点**: - **时间复杂度**:平均 $O(n \log n)$,最坏 $O(n^2)$(快速排序分区失衡时)。 - **空间复杂度**:$O(\log n)$(递归栈)。 3. **自定义比较器的影响**: - 使用Lambda或`IComparer`时,排序稳定性取决于实现: - 值类型数组:不稳定(可能改变相等元素的顺序)。 - 引用类型数组:稳定(保留相等元素的原始顺序)[^5]。 --- ### **三、关键注意事项** 1. **原地排序**:直接修改原数组,无返回值。 2. **多维数组限制**: - 需先转换为**交错数组**(`int[][]`)再排序[^4]。 3. **边界处理**: - 空数组或单元素数组直接返回。 - 包含`null`的引用类型数组:`null`排在末尾。 --- ### **示例代码(自定义排序)** ```csharp // 按字符串长度排序 string[] words = { "apple", "cat", "banana" }; Array.Sort(words, (x, y) => x.Length.CompareTo(y.Length)); // 输出:["cat", "apple", "banana"] ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值