数组第K小数问题 及其对于 快排和堆排 的相关优化比较

时间:2023-03-09 04:43:28
数组第K小数问题 及其对于 快排和堆排 的相关优化比较

题目描述

给定一个整数数组a[0,...,n-1],求数组中第k小数

输入描述

首先输入数组长度n和k,其中1<=n<=5000, 1<=k<=n

然后输出n个整形元素,每个数的范围[1, 5000]

输出描述

该数组中第k小数

样例输入

4 2
1 2 3 4

样例输出

2

其实可以用 堆 来做,保证根节点为最小值,然后逐步剔除。不过当然也可以直接排序。
权当熟悉一下STL:
 #include <vector>
#include <algorithm>
#include <iostream>
using namespace std; int main()
{
int n, k;
cin >> n >> k; vector<int> a(n, );
for (int i = ; i < n; i++)
{
cin >> a[i];
}
sort(a.begin(), a.end()); cout << a[k-]; return ;
}

在和 Space_Double7 讨论后(详见讨论区),于是有了想要比较 快排 和 堆排 对于这道题各自的效率的想法。
于是写了一个程序来比较它们各自的运行时间,程序主要包括 随机生成输入数据、分别用快排和堆排求解并计时、比较 这几个部分。
代码如下:
 #include <vector>
