iOS 面試第十四節 程序設計題

設計一個通過LRU緩存機制

確定存儲方案:

我們常用的存儲方案數組、鏈表。但是方案選鏈表存儲。原因如下:

  1. 由於數組是進行連續內存創建,需要在內存中開闢一塊連續內存地址才能進行的,而鏈表是將很多歌分散的內存連在一起。所以這一點上鍊表佔優勢。
  2. 數組同鏈表都支持數據的查找、插入和刪除操作。但是 我們知道,在進行數組的插入、刪除操作時,爲了保持內存數據的連續性,需要做大量的數據搬移,所以時間複雜度是 O(n)<什麼是時間複雜度?下面會講>。而在鏈表中插入或者刪除一個數據,我們並不需要爲了保持內存的連續性而搬移結點,因爲鏈表的存儲空間本身就不是連續的。所以,在鏈表中插入和刪除一個數據是非常快速的,因爲鏈表是相鄰結點指針進行操作,只需要將上個指針指向刪除結點的下一個結點就可以,所以鏈表的時間複雜度爲O(1)。但是,有利就有弊。鏈表要想隨機訪問第 k 個元素,就沒有數組那麼高效了。因爲鏈表中的數據並非連續存儲的,所以無法像數組那樣,根據首地址和下標,通過尋址公式就能直接計算出對應的內存地址,而是需要根據指針一個結點一個結點地依次遍歷,直到找到相應的結點。你可以把鏈表想象成一個隊伍,隊伍中的每個人都只知道自己後面的人是誰,所以當我們希望知道排在第 k 位的人是誰的時候,我們就需要從第一個人開始,一個一個地往下數。所以,鏈表隨機訪問的性能沒有數組好,需要 O(n) 的時間複雜度。雙向鏈表由於有pre next 兩個指針,所以雙向鏈表佔用內存空間更大一些。

在實際的軟件開發中,從鏈表中刪除一個數據無外乎這兩種情況:

刪除結點中“值等於某個給定值”的結點;
  刪除給定指針指向的結點。
對於第一種情況,不管是單鏈表還是雙向鏈表,爲了查找到值等於給定值的結點,都需要從頭結點開始一個一個依次遍歷對比,直到找到值等於給定值的結點,然後再通過我前面講的指針操作將其刪除。
對於第二種情況,我們已經找到了要刪除的結點,但是刪除某個結點 q 需要知道其前驅結點,而單鏈表並不支持直接獲取前驅結點,所以,爲了找到前驅結點,我們還是要從頭結點開始遍歷鏈表,直到 p->==q,說明 p 是 q 的前驅結點。
但是對於雙向鏈表來說,這種情況就比較有優勢了。因爲雙向鏈表中的結點已經保存了前驅結點的指針,不需要像單鏈表那樣遍歷。所以,針對第二種情況,單鏈表刪除操作需要 O(n) 的時間複雜度,而雙向鏈表只需要在 O(1) 的時間複雜度內就搞定了!
同理,如果我們希望在鏈表的某個指定結點前面插入一個結點,雙向鏈表比單鏈表有很大的優勢。雙向鏈表可以在 O(1) 時間複雜度搞定,而單向鏈表需要 O(n) 的時間複雜度。你可以參照我剛剛講過的刪除操作自己分析一下。
除了插入、刪除操作有優勢之外,對於一個有序鏈表,雙向鏈表的按值查詢效率也要比單鏈表高一些。因爲**,我們可以記錄上次查找的位置 p,每次查詢時,根據要查找的值與 p 的大小關係,決定是往前還是往後查找,所以平均只需要查找一半的數據。**
實際上,這裏有一個更加重要的知識點需要你掌握,那就是用空間換時間的設計思想。當內存空間充足的時候,如果我們更加追求代碼的執行速度,我們就可以選擇空間複雜度相對較高、但時間複雜度相對很低的算法或者數據結構。相反,如果內存比較緊缺,比如代碼跑在手機或者單片機上,這個時候,就要反過來用時間換空間的設計思路。
所以我總結一下,對於執行較慢的程序,可以通過消耗更多的內存(空間換時間)來進行優化;而消耗過多內存的程序,可以通過消耗更多的時間(時間換空間)來降低內存的消耗。
瞭解了循環鏈表和雙向鏈表,如果把這兩種鏈表整合在一起就是一個新的版本:雙向循環鏈表。我想不用我多講,你應該知道雙向循環鏈表長什麼樣子了吧?你可以自己試着在紙上畫一畫。
在這裏插入圖片描述
通過前面內容的學習,你應該已經知道,數組和鏈表是兩種截然不同的內存組織方式。正是因爲內存存儲的區別,它們插入、刪除、隨機訪問操作的時間複雜度正好相反。
在這裏插入圖片描述
不過,數組和鏈表的對比,並不能侷限於時間複雜度。而且,在實際的軟件開發中,不能僅僅利用複雜度分析就決定使用哪個數據結構來存儲數據。
數組簡單易用,在實現上使用的是連續的內存空間,可以藉助 CPU 的緩存機制,預讀數組中的數據,所以訪問效率更高。而鏈表在內存中並不是連續存儲,所以對 CPU 緩存不友好,沒辦法有效預讀。
數組的缺點是大小固定,一經聲明就要佔用整塊連續內存空間。如果聲明的數組過大,系統可能沒有足夠的連續內存空間分配給它,導致“內存不足(out of memory)”。如果聲明的數組過小,則可能出現不夠用的情況。這時只能再申請一個更大的內存空間,把原數組拷貝進去,非常費時。鏈表本身沒有大小的限制,天然地支持動態擴容,我覺得這也是它與數組最大的區別。

