前端資料結構與演算法細緻分析—上(複雜度分析)

Yxaw發表於2019-11-17

前端要不要學演算法?

這段時間一直在讀vue3原始碼以及C。時間擠不出來了,只能每天寫一點,接下來是一套演算法系列。當然只是針對前端同學,後端的可以按後退鍵了,因為這些對於後臺來說肯定是小case.
首先,寫這篇文章之前,先說一下前端要不要學習演算法。
先給上我的答案: 要,而且一定要。不知道你有沒有聽說: 程式=資料結構+演算法。有程式碼的地方就有資料結構。你的業務程式碼裡面全是全域性變數,全域性函式,那也叫有資料結構,你的資料結構是一盤散沙。再比如你的業務程式碼裡一些前端進行插入刪除操作非常非常頻繁的需求比較多,那麼你可能需要自己底層實現一個連結串列結構。
不排除一些前端的同學會說,我就做一個純靜態頁面的,都是form表單,不用管那麼多,有很多人都抱怨過(之前的我也是):面試造火箭,實際擰螺絲。那麼:你在別人眼中還是程式設計師嗎?你拿到的待遇還是程式設計師的待遇嗎?你未來的競爭力還是程式設計師所具備的抗風險能力嗎?
業務程式碼誰都會寫,再難的互動和計算我們都可以實現。假如你目前要做一個需要前端來處理一個很龐大的資料(比如公司讓你去開發一個h5小遊戲),裡面涉及到的查詢,刪除,插入,位移操作非常頻繁, 如果你這個時候還是中規中矩地去寫去實現,從前到後擼陣列,一個for解決不了就再來兩個,不知道跳錶,雜湊,二叉...甚至不知道連結串列是個什麼東西(咦?這個是戴的嗎?),那麼可能做出來你的上級會說,怎麼這麼卡?這麼慢?你回答:就這樣。
其實演算法本身也不是高深莫測,目的是去高效解決問題。比如之前做彩票業務,會有獎金計算的需求。若前端不擅長演算法,可能就會和服務端同學說:前端算不出來,把資料提交到後端,後端再把結果返回給前端吧。殊不知,這樣的做法既犧牲了使用者體驗,也加大了服務端的開銷導致公司成本的上升。所以我說,前端必須會演算法,但是作為一個純前端來說,你可以只做到了解常用演算法,會分析,會用。並不需要像演算法工程師那樣設計演算法架構,更不需要你手動實現一個紅黑樹。所以我接下來的幾篇文章只講一些基礎,如果再加上你的多多練習,那麼只是應付面試真的是足夠了。

演算法難不難?怎麼學?

其實我在最開始學的時候也覺得它非常的枯燥無味,甚至覺得很浪費時間。甚至對自己的智商產生了懷疑--!因為確實,它很抽象。沒有什麼權威的基本概念。但是我覺得其實真正的原因是沒有找到好的學習方法,沒有抓住學習的重點。實際上,資料結構和演算法的東西並不多,常用的、基礎的知識點更是屈指可數。只要掌握了正確的學習方法,學起來並沒有看上去那麼難,更不需要什麼高智商、厚底子。當然,大量的刷題確實能幫助你短時間提升一下,但是在這之前為何不先去了解一下底層的知識點?這樣的話你能更高效地去寫出驗證甚至優化你自己或者別人寫的程式碼

資料結構?演算法?

什麼是資料結構?什麼是演算法?首先,我明確地告訴你千萬不要死扣概念這樣會讓你陷入誤區(我連什麼是都不知道怎麼學?),但是真的,我覺得演算法這東西就是在學的時候慢慢理解起來的,你不需要死記硬背它是什麼,只需開始的時候去知道個大概,在後面慢慢理解。下面我先從廣義上來將一下幫助你理解:
比如你在網易公司,公司應該是將人先按類(部門)分組,然後每個組再可以細化出一個下標(第幾組)來儲存‘人’這種資料。當你要找一個人,比如你是研發部的,你需要去找一個銷售部的人。你會怎麼辦?從左上角找到右下角嗎?開個玩笑。。。你應該會先去找這個銷售部在哪個位置,然後這個人在銷售部的哪個組?這個組在哪一排?然後從這一排裡找到。不管是怎麼找,這些都可以理解為演算法。
如果細扣的話,佇列、棧、堆、二分查詢、動態規劃等。這些都是前人智慧的結晶,我們站在了巨人的肩膀上,可以直接拿來用。這些經典資料結構和演算法,都是前人從很多實際操作場景中抽象出來的,經過非常多的求證和檢驗,可以高效地幫助我們解決很多實際的開發問題。

