选择排序与快速排序:一次选极值,一次分区间

选择排序与快速排序:一次选极值,一次分区间

选择排序是体育课列队,快速排序是把"列队"这件事本身拆开。

为什么需要

体育课上整队,最高的站到最左边,第二高的站到第二个位置,依次往下------这就是选择排序(Selection Sort)。它是普通人第一时间能想到的排序思路:每次从剩下的人里挑出最该出列的那个。

快速排序(Quick Sort)不属于这一类。它不是"挑出最值再放下一个",而是先把整个序列按一个基准劈成两半,再分别处理两半。这个思路不来自直觉,来自分治。

这两种算法放在一篇里,是因为它们代表了排序的两端:一端是暴力枚举 的极限,一端是分治递归的代表。而且它们有一个共同的现实用途------都出现在这一章的"实测对比表"里,一个垫底,一个封顶。

一、选择排序

核心机制:遍历一遍,选一个极值

选择排序的基础版本非常直白:遍历一遍数组,找出最小的数,换到最左边;再遍历一遍,找出次小的,换到第二个位置;如此往复,直到有序。

它的动作是选择,不是交换------比较过程中元素位置不变,只有选出极值之后才和边界位置交换一次。这一点是它和冒泡排序的分界:冒泡在比较过程中不断交换,选择排序把交换压到了每趟一次。

既然每趟都要遍历一遍完整区间,那么无论数据是否已经有序,比较次数都不变。这决定了它的性能上限很差(后面详述)。

代码演示:一趟选两个数

一个常见的优化是一趟同时选出最小值和最大值,最小的换到左端、最大的换到右端,然后两头往中间夹:

c 复制代码
void SelectSort(int* a, int n)
{
    int begin = 0, end = n - 1;

    while (begin < end)
    {
        int mini = begin, maxi = begin;   // 存的是下标,不是值

        for (int i = begin + 1; i <= end; ++i)
        {
            if (a[i] > a[maxi])
            {
                maxi = i;
            }
            if (a[i] < a[mini])
            {
                mini = i;
            }
        }

        Swap(&a[begin], &a[mini]);
        Swap(&a[end], &a[maxi]);

        ++begin;
        --end;
    }
}

两个细节:

  • 存的是下标而不是值。 因为最后要做的是交换,必须知道极值在哪个位置。
  • 内层从 begin + 1 开始。 begin 位置的值已经被默认当作最小值和最大值的初始候选,自己跟自己比没有意义。

那个 bug:maxi 与 begin 重叠

上面这段代码有 bug,而且平时测不出来。

问题在两次 Swap 的顺序上。如果最大值恰好位于 begin 位置------也就是 maxi == begin------那么第一次交换会把最大值搬走:

c 复制代码
Swap(&a[begin], &a[mini]);   // 最大值原本在 begin 上,这一下被换到了 mini 的位置
Swap(&a[end], &a[maxi]);     // maxi 还指向 begin,但那里已经不是最大值了

第二次交换换到末尾的是个错误的值。结果就是:有些数据排出来对,有些数据排出来不对,而且不崩溃、不报错。

修复只需在两处交换之间补一句:

c 复制代码
Swap(&a[begin], &a[mini]);

// 最大值原本在 begin 上,第一次交换已经把它挪到了 mini 的位置
if (maxi == begin)
{
    maxi = mini;
}

Swap(&a[end], &a[maxi]);

这个 bug 的价值不在于选择排序本身,而在于调试方法 :遇到这类"有时对有时错"的问题,不要拿二十多个元素的数组硬调。应当把数组缩小到能画出图的程度 ,逐趟标出 begin、end、mini、maxi 的位置,让下标重叠的那一刻显形。用大数组调试,等于把问题埋在噪声里。

