基于比较的排序算法

465 阅读8分钟

1. 常见的排序

1.1 一些概念

  排序: 排序是将一组数据按照特定规则进行排列的过程,使得数据具有一定的有序性。

  稳定性: 如果待排序的数据中有两个相等的元素,它们在排序后的相对位置不会改变那么这种排序就是稳定的。

  内部排序: 数据元素全部放在内存中的排序。

  外部排序: 数据元素太多不能同时放在内存中,根据排序过程的要求在内外存之间移动数据的排序。

yuque_diagram.jpg

2. 插入排序

2.1 直接插入排序

  插入排序是一种简单直观的排序算法,其基本思路是将一个待排序的序列分为已排序和未排序两部分,每次将未排序部分的第一个元素插入到已排序部分的正确位置中。

Insertion-sort-example.gif

(ps:图片来自维基百科)

/**
 * 直接插入排序
 */
public static void insertionSort(int[] arr){
    int n = arr.length;
    for (int i = 1; i < n; i++) {

        int key = arr[i];//未排序部分的第一个元素
        int j = i - 1;// j管理已经排好序的部分
        //遍历已排序部分
        while(j >= 0 && arr[j] > key){
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

时间复杂度:

最好情况:当待排序序列已经有序时,直接插入排序每次只需要将当前元素插入到已排序序列的末尾,时间复杂度为O(n)

最坏情况:当待排序序列是逆序的时,直接插入排序需要进行n-1轮比较和移动,时间复杂度为O(n^2)

空间复杂度: O(1)

稳定性: 稳定

2.2 希尔排序

  希尔排序是一种插入排序的改进版本,其基本思想是将待排序的元素按照一定的间隔分成若干个子序列,分别进行插入排序,然后逐步缩小间隔直至为1,最后进行一次完整的插入排序。

排序.drawio.png

  可以看到上面的重复元素 5 排序过后的顺序不一样了,所以该排序是不稳定的。

  这里的gap(间隔)怎么取呢?这里用Knuth序列来确定间隔序列,即 gap=gap/3gap = gap/3,首先我们得要根据数组的长度来计算 gap 是从哪开始。

/**
 * 希尔排序
 */
public static void shellSort(int[] arr){
    int n = arr.length;
    int gap = 1;

    //不断将 gap 乘以 3 并加 1,直到 gap 大于等于数组长度的三分之一
    while (gap < (n / 3)){
        gap = gap * 3 + 1; //计算间隔序列
    }
    while (gap >= 1){
        //对每一组进行直接插入排序,注意这里是 i++
        for (int i = gap; i < n; i++) {
            int key = arr[i];
            int j = i - gap;
            while(j >= 0 && arr[j] > key){
                arr[j + gap] = arr[j];
                j -= gap;
            }
            arr[j + gap] = key;
        }
        gap /= 3;// 缩小间隔序列
    }
}

时间复杂度: 希尔排序的平均时间复杂度会根据间隔的不同而不同,很难分析;根据科学家的研究,它的平均复杂度在 O(n1.3)O(n^{1.3}) 左右(《数据结构(C语言版)》--- 严蔚敏)。

平均时间复杂度:O(n1.3)O(n^{1.3})

空间复杂度: O(1)O(1)

稳定性: 不稳定

3 选择排序

3.1 直接选择排序

  每次在待排序元素中选择最小(或最大)的一个元素,然后将其放在待排序序列的起始位置,重复这个过程,直到整个序列有序。

Selection-Sort-Animation.gif (图片来自维基百科)

/**
 * 直接选择排序
 */
public static void selectionSort(int[] arr){

    int n = arr.length;
    int minIndex = 0;//标记最小的位置

    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if(arr[j] < arr[minIndex]){
                minIndex = j;
            }
        }
        //将最小元素放在待排序序列的起始位置
        int tmp = arr[minIndex];
        arr[minIndex] = arr[i];
        arr[i] = tmp;
    }
}

时间复杂度:

  无论待排序序列的状态如何,选择排序每次都需要遍历未排序的元素来寻找最小值,因此无法利用已经有序的信息。最好情况和最坏情况时间复杂度均为 O(n2)O(n^2)

空间复杂度: O(1)O(1)

稳定性: 不稳定

3.2 堆排序

(没有学过堆相关的知识点可能有点难理解堆排序👉)Java中的优先级队列 PriorityQueue 与 堆 - 掘金 (juejin.cn)

  堆排序是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆排降序建小堆

  基本思想是先将待排序元素构建成一个最大堆或最小堆,然后依次将堆顶元素与堆底元素交换(相当于删除),并重新调整堆,直到整个序列有序。

演示文稿1.1.gif

  删除操作完后,整个数组就是升序了。

/**
 * 堆排序
 */

public static void heapSort(int[] arr){
    //升序建大根堆
    creatHeap(arr);  //O(n)

    int end = arr.length - 1;//最后一个位置的下标
    while(end > 0){
        //堆顶堆底互相交换(相当与删除)
        swap(arr,0,end);
        //交换后,向下调整,为什么不是先end--?因为向下调整调整下标的区间是[0,n-1]
        shiftDown(arr,0,end);
        end--;
    }
}

//建堆
public static void creatHeap(int[] arr){
    int n = arr.length;
    for (int parent = (n - 1 - 1)/2; parent >= 0; parent--) {
        shiftDown(arr,parent,n);
    }
}

//向下调整
public static void shiftDown(int[] arr,int parent,int n){

    int child = (2 * parent) + 1;
    while(child < n){
        if(child + 1 < n && arr[child] < arr[child + 1]){
            child++;
        }
        if(arr[child] > arr[parent]){
            swap(arr,child,parent);
            //向下移动
            parent = child;
            child = 2 * parent + 1;
        }else {
            break;
        }
    }
}

public static void swap(int[] arr,int x,int y){
    int tmp = arr[x];
    arr[x] = arr[y];
    arr[y] = tmp;
}

时间复杂度: 无论待排序序列的状态如何,堆排序的时间复杂度始终为 O(nlog2n)O(nlog_2n) ,无论最好情况还是最坏情况。

  堆排序的主要时间消耗集中在建堆和调整堆的过程中,建堆为O(n)O(n),调整堆为O(log2n)O(log_2n),要调整n个节点,那么它的复杂度就是O(n+nlog2n)O(n + nlog_2n),就等价于O(nlog2n)O(nlog_2n)

空间复杂度: O(1)O(1)

稳定性: 不稳定

4. 交换排序

4.1 冒泡排序

  它的基本思想是重复地遍历要排序的数列,每次比较相邻的两个元素,如果顺序错误就交换它们的位置,直到没有任何一对数字需要交换位置为止。

  1. 比较相邻的元素。如果第一个比第二个大,就交换它们的位置。
  2. 对每一对相邻元素进行比较,从开始第一对到结尾最后一对,这样在最后的元素应该是最大的数。
  3. 针对所有的元素重复以上步骤,除了最后一个。
  4. 重复步骤1-3,直到排序完成。
/**
 * 冒泡排序
 *
 */
public static void bubbleSort(int[] arr){
    int n = arr.length;
    for (int i = 0; i < n - 1; i++) {
        boolean flag = false;
        for (int j = 0; j < n - 1 - i; j++) {
            if(arr[j] > arr[j+1]){
                swap(arr,j,j+1);
                flag = true;
            }
        }
        //说明已经有序了
        if(!flag){
            break;
        }
    }
}

时间复杂度: 最坏的情况O(n2)O(n^2),最好的情况(有序)O(n)O(n)

空间复杂度: O(1)O(1)

稳定性: 稳定

4.2 快速排序

  基本思想是通过划分将待排序的数据分成两部分,一部分小于基准元素,一部分大于基准元素,然后对这两部分分别递归地进行快速排序,直到整个序列有序。

  1. 选择一个基准元素,一般选择第一个元素或者随机选择一个元素。
  2. 将待排序序列分成两部分,一部分小于基准元素,一部分大于基准元素,这个过程称为 partition。
  3. 对于两个子序列,重复步骤1-2,直到整个序列有序。

  实现时,通常采用双指针法,更详细的步骤:

  1. 从两端分别向中间扫描,当右指针指向的元素小于基准元素时(取第一个元素为基准),停止扫描,
  2. 当左指针指向的元素大于基准元素时,停止扫描,
  3. 然后交换左右指针所指向的元素。
  4. 重复以上步骤,直到左指针和右指针相遇,把基准值与相遇位置的值交换。
  5. 然后分别对左半部分和右半部分递归地进行快速排序。
public static void quickSort(int[] arr){
    quickSort(arr,0,arr.length - 1);
}

public static void quickSort(int[] arr, int l, int r) {
    //这里的大于号 预防 1,2,3,4,5等有序的情况
    if(l >= r){
        return;
    }
    int point = partition(arr,l,r);//进行交换调整,并返回分界点
    quickSort(arr,l,point -1);//递归左区间
    quickSort(arr,point+1,r);//递归右区间
}

private static int partition(int[] arr, int l, int r) {
    int index = l;//保存基数的位置
    int key = arr[l];
    while(l < r){
        //为什么让 right 先走,因为要保证相遇的位置是比 key 小,直到在left 与 right 相等时才与key交换
        //为什么要取等号? 预防死循环发生。
        while(l < r && arr[r] > key){
            r--;
        }
        while(l < r && arr[l] <= key){
            l++;
        }
        swap(arr,l,r);
    }
    swap(arr,l,index);//边界点与key交换
    return l;
}

时间复杂度:

最好情况:当待排序序列的基准值每次都能将序列分成两个长度相等的子序列时,快速排序的时间复杂度为O(nlog2n)O(nlog_2n)

最坏情况:当待排序序列已经是有序的,而且每次选取的基准值总是使得序列划分得最不均匀的时候,快速排序的时间复杂度为O(n2)O(n^2)

空间复杂度: O(log2n)O(log_2n),因为是递归的(消耗栈内存),最深的深度为O(log2n)O(log_2n),如果排序序列已经是有序的时候,空间复杂度为 O(n)O(n)

稳定性: 不稳定

快速排序可以优化:当它递归到比较小的区间的时候,剩下的区间用插入排序。

public static void quickSort(int[] arr, int l, int r) {
    //这里的大于号 预防 1,2,3,4,5
    if(l >= r){
        return;
    }

    //递归到比较小的区间的时候,剩下的区间用插入排序,这个区间的大小看数据量
    if((r - l + 1) <= 7){
        insertionSort(arr,l,r);
        return;
    }

    int point = partition(arr,l,r);//进行交换调整,并返回分界点;
    quickSort(arr,l,point -1);
    quickSort(arr,point+1,r);
}


//对 [l,r] 区间进行插入排序
public static void insertionSort(int[] arr,int l,int r){
    for (int i = l; i <= r; i++) {
        int key = arr[i];//未排序部分的第一个元素
        int j = i - 1;// j管理已经排好序的部分
        //遍历已排序部分
        while(j >= l && arr[j] > key){
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

5. 归并排序

  核心思想是将一个大问题分成若干个小问题,分别解决后再将其合并成一个解决了大问题的解。归并排序的思想可以概括为以下三个步骤:

  1. 分解:将待排序的数组递归地分解成两个子数组,直到每个子数组的长度为1为止。
  2. 合并:将分解得到的子数组两两合并,得到更大的有序数组。具体地,对于两个已经排好序的子数组,使用两个指针分别指向它们的开头,比较它们的元素大小,将较小的元素放入一个新的数组中,并将该指针后移一位,直到其中一个子数组的元素全部被放入新数组中,然后将另一个子数组的剩余元素全部放入新数组中。
  3. 归并:重复执行第二步,直到所有的子数组被合并成一个有序数组为止。
Merge-sort-example-300px.gif (图片来自维基百科)
/**
 * 归并排序
 */
public static void  mergeSort(int[] arr){
    mergeSort(arr,0, arr.length-1);
}

public static void mergeSort(int[] arr,int l,int r){
    if(l >= r){
        return;
    }
    int mid = (l + r) /2;
    mergeSort(arr,l,mid);// 递归处理左半边
    mergeSort(arr,mid + 1,r);// 递归处理右半边
    merge(arr,l,mid,r);// 合并左右半边
}

//合并两个有序数组
private static void merge(int[] arr,int left,int mid,int right){

    int[] tmp = new int[right - left + 1];//存储
    int k = 0;// tmp的下标

    int i = left;    //维护[left,mid]
    int j = mid + 1; //维护[mid + 1,right]

    while(i <= mid && j <= right){
        if(arr[i] < arr[j]){
            tmp[k++] = arr[i];
            i++;
        }else {
            tmp[k++] = arr[j];
            j++;
        }
    }
    //将剩下的装入tmp数组
    while(i <= mid){
        tmp[k++] = arr[i++];
    }
    while(j <= right){
        tmp[k++] = arr[j++];
    }
    //复原
    for (int m = 0; m < tmp.length; m++) {
        arr[left+m] = tmp[m];
    }
}

时间复杂度: 无论待排序序列的状态如何,归并排序都需要将序列分割成若干个子序列,然后将子序列合并成有序序列。归并排序的时间复杂度始终为 O(nlog2n)O(nlog_2n),无论最好情况还是最坏情况。

要递归 log2nlog_2n 层(树的高度),每一层都要遍历一次数组,那么时间复杂度就是 O(nlog2n)O(nlog_2n)

空间复杂度: 因为需要一个数组来存合并的数据,最后一次合并的长度是原数组的长度,所以空间复杂度为 O(n)O(n)

稳定性: 稳定

6. 时间复杂度汇总

排序方法最好平均最坏空间复杂度稳定性
冒泡排序O(n)O(n)O(n2)O(n^2)O(n2)O(n^2)O(1)O(1) 稳定
插入排序O(n)O(n)O(n2)O(n^2) O(n2)O(n^2) O(1)O(1) 稳定
选择排序O(n2)O(n^2) O(n2)O(n^2) O(n2)O(n^2)O(1)O(1)不稳定
希尔排序O(n1.32)O(n^{1.3\sim2}) O(1)O(1) 不稳定
堆排序O(nlog2(n))O(n log_2(n))O(nlog2(n))O(n log_2(n))O(nlog2(n))O(n log_2(n))O(1)O(1) 不稳定
快速排序O(nlog2(n))O(n log_2(n))O(nlog2(n))O(n log_2(n))O(n2)O(n^2)O(log2(n))O(log_2(n)) ~ O(n)O(n) 不稳定
归并排序O(nlog2(n))O(n log_2(n))O(nlog2(n))O(n log_2(n))O(nlog2(n))O(n log_2(n))O(n)O(n) 稳定