算法分析与设计 - 作业5

问题一

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

解法一

我会排序!

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

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

解法二

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

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

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

解法三

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

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

解法四

我学过快排!

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

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

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

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

但需要注意与解法一二不同的是,此解法仅能将前 \(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\left(\left\lceil \frac{n}{m} \right\rceil\right)\) 块。

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

归并的复杂度为 \(O(k)\) 级别,总时间复杂度 \(O\left(\left\lceil \frac{n}{m} \right\rceil\times m + k\right) = O(n)\) 级别。

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

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

解法六

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

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

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

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

可以证明求得的基准元素一定被限制在整个序列的 \(30\% \sim 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\)小根堆,当枚举到元素 \(a_i(1\le i\le n)\) 时:

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

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

堆中元素个数上限为 \(k\),则单次入堆/出堆操作的时间复杂度上限为 \(O(\log k)\) 级别,则总时间复杂度 \(O(n\log k)\) 级别,在能够保证前 \(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\) 的二叉搜索树,当枚举到元素 \(a_i(1\le i\le n)\) 时:

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

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

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

懒得写平衡树了代码略。

问题二

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

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

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

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

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

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

并且当 \(\operatorname{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 @ 2024-03-23 23:45  Rainycolor  阅读(100)  评论(0)    收藏  举报