LeetCode 2102 - Sequentially Ordinal Rank Tracker
The problem asks us to design a data structure that continuously tracks scenic locations ranked by two rules: 1. A higher score means a better location. 2. If two locations have the same score, the lexicographically smaller name is considered better.
Difficulty: 🔴 Hard
Topics: Design, Heap (Priority Queue), Data Stream, Ordered Set
Solution
Problem Understanding
The problem asks us to design a data structure that continuously tracks scenic locations ranked by two rules:
- A higher
scoremeans a better location. - If two locations have the same score, the lexicographically smaller
nameis considered better.
The system supports two operations:
add(name, score)inserts a new location.get()returns theithbest location, whereiis the number of timesget()has been called so far.
This second requirement is the key difficulty of the problem. The return value of get() changes over time because each call advances the requested rank.
For example:
- The first
get()returns the best location. - The second
get()returns the second best location. - The third
get()returns the third best location.
Importantly, locations are never removed. The dataset only grows.
The constraints are also important:
- At most
4 * 10^4operations total. - Every
nameis unique. - The number of
get()calls never exceeds the number ofadd()calls.
A naive implementation that re-sorts all locations on every operation would become too slow. We therefore need a data structure that can dynamically maintain ranking information efficiently.
A particularly tricky part is the ordering rule:
- Larger score is better.
- Smaller name is better when scores tie.
This means the comparison is not a simple numeric comparison.
Another subtle edge case is that a newly added location may belong anywhere in the ranking. It could become the new best location, or it could fall somewhere in the middle. The structure must efficiently adapt to these insertions while still allowing repeated sequential rank queries.
Approaches
Brute Force Approach
The most straightforward solution is to store every location in a list.
Whenever get() is called:
- Sort the entire list according to the ranking rules.
- Return the element at index
getCount - 1.
The sorting rule would be:
- Higher score first.
- Lexicographically smaller name first.
This approach is correct because sorting explicitly constructs the exact ranking order required by the problem.
However, it is inefficient. If there are n locations, each get() operation costs O(n log n) because we must sort the full collection repeatedly. With up to 4 * 10^4 operations, this becomes too slow.
Key Insight for the Optimal Solution
The crucial observation is that get() queries happen sequentially.
We do not need arbitrary rank queries like:
- "Give me the 17th best location"
- "Give me the 5th best location again"
Instead:
- The first query asks for rank 1.
- The second query asks for rank 2.
- The third query asks for rank 3.
This monotonic behavior allows us to maintain two heaps:
- One heap stores the current top
klocations. - The other heap stores the remaining locations.
Where k equals the number of get() calls already made.
More specifically:
- The left heap contains exactly the best
klocations. - The root of the left heap is the worst among those top
klocations. - That root is exactly the answer to the next
get().
When a new location is added, we rebalance the heaps so this invariant remains true.
This reduces every operation to logarithmic time.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n log n) per get() |
O(n) |
Re-sorts all locations repeatedly |
| Optimal | O(log n) per operation |
O(n) |
Uses two heaps to maintain rank partitions |
Algorithm Walkthrough
Data Structure Design
We maintain two heaps:
-
leftHeap -
Contains the current top
klocations. -
The root is the worst among those top
k. -
This root is the answer to the next
get()call. -
rightHeap -
Contains all remaining locations.
The difficult part is choosing heap orderings carefully.
Ranking Definition
A location A is better than location B if:
A.score > B.score- Or scores are equal and
A.name < B.name
Step-by-Step Algorithm
- Define a comparison ordering for locations.
We represent each location as (score, name).
Since Python heaps are min-heaps, we invert values strategically to simulate the needed ordering. 2. Maintain the invariant:
leftHeapalways contains exactlykbest elements, wherekequals the number of completedget()calls.- The root of
leftHeapis the worst among those topkelements.
- During
add(name, score):
- Insert the new location into
leftHeap. - The heap may now contain one extra element.
- Remove the worst element from
leftHeap. - Push that removed element into
rightHeap.
This preserves the invariant size relationship.
4. During get():
- The best candidate from
rightHeapshould now enter the top-ranked set. - Remove the best element from
rightHeap. - Push it into
leftHeap. - The root of
leftHeapbecomes the current answer.
- Return the root name from
leftHeap.
Why it Works
The invariant is:
leftHeapalways stores exactly the topkranked locations afterkcalls toget().- The root of
leftHeapis the worst among those topk.
When the next get() occurs, we move the best remaining location from rightHeap into leftHeap. That increases the tracked prefix from top k to top k + 1.
Because the heaps are always partitioned correctly, the returned value is always the exact ith best location required by the problem.
Python Solution
import heapq
from typing import List, Tuple
class SORTracker:
def __init__(self):
self.left_heap: List[Tuple[int, str]] = []
self.right_heap: List[Tuple[int, str]] = []
def add(self, name: str, score: int) -> None:
heapq.heappush(self.left_heap, (score, name))
score, name = heapq.heappop(self.left_heap)
heapq.heappush(self.right_heap, (-score, name))
def get(self) -> str:
score, name = heapq.heappop(self.right_heap)
heapq.heappush(self.left_heap, (-score, name))
return self.left_heap[0][1]
Implementation Explanation
The implementation relies on carefully chosen heap orderings.
The left_heap stores the current top-ranked locations. Since Python uses a min-heap, the smallest element appears at the root. We intentionally store entries as (score, name) so the root becomes:
- Lowest score first
- Lexicographically smallest name first for ties
This effectively makes the root the worst among the tracked top locations.
The right_heap stores remaining candidates. We use negative scores to simulate max-heap behavior. Its root therefore becomes the best remaining location.
During add():
- We first insert into
left_heap. - That may violate the size invariant.
- We remove the worst element from
left_heap. - We move it into
right_heap.
During get():
- We move the best remaining location from
right_heapintoleft_heap. - The root of
left_heapthen becomes the correct answer for the current query rank.
This structure guarantees logarithmic insertion and retrieval.
Go Solution
package main
import (
"container/heap"
)
type Location struct {
score int
name string
}
type LeftHeap []Location
func (h LeftHeap) Len() int {
return len(h)
}
func (h LeftHeap) Less(i, j int) bool {
if h[i].score == h[j].score {
return h[i].name < h[j].name
}
return h[i].score < h[j].score
}
func (h LeftHeap) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *LeftHeap) Push(x interface{}) {
*h = append(*h, x.(Location))
}
func (h *LeftHeap) Pop() interface{} {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
type RightHeap []Location
func (h RightHeap) Len() int {
return len(h)
}
func (h RightHeap) Less(i, j int) bool {
if h[i].score == h[j].score {
return h[i].name > h[j].name
}
return h[i].score > h[j].score
}
func (h RightHeap) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *RightHeap) Push(x interface{}) {
*h = append(*h, x.(Location))
}
func (h *RightHeap) Pop() interface{} {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
type SORTracker struct {
left LeftHeap
right RightHeap
}
func Constructor() SORTracker {
left := LeftHeap{}
right := RightHeap{}
heap.Init(&left)
heap.Init(&right)
return SORTracker{
left: left,
right: right,
}
}
func (this *SORTracker) Add(name string, score int) {
heap.Push(&this.left, Location{
score: score,
name: name,
})
item := heap.Pop(&this.left).(Location)
heap.Push(&this.right, item)
}
func (this *SORTracker) Get() string {
item := heap.Pop(&this.right).(Location)
heap.Push(&this.left, item)
return this.left[0].name
}
/**
* Your SORTracker object will be instantiated and called as such:
* obj := Constructor();
* obj.Add(name,score);
* param_2 := obj.Get();
*/
Go-Specific Notes
Go's container/heap package requires implementing the heap interface manually.
Unlike Python, Go does not provide a built-in max-heap. We therefore define two separate heap types with different Less() methods:
LeftHeapbehaves like a min-heap over ranking order.RightHeapbehaves like a max-heap over ranking order.
The logic otherwise matches the Python implementation exactly.
No integer overflow concerns exist here because scores are at most 10^5.
Worked Examples
Example 1
Operations:
add("bradford", 2)
add("branford", 3)
get()
add("alps", 2)
get()
add("orland", 2)
get()
State Trace
| Operation | left_heap | right_heap | Returned |
|---|---|---|---|
| add(bradford,2) | [] | [(2, bradford)] | |
| add(branford,3) | [] | [(3, branford), (2, bradford)] | |
| get() | [(3, branford)] | [(2, bradford)] | branford |
| add(alps,2) | [(3, branford)] | [(2, alps), (2, bradford)] | |
| get() | [(2, alps), (3, branford)] | [(2, bradford)] | alps |
| add(orland,2) | [(2, alps), (3, branford)] | [(2, bradford), (2, orland)] | |
| get() | [(2, bradford), (3, branford), (2, alps)] | [(2, orland)] | bradford |
Ranking Interpretation
After the third get() call, the ranking is:
- branford
- alps
- bradford
- orland
Therefore the returned value is correctly "bradford".
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(log n) |
Each heap insertion or removal costs logarithmic time |
| Space | O(n) |
All locations are stored across the two heaps |
Each operation performs only a constant number of heap pushes and pops. Since heap operations are O(log n), both add() and get() run efficiently even for the maximum constraint size.
The space complexity is linear because every inserted location remains stored permanently.
Test Cases
# Provided example
tracker = SORTracker()
tracker.add("bradford", 2)
tracker.add("branford", 3)
assert tracker.get() == "branford" # first best
tracker.add("alps", 2)
assert tracker.get() == "alps" # second best
tracker.add("orland", 2)
assert tracker.get() == "bradford" # third best
tracker.add("orlando", 3)
assert tracker.get() == "bradford" # fourth best
tracker.add("alpine", 2)
assert tracker.get() == "bradford" # fifth best
assert tracker.get() == "orland" # sixth best
# Single element
tracker = SORTracker()
tracker.add("a", 1)
assert tracker.get() == "a" # only location
# Same scores, lexicographical ordering
tracker = SORTracker()
tracker.add("delta", 5)
tracker.add("alpha", 5)
tracker.add("charlie", 5)
assert tracker.get() == "alpha" # lexicographically smallest
assert tracker.get() == "charlie" # next lexicographical
assert tracker.get() == "delta"
# Strictly descending scores
tracker = SORTracker()
tracker.add("a", 10)
tracker.add("b", 9)
tracker.add("c", 8)
assert tracker.get() == "a"
assert tracker.get() == "b"
assert tracker.get() == "c"
# Interleaved add and get
tracker = SORTracker()
tracker.add("x", 1)
assert tracker.get() == "x"
tracker.add("y", 10)
tracker.add("z", 5)
assert tracker.get() == "z"
assert tracker.get() == "x"
# Large score differences
tracker = SORTracker()
tracker.add("low", 1)
tracker.add("mid", 50000)
tracker.add("high", 100000)
assert tracker.get() == "high"
assert tracker.get() == "mid"
assert tracker.get() == "low"
Test Summary
| Test | Why |
|---|---|
| Provided example | Validates official behavior |
| Single element | Smallest valid dataset |
| Same scores | Ensures lexicographical tie-breaking |
| Descending scores | Verifies score priority |
| Interleaved operations | Tests dynamic heap balancing |
| Large score differences | Confirms numeric ordering correctness |
Edge Cases
Equal Scores With Different Names
One major source of bugs is handling ties incorrectly.
If two locations have identical scores, the lexicographically smaller name must rank higher. Many incorrect implementations accidentally reverse this ordering when using heaps.
For example:
("alpha", 5)
("beta", 5)
The correct order is:
alpha before beta
The implementation explicitly encodes this comparison rule into the heap ordering, ensuring ties are handled correctly.
Newly Added Best Location
A newly inserted location may immediately become the best overall location.
For example:
add("a", 1)
get() -> "a"
add("b", 100)
Even though "a" was previously returned, future rankings must adjust correctly.
The heap rebalancing guarantees that newly added locations are inserted into the correct partition immediately.
Frequent Alternation Between add() and get()
Another subtle case occurs when operations alternate heavily:
add
get
add
get
add
get
An incorrect implementation may lose track of how many top elements should remain in the tracked partition.
This solution avoids that issue because:
left_heapalways contains exactly as many elements as completedget()calls.- Every
get()increases that size by exactly one. - Every
add()preserves the invariant through rebalancing.
As a result, sequential rank tracking always remains correct.