【数据结构与算法】2 - 10 八大内部排序算法(下):选择、归并与基数

§2-10 八大内部排序算法(下):选择、归并与基数

2-10.1 选择排序(Selection sort)

2-10.1.1 直接选择排序(Direct selection sort)

排序思想:从数组的起始索引开始,对其后面的元素逐个比较,找到比它小的值并交换,一轮循环后,最小值的位置已确定并已正确排列。接着从 1 索引开始,重复上述过程,直至到结束索引 - 1 处,每一轮索引都会确定一个最小值。因此,对于一个长度为 n 的数组,只需循环 n - 1 次即可。

算法实现

public static void selectSort(int[] arr) {
    // 从起始索引处开始遍历
    for (int index = 0; index < arr.length - 1; index++) {
        // 对每个索引,依次比较基准索引后元素
        for (int i = index + 1; i < arr.length; i++) {
            if (arr[index] > arr[i]) {
                int temp = arr[index];
                arr[index] = arr[i];
                arr[i] = temp;
            }
        }
    }
}

注意

  1. 每一轮比较时,都存在一个基准索引,将基准索引的值与其后面的值做比较;
  2. 基准索引处的值会随着内层循环的进行可能发生变化,但一轮循环(外层循环)结束后,总会确定该轮循环基准索引处的值,即最小值。

直接选择排序的平均时间复杂度、最好情况、最坏情况都为 \(O(n^2)\),空间复杂度为 \(O(1)\),排序不稳定。

优化:直接确定当前轮中最小值,减少变量交换次数

若基准数后有多个比它小的数,每次满足条件时都会进行变量交换,耗费一定时间。可以先找到这一轮循环中的最小值,在循环结束时再做变量交换,就可实现每一轮循环仅一次交换,节省时间。

public static void selectOpt(int[] arr) {
    // 从起始索引处开始遍历
    for (int index = 0; index < arr.length - 1; index++) {
        // 在每一轮直接找到最小值,减少交换次数
        int minAtIndex = index; // 记录最小值所在索引,假设基准处为最小值
        for (int i = index + 1; i < arr.length; i++) {
            // 找出剩余元素的最小值索引
            if (arr[i] < arr[minAtIndex]) {
                minAtIndex = i;
            }
        }

        // 变量交换
        int temp = arr[index];
        arr[index] = arr[minAtIndex];
        arr[minAtIndex] = temp;
    }
}

2-10.1.2 堆排序(Heap sort)

排序思想:堆排序使用堆的数据结构,将数组以完全二叉树的形式对数组进行排序。

算法流程

下文内容来自:

排序算法:堆排序【图解+代码】_哔哩哔哩_bilibili

首先得先要了解一些概念。

大顶堆:堆中的父节点的值比其子节点的值都大的堆称为大顶堆。在堆排序中,使用数组存储大顶堆,如图所示。

image

下标为 i 的节点的父节点下标为 (i-1) / 2(整数除法);下标为 i 的父节点的左子节点下标为 i * 2 + 1,右子节点下标为 i * 2 + 2

使用数组存储堆时,使用的遍历顺序为层序遍历。且最底层尽可能向左填充,其余层完全填充,是一棵完全二叉树。

类似地,小顶堆就是父节点的值小于子节点的值的堆。使用相同的遍历方法存储为数组。堆排序使用大顶堆。

维护堆的性质

在排序过程中,要时刻维护大顶堆的性质,保证父节点的值大于子节点。当其中一个父节点不满足此性质时,应当从其直接子节点中找出最大值,将子节点中的该值与父节点的值对调。对调完成后,该子节点及其子节点可能不满足大顶堆的性质,应当检查该子节点是否满足大顶堆的性质,并用相同方法维护性质。维护完成,数组中存储的数据应当发生相同的变化。

维护堆的性质的代码实现:

	/**
     * 维护堆的性质(大顶堆)
     *
     * @param arr    待维护的数组
     * @param length 数组(堆)长度
     * @param index  待维护的下标(父节点)
     */
private static void heapify(int[] arr, int length, int index) {
    int max = index;    // 假设父节点具有最大值
    int leftChild = index * 2 + 1;  // 左子节点下标
    int rightChild = index * 2 + 2; // 右子节点下标

    // 从左右子节点中找到具有最大值的节点坐标,注意左右子节点坐标不应越界
    if (leftChild < length && arr[leftChild] > arr[max])
        max = leftChild;
    if (rightChild < length && arr[rightChild] > arr[max])
        max = rightChild;

    // 左右子节点判断流程结束,若父节点值并非最大值
    if (max != index) {
        // 交换父子节点的值,维护性质
        swap(arr, index, max);
        // 子节点在交换后可能破坏了堆的性质,应当检查并继续维护(递归)
        heapify(arr, length, max);
    }
}

// 变量交换
private static void swap(int[] arr, int a, int b) {
    int temp = arr[a];
    arr[a] = arr[b];
    arr[b] = temp;
}

维护堆性质的时间复杂度为 \(O(\log n)\)

建立大顶堆

进行堆排序时,所传入的数组可能是无序的,也就是说,这个堆也是无序的。则应当先建立大顶堆。这一过程实际上就是在维护大顶堆的性质。而建堆,需要从最后一个父节点(非叶节点)开始,以上图为例,则应当从索引 4 处的结点开始检查并维护堆的性质,然后到索引 3,索引 2,索引 1,以此类推。维护过程中仍然要注意变量交换时可能导致的性质破坏问题,应当递归调用上述方法维护性质。

建堆的时间复杂度为 O(n)(使用级数证明)。

建堆完成后,将堆顶元素与堆中最后一个元素交换,并将最后一个元素从堆中删去。将剩余的元素视为一个新堆,重复上述建堆、维护堆、交换删除的过程。直至堆中只剩一个元素时,认为全数组有序,排序完成。

算法实现

// 堆排序
public static void heapSort(int[] arr) {
    // 建立大顶堆:实际上就是维护堆的性质
    // 数组最后一位元素的下标为 arr.length - 1,其父节点(整数除法)下标为 (length - 1 - 1) / 2 = length / 2 - 1
    // 自下向上建堆
    for (int i = arr.length / 2 - 1; i >= 0; i--) {
        heapify(arr, arr.length, i);
    }

    // 排序
    for (int i = arr.length - 1; i > 0; i--) {
        // 交换堆顶和堆最后一个元素
        swap(arr, 0, i);
        // 删去堆中最后一个元素,则新堆长度为 length - 1,对新堆进行维护
        // 自上向下建堆
        heapify(arr, i, 0);
    }
}

对排序的时间复杂度是对 n 个数进行堆维护的时间复杂度,即 \(O(n \cdot \log n)\)(平均、最好、最坏),空间复杂度为 \(O(1)\),排序不稳定。

2-10.2 归并排序(Merge sort)

排序思想:将待排序的序列逐步对半划分,直至划分后得到的每一组都有且仅有 1 个元素,这样的组天然有序。然后,再对这样的组,再两两归并,归并过程中完成排序,直到最后整合为一个已经完全排好序的数组。这种排序方式也称为二路归并排序(2-way merge sort)。

算法实现参考自:

排序算法:归并排序【图解+代码】_哔哩哔哩_bilibili

算法实现

public static void mergeSort(int[] arr) {
    // 创建一个临时数组
    int[] tempArr = new int[arr.length];

    // 划分区间,并归并
    partition(arr, tempArr, 0, arr.length - 1);
}

	/**
     * 划分后归并。
     *
     * @param arr     待排序数组
     * @param tempArr 辅助数组
     * @param left    划分开始的左索引
     * @param right   划分结束的右索引
     */
