题目描述

✅ 981. 基于时间的键值存储

题意分析

同一个 key 可以在不同时间保存不同的值。查询 timestamp 时,要返回该键所有“不晚于查询时间”的记录中时间最大的那一条;不存在符合条件的记录就返回空串。

新值不能覆盖旧值,因为之后仍可能查询较早时刻。题目保证所有 set 调用的时间戳严格递增,因此同一个键的记录作为其中的子序列,也天然按时间递增,可以直接追加,不必重新排序。

解法:每个键保存有序历史,二分查前一条

核心思路

[!blue]

用哈希表 history 按键分组,每个键对应一个历史列表,列表中的每个记录同时保存 time 和 value。写入只需找到对应列表并追加;查询只在这一条列表中搜索,不受其他键的历史数量影响。

直接找“最后一个时间不大于 timestamp”可以转成查找严格上界:令 p 为第一条时间大于查询时间的记录下标。如果存在符合条件的记录,它就一定是 p - 1;若所有记录都符合条件,则把不存在的上界记为列表长度,仍然能统一取前一条。

二分使用左闭右开区间 [left, right)。始终保持:left 左边的记录时间都不大于查询时间,right 及右边的记录时间都大于查询时间。初始两侧已确定的区域都是空的,left = 0、right = entries.size()。

若中间记录时间小于等于目标,由于列表有序,它以及左边记录都符合条件,严格上界只能在后面,令 left = mid + 1。否则中间记录及右边都过晚,令 right = mid,保留它作为可能的第一个过晚位置。每次都会缩短未确定区间,最终 left == right,正是所求边界 p。

p == 0 表示连第一条记录都过晚,应返回空串;否则返回 entries[p - 1].value。相等时间在二分中归到符合条件的一侧,所以恰好时刻的值不会漏掉,也不必另设命中分支。

解题步骤

  1. set 把时间和值一起追加到该键列表。
  2. get 在左闭右开区间中二分第一条时间 >timestamp 的记录。
  3. 边界为 0 返回空串,否则返回前一个记录的值。

Java 对不存在的键直接返回空串;Go 读取不存在的键得到空切片,长度为零,二分不执行,也会在同一边界判断中返回空串。查询时间晚于所有历史时,边界等于列表长度,返回最新记录。get 不修改历史,因此查询时间可以任意先后,只有写入时间需要遵守递增条件。

代码实现

class TimeMap {
    private static class Entry {
        int time;
        String value;

        Entry(int time, String value) {
            this.time = time;
            this.value = value;
        }
    }

    private final Map<String, List<Entry>> history = new HashMap<>();

    public void set(String key, String value, int timestamp) {
        history.computeIfAbsent(key, k -> new ArrayList<>()).add(new Entry(timestamp, value));
    }

    public String get(String key, int timestamp) {
        List<Entry> entries = history.get(key);

        if (entries == null) {
            return "";
        }

        int left = 0;
        int right = entries.size();

        while (left < right) {
            int mid = left + (right - left) / 2;

            if (entries.get(mid).time <= timestamp) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }

        return left == 0 ? "" : entries.get(left - 1).value;
    }
}
type timeEntry struct {
    time  int
    value string
}
type TimeMap struct {
    history map[string][]timeEntry
}

func Constructor() TimeMap {
    return TimeMap{
        history: map[string][]timeEntry{},
    }
}

func (t *TimeMap) Set(key, value string, timestamp int) {
    t.history[key] = append(t.history[key], timeEntry{timestamp, value})
}

func (t *TimeMap) Get(key string, timestamp int) string {
    entries := t.history[key]
    left, right := 0, len(entries)
    for left < right {
        mid := left + (right-left)/2
        if entries[mid].time <= timestamp {
            left = mid + 1
        } else {
            right = mid
        }
    }
    if left == 0 {
        return ""
    }
    return entries[left-1].value
}

复杂度分析

  • 时间复杂度:不计字符串本身的处理成本,哈希表操作按期望常数时间计,set 均摊 $O(1)$,get 为 $O(\log(q+2))$,q 是该键的历史条数,空历史也只有常数开销。
  • 空间复杂度:总空间为全部记录数加保留字符串的大小。

关键点总结

[!green]

查询的目标是“最后一条不大于时间戳”,可统一转成“第一条大于时间戳,再减一”。

易错点总结

[!yellow]

  • 恰好等于查询时间的记录应保留,二分分支使用 <=。
  • 不存在的键与早于第一条记录的查询都返回空串。
  • 直接追加依赖时间戳递增;不能宣称这份实现支持任意乱序写入。

相似题目

题目 难度 关联与区别
1146. 快照数组 中等 同样保存有序历史并查找不晚于目标版本的最后记录,区别是快照版本由系统统一产生。
34. 在排序数组中查找元素的第一个和最后一个位置 中等 复用寻找严格上界的二分方式,再退一位得到符合时间条件的最后记录。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/59366873
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!