Skip to content

顺序统计量

线性查找 | Linux C 编程一站式学习

快速排序 | Hello 算法

BFPRT——Top k问题的终极解法 | 知乎@王金戈

无序序列里按名次取出元素, 第 \(k\) 小的那个叫k-th order statistic(顺序统计量). \(k=1\) 是最小, \(k=n\) 是最大, \(k=\lceil n/2\rceil\) 是中位数.

这和按关键字查找不是同一个概念: 后者给一个值, 问在不在表里; 这里给一个名次, 问排完后该位置上是谁. 最直接的办法是先排序再按下标取, 复杂度跟排序走, 比较排序最快也是 \(\Theta(n\log n)\).

找最小

\(k=1\). 扫一遍, 记下当前最小, \(\Theta(n)\). 这可以看作是简单选择排序的第一趟, 只是选完不用继续把后面都排好.

\(\{8, 3, 9, 2, 5\}\) 为例:

min=8
3 < 8, min=3
9 > 3
2 < 3, min=2
5 > 2
最小是 2, 比较 4 次

有没有比 \(\Theta(n)\) 更快的做法呢?

比较模型下是没有的. 没看过的那个数完全可能是最小的, 漏一个就不能下结论. 这和无序表只能顺序查找同理: 没有有序性, 折半也就无从下手.

找第二小

\(k=2\). 仍然无需排序整个表.

只需扫两遍, 先找最小, 再在剩下的里找最小, 大约 \(2n\) 次比较, 仍是 \(\Theta(n)\).

一遍也能做, 可以同时维护minsecond两个指针:

8: min=8
3: min=3, second=8
9: 9 比 8 大, 不动
2: min=2, second=3
5: 5 夹在 2 和 3 之间, 不动
第二小是 3

每个元素最多改这两个变量, 还是 \(\Theta(n)\).

更省比较次数的是锦标赛: 两两比, 败者出局, 冠军是最小, 一共 \(n-1\) 次. 第二小只可能出现在「输给过冠军」的那些人里, 最多 \(\lfloor\log_2 n\rfloor\) 个, 再扫这一小撮. 总比较次数 \(n+\lfloor\log_2 n\rfloor-2\), 量级仍是 \(\Theta(n)\).

\(\{5, 2, 4, 7, 1, 3\}\):

(5,2)→2  (4,7)→4  (1,3)→1
        (2,4)→2
        (2,1)→1     最小是 1
输给 1 的: 3 (第一轮), 2 (决赛). min(3,2)=2 即第二小

找第 k 小

把前两问里的 \(1\)\(2\) 换成任意 \(k\), 就是一般的顺序统计量.

先排序再取

任选一种内部排序, 取第 \(k\) 个. 正确, 但为了 \(n-1\) 个用不到的相对次序多付了 \(\log n\) 倍. 只查这一次、表又无序时, 往往不值得.

可使用进行优化: 建最小堆 \(\Theta(n)\), 再弹出 \(k-1\) 次, \(O(n+k\log n)\). 或者扫一遍维护大小为 \(k\) 的最大堆, \(O(n\log k)\), 堆顶就是第 \(k\) 小. \(k\) 接近 \(n/2\) 时, 仍落到 \(O(n\log n)\) 附近.

快速选择

快速排序partition 已经把枢轴放到了最终位置: 左边都更小, 右边都更大. 快排接着左右两边都递归; 这里只问第 \(k\) 小, 枢轴要么就是答案, 要么答案只在其中一半.

按照这个思想, 只递归一侧, 就是快速选择.

下面的 \(k\) 约定为排完后的下标 (从 \(0\) 起), 即第 \(k+1\) 小. partition 返回的 mid 正好是这个下标.

int partition(int *arr, int left, int right)
{
    int pivot = arr[left];
    while (left < right) {
        while (left < right && arr[right] >= pivot) {
            --right;
        }
        arr[left] = arr[right];

        while (left < right && arr[left] <= pivot) {
            ++left;
        }
        arr[right] = arr[left];
    }
    arr[left] = pivot;
    return left;
}

