【建議收藏】記一次騰訊面試,TopK問題有多少種解法?

這是我在面試騰訊時遇到的真實面試題,在很多面經中也能看到它的身影,今天我們就來徹底地搞懂它!

問題描述

如何從 10w 的數據中找到最大的 100 個數?

首先看問題,10w 的數據,在堆上建個數組暴力求是沒有問題的,要找最大的 100 個數,那麼先從最簡單最暴力的方法開始。

1. 排序法

衆所周知,快速排序和堆排序的時間複雜度都可以達到 O(Nlog2N)O(N *log_2N) ,我們只要給 10w 數據排個序,然後取出前 100 個就好了。這種方法很暴力,在數據總數不是很大時確實可以使用,比如100個裏面取前20個;當然,面試時我們只需簡單地提一下這種解法,就可以說下一種優化方法了。至於排序,不是本文的重點。

接下來考慮優化,我們只需要前 100 個,爲什麼要把全部數據排序呢?

2. 局部排序法

我們回憶一下冒泡排序和選擇排序的過程,在前 k 次循環中,可以得出前 k 個最大/最小值。

以冒泡排序(降序)爲例:

for(int i = 0; i < n; i++) {
  for (int j = 0; j < n-i-1; j++) {
    if (arr[j] < arr[j+1]) 
      swap(arr, j, j+1); // 交換 arr[j] 和 arr[j+1]
  }
}

因此在這裏,我們正好利用這兩種排序算法的特性,簡單寫下代碼:

// 我們只需要把最外層的 n 換爲 k
for(int i = 0; i < k; i++) { 
	for (int j = 0; j < n-i-1; j++) {
		//...
  }
}

這樣子,就能獲得最大的前 k 個數,並且位於 arr 中的前 k 個位置,這樣的時間複雜度就變爲了 O(NK)O(N * K)

簡單比較下前兩種方法的時間複雜度: O(Nlog2N)O(N *log_2N)O(NK)O(N * K),到低哪個好,得根據 K 和 N 的大小來看,如果 K 較小(K <= log2Nlog_2N) 的情況下,我們可以採用局部排序法。

3. Partition

回憶一下快速排序,快排中的每一步,都是將待排數據分做兩組,其中一組的數據的任何一個數都比另一組中的任何一個大,然後再對兩組分別做類似的操作,然後繼續下去…

如下圖,將 arr 中的數據分爲小於 k和大於k兩部分:

快速排序的分組操作

接下來,我們來看怎麼利用這種思想求出最大的K個數。

我們假設存在一個數組S,從中隨意挑出了一個數 X,然後將數組 S 分爲兩部分:

  • A:大於等於X
  • B:小於X

如下圖所示,我們對數組 S 進行 Partition 操作,可以得到兩種情況:

遞歸求解中的具體一步

  1. 如果A的個數大於K,那麼數組S的最大K個數,就是A中的最大K個數;

    這個很好理解,相當於說年級(S)前十名(K)一定是年級前五十名(A)中的前十名(K)

  2. 如果A的個數小於K,我們就需要在B中找到剩餘的部分,也就是A+B中的K-|A|個;

    同樣的,年級(S)前十名(K)一定是年級前三名(A)加上年級4-100名(B)中的前7名K-|A|);

如果上面這部分還沒理解,可以參考下方這個小例子,如果理解了,跳過即可:

例子

我們只需重複上面的操作,遞歸直到找到前K個數即可, 這樣的平均時間複雜度爲 O(Nlog2K)O(N * log_2K)

這裏附一份僞代碼:

編程之美-代碼清單-2-11

我根據這份僞代碼簡單寫了下代碼:(Java實現,但以通用方式來寫,對於cpp、go都有參考價值)

建議大家一定要自己動手實現,光看代碼是不夠的,萬一面試官讓你手寫代碼你就傻眼了。另外,這份代碼爲了好理解,很多地方實際上是不規範的,比如變量名用大寫字母等等,這些大家在寫的時候是可以想辦法去優化的。

public int[] KBig(int[] S, int K) {
  if (K <= 0) {
    return new int[0];
  }
  if (S.length <= K) {
    return S;
  }
  Sclass sclass = Partition(S);
  return contact(KBig(sclass.Sa, K), KBig(sclass.Sb, K - sclass.Sa.length));
}

