经典十大排序算法

前言

排序种类繁多,大致可以分为两大类:

  • 比较类排序:属于非线性时间排序,时间复杂度不能突破下界 (O(nlogn));

  • 非比较类排序:能达到线性时间 (O(n)),不是通过比较来排序,有基数排序、计数排序、桶排序。

了解一个概念:排序的稳定性

稳定是指相同大小的元素多次排序能保证其先后顺序保持不变。假设有一些学生的信息,我们先根据他们的姓名进行排序,然后我们还想根据班级再进行排序,如果这时使用的时不稳定的排序算法,那么第一次的排序结果可能会被打乱,这样的场景需要使用稳定的算法。

堆排序、快速排序、希尔排序、选择排序是不稳定的排序算法,而冒泡排序、插入排序、归并排序、基数排序是稳定的排序算法。

1、冒泡排序

大多数人学编程接触的第一种排序,名称很形象。每次遍历排出一个最大的元素,将一个最大的气泡冒出水面。

  • 时间复杂度:平均:(O(n^2));最好:(O(n));最坏:(O(n^2))
  • 空间复杂度:(O(1))
public static void bubbleSort(int[] arr) {
    /**
     * 总共走len-1趟即可,每趟排出一个最大值放在最后
     */
    for (int i = 0; i < arr.length - 1; i++) {
        for (int j = 0; j < arr.length - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int tp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = tp;
            }
        }
    }
}

2、选择排序

最直观易理解的排序算法,每次排出一个最小的元素。也是最稳定的算法,时间复杂度稳定为O(n^2)。需要一个变量记录每次遍历最小元素的位置。

  • 时间复杂度:(O(n^2))
  • 空间复杂度:(O(1))
public static void selectSort(int[] arr){
    int n = arr.length;
    for (int i = 0; i < n; i++) {
        int maxIdx = 0;
        for(int j = 1; j < n - i; j++){
            if(arr[maxIdx] < arr[j]){
                maxIdx = j;
            }
        }
        int tp = arr[maxIdx];
        arr[maxIdx] = arr[n - 1 - i];
        arr[n - 1 - i] = tp;
    }
}

3、插入排序

一种直观的排序算法,从第二个元素开始,每次往前面遍历找到自己该在的位置。

  • 时间复杂度:平均:(O(n^2));最好:(O(n));最坏:(O(n^2))
  • 空间复杂度:(O(1))
public static void insertSort(int[] arr){
    for (int i = 1; i < arr.length; i++) {
        for(int j = i; j > 0 && arr[j] < arr[j-1]; j--){
            int tp = arr[j];
            arr[j] = arr[j-1];
            arr[j-1] = tp;
        }
    }
}

4、希尔排序

是第一个突破O(n^2)复杂度的算法,是插入排序改进版,又称缩小增量排序。选择一个增量g,每次将数据按g分隔分成g组,每次对组内进行插入排序。每组排完后,将g/2继续直到g=1。一般选择g=len/2,但这不是最优选择,还有很多增量选择方案。

  • 时间复杂度:基于不同增量选择--O(n1.3~n2)
  • 空间复杂度:O(1)
public static void shellSort(int[] arr){
    for(int g = arr.length/2; g > 0; g /= 2){
        for(int i = g; i < arr.length; i++){
            for(int j = i; j - g >= 0 && arr[j] < arr[j - g]; j -= g){
                int tp = arr[j];
                arr[j] = arr[j-g];
                arr[j-g] = tp;
            }
        }
    }
}

5、快速排序

是冒泡排序的一种改进算法。

基本思想是:

  1. 在数据集之中,选择一个元素作为"基准"(pivot)。
  2. 所有小于"基准"的元素,都移到"基准"的左边;所有大于"基准"的元素,都移到"基准"的右边。
  3. 对"基准"左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。

