Java排序--排序算法

Posted 码农周某人

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java排序--排序算法相关的知识,希望对你有一定的参考价值。

常用内排序算法

  我们通常所说的排序算法往往指的是内部排序算法,即需要排序的数据在计算机内存中完成整个排序的过程,当数据率超大或排序较为繁琐时常借助于计算机的硬盘对大数据进行排序工作,称之为外部排序算法

  排序算法大体可分为两种:

  1. 比较排序:时间复杂度O(nlogn) ~ O(n^2),主要有:冒泡排序选择排序插入排序归并排序堆排序快速排序等。
  2. 非比较排序:时间复杂度可以达到O(n),主要有:基数排序基数排序桶排序等。

  常见比较排序算法的性能:

  

  • 有一点我们很容易忽略的是排序算法的稳定性

  排序算法稳定性的简单形式化定义为:如果Ai = Aj,排序前Ai在Aj之前,排序后Ai还在Aj之前,则称这种排序算法是稳定的。通俗地讲就是保证排序前后两个相等的数的相对顺序不变。

  对于不稳定的排序算法,只要举出一个实例,即可说明它的不稳定性;而对于稳定的排序算法,必须对算法进行分析从而得到稳定的特性。需要注意的是,排序算法是否为稳定的是由具体算法决定的,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。

  例如,对于冒泡排序,原本是稳定的排序算法,如果将记录交换的条件改成A[i] >= A[i + 1],则两个相等的记录就会交换位置,从而变成不稳定的排序算法。

  其次,说一下排序算法稳定性的好处。排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,前一个键排序的结果可以为后一个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位排序后元素的顺序在高位也相同时是不会改变的。

一、冒泡排序(Bubble Sort)

  冒泡排序是一种极其简单的排序算法,也是我所学的第一个排序算法。它重复地走访过要排序的元素,依次比较相邻两个元素,如果他们的顺序错误就把他们调换过来,直到没有元素再需要交换,排序完成。这个算法的名字由来是因为越小(或越大)的元素会经由交换慢慢“浮”到数列的顶端。

  冒泡排序的基本思想是,对相邻的元素进行两两比较,顺序相反则进行交换,这样,每一趟会将最小或最大的元素“浮”到顶端,最终达到完全

  代码实现

  在冒泡排序的过程中,如果某一趟执行完毕,没有做任何一次交换操作,比如数组[5,4,1,2,3],执行了两次冒泡,也就是两次外循环之后,分别将5和4调整到最终位置[1,2,3,4,5]。此时,再执行第三次循环后,一次交换都没有做,这就说明剩下的序列已经是有序的,排序操作也就可以完成了,来看下代码 

 1 /**
 2  * 冒泡排序
 3  * author tag
 4  */
 5  public static void bubbleSort(int[] arr) {
 6    for (int i = 0; i < arr.length - 1; i++) {
 7        boolean flag = true;//设定一个标记,若为true,则表示此次循环没有进行交换,也就是待排序列已经有序,排序已然完成。
 8             for (int j = 0; j < arr.length - 1 - i; j++) {
 9                 if (arr[j] > arr[j + 1]) {
10                     swap(arr,j,j+1);
11                     flag = false;
12                 }
13             }
14             if (flag) {
15                 break;
16             }
17      }
18  }
19             

  上述代码对序列{ 6, 5, 3, 1, 8, 7, 2, 4 }进行冒泡排序的实现过程如下

  

  使用冒泡排序为一列数字进行排序的过程如上图所示: 

  根据上面这种冒泡实现,若原数组本身就是有序的(这是最好情况),仅需n-1次比较就可完成;若是倒序,比较次数为 n-1+n-2+...+1=n(n-1)/2,交换次数和比较次数等值。所以,其时间复杂度依然为O(n2)。综合来看,冒泡排序性能还还是稍差于上面那种选择排序的。