你可能會說,我們 Java 中的 ArrayList 容器,也可以支持動態擴容啊?我們上一節課講過,當我們往支持動態擴容的數組中插入一個數據時,如果數組中沒有空閒空間了,就會申請一個更大的空間,將數據拷貝過去,而數據拷貝的操作是非常耗時的。
我舉一個稍微極端的例子。如果我們用 ArrayList 存儲了了 1GB 大小的數據,這個時候已經沒有空閒空間了,當我們再插入數據的時候,ArrayList 會申請一個 1.5GB 大小的存儲空間,並且把原來那 1GB 的數據拷貝到新申請的空間上。聽起來是不是就很耗時?
除此之外,如果你的代碼對內存的使用非常苛刻,那數組就更適合你。因爲鏈表中的每個結點都需要消耗額外的存儲空間去存儲一份指向下一個結點的指針,所以內存消耗會翻倍。而且,對鏈表進行頻繁的插入、刪除操作,還會導致頻繁的內存申請和釋放,容易造成內存碎片,如果是 Java 語言,就有可能會導致頻繁的 GC(Garbage Collection,垃圾回收)。
所以,在我們實際的開發中,針對不同類型的項目,要根據具體情況,權衡究竟是選擇數組還是鏈表。

答案揭曉

好了,關於鏈表的知識我們就講完了。我們現在回過頭來看下開篇留給你的思考題。如何基於鏈表實現+LRU+緩存淘汰算法?+我的思路是這樣的:我們維護一個有序單鏈表,越靠近鏈表尾部的結點是越早之前訪問的。當有一個新的數據被訪問時,我們從鏈表頭開始順序遍歷鏈表。

  1. 如果此數據之前已經被緩存在鏈表中了,我們遍歷得到這個數據對應的結點,並將其從原來的位置刪除,然後再插入到鏈表的頭部。

  2. 如果此數據沒有在緩存鏈表中,又可以分爲兩種情況:

    • 如果此時緩存未滿,則將此結點直接插入到鏈表的頭部;
    • 如果此時緩存已滿,則鏈表尾結點刪除,將新的數據結點插入鏈表的頭部。+這樣我們就用鏈表實現了一個+LRU+緩存,是不是很簡單?

    現在我們來看下 m 緩存訪問的時間複雜度是多少。因爲不管緩存有沒有滿,我們都需要遍歷一遍鏈表,所以這種基於鏈表的實現思路,緩存訪問的時間複雜度爲 O(n)。

實際上,我們可以繼續優化這個實現思路,比如引入散列表(Hash table)來記錄每個數據的位置,將緩存訪問的時間複雜度降到 O(1)。因爲要涉及我們還沒有講到的數據結構,所以這個優化方案,我現在就不詳細說了,等講到散列表的時候,我會再拿出來講。

參考文章:

如何實現LRU緩存淘汰算法?

  • 什麼是時間複雜度?
    是指執行算法所需要的計算工作量
  • 空間複雜度
    指執行這個算法所需要的內存空間

在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章