Data Structures & Algorithms (DSA) Module 9 – Trees and binary search trees
Ordered maps: floor, ceiling and rank queries
Floor, ceiling and range queries in O(log n) with TreeMap, std::map and sorted lists, and the subtree sizes that answer rank and k-th smallest.
What you will learn
- Use TreeMap, std::map and sorted lists for floor, ceiling and range queries
- Extend a binary search tree with subtree sizes to answer rank and k-th smallest
- Choose an ordered structure in languages that lack one, by size and update rate
Before you start
What you’ll learn · Before you start
What you will learn
- Use TreeMap, std::map and sorted lists for floor, ceiling and range queries
- Extend a binary search tree with subtree sizes to answer rank and k-th smallest
- Choose an ordered structure in languages that lack one, by size and update rate
Before you start
On this page
Ordered maps keep their keys sorted, so besides “is this key here?” they answer questions about neighbours: the
largest key at or below x, its floor; the smallest key at or above x, its ceiling; the next and previous
keys; and every key in a range. With one extra number in each node, the size of its subtree, they also answer
rank queries, how many keys are smaller than x, and find the k-th smallest key. A balanced search tree answers
each of these in O(log n), plus the size of the output for a range. Java has TreeMap and C++ has std::map;
Python, JavaScript and Go have no ordered map in their standard libraries, so programs there keep a sorted list and
binary search it, which is fast to query and slow to change.
What a hash map cannot answer
A hash map finds a key in O(1) on average, but it scatters keys on purpose, so it cannot tell which key comes next. Many everyday questions are about neighbours rather than exact keys: the free appointment closest to the one a patient asked for, the next train after a given time, the highest bid at or below a price, the address range that contains an address. Ordered maps answer all of them with two primitives, floor and ceiling.
Floor and ceiling in one walk down the tree
To find the floor of x, start at the root. A key at or below x is a candidate, and a better one can only lie to its right, so remember it and go right; a key above x cannot be the floor, so go left. When the walk falls off the tree, the last candidate is the floor. The ceiling is the mirror image. Both follow one path from the root, so they cost O(h): O(log n) in a balanced tree.
Floor and ceiling of 130 come from one walk down the tree
Text description of the diagram
The diagram shows the search tree of the lesson's run, with root 120, and the walk that finds the floor and the ceiling of 130.
- The root 120 is below 130, so it is the best floor so far, and anything closer must be to its right: the walk goes right.
- The next node, 180, is above 130, so it is the best ceiling so far, and anything closer must be to its left: the walk goes left.
- The node 135 is above 130 and closer than 180, so it becomes the ceiling. It has no left child, so the walk ends after three nodes.
- The answer is floor(130) = 120 and ceiling(130) = 135. The other nodes of the tree, 60, 30, 15, 75 and 240, are never visited.
"""Floor and ceiling in a binary search tree: one walk from the root, remembering the best candidate so far."""
class Node:
__slots__ = ("key", "left", "right")
def __init__(self, key):
self.key, self.left, self.right = key, None, None
def insert(root, key):
if root is None:
return Node(key)
if key < root.key:
root.left = insert(root.left, key)
elif key > root.key:
root.right = insert(root.right, key)
return root
def floor(root, x, path):
"""The largest key <= x: a key <= x is a candidate, and anything better can only be to its right."""
best, node = None, root
while node:
path.append(node.key)
if node.key == x:
return x
if node.key < x:
best, node = node.key, node.right
else:
node = node.left
return best
def ceiling(root, x, path):
"""The smallest key >= x: the mirror image."""
best, node = None, root
while node:
path.append(node.key)
if node.key == x:
return x
if node.key > x:
best, node = node.key, node.left
else:
node = node.right
return best
root = None
for key in (120, 60, 180, 30, 75, 135, 240, 15): # the free slots, inserted in a balanced order
root = insert(root, key)
for x in (40, 130, 250, 5):
fp, cp = [], []
f, c = floor(root, x, fp), ceiling(root, x, cp)
print(f"x = {x:3}: floor {f}, path {fp}; ceiling {c}, path {cp}") Output
x = 40: floor 30, path [120, 60, 30]; ceiling 60, path [120, 60, 30] x = 130: floor 120, path [120, 180, 135]; ceiling 135, path [120, 180, 135] x = 250: floor 240, path [120, 180, 240]; ceiling None, path [120, 180, 240] x = 5: floor None, path [120, 60, 30, 15]; ceiling 15, path [120, 60, 30, 15]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 floor_ceiling.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each query visited three or four of the eight nodes. Key 250 has no ceiling and 5 has no floor, so every API needs a way to say “none”.
The same queries in Java, C++ and Python
| Question | Java TreeMap |
C++ std::map |
Python, a sorted list a |
|---|---|---|---|
| ceiling, the smallest key at or above x | ceilingKey(x) |
lower_bound(x) |
a[bisect_left(a, x)] |
| floor, the largest key at or below x | floorKey(x) |
prev(upper_bound(x)) |
a[bisect_right(a, x) - 1] |
| keys from lo to hi, both included | subMap(lo, true, hi, true) |
lower_bound(lo) to upper_bound(hi) |
a[bisect_left(a, lo):bisect_right(a, hi)] |
| when there is no such key | null |
end(), or no key before begin() |
a position outside the list |
The run below keeps a clinic’s free appointment slots in a sorted Python list:
"""Free appointment slots in a sorted list: floor, ceiling and a range, with bisect."""
from bisect import bisect_left, bisect_right, insort
free = [15, 30, 60, 75, 120, 135, 180, 240] # minutes after 09:00 that are still free
def clock(minutes):
return f"{9 + minutes // 60:02}:{minutes % 60:02}"
def ceiling(a, x):
"""The smallest value >= x, or None."""
i = bisect_left(a, x) # the first position whose value is >= x
return a[i] if i < len(a) else None
def floor(a, x):
"""The largest value <= x, or None."""
i = bisect_right(a, x) # the first position whose value is > x
return a[i - 1] if i > 0 else None
def show(minutes):
return clock(minutes) if minutes is not None else "none"
for wanted in (40, 60, 250, 5):
print(f"wants {clock(wanted)}: at or after {show(ceiling(free, wanted))}, at or before {show(floor(free, wanted))}")
lo, hi = bisect_left(free, 60), bisect_right(free, 180) # every free slot from 10:00 to 12:00, both included
print("free from 10:00 to 12:00:", ", ".join(clock(m) for m in free[lo:hi]))
del free[bisect_left(free, 60)] # 10:00 is booked
insort(free, 45) # a cancellation frees 09:45
print("after booking 10:00 and freeing 09:45, a patient who wants 10:00 gets", show(ceiling(free, 60)),
"or", show(floor(free, 60))) Output
wants 09:40: at or after 10:00, at or before 09:30 wants 10:00: at or after 10:00, at or before 10:00 wants 13:10: at or after none, at or before 13:00 wants 09:05: at or after 09:15, at or before none free from 10:00 to 12:00: 10:00, 10:15, 11:00, 11:15, 12:00 after booking 10:00 and freeing 09:45, a patient who wants 10:00 gets 10:15 or 09:45
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 slots.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A patient who wants 09:40 is offered 10:00 or 09:30; 10:00 is its own floor and ceiling; nothing is free after 13:10 or before 09:05. Booking deletes from the list and a cancellation inserts into it, both O(n).
Rank queries and the k-th smallest, from subtree sizes
Store in every node the size of its subtree. For rank(x), walk down from the root: whenever the walk goes right past a node, that node and its whole left subtree are smaller than x, so add size(left) + 1. For select(k), the k-th smallest counted from 0, compare k with the size of the left subtree: smaller means go left, equal means this node, larger means subtract size(left) + 1 and go right. Both walk one path. An insertion adds 1 to the size of every node it passes, and a deletion subtracts 1.
"""Rank and k-th smallest from subtree sizes, checked against bisect on 100,000 shuffled scores."""
import random
from bisect import bisect_left
class Node:
__slots__ = ("key", "left", "right", "size")
def __init__(self, key):
self.key, self.left, self.right, self.size = key, None, None, 1
def size(node):
return node.size if node else 0
def insert(root, key):
"""Ordinary BST insertion that also adds 1 to the size of every node it passes (keys are distinct)."""
if root is None:
return Node(key)
node = root
while True:
node.size += 1
side = "left" if key < node.key else "right"
child = getattr(node, side)
if child is None:
setattr(node, side, Node(key))
return root
node = child
def rank(root, x):
"""How many keys are smaller than x: every time the walk goes right, the left subtree and the node count."""
smaller, node, visited = 0, root, 0
while node:
visited += 1
if x <= node.key:
node = node.left
else:
smaller += size(node.left) + 1
node = node.right
return smaller, visited
def select(root, k):
"""The k-th smallest key, counting from 0: compare k with the size of the left subtree at each node."""
node, visited = root, 0
while node:
visited += 1
left = size(node.left)
if k < left:
node = node.left
elif k == left:
return node.key, visited
else:
k -= left + 1
node = node.right
raise IndexError("k is out of range")
rng = random.Random(8)
scores = rng.sample(range(10_000_000), 100_000) # distinct scores, in random order
root = None
for s in scores:
root = insert(root, s)
ordered = sorted(scores)
print(f"rank of 5,000,000: {rank(root, 5_000_000)[0]:,} keys are smaller; bisect says {bisect_left(ordered, 5_000_000):,}")
print("select k = 0, 49,999 and 99,999:", ", ".join(f"{select(root, k)[0]:,}" for k in (0, 49_999, 99_999)))
print(" bisect says ", ", ".join(f"{ordered[k]:,}" for k in (0, 49_999, 99_999)))
worst = 0
for _ in range(10_000): # random checks against the sorted list
x = rng.randrange(10_000_000)
r, v1 = rank(root, x)
k = rng.randrange(100_000)
key, v2 = select(root, k)
assert r == bisect_left(ordered, x) and key == ordered[k]
worst = max(worst, v1, v2)
print(f"10,000 random rank and select queries agree with bisect; the longest walk visited {worst} nodes") Output
rank of 5,000,000: 49,973 keys are smaller; bisect says 49,973
select k = 0, 49,999 and 99,999: 98, 5,002,295, 9,999,793
bisect says 98, 5,002,295, 9,999,793
10,000 random rank and select queries agree with bisect; the longest walk visited 37 nodes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_stats.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
All 10,000 random rank queries and select queries agreed with a sorted list. This plain tree is as tall as its random insertion order made it, so its longest walk visited 37 nodes; a balanced tree keeps every walk near log₂ n, about 17 here.
Sorted lists, flat maps and languages without an ordered map
Python’s bisect finds a position in O(log n), but its documentation warns that insort is dominated by the O(n)
insertion step: every larger element shifts by one. C++23’s std::flat_map makes the same trade on purpose, with
two sorted arrays, logarithmic search and linear insertion, for small or read-mostly maps. Go and JavaScript offer
neither: Go’s map iteration order is not specified, and a JavaScript Map iterates in insertion order. For changing
data there, the usual choices are a sorted array with binary search, a Fenwick tree over keys known in advance, or a
randomised search tree such as the treap of the next lesson.
Exercise
Exercise · Medium · Python, JavaScript
A seat map that finds the nearest free seat
A hall has seats numbered 0 to n − 1, all free at the start. Write the class Seats(n) with three methods:
book(seat): takes the seat and returnsTrue, or returnsFalseif it was already taken (JavaScript:trueandfalse);release(seat): makes the seat free again; releasing a free seat changes nothing;nearest_free(x)(JavaScript:nearestFree(x)): the free seat closest to positionx, which may be any integer, even outside 0 to n − 1. On a tie, return the smaller seat number. If every seat is taken, returnNone(JavaScript:null).
Finding the nearest free seat may not scan the seats one by one: the tests ask 4,000 questions of a hall of 20,000 seats, and compare your class with a slow, obviously correct one on 20,000 random operations.
Python · Starter code · seats.py
class Seats:
"""Seats numbered 0 to n - 1, all free at the start."""
def __init__(self, n):
raise NotImplementedError("write Seats")
def book(self, seat):
"""Takes the seat; returns False if it was already taken."""
def release(self, seat):
"""Makes the seat free again (releasing a free seat changes nothing)."""
def nearest_free(self, x):
"""The free seat closest to x, the smaller one on a tie; None if every seat is taken.""" The sample tests · test_seats.py
import random
from seats import Seats
class SlowSeats:
"""Obviously correct and slow: a set of taken seats, and a scan for the nearest free one."""
def __init__(self, n):
self.n, self.taken = n, set()
def book(self, seat):
if seat in self.taken:
return False
self.taken.add(seat)
return True
def release(self, seat):
self.taken.discard(seat)
def nearest_free(self, x):
free = [s for s in range(self.n) if s not in self.taken]
return min(free, key=lambda s: (abs(s - x), s)) if free else None
def test_small_hall():
"""booking, releasing and the nearest free seat in a hall of 6 seats"""
s = Seats(6)
assert s.nearest_free(3) == 3
assert s.book(3) is True and s.book(3) is False
assert s.nearest_free(3) == 2 # 2 and 4 are both one seat away: the smaller wins
s.book(2)
assert s.nearest_free(3) == 4
s.release(3)
assert s.nearest_free(3) == 3
def test_outside_and_full():
"""positions outside the hall find the nearest end; a full hall answers None"""
s = Seats(3)
assert s.nearest_free(-7) == 0 and s.nearest_free(99) == 2
for seat in range(3):
s.book(seat)
assert s.nearest_free(1) is None
s.release(1)
s.release(1)
assert s.nearest_free(-7) == 1
def test_against_slow_version():
"""20,000 random operations in a hall of 300 seats give the same answers as the slow version"""
rng = random.Random(9)
fast, slow = Seats(300), SlowSeats(300)
for _ in range(20_000):
op, seat = rng.random(), rng.randrange(-20, 320)
if op < 0.45:
if 0 <= seat < 300:
assert fast.book(seat) == slow.book(seat)
elif op < 0.7:
if 0 <= seat < 300:
fast.release(seat)
slow.release(seat)
else:
assert fast.nearest_free(seat) == slow.nearest_free(seat), f"nearest_free({seat})"
def test_large_hall():
"""a hall of 20,000 seats with every even seat booked answers 4,000 queries without scanning"""
s = Seats(20_000)
for seat in range(0, 20_000, 2):
s.book(seat)
assert s.nearest_free(10_000) == 9_999 # 9,999 and 10,001 are both free and one away
assert all(s.nearest_free(x) % 2 == 1 for x in range(0, 20_000, 5)) JavaScript · Starter code · seats.mjs
/** Seats numbered 0 to n - 1, all free at the start. */
export class Seats {
constructor(n) {
throw new Error('write Seats');
}
/** Takes the seat; returns false if it was already taken. */
book(seat) {}
/** Makes the seat free again (releasing a free seat changes nothing). */
release(seat) {}
/** The free seat closest to x, the smaller one on a tie; null if every seat is taken. */
nearestFree(x) {}
} The sample tests · seats.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { Seats } from './seats.mjs';
class SlowSeats {
constructor(n) {
this.n = n;
this.taken = new Set();
}
book(seat) {
if (this.taken.has(seat)) return false;
this.taken.add(seat);
return true;
}
release(seat) {
this.taken.delete(seat);
}
nearestFree(x) {
let best = null;
for (let s = 0; s < this.n; s++) {
if (this.taken.has(s)) continue;
if (best === null || Math.abs(s - x) < Math.abs(best - x)) best = s;
}
return best;
}
}
test('booking, releasing and the nearest free seat in a hall of 6 seats', () => {
const s = new Seats(6);
assert.equal(s.nearestFree(3), 3);
assert.equal(s.book(3), true);
assert.equal(s.book(3), false);
assert.equal(s.nearestFree(3), 2);
s.book(2);
assert.equal(s.nearestFree(3), 4);
s.release(3);
assert.equal(s.nearestFree(3), 3);
});
test('positions outside the hall find the nearest end; a full hall answers null', () => {
const s = new Seats(3);
assert.equal(s.nearestFree(-7), 0);
assert.equal(s.nearestFree(99), 2);
for (let seat = 0; seat < 3; seat++) s.book(seat);
assert.equal(s.nearestFree(1), null);
s.release(1);
s.release(1);
assert.equal(s.nearestFree(-7), 1);
});
test('20,000 random operations in a hall of 300 seats give the same answers as the slow version', () => {
let seed = 9;
const next = (n) => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed % n;
};
const fast = new Seats(300);
const slow = new SlowSeats(300);
for (let i = 0; i < 20000; i++) {
const op = next(100);
const seat = next(340) - 20;
if (op < 45) {
if (seat >= 0 && seat < 300) assert.equal(fast.book(seat), slow.book(seat));
} else if (op < 70) {
if (seat >= 0 && seat < 300) {
fast.release(seat);
slow.release(seat);
}
} else assert.equal(fast.nearestFree(seat), slow.nearestFree(seat), `nearestFree(${seat})`);
}
});
test('a hall of 20,000 seats with every even seat booked answers 4,000 queries without scanning', () => {
const s = new Seats(20000);
for (let seat = 0; seat < 20000; seat += 2) s.book(seat);
assert.equal(s.nearestFree(10000), 9999);
for (let x = 0; x < 20000; x += 5) assert.equal(s.nearestFree(x) % 2, 1);
}); A hint
Keep the free seats in a sorted list. The nearest free seat to x is either the smallest free seat at or above x (a ceiling, from bisect_left) or the largest free seat below it (the one just before that position): compare the two distances, and prefer the smaller seat when they are equal.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Floor, ceiling and ranges in five languages, and the off-by-one traps Pro: opens with a Pro, Premium or Ultimate pass
Every library names floor and ceiling differently, and most bugs in ordered queries are one position off. This part writes the clinic in JavaScript, Java, C++ and Go, then reproduces four traps with their fixes.
In this part:
- The clinic in JavaScript, Java, C++ and Go
- Four off-by-one traps, reproduced
- What a range query costs
- Edge cases worth a test each
5 runnable examples (C++, Go, Java, JavaScript, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Rank and select kept exact through rotations, proved and measured Pro: opens with a Pro, Premium or Ultimate pass
Subtree sizes are useful only if every update keeps them exact, including the rotations of a balanced tree. This part proves rank and select correct, adds sizes to the AVL tree, and measures where a sorted list stops being enough.
In this part:
- Why rank and select return the right answer: the invariant
- Sizes through rotations: recompute two nodes, the lower one first
- Measured: when a sorted list stops being enough
2 runnable examples (Python) · about 2 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Leaderboards, bookings and choosing a structure, with numbers Pro: opens with a Pro, Premium or Ultimate pass
Which ordered structure is right depends on whether every key is known in advance, how often keys change, and which questions are asked. This part builds a leaderboard on a Fenwick tree, checks bookings for overlap with two lookups, and ends with a decision table.
In this part:
- A leaderboard with rank and k-th from a Fenwick tree
- Bookings that must not overlap: two neighbours are enough, proved
- Choosing an ordered structure: a trade-off table
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher · Warm-up
What do floor and ceiling return in an ordered map, and what happens when no such key exists?
The floor of x is the largest key at or below x, and the ceiling is the smallest key at or above x; a key equal to x is both. In a balanced search tree each takes one walk from the root, O(log n). When there is no such key, for a floor of a value below every key or a ceiling above every key, each library says so in its own way: Java's TreeMap.floorKey and ceilingKey return null, C++'s lower_bound returns end() (and a floor needs a check against begin() before stepping back), and with Python's bisect on a sorted list the computed position falls outside the list, which the code must test before indexing.
-
Mid to senior
A game leaderboard holds 2 million scores that change 5,000 times a second, and any player can ask for their rank. Which structure would you use, what does each operation cost, and what changes when several players share a score?
-
Fresher to mid
Meeting-room bookings are half-open intervals [start, end) that never overlap. Using an ordered map keyed by start, check a new booking for overlaps in O(log n). Which stored bookings must you look at, and why are they enough?
-
Mid to senior
A Python service keeps 5 million order prices in a list sorted with bisect.insort and receives 2,000 new prices a second, plus cancellations. Lookups are fast, but the service falls behind. Estimate why, and what would you replace the list with?
Key takeaways
- Floor and ceiling find the neighbours of any value with one walk down a search tree, in O(log n) when it is balanced.
TreeMapandstd::mapanswer them directly; in Python,bisecton a sorted list does, with O(n) updates.- Storing subtree sizes turns the same walk into rank queries and k-th smallest lookups.
References
- Class TreeMap (Java SE 25 API) (Oracle)
- std::map (cppreference.com)
- std::map::lower_bound (cppreference.com)
- std::flat_map (cppreference.com)
- bisect: array bisection algorithm (Python Software Foundation)
- The Go Programming Language Specification: For statements with range clause (The Go Authors)
- Package slices (The Go Authors)
- Map (JavaScript reference) (MDN Web Docs (Mozilla))
- Array.prototype.sort() (JavaScript reference) (MDN Web Docs (Mozilla))
- JEP 358: Helpful NullPointerExceptions (OpenJDK)
- A New Data Structure for Cumulative Frequency Tables (P. M. Fenwick, Software: Practice and Experience 24(3)) (John Wiley & Sons)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress