讀《算法圖解》— 對算法的一些基本理解

「算法」二字聽來高深,經常讓人望而卻步,而《算法圖解》是一本對算法初學者友好的書,此書圖文並茂,按部就班的幫咱們理清算法中一些基礎概念,還介紹了一些有意思的算法及其用途,以提高讀者的興趣,幫助咱們步入算法的大門。本書也許不只僅是一本講述概念的書,做者還在潛移默化中培養咱們的算法思惟,從計算機的角度來看待問題。javascript

原書中的示例使用 Python 編寫,不過考慮到目前我平常工做中使用最多的仍是JavaScript,爲了加深本身對相關概念的理解,筆記中我又用 JS 重寫了一些示例。html

何爲算法

算法其實沒有那麼高深,它只是一組完成任務的指令。任何代碼片斷均可以視爲算法。
算法又沒有那麼簡單,雖然寫出來的完成任務的代碼都是算法,可是算法也有好壞之分。

算法爲什麼重要

選用合適的算法能大大縮短運算時間。

關於這點,咱們來看一個實際的例子。java

問題:
從 1 ~ 100 個數中猜出某個我如今想到的數字,我只會告訴你「大了」,「小了」,「對了」,最快須要幾回猜到?node

解決這個問題有多種方案,若是你運氣好,也許1次就猜對了,運氣差也許會須要100次。有一種叫作「二分查找」的算法很是適合解決這類問題。git

二分查找

定義:
二分查找是一種算法,其輸入是一個有序的元素列表,若是元素包含在列表中,二分查找返回其位置,不然返回 null.github

分析:
簡單查找模式:從1開始往上猜,又稱「傻找」,最多會須要99次猜想。
二分查找:每次猜最大,最小數字的中間值,由此每次可排除通常的可能性,最多隻須要7次就可猜對。算法

通常而言,對於包含 n個元素的列表,用二分查找最多須要 ㏒ 2n步,而簡單查找最多須要n步。

代碼實現編程

/* 二分查找 */
function binarySearch(arr = [], item) {
  let low = 0,
    high = arr.length - 1;
  while (low <= high) {
    const mid = parseInt((low + high) / 2);
    const guess = arr[mid];
    if (guess === item) {
      return mid;
    }
    if (guess > item) {
      high = mid - 1;
    }
    if (guess < item) {
      low = mid + 1;
    }
  }
  return null;
}

const myList = [1, 3, 5, 7, 9];
console.log(binarySearch(myList, 3));
console.log(binarySearch(myList, 10));

上面提到簡單查找模式最多須要 99 次猜想, 而用二分查找最多須要 ㏒ n步,這其實引出了另一個算法初學者還不算熟悉但很重要的概念 — 運行時間數組

算法速度的表徵方式 —— 大O表示法

咱們都想選用效率最高的算法,以最大限度的減小運行時間或佔用空間,換句話說咱們想要選用時間複雜度低的算法,大O表示法是一種指明算法的速度有多快的特殊表示法,經常用它來表示時間複雜度。咱們繼續以上面的例子來講明
好比說咱們 1ms 能查看一個元素,下表表現了不一樣元素個數簡單查找和二分查找找到某個元素的時間緩存

image.png

不要誤解,大O表示法並不是指的是以秒爲單位的速度,而是告訴咱們運行時間會以何種方式隨着運算量的增長而增加,換句話說它指出了算法運行時間的的增速。

例如,假設列表包含 n 個元素。簡單查找須要檢查每一個元素,所以須要執行 n 次操做。使用大O表示法, 這個運行時間爲O(n)。O 是 Operation 的簡寫。

還須要注意的是大O表示法指出的是最糟糕的狀況下的運行時間,這種運行時間是一個保證。

一些常見的時間複雜度

算法的時間複雜度每每是如下幾種中的一種或者組合,雖然尚未介紹到具體的算法,不過咱們能夠先就各類時間複雜度留下一個印象。

  • O(log n):也叫對數時間,這樣的算法包括二分查找;
  • O(n):也叫線性時間,這樣的算法包括簡單查找;
  • O(n * log n): 這種算法包括快速排序 —— 一種速度較快的排序算法;
  • O(n²):這樣的算法包括選擇排序 —— 一種速度較慢的排序算法;
  • O(n!): 這樣的算法包括難以解決的旅行商問題 —— 一種很是慢的算法。