public Sclass Partition(int[] S) {
  Sclass sclass = new Sclass();
  int p = S[0]; // 省略了隨機選擇元素的過程
  for (int i = 1; i < S.length; i++) {
    if (S[i] > p) {
      sclass.Sa = append(sclass.Sa, S[i]);
    } else {
      sclass.Sb = append(sclass.Sb, S[i]);
    }
  }
  if (sclass.Sa.length < sclass.Sb.length) {
    sclass.Sa = append(sclass.Sa, p);
  } else {
    sclass.Sb = append(sclass.Sb, p);
  }
  return sclass;
}

注意到僞代碼中返回了兩個數組,我們這裏用一個類來存這兩個數組:

class Sclass { // 單純用來存儲兩個數組
  int[] Sa = new int[0];
  int[] Sb = new int[0];
}

輔助函數:

/**
* 在數組 arr 的末尾插入值 value
* @param arr   數組
* @param value 值
* @return 返回插入後的數組
*/
int[] append(int[] arr, int value) {
  int[] res = new int[arr.length + 1];
  System.arraycopy(arr, 0, res, 0, arr.length);
  res[arr.length] = value;
  return res;
}

/**
* 將兩個數組連接到一起
* @param a 數組a
* @param b 數組b
* @return 返回連接後的數組
*/
public int[] contact(int[] a, int[] b) {
  int[] res = new int[a.length + b.length];
  for (int i = 0; i < a.length; i++) { // 通用的拷貝方式
    res[i] = a[i];
  }
  // 在 java 中實際上可以通過 System.arraycopy 完成拷貝
  System.arraycopy(b, 0, res, a.length, b.length);
  return res;
}

當你寫完代碼,測試一下就會發現,實際上這種方法返回的最大的K個數是沒有排序的(其實題目也沒有要求你排序,且如果你對Partition的過程很清楚的話, 你也很容易知道這裏返回的是無序的最大K個數)我們需要考慮清楚應用場景,有些場景沒有排序要求,有些場景有,要學會選擇。

4. 二分搜索

我們要找數組S中最大的K個數,那麼如果我們知道了第K大的數,事情會變得簡單嗎?聰明的讀者可能已經發現了,如果我們知道了數組S中第K大的數p,那麼我們只需遍歷一遍數組,就能找到最大的K個數。(即所有大於等於p的數),這一步的時間複雜度爲 O(N)O(N)

有讀者可能會問,如果等於p的值有多個,這樣遍歷一遍取出來的數多於K個,怎麼辦呢?

事實上解決的辦法有很多,我這裏簡單說一種,遍歷的時候只把大於p的數取出來,最後根據大於p的數和K的差值,補相應的p就好了。

例子:S = [1, 2, 3, 3, 5],p = 3,K = 2;即我們知道第K大的數p 爲 3,我們遍歷一遍 S,把所有大於p的數取出來,即[5],接下來補K- [5].size() = 1p,即[5,3]就是最大的 K 個數。

回到我們的二分搜索方法中來,我們需要在S中找到第K大的數,僞代碼如下:

  • Vmax:數組S中的最大值
  • Vmin:數組S中的最小值
  • delta:比所有N個數中的任意兩個不相等的元素差值的最小值小。如果所有元素都是整數, delta可以取值0.5。

編程之美-代碼清單-2-12

整個算法的時間複雜度爲 O(Nlog2(VmaxVmindelta))O(N*log_2(\frac{|V_{max} - V_{min}|}{delta})) 。在數據平均分佈的情況下,時間複雜度爲 $ O(N*log_2N) $。

在整數的情況下,可以從另一個角度來看這個算法。假設所有整數的大小都在 [0,2m1][0,2^{m-1}] 之間,也就是說所有整數在二進制中都可以用m bit來表示(從低位到高位,分別用0, 1, ..., m-1標記)。我們可以先考察在二進制位的第(m-1)位,將N個整數按該位爲1或者0分成兩個部分。也就是將整數分成取值爲 [0,2m11][0,2^{m-1}-1][2m1,2m1][2^{m-1}, 2^m-1]兩個區間。
前一個區間中的整數第(m-1)位爲0,後一個區間中的整數第(m-1)位爲1。如果該位爲1的整數個數A大於等於K,那麼,在所有該位爲1的整數中繼續尋找最大的K個。否則,在該位爲0的整數中尋找最大的K-A個。接着考慮二進制位第(m-2)位,以此類推。思路跟上面的浮點數的情況本質上一樣。

