C# 归并排序性能优化

9

只是一个快速的提醒,这不是作业。我只是想要磨练一下我的算法。我正在使用C#玩耍MergeSort,并编写了一个递归方法,可以根据泛型进行排序:

class SortAlgorithms
{

    public T[] MergeSort<T> (T[] unsortedArray) where T : System.IComparable<T>
    {
        T[] left, right;
        int middle = unsortedArray.Length / 2;

        left = new T[middle];
        right = new T[unsortedArray.Length - middle];

        if (unsortedArray.Length <= 1)
            return unsortedArray;

        for (int i = 0; i < middle; i++)
        {
            left[i] = unsortedArray[i];
        }

        for (int i = middle; i < unsortedArray.Length; i++)
        {
            right[i - middle] = unsortedArray[i];
        }

        left = MergeSort(left);

        right = MergeSort(right);


        return Merge<T>(left, right);
    }

    private T[] Merge<T> (T[] left, T[] right) where T : System.IComparable<T>
    {
        T[] result = new T[left.Length + right.Length];

        int currentElement = 0;

        while (left.Length > 0 || right.Length > 0)
        {
            if (left.Length > 0 && right.Length > 0)
            {
                if (left[0].CompareTo(right[0]) < 0)
                {
                    result[currentElement] = left[0];
                    left = left.Skip(1).ToArray();
                    currentElement++;
                }
                else
                {
                    result[currentElement] = right[0];
                    right = right.Skip(1).ToArray();
                    currentElement++;
                }
            }
            else if (left.Length > 0)
            {
                result[currentElement] = left[0];
                left = left.Skip(1).ToArray();
                currentElement++;
            }
            else if (right.Length > 0)
            {
                result[currentElement] = right[0];
                right = right.Skip(1).ToArray();
                currentElement++;
            }
        }

        return result;
    }
}

这个功能可以运行,但是速度太慢了。我使用System.Diagnostic.StopWatch对性能进行了检查,并与使用QuickSort算法的Array.Sort进行了比较,以便与我的MergeSort进行比较,结果差别很大,我想知道是否可能是我实现有误。有什么建议吗?


1
你读过Jon的文章吗?http://msmvps.com/blogs/jon_skeet/archive/2011/01/06/reimplementing-linq-to-objects-part-26c-optimizing-orderedenumerable.aspx - Tim Schmelter
你尝试过不使用泛型的相同实现吗? - pfries
伙计们的回答很棒。抱歉回复这么晚,我一直在重写代码,最终得到的代码几乎与Rafe建议的一模一样。速度大大提升,但仍然比本地的Array.Sort慢得多。还在进行一些尝试。 - hobeau
4个回答

12
我不是C#程序员,但问题可能出在像这样的语句上吗?
left = left.Skip(1).ToArray();

这可能是通过强制底层数组进行深拷贝的方式实现的。如果是这样的话,这将使得合并的性能从O(n)下降到O(n2),从而导致结果归并排序的性能立即从O(n log n)下降到O(n2)。(这是因为递归从T(1) = O(1)和T(n) ≤ 2T(n / 2) + O(n)的情况变成了T(1) = O(1)和T(n) ≤ 2T(n / 2) + O(n2)的情况。前者的解为O(n log n),后者的解为O(n2)。)

1
它没有实现深拷贝,但它确实会创建一个新数组(使用浅拷贝)。不确定它如何更改边界,但它仍然不是理想的。 - user166390
1
@pst- 如果这会创建一个新的数组并浅复制元素,那么这也会导致性能下降,因为浅复制仍然需要 O(n) 的时间来完成。 - templatetypedef
谢谢您的澄清,我不确定这是否是深拷贝的一个方面。 - user166390
+1. 我认为根本不需要使用 ToArray() - 移除 ToArray 调用并将 left/right 设为 IEnumerator(手动调用 Current/MoveNext)可以使代码更快,并保持其时髦的 LINQ 感觉。 - Alexei Levenkov
@AlexeiLevenkov 这里之所以“需要”它,是因为目前该方法的设置方式(不过我同意这是个问题 ;-)。但通常情况下,人们会传递具有虚拟左/右分割的相同数组(例如 C 风格的实现),或者使用类似 ArraySegment 的东西。 - user166390

2

您不断地在形式为中间数组的内存中进行分配。考虑重新利用原始数组。


1

正如其他两个答案所说,您正在创建新的数组,浪费了大量时间和内存(我猜测,大部分时间和几乎所有内存使用都是如此)。

再次强调一下,所有其他条件相等的情况下,递归往往比迭代慢,并且使用更多的堆栈空间(甚至可能在问题足够大时导致溢出,而迭代则不会)。

然而,归并排序非常适合多线程处理,因为您可以让不同的线程处理第一批分区的不同部分。

因此,如果我在处理这个问题,我的下两个实验将是:

  1. 对于分区的第一部分,我将启动一个新线程,而不是递归调用MergeSort,直到我有一个线程每个核心运行(无论我是否应该在物理核心或超线程的情况下执行它,这本身就是我要尝试的东西)。
  2. 完成上述操作后,我将尝试重新编写递归方法,以在没有递归调用的情况下执行相同的操作。

在处理完ToArray()问题之后,采用多线程方法首先将工作分配给最优数量的核心,然后让每个核心迭代地完成其工作,这种方法可能非常有趣。


1
与其处理是否要生成新线程,您可以只创建一个新的“任务”并将其全部放入线程池中。线程池的大小可能会调整为大约等于物理处理器的数量。 - Servy
1
根据我的经验,在iCore 5/7系列上,使用1.5-2个最大线程每核心的合并排序(尽管在Windows上的JVM上)效果最佳(不要低估进程窃取;-))。此外,对于小的n(叶子和边缘分支),不应使用线程,并且切换到非合并排序以优化边缘是一种“常见优化”。 - user166390
@JonHanna 物理(虚拟?)核心。i5 没有超线程。不确定所有适用的因素,但限制生成的线程数量到硬件本地处理能力的“合理小”比率是有优势的(最好使用线程池/回收)。 - user166390
1
@Servy,虽然将相邻的分区交给同一个线程处理有优势,但是除非你有一个低于任务切换到完成工作而不是创建更多任务的限制,否则击中固定数量的线程比创建新任务更容易。最佳点在哪里是一个有趣的问题。 - Jon Hanna
@Servy 真是太有趣了。如果我继续思考这个问题,最后可能会得到一个多线程的归并排序,而我本来想要享受一次温暖的浴缸和一本小说的 ;) - Jon Hanna
显示剩余2条评论

0

首先,这里有一个链接,提供了一个类似问题的简化解决方案:Java归并排序,合并步骤应该使用队列还是数组?

你的解决方案很慢,因为你反复分配新的子数组。内存分配比大多数其他操作都要昂贵得多(你有分配成本、收集成本和缓存局部性损失)。通常这不是问题,但如果你试图编写一个紧凑的排序程序,那么它就很重要了。对于归并排序,你只需要一个目标数组和一个临时数组。

分叉线程以并行化仍然比那更昂贵几个数量级。所以,除非你有大量数据需要排序,否则不要分叉。

正如我在上面的答案中提到的,加速归并排序的一种方法是利用输入数组中的现有顺序。


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