Start learning free

LESSON 342 OF 569 · BINARY SEARCH

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

    JAVA · BINARY SEARCH

    Time Based Key Value Store in Java, 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(String key, String value, int timestamp) stores a value for a key at a time and keeps the key's earlier values. get(String key, int timestamp) returns the value that was set for the key with the largest timestamp that is still less than or equal to the one asked about. If nothing was set for the key at or before that time, or the key was never set, it returns the empty string "".

    The constraints say that the timestamps of the set calls are strictly increasing: each set has a larger timestamp than every set before it. Here is a session.

    TimeMap tm = new TimeMap();
    tm.set("foo", "bar", 1);
    System.out.println("[" + tm.get("foo", 1) + "] [" + tm.get("foo", 3) + "]");
    tm.set("foo", "bar2", 4);
    System.out.println("[" + tm.get("foo", 4) + "] [" + tm.get("foo", 5) + "]");
    System.out.println("[" + tm.get("foo", 0) + "] [" + tm.get("nope", 5) + "]");
    
    [bar] [bar]
    [bar2] [bar2]
    [] []
    

    At time 1, "foo" was set to "bar", so get("foo", 1) returns it. Nothing changed until time 4, so at time 3 the value is still "bar". From time 4 on, the answer is "bar2". Before the first value, at time 0, and for a key never set, the answer is the empty string. The brackets in the output only make the empty strings visible.

    Approach: a sorted list per key, then binary search

    The simple approach stores each key's values in a list and, for every get, scans the list for the latest entry whose timestamp is not after the one asked about. That is O(n) per call, where n is the number of values set for that key, and it is slow when one key has tens of thousands of values.

    The guarantee about set makes something better possible. Because timestamps only go up, appending each new pair to the end of its key's list keeps the list sorted by timestamp, oldest first, with no sorting at all. So set costs O(1). And a sorted list can be binary searched, which makes get cost O(log n).

    The search is not the usual one for an exact match. It looks for the last position whose timestamp is at most the one asked about. Whenever the middle entry qualifies (its timestamp is less than or equal to the query), it is a candidate answer, and anything better must lie to its right, so the search moves right. Whenever the middle entry is too new, it and everything after it are ruled out, so the search moves left. The last candidate seen is the answer, and if there was none, the answer stays empty.

    Step by step

    1. Declare the field store: a map from each key to a list of (timestamp, value) pairs. Java's pair is Map.Entry<Integer, String>, made with Map.entry(a, b) and read with getKey() and getValue().
    2. In set, create an empty list for the key if the key is new.
    3. Add Map.entry(timestamp, value) to the key's list. It has the largest timestamp so far, so the end is its place.
    4. In get, return "" if the key was never set.
    5. Keep the key's list in the variable entries. This is the same list under a shorter name, not a copy.
    6. Declare result as "". 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.size() - 1, both ends inclusive.
    8. Loop while left <= right. With < the last remaining position would never be checked.
    9. Compute middle as (left + right) / 2. Integer division rounds down, so it is a valid position.
    10. If the timestamp at middle is at most the query, this value was already set at that time. Store its value in result.
    11. Then set left = middle + 1 to look for a later value that still qualifies.
    12. Otherwise set right = middle - 1. This entry was set after the query, and so was every entry after it.
    13. After the loop, return result.

    The solution in Java

    class TimeMap {
        Map<String, List<Map.Entry<Integer, String>>> store = new HashMap<>();
    
        public void set(String key, String value, int timestamp) {
            if (!store.containsKey(key)) {
                store.put(key, new ArrayList<>());
            }
            store.get(key).add(Map.entry(timestamp, value));
        }
    
        public String get(String key, int timestamp) {
            if (!store.containsKey(key)) {
                return "";
            }
            List<Map.Entry<Integer, String>> entries = store.get(key);
            String result = "";
            int left = 0, right = entries.size() - 1;
            while (left <= right) {
                int middle = (left + right) / 2;
                if (entries.get(middle).getKey() <= timestamp) {
                    result = entries.get(middle).getValue();
                    left = middle + 1;
                } else {
                    right = middle - 1;
                }
            }
            return result;
        }
    }
    

    A few details about the Java code:

    • Map.Entry as a pair. Map.entry(timestamp, value) boxes the int into an Integer. The entries it creates are immutable: calling setValue on one throws UnsupportedOperationException, which is fine because the store never changes a pair.
    • Comparing a boxed value. entries.get(middle).getKey() <= timestamp unboxes the Integer and compares numbers. The relational operators unbox, which is safe. Writing == between two Integer objects would compare references instead, and gives wrong answers for larger values.
    • (left + right) / 2. With at most 100,000 values per key this sum cannot overflow an int. With lists of billions of entries, left + (right - left) / 2 would be the safe form.
    • Empty string, not null. store.get(key) would return null for a missing key, so the method checks containsKey first and returns "".

    Seeing it work

    Each key keeps its own list. After set("love", "high", 10) and set("love", "low", 20), asking at times 5, 10, 15, 20 and 25 gives:

    TimeMap tm2 = new TimeMap();
    tm2.set("love", "high", 10);
    tm2.set("love", "low", 20);
    for (int t : new int[]{5, 10, 15, 20, 25}) {
        System.out.print("[" + tm2.get("love", t) + "] ");
    }
    
    [] [high] [high] [low] [low]
    

    At time 5 the key has no value yet. Times 10 and 15 see "high": a value set exactly at the asked time counts, which is why the comparison is <=. Times 20 and 25 see "low".

    To follow a search by hand, take that list, [(10, high), (20, low)], and the query 15. The range is positions 0 to 1, so middle is 0. The timestamp 10 is at most 15, so result becomes "high" and left moves to 1. Now middle is 1, and 20 is more than 15, so right moves to 0. The range is empty and the method returns "high".

    Complexity

    set: O(1). It is a map lookup and a list append. No sorting is needed, because timestamps arrive in increasing order.

    get: O(log n), where n is the number of values set for that key. Each pass of the loop halves the range, so a list of 100,000 values needs about 17 passes. The lookup of the key in the map is O(1) on average. Scanning the list would take O(n) per call.

    Space: O(N) for N pairs in total, because every value is kept.

    Edge cases and common mistakes

    • A key that was never set. Return "" before touching the list, since there is none.
    • A time before the first value. Every entry is too new, so the search keeps moving left and result stays "".
    • Using < instead of <=. A value set exactly at the asked time would be missed: get("foo", 4) would return "bar" instead of "bar2".
    • Stopping at the first match. An exact-match binary search returns as soon as it finds the timestamp, but here a later entry may also qualify. After a match the search must keep going right.
    • Using left < right as the loop condition. A one-element range would be skipped and a single stored value would never be found.
    • Sorting in set. It is unnecessary. The sorted order comes for free from the increasing timestamps, and sorting would turn set into O(n log n).
    Keep going with Java500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free