1146-快照数组
实现支持下列接口的「快照数组」- SnapshotArray:
SnapshotArray(int length)
- 初始化一个与指定长度相等的 类数组 的数据结构。 初始时,每个元素都等于 ** 0**。void set(index, val)
- 会将指定索引index
处的元素设置为val
。int snap()
- 获取该数组的快照,并返回快照的编号snap_id
(快照号是调用snap()
的总次数减去1
)。int get(index, snap_id)
- 根据指定的snap_id
选择快照,并返回该快照指定索引index
的值。
示例:
**输入:** ["SnapshotArray","set","snap","set","get"]
[[3],[0,5],[],[0,6],[0,0]]
**输出:** [null,null,0,null,5]
**解释:** SnapshotArray snapshotArr = new SnapshotArray(3); // 初始化一个长度为 3 的快照数组
snapshotArr.set(0,5); // 令 array[0] = 5
snapshotArr.snap(); // 获取快照,返回 snap_id = 0
snapshotArr.set(0,6);
snapshotArr.get(0,0); // 获取 snap_id = 0 的快照中 array[0] 的值,返回 5
提示:
1 <= length <= 50000
- 题目最多进行
50000
次set
,snap
,和get
的调用 。 0 <= index < length
0 <= snap_id <
我们调用snap()
的总次数0 <= val <= 10^9
> Problem: 1146. 快照数组
[TOC]
思路
本题出现在《二分查找·系统掌握》这一训练专栏中,但第一眼看上去似乎和二分法并没有之间关系。不过,经过一系列性能优化后,二分法的妙用就体现出来了。
本节我们先分析一个朴素但效率极低的方法,然后分析针对这一方法的性能优化。
一种朴素但效率极低的方法
1 | class SnapshotArray { |
本方法通过STL对象unordered_map<int, vector<int>> m_snap_vec;
实现对快照数组的访问,即set、get以及snap操作。然而,这一代码提交的结果是:
仔细分析,各成员函数中,存储消耗最大的就是set操作,因为每次都会对一个vector对象进行拷贝,调用拷贝构造函数,这个无论是计算还是存储开销都是很大的。于是,接下来我们将分析存储开销的本质成因,以及如何优化。
性能瓶颈分析与优化思路
上述代码之所以需要在set操作中对vector对象进行拷贝,是因为用于访问快照数组的数据结构是一个从snap_id到vector的映射。这会造成大量重复的拷贝,这种拷贝可以从两个维度来分析:
【1】从行(第一维,dim=0)的角度分析,不一定每次snap操作之后,都会对当前快照的数组进行修改操作。如果snap后没有修改当前快照的数组,那get操作其实和访问之前的快照数组是等价的,这使得多次的snap操作都是无效的,完全不需要做;
【2】从列(第二维,dim=1)的角度分析,就算每一次snap操作之后,都对当前的快照数组进行修改了,也不一定每一列的元素都被修改。然而,每一次快照的拷贝操作,却是对每一列都进行拷贝了,这使得有些列的拷贝是无效的,也完全不需要做。
综上所述,我们可以得到一个更为凝练且具有深度的观点:以行id为备份快照的数据结构(即上面的 unordered_map<int, vector
解题方法
备份快照的数据结构分析
前文已经得出结论,以行id为备份快照的数据结构是无法完成本题的。这种数据结构,简单来说,就是二维数组,即使它是一个无序映射,但实际上这是一个二维数组。
那如何从一个,具有大量重复值/无效值的二维数组出发,提出适用于快照数组备份与访问的方案呢?试想一下,每一列元素,在每一次快照中都不一定被更新,那是不是可以以列为主体呢?只在需要更新时,才存储/更新列元素。
提出基于邻接表的高效存储快照
二维数组的存储方法,可以认为是一种邻接矩阵思想。反之,我们前面提到的,以列为主体的存储思路,就是一种邻接表思想。于是,本小节中我们提出了一种基于邻接表的高效存储快照。
这个数据结构是这样设计的:
1 | class SnapNode { |
上面的代码中,m_snap_vec 存储的每一个元素,代表快照数组中的项。这个项为什么是一个vector对象呢?这是因为,每一次经过更新的快照信息,需要维护起来,所以用vector实现维护。根据这一数据结构,我们可以得出下面的高效存储方案:
【1】m_snap_vec 中的每一个元素,都只在 set 操作时更新,类似于懒汉模式;
【2】在快照snap 操作中,无需备份,只需要更新snap的id即可,待到set操作再维护。
基于上述思路,我们可以发现,这种方案具有两个优势:
(1)snap快照操作没有任何额外的存储开销;
(2)set更新操作中只有O(1)的存储开销。
真正做到了,只存储“当前更新/需要维护的信息”,这是理论上的最优解。
提出基于二分法的高效访问快照
在实现了高效的存储后,如何高效的在给定snap_id 的情况下,对历史快照数组进行访问就是下一步需要解决的问题。
首先,我们发现,快照数组信息在更新的过程中,snap_id 是自增的,也就是说m_snap_vec 当中的每一个元素,都是以snap_id 升序排列的。这种单调性,不难让我们想到二分查找技术。于是,可以总结出基于二分法的高效访问快照方案:
【1】根据index,以及根据snap_id 升序排列的m_snap_vec[index]对象,我们可以用二分法求出小于目标id且尽可能大的快照值。
具体的,这个二分法,是在求小于目标snap_id,但尽可能大的元素。
复杂度
时间复杂度(k1表示执行get的次数,n表示m_snap_vec[index]的大小):
O(k_1*log_2(n))
空间复杂度(k2表示执行set的次数):
添加空间复杂度, 示例: O(k_2)
Code
1 |
|