面试-排序算法

【排序结构1】插入排序

1、基本概念介绍

 

(1) 如果待排序列中有两个相同的关键字 Ki = Kj,其顺序是Ki在Kj之前。如果经过排序之后,Ki 和 Kj的顺序颠倒了,则说明这个排序方法是不稳定 的。否则则是稳定 排序。

 

(2) 在内存中就可以完成的排序过程,称为内部排序 。如果待排数据量很大,内存不够容纳全部数据,在排序过程中必须对外存进行访问,则叫做外部排序 。

     实际上,由于数据量级别不同。排序的方法会有很大的改变,思考排序效率的角度也不一样。这个专题系列未经特殊注明,都属于内部排序方法。

 

 

2、直接插入排序  O(N^2)

 

      将一个记录插入到已经排好序的有序表中,从而得到一个新的,记录数增1的有序表。下面通过一个例子来说明这个排序流程:

                          待排序列:   49, 38 , 65 , 97, 76 , 13, 27 ,49

 

                            插入49:   49

                            插入38:   38, 49

                            插入65:   38, 49,  65

                            插入97:   38, 49,  65,  97

                            插入76:   38, 49,  65,  76,  97

                            插入13:   13, 38,  49,  65,  76,  97

                            插入27:   13, 27,  38,  49,  65,  76, 97

                            插入49:   13, 27,  38,  49,  49,  65, 76, 97

Cpp代码  

1.  #include<iostream.h>  

2.  /****************************** 

3.   * 直接插入排序 Straight Insertion Sort * 

4.   ******************************/  

5.  class SISortion{  

6.  public:  

7.      //递增稳定排序  

8.      static void inc_sort(int keys[], int size);  

9.  };  

10. void SISortion:: inc_sort(int keys[], int size){  

11.     //记录当前要插入的key  

12.     int post_key;  

13.     //从第二个数据开始  

14.     for(int i=1;i<size;i++){  

15.         post_key=keys[i];  

16.          int j;  

17.          //将比post_key要大的前面所有的key依次后移一位  

18.              for(j=i-1;j>=0;j--){  

19.                   if(post_key<keys[j])  

20.               keys[j+1]=keys[j];  

21.           else  

22.               break;  

23.          }  

24.          //将post_key插入到合适位置  

25.          keys[j+1]=post_key;  

26.       }  

27.       //打印排序结果  

28.       for(int k=0;k<size;k++)  

29.         cout<<keys[k]<<" ";  

30.       cout<<endl;  

31. }  

32. //Test SISortion  

33. void main(){  

34.     int raw[]={49,38,65,97,76,13,27,49};  

35.     int size=sizeof(raw)/sizeof(int);  

36.     SISortion::inc_sort(raw,size);  

37. }  

很显然,直接插入排序算法的时间复杂度为O(N^2) 。但是不需要额外的存储空间,因此空间复杂度为O(1) 。而且直接插入排序是稳定 的。

 

3、折半插入排序  O(N^2)

 

折半插入排序和直接插入排序的不同点在“查找”上。在有序关键字序列中,每次插入的关键字采用折半查找的方法来定位。虽然折半查找的时间复杂度为O(logN),但定位后循环数据后移仍然要花费O(N)的代价。因此时间复杂度仍然是O(N^2),空间复杂度为O(1),排序是稳定的。

Cpp代码  

1.  #include<iostream.h>  

2.  /****************************** 

3.   * 折半插入排序 Binary Insertion Sort   * 

4.   ******************************/  

5.  class BISortion{  

6.  public:  

7.      static void inc_sort(int keys[],int size);  

8.  };  

9.    

10. void BISortion :: inc_sort(int keys[],int size){  

11.       

12.     int post_key;  

13.     for(int i=1;i<size;i++){  

14.         post_key=keys[i];  

15.         //折半查找  

16.         int low=0,high=i-1;  

17.         while(low<=high){  

18.             int middle=(low+high)/2;  

19.             if(post_key<keys[middle])  

20.                 high=middle-1;  

21.             else low=middle+1;  

22.         }  

23.         //移动位置  

24.         for(int j=i-1;j>=high+1;j--)  

25.             keys[j+1]=keys[j];  

26.         keys[high+1]=post_key;  

27.     }  

28.   

29.     for(int k=0;k<size;k++){  

30.         cout<<keys[k]<<" ";  

31.     }  

32.     cout<<endl;  

33.   

34. }  

35. //Test BISortion  

36. void main(){  

37.     int keys[]={49,38,65,97,76,13,27,49};  

38.     int size=sizeof(keys)/sizeof(int);  

39.     BISortion::inc_sort(keys,size);  

40. }  

 