資料結構和演算法的關係?

那麼這兩者到底有什麼關係?為什麼好多資料或者書乃至我今天講得都叫資料結構與演算法呢?
這是因為,資料結構和演算法是相互作用的。特定的場合需要特定的資料結構,這類資料結構會對應一種綜合來說很高效的演算法。也可以說資料結構為演算法服務。所以,我們無法忽視資料結構來學演算法,也無法忽視演算法來學資料結構。一個已知有序陣列裡面找到一個特定數字,當陣列非常大時,絕對是二分查詢最快。 一個長度為10億的有序陣列(假設有),二分查詢也就不超過31次。 我所說的就是,當你在這種業務需求,遇到這種資料結構的時候,你可以找到一個很適合的演算法來解決這類。再比如一些業務需要首先按一個屬性排序,相等的話要按另一個屬性繼續排。那麼你就可能優先選擇一個穩定地排序演算法。接下來我們進入正題

資料結構與演算法的核心?

首先,學好演算法最核心的最基本的要求,需要你知道一個概念-> 複雜度以及分析
不要小看它,它的是演算法的精髓以及好壞的判斷依據!我們剛開始都不是高手,只有反覆地去寫,寫完去耐心分析,在成長中一點點積累,才能學好演算法。方法+量變引起質變,相信你也能達到高手的行列。
高手們都是怎麼分析複雜度?----------憑感覺!

為什麼要會複雜度分析?

也許有人會把程式碼從上到下執行一遍,藉助console.time,timeend,再借助監控、統計,就能得到演算法執行的時間和佔用的記憶體大小。用實時說話!不比你去分析來得更準確?這種事後統計法很多場合確實能用。但是侷限性非常大。
1、很依賴環境,可能你在chrome上執行時間為n,但是你沒有測試其它機型,會產生很多變數
2、資料規模會限制你的準確性,時間複雜度為10000n的a演算法和一個0.5n^2的b演算法,你只測試了一些n為50,60?的情況,你就說b比a快!
因此我們要找到一個不用具體到某個資料,某種情況就能進行準確分析的方法-時間複雜度分析法、空間複雜度分析法~

大O複雜度表示法

演算法的執行時間效率,說白了就是程式碼的執行時間,你可以用time and timeend來計算出來,但是我上面說了,那樣的話你很難做到測試的公平我們看一段非常簡單的程式碼

function add(n) {
    let result = 100; // 1
    let i = 0; // 2
    while(i <=n) { // 3
        result += i++ // 4
    }
    return result
}

因為這裡不涉及到高階API的使用,所以我們假設每一行程式碼的執行時間(比如let result = 100, let i = 0)都為一個單元時間time_base
那麼當函式add執行時 1,2行程式碼共用了time_base + time_base的時間,而第三行用了n time_base, 第四行也是n time base。所以該演算法總共用了 time_base + time_base + n time_base + n * time_base = 2(n + 1)time_base時間
假設總時間為T(n)的話那麼 T(n) = 2(n + 1)time_base。我們可以清晰地看到總時間與變數n之間成正比。怎麼樣是不是基礎分析還是很簡單的?我再稍稍改變一下:

function add(n) {
    let result = 100; // 1
    let i = 0; // 2
    while(i <=n) { // 3
        let j = 0; // 4
        while(j<=n) { // 5
            result += i++ * j++ // 6
        }
    }
    return result
}    

第一行程式碼: time_base 第二行程式碼: time_base
第三行程式碼: time_base n 第四行程式碼: time_base n
第五行程式碼: time_base n n
第六行程式碼: time_base n n (先忽略i++,j++)
所以總的時間:
T(n) = 2time_base+2ntime_base+2n^2*time_base