算法經常和數據結構掛鉤。在介紹數據結構以前,咱們須要先理解內存的工做原理,這樣有助於咱們理解數據結構。

常見的數據結構 — 數組和鏈表

內存的工做原理

咱們能夠把計算機的內存想象成有不少抽屜的櫃子,每一個櫃子都有其編號。

image.png

當須要將數據存儲到內存時,咱們會請求計算機提供存儲空間,計算機會分配一個櫃子給咱們(存儲地址),一個櫃子只能存放同樣東西,當須要存儲多項連續的數據時,咱們須要以一種特殊的方法來請求存儲空間,這就涉及到兩種基本的數據結構—數組和鏈表。

數組和鏈表的區別

數組

「數組」這個概念咱們很熟悉,在咱們的平日的開發過程當中會常常用到,不過暫時忘記 JavaScript 中的 Array 吧。咱們來看看數組的本質。

使用數組意味着全部的存儲事項在內存中都是相連的
這樣作的優勢是,若是咱們知道某個存儲事項的索引,咱們只須要一步就能找到其對應的內容。
可是缺點也顯而易見,若是計算機開始爲咱們的數組分配的是一塊只能容納三項的空間,當須要存儲第四項的時候,會須要把全部內容總體移動到新的分配區域。若是新的區域再滿了,則須要再總體移動到另一個更大的空區域。所以有時候在數組中添加新元素是很麻煩的一件事情。針對這個問題常見的解決方案時預留空間,不過預留空間也有兩個問題:

* 可能浪費空間;
* 預留的空間可能依舊不夠用;

鏈表

鏈表中的元素能夠存儲在任何地方,鏈表的每一個元素都存儲了下一個元素的地址,從而使得一系列的內存地址串在一塊兒。

咱們能夠經過一個更形象的好比來理解鏈表,能夠把鏈表比做一個尋寶遊戲,前往某個地點後,會打開一個寶箱,其中有一張紙條上寫着下個地點的位置。

考慮到鏈表的這些性質,鏈表在「插入元素」方便頗具優點,存儲後只須要修改相關元素的指向內存地址便可。固然伴隨而來也有劣勢,那就是在須要讀取鏈表的最後一個元素時,必須從第一個元素開始查找直至找到最後一個元素。

鏈表的劣勢偏偏是數組的優點,當須要隨機讀取元素時,數組的效率很高。(數組中的元素存儲的內存地址相鄰,可容易計算出任意位置的元素的存儲位置)。

鏈表和數組各類操做的時間複雜度對比

操做 數組 鏈表
讀取 O(1) O(n)
插入 O(n) O(1)
刪除 O(n) O(1)
刪除只考慮刪除,不考慮讀取,因爲數組支持隨機訪問,而數組支持隨機訪問,所以數組用得多。

前面介紹的「二分查找」的前提是查找的內容是有序的,在熟悉了數組和鏈表後,咱們來學習第一種排序算法—選擇排序。

選擇排序

定義
遍歷一個列表,每次找出其中最大的那個值,存入一個新的列表,並從原來的列表中去除該值,直到排完。

時間複雜度
每次遍歷的時間複雜度爲O(n),須要執行n次,所以總時間爲O(n²)。

雖然須要遍歷的個數愈來愈少,平均檢查元素數爲½*n,可是大O表示法經常忽略常數。

示例代碼

function findSmallest(arr) {
  let smallest = arr[0];
  let smallest_index = 0;
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] < smallest) {
      smallest = arr[i];
      smallest_index = i;
    }
  }
  return smallest_index;
}

function selectionSort(arr) {
  const newArr = [];
  const length = arr.length;
  for (let i = 0; i < length; i++) {
    const smallestIndex = findSmallest(arr);
    debugger
    newArr.push(arr.splice(smallestIndex, 1)[0]);
    debugger;
  }
  return newArr;
}

