學前端算法的數據結構基礎?只要4999!

前言

正好前段時間屯了修言大佬的《前端算法與數據結構面試:底層邏輯解讀與大廠真題訓練 》的小冊,因此這幾天放棄摸魚來學習一下,由於想着也是作一下本身的學習記錄,也能夠方便一些同窗簡單的瞭解這方面的知識,因此寫了這麼一篇學習筆記,主要結構是根據小冊來的,內容是修言大佬的內容結合個人一些本身理解,裏面的例子有的是小冊中的,有的是我本身想的,我也是初次學習,可能多有不妥之處,多包涵,話很少說,就進正題吧!(偷偷的告訴你,文章一共4999個字,不算這句悄悄話!)前端

數據結構層面

數組

建立方式:node

const arr = [1, 2, 3, 4]
複製代碼
const arr = new Array() 
複製代碼

推薦在不知道內部元素的狀況下使用第二種,而且知道有多少元素,指定Array(4),這樣的狀況下,假如元素相同,咱們就能夠避免寫一個重複的數組,如 let arr = [1,1,1,1] ,而是使用 let arr = (new Array(4)).fill(1) 來建立。面試

  • 注意:可是這裏須要明確一點,就是若是你的數組是一個二維數組(矩陣),那麼請不要用fill這個方法先去填充一個空數組,好比let arr = (new Array(4)).fill([]),沒錯你確實可以獲得七個空數組,但當你出現arr[0][0] = 1這樣的操做時,你就會獲得七個數組中的元素都變成了1,這是由於fill方法的參數若是是一個引用類型(數組,對象等),那麼fill在填充的時候就是對這個參數的引用,所以咱們能夠選擇用for循環來建立這個二維數組,同理,多維數組也是同樣

遍歷方式:算法

  • for循環 (性能上最快)
  • arr.forEach
  • arr.map(統一再加工)

增刪元素:數組

  • unshift(將元素添加到數組頭部):
const arr = [1,2,3]
arr.unshift(0) // [0,1,2,3]
複製代碼
  • push(將元素添加到數組尾部):
arr.push(4) // [0,1,2,3,4]
複製代碼
  • splice(將元素添加到數組任意位置,第一個參數是下標index,第二個參數是須要刪除的元素個數,第三個參數是你要放入的元素(可選)):
arr.splice(2,0,5) // [0,1,5,2,3,4]
arr.splice(2,1) // [0,1,2,3,4]
複製代碼
  • shift(將數組頭部元素刪除):
arr.shift() // [1,2,3,4]
複製代碼
  • pop(將數組尾部元素刪除):
arr.pop() // [1,2,3]
複製代碼

棧(後進先出--Last In First Out)

咱們能夠將他理解爲只使用pop和push方法的數組,由於他後進先出的特性,也就是咱們只能在尾部添加和刪除元素。markdown

好比咱們有一個瓶子,只有一個球的寬度,咱們要往裏面放5種顏色的球數據結構

const stack = []
// 入棧過程
stack.push('紅球')
stack.push('黃球')
stack.push('藍球')
stack.push('綠球')
stack.push('黑球') 
// ['紅球','黃球','藍球','綠球','黑球']

// 出棧過程
while(stack.length) {
    console.log('如今取出的是' + stack.pop())
} 
// 如今取出的是黑球
// 如今取出的是綠球
// 如今取出的是藍球
// 如今取出的是黃球
// 如今取出的是紅球
複製代碼

隊列(先進先出--First In First Out)

咱們能夠將他理解爲只使用push和shift方法的數組,由於他先進先出的特性,也就是咱們只能在尾部添加元素和在頭部刪除元素。函數

仍是上面那個例子:性能

const queue = []
// 入隊過程
queue.push('紅球')
queue.push('黃球')
queue.push('藍球')
queue.push('綠球')
queue.push('黑球') 
// ['紅球','黃球','藍球','綠球','黑球']