#include <algorithm>
#include <iostream>
#include <stdlib.h>
#include <time.h>
#include <windows.h> using namespace std; // for timing
clock_t start, stop;
double durationOfQsort, durationOfHeapsort; vector<int> num;
int k, ans, n;
bool found; // 快排: 选定轴点
int parti(int lo, int hi)
{
swap(num[lo], num[lo + rand() % (hi - lo + )]);
int pivot = num[lo];
while (lo < hi)
{
while ((lo < hi) && (pivot <= num[hi])) hi--;
num[lo] = num[hi];
while ((lo < hi) && (num[lo] <= pivot)) lo++;
num[hi] = num[lo];
}
num[lo] = pivot;
if (lo == k)
{
found = true; // 表征已确定找到第 k 小数
ans = num[k];
}
return lo;
} // 快排主体
void quicksort(int lo, int hi)
{
if ((hi - lo < ) || (found))
{
if ((!found) && (lo == k))
{
found = true;
ans = num[k];
}
return;
}
int mi = parti(lo, hi - );
quicksort(lo, mi);
quicksort(mi + , hi);
} #define InHeap(n, i) (( (-1) < (i) ) && ( (i) < (n) ))
#define Parent(i) ((i-1)>>1)
#define LastInternal(n) Parent(n-1)
#define LChild(i) (1+((i) << 1))
#define RChild(i) ((1+(i)) << 1)
#define LChildValid(n, i) InHeap(n, LChild(i))
#define RChildValid(n, i) InHeap(n, RChild(i))
#define Bigger(PQ, i, j) ((PQ[i])<(PQ[j])? j : i)
#define ProperParent(PQ, n, i) \
(RChildValid(n, i) ? Bigger(PQ, Bigger(PQ, i, LChild(i)), RChild(i)):\
(LChildValid(n, i) ? Bigger(PQ, i, LChild(i)):i\
)\
) // 对向量前 n 个元素中的第 i 实施下滤操作
int percolateDown(int n, int i)
{
int j;
while (i != (j = ProperParent(num, n, i)))
{
swap(num[i], num[j]);
i = j;
}
return i;
} // Floyd 建堆算法
void heapify()
{
for (int i = LastInternal(n); InHeap(n, i); i--)
percolateDown(n, i);
} // 删除堆中最大的元素
int delMax(int hi)
{
int maxElem = num[];
num[] = num[hi];
percolateDown(hi, );
return maxElem;
} // 堆排主体
void heapsort()
{
heapify();
int hi = n;
while (hi > )
{
--hi;
num[hi] = delMax(hi);
if (hi == k)
{
ans = num[k];
return;
}
}
} int main()
{
int scoreOfQsort = , scoreOfHeapsort = ; for (int iter = ; iter < ; iter++)
{
// 确定 n 的大致最大范围,注意随机 n 会有向右 MaxN 的偏差
const int MaxN = ; // 产生一个 0..n-1 的随机序列输入数组,n 最大为3000
cout << "**********************第" << iter + << "次************************" << endl;
//srand(unsigned(clock()));
n = rand() % MaxN + MaxN;
vector<int> a(n, );
for (int i = ; i < n; i++)
a[i] = i;
random_shuffle(a.begin(), a.end()); cout << "产生一个 0.." << n - << " 的随机序列输入数组:" << endl;
/*for (int i = 0; i < n; i++)
cout << a[i] << " ";*/
cout << endl; // 随机生成 k
//srand(unsigned(clock()));
k = rand() % n;
cout << "k = " << k << endl << endl; // 第 k 小的数一定是 k,因为 random_shuffle,同时避免退化情形对快排一般性的影响
cout << "在该数组中第 " << k << " 小的数是: " << k << endl << endl << endl; // qsort
cout << "快排:" << endl;
num = a;
start = clock();
found = false;
quicksort(, n);
stop = clock();
durationOfQsort = ((double)(stop - start)*) / CLK_TCK;
cout << "Ok.. 我已经找到了第 " << k << " 小的数,它是: " << ans << endl;
cout << "快排用时: " << durationOfQsort << "ms" << endl;
if (ans != k)
{
cout << "注意!!!答案错误!!!" << endl;
system("pause");
}
/*else
{
cout << "此时的序列情况是: ";
for (int i = 0; i < n; i++)
cout << num[i] << " ";
}*/
cout << endl << endl; // heapsort
cout << "堆排:" << endl;
num = a;
start = clock();
heapsort();
stop = clock();
durationOfHeapsort = ((double)(stop - start) * ) / CLK_TCK;
cout << "Ok.. 我已经找到了第 " << k << " 小的数,它是: " << num[k] << endl;
cout << "堆排用时: " << durationOfHeapsort << "ms" << endl;
if (num[k] != k)
{
cout << "注意!!!答案错误!!!" << endl;
system("pause");
}
/*else
{
cout << "此时的序列情况是: ";
for (int i = 0; i < n; i++)
cout << num[i] << " ";
}*/
cout << endl << endl; if (durationOfHeapsort > durationOfQsort) scoreOfQsort++;
else scoreOfHeapsort++;
}
cout << "*******************END***********************";
cout << endl << endl << "总比分(快排:堆排): " << scoreOfQsort << " : " << scoreOfHeapsort;
cout << endl; return ;
}

运行了几下,大致可以看出 快排:堆排 的效率比 趋近于 2:1(对于每个case的耗时少则计分,计分多的效率高),这是其中 3 次的结果:

数组第K小数问题 及其对于 快排和堆排 的相关优化比较

数组第K小数问题 及其对于 快排和堆排 的相关优化比较

数组第K小数问题 及其对于 快排和堆排 的相关优化比较

感兴趣的可以自己在本地运行一下~


然而我发现依然存在一个问题,我们主要想讨论的是 对于 “一找到 第k小 的元素便立即退出”这件事 对于整个完全排序本身优化程度的大小。

横向来看,仅仅比较 优化后的排序 耗时并不能确定 到底是快速排序 本身对于随机数据而言比 堆排序 性能好(从上面看,似乎是显然的),还是 “一找到 第k小 的元素便立即退出”这个优化在这里 帮了快速排序的大忙,使其在这里表现出了更好的性能。

纵向来看,我们想看看该优化对于完全排序而言,能优化到什么程度。

所以我决定比较两种排序 优化退出的时间占完全排序的时间 的比,来看看这个优化对于完全排序的影响程度。

这是修改以后的代码,算的是百分比,百分比越小,优化越明显


 #include <vector>
