面試常見的四種算法思想,全在這裏了

我是架構精進之路,點擊上方「關注」,堅持天天爲你分享技術乾貨,私信我回復「01」,送你一份程序員成長進階大禮包。php

 

面試常見的四種算法思想,全在這裏了,今天帶你一文了解。程序員

一、貪心

貪心算法有不少經典的應用,好比霍夫曼編碼(Huffman Coding)、Prim 和 Kruskal 最小生成樹算法、還有 Dijkstra 單源最短路徑算法。面試

解決問題步驟

第一步,當咱們看到這類問題的時候,首先要聯想到貪心算法:針對一組數據,咱們定義了限制值和指望值,但願從中選出幾個數據,在知足限制值的狀況下,指望值最大。
第二步,咱們嘗試看下這個問題是否能夠用貪心算法解決:每次選擇當前狀況下,在對限制值同等貢獻量的狀況下,對指望值貢獻最大的數據。
第三步,咱們舉幾個例子看下貪心算法產生的結果是不是最優的。
正則表達式

例子1

咱們有 m 個糖果和 n 個孩子。咱們如今要把糖果分給這些孩子吃,可是糖果少,孩子多(m<n),因此糖果只能分配給一部分孩子。每一個糖果的大小不等,這 m 個糖果的大小分別是 s1,s2,s3,……,sm。除此以外,每一個孩子對糖果大小的需求也是不同的,只有糖果的大小大於等於孩子的對糖果大小的需求的時候,孩子才獲得知足。假設這 n 個孩子對糖果大小的需求分別是 g1,g2,g3,……,gn。問題是,如何分配糖果,能儘量知足最多數量的孩子?算法

咱們能夠把這個問題抽象成,從 n 個孩子中,抽取一部分孩子分配糖果,讓知足的孩子的個數(指望值)是最大的。這個問題的限制值就是糖果個數 m。咱們如今來看看如何用貪心算法來解決。對於一個孩子來講,若是小的糖果能夠知足,咱們就不必用更大的糖果,這樣更大的就能夠留給其餘對糖果大小需求更大的孩子。另外一方面,對糖果大小需求小的孩子更容易被知足,因此,咱們能夠從需求小的孩子開始分配糖果。由於知足一個需求大的孩子跟知足一個需求小的孩子,對咱們指望值的貢獻是同樣的。數據庫

咱們每次從剩下的孩子中,找出對糖果大小需求最小的,而後發給他剩下的糖果中能知足他的最小的糖果,這樣獲得的分配方案,也就是知足的孩子個數最多的方案編程

例子2

這個問題在咱們的平常生活中更加廣泛。假設咱們有 1 元、2 元、5 元、10 元、20 元、50 元、100 元這些面額的紙幣,它們的張數分別是 c一、c二、c五、c十、c20、c50、c100。咱們如今要用這些錢來支付 K 元,最少要用多少張紙幣呢?數組

在生活中,咱們確定是先用面值最大的來支付,若是不夠,就繼續用更小一點面值的,以此類推,最後剩下的用 1 元來補齊。
在貢獻相同指望值(紙幣數目)的狀況下,咱們但願多貢獻點金額,這樣就可讓紙幣數更少,這就是一種貪心算法的解決思路。直覺告訴咱們,這種處理方法就是最好的。
微信

例子3

