常见排序算法详解

191 阅读6分钟

算法复杂度的简要介绍

在时间复杂度的表达式中,只要高阶项,不要低阶项,也不要高阶项的系数,剩余的部分假设为f(N)f(N),那么该算法的时间复杂度为O(f(N))O(f(N))。空间复杂度同理。


选择排序

复杂度

时间复杂度O(N2)O(N^2),空间复杂度O(1)O(1)

核心思想

遍历寻找最小(或最大),然后进行交换。

//排序结果为一个从小到大的数组
public static void selectionSort(int[] arr){
    //base case
    if(arr == null || arr.length < 2){
        return;
    }
    
    for(int = 0; i < arr.length - 1; i++){
        int minIndex = i; //初始化 minIndex,即当前数组中最小数所在位置
        for(j = i + 1; j < arr.length; j++){
            minIndex = arr[j] < arr[minIndex] ? j : minIndex;//将 minIndex定位到 i及 i之后的所有剩余元素中最小的一个元素的所在位置
        }
        swap(arr, i, minIndex);// i从0开始依次走过数组的每个位置,每走一次就将当前剩余数组元素中能找到的最小元素交换至当前i位置
    }
}

//完成两数位置交换
public static void swap(int[] arr, int i, int j){
    arr[i] = arr[i] ^ arr[j];
    arr[j] = arr[i] ^ arr[j];
    arr[i] = arr[i] ^ arr[j];
}

//或者使用以下写法,使用一个额外空间
public static void swap(int[] arr, int i, int j){
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

冒泡排序

复杂度

时间复杂度O(N2)O(N^2),空间复杂度O(1)O(1)

核心思想

遍历数组,将当前遍历到的位置的元素与他下一位的元素比较大小,将更大(或更小)的数放在下一位的位置上。

//排序结果为一个从小到大的数组
public ststic void bubbleSort(int[] arr){
    //base case
    if(arr == null || arr.length < 2){
        return;
    }
    
    for(int i = arr.length - 1; i > 0; i--){
        for(int j = 0; j < i; j++){
            if(arr[j] > arr[j+1]){
                swap(arr, j, j+1);
            }
        }
    }
}

插入排序

复杂度

时间复杂度O(N2)O(N^2),空间复杂度O(1)O(1)

核心思想

遍历中的每一趟将一个待排序元素,按其大小插入到前面已经排好序的一组元素的适当位置上,直到所有待排序元素元素全部插入为止。

public static void insertionSort(int[] arr){
    //base case
    if(arr == null || arr.length < 2){
        return;
    }
    
    for(int i = 1; i < arr.length; i++){// 0 ~ i 做到有序
        for(int j = i - 1; j >= 0 && arr[j] > arr[j+1]; j--){
            swap(arr, j, j+1);
        }
    }
}

归并排序

归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

复杂度

时间复杂度O(NlogN)O(NlogN),空间复杂度O(N)O(N)

动图演示

迭代法实现归并排序的动图演示 mergeSort_diedai.gif

实现原理

归并排序的实现有两种方法:

  • 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第2种方法);
    1. 将序列每相邻两个数字进行归并操作,形成n2\lfloor \frac{n}{2} \rfloor个序列,排序后每个序列包含两个元素;
    2. 将上述序列再次归并,形成n4\lfloor \frac{n}{4} \rfloor个序列,每个序列包含四个元素;
    3. 重复归并步骤,直到所有元素排序完毕。
  • 自下而上的迭代。
    1. 申请空间用于存放归并后的序列,其大小为两个已排序的序列之和;
    2. 设定两个指针,其初始位置分别为两个已排序的序列的起始位置;
    3. 比较两个指针所指向的元素,选择相对小的元素放入到归并空间,并移动指针到下一位置;
    4. 重复步骤3直到某一指针到达序列尾;
    5. 将另一序列剩下的所有元素直接复制到归并序列尾。

