Facebook Pixel

3709. Design Exam Scores Tracker

MediumDesignArrayBinary SearchPrefix Sum
LeetCode ↗

Problem Description

Alice takes exams over time and wants a system that can record her scores and answer questions about the total score within any given time range.

We need to implement a class called ExamTracker that supports three operations:

  • ExamTracker(): Creates and initializes the tracker object.
  • record(time, score): Records that Alice took an exam at time time and scored score points.
  • totalScore(startTime, endTime): Returns the sum of scores of all exams Alice took between startTime and endTime, inclusive of both endpoints. If no exams fall within this interval, the answer is 0.

There are two important guarantees that make the problem easier:

  1. Records arrive in order: Every call to record() uses a time value that is strictly larger than all previous ones. This means the recorded times naturally form a sorted (increasing) sequence — no sorting is ever needed.
  2. No queries about the future: Whenever totalScore(startTime, endTime) is called, the condition startTime <= endTime <= t holds, where t is the latest recorded time. In other words, queries only ask about time ranges that are already fully covered by the existing records.

For example, if Alice records:

  • record(1, 98) — score 98 at time 1
  • record(5, 99) — score 99 at time 5

Then:

  • totalScore(1, 3) returns 98, because only the exam at time 1 falls in the range [1, 3].
  • totalScore(1, 5) returns 197, because both exams fall in the range [1, 5].
  • totalScore(2, 4) returns 0, because no exam was taken in the range [2, 4].

The main challenge is to make totalScore efficient when there are many records and many queries. Since the times are already sorted, a natural idea is to maintain a prefix sum array alongside the times, so each query can be answered with binary search in O(log n) time instead of scanning all records.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

How We Pick the Algorithm

Why Design + Supporting Data Structures?

This problem maps to Design + Supporting Data Structures through a short path in the full flowchart.

Tree orgraph?noImplementclass /datayesDesign +Supporting DataStructures

Designing an exam tracker with range-sum queries uses prefix sums and binary search on chronologically sorted records.

Open in Flowchart

Intuition

The naive approach would be to store every (time, score) pair in a list, and for each totalScore(startTime, endTime) query, scan through all records and sum up the scores whose times fall inside the range. This works, but every query costs O(n) time, which becomes slow when there are many records and many queries.

To do better, let's look at what the problem gives us for free:

  1. The times are already sorted. Since record() is always called with strictly increasing time, the list of recorded times is naturally in ascending order without any extra work.
  2. A range-sum query on a sorted timeline is really an interval sum. The exams inside [startTime, endTime] form a contiguous block in our list, because the times are sorted. So the question "what is the total score in this time range?" reduces to "what is the sum of a contiguous segment of the score list?"

These two observations point directly to two classic tools:

  • Prefix sums answer "sum of a contiguous segment" in O(1). If pre[i] is the total score of the first i exams, then the sum of exams from index l+1 to r is simply pre[r] - pre[l].
  • Binary search finds the boundaries of that segment in O(log n). On the sorted times array, we can binary search for the first exam with time >= startTime and the last exam with time <= endTime.

Putting it together: maintain times and pre side by side. Each record() just appends to both arrays — appending a new time keeps times sorted, and appending pre[-1] + score extends the running total, both in O(1). Each totalScore() does two binary searches to locate the segment boundaries, then one subtraction of prefix sums.

One small but elegant trick: we initialize both arrays with a sentinel 0 (times = [0], pre = [0]). This sentinel acts as a "virtual exam at time 0 with score 0", which removes the need for special-case handling when the binary search lands before the first real record — the index -1 situation simply never occurs, and an empty range naturally yields pre[r] - pre[l] = 0.

This way, recording stays O(1) and querying drops from O(n) to O(log n).

Pattern Learn more about Binary Search and Prefix Sum patterns.

Solution Approach

We follow the Prefix Sum + Binary Search strategy, maintaining two parallel arrays that grow together as exams are recorded.

Data Structures

  • times: a sorted list of exam times, initialized as [0] (a sentinel).
  • pre: a prefix sum list, where pre[i] is the total score of the first i exams, initialized as [0].

The sentinel pair (times[0] = 0, pre[0] = 0) represents a virtual exam at time 0 with score 0. It guarantees that any binary search result minus one is still a valid index, so no edge-case checks are needed.