T(n) = 2(n^2 + n + 1) * time_base

我們就得出這段程式碼的總時間與n成正比。即n越大,總時間一定也就越大。假設 f(n) = 2 (n^2 + n + 1)
代入上面公式 -> T(n) = f(n) * time_base
但是像我們這麼寫的話貌似有些囉嗦,因為我們知道time_base一定是個非0非負數。這樣,大O出世:
T(n) = O(f(n))
其中 f(n) 是所有程式碼的執行次數(n)的總和, Tn剛才說了是總得執行時間,特別注意的一點是大O並不是你想象中的time_base,它只是一個表明Tn與n的一個變化趨勢,你可以把它想象成為一個座標軸上的增長曲線,全稱 漸進時間複雜度 所以上個例子中可以表示
T(n) = O (2(n^2 + n + 1)), 我們知道當n趨近於無窮大的時候,n^2>>n>>常熟C,2(n^2 + n + 1) 無限接近 2(n^2),n無限大時,這裡的2對n^2的增長趨勢產生的影響越來越小,所以最後我們可以表示成O(n^2),第一個例子中可以表示成O(n).

如何做複雜度分析?

我們知道了大O描述的是一種變化趨勢,其中fn中的常量,比例係數以及低階都可以在n趨於無窮大的時候忽視,所以我們可以得出第一個複雜度分析的常用方法 ——

找出迴圈中執行次數最多的部分即可

拿第一個例子我們知道第三四行執行最多為n,所以,複雜度為O(n),第二個例子第5,6行執行最多,所以為 O(n^2)
第二個,加法法則:總複雜度等於量級最大的那段程式碼的複雜度
看下面一段程式碼:

function ex(n) {
    let r1 = 0, r2 = 0, r3 = 0;
    let k1 = 0, k2 = 0, k3 = 0, j3 = 0;
    while (k1++ <= 100000) {
        r1 += k1
    }
    while (k2++ <= n) {
        r2 += k2
    }
    while (k3++ <= n) {
        j3 = 0;
        while (k1++ <= 100000) {
            r3 += k3 * j3
        }
    }
    return sum;
}

所以Tn為三個迴圈加起來的時間加上一個常數,後面的與第一個方法的分析類似,不再多囉嗦,只是特地強調一下 即使 第一個迴圈中的100000並不會影響增長趨勢,這個增長趨勢你可以更簡單地理解一下,當資料規模n增大的時候,線性切角是否變化。
總結: 假設 T1(n) = O(f(n)), T2(n) = O(h(n)), 若 T(n) = T1(n) + T2(n)。則 T(n) = O(f(n)) + O(h(n)) = max(O(f(n)),O(h(n))) = O(max(f(n), h(n)))

乘法法則:巢狀程式碼的複雜度等於巢狀內外程式碼複雜度的乘積
這個就不多說了,上面的迴圈巢狀就是一個例子,總結:
如果T1(n)=O(f(n)),T2(n)=O(h(n));那麼T(n)=T1(n)T2(n)=O(f(n))O(h(n))=O(f(n)*h(n))

常見的時間複雜度

複雜度量級:
常量階: O1
對數階: O(logn)
線性階: O(n)
線性對數階: O(n*logn)
k次方階: O(n^k)
指數階: O(2^n)
階乘階: O(n!)
其中的2^n和n!
時間複雜度錯略的分為兩類:多項式量級和非多項式量級. 其中, 非多項式量級只有兩個: O(2^n)和O(n!)我們把時間複雜度為非多項式量級的演算法問題叫做NP問題(Non-Deterministic Polynomial**, 非確定多項式).
多項量級就是說這個時間複雜度是由n作為底數的O(n) O(nlogn) 
非多項量級就是n不是作為底數!
其中非多項式量級的不做分析,這類屬於爆炸增長,你自己可以算一下。當n=30的時候,就需要運算2*10000...(32個0)的時間單元了,一般能寫出這種演算法的那絕對是上面有人。
對於上面的logn,剛接觸演算法的肯定有些陌生,我舉個例子


