缓存模拟

用哈希表定位缓存块,用每组的双向链表维护 LRU 顺序,并按替换先写回再读入的顺序输出内存操作。

OJ: shumeng

题目 ID: CSP202412C

难度:普及+/提高-

标签:模拟LRU哈希表双向链表

日期: 2026-07-31 16:21

形式化题目

缓存共 NN 组,每组 nn 个缓存行;内存块 aa 所属组为 (a/n)modN(a / n) \bmod N。依次执行 qq 条指令(0 读、1 写):

  • 命中:直接在缓存中访问,写指令把该缓存行标记为脏;
  • 未命中:从内存读入该块;若组已满,按 LRU 规则淘汰最久未使用的缓存行,脏行需先写回内存。

输出所有实际发生的内存操作:读入块输出 0 块号,写回脏块输出 1 块号,按发生顺序输出。

思路

核心是同时维护三件事:块到缓存行的定位、每组的 LRU 顺序、每个缓存行的脏位。

朴素做法:每组用 vector 扫描

先看直接模拟:每组用一个 vector 保存缓存行,命中时线性查找,未命中且满时淘汰尾部,命中或新插入都移到头部。

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-07-31 16:21
 * update_at: 2026-08-17 22:39
 */
// brute.cpp:小数据暴力解,用 vector 顺序扫描每组中的缓存行来模拟 LRU。
#include <bits/stdc++.h>
using namespace std;

struct Entry {
    int block;  // 缓存行保存的内存块编号
    bool dirty; // 是否被写改过
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, group_count, q;
    cin >> n >> group_count >> q;
    vector<vector<Entry> > cache(group_count); // 每组一个 vector,表头是最新使用的

    for (int operation = 1; operation <= q; operation++) {
        int type, block;
        cin >> type >> block;
        int group = (block / n) % group_count;

        // 线性查找当前组是否已缓存该块
        int hit = -1;
        for (int i = 0; i < (int)cache[group].size(); i++) {
            if (cache[group][i].block == block) {
                hit = i;
                break;
            }
        }

        if (hit != -1) {
            // 命中:按操作更新脏位,并移到表头
            Entry entry = cache[group][hit];
            if (type == 1) entry.dirty = true;
            cache[group].erase(cache[group].begin() + hit);
            cache[group].insert(cache[group].begin(), entry);
            continue;
        }

        // 未命中且已满:先写回最久未使用的脏块,再淘汰
        if ((int)cache[group].size() == n) {
            Entry victim = cache[group].back();
            if (victim.dirty) cout << "1 " << victim.block << '\n';
            cache[group].pop_back();
        }
        cout << "0 " << block << '\n';
        Entry entry;
        entry.block = block;
        entry.dirty = (type == 1);
        cache[group].insert(cache[group].begin(), entry);
    }

    return 0;
}

做法能正确确认操作顺序,但每次访问可能扫描整组并移动元素,只适合小数据。

主解:哈希定位 + 双向链表

把两个动作分开:

  1. 用哈希表 location 记录“内存块 → 缓存槽位”,期望 O(1)O(1) 判断命中并定位;
  2. 每组维护一条双向链表,表头是最近使用、表尾是最久未使用的缓存行。命中时把节点移到表头;未命中且已满时淘汰表尾。

缓存总容量 n×N65536n \times N \le 65536,可预先为每组分配 nn 个连续槽位:空槽直接使用,满组替换时复用 LRU 尾节点。

输出顺序

未命中时顺序很重要:若尾节点是脏的,先输出 1 old_block 写回,再输出 0 block 读入。写指令命中或读入后只把缓存行标记为脏,不会立刻再产生一次内存写。

样例推演

下表记录样例中组 00 的 LRU 状态(星号表示脏行):

指令 访问 结果 实际内存操作 处理后 LRU(表头在左)
1 读 0 未命中 0 0 0
2 读 1 未命中 0 1 1, 0
3 写 2 未命中 0 2 2*, 1, 0
4 读 1 命中 1, 2*, 0
5 写 0 命中 0*, 1, 2*
6 读 32 未命中 0 32 32, 0*, 1, 2*
7 写 33 未命中 1 20 33 33*, 32, 0*, 1
8 读 34 未命中 0 34 34, 33*, 32, 0*

注意第 7 条:组满后淘汰的尾节点是块 2,它曾被写入过,所以先写回 1 2 再读入 0 33

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-07-31 16:21
 * update_at: 2026-08-17 22:39
 */
#include <bits/stdc++.h>
using namespace std;

struct CacheLine {
    int block;
    int previous;
    int next;
    bool dirty;
};

struct CacheSet {
    int head;
    int tail;
    int used;
};

vector<CacheLine> lines;
vector<CacheSet> cache_sets;

void remove_from_lru(int group, int index) {
    int previous = lines[index].previous;
    int next = lines[index].next;

    if (previous == -1) cache_sets[group].head = next;
    else lines[previous].next = next;
    if (next == -1) cache_sets[group].tail = previous;
    else lines[next].previous = previous;

    lines[index].previous = -1;
    lines[index].next = -1;
}

void insert_to_front(int group, int index) {
    int old_head = cache_sets[group].head;
    lines[index].previous = -1;
    lines[index].next = old_head;
    if (old_head == -1) cache_sets[group].tail = index;
    else lines[old_head].previous = index;
    cache_sets[group].head = index;
}

void touch(int group, int index) {
    if (cache_sets[group].head == index) return;
    remove_from_lru(group, index);
    insert_to_front(group, index);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, group_count, q;
    cin >> n >> group_count >> q;

    lines.resize(n * group_count);
    cache_sets.resize(group_count);
    for (int group = 0; group < group_count; group++) {
        cache_sets[group].head = -1;
        cache_sets[group].tail = -1;
        cache_sets[group].used = 0;
    }
    unordered_map<int, int> location;
    location.reserve(q * 2 + 1);

    for (int operation = 1; operation <= q; operation++) {
        int type, block;
        cin >> type >> block;
        int group = (block / n) % group_count;

        unordered_map<int, int>::iterator found = location.find(block);
        if (found != location.end()) {
            int index = found->second;
            if (type == 1) lines[index].dirty = true;
            touch(group, index);
            continue;
        }

        // 未命中时先处理被替换缓存行的写回,再从内存读入新块。
        int index;
        if (cache_sets[group].used < n) {
            index = group * n + cache_sets[group].used;
            cache_sets[group].used++;
        } else {
            index = cache_sets[group].tail;
            if (lines[index].dirty) cout << "1 " << lines[index].block << '\n';
            location.erase(lines[index].block);
            remove_from_lru(group, index);
        }

        cout << "0 " << block << '\n';
        lines[index].block = block;
        lines[index].dirty = (type == 1);
        location[block] = index;
        insert_to_front(group, index);
    }

    return 0;
}

复杂度

设缓存总容量 C=nNC = nN、指令数 qq

  • 时间:哈希表操作期望 O(1)O(1),每条指令只做常数次链表操作,总期望 O(q)O(q)
  • 空间:缓存行与哈希表 O(C+q)O(C + q)

总结

缓存模拟的关键不是只判断命中,而是同时维护组映射、LRU 顺序和脏位。哈希表负责快速定位,双向链表负责 O(1)O(1) 调整顺序;发生替换时严格按照“脏块写回、目标块读入”的顺序输出。

图示解析

这张流程图概括一条读写指令的处理路径:

text
访问内存块 a
|- 哈希表命中
|  `- 按操作更新脏位并移动到 LRU 表头
`- 未命中
   |- 组未满:使用空槽位
   `- 组已满:检查 LRU 尾节点并写回脏块
      `- 输出读取 a,插入表头,写操作标记为脏

命中和未命中都必须更新 LRU 顺序。只有替换脏块才产生实际内存写,未命中的读入操作始终先产生实际内存读。