1.冒泡排序的改进:鸡尾酒排序

  鸡尾酒排序,也叫定向冒泡排序,是冒泡排序的一种改进。此算法与冒泡排序的不同处在于从低到高然后从高到低,而冒泡排序则仅从低到高去比较序列里的每个元素。他可以得到比冒泡排序稍微好一点的效能。

  鸡尾酒排序的代码如下:

 1 /**
 2  * 鸡尾酒排序
 3  * @param arr
 4  */
 5 public static void cocktailSort(int[] arr) {
 6     int left = 0, right = arr.length - 1;
 7     while (left < right) {
 8         boolean flag = true;
 9         for (int i = left; i < right; i++) {
10             if (arr[i] > arr[i + 1]) {
11                 int temp = arr[i];
12                 arr[i] = arr[i + 1];
13                 arr[i + 1] = temp;
14                 flag = false;
15             }
16         }
17         right--;
18         if (flag) {
19             break; // 当数组有序时减少一半无用的循环
20         } else {
21             flag = true;
22         }
23         for (int i = right; i > left; i--) {
24             if (arr[i] < arr[i - 1]) {
25                 int temp = arr[i];
26                 arr[i] = arr[i - 1];
27                 arr[i - 1] = temp;
28                 flag = false;
29             }
30         }
31         left++;
32         if (flag) {
33             break; // 当数组有序时减少一半无用的循环
34         }
35     }
36 }

   使用鸡尾酒排序为一列数字进行排序的过程如下图所示:

  

  以序列(2,3,4,5,1)为例,鸡尾酒排序只需要访问“一次”序列就可以完成排序,但如果使用冒泡排序则需要四次。但是在乱数序列的状态下,鸡尾酒排序与冒泡排序的效率都很差劲。上述两个排序算法效果差异不大,只是鸡尾酒排序每次都通过来回交换缩小范围,而普通冒泡排序每次都从头开始,所以总的循环次数是差不多的。


二、选择排序(Selection Sort)

  选择排序是最简单直观的一种算法,基本思想为每一趟从待排序的数据元素中选择最小(或最大)的一个元素作为首元素,直到所有元素排完为止,简单选择排序是不稳定排序。   

  在算法实现时,每一趟确定最小元素的时候会通过不断地比较交换来使得首位置为当前最小,交换是个比较耗时的操作。其实我们很容易发现,在还未完全确定当前最小元素之前,这些交换都是无意义的。我们可以通过设置一个变量min,每一次比较仅存储较小元素的数组下标,当轮循环结束之后,那这个变量存储的就是当前最小元素的下标,此时再执行交换操作即可。

  选择排序的代码如下:

 1 /**
 2  * 简单选择排序
 3  *
 4  * @param arr
 5  */
 6  public static void selectSort(int[] arr) {
 7     for (int i = 0; i < arr.length - 1; i++) {
 8         int min = i;//每一趟循环比较时,min用于存放较小元素的数组下标,这样当前批次比较完毕最终存放的就是此趟内最小的元素的下标,避免每次遇到较小元素都要进行交换。
 9         for (int j = i + 1; j < arr.length; j++) {
10             if (arr[j] < arr[min]) {
11                 min = j;
12             }
13         }
14         //进行交换,如果min发生变化,则进行交换
15         if (min != i) {
16             swap(arr,min,i);
17         }
18     }
19  } 

  上述代码对序列{ 8, 5, 2, 6, 9, 3, 1, 4, 0, 7 }进行选择排序的实现过程如下图

       

  选择排序是不稳定的排序算法,不稳定发生在最小元素与A[i]交换的时刻。

  比如序列:{ 5, 8, 5, 2, 9 },一次选择的最小元素是2,然后把2和第一个5进行交换,从而改变了两个元素5的相对次序。

  简单选择排序通过上面优化之后,无论数组原始排列如何,比较次数是不变的;对于交换操作,在最好情况下也就是数组完全有序的时候,无需任何交换移动,在最差情况下,也就是数组倒序的时候,交换次数为n-1次。综合下来,时间复杂度为O(n2)。


三、插入排序(Insertion Sort)

  直接插入排序基本思想是每一步将一个待排序的记录,插入到前面已经排好序的有序序列中去,直到插完所有元素为止。


  插入排序的代码如下:

 1 /**
 2  * 插入排序
 3  *
 4  * @param arr
 5  */
 6  public static void insertionSort(int[] arr) {
 7     for (int i = 1; i < arr.length; i++) {
 8         int j = i;
 9         while (j > 0 && arr[j] < arr[j - 1]) {
10             swap(arr,j,j-1);
11             j--;
12          }
13     }
14  }

  上述代码对序列{ 6, 5, 3, 1, 8, 7, 2, 4 }进行插入排序的实现过程如下

     

  使用插入排序为一列数字进行排序的宏观过程如上图:

  简单插入排序在最好情况下,需要比较n-1次,无需交换元素,时间复杂度为O(n);在最坏情况下,时间复杂度依然为O(n2)。但是在数组元素随机排列的情况下,插入排序还是要优于上面两种排序的。