选择排序的评价

  • 最好情况也是 O(N²)。 这是它最致命的一点。即使数组已经完全有序,算法也不知道,仍然要一趟趟遍历过去把极值"选"出来。加了 flag 优化的冒泡在有序输入上是 O(N),插入排序也是 O(N),而选择排序没有这种优化的空间。
  • 实测 5 万条随机数据,它在这组排序里垫底,比冒泡还差一些。
  • 所以它的价值主要在教学:逻辑直观,适合理解"选择"这一类排序的思想。工程实践里几乎没有位置。

📖 参考:《数据结构知识库》第六节把冒泡/插入/选择排序统一列为 O(n²);"最好情况也是 O(n²)"这一点,正是选择排序区别于另外两者的地方。

二、快速排序

核心机制:单趟做好两件事

快速排序的单趟排序(partition)是这样的:先选一个关键字(key) ,通常取区间最左边的值。然后做一件事------把比 key 小的值放到它左边,比它大的值放到它右边。

做法是左右双指针向中间走:

  • 右指针先走,找小:遇到大于等于 key 的值就继续往左,直到找到比 key 小的停下来。
  • 左指针再走,找大:遇到小于等于 key 的值就继续往右,直到找到比 key 大的停下来。
  • 两者都停下时交换,把小的甩到左边、大的甩到右边。
  • 重复,直到两指针相遇。最后把 key 与相遇位置的值交换。

单趟结束后,key 落到了它最终应该在的位置上------因为比它小的都在左边、比它大的都在右边,它的位置就已经确定了,后续不需要再动。

这就是快排和前面所有排序的根本差别。堆排序、选择排序、冒泡排序的出发点是"把最大或最小的数挑出来放到边界",而快排的单趟同时完成两件事:

  1. 排好一个数(key 归位);
  2. 完成一次分割(分出左右两个区间)。

左区间如果有序、右区间如果有序,整个区间就有序了。而"让左区间有序"------用同样的方式再走一遍。这就是递归。

相遇位置为什么一定比 key 小

单趟逻辑里有一个必须成立的前提:最后与 key 交换的那个相遇位置,值一定比 key 小。否则交换过去就把大的值放到了左边,整个分割就错了。

这个结论靠"谁先走"来保证。左边做 key 时,必须让右边先走。

理由可以拆成两种情况,但共同点是:相遇时,一定是某一方停在原地、另一方走过来。

  • 情况一:右指针停着,左指针走过来。 右指针是遇到比 key 小的值才停的,所以它停的位置比 key 小;左指针找不到比 key 大的值,一路走到这里,两者相遇。相遇点就是右指针停下的位置------比 key 小。
  • 情况二:左指针停着,右指针走过来。 左指针停下的位置是上一轮交换时"接收小值"的那个位置(交换规则把小值换到左边),所以那里的值比 key 小;右指针找不到比 key 小的值,一路走到这里相遇。相遇点同样比 key 小。

反过来,如果左边做 key 却让左边先走,相遇位置就可能比 key 大,交换之后就错了。

由此得到对称的推论:如果选最右边做 key,就必须让左边先走,这样才能保证相遇位置比 key 大。

单趟排序的三种实现

快排从发明到现在,单趟排序演化出了三种写法。它们的时间复杂度完全相同------单趟都是 O(N),没有任何效率差别------差别在理解难度和出错概率上。

1. 霍尔法(Hoare Partition)

最原始的版本,也就是上面讲的双指针找大找小:

c 复制代码
// hoare 版本
int PartSort1(int* a, int left, int right)
{
    int midi = GetMidi(a, left, right);   // 三数取中,见后文
    Swap(&a[left], &a[midi]);

    int keyi = left;
    int begin = left, end = right;

    while (begin < end)
    {
        // 右边找小
        while (begin < end && a[end] >= a[keyi])
        {
            --end;
        }

        // 左边找大
        while (begin < end && a[begin] <= a[keyi])
        {
            ++begin;
        }

        Swap(&a[begin], &a[end]);
    }

    Swap(&a[keyi], &a[begin]);
    return begin;
}

