算法之排序算法

1.排序算法

「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有 序数据通常能够被更有效地查找、分析和处理。
如下图所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。

1.1 评价维度

运行效率 :我们期望排序算法的时间复杂度尽量低且总体操作数量较少(即时间复杂度中的常数项降低)。
对于大数据量情况,运行效率显得尤为重要。
就地性 :顾名思义,「原地排序」通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
稳定性 :「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。
稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失。
# 输入数据是按照姓名排序好的
# (name, age)
('A', 19)
('B', 18)
('C', 21)
('D', 19)
('E', 23)
# 假设使用非稳定排序算法按年龄排序列表,
# 结果中 ('D', 19) ('A', 19) 的相对位置改变,(原本d在a的下面,此时,d变到了a的前面)
# 输入数据按姓名排序的性质丢失
('B', 18)
('D', 19)
('A', 19)
('C', 21)
('E', 23)
自适应性 :「自适应排序」的时间复杂度会受输入数据的影响,即最佳、最差、平均时间复杂度并不完全相等。
自适应性需要根据具体情况来评估。如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
是否基于比较 :「基于比较的排序」依赖于比较运算符(<、 = > )来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 𝑂(𝑛 log 𝑛) 。而「非比较排序」不使用比较运算符,时间复杂度可达 𝑂(𝑛) ,但其通用性相对较差。

1.2 理想排序算法

运行快、原地、稳定、正向自适应、通用性好 。显然,迄今为止尚未发现兼具以上所有特性的排序算法。因此,在选择排序算法时,需要根据具体的数据特点和问题需求来决定。
接下来,我们将共同学习各种排序算法,并基于上述评价维度对各个排序算法的优缺点进行分析。

2.选择排序

「选择排序 selection sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
设数组的长度为 𝑛 ,选择排序的算法流程如下图所示。
1. 初始状态下,所有元素未排序,即未排序(索引)区间为[0, 𝑛 − 1] 。
2.选取区间[0,n 一1]中的最小元素,将其与索引0处元素交换。完成后,数组前1个元素已排序。
3.选取区间[1,n一1]中的最小元素,将其与索引1处元素交换。完成后,数组前2个元素已排序。
4.以此类推。经过n―1轮选择与交换后,数组前n一1个元素已排序。
5.仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。

 

    /* 选择排序 */
    void selectionSort(int[] nums) {
        int n = nums.length;
        // 外循环:未排序区间为 [i, n-1]
        for (int i = 0; i < n - 1; i++) {
            // 内循环:找到未排序区间内的最小元素
            int k = i;
            for (int j = i + 1; j < n; j++) {
                if (nums[j] < nums[k])
                    k = j; // 记录最小元素的索引
            }
            // 将该最小元素与未排序区间的首个元素交换
            int temp = nums[i];
            nums[i] = nums[k];
            nums[k] = temp;
        }
    }

 2.1算法特性

·时间复杂度为O(n的2次方)、非自适应排序:外循环共n一1轮,第一轮的未排序区间长度为n,最后一轮的未排序区间长度为2,即各轮外循环分别包含n、n-1、...3、2轮内循环.求和为(n—1)(n+2)﹒空间复杂度O(1)、原地排序:指针i和j使用常数大小的额外空间。
·非稳定排序:如下图所示,元素nuns[i]有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。

3.冒泡排序

「冒泡排序 bubble sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样, 因此得名冒泡排序。
如下图所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。

 

 3.1算法流程

设数组的长度为n,冒泡排序的步骤如下图所示。
1.首先,对n个元素执行“冒泡”,将数组的最大元素交换至正确位置,
2.接下来,对剩余n―1个元素执行“冒泡”,将第二大元素交换至正确位置。3.以此类推,经过n―1轮“冒泡”后,前n―1大的元素都被交换至正确位置。4.仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。

    /* 冒泡排序 */
    void bubbleSort(int[] nums) {
        // 外循环:未排序区间为 [0, i]
        for (int i = nums.length - 1; i > 0; i--) {
            // 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
            for (int j = 0; j < i; j++) {
                if (nums[j] > nums[j + 1]) {
                    // 交换 nums[j] 与 nums[j + 1]
                    int tmp = nums[j];
                    nums[j] = nums[j + 1];
                    nums[j + 1] = tmp;
                }
            }
        }
    }

3.2效率优化

我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 flag 来监测这种情况,一旦出现就立即返回。
经过优化,冒泡排序的最差和平均时间复杂度仍为𝑂(𝑛2 ) ;但当输入数组完全有序时,可达到最佳时间复杂度𝑂(𝑛) 。
/* 冒泡排序(标志优化) */
void bubbleSortWithFlag(int[] nums) {
    // 外循环:未排序区间为 [0, i]
    for (int i = nums.length - 1; i > 0; i--) {
        boolean flag = false; // 初始化标志位
        // 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
        for (int j = 0; j < i; j++) {
            if (nums[j] > nums[j + 1]) {
                // 交换 nums[j] 与 nums[j + 1]
                int tmp = nums[j];
                nums[j] = nums[j + 1];
                nums[j + 1] = tmp;
                flag = true; // 记录交换元素
            }
          }
        if (!flag)
            break; // 此轮冒泡未交换任何元素,直接跳出
        }
}

3.3算法特性

·时间复杂度为O(n的2次方)、自适应排序:各轮“冒泡”遍历的数组长度依次为n —1、n一2、...、2、1,总和为(n — 1)n/2。在引入flag 优化后,最佳时间复杂度可达到O(n)。
·空间复杂度为O(1)、原地排序︰指针i和j使用常数大小的额外空间。

﹒稳定排序:由于在“冒泡”中遇到相等元素不交换。


4. 插入排序

「插入排序 insertion sort」是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。
下图展示了数组插入元素的操作流程。设基准元素为 base ,我们需要将从目标索引到 base 之间的所有元素向右移动一位,然后再将 base 赋值给目标索引。

4.1算法流程

插入排序的整体流程如下图所示。
1. 初始状态下,数组的第 1 个元素已完成排序。
2. 选取数组的第 2 个元素作为 base ,将其插入到正确位置后, 数组的前 2 个元素已排序
3. 选取第 3 个元素作为 base ,将其插入到正确位置后, 数组的前 3 个元素已排序
4. 以此类推,在最后一轮中,选取最后一个元素作为 base ,将其插入到正确位置后, 所有元素均已排序


 


    /* 插入排序 */
    void insertionSort(int[] nums) {
        // 外循环:已排序元素数量为 1, 2, ..., n
        for (int i = 1; i < nums.length; i++) {//将第一个元素视为已排序的部分,从下标为1的开始循环
            int base = nums[i], j = i - 1;//找到未排序部分的第一个元素,j用于在已排序部分找到 base 的正确位置。
            // 内循环:将 base 插入到已排序部分的正确位置
            while (j >= 0 && nums[j] > base) {
                nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位。将 nums[j] 向右移动一位,为插入 base 腾出位置。
                j--;
            }
            nums[j + 1] = base; // 将 base 赋值到正确位置
        }
    }

4.2算法特性

·时间复杂度O(n的2次方)、自适应排序∶最差情况下,每次插入操作分别需要循环n 一1、n一2、...、2、1次,求和得到(n 一 1)n/2,因此时间复杂度为O(n2)。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度O(n)。
·空间复杂度O(1)、原地排序:指针i和j使用常数大小的额外空间。
·稳定排序:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。
 

4.3 插入排序优势

插入排序的时间复杂度为O(n2),而我们即将学习的快速排序的时间复杂度为O(n log n)。尽管插入排序的时间复杂度相比快速排序更高,但在数据量较小的情况下,插入排序通常更快。
这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类O(n logn)的算法属于基于分治的排序算法,往往包含更多单元计算操作。而在数据量较小时,n2和 n log n的数值比较接近,复杂度不占主导作用;每轮中的单元操作数量起到决定性因素。
实际上,许多编程语言(例如Java)的内置排序函数都采用了插入排序,大致思路为:对于长数组,采用基于分治的排序算法,例如快速排序;对于短数组,直接使用插入排序。
虽然冒泡排序、选择排序和插入排序的时间复杂度都为O(n2),但在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因。
·冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及3个单元操作;插入排序基于元素赋值实现,仅需 1 个单元操作。因此,冒泡排序的计算开销通常比插入排序更高

‧ 选择排序在任何情况下的时间复杂度都为 𝑂(𝑛 2 ) 如果给定一组部分有序的数据,插入排序通常比选 择排序效率更高
‧ 选择排序不稳定,无法应用于多级排序。

5. 快速排序


「快速排序quick sort」是一种基于分治策略的排序算法,运行高效,应用广泛。
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如下图所示。
1.选取数组最左端元素作为基准数,初始化两个指针i和j分别指向数组的两端。
2.设置一个循环,在每轮中使用i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。

3.循环执行步骤2.,直到i和j相遇时停止,最后将基准数交换至两个子数组的分界线。

哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 ≤ 基 准数 ≤ 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序。
/* 元素交换 */
    void swap(int[] nums, int i, int j) {
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }
    /* 哨兵划分 */
    int partition(int[] nums, int left, int right) {
        // 以 nums[left] 作为基准数
        int i = left, j = right;
        while (i < j) {
            while (i < j && nums[j] >= nums[left])
                j--; // 从右向左找首个小于基准数的元素
            while (i < j && nums[i] <= nums[left])
                i++; // 从左向右找首个大于基准数的元素
            swap(nums, i, j); // 交换这两个元素
        }
        swap(nums, i, left); // 将基准数交换至两子数组的分界线
        return i; // 返回基准数的索引
    }

5.1算法流程

1. 首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组。
2. 然后,对左子数组和右子数组分别递归执行“哨兵划分”。
3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。

 void quickSort(int[] nums, int left, int right) {
        // 子数组长度为 1 时终止递归
        if (left >= right)
            return;
        // 哨兵划分
        int pivot = partition(nums, left, right);
        // 递归左子数组、右子数组
        quickSort(nums, left, pivot - 1);
        quickSort(nums, pivot + 1, right);
    }

5.2算法特性

·时间复杂度O(n log n)、自适应排序︰在平均情况下,哨兵划分的递归层数为log n ,每层中的总循环数为n,总体使用O(n logn)时间。在最差情况下,每轮哨兵划分操作都将长度为n的数组划分为长度为0和n―1的两个子数组,此时递归层数达到n层,每层中的循环数为n,总体使用O(n2)时间。·空间复杂度O(n)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度n,使用O(n)栈帧空间。排序操作是在原数组上进行的,未借助额外数组
·非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
 

5.3快排为什么快?

从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,主要有以下原因。
·出现最差情况的概率很低:虽然快速排序的最差时间复杂度为O(n2),没有归并排序稳定,但在绝大多数情况下,快速排序能在O(n log n)的时间复杂度下运行。
·缓存使用效率高:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。
·复杂度的常数系数低:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。

5.4基准数优化

快速排序在某些输入下的时间效率可能降低 。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 𝑛 − 1 、右子数组长度为 0 。如此递归下去,每轮哨兵划分后的右子数组长度都为 0 ,分治策略失效,快速排序退化为“冒泡排序”。
为了尽量避免这种情况发生, 我们可以优化哨兵划分中的基准数的选取策略 。例如,我们可以随机选取一个元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。
需要注意的是,编程语言通常生成的是“伪随机数”。如果我们针对伪随机数序列构建一个特定的测试样例,那么快速排序的效率仍然可能劣化。
为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素), 并将这三个候选 元素的中位数作为基准数 。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 𝑂(𝑛 2 ) 的概率大大降低。
/* 选取三个元素的中位数 */
    int medianThree(int[] nums, int left, int mid, int right) {
        // 此处使用异或运算来简化代码
        // 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
        if ((nums[left] < nums[mid]) ^ (nums[left] < nums[right]))
            return left;
        else if ((nums[mid] < nums[left]) ^ (nums[mid] < nums[right]))
            return mid;
        else
            return right;
    }
    /* 哨兵划分(三数取中值) */
    int partition(int[] nums, int left, int right) {
        // 选取三个候选元素的中位数
        int med = medianThree(nums, left, (left + right) / 2, right);
        // 将中位数交换至数组最左端
        swap(nums, left, med);
        // 以 nums[left] 作为基准数
        int i = left, j = right;
        while (i < j) {
            while (i < j && nums[j] >= nums[left])
                j--; // 从右向左找首个小于基准数的元素
            while (i < j && nums[i] <= nums[left])
                i++; // 从左向右找首个大于基准数的元素
            swap(nums, i, j); // 交换这两个元素
        }
        swap(nums, i, left); // 将基准数交换至两子数组的分界线
        return i; // 返回基准数的索引
    }
    void swap(int[] nums, int i, int j) {
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }

5.5尾递归优化

在某些输入下,快速排序可能占用空间较多。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为0,递归树的高度会达到n―1,此时需要占用O(n)大小的栈帧空间。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。由于较短子数组的长度不会超过n/2,因此这种方法能确保递归深度不超过log n,从而将最差空间复杂度优化至O(log n)。

 /* 快速排序(尾递归优化) */
    void quickSort(int[] nums, int left, int right) {
        // 子数组长度为 1 时终止
        while (left < right) {
            // 哨兵划分操作
            int pivot = partition(nums, left, right);
            // 对两个子数组中较短的那个执行快排
            if (pivot - left < right - pivot) {
                quickSort(nums, left, pivot - 1); // 递归排序左子数组
                left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
            } else {
                quickSort(nums, pivot + 1, right); // 递归排序右子数组
                right = pivot - 1; // 剩余未排序区间为 [left, pivot - 1]
            }
        }
    }
    int partition(int[] nums, int left, int right) {
        // 以 nums[left] 作为基准数
        int i = left, j = right;
        while (i < j) {
            while (i < j && nums[j] >= nums[left])
                j--; // 从右向左找首个小于基准数的元素
            while (i < j && nums[i] <= nums[left])
                i++; // 从左向右找首个大于基准数的元素
            swap(nums, i, j); // 交换这两个元素
        }
        swap(nums, i, left); // 将基准数交换至两子数组的分界线
        return i; // 返回基准数的索引
    }
    void swap(int[] nums, int i, int j) {
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }

6.归并排序

「归并排序 merge sort」是一种基于分治策略的排序算法,包含下图所示的“划分”和“合并”阶段。
1. 划分阶段 :通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
2. 合并阶段 :当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。

6.1 算法流程

如下图所示,“划分阶段”从顶至底递归地将数组从中点切分为两个子数组。
1. 计算数组中点 mid , 递归划分左子数组(区间 [left, mid] )和右子数组(区间 [mid + 1, right] )。
2. 递归执行步骤 1. ,直至子数组区间长度为 1 时,终止递归划分。
“合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的。
观察发现,归并排序与二叉树后序遍历的递归顺序是一致的。
        ‧ 后序遍历 :先递归左子树,再递归右子树,最后处理根节点。
        ‧ 归并排序 :先递归左子数组,再递归右子数组,最后处理合并。
    /* 合并左子数组和右子数组 */
    static void merge(int[] nums, int left, int mid, int right) {
        // 左子数组区间 [left, mid], 右子数组区间 [mid+1, right]
        // 创建一个临时数组 tmp ,用于存放合并后的结果
        int[] tmp = new int[right - left + 1];
        // 初始化左子数组和右子数组的起始索引
        //i 是左子数组的起始索引,而 j 是右子数组的起始索引。在 while 循环中,我们比较左右子数组的元素,
        // 并将较小的元素放入临时数组。通过 j = mid + 1,我们从右子数组的第一个元素开始比较。
        int i = left, j = mid + 1, k = 0;
        // 当左右子数组都还有元素时,比较并将较小的元素复制到临时数组中
        while (i <= mid && j <= right) {
            if (nums[i] <= nums[j])
                tmp[k++] = nums[i++];//先赋值再++
            else
                tmp[k++] = nums[j++];
        }
        // 将左子数组和右子数组的剩余元素复制到临时数组中
        while (i <= mid) {
            tmp[k++] = nums[i++];
        }
        while (j <= right) {
            tmp[k++] = nums[j++];
        }
        // 将临时数组 tmp 中的元素复制回原数组 nums 的对应区间
        for (k = 0; k < tmp.length; k++) {
            nums[left + k] = tmp[k];
        }
    }
    /* 元素交换 */
    void swap(int[] nums, int i, int j) {
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }
值得注意的是, nums 的待合并区间为 [left, right] ,而 tmp 的对应区间为 [0, right - left]

6.2算法特性

·时间复杂度O(n logn)、非自适应排序:划分产生高度为log n 的递归树,每层合并的总操作数量为n,因此总体时间复杂度为O(n log n)。
·空间复杂度O(n)、非原地排序:递归深度为log n,使用O(log n)大小的栈帧空间。合并操作需要借助辅助数组实现,使用O(n)大小的额外空间。
稳定排序:在合并过程中,相等元素的次序保持不变。
 

6.3链表排序

对于链表,归并排序相较于其他排序算法具有显著优势, 可以将链表排序任务的空间复杂度优化至 𝑂(1)
划分阶段 :可以通过使用“迭代”替代“递归”来实现链表划分工作,从而省去递归使用的栈帧空间。
合并阶段 :在链表中,节点增删操作仅需改变引用(指针)即可实现,因此合并阶段(将两个短有序链
表合并为一个长有序链表)无须创建额外链表。
具体实现细节比较复杂,有兴趣的同学可以查阅相关资料进行学习

7堆排序

「堆排序 heap sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和 “元素出堆操作”实现堆排序。
1. 输入数组并建立小顶堆,此时最小元素位于堆顶。
2. 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列
以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间。在实际中,我们通常使用一种更加优雅的实现方式。

7.1算法流程

设数组的长度为 𝑛 ,堆排序的流程如下图所示。
1. 输入数组并建立大顶堆。完成后,最大元素位于堆顶。
2. 将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换。完成交换后,堆的长度减
1 ,已排序元素数量加1 。
3. 从堆顶元素开始,从顶到底执行堆化操作(Sift Down)。完成堆化后,堆的性质得到修复。
4. 循环执行第 2. 3. 步。循环𝑛 − 1 轮后,即可完成数组排序。
在代码实现中,我们使用了与堆章节相同的从顶至底堆化 sift_down() 函数。值得注意的是,由于堆的长度会随着提取最大元素而减小,因此我们需要给 sift_down() 函数添加一个长度参数
𝑛 ,用于指定堆的当前有效长度。
    /* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
    void siftDown(int[] nums, int n, int i) {
        while (true) {
            // 判断节点 i, l, r 中值最大的节点,记为 ma
            int l = 2 * i + 1;
            int r = 2 * i + 2;
            int ma = i;
            if (l < n && nums[l] > nums[ma])
                ma = l;
            if (r < n && nums[r] > nums[ma])
                ma = r;
            // 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
            if (ma == i)
                break;
            // 交换两节点
            int temp = nums[i];
            nums[i] = nums[ma];
            nums[ma] = temp;
            // 循环向下堆化
            i = ma;
        }
    }

    /* 堆排序 */
    void heapSort(int[] nums) {
        // 建堆操作:堆化除叶节点以外的其他所有节点
        for (int i = nums.length / 2 - 1; i >= 0; i--) {
            siftDown(nums, nums.length, i);
        }
        // 从堆中提取最大元素,循环 n-1 轮
        for (int i = nums.length - 1; i > 0; i--) {
            // 交换根节点与最右叶节点(即交换首元素与尾元素)
            int tmp = nums[0];
            nums[0] = nums[i];
            nums[i] = tmp;
            // 以根节点为起点,从顶至底进行堆化
            siftDown(nums, i, 0);
        }
    }

7.2 算法特性

时间复杂度 𝑂(𝑛 log 𝑛) 、非自适应排序:建堆操作使用 𝑂(𝑛) 时间。从堆中提取最大元素的时间复杂度为 𝑂(log 𝑛) ,共循环𝑛 − 1 轮。
空间复杂度 𝑂(1) 、原地排序 :几个指针变量使用 𝑂(1) 空间。元素交换和堆化操作都是在原数组上进行的。
非稳定排序 :在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。

8.桶排序

前述的几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法 的时间复杂度无法超越 𝑂(𝑛 log 𝑛) 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。
「桶排序 bucket sort」是分治策略的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。

8.1算法流程

考虑一个长度为 𝑛 的数组,元素是范围 [0, 1) 的浮点数。桶排序的流程如图 11‑13 所示。
1. 初始化 𝑘 个桶,将 𝑛 个元素分配到 𝑘 个桶中。
2. 对每个桶分别执行排序(本文采用编程语言的内置排序函数)。
3. 按照桶的从小到大的顺序,合并结果。
 /* 桶排序 */
    void bucketSort(float[] nums) {
        // 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
        int k = nums.length / 2;
        List<List<Float>> buckets = new ArrayList<>();
        for (int i = 0; i < k; i++) {
            buckets.add(new ArrayList<>());
        }
        // 1. 将数组元素分配到各个桶中
        for (float num : nums) {
            // 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
            int i = (int) (num * k);
            // 将 num 添加进桶 i
            buckets.get(i).add(num);
        }
        // 2. 对各个桶执行排序
        for (List<Float> bucket : buckets) {
            // 使用内置排序函数,也可以替换成其他排序算法
            Collections.sort(bucket);
        }
        // 3. 遍历桶合并结果
        int i = 0;
        for (List<Float> bucket : buckets) {
            for (float num : bucket) {
                nums[i++] = num;
            }
        }
    }

8.2 算法特性

桶排序适用于处理体量很大的数据。例如,输入数据包含100万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成1000个桶,然后分别对每个桶进行排序,最后将结果合并。
·时间复杂度O(n+k)︰假设元素在各个桶内平均分布,那么每个桶内的元素数量为k分之n。假设排序单个桶使用O(k分之n log k分之n)时间,则排序所有桶使用O(n log k分之n)时间。当桶数量k比较大时,时间复杂度则趋向于O(n)。合并结果时需要遍历所有桶和元素,花费O(n+k)时间。
·自适应排序:在最坏情况下,所有数据被分配到一个桶中,且排序该桶使用O(n的2次方)时间。·空间复杂度O(n+k)、非原地排序:需要借助k个桶和总共n个元素的额外空间。·桶排序是否稳定取决于排序桶内元素的算法是否稳定。

 

8.3 如何实现平均分配

桶排序的时间复杂度理论上可以达到 𝑂(𝑛) 关键在于将元素均匀分配到各个桶中 ,因为实际数据往往不是 均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均, 低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 份,各个桶中的商品数量差距会非常大。
为实现平均分配,我们可以先设定一个大致的分界线,将数据粗略地分到 3 个桶中。 分配完毕后,再将商品 较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等
如图所示,这种方法本质上是创建一个递归树,目标是让叶节点的值尽可能平均。当然,不一定要每 轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
如果我们提前知道商品价格的概率分布, 则可以根据数据概率分布设置每个桶的价格分界线 。值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。
如下图所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。

 9.计数排序

「计数排序 counting sort」通过统计元素数量来实现排序,通常应用于整数数组。

9.1 简单实现  

先来看一个简单的例子。给定一个长度为n的数组nuns,其中的元素都是“非负整数”,计数排序的整体流程如下图所示。
1.遍历数组,找出数组中的最大数字,记为m,然后创建一个长度为m +1的辅助数组counter。

2.借助counter统计nuns 中各数字的出现次数,其中 counter[num]对应数字num的出现次数。统计方法很简单,只需遍历nuns(设当前数字为 num),每轮将counter[nun]增加1即可。
3.由于counter的各个索引天然有序,因此相当于所有数字已经被排序好了。接下来,我们遍历c
ounter,根据各数字的出现次数,将它们按从小到大的顺序填入nums即可。
 

    /* 计数排序 */
    // 简单实现,无法用于排序对象
    void countingSortNaive(int[] nums) {
        // 1. 统计数组最大元素 m
        int m = 0;
        for (int num : nums) {
            m = Math.max(m, num);
        }
        // 2. 统计各数字的出现次数
        // counter[num] 代表 num 的出现次数
        int[] counter = new int[m + 1];
        for (int num : nums) {
            counter[num]++;
        }
        // 3. 遍历 counter ,将各元素填入原数组 nums
        int i = 0;
        for (int num = 0; num < m + 1; num++) {
            for (int j = 0; j < counter[num]; j++, i++) {
                nums[i] = num;
            }
        }
    }
计数排序与桶排序的联系
从桶排序的角度看,我们可以将计数排序中的计数数组 counter 的每个索引视为一个桶,将统
计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据
下的一个特例。

 

9.2 完整实现

细心的同学可能发现, 如果输入数据是对象,上述步骤 3. 就失效了 。假设输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
那么如何才能得到原数据的排序结果呢?我们首先计算 counter 的“前缀和”。顾名思义,索引 i 处的前缀和prefix[i] 等于数组前 i 个元素之和:

 void countingSort(int[] nums) {
        // 1. 统计数组最大元素 m
        int m = 0;
        for (int num : nums) {
            m = Math.max(m, num);//最大4
        }

        // 2. 统计各数字的出现次数
        // counter[num] 代表 num 的出现次数
        int[] counter = new int[m + 1];
        for (int num : nums) {
            counter[num]++;//counter[]:[3,2,3,0,2],0出现三次,1出现两次...
        }

        // 3. 求 counter 的前缀和,将“出现次数”转换为“尾索引”
        // 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
        for (int i = 0; i < m; i++) {
            //[3,5,8,8,10],遍历结束后数组变成了这个样子,
            // 现在counter[num]-1就是代表数字num在排序后数组中最后一次出现的索引
            counter[i + 1] += counter[i];
        }

        // 4. 倒序遍历 nums ,将各元素填入结果数组 res
        // 初始化数组 res 用于记录结果
        int n = nums.length;//10
        int[] res = new int[n];
        //counter[]:[3,5,8,8,10]
        for (int i = n - 1; i >= 0; i--) {
            int num = nums[i];//拿出这个数字,4
            //counter[num] - 1,拿到数字num在排序后数组中最后一次出现的索引,
            res[counter[num] - 1] = num; // 将 num 放置到对应索引处,res[9]=4
            //这是为了确保下一个相同的数字(如果有的话)能够被正确放置到排序后数组中的前一个位置,这个过程对于保持排序的稳定性非常重要。
            counter[num]--; // 令前缀和自减 1 ,得到下次放置 num 的索引,也就是[3,5,8,8,9],下次再取到相同值的num如4,就会取到counter[num] - 1=8,在8位置上在放4
        }

        // 使用结果数组 res 覆盖原数组 nums
        for (int i = 0; i < n; i++) {
            nums[i] = res[i];
        }
    }
}

 9.3 算法特性

·时间复杂度O(n +m)︰涉及遍历nuns 和遍历counter ,都使用线性时间。一般情况下n> >m,时间复杂度趋于O(n)。
·空间复杂度O(n + m)、非原地排序:借助了长度分别为n和m的数组res和counter。
·稳定排序:由于向res中填充元素的顺序是“从右向左”的,因此倒序遍历nuns可以避免改变相等元素之间的相对位置,从而实现稳定排序。实际上,正序遍历nuns也可以得到正确的排序结果,但结果是非稳定的。

 

9.4 局限性  

看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序工作。然而,使用计数排序的前置条件相对较为严格。
计数排序只适用于非负整数 。若想要将其用于其他类型的数据,需要确保这些数据可以被转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去即可。
计数排序适用于数据量大但数据范围较小的情况
。比如,在上述示例中𝑚 不能太大,否则会占用过多空间。 而当𝑛 ≪(远远小于) 𝑚 时,计数排序使用 𝑂(𝑚) 时间,可能比𝑂(𝑛 log 𝑛) 的排序算法还要慢。

10 基数排序 

上一节我们介绍了计数排序,它适用于数据量 𝑛 较大但数据范围
𝑚 较小的情况。假设我们需要对𝑛 = 106 个学号进行排序,而学号是一个8 位数字,这意味着数据范围𝑚 = 10的8次方, 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
「基数排序 radix sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。

10.1 算法流程

以学号数据为例,假设数字的最低位是第 1 位,最高位是第 8 位,基数排序的流程如下图所示。
1. 初始化位数 𝑘 = 1。
2. 对学号的第𝑘 位执行“计数排序”。完成后,数据会根据第𝑘 位从小到大排序。
3. 将 𝑘 增加 1 ,然后返回步骤2. 继续迭代,直到所有位都排序完成后结束。

 /* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */
    int digit(int num, int exp) {
        // 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
        return (num / exp) % 10;
    }
    /* 计数排序(根据 nums 第 k 位排序) */
    void countingSortDigit(int[] nums, int exp) {
        // 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
        int[] counter = new int[10];
        int n = nums.length;
        // 统计 0~9 各数字的出现次数
        for (int i = 0; i < n; i++) {
            int d = digit(nums[i], exp); // 获取 nums[i] 第 k 位,记为 d
            counter[d]++; // 统计数字 d 的出现次数
        }
        // 求前缀和,将“出现个数”转换为“数组索引”
        for (int i = 1; i < 10; i++) {
            counter[i] += counter[i - 1];
        }
        // 倒序遍历,根据桶内统计结果,将各元素填入 res
        int[] res = new int[n];
        for (int i = n - 1; i >= 0; i--) {
            int d = digit(nums[i], exp);
            int j = counter[d] - 1; // 获取 d 在数组中的索引 j
            res[j] = nums[i]; // 将当前元素填入索引 j
            counter[d]--; // 将 d 的数量减 1
        }
        // 使用结果覆盖原数组 nums
        for (int i = 0; i < n; i++)
            nums[i] = res[i];
    }
    /* 基数排序 */
    void radixSort(int[] nums) {
        // 获取数组的最大元素,用于判断最大位数
        int m = Integer.MIN_VALUE;
        for (int num : nums)
            if (num > m)
                m = num;
        // 按照从低位到高位的顺序遍历
        for (int exp = 1; exp <= m; exp *= 10)
            // 对数组元素的第 k 位执行计数排序
            // k = 1 -> exp = 1
            // k = 2 -> exp = 10
            // 即 exp = 10^(k-1)
            countingSortDigit(nums, exp);
    }
为什么从最低位开始排序?
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 𝑎 < 𝑏 ,而第二轮排序结果 𝑎 > 𝑏 ,那么第二轮的结果将取代第一轮的结果。由于数字的高
位优先级高于低位,我们应该先排序低位再排序高位。

 10.2 算法特性

相较于计数排序,基数排序适用于数值范围较大的情况, 但前提是数据必须可以表示为固定位数的格式,且位 数不能过大 。例如,浮点数不适合使用基数排序,因为其位数
𝑘 过大,可能导致时间复杂度 𝑂(𝑛𝑘) ≫ 𝑂(𝑛 2 ) 。
时间复杂度 𝑂(𝑛𝑘):设数据量为 𝑛、数据为𝑑 进制、最大位数为 𝑘 ,则对某一位执行计数排序使用
𝑂(𝑛 + 𝑑) 时间,排序所有 𝑘 位使用 𝑂((𝑛 + 𝑑)𝑘) 时间。通常情况下,𝑑 和 𝑘 都相对较小,时间复杂 度趋向 𝑂(𝑛) 。
空间复杂度 𝑂(𝑛 + 𝑑) 、非原地排序:与计数排序相同,基数排序需要借助长度为 𝑛 和 𝑑 的数组 res 和 counter
稳定排序 :与计数排序相同。

11.总结