分治模式在每一层递归上有三个步骤:

  1. 分解(Divide):将nn个元素分成个含n2\frac{n}{2}个元素的子序列。
  2. 解决(Conquer):用归并排序法对两个子序列进行递归地排序。
  3. 合并(Combine):归并两个已排序的子序列,得到排序结果。
//最开始初始化时 L = 0, R = arr.length
public static void process(int[] arr, int L, int R){
    //base case
    if(L == R){
        return;
    }
    
    //这样求中间值可以防止溢出;使用位运算可以更快,左移一位相当于除以2
    int mid = L + ((R - L) >> 1);
    
    //递归处理部分
    process(arr, L, mid);
    process(arr, mid+1, R);
    mergeSort(arr, L, mid, R);
}

//迭代法实现归并排序,排序结果从小到大
public static void mergeSort(int[] arr, int L, int mid, int R){
    //申请空间用于存放归并后的序列,其大小为两个已排序的序列之和
    int[] help = new int[R - L + 1];
    
    //设定两个指针,其初始位置分别为两个已排序的序列的起始位置
    int p1 = L;
    int p2 = mid + 1;
    
    int i = 0;
    
    //比较两个指针所指向的元素,选择相对小的元素放入到归并空间,并移动指针到下一位置。重复此步骤直到某一指针到达序列尾
    while(p1 <= mid && p2 <= R){
        help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
    }
    
    //将另一序列剩下的所有元素直接复制到归并序列尾
    while(p1 <= mid){
        help[i++] = arr[p1++];
    }
    while(p2 <= R){
        help[i++] = arr[p2++];
    }
    
    for(i = 0; i < arr.length; i++){
        arr[L + i] = help[i];
    }
}

快速排序

复杂度

动图演示

快速排序的动图演示 quickSort.gif

实现原理

快速排序(Quicksort)是对冒泡排序算法的一种改进,也是一种采用分治法(Divide and Conquer)的排序算法。它选择一个元素作为枢轴元素(pivot),并围绕选定的枢轴元素对给定数组进行分区(partition)。快速排序实现步骤如下:

  1. 首先设定一个分界值,通过该分界值将数组分成左右两部分。
  2. 将大于(或等于)分界值的数据集中到数组右边,小于(或等于)分界值的数据集中到数组的左边。此时,左边部分中各元素都小于或等于分界值,而右边部分中各元素都大于或等于分界值。(等于分界值的数值挑其中一边放入即可)
  3. 然后左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。
  4. 重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。

快速排序有很多不同的版本,它们以不同的方式选择枢轴元素(分界值):

  • 总是选择第一个元素作为枢轴元素。
  • 总是选择最后一个元素作为枢轴元素。
  • 选择中值作为枢轴元素。
  • 随机选一个元素作为枢轴元素。

快排1.0:选择最后一个元素的数为基准分开数组,时间复杂度O(N2)O(N^2)

快排2.0:在1.0的基础上将等于分界值的元素单独分区,时间复杂度O(N2)O(N^2)

快排3.0:随机抽取数组中一个数放在数组的最后,然后进行分区及后续操作,时间复杂度O(NlogN)O(NlogN)

quickSort123.jpg

快排3.0的Java实现代码如下:

//初始化时 lowIndex = 0, highIndex = arr.length - 1
public static void quickSort(int[] arr, int lowIndex, int highIndex){
    //base case
    if(arr == null || arr.length < 2){
        return;
    }
    if(lowIndex > highIndex){
        return;
    }
    
    // 随机选择pivot
    int pivotIndex = new Random(),nextInt(highIndex - lowIndex) + lowIndex;
    int pivot = arr[pivotIndex];
    swap(arr, pivotIndex, highIndex);
    
    // 处理arr[lowIndex~highIndex]的部分
    int leftPointer = lowIndex; // 小于等于区
    int rightPointer = highIndex; // 大于等于区
    while(lowIndex < highIndex){
        while(leftPointer <= pivot && leftPointer < rightPointer){
            leftPointer++;
        }
        while(rightPointer >= pivot && leftPointer < rightPointer){
            rightPointer--;
        }
        swap(arr, leftPointer, rightPointer);
    }
    swap(arr, leftPointer, highIndex);
    
    // 递归处理子数组
    quickSort(arr, lowIndex, leftPointer - 1);
    quickSort(arr, leftPointer + 1, highIndex);
}

