排序算法总结

目录

一、时间复杂度O(n^2)

(1)冒泡排序

(2)选择排序

(3)插入排序

二、时间复杂度O(nlog(n))

(1)希尔排序

(2)堆排序

(3)快速排序(重点——分治思想)

(4)归并排序

(5)总结

三、时间复杂度O(n)

(1)计数排序

(2)基数排序

(3)桶排序

四、Java源码中 Arrays.sort() 分析


一、时间复杂度O(n^2)

(1)冒泡排序

一边比较一边两两交换,将最大值/最小值交换到最后的位置。

优化方法:使用一个布尔型变量对一轮中是否发生过交换进行记录,如果没有发生过交换——那么说明本身就是有序的,不需要进行后续的遍历。

缺点:如果本身的排序为倒序,那么这种情况的复杂度是最差的。

优化代码:

public static void bubbleSort(int[] nums){
    boolean ifSwapped = false;
    for (int i = 1; i < nums.length; i++) {
        ifSwapped = false;
        for (int j = 0; j < nums.length - 1; j++) {
            if(nums[j] > nums[j + 1]){
                int temp = nums[j];
                nums[j] = nums[j + 1];
                nums[j + 1] = temp;
                ifSwapped = true;
            }
        }
        if(!ifSwapped) return;
    }
}

(2)选择排序

每一轮进行遍历,都会找到未排序的最大值/最小值,将它放在对应的位置。

经过n-1次寻找,我们就可以完成这个遍历。

优化方法:每次寻找的时候,可以把最大值和最小值都找出来,这样只需要原遍历次数的一半即可完成。(优化过程中容易产生错误的地方均已进行标注)

public static void selectSort(int[] nums){
    int maxIndex, minIndex;
    for (int i = 0; i < nums.length / 2; i++) {
        minIndex = i;
        maxIndex = i;
        // 注意这里遍历的次数
        for (int j = i + 1; j < nums.length - i; j++) {
            if(nums[j] > nums[maxIndex]) maxIndex = j;
            if(nums[j] < nums[minIndex]) minIndex = j;
        }
        // 如果最大下标和最小下标相同,说明不需要进行后续排序
        if(maxIndex == minIndex) break;
        int temp = nums[i];
        nums[i] = nums[minIndex];
        nums[minIndex] = temp;
        // 如果maxIndex恰好为i,前面一次交换就会将原来的最大值换到minIndex处,所以需要进行判断
        if(maxIndex == i) maxIndex = minIndex;
        temp = nums[nums.length - 1 - i];
        nums[nums.length - 1 - i] = nums[maxIndex];
        nums[maxIndex] = temp;
    }
}

(3)插入排序

插入排序的感觉有点像打扑克的时候,每摸上来一张牌,就把它“插入”在适当的位置。

这样的方法有一个问题:就是如果我需要将新的数插入到某个位置,那么在它后面位置的数都需要向后进行挪动,这并不是一件轻松的事情。

方法:在新数字插入过程中,与前面的数字不断比较,前面的数字不断向后挪出位置,当新数字找到自己的位置后,插入一次即可。

public static void insertSort(int[] nums){
    for (int i = 1; i < nums.length; i++) {
        // 需要插入的新数字
        int cur = nums[i];
        int j = i - 1;
        // 这里两种情况:1、插入到中间 2、插入到头(j >= 0部分),此时j = -1
        while(j >= 0 && cur < nums[j]){
            nums[j + 1] = nums[j--];
        }
        nums[j + 1] = cur;
    }
}

二、时间复杂度O(nlog(n))

(1)希尔排序

(很少考,且时间复杂度不完全是nlogn,经过优化的希尔排序可以达到O(n^1.3) 甚至O(n^7/6))

基本思想:

(1)将待排序数组按照一定的间隔分为多个子数组,每组分别进行插入排序。这里按照间隔分组指的不是取连续的一段数组,而是每跳跃一定间隔取一个值组成一组
(2)逐渐缩小间隔进行下一轮排序
(3)最后一轮时,取间隔为1,也就相当于直接使用插入排序。但这时经过前面的「宏观调控」,数组已经基本有序了,所以此时的插入排序只需进行少量交换便可完成

选择增量序列:

(1)

(2)

(3)

我们这里以希尔增量序列举例:

public static void shellSort(int[] nums){
    for(int gap = nums.length / 2; gap > 0; gap /= 2){
        for (int i = gap; i < nums.length; i++) {
            // 当前数字
            int cur = nums[i];
            int j = i - gap;
            while(j >= 0 && cur < nums[j]){
                // 挪位置
                nums[j + gap] = nums[j];
                j -= gap;
            }
            nums[j + gap] = cur;
        }
    }
}

