從 LRU Cache 帶你看面試的本質

前言

你們好,這裏是《齊姐聊算法》系列之 LRU 問題。node

在講這道題以前,我想先聊聊「技術面試到底是在考什麼」這個問題。git

技術面試究竟在考什麼

在人人都知道刷題的今天,面試官也都知道你們會刷題準備面試,代碼你們都會寫,那面試爲何還在考這些題?那爲何有些人代碼寫出來了還掛了?github

你們知道美國的大廠面試 80%是在考算法,這實際上是最近 5-10 年以谷歌、雅虎爲首才興起的;國內大廠對於算法的考察雖然沒有這麼狂熱,但也愈來愈重視了。web

那麼算法面試真的只是在考算法嗎?顯然不是。本質上考的是思考問題的方式,分析、解決問題的能力,以及和同事溝通交流的能力,看你可否主動推動去解決問題。面試

答題思路

套路就是:算法

  • clarify 問題
  • 分析思路、時空複雜度、分析哪裏能夠優化、如何優化
  • 寫代碼
  • run test cases

雖然說是套路,但未嘗不是一個高效的工做方式?數據庫

那拿到一個問題,首先應該是去 clarify 這個問題,由於工做就是如此,不像在刷題網站作題什麼都給你定義好了,面試官一般都不會一次性給你全部條件,而是須要你思考以後去問他。那經過這個環節,面試官就知道了你遇到問題是怎麼去思考的,你考慮的是否全面,怎麼去和別人溝通的,從此和你一塊兒工做的狀態是怎樣的。緩存

就像咱們平時工做時,須要和 product manager 不斷的 clarify 需求,特別是沒定義清楚的部分,反反覆覆的討論,也是磨刀不誤砍柴工。那這個過程,在我司可能就要 1-2 周,不會很着急的就開始,不然努力錯了方向就是南轅北轍,得不償失。那麼面試時也是同樣,代碼都寫完了面試官說這不是我想問的,那時候已經沒時間了,買單的是咱們本身。服務器

第二點分析思路就是重中之重了,也是本文的核心,會以 LRU Cache 這到經典題爲例,展現我是如何思考、分析的。數據結構

第三點寫代碼,沒什麼好說的,終究是須要落到實處的。

第四點跑測試,不少同窗可能會忘,因此若是你能主動提出 run test cases,過幾個例子檢驗一下,是很好的。

  • 一來是給本身一個修正的機會,由於有不少 bug 是你跑兩個例子就能發現的,那即便有點 bug 你沒發現,只要你作完了這一步,面試官當場也沒發現的話,那面試結束後面試官雖然會拍照留念,但也不會閒的無聊再本身打到電腦上跑的;
  • 二來是展現你的這種意識,跑測試的意識,這種意識是很重要的。

有些人說每道題我都作出來了,爲何仍是掛了?那照着這四點對比一下,看看是哪一個環節出了問題。

常考不衰的緣由

另外這道題爲何各大公司都喜歡考呢?

一是由於它可以多方面、多維度的考察 candidate:這道題考察的是基本功,考對數據結構理解使用,考能不能寫出 readable 的代碼。一場 45 分鐘-60 分鐘的面試,如何摸清楚 candidate 的真實水平,也是不容易的啊。

二是由於這道題可難可易,能夠簡單到像 Leetcode 上那樣把 API 什麼的都已經定義好了,也能夠難到把 System Design 的內容都包含進來,聊一下 Redis 中的近似 LRU 算法。

因此 follow up 就能夠無限的深刻下去,若是面試官想問的你都能回答的頭頭是道,那 strong hire 天然跑不掉。那有些同窗只會到第一層或者第二層,面試是優中選優的過程,其餘同窗會的比你多,溝通交流能力又好,天然就是別人拿 offer 了。

那今天就以這道題爲例,在這裏淺談一下個人思考過程,爲你們拋磚引玉,歡迎在留言區分享你的想法。

LRU Cache

LRU 是什麼

LRU = Least Recently Used 最近最少使用
它是一種緩存逐出策略 cache eviction policies[1]

LRU 算法是假設最近最少使用的那些信息,未來被使用的機率也不大,因此在容量有限的狀況下,就能夠把這些不經常使用的信息踢出去,騰地方。

好比有熱點新聞時,全部人都在搜索這個信息,那剛被一我的搜過的信息接下來被其餘人搜索的機率也大,就比前兩天的一個過期的新聞被搜索的機率大,因此咱們把好久沒有用過的信息踢出去,也就是 Least Recently Used 的信息被踢出去。

舉個例子:咱們的內存容量爲 5,如今有 1-5 五個數。

咱們如今想加入一個新的數:6
但是容量已經滿了,因此須要踢出去一個。

那按照什麼規則踢出去,就有了這個緩存逐出策略。好比:

  • FIFO (First In First Out) 這個就是普通的先進先出。
  • LFU (Least Frequently Used) 這個是計算每一個信息的訪問次數,踢走訪問次數最少的那個;若是訪問次數同樣,就踢走很久沒用過的那個。這個算法其實很高效,可是耗資源,因此通常不用。
  • LRU (Least Recently Used) 這是目前最經常使用了。

LRU 的規則是把很長時間沒有用過的踢出去,那它的隱含假設就是,認爲最近用到的信息之後用到的機率會更大。

那咱們這個例子中就是把最老的 1 踢出去,變成:

不斷迭代...

Cache 是什麼?

簡單理解就是:把一些能夠重複使用的信息存起來,以便以後須要時能夠快速拿到。

那至於它存在哪裏就不必定了,最多見的是存在內存裏,也就是 memory cache,但也能夠不存在內存裏。

使用場景就更多了,好比 Spring 中有 @Cacheable 等支持 Cache 的一系列註解。上個月我在工做中就用到了這個 annotation,固然是我司包裝過的,大大減小了 call 某服務器的次數,解決了一個性能上的問題。

再好比,在進行數據庫查詢的時候,不想每次請求都去 call 數據庫,那咱們就在內存裏存一些經常使用的數據,來提升訪問性能。

這種設計思想實際上是遵循了著名的「二八定律」。在讀寫數據庫時,每次的 I/O 過程消耗很大,但其實 80% 的 request 都是在用那 20% 的數據,因此把這 20% 的數據放在內存裏,就可以極大的提升總體的效率。

總之,Cache 的目的是存一些能夠複用的信息,方便未來的請求快速得到。

LRU Cache

那咱們知道了 LRU,瞭解了 Cache,合起來就是 LRU Cache 了:

當 Cache 儲存滿了的時候,使用 LRU 算法把老傢伙清理出去。

思路詳解

說了這麼多,Let's get to the meat of the problem!

這道經典題你們都知道是要用 HashMap + Doubly Linked List,或者說用 Java 中現成的 LinkedHashMap,可是,爲何?你是怎麼想到用這兩個數據結構的?面試的時候不講清楚這個,不說清楚思考過程,代碼寫對了也沒用。

和在工做中的設計思路相似,沒有人會告訴咱們要用什麼數據結構,通常的思路是先想有哪些 operations,而後根據這些操做,再去看哪些數據結構合適。

分析 Operations

那咱們來分析一下對於這個 LRU Cache 須要有哪些操做:

  1. 首先最基本的操做就是可以從裏面讀信息,否則以後快速獲取是咋來的;
  2. 那還得能加入新的信息,新的信息進來就是 most recently used 了;
  3. 在加新信息以前,還得看看有沒有空位,若是沒有空間了,得先把老的踢出去,那就須要可以找到那個老傢伙而且刪除它;
  4. 那若是加入的新信息是緩存裏已經有的,那意思就是 key 已經有了,要更新 value,那就只須要調整一下這條信息的 priority,它已經從那次被寵幸晉升爲貴妃了~

找尋數據結構

那第一個操做很明顯,咱們須要一個可以快速查找的數據結構,非 HashMap 莫屬,還不瞭解 HashMap 原理和設計規則的在公衆號內發消息「HashMap」,送你一篇爆款文章;