// 出隊過程
while(queue.length) {
    console.log('如今取出的是' + queue.shift())
} 
// 如今取出的是紅球
// 如今取出的是黃球
// 如今取出的是藍球
// 如今取出的是綠球
// 如今取出的是黑球
複製代碼

鏈表

鏈表與數組的最大的區別,咱們能夠理解爲鏈表是不連續的,而數組是連續的,怎麼理解呢,即鏈表在內存空間內,咱們的結點並不會並排並的站在一塊兒,就比如一個操場,100米接力跑的同窗會站在400米跑道的各個100米位置,是不連續的,但他們知道本身的下一棒是誰,而數組就比如1~7個跑道是連續的。學習

鏈表的建立:

咱們首先定義一個鏈表的構造函數,而後經過指定val和next來建立

function createNode(val) {
    this.val = val // 當前結點的值
    this.next = null // 下一個結點指向
}

const node1 = new createNode(1) // 建立當前結點值爲1
node1.next = new createNode(2) // 指向了下一個結點值爲2

// 當咱們要插入結點時
const node3 = new createNode(3)
node3.next = node1.next // 將node1原來的next指向給node3的next
node1.next = node3 // 將node1的next指向node3

// 當咱們要刪除結點時
node1.next = node3.next // node3會由於沒法抵達就會被垃圾回收系統回收

// 另外咱們也能夠經過node1來抵達node3
// let target = node1.next
// node1.next = target.next
// 這樣也能夠達成目的
複製代碼

與數組相比,鏈表的優點與劣勢

  • 優點:咱們能夠經過上述所說的目標位置來找到對應的結點,這樣就能夠進行一個高效的增刪操做,這裏會涉及到時間複雜度的下降,從O(n)->O(1)

  • 劣勢:咱們若是須要找到一個特定的鏈表結點時,咱們必須從頭開始遍歷,與優點相反,會把時間複雜度從O(1)->O(n)提高,即訪問效率低

這裏咱們暫時先無論複雜度的問題,後面會再講到,另外須要提一點的是,JS的數組未必是一個真正的數組,由於一般的數組是一段連續的空間,而當JS數組中的元素不是一種類型時,他就變成了一段非連續的內存,此時他是由對象鏈表來實現的

關於樹,其實就跟現實中的樹同樣,經過不斷散發結點來擴張,有幾點咱們須要記住:

  • 層級:根結點所在的層級是第一層,日後每下一級結點就層級增長一層
  • 高度:最下面的葉子結點的高度爲1,每向上一層,高度增長1,直到根結點獲得的最大的高度就是樹的高度(由於結點擴散下去的層數可能不同,所以高度不必定,因此最大的高度纔是樹的高度)
  • 度:每一個結點分散的子結點的個數就是度,好比一個結點有兩個子結點,那麼他的度就是2,其中由於葉子結點再也不向下擴展,所以葉子結點的度爲0

詳細的你們能夠看一下我畫的這張示例圖👇,包含了上面說的這些內容

樹.png

二叉樹

二叉樹這個概念相信不少同窗聽的特別多,面試問到的概率也很大,那麼咱們來仔細的聊一聊,什麼是二叉樹,他又有什麼特色呢?

什麼樣的被稱爲二叉樹

  • 能夠沒有根結點,做爲一顆空樹存在
  • 若是不是一顆空樹,則必須具有:1.根結點;2.左子樹;3.右子樹,而其中左右子樹都必須仍然是二叉樹,即知足以上兩點

其實上面我畫的那張關於樹的圖,這就是一個二叉樹形,爲何說是形呢?由於二叉樹並不只僅單純的是每一個結點有兩個子結點,他必須是左右子樹的位置明確區分,沒法交換的,也就是每一個子結點,就必須在他所在位置,不能與他的兄弟結點進行互換。而後還要提到一點的是,二叉樹的每一個結點最多隻能有兩棵子樹,子樹能夠不存在,也能夠存在並其中一個爲空樹,或者兩個都爲空樹,或者都不爲空樹,由於即便是空樹,他也是二叉樹的一種,若是一棵二叉樹層數爲k,結點總數爲(2^k)-1,那麼他就是一棵滿二叉樹。

