基于时间的键值存储 图解题解
同一个 key 的时间戳天然有序,查「不超过某时刻的最新值」就是在有序列表上做右边界二分。
像翻一个人的日记找「某天之前最近的那篇」:日记按日期顺序排好,不用从第一篇翻到最后,直接翻到中间看日期——比目标晚就往前翻,比目标早或相等就记下来再往后探更晚的可行记录。每个 key 的时间戳天然递增,set 追加维护有序,get 对时间戳做右边界二分,找不超过 t 的最大时间戳对应的值。
这道题到底在问什么
- 输入
- set("foo","A6",6);get("foo",7)
- 输出
- "A6"(7 时刻最近的一次是 6 时刻存的 A6)
- 输入
- get("foo",2)
- 输出
- "A1"(2 时刻往前最近的是 1 时刻的 A1)
最优解:为什么这么做
一句话答案:LeetCode 981 基于时间的键值存储:同一个键的时间戳递增天然有序,get 时二分找不晚于 t 的最近一次值,找不到返回空串,单次查询 O(log n)、空间 O(总记录数)。
TimeMap 的 get 到底该返回哪次存的值
TimeMap 干两件事:set(key, value, timestamp) 把一次赋值连同发生的时刻存起来;get(key, timestamp) 返回这个键在不晚于该时刻的最近一次赋的值,没赋过就返回空串。题面里 set("foo","A6",6) 后,get("foo",7) 得 "A6"、get("foo",2) 得 "A1",都是取该时刻往前最近的一次。同一个键的 timestamp 严格递增地存入。
同一个键查一次就从最新往回翻,慢在哪
get 每查一次,最直接的做法是把这个键的记录从最新往回翻,碰到第一个时间戳不晚于 t 的就停。一个键存了 n 条,查很早的时刻最坏要翻遍全部 n 条,单次查询是 O(n)。key 存得越多、查得越勤越拖不动,可记录本就按时间排好序,逐条翻等于把这份有序扔着不用。
同一个键的时间戳只增不减,正好落进二分的门槛
省下这趟线性翻找,靠题目送的一句保证:同一个键的 timestamp 严格递增。set 按时间先后一条条追加,每个键的记录列表天生按时间戳从小到大排好,不用再排。找「不晚于 t 的最近一次」就是在有序时间戳里找 ≤ t 里最大的那个,有序数组上定位边界值正是二分查找的活:每比一次中点就甩掉一半记录。也因为有序不用自己维护,set 只管往列表尾追加、O(1) 就完事。
中点时间戳和 t 比完,往哪半收、候选怎么留
二分用两个指针 l、r 圈住候选段,起初 l=0、r=n-1,这是两端都算数的闭区间 [l,r]。再备 ans 存最好答案、初值空串。每轮取中点 mid=(l+r)//2,拿 arr[mid] 时间戳和 t 比:≤ t 是合法候选,先记进 ans,可右边也许还有更晚、仍不晚于 t 的,那才算「最近」,于是 l 跳到 mid+1 往右探;> t 太晚,右边只会更晚一起丢,r 退到 mid-1。循环写 while l<=r,l==r 时还剩一格要查。
foo 存了 A1、A6,查 7 和查 2 各走几轮
题面的 foo 按时间戳存两条:0 号 (1,"A1")、1 号 (6,"A6")。查 get("foo",7):l=0、r=1,mid=0 时间戳 1 ≤ 7,记 ans="A1"、l 到 1;再 mid=1,时间戳 6 ≤ 7,更新 ans="A6"、l 到 2,超过 r,返回 "A6"。查 get("foo",2):mid=0 时间戳 1 ≤ 2,记 ans="A1"、l 到 1;再 mid=1,时间戳 6 > 2 太晚,r 退到 0,返回 "A1"。
查得比最早那次还早,返回空串别硬凑一个值
单个键 n 条记录,二分把查询压到约 log₂n 次比较,单次 get 是 O(log n);set 尾部追加 O(1);空间存下全部 (时间戳, 值) 对,O(总记录数)。坑都在收尾。ans 初值必须空串:查比最早记录还早的时刻,r 被逼到 -1、没记下候选,就返回空串,若返回最小时间戳那条便是硬塞题目没要的值。时间戳 ≤ t 那支别当场 return,右边可能还有仍不晚于 t 的更晚记录,提前收手会拿到偏早的值,记下再往右才对。
▶ 动画逐步走查(共 27 步)——想跟着动画一帧帧对照就展开
- 3核心就一句:在有序时间戳里二分找「≤ t 的最大值」。ts[mid]≤t 就记下候选并往右,否则往左。
- 4开始二分:l 指向最左、r 指向最右,搜索范围是整串时间戳。还没有候选答案。
- 5范围 [0, 6] 取中点 mid = 3,那一格的时间戳是 6。拿它和查询时刻 7 比较。
- 6时间戳 6 ≤ 7,是个合法候选——先把它记进 ans(绿色这格)。但右边也许有更晚、仍不超过 7 的,所以 l 往右挪到 4 继续找。
- 7范围 [4, 6] 取中点 mid = 5,那一格的时间戳是 12。拿它和查询时刻 7 比较。
- 8时间戳 12 比 7 还晚(标红这格),不能要;它右边的更晚,一起丢掉。r 往左挪到 4。
- 9范围 [4, 4] 取中点 mid = 4,那一格的时间戳是 9。拿它和查询时刻 7 比较。
- 10时间戳 9 比 7 还晚(标红这格),不能要;它右边的更晚,一起丢掉。r 往左挪到 3。
- 11范围缩成空,二分结束。最后记下的候选是 6 时刻的 A6(绿色这格),这就是答案。
- 12边界要演一遍:查询时刻早于所有记录时,ans 全程没机会被记下,最后老实返回空串。
- 13再查一次,这回 开始二分:l 指向最左、r 指向最右,搜索范围是整串时间戳。还没有候选答案。
- 14范围 [0, 6] 取中点 mid = 3,那一格的时间戳是 6。拿它和查询时刻 0 比较。
- 15时间戳 6 比 0 还晚(标红这格),不能要;它右边的更晚,一起丢掉。r 往左挪到 2。
- 16范围 [0, 2] 取中点 mid = 1,那一格的时间戳是 3。拿它和查询时刻 0 比较。
- 17时间戳 3 比 0 还晚(标红这格),不能要;它右边的更晚,一起丢掉。r 往左挪到 0。
- 18范围 [0, 0] 取中点 mid = 0,那一格的时间戳是 1。拿它和查询时刻 0 比较。
- 19时间戳 1 比 0 还晚(标红这格),不能要;它右边的更晚,一起丢掉。r 往左挪到 -1。
- 20范围缩成空,全程没记下任何候选,说明这个时刻之前没存过值,返回空串。
- 21再演一个最常见的:查询时刻夹在两个时间戳之间,二分回退到它之前最近一次存的值。
- 22查 开始二分:l 指向最左、r 指向最右,搜索范围是整串时间戳。还没有候选答案。
- 23范围 [0, 6] 取中点 mid = 3,那一格的时间戳是 6。拿它和查询时刻 5 比较。
- 24时间戳 6 比 5 还晚(标红这格),不能要;它右边的更晚,一起丢掉。r 往左挪到 2。
- 25范围 [0, 2] 取中点 mid = 1,那一格的时间戳是 3。拿它和查询时刻 5 比较。
- 26时间戳 3 ≤ 5,是个合法候选——先把它记进 ans(绿色这格)。但右边也许有更晚、仍不超过 5 的,所以 l 往右挪到 2 继续找。
- 27范围 [2, 2] 取中点 mid = 2,那一格的时间戳是 4。拿它和查询时刻 5 比较。
- 28时间戳 4 ≤ 5,是个合法候选——先把它记进 ans(绿色这格)。但右边也许有更晚、仍不超过 5 的,所以 l 往右挪到 3 继续找。
- 29范围缩成空,二分结束。最后记下的候选是 4 时刻的 A4(绿色这格),这就是答案。
⚠️ 容易写错的地方
✗ 错:用线性扫描从后往前找 ≤ t 的时间戳
✓ 对:在有序时间戳上二分
时间戳本就递增,线性查询最坏 O(n),二分 O(log n) 才达到题目期望
✗ 错:ts[mid] ≤ t 时立刻 return,不再往右
✓ 对:记下候选 ans 后继续 l = mid + 1
右边可能还有更晚但仍 ≤ t 的时间戳,那个才是「最近一次」,提前返回会拿到偏早的值
✗ 错:找不到候选时返回数组里最小的值
✓ 对:ans 初始化为空串,没记下就返回空
题目要求「不晚于 t」,t 比所有时间戳都早时不存在合法答案,必须返回空串
完整代码(Python / C++ / Java)
Python
class TimeMap:
def __init__(self):
self.store = {} # key -> [(ts, val), ...]
def set(self, key, value, ts):
self.store.setdefault(key, []).append((ts, value))
def get(self, key, ts):
arr = self.store.get(key, [])
l, r, ans = 0, len(arr) - 1, ""
while l <= r:
mid = (l + r) // 2
if arr[mid][0] <= ts: # 合法候选
ans = arr[mid][1] # 记下值,往右找更晚的
l = mid + 1
else: # 太晚,往左
r = mid - 1
return ansC++
class TimeMap {
unordered_map<string, vector<pair<int,string>>> store;
public:
void set(string key, string value, int ts) {
store[key].push_back({ts, value});
}
string get(string key, int ts) {
auto& arr = store[key];
int l = 0, r = arr.size() - 1; string ans = "";
while (l <= r) {
int mid = (l + r) / 2;
if (arr[mid].first <= ts) { ans = arr[mid].second; l = mid + 1; }
else r = mid - 1;
}
return ans;
}
};Java
class TimeMap {
Map<String, List<int[]>> idx = new HashMap<>(); // ts
Map<String, List<String>> val = new HashMap<>(); // value
public void set(String key, String value, int ts) {
idx.computeIfAbsent(key, k -> new ArrayList<>()).add(new int[]{ts});
val.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
}
public String get(String key, int ts) {
List<int[]> a = idx.getOrDefault(key, List.of());
int l = 0, r = a.size() - 1; String ans = "";
while (l <= r) {
int mid = (l + r) / 2;
if (a.get(mid)[0] <= ts) { ans = val.get(key).get(mid); l = mid + 1; }
else r = mid - 1;
}
return ans;
}
}复杂度
set 时间
O(1)
直接往该键的列表末尾追加一条记录
get 时间
O(log n)
在该键的 n 条有序时间戳上二分查找
空间
O(总记录数)
存下所有 set 进来的 (时间戳, 值) 对
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 基于时间的键值存储 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么 get 能用二分,不怕漏掉某次赋值?+
因为同一个键的时间戳是按 set 的先后严格递增存进列表的,天生从小到大排好。要找的「不晚于 t 的最近一次」,在有序时间戳里就是 ≤ t 中最大的那个,这正是二分擅长的边界定位:中点时间戳 ≤ t 就往右还能找更晚的、> t 就往左,每步甩掉一半也不会把合法记录连带丢掉,因为丢的那半要么整段太晚、要么整段有更好的替身留在右边。O(log n) 就够。
set 需要把新记录插到列表中间、保持有序吗?+
不用。题目保证同一个键的 timestamp 是递增着传进来的,后来的一定比先前的晚,直接追加到列表尾巴,列表就一直有序,set 是 O(1)。要是题目没这条保证、时间戳乱序进来,才需要每次二分找位置插入、或者存完再统一排序,那 set 就不是 O(1) 了。
某个键从来没 set 过,get 会返回什么,会不会数组越界?+
返回空串,也不会越界。取这个键的列表时给个默认空列表,len 是 0,于是 r=len-1=-1,一进循环判断 l=0<=r=-1 就不成立,一轮都不跑,ans 保持初始空串直接返回。空列表这条边界被循环条件天然挡住,不用单独写 if 特判。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 基于时间的键值存储 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。