#include <algorithm>
#include <iostream>
#include <stdlib.h>
#include <time.h>
#include <windows.h> using namespace std; // for timing
clock_t start, stop, stop1;
double durationOfQsort, durationOfHeapsort; vector<int> num;
int k, ans, n;
bool found;
double time1, time2;
double average1 = , average2 = ; // 快排: 选定轴点
int parti(int lo, int hi)
{
swap(num[lo], num[lo + rand() % (hi - lo + )]);
int pivot = num[lo];
while (lo < hi)
{
while ((lo < hi) && (pivot <= num[hi])) hi--;
num[lo] = num[hi];
while ((lo < hi) && (num[lo] <= pivot)) lo++;
num[hi] = num[lo];
}
num[lo] = pivot;
if ((!found)&&(lo == k))
{
stop1 = clock();
found = true;
}
return lo;
} // 快排主体
void quicksort(int lo, int hi)
{
if (hi - lo < )
{
if ((!found) && (lo == k))
{
stop1 = clock();
found = true;
}
return;
}
int mi = parti(lo, hi - );
quicksort(lo, mi);
quicksort(mi + , hi);
} #define InHeap(n, i) (( (-1) < (i) ) && ( (i) < (n) ))
#define Parent(i) ((i-1)>>1)
#define LastInternal(n) Parent(n-1)
#define LChild(i) (1+((i) << 1))
#define RChild(i) ((1+(i)) << 1)
#define LChildValid(n, i) InHeap(n, LChild(i))
#define RChildValid(n, i) InHeap(n, RChild(i))
#define Bigger(PQ, i, j) ((PQ[i])<(PQ[j])? j : i)
#define ProperParent(PQ, n, i) \
(RChildValid(n, i) ? Bigger(PQ, Bigger(PQ, i, LChild(i)), RChild(i)):\
(LChildValid(n, i) ? Bigger(PQ, i, LChild(i)):i\
)\
) // 对向量前 n 个元素中的第 i 实施下滤操作
int percolateDown(int n, int i)
{
int j;
while (i != (j = ProperParent(num, n, i)))
{
swap(num[i], num[j]);
i = j;
}
return i;
} // Floyd 建堆算法
void heapify()
{
for (int i = LastInternal(n); InHeap(n, i); i--)
percolateDown(n, i);
} // 删除堆中最大的元素
int delMax(int hi)
{
int maxElem = num[];
num[] = num[hi];
percolateDown(hi, );
return maxElem;
} // 堆排主体
void heapsort()
{
heapify();
int hi = n;
while (hi > )
{
--hi;
num[hi] = delMax(hi);
if (hi == k) stop1 = clock();
}
} int main()
{
// 确定 n 的大致最大范围,注意随机 n 会有向右 MaxN 的偏差
const int MaxN = ; // 计算次数
const int times = ; int scoreOfQsort = , scoreOfHeapsort = ; for (int iter = ; iter < times; iter++)
{ // 产生一个 0..n-1 的随机序列输入数组,n 最大为3000
cout << "**********************第" << iter + << "次************************" << endl;
//srand(unsigned(clock()));
n = rand() % MaxN + MaxN;
vector<int> a(n, );
for (int i = ; i < n; i++)
a[i] = i;
random_shuffle(a.begin(), a.end()); cout << "产生一个 0.." << n - << " 的随机序列输入数组:" << endl;
/*for (int i = 0; i < n; i++)
cout << a[i] << " ";*/
cout << endl; // 随机生成 k
//srand(unsigned(clock()));
k = rand() % n;
cout << "k = " << k << endl << endl; // 第 k 小的数一定是 k,因为 random_shuffle,同时避免退化情形对快排一般性的影响
cout << "在该数组中第 " << k << " 小的数是: " << k << endl << endl << endl; // qsort
cout << "快排:" << endl;
num = a;
start = clock();
found = false;
quicksort(, n);
stop = clock();
time1 = (double)(stop1 - start) * / CLK_TCK;
time2 = (double)(stop - start) * / CLK_TCK;
cout << "找到 k 的时间: " << time1 << " ms" << endl;
cout << "完全排序 的时间: " << time2 << " ms" << endl;
durationOfQsort = time1 / time2 * ;
average1 += durationOfQsort;
/*cout << "Ok.. 我已经找到了第 " << k << " 小的数,它是: " << ans << endl;*/
cout << "快排占比: " << durationOfQsort << " %" << endl;
/*if (ans != k)
{
cout << "注意!!!答案错误!!!" << endl;
system("pause");
}*/
/*else
{
cout << "此时的序列情况是: ";
for (int i = 0; i < n; i++)
cout << num[i] << " ";
}*/
cout << endl << endl; // heapsort
cout << "堆排:" << endl;
num = a;
start = clock();
heapsort();
stop = clock();
time1 = (double)(stop1 - start) * / CLK_TCK;
time2 = (double)(stop - start) * / CLK_TCK;
cout << "找到 k 的时间: " << time1 << " ms" << endl;
cout << "完全排序 的时间: " << time2 << " ms" << endl;
durationOfHeapsort = time1 / time2 * ;
average2 += durationOfHeapsort;
//cout << "Ok.. 我已经找到了第 " << k << " 小的数,它是: " << num[k] << endl;
cout << "堆排占比: " << durationOfHeapsort << " %" << endl;
/*if (num[k] != k)
{
cout << "注意!!!答案错误!!!" << endl;
system("pause");
}*/
/*else
{
cout << "此时的序列情况是: ";
for (int i = 0; i < n; i++)
cout << num[i] << " ";
}*/
cout << endl << endl; if (durationOfHeapsort > durationOfQsort) scoreOfQsort++;
else scoreOfHeapsort++;
}
cout << "*******************END***********************";
cout << endl << endl << "总比分(快排:堆排): " << scoreOfQsort << " : " << scoreOfHeapsort;
cout << endl;
cout << endl << "快排平均占比: " << average1 / times << " %" << endl;
cout << endl << "堆排平均占比: " << average2 / times << " %" << endl; return ;
}


