中位数和顺序统计量
1. 什么是中位数和顺序统计量
这篇文章按照题目来看, 主要是解释中位数和顺序统计量, 不过主要是解释顺序统计量和其相关的算法.
我们先来看看什么是"中位数", 中位数的概念其在我们上初高中时就会了解到, 但是此"中位数"非彼中位数, 这么说吧: 对于一个元素个数为n的集合, 中位数就是这个集合中位置处于最中间的元素
- 如果集合中元素个数为奇数, 那其中位数的下标索引: i = ( n + 1 ) / 2 i=(n+1)/2 i=(n+1)/2, 且唯一
- 如果集合元素个数为偶数, 其中位数就不唯一了, 并且有: 下中位数, 上中位数之分, 索引分别: ⌈ ( n + 1 ) / 2 ⌉ \lceil (n+1)/2\rceil ⌈(n+1)/2⌉和 ⌊ ( n + 1 ) / 2 ⌋ \lfloor(n+1)/2\rfloor ⌊(n+1)/2⌋
这里的 ⌈ ⌉ \lceil \rceil ⌈⌉和 ⌊ ⌋ \lfloor \rfloor ⌊⌋分别是向上取整和向下取整的意思
需要注意的是, 为了简便起见, 教材中所用的"中位数"都是指下中位数
介绍完"中位数", 我们就开始进入正题, 正式介绍顺序统计量了: 所谓顺序统计量就是集合中第i小的元素, 称作第i个顺序统计量 (order statistic)
例如, 在一个元素集合中, 最小值是第1个顺序统计量 (i=1), 最大值是第n个顺序统计量(i=n) .
更进一步的, 我们要开始探讨从一个由n个互异的元素构成的集合中选择第i个顺序统计量的问题.
输入: 一个包含n个(互异的)数的集合A和一个整数 i , i ∈ [ 1 , n ] i,i\in [1,n] i,i∈[1,n]
输出: 元素 x , x ∈ A x, x\in A x,x∈A, A中恰好有i-1个元素小于X
一个可以马上想到的方法就是我们可以先使用排序算法对集合进行排序然后再取第i+1个数值, 这样算法的时间复杂度可以达到O(nlgn), 但是这里我们介绍一些其他方法可以找出集合中顺序统计量对应的下标i, 一种方法时间复杂度可以达到O(n)的期望值, 另一种方法在最坏的情况下运行时间也可以达到O(n).
我们先不考虑寻找顺序统计量, 我们先来想想如何找出集合中的最大值或者是最小值, 最直观的方式就是, 我们取出一个数然后把剩下n-1个数挨个和这个数进行比较, 这样时间复杂度就是O(n), 而如果是同时找到最大或最小值, 也不过是多比较一次, 也就是T(2n-2),
不过这里我们也可以加快一点, 达到T( 3 ⌊ n / 2 ⌋ 3\lfloor n/2\rfloor 3⌊n/2⌋) , 具体操作是这样的:
我们先取一对数按大小分别作为最小值和最大值, 每次从剩下的n-2个元素的集合中取一对数输入, 先对比一次这对数, 大的和最大值比, 小的和最小值比, 这样每次输入都要进行三次对比, 而如果n为奇数的话我们一开始就只取一个数同时作为最大值和最小值, 按照这个方法, 如果n是奇数 则总共是 3 ( n − 1 ) / 2 3(n-1)/2 3(n−1)/2次比较, 如果n是偶数则是 1 + 3 ( n − 2 ) / 2 = 3 n / 2 − 2 1+3(n-2)/2=3n/2-2 1+3(n−2)/2=3n/2−2次比较, 而不管是哪一种情况, 最差都不超过 3 ⌊ n / 2 ⌋ 3\lfloor n/2\rfloor 3⌊n/2⌋次比较
2. 期望为线性时间的选择算法
一般选择问题看起来要比找最小值这样的简单问题更难, 但是这两个问题的渐近运行时间却是相同的: Θ ( n ) \Theta(n) Θ(n), 这里我们要介绍一种算法叫做: RANDOMIZED-SELECT算法
上来直接给大家看看伪代码, 理解完伪代码后我再给大家解释一下:
(这里A是集合, p,r分别是集合的开头和结尾的索引, i是要找的顺序统计量)
RANDOMIZED_SELECT(A, p, r, i)
1 if p == r
2 return A[p]
3 q = RANDOMIZED_PARTITION(A, p, r)
4 k = q-p+1
5 if i == k
6 return A[q]
7 else if i < k
8 return RANDOMIZED_SELECT(A, p, q-1, i)
9 else return RANDOMIZED_SELECT(A, q+1, r, i-k)
这里提到了一个函数RANDOMIZED_PARTITION其实是快速排序中的一个算法, 如果有兴趣的可以自行去了解, 这里我简单说一下其功能就是将输入的集合, 在索引p和r范围内随机找一个数(我们称之为主元), 使得这个数之前一直到索引p都是小于这个数的数, 而这个数之后一直到索引r的数都比这个数大, 这里我贴一个C++实现的部分代码, 仅供参考不多加说明:
#include <iostream>
#include <random>
#include <vector>
int partition(vector<int> arr, int start, int end) {
random_device rd;
mt19937 gen(rd());
uniform_int_distribution<> distrib(start, end);
int i = start;
int rand_index = distrib(gen);
int key = arr[rand_index];
while (i < end) {
if (arr[i] < key) {
swap(arr[i], arr[start]);
start++;
}
i++;
}
swap(arr[start], arr[i]);
return start;
}
在看完上面的伪代码后, 我们用人话来翻译翻译这些内容, 首先呢是给了我们一个集合, 以及这个集合的开始索引和结束索引, 一般是0和n, 第一步我们就先看看给我们的集合是不是索引范围为一个数的, 如果是则直接输出这个数, 如果不是, 则说明我们需要在p到r的范围内再通过某种方式找到第i个最小的数, 我们使用RANDOMIZED_PARTITION算法将集合在p到r的范围内随机取一个数使得这个数之前一直到索引p都是小于这个数的数, 而这个数之后一直到索引r的数都比这个数大, 然后返回这个数的索引, 接着我们就进行判断, 如果这个被随机取到的数正好处在索引i上我们直接输出它, 如果这个数比我们要的数大了或者小了我们就可以舍弃另一半的数, 减少计算量, 继续递归地进行下一步.
这里我们只要明白主要是利用了快排的步骤, 在快速排序中RANDOMIZED_PARTITION就是找出一个数, 把比他小的放前面, 比他大的放后面, 然后分别再递归排序, 这里我们可以发现, 每一步都实现了两个目的:
- 把一个数放到它应该在的位置, 就是这个被随机取到的数, 我们可以看到, 这个数的前面是比他小的数, 后面是比他大的数, 递归调用的时候不再把这个数的索引加入新一轮递归排序中了, 也就是这个数的位置就定下了
- 小的数为一组, 大的数为一组, 分别排序, 降低了问题的规模, 以便于递归
这里我们的RANDOMIZED_SELECT就是利用了这两个好处, 一方面判断是不是找出了我们要的数, 另一方面降低了问题的规模进行下一轮的递归
如果到这里你理解了这个算法的原理, 接下来我们就来分析一下为什么这个算法是线性时间复杂度的, 假设这个问题的运行时间是 T ( n ) T(n) T(n), 我们可以计算这个时间的上界来得到时间复杂度.
首先我们知道在RANDOMIZED_PARTITION中随机取得任何一个主元的概论都是 1 / n 1/n 1/n, 假设我们取了一个主元 X k X_k Xk,这个主元有k个数比他小, 那么我们可以知道 E ( X k ) = 1 / n E(X_k)=1/n E(Xk)=1/n, 这里为了求上界我们就直接假定我们要的数始终在元素最多的子集中, 而子数组的数量分别是k-1和n-k, 另外我们知道RANDOMIZED_PARTITION的时间复杂度是O(n)那么有公式如下:
T ( n ) ≤ ∑ k = 1 n p ( X k ) ⋅ ( T ( m a x ( k − 1 , n − k ) ) + O ( n ) ) = ∑ k = 1 n p ( X k ) ⋅ T ( m a x ( k − 1 , n − k ) ) + O ( n ) T(n)\leq\sum^{n}_{k=1}p(X_k)\cdot (T(max(k-1,n-k))+O(n))\\ =\sum^{n}_{k=1}p(X_k)\cdot T(max(k-1,n-k))+O(n)\\ T(n)≤k=1∑np(Xk)⋅(T(max(k−1,n−k))+O(n))=k=1∑np(Xk)⋅T(max(k−1,n−k))+O(n)
对两边同时取期望可得:
E ( T ( n ) ) ≤ E [ ∑ k = 1 n X k ⋅ T ( m a x ( k − 1 , n − k ) ) + O ( n ) ] = ∑ k = 1 n E [ p ( X k ) ] ⋅ E [ T ( m a x ( k − 1 , n − k ) ) ] + O ( n ) = 1 n ∑ k = 1 n E [ T ( m a x ( k − 1 , n − k ) ) ] + O ( n ) E(T(n))\leq E[\sum^{n}_{k=1}X_k\cdot T(max(k-1,n-k))+O(n)]\\ =\sum^{n}_{k=1}E[p(X_k)]\cdot E[T(max(k-1,n-k))]+O(n)\\ =\frac{1}{n}\sum^{n}_{k=1}E[T(max(k-1,n-k))]+O(n)\\ E(T(n))≤E[k=1∑nXk⋅T(max(k−1,n−k))+O(n)]=k=1∑nE[p(Xk)]⋅E[T(max(k−1,n−k))]+O(n)=n1k=1∑nE[T(max(k−1,n−k))]+O(n)
我们单独考虑
∑
k
=
1
n
E
[
T
(
m
a
x
(
k
−
1
,
n
−
k
)
)
]
\sum^{n}_{k=1}E[T(max(k-1,n-k))]
∑k=1nE[T(max(k−1,n−k))], 其中
m
a
x
(
k
−
1
,
n
−
k
)
=
{
k
−
1
,
k
>
⌈
n
/
2
⌉
n
−
k
,
k
≤
⌈
n
/
2
⌉
max(k-1,n-k)= \begin{cases} k-1, k>\lceil n/2\rceil \\ n-k, k\leq\lceil n/2\rceil \end{cases}
max(k−1,n−k)={k−1,k>⌈n/2⌉n−k,k≤⌈n/2⌉
如果n是偶数, 则在 ∑ k = 1 n T ( m a x ( k − 1 , n − k ) ) \sum^{n}_{k=1}T(max(k-1,n-k)) ∑k=1nT(max(k−1,n−k))中 T ( ⌈ n / 2 ⌉ ) T(\lceil n/2\rceil) T(⌈n/2⌉)到 T ( n − 1 ) T(n-1) T(n−1)这些项都会出现两次, 如果n是奇数则除了 T ( ⌈ n / 2 ⌉ ) T(\lceil n/2\rceil) T(⌈n/2⌉)出现一次意外其他都出现两次, 则可以继续推出:
E ( T ( n ) ) ≤ 2 n ∑ k = ⌊ n / 2 ⌋ n − 1 E [ T ( k ) ] + O ( n ) E(T(n))\leq \frac{2}{n} \sum^{n-1}_{k=\lfloor n/2\rfloor}E[T(k)]+O(n) E(T(n))≤n2k=⌊n/2⌋∑n−1E[T(k)]+O(n)
这里我们使用替代法也就是数学归纳法, 假设 E ( T ( n ) ) = O ( n ) E(T(n))=O(n) E(T(n))=O(n), 则有 E ( T ( n ) ) ≤ c n E(T(n))\leq cn E(T(n))≤cn, 那么:
E ( T ( n ) ) ≤ 2 n ∑ k = ⌊ n / 2 ⌋ n − 1 E [ T ( k ) ] + O ( n ) = 2 c n ( ∑ k = 1 n − 1 k − ∑ k = 1 ⌊ n / 2 ⌋ − 1 k ) + a n = 2 c n ( ( n − 1 ) n 2 − ( ⌊ n / 2 ⌋ − 1 ) ⌊ n / 2 ⌋ 2 ) + a n ≤ 2 c n ( ( n − 1 ) n 2 − ( n / 2 − 2 ) ( n / 2 − 1 ) 2 ) + a n = c ( 3 n 4 + 1 2 − 2 n ) + a n ≤ 3 c n 2 + n 2 + a n E(T(n))\leq \frac{2}{n} \sum^{n-1}_{k=\lfloor n/2\rfloor}E[T(k)]+O(n)\\ =\frac{2c}{n}(\sum^{n-1}_{k=1}k-\sum^{\lfloor n/2\rfloor-1}_{k=1}k)+an\\ =\frac{2c}{n}(\frac{(n-1)n}{2}-\frac{(\lfloor n/2\rfloor-1)\lfloor n/2\rfloor}{2})+an\\ \leq \frac{2c}{n}(\frac{(n-1)n}{2}-\frac{(n/2-2)(n/2-1)}{2})+an\\ =c(\frac{3n}{4}+\frac{1}{2}-\frac{2}{n})+an\\ \leq \frac{3cn}{2}+\frac{n}{2}+an\\ E(T(n))≤n2k=⌊n/2⌋∑n−1E[T(k)]+O(n)=n2c(k=1∑n−1k−k=1∑⌊n/2⌋−1k)+an=n2c(2(n−1)n−2(⌊n/2⌋−1)⌊n/2⌋)+an≤n2c(2(n−1)n−2(n/2−2)(n/2−1))+an=c(43n+21−n2)+an≤23cn+2n+an
也就可以得出这个方法的时间复杂度是线性的了
最坏情况为线性时间的选择算法
除了上面提到的算法, 我们还有一个理论意义上最坏情况为线性时间的选择算法, 接下来我直接给出步骤:
- 将输入数组的n个元素划分成 ⌈ n / 5 ⌉ \lceil n/5\rceil ⌈n/5⌉个组, 也就是每五个元素一组, 最多有一个组其元素个数是 n m o d 5 n\ mod\ 5 n mod 5
- 分别对这些组进行插入排序, 然后各自取中位数, 组成一个集合
- 从第二步得到的集合中再次取其中位数x
- 使用x作为主元, 调用修改后的PARTITION算法, 将数组划分为两个部分. 主元x的左右分别是小于x和大于x的元素. 这样, 主元x的排名位置是k
- 递归查找目标元素
- 如果i = k, 则输出x
- 如果i < k, 则在划分出的左子数组中递归调用SELECT查找第i小的元素
- 如果i > k, 则在划分出的右子数组中递归调用SELECT查找第i-k小的元素
其实这个算法和上一个算法唯一的不同点就是partition算法的不同, 上一个算法是取的随机索引, 索引可以力求期望上时间复杂度的线性, 而这里使用这个算法的关键在于中位数的中位数的选择,它能够确保在最坏情况下, 划分总是比较平衡, 从而保证算法的线性时间复杂度.
ps:这是课本上的示意图, 但是我看不懂…
接下来我们就分析一下为什么这个可以做到最坏情况为线性时间, 首先我们假定运行时间为
T
(
n
)
T(n)
T(n), 而第一,二,四步的时间复杂度为O(n), 第三步的时间可以记作
T
(
⌈
n
/
5
⌉
)
T(\lceil n/5\rceil)
T(⌈n/5⌉), 对于第五步, 我们知道在
⌈
n
/
5
⌉
\lceil n/5\rceil
⌈n/5⌉个组中, 除了不满5个数的组和包含x的组, 其余至少有三个元素大于x, 那么大于x的元素的个数至少是:
3
(
⌈
1
2
⌈
n
5
⌉
⌉
−
2
)
≥
3
n
10
−
6
3(\lceil \frac{1}{2} \lceil \frac{n}{5} \rceil\rceil-2)\geq \frac{3n}{10}-6
3(⌈21⌈5n⌉⌉−2)≥103n−6
所以最少有3n/10-6个元素小于x, 那么至多有n-(3n/10-6)=7n/10+6个元素被SELECT递归调用, 可以记作
T
(
7
n
/
10
+
6
)
T(7n/10+6)
T(7n/10+6)
那么我们就可以得到:
T
(
n
)
≤
T
(
⌈
n
/
5
⌉
)
+
T
(
7
n
/
10
+
6
)
+
O
(
n
)
=
c
⌈
n
/
5
⌉
+
c
(
7
n
/
10
+
6
)
+
a
n
≤
c
n
/
5
+
c
(
7
n
/
10
+
6
)
+
a
n
=
9
c
n
/
10
+
7
c
+
a
n
T(n)\leq T(\lceil n/5\rceil)+T(7n/10+6)+O(n)\\ =c\lceil n/5\rceil+c(7n/10+6)+an\\ \leq cn/5+c(7n/10+6)+an\\ = 9cn/10+7c+an
T(n)≤T(⌈n/5⌉)+T(7n/10+6)+O(n)=c⌈n/5⌉+c(7n/10+6)+an≤cn/5+c(7n/10+6)+an=9cn/10+7c+an
因此,最坏情况下SELECT的运行时间是线性的.
SELECT 算法的另一个优势在于, 它不依赖于随机性, 也不依赖于输入数据的分布情况, 像RANDOMIZED-SELECT这样的算法尽管在期望情况下有很好的表现(线性时间O(n))但它们依赖于随机性, 可能在特定情况下退化O(n2)的复杂度. 对于某些应用场景, 例如系统关键任务, 算法的最坏情况性能非常重要. 在这些场景中, SELECT算法提供了强有力的最坏情况保证.
尽管 SELECT 算法在理论上有很好的最坏情况性能,但由于它的主元选择过程相对复杂,导致它在实际应用中的表现可能不如 RANDOMIZED-SELECT 那么快。选择中位数的中位数需要递归调用 SELECT 自身,而且需要对小组进行排序,这使得它的常数因子比随机选择算法要大。因此,尽管 SELECT 是理论上更稳健的选择,但在实际使用中,RANDOMIZED-SELECT 往往是更好的选择,因为它在平均情况下运行更快。
不过,在某些特殊情况下,如必须保证最坏情况下的表现(如实时系统或安全性要求高的系统),SELECT 的理论保证则显得至关重要。
为什么5个元素一组? 选择 5 个元素一组是经过理论分析和经验验证的折中选择。通过对 5 个元素的小组进行排序并取中位数,能保证每次划分后的不平衡性不会太大。如果选择更多元素一组,主元的确定将变得过于复杂,而选择更少的元素一组则可能导致划分效果不好,难以保证最坏情况下的线性时间。
以上就是本篇文章想要介绍的全部内容啦, 希望大家不吝赐教~