经典的快排选择第一个数为基准。当排的是一个近乎有序的序列时,经典快排会退化为(O(n^2)),这时采用随机快速排序则比较恰当,随机快排是在排序的数中随机选择一个做基准。快排可以做到原地实现。

  • 时间复杂度:平均(理想):(O(nlogn));最坏:(O(n^2));

    每一层递归都需要比较n次,如果理想情况都是五五开,那么递归深度就是(logn),如果最坏的情况递归深度就是n。

  • 空间复杂度:每次快排需要一个额外的空间,因此空间复杂度=递归深度=(O(logn))或者(O(n)).

  • 实现:

    形象点叫-填坑法,这个实现是《算法》里提到的,我觉得最简洁。

    public static void quick(int a[], int lo, int hi){
        if( lo >= hi)   return;
    
        int x = a[lo];
        int i = lo, j = hi;
    
        while (i < j){
            while (i < j && a[j] > x){
                j--;
            }
            if(i < j){
                a[i++] = a[j];
            }
            while (i < j && a[i] < x){
                i++;
            }
            if(i < j){
                a[j--] = a[i];
            }
        }
        a[i] = x;
        quick(a, lo, i-1);
        quick1(a, i+1, hi);
    }
    
  • 多路快排:

    当序列中有大量重复元素时,原算法也会退化,这时需要双路快速排序,甚至是三路快排,三路快排在重复元素时表现出很好的性能,在普通的,非大量重复时,速度略慢于普通快排,不过在可接受范围,因此一般使用三路快排。

6、归并排序

归并的思想是先使每个子序列有序,再使得子序列合并后有序。与快排一样,都基于分治思想,不过快排在于分,而归并在于合。

是一个稳定的算法。一般归并的比较次数少于快排,而移动次数多于快排。(所以这是计算机中多用快排的原因,移动数据的代价更高)。对于本身具有一定有序性的序列,可以采用改进的归并,最好的情况能将复杂度降低至O(n).

归并还有一个用途:用于求逆序数。

  • 时间复杂度:一直是(O(nlogn))
  • 空间复杂度:(O(n)),需要一个一样大的辅助数组

《算法》上看到的写法,非常优雅:

static void sort(int[] arr, int lo, int hi, int[] temp){
    if(lo >= hi)  return;
    int mid = lo + (hi - lo)/2;
    // 小技巧,递归时将数组反过来复制,temp->arr,这样不用每次都将排好序的temp复制回arr
    sort(temp, lo, mid, arr);
    sort(temp, mid + 1, hi, arr);
    merge(arr, lo, mid, hi, temp);
}

static void merge(int[] arr, int lo, int mid, int hi, int[] temp) {
    int i = lo, j = mid + 1;
    for (int k = lo; k <= hi; k++) {
        if (i > mid)             temp[k] = arr[j++];
        else if(j > hi)          temp[k] = arr[i++];
        else if(arr[i] > arr[j]) temp[k] = arr[j++];
        else                     temp[k] = arr[i++];
    }
}

7、堆排序

这里特指二叉堆,二叉堆是一种完全二叉树,并且所有结点的值大于(小于)等于子节点的值,分别称为大(小)顶堆。下面以大顶堆为例讲述。

image-20210916170808527

首先我们要知道,完全二叉树和数组可以相互映射,通过下标可以找到节点的子节点,节点i的左子节点为:2i+1;右子节点为:2i+2。通常我们排序的是数组,就将其映射成完全二叉树来实现。

