Every Built-in Method: Set & Map
After this lesson you will know what a Dart Set and Map look like inside memory (a compact hash table, a chained hash table, and a splay tree), why a lookup is O(1) in one and O(log n) in another, and you will have seen every public member of Set, Map, MapEntry and of the implementations HashSet, LinkedHashSet, SplayTreeSet, HashMap, LinkedHashMap and SplayTreeMap with its cost, the exceptions it can throw, whether it changes the collection or returns something new, and a runnable example whose exact output was checked against the real Dart SDK. Related lessons: D08 Collections, D22 Mutability, D26 List.
Source of truth: the Dart SDK 3.11 sources (core/set.dart, core/map.dart, core/iterable.dart, collection/hash_set.dart, hash_map.dart, linked_hash_set.dart, linked_hash_map.dart, splay_tree.dart) and the VM implementations _internal/vm_shared/lib/compact_hash.dart and collection_patch.dart. Complexities and table sizes describe the native VM / AOT; on the web (dart2js) the collections are built differently and int.hashCode differs. Every printed result was produced by running the code with Dart 3.11 and is asserted in verify/d27.dart.
1. What a Set and a Map really are
A Set is a guest list at a party door: each name appears at most once, and the question you ask all night is "is this person on the list?". A Map is a phone directory: you look up a key (a name) and get its value (a number); every name appears once, but two names may have the same number. One line of the phone directory (name + number) is a MapEntry. Both are built so that the question "is this key in here?" does not mean reading the whole directory: the key itself tells the machine where to look.
Words, defined once. A hash code is a whole number computed from a value (key.hashCode) that tells the machine roughly where to file it. A hash table is an array of slots in which values are filed by hash code. A collision is two different keys wanting the same slot. A tree is a structure where each node has up to two children (a smaller side and a larger side). Insertion order means iteration returns things in the order they were first added.
Set and Map are only promises (abstract interfaces). The real work is done by six classes in dart:collection. You get one of them every time you write a literal:
| Class | How you get it | Ordering | Equality used | Lookup / add / remove | Stored as |
|---|---|---|---|---|---|
LinkedHashSet / LinkedHashMap | the default: {1, 2}, {'a': 1}, Set(), Map(), toSet() | insertion order | == + hashCode | O(1) expected | an index table of numbers + an array of entries in insertion order |
HashSet / HashMap | you ask: HashSet<int>() | none promised (bucket order) | == + hashCode | O(1) expected, O(n) worst | an array of buckets, each a chain of entry objects |
SplayTreeSet / SplayTreeMap | you ask: SplayTreeSet<int>() | sorted by compare | only compare == 0 | O(log n) amortised | a binary search tree that rotates the last-used node to the root |
First look at all three side by side. In each player the variable lives on the stack, the collection object on the heap, and below it the real storage (arrays, buckets, tree) changes as the statement runs. Press play and read the captions: they name every decision.
The default: LinkedHashMap
HashMap
SplayTreeMap
firstKeyAfter) the others cannot answer. A Set is simply a map without values: the same three structures store only keys.2. Inside the three implementations
Hashing and the equality contract
Both hash-based classes do the same two-step dance for every lookup: (1) compute key.hashCode and use it to jump to one place; (2) compare the keys found there with ==. For this to work the contract must hold: if a == b then a.hashCode == b.hashCode (the reverse need not hold: different objects may share a hash). Break it and equal keys go to different places and are never compared.
On the native VM, int.hashCode is not the number itself: for small non-negative ints it is the number times 11601 (an odd constant that mixes the bits; the simulators below use exactly that and the page asserts it for 0..90 000). Strings, doubles and your own classes have other hash codes. The simulators therefore accept keys 0..999.
== that you override without overriding hashCode (or vice versa) compiles fine and fails silently: the set keeps "equal" duplicates or cannot find what it holds. Section 4 and the interview questions show both failures. A SplayTreeSet avoids the problem by never using == or hashCode: its only judge is the compare function.HashSet / HashMap: chained buckets
The table is an array of buckets (starting with 8). A key goes to bucket hashCode & (buckets - 1) (because the size is a power of two, that is "the last bits of the hash"). Each bucket holds a chain: a little linked list of entry objects (key, value, hash, next). A new entry is pushed on the front of its chain. When the table is more than 75% full (count × 4 > buckets × 3) it doubles and every entry is re-filed, which reverses chain order. Iteration visits bucket 0, 1, 2, ... and each chain from the front: that is the order you see.
Watch three keys collide (5, 13, 21 all end in bucket 5 of an 8-bucket table), then a lookup that has to walk the chain:
The growth rule in action: the 7th entry makes 7×4 = 28 > 8×3 = 24, so the table doubles. Notice how the iteration order of the same elements changes:
The same structure as a small Dart class (this is the model that the interview question "reproduce the SDK HashMap order" asks for; its key order is asserted equal to the real HashMap on thousands of random operations):
| entries so far | buckets | resize happens when the next entry makes |
|---|---|---|
| 0 – 6 | 8 | 7 × 4 = 28 > 8 × 3 = 24 (the 7th) |
| 7 – 12 | 16 | 13 × 4 = 52 > 16 × 3 = 48 (the 13th) |
| 13 – 24 | 32 | 25 × 4 = 100 > 96 (the 25th) |
| 25 – 48 | 64 | 49 × 4 = 196 > 192 (the 49th) |
Why the bad cases matter. If many keys share a bucket, a lookup walks a long chain. A weak hashCode (here k % 2, only two buckets are ever used) turns O(1) into O(n):
Now play with your own keys (0..999). Try keys that are multiples of 8 and watch the chain grow, then add enough keys to see a resize:
Input size → what is feasible: a HashMap with a decent hash handles 106 inserts in well under a second; the same map with a constant hashCode would do 106²/2 = 5·1011 comparisons (minutes). Quality of hashCode is the whole game.
HashMap and HashSet promise no order, so an implementation does not have to maintain one; that is their whole contract (the default linked collections give you the order for free, so most programs never need to ask for the plain hash kind). Another subtlety: on the VM the identity/custom-equality variants (HashMap.identity(), HashSet(equals: ..., hashCode: ...)) use the same chained table with identityHashCode or your callbacks. Never rely on the iteration order of a HashSet/HashMap: it changes with the table size and history.LinkedHashSet / LinkedHashMap: compact index + data
The default collection stores things in two arrays. The _data array simply holds the entries in the order they were added (key, value, key, value ... for a map). The _index array is the hash table: a power-of-two array of small numbers in which each used slot says "entry number n lives in _data". To look up a key: compute hashCode, shuffle it (((h & mask) × 3) & mask) to get a first slot, then probe slot after slot (linear probing: if the slot holds a different key, try the next one) until you find the key or hit an empty slot (0). Because the data array is in insertion order, iteration order is insertion order for free and iteration is a plain walk over an array.
Sizes (from the SDK source; they are not observable from Dart code): the first insert allocates an index of 8 slots and room for 4 entries. The index always has twice as many slots as there are entry places (load factor at most 1/2), so a miss usually ends after one or two probes. When the data array is full the table is rebuilt: twice as big, or the same size if more than half of the used entries are deleted. Removing an entry writes a DELETED marker (1) into its index slot and a deleted marker into its data entry; nothing moves, and the marker is cleaned out by the next rebuild. Updating the value of an existing key changes it in place: the key keeps its position.
Remove, re-add and overwrite show where tombstones and "moves to the end" come from:
| entries inserted (no deletes) | _index slots | room for entries |
|---|---|---|
| 1 – 4 | 8 | 4 |
| 5 – 8 | 16 | 8 |
| 9 – 16 | 32 | 16 |
| 17 – 32 | 64 | 32 |
The same idea in plain Dart (the interview solution "your own hash map" is a variant; this class keeps insertion order like the SDK, and its key order is asserted equal to the real LinkedHashMap on random operations):
Experiment with your own sequence (keys 0..999). Try removing a key and adding it back, then adding five different keys to see the rebuild:
first, keys.first and entries.first scan the data array from the front and skip deleted entries. After many removals from the front (an LRU cache evicting the oldest key again and again) they may skip many deleted slots before the next rebuild cleans them up. For very large caches use a linked list (lesson D28).Input size → what is feasible: 106 inserts into the default map take a fraction of a second (a rebuild copies the entries, about 2 copies per entry in total, amortised O(1)); iterating is a plain array walk.
SplayTreeSet / SplayTreeMap: a self-adjusting tree
A binary search tree keeps smaller keys in the left subtree and larger keys in the right, so lookup walks down comparing: O(depth). A plain BST can degrade into a chain (inserting 1, 2, 3, 4 in order). A splay tree repairs this without bookkeeping: after every access (insert, lookup, remove, even a miss) it rotates the node it ended on up to the root. Frequently used keys stay near the top, and a deep node costs a lot once but halves the depth of the path it was on. The guarantee is O(log n) amortised (averaged over a sequence), while a single operation can be O(n). Two consequences you must remember: (1) reads change the tree (the contents stay, the shape changes); (2) equality is decided only by compare == 0.
Insert 1, 2, 3, 4 in ascending order (each new node becomes the root with the old root on its left), then look up key 1, the deepest node:
A balanced-looking tree: each lookup, even a miss, ends with the last visited node as the root:
The algorithm, as the SDK implements it (_splay, top-down: it rotates and detaches pieces on the way down instead of recursing back up). This class counts key comparisons so you can measure the cost:
Your turn (keys 0..999). Insert in ascending order, then look up the smallest key, and compare the depth before and after:
Input size → what is feasible: 106 operations on a splay tree cost about 106 × 20 = 2·107 comparisons amortised; a hash map does the same job with ~106 steps. Choose a SplayTree only when you need sorted iteration or predecessor / successor queries.
3. Every member, grouped by purpose
Each card shows: the signature, what it means in plain words, a badge telling you what happens to the collection, the cost and why (the structure from section 2), the exceptions, and a runnable example with its exact output.
| Badge | Meaning |
|---|---|
| mutates | changes this collection (or, for firstKey etc., the shape of its tree) |
| returns new | builds a new object (set, map, string, list); the original is untouched |
| lazy view | returns a cheap wrapper that reads the original when iterated; later changes show through |
| read-only | reads or reports; changes nothing |
| creates | a constructor: makes a brand-new collection |
Set (5 constructors, 1 static helper, 17 instance members), 30 by Map (8 constructors, 1 static helper, 21 instance members), 4 by MapEntry, 33 inherited from Iterable (27 members plus the 6 extension members indexed, firstOrNull, lastOrNull, singleOrNull, elementAtOrNull, nonNulls), 5 from Object (==, hashCode, runtimeType, toString, noSuchMethod), and the implementation-specific ones: HashSet 4 constructors, LinkedHashSet 4, SplayTreeSet 3, HashMap 7, LinkedHashMap 7, SplayTreeMap 5 constructors + firstKey, lastKey, lastKeyBefore, firstKeyAfter. Deprecated: none of them in Dart 3.11. Dart has no floorKey / ceilingKey / firstEntry / pollFirst (Java-style names): use lastKeyBefore / firstKeyAfter plus containsKey, or keys.first.The players use a mini-language so you can type your own experiments. The first part names the collection: set[3,1,2] (default LinkedHashSet), hset[...] (HashSet), tset[...] (SplayTreeSet), map{1:10,2:20} (default LinkedHashMap), hmap{...} (HashMap), tmap{...} (SplayTreeMap); options: hset(mod3)[...] uses a custom hashCode of k % 3 (mod2 .. mod9), tset(desc)[...] compares descending, tset(mod10)[...] compares by last digit. Then operations separated by ;, for example set[3,1,2] ; add 4 ; remove 3 ; removeWhere even. Keys and elements are whole numbers 0..999 (the hash simulation assumes them), map values −999..999, at most 12 values and 12 operations. A predicate is even, odd, >N, <N or =N; for maps write removeWhere k >5 or removeWhere v even; update 3 +5 ifAbsent 0; lists are [1,2], maps {1:2,3:4}.
Index of every member (click a name to jump to its card)
3.1 Create: constructors and literals
How a Set or Map is created decides its kind (linked, hash, tree), whether it is mutable, and how elements are compared. The literal and Set() / Map() give the default linked kind; .of / .from copy; .unmodifiable freezes a copy; .identity compares by object identity. Input size → feasible: building from n elements costs n hash inserts: 106 is a few hundred milliseconds.
3.2 The six implementations: their constructors
These are the constructors that belong to the concrete classes: they add the options equals / hashCode (hash kinds) and compare (tree kinds), and the .identity variants. Remember the rules of thumb: give equals and hashCode together; a tree's compare must be a consistent total order, and compare == 0 is equality there.
HashMap created with custom equals / hashCode, the inherited update method looks the key up with the key's own hashCode and ==, ignoring your callbacks (the class overrides [], []=, putIfAbsent, remove and containsKey but not update). The result can be a second entry for a key that your equality considers the same. The interview section demonstrates it with exact output; prefer putIfAbsent and []= there, and re-check the behaviour on newer SDKs.3.3 Read and inspect
Reading does not change a hash-based collection: it hashes, jumps to a slot, compares. On a SplayTree every read rotates the found node (or the last node on the path) to the root. Remember: [] returns null for a missing key; containsValue is the one lookup with no index (O(n)). Input size → feasible: 106 lookups in a hash map: ~106 steps; 106 calls to containsValue on a 105 map: 1011 steps, far too slow, so keep a reverse map.
3.4 Add and put
Adding a new element is O(1) expected and amortised (the arrays or buckets double now and then), O(log n) amortised in the tree. Adding an element that is already there changes nothing for a set (and add says so with false) and replaces the value for a map (keeping its position in a linked map). Input size → feasible: 106 adds ~ 0.1–0.3 s.
3.5 Remove
Removing by key is O(1) expected (a tombstone in the linked table, an unlink in a chain) and O(log n) amortised in the tree. The bulk forms (removeWhere, retainWhere, removeAll, retainAll) first collect what to remove and then remove it, so they are safe to call but not free: O(n). The collection is never shrunk automatically except by clear and by the linked table's rebuild. Input size → feasible: one removeWhere over 106 elements ~106 steps; removing keys inside a for loop over the same collection throws ConcurrentModificationError.
3.6 Set algebra
union, intersection and difference return new sets and leave both inputs alone; the other members here test or edit in place. The order of the result follows the receiving set's kind: insertion order for the default, bucket order for a HashSet, sorted for a SplayTreeSet. Input size → feasible: two sets of 105 elements: ~2·105 hash operations; calling contains on a List inside a loop instead would be 1010.
3.7 Views and transforms
Several members return views instead of copies. keys, values and entries are live windows on the map; cast is a typed window; Map.map is eager and builds a new map; forEach just loops. Step through what lives where:
3.8 SplayTree extras: sorted-map queries
Only SplayTreeMap has firstKey, lastKey, lastKeyBefore and firstKeyAfter (and a SplayTreeSet's first / last are the same idea). They splay too, so they change the tree's shape though never its contents. Input size → feasible: 105 predecessor queries on 105 keys ~ 105 × 17 = 1.7·106 comparisons; a hash map cannot answer them at all without scanning.
3.9 Inherited from Iterable (the Set side)
A Set is an Iterable, so every Iterable member works on it, in the set's own order. A Map is not an Iterable (iterate keys, values or entries). The members that return an Iterable (map, where, take ...) are lazy and redo their work on every new loop. Because a Set cannot be indexed, skip, elementAt and last-style members walk from the start. One more detail: a chain made only of map, take and skip still knows its length, so toList() can return an empty list without pulling a single element when that length is 0. (Lesson D25 covers Iterable in depth.) Input size → feasible: a chain over 106 elements ~106 steps per pass; iterating the same lazy chain 1000 times repeats that 1000 times, so call toList() or toSet() once.
fold and reduce squash the whole set into one value:
Iterable.generate, Iterable.empty, Iterable.withIterator, Iterable.castFrom and the two iterableTo...String helpers belong to Iterable (lesson D25). UnmodifiableSetView, UnmodifiableMapView, MapView, SetBase and MapBase (build-your-own collection) are lesson D28.3.10 MapEntry
A MapEntry is one key/value pair as an object. It is immutable, and entries hands out a fresh snapshot each time you iterate. Watch the difference between a view of the map and a snapshot of a pair:
3.11 Object-level members
Every collection inherits five members from Object. The important one: == is identity for Set and Map. See it with two sets that hold the same elements, then with your own:
4. Mutable vs immutable sets and maps
"Mutable" means the object can change after it is made. Whether you can change a Set or Map depends on how it was made, and which members throw:
| How it was made | Add / remove / put / clear | Copy semantics |
|---|---|---|
{1, 2}, {'a': 1}, Set.of, Set.from, toSet(), Map.of, Map.from, HashSet() ... | allowed (growable) | a new independent collection (shallow: elements and values are shared) |
const {1, 2}, const {'a': 1} | UnsupportedError on every mutating member | one shared canonical object: identical(const {1, 2}, const {2, 1}) is false (order is part of the constant) but two identical literals are one object |
Set.unmodifiable(src), Map.unmodifiable(src) | UnsupportedError | a frozen copy: later changes to src do not show |
UnmodifiableSetView(src), UnmodifiableMapView(src) (D28) | UnsupportedError | a read-only view: changes to src do show through |
keys, values, entries, cast, where, map (Iterable) | no add/remove at all | live views; call toList() / toSet() for a snapshot |
Four traps follow from "variables hold references": aliasing (two names, one map), shallow copy (the map is copied but the lists inside it are shared), copy vs view, and changing the set of keys while looping. A fifth trap is special to hash collections: a key that changes after insertion is lost. Step through each.
final only stops you from pointing the variable at another collection: final m = {}; m['a'] = 1; is legal. Only const or unmodifiable freezes the object. And a Map key or Set element must never change in a way that affects == / hashCode (or compare) while it is stored. For loops: overwriting the value of an existing key while iterating keys is legal; adding or removing a key throws.Map.of, Map.from, {...m} and toSet() are all shallow.5. Choosing the right collection
The best structure is the one whose cheap operations match what your program does most. Complexities are for n elements on the native VM.
| Question | LinkedHashSet/Map (the default) | HashSet/HashMap | SplayTreeSet/Map |
|---|---|---|---|
| iteration order | insertion order | none (bucket order; changes on resize) | sorted by compare |
| add / lookup / remove | O(1) expected (add amortised) | O(1) expected, O(n) if everything collides | O(log n) amortised, one call can be O(n) |
| does a read change it? | no | no | yes: it splays the found node to the root |
| equality / identity of keys | == + hashCode (or custom / identity) | the same | only compare == 0 |
| first / last element | O(1) typical (skips deleted slots) | O(buckets) scan | O(log n) amortised (firstKey, first) |
| sorted iteration, range, predecessor / successor | no | no | yes: in-order walk O(n), lastKeyBefore, firstKeyAfter |
| iteration cost | O(data array) (skips deleted) | O(buckets + n) | O(n), plus the tree nodes |
| memory per element | 2–4 index slots (4 bytes each) + 1 data slot (set) or 2 (map) | one entry object (key, value, hash, next) + a bucket slot | one node object (key, value, left, right) |
| detects modification while iterating | yes (ConcurrentModificationError) | yes | yes |
| use it when | almost always (default) | you want the plainest semantics and never look at the order | you need sorted order, ranges or neighbours |
| Need | List (D26) | Set (default) | Map (default) |
|---|---|---|---|
| "is x in there?" | O(n) contains | O(1) expected | O(1) expected by key, O(n) by value |
| access by position | O(1) xs[i] | no index (elementAt is O(i)) | by key only |
| duplicates | allowed | never | keys never, values yes |
| order | by index | insertion | insertion |
| attach data to each item | list of records | no | yes: key → value |
Rule of thumb: default to the literal ({}). Switch to SplayTreeMap/Set when you need sorted keys or "nearest key" queries; use HashMap only when you truly do not care about order and want to avoid the insertion-order bookkeeping; use a List when you need positions. Input size → feasible: a default map holds 107 small entries comfortably (hundreds of MB); a duplicate check over n = 105 with List.contains is 5·109 steps, with a Set it is 105.
Quiz
Interview questions
Cheat sheet: every member
Generated from the same member data as the cards above: every member with its cost and whether it mutates or returns something new.