Start learning free

LESSON 341 OF 568 · BINARY SEARCH

Time Based Key-Value Store in C++: O(log n) get with Binary Search

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 12
    Ctrl + Enter to check

    C++ · BINARY SEARCH

    Time Based Key Value Store in C++, explained

    Problem overview

    A time-based key-value store remembers every value a key has ever had, 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, value, timestamp) stores a string value for a string key at a time, keeping the key's earlier values. 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, get returns the empty string.

    The timestamps of the set calls strictly increase: each set has a larger timestamp than every set before it, whatever the key. The statement asks for O(1) time for set and O(log n) for get, where n is the number of values set for that key.

    In the first example, set("foo", "bar", 1) is followed by get("foo", 1) and get("foo", 3), which both return bar: nothing was set between times 1 and 3. After set("foo", "bar2", 4), get("foo", 4) and get("foo", 5) both return bar2. The value set exactly at time 4 counts.

    Approach: a sorted list per key, then binary search

    The simple solution keeps each key's pairs in a list and scans it from the end, or from the start, until it finds the right one. That is O(n) per get for a key with n values.

    The key observation is that the timestamps only go up. So if each key owns a vector of (timestamp, value) pairs and every set appends to the end, each vector is already sorted by time, with no sorting step. set is a single push_back.

    A sorted list can be binary searched. The question for get is: what is the last pair whose timestamp is at most the one asked about? Look at the middle pair:

    • If its timestamp is <= timestamp, it could be the answer. Remember its value, then search to the right, because a later pair might still qualify.
    • Otherwise this pair and everything after it were set too late, so search to the left.

    When the search ends, the remembered value is the one with the largest qualifying timestamp. If no pair qualified, nothing was remembered, and the result stays empty. Each pass halves the range, so get takes O(log n): a search over 100,000 values for one key takes 16 passes.

    For the key "love", set to high at time 10 and low at time 20, the answers are:

    get("love", 5)  = ""
    get("love", 10) = "high"
    get("love", 15) = "high"
    get("love", 20) = "low"
    get("love", 25) = "low"
    get("hate", 25) = ""

    Step by step

    1. Declare store as an unordered_map from string to vector<pair<int, string>>. Each key maps to its pairs, with .first the timestamp and .second the value.
    2. In set, push {timestamp, value} onto store[key]. For a key that is not in the map yet, store[key] first creates an empty vector, so the first value needs no special case. The new pair has the largest timestamp so far, so the end is its place.
    3. In get, handle a key that was never set. If store.find(key) equals store.end(), return "".
    4. Take a reference to the key's vector: vector<pair<int, string>> &entries = store[key];. The & makes entries another name for the vector inside the map. Without it, C++ would copy the whole vector on every call and undo the speed-up.
    5. Start result as the empty string. If no value is old enough, this is the answer.
    6. Set up the search range: left at 0 and right at entries.size() - 1, both ends inclusive.
    7. Loop while left <= right. With <=, the case where one position is left is still checked.
    8. Compute middle as (left + right) / 2. Integer division rounds down, which gives a valid position.
    9. If entries[middle].first <= timestamp, store entries[middle].second in result.
    10. Then move left to middle + 1 to look for a later value that still qualifies.
    11. Otherwise move right to middle - 1. The pair at middle was set after timestamp, and so was every pair after it.
    12. Return result. Each match is at a later position than the one before, so result ends with the value of the largest qualifying timestamp.

    The solution in C++

    class TimeMap {
    public:
        unordered_map<string, vector<pair<int, string>>> store;
    
        void set(string key, string value, int timestamp) {
            store[key].push_back({timestamp, value});
        }
    
        string get(string key, int timestamp) {
            if (store.find(key) == store.end()) {
                return "";
            }
            vector<pair<int, string>> &entries = store[key];
            string result = "";
            int left = 0, right = entries.size() - 1;
            while (left <= right) {
                int middle = (left + right) / 2;
                if (entries[middle].first <= timestamp) {
                    result = entries[middle].second;
                    left = middle + 1;
                } else {
                    right = middle - 1;
                }
            }
            return result;
        }
    };
    

    The C++ details worth a closer look:

    • operator[] inserts. store[key] creates an empty vector when the key is missing. That is what set wants. In get, it would add a useless empty entry for every unknown key, which is why get checks with find first. After get("nokey", 5) on a store that holds two keys, the store still holds two keys.
    • The reference. vector<pair<int, string>> &entries binds to the stored vector. Copying instead would cost O(n) per call, and the strings inside would be copied too.
    • pair and braces. push_back({timestamp, value}) builds a pair<int, string> from the two values. The pair holds its own copy of the string.
    • size() is unsigned. entries.size() - 1 would wrap around for an empty vector. Here the vector is never empty: a key is only in the map after a set pushed to it, so there is at least one pair.
    • The midpoint. (left + right) / 2 could overflow int only for sizes near 2 billion. With at most 100,000 calls it is safe.

    Another way is the standard library's upper_bound, which finds the first pair whose timestamp is greater than the one asked about; the pair just before it is the answer. In a file of your own, with <algorithm> included, this works on the vector {1, "bar"}, {4, "bar2"}:

    vector<pair<int, string>> e = {{1, "bar"}, {4, "bar2"}};
    auto it = upper_bound(e.begin(), e.end(), ts,
        [](int t, const pair<int, string>& p) { return t < p.first; });
    string answer = (it == e.begin()) ? "" : (it - 1)->second;

    For ts equal to 0, 1, 3, 4 and 5, answer is "", bar, bar, bar2 and bar2. The hand-written loop does the same work and shows the idea more directly.

    Complexity

    Time: O(1) for set, O(log n) for get. set is a hash lookup plus a push_back, which is amortized constant. get does a hash lookup and then a binary search over the n pairs of that key, each pass halving the range. The hash of a key or a string comparison costs time in the length of the strings, which is at most 20 characters.

    Space: O(N) for N calls to set, one pair per call.

    Edge cases and common mistakes

    • A timestamp before the first value. get("love", 5) must return the empty string. Because result starts empty and is only assigned on a match, it does.
    • A timestamp exactly equal to a set time. get("foo", 4) returns bar2. Using < instead of <= would skip it and return bar.
    • A key that was never set. get("hate", 25) returns the empty string, and the check with find keeps get from creating an entry for it.
    • Keys do not share values. With a set to x at 1, b to y at 2, and a to z at 3: get("a", 2) is x, get("b", 1) is the empty string, get("b", 2) is y and get("a", 3) is z. The timestamps are increasing across all keys, but each key's own vector is still sorted.
    • Stopping at the first match. Returning as soon as one pair qualifies gives an old value when a later one also qualifies. The search must keep going right.
    • Using right = entries.size(). The range is inclusive, so the last position is size() - 1. A start of size() reads past the end of the vector.
    • Returning the timestamp instead of the value. The result is .second, the string.

    Testing

    • set("foo", "bar", 1), get("foo", 1), get("foo", 3), set("foo", "bar2", 4), get("foo", 4), get("foo", 5) gives bar bar bar2 bar2.
    • set("love", "high", 10), set("love", "low", 20), then get("love", 5), get("love", 10), get("love", 15), get("love", 20), get("love", 25), get("hate", 25) gives "" high high low low "".
    • set("a", "x", 1), set("b", "y", 2), set("a", "z", 3), then get("a", 2), get("b", 1), get("b", 2), get("a", 3) gives x "" y z.
    Keep going with C++500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free