record(time, score)O(1)

def record(self, time: int, score: int) -> None:
    self.times.append(time)
    self.pre.append(self.pre[-1] + score)

Two steps happen here:

  1. Append time to times. Because calls arrive with strictly increasing time, the array stays sorted automatically.
  2. Append pre[-1] + score to pre, extending the running total so that pre[i] - pre[j] always equals the sum of scores of exams j+1 through i.

totalScore(startTime, endTime)O(log n)

def totalScore(self, startTime: int, endTime: int) -> int:
    l = bisect_left(self.times, startTime) - 1
    r = bisect_left(self.times, endTime + 1) - 1
    return self.pre[r] - self.pre[l]

We need the sum of exams whose times lie in [startTime, endTime]. Two binary searches locate the boundaries of this contiguous block:

  1. Left boundary: bisect_left(times, startTime) returns the index of the first exam with time >= startTime. Subtracting 1 gives l, the index of the last exam strictly before startTime. Everything up to l must be excluded.
  2. Right boundary: bisect_left(times, endTime + 1) returns the index of the first exam with time > endTime. Subtracting 1 gives r, the index of the last exam with time <= endTime. Everything up to r must be included.
  3. Answer: the exams in the range are exactly those at indices l+1 through r, so the result is pre[r] - pre[l].

Example Walkthrough

Suppose Alice calls:

Operationtimespre
init[0][0]
record(1, 98)[0, 1][0, 98]
record(5, 99)[0, 1, 5][0, 98, 197]

Now query totalScore(1, 3):

  • l = bisect_left(times, 1) - 1 = 1 - 1 = 0
  • r = bisect_left(times, 4) - 1 = 2 - 1 = 1
  • Answer: pre[1] - pre[0] = 98 - 0 = 98

Query totalScore(2, 4) (empty range):

  • l = bisect_left(times, 2) - 1 = 2 - 1 = 1
  • r = bisect_left(times, 5) - 1 = 2 - 1 = 1
  • Answer: pre[1] - pre[1] = 0 ✓ — when no exam falls in the interval, l and r coincide and the subtraction naturally yields 0.

Complexity Analysis

  • Time: record is O(1) amortized (a single append to each array); totalScore is O(log n) due to the two binary searches, where n is the number of recorded exams.
  • Space: O(n) to store the two arrays.

Alternative Perspectives

  • Binary Indexed Tree / Segment Tree: These also support point updates and range-sum queries in O(log n). They are more general (e.g., they handle unordered insertions or score modifications), but here they are unnecessary overhead since times arrive pre-sorted and scores never change.
  • Sliding-window pruning: If queries were known to only touch recent times, old records could be discarded to save memory. The problem gives no such guarantee, so we keep the full history.

The prefix sum + binary search combination is the cleanest fit: it exploits the chronological-order guarantee to get constant-time updates and logarithmic-time queries with minimal code.

Example Walkthrough

Let's trace through a complete sequence of operations to see how the prefix sum + binary search approach works in practice.

Suppose Alice performs the following operations:

ExamTracker()
record(2, 70)
record(4, 80)
record(7, 90)
totalScore(2, 7)   → ?
totalScore(3, 6)   → ?
totalScore(5, 6)   → ?

Step 1 — Initialization

Both arrays start with the sentinel 0:

timespre
init[0][0]

The sentinel acts as a virtual exam at time 0 with score 0, so binary search results minus one never go out of bounds.

Step 2 — Recording exams (each O(1))

OperationtimespreNote
record(2, 70)[0, 2][0, 70]pre[-1] + 70 = 70
record(4, 80)[0, 2, 4][0, 70, 150]70 + 80 = 150
record(7, 90)[0, 2, 4, 7][0, 70, 150, 240]150 + 90 = 240

Since times arrive strictly increasing, times stays sorted with simple appends — no sorting needed. Each pre[i] holds the total score of the first i exams.

Step 3 — Query totalScore(2, 7) (full range)

  • Left boundary: l = bisect_left(times, 2) - 1 = 1 - 1 = 0 → the last exam strictly before time 2 is the sentinel at index 0.
  • Right boundary: r = bisect_left(times, 8) - 1 = 4 - 1 = 3 → the last exam with time ≤ 7 is at index 3.
  • Answer: pre[3] - pre[0] = 240 - 0 = 240 ✓ (all three exams: 70 + 80 + 90)

Step 4 — Query totalScore(3, 6) (partial range)

  • l = bisect_left(times, 3) - 1 = 2 - 1 = 1 → exam at time 2 (index 1) is excluded.
  • r = bisect_left(times, 7) - 1 = 3 - 1 = 2 → exam at time 4 (index 2) is the last one included; time 7 is just past the range.
  • Answer: pre[2] - pre[1] = 150 - 70 = 80 ✓ (only the exam at time 4)

Step 5 — Query totalScore(5, 6) (empty range)

  • l = bisect_left(times, 5) - 1 = 3 - 1 = 2
  • r = bisect_left(times, 7) - 1 = 3 - 1 = 2
  • Answer: pre[2] - pre[2] = 0

Notice the elegance in the empty case: when no exam falls inside the interval, l and r land on the same index, and the prefix-sum subtraction naturally yields 0 — no special-case code required.

Each query cost only two binary searches over 4 entries (O(log n)) plus one subtraction, regardless of how many exams sit inside the range.

Solution Implementation