假設咱們有 n 個區間,區間的起始端點和結束端點分別是[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。咱們從這 n 個區間中選出一部分區間,這部分區間知足兩兩不相交(端點相交的狀況不算相交),最多能選出多少個區間呢?多線程

好比任務調度、教師排課等等問題。
這個問題的解決思路是這樣的:咱們假設這 n 個區間中最左端點是 lmin,最右端點是 rmax。這個問題就至關於,咱們選擇幾個不相交的區間,從左到右將[lmin, rmax]覆蓋上。咱們按照起始端點從小到大的順序對這 n 個區間排序。
咱們每次選擇的時候,左端點跟前面的已經覆蓋的區間不重合的,右端點又儘可能小的,這樣可讓剩下的未覆蓋區間儘量的大,就能夠放置更多的區間。這實際上就是一種貪心的選擇方法。

用貪心算法實現霍夫曼編碼

假設我有一個包含 1000 個字符的文件,每一個字符佔 1 個 byte(1byte=8bits),存儲這 1000 個字符就一共須要 8000bits,那有沒有更加節省空間的存儲方式呢?

假設咱們經過統計分析發現,這 1000 個字符中只包含 6 種不一樣字符,假設它們分別是 a、b、c、d、e、f。而 3 個二進制位(bit)就能夠表示 8 個不一樣的字符,因此,爲了儘可能減小存儲空間,每一個字符咱們用 3 個二進制位來表示。那存儲這 1000 個字符只須要 3000bits 就能夠了,比原來的存儲方式節省了不少空間。不過,還有沒有更加節省空間的存儲方式呢?

a(000)、b(001)、c(010)、d(011)、e(100)、f(101)

 

霍夫曼編碼就要登場了。霍夫曼編碼是一種十分有效的編碼方法,普遍用於數據壓縮中,其壓縮率一般在 20%~90% 之間。如何給不一樣頻率的字符選擇不一樣長度的編碼呢?根據貪心的思想,咱們能夠把出現頻率比較多的字符,用稍微短一些的編碼;出現頻率比較少的字符,用稍微長一些的編碼。

可是,霍夫曼編碼是不等長的,每次應該讀取 1 位仍是 2 位、3 等等來解壓縮呢?這個問題就致使霍夫曼編碼解壓縮起來比較複雜。爲了不解壓縮過程當中的歧義,霍夫曼編碼要求各個字符的編碼之間,不會出現某個編碼是另外一個編碼前綴的狀況
假設這 6 個字符出現的頻率從高到低依次是 a、b、c、d、e、f。咱們把它們編碼下面這個樣子,任何一個字符的編碼都不是另外一個的前綴,在解壓縮的時候,咱們每次會讀取儘量長的可解壓的二進制串,因此在解壓縮的時候也不會歧義。通過這種編碼壓縮以後,這 1000 個字符只須要 2100bits 就能夠了。

咱們把每一個字符看做一個節點,而且附帶着把頻率放到優先級隊列中。咱們從隊列中取出頻率最小的兩個節點 A、B,而後新建一個節點 C,把頻率設置爲兩個節點的頻率之和,並把這個新節點 C 做爲節點 A、B 的父節點。最後再把 C 節點放入到優先級隊列中。重複這個過程,直到隊列中沒有數據。

二、分治

分治算法(divide and conquer)的核心思想其實就是四個字,分而治之 ,也就是將原問題劃分紅 n 個規模較小,而且結構與原問題類似的子問題,遞歸地解決這些子問題,而後再合併其結果,就獲得原問題的解。
分治算法是一種處理問題的思想,遞歸是一種編程技巧。實際上,分治算法通常都比較適合用遞歸來實現。分治算法的遞歸實現中,每一層遞歸都會涉及這樣三個操做:

  • 分解:將原問題分解成一系列子問題;

  • 解決:遞歸地求解各個子問題,若子問題足夠小,則直接求解;

  • 合併:將子問題的結果合併成原問題。

分治算法能解決的問題,通常須要知足下面這幾個條件:

  • 原問題與分解成的小問題具備相同的模式;

  • 原問題分解成的子問題能夠獨立求解,子問題之間沒有相關性,這一點是分治算法跟動態規劃的明顯區別,等咱們講到動態規劃的時候,會詳細對比這兩種算法;

  • 具備分解終止條件,也就是說,當問題足夠小時,能夠直接求解;

  • 能夠將子問題合併成原問題,而這個合併操做的複雜度不能過高,不然就起不到減少算法整體複雜度的效果了。

分治算法應用舉例分析

  1. 假設有n個數據,指望數據從小到大排序,那徹底有序的數據的有序度就是n(n-1)/2。逆序度等於0;相反,倒序排序的數據的有序度就是0,逆序度是n(n-1)/2。除了這兩種極端狀況外,咱們經過計算有序對或逆序對的個數,來表示數據的有序度或逆序度。

  2. 如今問:如何編程求出數組中的數據有序對個數或逆序對個數?

  3. 最簡單的辦法:拿每一個數字和他後面的數字比較,看有幾個比它小。將比它小的數字個數記做k,經過這樣的方式,把每一個數字都考察一遍後,對每一個數字對應的k值求和,最後獲得的總和就是逆序對個數。但時間複雜度是O(n^2)。

  4. 用分治算法,套用分治的思想,將書中分紅先後兩半A1和A2,分別二者中的逆序對數,而後在計算A1和A2之間的逆序對個數k3。那整個數組的逆序對個數就是k1+k2+k3。

  5. 要快速計算出兩個子問題A1和A2之間的逆序對個數須要藉助歸併排序算法。

歸併排序算法有個很是關鍵的操做,即將兩個有序的小數組,合併成一個有序的數組。實際上,在合併的過程當中,就能夠計算這兩個小數組的逆序對個數。每次合併操做,都計算逆序對個數,把這些計算出來的逆序對個數求和,就是這個數組的逆序對個數。

求兩個數的最大共因子(歐幾里得算法)



<?php/** * 分治算法 * 邏輯: * (1) 找出基線條件,這種條件必須儘量簡單。 * (2) 不斷將問題分解(或者說縮小規模),直到符合基線條件 */class dc { /** * 最大公因子(歐幾里得算法) * 能夠引伸到-客廳長寬固定,問選擇多大的正方形地磚,能夠正好鋪滿客廳 * @param $a * @param $b * @return mixed */    function greatestCommonFactor($a, $b) { if ($a < $b) { $c = $a; $a = $b; $b = $c; } $c = $a % $b; if ($c == 0) { return $b; } else { $n = $this->greatestCommonFactor($c, $b); } return $n; }}dd((new dc())->greatestCommonFactor(160, 56));

分治思想在海量數據處理中的應用

  1. 假設,給10GB的訂單文件按照金額排序這樣一個需求,看似是一個簡單的排序問題,可是由於數據量大,有10GB,而咱們的機器的內存可能只有2,3GB這樣子,沒法一次性加載到內存,也就沒法經過單純地使用快排,歸併等基礎算法來解決。

  2. 要解決這種數據量大到內裝不下的問題,咱們就能夠利用分治的思想,將海量的數據集合根據某種方法,劃分爲幾個小的數據集合,每一個小的數據集合單獨加載到內存來解決,而後在將小數據集合合併成大數據集合,實際上利用這種分治的處理思路,不只能克服內存的限制,還能利用多線程或者多機處理,加快處理的速度。

舉例分析

採用分治思想的算法包括:

  1. 快速排序算法

  2. 合併排序算法

  3. 桶排序算法

  4. 基數排序算法

  5. 二分查找算法

  6. 利用遞歸樹求解算法複雜度的思想

  7. 分佈式數據庫利用分片技術作數據處理

  8. MapReduce模型處理思想

三、回溯

深度優先搜索算法利用的是回溯算法思想。這個算法思想很是簡單,可是應用卻很是普遍。它除了用來指導像深度優先搜索這種經典的算法設計以外,還能夠用在不少實際的軟件開發場景中,好比正則表達式匹配、編譯原理中的語法分析等。不少經典的數學問題均可以用回溯算法解決,好比數獨、八皇后、0-1 揹包、圖的着色、旅行商問題、全排列等等。
回溯的處理思想,有點相似枚舉搜索。咱們枚舉全部的解,找到知足指望的解。爲了有規律地枚舉全部可能的解,避免遺漏和重複,咱們把問題求解的過程分爲多個階段。每一個階段,咱們都會面對一個岔路口,咱們先隨意選一條路走,當發現這條路走不通的時候(不符合指望的解),就回退到上一個岔路口,另選一種走法繼續走。

八皇后問題







<?php/** * 八皇后問題 * 有一個 8x8 的棋盤,但願往裏放 8 個棋子(皇后),每一個棋子所在的行、列、對角線都不能有另外一個棋子 * Class queen */class queen { //全局或成員變量,下標表示行,值表示queen存儲在哪一列 private $result = [];    public function cal8queens(int $row) { // 8個棋子都放置好了,打印結果 if ($row == 8) { $this->printQueens(); // 8行棋子都放好了,已經無法再往下遞歸了,因此就return return; } // 每一行都有8中放法 for ($column = 0; $column < 8; ++$column) { // 有些放法不知足要求 if ($this->isOk($row, $column)) { // 第row行的棋子放到了column列 $this->result[$row] = $column; // 考察下一行 $this->cal8queens($row + 1); } } }    private function isOk(int $row, int $column) { $leftUp = $column - 1; $rightUp = $column + 1; // 逐行往上考察每一行 for ($i = $row - 1; $i >= 0; --$i) { // 第i行的column列有棋子嗎 if ($this->result[$i] == $column) { return false; } // 考察左上對角線:第i行leftUp列有棋子嗎 if ($leftUp >= 0 && $this->result[$i] == $leftUp) { return false; } // 考察右上對角線:第i行rightUp列有棋子嗎 if ($rightUp < 8 && $this->result[$i] == $rightUp) { return false; } --$leftUp; ++$rightUp; } return true; } // 打印出一個二維矩陣    private function printQueens() { for ($row = 0; $row < 8; ++$row) { for ($column = 0; $column < 8; ++$column) { echo $this->result[$row] == $column ? "Q " : "* "; } echo "\n"; } echo "\n"; }}(new Queen())->cal8queens(0);

0-1 揹包問題

這個問題的經典解法是動態規劃,可是也可使用回溯算法,實現簡單,可是沒有那麼高效。

0-1 揹包問題有不少變體,這裏介紹一種比較基礎的。有一個揹包,揹包總的承載重量是 Wkg。如今咱們有 n 個物品,每一個物品的重量不等,而且不可分割。咱們如今指望選擇幾件物品,裝載到揹包中。在不超過揹包所能裝載重量的前提下,如何讓揹包中物品的總重量最大?
咱們能夠把物品依次排列,整個問題就分解爲了 n 個階段,每一個階段對應一個物品怎麼選擇。先對第一個物品進行處理,選擇裝進去或者不裝進去,而後再遞歸地處理剩下的物品。




<?phpclass backpack { public $maxW; // cw表示當前已經裝進去的物品的重量和;i表示考察到哪一個物品了; // w揹包重量;items表示每一個物品的重量;itemCount表示物品個數 // 假設揹包可承受重量100,物品個數10,物品重量存儲在數組a中,那能夠這樣調用函數: // f(0, 0, a, 10, 100)    public function f(int $i, int $cw, array $items, int $itemCount, int $w) { // cw==w表示裝滿了;i==n表示已經考察完全部的物品 if ($cw == $w || $i == $itemCount) { if ($cw > $this->maxW) { $this->maxW = $cw; } return; } // 遞歸調用表示不選擇當前物品,直接考慮下一個(第 i+1 個),故 cw 不更新 $this->f($i + 1, $cw, $items, $itemCount, $w); if ($cw + $items[$i] <= $w) { // 表示選擇了當前物品,故考慮下一個時,cw 經過入參更新爲 cw + items[i] $this->f($i + 1, $cw + $items[$i], $items, $itemCount, $w); } }}

正則表達式

正則表達式中,最重要的就是通配符,通配符結合在一塊兒,能夠表達很是豐富的語義。爲了方便講解,我假設正則表達式中只包含「*」和「?」這兩種通配符,而且對這兩個通配符的語義稍微作些改變,其中,「*」匹配任意多個(大於等於 0 個)任意字符,「?」匹配零個或者一個任意字符。基於以上背景假設,咱們看下,如何用回溯算法,判斷一個給定的文本,可否跟給定的正則表達式匹配?

咱們依次考察正則表達式中的每一個字符,當是非通配符時,咱們就直接跟文本的字符進行匹配,若是相同,則繼續往下處理;若是不一樣,則回溯。

若是遇到特殊字符的時候,咱們就有多種處理方式了,也就是所謂的岔路口,好比「*」有多種匹配方案,能夠匹配任意個文本串中的字符,咱們就先隨意的選擇一種匹配方案,而後繼續考察剩下的字符。若是中途發現沒法繼續匹配下去了,咱們就回到這個岔路口,從新選擇一種匹配方案,而後再繼續匹配剩下的字符。




public class Pattern { private boolean matched = false; private char[] pattern; // 正則表達式 private int plen; // 正則表達式長度 public Pattern(char[] pattern, int plen) { this.pattern = pattern; this.plen = plen; } public boolean match(char[] text, int tlen) { // 文本串及長度 matched = false; rmatch(0, 0, text, tlen); return matched; } private void rmatch(int ti, int pj, char[] text, int tlen) { if (matched) return; // 若是已經匹配了,就不要繼續遞歸了 if (pj == plen) { // 正則表達式到結尾了 if (ti == tlen) matched = true; // 文本串也到結尾了 return; } if (pattern[pj] == '*') { // *匹配任意個字符 for (int k = 0; k <= tlen-ti; ++k) { rmatch(ti+k, pj+1, text, tlen); } } else if (pattern[pj] == '?') { // ?匹配0個或者1個字符 rmatch(ti, pj+1, text, tlen); rmatch(ti+1, pj+1, text, tlen); } else if (ti < tlen && pattern[pj] == text[ti]) { // 純字符匹配才行 rmatch(ti+1, pj+1, text, tlen); } }}

回溯算法的思想簡單,大部分狀況下,都是用來解決廣義的搜索問題,也就是,從一組可能的解中,選擇出一個知足要求的解。回溯算法很是適合用遞歸來實現,在實現的過程當中,剪枝操做是提升回溯效率的一種技巧。利用剪枝,咱們並不須要窮舉搜索全部的狀況,從而提升搜索效率。

四、動態規劃

一個模型三個特徵
「一個模型」 指的是動態規劃適合解決的問題的模型。把這個模型定義爲「多階段決策最優解模型」。
「三個特徵」分別是最優子結構、無後效性和重複子問題。

  1. 最優子結構
    最優子結構指的是,問題的最優解包含子問題的最優解。反過來講就是,咱們能夠經過子問題的最優解,推導出問題的最優解。若是咱們把最優子結構,對應到咱們前面定義的動態規劃問題模型上,那咱們也能夠理解爲,後面階段的狀態能夠經過前面階段的狀態推導出來

  2. 無後效性
    無後效性有兩層含義,第一層含義是,在推導後面階段的狀態的時候,咱們只關心前面階段的狀態值,不關心這個狀態是怎麼一步一步推導出來的。第二層含義是,某階段狀態一旦肯定,就不受以後階段的決策影響。無後效性是一個很是「寬鬆」的要求。只要知足前面提到的動態規劃問題模型,其實基本上都會知足無後效性。

  3. 重複子問題
    若是用一句話歸納一下,那就是,不一樣的決策序列,到達某個相同的階段時,可能會產生重複的狀態。

解題思路

狀態轉移表法

回溯算法實現 - 定義狀態 - 畫遞歸樹 - 找重複子問題 - 畫狀態轉移表 - 根據遞推關係填表 - 將填表過程翻譯成代碼
先畫出一個狀態表。狀態表通常都是二維的,因此你能夠把它想象成二維數組。其中,每一個狀態包含三個變量,行、列、數組值。咱們根據決策的前後過程,從前日後,根據遞推關係,分階段填充狀態表中的每一個狀態。最後,咱們將這個遞推填表的過程,翻譯成代碼,就是動態規劃代碼了

狀態轉移方程法

找最優子結構 - 寫狀態轉移方程 - 將狀態轉移方程翻譯成代碼。
狀態轉移方程法有點相似遞歸的解題思路。咱們須要分析,某個問題如何經過子問題來遞歸求解,也就是所謂的最優子結構。有兩種代碼實現方法,一種是遞歸加「備忘錄」,另外一種是迭代遞推。

min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))

0-1揹包問題

咱們把整個求解過程分爲 n 個階段,每一個階段會決策一個物品是否放到揹包中。每一個物品決策(放入或者不放入揹包)完以後,揹包中的物品的重量會有多種狀況,也就是說,會達到多種不一樣的狀態,對應到遞歸樹中,就是有不少不一樣的節點。

四種算法思想比較分析

那貪心、回溯、動態規劃能夠歸爲一類,而分治單獨能夠做爲一類,由於它跟其餘三個都不大同樣。爲何這麼說呢?

前三個算法解決問題的模型,均可以抽象成咱們今天講的那個多階段決策最優解模型,而分治算法解決的問題儘管大部分也是最優解問題,可是,大部分都不能抽象成多階段決策模型

回溯算法是個「萬金油」。基本上能用的動態規劃、貪心解決的問題,咱們均可以用回溯算法解決。回溯算法至關於窮舉搜索。窮舉全部的狀況,而後對比獲得最優解。不過,回溯算法的時間複雜度很是高,是指數級別的,只能用來解決小規模數據的問題。對於大規模數據的問題,用回溯算法解決的執行效率就很低了。

儘管動態規劃比回溯算法高效,可是,並非全部問題,均可以用動態規劃來解決。能用動態規劃解決的問題,須要知足三個特徵,最優子結構、無後效性和重複子問題。在重複子問題這一點上,動態規劃和分治算法的區分很是明顯。分治算法要求分割成的子問題,不能有重複子問題,而動態規劃正好相反,動態規劃之因此高效,就是由於回溯算法實現中存在大量的重複子問題。

貪心算法其實是動態規劃算法的一種特殊狀況。它解決問題起來更加高效,代碼實現也更加簡潔。不過,它能夠解決的問題也更加有限。它能解決的問題須要知足三個條件,最優子結構、無後效性和貪心選擇性(這裏咱們不怎麼強調重複子問題)。

其中,最優子結構、無後效性跟動態規劃中的無異。「貪心選擇性」的意思是,經過局部最優的選擇,能產生全局的最優選擇。每個階段,咱們都選擇當前看起來最優的決策,全部階段的決策完成以後,最終由這些局部最優解構成全局最優解。

 

- END -


做者:架構精進之路,專一軟件架構研究,技術學習與我的成長,關注並私信我回復「01」,送你一份程序員成長進階大禮包。


 

往期熱文推薦:


「技術架構精進」專一架構研究,技術分享

 

Thanks for reading!

本文分享自微信公衆號 - 架構精進之路(jiagou_jingjin)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索