Start learning free

LESSON 346 OF 573 · BINARY SEARCH

Time Based Key-Value Store in TypeScript: Binary Search per Key

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

    TYPESCRIPT · BINARY SEARCH

    Time Based Key Value Store in TypeScript, explained

    Problem overview

    A time-based 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 class is TimeMap, with two methods:

    • set(key, value, timestamp) stores the string value for the string key at timestamp. Earlier values of the key are kept.
    • get(key, timestamp) returns the value whose timestamp is the largest one 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 strictly increase. Here is a run on a new TimeMap:

    const m = new TimeMap();
    m.set("foo", "bar", 1);
    console.log(m.get("foo", 1), m.get("foo", 3));   // bar bar
    m.set("foo", "bar2", 4);
    console.log(m.get("foo", 4), m.get("foo", 5));   // bar2 bar2

    At time 3, nothing new was set since time 1, so the value is still bar. A value set exactly at the asked time counts: get("foo", 4) is bar2.

    With set("love", "high", 10) and set("love", "low", 20), the calls get("love", t) for t = 5, 10, 15, 20, 25 return "" "high" "high" "low" "low", and get("hate", 25) returns "" because "hate" was never set.

    Approach: sorted lists and a binary search

    Each key needs its own history. Keep, for every key, an array of [timestamp, value] pairs. Because the timestamps of set calls only go up, each new pair belongs at the end, and every array stays sorted by time, oldest first, with no sorting at all. So set is a push: O(1).

    For get, the simple way walks through the key's pairs and remembers the last one that isn't after the asked time. That takes O(n) per call, where n is the number of values set for that key. But the array is sorted by time, which is exactly what binary search needs: ask the middle pair. If its time is at or before the asked time, it is a candidate answer, and a later pair could still beat it, so search the right half. Otherwise it and everything after it are too new, so search the left half. Each pass halves the positions left, so get takes O(log n).

    This is a binary search for the last position that satisfies a condition, rather than for an exact match. The candidate is saved in result every time the condition holds. Matches are found at later and later positions, so the last one saved is the best one, and if nothing ever matches, result stays the empty string.

    Step by step

    1. Declare store as a Map<string, [number, string][]>, set to new Map(). [number, string] is a tuple type, an array of exactly two items, and [number, string][] is an array of those pairs.
    2. set, first value of a key: if the map doesn't have the key, store an empty array for it.
    3. set, the push: push [timestamp, value] onto this.store.get(key). The end is the right place, so the list stays sorted.
    4. get, unknown key: return "" when the map doesn't have the key.
    5. Take the key's array in entries. This is the same array, not a copy.
    6. Start result as "", the answer if no value is old enough.
    7. Set the bounds: left = 0 and right = entries.length - 1, both inclusive.
    8. Loop while left <= right. With <=, the case where both are equal still checks the one remaining position.
    9. Find middle with Math.floor((left + right) / 2). In TypeScript, / keeps the fraction, so Math.floor is needed to get a valid position.
    10. The key test: if entries[middle][0] <= timestamp, store entries[middle][1] in result, and set left = middle + 1 to look for a later value.
    11. Otherwise set right = middle - 1: the pair at middle and all after it were set after timestamp.
    12. After the loop, return result.

    The solution in TypeScript

    class TimeMap {
        store: Map<string, [number, string][]> = new Map();
    
        set(key: string, value: string, timestamp: number): void {
            if (!this.store.has(key)) {
                this.store.set(key, []);
            }
            this.store.get(key).push([timestamp, value]);
        }
    
        get(key: string, timestamp: number): string {
            if (!this.store.has(key)) {
                return "";
            }
            let entries = this.store.get(key);
            let result = "";
            let left = 0, right = entries.length - 1;
            while (left <= right) {
                let middle = Math.floor((left + right) / 2);
                if (entries[middle][0] <= timestamp) {
                    result = entries[middle][1];
                    left = middle + 1;
                } else {
                    right = middle - 1;
                }
            }
            return result;
        }
    }

    Details:

    • Map.get returns undefined for a missing key, so the has check comes first. After it, this.store.get(key) is known to hold an array.
    • The method is named set and get like the Map methods it calls, but this.store.set(...) and this.store.get(...) are calls on the inner map, while m.set(...) is the class's own method.
    • The tuple types are checked only at compile time. At run time each pair is a plain two-element array.

    Complexity

    set is O(1): one map lookup and a push. get is O(log n) for the n values of that key, since each pass of the loop halves the range, and 100,000 values need at most 17 passes. Space is O(N) for N values stored in total.

    Edge cases and common mistakes

    • A time before the first value. Every pair is too new, the loop always goes left and result stays "". In the "love" example, get("love", 5) returns "".
    • An exact match counts. The test is <=, not <.
    • Stopping at the first match. The answer is the latest value at or before the time, so after a match the search continues to the right. For five pairs at times 10, 20, 30, 40, 50 and the asked time 35, the loop looks at positions 2 (time 30, a match) and 3 (time 40, too new) and ends with the value stored for time 30.
    • Keys keep separate histories. After set("a", "x", 1), set("b", "y", 2) and set("a", "z", 3), the calls get("a", 2), get("b", 1), get("b", 2) and get("a", 3) return x, "", y and z.
    • Wrong bounds. left < right would skip the last remaining position.
    Keep going with TypeScript500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free