1from bisect import bisect_left
2
3
4class ExamTracker:
5
6    def __init__(self):
7        # Sentinel values simplify boundary handling:
8        # record_times[i] is the timestamp of the i-th record,
9        # prefix_scores[i] is the cumulative score of the first i records.
10        self.record_times = [0]
11        self.prefix_scores = [0]
12
13    def record(self, time: int, score: int) -> None:
14        # Timestamps arrive in increasing order, so simply append
15        # the new time and extend the running prefix sum.
16        self.record_times.append(time)
17        self.prefix_scores.append(self.prefix_scores[-1] + score)
18
19    def totalScore(self, startTime: int, endTime: int) -> int:
20        # Index of the last record strictly before startTime.
21        left = bisect_left(self.record_times, startTime) - 1
22        # Index of the last record with time <= endTime.
23        right = bisect_left(self.record_times, endTime + 1) - 1
24        # The difference of prefix sums yields the total score
25        # of all records whose time lies in [startTime, endTime].
26        return self.prefix_scores[right] - self.prefix_scores[left]
27
28
29# Your ExamTracker object will be instantiated and called as such:
30# obj = ExamTracker()
31# obj.record(time, score)
32# param_2 = obj.totalScore(startTime, endTime)
33
1import java.util.ArrayList;
2import java.util.List;
3
4class ExamTracker {
5    // Sorted list of recorded times; a sentinel value 0 is added at the front
6    // so that prefix sums and binary search indices align cleanly.
7    private final List<Integer> times = new ArrayList<>();
8
9    // Prefix sums of scores; prefixSums.get(i) is the total score of the
10    // first i recorded exams. A sentinel 0L keeps the math simple.
11    private final List<Long> prefixSums = new ArrayList<>();
12
13    public ExamTracker() {
14        // Initialize with sentinel values to avoid edge-case handling later.
15        times.add(0);
16        prefixSums.add(0L);
17    }
18
19    /**
20     * Records an exam taken at the given time with the given score.
21     * Times are assumed to be recorded in strictly increasing order.
22     *
23     * @param time  the time the exam was taken
24     * @param score the score obtained
25     */
26    public void record(int time, int score) {
27        times.add(time);
28        // Append the running total: previous prefix sum plus the new score.
29        prefixSums.add(prefixSums.get(prefixSums.size() - 1) + score);
30    }
31
32    /**
33     * Returns the total score of all exams taken within [startTime, endTime].
34     *
35     * @param startTime inclusive lower bound of the time range
36     * @param endTime   inclusive upper bound of the time range
37     * @return the sum of scores within the range
38     */
39    public long totalScore(int startTime, int endTime) {
40        // Index of the last exam strictly before startTime.
41        int left = binarySearch(startTime) - 1;
42        // Index of the last exam with time <= endTime.
43        int right = binarySearch(endTime + 1) - 1;
44        // Difference of prefix sums gives the total score in the range.
45        return prefixSums.get(right) - prefixSums.get(left);
46    }
47
48    /**
49     * Finds the smallest index whose recorded time is greater than or
50     * equal to the target value (lower bound binary search).
51     *
52     * @param target the value to search for
53     * @return the first index i such that times.get(i) >= target
54     */
55    private int binarySearch(int target) {
56        int low = 0;
57        int high = times.size();
58        while (low < high) {
59            int mid = (low + high) >> 1;
60            if (times.get(mid) >= target) {
61                // mid could be the answer; narrow the search to the left half.
62                high = mid;
63            } else {
64                // times.get(mid) is too small; search the right half.
65                low = mid + 1;
66            }
67        }
68        return low;
69    }
70}
71
72/**
73 * Your ExamTracker object will be instantiated and called as such:
74 * ExamTracker obj = new ExamTracker();
75 * obj.record(time,score);
76 * long param_2 = obj.totalScore(startTime,endTime);
77 */
78
1class ExamTracker {
2public:
3    ExamTracker() {
4        // Insert sentinel values so that binary search never
5        // falls off the left edge of the arrays.
6        recordTimes.push_back(0);
7        prefixScores.push_back(0LL);
8    }
9
10    void record(int time, int score) {
11        // Times are recorded in strictly increasing order,
12        // so we can simply append the new time and extend
13        // the running prefix sum of scores.
14        recordTimes.push_back(time);
15        prefixScores.push_back(prefixScores.back() + score);
16    }
17
18    long long totalScore(int startTime, int endTime) {
19        // leftIndex: index of the last record whose time is
20        // strictly less than startTime (records before the range).
21        int leftIndex = lower_bound(recordTimes.begin(), recordTimes.end(), startTime)
22                        - recordTimes.begin() - 1;
23
24        // rightIndex: index of the last record whose time is
25        // less than or equal to endTime (records inside the range).
26        int rightIndex = lower_bound(recordTimes.begin(), recordTimes.end(), endTime + 1)
27                         - recordTimes.begin() - 1;
28
29        // The answer is the difference of the two prefix sums,
30        // covering exactly the records with startTime <= time <= endTime.
31        return prefixScores[rightIndex] - prefixScores[leftIndex];
32    }
33
34private:
35    vector<int> recordTimes;          // Sorted list of recorded times (with sentinel 0)
36    vector<long long> prefixScores;   // prefixScores[i] = sum of the first i scores
37};
38
39/**
40 * Your ExamTracker object will be instantiated and called as such:
41 * ExamTracker* obj = new ExamTracker();
42 * obj->record(time,score);
43 * long long param_2 = obj->totalScore(startTime,endTime);
44 */
45
1// Global state: sentinel values at index 0 simplify prefix-sum boundary handling.
2
3// Sorted list of recorded timestamps (strictly increasing as records arrive).
4const recordedTimes: number[] = [0];
5
6// prefixScores[i] = sum of all scores recorded up to and including recordedTimes[i].
7const prefixScores: number[] = [0];
8
9/**
10 * Binary search (lower bound): returns the first index in `sortedArr`
11 * at which `target` could be inserted while keeping the array sorted.
12 * Equivalent to lodash's _.sortedIndex.
13 */
14function lowerBound(sortedArr: number[], target: number): number {
15    let left = 0;
16    let right = sortedArr.length;
17    while (left < right) {
18        const mid = (left + right) >> 1;
19        if (sortedArr[mid] < target) {
20            left = mid + 1;
21        } else {
22            right = mid;
23        }
24    }
25    return left;
26}
27
28/**
29 * Records a score at the given time.
30 * Times are assumed to arrive in strictly increasing order,
31 * so we can append directly and extend the prefix sum.
32 */
33function record(time: number, score: number): void {
34    recordedTimes.push(time);
35    prefixScores.push(prefixScores[prefixScores.length - 1] + score);
36}
37
38/**
39 * Returns the total score of all records whose time t satisfies
40 * startTime <= t <= endTime, computed via prefix-sum difference.
41 */
42function totalScore(startTime: number, endTime: number): number {
43    // Index of the last record with time < startTime.
44    const leftIndex = lowerBound(recordedTimes, startTime) - 1;
45
46    // Index of the last record with time <= endTime.
47    const rightIndex = lowerBound(recordedTimes, endTime + 1) - 1;
48
49    // Sum over the range (leftIndex, rightIndex].
50    return prefixScores[rightIndex] - prefixScores[leftIndex];
51}
52
53/**
54 * Usage:
55 * record(time, score)
56 * const result = totalScore(startTime, endTime)
57 */
58

Time and Space Complexity

  • Time Complexity:

    • record: O(1) (amortized) — appending time to self.times and the running prefix sum self.pre[-1] + score to self.pre are both amortized constant-time list operations.
    • totalScore: O(log n) — each call performs two binary searches (bisect_left) on the sorted self.times array, where n is the number of recorded exams. The subsequent prefix-sum subtraction self.pre[r] - self.pre[l] takes O(1).
    • Overall, the dominant cost per query is O(log n).
  • Space Complexity: O(n) — the two arrays self.times and self.pre each store one entry per recorded exam (plus one sentinel element 0), so the total storage grows linearly with the number of record calls n.

Pattern Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Off-by-one errors when choosing binary search boundaries

This is by far the most frequent mistake. The query interval [startTime, endTime] is inclusive on both ends, and mixing up bisect_left / bisect_right (or forgetting the + 1 / - 1 adjustments) silently drops or double-counts boundary exams.

Typical wrong variants:

# WRONG: searches for endTime instead of endTime + 1.
# An exam recorded exactly at endTime gets excluded.
right = bisect_left(self.record_times, endTime) - 1

# WRONG: uses bisect_right for the left boundary.
# An exam recorded exactly at startTime gets excluded.
left = bisect_right(self.record_times, startTime) - 1

Consider record(5, 99) followed by totalScore(5, 5). The correct answer is 99, but both variants above return 0 because the exam sitting exactly on the boundary is skipped.

Solution: pin down the semantics of each search precisely and pick the matching pair:

  • Left boundary: find the index of the last exam strictly before startTimebisect_left(times, startTime) - 1. Equivalently, bisect_right(times, startTime - 1) - 1 works for integer times.
  • Right boundary: find the index of the last exam with time <= endTimebisect_left(times, endTime + 1) - 1, which is the same as bisect_right(times, endTime) - 1.

A quick sanity check before submitting: test a query whose endpoints exactly match recorded times (e.g., totalScore(t, t) after record(t, s) should return s), and a query that falls entirely between two records (should return 0).

2. Omitting the sentinel and hitting an invalid index

If the arrays are initialized empty instead of with the (time=0, score=0) sentinel, then left can become -1. In Python, pre[-1] does not crash — it silently reads the last element, producing a wrong answer rather than an error, which makes the bug hard to spot. For instance, with records at times [1, 5] and prefix sums [98, 197], the query totalScore(1, 3) computes left = -1, and pre[right] - pre[-1] = 98 - 197 = -119.

Solution: keep the sentinel pair times = [0], pre = [0]. Since the problem guarantees 1 <= time, the sentinel never collides with a real record, and every index - 1 result remains a valid non-negative position. Alternatively, clamp negative indices explicitly, but the sentinel achieves the same effect with zero extra logic.

3. Re-scanning or re-sorting on every operation

Two performance traps appear in naive implementations:

# WRONG approach 1: linear scan per query — O(n) per totalScore call.
def totalScore(self, startTime, endTime):
    return sum(s for t, s in self.records if startTime <= t <= endTime)

# WRONG approach 2: sorting inside record — O(n log n) per insertion,
# even though the input is already sorted.
def record(self, time, score):
    self.records.append((time, score))
    self.records.sort()

With up to ~10⁵ mixed operations these degrade to quadratic (or worse) total work and time out.

Solution: exploit the guarantee that record calls arrive with strictly increasing times. Appending keeps the array sorted for free (O(1)), and the prefix sum reduces every range query to two O(log n) binary searches plus one subtraction. Building the prefix sum incrementally inside record — rather than recomputing it inside totalScore — is what makes the whole design fast.

Ready to land your dream job?

Unlock your dream job with a 5-minute quiz for a personalized study roadmap!

Get My Roadmap
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Get a Personalized Study Roadmap:

Which of the following array represent a max heap?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More