Start learning free

LESSON 342 OF 569 · BINARY SEARCH

Time Based Key-Value Store in JavaScript: 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

    JAVASCRIPT · BINARY SEARCH

    Time Based Key Value Store in JavaScript, explained

    Problem overview

    A time-based key-value store remembers every value a key has ever had, together with the time it was set. The class TimeMap has two methods. set(key, value, timestamp) stores a string value for a string key at a given time and keeps the key's earlier values. get(key, timestamp) returns the value set for that key with the largest timestamp that is still less than or equal to timestamp. If the key had no value at or before that time, or was never set, it returns the empty string.

    The constraints add one important promise: the timestamps of set calls are strictly increasing, so each new set is later than every one before it.

    The first example: set("foo", "bar", 1), then get("foo", 1) and get("foo", 3), then set("foo", "bar2", 4), then get("foo", 4) and get("foo", 5). The four get calls return:

    "bar" "bar" "bar2" "bar2"
    

    At time 3 nothing newer than "bar" has been set, so "bar" is still the value. A value set exactly at the asked time counts, so get("foo", 4) already sees "bar2".

    Approach: sorted lists and binary search

    For each key, keep a list of [timestamp, value] pairs. Because timestamps only go up, a new pair belongs at the end of its list, and every list stays sorted by time with no sorting at all. So set is a push: O(1).

    A simple get walks through the key's list and remembers the last pair whose timestamp is not after the query. That costs O(n) per call, where n is the number of values set for that key. With a key that holds 100,000 values, 2,000 get calls of this kind took 486 ms in a test run, while the binary search version took 0.93 ms for the same 2,000 calls, with identical answers.

    A sorted list can be binary searched. The question is "the last pair whose timestamp is at most timestamp", which is a slight variation on finding an exact match. Look at the middle pair. If its timestamp is at most the query, it is a candidate answer, and every pair before it is older, so only a later pair could beat it: remember it and search the right half. If its timestamp is after the query, so is everything to its right: search the left half. When the range is empty, the last remembered candidate is the answer, or nothing was remembered and the answer is the empty string. Each pass halves the range, so get takes O(log n): about 17 passes for 100,000 values.

    Here is a key "k" with five values, stored as [[1,"a"],[3,"b"],[5,"c"],[7,"d"],[9,"e"]], and the query get("k", 6). The search visits:

    left 0 right 4 middle 2 time 5
    left 3 right 4 middle 3 time 7
    result "c"
    

    At middle 2 the time 5 is at most 6, so "c" is remembered and the search moves right. At middle 3 the time 7 is after 6, so the right edge moves left of it, the range is empty, and "c" is returned. For the same list, get("k", 0) returns "" because no value is that old, get("k", 100) returns "e", and get("k", 1) returns "a".

    Step by step

    1. Constructor: set this.store to a new Map. It maps each key to its array of pairs.
    2. set: if this.store does not have key yet, store an empty array for it. The first value of a key needs a list to go into.
    3. Push [timestamp, value] onto the key's array, this.store.get(key). This pair has the largest timestamp so far, so the end is its place.
    4. get: if the store does not have key, return "". A key that was never set has no value at any time.
    5. Put the key's array in entries. It is the same array under a shorter name, not a copy.
    6. Declare result as the empty string. It holds the best value found so far, and stays empty if no value is old enough.
    7. Declare left as 0 and right as entries.length - 1. Both ends are inclusive.
    8. Loop while left <= right. With < the loop would skip the case where one position is left to check.
    9. Compute middle as Math.floor((left + right) / 2). In JavaScript / keeps the fraction, so Math.floor turns it into a valid position.
    10. If entries[middle][0] <= timestamp, store entries[middle][1] in result. This is the key idea: the value was already set at the asked time, so it can be the answer. The comparison is <=, not <, because a value set exactly at timestamp counts.
    11. In the same branch, move left to middle + 1 to look for a later value that is still not after timestamp.
    12. Otherwise move right to middle - 1: the value at middle was set after timestamp, and so was every value after it.
    13. After the loop, return result.

    The solution in JavaScript

    class TimeMap {
        constructor() {
            this.store = new Map();
        }
    
        set(key, value, timestamp) {
            if (!this.store.has(key)) {
                this.store.set(key, []);
            }
            this.store.get(key).push([timestamp, value]);
        }
    
        get(key, timestamp) {
            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;
        }
    }
    

    JavaScript details:

    • Map instead of a plain object. has, get and set read and write entries by key. The method named set on TimeMap is separate from the Map's own set: inside the class, this.store.set(key, []) calls the map's.
    • Pairs are arrays. For a pair p, p[0] is the timestamp and p[1] the value. Pushing one array per call keeps the two together.
    • Arrays are shared. let entries = this.store.get(key) does not copy: entries and the stored array are the same object, which is why this line is free.
    • let left = 0, right = ... declares two variables in one statement.

    Complexity

    Time: set is O(1): a map lookup and an array push. get is O(log n), where n is the number of values set for that key: one map lookup and a binary search that halves the range on every pass.

    Space: O(N) for N stored values in total, one pair each.

    Edge cases and common mistakes

    • A key that was never set. get("hate", 25) returns "". Without the has check, entries.length would fail on undefined.
    • A time before the first value. In the second example, get("love", 5) returns "" because "love" was first set at time 10. The result variable starts empty and nothing overwrites it.
    • The exact timestamp. get("love", 10) returns "high", the value set at 10. Using < instead of <= would miss it.
    • Returning the first match. Stopping at the first pair with a timestamp at most the query returns an old value. Keep searching to the right to find the latest one.
    • Keys are independent. In the third example, get("a", 2) returns "x", because "z" is set only at time 3, and get("b", 1) returns "", because "b" first gets a value at time 2.
    • Sorting on every call. Unnecessary and slow. Timestamps arrive in order, so the end of the list is always the right place.

    Testing

    Outputs of the solution above:

    • Example 1 returns "bar" "bar" "bar2" "bar2".
    • Example 2 ("love" set to "high" at 10 and "low" at 20), the gets at times 5, 10, 15, 20, 25 return "" "high" "high" "low" "low", and get("hate", 25) returns "".
    • Example 3 returns "x" "" "y" "z".
    • The key with five values above: get("k", 6) returns "c", get("k", 0) returns "", get("k", 100) returns "e".
    Keep going with JavaScript500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free