Skip to content

Volume ITechnology

4.11.6 — Time Based Key-Value Store ​

LeetCode 981 · Medium

The problem ​

Design a store with two operations:

  • set(key, value, timestamp) — record a value for a key at a moment in time.
  • get(key, timestamp) — return the value that was set at the largest timestamp less than or equal to the one asked for. Return "" if there is none.
set("foo", "bar", 1)
get("foo", 1)    →  "bar"
get("foo", 3)    →  "bar"      (nothing newer than 1, so 1 still applies)
set("foo", "bar2", 4)
get("foo", 4)    →  "bar2"
get("foo", 5)    →  "bar2"
get("foo", 0)    →  ""         (nothing at or before 0)
get("foo", 0)    →  ""         (nothing at or before 0)

To put this in plain English: you are building a dictionary that remembers history. Instead of overwriting old values when a key is updated, you tag the new value with a timestamp. When someone asks for the value of "foo" at time T, you give them the most recent value of "foo" that was set at or before T.

Constraints to Keep in Mind: ​

  • 1 <= key.length, value.length <= 100
  • 1 <= timestamp <= 10^7
  • set and get will be called up to 2 * 10^5 times.
  • All calls to set for a given key arrive with strictly increasing timestamps. That promise is what makes the solution simple.

The Brute Force Approach ​

The simplest way to build this is to use a hash map where store[key] holds a list of (timestamp, value) pairs.

  • How it works: set just appends the pair to the list. For get, you iterate backwards through the list for the requested key, checking every timestamp until you find one \le the requested timestamp.
  • Why it's too slow: While set is O(1), get is O(n), where n is the number of times that specific key has been updated. With up to 200,000 operations, calling get repeatedly on a frequently-updated key will cause a Time Limit Exceeded (TLE) error. Since the list of timestamps for each key is guaranteed to be strictly increasing, we should use binary search to make get O(\log n).

The pattern ​

Two structures, each doing one job:

  • A hash map from key to a list, giving O(1) access to that key's history.
  • A list of (timestamp, value) pairs per key, which is already sorted because set is promised to arrive in increasing time order.

Then get is a binary search over that list, looking not for an exact match but for the largest timestamp not exceeding the target. That variant has a name — a floor search, or bisect_right minus one — and it is the reusable idea on this page.

Because the list arrives sorted for free, set is O(1): just append. Nothing needs to be sorted or shifted.

Ordinary binary search returns −1 when the value is absent. Here absence is normal — you almost never ask for a timestamp that exists exactly — so the search must converge on a position instead.

Use the boundary shape and carry the best candidate found so far:

python
low, high = 0, len(entries) - 1
answer = ""
while low <= high:
    mid = (low + high) // 2
    if entries[mid][0] <= timestamp:
        answer = entries[mid][1]     # valid candidate — remember it
        low = mid + 1                # but try for a later one
    else:
        high = mid - 1               # too new — look earlier
return answer

Every time you find a timestamp at or below the target, it is a legal answer, so record it. Then keep pushing right to see whether a later legal one exists. When the loop ends, answer holds the latest valid value.

Initialise answer to "", not to the first entry. Report 4 recorded this exact trap: seeding the tracker with index 0 assumes there is always a valid historical entry, and there is not — get("foo", 0) in the example above must return "". Use a value that cannot be confused with real data.

The solution ​

python
class TimeMap:
    def __init__(self):
        self.store = {}                       # key → list of (timestamp, value)

    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))     # already in order

    def get(self, key: str, timestamp: int) -> str:
        entries = self.store.get(key, [])
        answer = ""
        low, high = 0, len(entries) - 1

        while low <= high:
            mid = low + (high - low) // 2
            if entries[mid][0] <= timestamp:
                answer = entries[mid][1]
                low = mid + 1
            else:
                high = mid - 1

        return answer
ts
class TimeMap {
  private store = new Map<string, Array<[number, string]>>();

  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 {
    const entries = this.store.get(key) ?? [];
    let answer = "";
    let low = 0, high = entries.length - 1;

    while (low <= high) {
      const mid = low + Math.floor((high - low) / 2);
      if (entries[mid][0] <= timestamp) {
        answer = entries[mid][1];
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    return answer;
  }
}

self.store.get(key, []) handles a key that was never set, returning an empty list so the loop simply does not run.

Hand Trace ​

Let's trace get("foo", 3) where entries for "foo" is [(1, "bar"), (4, "bar2")]. Target timestamp = 3.

  • low = 0, high = 1. answer = ""
Steplowhighmidentries[mid][0] (timestamp)Valid (<= 3)?Action
10101Yes (1 <= 3)answer = "bar", low = 1
21114No (4 > 3)high = 0

Loop breaks (low > high). Returns answer = "bar". ✓

The library version ​

Python has this search built in:

python
import bisect

def get(self, key, timestamp):
    entries = self.store.get(key, [])
    i = bisect.bisect_right(entries, (timestamp, chr(127)))
    return entries[i - 1][1] if i else ""

bisect_right returns the insertion point after any equal entries, so i - 1 is the last entry at or below the target. The chr(127) is a sentinel that sorts after any ordinary string, making sure an exact timestamp match is included.

Write the manual loop in an interview. The point of the question is the floor search, and reaching for bisect skips it. Mention it afterwards.

Complexity ​

  • Time Complexity:
    • set is O(1). Appending to a list takes amortized constant time.
    • get is O(\log n) for a key with n entries. Binary search halves the search space at each step.
  • Space Complexity: O(N) extra space, where N is the total number of set operations. Every call to set adds a new tuple (timestamp, value) to a list in the hash map. No previous values are deleted.

What if the timestamps were not sorted? ​

The promise of increasing timestamps is doing real work. Without it, set would have to insert into the right position, which is O(n) in an array because of the shifting, or O(\log n) with a balanced tree or a skip list (4.13.2).

Noticing which constraint makes the easy solution legal is a good habit. It is the same reasoning as spotting "the array is sorted" in 4.5.2.

Where this goes next ​

This is a small version of a real thing:

  • Multi-version concurrency control — a database keeps several versions of a row, each stamped with the transaction that wrote it, and a reader finds the latest version visible to it. That lookup is this floor search. Chapter 7.4.
  • Time-series databases — a query for "the value at time T" is exactly this.
  • Snapshot reads and event sourcing — find the last snapshot at or before a point, then replay forward.

The honest limitation: memory grows forever, because nothing is ever deleted. A real system runs a compaction job that discards versions no reader can still see.

What the interviewer will push on ​

"Why is set O(1)?" Timestamps arrive in increasing order, so appending keeps the list sorted.

"How does your binary search handle a missing exact match?" It converges on the largest timestamp at or below the target, remembering the best candidate as it goes.

"What do you initialise the answer to?" A sentinel that cannot be a real value, not index 0.

"What if timestamps arrived out of order?" Insertion becomes O(n) in an array, or you switch to a balanced tree.

"How would this scale?" Memory is unbounded without compaction; naming that is the systems answer.

One thing to volunteer: name the search variant. "This is a floor search — the largest key not exceeding the target — rather than an exact-match search." That is the transferable piece.

Next: 4.11.7 Median of Two Sorted Arrays — the hardest binary search in the set, and the one where you search for a cut rather than a value.