National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 72 24°C PM2.5 13
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 18

LRU 快取與資料結構的選擇

雜湊表加雙向串列做出 O(1) 的快取,以及依操作挑選資料結構
Interaction實作 25 minute(s)

Learning Objectives

  • 說明快取為什麼要有淘汰策略,以及 LRU(淘汰最久沒用的)在做什麼
  • 說明只用串列或只用雜湊表實作 LRU 時,哪一個操作會變成 O(n)
  • 用雜湊表加上有頭尾哨兵節點的雙向鏈結串列實作 LRU,追蹤 get、put、淘汰時改了哪些指標
  • 使用 Python 的 OrderedDict、Java 的 LinkedHashMap 做出 LRU 快取
  • 列出程式最常做的操作,依操作成本選擇或組合資料結構

快取放不下時,要丟掉誰

快取(cache)是把「最近可能還會用到」的資料放在比較快的地方:瀏覽器把看過的圖片存在本機、 資料庫把常讀的資料頁留在記憶體、CPU 把剛讀過的記憶體內容放在晶片上。快取比原來的地方快,但容量有限, 放滿之後要放新資料,就得先丟掉一筆。決定丟掉誰的規則叫淘汰策略(eviction policy)。

LRU(least recently used,最近最少使用)的規則是:丟掉最久沒被用過的那一筆。 背後的想法是:剛用過的資料,很可能馬上又會用到;很久沒碰的,大概暫時用不到了。

LRU 快取要支援兩個操作,而且希望都是 O(1),因為快取就是為了快:

操作 做什麼
get(key) 有這個 key 就回傳值(命中,hit),並把它記成「最近用過」;沒有就回傳 -1(沒命中,miss)
put(key, val) 放入或更新一筆,記成「最近用過」;快取滿了就先淘汰最久沒用的那一筆

這一課把前面學過的兩個結構組合起來完成它,最後整理整個資料結構軌:遇到一個問題,怎麼挑資料結構。

只用串列記順序:每次都要搬

先試直覺的做法:雜湊表 data 存值,另外用一個陣列 order 記使用的先後。get 查雜湊表很快,但「把 key 搬到最後面」要先一格一格找到它、再把後面的元素全部往前搬;淘汰第 0 格也一樣要搬。看 ▲ i 在 order 裡走了多少格。

只用一種結構都不夠

LRU 要同時做到兩件事:用 key 很快找到資料,以及很快調整與找出使用順序。

做法 get 找資料 更新成「最近用過」 找出最久沒用的並淘汰
雜湊表+陣列記順序(上一個範例) O(1) O(n):找到再搬移 O(n):拿掉第 0 格要搬全部
只用鏈結串列 O(n):從頭找 key O(1) O(1)
只用雜湊表,每筆記「上次使用時間」 O(1) O(1):改時間 O(n):掃過全部找最小的時間
雜湊表+堆積(依時間排) O(1) O(log n):改了時間要調整堆積 O(log n)

每種結構都只擅長一半。解法是兩種一起用,各管一件事:

  • 雜湊表:key → 節點。負責「用 key 找到資料」,O(1)
  • 雙向鏈結串列:依使用順序串起所有節點,最前面最近用過,最後面最久沒用。負責「調整順序」與「找出最久沒用的」

關鍵在雜湊表存的不是值,而是串列裡那個節點的位置(Python 是物件參考、C++ 是指標、Java 是參考)。 拿到節點之後,從串列裡拿掉、再接到最前面,只要改幾個指標,不必在串列裡找。

還有兩個細節:

  1. 為什麼要雙向:從串列拿掉一個節點,要改「前一個節點的 next」。單向串列不知道前一個是誰,只能從頭找(第 06 課);雙向串列的 node.prev 直接就是。
  2. 節點裡要存 key:淘汰時拿到的是串列尾端的節點,要把它從雜湊表刪掉,得知道它的 key 是什麼。

雙向串列加頭尾哨兵:拿掉與接到最前面

head、tail 是兩個不存資料的哨兵節點(sentinel)。有了它們,串列再空也至少有兩個節點,每一個真正的節點前後一定有鄰居,remove_node 和 add_front 不必特別處理「串列是空的」「拿掉的是第一個」這些情況。看 ⇄ 的變化:拿掉中間的 2 只改兩個指標,接到最前面改四個。

LRU 快取:雜湊表加雙向串列

容量 3。先放入 1、2、3(串列變成 3 ⇄ 2 ⇄ 1,1 最久沒用);get(1) 命中,1 搬到最前面;put(4, 40) 時快取滿了,淘汰尾端的 2。注意每一個操作都只改固定幾個指標、查固定幾次雜湊表,和快取裡有幾筆無關。put(3, 33) 是更新:3 本來就在,只改值並搬到最前面,不會淘汰任何人。

動手做:LRU 快取

左邊是雜湊表,右邊是雙向串列(左端最近用過、右端最久沒用)。按 get(k) 或填好 key、value 按 put,看命中、沒命中、淘汰時兩個結構怎麼一起變,上方的數字是這次操作實際改了幾個指標。

各語言的現成做法

實際寫程式時,Python 和 Java 都有「雜湊表+雙向串列」的現成容器,幾行就能做出 LRU。

Python:collections.OrderedDict 會記住 key 的順序。move_to_end(key) 把 key 搬到最後面, popitem(last=False) 拿掉最前面的一筆(last 預設是 True,拿最後面的)。

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()          # 依使用順序排:最前面最久沒用

    def get(self, key):
        if key not in self.data:
            return -1
        self.data.move_to_end(key)         # 用過了,搬到最後面
        return self.data[key]

    def put(self, key, val):
        if key in self.data:
            self.data.move_to_end(key)
        self.data[key] = val
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)  # 拿掉最前面(最久沒用)的

要快取的是「函數的回傳值」時,直接用 functools.lru_cache 裝飾器,它內部就是 LRU。

Java:LinkedHashMap 有一個建構子 LinkedHashMap(initialCapacity, loadFactor, accessOrder),accessOrder 設成 true, 走訪順序就是「最久沒存取的在前、最近存取的在後」;再覆寫 removeEldestEntry,每次放入新資料後,它會問要不要移除最舊的一筆。

import java.util.LinkedHashMap;
import java.util.Map;

class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    LRUCache(int capacity) {
        super(16, 0.75f, true);            // true:依存取順序排列
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;          // 超過容量就移除最久沒用的
    }
}

使用時 cache.getOrDefault(key, -1) 取值、cache.put(key, val) 放入;get、put、getOrDefault 都算一次存取。

C++:標準函式庫沒有現成的,通常用 list(雙向鏈結串列)加 unordered_map 存「key → 串列裡的位置(迭代器)」。 list::splice 可以把一個節點搬到另一個位置,不必複製、也不會讓指向它的迭代器失效:

int capacity = 3;
list<pair<int, int>> items;                                // 最前面最近用過
unordered_map<int, list<pair<int, int>>::iterator> pos;    // key → 在 items 裡的位置

int get(int key) {
    auto it = pos.find(key);
    if (it == pos.end()) return -1;
    items.splice(items.begin(), items, it->second);        // 搬到最前面,O(1)
    return it->second->second;
}

void put(int key, int val) {
    auto it = pos.find(key);
    if (it != pos.end()) {
        it->second->second = val;
        items.splice(items.begin(), items, it->second);
        return;
    }
    if ((int)items.size() == capacity) {
        pos.erase(items.back().first);                     // 串列節點存著 key
        items.pop_back();
    }
    items.push_front({key, val});
    pos[key] = items.begin();
}

三種寫法的結構和前面自己做的完全相同:雜湊表找節點,雙向串列管順序。

想一想:LRU 快取

1.

容量 2 的 LRU 快取依序執行:put(1)、put(2)、get(1)、put(3)。哪一個 key 被淘汰?

2.

LRU 的串列節點為什麼除了值,還要存 key?

3.

把 LRU 的雙向串列換成單向串列(只有 next),哪一個操作會變慢?

第 1 次作答

依操作挑資料結構

LRU 的解法示範了選資料結構的一般步驟:

  1. 列出程式要做哪些操作,以及各自多常做(LRU:用 key 查、更新順序、找最舊的)
  2. 找出每個操作最適合的結構,看它的成本(查表)
  3. 沒有一種結構能讓所有操作都夠快時,組合兩種,讓它們指向同一份資料
  4. 最後才考慮記憶體、常數、程式好不好寫、語言有沒有內建