4、希尔排序(N*logN)

 

     希尔排序(Shell's Sort)又称缩小增量排序(Diminishing Increment Sort),它也是一种插入排序,但是在时间效率上比前面两种有较大的改进。

 

     对本身就是“正序”的关键字序列,直接插入排序的时间复杂度将降低到O(n)。由此可以设想,对"基本有序"的序列进行直接插入排序,效率将大大提高。希尔排序方法就是基于这个思想提出来的,其基本思想就是:先将整个待排序列分割成若干个子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,在对全体记录进行一次直接插入排序。

 

    我们用下图的例子来看看希尔排序

          

     很明显,希尔排序对每一趟增量子序列都是一种直接插入排序。但是每一趟排序中记录关键字都是和同一子序列中前一个记录的关键字进行比较(子序列相邻关键字之间的位置相差一个增量),因此关键字较小的记录不是一步一步向前挪动,而是根据增量大小跳跃式的前进。当序列基本有序的时候,第三趟增量为1的希尔排序就是直接排序,这时只需要比较和移动少量的记录即可。

Cpp代码  

1.  #include<iostream.h>  

2.  /****************** 

3.   * 希尔排序 Shell Sort   * 

4.   ******************/  

5.  class ShellSort{  

6.  public:  

7.      //希尔递增排序  

8.      static void inc_sort(int keys[],int size);  

9.  };  

10.   

11. void ShellSort :: inc_sort(int keys[],int size){  

12.   int increment=size; //增量  

13.   do{  

14.     increment=increment/3+1; //增量逐步减少至1  

15.         int post_key;  

16.     for(int i=increment;i<size;i++){  

17.         if(keys[i]<keys[i-increment]){  

18.             post_key=keys[i];  

19.             for(int j=i-increment;j>=0&&post_key<keys[j];j=j-increment){  

20.                 keys[j+increment]=keys[j];  

21.             }  

22.             keys[j+increment]=post_key;  

23.         }  

24.     }  

25.     cout<<"一趟希尔排序(增量="<<increment<<"):";  

26.     for(int k=0;k<size;k++)  

27.         cout<<keys[k]<<" ";  

28.     cout<<endl;  

29.    }while(increment>1);  

30. }  

31.   

32. void main(){  

33.     int raw[]={49,38,65,97,76,13,27,49};    

34.     int size=sizeof(raw)/sizeof(int);    

35.     ShellSort::inc_sort(raw,size);  

36. }  

 

     希尔排序的性能是个很复杂的问题,主要与增量的取值有关。到目前为止,还没有人求的最好的增量结果。但是大量数据实验表明,布尔排序的时间复杂度趋近于O(N*logN) 。但不管增量如何取值,最后一趟希尔排序的增量必须为1才能真正得到有序序列。而且希尔排序是不稳定的。

 

 

【排序结构2】交换排序

1、起泡排序  O(N^2)

 

起泡排序的过程很简单,首先将第一个关键字和第二个关键字比较,若为逆序,则将两个记录交换。然后再用第二个关键字和第三个关键字比较,以此类推,知道第n-1个关键字和第n个比较完,这样最大的关键字将被交换到第n个位置上。这个过程称做第一趟气泡排序。然后对前n-1进行第二趟气泡排序,将第二大的关键字交换到第n-1个位置上。当第n-1趟气泡排序完成之后,有序序列也就随之产生了。

Cpp代码  

1.  #include<iostream.h>  

2.  /************************** 

3.   * 起泡排序 Bubble Sort   *  

4.   **************************/    

5.  class BubbleSort{  

6.  public:  

7.      static void inc_sort(int keys[],int size);  

8.  };  

9.    

10. void BubbleSort :: inc_sort(int keys[], int size){  

11.     for(int i=size-1;i>=0;i--)  

12.         for(int j=0;j<i;j++){  

13.             if(keys[j]>keys[j+1]){  

14.                 int temp=keys[j];  

15.                 keys[j]=keys[j+1];  

16.                 keys[j+1]=temp;  

17.             }  

18.         }  

19.     for(int k=0;k<size;k++)  

20.         cout<<keys[k]<<" ";  

21.     cout<<endl;  

22. }  

23. //Test BubbleSort  

24. void main(){  

25.     int raw[]={49,38,65,97,76,13,27,49};    

26.     int size=sizeof(raw)/sizeof(int);    

27.     BubbleSort::inc_sort(raw,size);    

28. }  

显然,气泡排序的时间复杂度为O(N^2),其空间复杂度为O(1) ,而且是稳定的 。

 

2、快速排序 O(N*logN)

 

快速排序是对起泡排序的一种改进。它的基本思想就是:通过一趟排序将待排记录分割成独立的两个部分,其中一个部分的关键字都比另一个部分关键字小,然后可以分别再对这两个部分继续快排,已达到整个序列有序。

 

具体的做法是:对待排序列keys[n]确定两个指针(low=0,high=n-1)。然后取第一个关键字keys[0]作为pivotkey(枢轴)。首先从high所指的位置起向前搜索找到第一个关键字小于pivotkey的记录,并将这个记录的关键字与pivotkey交换。然后从low所指的位置向后搜索,找到第一个关键字大于pivotkey的记录,并交换。轮流重复这两个步骤直到low=high位置。这样一个过程我们叫做一次快排(又叫一次划分)。

 

对划分后的序列的两个部分继续分别快排,知道所有序列有序位置,整个过程就是快排。

Cpp代码  

1.  #include<iostream.h>  

2.    

3.  class QuickSort{  

4.  private:  

5.      //一次快排(划分)  

6.      static int partition(int parts[],int low, int high);  

7.      //快排  

8.      static void quick_sort(int parts[],int low, int high);  

9.  public:  

10.     //递增排序  

11.     static void inc_sort(int keys[],int size);  

12. };  

13.   

14. int QuickSort :: partition(int parts[], int low, int high){   

15.     int pivotkey=parts[low];    //将第一个元素作为枢轴  

16.     while(low<high){  

17.         while(low<high && parts[high]>=pivotkey) --high; //将比枢轴小的关键字记录移动到低端  

18.         parts[low]=parts[high];  

19.         while(low<high && parts[low]<=pivotkey) ++low; //将比枢轴大的关键字记录移动到高端  

20.         parts[high]=parts[low];  

21.     }  

22.     parts[low]=pivotkey;  

23.     return low; //返回枢轴的位置  

24. }  

25.   

26. void QuickSort :: quick_sort(int parts[],int low,int high){  

27.     if(low<high){  

28.         int pivotloc=QuickSort::partition(parts,low,high);  

29.         QuickSort::quick_sort(parts,low,pivotloc-1);  

30.         QuickSort::quick_sort(parts,pivotloc+1,high);  

31.     }  

32. }  

33.   

34. void QuickSort :: inc_sort(int keys[],int size){  

35.     QuickSort::quick_sort(keys,0,size-1);  

36.       

37.     for(int k=0;k<size;k++)  

38.         cout<<keys[k]<<" ";  

39.     cout<<endl;  

40. }  

41.   

42. void main(){  

43.     int raw[]={49,38,65,97,76,13,27,49};      

44.     int size=sizeof(raw)/sizeof(int);      

45.     QuickSort::inc_sort(raw,size);      

46. }  

 

C代码  

1.  //快速排序非递归算法  

2.  #include<iostream.h>  

3.  #include<malloc.h>  

4.    

5.  void quick_sort(int *pArr, int size){  

6.      int * beginIdxs=(int *)malloc(size * sizeof(int)); //记录待调整子序列的首地址  

7.      int * endIdxs=(int *)malloc(size * sizeof(int));//记录待调整子序列的尾地址  

8.      beginIdxs[0]=0;  

9.      endIdxs[0]=size-1;  

10.     int curIdx=0;  

11.     while(curIdx>-1){  

12.         int low=beginIdxs[curIdx];  

13.         int high=endIdxs[curIdx];  

14.         int pivotkey=pArr[low]; //将第一个元素作为枢轴    

15.         while(low<high){  

16.             while(low<high && pivotkey<=pArr[high]) --high;  

17.             pArr[low]=pArr[high];  

18.             while(low<high && pivotkey>=pArr[low]) ++low;  

19.             pArr[high]=pArr[low];  

20.         }  

21.         pArr[low]=pivotkey;  

22.         int pivotidx=low;  

23.         int begin=beginIdxs[curIdx];  

24.         int end=endIdxs[curIdx];  

25.         curIdx--;  

26.         if(begin<pivotidx-1){  

27.             beginIdxs[++curIdx]=begin;  

28.             endIdxs[curIdx]=pivotidx-1;  

29.         }  

30.         if(end>pivotidx+1){  

31.             beginIdxs[++curIdx]=pivotidx+1;  

32.             endIdxs[curIdx]=end;  

33.         }  

34.     }  

35.       

36.     //打印结果  

37.     for(int k=0;k<size;k++){  

38.         cout<<pArr[k]<<" ";  

39.     }  

40. }  

41. void main(){  

42.     int raw[]={49,38,65,97,76,13,27,49};        

43.     int size=sizeof(raw)/sizeof(int);        

44.     quick_sort(raw,size);        

45. }  

 

快速排序的平均时间复杂度为O(N*logN) 。但快速排序在待排关键字序列有序或基本有序的情况下, 快速排序将蜕化成起泡排序,其 时间复杂度降为O(N^2) 。 经验证明,在所有O(N*logN)级的此类排序算法中,快速排序是目前被认为最好的一种内部排序方法。但是快排需要一个栈空间来实现递归。若每一趟划分都能够均匀的分割成长度相接近的两个子序列,则栈的最大深度为[logN]+1。因此空间复杂度为O(logN)。快排也是不稳定的。

 

快速排序有一个最坏的可能性,因此也就有很多优化来改进这个算法。

 

随机化快排: 快速排序的最坏情况基于每次划分对主元的选择。基本的快速排序选取 第一个元素作为主元。这样在数组已经有序的情况下,每次划分将得到最坏的结果。一种比较 常见的优化方法是随机化算法,即随机选取一个元素作为主元。这种情 况下虽然最坏情况仍然是O(n^2),但最坏情况不再依赖于输入数据,而是由于随机函数取值不佳。 实际上,随机化快速排序得到理论最坏情况的可能性仅为 1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)的期望时间复杂度。一位前辈做出了一个精辟的总结:“随机化快速排序可 以满足一个人一辈子的人品需求。 ”
  随机化快速排序的唯一缺点在于,一旦输入数据中有很多的相同数据,随机化的效果将直 接减弱。对 于极限情况,即对于n个相同的数排序,随机化快速排序的时间复杂度将毫无疑问的降低到O(n^2)。 解决方法是用一种方法进行扫描,使没有交换的情况下主 元保留在原位置。

 

