代码改变世界

Sorting[翻译]

2007-03-05 16:38  老博客哈  阅读(1297)  评论(2编辑  收藏  举报

                                                                                                      Sorting
       
                                      【原文见:http://www.topcoder.com/tc?module=Static&d1=tutorials&d2=sorting
                                                                                                                                                     作者 By timmac
                                                                                                                                                      TopCoder Member
                                                                                                                                                       翻译    农夫三拳@seu
                                                                                                                                                       drizzlecrj@gmail.com
    许多实际应用的软件在计算时都需要计算的对象是有序的。甚至在开始计算之前,排序的重要性已经摆在我们面前了。从在集体照相的时候需要最高的人站在后面到在圣诞节获得最高收益的商人,把对象按从小到大或者从第一个到最后一个进行排列的需要不能被忽视。

    当我们查询一个数据库的时候,在后面加上一个ORDER BY的子句,其实就是排序。当我们在电话簿中找一个条款时,我们处理的是一个已经排好序的列表。(想想看如果没有排序的话!)如果你需要使用二分查找在一个数组中高效查找,首先将数组进行排序是很必要的。当一个问题要求我们在答案相同的情况下按照字典序返回第一个结果,好的...,你知道这个意思了。

General Considerations
    考虑有一群人,给他们每一个人一叠打乱过的扑克牌,并且需要它们将这些扑克牌以升序的方式进行排序。一些人可能开始的时候放成一堆堆的,而其他人可能将扑克牌在桌子上铺开,而仍然有一些人在他们的手里玩这些扑克牌。这个练习可能需要几秒,对某些人可能几分钟或者更长时间。一些人在完成之后的扑克牌中,黑桃的总是在红桃之前,在其他的人当中或许根本没有组织。基本上,这些就是算法家们争论的不同排序算法的正反面的要点。

    当比较不同的排序算法时,需要考虑许多事情。第一个通常是运行时间。当处理不断增长的大量数据时,应用中实际使用的低效算法通常会运行的很慢。

    第二个要考虑的是内存使用。快速的算法通常需要递归调用,不可避免的包含了待排序的数据的拷贝。在某些环境下,内存空间可能很重要(比如在嵌入式系统中),特定的算法可能并不实际。在某些情况下,也许可以将算法修改成“在原地”进行的,而不需要创建数据的拷贝。尽管如此,这个修改也许会损失一些性能。

    第三个要考虑的是稳定性。稳定性,简单的定义,元素的相对位置不变。在一个稳定的排序中,那些比较关键字相同的元素在排序后的相对位置和排序前是一样的。在一个不稳定排序中,那些比较关键字相同的元素不能保证排序后的相对位置。

Bubble Sort
    第一个教给学生的排序算法是冒泡排序。虽然它在实用中对一些较小的数据集合比较快而并不是对于所有的都快,但是它能够展示一个排序算法是怎样工作的。一般来说,它看起来像下面这样:

for (int i = 0; i < data.Length; i++)
   
for (int j = 0; j < data.Length - 1; j++)
      
if (data[j] > data[j + 1])
      
{
         tmp 
= data[j];
         data[j] 
= data[j + 1];
         data[j 
+ 1= tmp;
      }

   思路是从数据的一端到另外一端,通过当第一个数字大于后一个的时候,交换两个相邻的元素。因此,最小的元素将会“冒泡”到表面。这个算法是O(n^2)的运行时,因此对于大的数据集合很慢。冒泡排序最好的优点,是它非常易于理解并且编码。此外,它是一个稳定的排序,不需要额外的内存,因为所有的交换都在原地进行的。

Insertion Sort
    插入排序是一个每次将一个元素进行排序的算法。在每一次迭代的过程中,它取得待排序的元素,然后将它放到那些已经排好序的数中合适的位置。

for (int i = 0; i <= data.Length; i++{
   
int j = i;
   
while (j > 0 && data[i] < data[j - 1])
      j
--;
   
int tmp = data[i];
   
for (int k = i; k > j; k--)
      data[k] 
= data[k - 1];
   data[j] 
= tmp;
}

外层循环每次循环的数据应该像下面这样:

{18,  6,  9,  1,  41512,  5,  6,  711}
618,  9,  1,  41512,  5,  6,  711}
6,  918,  1,  41512,  5,  6,  711}
1,  6,  918,  41512,  5,  6,  711}
1,  4,  6,  9181512,  5,  6,  711}
1,  4,  6,  9151812,  5,  6,  711}
1,  4,  6,  9121518,  5,  6,  711}
1,  4,  5,  6,  9121518,  6,  711}
1,  4,  5,  6,  6,  9121518,  711}
1,  4,  5,  6,  6,  7,  912151811}
1,  4,  5,  6,  6,  7,  911121518}

    插入排序的一个优点是它对于初始几乎排好序的列表非常高效。更进一步的,它能够工作在不断增加元素的集合上。例如,如果一个人想要维护一个比赛中达到的最高分的排好序的列表,插入排序这是将会工作的很好,因为随着比赛的进行,新的元素将会加入到元素中。

