We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Performance and Data Layout step 11 of 20
Sorting: sort vs sort_unstable vs sort_by_cached_key
Rust slices have several sorting methods. This exercise is about two questions that decide which one to call: does the sort need to allocate, and how many times does it compute the sort key?
Stable versus unstable
A stable sort keeps elements that compare equal in their original order. An unstable sort may reorder them.
sort,sort_byandsort_by_keyare stable. The standard library documentation says short slices are sorted without allocating, while longer slices get a temporary scratch buffer of up tolenelements.sort_unstable,sort_unstable_byandsort_unstable_by_keyare unstable and documented as in-place: they do not allocate.
The documentation describes the unstable sort as typically faster than the stable one, with exceptions such as partially sorted input. How much faster depends on the data, so measure your own case before relying on a number. When equal elements can never occur (every key is distinct), both kinds of sort produce the same order, and the allocation is the difference you can observe.
How often the key is computed
sort_by_key(f) does not compute f once per element and remember it. On this
toolchain it compares two elements by calling f on each of them, so f runs
twice per comparison, and a sort makes roughly n log n comparisons. The
documentation states the cost as O(m × n × log n), where m is the
cost of one key call.
If the key is cheap, such as |r| r.id, this does not matter. If the key
allocates, for example with to_lowercase, to_string or collect, every
one of those calls allocates again.
sort_by_cached_key(f) calls f at most once per element. It stores the keys
in a temporary Vec of (key, index) pairs, sorts that, and then rearranges
the slice to match. It is stable. The cost is the temporary Vec itself, so
the documentation notes that for simple keys sort_by_key is likely to be
faster. Caching a key that costs nothing only adds an allocation.
For this exercise's test data at 20 000 rows (measured with Rust 1.98.1):
computing the expensive key below inside sort_by_key ran the key 614 090
times and made 1 228 183 allocations in total; the same key with
sort_by_cached_key ran it 20 000 times and made 40 003 allocations.
What to write
pub struct Row {
pub id: u32,
pub name: String,
}
pub fn rank_records(rows: &[Row]) -> Vec<u32>
pub fn rank_by_len(rows: &[Row]) -> Vec<u32>
Both functions return the id of every row, once each, in sorted order. Do
not modify rows. For an empty slice, return an empty Vec.
rank_recordssorts by an expensive key: the row's name converted withstr::to_lowercase, with its characters (chars) then reversed. The starter code provides this assort_key. Keys are compared asStrings, which is lexicographic by bytes, so"ab"sorts before"b"and""sorts first. Rows with equal keys are ordered by ascendingid.rank_by_lensorts by a cheap key:name.len(), the length of the name in UTF-8 bytes (not in characters). Rows with equal lengths are ordered by ascendingid.
Example: rows (3, "Bob"), (1, "abc"), (2, "ab").
rank_records: the keys are"bob","cba"and"ba". In order that is"ba","bob","cba", so the answer is[2, 3, 1].rank_by_len: the lengths are 3, 3 and 2. The row of length 2 comes first, then the two rows of length 3 by id, so the answer is[2, 1, 3].
How it is tested
The test harness generates n rows (from 0 up to 20 000). Every id is
distinct, and names are ASCII capital letters followed by decimal digits. It
compares the full output of both functions (as the first six ids plus a
checksum of the whole list).
It also installs a counting global allocator. Each call to alloc,
alloc_zeroed or realloc counts as one allocation; freeing memory is not
counted. The count covers each function call on its own:
rank_records: at most3n + 64allocations. Computing each key once costs about two allocations per row (one forto_lowercase, one for the reversedString); the official solution makes2n + 3oncenis at least 2. Computing keys insidesort_by_keyfails this budget on the 60-row and 20 000-row tests.rank_by_len: at most 2 allocations in total, for example oneVecof indices to sort and oneVecfor the result. Addingsort_by_cached_keymakes a third. So does a stable sort on a long enough slice: on the 20 000-row test,sort_by_keyallocates its scratch buffer, whilesort_unstable_by_keydoes not.
Because each (key, id) pair is distinct, stable and unstable sorts give the
same order here; only the allocation counts tell them apart.
Related details
Borrowed keys. rows.sort_by_key(|r| &r.name) does not compile. The key
type K of sort_by_key is one fixed type for the whole sort, so it cannot
be a reference whose lifetime is tied to the particular element passed to
the closure. Rust 1.98.1 reports error: lifetime may not live long enough,
without an error code. Two ways around it: compare inside sort_by, as in
rows.sort_by(|a, b| a.name.cmp(&b.name)), which allocates nothing per
comparison, or return an owned key such as r.name.clone(), which allocates
on every key call.
Floating-point keys. f64 implements PartialOrd but not Ord, because
NaN is not comparable with anything. So sort() on a Vec<f64>, or
sort_by_key with an f64 key, fails to compile with error E0277. The
comparator a.partial_cmp(b).unwrap() compiles but panics if a NaN is
present. f64::total_cmp, stable since Rust 1.62, gives a total order that
never panics: for example, it sorts [1.0, NaN, -0.0, 0.0, -1.0] to
[-1.0, -0.0, 0.0, 1.0, NaN].
Inconsistent comparators. If a comparison function, or a key type's Ord
implementation, is not a total order, the documentation says the sort may
panic, and that if it returns normally the order of the slice is
unspecified. Which of those happens depends on the input and the standard
library version (the current sort implementations arrived in Rust 1.81), so
do not write tests that expect a panic. Fix the comparator instead.
Clippy lints. Grading runs Clippy's default lints with warnings treated
as errors. Two relevant lints are in that default set:
unnecessary_sort_by (you wrote sort_by(|a, b| a.f.cmp(&b.f)) where
sort_by_key(|x| x.f) says the same thing) and derive_ord_xor_partial_ord
(an error by default: it fires when you derive one of Ord and PartialOrd
and implement the other by hand, which can make the two disagree).
stable_sort_primitive (suggests sort_unstable for primitive types) and
float_cmp are in the pedantic group, which is not enabled by default.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Rust reference solution
Sign in to track your attempts toward unlocking the reference solution.