相信计算机专业的同学应该都对快速排序有或多或少的了解。
设置此模块是因为,线性时间选择TopK与快速排序的思想有相通之处,可以辅助我们理解。
快速排序的思路:
我们可以将快速排序算法分为以上三步,概括来讲就是三个函数:
我们也都知道的一件事是,快速排序的性能不稳定。
而导致其性能不稳定的关键因素就是分割元素的选取。
如果分割元素每次都可以将工作区间近乎等分的话,就可以有O(N * lgN)的优秀性能。
如果分割元素每次都取在工作区间的最大值或最小值的话,那就要付出O(N ^ 2)的代价。
到此为止,我们复习了快速排序的思路和分割元素的选取对性能有关键影响的结论。
希望大家谨记。
带着刚刚的收获,我们来看TopK问题。此后以在所有元素中选取第k大的元素为例。
试想我们已经按照快速排序的方法,以分割元素ParElem将区间分成了两部分。
即ParElem左边都是比他小的元素,ParElem右边都是比他大的元素。
那整个[l, r] 区间就可以被分成两部分 [l, ParElem - 1] 和 [ParElem, r]。
递归状态转移
我们可以确定地是,问题的解不是在左半部分区间,就是在右半部分区间,而且右半部分区间地数字都比左半部分的数字大。
发现联系了没有,快速排序的GetPartition和Partition两个函数可以为求解TopK服务,我们需要做的只是将Qsort换成的对应的SelectK即可。
还记得我们在上一模块得出的重要结论吗?
分割元素的选取对性能有关键影响。
那么怎么选取分割元素比较好呢?
分割元素将区间分成的两部分长度越接近越好。
也就等价于,分割元素的数值大小是区间内数值的中位数最好。
那么我们如何才能实现这一需求呢?
我们之前得到的分割元素数值大小最好是中位数,所以最好的办法应该是和中位数有关的。
当然,选取中位数的算法和TopK的复杂度是一致的了,显然不值得。
所以我们在此使用的选取分割元素的方法是和中位数有关,但他选取的不是整个工作区间内的中位数。
GetPartition函数描述
比较复杂对不对?但这就是我们线性选择TopK的关键所在了。
选取了合适的分割元素后,就可以快乐的按照我们之前提到的Partition和SelectK,递归调用求解了。
num是存放数字的数组
SIZE是分组时的小组长度
MIN_LEN是指,当我们的区间长度缩小到该程度后,就不必费劲的去递归求解了。
可以直接排序求解
int GetPartition(int l, int r)
{
int len = r - l - +1;
// SIZE个元素一组,可以分成多少组
int GroupNum = (len - SIZE - 1) / SIZE;
for (int i = 0; i < GroupNum; i++)
{
// 对第i组的元素进行排序
sort(num + l + SIZE * i, num + l + SIZE * i + SIZE);
// 将第i组元素中的中位数取出
swap(num[l + i], num[l + SIZE * i + SIZE / 2]);
}
// 选择所有组别中位数中的中位数,即分割元素
int ParElem = SelectK(l, l + GroupNum, GroupNum / 2);
// 返回分割元素下标
return ParElem;
}
}
int Partition(int l, int r, int ParElem)
{
// [l, r]范围内的元素,以第ParELem号元素做分割元素,完成分割
swap(num[l], num[ParElem]);
int x = num[l];
int i = l, j = r + 1;
while (true)
{
while (num[++i] < x && i < r)
;
while (num[--j] > x && j > l)
;
if (i >= j)
break;
swap(num[i], num[j]);
}
num[l] = num[j];
num[j] = x;
// 返回做完分割后的分割元素下标
return j;
}
int SelectK(int l, int r, int k)
{
int len = r - l + 1;
if (len < MIN_LEN)
{
// 当前区间长度缩小到MIN_LEN后,直接排序求解即可
sort(num + l, num + r);
// 因为是求第k大元素,所以返回第r - k + 1个元素
return num[r - k + 1];
}
// 获得分割元素,并以该分割元素完成分割
int ParElem = GetPartition(l, r);
ParElem = Partition(l, r, ParElem);
// RL表示区间[ParElem, r]的长度
// 如果求第k小就应该看左区间长度了
int RL = r - ParElem + 1;
if (k <= RL)
{
// 说明答案是在右区间的第k大元素
return SelectK(ParElem, r, k);
}
else
{
// 说明答案是左区间的第k - RL个元素
return SelectK(l, ParElem - 1, k - RL);
}
}
我们已经知道该问题的解法与快排及其相似,Partition和GetPartirtion两个函数甚至是完全一样的。那为什么快排的复杂度是O(N * lgN),而本算法的复杂度是O(N)呢?
因为快排是分治,而本算法是减治。
这两者有何分别呢?
在快排中,选出分割元素并完成分割后,我们要做什么呢?递归对分割元素左右两边求解。也就是说,在快排中我们完成分割后,分割元素左右两侧仍然是需要求解的,因为我们要进行的是排序。这就是分治,将整个问题集合分成小问题来逐一解决。
那在TopK中,选出分割元素并完成分割后,我们要做什么呢?递归对分割元素左侧或右侧求解。 也就是说,左右两侧我们只需求解一侧即可。因为这是选择性问题,我们可以确定答案在某一侧,那另一侧就不需要求解了。这就是减治,将问题集合分开后选择一个子集和求解。