~/data-structures/sorted-containers
Sorted containers
Keep keys in order while they change, and find the nearest key at or below any value in O(log n).
A set or map that stays sorted as you add and remove keys. A binary search finds any value's neighbours: the floor below it and the ceiling above it.
The data changes over time, and each query asks for the nearest key at or below (or above) some value.
O(log n) per operation in a tree; O(n) insert in a Python list
O(n)
You’ll recognise it when
- Items arrive one at a time, and after each one you need them in order, not just at the end.
- A query asks for the nearest key at or below a value, or at or above it: the latest price at or before time
t, the closest free spot, the first server clockwise from a point. - A new interval must not overlap the ones you already have: bookings, memory blocks, ranges that merge as numbers arrive.
- You need a player’s rank or the top k while scores keep changing.
A heap is the usual mix-up. It also stays ordered as items arrive, but it only shows you the smallest. If you need the neighbours of any value, or to delete any item, you need a sorted container.
The idea
Think of a paper diary. To check whether 2 pm to 3 pm is free, you don’t read the whole week. You find where 2 pm falls, then look at the appointment just before it and the one just after. If the one before ends by 2 and the one after starts at 3 or later, the slot is free. Nothing further away can get in the way.
A sorted container keeps keys in order while you add and remove them, so every “nearest” question becomes a binary search. The answer always sits right next to where the search lands:
- the floor of
xis the largest key<= x; - the ceiling of
xis the smallest key>= x.
The strict versions, largest < x and smallest > x, are often called lower and higher.
The three languages differ a lot here. C++ and Java have balanced trees built in: std::set and std::map, TreeSet and TreeMap. Every search, insert and delete is O(log n). Python has no balanced tree in its standard library. The usual substitute is a plain list kept sorted with the bisect module: searching is O(log n), but inserting is O(n) because later items shift along. The third-party sortedcontainers.SortedList makes inserts fast too, but it’s often not available in interviews, and this site’s judge doesn’t have it either.
How it works
The example is a calendar that accepts a booking [start, end) only if it overlaps nothing already booked. Bookings are half-open, so one ending at 10 and one starting at 10 don’t clash.
Scroll through the steps and the graphic follows along. The top rows are the sorted container; the bottom is the same bookings on a timeline. Press edit to try your own bookings.
- Keep the accepted bookings sorted by start:
startsin order, andends[j]for the booking that starts atstarts[j]. In C++ and Java, one map from start to end does both. - For a new booking, binary search for its start:
i = bisect_right(starts, start). Every booking beforeistarts at or beforestart; every booking fromion starts later. - The booking at
i - 1is the floor: the last one starting at or beforestart. It clashes if it’s still running atstart, that is ifends[floor] > start. Ending exactly atstartis fine. - The booking at
i, calledafter, is the first one that starts later. It clashes if it starts before the new one ends:starts[after] < end. - If neither clashes, insert the new booking at index
i. The lists stay sorted without any re-sorting. - A clash on the left:
[12, 15)starts while[10, 14)is still running, so it’s rejected and nothing changes. - A clash on the right:
[14, 17)only touches[10, 14), which is fine, but it runs into[16, 20).
Why two neighbours are enough: accepted bookings never overlap, so every booking before the floor has ended by the time the floor starts, which is at or before start. Every booking after after starts even later than after does, so if after doesn’t reach into the new booking, nothing beyond it can.
import bisect
class Bookings:
"""Non-overlapping bookings [start, end)."""
def __init__(self):
self.starts = [] # sorted start times
self.ends = [] # ends[j] goes with starts[j]
def book(self, start, end):
# starts[:i] <= start < starts[i:]
i = bisect.bisect_right(self.starts, start)
floor, after = i - 1, i
# the last booking starting at or before `start`
if floor >= 0 and self.ends[floor] > start:
return False
# the first booking starting after `start`
n = len(self.starts)
if after < n and self.starts[after] < end:
return False
self.starts.insert(i, start)
self.ends.insert(i, end)
return True#include <map>
using namespace std;
// Non-overlapping bookings [start, end).
class Bookings {
map<int, int> cal; // start -> end, sorted by start
public:
bool book(int start, int end) {
// the first booking starting after `start`
auto after = cal.upper_bound(start);
// the one before it is the floor
if (after != cal.begin()) {
auto floor = prev(after);
if (floor->second > start) {
return false;
}
}
if (after != cal.end() && after->first < end) {
return false;
}
cal.emplace_hint(after, start, end);
return true;
}
};import java.util.TreeMap;
// Non-overlapping bookings [start, end).
class Bookings {
// start -> end, sorted by start
TreeMap<Integer, Integer> cal = new TreeMap<>();
boolean book(int start, int end) {
// the last booking starting at or before `start`
Integer floor = cal.floorKey(start);
if (floor != null && cal.get(floor) > start) {
return false;
}
// the first booking starting after `start`
Integer after = cal.higherKey(start);
if (after != null && after < end) {
return false;
}
cal.put(start, end);
return true;
}
}In C++ and Java the tree does the searching. upper_bound(start) and higherKey(start) find after directly, and the floor is prev(after) or floorKey(start). Here are the same lookups side by side:
| You want | C++ set |
Java TreeSet |
Python list a |
|---|---|---|---|
ceiling, >= x |
lower_bound(x) |
ceiling(x) |
a[bisect_left(a, x)] |
higher, > x |
upper_bound(x) |
higher(x) |
a[bisect_right(a, x)] |
floor, <= x |
prev(upper_bound(x)) |
floor(x) |
a[bisect_right(a, x) - 1] |
lower, < x |
prev(lower_bound(x)) |
lower(x) |
a[bisect_left(a, x) - 1] |
| none found | end(), or begin() before prev |
null |
index len(a) or -1 |
| pop smallest | erase(begin()) |
pollFirst() |
a.pop(0), O(n) |
keys < x |
begin() to lower_bound(x) |
headSet(x) |
a[:bisect_left(a, x)] |
std::map has the same lower_bound and upper_bound. TreeMap names them floorKey, ceilingKey and so on (or floorEntry to get the value too), with headMap and pollFirstEntry for the last two rows. Java’s headMap and tailMap are live views, not copies: removing a key through the view removes it from the map. To add in Python, bisect.insort(a, x) does the search and the insert in one call.
Why it’s O(log n)
std::map and TreeMap are balanced binary search trees (red-black trees in practice). They rebalance after every change, so their height stays at most about 2 log₂ n. A search, insert or delete walks one path from the root down, so each is O(log n).
A Python list is different. bisect is a binary search, so finding the spot is O(log n). But insert and pop(i) shift every later item one place, which is O(n). The shift is one fast block copy, so it’s fine in practice up to about 10⁵ inserts: that many inserts at random places take under a second, while 10⁶ take over a minute.
| Operation | std::set, TreeSet |
Python list + bisect |
|---|---|---|
| floor, ceiling, contains | O(log n) | O(log n) |
| insert, delete | O(log n) | O(n) |
| smallest, largest | O(log n) or better | O(1) |
| k-th smallest | O(n) | O(1): a[k] |
rank of x |
O(n) | O(log n): bisect_left(a, x) |
The last two rows are where the list wins. A tree has no indexes, so finding the k-th key means walking k steps. Space is O(n) either way.
Common mistakes
bisect_left for the floor
bisect_left stops before any copies of x, so the item just left of it is strictly smaller than x. If x itself is in the list, you miss it.
a[bisect_left(a, x) - 1] # ✗ largest key < x
a[bisect_right(a, x) - 1] # ✓ largest key <= x
Not checking that a neighbour exists
In Python, index -1 silently reads the largest item. In C++, prev(s.begin()) is undefined behaviour. In Java, the lookup returns null, and storing it in an int throws a NullPointerException.
i = bisect_right(a, x)
return a[i - 1] # ✗ i == 0 reads a[-1]
return a[i - 1] if i > 0 else None # ✓
Checking only one neighbour
The floor catches a booking that is still running when the new one starts. after catches one that starts inside the new one. Each misses what the other catches, so interval problems need both.
Changing a key in place
If a score changes and you overwrite its entry where it sits, the list is no longer sorted, and every later search can be wrong. Remove the old entry, then insert the new one. A C++ set won’t let you edit a key at all, for the same reason.
board[j] = (-new, name) # ✗ the list may be out of order now
board.pop(bisect_left(board, (-old, name)))
insort(board, (-new, name)) # ✓ remove, then insert
Variations
- Latest value at or before time
t. Keep(time, value)pairs sorted by time and take the floor oft. If times only ever increase, appending keeps the list sorted, so only the lookup needsbisect. - Leaderboards. Keep
(-score, name)pairs in a sorted list. The top k are the first k items, and a player’s rank isbisect_left(board, (-score, name)) + 1. Negating the score puts the highest first, and equal scores fall back to name order. - Merging ranges on insert. Keep disjoint ranges keyed by start. A new value or range can only touch its floor and its ceiling, so merge with those, and keep merging while the new range swallows the next one. A memory allocator does the same when a freed block joins its free neighbours.
- Wrap-around lookups. For points on a circle, like servers on a hash ring, take the ceiling, and if there is none, wrap round to the smallest key.
- Duplicates. C++
std::multisetkeeps copies:s.erase(s.find(x))removes one, whiles.erase(x)removes them all. Java has no multiset, so use aTreeMapfrom key to count.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
Price updates arrive as
(time, price)pairs, in no particular order. Between updates, you’re asked: “what was the price at the latest time at or beforet?” What fits best?“Latest at or before
t” is the floor oft, and the times keep changing, so you need a structure that stays sorted: O(log n) per lookup in a tree. A hash map only finds exact times, so it fails whenevertfalls between two updates. A heap only shows the smallest time. Sorting before every query works but costs O(n log n) each time. -
2
What does this print?
from bisect import bisect_left, bisect_righta = [2, 4, 4, 9]def floor(x):i = bisect_right(a, x)return a[i - 1] if i > 0 else Nonedef ceiling(x):i = bisect_left(a, x)return a[i] if i < len(a) else Noneprint(floor(4), ceiling(5), floor(1), ceiling(10))bisect_right(a, 4)is 3, one past the last 4, so the floor isa[2] = 4.bisect_left(a, 5)is 3, so the ceiling is 9. Nothing is<= 1, soiis 0 and the guard returnsNone.2is what you’d get withbisect_leftfor the floor, and9forfloor(1)is what happens without thei > 0guard:a[-1]is the largest item. -
3
This C++ function should return the largest key
<= x. It works in most tests but sometimes crashes or returns garbage. What’s wrong?set<int> s = {10, 20, 30};int floorOf(int x) {auto it = s.upper_bound(x);return *prev(it);}For
x = 5,upper_boundreturnsbegin(), and stepping back from it is undefined. Checkit != s.begin()first and handle the no-floor case.upper_boundis right: withlower_bound,floorOf(20)would return 10, the largest key strictly below 20.prevmoves one step, which costs O(log n) at worst, and asetis always sorted. -
4
You call
bisect.insort(a, x)for 10⁶ values in random order.bisectis O(log n), yet it takes over a minute. Why?Finding the spot is a real binary search, O(log n). Putting the item there moves everything after it, which is O(n). At 10⁵ inserts the fast block copy hides this; at 10⁶ it dominates.
insortnever re-sorts, and Python lists are arrays, so indexing is O(1). For that many inserts you need a balanced tree or a different approach. -
5
What happens when this Java code runs?
TreeMap<Integer, String> m = new TreeMap<>();m.put(10, "a");m.put(20, "b");int k = m.floorKey(5);System.out.println(k);No key is
<= 5, sofloorKeyreturnsnull. Assigningnullto anintunboxes it, which throws.10is the ceiling, not the floor, and Java never uses-1or0to mean “not found” here. Store the result in anIntegerand compare it withnullfirst.
Practice problems
Solve these right here, in Python, C++ or Java. Tests run as you go.