【经典算法】线性时间排序

  在计算机科学中,排序是一门基础的算法技术,许多算法都要以此作为基础,不同的排序算法有着不同的时间开销和空间开销。排序算法有非常多种,如我们最常用的快速排序和堆排序等算法,这些算法需要对序列中的数据进行比较,因为被称为基于比较的排序

基于比较的排序算法是不能突破O(NlogN)的。简单证明如下:

  N个数有N!个可能的排列情况,也就是说基于比较的排序算法的判定树有N!个叶子结点,比较次数至少为log(N!)=O(NlogN)(斯特林公式)。

  而非基于比较的排序,如计数排序,桶排序,和在此基础上的基数排序,则可以突破O(NlogN)时间下限。但要注意的是,非基于比较的排序算法的使用都是有条件限制的,例如元素的大小限制,相反,基于比较的排序则没有这种限制(在一定范围内)。但并非因为有条件限制就会使非基于比较的排序算法变得无用,对于特定场合有着特殊的性质数据,非基于比较的排序算法则能够非常巧妙地解决。

  本文着重介绍三种线性的非基于比较的排序算法:计数排序、桶排序与基数排序。

1.计数排序 

  假设我们有一个待排序的整数序列A,其中元素的最小值不小于0,最大值不超过K。建立一个长度为K的线性表C,用来记录不大于每个值的元素的个数。这个算法伪代码如下:

  假设输入数组A[1..n],A.length=n,我们还需要两个数组:B[1..n]存放排序的输出,C[0..k]提供临时存储空间。

