1. 程式人生 > 實用技巧 >出現次數超過一半的數

出現次數超過一半的數

題意:陣列中有一個數字出現的次數超過了陣列長度的一半,找出這個數字。

分析與解法

一個數組中有很多數,現在我們要找出其中那個出現次數超過總數一半的數字,怎麼找呢?大凡當我們碰到某一個雜亂無序的東西時,我們人的內心本質期望是希望把它梳理成有序的。所以,我們得分兩種情況來討論,無序和有序。

解法一

如果陣列無序,那麼我們是不是可以先把陣列中所有這些數字先進行排序(至於排序方法可選取最常用的快速排序)。排完序後,直接遍歷,在遍歷整個陣列的同時統計每個數字的出現次數,然後把那個出現次數超過一半的數字直接輸出,題目便解答完成了。總的時間複雜度為\(O(nlogn + n)\)

如果是有序的陣列呢,或者經過排序把無序的陣列變成有序後的陣列呢?是否在排完序\(O(nlogn)\)

後,還需要再遍歷一次整個陣列?

我們知道,既然是陣列的話,那麼我們可以根據陣列索引支援直接定向到某一個數。我們發現,一個數字在陣列中的出現次數超過了一半,那麼在已排好序的陣列索引的\(N/2\)處(從零開始編號),就一定是這個數字。自此,我們只需要對整個陣列排完序之後,然後直接輸出陣列中的第N/2處的數字即可,這個數字即是整個陣列中出現次數超過一半的數字,總的時間複雜度由於少了最後一次整個陣列的遍歷,縮小到\(O(n*logn)\)

然時間複雜度並無本質性的改變,我們需要找到一種更為有效的思路或方法。

解法二

既要縮小總的時間複雜度,那麼可以用查詢時間複雜度為O(1)的hash表,即以空間換時間。雜湊表的鍵值(Key)為陣列中的數字,值(Value)為該數字對應的次數。然後直接遍歷整個hash表

,找出每一個數字在對應的位置處出現的次數,輸出那個出現次數超過一半的數字即可。

解法三

Hash表需要O(n)的空間開銷,且要設計hash函式,還有沒有更好的辦法呢?我們可以試著這麼考慮,如果每次刪除兩個不同的數(不管是不是我們要查詢的那個出現次數超過一半的數字),那麼,在剩下的數中,我們要查詢的數(出現次數超過一半)出現的次數仍然超過總數的一半。通過不斷重複這個過程,不斷排除掉其它的數,最終找到那個出現次數超過一半的數字。這個方法,免去了排序,也避免了空間O(n)的開銷,總得說來,時間複雜度只有O(n),空間複雜度為O(1),貌似不失為最佳方法。

舉個簡單的例子,如陣列a[5] = {0, 1, 2, 1, 1};

很顯然,若我們要找出陣列a中出現次數超過一半的數字,這個數字便是1,若根據上述思路4所述的方法來查詢,我們應該怎麼做呢?通過一次性遍歷整個陣列,然後每次刪除不相同的兩個數字,過程如下簡單表示:

0 1 2 1 1 =>2 1 1=>1

最終1即為所找。

此外,對於序列{5, 5, 5, 5, 1},每次分別從陣列兩端嘗試各刪除一個數(左邊刪除5, 右邊刪除1,兩個數不相同),之後剩餘{5, 5, 5},這時無法找到兩個不同的數進行刪除,說明剩餘元素全部相同,返回5作為結果即可。

解法四

更進一步,考慮到這個問題本身的特殊性,我們可以在遍歷陣列的時候儲存兩個值:一個candidate,用來儲存陣列中遍歷到的某個數字;一個nTimes,表示當前數字的出現次數,其中,nTimes初始化為1。當我們遍歷到陣列中下一個數字的時候:

  • 如果下一個數字與之前candidate儲存的數字相同,則nTimes加1;
  • 如果下一個數字與之前candidate儲存的數字不同,則nTimes減1;
  • 每當出現次數nTimes變為0後,用candidate儲存下一個數字,並把nTimes重新設為1。 直到遍歷完陣列中的所有數字為止。

舉個例子,假定陣列為{0, 1, 2, 1, 1},按照上述思路執行的步驟如下:

  • 1.開始時,candidate儲存數字0,nTimes初始化為1;
  • 2.然後遍歷到數字1,與數字0不同,則nTimes減1變為0;
  • 3.因為nTimes變為了0,故candidate儲存下一個遍歷到的數字2,且nTimes被重新設為1;
  • 4.繼續遍歷到第4個數字1,與之前candidate儲存的數字2不同,故nTimes減1變為0;
  • 5.因nTimes再次被變為了0,故我們讓candidate儲存下一個遍歷到的數字1,且nTimes被重新設為1。最後返回的就是最後一次把nTimes設為1的數字1。

思路清楚了,完整的程式碼如下:

//a代表陣列,length代表陣列長度
int FindOneNumber(int* a, int length)
{
    int candidate = a[0];
    int nTimes = 1;
    for (int i = 1; i < length; i++)
    {
        if (nTimes == 0)
        {
            candidate = a[i];
            nTimes = 1;
        }
        else
        {
            if (candidate == a[i])
                nTimes++;
            else
                nTimes--;
        }
    }
    return candidate;
}

即針對陣列{0, 1, 2, 1, 1},套用上述程式可得:

i=0,candidate=0,nTimes=1;
i=1,a[1] != candidate,nTimes--,=0;
i=2,candidate=2,nTimes=1;
i=3,a[3] != candidate,nTimes--,=0;
i=4,candidate=1,nTimes=1;
如果是0,1,2,1,1,1的話,那麼i=5,a[5] == candidate,nTimes++,=2;......
解法五

再進一步,再次考慮這個問題本身的特殊性,可以採用概率方法,每次取兩個數出來比較,直到發現兩個相同的數。(解法五的發散思維來自Rogn dalao)

#include<cstdio>
#include<cstdlib>

int main()
{
    int a[] = {1, 2, 3, 4, 5, 6, 6, 6, 6, 6};
    int n = 8;
    int i = rand() % n;
    int j = rand() % n;
    while(a[i] != a[j])
    {
        i = rand() % n;
        j = rand() % n;
    }
    printf("%d\n", a[i]);
}

複雜度:

任意兩個數不同的概率

p約等於3/4,p^10= 0.056,也就是說10次還沒出結果的概率為0.056,已經比較小了。

有趣的是,n越大,該概率越小。

舉一反三

題意:找出出現次數剛好是一半的數字

分析:我們知道:有N個數,其中有一個數出現超過一半,要求線上性時間求出這個數。那麼,我的問題是,加強版水王:有N個數,其中有一個數剛好出現一半次數,要求線上性時間內求出這個數。

因為,很明顯,如果是剛好出現一半的話,如此例: 0,1,2,1 :

遍歷到0時,candidate為0,times為1
遍歷到1時,與candidate不同,times減為0
遍歷到2時,times為0,則candidate更新為2,times加1
遍歷到1時,與candidate不同,則times減為0;我們需要返回所儲存candidate(數字2)的下一個數字,即數字1。