平衡快排(Balanced Quicksort) :每次尽可能地选择一个能够 代表中值的元素作为关键数据,然后遵循普通快排的原则进行比较、替换和递归。通常来说, 选择这个数据的方法是取开头、结尾、中间3个数据,通过比较选出其 中的中值。 取这3个值的好处是在实际问题(例如信息学竞赛……)中,出现近似顺序数据或逆序数据的概率较大,此时中间数据必然成为中值,而也是事实上的近 似中值。万一遇到正好中间大两边小(或反之)的数据,取的值都接近最值,那么由于至少能将两部分分开,实际效率也会有2倍左右的增加,而且利于将数据略微 打乱,破坏退化的结构。

对于快排优化的问题可以看看Java API(JDK)中的例子,参见《 java.util.Arrays的排序研究 》

 

 

【排序结构3】 选择排序

(1) 简单选择排序 O(N^2)

一趟简单选择排序的操作为:通过n-i 次关键字间的比较,从n-i+1 个记录中选择出关键字最小的记录,并和第 i (i<=i<=n)个记录交换之。

Cpp代码  

1.  #include<iostream.h>  

2.  /***************************************  

3.   * 简单选择排序 Simple Selection Sort  *   

4.   ***************************************/   

5.  class SimpleSelectSort{  

6.  public:  

7.      //递增排序  

8.      static void inc_sort(int keys[],int size);  

9.  };  

10. void SimpleSelectSort :: inc_sort(int keys[], int size){  

11.     for(int i=0;i<size;i++){       

12.         int min_key=keys[i]; //存储每一趟排序的最小值  

13.         int min_key_pos=i;   //存储最小值的位置  

14.         for(int j=i+1;j<size;j++){  

15.             if(min_key>keys[j]){ //定位最小值  

16.                 min_key=keys[j];  

17.                 min_key_pos=j;  

18.             }  

19.         }     

20.         keys[min_key_pos]=keys[i]; //将选择的最小值交换位置  

21.         keys[i]=min_key;      

22.     }  

23.     for(int k=0;k<size;k++)  

24.         cout<<keys[k]<<" ";  

25.     cout<<endl;  

26. }  

27. //Test SimpleSelectSort  

28. void main(){  

29.     int raw[]={49,38,65,97,76,13,27,49};   

30.     int size=sizeof(raw)/sizeof(int);     

31.     SimpleSelectSort::inc_sort(raw,size);      

32. }  

 简单选择排序的时间复杂度为O(N^2),空间复杂度为O(1),排序方法是稳定的 。这种排序方法在n个关键字中选出最小值,至少进行n-1次比较,然而,继续在剩余的n-1个关键字中选择次小值就并非一定要进行n-2次比较了。下面的树形选择排序后一轮比较可以利用前一轮的比较结果,从而大大减少比较的次数。

 

(2) 树形选择排序 O(N * logN)

 

树形选择排序(Tree Selection Sort),又称竞标赛排序(Tournament Sort),是一种按照竞标赛思想进行的选择排序方法。首先对n个记录的关键字两两比较,然后在其中[n/2]个较小者之间在进行两两比较,如此重复,直至选出最小关键字的记录为止。这个过程可用一颗有n个叶子结点的完全二叉树表示。 下图展示了树形选择排序的过程:

 

(a)图选择出最小值13需要7次比较,但是(b)图选择第二小的27就只需要3次了。应为(a)图中的最小值13已经找到,只需要在(b)图中将13的位置赋值为无穷大,这样,就只需要再次比较树的一部分就可以找到第二小的值。

Java代码  

1.  #include<iostream.h>  

2.  #include<malloc.h>  

3.  #define MAX_INT 32767;  

4.    

5.  typedef struct sort_node{  

6.      int key; //待排关键字  

7.      int pos; //此关键字在待排序列中的位置  

8.  }SortNode;  

9.    

10. int level=1;  

11. SortNode **level_point;  

12. //记录每一层的关键字容量的连续空间  

13. int *level_count;  

14. //记录已经排序号的关键字序列  

15. int *sorted_keys;  

16.   

17. //释放多维指针  

18. void freeAll(SortNode ** deleted, int size){  

19.     for(int i=0;i<size;i++)  

20.         free(deleted[i]);  

21. }  

22. //递增排序  

23. void inc_sort(int keys[],int size){  

24.   

25.     //开辟存储排序序列的容量  

26.     sorted_keys=(int *)malloc(size*sizeof(int));  

27.   

28.     //根据待排序列的数量确定排序树的层次  

29.     int a_size=size;  

30.     bool isPower=true;  

31.     if(a_size>1){  

32.         while(a_size!=1){  

33.             if(a_size%2==1)  

34.                 isPower=false;  

35.             level++;  

36.             a_size/=2;  

37.         }  

38.     }  

39.     if(isPower==false) level++;  

40.       

41.     //够着排序树的内存结构,为每一层开辟可以容纳一定数量关键字的内存空间  

42.     level_point=(SortNode **)malloc(level*sizeof(SortNode *));  

43.     level_count=(int *)malloc(level*sizeof(int));  

44.     int level_size=size;  

45.     for(int l=0;l<level;l++){  

46.         level_count[l]=level_size;  

47.         level_point[l]=(SortNode *)malloc(level_size*sizeof(SortNode));  

48.         level_size=level_size/2+level_size%2;  

49.     }  

50.   

51.     //为第0层赋值待排序列,并建立排序树,找到第一次最小的关键字  

52.     for(int i=0;i<size;i++){  

53.         level_point[0][i].key=keys[i];  

54.         level_point[0][i].pos=i;  

55.     }  

56.     int cur_level=1;  

57.     while(cur_level<level){  

58.         for(int j=0;j<level_count[cur_level];j++){  

59.             int left_child=level_point[cur_level-1][j*2].key;  

60.             //没有右孩子  

61.             if((j*2+1)>=level_count[cur_level-1]){  

62.                 level_point[cur_level][j].key=left_child;  

63.                 level_point[cur_level][j].pos=level_point[cur_level-1][j*2].pos;  

64.             }else{  

65.                 int right_child=level_point[cur_level-1][j*2+1].key;  

66.                 level_point[cur_level][j].key=left_child<=right_child ? left_child : right_child;  

67.                 level_point[cur_level][j].pos=left_child<=right_child ? level_point[cur_level-1][j*2].pos : level_point[cur_level-1][j*2+1].pos;  

68.             }  

69.         }  

70.         cur_level++;  

71.     }  

72.   

73.     //打印第一次的树形选择排序:  

74.     cout<<"第1次树形选择排序 (关键字 - 关键字在待排表中的位置):"<<endl;  

75.     for(int u=level-1;u>=0;u--){  

76.         for(int i=0;i<level_count[u];i++)  

77.             cout<<"("<<level_point[u][i].key<<"-"<<level_point[u][i].pos<<")  ";  

78.         cout<<endl;  

79.     }  

80.   

81.     //第一次树形排序的最小值和最小位置  

82.     int cur_min_key=level_point[level-1][0].key;  

83.     int cur_min_pos=level_point[level-1][0].pos;  

84.     sorted_keys[0]=cur_min_key;  

85.   

86.     //输出剩下size-1个最小的数  

87.     for(int count=1;count<=size-1;count++){  

88.         level_point[0][cur_min_pos].key=MAX_INT;  

89.           

90.         //找到需要重新比较的两个位置  

91.         int a_pos=cur_min_pos;  

92.         int b_pos=a_pos%2==0 ? a_pos+1 : a_pos-1;  

93.   

94.         for(int m=1;m<level;m++){  

95.             if(b_pos>=level_count[m-1]){  

96.                 level_point[m][a_pos/2].key=level_point[m-1][a_pos].key;  

97.                 level_point[m][a_pos/2].pos=level_point[m-1][a_pos].pos;  

98.             }else{  

99.                 level_point[m][a_pos/2].key=level_point[m-1][a_pos].key<=level_point[m-1][b_pos].key ? level_point[m-1][a_pos].key : level_point[m-1][b_pos].key;  

100.                  level_point[m][a_pos/2].pos=level_point[m-1][a_pos].key<=level_point[m-1][b_pos].key ? level_point[m-1][a_pos].pos : level_point[m-1][b_pos].pos;  

101.              }  

102.              a_pos=a_pos/2;  

103.              b_pos=a_pos%2==0 ? a_pos+1 : a_pos-1;  

104.          }  

105.          //记录每一次树形排序的最小值和对应的位置  

106.          cur_min_key=level_point[level-1][0].key;  

107.          cur_min_pos=level_point[level-1][0].pos;  

108.          sorted_keys[count]=cur_min_key;  

109.    

110.          //打印第count次的树形选择排序:  

111.          cout<<"第"<<(count+1)<<"次树形选择排序 (关键字 - 关键字在待排表中的位置):"<<endl;  

112.          for(int u=level-1;u>=0;u--){  

113.              for(int i=0;i<level_count[u];i++)  

114.                  cout<<"("<<level_point[u][i].key<<"-"<<level_point[u][i].pos<<")  ";  

115.              cout<<endl;  

116.          }  

117.      }  

118.        

119.      //打印排序好的序列  

120.      cout<<endl<<endl<<"排序序列:";  

121.      for(int k=0;k<size;k++)  

122.          cout<<sorted_keys[k]<<" ";  

123.      cout<<endl;  

124.    

125.      free(level_count);  

126.      free(sorted_keys);  

127.      freeAll(level_point,level);  

128.  }  