1.插入排序的改进:二分插入排序

  对于插入排序,如果比较操作的代价比交换操作大的话,可以采用二分查找法来减少比较操作的次数,我们称为二分插入排序,代码如下:

 1 /**
 2  * 二分插入排序
 3  * @param arr
 4  */
 5 public static void binaryInsertSort(int[] arr) {
 6     for (int i = 1; i < arr.length; i++) {
 7         int get = arr[i];  // 右手抓到一张扑克牌
 8         int left = 0;  // 拿在左手上的牌总是排序好的,所以可以用二分法
 9         int right = i - 1;  // 手牌左右边界进行初始化
10         while (left <= right) { // 采用二分法定位新牌的位置
11             int mid = (left + right) / 2;
12             if (arr[mid] > get)
13                 right = mid - 1;
14             else
15                 left = mid + 1;
16         }
17         for (int j = i - 1; j >= left; j--){  // 将欲插入新牌位置右边的牌整体向右移动一个单位
18             arr[j + 1] = arr[j];
19         }
20         arr[left] = get; // 将抓到的牌插入手牌
21     }
22 }

  当n较大时,二分插入排序的比较次数比直接插入排序的最差情况好得多,但比直接插入排序的最好情况要差,所当以元素初始序列已经接近升序时,直接插入排序比二分插入排序比较次数少。二分插入排序元素移动次数与直接插入排序相同,依赖于元素初始序列。


四、插入排序的更高效改进:希尔排序(Shell Sort)

  希尔排序是希尔(Donald Shell)于1959年提出的一种排序算法。希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序,同时该算法是冲破O(n2)的第一批算法之一。

  a.基本思想

  简单插入排序很循规蹈矩,不管数组分布是怎么样的,依然一步一步的对元素进行比较,移动,插入,比如[5,4,3,2,1,0]这种倒序序列,数组末端的0要回到首位置很是费劲,比较和移动元素均需n-1次。而希尔排序在数组中采用跳跃式分组的策略,通过某个增量将数组元素划分为若干组,然后分组进行插入排序,随后逐步缩小增量,继续按组进行插入排序操作,直至增量为1。希尔排序通过这种策略使得整个数组在初始阶段达到从宏观上看基本有序,小的基本在前,大的基本在后。然后缩小增量,到增量为1时,其实多数情况下只需微调即可,不会涉及过多的数据移动。

  我们来看下希尔排序的基本步骤,在此我们选择增量gap=length/2,缩小增量继续以gap = gap/2的方式,这种增量选择我们可以用一个序列来表示,{n/2,(n/2)/2...1},称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我们选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。此处我们做示例使用希尔增量。

  b.代码实现  

  在希尔排序的理解时,我们倾向于对于每一个分组,逐组进行处理,但在代码实现中,我们可以不用这么按部就班地处理完一组再调转回来处理下一组(这样还得加个for循环去处理分组)比如[5,4,3,2,1,0] ,首次增量设gap=length/2=3,则为3组[5,2] [4,1] [3,0],实现时不用循环按组处理,我们可以从第gap个元素开始,逐个跨组处理。同时,在插入数据时,可以采用元素交换法寻找最终位置,也可以采用数组元素移动法寻觅。希尔排序的代码比较简单,如下:

 1 package sortdemo;
 2 
 3 import java.util.Arrays;
 4 
 5 /**
 6  * author tag
 7  */
 8 public class ShellSort {
 9     public static void main(String []args){
10         int []arr ={1,4,2,7,9,8,3,6};
11         sort(arr);
12         System.out.println(Arrays.toString(arr));
13         int []arr1 ={1,4,2,7,9,8,3,6};
14         sort1(arr1);
15         System.out.println(Arrays.toString(arr1));
16     }
17 
18     /**
19      * 希尔排序 针对有序序列在插入时采用交换法
20      * @param arr
21      */
22     public static void sort(int []arr){
23         //增量gap,并逐步缩小增量
24        for(int gap=arr.length/2;gap>0;gap/=2){
25            //从第gap个元素,逐个对其所在组进行直接插入排序操作
26            for(int i=gap;i<arr.length;i++){
27                int j = i;
28                while(j-gap>=0 && arr[j]<arr[j-gap]){
29                    //插入排序采用交换法
30                    swap(arr,j,j-gap);
31                    j-=gap;
32                }
33            }
34        }
35     }
36 
37     /**
38      * 希尔排序 针对有序序列在插入时采用移动法。
39      * @param arr
40      */
41     public static void sort1(int []arr){
42         //增量gap,并逐步缩小增量
43         for(int gap=arr.length/2;gap>0;gap/=2){
44             //从第gap个元素,逐个对其所在组进行直接插入排序操作
45             for(int i=gap;i<arr.length;i++){
46                 int j = i;
47                 int temp = arr[j];
48                 if(arr[j]<arr[j-gap]){
49                     while(j-gap>=0 && temp<arr[j-gap]){
50                         //移动法
51                         arr[j] = arr[j-gap];
52                         j-=gap;
53                     }
54                     arr[j] = temp;
55                 }
56             }
57         }
58     }
59     /**
60      * 交换数组元素
61      * @param arr
62      * @param a
63      * @param b
64      */
65     public static void swap(int []arr,int a,int b){
66         arr[a] = arr[a]+arr[b];
67         arr[b] = arr[a]-arr[b];
68         arr[a] = arr[a]-arr[b];
69     }
70 }

   以23, 10, 4, 1的步长序列进行希尔排序如下图:

  

  希尔排序是不稳定的排序算法,虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱。

  比如序列:{ 3, 5, 10, 8, 7, 2, 8, 1, 20, 6 },h=2时分成两个子序列 { 3, 10, 7, 8, 20 } 和  { 5, 8, 2, 1, 6 } ,未排序之前第二个子序列中的8在前面,现在对两个子序列进行插入排序,得到 { 3, 7810, 20 } 和 { 12568 } ,即 { 3, 1, 7, 2, 8, 5, 10, 6, 20, 8 } ,两个8的相对次序发生了改变。

  本文介绍了希尔排序的基本思想及其代码实现,希尔排序中对于增量序列的选择十分重要,直接影响到希尔排序的性能。我们上面选择的增量序列{n/2,(n/2)/2...1}(希尔增量),其最坏时间复杂度依然为O(n2),一些经过优化的增量序列如Hibbard经过复杂证明可使得最坏时间复杂度为O(n3/2)。


 五、归并排序(Merge Sort)

  归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之)。

  a.分而治之

  

   可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。阶段可以理解为就是递归拆分子序列的过程,递归深度为log2n。

  b.合并相邻有序子序列

  再来看看阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现

  

 1 package com.tag;
 2 
 3 import java.util.Arrays;
 4 
 5 /**
 6  * author tag
 7  */
 8 public class MergeSort {
 9     public static void main(String []args){
10         int []arr = {9,8,7,6,5,4,3,2,1};
11         sort(arr);
12         System.out.println(Arrays.toString(arr));
13     }
14     public static void sort(int []arr){
15         int []temp = new int[arr.length];//在排序前,先建好一个长度等于原数组长度的临时数组,避免递归中频繁开辟空间
16         sort(arr,0,arr.length-1,temp);
17     }
18     private static void sort(int[] arr,int left,int right,int []temp){
19         if(left<right){
20             int mid = (left+right)/2;
21             sort(arr,left,mid,temp);//左边归并排序,使得左子序列有序
22             sort(arr,mid+1,right,temp);//右边归并排序,使得右子序列有序
23             merge(arr,left,mid,right,temp);//将两个有序子数组合并操作
24         }
25     }
26     private static void merge(int[] arr,int left,int mid,int right,int[] temp){
27         int i = left;//左序列指针
28         int j = mid+1;//右序列指针
29         int t = 0;//临时数组指针
30         while (i<=mid && j<=right){
31             if(arr[i]<=arr[j]){
32                 temp[t++] = arr[i++];
33             }else {
34                 temp[t++] = arr[j++];
35             }
36         }
37         while(i<=mid){//将左边剩余元素填充进temp中
38             temp[t++] = arr[i++];
39         }
40         while(j<=right){//将右序列剩余元素填充进temp中
41             temp[t++] = arr[j++];
42         }
43         t = 0;
44         //将temp中的元素全部拷贝到原数组中
45         while(left <= right){
46             arr[left++] = temp[t++];
47         }
48     }
49 }
执行结果:[1, 2, 3, 4, 5, 6, 7, 8, 9]

  上述代码对序列{ 6, 5, 3, 1, 8, 7, 2, 4 }进行归并排序的实例如下 

       

  使用归并排序为一列数字进行排序的宏观过程如上图:


六、堆排序(Heap Sort)

  堆排序是利用这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。首先简单了解下堆结构。

  堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:

同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子

该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:

大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2] 

小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2] 

ok,了解了这些定义。接下来,我们来看看堆排序的基本思想及基本步骤:

堆排序基本思想及步骤

  堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了

步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。

a.假设给定无序序列结构如下

<

以上是关于Java排序--排序算法的主要内容,如果未能解决你的问题,请参考以下文章

算法排序之堆排序

排序算法之冒泡选择插入排序(Java)

排序算法之冒泡选择插入排序(Java)

Java数据结构—排序算法

java排序算法

Java常用的八种排序算法与代码实现