排序算法总结

排序一般是面试的开始,如果开不好头后续的面试过程就比较困难了。

十大排序算法:冒泡排序,选择排序,插入排序,归并排序,堆排序,快速排序、希尔排序、计数排序,基数排序,桶排序。一般面试中比较爱考的是归并排序和快速排序。

1、冒泡排序

升序步骤:

  1. 从数组头开始,比较相邻元素,如果第一个比第二个大就交换
  2. 对每一对相邻元素做同样的工作,从开始的第一对到尾部的最后一对,这样最后的元素是最大的数,这就完成一趟冒泡排序
  3. 重复上述两个步骤,重复次数等于数组的长度,直到排序完成

示例代码:

java 复制代码
public class BubbleSort {
    
    public static int[] sort(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        int temp;
        // 冒泡排序需要对每一个元素进行一趟排序,因此要进行 array.length 循环
        for (int i = 0; i < array.length; i++) {
            for (int j = 0; j < array.length - i - 1; j++) {
                if (array[j] > array[j + 1]) {
                    temp = array[j];
                    array[j] = array[j + 1];
                    array[j + 1] = temp;
                }
            }
        }

        return array;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

SortConstants.NUMBER_NEED_SORT 是一个常量,值为 2,数组元素数量小于 2 不用排序。

2、简单选择排序

可以看作是对冒泡排序的优化,只有找到最大/最小值时才进行交换,大大减少了交换次数。

简单选择排序的步骤:

  • 首先,找到数组中最大(小)的那个元素;
  • 其次,将它和数组的第一个元素交换位置(如果第一个元素就是最大(小)元素那么它就和自己交换);
  • 再次,在剩下的元素中找到最大(小)的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。

这种方法叫做选择排序,因为它在不断地选择剩余元素之中的最大(小)者。

示例代码:

java 复制代码
public class SimpleSelectSort {

    public static int[] sort(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        int max;
        int maxIndex = 0;
        // 选择排序需要循环 N - 1 次,因为剩下最后两个元素的时候进行
        // 一次排序即可,所以循环次数为元素数减 1
        for (int i = 0; i < array.length - 1; i++) {
            // 注意每趟比较都需要重置最大值,否则使用的是上一趟的最大值
            max = Integer.MIN_VALUE;
            // 内层是从未排序区找出最大的元素放到排序区
            for (int j = 0; j < array.length - i; j++) {
                if (array[j] > max) {
                    max = array[j];
                    maxIndex = j;
                }
            }
            swap(array, maxIndex, array.length - i - 1);
        }

        return array;
    }

    public static void swap(int[] array, int index1, int index2) {
        int temp = array[index1];
        array[index1] = array[index2];
        array[index2] = temp;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

sort() 有可以优化的地方,就是我们不必使用 max 变量来记录最大值,只需要记录最大值的索引就可以了:

java 复制代码
	public static int[] sort(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        int maxIndex;
        // 选择排序需要循环 N - 1 次,因为剩下最后两个元素的时候进行
        // 一次排序即可,所以循环次数为元素数减 1
        for (int i = 0; i < array.length - 1; i++) {
            // 注意每趟比较都需要重置最大值索引,否则使用的是上一趟的
            maxIndex = 0;
            // 内层是从未排序区找出最大的元素索引
            for (int j = 0; j < array.length - i; j++) {
                maxIndex = array[j] > array[maxIndex] ? j : maxIndex;
            }
            swap(array, maxIndex, array.length - i - 1);
        }

        return array;
    }

3、简单插入排序

算法思想:

  • 对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入
  • 为了给要插入的元素腾出空间,我们需要将插入位置之后的已排序元素在都向后移动一位。插入排序所需的时间取决于输入中元素的初始顺序。例如,对一个很大且其中的元素已经有序(或接近有序)的数组进行排序将会比对随机顺序的数组或是逆序数组进行排序要快得多。总的来说,插入排序对于部分有序的数组十分高效,也很适合小规模数组

至于具体操作,插入排序的已排序区域在前面,外层循环的目的是从第二个元素开始到最后一个元素,内层则是对每一个进行排序的元素,向其前面的已排序区里逐个检查,找到合适的位置进行插入。

示例代码如下:

java 复制代码
public class SimpleInsertSort {

    public static int[] sort(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        // 简单插入排序也是要遍历 N-1 次,并且是从数组的第二个元素
        // 开始,向前找合适的插入位置
        for (int i = 1; i < array.length; i++) {
            for (int j = i + 1; j > 0; j--) {
                if (array[j] < array[j - 1]) {
                    swap(array, j, j - 1);
                } else {
                    break;
                }
            }
        }

        return array;
    }

    public static void swap(int[] array, int index1, int index2) {
        int temp = array[index1];
        array[index1] = array[index2];
        array[index2] = temp;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

sort() 仍然有优化空间,就是对正在排序的元素,不必每次都进行交换,而是找到合适的位置索引后,插入到该位置即可,这样可以减少交换的次数:

上面的想法不对,因为你记住索引然后插入,那么该索引后面的元素也是要向后移动一位的,还是发生了交换,而且交换次数与上面的示例代码应该是一样的。

再一写代码,发现第二个说的也不完全对。第二种比第一种的纯交换要好一点,因为向后移位是直接把值赋值给下一位,只有一个赋值语句,而交换则有三个赋值语句。

或者内层使用 while 循环写:

java 复制代码
	public static int[] sort1(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        // 已经排好序的索引(包含)
        int preIndex;
        // 当前正在排序的元素值
        int currentValue;

        // 如果 i 从 1 开始的话,后续数组进行比较时会出现 array[preIndex - 1] 的情况,
        // 要求 preIndex 大于 1,但是 preIndex 理论上可以为 0,所以将 i 改为从 0 开始
        for (int i = 0; i < array.length - 1; i++) {
            preIndex = i;
            // 已经排好序的索引是 i,那么当前正在排序的元素值索引是 i + 1
            currentValue = array[preIndex + 1];
            // 从已排序区域的右侧开始,向左逐个元素比对,将大于 currentValue 的向后挪一位
            while (preIndex >= 0 && currentValue < array[preIndex]) {
                array[preIndex + 1] = array[preIndex];
                preIndex--;
            }
            array[preIndex + 1] = currentValue;
        }

        return array;
    }

4、希尔排序

希尔排序是一种基于插入排序的快速的排序算法。简单插入排序对于大规模乱序数组很慢,因为元素只能一点一点地从数组的一端移动到另一端。例如,如果主键最小的元素正好在数组的尽头,要将它挪到正确的位置就需要 N-1 次移动。

希尔排序为了加快速度简单地改进了插入排序,也称为缩小增量排序,同时该算法是突破 O(n^2)的第一批算法之一。

希尔排序是把待排序数组按一定数量的分组,对每组使用直接插入排序算法排序;然后缩小数量继续分组排序,随着数量逐渐减少,每组包含的元素越来越多,当数量减至 1 时,整个数组恰被分成一组,排序便完成了。这个不断缩小的数量,就构成了一个增量序列。

实际上希尔排序相比于简单插入排序就是多了一步根据增量进行分组的操作。我们来看示意图,图中是按照降序排序给出的示例:

首先要确定增量的选取标准以确定增量序列。上面是使用 N/2 的结果作为增量,因此在 14 个数据的数组中,增量序列就是 {7,3,1}。

然后开始对组内进行插入排序。第一次排序时 gap = 7,所以图中标记相同颜色的数组是一组,35 与 72 同组,比较后 35 比 72 小,由于是降序排列,因此要将 35 向后移动到 72 的位置,然后将 72 放到 35 的位置。其他组别也做类似操作,这样就完成了第一次排序。

第二次排序时,增量为 3,还是相同颜色的为一组,在 {72,43,53,48,18} 这一组中,排序过程如下:

  • 从 43 开始向前找同组的 72,43 小于 72 因此不动;
  • 接下来排 53,53 比 43 大,因此 43 在同组内向后移动一位到 53 的位置;然后 53 再跟前面的 72 比,小于 72,因此 53 的插入位置就是 43 原来的位置
  • 再排 48,48 比 43 大,但是比 53 小,因此 43 在同组内向后移动一位到 48 原来的位置,48 则插入到 43 的位置
  • 最后看 18,18 比前面的 43 小,因此它就是在当前位置插入(不用动)

排完后这一组的排序就完成了,另外两组也做相同排序,这一次的排序就完成了。

第三次排序增量为 1,所有元素都在一组,还是对组内用插入排序,排完后整个希尔排序就完成了。

示例代码(升序):

java 复制代码
public class ShellSort {

    public static int[] sort(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        int length = array.length;
        int currentValue;
        int preIndex;
        // 外层循环控制步长,即增量 gap 的控制
        for (int gap = length / 2; gap > 0; gap /= 2) {
            // 内层循环控制数组元素进行插入排序,从 gap 开始
            for (int i = gap; i < length; i++) {
                // 当前正在排序的元素
                currentValue = array[i];
                // 与 currentValue 同组的前一个元素索引
                preIndex = i - gap;
                // 升序排列时,如果 currentValue 比同组的前面元素小的话,前面元素
                // 就要逐个向后移动一位,直到找到 currentValue 应该插入的位置
                while (preIndex >= 0 && array[preIndex] > currentValue) {
                    array[preIndex + gap] = array[preIndex];
                    preIndex -= gap;
                }
                // 找到 currentValue 应该插入的位置后,将 currentValue 插入到该位置
                array[preIndex + gap] = currentValue;
            }
        }

        return array;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

在先前较大的增量下每个子序列的规模都不大,用直接插入排序效率都较高,尽管在随后的增量递减分组中子序列越来越大,由于整个序列的有序性也越来越明显,则排序效率依然较高。

从理论上说,只要一个数组是递减的,并且最后一个值是1,都可以作为增量序列使用。有没有一个步长序列,使得排序过程中所需的比较和移动次数相对较少,并且无论待排序列记录数有多少,算法的时间复杂度都能渐近最佳呢?但是目前从数学上来说,无法证明某个序列是"最好的"。

常用的增量序列:

  • 希尔增量序列 :{N/2, (N / 2)/2, ..., 1},其中N为原始数组的长度,这是最常用的序列,但却不是最好的
  • Hibbard 序列:{2^k-1, ..., 3,1}
  • Sedgewick 序列:{... , 109 , 41 , 19 , 5,1} 表达式为 9 * 4^i - 9 * 2^i + 1,其中 i = 0,1,2,3...

本质上还是插入排序,以增量分组的形式优化了效率

5、归并排序

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

对于给定的一组数据,利用递归与分治技术将数据序列划分成为越来越小的半子表,在对半子表排序后,再用递归方法将排好序的半子表合并成为越来越大的有序序列。

为了提升性能,有时我们在半子表的个数小于某个数(比如15)的情况下,对半子表的排序采用其他排序算法,比如插入排序。

若将两个有序表合并成一个有序表,称为2-路归并,与之对应的还有多路归并。

图示如下:

先拆分,拆分后对子表进行排序,排完序合并:

示例代码:

java 复制代码
/**
 * 归并排序,JDK 提供了实现:Arrays.parallelSort(array);
 */
public class ParallelSort {

    /**
     * 递归过程,实际上是对数组进行拆分,直到拆分到数组大小为 1 为止,当拆分到
     * 1 了之后返回这个数组作为递归的停止条件。
     * 然后对拆分后的数组进行排序,这个排序实际上是在合并过程中进行的。将两个大小
     * 为 1 的数组合并为 2 的时候,分别取两个数组中尚未排序的元素进行比较,逐个
     * 放入到合并后数组的排序位置。实际上是将排序与合并一起做了,返回合并后的数组。
     */
    public static int[] sort(int[] array) {
        // 数组长度小于 2 是递归的结束条件
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        // 对 array 数组进行拆分
        int mid = array.length / 2;
        int[] left = Arrays.copyOfRange(array, 0, mid);
        int[] right = Arrays.copyOfRange(array, mid, array.length);

        return merge(sort(left), sort(right));
    }

    /**
     * 按照大小顺序合并两个数组
     */
    private static int[] merge(int[] left, int[] right) {
        int capacity = left.length + right.length;
        int[] result = new int[capacity];
        // 合并后数组索引以及左右两个数组索引
        int index = 0, leftIndex = 0, rightIndex = 0;
        while (index < capacity) {
            // 比较两个数组中未排序的元素,将较小的元素放入合并后的数组
            if (leftIndex < left.length && left[leftIndex] < right[rightIndex]) {
                result[index++] = left[leftIndex++];
            } else if (rightIndex < right.length && right[rightIndex] < left[leftIndex]) {
                result[index++] = right[rightIndex++];
            }

            // 如果其中一个数组已经排序完毕,则将另一个数组剩余的元素放入合并后的数组
            if (leftIndex == left.length) {
                while (rightIndex < right.length) {
                    result[index++] = right[rightIndex++];
                }
            } else if (rightIndex == right.length) {
                while (leftIndex < left.length) {
                    result[index++] = left[leftIndex++];
                }
            }
        }
        return result;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

6、快速排序

之前在看面试专题时,快速排序相关面试题放的就是这一节的视频,因此这里直接把之前做的笔记拿过来了。

6.1 全面概述

快速排序(Quick Sort)是一种常用的高效排序算法,它采用分治的思想来进行排序。快速排序的核心思想是通过选择一个基准元素(Pivot),将待排序的元素划分为两个子序列,使得左边的子序列都小于等于基准元素,右边的子序列都大于等于基准元素,然后对子序列进行递归排序,最终实现整个序列的有序排列。

下面是快速排序的一般步骤:

  1. 选择基准元素:从待排序序列中选择一个元素作为基准元素。通常选择第一个元素、最后一个元素或者随机选择一个元素作为基准。

  2. 划分操作:将待排序序列中的元素按照基准元素的大小进行划分,比基准元素小的放在左边,比基准元素大的放在右边,相等的可以放在任意一边。可以使用两个指针(一前一后)或者挖坑法进行划分。

  3. 递归排序:对划分后的两个子序列(左边和右边)进行递归排序,重复上述步骤,直到子序列的长度为 1 或 0,即已经有序。

  4. 合并结果:将左边子序列、基准元素和右边子序列按照顺序合并起来,即得到最终的有序序列。

快速排序的关键在于划分操作,它的目标是将基准元素放置到最终排序后的位置,并确保左边的元素都小于等于基准元素,右边的元素都大于等于基准元素。划分操作可以采用多种方法实现,如两指针法、挖坑法或者双边循环法。

快速排序的平均时间复杂度为 O(nlogn),最坏情况下为 O(n^2)(当序列已经有序或基准元素选择不当时),空间复杂度为 O(logn)(递归调用所需的栈空间)。由于快速排序使用了递归,对于大规模数据排序时,可能会导致栈溢出,因此在实际应用中需要注意优化递归过程或使用非递归实现。

快速排序是一种高效的排序算法,常被应用于实际场景中。它的性能优势在于平均时间复杂度较低,并且可以进行原地排序(不需要额外的辅助空间)。

为了提升性能,有时在分割独立的两部分之后,如果某个部分的数量小于一个值(比如 15),会采用其他排序算法,如插入排序。

6.2 具体操作

结合简单示例理解快排原理:

基准数可以选择组内的任意一个元素,举例时我们选择每组的第一个元素。大致步骤如下:

  1. 以原始数组的第一个元素 35 为基准,大于 35 的放它左边,小于 35 的放它右边得到图 2
  2. 对 35 左右两个分组继续执行第一步的策略,35 左边这组的第一个元素 63 为基准,大于 63 的放左边,小于 63 的放右边;右侧组同理,得到图 3
  3. 63 左侧分组已经排好,右侧分组需要以 48 为基准,将大于 48 的 53 放到左边,其余组别已经排列好的了,得到图 4

图中的最后一句说到实际实现时在原数组上直接操作即可实现快排,这是借助了分区指示器:

注意看图中的文字叙述对分区指示器的处理原则,以下是一趟快排的步骤描述:

  1. 分区指示器初始位于 index = -1 的位置,开始遍历数组时,由于 35 小于 48,因此分区指示器需要向右移一位到 index = 0,由于 35 小于基准数 48 且它的 index 是 0 等于分区指示器的位置,所以不用交换
  2. 遍历到下一个元素 63,大于基准数 48,无需做任何变化
  3. 遍历到下一个元素 11,小于 48,分区指示器向右移一位至 index = 1,由于此时下标大于分区指示器下标,所以将二者,也就是 11 和 63 进行交换
  4. 遍历到下一个元素 9,小于 48 分区指示器向右移一位至 index = 2,由于此时下标大于分区指示器下标,所以将二者,也就是 9 和 63 进行交换
  5. 再向下遍历到 86,大于 48 无需改变
  1. 再向下遍历到 24,小于 48 分区指示器向右移一位至 index = 3,由于此时下标大于分区指示器下标,所以将二者,也就是 24 和 63 进行交换
  2. 再向下遍历到 53,比 48 大无需改变
  3. 最后遍历到 48,等于基准数,分区指示器向右移一位到 index = 4,由于 48 不大于基准数 48 且索引大于分区指示器索引,要交换到分区指示器的位置,即 48 与 86 交换,这样最后得到的数列,比 48 小的都排到了其左侧,比 48 大的全都排到其右侧

以上是一趟快排的步骤,接下来就是递归,在 48 的左右两侧继续执行上述步骤,直到所有递归情况中的索引左右两侧都已排好序为止。

6.3 示例代码

java 复制代码
package com.enjoy.basic.sort;

import java.util.Arrays;

public class QuickSort {

    public static void sort(int[] array, int start, int end) {
        // 边界条件
        if (array.length < 1 || start < 0 || end >= array.length || start > end) {
            return;
        }
        // 分区指示器索引
        int zoneIndex = partition(array, start, end);
        // 用索引作为是否进行递归的条件,这是对基准数左侧进行排序
        if (zoneIndex > start) {
            sort(array, start, zoneIndex - 1);
        }
        if (zoneIndex < end) {
            sort(array, zoneIndex + 1, end);
        }
    }

    // 进行一趟快排,返回结果的分区指示器索引
    private static int partition(int[] array, int start, int end) {
        // 基准数,选取一个在范围内的随机索引作为基准数
        int pivot = (int) (start + Math.random() * (end - start + 1));
        // 分区指示器初始位置在 start 的前一个
        int zoneIndex = start - 1;
        // 将基准数放到序列的最后一个
        swap(array, pivot, end);
        // 遍历序列
        for (int i = start; i <= end; i++) {
            // 如果当前位置元素不大于基准数
            if (array[i] <= array[end]) {
                // 首先需要将分区指示器右移一位
                zoneIndex++;
                // 其次检查当前元素下标是否大于指示器,如是则交换两个元素
                if (i > zoneIndex) {
                    swap(array, i, zoneIndex);
                }
            }
        }
        return zoneIndex;
    }

    public static void swap(int[] array, int i, int j) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    public static void main(String[] args) {
        int[] array = new int[]{86, 11, 77, 23, 32, 45, 58, 63, 93, 4, 37, 22};
        System.out.println(Arrays.toString(array));
        System.out.println("==============================================");
        sort(array, 0, array.length - 1);
        System.out.println(Arrays.toString(array));
    }
}

6.4 基准数

基准数的选取:最优的情况是刚好取在无序区数值的中位数,这样能最大效率的让两边排序,同时最大的减少递归划分的次数,但是一般很难做到最优。一般有三种方式选取基准数:数组的第一个元素、最后一个元素、第一个与最后一个以及中间元素的中位数(如 4 5 6 7,第一个是 4,最后一个是 7,中间的为 5,这三个数的中位数是 5,所以选 5 作为基准)。

Dual - Pivot 快排:双基准快速排序算法,其实就使用两个基准数,把整个数组分成三份来进行快排,比经典快排从实验上来看节省 10% 的时间。JDK 就是使用这种方式实现的快排,在 Arrays.sort() 中,使用的是 DualPivotQuicksort.sort()。

6.5 总结

这一次再来看快速排序的内容,有了新的理解,并重写了代码。

首先我们要知道,快速排序在写代码时的具体操作步骤,与快速排序的算法思想是有一些不同的。

算法思想上是在数组内选取一个基准数,然后将所有小于基准数的数放到它左边,大于基准数的放到它右边,然后对基准数左右两侧的子数组进行递归,重复上述过程直到数组中只剩一个元素。

而具体操作方面,则是对数组进行分区操作,该操作得到一个分区指示器,就是一个位置索引。接下来就是对该索引的左侧和右侧进行递归操作。

分区操作实际上就是将所有数进行了一趟排序,将大于基准数的数放到基准数的右侧,小于基准数的数放到基准数的左侧,并且返回基准数的索引,也就是我们后续示例代码中 partition() 的作用。

至于分区操作的具体步骤,上面实际上已经给出了:

  • 当前元素大于等于基准数,不做任何变化
  • 当前元素 小于等于基准数时,分割指示器右移一位 ,当前元素下标 小于等于分割指示器时当前元素保持不动,当前元素下标大于分割指示器时,当前元素和分割指示器所指元素交换

实际上这就是对数组进行一趟快速排序的过程,经过这一趟之后可以实现比基准数小的数在其左侧,比基准数大的数在其右侧。

java 复制代码
public class QuickSort {

    public static int[] sort(int[] array, int start, int end) {
        // 边界条件
        if (array.length < 1 || start < 0 || end >= array.length || start > end) {
            return array;
        }
        // 分区指示器索引
        int zoneIndex = partition(array, start, end);
        // 用索引作为是否进行递归的条件,这是对基准数左侧进行排序
        if (zoneIndex > start) {
            sort(array, start, zoneIndex - 1);
        }
        if (zoneIndex < end) {
            sort(array, zoneIndex + 1, end);
        }

        return array;
    }

    /**
     * 进行一趟快排,将大于基准数的元素放到基准数右侧,小于基准数的元素放到基准数左侧。
     * 返回结果的分区指示器索引
     */
    private static int partition(int[] array, int start, int end) {
        // 基准数,选取范围内的随机索引
        int pivotIndex = (int) (start + Math.random() * (end - start + 1));
        // 分区指示器初始位置在 start 的前一个
        int zoneIndex = start - 1;
        // 将基准数放到序列的最后一个
        swap(array, pivotIndex, end);
        // 遍历序列
        for (int i = start; i <= end; i++) {
            // 如果当前位置元素不大于基准数
            if (array[i] <= array[end]) {
                // 首先需要将分区指示器右移一位
                zoneIndex++;
                // 其次检查当前元素下标是否大于指示器,如是则交换两个元素
                if (i > zoneIndex) {
                    swap(array, i, zoneIndex);
                }
            }
        }
        return zoneIndex;
    }

    public static void swap(int[] array, int index1, int index2) {
        int temp = array[index1];
        array[index1] = array[index2];
        array[index2] = temp;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY, 0, SortConstants.ARRAY.length - 1)));
    }
}

7、堆排序

堆排序(Heap Sort)是一种基于完全二叉树(堆)的排序算法。它的基本思想是将待排序的序列构建成一个大顶堆(或小顶堆),然后将堆顶元素与堆尾元素交换,使得最大(或最小)元素移动到序列末尾,然后对剩余元素重新构建堆,重复这个过程直到整个序列有序。

以下是升序堆排序的基本步骤:

  1. 构建最大堆(Build Max Heap):将待排序的序列构建成一个大顶堆。从最后一个非叶子节点开始,依次向前调整元素的位置,使得每个节点都满足堆的性质(父节点的值大于等于子节点的值)。
  2. 堆排序(Heapify):将堆顶元素与堆尾元素交换,然后重新调整堆,使得剩余元素仍然构成一个大顶堆。重复这个过程,直到所有元素都被取出,得到有序序列。

7.1 完全二叉树与最大堆(大顶堆)

许多应用程序都需要处理有序的元素,但不一定要求他们全部有序,或者不一定要一次就将他们排序,很多时候,我们每次只需要操作数据中的最大元素(最小元素),那么有一种基于二叉堆的数据结构可以提供支持。

所谓二叉堆,是一个完全二叉树的结构,同时满足堆的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。在一个二叉堆中,根节点总是最大(或者最小)节点。

堆排序算法就是抓住了这一特点,每次都取堆顶的元素,然后将剩余的元素重新调整为最大(最小)堆,依次类推,最终得到排序的序列。

二叉树的形态如下:

二叉堆的前提是完全二叉树,因此我们还需要了解完全二叉树的表示方法与推论:

7.2 最大堆初始化

首先将数组按照完全二叉树的形式进行排列(除了最下面一层,其余节点位置都是满的,最后一层也要按序排),升序排列的示意图如下:

由于最大的数不是根节点,所以现在它只是一个普通的完全二叉树,想成为最大堆需要进行调整。调整的方式图中已经给出:从最后一个非叶节点开始,从下到上,从右到左调整。

从最后一个非叶节点开始调整,是为了满足所有子树的根节点要大于(最小堆就是小于)其子节点,因此没有必要从叶子节点开始排。

这里我们要强调一下调整的概念,它是一个递归过程,不仅仅是节点的交换那么简单。比如完全二叉树的根节点 A 的左右孩子分别为 B 和 C,B 的左右孩子又分别为 D 和 E。假设 A~E 对应的值分别为 1~5,构建大顶堆的调整过程如下:

  • 从最后一个非叶节点 B 开始调整,B 比它最大的孩子 E 的值小,因此二者要交换位置
  • 再调整下一个非叶节点,也就是根节点 A,它比最大的孩子 E 要小,因此二者交换位置
  • A 的调整还没完,由于 A 和 E 交换了,A 现在是 D 和 B 的父节点,由于比 D 小,因此 A 和 D 还要交换一次

所以实际上,对 A 的调整进行了两次交换,对应上面步骤的最后两步和图中的最后两个图,这两步是一个递归的关系。A 从根节点被调整下来后,只要其所在的位置是非叶节点,就还要继续看以它为根的子树是否满足根节点大于任意孩子这一条件,直到该节点被证实满足条件或者被调整到叶节点的位置。

我们再看一个复杂一点的例子:

步骤如下:

  • 按照公式,最后一个非叶节点的索引是 N / 2 - 1,在上面的例子中就是 3,也就是 9 所在的位置,它的孩子 11 比 9 大,因此二者要交换位置
  • 按照由下至上、由右至左的调整顺序,下一个应该调整索引为 2,即 48 为根的子树,48 比它的右孩子 53 小,因此二者交换
  • 轮到索引为 1 的 63,比其右孩子 86 要小,因此二者交换
  • 最后是索引为 0,整棵树的根节点 35,小于左孩子 86,二者交换。注意,此时 35 作为根节点的子树,左孩子为 11,右孩子为 63,35 比 63 小,二者还需进行一次交换,才得到最后面一个图。

至此,我们将原始数组存入了最大堆,初始化完成。

7.3 排序过程

排序过程就是不断取堆顶元素再进行调整的过程。先摆出示意图,左侧是上一步初始化后的状态:

具体做法是:将堆顶元素与尾元素交换,再输出尾元素(同时让尾节点分离),这样得到右侧的状态。很明显,此时需要调整元素的位置使之重新变成最大堆,只不过与初始化不同的是,排序过程的调整是从上至下。9 比左孩子 63 小,与 63 交换;交换后又比右孩子 35 小,再进行第二次交换。调整后的结果:

这一次将根节点 63 与尾节点 48 交换并输出 63,继续调整为最大堆:

如此往复直到所有元素都被输出即可。

示例代码:

java 复制代码
public class HeapSort {

    /**
     * 数组长度会随着每一轮输出最大值而减少,因此要全局化
     */
    private static int length;

    public static int[] sort(int[] array) {

        if (array == null || (length = array.length) < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        // 构建最大堆
        buildMaxHeap(array);

        while (length > 0) {
            // 将堆顶元素与最后一个元素交换
            swap(array, 0, length - 1);
            // 由于已经将最大元素放到数组末尾,因此需要将数组长度减 1
            length--;
            // 调整堆,使其重新成为最大堆
            heapify(array, 0);
        }

        return array;
    }

    private static void buildMaxHeap(int[] array) {
        // 初始化二叉堆是从最后一个非叶子节点开始,该节点的索引为 N/2-1
        for (int i = array.length / 2 - 1; i >= 0; i--) {
            heapify(array, i);
        }
    }

    /**
     * 调整堆,使其成为最大堆
     * @param childRootIndex 子树根节点的索引
     */
    private static void heapify(int[] array, int childRootIndex) {
        int leftChildIndex = childRootIndex * 2 + 1;
        int rightChildIndex = childRootIndex * 2 + 2;

        // 查找当前子树中值最大的节点
        int maxIndex = childRootIndex;
        if (leftChildIndex < length && array[leftChildIndex] > array[maxIndex]) {
            maxIndex = leftChildIndex;
        }
        if (rightChildIndex < length && array[rightChildIndex] > array[maxIndex]) {
            maxIndex = rightChildIndex;
        }

        // 如果当前子树中值最大的节点不是子树根节点,则交换
        if (maxIndex != childRootIndex) {
            swap(array, childRootIndex, maxIndex);
            // 将最大值 maxIndex 与根节点交换后,需要检查以 maxIndex
            // 为根节点的子树是否满足最大堆的条件,因此递归调用 heapify
            heapify(array, maxIndex);
        }
    }

    public static void swap(int[] array, int index1, int index2) {
        int temp = array[index1];
        array[index1] = array[index2];
        array[index2] = temp;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

7、计数排序

计数排序对一定范围内的整数排序时候的速度非常快,一般快于其他排序算法。但计数排序局限性比较大,只限于对整数进行排序,而且待排序元素值分布较连续、跨度小的情况。

计数排序是一个排序时不比较元素大小的排序算法。

如果一个数组里所有元素都是整数,而且都在 0-K 以内。对于数组里每个元素来说,如果能知道数组里有多少项小于或等于该元素,就能准确地给出该元素在排序后的数组的位置。

比如说:

由于数组的取值范围是 [0,5],因此总共需要一个容量为 6 的数组用来对 [0,5] 之间的每个整数进行计数。计数完成后通过计算不大于某个元素的整数数量就可以计算出该元素的位置。

整个排序过程如下:

关于计数排序的局限性:

  • 实际应用中我们会同时找出数组中的 max 和 min,主要是为了尽量节省空间。试想 [1003, 1001, 1030, 1050] 这样的数据要排序,真的需要建立长度为 1050 + 1 的数组吗?我们只需要长度为 1050 - 1003 + 1= 48 的数组(先不考虑额外 +1 的长度),就能囊括从最小到最大元素之间的所有元素了
  • 如果待排序数组的元素值跨度很大,比如 [99999, 1, 2],为三个元素排序要使用 99999 - 1 + 1 的空间,实在是浪费。所以计数排序适用于待排序元素值分布较连续、跨度小的情况

示例代码:

java 复制代码
public class CountingSort {

    public static int[] sort(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        // 先找出数组中的最大值和最小值
        int min = array[0], max = array[0];
        for (int i : array) {
            min = Math.min(i, min);
            max = Math.max(i, max);
        }

        // 偏移量,用于定位原始数组中的元素在计数数组中的下标
        int bias = -min;

        // 创建计数数组并计算数组中每个元素的个数
        int[] countingArray = new int[max - min + 1];
        Arrays.fill(countingArray, 0);
        for (int i : array) {
            countingArray[i + bias]++;
        }

        // 进行最终的排序
        int originIndex = 0, countingIndex = 0;
        while (originIndex < array.length) {
            // 计数数组的值不为 0,说明原始数组中存在该元素,应将其放入排序后的原始数组中
            if (countingArray[countingIndex] != 0) {
                array[originIndex++] = countingIndex - bias;
                countingArray[countingIndex]--;
            } else {
                // 计数数组的值为 0,说明原始数组中不存在该元素,应继续查找下一个元素
                countingIndex++;
            }
        }

        return array;
    }

    public static void main(String[] args) {
        int[] array = {5, 4, 5, 0, 3, 6, 2, 0, 2, 4, 3, 3};
        System.out.println(Arrays.toString(sort(array)));
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

8、桶排序

工作原理:假设输入数据服从均匀分布,利用某种函数的映射关系将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序)。

桶排序利用函数的映射关系,减少了几乎所有的比较工作。实际上,桶排序的 f(k) 值的计算,其作用就相当于快排中划分,已经把大量数据分割成了基本有序的数据块(桶)。然后只需要对桶中的少量数据做排序即可。

示例如下:

对于上面这个跨度接近 50 的数组,可以指定每个桶内有 10 个元素,这样就可以分成 5 个桶。注意桶的 10 个元素是指有 10 种不同的元素,比如上面的第一个桶就是 0 ~ 9 这 10 种元素,但是每种元素可以有多个,比如 3 个 0、5 个 1 等等。这一点有点类似于计数排序的计数数组。

分好桶后,所有元素都可以找到相应的桶。桶内排序可以使用此前介绍过的其他排序方法,也可以继续使用桶排序(那就是递归了)。桶内排好序后,按照桶的排列顺序展开桶内元素就算排序完成了。

示例代码:

java 复制代码
public class BucketSort {

    /**
     * 桶排序
     *
     * @param arrayList  待排序的列表
     * @param bucketSize 桶的大小
     */
    public static ArrayList<Integer> sort(ArrayList<Integer> arrayList, int bucketSize) {
        if (arrayList == null || arrayList.size() < SortConstants.NUMBER_NEED_SORT) {
            return arrayList;
        }
        ArrayList<Integer> resultList = new ArrayList<>(arrayList.size());

        // 1.根据数值范围和桶的大小确定桶的数量,并创建这些桶
        int max = arrayList.get(0);
        int min = arrayList.get(0);
        for (int i = 1; i < arrayList.size(); i++) {
            max = Math.max(arrayList.get(i), max);
            min = Math.min(arrayList.get(i), min);
        }

        int bucketCount = (max - min) / bucketSize + 1;
        ArrayList<ArrayList<Integer>> bucketList = new ArrayList<>(bucketCount);
        for (int i = 0; i < bucketCount; i++) {
            bucketList.add(new ArrayList<>());
        }

        // 2.遍历原始数组,将每个元素放入对应的桶中
        for (int i = 0; i < arrayList.size(); i++) {
            int bucketIndex = (arrayList.get(i) - min) / bucketSize;
            bucketList.get(bucketIndex).add(arrayList.get(i));
        }

        // 3.查看桶的数据分布,便于调试
        for (int i = 0; i < bucketList.size(); i++) {
            System.out.print("Number " + i + " bucket includes:");
            System.out.println(bucketList.get(i));
        }

        // 4.对每个桶的内部仍使用桶排序(递归了),当然也可以用别的排序
        for (int i = 0; i < bucketCount; i++) {
            // 如果桶的容量为 1,那么就不用继续排序了,因为桶内只有一个元素,按照桶的顺序直接放入结果数组即可
            if (bucketSize == 1) {
                // 虽然桶内只有一个元素,但是该元素可出现多次,所以使用 addAll() 将相同元素一起添加
                resultList.addAll(bucketList.get(i));
            } else {
                // else 执行一般情况,对每个桶内继续使用桶排序
                // 如果桶的数量为 1,说明 bucketSize 设置的过大,导致经过
                // int bucketCount = (max - min) / bucketSize + 1;
                // 的除法算出来的是 0,所以需要减小 bucketSize,否则递归算出的
                // bucketCount 永远都是 1,导致无限递归最终栈溢出
                if (bucketCount == 1) {
                    bucketSize--;
                }
                ArrayList<Integer> temp = sort(bucketList.get(i), bucketSize);
                resultList.addAll(temp);
            }
        }

        return resultList;
    }

    public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>(Arrays.asList(SortConstants.INTEGER_ARRAY));
        System.out.println(sort(list, 5));
    }
}

第 4 步中 bucketSize == 1 这个条件实际上是递归结束的条件,也就是说,实际上,桶排序的递归实现,是一直将桶的容量减小,直到桶的容量为 1 无需排序直接添加到结果集合中,然后再一步一步地向上返回,最终得到整个数组的排序结果。

9、基数排序

9.1 介绍

常见的数据元素一般是由若干位组成的,比如字符串由若干字符组成,整数由若干位 0~9 数字组成。基数排序按照从右往左的顺序,依次将每一位都当做一次关键字,然后按照该关键字对数组排序,同时每一轮排序都基于上轮排序后的结果;当我们将所有的位排序后,整个数组就达到有序状态。基数排序不是基于比较的算法。

基数是什么意思?对于十进制整数,每一位都只可能是 0~9 中的某一个,总共 10 种可能。那 10 就是它的基,同理二进制数字的基为 2;对于字符串,如果它使用的是 8 位的扩展 ASCII 字符集,那么它的基就是 256。

基数排序有两种方法:

  • MSD 从高位开始进行排序
  • LSD 从低位开始进行排序

基数排序 vs 计数排序 vs 桶排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶
  • 计数排序:每个桶只存储单一键值
  • 桶排序:每个桶存储一定范围的数值

实际例子:

原始数组内保存的是十进制整数,因此分成 0~9 共计 10 个桶。

先对个位排序,将数字放到与之个位数相对应的桶中,然后输出一次结果(先进桶的先输出,比如 35 在 5 之前)。

然后对十位排序,还是将数字放到与之十位数相对应的桶中,再输出结果即为最终的排序结果。

9.2 示例代码

java 复制代码
public class RadixSort {

    public static int[] sort(int[] array) {
        if (array == null || array.length < SortConstants.NUMBER_NEED_SORT) {
            return array;
        }

        // 1.先找出最大的数,这样你才知道是几位数,需要几次排序
        int max = array[0];
        for (int i = 1; i < array.length; i++) {
            max = Math.max(array[i], max);
        }

        // 2.计算出最大数的位数
        int maxDigit = 0;
        while (max > 0) {
            max = max / 10;
            maxDigit++;
        }

        // 3.创建桶
        ArrayList<ArrayList<Integer>> bucketList = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            bucketList.add(new ArrayList<Integer>());
        }

        // 4.排序:每个数字按照从右往左的顺序,依次将每一位都当做一次关键字,
        // 放入对应的桶中,所有数字都存入桶中之后取出它们作为本轮排序的结果,
        // 供下一轮排序使用
        for (int i = 0, div = 1, mod = 10; i < maxDigit; i++, div *= 10, mod *= 10) {
            // 4.1 将所有数字放入对应的桶中,个位、十位、百位
            for (int value : array) {
                int index = (value % mod) / div;
                bucketList.get(index).add(value);
            }

            // 4.2 将桶中的数字取出放入数组中,作为下一轮排序的输入,清空桶
            int resultIndex = 0;
            // j 是桶的索引,k 是桶内数字的索引
            for (int j = 0; j < bucketList.size(); j++) {
                for (int k = 0; k < bucketList.get(j).size(); k++) {
                    array[resultIndex++] = bucketList.get(j).get(k);
                }
                bucketList.get(j).clear();
            }
        }

        return array;
    }

    public static void main(String[] args) {
        System.out.println(Arrays.toString(sort(SortConstants.ARRAY)));
    }
}

需要注意的是如何计算数字对应的桶,举个例子来解释 int index = (value % mod) / div。比如找 328 各个位的桶:

  • 进行个位处理时,对 10 取模就可以了,存入8 号桶
  • 进行十位处理时,要对 100 取模了,得到的 28 再除以 10 得到 2 号桶
  • 进行百位处理时,对 1000 取模得到 328,再除以 100 得到 3 号桶

总结以上规律,可以得出结论,初始处理个位时,取模用的 mod 初始化为 10,除数 div 初始化为 1,后续每向左处理一位,mod 和 div 就要乘以 10。

10、外部排序

外部排序并不是十大排序算法之一,但是在工作和面试中会经常出现。

有时,待排序的文件很大,计算机内存不能容纳整个文件,这时候对文件就不能使用内部排序了(我们一般的排序都是在内存中做的,所以称之为内部排序,而外部排序是指待排序的内容不能在内存中一下子完成,它需要做内外存的内容交换),外部排序常采用的排序方法也是归并排序,这种归并方法由两个不同的阶段组成:

  • 采用适当的内部排序方法对输入文件的每个片段进行排序,将排好序的片段(成为归并段)写到外部存储器中(通常由一个可用的磁盘作为临时缓冲区),这样临时缓冲区中的每个归并段的内容是有序的
  • 利用归并算法,归并第一阶段生成的归并段,直到只剩下一个归并段为止

假如要对 4500 条数据进行排序,而内存最多只能放 750 条数据,那么可以将 4500 条数据分成 6 个 Segment,每次读取一个 Segment 的数据存入临时缓冲区(磁盘,外部存储)。

然后进行 Segment 的合并,将内存分成三份(如第二张图绿色的部分),每份可读取 250 条数据,两个输入缓冲区分别用来从两个 Segment 中读取数据,然后按照归并排序的思路,将符合顺序的数据写入到输出缓冲区中。每当输出缓冲区的数据满了(到了 250 条)之后,将数据输出到磁盘的临时缓冲区一次。

这样就可以实现两个 Segment 的合并,然后按照图一给出的合并思路,两个 750 的可以合成为一个 1500 的,两个 1500 的可以合成 3000 的,这个 3000 和落单的 1500 合并最终将 4500 条数据合并到一起,实现了外部的归并排序。

11、排序算法总结

首先是各个排序算法的比较,需要背下来,面试会考:

11.1 稳定性

算法的稳定性:

  • 稳定:如果 a 原本在 b 前面,而 a=b,排序之后 a 仍然在 b 的前面
  • 不稳定:如果 a 原本在 b 的前面,而 a=b,排序之后 a 可能会出现在 b 的后面

排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,前一个键排序的结果可以为后一个键排序所用。

11.2 复杂度

算法的复杂度往往取决于数据的规模大小和数据本身分布性质。

通常从时间和空间两个维度衡量算法的复杂度:

  • 时间复杂度: 一个算法执行所耗费的时间
  • 空间复杂度:对一个算法在运行过程中临时占用存储空间大小的量度

常见复杂度由小到大:O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n)

在各种不同算法中,若算法中语句执行次数(时间复杂度)/占用空间(空间复杂度)为一个常数,则复杂度为 O(1);当一个算法的复杂度与以 2 为底的 n 的对数成正比时,可表示为 O(log n);当一个算法的复杂度与 n 成线性比例关系时,可表示为 O(n),依次类推。

时间复杂度记忆

可以分成几大类进行记忆:

  • 冒泡、选择、插入排序需要两个 for 循环,每次只关注一个元素,平均时间复杂度为 O(n²)(一遍找元素O(n),一遍找位置O(n))
  • 快速、归并、堆基于分治思想,log 以 2 为底,平均时间复杂度往往和 O(nlogn)(一遍找元素O(n),一遍找位置O(logn))相关
  • 而希尔排序依赖于所取增量序列的性质,但是到目前为止还没有一个最好的增量序列 。例如希尔增量序列时间复杂度为O(n²),而 Hibbard 增量序列的希尔排序的时间复杂度为O(n^(3/2)),有人在大量的实验后得出结论:当 n 在某个特定的范围后希尔排序的最小时间复杂度大约为 n^1.3。
  • 基数排序时间复杂度为 O(N*M),其中 N 为数据个数,M 为数据位数

快速排序的优势

从平均时间来看,快速排序是效率最高的:

快速排序中平均时间复杂度O(nlog n),这个公式中隐含的常数因子很小,比归并排序的O(nlog n)中的要小很多,所以大多数情况下,快速排序总是优于合并排序的。

而堆排序的平均时间复杂度也是O(nlog n),但是堆排序存在着重建堆的过程,它把根节点移除后,把最后的叶子结点拿上来,是为了重建堆,但是,拿上的值是要比它的两个叶子结点要差很多的,它要比较很多次,才能回到合适的位置。堆排序就会有很多的时间耗在堆调整上。

虽然快速排序的最坏情况为排序规模为 O(n²),但是这种最坏情况取决于每次选择的基准, 对于这种情况,已经提出了很多优化的方法,比如三取样划分和Dual-Pivot快排。

同时,当排序规模较小时,划分的平衡性容易被打破,而且频繁的方法调用超过了O(nlog n)为O(n²)省出的时间,所以一般排序规模较小时,会改用插入排序或者其他排序算法。

相关推荐
橘猫云计算机设计几秒前
基于Java的班级事务管理系统(源码+lw+部署文档+讲解),源码可白嫖!
java·开发语言·数据库·spring boot·微信小程序·小程序·毕业设计
网安秘谈2 分钟前
密码学国密算法深度解析:SM2椭圆曲线密码与SM3密码杂凑算法
算法·密码学
多多*6 分钟前
JavaEE企业级开发 延迟双删+版本号机制(乐观锁) 事务保证redis和mysql的数据一致性 示例
java·运维·数据库·redis·mysql·java-ee·wpf
计算机-秋大田9 分钟前
基于Spring Boot的个性化商铺系统的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
士别三日&&当刮目相看21 分钟前
JAVA学习*String类
java·开发语言·学习
烂蜻蜓28 分钟前
深度解读 C 语言运算符:编程运算的核心工具
java·c语言·前端
王嘉俊92536 分钟前
ReentranLock手写
java·开发语言·javase
my_realmy44 分钟前
JAVA 单调栈习题解析
java·开发语言
小羊在奋斗1 小时前
【算法】动态规划:回文子串问题、两个数组的dp
算法·动态规划
高飞的Leo1 小时前
工厂方法模式
java·开发语言·工厂方法模式