【算法】【学习笔记】排序

排序

排序算法,应该是学习时第一个遇到的算法知识。

排序算法有很多种呢,列出来的话,有:

  1. 时间复杂度为 \(O(N \log N)\)
  2. 时间复杂度为 \(O(N^2)\)
  3. 时间复杂度为 \(O(N)\)(非比较排序)
  4. 进阶

各个排序详解

快速排序

🥸思想

核心思想为两点:分治思想选基准归类
以寻找单调递增为例,即随机选一个基准值(Pivot),把大于它的数都放右边,把小于它的数都放左边。

核心三步走

  1. 选基准(Pick Pivot):从数组中挑出一个数(比如第一个数、最后一个数或随机数)作为基准值。

  2. 分区(Partition):调整数组元素,使得:

    • 所有比 Pivot 小的元素都移动到 Pivot 的左边;

    • 所有比 Pivot 大的元素都移动到 Pivot 的右边。

    • (此时 Pivot 的位置就被固定住了)

  3. 递归求解(Recurse):对 Pivot 左边的子数组和右边的子数组分别重复上述过程,直到子数组长度为 0 或 1。

🔎特性

属性名 属性 原因
平均时间复杂度 \(O(N \log N)\) 每次递归能把问题规模对半砍,需要递归 \(\log N\) 层。
最坏时间复杂度 \(O(N^2)\) 当数组有序且每次固定选第一个数为 pivot 时,退化为冒泡排序
空间复杂度 \(O(\log N)\) 来自递归栈的深度(不需要额外开辟数组,是原地排序)。
稳定性 不稳定 在跨越式交换元素的过程中,相同元素的相对顺序可能会被颠倒。
是否原地 使用双指针法交换位置,不需要额外数组。

🤓实现

快速排序是原地的,使用双指针法:(partition)

  1. 在数组中挑一个数作为基准 pivot。

  2. 定义两个指针 left 和 right 分别指向两端。

  3. right 先从右往左找比 pivot 小的数,left 再从左往右找比 pivot 大的数。

  4. 找到后,交换这两个数。

  5. 重复上述步骤,直到 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);
}
尝试写时出现的错误
  1. 分区和递归顺序出错,要注意,先分区,再递归,顺序不能乱!
  2. 注意边界条件 \(l >= r\) ,递归需要条件跳出递归,缺少将成为死循环!
  3. 双指针移动条件:在不满足条件的时候,移动对应指针(移动完后要注意左指针是否还在右指针的左边)。
  4. pivot不是索引,是值,要存放值,不然移动后,位置错乱了,无法准确将这个值放在对应位置。
  5. 当 Pivot 固定在最左边(l)时,必须让右指针 \(j\) 先走。这样能确保相遇点的值必定 \(\le pivot\),最后直接将 pivot(arr[l])与相遇点(i 或 j)交换即可。

🤯注意

  1. 有序的情况

    在有序的情况,需要随机选择 Pivot(随机化快排)。如果输入数组原本就是递增的(如 [1, 2, 3, 4, 5]),固定选第一个元素作为 Pivot 会导致每次只裁掉一个元素,递归深度变成 \(N\),时间复杂度严重退化为 \(O(N^2)\)。解法:在 partition 开始前,随机选一个下标和 left 交换(如上面代码实现所示)。

  2. 大量重复元素会导致退化

    这时候需要使用三路快排(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];
        }
    }

}
尝试写时出现的错误
  1. temp数组合并完后,要拷贝回原数组中。
  2. 在合并数组时,不要使用嵌套while,不然左数组已经遍历完后,在判断右数组时,获取左数组的值会超界。
  3. 不要在递归内使用动态数组一直push数据,而是提前划分好一个大小和原数组相同的temp数组。

🤯注意

  • 剪枝优化
    在合并前加入判断 if (arr[mid] <= arr[mid + 1]) return;。当左半边的最大值已经小于等于右半边的最小值时,说明整个区间天然有序,直接跳过合并,能大幅提升在“近乎有序数组”上的运行效率。

  • 注意位运算的优先级
    计算中点时使用 l + ((r - l) >> 1),外层的括号绝对不能省略!因为在很多语言(如 C#、Java、C++)中,加法 + 的优先级高于位移 >>,不加括号会导致计算顺序错乱引发越界。

  • 保持稳定性(<= 的作用)
    在比较 arr[i] <= arr[j] 时,必须使用 <=。如果写成 <,那么遇到相同元素时右边的元素会先被放进 temp,这就破坏了归并排序“稳定”的特性。

堆排序

🥸思想

核心思想
利用“堆”这种特殊数据结构的挑选能力,每次选出最大(或最小)的元素摆到最后。

堆排序通常使用大顶堆来实现升序排序。

堆的回顾

核心两步走

  1. 建堆(Build Heap)

    • 把一个乱序数组变成大顶堆。

    • 从最后一个非叶子节点(下标为 n / 2 - 1)开始,自底向上对每个节点执行“下沉(Sift Down)”——如果父节点比子节点小,就和较大的那个子节点交换,直到整个数组都满足大顶堆性质。

  2. 排序(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);
        }
    }
};
尝试写时出现的错误
  1. 写下沉方法时,不要定死为最后一个非叶子节点,而是只看传入参数。
  2. 堆的有效长度不是原数组长度,而是传入的新长度,不然无法缩小堆。
  3. 要注意先初始建好堆先,不能立刻下沉
  4. 先将顶和尾交换后,再下沉,不要弄反顺序了。

🤯注意

  • 下标映射规则:父节点 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 数组,根据记录的次数,把对应的数字平铺填回原数组。

由此可以看出,这方法只可以处理整数,因为浮点数是无限的。

但是这样子是不稳定的,若要变得稳定,则利用前缀和反向遍历实现:
- 将频次数组转换为前缀和数组,此时每个数值代表当前数中有多少个数 <= 它的值。
- 说明他的相对位置就是和减去一。然后从后面往前遍历,可以保持它的相对位置一致。

💡 什么时候用正序遍历,什么时候用反序遍历呢

  1. 反序遍历:含自己(标准前缀和) \(\rightarrow\) 锁定的是右边界(最大下标) \(\rightarrow\) 倒序遍历,存一个,界限往左退一位(count--)。
  2. 正序遍历:不含自己(排他前缀和) \(\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)\) 的时间复杂度。

核心三步走

  1. 拆分与扩展 Run(自然有序段)
    • 扫描 Run:算法顺着数组扫描,寻找天然递增或递减的连续子数组(称为一个 Run)。其中,如果是严格递减,就原地反转成递增。
    • minrun 动态基准:为了避免归并时出现极度不平衡的子数组,Tim 排序会假设出一个最小长度的 run 数组 —— minrun(长度通常在 \(32 \sim 64\) 之间)。
    • 二分插入排序填充:如果扫描到的自然 Run 长度小于 minrun,就用二分插入排序从后面强行补齐到 minrun 的长度。
  2. 栈管理与均衡归并
    • 算法每产生一个 Run,就会把它压入一个专门的栈中。
    • 为了保证归并时的效率最高(像平衡二叉树一样),栈内的 Run 长度必须满足类似斐波那契数列的约束条件(如 \(Len(Z) > Len(Y) + Len(X)\))。
    • 一旦破坏了约束,就会强制触发相邻 Run 的归并,保证栈里的 Run 长度始终保持均衡。
  3. ⭐归并优化:飞奔/驰骋模式(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. 使数组元素相等的减少操作次数

【点击跳转】使数组元素相等的减少操作次数——力扣

解题思路

根据题目:

  1. 找最大值,定位i,有多个最大值则取最小的i
  2. 找下一个最大值,记nextLargest
  3. 将 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个最大元素——力扣

解题思路

有三个方法:

  1. 计数排序
    使用计数排序的方法,记好了频率就可以获得第K个最大元素了,时间复杂度只有 \(O(N+K)\) 呢。

  2. 小顶堆/优先队列
    维护一个长度只有K的小顶堆:

    • 当小顶堆未满,直接存入数据;
    • 当小顶堆已满,判断该数是否大于顶,大于时弹出顶并将数存入。
      最后,堆顶就是结果。
  3. 快速选择排序
    快速排序的过程是:时间复杂度 \(O(N \log N)\)

    1. 挑选一个基准值 pivot;
    2. 把数组切成两半(左边比 pivot 小,右边比 pivot 大);
    3. 递归处理左边 AND 递归处理右边。

    则快速选择算法(Quickselect)如下:

    1. 选随机 Pivot 并划分:选出 pivot 后,把左侧比 pivot 大的和比 pivot 小的交换,划分为 [小数区, pivot, 大数区]。
      获得最终位置:返回 pivot 的确切下标 pivotIdx。

    2. 范围更新:

      • 如果 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开始寻找第一个大于当前数的值,并插入。

总结:

  1. 数组 vs 链表插入排序

    • 数组的插入排序在插入元素时需要批量往后挪动数据,开销较大;
    • 链表利用指针调整,天生支持 \(O(1)\) 插入,无需做任何元素的移动。
  2. 原地指针插入算法

    • 哨兵节点 Dummy:新建一个值为 极小值 的哨兵节点指向头节点,方便在头部插入元素时统一逻辑。
    • 维护已排序区间:使用 prevEnd 标记已排序区域的尾节点,curr 标记当前待处理的节点。
  3. 快慢通道优化(Fast Path)

    • 快通道:若 prevEnd.val <= curr.val,说明当前节点比已排序区域的最大值还要大,它天生就属于末尾,即已有序。直接将 prevEnd 往前推即可,无需按顺序从头遍历寻找插入位置。
    • 慢通道:若顺序颠倒,则从 dummy 开始向后遍历找到第一个 p.next.val >= curr.val 的节点 p,将 curr 提取出来插入到 pp.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 辅助整理。

posted @ 2026-07-29 22:19  SEHOD  阅读(36)  评论(0)    收藏  举报