console.log(selectionSort([5, 7, 3, 6, 1, 8]));  /* [ 1, 3, 5, 6, 7, 8 ]*/
附:咱們能夠在 VisuAlgo - Sorting (Bubble, Selection, Insertion, Merge, Quick, Counting, Radix)查看選擇排序的動態過程。

上面的選擇排序用到了循環,遞歸是不少算法都使用的一種編程方法,甚至不誇張的講,遞歸是一種計算機思惟。

遞歸

遞歸是一種優雅的問題解決方法。其優雅體如今它讓解決方案更爲清晰,雖然在性能上,有些狀況下遞歸不如循環。

若是使用循環,程序的性能可能更高;若是使用遞歸,程序可能更容易理解。 —- Leigh Caldwell

遞歸的組成

遞歸由兩部分組成:

  • 基線條件(base case):函數再也不調用本身的條件
  • 遞歸條件(recursive case): 函數調用本身的條件

上面提到,遞歸可能存在性能問題,想要理解這一點,須要先理解什麼是「調用棧」。

「棧」是一種先入後出(FILO)簡單的數據結構。「調用棧」是計算機在內部使用的棧。當調用一個函數時,計算機會把函數調用所涉及到的全部變量都存在內存中。若是函數中繼續調用函數,計算機會爲第二個函數頁分配內存並存在第一個函數上方。當第二個函數執行完時,會再回到第一個函數的內存處。

咱們再看看「遞歸調用棧」:

使用遞歸很方便,可是也要̶出代價:存儲詳盡的信息須要佔用大量的內存。遞歸中函數會嵌套執行多層函數,每一個函數調用都要佔用必定的內存,若是棧很高,計算機就須要存儲大量函數調用的信息,這就是爲何有的語言會限制遞歸最多的層數。

若是全部函數都是尾調用,那麼徹底能夠作到每次執行時,調用記錄只有一項,這將大大節省內存。這就是"尾調用優化"的意義。

附:棧和堆的區別