/* 从start到end之间找出第k小的元素 */
int order_statistics(int *arr, int start, int end, int k)
{
    /* 用partition函数把序列分成两半,中间的pivot元素是序列中的第i个 */
    int pivot = partition(arr, start, end);
    if (k == pivot) {
        return arr[pivot];
    } else if (k < pivot) {
        /* 从左半部分找出第k-i小的元素并返回 */
        return order_statistics(arr, start, pivot - 1, k);
    } else {
        /* 从右半部分找出第k-i小的元素并返回 */
        return order_statistics(arr, pivot + 1, end, k - pivot);
    }
}

partition快排的划分完全相同. 和快排的差别只有: 命中 mid 就停, 否则只走包含 \(k\) 的那一侧. Base Case 是枢轴下标恰好等于 \(k\).

名次和下标不要混

若把 \(k\) 定义成当前区间内的第几小 (书上习题那种, 从 \(1\) 起), 枢轴的名次是 \(i=\textit{mid}-\textit{start}+1\). 此时 \(k>i\) 才改写成找右半的第 \(k-i\) 小; \(k<i\) 时左半仍找第 \(k\) 小.

上面代码里 \(k\)整表排完后的下标, 左右递归都传同一个 \(k\), 不要再减 mid. 两种约定各自自洽, 混用会把右半的名次算错.

\(\{5, 2, 4, 7, 1, 3, 2, 6\}\) 找第 \(4\) 小, 即 \(k=3\):

[5  2  4  7  1  3  2  6]     pivot=5, mid=5
 2  2  4  3  1 |5| 7  6      5 是第 6 小, k=3 在左半

[2  2  4  3  1]              pivot=2, mid=2
 1  2 |2| 3  4               这个 2 是第 3 小, k=3 在右半

[3  4]                       pivot=3, mid=3
 |3| 4                       k == mid, 答案 3

全程没有把数组排成 \(1,2,2,3,4,5,6,7\). 丢掉的那一半里, 元素之间的次序可以一直乱着.

算法分析

一次 partition 扫完当前区间, \(\Theta(n)\). 总时间和快排一样, 取决于划分是否均匀, 但递归树只有一条路:

  • 最好 / 平均: 每次大约砍掉一半, \(T(n)=T(n/2)+\Theta(n)=\Theta(n)\). 写成 \(n+n/2+n/4+\cdots<2n\)

  • 最坏: 每次切得极偏 (总拿到最值当枢轴), \(T(n)=T(n-1)+\Theta(n)=\Theta(n^2)\), 和快排退化同形

  • 递归栈平均 \(O(\log n)\), 最坏 \(O(n)\); 改成循环后额外空间 \(O(1)\)

  • 不稳定, 只适用于顺序表, 原因和快排相同

避免最坏的手段也沿用快排: 随机枢轴 / 三数取中. 最坏也要线性时用下面的BFPRT.

和查找、排序的关系

折半查找也是每步丢掉一半, 但前提是表已经有序, 中点下标就能比较. 快速选择的表无序, 必须先花 \(\Theta(n)\)partition, 用枢轴的名次代替有序表的中点.

快排可以看成: 对每个 \(k\) 都做一次选择, 所以两边都要递归, 多出 \(\log n\) 倍. 只要一个名次, 就停在选择.

Top k 问题

从长度为 \(n\) 的无序数组里找出\(k\) (或前 \(k\) 小). 和第 \(k\) 小不同: 那里只要名次上那一个值, 这里要 \(k\) 个元素组成的集合. 集合内部不必有序. 一旦找出第 \(k\) 大, 再扫一遍就能收齐这 \(k\) 个.

下界仍是 \(\Omega(n)\): 每个元素至少看一眼, 否则漏掉的那个可能挤进前 \(k\).

全排序

任选一种内部排序, 取前 \(k\) 个. 比较排序最快也是 \(\Omega(n\log n)\), 因为把不需要的 \(n-k\) 个也排完了.

不必整表有序. 两种用法:

  • 全体建成大根堆, 弹出 \(k\) 次. 建堆 \(O(n)\), 每次修复 \(O(\log n)\), 合计 \(O(n+k\log n)\). 弹出序列本身有序

  • 大小为 \(k\) 的小根堆, 始终装着「目前见到的最大 \(k\) 个」: 先取前 \(k\) 个建堆 \(O(k)\), 后面每个新元素和堆顶比, 比堆顶大就替换并修复, \(O((n-k)\log k)\). 堆里就是前 \(k\) 大, 堆顶是其中最小的那个 (第 \(k\) 大). 前 \(k\) 小则改成大小为 \(k\) 的大根堆

