缓存优化模拟
问题背景
缓存是提升数据库查询效率的关键技术。为了对缓存机制进行性能评估和优化,我们需要使用一组训练数据来模拟数据的访问过程,并找出在理想情况下,数据库的最少访问次数。
模拟访问规则
-
缓存命中(Hit) : 当需要查询的数据已经存在于缓存中时,直接从缓存获取,无需访问数据库。
-
缓存未命中(Miss) : 当需要查询的数据不在缓存中时,判定为一次"未命中",此时必须访问数据库。
-
数据加载与替换:
- 数据库访问后,需要将查询到的数据放入缓存。
- 如果此时缓存已满,必须先从缓存中删除一个已有的数据,为新数据腾出空间。这个删除的过程称为"替换"或"驱逐"。
优化目标:最少的数据库访问
为了实现最少的数据库访问次数,我们在缓存已满、需要替换数据时,必须做出最优选择。最优的替换策略是:
替换掉那个在未来最长时间内不会被访问的数据。
这是一种"先知"般的策略,因为它需要预知未来的所有访问请求来做出当前最好的决定。
任务要求
给定缓存的大小 cacheSize
和一组按顺序访问的训练数据 dataIds
,请模拟整个访问过程。在每次需要替换缓存数据时,都采用上述的最优策略,最终计算出整个过程所产生的最少数据库访问次数。
输入格式
-
cacheSize
: 第一个参数,一个整数,表示缓存的大小。1 <= cacheSize <= 100
-
dataIds
: 第二个参数,一个整数数组,表示按顺序需要查询的数据编号。0 <= dataIds.length <= 1000
0 <= dataIds[i] <= 1000
-
假设 : 每个数据在缓存中占用的空间都为
1
。
输出格式
- 一个整数,代表在最优替换策略下,数据库的最少访问次数。
样例说明
样例 1
-
输入:
cacheSize = 2
dataIds = [1, 2, 3, 1, 2, 3]
-
输出 :
4
-
解释:
我们逐步模拟这个过程,并记录数据库访问次数(DB Accesses)和缓存状态(Cache)。
-
查询
1
:- Miss : 缓存中没有
1
。 - DB Accesses: 1
- Cache :
[1]
- Miss : 缓存中没有
-
查询
2
:- Miss : 缓存中没有
2
。 - DB Accesses: 2
- Cache :
[1, 2]
(缓存已满)
- Miss : 缓存中没有
-
查询
3
:- Miss : 缓存中没有
3
。 - DB Accesses: 3
- 决策 : 缓存
[1, 2]
已满,需要替换一个。查看未来的访问序列[1, 2, 3]
。数据1
紧接着就会被访问,数据2
在1
之后被访问。因此,应该替换掉未来最晚被访问的2
。 - Cache :
[1, 3]
- Miss : 缓存中没有
-
查询
1
:- Hit : 缓存中有
1
。 - DB Accesses: 3
- Cache :
[1, 3]
- Hit : 缓存中有
-
查询
2
:- Miss : 缓存中没有
2
。 - DB Accesses: 4
- 决策 : 缓存
[1, 3]
已满,需要替换一个。查看未来的访问序列[3]
。数据3
马上会被访问,而数据1
在未来不再出现。因此,替换掉1
。 - Cache :
[2, 3]
- Miss : 缓存中没有
-
查询
3
:- Hit : 缓存中有
3
。 - DB Accesses: 4
- Cache :
[2, 3]
- Hit : 缓存中有
整个过程结束,总共访问了 4 次数据库。
-
样例 2
-
输入:
cacheSize = 1
dataIds = [100, 200, 100, 200]
-
输出 :
4
-
解释:
- 缓存大小为 1,意味着每次访问一个新数据,都必须从数据库读取,并替换掉缓存中已有的数据。
- 序列
100, 200, 100, 200
中的每一次访问都是一次 Miss。 - 因此,总共需要访问 4 次数据库。
java
import java.util.HashSet;
import java.util.Scanner;
import java.util.Set;
import java.util.Arrays;
/**
* 解决模拟缓存访问,计算最少数据库访问次数的问题。
*/
public class Main {
public static void main(String[] args) {
// ACM 模式的输入处理
Scanner scanner = new Scanner(System.in);
int cacheSize = Integer.parseInt(scanner.nextLine().trim());
String dataLine = scanner.nextLine().trim();
scanner.close();
// 将输入的字符串行解析为整数数组
int[] dataIds;
if (dataLine.isEmpty()) {
dataIds = new int[0];
} else {
String[] dataIdStrings = dataLine.split("\s+");
dataIds = new int[dataIdStrings.length];
for (int i = 0; i < dataIdStrings.length; i++) {
dataIds[i] = Integer.parseInt(dataIdStrings[i]);
}
}
// 调用核心逻辑并打印结果
System.out.println(minDatabaseAccess(cacheSize, dataIds));
}
/**
* 计算在最优缓存替换策略下的最少数据库访问次数。
*
* @param cacheSize 缓存大小
* @param dataIds 按顺序访问的数据ID列表
* @return 最少的数据库访问次数
*/
public static int minDatabaseAccess(int cacheSize, int[] dataIds) {
// --- 边界情况处理 ---
// 如果没有缓存容量,每次访问都需要读数据库
if (cacheSize <= 0) {
return dataIds.length;
}
// 如果没有数据访问请求,则访问次数为0
if (dataIds.length == 0) {
return 0;
}
// --- 初始化 ---
// 使用 HashSet 模拟缓存,提供 O(1) 的平均查找时间
Set<Integer> cache = new HashSet<>();
// 数据库访问次数计数器
int dbAccessCount = 0;
// --- 遍历访问序列 ---
for (int i = 0; i < dataIds.length; i++) {
int currentDataId = dataIds[i];
// 1. 检查是否缓存命中
if (cache.contains(currentDataId)) {
// 缓存命中,无需访问数据库,继续下一次访问
continue;
}
// 2. 缓存未命中,必须访问数据库
dbAccessCount++;
// 3. 将数据放入缓存
// a. 如果缓存未满
if (cache.size() < cacheSize) {
cache.add(currentDataId);
}
// b. 如果缓存已满,执行最优替换策略 (Belady's Algorithm)
else {
int itemToEvict = -1; // 记录要被替换出的数据
int farthestNextUse = -1; // 记录最远的下一次访问位置
// 遍历当前缓存中的所有数据,找到在未来最晚被使用的一个
for (int itemInCache : cache) {
int nextUseIndex = Integer.MAX_VALUE; // 默认为无穷远(未来不再使用)
// 在未来的请求序列中查找 itemInCache 的下一次出现位置
for (int j = i + 1; j < dataIds.length; j++) {
if (dataIds[j] == itemInCache) {
nextUseIndex = j; // 找到了,记录索引
break; // 只关心第一次出现,所以可以 break
}
}
// 如果这个数据的下一次使用位置比目前记录的最远位置还要远
if (nextUseIndex > farthestNextUse) {
farthestNextUse = nextUseIndex;
itemToEvict = itemInCache;
}
// 如果 nextUseIndex 是 Integer.MAX_VALUE,说明它再也不会被用到,
// 它将成为 farthestNextUse 的最大值,是最佳的替换对象。
}
// 从缓存中移除确定要替换的数据
cache.remove(itemToEvict);
// 将新数据加入缓存
cache.add(currentDataId);
}
}
// 返回总的数据库访问次数
return dbAccessCount;
}
}