[尾調用優化](http://www.ruanyifeng.com/blog/2015/04/tail-call.html)

上面提到的 「選擇排序」仍是太慢,接下來咱們基於遞歸介紹一種業界通用的排序方法 —— 快速排序。

在正式講解「快速排序」以前,咱們先介紹一下「分而治之」這種方法。
「分而治之 」(divide and conquer D&C )是一種著名的遞歸式問題解決方法。只能解決一種問題的算法畢竟做用有限,D&C 可能幫咱們找到解決問題的思路。

分而治之

運用 D & C 解決問題的過程包括兩個步驟:

  1. 找出基線條件,這種條件必須儘量簡單;
  2. 不斷將問題分解,直到符合基線條件;(每次遞歸調用都必須縮小問題的規模)

image.png

咱們以快速排序爲例來看看,如何運用分而治之的思惟。

快速排序

  1. 選擇基準值
  2. 將數組分爲兩個子數組,小於基準值的元素和大於基準值的元素
  3. 對兩個子數組再次運用快速排序

代碼實現

const quickSort = (array) => {
  if (array.length < 2) {
    return array;
  }
  const pivot = array[0];
  const keysAreLessPivot = array.slice(1).filter(key => key <= pivot);
  const keysAreMorePivot = array.slice(1).filter(key => key > pivot);
  return [...quickSort(keysAreLessPivot), pivot, ...quickSort(keysAreMorePivot)];
};

console.log(quickSort([10, 5, 2, 3])); // [2, 3, 5, 10]

在最糟狀況下,棧長爲 O(n),而在最佳狀況下,棧長爲O(log n)
快速排序的平均時間複雜度爲 nlog(n),比選擇排序快多了O(n²)

常見的數據結構 —— 散列表

在編程語言中,存在另一種和數組不一樣的複雜數據結構,好比JavaScript中的對象,或 Python 中的 字典。對應到計算機的存儲上,它們可能能夠對應爲 散列表。

要理解散列表,咱們須要先看看散列函數。

散列函數

散列函數是一種將輸入映射到數字,且知足如下條件的函數:

  • 相同的輸入會獲得相同的輸出;
  • 不一樣的輸入會映射到不一樣的數字

散列函數準確的指出了數據的存儲位置,能作到這個是由於:

  • 散列函數老是將一樣的輸入映射到相同的索引;
  • 散列函數將不一樣的輸入映射到不一樣的索引;
  • 散列函數知道數組有多大,只返回有效的索引;

咱們能夠把散列表定義爲::使用散列函數和數組建立了一種數據結構。::

散列表也被稱爲 「散列映射」,「映射」,「字典」和「關聯數組」。

可是就像上面提到的,須要連續內存位置的數組存儲空間畢竟有限,散列表中若是兩個鍵映射到了同一個位置,通常作法是在這個位置上存儲一個鏈表。

散列表的用途

因爲散列函數的存在,散列表中數據的查找變得很是快,散列函數值惟一,自己就是一種映射,基於這些,散列表可用做如下用途:

  • 模擬映射關係
  • 防止重複
  • 緩存/記住數據

使用散列表頁存在一些注意事項:

  1. 散列函數很重要,理想的狀況下,散列函數將鍵均勻地映射到散列表的不一樣位置;
  2. 若是散列表存儲的鏈表很長,散列表的速度將急劇降低;

散列表的時間複雜度

理想狀況下 散列表的操做爲O(1),可是最糟狀況下,全部操做的運行時間爲O(n)

操做 散列表(平均狀況) 散列表最差狀況 數組 鏈表
查找 O(1) O(n) O(1) O(n)
插入 O(1) O(n) O(n) O(1)
刪除 O(1) O(n) O(n) O(1)

在平均狀況下,散列表的查找速度和數組同樣快,而插入和刪除速度和鏈表同樣快,所以它兼具兩者的優勢。可是在最糟的狀況下,散列表的各項操做速度都很慢。

要想讓散列表儘量的快,須要避免衝突。
想要避免衝突,須要知足如下兩個條件:

  • 較低的填充因子;
  • 良好的散列函數;

填裝因子 = 散列表包含的元素總數 / 位置總數

若是填裝因子大於1 意味着商品數量超過數組的位置數。一旦填裝因子開始增大,就須要在散列表中添加位置,這被稱爲調整長度。

一個不錯的經驗是,當填裝因子大於 0.7 的時候,就調整散列表的長度。

良好的散列函數

良好的散列函數讓數組中的值呈均勻分佈,不過咱們不用擔憂該如何才能構造好的散列函數,著名的SHA 函數,就可用做散列函數。

在大多數編程語言中,散列表都有內置的實現。下面咱們看看散列表的用途。

廣度優先搜索

廣度優先算法是圖算法的一種,可用來找出兩樣東西之間的最短距離。在介紹這種算法以前,咱們先看看什麼叫圖。

什麼是圖

圖由節點(node)和邊(edge)組成,它模擬一組鏈接。一個節點可能與衆多節點直接相連,這些節點被稱爲鄰居。有向圖指的是節點之間單向鏈接,無向圖指的是節點直接雙向鏈接。

在編程語言中,咱們能夠用散列表來抽象表示圖

image.png

廣度優先搜索解決的問題

廣度優先搜索用以回答兩類問題:

  • 從節點A出發,有前往節點B的路徑嗎?
  • 從節點A出發,到節點B的哪條路徑最短?

咱們仍是舉例來講明該如何運用 廣度優先搜索。

芒果銷售商問題
若是你想從你的朋友或者有朋友的朋友中找到一位芒果銷售商,涉及關係最短的路徑是什麼呢?

分析:查看本身的朋友中,是否有芒果銷售商,若是沒有則檢查朋友的朋友,在最近的那一層檢查完以前不去檢查下一層。

編碼實現

const graph = {};
graph.you = ['alice', 'bob', 'claire'];
graph.bob = ['anuj', 'peggy'];
graph.alice = ['peggy'];
graph.claire = ['thom', 'jonny'];
graph.anuj = [];
graph.peggy = [];
graph.thom = [];

const isSeller = name => name[name.length - 1] === 'm';

const search = (name, graph) => {
  const iter = (waited, visited) => {
    if (waited.length === 0) {
      return false;
    }
    const [current, ...rest] = waited;
    if (visited.has(current)) {
      return iter(rest, visited);
    }
    if (isSeller(current)) {
      console.log(`${current} is a mango seller!`);
      return true;
    }
    visited.add(current);
    const personFriends = graph[current];
    return iter([...rest, ...personFriends], visited);
  };
  return iter(graph[name], new Set());
};

search('you');

上面的編碼中涉及到了一種新的數據結構 —— 隊列。

隊列
隊列的工做原理和現實生活中的隊列徹底相同,可類比爲在公交車前排隊,隊列只支持兩種操做:入隊 和 出隊。
隊列是一種先進先出的(FIFO)數據結構。

散列表模擬圖
散列表是一種用來模擬圖的數據結構

廣度優先算法的時間複雜度

廣度優先算法的時間複雜度爲 O(V+E) 其中V爲頂點數,E爲邊數。

提到圖就不得不說一種特殊的圖 —— 樹。

image.png

樹其實能夠看作是全部的邊都只能往下指的圖。樹的用途很是廣,還有不少種分支,更多信息可參考)