private static void partition(int[] arr, int[] tempArr, int left, int right) {
    // 对原数组作划分
    // 若划分区间为 1,该区间天然有序,无需划分
    // 否则,继续划分,直至区间长度为 1
    if (left < right) {
        // 找到划分左右子区间的中间点
        int mid = (left + right) / 2;
        // 递归划分左子区间
        partition(arr, tempArr, left, mid);
        // 递归划分右子区间
        partition(arr, tempArr, mid + 1, right);
        // 划分完成后,归并已经排序的部分
        merge(arr, tempArr, left, mid, right);
    }
}

	/**
     * 归并,并排序。
     *
     * @param arr     原数组
     * @param tempArr 辅助数组
     * @param left    左划分子区间的起始索引
     * @param mid     划分子区间的中间点
     * @param right   右划分子区间的结束索引
     */
private static void merge(int[] arr, int[] tempArr, int left, int mid, int right) {
    // 记录左子区间的首个未排序元素
    int leftPos = left;
    // 记录右子区间的首个未排序元素
    int rightPos = mid + 1;
    // 临时数组的下标
    int tempPos = left;

    // 比较两个区间的元素,按升序排列放到辅助数组中
    while (leftPos <= mid && rightPos <= right) {
        // 若左子区间的元素更小
        if (arr[leftPos] < arr[rightPos])
            tempArr[tempPos++] = arr[leftPos++];    // 赋值完需要移动指针,指向下一个未排序元素
        else // 反之,则右子区间的元素更小
            tempArr[tempPos++] = arr[rightPos++];
    }

    // 若分区大小不一致(即分区不均匀),则其中一区间已经遍历完,另一区间还余有元素
    // 这时,上述循环条件不满足,应当拆开上述循环
    // 余有元素的区间剩余元素已经有序,直接拼接,合并剩余元素
    // 两个循环只能二选一进行
    while (leftPos <= mid)
        tempArr[tempPos++] = arr[leftPos++];
    while (rightPos <= right)
        tempArr[tempPos++] = arr[rightPos++];

    // 将临时数组中的元素复制到原来的数组
    while (left <= right) {
        arr[left] = tempArr[left];
        left++;
    }
}

注意

  1. 调用 partition() 方法划分区间时,若划分长度为 1,此时 left == right,不满足条件,无需划分。

该算法的平均时间复杂度、最好情况、最坏情况为 \(O(n \cdot \log n)\),空间复杂度为 \(O(n)\),排序稳定。

2-10.3 基数排序(Radix sort)

排序思想:不同于其他排序算法,基数排序没有任何的变量比较与交换,整个排序过程通过分配再收集的方式完成。数组中最大值的位数,决定排序的循环轮次,依次为个、十、百、千等,用于匹配每一轮循环中遍历数组时,收集元素对应的位数的值。以第一轮循环为例,遍历数组,将数组元素按照个位数的值收集到不同的 “桶” 当中,存储完成后,再从个位数 0-9 的顺序依次抽出,组成新数组。接着,进入百位循环(如果有的话),重复这一过程,直至完成排序。

但值得注意的是,该排序算法只对非负整数序列有效

算法实现

// 数组最大值的位数决定循环轮次
private static int getRound(int[] arr) {
    int max = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max)
            max = arr[i];
    }

    // 返回其长度
    return String.valueOf(max).length();
}

public static void radixSort(int[] arr) {
    // 定义二维数组 “桶”,用于存放数位中对应的不同值
    int[][] remainders = new int[10][arr.length];   // 防止极端情况,一维数组长度为待排序数组长度
    // 定义统计数组,用于收集阶段统计数位对应值所有的元素的数量
    int[] stats = new int[10];
    // 确定循环轮次
    int round = getRound(arr);

    // 收集-分配-再收集-再分配过程
    for (int i = 0, n = 1; i < round; i++, n *= 10) {
        // 收集阶段
        for (int i1 = 0; i1 < arr.length; i1++) {
            // 提取对应数位上的数字,由循环轮次决定
            int digit = arr[i1] / n % 10;
            // 存放进基数数组中,统计数组同步更新
            remainders[digit][stats[digit]++] = arr[i1];
        }

        // 分配阶段(取出)
        // 根据统计数组中的统计信息抽取
        int index = 0;  // 原数组的索引,从起始处开始
        for (int i1 = 0; i1 < stats.length; i1++) {
            // 若为 0 表明当前值没有元素
            if (stats[i1] != 0) {
                // 抽取元素,按基数数组中的元素顺序放回原数组
                for (int j = 0; j < stats[i1]; j++) {
                    arr[index] = remainders[i1][j];
                    index++;    // 移动原数组指针
                }

                // 抽取完成,更新统计数据
                stats[i1] = 0;
            }
        }
    }
}

该算法的时间复杂度为 \(O(d(n+r))\),其中 \(d\) 是位数,\(r\) 是基数,空间复杂度为 \(O(r)\),排序稳定。

2-10.4 不同排序算法的选择

这八大排序算法的时间和空间复杂度如下表所示:

排序方法 最好时间 平均时间 最坏时间 辅助存储 稳定性
冒泡排序 \(O(n)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 稳定
快速排序 \(O(n\log n)\) \(O(n\log n)\) \(O(n^2)\) \(O(\log n)\) 不稳定
直接插入排序 \(O(n)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 稳定
希尔排序 \(O(n^{1.3})\) \(O(n\log n)\) \(O(n^2)\) \(O(1)\) 不稳定
直接选择排序 \(O(n^2)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 不稳定
堆排序 \(O(n\log n)\) \(O(n\log n)\) \(O(n\log n)\) \(O(1)\) 不稳定
归并排序 \(O(n\log n)\) \(O(n\log n)\) \(O(n\log n)\) \(O(n)\) 稳定
基数排序 \(O(nd)\) \(O(nd)\) \(O(nd)\) \(O(n)\) 稳定

从平均情况看:堆排序、归并排序、快速排序胜过希尔排序;

从最好情况看:冒泡排序和直接插入排序更胜一筹;

从最差情况看:堆排序和归并排序强于快速排序。

下文内容引用自《数据结构教程 第 5 版》李春葆 著

不同的排序方法适应不同的应用环境和要求,选择合适的排序算法应当综合考虑下列因素:

  • 待排序的元素数目(问题规模);
  • 元素的大小(每个元素的规模);
  • 关键字结构及其初始状态;
  • 对稳定性的要求;
  • 语言工具的条件;
  • 数据存储结构;
  • 时间和空间复杂度等;

没有哪一种排序方法是绝对地好的,任何一种方法都有其优缺点,适用于不同的环境,应在实际应用场景中视具体情况作选择。

下面给出综合考虑了以上几个方面所得出的大致结论:

  • n 较小(如 n <= 50),可采用直接插入或简单选择排序;一般地,直接插入排序较好,但简单选择排序移动的元素少于直接排序;
  • 若文件初始状态基本有序(正序),则选用直接插入或冒泡排序;
  • n 较大,应采用时间复杂度为 \(O(n \cdot \log n)\) 的排序方法(快速排序、堆排序、二路归并排序)。快速排序是目前基于比较的内部排序中认为最好的方法,当待排序的关键字随机分布时,快速排序平均时间最少;堆排序的所需辅助空间少于快速排序,且不会出现快速排序可能出现的最坏情况;若要追求稳定,则应当选择归并排序;
  • 若需要将两个有序表合并形成新的有序表,最好用二路归并排序;
  • 基数排序可能在 \(O(n)\) 的时间内完成排序,但只适用于字符串、整数这类具有明显结构特征的关键字;若 n 很大,元素关键字位数较少且可以分解时采用基数排序较好。
posted @ 2024-01-14 17:06  Zebt  阅读(57)  评论(0)    收藏  举报