三个易错点:

内层循环必须带 begin < end 条件。 少了它,指针会一直走下去,越过相遇点访问错误位置。

返回的是相遇位置的下标,不是值。 递归时要靠这个下标划分左右区间。

交换的是 a[keyi](key 所在的位置),不是 key 这个值。 这一点最容易写错------keyi 是下标,交换要作用在数组上;如果误写成拿一个局部变量去交换,编译能过,逻辑全错。

2. 挖坑法(Pit Method)

挖坑法的思路是:把 key 的值先保存起来,它空出来的位置就成了一个"坑"。

  • 右边找小,填到左边的坑里------这一下,右边又空出一个新坑;
  • 左边找大,填到右边的坑里------左边又空出一个新坑;
  • 交替进行,直到相遇。相遇的位置一定是个坑,因为两指针中总有一个是坑位。

最后把保存的 key 填进这个坑:

c 复制代码
// 挖坑法:key 的值先保存起来,空出的位置就是"坑"
int PartSort3(int* a, int left, int right)
{
    int key = a[left];            // 保存值,left 成为第一个坑
    int begin = left, end = right;

    while (begin < end)
    {
        // 右边找小,填到左边的坑
        while (begin < end && a[end] >= key)
        {
            --end;
        }
        a[begin] = a[end];        // 填坑,end 成为新坑

        // 左边找大,填到右边的坑
        while (begin < end && a[begin] <= key)
        {
            ++begin;
        }
        a[end] = a[begin];        // 填坑,begin 成为新坑
    }

    a[begin] = key;               // 把 key 填进最后相遇的坑
    return begin;
}

挖坑法没有效率提升,但更不容易出错。 两点原因:

  • 不用再纠结"相遇位置为什么比 key 小"。相遇位置就是坑,把 key 填进去天然成立。
  • 不用再纠结"凭什么让右边先走"。最开始坑在左边,那当然是右边找值来填------逻辑上是必然的,不是规定。

代价是:单趟结束后的具体排列可能和霍尔法不同(但都满足"左边比 key 小、右边比 key 大"这个目标,不影响正确性)。所以做题时如果要写某个单趟的结果,得先看清用的是哪种方法。

3. 前后指针法(Two Pointers)

最新的写法,也是教科书里出现最多的一种。它用一前一后两个指针,逻辑是线性的:

c 复制代码
// 前后指针法
int PartSort2(int* a, int left, int right)
{
    int midi = GetMidi(a, left, right);   // 三数取中
    Swap(&a[left], &a[midi]);
    int keyi = left;

    int prev = left;
    int cur = prev + 1;

    while (cur <= right)
    {
        if (a[cur] < a[keyi] && ++prev != cur)
        {
            Swap(&a[prev], &a[cur]);
        }
        cur++;
    }

    Swap(&a[prev], &a[keyi]);
    return prev;
}

它的过程可以这样理解:

  • cur 负责找小,一路向右扫描。
  • prev 始终指向"已处理部分中最后一个比 key 小的值"。
  • 遇到比 key 小的值,就 ++prev 然后交换------把这个小值"翻跟斗"式地甩到左边,同时把中间的大值推向右边。
  • 两者之间要么紧挨着,要么隔着若干个比 key 大的值。

++prev != cur 这个写法是为了跳过"自己跟自己交换"(当 prev 和 cur 相邻且都指向小值时)。交换完,prev 停在最后一个比 key 小的位置上,最后与 keyi 交换即可。

前后指针法代码最短、结构最线性,是三种里最好写对的。

三种写法放在一起对比:

核心动作 单趟效率 难点
霍尔法 左右指针找大找小,交换 O(N) 必须理解"谁先走"与"相遇位置为何比 key 小"
挖坑法 key 存值留坑,交替填坑 O(N) 无------坑位天然保证相遇点正确
前后指针法 cur 找小,++prev 后交换 O(N) ++prev != cur 的跳过逻辑