function ex(n) {
    let i = 1;
    while(i < n) {
        i *= 2
    }
}

我們來實際計算一下,每次迴圈i都在本身的基礎上乘以2,直到>n;
所以 1 , 2 , 4 , 8 , 16 , .... m= n 即每一次的執行過程是
2^0 , 2^1 , 2^2 , 2^3 , ... , 2^m = n, 整體執行次數其實就是m(當執行了m次後,i > n,迴圈結束),所以m = logn.
那麼

function ex2(n) {
    let i = 1;
    while(i < n) {
        i *= 5
    }
}

我們根據上面的推導可以得出m = log5n(以5為底n的對數),那麼這個時間複雜度就是log5n吧。
其中log5n = log₅2 * log₂n。我們知道係數是可以省略的在大O複雜度表示法中。所以忽略底數後,可以表示為logn。

那麼nlogn呢?我們根據乘法法則可以很容易想到

function ex3(n) {
    let i = 1;
    let j = 1;
    while(i < n) {
        j = 1;
        while(j < n) {
            j *= 2
        }
    }
}

如何分析不再囉嗦。至於空間複雜度,相比於時間,很簡單,也不做介紹。有興趣的可以去自己查閱一下資料。回到為什麼我們要做複雜度分析的問題。你可能會有一個自己的想法。 這些分析與寄生環境無關,雖然它也只是粗略地來表明一個演算法的效率,因為你不能輸O(1)就一定比O(n^2)好,在效能極致優化的情況下,我們甚至還需要針對n的資料規模,分段設計不同的演算法, 舉個後面我會來教大家的一個例子: 我們都知道sort這個陣列API吧?但是你有沒有了解它的底層實現?拿V8引擎來說
sort.jpg

我們看實現和註釋可以知道,陣列長度小於等於 22 的用插入排序,其它的用快速排序,想深入研究的可以看下array原始碼
只有有了這些基礎,你才能對不同的演算法有了一個“效率”上的感性認識。以至於以後可以靈活地去運用,寫出更高效地程式,使你的頁面互動更快更加流暢!

時間複雜度的最好情況和最壞情況

function add(n) {
    let result = 100; // 1
    let i = 0; // 2
    while(i <=n) { // 3
        result += i++ // 4
    }
    return result
}

我們知道上面的程式碼很容易分析,因為不論n多大,你都要i從1開始增長到n,這個過程我們是確定的,可以預知的。但是我說一種情況,只要是做過前端開發的同學一定都熟悉這種業務場景,後臺返回一個陣列,前端判斷是否有一個特定的標識,有就繼續下一步操作,並返回,沒有就提示失敗,不用indexof, includes。我們可能會這樣寫(這個陣列長度假如無序,未知)

function findViptag(arr, target) {
    let l = arr.length - 1;
    let findResult = false;
    for (let i = 0; i < l; i++) {
        if (arri[i] === target) {
            findResult = true
        }
    }
    return findResult
}

我們可以一眼看出這個演算法的時間複雜度為O(n),但是這樣未免太浪費效能,因為我們關係的結果(有、無)。所以只要我們拿到結果之後,我們的目的就達到了!所以:

function findViptag(arr, target) {
    let l = arr.length - 1;
    let findResult = false;
    for (let i = 0; i < l; i++) {
        if (arri[i] === target) {
            return findResult = true
        }
    }
    return findResult
}

這樣,當我們拿到結果之後,此段程式碼終止,那麼這段程式碼的時間複雜度?顯然我之前所說的在這裡好像看不出來,但是我們知道最好情況其實就是我們要查詢的結果在陣列的第一個位置,這樣的話我們只需要迴圈開始的第一次就結束了,這種情況就是最好情況時間複雜度。那麼最快情況呢?顯然,要找的東西不在陣列裡或者是在陣列的最後一個位置,那麼我們就需要遍歷整個陣列。這種情況就是最壞情況時間複雜度,那麼該演算法的時間複雜度到底是多少?是不是分情況?這時候,我們來引入另一個概念平均情況時間複雜度

