刚开始刷 LeetCode 的读者看到 981 这题,多半会把它归到“设计题”一类:题面很短,意思也好懂,实现一个 TimeMap,支持 set(key, value, timestamp) 和 get(key, timestamp),其中 get 要返回该 key 在给定时间戳或之前最近一次写入的 value。很多人看完第一反应是“HashMap 套一个按时间排序的数组不就行了”,这个方向没有错,但把它真正落到 Java 代码上,不同写法的时间和内存差距非常明显。尤其当你把“内存表现出色”作为目标时,这题反而是我做过的最适合用来理解 Java 对象内存布局的题目之一。
我最初提交的版本和大多数人一样,十分钟 AC 就划走了。后来再刷到它,发现朴素实现虽然能过,但每条记录背后藏着一堆看不见的包装对象,数据量一大内存就会很难看。这篇文章没有铺垫,直接从“先 AC”开始,到“把内存压下去”结束,把二分边界和 Java 存储结构都讲透,适合想夯实设计题底层逻辑、或者正准备面试时被问到“还能怎么优化”的读者。
1. 题目在考什么:不是“会不会排序”,是“会不会做版本化读取”
1.1 如果系统要回看历史快照,你会怎么设计
抛开 LeetCode 题目的包装,981 描述的场景其实非常常见:某个 key 会在不同时间点被写入不同值,而你查询时希望拿到“在某个时间点之前最后一次生效的值”。
典型业务例子就是配置中心。一份配置被修改多次,每次改动都会留下一个时间戳,线上实例收到“配置变更通知”时,可能因为网络原因晚到一会儿,这时候它查到的不应该是最新的,而是“消息里携带的时间戳那一刻应该是什么配置”。另一个常见例子是价格快照:商品价格在 9 点改成 100 元、10 点改成 120 元,用户 9:30 加购,结算时要按 9:30 的价格计算,而不是查询当下 12 点的价格。
这类需求本质上是带版本的数据读取。版本不一定是自增数字,用时间戳作为版本号更自然,也更好对账。所以 981 不是一道“背个数据结构就能过”的题,它是在考察你能不能抽象出“按 key 索引 + 按时间追加 + 按时间查询”的模型。
1.2 原题里最容易忽略的两个约束条件
很多解法写得不对,不是因为代码写得差,而是漏看了题面约束。
第一个约束:同一个 key 的 set 调用,时间戳是严格递增的。这不是说全局所有 set 都会按时间顺序到达,而是说“对于同一个 key,后一次 set 的时间戳一定大于前一次”。这意味着每个 key 自己的历史记录天然有序,不需要在插入时维护排序。若没有这个约束,新记录就必须插入到有序位置,复杂度就从 O(1) 追加变成 O(n) 插入,或者要改用平衡树结构。
第二个约束:当某个 key 还没有任何早于查询时间戳的记录时,get 要返回空字符串。这个边界看似简单,但真写二分的时候,你会发现它往往决定你最后返回的是 list.get(mid).value 还是 "",处理不好就会数组越界或返回错误结果。
第三个容易被忽略的细节是:时间戳相等的情况不会出现在同一个 key 的历史记录里,但查询时 timestamp 可能恰好等于某次 set 的时间戳。此时必须返回那一条记录本身,而不是更早的一条。也就是说,查询语义是“小于等于”,不是“严格小于”。
1.3 数据结构上的第一直觉:HashMap 打底就够了
通用的键值存储用哈希表,这没什么好说的。区别在于 value 的形态:每个 key 对应的不是一个值,而是一条按时间排列的历史记录链。因此最直接的结构就是 Map<String, List<Record>>。
为什么不用 TreeMap<Integer, String> 作为每个 key 的 value?从功能上完全可以,floorEntry(timestamp) 正好返回小于等于给定时间戳的最大记录,代码写出来也最简单:
java复制Map<String, TreeMap<Integer, String>> map = new HashMap<>();
但这样实现有一个问题:每个 key 的每一条时间记录,在 TreeMap 里都是一个独立的树节点,每个节点除了要存 key、value、左右孩子引用、颜色标记之外,还要承担树平衡带来的额外指针开销。对于高频写入的场景,TreeMap 的内存比线性结构大得多。而且题目本身就保证了时间戳递增,根本用不上树的自动排序能力,这个排序能力是“白花钱买的”。所以线性数组才是更贴合题目特征的存储形态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一版朴素实现:先保证逻辑正确
2.1 用嵌套 Node 记录时间戳和值
第一版不需要任何优化技巧,只需要把数据结构说清楚。既然 value 需要用对象封装,我定义一个简单的内部类:
java复制class TimeMap {
static class Entry {
int timestamp;
String value;
Entry(int timestamp, String value) {
this.timestamp = timestamp;
this.value = value;
}
}
private final Map<String, List<Entry>> map = new HashMap<>();
public void set(String key, String value, int timestamp) {
map.computeIfAbsent(key, k -> new ArrayList<>())
.add(new Entry(timestamp, value));
}
public String get(String key, int timestamp) {
List<Entry> list = map.get(key);
if (list == null) {
return "";
}
int lo = 0, hi = list.size() - 1;
while (lo <= hi) {
int mid = lo + ((hi - lo) >>> 1);
Entry entry = list.get(mid);
if (entry.timestamp <= timestamp) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return hi < 0 ? "" : list.get(hi).value;
}
}
这段代码有两个关键点。
第一,set 只需向列表尾部追加。因为同一个 key 的时间戳严格递增,所以尾部的 timestamp 一定比之前所有元素都大,List 天然保持有序,不需要再调用 Collections.sort。如果你在多线程场景下用这个结构,并发写就会破坏该前提,但 LeetCode 的调用是单线程顺序的。
第二,get 用二分在有序数组中找“最后一个小于等于 timestamp 的位置”。hi 的最后值就是这个位置,如果 hi < 0,说明连最早的一条记录都比查询时间晚,返回空字符串。
2.2 为什么这版不能算真正做完
这个版本的时间复杂度是:set 均摊 O(1),get O(log n),n 是某个 key 的历史记录数。功能上没有任何问题,大多数用 Java 刷题的人交这一版也能过。
但注意两个隐患。
第一个隐患是对象数量。每条记录都有一个新的 Entry 对象,Entry 对象头 12 字节,加上 int timestamp 4 字节、String value 引用 8 字节,一个 Entry 实例普遍占 24 字节左右。而 ArrayList 底层又是一个对象数组,数组里每个槽位只存 8 字节引用。也就是说,存一条 100 万条记录量的 key,光 Entry 对象就要占接近 24 MB,还没算字符串本身和数组扩容损耗。
第二个隐患是 ArrayList 的初始容量策略。ArrayList 无参构造时底层容量为 0,第一次 add 后变成 10,之后每次扩容变成原来的 1.5 倍。如果你有大量只写入一两次的 key,每个 key 的 ArrayList 都会白白分配出能装 10 个引用的数组。小 key 越多,浪费越明显。这两个隐患正是标题里“内存 100”要解决的核心问题。
3. 二分查找为什么容易写歪:几个边界案例值得单独说
3.1 “最后一个小于等于”不是“第一个大于等于”
这题的二分目标非常明确,但因为写