三者的单趟效率完全相同,都只是把区间扫描一遍。差别在易错程度:霍尔法要额外论证两件事,挖坑法把这两件事变成"逻辑必然",前后指针法则把整个流程压成一次线性扫描。代价是三种方法单趟排完的具体排列可能不同------但它们都满足同一个目标(左边比 key 小、右边比 key 大),所以都是正确的分割。

递归:像二叉树的前序遍历

单趟只排好一个数,剩下的交给递归:

c 复制代码
void QuickSort(int* a, int left, int right)
{
    if (left >= right)      // 区间只有一个值,或区间不存在 ------ 递归出口
    {
        return;
    }

    int keyi = PartSort1(a, left, right);

    // [left, keyi-1] keyi [keyi+1, right]
    QuickSort(a, left, keyi - 1);
    QuickSort(a, keyi + 1, right);
}

递归出口有两个条件,缺一不可:

  • left == right:区间只剩一个值,天然有序,不需要再排;
  • left > right:区间不存在。比如区间是 [0, 1],key 归位在下标 1,那么右区间就是 [2, 1]------left > right,这个区间不存在。

递归展开的过程和二叉树的前序遍历完全一致:先处理当前区间(相当于根),再递归左区间,再递归右区间。区间不断对半分、不断缩小,直到只剩一个值或不存在为止。

由此可以直接读出复杂度。理想情况下每层对半分,递归深度是 log N 层 ;而每一层的单趟处理加起来大约是 N 次比较(左右指针合计走完整个区间)。所以:

text 复制代码
O(N log N) = 每层 O(N) × 共 log N 层

这里要注意和希尔排序同样的道理:看单趟不要数循环层数。霍尔法的单趟是两层循环嵌套,但两指针合计只走完区间一遍,整体是 O(N)。

实测:随机数据够用,有序数据崩了

把快排放进实测对比台,跑不同规模的数据:

数据规模 快速排序表现
10 万条(随机) 效率不错
100 万条(随机) 与堆排序等同量级算法相比没有明显差距
500 万条(随机) 开始略显吃力
10 万条(已有序) 约 1.2 秒

随机数据下的表现符合 O(N log N) 的预期------它不是这一组里最快的,但稳稳处在 O(N log N) 那一档。真正的问题出在最后一行:10 万个已经排好序的数据,反而要花 1.2 秒。这暴露了一个在随机数据上完全看不出来的缺陷。

两件容易被忽略的事(都会影响实测结论):

重复数据会让对比结论翻转。 C 标准库的 rand() 最多只能产生 3 万多个不重复的值------10 万个数据里大约有 7 万多个是重复的,平均每个值重复五六次。数据重复度高时,堆排序反而会输给快速排序:建堆过程中大量相同值堆叠在一起,向下调整很难把较大的值有效顶上去,"选数"的效果被削弱。所以做性能对比时必须说明数据的重复度,否则同一组算法可以得出相反的结论。

快速排序的耗时本身有波动。 选 key 带有概率性,同一组数据多跑几次,结果可能相差不小;堆排序则相对稳定。做这类实测应当多测几组取平均,而不是看单次结果。

缺陷:有序时的退化与栈溢出

上面的推导有一个前提:每次选的 key 都接近中间值,这样才是均匀对半分。

如果 key 固定取最左边的值,遇到已经有序的数据会怎样?

有序时最左边的值就是最小值。单趟排完,比它小的没有,左边区间为空,右边剩下 N−1 个元素。下一趟再取最左,又是最小值,右边剩 N−2 个......

  • 复杂度退化 :每趟处理 N、N−1、N−2......累加是等差数列,退化为 O(N²)。上一节那 1.2 秒就是它。
  • 更严重的是栈溢出:递归深度从 log N 变成了 N。函数调用需要在栈上建立栈帧,而栈空间通常只有 8 MB 左右。深度太深,栈就爆了。Debug 版本每个栈帧更大,大约 1 万层就崩;Release 版本优化后栈帧更小,能撑更久,但本质上仍在裸奔。