二叉樹的編碼實現

二叉樹的結構分爲三塊:

  • 數據域
  • 左側子結點(左子樹根結點)的引用
  • 右側子結點(右子樹根結點)的引用
// 首先咱們須要建立一個構造函數,而且設定子樹爲空,若是咱們須要子樹,那麼就對左右子結點進行新的建立操做
function createTreeNode(val) {
    this.val = val // 定義當前結點的值
    this.left = null // 左子樹爲空
    this.right = null // 右子樹爲空
}

// 實例化
const node1 = new createTreeNode(1)
複製代碼

二叉樹的遍歷

接下來這部分很重要,就是二叉樹的遍歷,修言大佬說了,只理解不記憶,你就回家種地吧(虧的我是農村人,有地種,城裏的同窗另謀出路吧~ ^_^!)

首先遍歷分爲兩類:

  • 按照順序規則不一樣,分爲四種:

    • 先序遍歷

      根結點 -> 左子樹 -> 右子樹 (假定左子樹先於右子樹)

      這裏咱們就來仔細的說一下什麼是先序遍歷,你們也都看到了,所謂的先中後實際上是對根結點遍歷的時間節點的定義,就是何時去遍歷根結點。那咱們來講到先序遍歷,即咱們先遍歷根結點,在遍歷左子樹,最後遍歷右子樹,而後這個遍歷順序呢,須要一直貫穿到每個子樹當中,咱們如下圖👇爲例,圖中咱們能夠看到總體的遍歷順序就是先從根結點A遍歷後,到左子樹的根結點B,而後再到B的左子樹D,假如此處D還有子樹,按照一樣的順序遍歷,D結束以後再遍歷E,而後再到A的右子樹C,一樣是先根結點,而後左子樹,而後右子樹的遍歷方式,最後所有走完到G也就是完成了整個先序遍歷:1 -> 2 -> 3 -> 4 -> 5 -> 6 樹 (3).png

      接下來咱們來看一下代碼的實現:

      // 遍歷對象
      const treeList = {
          val: 'A',
          left: {
              val: 'B',
              left: {
                  val: 'C',
                  left: {
                      val: 'D'
                  },
                  right: {
                      val: 'E'
                  }
              },
              right: {
                  val: 'F'
              }
          },
          right: {
              val: 'G',
              left: {
                  val: 'H',
                  left: {
                      val: 'I'
                  },
                  right: {
                      val: 'J'
                  }
              }
          }
      }
      
      /* 先序遍歷函數: 這裏說一下整個思路,就是咱們首先判斷是否是一個 空樹,若是不是咱們就先遍歷根結點,而後去遍歷左右子樹,而左右子樹同 樣是執行這個操做,一個結點遍歷結束的標誌就是,當前結點是一個空樹, 那麼咱們對當前結點的遍歷就結束了,整個的遞歸過程就是一個先序遍歷 */
      function treeTraverse(root) {
          if(!root) {
              return // 若是當前結點爲空,那就返回
          }
          console.log(root.val); // 打印當前結點的值
      
          // 接着遍歷左子樹
          treeTraverse(root.left)
          // 而後遍歷右子樹
          treeTraverse(root.right)
      }
      
      treeTraverse(treeList)
      複製代碼

      結果以下圖:

      result.jpg

    • 中序遍歷

      左子樹 -> 根結點 -> 右子樹 (假定左子樹先於右子樹)

      在瞭解先序遍歷以後,咱們在去了解中序遍歷就不是很困難的事情了,這裏就不在說具體的思路了,直接上代碼:

      // 中序遍歷函數
      function treeTraverse(root) {
          if(!root) {
              return // 若是當前結點爲空,那就返回
          }
      
          // 先遍歷左子樹
          treeTraverse(root.left)
      
          // 再遍歷根結點
          console.log(root.val);
      
          // 而後遍歷右子樹
          treeTraverse(root.right)
      }
      
      treeTraverse(treeList)
      
      // D
      // C
      // E
      // B
      // F
      // A
      // I
      // H
      // J
      // G
      複製代碼
    • 後序遍歷

      左子樹 -> 右子樹 -> 根結點 (假定左子樹先於右子樹)

      後序遍歷也是同樣的:

      // 中序遍歷函數
      function treeTraverse(root) {
          if(!root) {
              return // 若是當前結點爲空,那就返回
          }
      
          // 先遍歷左子樹
          treeTraverse(root.left)
          
          // 而後遍歷右子樹
          treeTraverse(root.right)
      
          // 再遍歷根結點
          console.log(root.val);
      }
      
      treeTraverse(treeList)
      
      // D
      // E
      // C
      // F
      // B
      // I
      // J
      // H
      // G
      // A
      複製代碼
    • 層次遍歷

      關於層次遍歷,其實也是比較好理解的,層次二字個人理解是跟樹的層級有關,最開始的時候,咱們是否是提到過層級的定義,從層級面上來講,就是咱們先遍歷第一層級,而後第二層級,由於左子樹優先於右子樹,因此整個的遍歷順序也很明確,另外我查閱的解釋是用隊列的知識來實現:每次出隊一個元素,就將該元素的孩子節點加入隊列中,直至隊列中元素個數爲0時,出隊的順序就是該二叉樹的層次遍歷結果。

      怎麼理解這句話呢,經過前面的學習,咱們知道了隊列是先進先出的,當咱們把一個根結點放入隊列後,而後再取出,此時是否是就將他的左右子樹的根結點放入了隊列,而後咱們再對左右子樹的根結點進行這個操做,整個出隊的順序就是層次遍歷,來看一張示意圖,我相信你就明白了:

層次遍歷.png

看完了順序,咱們用代碼來實現一下

// 層次遍歷函數
function treeTraverse(root) {
    // 先定義一個隊列
    const queue = []
    // 先將根結點放入隊列
    queue.push(root)
    // 而後進行取出根元素放入子元素的循環
    while(queue.length) {
        // 進入循環表明如今還有元素沒有遍歷,當數組長度爲0,就完整了整個層次遍歷
        const first = queue[0] //獲取列表第一個元素

        console.log(first.val); // 對根元素進行遍歷
        // 把第一個元素取出
        queue.shift(first)
        // 取出根元素後,須要將左右子樹放入
        if(first.left) {
            queue.push(first.left)
        }
        if(first.right) {
            queue.push(first.right)
        }
    }
}

treeTraverse(treeList)

// A
// B
// G
// C
// F
// H
// D
// E
// I
// J
複製代碼
  • 按照實現方式不一樣,分爲兩種:

    • 遞歸遍歷(先、中、後序遍歷)
    • 迭代遍歷(層次遍歷)

複雜度

學完數據結構的基礎知識後,咱們再來學習複雜度的知識。關於複雜度這個事情,稍微瞭解過一點算法的同窗都知道,就是你怎麼去衡量你的算法到底好仍是很差呢,一樣可以解出來一道題目,那麼到底誰的更好呢,這就是複雜度存在的意義,光從概念上可能理解起來比較費勁,修言大佬帶咱們用代碼去認識這兩個標準---時間複雜度和空間複雜度!

時間複雜度(time complexity)

關於時間複雜度,咱們常常會遇到的是相似於O(1),O(n)這樣的表達,那麼這些表達是怎麼來的呢,又還有哪些表達呢?

咱們先來看一下一段代碼:

function traverse(arr) {
    var len = arr.length
    for(var i = 0; i < len; i++) {
        console.log(arr[i])
    }
}
複製代碼

假如上面這個traverse函數執行,那麼咱們若是將全部的循環都用代碼來寫出來,總共須要執行多少次呢?咱們來一一計算一下(下面咱們將數組長度定義爲n):

  • var len = arr.length 執行1次

  • var i=0 執行1次

  • i<len 執行n+1次

  • i++ 執行n次

  • console.log(arr[i]) 執行n次