这段代码与插入排序极为相似,唯一的区别在于外层的gap循环,即对于子数组的划分。

(2)堆排序

堆排序中用到了大根堆和小根堆,这是一种树的数据结构。

大根堆需要满足如下条件:

(1)完全二叉树

(2)根节点的值 >= 子节点的值

我们对数组中的元素进行标号,根节点为0;有如下性质:

(1)对于完全二叉树中第i个数,左节点为第2i+1个数

(2)对于完全二叉树中第i个数,右节点为第2i+2个数(或者左节点+1)

(3)对于有n个元素的完全二叉树(n≥2),它的最后一个非叶子结点的下标:n/2 - 1

那么我们创建大根堆的方法如下:

(1)首先将数组视作是一个完全二叉树,自底向上调整树的结构,使其满足大根堆的要求。

(2)将根节点元素置换到数组最后一个位置,然后对剩余的元素构造大根堆

(3)重复此操作,直到所有的元素排列完毕

代码:

public static void heapSort(int[] nums){
    // 初始化大根堆
    buildMaxHeap(nums);
    for (int i = nums.length - 1; i > 1; i--) {
        // 交换首末
        swap(nums, 0, i);
        // 调整大根堆
        maxHeapify(nums, 0, i);
    }
}

private static void buildMaxHeap(int[] nums) {
    // 构建初始大顶堆
    // 从最后一个非叶子结点开始调整大顶堆,最后一个非叶子结点的下标就是 arr.length / 2-1
    for (int i = nums.length / 2 - 1; i >= 0; i--) {
        maxHeapify(nums, i, nums.length);
    }
}
private static void maxHeapify(int[] nums, int i, int heapSize){
    // 调整大根堆
    int l = 2 * i + 1;
    int r = l + 1;
    int largest = i;
    if(l < heapSize && nums[l] > nums[largest]) largest = l;
    if(r < heapSize && nums[r] > nums[largest]) largest = r;
    if(largest != i) {
        swap(nums, i, largest);
        // 进行交换过后,我们仍需要对交换下去的数字进行检查,以满足大根堆的要求
        maxHeapify(nums, largest, heapSize);
    }
}
private static void swap(int[] arr, int i, int j) {
    // 交换方法
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

时间复杂度为n^2的排序算法均无法通过leetcode lc.912

(3)快速排序(重点——分治思想)

快速排序在时间复杂度为nlogn的排序算法中,大多数情况下效率更高,所以我们经常采用快速排序的方法。且对于时间复杂度为n的排序算法,它们可能是在一些特殊情况下使用。

思想:

(1)从数组中取出一个数,作为基数(pivot)

(2)遍历数组,把比基数小的放在它左边,把比基数大的放在它右边,得到两个子数组

(3)对两个子数组进行同样的操作,直到排序完成

代码:

public static void quickSort(int[] nums, int l, int r){
    if(l >= r) return;
    int mid = partition(nums, l ,r);
    quickSort(nums, l, mid - 1);
    quickSort(nums, mid + 1, r);
}

private static int partition(int[] nums, int l, int r){
    int i = l;
    int j = r;
    while(i < j){
        while(i < j && nums[j] >= nums[l]) j--;
        while(i < j && nums[i] <= nums[l]) i++;
        swap(nums, i , j);
    }
    swap(nums, i ,l);
    return i;
}

这里有一个地方:就是j--的循环一定要在 i++的循环前面进行。

原因:因为最后我们需要对 i 和 l 位置的数进行交换,也就是说 l 位置的数一定要比 i 位置的数小(这是我们需要达成的快速排序的目标)。但如果 i 在前面的话,i停下来的时候,指向的数有可能比 l 位置的数大(因为i的循环条件是nums[ i ]<=nums[ l ]),所以我们一定需要先 j-- 再 i++。

(4)归并排序

归并排序使用的也是分而治之的思想,它将数组进行不断的二分操作,将其变为一个一个小的部分,这是它的“分”;而后我们对小部分进行归并,也就是“合”。在递归地进行操作后,从小部分到大部分达成排序操作的完成。

归并排序的流程是按照「深度优先搜索」的方式进行的。事实上,所有的递归函数的调用过程,都是按照「深度优先搜索」的方式进行的。

代码:

public static void mergeSort(int[] nums, int start, int end, int[] result){
    // 这里参数int[] result 是为了避免在归并的过程中产生大量的临时空间
    if(start == end) return;
    int middle = (start + end) / 2;
    mergeSort(nums, start, middle, result);
    mergeSort(nums, middle + 1, end, result);
    merge(nums, start, end, result);
}

private static void merge(int[] nums, int start, int end, int[] result){
    // 分为两个有序数组进行合并
    int middle = (start + end) / 2;
    // 两个数组的始末以及两个指针
    int start1 = start;
    int end1 = middle;
    int start2 = middle + 1;
    int end2 = end;
    int index1 = start1;
    int index2 = start2;
    // 结果数组的指针
    int resultIndex = start;
    // 进行遍历
    while(index1 <= end1 && index2 <= end2){
        if(nums[index1] <= nums[index2]) result[resultIndex++] = nums[index1++];
        else result[resultIndex++] = nums[index2++];
    }
    while(index1 <= end1){
        result[resultIndex++] = nums[index1++];
    }
    while(index2 <= end2){
        result[resultIndex++] = nums[index2++];
    }
    for (int i = start; i <= end; i++) {
        nums[i] = result[i];
    }
}

归并排序存在两个步骤,一个分一个合,这里用mergeSort来表示分,用merge来归并。

(5)总结

希尔排序是插入排序的优化版本,最后一轮增量为1的时候即为插入排序。它通过交换两个较远位置的数字来达成一次交换能消除一个以上的逆序对。打破在O(1)空间复杂度的情况下,时间复杂度始终为O(n^2)的问题。

堆排序分为三个步骤:一是创建大根堆,然后将最大的元素放置到数组末尾;二是对剩余的元素重新进行大根堆的调整。三是重复操作,直到数组有序。

快速排序是一种考的比较多的排序,它的思路是首先取出一个基数(pivot),然后将数组中比它小的元素放在它的左侧,而比它大的元素放在右侧。这样我们得到两个子数组,对这两个子数组进行相同的操作。

归并排序也是一种分而治之思想的排序,它的思想是对于两个有序数组,我们可以通过指针的形式将它归并成为一个有序数组,而这种递归的思想是深度优先的。

三、时间复杂度O(n)

时间复杂度O(n)的排序算法存在已久,但是它们存在某些限制,只能在特定场景下使用。

(1)计数排序

先说结论,计数排序的时间复杂度和空间复杂度都是O(n+k),其中k是整数的范围。

那么我们可以看出,计数排序的适用范围为:整数的范围大小较小

假如:我的整数范围为int类型,那么时间和空间的耗费都非常之大!

倒序遍历的计数排序(只有采用倒序才能保证计数排序的稳定性):

public static void countingSort(int[] nums){
        if(nums == null || nums.length <= 1) return;
        int max = nums[0];
        int min = nums[0];
        for (int i = 1; i < nums.length; i++) {
            if(nums[i] > max) max = nums[i];
            else if(nums[i] < min) min = nums[i];
        }
        int range = max - min + 1;
        int[] counting = new int[range];
        // 进行赋值
        for (int num : nums) {
            counting[num - min]++;
        }
        counting[0]--;
        // 累计
        for (int i = 1; i < nums.length; i++) {
            counting[i] += counting[i - 1];
        }
        int[] result = new int[nums.length];
        for (int i = nums.length - 1; i > 0; i--) {
            result[counting[nums[i] - min]] = nums[i];
            counting[nums[i] - min]--;
        }
        System.arraycopy(result, 0, nums, 0, nums.length);
    }

(2)基数排序

我们对日期前后进行判断的时候,就是使用的基数排序。

例如2022年4月10日和2022年6月19日比较,年份相同比较月份,月份6 > 4,所以2022年6月19日在后。

而基数排序使用的是从数字个位开始向高进行比较(最低位优先法 LSD)。

包含负数的基数排序:

public static void radixSort(int[] nums){
    if(nums == null || nums.length <= 1) return;
    // 绝对值最大的数
    int max = Math.abs(nums[0]);
    for(int num : nums){
        if(Math.abs(num) > max) max = Math.abs(num);
    }
    // 最大位数
    int maxDigitLength = 0;
    while(max != 0){
        maxDigitLength++;
        max /= 10;
    }
    // -9 -> 9
    int[] counting = new int[19];
    int[] result = new int[nums.length];
    int digit = 1;
    for (int j = 0; j < maxDigitLength; j++){
        // 基数计数
        for(int num : nums){
            int radix = num / digit % 10 + 9;
            counting[radix]++;
        }
        counting[0]--;
        // 计数累计
        for (int i = 1; i < counting.length; i++) {
            counting[i] += counting[i - 1];
        }
        // 回填
        for (int i = nums.length - 1; i >= 0; i--) {
            int radix = nums[i] / digit % 10 + 9;
            result[counting[radix]] = nums[i];
            counting[radix]--;
        }
        System.arraycopy(result, 0, nums, 0, nums.length);
        Arrays.fill(counting, 0);
        digit *= 10;
    }
}

(3)桶排序

桶的数量 和 桶的数据结构 以及 桶内排序使用的算法 会对效率产生很大影响。

代码:

public static void bucketSort(int[] arr) {
    // 判空及防止数组越界
    if (arr == null || arr.length <= 1) return;
    // 找到最大值,最小值
    int max = arr[0];
    int min = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) max = arr[i];
        else if (arr[i] < min) min = arr[i];
    }
    // 确定取值范围
    int range = max - min;
    // 设置桶的数量,这里我们设置为 100 个,可以任意修改。
    int bucketAmount = 100;
    // 桶和桶之间的间距
    double gap = range * 1.0 / (bucketAmount - 1);
    HashMap<Integer, Queue<Integer>> buckets = new HashMap<>();
    // 装桶
    for (int value : arr) {
        // 找到 value 属于哪个桶
        int index = (int) ((value - min) / gap);
        if (!buckets.containsKey(index)) {
            buckets.put(index, new LinkedList<>());
        }
        buckets.get(index).add(value);
    }
    int index = 0;
    // 对每个桶内的数字进行单独排序
    for (int i = 0; i < bucketAmount; i++) {
        Queue<Integer> bucket = buckets.get(i);
        if (bucket == null) continue;
        // 将链表转换为数组,提升排序性能
        int[] arrInBucket = bucket.stream().mapToInt(Integer::intValue).toArray();
        // 这里需要结合其他排序算法,例如:插入排序
        insertSort(arrInBucket);
        // 排序完成后将桶内的结果收集起来
        System.arraycopy(arrInBucket, 0, arr, index, arrInBucket.length);
        index += arrInBucket.length;
    }
}
// 插入排序
public static void insertSort(int[] arr) {
    // 从第二个数开始,往前插入数字
    for (int i = 1; i < arr.length; i++) {
        int currentNumber = arr[i];
        int j = i - 1;
        // 寻找插入位置的过程中,不断地将比 currentNumber 大的数字向后挪
        while (j >= 0 && currentNumber < arr[j]) {
            arr[j + 1] = arr[j];
            j--;
        }
        // 两种情况会跳出循环:1. 遇到一个小于或等于 currentNumber 的数字,跳出循环,currentNumber 就坐到它后面。
        // 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNumber 的数字,也会跳出循环,此时 j 等于 -1,currentNumber 就坐到数列头部。
        arr[j + 1] = currentNumber;
    }
}

四、Java源码中 Arrays.sort() 分析

版本为JDK 11.0.8

Arrays.sort() 通过分析输入数据的规模和特点来对使用的排序算法进行选取。

该方法对基本类型使用 DualPivotQuicksort.sort() 进行排序;

对于非基本类型,在Jdk 1.7之前默认使用归并排序,在1.7之后使用Timsort(?),但可以通过JVM参数设置继续使用归并排序。

DualPivotQuicksort.sort() 为双轴快排,文中前面介绍的快排为单轴快排。

(1)TimSort

TimSort是归并排序的一种优化,归并排序的“分”过程是不断地将数组对半分;而TimSort的优化方法是将数组分为几个run,每个run中的数都是单调递增的(如果原子数组是单调递减的,那么就将它反转变成单调递增),然后再将run两两进行归并操作,最后得到一个有序数组。

这样的拆解方式不一定比归并排序更好,因为当数组中存在很多单调递减的run,翻转的时间复杂度是很高的。

那么在什么条件下在Arrays.sort 中会使用TimSort呢?

1、数组长度达到 QUICKSORT_THRESHOLD(286)

2、run块数小于MAX_RUN_COUNT的值(67)

3、连续相等的数字小于MAX_RUN_LENGTH(33)(因为sort存在对连续相同数字的优化)

即当数组长度较长,高度结构化,且不存在大量连续数字相等的时候,使用TimSort,否则调用 sort(int[] a, int left, int right, boolean leftmost) 函数进行排序。

TimSort具体实现:to do

(2)插入排序 & 双插入排序

如果数组长度小于INSERTION_SORT_THRESHOLD(47),则采用插入排序或双插入排序。因为插入排序在数据量比较小的时候性能比较好。

(3)双轴快速排序

剩余的情况我们使用双轴快速排序,即数组的长度达到INSERTION_SORT_THRESHOLD(47)

  • 30
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值