堆排序就是给定了一个无序的序列,先将其构造成一个大顶堆,然后将大顶堆的堆顶元素与最后一个元素交换,那么第一个数就排好了,然后逐个排序

  • 建堆过程

    常见的建堆有两种方法,一种是应用于堆元素已经确定了的,另外一种是针对元素动态增加的。

    对于元素已经确定的我们通常做法是这样的:

    a. 先找到这颗完全二叉树的最后一个非叶子节点的位置 k:(size/2 - 1);如上图就是 9/2-1=3.

    b. 以这个节点作为根节点构造一个二叉堆,找出该节点和其子节点中最大的节点,并将堆顶交换为最大的那个节点。

    c. 然后前移找前一个非叶子节点,也重复步骤b。直到整个树都被构造成二叉堆,堆就建好了。

    public static void buildHeap(int[] arr){
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            adjustHeap(arr, i, arr.length);
        }
    }
    

    关键是调整代码:

    // 截取原来的二叉树,调整以 i 为根节点的堆
    private static void adjustHeap(int[] arr, int i, int len){
        int left = 2*i + 1, right = 2*i +2;
        int maxIndex = i;
    
        if(left<len && arr[left] > arr[maxIndex]){
            maxIndex = left;
        }
        if(right<len && arr[right] > arr[maxIndex]){
            maxIndex = right;
        }
        if(maxIndex != i){
            swap(arr,maxIndex,i);
            adjustHeap(arr, maxIndex, len);
        }
    }
    
  • 排序

    建好堆后,堆顶就是最大的元素,我们取堆顶元素,将其与堆末尾元素交换,然后我们将原本堆的末尾元素剔除,堆元素减一,此时由于交换使得堆需要再次调整。调整完后堆顶元素即为第二大元素,然后交换到第二末尾的位置,剔除,调整堆...直到堆的大小变为1,排序完成。

完整代码:

public static void heapsort(int[] arr) {
    buildHeap(arr);
    for (int i = arr.length - 1; i > 0; i--) {
        swap(arr,0,i);
        adjustHeap(arr, 0, i);
    }
}

private static void buildHeap(int[] arr){
    // len/2-1 是最后一个非叶子节点的下标
    for (int i = arr.length / 2 - 1; i >= 0; i--) {
        adjustHeap(arr, i, arr.length);
    }
}

private static void adjustHeap(int[] arr, int i, int len){
    int left = 2*i + 1, right = 2*i +2;
    int maxIndex = i;

    if(left<len && arr[left] > arr[maxIndex]){
        maxIndex = left;
    }
    if(right<len && arr[right] > arr[maxIndex]){
        maxIndex = right;
    }
    if(maxIndex != i){
        swap(arr,maxIndex,i);
        adjustHeap(arr, maxIndex, len);
    }
}

private static void swap(int[] arr, int i, int j){
    int t = arr[i];
    arr[i] = arr[j];
    arr[j] = t;
}
  • 堆排序时间复杂度稳定在O(nlogn),看起来堆排序比快速排序还要好?

    但实际上计算机中排序还是没有采用它,它的实际效率还是低于快速排序。因为排序的时间不仅要考虑实际cpu计算的时间,还要考虑数据读取的时间,计算机中采用的cache机制使得一次不能读取很多数据,数据一般是分段读入cache的,快排进行计算时使用的数据具有良好的局部性(这些数据一般挨得很近),而堆排序由于经常同时操作树头和树尾的数据,这些数据通常隔得比较远,所以需要花很多时间在数据读取上,整体效率还下降了。

  • 时间复杂度:(O(nlogn))

    建堆的复杂度为(O(n)),排序时重建堆的复杂度为(O(nlogn)).

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

8、计数排序

计数排序是非比较型排序,适用于具有确定范围的序列。计数排序新建了一个k=maxval-minval大小的数组用来统计各个数出现次数。是一个稳定的排序算法,当序列比较集中时,它优于所有的比较排序算法。但当(O(k)>O(n*logn))时,效率反而不如比较型算法。

  • 时间复杂度:(O(n+k))
  • 空间复杂度:(O(k))
public static void countSort(int[] arr) {
  	// 假设arr[i]都在0-99范围内
    int[] space = new int[100];
    for (int i = 0; i < arr.length; i++) {
        space[arr[i]]++;
    }
    for (int i = 0, j = 0; i < space.length; i++) {
        while (space[i]-- > 0){
            arr[j++] = i;
        }
    }
}

9、桶排序

划分多个范围相同的区间,每个子区间自排序,最后合并

桶排序是计数排序的扩展版本,计数排序可以看成每个桶只存储相同元素。而桶排序每个桶存储一定范围的元素,通过映射函数,将待排序数组中的元素映射到各个对应的桶中,对每个桶中的元素进行排序,最后将非空桶中的元素逐个放入原序列中。

