【算法】【学习笔记】排序
排序
排序算法,应该是学习时第一个遇到的算法知识。
排序算法有很多种呢,列出来的话,有:
- 时间复杂度为 \(O(N \log N)\)
- 时间复杂度为 \(O(N^2)\)
- 时间复杂度为 \(O(N)\)(非比较排序)
- 进阶
各个排序详解
快速排序
🥸思想
核心思想为两点:分治思想和选基准归类。
以寻找单调递增为例,即随机选一个基准值(Pivot),把大于它的数都放右边,把小于它的数都放左边。
核心三步走:
-
选基准(Pick Pivot):从数组中挑出一个数(比如第一个数、最后一个数或随机数)作为基准值。
-
分区(Partition):调整数组元素,使得:
-
所有比 Pivot 小的元素都移动到 Pivot 的左边;
-
所有比 Pivot 大的元素都移动到 Pivot 的右边。
-
(此时 Pivot 的位置就被固定住了)
-
-
递归求解(Recurse):对 Pivot 左边的子数组和右边的子数组分别重复上述过程,直到子数组长度为 0 或 1。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N \log N)\) | 每次递归能把问题规模对半砍,需要递归 \(\log N\) 层。 |
| 最坏时间复杂度 | \(O(N^2)\) | 当数组有序且每次固定选第一个数为 pivot 时,退化为冒泡排序。 |
| 空间复杂度 | \(O(\log N)\) | 来自递归栈的深度(不需要额外开辟数组,是原地排序)。 |
| 稳定性 | 不稳定 | 在跨越式交换元素的过程中,相同元素的相对顺序可能会被颠倒。 |
| 是否原地 | 是 | 使用双指针法交换位置,不需要额外数组。 |
🤓实现
快速排序是原地的,使用双指针法:(partition)
-
在数组中挑一个数作为基准 pivot。
-
定义两个指针 left 和 right 分别指向两端。
-
right 先从右往左找比 pivot 小的数,left 再从左往右找比 pivot 大的数。
-
找到后,交换这两个数。
-
重复上述步骤,直到 left 和 right 相遇,将 pivot 换到相遇位置。此时基准数达到自己归属的位置。
点击查看实现代码(TypeScript)
function partition(arr:number[], l:number, r:number):number{
// 进行分区
// 找基准
const randomIdx = l + Math.floor(Math.random() * (r - l + 1));
// 调换到最左
swap(arr, randomIdx, l);
let pivot = arr[l], i = l, j = r;
// 进行检查并交换位置
while(i < j){
while(i < j && arr[j] >= pivot){
j--;
}
while(i < j && arr[i] <= pivot){
i++;
}
if(i < j){
swap(arr, i, j);
}
}
// 将基准放到归属位置
swap(arr, i, l);
// 返回归属位置
return i;
}
function quickSort(arr: number[], l: number=0, r: number=arr.length-1){
if(l >= r) return;
let p = partition(arr, l, r);
quickSort(arr, l, p-1);
quickSort(arr, p+1, r);
}
function doQuickSort(arr:number[]){
const n = arr.length;
quickSort(arr, 0, n-1);
}
尝试写时出现的错误
- 分区和递归顺序出错,要注意,先分区,再递归,顺序不能乱!
- 注意边界条件 \(l >= r\) ,递归需要条件跳出递归,缺少将成为死循环!
- 双指针移动条件:在不满足条件的时候,移动对应指针(移动完后要注意左指针是否还在右指针的左边)。
- pivot不是索引,是值,要存放值,不然移动后,位置错乱了,无法准确将这个值放在对应位置。
- 当 Pivot 固定在最左边(l)时,必须让右指针 \(j\) 先走。这样能确保相遇点的值必定 \(\le pivot\),最后直接将 pivot(arr[l])与相遇点(i 或 j)交换即可。
🤯注意
-
有序的情况
在有序的情况,需要随机选择 Pivot(随机化快排)。如果输入数组原本就是递增的(如 [1, 2, 3, 4, 5]),固定选第一个元素作为 Pivot 会导致每次只裁掉一个元素,递归深度变成 \(N\),时间复杂度严重退化为 \(O(N^2)\)。解法:在 partition 开始前,随机选一个下标和 left 交换(如上面代码实现所示)。
-
大量重复元素会导致退化
这时候需要使用三路快排(3-Way QuickSort)
- 原因:如果数组里有大量相同的值(如 [2, 2, 2, 2, 2]),普通的快排会进行多次无意义的分区。
- 解法:三路快排将数组切分为三段:< pivot、== pivot、> pivot。对于 == pivot 的那一大串元素区间,在下一轮递归直接跳过它们,极大提升性能!
- 三路快排需要用到 三个指针:
- lt (less than):小于区域的右边界(初值设为 l)。
- gt (greater than):大于区域的左边界(初值设为 r)。
- i:当前正在扫描的元素指针(初值设为 l + 1)。
- 把小于的,用i和lt将其调转到左区域,大于的调转到右区域,这时候不要i++,因为刚从gt换过来的值还没进行调查。
- 三路快排需要用到 三个指针:
归并排序
🥸思想
核心思想:先分后合。
如果说快速排序是“边拆边排”(前序遍历思想),那么归并排序就是“拆到底再排”(后序遍历思想)。
即,归并排序先把递归函数放在前面,因为要递归到最后再开始排,因为递归到最后,就等于两个元素只有1个的数组进行合并,必定排好序了。
核心三步走:
- 分(Divide):直接从中间一刀切,切成左右两半(mid = floor((l + r) / 2))。
- 治(Conquer):递归去把左半边排好序,把右半边排好序。
- 合(Merge):把两个已经有序的子数组,合并成一个更大的有序数组。
核心难点:
两个排序好的数组如何合并呢?比如[1,4]和[2,3]。
这时候创建一个临时数组temp,左指针代表左数组,右指针代表右数组,当左小于右,存入temp,并更新左指针,以此类推。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N \log N)\) | 每次递归能把问题规模对半砍,需要递归 \(\log N\) 层。 |
| 最坏时间复杂度 | \(O(N \log N)\) | 稳定不会退化。 |
| 空间复杂度 | \(O(N) + O(\log N) = O(N)\) | 来自递归栈的深度和一个与原数组相同大小的temp数组。 |
| 稳定性 | 稳定 | 是连续的进行比较,相同数值优先放入左半边元素(使用 <=)。 |
| 是否原地 | 否 | 但是需要一个temp额外存放。 |
🤓实现
使用递归将数组分到两边只剩下长度为1的数组,这时候两个数组天生有序,进行合并即可,然后向上走。
点击查看实现代码(C#)
public class Sort {
public void DoMergeSort(int[] arr){
if(arr == null) return;
int n = arr.Length;
if(n <= 1) return;
int[] temp = new int[n];
MergeSort(arr, 0, n-1, temp);
}
private void MergeSort(int[] arr, int l, int r, int[] temp){
if(l >= r) return;
int mid = l + ((r - l) >> 1);
MergeSort(arr, l, mid, temp);
MergeSort(arr, mid+1, r, temp);
// 当左边的最大值小于右边的最小值,说明已经排序
if(arr[mid] <= arr[mid+1]) return;
Merge(arr, l, mid, r, temp);
}
private void Merge(int[] arr, int l, int mid, int r, int[] temp){
// 合并数组
int i = l, j = mid+1, k = l;
while(i <= mid && j <= r){
if(arr[i] <= arr[j]){
temp[k++] = arr[i++];
}else{
temp[k++] = arr[j++];
}
}
while(i<= mid){
temp[k++] = arr[i++];
}
while(j <= r){
temp[k++] = arr[j++];
}
for(int v = l; v <= r; v++){
arr[v] = temp[v];
}
}
}
尝试写时出现的错误
- temp数组合并完后,要拷贝回原数组中。
- 在合并数组时,不要使用嵌套while,不然左数组已经遍历完后,在判断右数组时,获取左数组的值会超界。
- 不要在递归内使用动态数组一直push数据,而是提前划分好一个大小和原数组相同的temp数组。
🤯注意
-
剪枝优化
在合并前加入判断if (arr[mid] <= arr[mid + 1]) return;。当左半边的最大值已经小于等于右半边的最小值时,说明整个区间天然有序,直接跳过合并,能大幅提升在“近乎有序数组”上的运行效率。 -
注意位运算的优先级
计算中点时使用l + ((r - l) >> 1),外层的括号绝对不能省略!因为在很多语言(如 C#、Java、C++)中,加法+的优先级高于位移>>,不加括号会导致计算顺序错乱引发越界。 -
保持稳定性(<= 的作用)
在比较arr[i] <= arr[j]时,必须使用<=。如果写成<,那么遇到相同元素时右边的元素会先被放进 temp,这就破坏了归并排序“稳定”的特性。
堆排序
🥸思想
核心思想:
利用“堆”这种特殊数据结构的挑选能力,每次选出最大(或最小)的元素摆到最后。
堆排序通常使用大顶堆来实现升序排序。
核心两步走:
-
建堆(Build Heap)
-
把一个乱序数组变成大顶堆。
-
从最后一个非叶子节点(下标为 n / 2 - 1)开始,自底向上对每个节点执行“下沉(Sift Down)”——如果父节点比子节点小,就和较大的那个子节点交换,直到整个数组都满足大顶堆性质。
-
-
排序(Sort)
-
此时 arr[0] 绝对是当前最大值。把 arr[0] 与当前堆的最后一个元素交换,最大值就归位到了数组末尾。
-
堆的有效长度减 1(把刚才归位的值排除在外)。
-
此时新的 arr[0] 可能破坏了大顶堆性质,对其执行一次“下沉”,重新恢复大顶堆。
-
重复上述步骤,直到堆里只剩下 1 个元素。
-
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N \log N)\) | 建堆 \(O(N)\),后续 \(N-1\) 次调整,每次下沉 \(O(\log N)\)。 |
| 最坏时间复杂度 | \(O(N \log N)\) | 稳定不会退化。 |
| 空间复杂度 | \(O(1)\) | 纯迭代实现,无递归栈,属于真正的原地排序。 |
| 稳定性 | 不稳定 | 用二叉树的方法,远距离的 swap(arr[0], arr[len]) 会破坏相同元素的相对位置。 |
| 是否原地 | 是 | 将原数组转化为堆。 |
🤓实现
先自底向上对非叶子节点执行下沉将原数组建成堆,随后不断将堆顶最大值交换至数组末尾,并对新堆顶执行下沉维护。
点击查看实现代码(C++)
class Solution {
private:
void down(vector<int>& arr, int len, int cur){
int l = 2 * cur + 1, r = 2 * cur + 2;
while(l < len){
int best = cur;
if(arr[l] > arr[cur]){
best = l;
}
if(r < len && arr[r] > arr[best]){
best = r;
}
if(best == cur){
break;
}
swap(arr[cur], arr[best]);
cur = best;
l = 2 * cur + 1;
r = 2 * cur + 2;
}
}
void heapSort(vector<int>& arr){
int n = arr.size();
if(n <= 1) return;
// 初始建堆
for(int i = (n >> 1) - 1; i >= 0; i--){
down(arr, n, i);
}
for(int len = n-1; len > 0; len--){
swap(arr[0], arr[len]);
down(arr, len, 0);
}
}
};
尝试写时出现的错误
- 写下沉方法时,不要定死为最后一个非叶子节点,而是只看传入参数。
- 堆的有效长度不是原数组长度,而是传入的新长度,不然无法缩小堆。
- 要注意先初始建好堆先,不能立刻下沉
- 先将顶和尾交换后,再下沉,不要弄反顺序了。
🤯注意
-
下标映射规则:父节点 i 的左孩子为 \(2*i + 1\) ,右孩子为 \(2*i + 2\) ;最后一个非叶子节点下标为 \((\frac{n}{2}) - 1\) 。
-
右孩子越界检查:在下沉时,判断右孩子时一定要满足
r < len,且比较对象是arr[r] > arr[best](与当前最大的比,而不是只与父节点比)。
插入排序
🥸思想
核心思想:它把数组分为 左侧已排序 和 右侧未排序 两部分。每次从右侧未排序部分取出一个元素,从右往左在已排序部分中寻找合适的位置,把它“插进去”。
简单来说就是:将右侧未排序的按照顺序插入到左侧已排序的对应位置。
所以需要假设最左侧的第一个数字为一个区间,其是有序的,而由于只有一个数字,则此区域必定有序。
然后开始进行插入排序。
但是这里的步骤看起来需要一个辅助数组,该怎么原地排序呢。
核心三步走:
- 先抓起待插入的牌(从1开始遍历的
r),记录数值cur; - 对已排序好的末尾开始比较(即
l = r-1开始往前遍历):- 只要前面的数比
cur大时,就把前面的数往右挪一位nums[l+1] = nums[l](是连续地查看,当不满足条件时跳出循环,所以不用担心数值的丢失)
- 只要前面的数比
- 跳出循环后,此时的
l+1就是空出的给cur的位置。- 没有一个
l满足时则是他本身的位置; - 有满足的数值时,因为满足条件的都已经右移过,此时的
l+1是空白位置。
- 没有一个
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N^2)\) | 因为要双层遍历。 |
| 最好时间复杂度 | \(O(N)\) | 当数组已有序时,内层 while 一次都不触发。 |
| 最坏时间复杂度 | \(O(N^2)\) | 当数组完全逆序时,每个元素都要移动到最左侧。 |
| 空间复杂度 | \(O(1)\) | 原地排序,仅使用常数个辅助变量。 |
| 稳定性 | 稳定 | 比较条件是严格大于 >,遇到相等元素会停止平移,保持相对顺序。 |
| 是否原地 | 是 | 直接在原地插入排序。 |
🤓实现
function insertionSort(nums: number[]): number[] {
for (let r = 1; r < nums.length; r++) {
const cur = nums[r]; // 抓起当前待插入的牌
let l = r - 1; // 从前面已排好序的牌堆末尾开始比较
// 只要前面的牌比 cur 大,就把前面的牌往右挪一位
while (l >= 0 && nums[l] > cur) {
nums[l + 1] = nums[l];
l--;
}
// 空出来的位置就是 cur 应该插入的位置
nums[l + 1] = cur;
}
return nums;
}
尝试时的错误
- 忘记每次抽取右边时,要刷新左侧末尾索引
- 使用辅助数组了,实际上可以原地修改
冒泡排序
🥸思想
核心思想:不断比较相邻的两个元素,如果顺序不对(左边比右边大)就交换位置。
-
第一轮遍历后,最大的元素会被沉到(浮到)数组的最右侧(nums[n-1]);
-
第二轮遍历后,第二大的元素会被送到倒数第二个位置(nums[n-2]);
-
如此往复,直到整个数组有序。
优化关键(提前打断后续冒泡):
如果某一轮遍历中,一次交换都没有发生,说明整个数组其实已经是有序的了,此时可以直接跳出循环,避免无效计算。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N^2)\) | 随机数组下的平均比较与交换次数。 |
| 最好时间复杂度 | \(O(N)\) | 当数组本身已有序时,加了 swapped 标志位只需跑第一轮即可提前结束。 |
| 最坏时间复杂度 | \(O(N^2)\) | 当数组完全逆序时,需要比较并交换 \(\frac{N(N-1)}{2}\) 次。 |
| 空间复杂度 | \(O(1)\) | 原地排序。 |
| 稳定性 | 稳定 | 比较条件是严格大于 >,遇到相等元素不会触发交换,保持相对顺序。 |
| 是否原地 | 是 | 原地排序,仅需常数个交换变量。 |
🤓实现
分别使用了for循环和while循环实现,while可能会更直观。
function bubbleSort1(nums: number[]): number[] {
const n = nums.length;
// 外层循环:控制比较的轮数(最多 n - 1 轮)
for (let i = 0; i < n - 1; i++) {
let swapped = false; // 优化:记录本轮是否发生过交换
// 内层循环:逐对比较相邻元素
// 注意界限:每跑完一轮,右侧就有 i 个元素已经排好序,所以终止条件是 n - 1 - i
for (let j = 0; j < n - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换相邻元素
[nums[j], nums[j + 1]] = [nums[j + 1], nums[j]];
swapped = true;
}
}
// 如果一整轮都没有发生交换,说明数组已经完全有序
if (!swapped) break;
}
return nums;
}
function bubbleSort2(nums: number[]): number[]{
const n = nums.length;
let rightBound = n-1;
let needSwap = true;
while(needSwap){
needSwap = false;
for(let i = 0; i < rightBound; i++){
if(nums[i] > nums[i+1]){
swap(nums, i, i+1);
needSwap = true;
}
}
rightBound--; // 每轮跑完,右侧就有一个数归位,边界向左缩一位
}
return nums;
}
function swap(nums:number[], a:number, b:number){
const temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}
🤯注意
-
内层循环界限是
n - 1 - i:-
1: 是因为要比较 nums[j] 与 nums[j + 1],防止下标越界;
-
i: 是因为末尾已经有 i 个元素是排好序的大数,不需要重复比较。
-
-
与插入排序的区别:
-
冒泡排序:始终在不断交换相邻元素,每次把未排序区间的最大值往右推。
-
插入排序:抓起右侧未排序区的第一个元素,在已排序区里往左找位置插进去(平移元素)。
-
选择排序
🥸思想
核心思想:把数组分为左侧已排序和右侧未排序两部分。每一轮在未排序部分中找出最小(或最大)元素的索引,然后把它与未排序部分的第一个元素进行交换。
实际上就是从左边第一个数开始往后寻找,是否有小于他的数,若存在,进行交换。以此往复。
看起来有点像插入排序,只不过他不是每次插入,而是找到最小的进行交换。
核心一步走:
-
第一轮:扫描全数组 [0 ... n-1],找到最小值的位置,与 nums[0] 交换;
-
第二轮:扫描 [1 ... n-1],找到最小值的位置,与 nums[1] 交换;
-
如此往复,直到只剩下最后一个元素(天然有序),排序完成。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N^2)\) | 固定双层循环。 |
| 最坏时间复杂度 | \(O(N^2)\) | 无论如何都需要比较 \(\frac{N(N-1)}{2}\) 次。 |
| 最好时间复杂度 | \(O(N^2)\) | 无法提前打断。 |
| 空间复杂度 | \(O(1)\) | 原地排序。 |
| 稳定性 | 不稳定 | 长距离交换会破坏相同元素的相对顺序(例如 [5, 8, 5, 2],第一轮 5 和 2 交换后,两个 5 的相对顺序就变了)。 |
| 是否原地 | 是 | 原地修改数组。 |
🤓实现
function selectionSort(nums: number[]): number[] {
const n = nums.length;
// 外层循环:确定已排序区间的右边界(只需要遍历到 n - 2)
for (let i = 0; i < n - 1; i++) {
let minIdx = i; // 假设当前未排序区的第一个元素是最小值
// 内层循环:在未排序区 [i + 1 ... n - 1] 中寻找真正的最小值索引
for (let j = i + 1; j < n; j++) {
if (nums[j] < nums[minIdx]) {
minIdx = j; // 更新最小值下标
}
}
// 只有当最小值不在起点时才触发交换,避免无用开销
if (minIdx !== i) {
[nums[i], nums[minIdx]] = [nums[minIdx], nums[i]];
}
}
return nums;
}
尝试时的错误
🤯注意
三个时间复杂度为 \(O(N^2)\) 的排序比较:
- 插入排序(最推荐,工程最常用):
最好情况 \(O(N)\)(对近乎有序的数据极快)。
稳定,适合作为大算法(如快排/归并)切分到小规模数据时的“清场工具”。 - 冒泡排序:
加标志位后最好情况也能达到 \(O(N)\),且稳定。
缺点是频繁交换相邻元素,写开销大。 - 选择排序:
交换次数最少(最多 \(N-1\) 次),但最好情况依然是 \(O(N^2)\),且不稳定。
计数排序
🥸思想
核心思想:不通过元素之间的两两比较,而是利用数组下标来确定元素的正确位置。通过统计每个数值在数组中出现的次数,再根据频次直接将元素按顺序填充回原数组。
核心三步走:
- 求范围:扫描一遍数组,找到最小值 min 和最大值 max,确定数据范围 \(K = \text{max} - \text{min} + 1\)。
- 计频次:创建一个长度为 \(K\) 的计数数组 count,遍历原数组,把每个数值出现的次数累加到 count[num - min] 中。
- 按序还原:按顺序遍历 count 数组,根据记录的次数,把对应的数字平铺填回原数组。
由此可以看出,这方法只可以处理整数,因为浮点数是无限的。
但是这样子是不稳定的,若要变得稳定,则利用前缀和和反向遍历实现:
- 将频次数组转换为前缀和数组,此时每个数值代表当前数中有多少个数 <= 它的值。
- 说明他的相对位置就是和减去一。然后从后面往前遍历,可以保持它的相对位置一致。
💡 什么时候用正序遍历,什么时候用反序遍历呢
- 反序遍历:含自己(标准前缀和) \(\rightarrow\) 锁定的是右边界(最大下标) \(\rightarrow\) 倒序遍历,存一个,界限往左退一位(count--)。
- 正序遍历:不含自己(排他前缀和) \(\rightarrow\) 锁定的是左边界(起始下标) \(\rightarrow\) 正序遍历,存一个,界限往右进一位(start++)。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N + K)\) | 需要遍历一次原数组和频次数组。 |
| 最坏时间复杂度 | \(O(N + K)\) | 稳定不会退化。 |
| 空间复杂度 | \(O(K)\) | 需要辅助数组记录数字频次。 |
| 稳定性 | 稳定 | 简化版直接覆盖数值丢失了对象引用,将不稳定;标准版利用“前缀和+反向遍历”可保持稳定。 |
| 是否原地 | 否 | 需要额外的频次数组,无法原地修改。 |
🤓实现
// 不稳定的
function CountSort(nums:number[]):number[] {
if(nums.length <= 1) return nums;
// 找数组中的最大值和最小值
let minNum = nums[0], maxNum = nums[0];
for(let num of nums){
minNum = Math.min(num, minNum);
maxNum = Math.max(num, maxNum);
}
// 创建频次数组,并统计
const len = maxNum - minNum + 1;
const frequency:number[] = Array(len).fill(0);
for(let num of nums){
frequency[num-minNum]++;
}
// 根据频次数组按顺序填入原数组
let idx = 0;
for(let i = 0; i < len; i++){
const cur = minNum+i;
while(frequency[i] > 0){
nums[idx++] = cur;
frequency[i]--;
}
}
return nums;
}
// 稳定的
function countingSortStable(nums: number[]): number[] {
if (nums.length <= 1) return nums;
// 1. 找 min 和 max
let minNum = nums[0], maxNum = nums[0];
for (const num of nums) {
minNum = Math.min(minNum, num);
maxNum = Math.max(maxNum, num);
}
const range = maxNum - minNum + 1;
const count: number[] = new Array(range).fill(0);
// 2. 统计词频
for (const num of nums) {
count[num - minNum]++;
}
// 3. 计算前缀和(累加)
// 累加后,count[i] 的含义变为:<= 当前数值的元素一共有多少个
for (let i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 4. 创建结果数组,从后往前遍历原数组填入
const res: number[] = new Array(nums.length);
for (let i = nums.length - 1; i >= 0; i--) {
// 当前值和前缀和对应下标
const val = nums[i];
const valIdx = val - minNum;
// count[valIdx] 表示当前元素应该排在第几个位置(从 1 开始计数)
// 减 1 转为 0-based 数组下标
const targetIdx = count[valIdx] - 1;
res[targetIdx] = val;
// 关键:占用掉一个名额,下一次遇到相同的数值时,放在前一个位置
count[valIdx]--;
}
return res;
}
🤯注意
- 适用数据类型有限:只可以处理整数(或能映射为整数的离散值),无法直接处理浮点数。
- 致命短板(数值范围不能过大):如果数据只有 3 个 [1, 2, 100000000],也需要开辟 1 亿长度的数组,会导致严重的空间浪费甚至内存爆仓(OOM)。只有当数值范围 \(K\) 远小于或接近 \(N\) 时,性能才最优。
桶排序
🥸思想
核心思想:分类装桶,各自排序,最后串联。
当待排序的数据分布比较均匀时,桶排序能把时间复杂度接近 \(O(N)\)。
核心四步走:
- 计算范围与建桶:遍历数组,找到最大值 max 和最小值 min。根据数据范围划分出 \(K\) 个互相不重叠的区间(每个区间对应一个“桶”)。
- 数据投递入桶:利用映射函数,例如\[\text{index} = \lfloor\frac{\text{val} - \text{min}}{\text{bucketSize}}\rfloor \]算出每一个元素属于哪个桶,把它塞进去。
- 桶内各自排序:对每一个桶内部的数据分别进行排序(通常使用插入排序或内置排序算法)。
- 按顺序合并:按照桶的先后顺序,依次把各个桶里的元素提取出来拼在一起,整个数组就排好序了。
K的取值:
-
根据元素个数设定:直接让桶的数量等于数组元素的个数 \(N\)。
原理:如果数据分布均匀,\(N\) 个数装进 \(N\) 个桶里,平均每个桶刚好装 1 个数。
此时桶内不需要额外排序,总时间复杂度为 \(O(N)\)。
计算公式:\[K = N \]某个数值 val 对应的桶下标计算:
\[\text{bucketIdx} = \lfloor \frac{(\text{val} - \text{min}) \times (N - 1)}{\text{max} - \text{min}} \rfloor \] -
自定义“每个桶装多少范围” (设置 bucketSize):先指定每一个桶覆盖的数值区间宽度(例如 bucketSize = 5 或 10),然后用数据总范围除以宽度来计算 \(K\)。
原理:当你对数据的数值范围比较了解时(比如考试成绩 \(0 \sim 100\) 分,设每个桶装 10 分的区间),这种方式最直观。
计算公式:\[K = \lfloor \frac{\text{max} - \text{min}}{\text{bucketSize}} \rfloor + 1 \]某个数值 val 对应的桶下标计算:
\[\text{bucketIdx} = \lfloor \frac{\text{val} - \text{min}}{\text{bucketSize}} \rfloor \]
桶排序的优势在于:排序对象可以是浮点数、小数、自定义对象。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N+K)\) | 如果数据分布非常均匀,每个桶里的元素很少,桶内排序接近 \(O(1)\),总耗时就是绝对的线性时间。 |
| 最好时间复杂度 | \(O(N+K)\) | 元素完美均匀分散在各个桶中。 |
| 最坏时间复杂度 | \(O(N^2)\) | 如果数据极其倾斜(比如 1000 个数有 999 个都挤在同一个桶里),桶排序就会直接退化成桶内排序算法的复杂度(如 \(O(N^2)\) 或 \(O(N \log N)\))。 |
| 空间复杂度 | \(O(N+K)\) | 需要额外的辅助数组和列表空间来存桶和数据。 |
| 稳定性 | 不稳定 / 稳定 | 取决于桶内使用的排序算法。如果桶内用的是稳定的排序(如插入排序、归并排序),那桶排序就是稳定的。 |
| 是否原地 | 否 | 必须借由外部桶容器进行数据分流与暂存。 |
🤓实现
function bucketSort(nums:number[]): number[]{
let min = nums[0], max = nums[0];
for(const num of nums){
if(min > num) min = num;
if(max < num) max = num;
}
if(max === min) return nums;
// 找桶数并装桶
const n = nums.length;
const K = n;
const bucket:number[][] = Array.from({length: K}, ()=> []);
for(const val of nums){
const idx = Math.floor((val - min) * (n - 1) / (max - min));
bucket[idx].push(val);
}
// 对每个桶排序
for(const b of bucket){
insertSort(b);
}
const res:number[] = [];
for(const b of bucket){
for(const val of b){
res.push(val);
}
}
return res;
}
function insertSort(nums:number[]): number[]{
if(nums.length <= 1) return nums;
// 插入排序
for(let r = 1; r < nums.length; r++){
let l = r-1;
let cur = nums[r];
while(l >= 0 && nums[l] > cur){
nums[l+1] = nums[l];
l--;
}
nums[l+1] = cur;
}
return nums;
}
// 优化版
function bucketSort2(nums: number[]): number[] {
if (nums.length <= 1) return nums;
// 1. 寻找极值
let min = nums[0], max = nums[0];
for (const num of nums) {
if (min > num) min = num;
if (max < num) max = num;
}
// 防御处理:所有元素均相同
if (max === min) return nums;
const n = nums.length;
// 2. 初始化 K 个独立的桶
const bucket: number[][] = Array.from({ length: n }, () => []);
// 计算桶区间大小
const bucketSize = Math.max(1, Math.floor((max - min) / (n - 1)));
// 3. 数据装桶
for (const val of nums) {
// 限制下标不超过 n - 1
const idx = Math.min(Math.floor((val - min) / bucketSize), n - 1);
bucket[idx].push(val);
}
// 4. 桶内排序 + 原地覆盖收集
let index = 0;
for (const b of bucket) {
if (b.length === 0) continue;
insertSort(b); // 或 b.sort((a, b) => a - b);
for (const val of b) {
nums[index++] = val; // 原地覆盖,节约空间
}
}
return nums;
}
尝试时的错误
- 要注意TypeScript中创建二维数组的时候,可不能
Array().fill()噢,这样子记录的全是同一个地址的数组噢,要用Array.from({length: K}, ()=>[])。 - 可以记录下标改为修改原数组
- 桶Size可以优化为
Math.max(1, Math.floor((max - min) / (n - 1)))
基数排序
🥸思想
核心思想:按位切分,逐位排序,稳定收集。
核心三步走:
- 确定最大位数 \(d\):找到数组中的最大值,算出它一共有多少位(比如最大数是 802,则需要排 \(3\) 轮:个位、十位、百位)。
- 按位入桶(从低位到高位):第 1 轮:只看每个数的个位,将数字塞进对应 \(0 \sim 9\) 的 10 个桶中,再按桶顺序提取出来。第 2 轮:只看每个数的十位,重复上面的装桶与提取过程。第 3 轮:只看每个数的百位,重复过程……
- 完成排序:当最高位(第 \(d\) 位)处理完毕后,按顺序收集出来的数组就已经完全有序了。
但这个方法观察可以知道,只有数组内全是正整数才能处理,那么有负数时该怎么办?
添加上偏移量法(Offset):
-
寻找最小值 min:如果 min < 0,说明有负数。
-
整体平移(让所有数变成非负数):将数组中的每一个数字都加上 |min|。这样最小的负数变成了 0,所有数字都平移到了非负区间!
-
标准基数排序:对平移后的数组正常执行基数排序。
-
还原数值:排序完成后,再将每一个数字减去 |min| 还原回来。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(d \times (N + r))\) | \(d\) 为最大位数,\(N\) 为元素个数,\(r\) 为基数(十进制下 \(r=10\))。 |
| 最坏时间复杂度 | \(O(d \times (N + r))\) | - |
| 空间复杂度 | \(O(N+r)\) | 需要辅助桶空间存数据,\(r=10\) 为桶的数量。 |
| 稳定性 | 稳定 | 依赖于桶的先进先出(FIFO)收集顺序,相同数值的相对位置绝不改变。 |
| 是否原地 | 否 | 需要借助额外的桶暂存数据。 |
🤓实现
function radixSort(nums:number[]): number[]{
if(nums.length <= 1) return nums;
// 找最大值
let max = nums[0];
for(const num of nums){
if(num > max) max = num;
}
// 初始化10个桶
const bucket:number[][] = Array.from({length: 10}, ()=>[]);
// 按位循环
for(let exp = 1; Math.floor(max / exp) > 0; exp *= 10){
// 入桶
for(const num of nums){
const cur = Math.floor(num / exp) % 10;
bucket[cur].push(num);
}
// 依次提取回原数组,并清空桶
let idx = 0;
for(const b of bucket){
for(const num of b){
nums[idx++] = num;
}
b.length = 0;
}
}
return nums;
}
// 可负数版
function radixSort2(nums:number[]): number[]{
if(nums.length <= 1) return nums;
// 找最大值
let max = nums[0], min = nums[0];
for(const num of nums){
if(num > max) max = num;
if(num < min) min = num;
}
let needOffset = false;
if(min < 0){
needOffset = true;
for(let i = 0; i < nums.length; i++){
nums[i] -= min;
}
max -= min;
}
// 初始化10个桶
const bucket:number[][] = Array.from({length: 10}, ()=>[]);
// 按位循环
for(let exp = 1; Math.floor(max / exp) > 0; exp *= 10){
// 入桶
for(const num of nums){
const cur = Math.floor(num / exp) % 10;
bucket[cur].push(num);
}
// 依次提取回原数组,并清空桶
let idx = 0;
for(const b of bucket){
for(const num of b){
nums[idx++] = num;
}
b.length = 0;
}
}
if(needOffset){
for(let i = 0; i < nums.length; i++){
nums[i] += min;
}
}
return nums;
}
尝试时的错误
- 并不是当前是个位时就可以直接推进。
- 位数计算时写错了,准确是记录位数,每次10, 而不是10i(变成加分了都)。
- 桶在每次排完后,都要清空!
希尔排序
🥸思想
核心思想:本质为带步长的插入排序(又称缩小增量排序)。先宏观调控(跨大步预排序),后微观精调(小步增量修正)。
核心四步走:
- 引入增量(\(gap\)):设置一个初始间隔 \(gap\)(比如 \(gap = \lfloor N / 2 \rfloor\)),把距离/步长为 \(gap\) 的元素归为一组——即每gap,取一个放入同样的数组(仅操作上划分组,并不开辟新空间存放)。
- 分组插入排序:对每个分组分别进行插入排序。由于 \(gap\) 很大,小的数值可以一次性跨越半个数组快速挪到前面。
- 逐步缩小增量:每轮排序后让 \(gap\) 缩小(如 \(gap = \lfloor gap / 2 \rfloor\)),直到 \(gap = 1\)。
- 终局微调:当 \(gap = 1\) 时,整个算法退化为标准的插入排序。但此时数组已经经过了前面的“预调”,达到了高度基本有序的状态,插入排序只需要做极少量的微调就能瞬间完成。
简化:
由此可以看出,插入排序就是步长为1的希尔排序。
在外层套一个步长递减的循环,内层为当前步长的插入排序。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N^{1.3} \sim N^{1.5})\) | 取决于增量序列(Gap Sequence)的选择。 |
| 最好时间复杂度 | \(O(N)\) | 当数组完全有序时,每一轮 \(gap\) 扫描一次即可跳出。 |
| 最坏时间复杂度 | \(O(N^2)\) / \(O(N^{1.5})\) | 使用原版 Shell 增量在极端数据下退化至 \(O(N^2)\);使用 Hibbard 增量可优化至 \(O(N^{1.5})\)。 |
| 空间复杂度 | \(O(1)\) | 原地排序。 |
| 稳定性 | 不稳定 | 长距离的跨步交换可能会改变相同数值元素的相对位置。 |
| 是否原地 | 是 | 纯数组内部指针覆盖与交换。 |
🤓实现
function shellSort(nums:number[]): number[]{
const n = nums.length;
// 外层:gap折半递减
for(let gap = n >> 1; gap > 0; gap = gap >> 1){
// 从第 gap 个元素开始,交错对各个分组执行步长为 gap 的插入排序
for(let r = gap; r < n; r++){
let cur = nums[r];
let l = r - gap;
// 步长为 gap 的平移逻辑
while (l >= 0 && nums[l] > cur) {
nums[l + gap] = nums[l];
l -= gap;
}
nums[l + gap] = cur;
}
}
return nums;
}
尝试时的错误
- 要原地修改,不然希尔排序的意义就没了
- 要注意缩小 \(gap\) 不要只是 \(gap = gap - 1\)。
🤯注意
增量序列的选择:
- Shell 原版增量(二分增量):\(gap = N / 2^k\) (即 \(N/2, N/4, \dots, 1\))。最简单,但相邻增量不互质,某些偶数/奇数位置数据可能到最后 \(gap=1\) 才能互相比较。
- Hibbard 增量(互质增量):\(gap = 2^k - 1\) (即 \(1, 3, 7, 15, \dots\))。相邻增量互质,最坏时间复杂度可以提升到 \(O(N^{1.5})\)。
- Knuth 增量:\(gap = (3^k - 1) / 2\) (即 \(1, 4, 13, 40, \dots\))。实践中综合性能极佳。
Tim排序
Tim 排序由 Tim Peters 于 2002 年设计,它的核心哲学是:充分利用现实现实世界数据中天然存在局部有序片段的特点。
🥸思想
核心思想:
它是插入排序与归并排序的结合体:
- 对于极小数据:插入排序(尤其是二分插入排序)不仅常数小、缓存命中率极高,而且极快。
- 对于大规模数据:归并排序能稳定提供 \(O(N \log N)\) 的时间复杂度。
核心三步走:
- 拆分与扩展 Run(自然有序段)
- 扫描 Run:算法顺着数组扫描,寻找天然递增或递减的连续子数组(称为一个 Run)。其中,如果是严格递减,就原地反转成递增。
- minrun 动态基准:为了避免归并时出现极度不平衡的子数组,Tim 排序会假设出一个最小长度的 run 数组 —— minrun(长度通常在 \(32 \sim 64\) 之间)。
- 二分插入排序填充:如果扫描到的自然 Run 长度小于 minrun,就用二分插入排序从后面强行补齐到 minrun 的长度。
- 栈管理与均衡归并
- 算法每产生一个 Run,就会把它压入一个专门的栈中。
- 为了保证归并时的效率最高(像平衡二叉树一样),栈内的 Run 长度必须满足类似斐波那契数列的约束条件(如 \(Len(Z) > Len(Y) + Len(X)\))。
- 一旦破坏了约束,就会强制触发相邻 Run 的归并,保证栈里的 Run 长度始终保持均衡。
- ⭐归并优化:飞奔/驰骋模式(Galloping Mode)
- 当归并数组 \(A\) 和数组 \(B\) 时,如果发现数组 \(A\) 的元素连续多次(默认 7 次)都比数组 \(B\) 的当前元素小;
- 算法就会意识到:“\(A\) 中可能有一大片元素都小于 \(B\)!” 于是直接切换到飞奔模式:
- 放弃逐个比较,改为用指数查找(Exponential Search)+ 二分查找,直接定位到 \(B\) 元素的插入位置,然后整块内存直接复制。
🔎特性
| 属性名 | 属性 | 原因 |
|---|---|---|
| 平均时间复杂度 | \(O(N \log N)\) | 继承了归并排序的高效上限。 |
| 最好时间复杂度 | \(O(N)\) | 数组本身完全有序(或高度有序)时,只需一次线性扫描。 |
| 最坏时间复杂度 | \(O(N \log N)\) | 哪怕完全乱序,也能通过平衡归并保证时间复杂度不退化。 |
| 空间复杂度 | \(O(N)\) | 需要辅助数组来进行 Run 之间的归并(但归并时只会开辟较小 Run 长度的临时空间,比普通归并省内存)。 |
| 稳定性 | 稳定 | 组合使用的“二分插入排序”和“归并排序”均为稳定排序。 |
| 是否原地 | 否 | 将原数组转化为堆。 |
🤓实现
简单版实现
const MIN_MERGE = 32;
// 计算minRun
function getMinrun(n: number): number {
let r = 0;
while (n >= MIN_MERGE) {
// 把 $n$ 一直除以 2(右移),直到 $n < 32$
r |= n & 1; // 记录余数
n >>= 1;
}
return n + r;
}
// 2. 二分插入排序:专门用来给小的 Run 补齐填充
function binaryInsertionSort(arr: number[], left: number, right: number, start: number) {
if (start === left) start++;
for (; start <= right; start++) {
const val = arr[start];
let l = left, r = start - 1;
// 二分查找插入位置
while (l <= r) {
const mid = (l + r) >> 1;
if (arr[mid] > val) r = mid - 1;
else l = mid + 1;
}
// 元素后移
for (let j = start - 1; j >= l; j--) {
arr[j + 1] = arr[j];
}
arr[l] = val;
}
}
// 3. 辅助归并函数
function merge(arr: number[], l: number, m: number, r: number) {
const leftArr = arr.slice(l, m + 1);
const rightArr = arr.slice(m + 1, r + 1);
let i = 0, j = 0, k = l;
while (i < leftArr.length && j < rightArr.length) {
if (leftArr[i] <= rightArr[j]) arr[k++] = leftArr[i++];
else arr[k++] = rightArr[j++];
}
while (i < leftArr.length) arr[k++] = leftArr[i++];
while (j < rightArr.length) arr[k++] = rightArr[j++];
}
function timSort(nums: number[]): number[] {
const n = nums.length;
if (n <= 1) return nums;
// 计算minrun
const minRun = getMinrun(n);
// 步骤 A: 逐块处理,用二分插入排序把各个小区间排好序(构建 Runs)
for (let i = 0; i < n; i += minRun) {
binaryInsertionSort(nums, i, Math.min(i + minRun - 1, n - 1), i);
}
// 步骤 B: 类似归并排序,逐步扩大 size 归并合并各个 Run
for (let size = minRun; size < n; size = 2 * size) {
for (let left = 0; left < n; left += 2 * size) {
const mid = left + size - 1;
const right = Math.min(left + 2 * size - 1, n - 1);
if (mid < right) {
merge(nums, left, mid, right);
}
}
}
return nums;
}
🎨思维导图
❔题目
1. 最小绝对差
解题思路
一开始想着排序后,然后找最前面的两个数和最后的两个数差判断大小,然后按这个基准寻找就好。
但是问题是,最小差并不是一定在左右侧呀,中间也有可能有最小差的。
排序后,最小差一定是相邻数字, 那么只要一次遍历中,当刷新最小差时,清空数组,并存入当前值;当等于最小差时,存入当前值即可。
实现
function minimumAbsDifference(arr: number[]): number[][] {
arr.sort((a,b)=>a-b);
const n = arr.length;
const res: number[][] = [];
let minDiff = Infinity;
for(let i = 0; i < n-1; i++){
let cur = arr[i+1] - arr[i];
if(cur < minDiff){
minDiff = cur;
res.length = 0;
res.push([arr[i], arr[i+1]]);
}else if(cur === minDiff){
res.push([arr[i], arr[i+1]]);
}
}
return res;
};
public class Solution {
public IList<IList<int>> MinimumAbsDifference(int[] arr) {
Array.Sort(arr);
List<IList<int>> res = new List<IList<int>>();
int minDiff = int.MaxValue;
for(int i = 0 ; i < arr.Length-1; i++){
int cur = arr[i+1] - arr[i];
if(cur < minDiff){
minDiff = cur;
res.Clear();
res.Add([arr[i], arr[i+1]]);
}else if(cur == minDiff){
res.Add([arr[i], arr[i+1]]);
}
}
return res;
}
}
class Solution {
public:
vector<vector<int>> minimumAbsDifference(vector<int>& arr) {
sort(arr.begin(), arr.end());
vector<vector<int>> res;
int minDiff = INT_MAX;
for(int i = 0; i < arr.size()-1; i++){
int cur = arr[i+1] - arr[i];
if(cur < minDiff){
minDiff = cur;
res.clear();
res.push_back({arr[i], arr[i+1]});
}else if(cur == minDiff){
res.push_back({arr[i], arr[i+1]});
}
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N \log N)\)
- 空间复杂度: \(O(\log N)\)
2. 使数组元素相等的减少操作次数
解题思路
根据题目:
- 找最大值,定位i,有多个最大值则取最小的i
- 找下一个最大值,记nextLargest
- 将 nums[i]减少到nextLargest
目标:要操作多少次,全变成数组的最小值罢了
也就是说下标根本不重要,只是每次操作,把最大的变成第二大的。其实就是不是最小值的数字下阶梯需要多少次,即阶梯和。
按递增顺序的话,每一个不同的数代表一个阶梯,也就是使用一个值代表当前阶梯数,每次不同时+1即可,并且每走一个数字加一次阶梯数。
逆转后,下标 i + 1 天然就是高位数字的总个数,省去了任何额外变量。变成了:此时堆在高处的大数一共有几个,要一起降一步?
不需要再计算每个数字要降几步了。
实现
public class Solution {
public int ReductionOperations(int[] nums) {
Array.Sort(nums, (a, b) => b.CompareTo(a));
int count = 0;
for (int i = 0; i < nums.Length - 1; i++){
if (nums[i + 1] != nums[i]){
count += (i + 1);
}
}
return count;
}
}
function reductionOperations(nums: number[]): number {
nums.sort((a,b)=>b-a);
let res = 0;
for(let i = 0; i < nums.length-1; i++){
if(nums[i+1] != nums[i]){
res += (i+1);
}
}
return res;
};
class Solution {
public:
int reductionOperations(vector<int>& nums) {
sort(nums.begin(), nums.end());
int res = 0, n = nums.size();
for(int i = n-1; i >= 1; i--){
if(nums[i] != nums[i-1]){
res += (n - i -1 ) + 1;
}
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
- 空间复杂度: \(O(1)\)
3. 合并区间
解题思路
重叠的两个数组a和b,一定有 a[1] >= b[0] ,所以这时候合并后的数组为 [a[0], max(a[1],b[1])] 。
由此可以知道,先按第一个元素从小到大进行排序,然后再判断条件更新右边界即可。
实现
function merge(intervals: number[][]): number[][] {
// 怎么样是重叠呢,就是两个数组,a[1] > b[0]
// 这时候合并成 [a[0], max(a[1],b[1])]
intervals.sort((a,b)=>a[0]-b[0]);
const res:number[][] = [intervals[0]];
for(let i = 1; i < intervals.length; i++){
const last = res[res.length-1];
const cur = intervals[i];
if(cur[0] <= last[1]){
last[1] = Math.max(cur[1], last[1]);
}else{
res.push(cur);
}
}
return res;
};
public class Solution {
public int[][] Merge(int[][] intervals) {
Array.Sort(intervals, (a,b)=>a[0].CompareTo(b[0]));
List<int[]> res = new List<int[]>();
res.Add(intervals[0]);
for(int i = 1; i < intervals.Length; i++){
int[] cur = intervals[i];
int[] last = res[res.Count-1];
if(last[1] >= cur[0]){
last[1] = Math.Max(last[1], cur[1]);
}else{
res.Add(cur);
}
}
return res.ToArray();
}
}
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), [](const auto& a, const auto& b){return a[0]<b[0];});
vector<vector<int>> res;
res.push_back(intervals[0]);
for(int i = 1; i < intervals.size(); i++){
vector<int>& last = res[res.size()-1];
vector<int>& cur = intervals[i];
if(last[1] >= cur[0]){
last[1] = max(last[1], cur[1]);
}else{
res.push_back(cur);
}
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N \log N)\)
- 空间复杂度: \(O(N)\)
4. 数组中的第K个最大元素
解题思路
有三个方法:
-
计数排序
使用计数排序的方法,记好了频率就可以获得第K个最大元素了,时间复杂度只有 \(O(N+K)\) 呢。 -
小顶堆/优先队列
维护一个长度只有K的小顶堆:- 当小顶堆未满,直接存入数据;
- 当小顶堆已满,判断该数是否大于顶,大于时弹出顶并将数存入。
最后,堆顶就是结果。
-
快速选择排序
快速排序的过程是:时间复杂度 \(O(N \log N)\)- 挑选一个基准值 pivot;
- 把数组切成两半(左边比 pivot 小,右边比 pivot 大);
- 递归处理左边 AND 递归处理右边。
则快速选择算法(Quickselect)如下:
-
选随机 Pivot 并划分:选出 pivot 后,把左侧比 pivot 大的和比 pivot 小的交换,划分为 [小数区, pivot, 大数区]。
获得最终位置:返回 pivot 的确切下标 pivotIdx。 -
范围更新:
-
如果 pivotIdx < targetIdx(说明目标在右侧):范围更新为 [pivotIdx + 1, right]。(因为 nums[pivotIdx] 此时位置已经完全固定且排好序了,它绝对不是我们要找的目标,所以可以彻底排除它,直接从它的下一个位置 + 1 开始找。)
-
如果 pivotIdx > targetIdx(说明目标在左侧):范围更新为 [left, pivotIdx - 1]。
-
循环往复:在缩小后的新范围内,继续选随机 pivot 划分,直到 pivotIdx == targetIdx。
-
由于每次都扔掉了一半的数据,它的时间复杂度计算如下:
\[N + \frac{N}{2} + \frac{N}{4} + \frac{N}{8} + \dots \le 2N = O(N) \]则平均时间复杂度降到了 \(O(N)\)。
实现
function findKthLargest(nums: number[], k: number): number {
// 用计数排序的思想就好了咯
if(nums.length == 0) return -1;
let min = nums[0], max = nums[0];
for(let num of nums){
if(min > num) min = num;
if(max < num) max = num;
}
const range = max - min + 1;
const count:number[] = Array<number>(range).fill(0);
for(let num of nums){
count[num-min]++;
}
for(let i = range-1; i >= 0; i--){
if(count[i] === 0) continue;
k -= count[i];
if(k <= 0){
return min + i;
}
}
return min;
};
public class Solution {
public int FindKthLargest(int[] nums, int k) {
int targetIdx = nums.Length - k; // 第K大在升序数组中的下标
int left = 0, right = nums.Length - 1;
while(left <= right){
// 在[left, right] 范围内进行划分,返回pivot最终下标
int pivotIdx = Partition(nums, left, right);
// 命中目标,返回结果
if(pivotIdx == targetIdx) {
return nums[pivotIdx];
}else if(pivotIdx > targetIdx){
// 假如大于目标了,说明在左侧
right = pivotIdx - 1;
}else{
// 假如小于目标了,说明在右侧
left = pivotIdx + 1;
}
}
return -1;
}
private int Partition(int[] nums, int left, int right){
// 随机选择pivot
int randomIdx = Random.Shared.Next(left, right+1);
(nums[randomIdx], nums[left]) = (nums[left], nums[randomIdx]);
int pivot = nums[left];
int l = left+1, r = right;
while(true){
while(l <= r && nums[l] < pivot) l++;
while(l <= r && nums[r] > pivot) r--;
if(l >= r) break;
(nums[l], nums[r]) = (nums[r], nums[l]);
l++;
r--;
}
(nums[r], nums[left]) = (nums[left], nums[r]);
return r;
}
}
复杂度分析
- 时间复杂度:
- 快速选择算法:平均 \(O(N)\),最坏 \(O(N^2)\)(随机 Pivot 几乎不可能触发最坏情况)。
- 计数排序:\(O(N + K)\),其中 \(K = \text{max} - \text{min} + 1\) 为数值范围。
- 空间复杂度:
- 快速选择算法:\(O(1)\)
- 计数排序:\(O(K)\)
5. 排序数组
解题思路
本题对算法的时间稳定性与空间开销提出了严格要求,所以我先考虑到了快速排序,不过快排需要递归空间。
这时候就想起了堆排序,空间复杂度仅 \(O(1)\),更符合题意。
实现
function quickSort(nums: number[], left: number, right: number){
if(left >= right) return;
let pivot = partition(nums, left, right);
quickSort(nums, left, pivot-1);
quickSort(nums, pivot+1, right);
}
function partition(nums: number[], left: number, right: number):number{
const randomIdx = left + Math.floor(Math.random() * (right - left + 1));
[nums[randomIdx], nums[left]] = [nums[left], nums[randomIdx]];
let i = left+1, j = right;
const pivot = nums[left];
while(true){
while(i <= j && nums[i] < pivot) i++;
while(i <= j && nums[j] > pivot) j--;
if(i >= j) break;
[nums[i], nums[j]] = [nums[j], nums[i]];
i++;
j--;
}
[nums[j], nums[left]] = [nums[left], nums[j]];
return j;
}
function doQuickSort(nums: number[]){
quickSort(nums, 0, nums.length-1);
}
function sortArray(nums: number[]): number[] {
doQuickSort(nums);
return nums;
};
public class Solution {
private int[] HeapSort(int[] nums){
// 堆排序,将原数组弄成堆之后持续缩小堆的大小
// 先一次性把原数组下沉,在最后一个非叶子节点下沉
int n = nums.Length;
for(int i = n/2 - 1; i >= 0; i--){
down(nums, i, n);
}
for(int len = n - 1; len > 0; len--){
(nums[0], nums[len]) = (nums[len], nums[0]);
down(nums, 0, len);
}
return nums;
}
private void down(int[] nums, int idx, int len){
int l = 2 * idx + 1;
int r = l + 1;
while(l < len){
int best = idx;
if(nums[l] > nums[idx]) best = l;
if(r < len && nums[r] > nums[best]) best = r;
if(best == idx) break;
(nums[best], nums[idx]) = (nums[idx], nums[best]);
idx = best;
l = 2 * idx + 1;
r = l + 1;
}
}
public int[] SortArray(int[] nums) {
return HeapSort(nums);
}
}
复杂度分析
- 时间复杂度: \(O(N \log N)\)
- 空间复杂度:
- 快速排序:平均情况下为 \(O(\log N)\),最坏情况(退化成单侧递归)为 \(O(N)\)
- 堆排序:\(O(1)\)
6. 对链表进行插入排序
解题思路
一开始想着用数组来辅助,先将所有指针存入数组,然后使用传统插入排序法,一边更新数组位置,一边修改指针。
不过其实因为是指针,在插入排序时,查找第一个大于当前值然后插入到对应位置,天生位移,不需要像数组这样子一直交换位置。
所以应该使用一个哨兵节点,新建一个新的空链表,用于存放已排序的链表,进行插入排序。
每次寻找已排序链表中第一个大于当前数的值,将当前数插入到其之间。
可以看出,当当前节点已经大于已排序链条的最后一个值,则直接存到尾部即可。
那么使用原地法的话,那在当前节点已经大于已排序链条的最后一个值时,可以直接跳过该值了(等于已经排序完);
而普通的情况下,从dummy.next开始寻找第一个大于当前数的值,并插入。
总结:
-
数组 vs 链表插入排序
- 数组的插入排序在插入元素时需要批量往后挪动数据,开销较大;
- 链表利用指针调整,天生支持 \(O(1)\) 插入,无需做任何元素的移动。
-
原地指针插入算法
- 哨兵节点 Dummy:新建一个值为 极小值 的哨兵节点指向头节点,方便在头部插入元素时统一逻辑。
- 维护已排序区间:使用
prevEnd标记已排序区域的尾节点,curr标记当前待处理的节点。
-
快慢通道优化(Fast Path)
- 快通道:若
prevEnd.val <= curr.val,说明当前节点比已排序区域的最大值还要大,它天生就属于末尾,即已有序。直接将prevEnd往前推即可,无需按顺序从头遍历寻找插入位置。 - 慢通道:若顺序颠倒,则从
dummy开始向后遍历找到第一个p.next.val >= curr.val的节点p,将curr提取出来插入到p与p.next之间。
- 快通道:若
实现
function insertionSortList(head: ListNode | null): ListNode | null {
if(head === null || head.next === null) return head;
const dummy = new ListNode(-Infinity, head); // 哨兵节点,指向已排序链表的头部
let prevEnd = head;
let curr = head.next; // 当前待插入的节点
while(curr !== null){
if(prevEnd.val <= curr.val){
// 快通道:如果当前节点 >= 排序区最大值,直接推进prev,无需重排
prevEnd = curr;
curr = curr.next;
}else{
// 慢通道:正常查找
// 每次从dummy查询插入位置
// 找到第一个 val >= curr.val 的节点的前驱节点 prev
let p = dummy;
while(p.next !== null && p.next.val < curr.val){
p = p.next;
}
// 将curr从原位置移除,并插入到prev和prev.next之间
// 将有序的最后一个节点接上当前的下一个节点
prevEnd.next = curr.next;
// 当前的节点接上第一个大于数的下一个节点
curr.next = p.next;
// 第一个大于数接上当前数(交换位置)
p.next = curr;
// 当前数移动到下一个未排序
curr = prevEnd.next;
}
}
return dummy.next;
};
public class Solution {
public ListNode InsertionSortList(ListNode head) {
if(head == null || head.next == null) return head;
var dummy = new ListNode(int.MinValue, head);
var prevEnd = head;
var curr = prevEnd.next;
while(curr != null){
if(prevEnd.val <= curr.val){
// 当有序,跳过
prevEnd = curr;
curr = prevEnd.next;
}else{
var p = dummy;
while(p.next != null && p.next.val < curr.val) p = p.next;
// 插入
prevEnd.next = curr.next;
curr.next = p.next;
p.next = curr;
curr = prevEnd.next;
}
}
return dummy.next;
}
}
复杂度分析
- 时间复杂度:
- 平均/最坏情况:\(O(N^2)\)(随机乱序或完全倒序)。
- 最好情况(链表已升序):\(O(N)\)(每次都命中快通道,无需线性扫描寻找插入位置)。
- 空间复杂度: \(O(1)\)
🤯拓展
在C++写排序用的规则函数时,写成 const auto& a 引用传递可以避免不必要的拷贝开销。
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号