Problem overview
A time-based key-value store keeps every value a key has ever had, together with the time it was set. You can then ask what a key's value was at any moment: the value set most recently at or before that moment.
The lesson asks for a TimeMap struct. Constructor creates an empty store. Set(key, value, timestamp) stores a string value for a key at a time, and keeps the key's earlier values. Get(key, timestamp) returns the value with the largest timestamp that is still <= timestamp, or the empty string "" if the key has no value that old or was never set.
One guarantee makes the design simple: the timestamps of the Set calls are strictly increasing. Each Set has a larger timestamp than every Set before it.
In the first example, Set("foo", "bar", 1) is followed by Get("foo", 1) and Get("foo", 3), which both return "bar": nothing changed between times 1 and 3. After Set("foo", "bar2", 4), both Get("foo", 4) and Get("foo", 5) return "bar2". Get("foo", 0) returns "", because nothing was set yet at time 0.
Approach: one sorted list per key, then binary search
The simplest Get looks through all of a key's values and keeps the latest one that isn't after the asked time. That costs O(n) per call, where n is the number of values set for that key.
The statement's guarantee removes the need to look at all of them. For each key, the store keeps a list of (timestamp, value) pairs. Since each new pair has the largest timestamp so far, it belongs at the end of its list, so every list stays sorted by time without any sorting, and Set takes O(1). A sorted list can be binary searched, so Get takes O(log n).
This is not the plain "find the value" binary search. The asked time usually falls between two timestamps, so there may be no exact match. The search looks for the last entry whose timestamp is not after the asked time. Whenever the entry in the middle qualifies, it is remembered as the best answer so far, and the search continues to the right, in case a later entry also qualifies. When the entry in the middle is too new, the search continues to the left.
As an example, take five values for one key with timestamps 10, 20, 30, 40, 50, and ask for time 35. The first middle is position 2 (timestamp 30): it qualifies, so the search moves right to positions 3 to 4. The middle is position 3 (timestamp 40): too new, so the search moves left and the range is empty. The answer is the value at timestamp 30.
Step by step
- Declare the
entrystruct with two fields:timestampas anintandvalueas astring. - Declare
storeinTimeMapas amap[string][]entry: for each key, its entries in the order they were set, which is also the order of their timestamps. - Create the map in
Constructor. The zero value of a map is nil, and storing into a nil map stops the program. - In
Set, appendentry{timestamp, value}tothis.store[key]and store the result back. For a new key,this.store[key]is a nil slice andappendworks on it. - In
Get, read the key's entries and whether the key exists in one line:entries, found := this.store[key]. - If the key isn't found, return
"". - Declare
resultas"". If no entry is old enough, it stays empty, which is the right answer. - Set
leftandrightto the first and the last position. Both ends are inclusive. - Loop while
left <= right. When they are equal, one position is still left to check. - Compute
middleas(left + right) / 2. Integer division rounds down, so it is a valid position. - If the entry at
middleis not aftertimestamp(<=, so a value set exactly at the asked time counts), save its value inresult. - Move
lefttomiddle + 1, to look for a later entry that still qualifies. - Otherwise move
righttomiddle - 1, because the entry and every one after it are too new. - Return
resultafter the loop.
The solution in Go
type entry struct {
timestamp int
value string
}
type TimeMap struct {
store map[string][]entry
}
func Constructor() TimeMap {
return TimeMap{store: map[string][]entry{}}
}
func (this *TimeMap) Set(key string, value string, timestamp int) {
this.store[key] = append(this.store[key], entry{timestamp, value})
}
func (this *TimeMap) Get(key string, timestamp int) string {
entries, found := this.store[key]
if !found {
return ""
}
result := ""
left, right := 0, len(entries)-1
for left <= right {
middle := (left + right) / 2
if entries[middle].timestamp <= timestamp {
result = entries[middle].value
left = middle + 1
} else {
right = middle - 1
}
}
return result
}
The Go details worth a closer look:
- Positional struct literal.
entry{timestamp, value}fills the fields in the order the struct declares them. If the declaration order changed, the literal would break, or silently swap fields of the same type. - The comma-ok form. A map read with two variables returns the value and a bool telling whether the key existed. Without it, a missing key would give a nil slice, which looks like a key with no entries. Here both cases happen to give
"", but the form makes the intent clear. - Appending to a map entry.
this.store[key]yields a slice header copy, so the result ofappendmust be assigned back to the map. - Safe midpoint.
(left + right) / 2could overflow for indexes near the limit ofint, but Go'sintis 64 bits here and a slice can't be that long, so it is safe.
Complexity
Time. Set is O(1) amortized: a map lookup and an append. Get is O(log n), where n is the number of values set for that key: every pass halves the positions still to check, so a key with 100,000 values needs at most 17 passes.
Space: O(N) for all N values ever set, since nothing is thrown away.
Edge cases and common mistakes
- A key that was never set.
Get("hate", 25)returns"". - A time before the first value. With
Set("love", "high", 10),Get("love", 5)returns"": no entry qualifies, soresultis never assigned. - An exact match.
Get("love", 10)returns"high". Using<instead of<=would miss it. - A time between two timestamps. After
Set("love", "low", 20),Get("love", 15)returns"high", the older value, andGet("love", 25)returns"low". - Several keys. Each key has its own list. After
Set("a", "x", 1),Set("b", "y", 2)andSet("a", "z", 3), the callsGet("a", 2),Get("b", 1),Get("b", 2)andGet("a", 3)return"x","","y"and"z". - Stopping at the first match. Returning as soon as an entry qualifies gives an old value. The search must continue to the right.
- Using
left < right. The last position would never be checked.
Testing
With the first example's calls, the four Get results are "bar", "bar", "bar2", "bar2". With the second example, the six Get results are "", "high", "high", "low", "low", "". For a key with the values "v1" to "v5" set at times 10, 20, 30, 40, 50, Get("k", 35) returns "v3", after two passes of the loop: the first looks at the timestamp 30 and the second at 40.
