「筆記」數據結構與算法之美 - 基礎篇(一)

數組

  • 數組(Array)是一種線性表數據結構,它用一組連續的內存空間,來存儲一組具有相同類型的數據
  • 線性表
    • 線性表就是數據排成像一條線一樣的結構,每個線性表上的數據最多隻有前和後兩個方向

      img

  • 非線性表
    • 在非線性表中,數據之間並不是簡單的前後關係(比如二叉樹、堆、圖等

      img

  • 連續的內存空間和相同類型的數據帶來的一個堪稱“殺手鐗”的特性:“隨機訪問”
    • 數組支持隨機訪問,根據下標隨機訪問的時間複雜度爲 O(1)(數組適合查找,查找時間複雜度爲 O(1)
    • 低效的“插入”和“刪除”
      • 要想在數組中刪除、插入一個數據,爲了保證連續性,就需要做大量的數據搬移工作
      • 刪除時可以先記錄下已經刪除的數據。每次的刪除操作並不是真正地搬移數據,只是記錄數據已經被刪除
        • 關聯類似思想知識點:JVM 標記清除垃圾回收算法
    • 警惕數組的訪問越界問題(Java --> java.lang.ArrayIndexOutOfBoundsException
  • 容器能否完全替代數組
    • ArrayList 最大的優勢就是可以將很多數組操作的細節封裝起來,另一個優勢就是支持動態擴容
      • 因爲擴容操作涉及內存申請和數據搬移,是比較耗時的,最好在創建 ArrayList 的時候事先指定數據大小
    • 用數組會更合適的場景
      • Java ArrayList 無法存儲基本類型,比如 int、long,需要封裝爲 Integer、Long 類(拆裝箱性能損耗
      • 如果數據大小事先已知,並且對數據的操作非常簡單,用不到容器所帶來的優勢時
      • 當要表示多維數組時,用數組往往會更加直觀
  • 擴展:爲什麼大多數編程語言中,數組要從 0 開始編號,而不是從 1 開始呢
    • 從效率的角度上上,能夠減少一次減法操作
      • 若從 0 開始,計算 a[k] 的內存地址的公式:a[k]_address = base_address + k * type_size
      • 若從 1 開始,計算 a[k] 的內存地址的公式:a[k]_address = base_address + (k-1)*type_size
    • 最主要的原因可能是歷史原因
      • C 語言設計者用 0 開始計數數組下標,之後的 Java、JavaScript 等高級語言都效仿了 C 語言
      • 爲了在一定程度上減少 C 語言程序員學習 Java 的學習成本,因此繼續沿用了從 0 開始計數的習慣

鏈表

  • 經典的鏈表應用場景,那就是 LRU 緩存淘汰算法(緩存淘汰策略

    • 常見的策略有三種:先進先出策略 FIFO、最少使用策略 LFU、最近最少使用策略 LRU
  • 從底層的存儲結構上來看,鏈表與數組的區別

    img

    • 數組需要一塊連續的內存空間來存儲,對內存的要求比較高
    • 鏈表不需要一塊連續的內存空間,它通過“指針”將一組零散的內存塊串聯起來使用
  • 單鏈表

    img

    • 我們習慣性地把第一個結點叫作頭結點,把最後一個結點叫作尾結點
      • 頭結點用來記錄鏈表的基地址
      • 尾結點特殊的地方是:指針不是指向下一個結點,而是指向一個空地址 NULL
  • 循環鏈表

    img

    • 它跟單鏈表唯一的區別就在尾結點(尾結點指針是指向鏈表的頭結點
    • 循環鏈表的優點是從鏈尾到鏈頭比較方便。當要處理的數據具有環型結構特點時,就特別適合採用循環鏈表
  • 雙向鏈表

    img

    • 支持兩個方向,每個結點不止有一個後繼指針 next 指向後面的結點,還有一個前驅指針 prev 指向前面的結點
    • 可以支持雙向遍歷,這樣也帶來了雙向鏈表操作的靈活性,但相對於單鏈表來說會佔用更多的內存空間
    • 擴展:雙向循環鏈表
  • 鏈表 VS 數組性能大比拼

    • 插入、刪除、隨機訪問操作的時間複雜度對比

      img

    • 訪問效率對比

      • 數組可以藉助 CPU 的緩存機制,預讀數組中的數據,訪問效率更高
      • 鏈表在內存中並不是連續存儲,所以對 CPU 緩存不友好,沒辦法有效預讀
    • 擴容難易對比

      • 數組的缺點是大小固定,一經聲明就要佔用整塊連續內存空間,擴容時需要全量拷貝,非常耗時
      • 鏈表本身沒有大小的限制,天然地支持動態擴容
    • 如果你的代碼對內存的使用非常苛刻,那數組就更適合你

      • 鏈表中的每個結點都需要消耗額外的存儲空間去存儲一份指向下一個結點的指針
      • 對鏈表進行頻繁的插入、刪除操作,還會導致頻繁的內存申請和釋放,容易造成內存碎片

鏈表編碼技巧

  • 技巧一:理解指針或引用的含義

    • 將某個變量賦值給指針,實際上就是將這個變量的地址賦值給指針
    • 指針中存儲了這個變量的內存地址,指向了這個變量,通過指針就能找到這個變量
    • 例子
      • p->next=q (p 結點中的 next 指針存儲了 q 結點的內存地址
      • p->next=p->next->next(p 結點的 next 指針存儲了 p 結點的下下一個結點的內存地址
  • 技巧二:警惕指針丟失和內存泄漏

    • BadCase

      img

        p->next = x; // 將p的next指針指向x結點;
        x->next = p->next; // 將x的結點的next指針指向b結點;
      
    • 第一步執行完後 p->next 將不會再指向 b ,導致第二步操作實際上是在自己指向自己(整個鏈表被斷開

    • 插入結點時,一定要注意操作的順序

    • 刪除鏈表結點時,也一定要記得手動釋放內存空間(如果是Java語言,注意設置爲NULL,避免內存泄漏

  • 技巧三:利用哨兵簡化實現難度

    • 針對鏈表的插入、刪除操作,需要對插入第一個結點和刪除最後一個結點的情況進行特殊處理

    • 我們也把這種有哨兵結點的鏈表叫帶頭鏈表。相反,沒有哨兵結點的鏈表就叫作不帶頭鏈表

    • 例子

        // 原邏輯
          int i = 0;
          // 這裏有兩個比較操作:i<n和a[i]==key.
          while (i < n) {
            if (a[i] == key) {
              return i;
            }
            ++i;
          }
        
        // 增加哨兵
          // 這裏因爲要將a[n-1]的值替換成key,所以要特殊處理這個值
          if (a[n-1] == key) {
            return n-1;
          }
          
          // 把a[n-1]的值臨時保存在變量tmp中,以便之後恢復。tmp=6。
          // 之所以這樣做的目的是:希望find()代碼不要改變a數組中的內容
          char tmp = a[n-1];
          // 把key的值放到a[n-1]中,此時a = {4, 2, 3, 5, 9, 7}
          a[n-1] = key;
          
          int i = 0;
          // while 循環比起代碼一,少了i<n這個比較操作
          while (a[i] != key) {
            ++i;
          }
        
          // 恢復a[n-1]原來的值,此時a= {4, 2, 3, 5, 9, 6}
          a[n-1] = tmp;
          
          if (i == n-1) {
            // 如果i == n-1說明,在0...n-2之間都沒有key,所以返回-1
            return -1;
          } else {
            // 否則,返回i,就是等於key值的元素的下標
            return i;
          }
        }
      
  • 技巧四:重點留意邊界條件處理

    • 常用來檢查鏈表代碼是否正確的邊界條件有這樣幾個
      • 如果鏈表爲空時,代碼是否能正常工作?
      • 如果鏈表只包含一個結點時,代碼是否能正常工作?
      • 如果鏈表只包含兩個結點時,代碼是否能正常工作?
      • 代碼邏輯在處理頭結點和尾結點的時候,是否能正常工作?
    • 針對不同的場景,可能還有特定的邊界條件,這個需要自己去思考(寫任何代碼時也要去考慮,提高代碼健壯性
  • 技巧五:舉例畫圖,輔助思考

    • 對於稍微複雜的鏈表操作,可以通過舉例法和畫圖法來幫助自己理清思路

      img

  • 技巧六:多寫多練,沒有捷徑

    • 6 個常見的鏈表操作
      • 單鏈表反轉
      • 鏈表中環的檢測
      • 兩個有序的鏈表合併
      • 刪除鏈表倒數第 n 個結點
      • 求鏈表的中間結點
      • 刪除鏈表中重複的結點(個人推薦)
    • 寫鏈表代碼是最考驗邏輯思維能力的
      • 鏈表代碼到處都是指針的操作、邊界條件的處理,稍有不慎就容易產生 Bug
      • 鏈表代碼寫得好壞,可以看出一個人寫代碼是否夠細心,考慮問題是否全面,思維是否縝密

  • 當某個數據集合只涉及在一端插入和刪除數據,並且滿足後進先出、先進後出的特性,就應該首選“棧”這種數據結構

    img

    • 棧是一種“操作受限”的線性表,只允許在一端插入和刪除數據
    • 數組或鏈表暴露了太多的操作接口,操作上的確靈活自由,但使用時就比較不可控,自然也就更容易出錯
  • 棧的實現

    • 用數組實現的棧,我們叫作順序棧
    • 用鏈表實現的棧,我們叫作鏈式棧
  • 支持動態擴容的順序棧

    • 如果要實現一個支持動態擴容的棧,我們只需要底層依賴一個支持動態擴容的數組就可以了
    • 當棧滿了之後,我們就申請一個更大的數組,將原來的數據搬移到新數組中
  • 棧的應用

    • 函數調用棧
      • 操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構, 用來存儲函數調用時的臨時變量
      • 每進入一個函數,就會將臨時變量作爲一個棧幀入棧,當被調用函數執行完成,返回之後,將這個函數對應的棧幀出棧
    • 利用棧來實現表達式求值
      • 例子:34+13*9+44-12/3 (如何實現這一一個表達式求值功能

        img

      • 編譯器就是通過兩個棧來實現的

        • 如果比運算符棧頂元素的優先級高,就將當前運算符壓入棧
        • 如果比運算符棧頂元素的優先級低或者相同,從運算符棧中取棧頂運算符,從操作數棧的棧頂取 2 個操作數,然後進行計算,再把計算完的結果壓入操作數棧,繼續比較
    • 棧在括號匹配中的應用
      • 藉助棧來檢查表達式中的括號是否匹配
        • 我們用棧來保存未匹配的左括號,從左到右依次掃描字符串
        • 當掃描到左括號時,則將其壓入棧中;
        • 當掃描到右括號時,從棧頂取出一個左括號,進行匹配,若不能配對或棧中無數據則認爲是非法格式
        • 當所有的括號都掃描完成之後,如果棧爲空,則說明字符串爲合法格式

    隊列

    • 如何理解隊列

      img

      • 先進者先出,這就是典型的“隊列”,同時棧也是一種操作受限的線性表數據結構
      • 隊列的應用也非常廣泛,特別是一些具有某些額外特性的隊列,比如循環隊列、阻塞隊列、併發隊列
        • 高性能隊列 Disruptor、Linux 環形緩存,都用到了循環併發隊列
        • Java concurrent 併發包利用 ArrayBlockingQueue 來實現公平鎖等。
    • 順序隊列和鏈式隊列

      • 用數組實現的隊列叫作順序隊列
        • 隨着不停地進行入隊、出隊操作,head 和 tail 都會持續往後移動,移到最右邊時,則需要數據搬移
      • 用鏈表實現的隊列叫作鏈式隊列
        • 鏈式隊列則不需要考慮擴容數據搬移問題

          img

    • 循環隊列

      img

      • 原本順序隊列是有頭有尾的,是一條直線,現在我們把首尾相連,扳成了一個環,得到循環隊列
      • 想寫出沒有 bug 的循環隊列的實現代碼,我個人覺得,最關鍵的是,確定好隊空和隊滿的判定條件
        • 隊列爲空的判斷條件仍然是 head == tail
        • 當隊滿時,(tail+1)%n=head
    • 阻塞隊列和併發隊列

      • 阻塞隊列其實就是在隊列基礎上增加了阻塞操作
        • 隊列爲空的時候,從隊頭取數據會被阻塞,如果隊列已經滿了,那麼插入數據的操作就會被阻塞
        • 可以使用阻塞隊列,輕鬆實現一個“生產者 - 消費者模型”
      • 線程安全的隊列我們叫作併發隊列
        • 最簡單直接的實現方式是直接在 enqueue()、dequeue() 方法上加鎖,但是鎖粒度大併發度會比較低
        • 基於數組的循環隊列,利用 CAS 原子操作,可以實現非常高效的併發隊列
    • 擴展:線程池沒有空閒線程時,新的任務請求線程資源時,線程池該如何處理?處理策略又是如何實現的呢?

      • 一般有兩種處理策略
        • 非阻塞的處理方式,直接拒絕任務請求
        • 阻塞的處理方式,將請求排隊,等到有空閒線程時,取出排隊的請求繼續處理
      • 如何存儲排隊的請求
        • 基於鏈表的實現方式
          • 可以實現一個支持無限排隊的無界隊列(unbounded queue)
          • 可能會導致過多的請求排隊等待,請求處理的響應時間過長
          • 針對響應時間比較敏感的系統,基於鏈表實現的無限排隊的線程池是不合適的
        • 基於數組的實現方式
          • 可以實現一個有界隊列(bounded queue):隊列的大小有限
          • 線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕
          • 對響應時間敏感的系統來說,就相對更加合理,但設置一個合理的隊列大小,也是非常有講究的
            • 隊列太大導致等待的請求太多
            • 隊列太小會導致無法充分利用系統資源、發揮最大性能
      • 對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過“隊列”這種數據結構來實現請求排隊

    遞歸

    • 遞歸是一種應用非常廣泛的算法(或者編程技巧),很多數據結構和算法的編碼實現都要用到遞歸
      • 比如 DFS 深度優先搜索、前中後序二叉樹遍歷等
    • 遞歸需要滿足的三個條件
      • 一個問題的解可以分解爲幾個子問題的解
      • 這個問題與分解之後的子問題,除了數據規模不同,求解思路完全一樣
      • 存在遞歸終止條件
    • 如何編寫遞歸代碼
      • 寫遞歸代碼的關鍵步驟
        • 找到如何將大問題分解爲小問題的規律
        • 基於此寫出遞推公式
        • 再推敲終止條件
        • 最後將遞推公式和終止條件翻譯成代碼
      • 遇到遞歸,就把它抽象成一個遞推公式,不用想一層層的調用關係,不要試圖用人腦去分解遞歸的每個步驟
  • 遞歸代碼要警惕堆棧溢出

    • 如果遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險

    • Exception in thread “main” java.lang.StackOverflowError

    • 可以通過在代碼中限制遞歸調用的最大深度的方式來解決這個問題

      • 不能完全解決問題,因爲最大允許的遞歸深度跟當前線程剩餘的棧空間大小有關,事先無法計算
    • 遞歸代碼要警惕重複計算

      img

      • 可以直觀地看到,想要計算 f(5),需要先計算 f(4) 和 f(3),而計算 f(4) 還需要計算 f(3)
        • f(3) 就被計算了很多次,這就是重複計算問題
      • 爲了避免重複計算,我們可以通過一個數據結構(比如散列表)來保存已經求解過的 f(k)
        • 當遞歸調用到 f(k) 時,先看下是否已經求解過,如果是,則直接從散列表中取值返回
    • 在時間效率上,遞歸代碼裏多了很多函數調用,當這些函數調用的數量較大時,就會積聚成一個可觀的時間成本

      • 在空間複雜度上,因爲遞歸調用一次就會在內存棧中保存一次現場數據
    • 所有的遞歸代碼都可以改爲這種迭代循環的非遞歸寫法

      • 因爲遞歸本身就是藉助棧來實現的,只不過我們使用的棧是系統或者虛擬機本身提供的
      • 這種思路實際上是將遞歸改爲了“手動”遞歸,本質並沒有變
        • 也並沒有解決前面講到的某些問題,徒增了實現的複雜度
    • 擴展:對於遞歸代碼,你有什麼好的調試方法呢

      • 打印日誌發現,遞歸值
      • 結合條件斷點進行調試
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章