堆排序

堆的定义

  1. 堆结构就是用数组实现的完全二叉树结构。
  2. 完全二叉树中如果每棵子树的最大值都在顶部就是大根堆。
  3. 完全二叉树中如果每棵子树的最小值都在顶部就是小根堆。

一个数组[3,5,2,7,1,9,6]

其下标为[0,1,2,3,4,5,6]

画成完全二叉树就是

fbt.png

用下标来表示的完全二叉树就是

fbt_i.png

那么,下标ii位置的左孩子为:2×i+12 \times i + 1,右孩子为:2×i+22 \times i + 2,父结点为:i12\lfloor \frac{i-1}{2} \rfloor,定义i=0i = 0的父结点还是00,左右孩子的关系为right=left+1right = left + 1

堆结构的 heapInsert 与 heapify 操作

heapInsert: 每次输入一个数,都和父结点比较以形成大(或小)根堆。即向堆中插入新元素并形成新的堆的过程,是一个向上调整的过程。

//以大根堆为例
public static void heapInsert(int[] arr, int index){
    while(arr[index] > arr[(index - 1) / 2]){
        swap(arr, index, (index - 1) / 2);
        index = (index - 1) / 2;
    }
}

heapify: 返回最大的数arr[0](假设是大根堆),并将剩余的数排成大根堆。将arr[0]与数组的最后一个数交换位置,通过heapSize--的操作去掉最后一个数,并重复父结点与左右子结点的比较过程,形成新的大根堆。是一个向下调整重新堆化的过程。

public static void heapify(int[] arr, int index, int heapSize){
    int left = index * 2 + 1;//左孩子的下标
    while(left < heapSize){
        //两个孩子中谁的值较大,就把其下标给largest
        int largest = ((left + 1 < heapSize) && (arr[left + 1] > arr[left])) ? left + 1 : left;
        
        //父和较大孩子之间谁的值较大,就把其下标给largest
        largest = arr[largest] > arr[index] ? largest : index;
        
        if(largest == index){
            break;
        }
        swap(arr, largest, index);
        index = largest;
        left = index * 2 + 1;
    }
}

堆排序

堆排序的Java实现如下:

public static void heapSort(int[] arr){
    //base case
    if(arr == null || arr.length < 2){
        return;
    }
    
    for(int i = 0; i < arr.length; i++){
        heapInsert(arr, i);
    }
    int heapSize = arr.length;
    swap(arr, 0, --heapSize);
    while(heapSize > 0){
        heapify(arr, 0, heapSize);
        swap(arr, 0, --heapSize);
    }
}

优先级队列结构就是堆结构

Java中小根堆的实现就是PriorityQueue默认构造。

public static void main(){
    PriorityQueue<Integer> heap = new PriorityQueue<>();
    
    //add添加元素
    heap.add(8);
    heap.add(4);
    
    while(!heap.isEmpty()){//isEmpty判断是否为空
        System.out.println(heap.poll());//poll弹出并移除元素
    }
}

桶排序

桶排序(Bucket Sort)又称箱排序,是一种不基于比较的排序,而是利用“桶”来完成排序的算法。

复杂度

时间复杂度O(N)O(N)

核心思想

  1. 根据要排序的数据样本量,设置一定数量的、有序的空桶。
    • 例如要对一组取值范围在[0,100][0, 100]的数据进行排序,可以设置十个桶,其存放数据的范围分别对应[0,10),[10,20)...[90,100][0, 10), [10, 20) ... [90, 100]
  2. 遍历输入数据,并且把数据一个一个放到对应的桶里去。
  3. 对每个不是空的桶内部进行排序(可以使用别的排序算法,也可以使用桶排序进行递归操作)。
  4. 从不是空的桶里把排好序的数据拼接起来。

