问题分析
面对这个问题,最简单的想法是对数据进行排序,然后根据下标即可找到第k小的元素,目前已知的排序算法的最低时间复杂度为 O ( n log 2 log 2 n ) O(n\sqrt{\log_2 {\log_2 n}}) O(nlog2log2n),但并不为人熟知。目前应用最广的排序算法的最低时间复杂度为 O ( n log 2 n ) O(n\log_2 n) O(nlog2n)。
但是,作为完美主义者的程序员,需要思考,找到第k小的元素一定需要排序吗?但除了寻找最大或最小的元素之外,我们似乎只能选择排序。那么能否只进行部分排序,便可找到第k小的元素呢?答案是显然的,只需要采用每一趟都能确定一个固定元素的排序算法即可。
思考 O ( n log 2 n ) O(n\log_2 n) O(nlog2n)的排序算法中有哪些算法每一趟可以确定一个固定元素,答案是快速排序和堆排序(不考虑锦标赛排序,堆排序是锦标赛排序的升级版)。这里以快速排序为例,方便引入后面介绍的BFPRT算法,不过还有一个原因是堆排序获取第k小的元素的时间复杂度是 O ( n ) O(n) O(n)的概率小于快速排序。
快排应用
我们知道,快排每一趟都会有随机有一个元素处于最终位置上,故只需在快排中设置一个新的递归出口再加以微改便能返回第k小的元素。
代码如下:
这里采用的快排为随机化快速排序,没有了解过的的朋友可以看一下我的另外一篇博客:升级版快速排序——随机化快速排序
template<typename T>
void partition(T array[],int left,int right,int& mid)
{
srand(time(0));
int i = left, j = right, move = rand() % (right - left + 1) + left;
T temp = array[move];
array[move] = array[left];
while (i != j)
{
while (array[j] > temp&&j > i) j--;
if (i < j) array[i++] = array[j];
while (array[i] < temp&&i < j) i++;
if (i < j) array[j--] = array[i];
}
array[i] = temp;
mid = i;
}
template<typename T>
T random_quick_sort(T array[], int left, int right,int k)
{
if (k > right - left + 1) exit(7);
int i;
partition(array, left, right, i);
if (k == i - left + 1) return array[i];
else if (k > i - left + 1) return random_quick_sort(array, i + 1, right, k - i + left - 1);
else return random_quick_sort(array, left, i - 1, k);
}
这个算法的时间复杂度的确定需要用到指示器随机变量。
E
[
T
(
n
)
]
=
E
[
∑
k
=
0
n
−
1
X
k
(
T
(
m
a
x
E[T(n)]=E[\sum_{k=0}^{n-1}X_k(T(max
E[T(n)]=E[∑k=0n−1Xk(T(max{
k
,
n
−
k
−
1
k,n-k-1
k,n−k−1}
)
)
+
Θ
(
n
)
]
))+\Theta(n)]
))+Θ(n)],最终得到
E
[
T
(
n
)
]
=
Θ
(
n
)
E[T(n)]=\Theta(n)
E[T(n)]=Θ(n),即该算法的时间复杂度的期望值为
O
(
n
)
O(n)
O(n)
但是存在最坏情况,即每一次划分只能划分出一个部分,即
T
(
n
)
=
T
(
n
−
1
)
+
Θ
(
n
)
T(n)=T(n-1)+\Theta(n)
T(n)=T(n−1)+Θ(n),由等差级数可以得到最坏情况下的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)
BFPRT算法
BFPRT算法由Blum、Floyd、Pratt、Rivest、Tarjan提出,故以其名首字母拼接命名。
在应用快排寻找第k小的元素时,最坏情况为
O
(
n
²
)
O(n²)
O(n²)的原因是没有明显划分,为了“消除这个最坏情况”,只需要找到一个合适的值,使得划分具有意义,并且这个值递归迭代后仍保留这个性质。不妨称这个数为主元,BFPRT算法就找到了这样的一个主元。
下面开始欣赏这个算法的巧妙之处吧!
- 首先,把数据按5个数为一组进行分组,最后不足5个的为一组
- 对每组数进行排序求得其中位数,图中标红部分为每组的中位数,用箭头表示两个数之间的大小关系,箭头的数比箭尾的数大,按照这样操作,便形成了如图所示的有向图模型。
- 对前面所求得的中位数进行排序(在图中对应列跟着一起动,但实际算法中不需要这样,此处这样操作是便于理解),求得中位数的中位数,各个中位数的大小关系也在图中体现了出来。
- 看到这里你可能会觉得这有什么卵用,别急,我一开始学的时候也是这么想的,来吧,展示!
观察下面的图片,可以发现有一个矩形区域(紫色框)的所有数字都比中位数的中位数要大,有一个矩形区域(红色框)的所有数字都比中位数的中位数小。看到这里,聪明的读者应该知道这意味着什么了吧。
这意味着这个中位数的中位数便是我们寻找的一个主元,可以得到一个优质的划分。
接下来开始定量分析:
在该算法中,所有数据摆成了5行
⌈
n
5
⌉
\lceil \frac{n}{5} \rceil
⌈5n⌉列,根据主元进行划分,可以得到至少有
⌊
⌊
n
5
⌋
2
⌋
∗
3
\lfloor \frac{\lfloor \frac{n}{5} \rfloor}{2}\rfloor *3
⌊2⌊5n⌋⌋∗3个数是小于主元的,至少有
(
1
−
⌊
⌊
n
5
⌋
2
⌋
)
∗
3
(1-\lfloor \frac{\lfloor \frac{n}{5} \rfloor}{2}\rfloor )*3
(1−⌊2⌊5n⌋⌋)∗3的数是大于主元的。
在一开始的确定中值的时间开销为 Θ ( n ) \Theta(n) Θ(n)(因为每组数只有5个),接着递归求解中值的中值,时间开销为 T ( n 5 ) T(\frac{n}{5}) T(5n),紧接着便是递归划分,由数学知识,当 n n n较大时 ⌊ ⌊ n 5 ⌋ 2 ⌋ ∗ 3 > n 4 \lfloor \frac{\lfloor \frac{n}{5} \rfloor}{2}\rfloor *3>\frac{n}{4} ⌊2⌊5n⌋⌋∗3>4n,假设其余 3 n 4 \frac{3n}{4} 43n的元素都比主元大,则每一次递归的最大子问题规模为 T ( 3 n 4 ) T(\frac{3n}{4}) T(43n),故有 T ( n ) ≤ T ( n 5 ) + T ( 3 n 4 ) + Θ ( n ) T(n)≤T(\frac{n}{5})+T(\frac{3n}{4})+\Theta(n) T(n)≤T(5n)+T(43n)+Θ(n),由代换法解得 T ( n ) < = c n T(n)<=cn T(n)<=cn
下面给出BFPRT算法的具体代码实现:
- 1° 首先把数组按5个数为一组进行分组,最后不足5个的为一组。对每组数进行排序求取其中位数,并将所有中位数移到当前数组的前面
- 2° 对这些中位数重复(递归)1操作,最后返回最终的中位数
- 3° 将上一步得到的中位数作为划分的主元进行整个数组的划分。(基于快速排序)
- 4° 判断第k个数在划分结果的左边、右边还是恰好是划分结果本身,前两者递归处理,后者直接返回答案。
template<typename T>
void partition(T array[], int left, int right, int& mid)
{
int i = left, j = right;
T temp = array[left];
while (i != j)
{
while (array[j] > temp&&j > i) j--;
if (i < j) array[i++] = array[j];
while (array[i] < temp&&i < j) i++;
if (i < j) array[j--] = array[i];
}
array[i] = temp;
mid = i;
}
template<typename T>
void quick_sort(T array[], int left, int right)
{
if (left >= right) return;
int i;
partition(array, left, right, i);
quick_sort(array, i + 1, right);
quick_sort(array, left, i - 1);
}
template<typename T>
void grouping(T arr[], int left, int right)
{
int count = left;
for (int i=left; i <= right; i += 5)
{
if (i + 5 <= right)
{
quick_sort(arr, i, i + 4);
swap(arr[i + 2], arr[count++]);
}
else
{
quick_sort(arr, i, right);
swap(arr[(i + right) / 2], arr[count++]);
}
}
}
template<typename T>
void findmid(T arr[],int left,int right)
{
grouping(arr, left, right);
if (right - left <= 4) return;
else findmid(arr, left, left + (right - left + 1) / 5);
}
template<typename T>
T BFPRT(T arr[],int left,int right,int k)
{
if (k > right - left + 1) exit(7);
grouping(arr, left, right);
findmid(arr, left, left + (right - left + 1) / 5 );
int mid;
partition(arr, left, right, mid);
if (k == mid - left + 1) return arr[mid];
else if (k > mid - left + 1) return BFPRT(arr, mid + 1, right, k - mid + left - 1);
else return BFPRT(arr, left, mid - 1, k);
}
在任何情况下,BFPRT算法在任意情况下都可以在线性时间内求出第k小的元素,它的思想主要是以每5个元素为一组,找出中位数的中位数的中位数的…中位数。如果3个元素为一组,将达不到这个效果。5是能达到效果的最小数字,7也可以成立,但性能提升不明显。