【OD机试题解法笔记】文件缓存系统
题目描述
请设计一个文件缓存系统,该文件缓存系统可以指定缓存的最大值(单位为字节)。
文件缓存系统有两种操作:
- 存储文件(put)
- 读取文件(get)
操作命令为:
- put fileName fileSize
- get fileName
存储文件是把文件放入文件缓存系统中;
读取文件是从文件缓存系统中访问已存在,如果文件不存在,则不作任何操作。
当缓存空间不足以存放新的文件时,根据规则删除文件,直到剩余空间满足新的文件大小位置,再存放新文件。
具体的删除规则为:文件访问过后,会更新文件的最近访问时间和总的访问次数,当缓存不够时,按照第一优先顺序为访问次数从少到多,第二顺序为时间从老到新的方式来删除文件。
输入描述
第一行为缓存最大值 m(整数,取值范围为 0 < m ≤ 52428800)
第二行为文件操作序列个数 n(0 ≤ n ≤ 300000)
从第三行起为文件操作序列,每个序列单独一行,文件操作定义为:
op file_name file_size
file_name 是文件名,file_size 是文件大小
输出描述
输出当前文件缓存中的文件名列表,文件名用英文逗号分隔,按字典顺序排序,如:
a,c
如果文件缓存中没有文件,则输出NONE
备注
- 如果新文件的文件名和文件缓存中已有的文件名相同,则不会放在缓存中
- 新的文件第一次存入到文件缓存中时,文件的总访问次数不会变化,文件的最近访问时间会更新到最新时间
- 每次文件访问后,总访问次数加1,最近访问时间更新到最新时间
- 任何两个文件的最近访问时间不会重复
- 文件名不会为空,均为小写字母,最大长度为10
- 缓存空间不足时,不能存放新文件
- 每个文件大小都是大于 0 的整数
用例1
输入:
50
6
put a 10
put b 20
get a
get a
get b
put c 30
输出:
a,c
用例2
输入:
50
1
get file
输出:
NONE
思考
输入有读取和存储两个操作。定义集合 fileSet 存储文件名称,哈希表 fileSizeMap 存储映射每个文件的大小,key 是文件名,value 是文件大小,readFreqMap 存储映射每个文件的读取频次,priorityQueue 维护一个文件名队列,每次出队的都是最久未被读取的文件名。 读取操作:如果读取的文件不存在,就不执行任何操作;如果文件存在就给该文件的访问频次+1,同时调整优先队列,把读取的文件名移到队尾,每次这样操作,队首的文件一定的最久未被访问的文件。存储操作:每次存储文件判断文件名是否存在,存在就不执行操作;如果不存在,判断剩余存储空间 leftSize 是否大于等于当前文件大小,如果不满足就要删除读取频次最少且最久未被访问的文件,更新 leftSize += deleteFileSize,循环执行此操作直到满足存储需求,如果文件都删完了还是不满足就不执行任何操作。存储文件到 fileSet 中,更新 leftSize -= newFileSize,题目要求存储新文件也要更新最新访问时间,那么我们就把新文件名加到优先队列的队尾,优先队列队尾文件名始终表示最新访问的文件。删除操作:首先查找访问频次最少的文件名,如果只有一个最少的访问频次,直接删这个文件就对了。如果有多个相同的最少访问频次文件名,需要进一步在其中找到最久未被访问的文件,显然由于每次优先队列的队尾都是更新最新访问时间的文件名,队首的文件就是最久未被访问的,遍历优先队列找到最少访问频次文件集合中包含当前文件名的文件中止循环,删除该文件。为什么还遍历队列匹配最少访问频次集合中的文件?难道队列队首文件名可能不在最少访问频次文件集合中?有可能的,虽然队首文件名是最久未被访问的,但是未必就是访问频次最少的文件,假如有个文件 a 在历史访问记录中达到了100次,接着存储了一个新文件 b,这时候文件 b 是最新访问的文件,存在队尾,那么 显然 a 是存在队首的最久未被访问的文件,但它的访问频率达到100次,b 只有一次,显然不能直接删除队首元素 a 。由于 JavaScript 没有优先队列 API,如果不熟悉,做题的时候写个正确的优先队列数据结构也不容易,因此我先用 JS 数组模拟优先队列的特性实现这个算法,实际能在有效时间内通过所有测试用例才是最重要的。然后提供一个 JS 版二叉堆实现的优先队列版本代码。
算法过程(哈希表+数组模拟优先队列)
-
数据结构:
- 用
fileSet
记录缓存中的文件;fileSizesMap
存储文件大小;readFreqMap
记录访问次数;priorityQueue
(数组)按访问时间排序(最新访问在末尾)。 leftSize
追踪剩余缓存空间。
- 用
-
核心操作:
put
操作:- 若文件已存在,直接跳过。
- 若缓存空间不足,按规则淘汰文件:先筛选访问次数最少的文件列表,再从列表中删除
priorityQueue
中最早出现(最久未访问)的文件,直到空间足够。 - 空间足够时,新增文件至缓存,初始化访问次数为0,加入
priorityQueue
末尾。
get
操作:- 若文件存在,访问次数+1,从
priorityQueue
中移除后重新加入末尾(更新为最新访问)。
- 若文件存在,访问次数+1,从
-
结果输出:
缓存中的文件按字典排序,空则输出NONE
。
时间复杂度
- 单次
put
操作:- 淘汰文件时,遍历查找最少访问次数文件的时间为
O(k)
(k
为当前缓存文件数),最坏情况下需淘汰k
次,总复杂度为O(k²)
。 - 新增文件为
O(1)
。
- 淘汰文件时,遍历查找最少访问次数文件的时间为
- 单次
get
操作:- 查找并移动文件在
priorityQueue
中的位置为O(k)
。
- 查找并移动文件在
- 整体复杂度:
设操作总数为n
,单个操作涉及的最大文件数为k
(≤缓存可容纳的最大文件数),总时间复杂度为O(n·k²)
。
(注:因k
通常远小于n
,实际性能可接受,尤其适合中小规模操作场景。)
参考代码(Least Frequence Used + JS 数组模拟优先队列)
function solution(totalSize, operations) {const fileSet = new Set();const fileSizesMap = new Map();const readFreqMap = new Map();const priorityQueue = [];let leftSize = totalSize;for (const operation of operations) {const [op, fileName, fileSize] = operation;if (op === 'put') {if (fileSet.has(fileName)) {continue;}// 缓存不够,要删文件while (leftSize - fileSize < 0 && priorityQueue.length) {let count = Infinity;let toDeleteList = [];for (let [k, v] of readFreqMap) { // 查找访问频率最少的文件if (v < count) {toDeleteList = [k];count = v;} else if (v === count) {toDeleteList.push(k);}}if (toDeleteList.length === 1) { // 如果找到一个访问频率最少的文件直接删除fileSet.delete(toDeleteList[0]);const delSize = fileSizesMap.get(toDeleteList[0]);leftSize += delSize;readFreqMap.delete(toDeleteList[0]);let index = priorityQueue.indexOf(toDeleteList[0]);priorityQueue.splice(index, 1);} else { // 多个访问频率最少的文件需要筛选最久未被访问的文件,然后删除for (let i = 0; i < priorityQueue.length; i++) {if (toDeleteList.includes(priorityQueue[i])) {let deleteFileName = priorityQueue[i];fileSet.delete(deleteFileName);const delSize = fileSizesMap.get(deleteFileName);leftSize += delSize;priorityQueue.splice(i, 1);readFreqMap.delete(deleteFileName);break;}}} }if (leftSize - fileSize >= 0) {fileSet.add(fileName);fileSizesMap.set(fileName, fileSize);readFreqMap.set(fileName, 0);priorityQueue.push(fileName);leftSize -= fileSize;console.log('leftSize: ', leftSize, ' fileSize: ', fileSize);}} else { // getif (fileSet.has(fileName)) {readFreqMap.set(fileName, readFreqMap.get(fileName) + 1); // 更新访问频率let index = priorityQueue.indexOf(fileName);if (index !== -1) {priorityQueue.splice(index, 1);}priorityQueue.push(fileName); // 移到访问队列队首,表示最新访问的文件 }}}const fileList = Array.from(fileSet);fileList.sort();let result = "NONE";if (!fileList.length) {console.log("NONE");return "NONE";}result = fileList.join(',');console.log(result);return result;
}const cases = [`50
6
put a 10
put b 20
get a
get a
get b
put c 30`, `50
1
get file`
];let caseIndex = 0;
let lineIndex = 0;const readline = (function () {let lines = [];return function () {if (lineIndex === 0) {lines = cases[caseIndex].trim().split("\n").map((line) => line.trim());}return lines[lineIndex++];};
})();cases.forEach((_, i) => {caseIndex = i;lineIndex = 0;// console.time(`${i} cost time`);solution();// console.timeEnd(`${i} cost time`); console.log('-------');
});
算法过程(优先队列)
-
数据结构:
- 核心使用
MinPriorityQueue
(最小优先队列,基于堆实现)存储文件信息,堆的比较规则为“访问次数少优先,次数相同则时间早优先”。 _cache
(Map)快速映射文件名到文件详情(大小、访问次数、时间戳);_usedSize
记录已用缓存空间;_timeCount
生成唯一时间戳。
- 核心使用
-
核心操作:
put
操作:- 若文件已存在或大小超缓存上限,直接跳过。
- 若缓存空间不足,循环删除堆顶元素(优先级最高的待淘汰文件),直到空间足够。
- 新增文件至
_cache
和堆,初始化访问次数为0,更新时间戳和已用空间。
get
操作:- 若文件存在,先从堆中删除该文件(通过替换+堆调整实现),再更新其访问次数和时间戳,重新加入堆。
-
堆调整细节:
- 中间删除元素时,用堆尾元素替换目标位置,再通过
swim
(上浮)和sink
(下沉)确保堆结构正确。 - 堆顶始终是“访问次数最少且最久未访问”的文件,淘汰时直接删除堆顶。
- 中间删除元素时,用堆尾元素替换目标位置,再通过
-
结果输出:
缓存中的文件按字典排序,空则输出NONE
。
时间复杂度
- 单次
put
操作:- 入队(
enqueue
)和出队(dequeue
)均为堆操作,时间复杂度O(log k)
(k
为当前缓存文件数)。 - 最坏情况下需淘汰
k
个文件,总复杂度O(k·log k)
。
- 入队(
- 单次
get
操作:- 查找文件在堆中的位置为
O(k)
,删除并重新入队为O(log k)
,总复杂度O(k + log k)
。
- 查找文件在堆中的位置为
- 整体复杂度:
设操作总数为n
,总时间复杂度为O(n·k·log k)
(k
为缓存中最大文件数)。
(注:堆优化了淘汰阶段的查找效率,相比数组模拟的O(k²)
,在大k
场景下性能更优。)
参考代码(Least Frequence Used + 最小优先队列)
class MinPriorityQueue {constructor(compare) {this._data = [];this._compare = compare || ((a, b) => a - b);}enqueue(e) {this._data.push(e);this.swim(this._data.length-1);}dequeue() {if (this.isEmpty()) return null;const last = this._data.pop();if (this._data.length > 0) {this._data[0] = last;this.sink(0);}}swim(index) {while (index > 0) {let parentIndex = Math.floor((index - 1) / 2);if (this._compare(this._data[index], this._data[parentIndex]) < 0) {[this._data[parentIndex], this._data[index]] = [this._data[index],this._data[parentIndex],];index = parentIndex;continue;}break;}}// 从指定索引开始下沉sink(index) {const n = this._data.length;while (true) {let left = 2 * index + 1;let right = left + 1;let smallest = index;if (left < n && this._compare(this._data[left], this._data[index]) < 0) {smallest = left;}if (right < n && this._compare(this._data[right], this._data[smallest]) < 0) {smallest = right;}if (smallest !== index) {[this._data[smallest], this._data[index]] = [this._data[index],this._data[smallest],];index = smallest;continue;}break;}}front() {return this._data[0];}isEmpty() {return this._data.length === 0;}size() {return this._data.length;}
}class LFUCacheSystem {constructor(capibility) {this._capibility = capibility; // 最大缓存大小this._usedSize = 0; // 当前使用的缓存大小this._cache = new Map();this._minHeap = new MinPriorityQueue((a, b) => { // 传入自定义比较函数if (a.accessCount === b.accessCount) {return a.lastAccessTime - b.lastAccessTime; // 访问频次相同再比较访问时间}return a.accessCount - b.accessCount;});this._timeCount = 0;}put(fileName, fileSize) {if (fileSize > this._capibility) return;if (this._cache.has(fileName)) { // 如果文件已存在,更新文件的访问时间return;}// 缓存不够用了,从最小优先队列中删除优先级最高的文件(访问频次最少且最久未被访问的文件)while (fileSize + this._usedSize > this._capibility) {const toDelete = this._minHeap.front();if (toDelete) {this._minHeap.dequeue(); // 删除文件this._cache.delete(toDelete.fileName);this._usedSize -= toDelete.size; // 更新可用缓存大小}}const currentTime = this._timeCount++;const file = { fileName, size: fileSize, accessCount: 0, lastAccessTime: currentTime };this._minHeap.enqueue(file);this._cache.set(fileName, file);this._usedSize += file.size;}get(fileName) {if (!this._cache.has(fileName)) return;const file = this._cache.get(fileName);this._removeFileFromHeap(fileName);file.accessCount++;file.lastAccessTime = this._timeCount++;this._minHeap.enqueue(file);}_removeFileFromHeap(fileName) {const index = this._minHeap._data.findIndex(item => item.fileName === fileName);if (index === -1) return;// 用最后一个元素替换目标元素const last = this._minHeap._data[this._minHeap._data.length-1];this._minHeap._data[index] = last;this._minHeap._data.pop();if (index < this._minHeap._data.length) {this._minHeap.swim(index);this._minHeap.sink(index);}}getFileList() {const fileList = Array.from(this._cache.keys());fileList.sort();return fileList;}
};function solution2(totalSize, ops) {const cacheSys = new LFUCacheSystem(totalSize);for (const op of ops) {const [operation, fileName, fileSize] = op;if (operation === 'put') {cacheSys.put(fileName, parseInt(fileSize));} else { // getcacheSys.get(fileName);}}const fileList = cacheSys.getFileList();let result = "NONE";if (!fileList.length) {console.log("NONE");return "NONE";}result = fileList.join(',');console.log(result);return result;
}function entry() {const totalSize = parseInt(readline());const n = parseInt(readline());const ops = [];for (let i = 0; i < n; i++) {const [op, fileName, fileSize] = readline().split(' ');ops.push([op, fileName, parseInt(fileSize)]);}// console.log('case: totalSize n ops ', totalSize, n, ops);// solution1 // const result = solution(totalSize, ops);// solution2const result2 = solution2(totalSize, ops);// console.log(result === result2);}const cases = [`50
6
put a 10
put b 20
get a
get a
get b
put c 30`, `50
1
get file`
];// 生成随机 300000 个 put/get 操作用例,文件名最多100个不重复,超过26个字母范围用数字后缀
(function(){const ops = [];const fileNames = [];let totalFiles = 0;const maxFiles = 100;const maxFileSize = 100;const totalOps = 300000;const cacheSize = 1000;function getFileName(idx) {if (idx < 26) {return String.fromCharCode(97 + idx); // 'a' ~ 'z'} else {return String.fromCharCode(97 + (idx % 26)) + (Math.floor(idx / 26));}}for (let i = 0; i < totalOps; i++) {if (Math.random() < 0.5 || totalFiles === 0) {// put 操作let idx = Math.floor(Math.random() * maxFiles);let fileName = getFileName(idx);let fileSize = Math.floor(Math.random() * maxFileSize) + 1;ops.push(`put ${fileName} ${fileSize}`);if (!fileNames.includes(fileName)) {fileNames.push(fileName);totalFiles++;}} else {// get 操作let fileName = fileNames[Math.floor(Math.random() * fileNames.length)];ops.push(`get ${fileName}`);}}const input = `${cacheSize}\n${totalOps}\n${ops.join('\n')}`;cases.push(input);
})();let caseIndex = 0;
let lineIndex = 0;const readline = (function () {let lines = [];return function () {if (lineIndex === 0) {lines = cases[caseIndex].trim().split("\n").map((line) => line.trim());}return lines[lineIndex++];};
})();caseIndex = 0;
lineIndex = 0;
cases.forEach((_, i) => {caseIndex = i;lineIndex = 0;entry();console.log('-------');
});
验证
两种方法测试结果相同: