12、哈希表在缓存系统中的应用:LRU缓存原理

说到哈希表在实际项目中的应用,缓存系统绝对是最经典的场景之一。我个人在早年做嵌入式开发时,就吃过不少没用好缓存的亏。今天咱们就来聊聊缓存系统中一个非常核心的算法——LRU(Least Recently Used,最近最少使用)。

你想想看,内存是有限的,但数据是无限的。当缓存满了,必须淘汰一些旧数据,腾出空间给新数据。那问题来了:该淘汰谁?

LRU 的思路很简单:如果数据最近被访问过,那么将来被访问的概率也更高。所以,淘汰那些最久没被访问的数据。

核心思想:LRU 缓存维护一个固定大小的容器,每次访问数据时,把该数据移到“最近使用”的位置。当容器满了,移除“最久未使用”的那个数据。

12.1 哈希表 + 双向链表实现 LRU

纯用数组或链表实现 LRU 其实不难,但效率是个大问题。比如用单链表,每次查找一个节点需要 O(n) 时间,这在缓存场景下是不可接受的。

那怎么办?哈希表 + 双向链表 这个组合拳,就是业界公认的标准解法。我当年第一次看到这个设计时,忍不住拍了一下大腿——太巧妙了。

具体来说:

  • 哈希表:负责 O(1) 的查找。key 映射到链表节点。
  • 双向链表:负责 O(1) 的插入和删除。节点按访问时间排序,头部是最近使用的,尾部是最久未使用的。

为什么用双向链表而不是单向?因为我们需要在 O(1) 时间内删除一个节点。单向链表删除节点需要知道前驱节点,而双向链表每个节点都保存了前驱指针,直接就能删。

我的经验:曾经有个项目,我一开始用了单向链表 + 哈希表,结果删除节点时还得遍历链表找前驱,性能直接崩了。后来改成双向链表,问题瞬间解决。这个坑我替你们踩过了。

12.2 数据结构定义

我们先定义双向链表的节点和 LRU Cache 的结构体:

// 双向链表节点
typedef struct DListNode {
    int key;                // 键
    int value;              // 值
    struct DListNode *prev; // 前驱指针
    struct DListNode *next; // 后继指针
} DListNode;

// LRU Cache 结构
typedef struct LRUCache {
    DListNode *head;        // 虚拟头节点(方便操作)
    DListNode *tail;        // 虚拟尾节点(方便操作)
    int capacity;           // 缓存容量
    int size;               // 当前大小
    DListNode **hashTable;  // 哈希表,存储 key -> 节点指针
} LRUCache;

这里我用了虚拟头尾节点,这是个很实用的小技巧。没有虚拟节点的话,每次插入删除都要判断边界条件,代码会变得很啰嗦。有了虚拟节点,所有操作都统一了。

12.3 核心操作实现

LRU Cache 需要实现三个核心操作:

  1. 创建缓存:初始化哈希表和双向链表
  2. 获取数据 get(key):如果 key 存在,返回 value 并将节点移到头部;否则返回 -1
  3. 写入数据 put(key, value):如果 key 存在,更新 value 并移到头部;如果不存在,插入到头部。如果容量满了,先删除尾部节点再插入

其中,将节点移到头部 这个操作可以拆解为两步:先从链表中删除该节点,再插入到头部。我们把它封装成一个函数:

// 从链表中移除一个节点
void removeNode(LRUCache *cache, DListNode *node) {
    node->prev->next = node->next;
    node->next->prev = node->prev;
}

// 将节点插入到头部(紧跟在虚拟头节点之后)
void addToHead(LRUCache *cache, DListNode *node) {
    node->prev = cache->head;
    node->next = cache->head->next;
    cache->head->next->prev = node;
    cache->head->next = node;
}

// 将节点移动到头部 = 移除 + 插入
void moveToHead(LRUCache *cache, DListNode *node) {
    removeNode(cache, node);
    addToHead(cache, node);
}

有了这些辅助函数,get 和 put 的实现就清晰多了:

int lruGet(LRUCache *cache, int key) {
    // 哈希表中查找
    DListNode *node = cache->hashTable[key];
    if (node == NULL) {
        return -1;  // 没找到
    }
    // 找到了,移到头部
    moveToHead(cache, node);
    return node->value;
}

void lruPut(LRUCache *cache, int key, int value) {
    DListNode *node = cache->hashTable[key];
    if (node != NULL) {
        // key 已存在,更新值并移到头部
        node->value = value;
        moveToHead(cache, node);
        return;
    }
    // key 不存在,创建新节点
    DListNode *newNode = (DListNode *)malloc(sizeof(DListNode));
    newNode->key = key;
    newNode->value = value;
    
    // 如果缓存满了,先淘汰尾部节点
    if (cache->size == cache->capacity) {
        DListNode *toRemove = cache->tail->prev; // 真正的最久未使用节点
        removeNode(cache, toRemove);
        cache->hashTable[toRemove->key] = NULL; // 从哈希表中移除
        free(toRemove);
        cache->size--;
    }
    
    // 插入新节点到头部
    addToHead(cache, newNode);
    cache->hashTable[key] = newNode;
    cache->size++;
}

注意:哈希表这里我用的是简单的数组实现,实际项目中建议用更完善的哈希表(比如链地址法)。另外,别忘了处理内存释放,否则会有内存泄漏。我曾经在一个线上服务中因为忘了 free 淘汰的节点,内存一路飙升,最后 OOM 了。