129.  //Test  

130.  void main(){  

131.      int raw[]={49,38,65,97,76,13,27,49};     

132.      int size=sizeof(raw)/sizeof(int);       

133.      inc_sort(raw,size);     

134.    

135.  }  

 

树形选择排序需要建立一棵含n个叶子结点的完全二叉树,其深度为[logN]+1。因此,除第一次排序需要比较n次以外,其余每一次树形选择排序都只需要比较logN次。因此树形选择排序的时间复杂度为O(N*logN) 。但是这种排序方法大量额外的空间,一棵n个叶子结点的满二叉树有2n-1个结点。则对N个关键字的树形选择排序需要近2N左右的结点。空间复杂度为O(2N) 。该方法也是稳定的排序 。

 

树形选择排序仍然有很多缺点,比如空间代价高,需要和无穷大关键字做比较等。为了弥补,J.willioms在1964年提出了下面的另一种选择排序——堆排序。

 

(3) 堆排序

堆的定义如如下:n个元素的序列{K0 ... K(n-1)},当且仅当满足下关系时,称之为堆。(注: 序列从下标0作为第一个元素开始)

                  ki <= k(2i+1) && ki <= k(2i+2)    —— 小顶堆

                  ki >= k(2i+1) && ki >= k(2i+2)    —— 大顶堆

 

若将此序列对应的一维数组(序列的内存结构)看成是一个完全二叉树,即Ki 的左孩子是K(2i+1),右孩子是K(2i+2)。则堆的含义就是,完全二叉树中所有非终结点的值均不大于(不小于)其左、右孩子结点的值。因此,堆顶元素K0就是整个序列的最小值了。

 

堆排序的算法流程:

首先,将待排序列整理成堆。即从序列的第[n/2]-1个元素(完全二叉树最后一个非终结点)开始,到第0个结点为止调整堆。具体过程见下图:

然后,输出堆顶元素K0后,用当前堆中最后一个元素K(n-1)代替堆顶。并将待排序列减少一个(最后一个元素已经移到了第0号位置),接着调整堆,即将移动后的堆顶元素向下调整(保证小顶堆)。具体过程如下图:

  

最后,依次循环下去,直到输出序列的全部元素为止。

Java代码  

1.  #include<iostream.h>  

2.  /********************* 

3.   * 堆排序 Heap Sort  *    

4.   *********************/     

5.  class HeapSort{  

6.  public:  

7.      //递增排序  

8.      static void inc_sort(int keys[], int size);  

9.  private:  

10.     //创建堆  

11.     static void create(int keys[],int size);  

12.     //调整堆  

13.     static void adjust(int keys[],int var_size);  

14.     //交换  

15.     static void swap(int keys[],int pos_a,int pos_b);  

16. };  

17. //创建堆  

18. void HeapSort :: create(int keys[],int size){  

19.     for(int i=(size-1)/2;i>=0;i--){        

20.         int lchild=i*2+1;  

21.         int rchild=i*2+2;  

22.         while(lchild<size){  

23.           

24.             int next_pos=-1;  

25.             if(rchild>=size&&keys[i]>keys[lchild]){  

26.                 HeapSort ::swap(keys,i,lchild);  

27.                 next_pos=lchild;  

28.             }  

29.             if(rchild<size){  

30.                 int min_temp=keys[lchild]<=keys[rchild] ? keys[lchild] : keys[rchild];  

31.                 int min_pos=keys[lchild]<=keys[rchild] ? lchild : rchild;  

32.                 if(keys[i]>keys[min_pos]){  

33.                     swap(keys,i,min_pos);  

34.                     next_pos=min_pos;  

35.                 }  

36.             }  

37.             if(next_pos==-1) break;  

38.             lchild=next_pos*2+1;  

39.             rchild=next_pos*2+2;  

40.         }  

41.     }  

42. }  

43. //调整堆  

44. void HeapSort :: adjust(int keys[],int var_size){  

45.   

46.     int pos=0;  

47.     while((pos*2+1)<var_size){  

48.         int next_pos=-1;  

49.         if((pos*2+2)>=var_size&&keys[pos]>keys[pos*2+1]){  

50.             swap(keys,pos,pos*2+1);  

51.             next_pos=pos*2+1;  

52.         }  

53.         if((pos*2+2)<var_size){  

54.             int min_keys=keys[pos*2+1]<=keys[pos*2+2] ? keys[pos*2+1] : keys[pos*2+2];  

55.             int min_pos=keys[pos*2+1]<=keys[pos*2+2] ? (pos*2+1) : (pos*2+2);  

56.             if(keys[pos]>min_keys){  

57.                 swap(keys,pos,min_pos);  

58.                 next_pos=min_pos;  

59.             }  

60.         }  

61.         if(next_pos==-1) break;  

62.         pos=next_pos;  

63.     }         

64. }  

65. //递增排序  

