题目描述

✅ 1146. 快照数组

image-20260929000722915

image-20260929000722916

题意分析

维护一个初值全为零的定长数组。set(index, val) 修改当前值;snap() 保存当前状态并返回编号,编号从零开始;get(index, snap_id) 返回指定历史快照中该下标的值。

创建快照后的新写入不能改变旧快照。同一次快照之前,对同一位置写多次只以最后一次为准;没有写入的位置继续沿用原值。题目保证查询的快照编号已经由 snap() 创建。

解法:按下标记录变更历史

核心思路

[!blue]

每次快照都复制整条数组,会把未变化的位置也重复保存。实际上一个位置的值只在写入时改变,可以让每个下标单独保存按版本递增的记录 (版本号, 值),快照只负责划分写入属于哪个版本。

snapId 表示当前尚未完成的快照周期。set 发现末条记录也属于这个周期,就覆盖末条值,因为周期内的中间写入不会被历史查询单独观察;若末条属于更早的周期,就追加新记录,保证已经完成的快照内容不再改写。snap() 返回当前编号后再递增,后续写入自然进入新周期。

一条记录从它的版本开始生效,直到下一条记录出现。查询版本 t 时,需要最后一条编号不大于 t 的记录,而不是必须找到恰好等于 t 的记录:期间即使创建了多个快照,只要这个位置没写入,旧值就一直有效。

记录编号有序,可以二分最后一个满足 版本 <= t 的位置。可行时保留中点并向右找,不可行时排除中点和右侧。因为更新使用 left = mid,中点向上取整,才能在只剩两个候选时继续收缩。

每个下标预置记录 (0, 0),保证所有合法历史查询至少有一个可用初值。首次快照之前的写入允许覆盖这条记录;首次快照完成后,版本号已经推进,旧记录就不会再被改动。

解题步骤

  1. 构造时创建各位置的历史列表,分别放入 (0, 0),当前版本从零开始。
  2. 写入时比较最后一条记录的版本:等于当前版本则覆盖值,否则追加 (当前版本, 新值)。
  3. 创建快照时,先返回当前版本号,再将当前版本加一,不复制任何数组内容。
  4. 查询时取得对应位置的历史,在闭区间内用上中点二分最后一个版本不晚于目标的记录。
  5. 两端相遇后返回该记录的值;未来周期的记录会在二分中被排除。

代码实现

class SnapshotArray {
    private final List<int[]>[] history;
    private int snapId;

    public SnapshotArray(int length) {
        history = new List[length];

        for (int i = 0; i < length; i++) {
            history[i] = new ArrayList<>();
            history[i].add(new int[] {
                0,
                0
            });
        }
    }

    public void set(int index, int val) {
        List<int[]> records = history[index];
        int[] last = records.get(records.size() - 1);

        // 同一快照周期覆盖末条,已完成版本不再改写
        if (last[0] == snapId) {
            last[1] = val;
        } else {
            records.add(new int[] {
                snapId,
                val
            });
        }
    }

    public int snap() {
        return snapId++;
    }

    public int get(int index, int snap_id) {
        List<int[]> records = history[index];
        int left = 0;
        int right = records.size() - 1;

        while (left < right) {
            // 条件成立时令 left=mid 保留当前中点,向上取整保证两个候选时仍能收缩
            int mid = left + (right - left + 1) / 2;

            if (records.get(mid)[0] <= snap_id) {
                left = mid;
            } else {
                right = mid - 1;
            }
        }

        return records.get(left)[1];
    }
}
type record struct {
    snapID int
    value  int
}

type SnapshotArray struct {
    history [][]record
    snapID  int
}

func Constructor(length int) SnapshotArray {
    history := make([][]record, length)
    for i := range history {
        history[i] = []record{
            {snapID: 0, value: 0},
        }
    }
    return SnapshotArray{history: history}
}

func (this *SnapshotArray) Set(index int, val int) {
    records := this.history[index]
    last := len(records) - 1
    // 同一快照周期覆盖末条,已完成版本不再改写
    if records[last].snapID == this.snapID {
        records[last].value = val
        return
    }
    // 追加可能更换底层数组,必须把新切片写回历史表
    this.history[index] = append(records, record{snapID: this.snapID, value: val})
}

func (this *SnapshotArray) Snap() int {
    id := this.snapID
    this.snapID++
    return id
}

func (this *SnapshotArray) Get(index int, snap_id int) int {
    records := this.history[index]
    left, right := 0, len(records)-1
    for left < right {
        // 条件成立时令 left=mid 保留当前中点,向上取整保证两个候选时仍能收缩
        mid := left + (right-left+1)/2
        if records[mid].snapID <= snap_id {
            left = mid
        } else {
            right = mid - 1
        }
    }
    return records[left].value
}

复杂度分析

  • 时间复杂度:数组长度为 L 时构造为 $O(L)$;写入均摊 $O(1)$,创建快照为 $O(1)$;某下标有 m 条记录时,查询为 $O(\log(m+1))$。
  • 空间复杂度:$O(L+q)$,q 为累计写入次数。每个位置有一条初值记录,每次写入最多追加一条,同周期覆盖不会增加记录数。

关键点总结

[!green]

  • 按位置保存变更,快照只是版本分界,无需复制没有改变的数据。
  • 当前周期允许覆盖,已完成版本只能保留,才能保证历史值稳定。
  • 查询取最后一个不晚于目标的版本,自动跨越没有写入的快照空档。
  • 保留可行中点向右搜索时使用上中点,保证区间收缩。

易错点总结

[!yellow]

  • 创建快照先增加编号再返回,第一次编号会变成一,与接口约定不一致。
  • 后续写入直接覆盖历史最后值,却不检查版本,已经保存的快照会被篡改。
  • 只找与目标完全相等的记录,漏掉这个版本未写入、但沿用更早值的情况。
  • 找第一个不小于目标的记录,可能返回查询时尚未生效的未来值。
  • 二分用下中点同时执行 left = mid,两个候选时可能原地循环。
  • Go 追加历史后不把新切片写回 history[index],新长度或重新分配的底层数组不会保存到对象中。

相似题目

题目 难度 关联与区别
981. 基于时间的键值存储 中等 同样为每个键或下标维护版本历史,查询时二分找到不超过目标版本的最后一次赋值。
35. 搜索插入位置 简单 版本查询需要边界二分,目标不是必须存在的快照记录,而是最近的较早记录。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/69888132
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!