12.4 整体流程可视化

下面这张图展示了 LRU Cache 的核心工作流程。我特意画了哈希表和双向链表之间的对应关系,方便你理解它们是如何协同工作的。

LRU Cache 核心流程(哈希表 + 双向链表) 哈希表 key=1 → 节点A key=2 → 节点B key=3 → 节点C key=4 → 节点D 双向链表(头部=最近使用,尾部=最久未使用) head 节点A key=1, val=10 节点B key=2, val=20 节点C key=3, val=30 节点D key=4, val=40 tail 操作说明: • get(key):查找并移到头部 • put(key,val):插入到头部 • 满时淘汰尾部节点

12.5 实战:实现一个简单的 LRU Cache

好了,理论讲完了,咱们动手写一个完整的 LRU Cache。下面这个实现包含了初始化、销毁、get 和 put 四个接口。你可以直接拿去用,或者在此基础上扩展。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 双向链表节点
typedef struct DListNode {
    int key;
    int value;
    struct DListNode *prev;
    struct DListNode *next;
} DListNode;

// LRU Cache
typedef struct {
    DListNode *head;
    DListNode *tail;
    int capacity;
    int size;
    DListNode **hashTable;  // 简单哈希表,实际项目建议用链地址法
} LRUCache;

// 创建节点
DListNode* createNode(int key, int value) {
    DListNode *node = (DListNode *)malloc(sizeof(DListNode));
    node->key = key;
    node->value = value;
    node->prev = NULL;
    node->next = NULL;
    return node;
}

// 初始化 LRU Cache
LRUCache* lruCreate(int capacity) {
    LRUCache *cache = (LRUCache *)malloc(sizeof(LRUCache));
    cache->capacity = capacity;
    cache->size = 0;
    
    // 虚拟头尾节点
    cache->head = createNode(0, 0);
    cache->tail = createNode(0, 0);
    cache->head->next = cache->tail;
    cache->tail->prev = cache->head;
    
    // 初始化哈希表(假设 key 范围 0-9999)
    cache->hashTable = (DListNode **)calloc(10000, sizeof(DListNode *));
    
    return cache;
}

// 移除节点
void removeNode(LRUCache *cache, DListNode *node) {
    node->prev->next = node->next;
    node->next->prev = node->prev;
}

// 插入到头部
void addToHead(LRUCache *cache, DListNode *node) {
    node->prev = cache->head;
    node->next = cache->head->next;
    cache->head->next->prev = node;
    cache->head->next = node;
}

// 移动到头部
void moveToHead(LRUCache *cache, DListNode *node) {
    removeNode(cache, node);
    addToHead(cache, node);
}

// 获取值
int lruGet(LRUCache *cache, int key) {
    DListNode *node = cache->hashTable[key];
    if (node == NULL) {
        return -1;
    }
    moveToHead(cache, node);
    return node->value;
}

// 写入值
void lruPut(LRUCache *cache, int key, int value) {
    DListNode *node = cache->hashTable[key];
    if (node != NULL) {
        node->value = value;
        moveToHead(cache, node);
        return;
    }
    
    DListNode *newNode = createNode(key, value);
    
    if (cache->size == cache->capacity) {
        // 淘汰尾部节点
        DListNode *toRemove = cache->tail->prev;
        removeNode(cache, toRemove);
        cache->hashTable[toRemove->key] = NULL;
        free(toRemove);
        cache->size--;
    }
    
    addToHead(cache, newNode);
    cache->hashTable[key] = newNode;
    cache->size++;
}

// 销毁缓存
void lruFree(LRUCache *cache) {
    DListNode *cur = cache->head->next;
    while (cur != cache->tail) {
        DListNode *tmp = cur;
        cur = cur->next;
        free(tmp);
    }
    free(cache->head);
    free(cache->tail);
    free(cache->hashTable);
    free(cache);
}

// 测试代码
int main() {
    LRUCache *cache = lruCreate(3);
    
    lruPut(cache, 1, 10);
    lruPut(cache, 2, 20);
    lruPut(cache, 3, 30);
    
    printf("get(1) = %d\n", lruGet(cache, 1)); // 10
    printf("get(2) = %d\n", lruGet(cache, 2)); // 20
    
    lruPut(cache, 4, 40); // 淘汰 key=3
    
    printf("get(3) = %d\n", lruGet(cache, 3)); // -1(已被淘汰)
    printf("get(4) = %d\n", lruGet(cache, 4)); // 40
    
    lruFree(cache);
    return 0;
}

避坑指南:我曾经在实现 LRU 时犯过一个低级错误——在 moveToHead 之后忘记更新哈希表。结果 get 操作返回了旧值,排查了半天才发现。记住:哈希表和链表必须保持同步,这是 LRU 实现中最容易出错的地方。

12.6 复杂度分析

操作 时间复杂度 空间复杂度
get(key) O(1) O(1)
put(key, value) O(1) O(1)
整体空间 O(capacity)

说白了,哈希表保证了查找是 O(1),双向链表保证了插入和删除也是 O(1)。这两个数据结构一结合,LRU 的所有操作都是常数时间。这也是为什么这个方案能成为工业级标准。

嗯,LRU 缓存的内容就讲到这里。这个算法在 Redis、Memcached、操作系统页面置换等场景中都有广泛应用。你如果能把上面这份代码吃透,面试中遇到 LRU 相关的问题基本就稳了。


公众号:蓝海资料掘金营,微信deep3321