COUNTING-SORT(A,B,k)
1 let C[0..k]be a new array
2 for i=0 to k
3     C[i] = 0
4 for j = 1 to A.length
5     C[A[j]] = C[A[j]] + 1;
6 //C[i] now contains the number of elements equal to i.
7 for i = 1 to k
8     C[i] = C[i] + C[i-1]
9 //C[i]now contains the number of elements less than or equal to i.
10 for j = A.length downto 1
11     B[C[A[j]]] = A[J]
12     C[A[J]] = C[A[j]] - 1;

  上述代码在第2~3行for循环的初始化操作之后,数组C的值全为0;第4~5行的for循环遍历每一个输入元素。如果一个输入元素的值为i,就将C[i]值加1.于是,在第5行执行完成后,C[i]中保存的就是等于i的元素的个数,其中i=0,1,...,k。第7~8行通过加总计算确定对每一个i=0,1,...,k,就有多少输入元素是小于或等于i的。

  最后,在第10~12行的for循环部分,把每个元素A[j]放到它在输出数组B中的正确位置上。如果所有n个元素都是互异的,那么当第一次执行第10行时,对每个A[j]值来说,C[A[j]]就是A[j]在输出数组中的最终正确的位置。这是因为共有C[A[j]]个元素小于等于A[j]。因为所有的元素可能并不都是互异的,所以,我们每将一个值A[j]放入数组B中一后,都要将C[A[j]]的值减1.这样,当遇到下一个值等于A[j]的输入元素(如果存在)时,该元素可以直接被放在输出数组A[j]的前一个位置上。

  例如,输入数组A为{3, 4, 3, 2, 1},最大是4,数组长度是5。

  建立计数数组C{0, 0, 0, 0}。

  遍历输入数组:

  A{3, 4, 3, 2, 1} -> C{0, 0, 1, 0}
  A{3, 4, 3, 2, 1} -> C{0, 0, 1, 1}
  A{3, 4, 3, 2, 1} -> C{0, 0, 2, 1}
  A{3, 4, 3, 2, 1} -> C{0, 1, 2, 1}
  A{3, 4, 3, 2, 1} -> C{1, 1, 2, 1}

  计数数组现在是{1, 1, 2, 1},我们现在把它写回到输入数组里:

  C{0, 1, 2, 1} -> A{1, 4, 3, 2, 1}
  C{o, o, 2, 1} -> A{1, 2, 3, 2, 1}
  C{o, o, 1, 1} -> A{1, 2, 3, 2, 1}
  C{o, o, o, 1} -> A{1, 2, 3, 3, 1}
  C{o, o, o, o} -> A{1, 2, 3, 3, 4}

  这样就排好序了。

  时间:O(n + k),n是输入数组长度,k是最大的数的大小。

  空间:O(n + k),n是输入数组长度,k是最大的数的大小。

  C++代码如下:

  

 1 void CountingSort(int A[], int len, int k) {
 2     if (A == NULL || len <= 0 || k <= 0)
 3         return;
 4     
 5     int *B = new int[len]();
 6     int *C = new int[k+1]();
 7     for (int i = 0; i < len; ++i)
 8         C[A[i]]++;
 9     for (int i = 1; i <= k; ++i)
10         C[i] += C[i - 1];
11 
12     for (int i = len - 1; i >= 0; --i) {
13         B[C[A[i]] - 1] = A[i];
14         C[A[i]]--;
15     }
16 
17     for (int i = 0; i < len; ++i)
18         A[i] = B[i];
19 
20     delete []C;
21     delete []B;
22 }

 

 2. 桶排序  

  这种特殊实现的方式时间复杂度为O(N+K),空间复杂度也为O(N+K),同样要求每个元素都要在K的范围内。更一般的,如果我们的K很大,无法直接开出O(K)的空间该如何呢?

  首先定义桶,桶为一个数据容器,每个桶存储一个区间内的数。依然有一个待排序的整数序列A,元素的最小值不小于0,最大值不超过K。假设我们有M个桶,第i个桶Bucket[i]存储iK/M至(i+1)K/M之间的数,有如下桶排序的一般方法:

  1. 扫描序列A,根据每个元素的值所属的区间,放入指定的桶中(顺序放置)。
  2. 对每个桶中的元素进行排序,什么排序算法都可以,例如快速排序。
  3. 依次收集每个桶中的元素,顺序放置到输出序列中。

  对该算法简单分析,如果数据是期望平均分布的,则每个桶中的元素平均个数为N/M。如果对每个桶中的元素排序使用的算法是快速排序,每次排序的时间复杂度为O(N/Mlog(N/M))。则总的时间复杂度为O(N)+O(M)O(N/Mlog(N/M)) = O(N+ Nlog(N/M)) =O(N + NlogN - NlogM)。当M接近于N是,桶排序的时间复杂度就可以近似认为是O(N)的。就是桶越多,时间效率就越高,而桶越多,空间却就越大,由此可见时间和空间是一个矛盾的两个方面。

  桶中元素的顺序放入和顺序取出是有必要的,因为这样可以确定桶排序是一种稳定排序算法,配合基数排序是很好用的

  代码如下,使用的例子是算法导论上的例子(区间为[0,1)):

  

 1 struct Node {
 2     float value;
 3     Node* next;
 4 
 5     Node(float v = 0) :value(v), next(NULL) {}
 6 };
 7 
 8 void Destruct(Node *p) {
 9     if (p == NULL) {
10         return;
11     }else {
12         Destruct(p->next);
13         delete p;
14     }
15 }
16 
17 void BucketSort(float A[],  int n) {
18     Node **B = new Node*[n];
19     memset(B, NULL, sizeof(Node*)*n);
20     
21     int i = 0, j;
22     for (i = 0; i < n; i++) {
23         int bi = n * A[i] ;
24         Node* q = new Node(A[i]);
25         Node *p = B[bi];
26         if (p == NULL) {
27             B[bi] = q;
28         } else if (p->next == NULL) {
29             if (A[i] < p->value) {
30                 B[bi] = q;
31                 q->next = p;
32             } else {
33                 p->next = q;
34             }
35         } else {
36             while ( p->next != NULL && A[i] > p->next->value)
37                 p = p->next;
38             q->next = p->next;
39             p->next = q;
40         }
41     }
42 
43     for (i = j = 0; i < n; i++) {
44         Node *p = B[i];
45         while (p != NULL) {
46             A[j++] = p->value;
47             p = p->next;
48         }
49     }
50 
51     for (i = 0; i < n; i++)
52         Destruct(B[i]);
53 
54     delete []B;
55 }

 