66. void HeapSort :: inc_sort(int keys[], int size){  

67.     HeapSort::create(keys,size);  

68.     int var_size=size;  

69.     while(var_size>0){  

70.         cout<<keys[0]<<" "; //Êä³öÿһÂÖ¶ÑÅÅÐòµÄ×îСֵ  

71.         keys[0]=keys[var_size-1];  

72.         --var_size;  

73.         adjust(keys,var_size);  

74.     }  

75. }  

76. //keys[pos_a]  <-> keys[pos_b]  

77. void HeapSort :: swap(int keys[],int pos_a,int pos_b){  

78.     int temp=keys[pos_a];  

79.     keys[pos_a]=keys[pos_b];  

80.     keys[pos_b]=temp;  

81. }  

82. //Test HeapSort  

83. void main(){  

84.     int raw[]={49,38,65,97,76,13,27,49};       

85.     int size=sizeof(raw)/sizeof(int);         

86.     HeapSort::inc_sort(raw,size);       

87. }  

 

堆排序方法对记录较少的文件效果一般,但对于记录较多的文件还是很有效的。其运行时间主要耗费在创建堆和反复调整堆上。堆排序即使在最坏情况下,其时间复杂度也为O(N*logN) 。这一点比快速排序 要好。另外,堆排序所需要的空间复杂度为O(1) 。但却是不稳定排序 。

 

 

【排序结构4】 归并排序

归并排序 O(N*logN) 是另一种效率很高的排序方法。"归并"的含义就是将两个或两个以上的有序表组合成一个有序表。加入两个有序表的长度分别为m、n,则一次归并的时间复杂度为O(m+n)。

 

我们可以用"归并"的思想来实现排序。假如待排序列含有n个关键字,则可看成是n个有序的子序列,每个序列长度为1,然后两两归并,得到[n/2]个长度为2或1的子序列,在两两归并....,知道得到一个长度为n的有序序列为止。这就是2-路归并算法。

 

下图就是2-路归并排序的一个例子:

 

我们可以用分治的思想解决归并排序,给定一个有N个关键字的有序序列,分治法的步骤如下:

(1)划分: 如果S中有1个元素,则直接返回S,因为它已经有序了。否则(S中至少有两个元素),把它们分别放入两个序列S1和S2中,S1和S2各包含大约S中的一半元素,即S1包含S中的前[N/2]个元素,S2包含S中的后[N/2]个元素。

(2)递归:递归求解子问题S1和S2。

(3)求解:归并有序序列S1和S2,使得他们成为一个有序序列,将其中的元素放回S中。

Java代码  

1.  #include<iostream.h>  

2.  #include<malloc.h>  

3.  /************************   

4.   * 归并排序 Merge Sort  *    

5.   ***********************/     

6.  class MergeSort{  

7.  public:  

8.      //递增排序  

9.      static void inc_sort(int keys[], int size);  

10. private:  

11.     //归并排序算法  

12.     static void merge_sort(int raw[], int *merged, int s, int t);  

13.     //归并  

14.     static void merge(int raw[], int *merged, int si, int mi, int ti);  

15. };  

16.   

17. void MergeSort:: merge(int raw[], int *merged, int si, int mi, int ti){  

18.   

19.     //把已近排序号的si-mi,mi-ti两个序列赋值给raw  

20.     for(int t=si;t<=ti;t++)  

21.             raw[t]=merged[t];  

22.     //归并  

23.     int i=si,j=mi+1,k=si;  

24.     for(;i<=mi&&j<=ti;){  

25.         if(raw[i]<=raw[j]) merged[k++]=raw[i++];  

26.         else merged[k++]=raw[j++];  

27.     }  

28.     if(i<=mi)  

29.         for(int x=i;x<=mi;)  

30.             merged[k++]=raw[x++];  

31.     if(j<=ti)  

32.         for(int y=j;y<=ti;)  

33.             merged[k++]=raw[y++];  

34. }  

35. //划分  

36. void MergeSort:: merge_sort(int raw[], int *merged, int s, int t){  

37.   

38.     if(s==t) merged[s]=raw[s];  

39.     else{  

40.         int m=(s+t)/2;  

41.         MergeSort::merge_sort(raw, merged, s, m);  

42.         MergeSort::merge_sort(raw, merged, m+1,t);  

43.         MergeSort::merge(raw, merged, s,m,t);  

44.     }  

45. }  

46.   

47. void MergeSort:: inc_sort(int keys[],int size){  

48.   

49.     int * merged=(int *)malloc(size*sizeof(int));  

50.     MergeSort::merge_sort(keys,merged,0,size-1);  

51.     //打印排序结果  

52.     for(int i=0;i<size;i++)  

53.         cout<<merged[i]<<" ";  

54.     cout<<endl;  

55.   

56.     free(merged);  

57. }  

58. //Test MergeSort  

59. void main(){  

60.     int raw[]={49,38,65,97,76,13,27,49};     

61.     int size=sizeof(raw)/sizeof(int);       

62.     MergeSort::inc_sort(raw,size);      

63. }  

 

 代价分析:
上图可以看出,一个N关键字的序列,两两归并可以构造一棵高度为[logN]的归并排序树。而每一次归并的时间复杂度为O(m+n)。因此每一趟归并的时间复杂度为O(N),如上图。归并排序算法的总时间复杂度为O(N*logN) 。其所需要的辅助空间就是一个能容纳中间合并结果的数量为N的连续空间。因此空间复杂度为O(N) 。是稳定排序方法 。

 

 

【排序结构5】 基于比较的内部排序总结

★ 基于“比较”操作的内部排序性能大PK

 

我们首先总结一下《排序结构专题1-4》中的十种方法的性能((N个关键字的待排序列)):

排序方法       

平均时间  

最坏时间   

辅助存储空间

  稳定性  

 

直接插入排序

O(N^2)  

O(N^2)   

O(1)           

    √     

折半插入排序

O(N^2)

O(N^2) 

O(1) 

    √

希尔排序 

O(N*logN)

O(N*logN)

O(1)  

     ×

 

起泡排序       

O(N^2)

O(N^2)     

O(1)          

      √     

快速排序

O(N*logN)

O(N^2)

O(logN)

      ×

 

简单选择排序    

O(N^2)        

O(N^2)        

O(1)              

      √       

树形选择排序

O(N*logN)

O(N*logN)

O(N)

      √

堆排序

O(N*logN)

O(N*logN)

O(1)

      ×

 

归并排序               

O(N*logN)       

O(N*logN)          

O(N)                        

√          

 

1、 O(N^2) 级别的普通排序算法,我们用C++ 的随机函数rand() 产生的随机数进行排序,并计算耗费时间。

其中分别随机生成1W,3W,5W... 19W(增量为2W)共十组待排序列进行测试。得到直接插入排序、折半插入排序、起泡排序、简单选择排序的耗时统计图如下所示(SPSS软件做图统计)。

 

 

从上图可以发现,起泡排序的耗时最大,其他三者的耗时差不多。其中折半插入排序在待排数据量达到19W以后,其性能要比直接插入排序,和简单排序要好一些。另外,在数据量较小的情况下,插入排序的性能要比选择排序要略好。

 

普通算法分析:在数据规模较小时(9W之内),折半插入、直接插入、简单选择插入差不多。当数据量较大时,折半插入要好一些。而起泡排序算法的时间代价是最昂贵的。 另外,普通排序算法基本上都是相邻元素进行比较,因此O(N^2)基本的排序算法都是稳定的。

 

2、O(N*logN) 级别的先进排序算法,其时间复杂度要比普通算法要快得多。由于数据本身要小的多,因此我们没有拿它们和普通算法进行比较,而是另外选择从10W——140W(增量10W)的15组数据进行测试,耗时性能比较如下(SPSS软件做图统计):

 