Merge Sort
    归并排序是递归的运行的。首先将数据集合划分成一半,然后分别对每一部分进行排序。然后,两个列表的第一个元素将进行比较。较小的元素从它的列表中移除并加入到最终结果的列表中。

int[] mergeSort (int[] data) {
   
if (data.Length == 1)
      
return data;
   
int middle = data.Length / 2;
   
int[] left = mergeSort(subArray(data, 0, middle - 1));
   
int[] right = mergeSort(subArray(data, middle, data.Length - 1));
   
int[] result = new int[data.Length];
   
int dPtr = 0;
   
int lPtr = 0;
   
int rPtr = 0;
   
while (dPtr < data.Length) {
      
if (lPtr == left.Length) {
         result[dPtr] 
= right[rPtr];
         rPtr
++;         
      }
 else if (rPtr == right.Length) {
         result[dPtr] 
= left[lPtr];
         lPtr
++;
      }
 else if (left[lPtr] < right[rPtr]) {
         result[dPtr] 
= left[lPtr];
         lPtr
++;
      }
 else {
         result[dPtr] 
= right[rPtr];
         rPtr
++;         
      }

      dPtr
++;
   }

   
return result;
}

    每一个递归调用是O(n)的运行时,并且需要O(logn)次的递归调用,因此这个算法的时间复杂度为O(n*logn)。归并排序同样在初始将近排好序的列表上有着很好的性能。在对每一个部分进行排序后,如果一个列表中的最大元素小于另外一半的最小元素,那么合并的步骤就不需要了。(例如,Java API实现了这个特殊的优化)数据,随着过程不断的递归调用,可能像下面这样:

{186914151256711}
{186914} {151256711}
{186} {914} {15125} {6711}
{18} {6} {9} {14} {15} {125} {6} {711}
{18} {6} {9} {1} {4} {15} {12} {5} {6} {7} {11}
{18} {6} {9} {14} {15} {512} {6} {711}
{618} {149} {51215} {6711}
{146918} {567111215}
{145667911121518}

    除了很高效之外,归并排序具有另外一个优点,它可以用来解决其他问题,例如决定一个给定列表的“没排序”程度。

Heap Sort
    在堆排序中,我们创建了一个堆数据结构。堆是用来以树的形式进行数据存储的数据结构,最小的元素总是在根节点处。
(堆,和优先队列一样,在Data Structures中讲述了它们更多的细节。)为了进行堆排序,所有的列表中的数据都被插入到了堆当中,并且根节点的元素不断的被移除,并放在列表的最后。由于根节点总是最小的元素,因此结果是一个排好序的列表。如果你已经有了一个可用的堆实现或者你使用Java PriorityQueue(1.5版本中可用的),执行一个堆排序的代码很短:

Heap h = new Heap();
for (int i = 0; i < data.Length; i++)
   h.Add(data[i]);
int[] result = new int[data.Length];
for (int i = 0; i < data.Length; i++)
   data[i] 
= h.RemoveLowest();

   堆排序的上界范围是O(n*logn)。存储空间仅需要存储堆的部分;这个大小和列表的大小成线性关系。堆排序的缺点是不稳定,并且它在基本实现中稍微有点复杂且不易理解。

Quick Sort
    快速排序,正如它的名字,是一个高效的排序算法。它背后的理论和人类排序的方式很接近。首先,将书籍分为两个组,“高”
值和“低”值。然后,递归处理这两半。最后重新组合成现在的排序好的列表。

    Array quickSort(Array data) {
   
if (Array.Length <= 1)
      
return;
   middle 
= Array[Array.Length / 2];
   Array left 
= new Array();
   Array right 
= new Array();
   
for (int i = 0; i < Array.Length; i++)
      
if (i != Array.Length / 2{
         
if (Array[i] <= middle)
            left.Add(Array[i]);
         
else
            right.Add(Array[i]);
      }

   
return concatenate(quickSort(left), middle, quickSort(right));
}

    快速排序的挑战是为划分成两个组而挑选的合理的“中间”值。算法的效率完全依赖于成功找到一个准确的中值。在最好的情况下,这个算法的时间复杂度为O(n*logn)。在最坏的情况下,两个分组中总是有一个只要一个元素,运行时就变为了O(n^2)。真实排序的元素可像下面这样工作:

{186914151256711}
{69141256711} {15} {18}
{691456711} {12} {15} {18}
{14} {5} {696711} {12} {15} {18}
{1} {4} {5} {6} {6} {9711} {12} {15} {18}
{1} {4} {5} {6} {6} {7} {911} {12} {15} {18}
{1} {4} {5} {6} {6} {7} {9} {11} {12} {15} {18}

    如果待排序的数据在一定的范围之内,或者满足特定的分布模型,这将有助于通过选择能够把数据均等的分成两半的中间值来提高算法的效率。一般通常不考虑数据类型或者值的范围的算法可以简单的从一个没排序的列表中挑选一个值,或者使用一些随机方法来得到中间值。

Radix Sort
    基数排序最初是通过不进行元素之间的两两对比而来对数据排序。基数排序首先取得最低有效位(或者多个数字,或者位),然后将这些值放入桶中。如果我们一次去4个位,那么我们需要16个桶。我们接着将列表的最后串在一起,可以的到一个按照最低有效位进行排序的结果列表了。我们继续执行相同的过程,这次使用第二个最低有效位。我们不断的重复直到使用了最高有效位,这是的结果就是一个排好序的列表了。

    例如,让我们看下面的数字列表,通过使用1个位的基数来进行基数排序。注意需要花4步才能得到结果,并且在每一步中我们建立了两个桶:

{6914151256711}
{64126} {91155711}
{412915} {6615711}
{9111} {412566157}
{145667} {9111215}

    现在我们使用2个位的基数来做同样的事情。注意仅仅需要两步就得到了结果,但是每步需要建立了4个桶:

{6914151256711}
{412} {915} {66} {15711}
{1} {45667} {911} {1215}

    由于样例的范围较小,我们可以使用4个位的基数并且每一步需要16个桶:

{6914151256711}
{1} {} {} {4} {5} {66} {7} {} {9} {} {11} {12} {} {} {15}

    注意,尽管如此,在最后一个例子中,我们有了几个空的桶。这个说明了一点,在一个较大的范围里,在超过可用内存之前有一个明显的我们可以增长的基数的上届。从某点上来说将大量的桶串成单个列表的处理时间将会成为一个重要的考虑点。

    由于基数排序本质上不同于比较排序,在很多情况下它能够非常的高效的执行。运行时是O(n*k),这里的k是关键字的大小(32位整数,一次取4个位,将有k=8。)这个算法的主要缺点在于一些类型的数据可能使用非常长的关键字(例如,字符串),或者将它从最低有效位到最高有效位处理的表示不是很容易。(负的浮点数是最常见的一个例子。)

Sorting Libraries
   现在,许多程序设计平台为我们提供了大量有用和可复用的函数。.Net framework, Java API和C++ STL都内建了排序的能力。
更好的是,它们背后的基本原理在不同语言之间是一样的。

    对标准数据类型比如数量,浮点数和字符串,所有需要对一个数组进行排序都包含在了标准库中。但是我们如果自定义了数据类型并且需要更复杂的比较逻辑呢?幸运的是,面向对象的程序设计语言为标准库提供了解决这个问题的能力。

    在Java和C#当中(至于VB),有一个叫做Comparable(.Net中是IComparable)的接口.通过在用户定义的类上实现IComparable的接口,你可以增加一个方法,int CompareTo (object other),如果小于的话返回负值,如果等于返回0,而如果大于那个参数,返回一个正数。库里面的排序函数将会很好的在你的新的数据类型的数组上工作了。

    此外,有另外一个叫做Comparator的接口(.Net中是IComparer),它定义了一个简单的方法int Compare (object obj1, object obj2),用来返回两个参数比较的结果。

    使用库里面提供的排序函数最大的乐趣是它为你节省了编码的时间,并且需要很少的思考和精力。尽管如此,即使有着这么重大的武器,知道它是怎样工作的仍然是很好的。