运行 3 次的结果:

数组第K小数问题 及其对于 快排和堆排 的相关优化比较

数组第K小数问题 及其对于 快排和堆排 的相关优化比较

数组第K小数问题 及其对于 快排和堆排 的相关优化比较

可见,

1、这个优化对于两种排序的影响程度是差不多的(百分比越小,优化越明显);

2、对于完全排序而言,它大概相当于在前面加了一个 0.6 的系数,也就是 只 干了完全排序 0.6 倍的工作量,正如分析来看,依然是常系数级的优化。

由于数据是完全随机的(并且没有重复元素),快排也适应得很好,在实际用途中(对于近似随机数据),它的效率是可观的。


不!还没完!

来来来,现在我们回到原问题本身,回到 查找 数组 第 k 小数 这样经典而基础的问题本身上来……

尽管 原问题 数据规模小,水水就能过,但是既然已经鼓捣过了,干脆鼓捣完。

所以我还是决定写一个对于大规模数据具有普适意义的尽可能优化的算法来解决问题(优化到线性复杂度)。

再考虑这个问题,在写了一遍快排之后,会发现这是一个与 快排中选取轴点 很类似的问题。

轴点是左右集合的分界点,左集合所有元素比轴点小,右集合所有元素比轴点大,你可以发现,找第 K 小数就是找在位置 K 上的轴点(也正如上述优化所想)!

然而我们依然要向上面所写的程序一样,找到 k 就退出吗?

1、快速选取算法

考虑这样的情形,

当前选取的轴点正好是 第k个,自然就退出;

当选取的轴点比 k 小时,我们实际上可以不用再对左集合排序了!因为我们只需要知道它们都比轴点要小,而且知道它们的个数,而此时轴点比 k 还要小,所以我们可以继续只对 右集合 分治下去!

