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:

ClassHow you get itOrderingEquality usedLookup / add / removeStored as
LinkedHashSet / LinkedHashMapthe default: {1, 2}, {'a': 1}, Set(), Map(), toSet()insertion order== + hashCodeO(1) expectedan index table of numbers + an array of entries in insertion order
HashSet / HashMapyou ask: HashSet<int>()none promised (bucket order)== + hashCodeO(1) expected, O(n) worstan array of buckets, each a chain of entry objects
SplayTreeSet / SplayTreeMapyou ask: SplayTreeSet<int>()sorted by compareonly compare == 0O(log n) amortiseda 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

Three data structures behind one interface. Linked (default): O(1) lookups and insertion order. Hash: O(1) lookups, no useful order. SplayTree: O(log n) lookups but always sorted, with range questions (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.


  
An == 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 farbucketsresize happens when the next entry makes
0 – 687 × 4 = 28 > 8 × 3 = 24 (the 7th)
7 – 121613 × 4 = 52 > 16 × 3 = 48 (the 13th)
13 – 243225 × 4 = 100 > 96 (the 25th)
25 – 486449 × 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 slotsroom for entries
1 – 484
5 – 8168
9 – 163216
17 – 326432

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.

BadgeMeaning
mutateschanges this collection (or, for firstKey etc., the shape of its tree)
returns newbuilds a new object (set, map, string, list); the original is untouched
lazy viewreturns a cheap wrapper that reads the original when iterated; later changes show through
read-onlyreads or reports; changes nothing
createsa constructor: makes a brand-new collection
Coverage. Checked against the SDK sources with a script: 129 public members are covered: 23 declared by 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.

An SDK quirk (Dart 3.11.5). On a 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:

Not on Set / Map themselves. The static 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 madeAdd / remove / put / clearCopy 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 memberone 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)UnsupportedErrora frozen copy: later changes to src do not show
UnmodifiableSetView(src), UnmodifiableMapView(src) (D28)UnsupportedErrora read-only view: changes to src do show through
keys, values, entries, cast, where, map (Iterable)no add/remove at alllive 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.

  
To copy a map whose values are mutable (lists, sets, maps) you need a deep copy: copy each value too. 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.

QuestionLinkedHashSet/Map (the default)HashSet/HashMapSplayTreeSet/Map
iteration orderinsertion ordernone (bucket order; changes on resize)sorted by compare
add / lookup / removeO(1) expected (add amortised)O(1) expected, O(n) if everything collidesO(log n) amortised, one call can be O(n)
does a read change it?nonoyes: it splays the found node to the root
equality / identity of keys== + hashCode (or custom / identity)the sameonly compare == 0
first / last elementO(1) typical (skips deleted slots)O(buckets) scanO(log n) amortised (firstKey, first)
sorted iteration, range, predecessor / successornonoyes: in-order walk O(n), lastKeyBefore, firstKeyAfter
iteration costO(data array) (skips deleted)O(buckets + n)O(n), plus the tree nodes
memory per element2–4 index slots (4 bytes each) + 1 data slot (set) or 2 (map)one entry object (key, value, hash, next) + a bucket slotone node object (key, value, left, right)
detects modification while iteratingyes (ConcurrentModificationError)yesyes
use it whenalmost always (default)you want the plainest semantics and never look at the orderyou need sorted order, ranges or neighbours
NeedList (D26)Set (default)Map (default)
"is x in there?"O(n) containsO(1) expectedO(1) expected by key, O(n) by value
access by positionO(1) xs[i]no index (elementAt is O(i))by key only
duplicatesallowedneverkeys never, values yes
orderby indexinsertioninsertion
attach data to each itemlist of recordsnoyes: 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.