狄克斯特拉算法

廣度優先搜索只能找出最短的路徑,可是它卻並不必定是最快的路徑,想要獲得最快的路徑須要用到狄克斯特拉算法(Dijkstra’s algorithm)

在狄克斯特拉算法算法中,每段路徑存在權重,狄克斯特拉算法算法的做用是找出的是總權重最小的路徑。

平時咱們使用地圖軟件導航到某個咱們不熟悉的地點時,地圖軟件每每會給咱們指出多條路線。直達可是繞圈的公交併不必定會比須要換乘的地鐵快。

狄克斯特拉算法的使用步驟以下:

  1. 畫一張表,列出全部節點,並標註出從當前出發可到達節點的值,找出其中最便宜(可最快到達)的節點供第二步使用;
  2. 更新該節點的到達其全部鄰居節點的時間,依據結構修改上面列出的表,檢查是否有前往它們的更短路徑,若是有,更新其開銷,並更新其父節點;
  3. 重複這個過程,直到對圖中的節點都這麼作了;
  4. 統計最終的表格,找出最短的路徑;

狄克斯特拉算法涉及到的這種擁有權重的圖被稱爲 「加權圖」
不存在權限的圖被稱爲「非加權圖」。
image.png

狄克斯特拉算法算法只適用於有向無環圖。
不能將狄克斯特拉算法算法用於負權邊的狀況。

編碼實現

// the graph
const graph = {};
graph.start = {};
graph.start.a = 6;
graph.start.b = 2;

graph.a = {};
graph.a.fin = 1;

graph.b = {};
graph.b.a = 3;
graph.b.fin = 5;

graph.fin = {};

// The costs table
const costs = {};
costs.a = 6;
costs.b = 2;
costs.fin = Infinity;

// the parents table
const parents = {};
parents.a = 'start';
parents.b = 'start';
parents.fin = null;

let processed = [];


const findLowestCostNode = (itCosts) => {
  let lowestCost = Infinity;
  let lowestCostNode = null;

  Object.keys(itCosts).forEach((node) => {
    const cost = itCosts[node];
    // If it's the lowest cost so far and hasn't been processed yet...
    if (cost < lowestCost && (processed.indexOf(node) === -1)) {
      // ... set it as the new lowest-cost node.
      lowestCost = cost;
      lowestCostNode = node;
    }
  });
  return lowestCostNode;
};

let node = findLowestCostNode(costs);

while (node !== null) {
  const cost = costs[node];
  // Go through all the neighbors of this node
  const neighbors = graph[node];
  Object.keys(neighbors).forEach((n) => {
    const newCost = cost + neighbors[n];
    // If it's cheaper to get to this neighbor by going through this node
    if (costs[n] > newCost) {
      // ... update the cost for this node
      costs[n] = newCost;
      // This node becomes the new parent for this neighbor.
      parents[n] = node;
    }
  });

  // Mark the node as processed
  processed = processed.concat(node);

  // Find the next node to process, and loop
  node = findLowestCostNode(costs);
}

console.log('Cost from the start to each node:');
console.log(costs); // { a: 5, b: 2, fin: 6 }

並不是全部問題都存在最優解