同样的,当选取的轴点比 k 大时,我们实际上可以不用再对右集合排序了!因为我们只需要知道它们都比轴点要大,而且知道它们的个数,而此时轴点比 k 还要大,所以我们可以继续只对 左集合 分治下去!

这样,我们可以把每一次的向下两次递归变成一次。

用 非递归 版本代码大致表示如下:

 void quickSelect()
{
for (int lo = , hi = n-; lo < hi; )
{
int i = lo, j = hi, pivot = num[lo];
while (i < j)
{
while ( (i<j) && (pivot <= num[j]) ) j--;
num[i] = num[j];
while ( (i<j) && (num[i] <= pivot) ) i++;
num[j] = num[i];
}
num[i] = pivot;
if ( k<=i ) hi = i - ;
if ( i<=k ) lo = i + ;
}
} // 结束后 num[k] 即是解

对于一般情况(数据接近随机),跟快排一样,此算法效率很高,不过快排的缺点它也一样具有。也就是当轴点把左右集合划分得极不均匀甚至某一个集合为空时,此时效率跟快排一样退化到 O(n^2)。

接下来考虑, 堆排是不是也有优化空间呢?

2、堆选取算法

题目只需要我们选取 第 K 小数,跟快速选取一样,无关目的的部分我们完全没必要做无用功。

基于这样的考虑,我们完全可以只维护一个规模为 K 的大根堆嘛!~

算法思考大致是:

首先将序列前 K 的元素用 Floyd 建堆(O(k)效率)维护成一个大根堆。

然后将剩下的元素依次插入堆,每插入一个,随机删除堆顶,使堆的规模保持在 k。

这样当所有的元素插入完毕,那么堆的根就是问题的解。

一般情况下,它的效率是比完全排序效率要高的,不过当 k 接近于 n/2 的时候,它的复杂度又会退化到 O( nlogn )。

难道真的不能从实质上将这个问题优化到线性效率上来吗?

3、k-选取算法

算法里面对于排序有一个丧病的思路:选定一个 k,当序列长度小于 k 时,sort 函数直接不作处理返回原序列。整个序列经过这样一次 sort 之后当然不是有序的,此时对这个序列做一次插入排序(因为插入排序在处理 “几乎” 有序的序列时,运行非常快)。根据算导的结论,这样的复杂度是 O(nk + n log(n/k)) 的。(其实就是相当于做n/k次k长的插入)

这种思想在这里我们也可以借鉴,大致的算法思想如下:

0) 选定一个数Q,Q为一个不大的常数;

select(A, k):

1) 如果序列A规模不大于 Q 时直接蛮力算法;      // 递归基

2) 将A均匀划分为 n/Q 个子序列,各含 Q 个元素;

3) 各子序列分别排序(可采用任何排序算法),计算中位数,并将所有中位数组成一个序列;

4) 递归调用select(),计算出中位数序列的中位数,记作M;

5) 根据元素相对于 M 的大小,将 A 中元素分为三个子集:L(小于),E(相等)和G(大于);

6) if ( |L| >= k ) return select(L, k);

  else if ( |L| + |E| >= k ) return M;

else return select(G, k - |L| - |E|);

复杂度分析:(计最坏情况下运行时间为 T(n))

2): O(n);

3): 由于Q为常数,累计也为 O(n);

4): 递归调用,T(n/Q)

5): O(n);

6): 中位数序列的中位数一定是全局中位数 M,而 L 和 G 的规模一定不会超过 3n/4。

所以可得如下递推关系:

T(n) = cn + T(n/Q) + T(3n/4),c 为常数

如果取 Q = 5, 则有:

T(n) = cn + T(n/5) + T(3n/4) = O(20cn) = O(n)

可见,复杂度是线性。虽如此,其常系数过大,且算法过程较复杂,在一般规模的应用中难以真正体现出效率的优势。

Reference : 《数据结构习题解析》,邓俊辉