从上图可以发现,先进排序的耗时代价远远小于普通排序算法。而先进算法之间也有区别。其中快速排序无疑是最优秀的。其次是归并排序和希尔排序,堆排序稍微差一些,而最差的就是树形选择排序了。

 

先进算法分析:

(1) 就时间性能而言, 希尔排序、快速排序、树形选择排序、堆排序和归并排序都是较为先进的排序方法。耗时远小于O(N^2)级别的算法。

 

(2) 先进算法之中,快排的效率是最高的。 但其缺点十分明显:在待排序列基本有序的情况下,会蜕化成起泡排序,时间复杂度接近 O(N^2)。

 

(3) 希尔排序的性能让人有点意外,这种增量插入排序的高效性完全说明了:在基本有序序列中,直接插入排序绝对能达到令人吃惊的效率。但是希尔排序对增量的选择标准依然没有较为满意的答案,要知道增量的选取直接影响排序的效率。

 

(4) 归并排序的效率非常不错,在数据规模较大的情况下,它比希尔排序和堆排序都要好。

 

(5)堆排序在数据规模较小的情况下还是表现不错的,但是随着规模的增大,时间代价也开始和上面两种排序拉开的距离。

 

(6)树形选择排序并不是较好的先进排序方法,数据规模越大,其耗时代价越高。而且它所需要的额外辅助空间较多,达到O(N)级别。想想看,排序140W数据,需要额外再开辟140W的空间,实在是无法忍受。

 

(7) 多数先进排序都因为跳跃式的比较,降低了比较次数,但是也牺牲了排序的稳定性。

 

 

总的来说,并不存在“最佳”的排序算法。必须针对待排序列自身的特点来选择“良好”的算法。下面有一些指导性的意见:

(1) 数据规模很小,而且待排序列基本有序的情况下,选择直接插入排序绝对是上策。不要小看它O(N^2)级别。

(2) 数据规模不是很大,完全可以使用内存空间。而且待排序列杂乱无序(越乱越开心),快排永远是不错的选择,当然付出log(N)的额外空间是值得的。

(3) 海量级别的数据,必须按块存放在外存(磁盘)中。此时的归并排序是一个比较优秀的算法。

 

附:以上两个图的数据测试在Pentium 4 CPU 3.06GHZ下,CPU占用率0%的情况下运行的结果。 另外,下面是我测试九种排序算法的C源代码,可供大家下载使用。

 

★ 一个关于O(N*logN)耗时下限的理论

这里有一个疑问:是不是O(N*logN)是排序算法时间代价最好的极限呢?

 

当然不是,但是如果排序算法是基于"关键字比较"操作的,那么在最坏情况下确实能够到达的最好效果就是O(N*logN)了。 在最好情况下就没必要说了,如果待排序列基本有序,那么直接插入排序的比较次数都非常的少。

 

下面我们来证明一下(注意:这些排序算法的基本操作就是比较,其时间主要消耗在比较次数上)。现在有三个关键字K1、K2、K3。那么下图给出了这三个关键字记录在任何可能的排序状态下的判定树,树中的内部结点都进行了一次必要的比较。

三个关键字的待排序列只有上面叶子结点所描述的6中排序状态。而判定树上的每一次比较都是必须的。因此、这个判定树足以描述通过“比较”进行的排序过程。并且,每一个待排序列经过排序达到有序序列所需要进行的"比较"次数,恰为从树根到叶子结点的路径长度。因此3个关键字的比较最少需要2次,最多需要3次。

 

扩展一下,有N个关键字序列。那么就有N!种排序状态,自然判定树就有N!个叶子节点。我们知道,二叉树的树高为h的情况下,叶子结点最多有2^(h-1)个。而现在又N!个叶子结点,那么树高至少为log(N!)+1。也就是说,描述N个记录排序的判定树必存在一条长度为[log(N!)+1]的路径。根据斯特林公式(n!的高精度近似求解公式): log(N!)=N*log(N)。因此,最少的比较次数也就是N*log(N)了。

 

基于比较操作的排序算法的时间复杂度下限确实是O(N*logN)。那么如果不比较呢,耗时代价会不会进一步减少。当然,关于这方面的排序算法,请见《桶排序 》、《基数排序 》。

·         排序算法性能测试C源码.rar (3.3 KB)

·         下载次数: 33

 

 

【排序结构6】 桶排序

从《基于比较的排序结构总结 》中我们知道:全依赖“比较”操作的排序算法时间复杂度的一个下界O(N*logN)。但确实存在更快的算法。这些算法并不是不用“比较”操作,也不是想办法将比较操作的次数减少到 logN。而是利用对待排数据的某些限定性假设 ,来避免绝大多数的“比较”操作。桶排序就是这样的原理。

 

桶排序的基本思想

       假设有一组长度为N的待排关键字序列K[1....n]。首先将这个序列划分成M个的子区间(桶) 。然后基于某种映射函数 ,将待排序列的关键字k映射到第i个桶中(即桶数组B的下标 i) ,那么该关键字k就作为B[i]中的元素(每个桶B[i]都是一组大小为N/M的序列)。接着对每个桶B[i]中的所有元素进行比较排序(可以使用快排)。然后依次枚举输出B[0]....B[M]中的全部内容即是一个有序序列。

 

[桶—关键字]映射函数

      bindex=f(key)   其中,bindex 为桶数组B的下标(即第bindex个桶), k为待排序列的关键字。桶排序之所以能够高效,其关键在于这个映射函数,它必须做到:如果关键字k1<k2,那么f(k1)<=f(k2)。也就是说B(i)中的最小数据都要大于B(i-1)中最大数据。很显然,映射函数的确定与数据本身的特点有很大的关系,我们下面举个例子:

 

假如待排序列K= {49、 38 、 35、 97 、 76、 73 、 27、 49 }。这些数据全部在1—100之间。因此我们定制10个桶,然后确定映射函数f(k)=k/10。则第一个关键字49将定位到第4个桶中(49/10=4)。依次将所有关键字全部堆入桶中,并在每个非空的桶中进行快速排序后得到如下图所示:

                                                       

对上图只要顺序输出每个B[i]中的数据就可以得到有序序列了。

 

桶排序代价分析

桶排序利用函数的映射关系,减少了几乎所有的比较工作。实际上,桶排序的f(k)值的计算,其作用就相当于快排中划分,已经把大量数据分割成了基本有序的数据块(桶)。然后只需要对桶中的少量数据做先进的比较排序即可。

 

对N个关键字进行桶排序的时间复杂度分为两个部分:

(1) 循环计算每个关键字的桶映射函数,这个时间复杂度是O(N)。

(2) 利用先进的比较排序算法对每个桶内的所有数据进行排序,其时间复杂度为  ∑ O(Ni*logNi) 。其中Ni 为第i个桶的数据量。

 

很显然,第(2)部分是桶排序性能好坏的决定因素。尽量减少桶内数据的数量是提高效率的唯一办法(因为基于比较排序的最好平均时间复杂度只能达到O(N*logN)了)。因此,我们需要尽量做到下面两点:

(1) 映射函数f(k)能够将N个数据平均的分配到M个桶中,这样每个桶就有[N/M]个数据量。

(2) 尽量的增大桶的数量。极限情况下每个桶只能得到一个数据,这样就完全避开了桶内数据的“比较”排序操作。 当然,做到这一点很不容易,数据量巨大的情况下,f(k)函数会使得桶集合的数量巨大,空间浪费严重。这就是一个时间代价和空间代价的权衡问题了。

 

