We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Collections, Text and First Iterators step 15 of 21
Grouping without itertools: anagram buckets
Group words that are anagrams of each other.
pub fn anagram_groups(words: Vec<String>) -> Vec<Vec<String>>
Two words belong together when they are permutations of the same multiset of characters. Ordering of the result:
- groups sorted by size descending;
- ties broken by the group’s first word ascending;
- words within a group stay in input order.
Comparison is exact — "Ab" and "ab" are not anagrams, because 'A' and
'a' are different characters. Non-ASCII words work the same way: characters,
not bytes.
There is no group_by in std
In most ecosystems this is one call: itertools.groupby, Enumerable#group_by,
_.groupBy, stream().collect(groupingBy(..)). Rust’s standard library has
none of them, and this site has no external crates at all — so if your
first move is to reach for itertools::Itertools::into_group_map, you are
about to hit a wall.
That is a feature here rather than a hardship. The std answer is four lines
and once you have written it you will never be confused about what
into_group_map does:
let mut map: BTreeMap<Key, Vec<Item>> = BTreeMap::new();
for item in items {
map.entry(key_of(&item)).or_default().push(item);
}
A map from key to Vec — a multimap — plus the Entry API from earlier in
this track. or_default() creates the empty Vec on first sight of a key and
hands you a &mut Vec either way, so there is exactly one lookup per item.
Use a BTreeMap rather than a HashMap and the buckets come out in key
order, which makes the whole function deterministic before you even start
sorting.
chunk_by is a different operation, and people conflate them
Rust does have a grouping primitive: slice::chunk_by. It groups
adjacent elements that satisfy a predicate:
[1, 1, 2, 2, 1].chunk_by(|a, b| a == b) // [1,1], [2,2], [1]
Note the trailing [1]: it is a third group, not part of the first. That is
the whole difference. chunk_by needs no map and no allocation because it
only ever compares neighbours; grouping by an arbitrary key needs a map,
because the matching item may be anywhere.
A useful equivalence: sort by the key first and chunk_by becomes
interchangeable with the map version — at the cost of an O(n log n) sort. If
the data is already sorted, chunk_by is strictly better. If it is not, the
map is.
The compile error
error[E0507]: cannot move out of `*w` which is behind a shared reference
The starter iterates for w in &words, so w is a &String — borrowed.
Pushing into a Vec<String> needs an owned String, and *w does not
conjure ownership; dereferencing a shared reference gives you a place you may
read, not a value you may take. Rust is refusing to leave a hole in words.
Three ways out, and choosing between them is the actual lesson:
-
for w in words— consume the vector. You own eachString, no clone, no allocation. Correct here, because the caller gave you theVecby value and does not want it back. -
w.clone()— keepwordsintact and pay for a copy per word. Correct if you need the input afterwards. -
change the return type to
Vec<Vec<&str>>— hand back borrowed slices and allocate nothing at all. Correct if the caller keepswordsalive.
The signature already decided for you: words: Vec<String> by value means the
caller handed over ownership, so option 1 is free and the other two are
paying for something nobody asked for.
::: question Why is the key a String of sorted characters rather than a sorted Vec<char>?
Either works — Vec<char> is Ord, so a BTreeMap<Vec<char>, _> compiles
and behaves identically. String is chosen because it is four bytes per key
rather than four bytes per character plus a pointer, and because it is what
you would want if the key ever needed printing or storing.
What does not work is sorting the bytes: w.as_bytes().sort() would
scramble multi-byte UTF-8 sequences and could produce a key that is not valid
UTF-8 at all. chars() iterates whole scalar values, which is why the
non-ASCII test case passes. (Strictly, even chars() is not the last word —
a combining accent is two chars that render as one grapheme — but that
needs a crate, and for this problem characters are the right unit.)
:::
Sorting the groups
Two keys, one descending and one ascending, so a single sort_by with a
chained comparator:
groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()) // size, descending
.then_with(|| a.1[0].cmp(&b.1[0]))); // first word, ascending
Descending is expressed by swapping the operands, not by negating anything.
then_with runs its closure only when the first comparison was Equal.
One style note. Vec<(String, Vec<String>)> as an intermediate type is
readable but noisy, and a type alias costs one line:
type Grouped = Vec<(String, Vec<String>)>;
clippy has a type_complexity lint for genuinely unreadable types — verified
against clippy 0.1.95, its default threshold does not fire on a two-level
type like this one, but it does on a three-level nest such as
BTreeMap<String, Vec<(String, Vec<Option<String>>)>>. When it does fire, the
fix is the alias, not an #[allow].
Remember the grade is compile + tests + clippy -D warnings.
Loading visualization…
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.