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.

LeetCode Problem 2102

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:

  1. A higher score means a better location.
  2. If two locations have the same score, the lexicographically smaller name is considered better.

The system supports two operations:

  • add(name, score) inserts a new location.
  • get() returns the ith best location, where i is the number of times get() 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^4 operations total.
  • Every name is unique.
  • The number of get() calls never exceeds the number of add() 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:

  1. Sort the entire list according to the ranking rules.
  2. 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 k locations.
  • 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 k locations.
  • The root of the left heap is the worst among those top k locations.
  • 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 k locations.

  • 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:

  1. A.score > B.score
  2. Or scores are equal and A.name < B.name

Step-by-Step Algorithm

  1. 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:

  • leftHeap always contains exactly k best elements, where k equals the number of completed get() calls.
  • The root of leftHeap is the worst among those top k elements.
  1. 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 rightHeap should now enter the top-ranked set.
  • Remove the best element from rightHeap.
  • Push it into leftHeap.
  • The root of leftHeap becomes the current answer.
  1. Return the root name from leftHeap.

Why it Works

The invariant is:

  • leftHeap always stores exactly the top k ranked locations after k calls to get().
  • The root of leftHeap is the worst among those top k.

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():

  1. We first insert into left_heap.
  2. That may violate the size invariant.
  3. We remove the worst element from left_heap.
  4. We move it into right_heap.

During get():

  1. We move the best remaining location from right_heap into left_heap.
  2. The root of left_heap then 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:

  • LeftHeap behaves like a min-heap over ranking order.
  • RightHeap behaves 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:

  1. branford
  2. alps
  3. bradford
  4. 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_heap always contains exactly as many elements as completed get() 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.