1. 重点回顾
‧ 冒泡排序通过交换相邻元素来实现排序。通过添加一个标志位来实现提前返回,我们可以将冒泡排序的最佳时间复杂度优化到 𝑂(𝑛)
‧插入排序每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的 时间复杂度为 𝑂(𝑛2 ) ,但由于单元操作相对较少,它在小数据量的排序任务中非常受欢迎。

 ‧ 快速排序基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,导致时间复杂度劣化至 𝑂(𝑛2 ) 。引入中位数基准数或随机基准数可以降低这种劣化的概率。尾递归方法可以有效地减少递归深度,将空间复杂度优化到𝑂(log 𝑛)

‧ 归并排序包括划分和合并两个阶段,典型地体现了分治策略。在归并排序中,排序数组需要创建辅助数组,空间复杂度为 𝑂(𝑛) ;然而排序链表的空间复杂度可以优化至 𝑂(1)
桶排序包含三个步骤:数据分桶、桶内排序和合并结果。它同样体现了分治策略,适用于数据体量很大的情况。桶排序的关键在于对数据进行平均分配。
‧ 计数排序是桶排序的一个特例,它通过统计数据出现的次数来实现排序。计数排序适用于数据量大但数据范围有限的情况,并且要求数据能够转换为正整数。
‧ 基数排序通过逐位排序来实现数据排序,要求数据能够表示为固定位数的数字。
‧ 总的来说,我们希望找到一种排序算法,具有高效率、稳定、原地以及正向自适应性等优点。然而,正如其他数据结构和算法一样,没有一种排序算法能够同时满足所有这些条件。在实际应用中,我们需要根据数据的特性来选择合适的排序算法。

12 Q&A

排序算法稳定性在什么情况下是必须的?
在现实中,我们有可能是在对象的某个属性上进行排序。例如,学生有姓名和身高两个属性,
我们希望实现一个多级排序/
先按照姓名进行排序,得到 (A, 180) (B, 185) (C, 170) (D, 170) ;接下来对身高进行排序。
由于排序算法不稳定,我们可能得到 (D, 170) (C, 170) (A, 180) (B, 185)
可以发现,学生 D 和 C 的位置发生了交换,姓名的有序性被破坏了,而这是我们不希望看到
的。
哨兵划分中“从右往左查找”与“从左往右查找”的顺序可以交换吗?
不行,当我们以最左端元素为基准数时,必须先“从右往左查找”再“从左往右查找”。这个
结论有些反直觉,我们来剖析一下原因。
哨兵划分 partition() 的最后一步是交换 nums[left] nums[i] 。完成交换后,基准数左边
的元素都 <= 基准数, 这就要求最后一步交换前 nums[left] >= nums[i] 必须成立 。假设我们
先“从左往右查找”,那么如果找不到比基准数更小的元素, 则会在 i == j 时跳出循环,此时
可能 nums[j] == nums[i] > nums[left] 。也就是说,此时最后一步交换操作会把一个比基准
数更大的元素交换至数组最左端,导致哨兵划分失败。
举个例子,给定数组 [0, 0, 0, 0, 1] ,如果先“从左向右查找”,哨兵划分后数组为 [1, 0, 0, 0, 0] ,这个结果是不正确的。
再深入思考一下,如果我们选择 nums[right] 为基准数,那么正好反过来,必须先“从左往右
查找”。

 

关于尾递归优化,为什么选短的数组能保证递归深度不超过 log 𝑛
递归深度就是当前未返回的递归方法的数量。每轮哨兵划分我们将原数组划分为两个子数组。
在尾递归优化后,向下递归的子数组长度最大为原数组的一半长度。假设最差情况,一直为一
半长度,那么最终的递归深度就是 log
𝑛
回顾原始的快速排序,我们有可能会连续地递归长度较大的数组,最差情况下为
𝑛 𝑛 − 1 、…、 2 1 ,递归深度为 𝑛 。尾递归优化可以避免这种情况的出现。
当数组中所有元素都相等时,快速排序的时间复杂度是 𝑂(𝑛 2 ) 吗?该如何处理这种退化情
况?
是的。这种情况可以考虑通过哨兵划分将数组划分为三个部分:小于、等于、大于基准数。仅
向下递归小于和大于的两部分。在该方法下,输入元素全部相等的数组,仅一轮哨兵划分即可
完成排序。
桶排序的最差时间复杂度为什么是 𝑂(𝑛 2 )
最差情况下,所有元素被分至同一个桶中。如果我们采用一个 𝑂(𝑛2 ) 算法来排序这些元素, 则时间复杂度为 𝑂(𝑛2 )
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值