平均情況時間複雜度

我們知道在一個非常龐大的陣列中,你所要找的元素剛好出現在第一個位置或者是最後一個位置的情況並不多。
平均時間怎麼算?
假設我們要找的元素出現在陣列中還是沒有出現的概率相等,且出現在陣列的任意一位置的可能性也都相同,那麼我們就可以求出,我們平均要遍歷多少次,假設陣列的長度為n,所以我們共有可能遍歷的情況為1,2,3,4,5,...,n - 1, n, n。注意我的最後寫了兩個n是因為該元素沒有在陣列中的時候你需要遍歷n次,在最後一個位置的時候也需要n次,那麼一共就這n + 1中可能性,所以平均為 (1 + 2 + ... + n - 1 + n + n)/ (n + 1) = ((1 + n) n + n)/2(n + 1) = (n^2 + 2n) /2(n + 1),根據我上邊講得,忽略係數,常熟,取最高階,這個演算法的時間複雜度為O(n)。但是這樣算的話可能不公平,那麼我們再從概率的角度再推導一遍,因為,假設條件不變,那麼我們知道 這個數要麼出現在陣列裡要麼不在, 所以 出現在每一個位置的概率為 1/2 1/n = 1 / 2n。那麼需要遍歷1,2,3,4,5,6...n次的概率為 (1/2n) 1 + (1/2n) 2 + ... + (1/2n) n + 1/2 n = (3n + 1) / 4 時間複雜度也為O(n).
你們應該瞭解概率中的期望,這種其實就是期望值,也是加權平均值,同時這種複雜度的推導也叫加權平均(或期望)時間複雜度
其實大多數情況下我們是不需要進行這種推導的。只有在各個情況出現的概率有著明顯的傾斜或者做追求到係數甚至常量級別的效能分析時才會考慮進去。

均攤時間複雜度

這種複雜度分析對於前端來說一般不重要,可以簡單瞭解一下,不明白也沒事,假設我們由於業務需要要維護一個陣列,它的長度是定的,為n,我們要像裡面新增資料:

... k => new Array(n)
...............
function insert(d) {
    // 如果陣列長度滿了,我們希望將現有陣列做一下整合,比如
    // 對比一下資料,或者做個求和,求積?都可以,總之要遍歷
    if (// 陣列長度滿 ) {
        // 遍歷做處理
        ...
        ...
        // 然後將陣列長度擴容 * 2
        ...
        k.length = 2 * k.length
    } else {
        // 直接插入空位置
        ...
    }
}

這樣當我們知道,當插入的時候,只是簡單的一個按下標隨機訪問地插入操作,時間複雜度O(1),但是當容量不夠的時候,我們需要遍歷整合,時間複雜度為O(n),那麼到底時間複雜度是多少呢?這種情況,其實我們沒有必要像剛才那樣求平均複雜度那麼麻煩,簡答的分析一下,與剛才的求平均時間複雜度作比較,上個例子中,極少地概率會出現O(1)的情況,即(所找元素正好為第一個),但是本例中n-1量級資料內都是O(1),在第n次為O(n),即n次操作我們需要O(n) + n(n - 1) * O(1)的複雜度,平均下來也是O(1).可以這樣說,耗時最長的那個操作被前面大部分操作均攤了下來。
不懂沒關係,這段可以跳過。繼續,有插入就必然伴隨著刪除,那麼當刪除的時候我們假設都是從後面開始刪除,那麼時間複雜度也是O(1),但是當陣列中空元素佔據絕大多數時,即陣列中的內容很少,假設這個時候我們的陣列已經擴容到了n * 4.這個時候,為了節省空間,我們可以進行縮容,假設縮容那次我們也要所遍歷整合,即前面(n-1)次操作耗費時間總和為(n-1),第n次操作耗費時間為(n+1),n為對陣列進行縮容操作耗時,1為刪除這個元素耗時。所以均攤來看每次耗費時間仍然是2,時間複雜度為O(1)。
那麼這樣設計的話就完美了嗎?沒有,你又可能會遇到複雜度震盪