对于N个待排数据,M个桶,平均每个桶[N/M]个数据的桶排序平均时间复杂度为:

             O(N)+O(M*(N/M)*log(N/M))=O(N+N*(logN-logM))=O(N+N*logN-N*logM)

当N=M时,即极限情况下每个桶只有一个数据时。桶排序的最好效率能够达到O(N)。

 

总结: 桶排序的平均时间复杂度为线性的O(N+C),其中C=N*(logN-logM)。如果相对于同样的N,桶数量M越大,其效率越高,最好的时间复杂度达到O(N)。 当然桶排序的空间复杂度 为O(N+M),如果输入数据非常庞大,而桶的数量也非常多,则空间代价无疑是昂贵的。此外,桶排序是稳定的。

 

其实我个人还有一个感受:在查找算法中,基于比较的查找算法最好的时间复杂度也是O(logN)。比如折半查找、平衡二叉树、红黑树等。但是Hash表却有O(C)线性级别的查找效率(不冲突情况下查找效率达到O(1))。大家好好体会一下:Hash表的思想和桶排序是不是有一曲同工之妙呢?

 

 

桶排序在海量数据中的应用

 

一年的全国高考考生人数为500 万,分数使用标准分,最低100 ,最高900 ,没有小数,你把这500 万元素的数组排个序。

 

分析:对500W数据排序,如果基于比较的先进排序,平均比较次数为O(5000000*log5000000)≈1.112亿。但是我们发现,这些数据都有特殊的条件:  100=<score<=900。那么我们就可以考虑桶排序这样一个“投机取巧”的办法、让其在毫秒级别就完成500万排序。

 

方法:创建801(900-100)个桶。将每个考生的分数丢进f(score)=score-100的桶中。这个过程从头到尾遍历一遍数据只需要500W次。然后根据桶号大小依次将桶中数值输出,即可以得到一个有序的序列。而且可以很容易的得到100分有***人,501分有***人。

 

实际上,桶排序对数据的条件有特殊要求,如果上面的分数不是从100-900,而是从0-2亿,那么分配2亿个桶显然是不可能的。所以桶排序有其局限性,适合元素值集合并不大的情况。

 

 

源代码

Cpp代码  

1.  #include<iostream.h>  

2.  #include<malloc.h>  

3.    

4.  typedef struct node{  

5.      int key;  

6.      struct node * next;  

7.  }KeyNode;  

8.    

9.  void inc_sort(int keys[],int size,int bucket_size){  

10.     KeyNode **bucket_table=(KeyNode **)malloc(bucket_size*sizeof(KeyNode *));  

11.     for(int i=0;i<bucket_size;i++){  

12.         bucket_table[i]=(KeyNode *)malloc(sizeof(KeyNode));  

13.         bucket_table[i]->key=0; //记录当前桶中的数据量  

14.         bucket_table[i]->next=NULL;  

15.     }  

16.     for(int j=0;j<size;j++){  

17.         KeyNode *node=(KeyNode *)malloc(sizeof(KeyNode));  

18.         node->key=keys[j];  

19.         node->next=NULL;  

20.         //映射函数计算桶号  

21.         int index=keys[j]/10;  

22.         //初始化P成为桶中数据链表的头指针  

23.         KeyNode *p=bucket_table[index];  

24.         //该桶中还没有数据  

25.         if(p->key==0){  

26.             bucket_table[index]->next=node;  

27.             (bucket_table[index]->key)++;  

28.         }else{  

29.             //链表结构的插入排序  

30.             while(p->next!=NULL&&p->next->key<=node->key)  

31.                 p=p->next;     

32.             node->next=p->next;  

33.             p->next=node;  

34.             (bucket_table[index]->key)++;  

35.         }  

36.     }  

37.     //打印结果  

38.     for(int b=0;b<bucket_size;b++)  

39.         for(KeyNode *k=bucket_table[b]->next; k!=NULL; k=k->next)  

40.             cout<<k->key<<" ";  

41.     cout<<endl;  

42. }  

43.   

44. void main(){  

45.     int raw[]={49,38,65,97,76,13,27,49};     

46.     int size=sizeof(raw)/sizeof(int);     

47.     inc_sort(raw,size,10);  

48. }  

 

 上面源代码的桶内数据排序,我们使用了基于单链表的直接插入排序算法。可以使用基于双向链表的快排算法提高效率。

 

 

【排序结构7】 基数排序

《桶排序 》中我们能够看到,数据值的范围越大,可能需要桶的个数也就越多,空间代价也就越高。对于上亿单位的关键字,桶排序是很不实用的。基数排序是对桶排序的一种改进,这种改进是让“桶排序”适合于更大的元素值集合的情况,而不是提高性能。

多关键字排序问题(类似于字典序):

 

我们先看看扑克牌的例子。一张牌有两个关键字组成:花色(桃<心<梅<方)+面值(2<3<4<...<A)。假如一张牌的大小首先被花色决定,同花色的牌有数字决定的话。我们就有两种算法来解决这个问题。

 

(1) 首先按照花色对所有牌进行稳定排序,这样就可以将所有牌分成4组。然后同组的牌(同花色)再按照面值进行排序。

(2) 首先按照面值对所有牌进行稳定排序,然后按照花色再次对所有牌进行稳定排序。

 

显然,第一种方法需要将序列分割成几个子序列。而第二种方法则完全不需要。因此我们采用从次关键字排序开始的方法。

 

基数排序

 

上面的问题是多关键字的排序,但单关键字也仍然可以使用这种方式。

比如字符串“abcd” “aesc” "dwsc" "rews"就可以把每个字符看成一个关键字。另外还有整数 425、321、235、432也可以每个位上的数字为一个关键字。

 

基数排序的思想就是将待排数据中的每组关键字依次进行桶分配。比如下面的待排序列:

                 278、109、063、930、589、184、505、269、008、083

我们将每个数值的个位,十位,百位分成三个关键字: 278 -> k1(个位)=8  ,k2(十位)=7 ,k3=(百位)=2。

然后从最低位个位开始(从最次关键字开始),对所有数据的k1关键字进行桶分配(因为,每个数字都是 0-9的,因此桶大小为10),再依次输出桶中的数据得到下面的序列。

                       930、063、083、184、505、278、008、109、589、269

再对上面的序列接着进行针对k2的桶分配,输出序列为:

                       505、008、109、930、063、269、278、083、184、589

最后针对k3的桶分配,输出序列为:

                       008、063、083、109、184、269、278、505、589、930

 

性能分析

 

很明显,基数排序的性能比桶排序要略差。每一次关键字的桶分配都需要O(N)的时间复杂度,而且分配之后得到新的关键字序列又需要O(N)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2N) ,当然d要远远小于N,因此基本上还是线性级别的。基数排序的空间复杂度为O(N+M),其中M为桶的数量。一般来说N>>M,因此额外空间需要大概N个左右。

 

但是,对比桶排序,基数排序每次需要的桶的数量并不多。而且基数排序几乎不需要任何“比较”操作,而桶排序在桶相对较少的情况下,桶内多个数据必须进行基于比较操作的排序。因此,在实际应用中,基数排序的应用范围更加广泛。

 

http://c.chinaitlab.com/special/cpxsf/index.html

http://www.cnblogs.com/ziyiFly/archive/2008/09/10/1288516.html

http://ardenyu.iteye.com/blog/143005