💡 背景补充:递归函数的每次调用都会在调用栈上压入一个栈帧(stack frame),保存返回地址、局部变量等信息,调用返回时弹出。栈空间是有限的,递归深度过大就会栈溢出(stack overflow)。Debug 构建为了支持调试会保留更多信息,栈帧更大,因此能承受的深度明显小于 Release 构建。这也是"同一段代码 Debug 崩、Release 不崩"这类现象的常见来源。

优化一:三数取中

根源在于"固定选最左做 key"。有两个思路:

随机选 key:从区间里随机挑一个位置的值做 key。有序数据下就不会每次都选中最小值了。但随机数多少有点不靠谱,运气差仍可能选到极值。

三数取中(median-of-three) :取最左边、最右边、最中间 三个位置的值,选其中不是最大也不是最小的那个做 key。

这个做法在有序数据上效果极好:中间位置的值就是中位数,每次都能均匀二分,直接把最坏情况变成了最好情况。实测效果很直接------10 万个已经有序 的数据,未优化时要 2000 多毫秒,加上三数取中后降到 1 毫秒。

它还几乎杜绝了最坏情况的发生:要让快排退化,必须连续多次都恰好选中极值做 key。三个位置里包含极值的概率本就不高,连续命中的概率更是极低。

但"三数取中"不能想当然------它不是取中间那个下标的值,而是要取大小居中的那个值,必须两两比较:

c 复制代码
// 返回三个位置中,"值"居中的那个位置的下标
int GetMidi(int* a, int left, int right)
{
    int midi = (left + right) / 2;

    if (a[left] < a[midi])
    {
        if (a[midi] < a[right])       // left < midi < right
        {
            return midi;
        }
        else if (a[left] < a[right])  // left < right <= midi
        {
            return right;
        }
        else                          // right <= left < midi
        {
            return left;
        }
    }
    else                              // a[left] >= a[midi]
    {
        if (a[midi] > a[right])       // right < midi <= left
        {
            return midi;
        }
        else if (a[left] < a[right])  // midi <= left < right
        {
            return left;
        }
        else                          // midi <= right <= left
        {
            return right;
        }
    }
}

逻辑本质是:要找出中间值,得先能确定最小值和最大值。三数里找最大、找最小都容易,唯独"中间那个"必须靠两两比较才能确定。

拿到中间值的下标后,要和最左边的值交换,然后仍然用最左边做 key:

c 复制代码
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);

这一步很关键:单趟排序的逻辑完全不用改。三数取中不是"换一种 key 的用法",而是"把中位数换到 key 该在的位置上"。

优化二:小区间优化

先看递归调用的分布。如果每趟都能均匀二分,递归过程就是一棵理想二叉树,各层结点数是 1、2、4、8......最后一层的结点数约占总数的 50%,倒数第二层占 25%,再往上依次是 12.5%、6.25%......

也就是说:去掉最后 3 层递归,就去掉了 80% 以上的递归调用;去掉最后 4 层,就是 90%。原本要递归 1 万次,现在只需要 1000 次。

而这些被砍掉的调用有个共同点:它们处理的数据量极小 (比如不到 10 个元素)。为几个元素创建一次栈帧、做一次单趟分割,开销已经超过"直接把这几个元素排好"本身。所以:当区间长度小于某个阈值(如 10)时,停止递归,改用插入排序。

c 复制代码
void QuickSort(int* a, int left, int right)
{
    // 小区间优化:不再递归分割,减少递归次数
    if ((right - left + 1) < 10)
    {
        InsertSort(a + left, right - left + 1);
    }
    else
    {
        int midi = GetMidi(a, left, right);
        Swap(&a[left], &a[midi]);

        int keyi = PartSort1(a, left, right);
        QuickSort(a, left, keyi - 1);
        QuickSort(a, keyi + 1, right);
    }
}