3.基数排序

  下面说到我们的重头戏,基数排序(Radix Sort)。上述的基数排序和桶排序都只是在研究一个关键字的排序,现在我们来讨论有多个关键字的排序问题。

  假设我们有一些二元组(a,b),要对它们进行以a为首要关键字,b的次要关键字的排序。我们可以先把它们先按照首要关键字排序,分成首要关键字相同的若干堆。然后,在按照次要关键值分别对每一堆进行单独排序。最后再把这些堆串连到一起,使首要关键字较小的一堆排在上面。按这种方式的基数排序称为MSD(Most Significant Dight)排序。

  第二种方式是从最低有效关键字开始排序,称为LSD(Least Significant Dight)排序。首先对所有的数据按照次要关键字排序,然后对所有的数据按照首要关键字排序。要注意的是,使用的排序算法必须是稳定的,否则就会取消前一次排序的结果。由于不需要分堆对每堆单独排序,LSD方法往往比MSD简单而开销小。下文介绍的方法全部是基于LSD的。

  代码如下:

  

 1 #include<iostream>
 2 #include<vector>
 3 #include<cmath>
 4 using namespace std;
 5 
 6 int getMax(int arr[], int n) {
 7     int mx = arr[0];
 8     for (int i = 1; i < n; i++)
 9         if (arr[i] > mx)
10         mx = arr[i];
11 
12     return mx;
13 }
14 
15 void CountSort(int arr[], int n, int exp) {
16     int *output = new int[n];
17     int i, count[10] = { 0 };
18 
19     //Store count of occurrences in count[]
20     for (i = 0; i < n; i++)
21         count[(arr[i] / exp) % 10]++;
22 
23     //Change count[i] so that count[i] now contains actual position of
24     //this digit in output[]
25     for (i = 1; i < 10; i++)
26         count[i] += count[i - 1];
27 
28     //Build the output array
29     for (i = n - 1; i >= 0; i--) {
30         output[count[(arr[i] / exp) % 10] - 1] = arr[i];
31         count[(arr[i] / exp) % 10]--;
32     }
33 
34     //Copy the output array to arr[], so that arr[] now 
35     //contains sorted numbers according to current digit
36     for (i = 0; i < n; i++)
37         arr[i] = output[i];
38 
39     delete []output;
40 }
41 
42 void RadixSort(int arr[], int n) {
43     //Find the maximum number fo know number of digits
44     int m = getMax(arr, n);
45 
46     //Do counting sort for every digit. Note that instead of passing digit
47     //number, exp is passed. exp is 10^i where i is current digit number
48     for (int exp = 1; m / exp > 0; exp *= 10)
49         CountSort(arr, n, exp);
50 }
51 
52 //A utility function to print an array
53 void Print(int arr[], int n) {
54     for (int i = 0; i < n; i++)
55         cout << arr[i] << " ";
56 }
57 
58 int main()
59 {
60     int arr[] = { 170, 45, 90, 802, 24, 2, 66 };
61     int n = sizeof(arr) / sizeof(arr[0]);
62     RadixSort(arr, n);
63     Print(arr, n);
64 
65     system("pause");
66     return 0;
67 
68 }

 

三种排序算法的比较:   

  从整体上来说,计数排序,桶排序都是非基于比较的排序算法,而其时间复杂度依赖于数据的范围,桶排序还依赖于空间的开销和数据的分布。而基数排序是一种对多元组排序的有效方法,具体实现要用到计数排序或桶排序。

相对于快速排序、堆排序等基于比较的排序算法,计数排序、桶排序和基数排序限制较多,不如快速排序、堆排序等算法灵活性好。但反过来讲,这三种线性排序算法之所以能够达到线性时间,是因为充分利用了待排序数据的特性,如果生硬得使用快速排序、堆排序等算法,就相当于浪费了这些特性,因而达不到更高的效率。

  在实际应用中,基数排序可以用于后缀数组的倍增算法,使时间复杂度从O(NlogNlogN)降到O(N*logN)。线性排序算法使用最重要的是,充分利用数据特殊的性质,以达到最佳效果

 

参考文献:

  1.http://www.geeksforgeeks.org/radix-sort/

  2.http://www.geeksforgeeks.org/counting-sort/

  3.https://www.byvoid.com/blog/sort-radix

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。