整個資料結構軌學過的結構,依「要做什麼」整理如下(n 是元素個數;雜湊表是平均情況):

最常做的操作 選擇 成本 課
依位置存取第 i 個 陣列、動態陣列 O(1) 02、03
在尾端加入 動態陣列 攤銷 O(1) 03
兩端加入、拿出(先進先出) 環狀佇列、雙端佇列 O(1) 04
只在一端放、拿(後進先出) 堆疊 O(1) 05
已經拿到節點,在旁邊插入、刪除 鏈結串列(雙向) O(1) 06
用 key 查、加入、刪除,不在乎順序 雜湊表 O(1) 08
用 key 查,還要依大小走訪、找範圍、找前一個後一個 平衡二元搜尋樹(AVL、紅黑樹) O(log n) 10、11、12
資料大到放在磁碟上,要盡量少讀幾次 B 樹、B+ 樹 O(log n),每次讀一個大節點 13
每次拿出最小(或最大)的 堆積(優先佇列) 加入、拿出 O(log n),看最小 O(1) 14
依前綴找字串、自動完成 Trie O(字串長度) 15
表示誰和誰相連 鄰接串列(稀疏)、鄰接矩陣(稠密) 列出鄰居 O(deg)、查邊 O(1) 16
區間和、區間最小值,資料還會修改 線段樹、Fenwick 樹 O(log n) 17
用 key 查,還要知道誰最久沒用 雜湊表+雙向鏈結串列 O(1) 18

常見的組合還有:雜湊表+堆積(優先佇列裡的元素要能依 key 找到並修改優先順序)、 平衡樹+雜湊表(依 key 查的同時維持排名)。看到一個需求同時要求兩種很不一樣的操作時,先想想能不能讓兩個結構指向同一份資料。

情境題:挑一個資料結構

1.

一個成績系統常常要回答「分數在 60 到 70 之間的有哪些學生」,也會不斷新增、刪除成績。最適合用哪一種結構存分數?

2.

醫院急診依病情嚴重程度看診,隨時有新病人進來,每次叫號都叫目前最嚴重的。用哪一種結構?

3.

手機輸入法要在使用者打出前幾個字母時,列出所有以它開頭的單字。用哪一種結構?

4.

瀏覽器的「上一頁、下一頁」:按上一頁回到前一個頁面,按下一頁再回來;開新頁面時,下一頁的紀錄就清空。最自然的做法是?

第 1 次作答

實作:LRU 的 get 與淘汰

remove_node、add_front 和 put 的大部分已經寫好。請完成兩個地方:lru_get 命中時要把節點搬到最前面再回傳值;lru_put 在快取滿的時候要淘汰最久沒用的節點(從串列拿掉、從雜湊表刪掉,並印出「 淘汰 k」)。這一次容量是 2。如果 get 忘了搬動節點,被淘汰的會是錯的 key,輸出就會不同。

你的輸出
預期輸出

重點整理

  • 快取容量有限,放滿時依淘汰策略丟掉一筆;LRU 丟掉最久沒用的
  • 只用陣列記順序:更新順序與淘汰是 O(n);只用串列:找 key 是 O(n);只用雜湊表記時間:找最舊的是 O(n)
  • 雜湊表(key → 節點)+雙向鏈結串列(依使用順序):get、put、淘汰都是 O(1)。串列要雙向,拿掉節點才不必找前一個;節點要存 key,淘汰時才能刪雜湊表
  • 頭尾哨兵節點讓插入與刪除不必處理空串列與頭尾的特殊情況
  • Python 用 OrderedDict(move_to_end、popitem(last=False))或 functools.lru_cache;Java 用 LinkedHashMap(容量, 0.75f, true) 加 removeEldestEntry;C++ 用 list 加 unordered_map 與 splice
  • 選資料結構:先列出操作與頻率,查每種結構的成本;一種不夠就組合兩種,讓它們指向同一份資料

資料結構軌到這裡結束。十八課裡的每一種結構都是在「哪些操作要快、願意多付出多少空間」之間的一種取捨; 遇到新問題時,從要做的操作出發,你就能判斷該用哪一種,或怎麼把它們組合起來。演算法軌會繼續用這些結構解決排序、搜尋、最短路徑等問題。

這一課完成了!
看我的學習進度
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。