一般的做法是,找到排序的序列所在区间,平均分成几个区域(即几个桶),然后根据映射关系将数逐一放入桶内,对每个入桶的数据在该桶内还要进行排序(可以自选排序算法),然后依次取出桶中数据得到有序数列。

当数据服从均匀分布时,该算法线性时间O(n),如果数据很集中,当所有数据集中在同一个桶中时,桶排序就失去了意义。

桶排序的关键:

元素值域的划分,也就是元素到桶的映射规则。映射规则需要根据待排序集合的元素分布特性进行选择,若规则设计的过于模糊、宽泛,则可能导致待排序集合中所有元素全部映射到一个桶上,则桶排序向比较性质排序算法演变。若映射规则设计的过于具体、严苛,则可能导致待排序集合中每一个元素值映射到一个桶上,则桶排序向计数排序方式演化。

  • 时间复杂度:

    对于待排序序列大小为 N,共分为 M 个桶,主要步骤有:

    N 次循环,将每个元素装入对应的桶中

    M 次循环,对每个桶中的数据进行排序(平均每个桶有 N/M 个元素)

    假如桶内排序算法时间复杂度为(O(NlogN)),那么总复杂度为:

    (O(N)+O(M*(N/M*log(N/M)))=O(N+N*log(N/M)))

    当M=N时,复杂度则为O(N),完全退化为计数排序。

  • 空间复杂度:(O(N+M))

public static void bucketSort(int[] arr) {
  
    int max = Integer.MIN_VALUE,
        min = Integer.MAX_VALUE;
    for (int i : arr) {
        max = Math.max(max,i);
        min = Math.min(min,i);
    }

    int bucketNum = (max-min)/arr.length + 1;
    List<List<Integer>> bucketArr = new ArrayList<>(bucketNum);
    for (int i = 0; i < bucketNum; i++) {
        bucketArr.add(new ArrayList<>());
    }

    for (int i = 0; i < arr.length; i++) {
        int index = (arr[i] - min) / arr.length;
        bucketArr.get(index).add(arr[i]);
    }

    for (int i = 0; i < bucketArr.size(); i++) {
        Collections.sort(bucketArr.get(i));
    }

    for (int i = 0, j = 0; i < bucketArr.size(); i++) {
        for (int i1 = 0; i1 < bucketArr.get(i).size(); i1++) {
            arr[j++] = bucketArr.get(i).get(i1);
        }
    }

}

10、基数排序

基数排序是逐位进行排序的算法。先按照低位先排序,排好后收集,再逐次升高位数进行排序,收集,直到最高位,收集后即为有序序列。相当于用十个桶进行排序,又称为桶子法。其排序效率取决于最大数的位数d,每位数字的分布范围k(0-9),一般k=10。

  • 时间复杂度:(O(d*(n+k)))
  • 空间复杂度:取决于桶的数量 (O(k+n))
public static void baseSort(int[] arr) {
    // 取得最大数的位数
    int digits = 3;
    List<List<Integer>> tong = new ArrayList<>(10);
    for (int i = 0; i < 10; i++) {
        tong.add(new ArrayList<>());
    }

    int mod = 10, dev = 1;
    for (int i = 0; i < digits; i++, dev *= 10, mod *= 10) {
        for (int j = 0; j < arr.length; j++) {
            int index = (arr[j] % mod) / dev;
            tong.get(index).add(arr[j]);
        }
        for (int j = 0, index = 0; j < tong.size(); j++) {
            while (!tong.get(j).isEmpty()){
                arr[index++] = tong.get(j).get(0);
                tong.get(j).remove(0);
            }
        }
    }
}

外部排序

前述排序都是指直接在内存中可以完成的,即内部排序。外部排序是指数据量很大,需要多次从磁盘读入的情况。

外部排序一般使用归并算法,为了提高效率,并且通常是多路归并。

原文地址:https://www.cnblogs.com/cpcpp/p/14529045.html