算法分析与设计 - 作业5

问题一

在不同应用领域,经常涉及到top-K的问题,请给出不同策略在一系列数中返回Top-K元素,并分析你的策略。

解法一

我会排序!

考虑对给定序列进行排序,取前 k 大即可。

使用基于比较的排序算法,时间复杂度 O(nlogn) 级别。

解法二

k 较小时,将整个序列排序有些太浪费了。

考虑进行 k 轮冒泡排序或选择排序,仅使 k 大值有序即可。

时间复杂度 O(nk) 级别,当 k<n 时较优。

解法三

当序列中的元素均为整数且值域较小时,可以考虑进行计数排序,并在顺序枚举值域时取枚举到的前 k 大即可。

若值域为 O(m) 级别,则时间复杂度为 O(n+m) 级别。

解法四

我学过快排!

考虑快排每轮进行分治时进行的操作:选择基准元素,将小于/大于基准元素的元素分别划分到基准元素的两侧。则此时根据基准元素左侧的元素数量就可以得到基准元素的排名 r

  • r=k,说明基准元素及其左侧元素即为前 k 大元素,停止算法。
  • r>k,说明前 k 大元素均小于基准元素,问题转化为求基准元素左侧的所有元素的第 k 大,递归进行即可。
  • r<k,说明不大于基准元素的均为前 k 大,可以直接加入答案中,问题转化为求基准元素右侧的所有元素的前 kr 大值,递归进行即可。

比起分治更像是一种减治。

与快速排序复杂度分析类似地,若基于随机选择基准元素,上述算法时间复杂度期望 O(n) 级别,但是时间复杂度上限O(n2) 级别。

但需要注意与解法一二不同的是,此解法仅能将前 k 大值选出而不能保证前 k 大值的内部的有序性。

该算法即为 C++ STL 中的 nth_elements 内部实现的大致思路,另外 nth_elements 在区间长度小于 3 时会转化为插入排序,很酷!

复制
//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
int a[kN];
//=============================================================
int kth_elements(int l_, int r_, int k_) {
int p = rand() % (r_ - l_ + 1) + l_, i = l_, j = l_;
std::swap(a[p], a[r_]);
while (j < r_) {
if (a[j] >= a[r_]) std::swap(a[i], a[j]), ++ i;
++ j;
}
std::swap(a[i], a[j]);
if (r_ == k_ + i - 1) return a[i];
if (r_ > k_ + i - 1) return kth_elements(i + 1, r_, k_);
return kth_elements(l_, i - 1, k_ - (r_ - i + 1));
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
// std::ios::sync_with_stdio(0), std::cin.tie(0);
srand(time(0));
int n, k; std::cin >> n >> k;
for (int i = 1; i <= n; ++ i) std::cin >> a[i];
int ak = kth_elements(1, n, k);
for (int i = 1; i <= k; ++ i) std::cout << a[i] << " ";
return 0;
}

解法五

我会分治!

考虑将序列分块,每 m 个连续元素被分为一块,则共有 O(nm) 块。

对于每块套用解法四 O(m) 地求出其中的前 k 大值,然后对所有块的有序的前 k 大值归并即得整体的前 k 大值。

归并的复杂度为 O(k) 级别,总时间复杂度 O(nm×m+k)=O(n) 级别。

解法四同阶但是常数更大了上,感觉多此一举!

但是注意到分解后的子问题可以分布式地执行后再合并,不缺算力的情况下推荐使用。

解法六

BFPRT 算法,又称中位数的中位数算法,一种对解法四的优化,可保证复杂度上限为 O(n) 级别。

以发明者 Blum、Floyd、Pratt、Rivest、Tarjan 的首字母命名。怎么都是熟人、、、又见到了我最喜欢的 LCT 的 Tarjan 大神怎么哪都有你

为什么解法四会被卡到 O(n2) 级别?因为无法保证每次划分选择基准元素时均能选取到中位数,使基准元素两侧元素数量级不平衡,但是又会减治递归到元素数量较多一侧,从而使最坏情况下递归次数变为 O(n) 级别。BFPRT 算法在算法四的基础上,对选取基准元素的过程进行了优化,使得基准元素能更加接近中位数,从而避免了上述最坏情况的出现。

具体地,在选择基准元素时,首先将整个序列每 5 个相邻元素进行分块,求得每块中的中位数,再将求得的所有中位数组成一个序列后,递归调用 BFPRT 算法求该序列的中位数即为基准元素。

可以证明求得的基准元素一定被限制在整个序列的 30%70% 范围内,避免了最坏情况的发生。

在找基准元素仅仅首先遍历了整个序列,然后递归调用了 BFPRT 算法,则时间复杂度不变仍为 O(n) 级别。通过递归分析可知算法总时间复杂度为 O(n) 级别。

时间复杂度分析详见:BFPRT——Top k问题的终极解法 - 知乎

代码实现时考虑了重复出现的基准元素的影响。

//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
std::vector <int> a;
//=============================================================
int bfprt(std::vector <int> &a_, int l_, int r_, int k_);
int medianOfMedians(std::vector<int> &a_, int l_, int r_) {
int len = r_ - l_ + 1, bnum = len / 5 + (len % 5 > 0);
std::vector <int> temp1, temp2;
for (int i = 1; i <= bnum; ++ i) {
int bl = l_ + 5 * (i - 1), br = std::min(l_ + 5 * i - 1, r_), blen = br - bl + 1;
for (int j = bl, k = 1; j <= br; ++ j, ++ k) temp1.push_back(a_[j]);
std::sort(temp1.begin(), temp1.end());
temp2.push_back(temp1[blen / 2]);
}
std::sort(temp2.begin(), temp2.end());
return bfprt(temp2, 0, bnum - 1, bnum / 2);
}
std::vector<int> partition(std::vector <int> &a_, int l_, int r_, int pivot_) {
int greater = l_ - 1, equal = 0, temp;
for (int i = l_; i <= r_; ++ i) {
if (a_[i] > pivot_) {
++ greater;
temp = a_[greater], a_[greater] = a_[i];
if (equal > 0) {
a_[i] = a_[greater + equal];
a_[greater + equal] = temp;
} else {
a_[i] = temp;
}
} else if (a_[i] == pivot_) {
++ equal;
temp = a_[i];
a_[i] = a_[greater + equal];
a_[greater + equal] = temp;
}
}
return std::vector<int> {greater + 1, greater + equal};
}
int bfprt(std::vector <int> &a_, int l_, int r_, int k_) {
if (l_ == r_) return a_[l_];
int pivot = medianOfMedians(a_, l_, r_);
std::vector<int> p = partition(a_, l_, r_, pivot);
if (l_ + k_ >= p[0] && l_ + r_ <= p[1]) return a_[p[0]];
if (l_ + k_ < p[0]) return bfprt(a_, l_, p[0] - 1, k_);
return bfprt(a_, p[1] + 1, r_, k_ + l_ - p[1] - 1);
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
// std::ios::sync_with_stdio(0), std::cin.tie(0);
int n, k; std::cin >> n >> k;
for (int i = 1; i <= n; ++ i) {
int x; std::cin >> x;
a.push_back(x);
}
int ak = bfprt(a, 0, n - 1, k);
for (int i = 0; i < k; ++ i) std::cout << a[i] << " ";
return 0;
}

解法七

我会使用数据结构!

考虑使用支持求解最大元素的数据结构进行维护。

考虑在遍历序列元素时维护一个元素个数上限为 k小根堆,当枚举到元素 ai(1in) 时:

  • 若堆中元素个数不大于 k,则直接入堆。
  • 若堆中元素个数为 k,则比较堆顶元素与 ai 的大小关系,若 ai 大于堆顶元素则令堆顶元素弹出,并将 ai 入堆。

遍历完成后堆中的 k 个元素即为序列的前 k 大,且通过不断弹出直至堆空可使序列的前 k 大处于有序状态。

堆中元素个数上限为 k,则单次入堆/出堆操作的时间复杂度上限为 O(logk) 级别,则总时间复杂度 O(nlogk) 级别,在能够保证前 k 大有序的情况下,时间复杂度优于解法一二。

偷懒写优先队列哈哈

//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
int a[kN];
//=============================================================
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
// std::ios::sync_with_stdio(0), std::cin.tie(0);
int n, k; std::cin >> n >> k;
std::priority_queue <int, std::vector <int>, std::greater<int> > q;
for (int i = 1; i <= n; ++ i) {
std::cin >> a[i];
if (i <= k) q.push(a[i]);
else if (a[i] > q.top()) q.pop(), q.push(a[i]);
}
while (!q.empty()) {
std::cout << q.top() << " ";
q.pop();
}
return 0;
}

解法八

我会使用数据结构!

既然能用堆,那也能用二叉搜索树!

与解法六类似地,考虑维护一棵元素个数上限为 k 的二叉搜索树,当枚举到元素 ai(1in) 时:

  • 若树中元素个数不大于 k,则直接插入。
  • 若树中元素个数为 k,则比较树中最小元素与 ai 的大小关系,若 ai 大于最小元素则删除最小元素,并插入 ai

遍历完成后对二叉搜索树进行中序遍历即得有序的前 k 大值。

时间复杂度也为 O(nlogk) 级别,与解法七同阶并且结果一致,但是常数较大而且平衡树也很难写,与解法七相比没有什么竞争力。

懒得写平衡树了代码略。

问题二

调研学习排序算法 CubeSort,体会分治思想的使用。

不是我要笑死了这 b 排序、、、

考虑将待排序序列每 C 个连续元素分为一段(称为一个 Cube),对每段分别调用其他排序方法排序后,再将所有段归并即得有序序列。

一般 C 取一个较小的值,如 8, 16。时间复杂度依赖于调用的其他排序方法,但总时间复杂度与直接调用该排序方法同阶。

若调用的其他排序方法是稳定的,则 Cubesort 是稳定的。

Cubesort 的优势在于可以分布式地处理不同段的排序,得到结果后再归并即可,适合算力充足的情况。

并且当 C 较小时,可以使用在较小数据范围时近似线性的插入排序,使排序的平均复杂度远低于上限,实际使用时表现很优秀。

#include <iostream>
#include <algorithm>
const int CUBE_SIZE = 8;
// function to sort the cube
void cubeSort(int arr[], int n)
{
// divide the array into cubes of size CUBE_SIZE
for (int i = 0; i < n; i += CUBE_SIZE)
{
std::sort(arr + i, arr + std::min(i + CUBE_SIZE, n));
}
// merge the cubes
int temp[n];
for (int i = 0; i < n; i += CUBE_SIZE)
{
std::merge(arr + i, arr + std::min(i + CUBE_SIZE, n),
arr + std::min(i + CUBE_SIZE, n),
arr + std::min(i + 2*CUBE_SIZE, n),
temp + i);
}
// copy the result from temp[] back to arr[]
for (int i = 0; i < n; i++)
arr[i] = temp[i];
}
// main function
int main()
{
// input array
int arr[] = {3, 6, 7, 1, 5, 2, 8, 4};
int n = sizeof(arr) / sizeof(arr[0]);
// call cubeSort
cubeSort(arr, n);
// print the sorted array
for (int i = 0; i < n; i++)
std::cout << arr[i] << " ";
return 0;
}

写在最后

参考:

posted @   Rainycolor  阅读(81)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 25岁的心里话
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示