Problem overview
A time-based key-value store remembers every value a key has ever had, together with the time it was set, and can answer what a key's value was at any moment. The class TimeMap has two methods:
set(key: String, value: String, timestamp: Int): Unitstoresvalueforkeyattimestamp. Earlier values of the key are kept.get(key: String, timestamp: Int): Stringreturns the value set forkeywith the largest timestamp that is still<= timestamp. If the key has no value at or before that time, or was never set, it returns the empty string"".
The timestamps of the set calls are strictly increasing: every set has a larger timestamp than every set before it.
Example: after set("foo", "bar", 1), both get("foo", 1) and get("foo", 3) return bar, since nothing changed between times 1 and 3. After set("foo", "bar2", 4), get("foo", 4) and get("foo", 5) return bar2. The value set exactly at the asked time counts.
Approach
The simple way and its cost
Keep each key's values in a list and, in get, look through the whole list for the latest entry that is not after the asked time. That costs O(n) per call, where n is the number of values set for that key. The lesson asks for O(1) in set and O(log n) in get.
Lists that sort themselves
For each key the class keeps an ArrayBuffer of (timestamp, value) pairs. Because every new timestamp is larger than all earlier ones, a new pair always belongs at the end. The list therefore stays sorted by time, oldest first, without any sorting, and set is just an append.
A sorted list can be binary searched. The twist is that the target time usually is not in the list, so the search is not "find this timestamp" but "find the last timestamp that is not after the target". The method:
- look at the middle entry;
- if its time is
<=the asked time, its value is a valid answer, and anything better must be to its right: remember the value and movelefttomiddle + 1; - otherwise the entry and everything after it are too new, so move
righttomiddle - 1.
Each pass halves the positions still to check. Because every match is further right than the one before, the remembered value at the end belongs to the largest timestamp that is not after the asked time. If no entry qualifies, the remembered value stays "", which is the right answer.
A trace on one key with the values (1, "a"), (2, "b"), (4, "c"), (7, "d"), (9, "e"), asking for time 8:
| left | right | middle | time at middle | what happens |
|---|---|---|---|---|
| 0 | 4 | 2 | 4 | 4 <= 8: result is c, search to the right |
| 3 | 4 | 3 | 7 | 7 <= 8: result is d, search to the right |
| 4 | 4 | 4 | 9 | 9 > 8: search to the left, the range is now empty |
The result is d. Asking for time 0 on the same list looks at times 4 and 1, both too new, and returns "".
Step by step
- Declare
store, amutable.HashMap[String, mutable.ArrayBuffer[(Int, String)]]. It maps each key to its buffer of pairs.(Int, String)is a tuple type, read with._1and._2. - In
set, create the key's buffer ifstoredoes not contain the key yet. - Append the pair
(timestamp, value)tostore(key)with+=. The pair needs its own parentheses inside those of+=. - In
get, return""at once if the key was never set. - Name the buffer
entries. It is the same buffer, not a copy. - Declare
resultas avarholding"", the answer when no value is old enough. - Declare
leftandright, the inclusive ends of the range still to check:0andentries.length - 1. - Loop while
left <= right. When the two are equal, one position is still left to check. - Take
middleas(left + right) / 2. Integer division rounds down, so it is a valid position. - If
entries(middle)._1 <= timestamp, storeentries(middle)._2inresult... - ...and move
lefttomiddle + 1. - Otherwise move
righttomiddle - 1. - Return
result.
The solution in Scala
class TimeMap {
val store = mutable.HashMap[String, mutable.ArrayBuffer[(Int, String)]]()
def set(key: String, value: String, timestamp: Int): Unit = {
if (!store.contains(key)) {
store(key) = mutable.ArrayBuffer[(Int, String)]()
}
store(key) += ((timestamp, value))
}
def get(key: String, timestamp: Int): String = {
if (!store.contains(key)) {
return ""
}
val entries = store(key)
var result = ""
var left = 0
var right = entries.length - 1
while (left <= right) {
val middle = (left + right) / 2
if (entries(middle)._1 <= timestamp) {
result = entries(middle)._2
left = middle + 1
} else {
right = middle - 1
}
}
return result
}
}
Using the class:
val t = new TimeMap
t.set("love", "high", 10)
t.set("love", "low", 20)
val answers = List(t.get("love", 5), t.get("love", 10), t.get("love", 15), t.get("love", 20), t.get("love", 25), t.get("hate", 25))
println(answers.map(s => "\"" + s + "\""))
List("", "high", "high", "low", "low", "")
The first and last answers are empty strings: "love" has no value at time 5, and "hate" was never set.
Scala details:
store(key) = ...andstore(key) += .... The first stores a new buffer under the key. The second reads the buffer for the key and appends to it in place.- Double parentheses in
+= ((timestamp, value)). The outer ones belong to the call, the inner ones build the tuple. Writing+= (timestamp, value)is a type mismatch, since the compiler sees two arguments. - Tuples.
entries(middle)._1is the first element of the pair and._2is the second. (left + right) / 2divides twoInts, so the fraction is dropped. With the limits in this lesson the sum cannot overflow anInt.- The lesson's code uses
mutablewithout a prefix. In a file of your own, addimport scala.collection.mutable.
Complexity
Time. set is O(1) on average: a hash lookup and an append. get is O(log n) for the n values of that key: the range halves on every pass, and a list of 100,000 values needs at most 17 passes.
Space. O(N) for N values in total, since every set keeps its pair.
Edge cases and common mistakes
- Using
<instead of<=. A value set exactly at the asked time must count. With a strict comparison,get("love", 10)returns""instead ofhigh. - A key that was never set.
store(key)on a missing key throwsNoSuchElementException, so the check at the top ofgetis needed.get("hate", 25)returns"". - A time before the key's first value. Every entry is too new,
rightkeeps moving left, andresultstays"". In example terms,get("love", 5)returns"". - Returning the middle at once. Stopping at the first entry with
time <= timestampwould return an older value when a newer one is also valid. Keep searching to the right after a match. - Keys do not share lists. After
set("a", "x", 1),set("b", "y", 2)andset("a", "z", 3),get("a", 2)returnsxandget("b", 1)returns"". - Using
while (left < right). The last position would never be checked.
Testing
- After
set("foo", "bar", 1):get("foo", 1)andget("foo", 3)returnbar. - After
set("foo", "bar2", 4):get("foo", 4)andget("foo", 5)returnbar2. - After
set("a", "x", 1),set("b", "y", 2),set("a", "z", 3):get("a", 2),get("b", 1),get("b", 2)andget("a", 3)returnx,"",yandz. - With the values from the trace above, asking for times 0 to 10 gives
"",a,b,b,c,c,c,d,d,e,e.
