3709. Design Exam Scores Tracker
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 timetimeand scoredscorepoints.totalScore(startTime, endTime): Returns the sum of scores of all exams Alice took betweenstartTimeandendTime, inclusive of both endpoints. If no exams fall within this interval, the answer is0.
There are two important guarantees that make the problem easier:
- Records arrive in order: Every call to
record()uses atimevalue that is strictly larger than all previous ones. This means the recorded times naturally form a sorted (increasing) sequence — no sorting is ever needed. - No queries about the future: Whenever
totalScore(startTime, endTime)is called, the conditionstartTime <= endTime <= tholds, wheretis 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 1record(5, 99)— score 99 at time 5
Then:
totalScore(1, 3)returns98, because only the exam at time 1 falls in the range[1, 3].totalScore(1, 5)returns197, because both exams fall in the range[1, 5].totalScore(2, 4)returns0, 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.
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.
Designing an exam tracker with range-sum queries uses prefix sums and binary search on chronologically sorted records.
Open in FlowchartIntuition
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:
- The times are already sorted. Since
record()is always called with strictly increasingtime, the list of recorded times is naturally in ascending order without any extra work. - 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). Ifpre[i]is the total score of the firstiexams, then the sum of exams from indexl+1toris simplypre[r] - pre[l]. - Binary search finds the boundaries of that segment in
O(log n). On the sortedtimesarray, we can binary search for the first exam with time>= startTimeand 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, wherepre[i]is the total score of the firstiexams, 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:
- Append
timetotimes. Because calls arrive with strictly increasingtime, the array stays sorted automatically. - Append
pre[-1] + scoretopre, extending the running total so thatpre[i] - pre[j]always equals the sum of scores of examsj+1throughi.
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:
- Left boundary:
bisect_left(times, startTime)returns the index of the first exam with time>= startTime. Subtracting1givesl, the index of the last exam strictly beforestartTime. Everything up tolmust be excluded. - Right boundary:
bisect_left(times, endTime + 1)returns the index of the first exam with time> endTime. Subtracting1givesr, the index of the last exam with time<= endTime. Everything up tormust be included. - Answer: the exams in the range are exactly those at indices
l+1throughr, so the result ispre[r] - pre[l].
Example Walkthrough
Suppose Alice calls:
| Operation | times | pre |
|---|---|---|
| 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 = 0r = 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 = 1r = bisect_left(times, 5) - 1 = 2 - 1 = 1- Answer:
pre[1] - pre[1] = 0✓ — when no exam falls in the interval,landrcoincide and the subtraction naturally yields0.
Complexity Analysis
- Time:
recordisO(1)amortized (a single append to each array);totalScoreisO(log n)due to the two binary searches, wherenis 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:
times | pre | |
|---|---|---|
| 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))
| Operation | times | pre | Note |
|---|---|---|---|
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 time2is the sentinel at index0. - Right boundary:
r = bisect_left(times, 8) - 1 = 4 - 1 = 3→ the last exam with time≤ 7is at index3. - 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 time2(index 1) is excluded.r = bisect_left(times, 7) - 1 = 3 - 1 = 2→ exam at time4(index 2) is the last one included; time7is just past the range.- Answer:
pre[2] - pre[1] = 150 - 70 = 80✓ (only the exam at time4)
Step 5 — Query totalScore(5, 6) (empty range)
l = bisect_left(times, 5) - 1 = 3 - 1 = 2r = 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)
331import 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 */
781class 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 */
451// 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 */
58Time and Space Complexity
-
Time Complexity:
record:O(1)(amortized) — appendingtimetoself.timesand the running prefix sumself.pre[-1] + scoretoself.preare both amortized constant-time list operations.totalScore:O(log n)— each call performs two binary searches (bisect_left) on the sortedself.timesarray, wherenis the number of recorded exams. The subsequent prefix-sum subtractionself.pre[r] - self.pre[l]takesO(1).- Overall, the dominant cost per query is
O(log n).
-
Space Complexity:
O(n)— the two arraysself.timesandself.preeach store one entry per recorded exam (plus one sentinel element0), so the total storage grows linearly with the number ofrecordcallsn.
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
startTime→bisect_left(times, startTime) - 1. Equivalently,bisect_right(times, startTime - 1) - 1works for integer times. - Right boundary: find the index of the last exam with time
<= endTime→bisect_left(times, endTime + 1) - 1, which is the same asbisect_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 RoadmapWhich of the following array represent a max heap?
Recommended Readings
https assets algo monster cover_photos Binary_Search svg Binary Search Intuition Binary search is an efficient array search algorithm It works by narrowing down the search range by half each time If you have looked up a word in a physical dictionary you've already used binary search in real life Let's
Prefix Sum Technique Explained Prefix Sum A prefix sum transforms an array into a new array of running totals For an input array arr define prefix k as the sum of all elements before index k prefix 0 0 prefix 1 arr 0 prefix 2 arr 0 arr 1 and so on The power
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Want a Structured Path to Master System Design Too? Don’t Miss This!