两点细节:

  • 传的是 a + left,不是 a。 插入排序的原型是"从给定指针开始的 n 个数据",不加上 left 就永远在排数组最前面那几个元素------而递归划分出的区间可能在中间、可能在后面。

  • 为什么选插入排序? 这里经过了几轮淘汰:

    • 希尔排序:它的价值在于用大 gap 让大数据快速跳到后面,但只有 10 个元素的规模,预排序发挥不出作用;
    • 堆排序:10 个数还要先建堆,而建堆本身就是 O(N)------还没开始选数,成本先付了;
    • 冒泡、选择对比插入:三者同为 O(N²),但插入排序对"部分有序"最敏感------某个元素只要和前面一半的元素比较就能落位,移动次数就少一半。同样规模下,它比冒泡、选择都快。

    所以插入排序胜出。库实现里也是这么做的。

效果:Debug 版本提升显著,Release 版本也有约 10%~17% 的提升,主要来自减少的大量无效递归调用。

非递归实现:用栈模拟递归

面试里递归写得顺,下一问往往就是"写个非递归"。这不是刁难,考的是两点:能写出非递归,说明你对递归的理解是透彻的 ------清楚递归到底在做什么、参数怎么传;而且递归本身有风险,极端情况下会栈溢出,非递归能把这份风险拿掉。

做法是自己用栈模拟系统调用栈 :把待处理区间的两个端点压进栈,每次循环取出一段区间做单趟排序,再把它分割出的子区间压回去,直到栈空。用自己管理的数据结构存这些区间,就等于把栈空间从系统调用栈转移到了堆上。

理解它的关键是一句映射:

循环每走一次,就等于之前的一次递归调用。

递归版本的每一次函数调用,做的是"接住一个区间 → 单趟分割 → 发起两次新调用";非递归版本的每一次循环,做的是"接住一个区间 → 单趟分割 → 把两个子区间入栈"。行为完全对应,只不过递归靠系统栈自动保存现场,非递归把这件事显式地做了一遍。

c 复制代码
#include "../栈和队列/stack.h"

void QuickSortNonR(int* a, int left, int right)
{
    ST st;
    STInit(&st);
    STPush(&st, right);
    STPush(&st, left);

    while (!STEmpty(&st))
    {
        int begin = STTop(&st);
        STPop(&st);
        int end = STTop(&st);
        STPop(&st);

        int keyi = PartSort2(a, begin, end);

        // [begin, keyi-1] keyi [keyi+1, end]
        if (keyi + 1 < end)
        {
            STPush(&st, end);
            STPush(&st, keyi + 1);
        }

        if (begin < keyi - 1)
        {
            STPush(&st, keyi - 1);
            STPush(&st, begin);
        }
    }

    STDestroy(&st);
}

五个要点:

栈里存的是两个端点值,不是结构体。 这里有个容易过度设计的点:既然要存一个区间,是不是该定义个 struct?不必------只要两个值,那就入两次、出两次 。入栈时先压 right 再压 left,出栈时先弹出的就是 left,一个区间就到手了。用两个基本类型解决问题,不必引入新类型。

入栈顺序是"先右后左"。 栈是后进先出(LIFO),先压右区间、再压左区间,弹出来时左区间先被处理------这样才和递归"先处理左边"的顺序一致。

只有区间长度大于 1 才入栈。 keyi + 1 < end 和 begin < keyi - 1 这两个判断,过滤掉了单元素和空区间。只有一个值或没有值的区间,既不需要排、也不该白占栈空间。

非递归版本推荐用前后指针法。 三种单趟写法里,前后指针法的指针只单向移动,没有"左边先走还是右边先走"的问题------老师对它的评价是"百无禁忌"。搬到非递归里最不容易出错。上面代码用的 PartSort2 正是前后指针法。

