A video-metadata extractor scans a sorted list of chapter timestamps on every request at a 1,200-request-per-minute peak - how do you remove that Python-level loop?
answer
- The list is already ordered - exploit it
- A search primitive that runs in C
- Left or right changes tie placement
- The key is applied only to probed elements
- Profile the request path before rewriting
basics
~10 sThe list is already sorted, so replace the scan with bisect.bisect_right, which runs the binary search in C and returns an insertion point; subtract one for the last timestamp at or before the position.
solid answer
~50 sSince the list is sorted, the interpreted scan can be replaced outright by the `bisect` module, which does the search in C: `bisect.bisect_right(marks, pos) - 1` gives the last mark at or before `pos`, and `bisect.bisect_left(marks, pos)` gives the first at or after it - the two differ only in how they place equal values. Since 3.10 a `key` argument lets you search a list of records, and the key is applied only to the roughly log2(n) elements actually examined, not to all n. To keep the list ordered as new marks arrive, `bisect.insort` searches in log time and then shifts the tail with a single C block move: still O(n), but hundreds of times cheaper per element than an interpreted loop. Before any of this, profile - at twenty requests a second, parsing a locale-dependent timestamp format on the same path is the more likely hotspot.
code
python · 9 linesimport bisect
marks = [0.0, 12.5, 48.2, 91.0, 130.75]
pos = 50.0
i = bisect.bisect_right(marks, pos)
print(i, marks[i - 1] if i else None)
bisect.insort(marks, 60.0)
print(marks)go deeper
Know that the bisect module exists, that it only works on an already-sorted list, and that it answers where-does-this-value-go questions far faster than walking the list yourself.
Explain the mechanics: bisect_left versus bisect_right on equal values, the off-by-one when you want the element before the insertion point, and that insort is a log-time search plus a linear but C-level tail shift.
Demonstrate the operating judgement: profile the request path first, protect the sortedness invariant because a wrong index raises nothing, and know that the key argument only calls its function on the probed elements.
Own where this stops: replacing interpreted loops with search primitives has a ceiling, and past it the answer is a data-layout change, precomputation at ingest, or moving the work into a compiled extension - a decision to argue with numbers rather than instinct.
### What the scan is costing The original shape is something like a loop over the sorted marks that breaks when it passes the requested position. Every request pays interpreted iterations proportional to how far into the list the answer lies: an iterator advance, a comparison and a branch per element, all as bytecode. At a 1,200-request-per-minute peak - twenty requests a second - with a few hundred chapter marks per asset, that is a few thousand interpreted iterations a second. Noticeable, but the first thing to establish is whether it is the request's actual hotspot, which `cProfile` answers in minutes. ### Replacing the loop with the module The list is already ordered, which is the precondition the `bisect` module requires and never checks. `bisect.bisect_right(marks, pos)` returns the index where `pos` would be inserted to sit after any equal entries; `bisect.bisect_left(marks, pos)` returns the index where it would sit before them. The search is a binary search implemented in C, so per request you go from a scan proportional to the list length to a handful of comparisons with essentially no interpreter overhead - and unlike the built-in fold functions, this is not merely a constant-factor win: it changes the search from linear to logarithmic. The two variants matter and mixing them up is a real bug, not a style choice. For the last mark at or before a position, use `bisect_right(marks, pos) - 1`; that includes a mark exactly equal to `pos`. For the first mark strictly after it, use `bisect_right` without the subtraction. For the first mark at or after it, use `bisect_left`. Guard the boundaries: an index of `0` from `bisect_right` means the position precedes every mark, and subtracting one there would silently wrap around to the last element. The module compares using only `<`, that is the element type's `__lt__`. If the list is sorted by a different ordering than the one `<` implements - reverse order, or a locale-sensitive text ordering that does not match the default string comparison - the search returns a wrong index with no error at all. That silence is the main hazard, and it is why the list should be built once with `sorted()` and treated as an invariant rather than assumed to be ordered because it usually is. ### Searching records rather than bare values Real metadata rarely stores bare floats. Since Python 3.10 the bisect functions take a `key` argument, so a list of `(offset, title)` records can be searched by offset. Two details worth stating: the value you pass is the *key* value, not a record, and the key callable is applied only to the elements the binary search actually examines - about log2(n) of them - so even a Python-level key stays cheap. Before 3.10 the equivalent was keeping a parallel list of keys to search, then indexing the records with the result. ### Keeping the list sorted When new marks arrive, `bisect.insort` (and its explicit `insort_left` / `insort_right` forms) finds the position in log time and then calls the list's insert, which shifts the tail. That shift is O(n) in the number of elements - but it is a single contiguous block move of pointers in C, not an interpreted loop, so the constant is tiny and it stays comfortably fast for lists of thousands. The judgement is about the ratio: if reads dominate and inserts are occasional, `insort` is the right answer; if the workload is insert-heavy on a large list, batch the arrivals and re-sort once, or use a structure whose insert is genuinely logarithmic. What you must not do is call `sorted()` on every request - that is O(n log n) per request to serve a query that should cost log n. ### The judgement layer This is where a senior answer separates from a mechanical one. Measure first: with `cProfile` on the request path, the per-request parse of a locale-dependent timestamp format is a strong candidate to outweigh the scan, and if so the right fix is to parse once at ingest, store a numeric offset, and cache the sorted list per asset rather than to micro-optimise the search. Second, know the ceiling: this rewrite removes interpreted iterations, and once the loop is gone the remaining cost is whatever the surrounding Python does per request. If the profile then shows the numeric work itself dominating over large arrays, the next step is not another built-in but moving the whole operation into a compiled extension that works on contiguous buffers. Third, protect the invariant with a cheap assertion or a construction path that cannot produce an unsorted list, because a silently wrong index in a metadata extractor surfaces as subtly wrong output rather than as an exception.
- What happens if the list is not actually sorted by the ordering bisect assumes?You get a wrong index and no exception. The module never validates the input; it performs a binary search using only `<` on the elements. A list ordered by anything other than that comparison - reversed, or by a locale-sensitive rule that differs from the default - will return plausible nonsense. Build the list once through `sorted()` and treat sortedness as an invariant of the structure rather than a hope.
- bisect.insort is O(n) because of the tail shift - when does that stop being acceptable?When inserts dominate reads, or the list is large enough that moving the tail shows up in the profile. The shift is a single contiguous block move in C, so the constant is very small and lists of thousands stay fast. Past that, batch the arrivals and re-sort once, or move to a structure with genuinely logarithmic insertion. Re-sorting on every insert is always the wrong answer.
- How would you confirm the scan is worth removing at all before you change anything?Run `cProfile` over a realistic sample of the request path and look at cumulative time per function, then `timeit` the isolated lookup at the real list size. In an extractor handling twenty requests a second, per-request parsing of a locale-dependent timestamp format frequently costs more than the scan - in which case parsing once at ingest and caching the numeric offsets is the change that actually pays.
saying these in an interview costs you the question
- Reaches for bisect without establishing the list is sorted
- Re-sorts the whole list on every request
- Confuses bisect_left and bisect_right on equal values
- Assumes a key function is applied to all n elements
- Optimizes the scan before profiling the request path
- Claims insort is logarithmic overall