桶排序思想下的排序

计数排序

复杂度

时间复杂度O(N)O(N),空间复杂度O(N)O(N)

核心思想

  1. 找出待排序的数组arrarr中最大和最小的元素,分别记为maxmaxminmin
  2. 新建一个空数组resres,其长度为maxmin+1max-min+1,其每项元素初始化为00
  3. 使用minmin作为基准偏移量,对arrarr中出现的所有的元素分别计数累加至resres中;
    • 例如,arrarr中有一个元素ii,则对应res[imin+1]+=1res[i-min+1] += 1
  4. 输出结果时,按照resres下标从小到大,依次加上基准偏移量的值,然后输出。

实现代码

基数排序

复杂度

时间复杂度O(N)O(N),空间复杂度O(N)O(N)

核心思想

基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。

实现代码

public class RadixSort {
    public static void main(String[] args) {
        int[] array = {72,538,627,56,332,453,312,4};
        radixSort(array);
    }

    /**
     * 基数排序
     */
    public static void radixSort(int[] array){
        //初始化桶
        int[][] bucket = new int[10][array.length];
        //初始化一个数组用来存放指向每个桶中最大的元素的指针
        int[] bucketIndex = new int[10];
        //获取数组中最大的元素
        int max = 0;
        for (int i = 0; i < array.length; i++) {
            if (array[i] > max){
                max = array[i];
            }
        }
        //获取最大元素的长度
        int maxLength = (max+"").length();
        //将数组中的元素按照指定规则放入到桶中
        for (int i = 0; i < maxLength; i++) {
            int div = (int)Math.pow(10,i);
            for (int j = 0; j < array.length; j++) {
                //获取元素的个位、十位、百位、千位...
                int element = (array[j]/div)%10;
                bucket[element][bucketIndex[element]] = array[j];
                bucketIndex[element]++;
            }
            int index = 0;
            for (int k = 0; k < bucketIndex.length; k++) {
                //桶中有元素
                if (bucketIndex[k] != 0){
                    //取出桶中元素
                    for (int j = 0; j < bucketIndex[k]; j++) {
                        array[index++] = bucket[k][j];
                    }
                }
                bucketIndex[k] = 0;
            }
            System.out.println("第" + (i+1) + "次排序的结果为" + Arrays.toString(array));
        }
    }
}

排序算法的稳定性及其汇总

同样值的元素之间,如果不因为排序而改变其相对次序,就说这个排序是有稳定性的,否则就没有。

不具备稳定性的排序:选择排序、快速排序、堆排序...

具备稳定性的排序:冒泡排序、归并排序、一切桶排序思想下的排序...

时间复杂度空间复杂度稳定性
选择排序O(N2)O(N^2)O(1)O(1)不具有❌
冒泡排序O(N2)O(N^2)O(1)O(1)具有✅
插入排序O(N2)O(N^2)O(1)O(1)具有✅
归并排序O(NlogN)O(NlogN)O(N)O(N)具有✅
快速排序O(NlogN)O(NlogN)O(logN)O(logN)不具有❌
堆排序O(NlogN)O(NlogN)O(1)O(1)不具有❌

目前没有找到时间复杂度O(NlogN)O(NlogN),额外空间复杂度O(1)O(1),而又稳定的排序。

实际应用中,在面对大样本量的数据时选用时间复杂度为O(NlogN)O(NlogN)的排序算法加快排序速度,面对小样本量的数据时选用时间复杂度为O(N2)O(N^2)的排序算法,此时时间消耗相差无几,而时间复杂度为O(N2)O(N^2)的排序算法通常更节省空间且更易于实现和理解。

Java自带的排序接口 Arrays.sort() 实现原理:如果是基础类型数据的排序则会使用快速排序完成;如果是非基础类型数据(自定类型数据)的排序会出于稳定性的考虑而使用归并排序来完成。