但是後面的操做 HashMap 就不頂用了呀。。。

來來來,咱們來數一遍基本的數據結構:
Array, LinkedList, Stack, Queue, Tree, BST, Heap, HashMap

在作這種數據結構的題目時,就這樣把全部的數據結構列出來,一個個來分析,有時候不是由於這個數據結構不行,而是由於其餘的數據結構更好。

怎麼叫更好?忘了咱們的衡量標準嘛!時空複雜度,趕忙複習遞歸那篇文章,公衆號內回覆「遞歸」便可得到。

那咱們的分析以下:

Array, Stack, Queue 這三種本質上都是 Array 實現的(固然 Stack, Queue 也能夠用 LinkedList 來實現。。),一會插入新的,一會刪除老的,一會調整下順序,array 不是不能作,就是得 O(n) 啊,用不起。

BST 同理,時間複雜度是 O(logn).

Heap 即使能夠,也是 O(logn).

LinkedList,有點能夠哦,按照從老到新的順序,排排站,刪除、插入、移動,均可以是 O(1) 的誒!可是刪除時我還須要一個 previous pointer 才能刪掉,因此我須要一個 Doubly LinkedList.

那麼咱們的數據結構敲定爲:
HashMap + Doubly LinkedList

定義清楚數據結構的內容

選好了數據結構以後,還須要定義清楚每一個數據結構具體存儲的是是什麼,這兩個數據結構是如何聯繫的,這纔是核心問題。

咱們先想個場景,在搜索引擎裏,你輸入問題 Questions,谷歌給你返回答案 Answer。

那咱們就先假設這兩個數據結構存的都是 <Q, A>,而後來看這些操做,若是都很順利,那沒問題,若是有問題,咱們再調整。

那如今咱們的 HashMap 和 LinkedList 長這樣:

而後咱們回頭來看這四種操做:

操做 1,沒問題,直接從 HashMap 裏讀取 Answer 便可,O(1);

操做 2,新加入一組 Q&A,兩個數據結構都得加,那先要判斷一下當前的緩存裏有沒有這個 Q,那咱們用 HashMap 判斷,

  • 若是沒有這個 Q,加進來,都沒問題;
  • 若是已經有這個 Q,HashMap 這裏要更新一下 Answer,而後咱們還要把 LinkedList 的那個 node 移動到最後或者最前,由於它變成了最新被使用的了嘛。

但是,怎麼找 LinkedList 的這個 node 呢?一個個 traverse 去找並非咱們想要的,由於要 O(n) 的時間嘛,咱們想用 O(1) 的時間操做。

那也就是說這樣記錄是不行的,還須要記錄 LinkedList 中每一個 ListNode 的位置,這就是本題關鍵所在。

那天然是在 HashMap 裏記錄 ListNode 的位置這個信息了,也就是存一下每一個 ListNode 的 reference。

想一想其實也是,HashMap 裏沒有必要記錄 Answer,Answer 只須要在 LinkedList 裏記錄就能夠了。

以後咱們更新、移動每一個 node 時,它的 reference 也不須要變,因此 HashMap 也不用改動,動的只是 previous, next pointer.

那再一想,其實 LinkedList 裏也不必記錄 Question,反正 HashMap 裏有。

這兩個數據結構是相互配合來用的,不須要記錄同樣的信息。

更新後的數據結構以下:

這樣,咱們才分析出來用什麼數據結構,每一個數據結構裏存的是什麼,物理意義是什麼。

那其實,Java 中的 LinkedHashMap 已經作了很好的實現。可是,即使面試時可使用它,也是這麼一步步推導出來的,而不是一看到題目就知道用它,那一看就是背答案啊。

有同窗問我,若是面試官問我這題作沒作過,該怎麼回答?

答:實話實說。

真誠在面試、工做中都是很重要的,因此實話實說就行了。但若是面試官沒問,就沒必要說。。。

其實面試官是不 care 你作沒作過這道題的,由於你們都刷題,基本都作過,問這個問題沒有意義。只要你能把問題分析清楚,講清楚邏輯,作過了又怎樣?不少作過了題的人是講不清楚的。。。

總結

那咱們再總結一下那四點操做:

第一個操做,也就是 get() API,沒啥好說的;

二三四,是 put() API,有點小麻煩:

畫圖的時候邊講邊寫,每一步都從 high level 到 detail 再到代碼,把代碼模塊化。

  • 好比「Welcome」是要把這個新的信息加入到 HashMap 和 LinkedList 裏,那我會用一個單獨的 add() method 來寫這塊內容,那在下面的代碼裏我取名爲 appendHead(),更精準;
  • 「踢走老的」這裏我也是用一個單獨的 remove() method 來寫的。

當年我把這圖畫出來,面試官就沒讓我寫代碼了,直接下一題了...

那若是面試官還讓你寫,就寫唄。。。

class LRUCache {
  // HashMap: <key = Question, value = ListNode>
  // LinkedList: <Answer>

  public static class Node {
      int key;
      int val;
      Node next;
      Node prev;
      public Node(int key, int val) {
          this.key = key;
          this.val = val;
      }
  }

  Map<Integer, Node> map = new HashMap<>();
  private Node head;
  private Node tail;
  private int cap;

  public LRUCache(int capacity) {
      cap = capacity;
  }

  public int get(int key) {
      Node node = map.get(key);
      if(node == null) {
          return -1;
      } else {
          int res = node.val;
          remove(node);
          appendHead(node);
          return res;
      }
  }

  public void put(int key, int value) {
      // 先 check 有沒有這個 key
      Node node = map.get(key);
      if(node != null) {
          node.val = value;
          // 把這個node放在最前面去
          remove(node);
          appendHead(node);
      } else {
          node = new Node(key, value);
          if(map.size() < cap) {
              appendHead(node);
              map.put(key, node);
          } else {
              // 踢走老的
              map.remove(tail.key);
              remove(tail);
              appendHead(node);
              map.put(key, node);
          }
      }
  }

  private void appendHead(Node node) {
      if(head == null) {
          head = tail = node;
      } else {
          node.next = head;
          head.prev = node;
          head = node;
      }
  }

  private void remove(Node node) {
      // 這裏我寫的可能不是最 elegant 的,可是是很 readable 的
      if(head == tail) {
          head = tail = null;
      } else {
          if(head == node) {
              head = head.next;
              node.next = null;
          } else if (tail == node) {
              tail = tail.prev;
              tail.next = null;
              node.prev = null;
          } else {
              node.prev.next = node.next;
              node.next.prev = node.prev;
              node.prev = null;
              node.next = null;
          }
      }
  }


}

/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache obj = new LRUCache(capacity);
* int param_1 = obj.get(key);
* obj.put(key,value);
*/

總結


那再回到面試上來,爲何不少面試是以算法考察爲主的?這樣的面試道理何在?算法題面試真的能衡量一我的的工做能力嗎?(固然了,對於有些工做經驗的人還會考察系統設計方面的內容。)

這是我一直在思考的問題,工做以後愈發以爲,這樣的面試真的是有效的。

由於咱們須要的是可以去解決未知的問題的能力,知識可能會被遺忘,可是思考問題的方式方法是一直跟隨着咱們的,也是咱們須要不斷提升的。那麼在基本功紮實的前提下,有正確的方法和思路作指引,nothing is impossible.

若是你喜歡這篇文章,記得給我點贊留言哦~大家的支持和承認,就是我創做的最大動力,咱們下篇文章見!

我是小齊,紐約程序媛,終生學習者,天天晚上 9 點,雲自習室裏不見不散!

更多幹貨文章見個人 Github: https://github.com/xiaoqi6666/NYCSDE


參考資料

[1]

Cache replacement policies: https://en.wikipedia.org/wiki/Cache_replacement_policies

相關文章
相關標籤/搜索