\(k \ll n\) 时两种都接近线性, 已经贴着 \(\Omega(n)\) 下界. \(k=n/2\) (中位数) 时 \(\log k\) 仍是 \(\Theta(\log n)\), 堆方案退回 \(O(n\log n)\).

快速选择法

快速选择已经能在平均 \(\Theta(n)\) 里找出第 \(k\) 大. 分界点一旦就位, 大于它的那一侧 (不必再排) 就是前 \(k\) 大. 任意 \(k\) (包括中位数) 平均都是线性; 最坏仍可能 \(O(n^2)\), 随机枢轴后期望 \(\Theta(n)\).

BFPRT

快速选择怕的是枢轴总拿最值. 若每次都能拿到中位数, 划分绝对均匀, 就是最好情况 \(\Theta(n)\). 精确中位数和 Top k 是同一量级的问题, 不能先花同样多的时间去求. BFPRT (Blum / Floyd / Pratt / Rivest / Tarjan, 又称 median of medians) 用较小代价造一个不太偏的近似中位数当枢轴, 从而最坏也是 \(\Theta(n)\).

  1. \(5\) 个一组 (末组可不足 \(5\)), 组内插入排序, 取出该组中位数, 得到 \(\lceil n/5\rceil\)

  2. 对这些中位数递归调用 BFPRT, 求出它们的真正中位数 \(x\) (中位数的中位数)

  3. \(x\)partition, 之后和快速选择一样只进一侧

\(x\) 至少大于一半小组的中位数; 每个这样的小组里又至少有 \(3\) 个数 \(\geqslant x\) (中位数自己和两个更大的). 粗算至少 \(3n/10\) 个元素可以丢掉, 递归那一侧最多 \(7n/10\):

\[ T(n) \le T\!\left(\frac{n}{5}\right) + T\!\left(\frac{7n}{10}\right) + \Theta(n) \]

代入 \(T(n)\le cn\)\(cn/5 + 7cn/10 + an = 9cn/10 + an\), 只要 \(c\ge 10a\) 就有 \(T(n)=O(n)\). 最坏线性.

组大小改成 \(3\): \(T(n)=T(n/3)+T(2n/3)+\Theta(n)\), 代入后多出一项 \(an\), 推不出线性. \(\geqslant 5\) 的奇数都行, \(5\) 组内排序常数最小, 所以选 \(5\).

必须互递归

\(\lceil n/5\rceil\) 个中位数的中位数时, 要再调 BFPRT 自己, 不能只反复「分组取中位数」缩到 \(1\) 个. 后者拿到的是中位数的近似中位数, \(3n/10\) 的丢掉比例不再成立, 结果仍对, 最坏线性没有了. medianOfMediansbfprt, bfprt 再调 medianOfMedians, 这是互递归.

实践里常数比随机快速选择大很多, 库函数几乎不用纯 BFPRT. Introselect 默认走随机快速选择, 发现规模几乎没按比例下降 (退化成每次只少 \(1\) 个) 再切到 BFPRT; 也可以退化时改切堆选择.

对照

全排序 全体大根堆 大小 \(k\) 的堆 快速选择 BFPRT
时间 \(\Omega(n\log n)\) \(O(n+k\log n)\) \(O(k+(n-k)\log k)\) 平均 \(\Theta(n)\), 最坏 \(O(n^2)\) 最坏 \(\Theta(n)\)
Top \(k\) 是否有序 弹出顺序有序
\(k\approx n/2\) \(O(n\log n)\) \(O(n\log n)\) \(O(n\log n)\) 平均仍线性 仍线性
适用 \(n\) 小, 随后还要整表有序 要前 \(k\) 有序 \(k\) 很小 / 流式 离线、可改原数组 理论最坏保证

Tip

  • \(k\) 很小, 或数据流式到达、只能看一遍: 大小为 \(k\) 的堆

  • 离线一批、可原地改数组: 随机快速选择, 平均线性

  • 只要最坏也线性: BFPRT; 工程上更常见的是 Introselect (快选 + 退化切 BFPRT / 堆)

  • 随后还要按名次反复查, 或需要整表有序: 先排序, 以后按下标 \(O(1)\) 取, 或对有序表折半