那麼最後咱們將這些次數所有加起來就是咱們總的執行次數,也就是1+1+(n+1)+n+n = 3n+3次,而像這樣的時間複雜度,咱們統一將他們認定爲時間複雜度爲n,即忽略他的常數,只看總體的趨勢,而算法的時間複雜度,就是對代碼總體執行次數的一個變化趨勢的反應,之前讀書的時候咱們學過,在n趨於無窮大的時候,他前面的係數和常數也就失去了意義,能夠忽略,因此咱們最終獲得的時間複雜度纔是n

那麼同理,咱們來看一下雙循環的代碼:

function traverse(arr) {
    var len = arr.length
    for(let i = 0; i < len; i++) {
        for(let j = 0; j < len; j++) {
            console.log(arr[i][j])
    }
}
複製代碼

咱們來分析一下他執行的總的次數:

  • var len = arr.length執行1次

  • let i = 0執行1次

  • i < len執行n+1次

  • i++ 執行n次

  • let j = 0執行n次

  • j < len執行n*(n+1)次

  • j++ 執行n*n次

  • console.log(arr[i][j])執行n*n次

那麼以上總的執行次數就是1+1+n+1+n+n+n*(n+1)+n * n+n * n=3n^2+4n+3,由此咱們能夠發現次數的最高次來到了2,那根據咱們第一次獲得結論,首先係數和常數項能夠忽略,再者,當n趨於無窮大的時候,低次項也能夠被忽略,所以咱們最後獲得的趨勢也就是時間複雜度即爲n^2

通過上面兩個案例的分析,相信你也明白了即從執行次數獲得時間複雜度是如何得來的,而後其實還有好幾種比較常見的時間複雜度,按照複雜程度作了排列,請看👇下面這張圖:

樹 (6).png

空間複雜度(space complexity)

空間複雜度其實是對算法在運行過程當中臨時佔用存儲空間大小的衡量,是內存增加的趨勢,我本身的理解的話,能夠把時間複雜度和空間複雜度構成一個座標系,X軸爲時間複雜度,那麼Y軸相應的爲空間複雜度,兩個複雜度之間不互相影響,可是共同構成了咱們算法好與壞的一個衡量標準。

常見的空間複雜度有下面幾種:

  • O(1)
  • O(n)
  • O(n^2)

一樣的咱們來看一下接來下這個例子,來理解空間複雜度吧!

function traverse(arr) {
    var len = arr.length
    for(var i = 0; i < len; i++) {
        console.log(arr[i])
    }
}
複製代碼

用的仍是這個函數,咱們來看一下,佔用內存的是這幾個:arrilen,而咱們在總體的執行過程當中,並無新的變量出現,所以也沒有開闢新的內存空間,這樣咱們是否是就能夠認爲,空間複雜度是恆定不變的,那麼就是一個常量了,對於常量的複雜度,咱們都統一將其認定爲1,那麼最後獲得的就是O(1)

接下來咱們看另一個例子:

function init(n) {
    var arr = []
    for(var i=0;i<n;i++) {
        arr[i] = i
    }
    return arr
}
複製代碼

這裏咱們發現佔用內存的有narri,而根上個例子不同的地方你們也發現了,也就是arr的大小會根據n的值的傳入不同而線性改變,像這樣會隨着變化的空間複雜度,咱們將其記爲O(n)

後記

上面就是小冊中關於算法的數據結構和基礎概念的一個簡單的梳理,我我的認爲仍是比較好理解的,後面的真題部分我準備開始看了,碰到以爲須要補充的概念或者經常使用的解法,我會在單獨寫一篇文章來跟你們分享,那我寫的是通過我消化了的,若有錯誤,歡迎指正,想全面瞭解學習的,就快去讀修言大佬的這本小冊吧!

相關文章
相關標籤/搜索