Start learning free

LESSON 342 OF 569 · BINARY SEARCH

Time Based Key-Value Store in Python: Binary Search on Timestamps

Practice O(log n) Read the full explanation

A time-based key-value store keeps every value a key has ever had, each with the time it was set. You can then ask what a key's value was at any moment: the value that was set most recently at or before that moment.

Implement the class TimeMap:

  • TimeMap() creates an empty store.
  • set(key, value, timestamp) stores the string value for the string key at the time timestamp. The key's earlier values are kept.
  • get(key, timestamp) returns the value that was set for key with the largest timestamp that is still <= timestamp. If no value was set for key at or before timestamp, or key was never set at all, it returns the empty string "".

The input is a list of operations, applied in order to one new TimeMap. The output lists what the get calls return, in order; an empty string is shown as "". The starter code spells these names the way your language does.

 

Example 1:

Input:
    set("foo", "bar", 1)
    get("foo", 1)
    get("foo", 3)
    set("foo", "bar2", 4)
    get("foo", 4)
    get("foo", 5)
Output: bar bar bar2 bar2
Explanation:
At time 1, "foo" was set to "bar", so get("foo", 1) is "bar".
Nothing was set for "foo" between 1 and 3, so at time 3 the value is still "bar".
At time 4, "foo" was set to "bar2": from then on, get returns "bar2", at time 4 and at time 5.

Example 2:

Input:
    set("love", "high", 10)
    set("love", "low", 20)
    get("love", 5)
    get("love", 10)
    get("love", 15)
    get("love", 20)
    get("love", 25)
    get("hate", 25)
Output: "" high high low low ""
Explanation:
At time 5, "love" had no value yet, so the answer is the empty string.
At times 10 and 15 the latest value is "high"; at 20 and 25 it is "low".
"hate" was never set, so its answer is the empty string too.

Example 3:

Input:
    set("a", "x", 1)
    set("b", "y", 2)
    set("a", "z", 3)
    get("a", 2)
    get("b", 1)
    get("b", 2)
    get("a", 3)
Output: x "" y z
Explanation: Each key keeps its own values. At time 2, "a" still has "x": "z" comes only at time 3. "b" gets its first value at time 2, so at time 1 it has none.

 

Constraints:

  • 1 <= key.length, value.length <= 20
  • key and value consist of lowercase English letters and digits.
  • 1 <= timestamp <= 107
  • The timestamps of the set calls are strictly increasing: each set has a larger timestamp than every set before it.
  • At most 105 calls in total are made to set and get.

Note:

set should run in O(1) time and get in O(log n) time, where n is the number of values set for that key. Looking through all of a key's values in get would take O(n) time per call.

STEP 1 OF 13
    Ctrl + Enter to check

    PYTHON · BINARY SEARCH

    Time Based Key Value Store in Python, explained

    Problem overview

    A time-based key-value store never forgets. Each time a key is set, the new value is stored together with its timestamp, and the older values stay. The class TimeMap has two methods:

    • set(key, value, timestamp) stores value for key at timestamp.
    • get(key, timestamp) returns the value whose timestamp is the largest one that is still <= timestamp. If the key was never set, or has no value that old, it returns the empty string "".

    The timestamps of the set calls strictly increase: each set has a larger timestamp than every set before it. That promise is what the solution builds on.

    Example: set("love", "high", 10) and set("love", "low", 20). Then get("love", 5) is "", because nothing existed yet. At times 10 and 15 the answer is "high"; at 20 and 25 it is "low". A key never set, such as "hate", gives "" at any time.

    Approach: sorted lists and binary search

    The first idea is to keep, for each key, a list of (timestamp, value) pairs and scan it in get, remembering the last pair whose timestamp isn't too large. It is correct but costs O(n) per get, where n is the number of values set for that key. With 100,000 values on one key and 2,000 calls to get, a scan like that took about 3.4 seconds in a test run.

    Look at what the list looks like instead. Timestamps only increase, so appending each new pair at the end keeps the list sorted by time, with no sorting step. A sorted list can be searched by halving. The question "which is the last pair with a timestamp <= t?" is a binary search for the rightmost match. The same 2,000 calls took about 0.008 seconds with the search below.

    So set costs O(1) (a dictionary lookup and an append) and get costs O(log n).

    Step by step

    1. In __init__, set self.store to an empty dictionary. It maps each key to its list of pairs.
    2. In set, if the key isn't in self.store yet, give it an empty list.
    3. Append the tuple (timestamp, value) to that list. It has the largest timestamp so far, so the end is its place.
    4. In get, if the key isn't in self.store, return "". Reading self.store[key] for a missing key would raise a KeyError.
    5. Put the key's list in entries. This is the same list under a shorter name, not a copy.
    6. Create result as the empty string. It holds the best value found so far, and stays empty if nothing is old enough.
    7. Set up the search positions: left, right = 0, len(entries) - 1. Both ends are inclusive.
    8. Loop while left <= right. With <, the case where one position is left would be skipped.
    9. Compute middle = (left + right) // 2. The floor division gives a whole number.
    10. If entries[middle][0] <= timestamp, that value was already set at the asked time, so it is a candidate. Save entries[middle][1] in result and move left to middle + 1, because a later entry might be a better candidate.
    11. Otherwise this entry, and every entry after it, is too new. Move right to middle - 1.
    12. After the loop, return result. Each saved candidate sits at a later position than the one before, so the last one saved has the largest timestamp not above the request.

    The solution in Python

    class TimeMap:
        def __init__(self):
            self.store = {}
    
        def set(self, key: str, value: str, timestamp: int) -> None:
            if key not in self.store:
                self.store[key] = []
            self.store[key].append((timestamp, value))
    
        def get(self, key: str, timestamp: int) -> str:
            if key not in self.store:
                return ""
            entries = self.store[key]
            result = ""
            left, right = 0, len(entries) - 1
            while left <= right:
                middle = (left + right) // 2
                if entries[middle][0] <= timestamp:
                    result = entries[middle][1]
                    left = middle + 1
                else:
                    right = middle - 1
            return result
    

    Using the class with three values for one key:

    tm = TimeMap()
    for ts, v in [(1, "a"), (4, "b"), (9, "c")]:
        tm.set("k", v, ts)
    print(tm.store)
    print([(x, tm.get("k", x)) for x in (0, 1, 3, 4, 8, 9, 20)])
    
    {'k': [(1, 'a'), (4, 'b'), (9, 'c')]}
    [(0, ''), (1, 'a'), (3, 'a'), (4, 'b'), (8, 'b'), (9, 'c'), (20, 'c')]
    

    Python details worth knowing:

    • A tuple such as (timestamp, value) is one item holding two values. entries[middle][0] is its timestamp and entries[middle][1] its value.
    • // rounds down, so middle is a valid position. In Python, integers don't overflow, so (left + right) // 2 is safe.
    • The method name set shadows nothing: it is looked up on the object (tm.set(...)), and the built-in set is unaffected.
    • The standard bisect module can do this search too, but writing the loop shows how the narrowing works.

    Complexity

    Time: set is O(1): a dictionary lookup and an append. get is O(log n), where n is the number of values set for that key. Each pass halves the positions still to check, so about 17 passes cover 100,000 values.

    Space: O(N) for N values set in total, since every value is kept.

    Edge cases and common mistakes

    • Before the first value. At time 0 the first entry is already too new, so every step moves right left and result stays "".
    • An exact match. The test is <=, not <: get("k", 4) must return "b".
    • A time after the last value. The answer is the last value, as get("k", 20) shows.
    • A missing key. Check it before reading the dictionary, or a KeyError stops the program.
    • Stopping at the first match. A binary search that returns as soon as it finds a timestamp <= t can give an old value. The search must keep going right after every match.
    • Keys are independent. A value set for one key never affects another.

    Testing

    The love example from the overview, run on the class:

    tm = TimeMap()
    tm.set("love", "high", 10)
    tm.set("love", "low", 20)
    print([tm.get("love", x) for x in (5, 10, 15, 20, 25)])
    print(repr(tm.get("hate", 25)))
    
    ['', 'high', 'high', 'low', 'low']
    ''
    
    Keep going with Python500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free