5. BFPRT算法

這個算法比較複雜,我們這裏不做詳細介紹,簡單說下, 也是類似快速排序的思想,但是能從n個元素的序列中選出第k大/小的元素,且保證最壞時間複雜度爲 O(N)O(N)

爲什麼 O(N)O(N) 的算法不講,要去講那些看起來更 “慢” 的算法呢?要注意,我們通常講的時間複雜度是平均/最差,而且是忽略掉係數的,真實應用場景下還要考慮是否容易實現(過於複雜的可能頻繁出bug得不償失),還要考慮各種各樣的問題,並不是無腦選擇時間複雜度低的方法。

這個方法配合我們前面所說的,已知數組S中第K大的數p,我們只需再遍歷一遍數組,就能找到最大的K個數。這一步的時間複雜度也爲 O(N)O(N)

所以總的時間複雜度就是 O(N)O(N)

算法步驟:

  1. 將n個元素每5個一組,分成n/5(上界)組。

  2. 取出每一組的中位數,任意排序方法,比如插入排序。

  3. 遞歸的調用selection算法查找上一步中所有中位數的中位數,設爲x,偶數箇中位數的情況下設定爲選取中間小的一個。

  4. x來分割數組,設小於等於x的個數爲k,大於x的個數即爲n-k

  5. i==k,返回x;若i!=k,在大於x的元素中遞歸查找第i-k小的元素。終止條件:n=1時,返回的即是i小元素。

6. 最大最小堆

我們前面談到的解法有個共同的地方,如果數據量較大時,就得對數據訪問多次。

那麼如果面試官問的不是從 10w 中找100個數,而是10億呢? 這個時候數據是不能一次性讀入內存的,所以我們要盡可能少的遍歷所有數據

回憶我們的堆排序,我們需要維護一個最大堆/最小堆,關鍵點就在這裏了。我們可以從100億個數據中取出前K個,然後用這K個數建立一個最小堆,之後去遍歷所有數據,每取出一個數,如果大於當前堆中的最小值,就替換掉當前的最小堆中的最小值,然後維護堆的秩序,只需遍歷所有數據一次,我們就能獲得有序的最大 K 個數。維護堆的時間複雜度爲 O(log2K)O(log_2K),所以算法總體的時間複雜度爲 O(Nlog2K)O(N*log_2K)

囉嗦一句,我們這裏是用最小堆,去存最大的k個數,爲什麼不用最大堆來存呢?因爲更新的時候又得調換下順序,沒有必要多此一舉。

接下來我們詳細說說算法該怎麼實現,對堆排序熟悉的同學可能已經可以自己寫出來了,那麼可以跳過這部分。

我們使用一個數組H[]來建立一個K=8的堆:

數組H

採用數組來存儲堆

我們知道,堆中的每個元素H[i],它的父親結點是H[i/2],左孩子結點是H[2*i+ 1],右孩子結點是H[2*i+2]。每新考慮一個數X,需要進行的更新操作僞代碼如下:

編程之美-代碼清單-2-13

解讀下僞代碼,一開始進行判斷X是否大於當前的堆裏面最小值,如果比這個堆的最小值還小,那就不用看了,肯定不是最大的K個數之一;如果是大於最小值,那麼就替換掉最小值,如下圖所示:

替換X和H0

然後我們就要維護堆的秩序了,依次將X跟它的左右孩子進行比較,如果比它們大,就要交換,否則不動,假設X大於H[1],那麼X就要跟H[1]交換:

替換X和H1

交換完後,p=q,所以接下來會繼續判斷XH[3]的大小,假設X小於H[3],那麼就X就停止於此,結束循環。

7. 總結

方法 時間複雜度 特點
排序法 O(Nlog2N)O(N *log_2N) 實現簡單,數據量小,對速度要求不敏感
局部排序法 O(NK)O(N * K) 實現簡單,數據量小,且對速度不敏感時,
K<log2NK < log_2N 時可以考慮使用
Partition O(Nlog2K)O(N * log_2K) 速度快,返回數據無序
二分搜索 O(Nlog2N)O(N*log_2N) 速度較快,特定場景下可以使用位來實現
BFPRT O(N)O(N) 實際效果並沒有想象中的好
最大最小堆 O(Nlog2K)O(N * log_2K) 支持超大數據量,且可更新,有序

參考書籍:《編程之美》

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章