演算法分析與設計 - 作業5

Rainycolor發表於2024-03-23

目錄
  • 問題一
    • 解法一
    • 解法二
    • 解法三
    • 解法四
    • 解法五
    • 解法六
    • 解法七
    • 解法八
  • 問題二
  • 寫在最後

問題一

在不同應用領域,經常涉及到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;
}

寫在最後

參考:

  • 推薦閱讀:BFPRT——Top k問題的終極解法 - 知乎
  • 排序(下):如何用快排思想在O(n)內查詢第K個大元素? - 知乎
  • 【每日演算法Day 82】面試經典題:求第K大數,我寫了11種實現,不來看看嗎? - 知乎
  • Cubesort in C++ Code of Code

相關文章