複雜度震盪

假設,縮容後陣列容量退化為n,這時候又插入一個元素,還需要擴容,也就是這兩次的時間複雜度是2 (O(n) + 1)。如果我們插入刪除的平衡點剛好卡再這裡,那麼這種演算法的時間複雜度就由O(1)退化到了O(n).那怎麼辦呢?其實只需我們擴容和縮容的分界點不同就可以了,比如我們可以在n的時候擴容到2n容量,在資料量剩餘不足1/8 n時進行縮容。這樣即使有插入有刪除我們也都是O(1)級別的操作。

加餐

上面我講了一些基礎的演算法複雜度分析,接下來,增加一點難度。
我們應該都知道遞迴,尤其是當你設計一套演算法的時候,很大概率地會使用遞迴,那麼如何求解遞迴演算法的時間複雜度呢?
我這裡拿一個歸併排序的例子來講(後面寫排序的時候我會細緻地給你分析)。不知道歸併排序不要緊,程式碼你應該能看得懂。
排序相信大家耳熟能詳,面試問的更是多,下個專題我會非常細緻地把基本的排序用法以及它的原理進行細緻地講解,至少能足夠讓你應付一些基礎面試。
歸併排序的思想是分合,如下圖:
歸併.png

這張圖你應該能一目瞭然,用javascript可以這樣實現歸併:

function sort(arr) {
    return divide(arr, 0, arr.length - 1)
}

function divide(nowArray, start, end) {
    if (start >= end) {
        return [ nowArray[start] ];
    }
    let middle = Math.floor((start + end) / 2);
    let left = divide(nowArray, start, middle);
    let right = divide(nowArray, middle + 1, end);
    return merge(left, right)
}

function merge(left, right) {
    let arr = [];
    let pointer = 0, lindex = 0, rindex = 0;
    let l = left.length;
    let r = right.length;
    while (lindex !== l && rindex !== r) {
        if (left[lindex] < right[rindex]) {
            arr.push(left[lindex++])
        } else {
            arr.push(right[rindex++])
        }
    }
    // 說明left有剩餘
    if (l !== lindex) {
        while (lindex !== l) {
            arr.push(left[lindex++])
        }
    } else {
        while (rindex !== r) {
            arr.push(right[rindex++])
        }
    }
    return arr;
}

sort(arr)

具體分析我在下一專題來講,我們先來分析它的複雜度O,
我們設總時間為T(n),其中我們知道,divide中有遞迴,宣告變數所花費的時間我們忽略,其實總的時間T(n)分解之後主要為left遞迴和right遞迴以及merge left和right,其實我們可以這麼理解,T(n)分為了兩個子問題(程式)T(left)和T(right)以及merge left和right
所以 T(n) = T(left) + T(right) + merge(left, right)
我們設merge(left, right) = C;
T(n) = T(left) + T(right) + C;
因為我們是從中間分開,所以若總時間為T(n)那麼兩個相等的子陣列排序並merge的時間為T(n/2),我們知道最後merge兩個子陣列的時間複雜度O(n)[不用深入考慮遞迴,我們只看最後的left和right]
所以

T(n) = 2T(n/2) + n
     = 2(2T(n/4) + n/2) + n = 4T(n/4) + 2n
     = 4(2T(n/8) + n/4) + 2n = 8T(n/8) + 3n
     = 8(2T(n/16) + n/8) + 3n = 16T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
整理一下可以得到 
T(n) = 2^kT(n/2^k)+kn
當T(n/2^k)=T(1)即n/2^k = 1;所以k=log2n
代入可得T(n)=n+nlog2n = n(logn + 1)

所以時間複雜度就是O(nlogn)
如果看不懂也沒有關係,我後面還會再細緻地寫,其實這種問題用二叉樹來分析會更簡單,後面我也會介紹怎麼用。

結語

還是那句話想學好演算法,最基本的複雜度分析一定要掌握,一定要會,這是基礎,也是你能看出,評測出你寫或者別人寫的演算法的效率。文章可能有錯別字,請大家見諒,大家加油,下期見!

相關文章