http://dongxicheng.org/structure/sort/

 

1. 概述

排序算法是计算机技术中最基本的算法,许多复杂算法都会用到排序。尽管各种排序算法都已被封装成库函数供程序员使用,但了解排序算法的思想和原理,对于编写高质量的软件,显得非常重要。

本文介绍了常见的排序算法,从算法思想,复杂度和使用场景等方面做了总结。

2. 几个概念

(1)排序稳定:如果两个数相同,对他们进行的排序结果为他们的相对顺序不变。例如A={1,2,1,2,1}这里排序之后是A = {1,1,1,2,2} 稳定就是排序后第一个1就是排序前的第一个1,第二个1就是排序前第二个1,第三个1就是排序前的第三个1。同理2也是一样。不稳定就是他们的顺序与开始顺序不一致。

(2)原地排序:指不申请多余的空间进行的排序,就是在原来的排序数据中比较和交换的排序。例如快速排序,堆排序等都是原地排序,合并排序,计数排序等不是原地排序。

总体上说,排序算法有两种设计思路,一种是基于比较,另一种不是基于比较。《算法导论》一书给出了这样一个证明:“基于比较的算法的最优时间复杂度是O(N lg N)”。对于基于比较的算法,有三种设计思路,分别为:插入排序,交换排序和选择排序。非基于比较的排序算法时间复杂度为O(lg N),之所以复杂度如此低,是因为它们一般对排序数据有特殊要求。如计数排序要求数据范围不会太大,基数排序要求数据可以分解成多个属性等。

3. 基于比较的排序算法

正如前一节介绍的,基于比较的排序算法有三种设计思路,分别为插入,交换和选择。对于插入排序,主要有直接插入排序,希尔排序;对于交换排序,主要有冒泡排序,快速排序;对于选择排序,主要有简单选择排序,堆排序;其它排序:归并排序。

3.1  插入排序

(1) 直接插入排序

特点:稳定排序,原地排序,时间复杂度O(N*N)

思想:将所有待排序数据分成两个序列,一个是有序序列S,另一个是待排序序列U,初始时,S为空,U为所有数据组成的数列,然后依次将U中的数据插到有序序列S中,直到U变为空。

适用场景:当数据已经基本有序时,采用插入排序可以明显减少数据交换和数据移动次数,进而提升排序效率。

(2)希尔排序

特点:非稳定排序,原地排序,时间复杂度O(n^lamda)(1 < lamda < 2), lamda和每次步长选择有关。

思想:增量缩小排序。先将序列按增量划分为元素个数近似的若干组,使用直接插入排序法对每组进行排序,然后不断缩小增量直至为1,最后使用直接插入排序完成排序。

适用场景:因为增量初始值不容易选择,所以该算法不常用。

3.2  交换排序

(1)冒泡排序

特点:稳定排序,原地排序,时间复杂度O(N*N)

思想:将整个序列分为无序和有序两个子序列,不断通过交换较大元素至无序子序列首完成排序。

适用场景:同直接插入排序类似

(2)快速排序

特点:不稳定排序,原地排序,时间复杂度O(N*lg N)

思想:不断寻找一个序列的枢轴点,然后分别把小于和大于枢轴点的数据移到枢轴点两边,然后在两边数列中继续这样的操作,直至全部序列排序完成。

适用场景:应用很广泛,差不多各种语言均提供了快排API

3.3  选择排序

(1)简单选择排序

特点:不稳定排序(比如对3 3 2三个数进行排序,第一个3会与2交换),原地排序,时间复杂度O(N*N)

思想:将序列划分为无序和有序两个子序列,寻找无序序列中的最小(大)值和无序序列的首元素交换,有序区扩大一个,循环下去,最终完成全部排序。

适用场景:交换少

(2) 堆排序

特点:非稳定排序,原地排序,时间复杂度O(N*lg N)

思想:小顶堆或者大顶堆

适用场景:不如快排广泛

3.4  其它排序

(1) 归并排序

特点:稳定排序,非原地排序,时间复杂度O(N*N)

思想:首先,将整个序列(共N个元素)看成N个有序子序列,然后依次合并相邻的两个子序列,这样一直下去,直至变成一个整体有序的序列。

适用场景:外部排序

4. 非基于比较的排序算法

非基于比较的排序算法主要有三种,分别为:基数排序,桶排序和计数排序。这些算法均是针对特殊数据的,不如要求数据分布均匀,数据偏差不会太大。采用的思想均是内存换时间,因而全是非原地排序。

4.1 基数排序

特点:稳定排序,非原地排序,时间复杂度O(N)

思想:把每个数据看成d个属性组成,依次按照d个属性对数据排序(每轮排序可采用计数排序),复杂度为O(d*N)

适用场景:数据明显有几个关键字或者几个属性组成

4.2  桶排序

特点:稳定排序,非原地排序,时间复杂度O(N)

思想:将数据按大小分到若干个桶(比如链表)里面,每个桶内部采用简单排序算法进行排序。

适用场景:0

4.3  计数排序

特点:稳定排序,非原地排序,时间复杂度O(N)

思想:对每个数据出现次数进行技术(用hash方法计数,最简单的hash是数组!),然后从大到小或者从小到大输出每个数据。

使用场景:比基数排序和桶排序广泛得多。

5.  总结

对于基于比较的排序算法,大部分简单排序(直接插入排序,选择排序和冒泡排序)都是稳定排序,选择排序除外;大部分高级排序(除简单排序以外的)都是不稳定排序,归并排序除外,但归并排序需要额外的存储空间。对于非基于比较的排序算法,它们都对数据规律有特殊要求 ,且采用了内存换时间的思想。排序算法如此之多,往往需要根据实际应用选择最适合的排序算法。

6.  参考资料

(1)博文《排序算法总结》:

http://www.cppblog.com/shongbee2/archive/2009/04/25/81058.html

(2)博文:《八大排序算法》:

http://blog.csdn.net/yexinghai/archive/2009/10/10/4649923.aspx

 

 

排序算法的性质:

 

排序算法的稳定性:

 (1)冒泡排序

        冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,我想你是不会再无聊地把他们俩交换一下的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。

(2)选择排序

      选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n-1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果当前元素比一个元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。比较拗口,举个例子,序列5 8 5 2 9, 我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了,所以选择排序不是一个稳定的排序算法。

(3)插入排序
     插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,就是第一个元素。比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面,否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的。

(4)快速排序
    快速排序有两个方向,左边的i下标一直往右走,当a[i] <= a[center_index],其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]。如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i>j。 交换a[j]和a[center_index],完成一趟快速排序。在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为 5 3 3 4 3 8 9 10 11, 现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j]交换的时刻。

(5)归并排序
    归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。

(6)基数排序
   基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序,最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以其是稳定的排序算法。

(7)希尔排序(shell)
    希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比o(n^2)好一些。由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。

(8)堆排序
   我们知道堆的结构是节点i的孩子为2*i和2*i+1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。在一个长为n的序列,堆排序的过程是从第n/2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。但当为n/2-1, n/2-2, ...1这些个父节点选择元素时,就会破坏稳定性。有可能第n/2个父节点交换把后面一个元素交换过去了,而第n/2-1个父节点把后面一个相同的元素没有交换,那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序不是稳定的排序算法。

综上,得出结论: 选择排序、快速排序、希尔排序、堆排序不是稳定的排序算法,而冒泡排序、插入排序、归并排序和基数排序是稳定的排序算法。还有一些排序算法我没有进行

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(面试-排序算法)