排序算法用于对一组数据按照特定顺序进行排列。
排序算法有着广泛的应用,因为有序数据通常能够被更有效地查找、分析和处理。
如图所示,排序算法中的数据类型可以是整数、浮点数、字符或字符串等。
排序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则
稳定排序是多级排序场景的必要条件。
假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,非稳定排序可能导致输入数据的有序性丧失。
# 输入数据是按照姓名排序好的
# (name, age)
('A', 19)
('B', 18)
('C', 21)
('D', 19)
('E', 23)
# 假设使用非稳定排序算法按年龄排序列表,
# 结果中 ('D', 19) 和 ('A', 19) 的相对位置改变,
# 输入数据按姓名排序的性质丢失
('B', 18)
('D', 19)
('A', 19)
('C', 21)
('E', 23)
自适应性需要根据具体情况来评估。 如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;
而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
运行快、原地、稳定、正向自适应、通用性好。
显然,迄今为止尚未发现兼具以上所有特性的排序算法。
因此,在选择排序算法时,需要根据具体的数据特点和问题需求来决定。
下面,我们将共同学习各种排序算法,并基于上述评价维度对各个排序算法的优缺点进行分析。
选择排序 的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
设数组的长度为 ,选择排序的算法流程如图所示:
/* 选择排序 */
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;
}
}
冒泡排序通过连续地比较与交换相邻元素实现排序。
这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。
如图所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果 左元素 > 右元素 就交换它俩。
遍历完成后,最大的元素会被移动到数组的最右端。
设数组的长度为 ,冒泡排序的步骤如图所示:
/* 冒泡排序 */
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;
}
}
}
}
我们发现,如果某轮 冒泡 中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。
因此,可以增加一个标志位 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; // 此轮冒泡未交换任何元素,直接跳出
}
}
插入排序是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。
具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。
下图展示了数组插入元素的操作流程。
设基准元素为 base ,我们需要将从目标索引到 base 之间的所有元素向右移动一位,然后再将 base 赋值给目标索引。
插入排序的整体流程如图所示:
/* 插入排序 */
void insertionSort(int[] nums) {
// 外循环:已排序元素数量为 1, 2, ..., n
for (int i = 1; i < nums.length; i++) {
int base = nums[i], j = i - 1;
// 内循环:将 base 插入到已排序部分的正确位置
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位
j--;
}
nums[j + 1] = base; // 将 base 赋值到正确位置
}
}
插入排序的时间复杂度为 (2) ,而我们即将学习的快速排序的时间复杂度为 ( log ) 。
尽管插入排序的时间复杂度相比快速排序更高,但在数据量较小的情况下,插入排序通常更快。
这个结论与线性查找和二分查找的适用情况的结论类似。
快速排序这类 ( log ) 的算法属于基于分治的排序算法,往往包含更多单元计算操作。而在数据量较小时,2 和 log 的数值比较接近,复杂度不占主导作用;
每轮中的单元操作数量起到决定性因素。
实际上,许多编程语言(例如 Java)的内置排序函数都采用了插入排序,大致思路为:对于长数组,采用基于分治的排序算法,例如快速排序;对于短数组,直接使用插入排序。
虽然冒泡排序、选择排序和插入排序的时间复杂度都为 (2) ,但在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因:
快速排序是一种基于分治策略的排序算法,运行高效,应用广泛。
快速排序的核心操作是 哨兵划分 ,其目标是:选择数组中的某个元素作为 基准数 ,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。
具体来说,哨兵划分的流程如图所示:
快速排序的分治策略: 哨兵划分的实质是将一个较长数组的排序问题简化为两个较短数组的排序问题
/* 元素交换 */
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; // 返回基准数的索引
}
快速排序的整体流程如图所示:
/* 快速排序 */
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);
}
从名称上就能看出,快速排序在效率方面应该具有一定的优势。
尽管快速排序的平均时间复杂度与 归并排序 和 堆排序 相同,但通常快速排序的效率更高
主要有以下原因:
快速排序在某些输入下的时间效率可能降低。
举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 − 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; // 返回基准数的索引
}
在某些输入下,快速排序可能占用空间较多。
以完全倒序的输入数组为例:
由于每轮哨兵划分后右子数组长度为 0 ,递归树的高度会达到 − 1 ,此时需要占用 () 大小的栈帧空间。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。
由于较短子数组的长度不会超过 /2 ,因此这种方法能确保递归深度不超过 log ,从而将最差空间复杂度优化至 (log ) 。
/* 快速排序(尾递归优化) */
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]
}
}
}
归并排序是一种基于分治策略的排序算法,包含图所示的 划分 和 合并 阶段。
如图所示, 划分阶段 从顶至底递归地将数组从中点切分为两个子数组。
合并阶段 从底至顶地将左子数组和右子数组合并为一个有序数组。
需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的。
观察发现,归并排序与二叉树后序遍历的递归顺序是一致的。
/* 合并左子数组和右子数组 */
void merge(int[] nums, int left, int mid, int right) {
// 左子数组区间 [left, mid], 右子数组区间 [mid+1, right]
// 创建一个临时数组 tmp ,用于存放合并后的结果
int[] tmp = new int[right - left + 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 mergeSort(int[] nums, int left, int right) {
// 终止条件
if (left >= right)
return; // 当子数组长度为 1 时终止递归
// 划分阶段
int mid = (left + right) / 2; // 计算中点
mergeSort(nums, left, mid); // 递归左子数组
mergeSort(nums, mid + 1, right); // 递归右子数组
// 合并阶段
merge(nums, left, mid, right);
}
值得注意的是,nums 的待合并区间为 [ left, right ] ,而 tmp 的对应区间为 [ 0, right - left ] 。
对于链表,归并排序相较于其他排序算法具有显著优势,可以将链表排序任务的空间复杂度优化至 (1) 。
具体实现细节比较复杂,有兴趣的同学可以查阅相关资料进行学习。
堆排序是一种基于堆数据结构实现的高效排序算法。
我们可以利用已经学过的 建堆操作 和 元素出堆操作 实现堆排序。
以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间。
在实际中,我们通常使用一种更加优雅的实现方式。
设数组的长度为 ,堆排序的流程如图所示:
实际上,元素出堆操作中也包含第 2. 和 3. 步,只是多了一个弹出元素的步骤。
在代码实现中,我们使用了与堆章节相同的从顶至底堆化 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);
}
}
前述的几种排序算法都属于 基于比较的排序算法 ,它们通过比较元素间的大小来实现排序。
此类排序算法的时间复杂度无法超越 ( log ) 。
接下来,我们将探讨几种非比较排序算法,它们的时间复杂度可以达到线性阶。
桶排序是分治策略的一个典型应用。
它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。
考虑一个长度为 的数组,元素是范围 [ 0, 1 ) 的浮点数。桶排序的流程如图所示:
/* 桶排序 */
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;
}
}
}
桶排序适用于处理体量很大的数据。
例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。
此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。
桶排序的时间复杂度理论上可以达到 () ,关键在于将元素均匀分配到各个桶中,因为实际数据往往不是均匀分布的。
例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。
若将价格区间平均划分为 10 份,各个桶中的商品数量差距会非常大。
为实现平均分配,我们可以先设定一个大致的分界线,将数据粗略地分到 3 个桶中。分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等。
如图所示,这种方法本质上是创建一个递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择。
如果我们提前知道商品价格的概率分布,则可以根据数据概率分布设置每个桶的价格分界线。
值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。
如图所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中
计数排序通过统计元素数量来实现排序,通常应用于整数数组。
先来看一个简单的例子
给定一个长度为 的数组 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 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。
本质上,计数排序是桶排序在整型数据下的一个特例。
细心的同学可能发现,如果输入数据是对象,上述步骤 3. 就失效了。
假设输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
那么如何才能得到原数据的排序结果呢?
我们首先计算 counter 的 前缀和 。
顾名思义,索引 i 处的前缀和prefix[ i ] 等于数组前 i 个元素之和:
前缀和具有明确的意义,prefix[ num ] - 1 代表元素 num 在结果数组 res 中最后一次出现的索引。
这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。
接下来,我们倒序遍历原数组 nums 的每个元素 num ,在每轮迭代中执行以下两步:
遍历完成后,数组 res 中就是排序好的结果,最后使用 res 覆盖原数组 nums 即可。
下图展示了完整的计数排序流程
计数排序的实现代码如下所示
/* 计数排序 */
// 完整实现,可排序对象,并且是稳定排序
void countingSort(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 的前缀和,将“出现次数”转换为“尾索引”
// 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
for (int i = 0; i < m; i++) {
counter[i + 1] += counter[i];
}
// 4. 倒序遍历 nums ,将各元素填入结果数组 res
// 初始化数组 res 用于记录结果
int n = nums.length;
int[] res = new int[n];
for (int i = n - 1; i >= 0; i--) {
int num = nums[i];
res[counter[num] - 1] = num; // 将 num 放置到对应索引处
counter[num]--; // 令前缀和自减 1 ,得到下次放置 num 的索引
}
// 使用结果数组 res 覆盖原数组 nums
for (int i = 0; i < n; i++) {
nums[i] = res[i];
}
}
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序工作。然而,使用计数排序的前置条件相对较为严格。
计数排序只适用于非负整数。
若想要将其用于其他类型的数据,需要确保这些数据可以被转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。
例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去即可。
计数排序适用于数据量大但数据范围较小的情况。
比如,在上述示例中 不能太大,否则会占用过多空间。而当 ≪ 时,计数排序使用 () 时间,可能比 ( log ) 的排序算法还要慢。
上面我们介绍了计数排序,它适用于数据量 较大但数据范围 较小的情况。
假设我们需要对 = 106个学号进行排序,而学号是一个 8 位数字,这意味着数据范围 = 108 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
基数排序的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。
以学号数据为例,假设数字的最低位是第 1 位,最高位是第 8 位,基数排序的流程如图 所示:
下面来剖析代码实现。对于一个 进制的数字 ,要获取其第 位 x k x_k xk ,可以使用以下计算公式:
其中 ⌊⌋ 表示对浮点数 向下取整,而 mod 表示对 取余。对于学号数据, = 10 且 ∈ [1, 8] 。
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 位进行排序。
/* 获取元素 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);
}
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。
举例来说,如果第一轮排序结果 < ,而第二轮排序结果 > ,那么第二轮的结果将取代第一轮的结果。
由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。
相较于计数排序,基数排序适用于数值范围较大的情况,但前提是数据必须可以表示为固定位数的格式,且位数不能过大。
例如,浮点数不适合使用基数排序,因为其位数 过大,可能导致时间复杂度 () ≫ (2)。
在现实中,我们有可能是在对象的某个属性上进行排序。
例如,学生有姓名和身高两个属性,我们希望实现一个多级排序,先按照姓名进行排序,得到 (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 。
回顾原始的快速排序,我们有可能会连续地递归长度较大的数组,最差情况下为 、 − 1、…、2、1 ,递归深度为 。尾递归优化可以避免这种情况的出现。
是的。
这种情况可以考虑通过哨兵划分将数组划分为三个部分:小于、等于、大于基准数。
仅向下递归小于和大于的两部分。
在该方法下,输入元素全部相等的数组,仅一轮哨兵划分即可完成排序。
最差情况下,所有元素被分至同一个桶中。如果我们采用一个 (2) 算法来排序这些元素,则时间复杂度为 (2) 。