用栈还是用队列,结果不同。 换成队列(先进先出 FIFO)也能排好,但处理顺序变成了层序------一层一层来,也就是广度优先(BFS);用栈是深度优先(DFS),与递归顺序一致。面试要考的是栈模拟这一种,因为它才真正等价于递归。

不过队列这条路有个隐藏限制:队列不一定总能模拟出递归的逻辑顺序。 这道题是因为"层序排完数组也是有序的",才看不出差别;换成二叉树的中序遍历,层序根本对应不上递归的执行顺序,就模拟不出来。栈能模拟是因为它和系统调用栈同构;队列在这里能用,只是碰巧。

顺带一提:这个套路可以搬走

把上面的经验抽出来,是一句通用方法:

递归改非递归、用栈模拟,核心是------把「参数」存进栈。

快排递归的参数是一个区间(两个端点),所以栈里存两个端点;换成二叉树的非递归遍历 ,参数是"当前要处理的结点",那么栈里存的就是结点 。形式变了,思路没变:先看清递归函数收的是什么参数,再让栈去存同样的东西。

边界也要说清楚:不是所有递归都能这样直接搬。老师特别说明,"伪递归"那一类特殊情形不在本课范围内,这里只处理"参数入栈"这一种标准套路。

三、面试与实践

快速排序是这一章考频最高的算法。 面试官常以"你学过数据结构吧,写个快排"开场,因为它同时考察三件事:对递归的理解、对二叉树的熟悉程度、以及代码实现能力。堆排序也有可能考(常与 Top-K 问题结合),而冒泡、选择这类简单排序被要求手写的概率很低。

手撕策略:写基础框架,口头讲优化。

面试时间有限,快排主体在 5 分钟左右就该写完。如果一上来先去写插入排序和三数取中,很容易超时被打断------重点是单趟和递归这个主体。所以:

  • 代码只写基础递归框架;
  • 三数取中、小区间优化不写,写完口头说明这两个优化点的思路,展示深度即可。

非递归是进阶考点。 如果递归写得很顺,面试官可能顺势要求"那你写个非递归"。这时用栈模拟即可;如果一时写不出来,诚实说明并改回递归版本,比硬憋着强。

稳定性:快速排序是不稳定的,选择排序也不稳定。 单趟里的交换会跨越多个元素,相等元素的相对次序无法保证。

💡 背景补充:"稳定性"这一节在课堂上被反复推迟,最终没有展开讲 (老师多次说"稳定性留到最后再讲"、"现在别谈稳定性")。这里补上结论和一句话理由:所谓稳定,指排序后值相等的元素前后次序保持不变;快排单趟的交换会跨越多个元素,两个相等的值谁先谁后无法保证,所以不稳定------选择排序同理,每趟把极值甩到边界,跨距离交换同样打乱次序。完整定义与判断方法见《数据结构知识库》。
📖 参考:《数据结构知识库》第六节把快速排序、归并排序并列为平均 O(n log n),与冒泡/插入/选择的 O(n²) 区分开。堆排序之所以能把"选择"加速到 O(n log n),见该知识库第四节·堆。

本节要点

选择排序

  • 基础版每趟遍历选出最小(或最大)值放到边界,动作是选择而非交换,每趟只交换一次。
  • 双向优化版一趟选出最小值和最大值,两头往中间夹;存的是下标,不是值 ;内层从 begin + 1 开始。
  • 有一个隐蔽 bug:maxi == begin 时 ,第一次交换会把最大值搬走,导致第二次交换换错。修复方式是在两次交换之间补 if (maxi == begin) maxi = mini;。
  • 最好情况也是 O(N²) ------它无法感知"已经有序",这是它区别于冒泡(加 flag 后有序时 O(N))的关键。实测 5 万条数据它垫底。
  • 价值在教学:它是最直观的"选择"类排序。调试此类"有时对有时错"的问题,应把数组缩小到能画图的程度。

