1. 程式人生 > >流式資料中的數學統計量計算

流式資料中的數學統計量計算

在科技飛速發展的今天,每天都會產生大量新資料,例如銀行交易記錄,衛星飛行記錄,網頁點選資訊,使用者日誌等。為了充分利用這些資料,我們需要對資料進行分析。在資料分析領域,很重要的一塊內容是流式資料分析。流式資料,也即資料是實時到達的,無法一次性獲得所有資料。通常情況下我們需要對其進行分批處理或者以滑動視窗的形式進行處理。分批處理也即每次處理的資料之間沒有交集,此時需要考慮的問題是吞吐量和批處理的大小。滑動視窗計算表示處理的資料每次向前移N個單位,N小於要處理資料的長度。例如,在語音識別中,每個包處理大約25ms的音訊資料,然後以步幅10ms向前移動處理下一個包的資料。語音識別就是一個典型的流式資料通過滑動視窗方式進行處理的例子。在本文中,我們關注N=1的情況,也即每次處理完一個包之後,向前移動一個單位繼續處理下一個包,如下圖所示。
圖1 基於滑動視窗的流式資料處理示例


我們主要關注幾個常見的數學統計量:最小(大)值、平均值和中位數。事實上,只要知道了最大值和最小值的求法,很容易計算極差;知道了平均值的求法,就可以很容易地計算方差和標準差。針對上述統計量的計算都有一個naïve演算法,也即不考慮前後兩個包之間資料重疊,將每個包看成獨立的,對每一個包分別計算上述統計量。如果總資料長度為n,每個包的長度為k,則計算上述統計量的複雜度為O(nk)(針對給定陣列求中位數的問題,存在複雜度O(k)的演算法,實現方法是基於快排進行改進,網上資料很多在此不再做介紹)。我們嘗試在naïve演算法的基礎上降低每個統計量的計算複雜度,下面開始正式的介紹。
1. 最小(大)值

這是一個經典問題,通常被稱為滑動極值問題。問題描述:給定一個長度為n的數列a0,a1,...,an1和一個整數k,求數列bi=min{ai,ai+1,...,ai+k1}(i=0,1,...,nk)
通過使用單調佇列可以在O(n)的時間內解決。單調佇列維護數列的下標,佇列內的元素滿足:
設單調佇列從頭部開始的元素值為xi,則xi<xi+1axi<axi+1
簡單來說單調佇列就是下標對應的元素是嚴格遞增的順序(當然在實際應用過程中,可能不嚴格單調,也可能是遞減的順序)。
考慮以ai結尾的k個元素,求bik+1。假定單調遞增佇列中維護了ai之前的k-1個元素相關的最小值下標,為了求b
ik+1
,我們需要將ai和單調佇列中元素進行比較。當佇列末尾的元素j滿足ajai,則不斷取出末尾元素,直到佇列為空或者aj<aiai不僅會影響bik+1的計算,也會影響後續k-1個bi的計算。如果ai是這一段的最小值,則它在單調佇列中就不會被刪除,進而可以用O(1)的時間求單個bi
當刪除單調佇列的元素時,需要判斷頭部元素是否還需要。如果已經脫離計算bi的範圍,則可以刪除頭部元素。求單個bi的值,只需要返回單調佇列的頭部元素即可。均攤複雜度為O(n)。求最小值的程式碼如下:

#define MAX_N 100000

static int a[MAX_N];
static int b[MAX_N];
static int deque[MAX_N];

void range_min(int n,int k)
{
    int s=0,t=0;//單調佇列的頭和尾指標

    for (int i=0;i<n;i++)
    {
        //在單調佇列的末尾加入i
        while (s<t&&a[deque[t-1]]>=a[i]) t--;//維護嚴格的單調遞增佇列
        deque[t++]=i;

        if (i-k+1>=0)
        {
            b[i-k+1]=a[deque[s]];
        }

        //從單調佇列頭部刪除元素
        if (deque[s]==i-k+1)
        {
            s++;
        }
    }
}

求滑動最大值只需要將大於等於號改為小於等於號即可,維護一個單調遞減佇列。通過使用單調佇列,流式資料中極值計算的複雜度可以由O(nk)降為O(n),當每個包的長度很大時,演算法的優化效果會非常明顯。滑動極值問題具有很廣泛的應用,希望大家能知道這個優雅的解法。單調佇列還有很多其他應用場景,比如解決《leetcode之Largest Rectangle in Histogram》。此外,在一些動態規劃問題中,它也可以用來降低時間複雜度。
2. 平均值
滑動平均值的計算比較容易優化,我們需要做的就是維護區間內元素的和,除以區間元素個數k即是區間平均值。當計算下一個區間的平均值時,我們先將上一個區間的和減掉上一個區間第一個元素的值,然後加上當前區間最後一個元素的值,然後除以k即是當前區間的平均值。求區間平均值的程式碼如下:

#define MAX_N 100000

static int a[MAX_N];
static int b[MAX_N];

void range_mean(int n,int k)
{
    int sum=0;
    for (int i=0;i<n;i++)
    {
        sum+=a[i];
        if(i-k+1>=0)
        {
            b[i-k+1]=sum/k;
            sum-=a[i-k+1];
        }
    }
}

很明顯可以看出上述程式碼的複雜度為O(n)。求方差可以採用類似的思路,在求和的同時也求一個平方和,之後採用方差的平方和公式即可求得方差。
3. 中位數
中位數是一個非常重要的指標,在很多應用中都會用到,但是相比前兩個統計量,中位數的優化要麻煩很多。
在介紹基於滑動視窗的中位數計算之前,我們先看一個類似的問題:也是流式資料求中位數,但是每次都求前面所有資料的中位數。該問題也很經典,出現在劍指offer一書中,具體解法可參考《資料流中的中位數》。簡單來說,就是構造一個最大堆和一個最小堆,最大堆的元素都小於最小堆中的元素,而且最小堆中的元素個數至多比最大堆中的元素個數多1。每次來新元素的時候,根據當前兩個堆的元素個數來決定往哪個堆插入元素,在插入的同時保證上面所說的兩個前提。插入複雜度是O(log n),查詢複雜度是O(1)。
基於滑動視窗的中位數計算解法和上面的問題類似,也需要構造一個最大堆和最小堆,同時也滿足上面的兩個條件,區別就在於我們每次計算完一次中位數之後,都需要從堆中刪除一個最老的元素。可以通過和中位數比較來確定刪除哪個堆中的元素。通常的堆操作一般是插入和刪除堆頂元素,在此需要實現一個函式可以刪除任意位置的堆元素,同時保證堆的結構不被破壞,這不是一個困難的問題,實現和刪除堆頂元素類似。如果資料是以陣列形式一次給定,最老的元素可以通過訪問原陣列獲得,如果流式資料一次只給定一個數據,我們可以通過迴圈佇列儲存最近的k個元素來獲得最老的元素。程式碼實現可以參考部落格《找滑動視窗的中位數》,在此就不給出詳細程式碼。每來一個數據都需要執行一次插入和刪除,複雜度是O(log k),所以針對流式資料的中位數問題演算法複雜度是O(nlogk),相比樸素演算法也有明顯地提升。