代码改变世界

算法与数据结构复习系列:开篇,快速排序

2011-10-06 20:50  starlightliu  阅读(1746)  评论(0)    收藏  举报

趁着国庆最后几天放假,又重新复习了一下一些常用算法。貌似不论是上学的时候,还是后来工作,总要抽空复习一下。

又动手写了一遍,发现很多算法原理即便已经很熟悉了,也很难一遍写对。哪怕一些原理很简单的也是如此。为什么算法就如此难写呢?这个系列就是记录一些常用的算法的实现,及一些在实现的过程中遇到的问题和容易犯的错误。

快速排序

快速排序是一个很好的例子,因为算法思想很简单,然而实现却比较复杂,不仅容易出错,而且因为递归的关系,调试困难。快速排序的算法思想如下:从一堆数字中任取一个数pivot,将小于等于pivot的放在左边,大于pivot放在右边,然后递归的操作左右两堆数字。

简单实现

按照快速排序的思想,可以很快的写出如下代码:

public static IEnumerable<T> QuickSort2<T>(IEnumerable<T> list) where T : IComparable<T>
{
    if (list.Count() <= 1) return list;
    var pivot = list.First();
    return QuickSort2(list.Where(x => x.CompareTo(pivot) < 0))
                   .Concat(list.Where(x => x.CompareTo(pivot) == 0))
                   .Concat(QuickSort2(list.Where(x => x.CompareTo(pivot) > 0)));

}

很简单也很优雅,而且贴合算法的思想,可惜这样的实现比一般的实现用了更多的时间和空间。也体现不出来一些问题。

常规解法

回到常规的解法。与之前不同,这里选择用数组作为容器。先定义函数,然后调用内部递归函数:

public static IEnumerable<T> QuickSort<T>(T[] arr) where T:IComparable<T>
{
    QuickSortCore(ref arr, 0, arr.Length - 1);
    return arr;
}

这里通常不会有什么问题,然后是递归函数的实现:

private static void QuickSortCore<T>(ref T[] arr,int left,int right) where T:IComparable<T>
{
    if (left < right)
    {
        var middle = Partition(ref arr, left, right);
        QuickSortCore(ref arr, left, middle-1);
        QuickSortCore(ref arr, middle + 1, right);
    }
}

递归函数忠实的呈现了算法的思想,先分组,然后递归排序左边和右边。这里有几个地方需要注意,首先是递归终止条件,由于left和right分别表示当前需要操作的数组第一个和最后一个元素,那么在left>=right的情况下不需要再次排序。接下来是QuickSortCore(ref arr, left, middle-1)这句话,一开始写的时候写成了QuickSortCore(ref arr, left, middle),结果有时候结果正常,有时候会抛出StackOverFlow,原因是当数组中有重复的数字的时候,会一直递归下去。其实按照算法的思想,取出的数x应该不会参与到下次的递归操作中。接下来是函数Partition的实现:

private static int Partition<T>(ref T[] arr, int left, int right) where T:IComparable<T>
{
    var pivot = left;
    while (left < right)
    {
        while (arr[left].CompareTo(arr[pivot]) <= 0 && left<=right && left<arr.Length-1)
        {
            left++;
        }
        while (arr[right].CompareTo(arr[pivot]) > 0 && right>=left && right>0)
        {
            right--;
        }
        if (left < right)
        {
            Swap(ref arr[left], ref arr[right]);
        }
    }
    Swap(ref arr[pivot], ref arr[right]);  
    return right;
}

Partition主要做的事情是选择某个pivot,这里选取第一数字作为pivot,可以选择其他数字,比如选取一个随机位置。但是不论选择哪个位置,都需要使这个位置的数字处于第一位置。比如:

var pivot = left;

可以改写成:

var rnd = new Random();
var pivot = rnd.Next(left, right);
Swap(ref arr[pivot], ref arr[left]);
pivot = left;

这样做原因是使选择的数字不会参与到后面的交换操作中。接下来做的事情是使left不断前进,直到找到某个数字大于pivot,使right不断后退,直到找到某个数字小于pivot,然后交换left和right所指向的数字,这样做的目的是使left经过的数字都小于pivot,right经过的数字都大于pivot,直到left>right。 以3(pivot),1,2(right),5(left),4为例,pivot指向3,left指向5,right指向2,此时left>right。交换pivot和right所指向的数字后,变成2(pivot),1,3(right),5(left),4。此时right左边的数字小于right指向的数字,right右边的数字大于right指向的数字,right成为新的中点,返回right。Partition基本逻辑不复杂,但是有很多边界条件值得,比如left<arr.Length-1,right>0,漏掉这两条有些情况下会抛出IndexOutOfRange异常。left<=right和right>=left并非十分必要,主要是为了避免多余的操作。

对比

比较一下两种解法的性能:

[Test]
public void QuickSortTest()
{
    var arr = new int[10];
    var rnd = new Random();
    for (int i = 0; i < arr.Length; i++)
    {
        arr[i] = rnd.Next(100);
    }
    CodeTimer.Initialize();
    CodeTimer.Time("quickSort", 100000, () => Algorithm.QuickSort(arr));
    CodeTimer.Time("quickSort2", 100000, () => Algorithm.QuickSort2(arr));
}

quickSort
Time Elapsed: 7ms
CPU Cycles: 20,516,435
Gen 0: 0
Gen 1: 0
Gen 2: 0

quickSort2
Time Elapsed: 142ms
CPU Cycles: 396,313,212
Gen 0: 12
Gen 1: 0
Gen 2: 0

和预期一样,QuickSort2不论是时间和空间都比QuickSort要差很多。

总结

quickSort2实现很简单,也不容易犯错,回避了很多细节的问题,但是性能上损失很严重。QuickSort由于是用数组来实现的,解决问题的难点从算法思想的实现转到了如何正确的交换数组的数据,这中间,稍不小心,就会漏掉边界条件的检查,而且由于算法本身是递归的,很多问题难以发现,即便发现了,也很难找出问题所在。这些大大加深了算法的实现难度,这也是为什么很多算法想想容易,实现起来却很困难的原因。以上结论皆是个人根据测试结果的猜测,如有错误,欢迎指出。

标签: