常见排序算法思路总结与性能分析

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u010414589/article/details/89817462

在这里插入图片描述

1. 冒泡排序

1.1 冒泡排序思路

每次循环,从数组第一个元素开始,对数组内的元素两两比较,若array[i]>array[i+1] 则元素交换并向后遍历,经过一轮遍历之后最大值移动到了数组尾部。经过 n-1 轮循环即可完成排序。

public void BubbleSort(int[] array,int n){
    if(n<=1) return;
    //外层循环n-1次
    for(int i=0;i<n-1;i++){
        for(int j=0;j<n-i-1;j++){
            if(array[j]>array[j+1]){
                int tmp = array[j];
                array[j] = array[j+1];
                array[j+1] =tmp;
            }
        }
    }
}

1.2 优化思路

在上面的分析中,在数组元素完全有序之前,每一轮都会做元素的交换。也即是说,当我们进行到某一轮循环,整个过程中没有出现元素的交换,表明数组已经完全有序了,即可以终止外层循环。

 public void BubbleSort2(int[] array,int n){
     if(n<=1) return;
     int flag = 1;
     //外层循环n-1次
     for(int i=0;i<n-1;i++){
         if(flag==0) break;
         flag = 0;
         for(int j=0;j<n-i-1;j++){
             if(array[j]>array[j+1]){
                 int tmp = array[j];
                 array[j] = array[j+1];
                 array[j+1] =tmp;
                 flag = 1;
             }
         }
     }
 }

1.3 算法性能分析

  • 时间复杂度分析
    • 最好情况时间复杂度:对于冒泡排序的最好情况是数组元素完全有序,如 1,2,3,4,5,此时基于优化的冒泡排序只需要遍历一次,因此最好时间复杂度为O(n)。
    • 最坏情况时间复杂度:最坏情况是数组元素完全逆序,如5,4,3,2,1,那么需要执行n-1次循环,每个内层循环中第一次执行n-1个操作,第二次执行n-2个操作,最后一次执行1个操作;总的操作数为 1+2+3+…+(n-1) = n(n-1)/2个操作。另外说明完全逆序情况下每个操作中均包含了比较和交换。因此最坏时间复杂度为 O ( n 2 ) O(n^2)
    • 平均时间复杂度:在上文的分析中我们发现“交换”操作是数组元素从无序到有序的必然步骤,对于完全有序的数组交换次数为0次,完全逆序的数组交换次数为n(n-1)/2次,那么平均状态下的数组交换次数为n(n-1)/4次。此外还有比较次数,比较次数大于交换次数,同时小于完全逆序的比较次数n(n-1)/4。因此平均时间复杂度为 O ( n 2 ) O(n^2)
  • 空间复杂度分析
    • 冒泡排序属于原地排序算法。所谓原地排序算法,是指不需要申请多余的存储空间,在自身数组中通过交换和比较完成排序的算法。因此原地排序算法空间复杂度为O(1)。
  • 稳定性分析
    • 所谓稳定,是指在原有排序数据中存在相同数值的时候,经过排序后相同数值的元素相对次序仍然不变。对于冒泡排序,我们只要在遇到相同数值元素的时候不经过交换,即可保持算法稳定性。因此冒泡排序属于稳定性算法。

2. 插入算法

2.1 插入算法思路

插入算法是把一个数组分为两部分,前面部分是已排序的部分,后面部分是未排序的部分。每次循环是从未排序部分取出第一个元素,插入到已排序部分中。

 public void insertionSort(int[] array,int n){
     if(n<=1) return;
     int i,j;

     for(i=1;i<n;i++){
         int tmp = array[i];
         for(j=i-1;j>=0;j--){
             if(array[j]>tmp)
                 array[j+1]=array[j];
             else
                 break;
         }
         array[j+1] = tmp;
     }
 }

2.2 算法性能分析

  • 时间复杂度分析
    • 最好情况时间复杂度:最好情况即数组元素完全有序,每次循环仅进行了一次元素比较,不涉及到元素移动。循环执行了n-1 次,因此时间复杂度为O(n)。
    • 最坏情况时间复杂度:最坏情况是数组元素完全逆序,每次循环都要进行元素的比较和移动;且第一次循环执行一个操作,第二次执行2个操作,…,第n-1次执行n-1个操作。总的操作数为(n-1) = n(n-1)/2个操作。因此时间复杂度为 O ( n 2 ) O(n^2)
    • 平均情况时间复杂度:在冒泡排序中分析平均情况时间复杂度时,我们介绍到数组序列的有序度是和交换次数等价的,然后我们拿平均情况下的交换次数来代表平均有序度的数组序列。在插入算法中,没有直接的元素交换操作,而是元素移动操作。同样的,完全有序的数组移动次数为0,完全逆序的数组移动次数为 n(n-1)/2,那么平均状态下的数组移动次数为n(n-1)/4次。因此时间复杂度也为 O ( n 2 ) O(n^2) 。这里还要说明的一点是,虽然冒泡排序和插入排序在平均情况时间复杂度均为 O ( n 2 ) O(n^2) ,但冒泡排序中涉及的是交换操作,插入排序涉及的是移动操作,交换操作通常是移动操作的三倍时间,也即是说在同等复杂度下,通常插入排序时间上更快一些。
    • 性能测试待补充。验证冒泡排序、优化冒泡排序、插入排序时间
  • 空间复杂度分析
    • 插入排序属于原地排序算法。因此空间复杂度为O(1)。
  • 稳定性分析
    • 对于插入排序,我们只要在遇到相同数值元素的时候不移动元素,即可保持算法稳定性。因此插入排序属于稳定性算法。

3. 选择排序

3.1 选择排序思路

可以把数组看作已排序部分和未排序部分,初始状态全部是未排序部分。

每次循环从未排序部分选择出最小值,与未排序的第一个元素交换,交换完成后归入已排序部分。

public static void sort(int[] array ,int n){
    if(n<=1) return ;

    for(int i=0;i<n-1;i++){
        int min = i;
        for(int j=i+1;j<n;j++){
            if(array[min]>array[j]){
                min = j;
            }
        }
        swap(array,i,min);
    }
}

3.2 算法性能分析

  • 时间复杂度分析:选择排序算法无论数组元素完全有序或是完全逆序,都需要进行 n(n-1)/2次比较操作,因此它的时间复杂度为 O ( n 2 ) O(n^2)
  • 空间复杂度分析:选择排序算法依然是原地排序算法,空间复杂度为O(1)。
  • 稳定性分析:选择排序算法中元素交换会破坏稳定性。以[5,4,5,2,6]为例,排序后为[2,4,5,5,6], 此时两两5 已经改变了相对次序。因此选择排序算法是不稳定性的算法。

4. 希尔排序

4.1 希尔排序思路

希尔排序是直接插入排序的改进版,希尔排序算法中有一个增量(gap)的概念,也称为缩小增量排序算法。

希尔排序的过程是基于数组下标的增量进行分组,对每组内进行直接插入排序;之后缩小增量,重新分组,并对每组内进行直接插入排序;直到增量为1,完全变成直接插入排序。

为什么要这样设计呢?主要是基于直接插入排序的特点来改进的。我们知道直接插入排序比较适合数据量小,数组基本有序的情况。那么希尔排序在增量大的时候,每组元素很少,直接插入排序会很快且交换元素移动距离大;当增量小时,虽然每组元素增多,但已经逐渐基本有序,同样直接插入排序会很快。因此整体的算法排序性能比原始直接插入排序性能好。

 public void shellSort(int[] array){
     int n = array.length;
     int gap = n;

     while(gap>1){
         gap = gap/3+1;
         for(int i= gap;i<n;i++){
             int tmp = array[i];
             int j = i-gap;
             while(j>=0 && array[j]>tmp){
                 array[j+gap] = array[j];
                 j = j-gap;
             }
             array[j+gap] = tmp;
         }
     }
 }

4.2 算法性能分析

  • 时间复杂度分析:希尔排序的时间复杂度分析较为复杂,取决于增量序列的选取。Shell 最初提出这个算法时采用 gap=n/2 向下取整,直到为1。实际上导致增量序列中各增量都是倍数关系,奇数位置的元素直到增量为1时才会和偶数位置的元素进行比较,效率不够优化,有文章证明希尔增量的时间复杂度仍为 O ( n 2 ) O(n^2) 。。上文我们使用的是叫Hibbard增量序列,同样证明很复杂,这里直接给出结论为 O ( n 3 2 ) O(n^{\frac 3 2}) 。感兴趣的可以自己再去了解一下。
  • 空间复杂度分析:希尔排序属于原地排序算法。
  • 稳定性分析:我们知道直接插入排序是稳定的算法。希尔排序在增量降为1时为直接插入排序,所有元素在一个组内,我们可以控制算法的稳定性。但希尔排序在增量大于1时是把元素分配到不同的组中,使得相同的元素在不同组内相对次序发生改变成为可能。因此希尔排序是不稳定的排序算法。

5. 快速排序

5.1 快速排序思路

快速排序是一种分治算法,它的步骤主要有三步:1. 找到分区点;2. 对分区点左侧区域进行排序;3. 对分区点右侧区域进行排序。

public void quickSort(int[] array,int low,int high){
    if(low>=high) return;
    int mid = partition(array,low,high);
    quickSort(array,low,mid-1);
    quickSort(array,mid+1,high);
}

可以看出快速排序的关键步骤是编写分区函数partition来快速找到分区点,之后两个步骤分别在递归调用即可。

下面我们介绍分区函数的设计以及中轴选择。

5.2 分区函数

我们在课本中学到的快速排序最经典的分区函数是采用双向扫描法的思想:设置两个指针low和high,low从左向右扫描,high从右向左扫描,当low大于等于high终止。扫描过程中是首先保存最高位的中轴值,然后拿low指针所在元素与中轴值比较,若小于中轴值则一直向右移动,直到大于等于中轴值则将所在元素值赋给high指针所在位置;相应的,high指针所在元素与中轴值比较,若大于中轴值则一直向左移动,直到小于等于中轴值则将所在元素值赋值给low指针所在位置。最后循环结束将提前保存的中轴值赋给low所在位置,并返回low下标。

实现代码如下所示:

public int partition(int[] array,int low,int high){
    int tmp = array[high];
    while(low<high){
        while(low<high && array[low]<tmp){
            low++;
        }
        if(low<high && array[low]>=tmp){
            array[high--] = array[low];
        }
        while(low<high && array[high]>tmp){
            high--;
        }
        if(low<high && array[high]<=tmp){
            array[low++] = array[high];
        }
    }
    array[low] = tmp;
    return low;
}

注意事项:

  1. 一定要注意边界,在整个循环过程中要加上low<high 的条件,否则就越出边界。
  2. 在里面的两个while循环中,分别有array[low]<tmp 和 array[high]>tmp , 这里不要写成<=或>=。原因是当数组内重复值特别多时,会导致分区点严重偏向一侧。举例来说10个元素的数组[4,5,5,5,5,5,5,5,5,5],若写成<=或>=,第一次的分区点为下标9,导致分区两侧完全不均衡;接下来的分区不均衡情况也会一直如此,导致算法的时间复杂度会变为 O ( n 2 ) O(n^2)

还要一种思路是采用单向扫描法,顾名思义指针向一个方向扫描。这种方法的思想是把数组分为两部分,左边部分都是小于中轴值的元素,右边部分都是大于等于中轴值的部分,初始状态下左边部分为空。然后从左至右扫描,若当前原始小于中轴值,则将该元素与右边第一个元素交换,并加入到左边部分;若大于等于中轴值,则继续右移。循环结束后将右边第一个元素与最高位交换,并返回右边第一个元素位置。

public int partition(int[] array,int low,int high){
    int left = low-1;
    int cur = low;
    int tmp = array[high];
    while(cur<high){
        if(array[cur]<tmp){
            swap(array,++left,cur++);
        }else{
            cur++;
        }
    }
    swap(array,left+1,high);
    return left+1;
}

这种思路很简洁,但是一定要明白它的思想,才能熟练运用。

这种思路的最大缺点是对重复值比较多的数组效果很差。仍以[4,5,5,5,5,5,5,5,5,5]为例,我们分析发现它第一次分区位置为1,分的两个区间为[4]和[5,5,5,5,5,5,5,5];之后的分区过程都是极其不均衡的分区。导致性能很差。而重复值在实际情况下是非常普通的,因此这种思路不建议直接使用。

那么我们对这个算法进行优化,要把重复值考虑在内。优化思路中我们要把数组看作三部分,左边部分是小于中轴值的元素,中间部分是等于中轴值的元素,右侧部分是大于中轴值的元素。分区函数最后返回的是两个值,分别是左边最右侧的元素下标和右边最左侧的元素下标。

以[9,8,3,6,5,1,5]为例,经过分区函数之后应该变为[1,3,5,5,6,8,9],并返回下标[1,4]。之后的排序区间为[1,3]和[6,8,9]。

直接看优化后的实现:

public void quickSort(int[] array,int low,int high){
    if(low>=high) return;
    //这里partition返回类型发生了改变
    int[] mid = partition(array,low,high);
    quickSort(array,low,mid[0]);
    quickSort(array,mid[1],high);
}

public int[] partition(int[] array,int low,int high){
    int left = low-1;
    int right = high+1;
    int cur = low;
    int tmp = array[high];
    while(cur<right){
        if(array[cur]<tmp){
            swap(array,++left,cur++);
        }else if(array[cur]>tmp){
            swap(array,cur,--right);
        }else{
            cur++;
        }
    }
    return new int[]{left,right};
}

如果了解荷兰国旗问题的朋友,这里应该已经看出来了,这种思路其实就是应用的荷兰国旗思路来实现的。对荷兰国旗问题感兴趣,可以点击这个链接学习。一文学习荷兰国旗问题