有些時候,咱們很難找出最優解,這時候咱們能夠採用另一種計算機思惟來解決問題 —— 貪婪算法。
貪婪算法指的是每步都採起最優的作法,每步都選擇局部最優解,最終的結果必定不會太差。

完美是優秀的敵人。有時候,你只須要找一個可以大體解決問題的算法,此時貪婪算法正好可派上用場,它們的實現很容易,獲得的結果又與正確結果接近。這時候採用的算法又被稱做近似算法。

判斷近似算法優劣的標準以下:

  • 速度有多快;
  • 獲得的近似解和最優解的接近程度;

有的問題也許不存在所謂的最優解決方案,這類問題被稱爲「NP徹底問題」。這類問題以難解著稱,如旅行商問題集合覆蓋問題。不少很是聰明的人都認爲,根本不可能編寫出可快速解決這些問題的算法。對這類問題採用近似算法是很好的方案,能合理的識別NP徹底問題,能夠幫咱們再也不無結果的問題上浪費太多時間。

如何識別NP徹底問題

如下是NP完成問題的一些特色,能夠幫我咱們識別NP徹底問題:

  • 元素較少時,算法的運行速度很是快,可是隨着元素的增長,速度會變得很是慢;
  • 涉及 全部組合 的問題一般是NP完成問題;
  • 不能將問題分解爲小問題,必須考慮各類可能的狀況的問題,多是NP徹底問題;
  • 若是問題涉及到序列且難以解決(旅行商問題中的城市序列),則多是NP徹底問題;
  • 若是問題涉及到集合(如廣播臺集合)且難以解決,多是NP徹底問題;
  • 若是問題可轉換我集合覆蓋問題或者旅行商問題,必定就是NP徹底問題;

動態規劃

還有一種被稱做「動態規劃」的思惟方式能夠幫咱們解決問題
這種思惟方式的核心在於,先解決子問題,再逐步解決大問題。這也致使「動態規劃」思想適用於子問題都是離散的,即不依賴其餘子問題的問題。

動態規劃使用小貼士:

  • 每種動態規劃解決方案都設計網格;
  • 單元格中的值一般是要優化的值;
  • 每一個單元格都是一個子問題
附: 什麼是動態規劃?動態規劃的意義是什麼?—知乎討論

K最近鄰算法

本書對 KNN 也作了簡單的介紹,KNN的合併觀點以下

  • KNN 用於分類和迴歸,須要考慮最近的鄰居。
  • 分類就是編組
  • 迴歸就是預測結果
  • 特徵抽離意味着將物品轉換爲一系列可比較的數字。
  • 可否挑選合適的特徵事關KNN算法的成敗

進一步的學習建議

讀完本書,對算法總算有了一個入門的理解,固然算法還有不少值得深刻學習的地方,如下是做者推薦的一些方向。

  • 反向索引:搜索引擎的原理
  • 傅里葉變換:傅里葉變換很是適合用於處理信號,可以使用它來壓縮音樂;
  • 並行算法:速度提高並不是線性的,並行性管理開銷,負載均衡
  • MapReduce:是一種流行的分佈式算法,可經過流行的開源工具 Apache Hadoop 來使用;
  • 布隆過濾器和 HyperLogLog:面對海量數據,找到鍵對於的值是一個挑戰性的事情,布隆過濾器是一種機率性的數據結構,答案可能不對也多是正確的;其優勢在於佔用的存儲空間很小
  • SHA 算法(secure hash algorithm)安全散列函數,可用於對比文件,檢查密碼
  • 局部敏感的散列算法,讓攻擊者沒法經過比較散列值是否相似來破解密碼
  • Diffie-Hellman 密鑰交換
  • 線性規劃:用於在給定約束條件下最大限度的改善制定的指標

書讀完了

《算法圖解》確實是一本比較好的算法入門書,不枯燥,又能讓人有收穫就能激勵出人的學習慾望,學習算法單閱讀本書確定仍是不夠的,

Coursera | Online Courses From Top Universities. Join for Free是一門很是好的算法課,若是感興趣能夠一塊兒學學。

本文在GitHub上的地址,歡迎來一塊兒討論

相關文章
相關標籤/搜索