快速排序

  • 单趟排序同时完成两件事:排好一个数(key 归位)+ 分割出左右区间。这是它区别于其他所有排序的出发点。
  • 单趟目标:比 key 小的在左、比 key 大的在右。相等值放在哪边都可以(代码中让它留在右边)。
  • 左边做 key 必须右边先走,这样才能保证相遇位置比 key 小;反之右边做 key 要左边先走。证明的关键是"相遇时总有一方停在原地"。
  • 单趟的三种实现:霍尔法 (原始,需理解"谁先走")、挖坑法 (保存 key,相遇点必是坑,最不易错)、前后指针法 (prev/cur,代码最短)。三者单趟效率完全相同,差别只在易错程度;单趟排完的具体排列可能不同。
  • 递归结构与二叉树前序 一致;递归出口有两个:left == right(单值区间)和 left > right(区间不存在)。
  • 复杂度:理想情况下每层 O(N)、共 log N 层,即 O(N log N)。判断单趟复杂度要看指针实际走了多少,不是循环嵌套了几层。
  • 有序数据下会退化 :key 固定取最左时,每趟只能分出一个元素,复杂度退化为 O(N²),且递归深度从 log N 变成 N,引发栈溢出(Debug 约 1 万层即崩)。
  • 三数取中 :取最左、最右、最中三个值中大小居中的那个,与最左交换后仍然用最左做 key------单趟逻辑不变。必须两两比较才能找出中间值。有序数据实测从 2000 多毫秒降到 1 毫秒,且几乎杜绝最坏情况(那需要连续多次都选中极值)。
  • 小区间优化 :递归树最后一层约占 50% 的递归调用、倒数第二层占 25%------去掉后 3 层就省下 80% 以上的递归 。区间长度小于阈值(如 10)时改用插入排序,注意传 a + left。选插入排序,是淘汰希尔(规模太小、预排序发挥不出价值)和堆排序(建堆本身就是 O(N))之后的结果。
  • 实测有两个陷阱 :① 重复数据会让结论翻转------rand() 最多产生 3 万多个不重复值,重复度高时堆排序反而输给快排;② 快排耗时受选 key 影响本就有波动,应多测几组取平均。
  • 非递归 :用栈模拟递归,循环每走一次 = 一次递归调用 ;栈里直接存两个端点值(不必封装成结构体),入栈顺序"先右后左"以保证深度优先;只有长度大于 1 的区间才入栈;单趟推荐用前后指针法(无"谁先走"的纠结)。换成队列会变成广度优先,但队列不一定总能对应递归的逻辑顺序。
  • 递归改非递归的通用套路 :把参数存进栈------快排存区间端点,二叉树遍历存结点。
  • 面试策略 :手撕只写基础递归框架,三数取中和小区间优化口头阐述思路即可;非递归是进阶考点;快排不稳定(稳定性本课只作推迟、未展开,此处按结论掌握)。

📖 参考:《数据结构知识库》第四、六节


迪亚波罗学编程 · 前组织 Boss 的程序员轮回

相关推荐
All for pursuit.1 小时前
【动态规划-8】152.乘积最大子数组
数据结构·c++·算法·leetcode·动态规划
信奥卷王1 小时前
[GESP202512 六级] 路径覆盖
数据结构·算法
Lazionr2 小时前
手撕 unordered_set 和 unordered_map,剖析哈希表底层实现
c++·算法
0+1112 小时前
算法 --归并排序
数据结构·算法·排序算法
Run_Teenage2 小时前
算法:树的重心、树的直径
算法
专注仿真2 小时前
高代价计算后置:探测、交战、毁伤三条链路的统一设计哲学
算法·探测·毁伤
xie0510_2 小时前
Any类简要实现
开发语言·c++·算法
在所不辞兄2 小时前
为什么PINN非常适合求解有限元模型
人工智能·深度学习·神经网络·算法·机器学习
一棵树73512 小时前
数据结构闲聊
数据结构·算法