5.3 中轴的选择

在4.2小节中我们默认选择中轴为最高位元素。但这种方式不是最优的思路。因为我们知道,快速排序的时间复杂度取决于分区函数分区之后两个区间的均衡程度。若两个区间完全均衡,规模相等,是最优的时间复杂度O(logn)(最后一节会专门介绍时间复杂度分析)。若两个区间完全不均衡,则时间复杂度会变为 O ( n 2 ) O(n^2)

而区间是否均衡和中轴选择关系密切,比如选择最高位为中轴,而数组是基本有序的数组就会出现这种情况。

应用最广泛的中轴选择方法是采用三数中值法。即分别取数组的最低位、最高位和中间元素进行比较,找到中间值并将它与最高位交换。

 public void dealpivot(int[] array,int low,int high){
     int mid = low+(high-low)/2;
     if(array[low]>=array[mid]){
         swap(array,low,mid);
     }
     if(array[low]>=array[high]){
         swap(array,low,high);
     }
     //此时low位置已经是最小值
     //保证high位置为中间值
     if(array[mid]<=array[high]){
         swap(array,mid,high);
     }
 }

5.4 算法性能分析

  • 时间复杂度分析:通过上文我们了解到快速排序最好情况是通过分区函数实现两个分区均衡的情况下,然后我们通过三数取中法可以去避免出现分区极度不均衡的情况。下面我们按照均衡的情况进行分析:

    设总的时间为T(n),那么T(n)包含三部分,分别是分区函数执行时间 n 和两个区间继续执行排序的时间T(n/2)。即有下面推导

T ( n ) = 2 T ( n / 2 ) + n = 4 T ( n / 4 ) + 2 n = 8 T ( n / 8 ) + 3 n = . . . = 2 k T ( n / 2 k ) + k n T(n) = 2*T(n/2)+n=4T(n/4)+2n=8T(n/8)+3n=...=2^kT(n/2^k)+kn

n / 2 k = 1 n/2^k = 1

​ 得到k=logn, 带入T(n) = nlogn+Cn,其中T(1)=C。因此快速排序时间复杂度为O(nlogn)。

  • 空间复杂度分析:快速排序本身不需要申请多余的内存空间,属于原地排序算法,只是在递归调用中也需要借助栈空间,至多为O(logn)。

  • 稳定性分析:快速排序涉及到非相邻元素的交换,破坏了数据的稳定性。属于非稳定的排序算法。

6. 归并排序

6.1 归并排序思想

归并排序和快速排序一样,也属于分治算法。归并排序也是三个步骤:以数组中间元素划分为两个区域,对左侧进行归并排序,对右侧进行归并排序,将各自排好序的左右两个区间进行合并。表达成代码如下所示:

public void mergeSort(int[] array,int low,int high){
    if(low<high){
        int mid = low+(high-low)/2;
        mergeSort(array,low,mid);
        mergeSort(array,mid+1,high);
        merge(array,low,mid,high);
    }
}

其中merge方法就是实现对两个有序数组的合并。代码如下:

public void merge(int[] array,int low,int mid,int high){
    int n = high-low+1;
    int[] tmp = new int[n];
    int i = 0;
    int p = low;
    int q = mid+1;
    while(p<=mid && q<=high){
        if(array[p]<=array[q]){
            tmp[i++] = array[p++];
        }else{
            tmp[i++] = array[q++];
        }
    }
    while(p<=mid){
        tmp[i++] = array[p++];
    }
    while(q<=high){
        tmp[i++] = array[q++];
    }
    for(i=0;i<n;i++){
        array[low+i] = tmp[i];
    }
}

6.2 算法性能分析

  • 时间复杂度分析:和快速排序的时间复杂度分析类似,归并排序的时间也是包含两个子区间的归并排序时间和对两个子区间合并的算法时间。公式表示T(n)= 2T(n/2)+n ,同快排推导方式一样,最后我们可以推出归并排序的时间复杂度为O(nlogn)。值得注意的是,归并排序是直接按照中间位置划分的两个子区间,无论数组有序程度如何,都需要执行相同的两个子区间的归并排序时间和对两个子区间合并的算法时间,因此归并排序可以说最优、最坏和平均情况均为O(nlogn)。
  • 空间复杂度分析:在对两个有序的子区间做merge函数时,需要借助O(n)的空间来存储数组元素。因此算法空间复杂度为O(n)。
  • 稳定性分析:归并排序的稳定性,体现在merge函数时左侧区间的值遇到小于等于的值,都先存储到临时数组中,就能保证数组的稳定性。因此归并排序属于稳定排序算法。

猜你喜欢

转载自blog.csdn.net/u010414589/article/details/89817462
今日推荐