Hash array mapped trie
A hash array mapped trie (HAMT) is a trie over the bits of the keys' hash values. Each level of the tree consumes a few bits of the hash, usually five, to choose one of 32 children, and each node stores only the children that exist, in a compact array, together with a 32-bit bitmap recording which of the 32 slots they occupy. A child's position in the array is the number of set bits in the bitmap before its slot, one population-count instruction. Lookups follow about levels, which is at most seven for any hash map that fits in memory. Updated by copying only the path from the root to the changed leaf, a HAMT is a persistent hash map. Bagwell introduced it in 2001[1], and it is the standard implementation of immutable maps and sets in Clojure[3], Scala[4] and Haskell's unordered-containers[9].
Structure
A key is hashed to a fixed-width integer, and the hash is split into chunks of bits, usually . The chunk at level selects one of slots in the node at that level:
A node with a full array of 32 children would waste most of its space in a sparse tree, since most nodes near the leaves have only a few children. A HAMT node instead stores a bitmap with bit set when slot is occupied, and an array holding only the occupied children, in slot order. The child for slot is at position
the number of occupied slots before it. Population count is a single instruction on current processors[7]. A key is stored in a leaf as soon as its prefix of chunks is unique, so the tree is only as deep as needed to separate the keys. Keys whose entire hashes are equal share a collision node at the bottom, searched linearly.
Operations
Lookup hashes the key and walks down, at each node testing the bitmap bit for the current chunk and, if it is set, moving to the child at the computed index; at a leaf it compares the key. Insertion follows the same path. If the slot is empty, it copies the node into an array one longer with the new leaf inserted. If the slot holds a leaf with a different key, it replaces the leaf with a new node one level down that contains both, continuing down while their chunks coincide. Every node on the path is copied, and everything else is shared with the previous version, so the old map remains valid[6][8].
A persistent HAMT in OCaml with 5-bit chunks, bitmap-indexed nodes and collision lists.
(* A persistent hash array mapped trie. The hash of a key is read 5 bitsat a time; each 5-bit chunk chooses one of 32 slots. A node stores onlyits occupied slots, in a dense array, with a 32-bit bitmap saying whichslots they are. *)type ('k, 'v) t =| Empty| Leaf of int * 'k * 'v (* hash, key, value *)| Collision of int * ('k * 'v) list (* keys whose whole hash is equal *)| Node of int * ('k, 'v) t array (* bitmap, occupied children *)let bits = 5let chunk h shift = (h lsr shift) land 31let popcount x =let x = x - ((x lsr 1) land 0x55555555) inlet x = (x land 0x33333333) + ((x lsr 2) land 0x33333333) inlet x = (x + (x lsr 4)) land 0x0f0f0f0f in((x * 0x01010101) lsr 24) land 0xff(* The position of a slot in the dense array: the number of occupied slotsbefore it. *)let index bitmap bit = popcount (bitmap land (bit - 1))let rec find_h h k shift = function| Empty -> None| Leaf (h', k', v) -> if h = h' && k = k' then Some v else None| Collision (h', kvs) -> if h = h' then List.assoc_opt k kvs else None| Node (bitmap, children) ->let bit = 1 lsl chunk h shift inif bitmap land bit = 0 then None else find_h h k (shift + bits) children.(index bitmap bit)let find k t = find_h (Hashtbl.hash k) k 0 t(* Two entries that fall in the same slot go into a new node one leveldown; once all 30 bits of the hash are used up, into a collision list. *)let rec merge shift ((h1, k1, v1) as e1) ((h2, k2, v2) as e2) =if shift >= 30 then Collision (h1, [ (k1, v1); (k2, v2) ])elselet c1 = chunk h1 shift and c2 = chunk h2 shift inif c1 = c2 then Node (1 lsl c1, [| merge (shift + bits) e1 e2 |])elselet l1 = Leaf (h1, k1, v1) and l2 = Leaf (h2, k2, v2) inNode ((1 lsl c1) lor (1 lsl c2), if c1 < c2 then [| l1; l2 |] else [| l2; l1 |])let rec add_h h k v shift t =match t with| Empty -> Leaf (h, k, v)| Leaf (h', k', _) when h = h' && k = k' -> Leaf (h, k, v)| Leaf (h', k', v') -> merge shift (h', k', v') (h, k, v)| Collision (h', kvs) -> Collision (h', (k, v) :: List.remove_assoc k kvs)| Node (bitmap, children) ->let bit = 1 lsl chunk h shift inlet i = index bitmap bit inif bitmap land bit <> 0 then begin(* Path copying: a new array with one child replaced. *)let a = Array.copy children ina.(i) <- add_h h k v (shift + bits) children.(i);Node (bitmap, a)end else beginlet n = Array.length children inlet a = Array.make (n + 1) (Leaf (h, k, v)) inArray.blit children 0 a 0 i;Array.blit children i a (i + 1) (n - i);Node (bitmap lor bit, a)endlet add k v t = add_h (Hashtbl.hash k) k v 0 tlet rec stats depth (nodes, slots, leaves, sum_depth, max_depth) = function| Empty -> (nodes, slots, leaves, sum_depth, max_depth)| Leaf _ -> (nodes, slots, leaves + 1, sum_depth + depth, max max_depth depth)| Collision (_, kvs) -> (nodes, slots, leaves + List.length kvs, sum_depth + depth * List.length kvs, max max_depth depth)| Node (_, a) -> Array.fold_left (fun acc c -> stats (depth + 1) acc c) (nodes + 1, slots + Array.length a, leaves, sum_depth, max_depth) a
Running it.
1000000 keys inserted, all found: true, absent key: None307474 internal nodes, 4.3 children per node on averagedepth of a key: average 4.64, maximum 6 (log32 n = 3.99)after an update: new -1, old 42bitmap with slots 3, 9, 20 occupied: index of slot 9 = 1, of slot 20 = 2
With a million keys, lookups visit on average 4.64 nodes and at most 6. The average is above because a key needs one more level whenever another key shares its chunks so far, and at the fourth level there are about as many slots as keys. Internal nodes near the root are full and those near the leaves hold two or three children, 4.3 on average; with full 32-entry arrays the same tree would use more than seven times as much space for child pointers.
Cost
For a hash of bits the depth is at most , so lookup, insertion and deletion take steps, constant for a fixed hash width, and on average when the hash is uniform. Each update allocates the copied path, at most small arrays. Compared with a balanced binary tree the depth is about five times smaller, and nodes are arrays, so a lookup touches fewer cache lines; compared with a hash table, a HAMT never needs to be resized all at once, and it is persistent.
Persistence and concurrency
Because updates never modify existing nodes, a HAMT can be shared between threads without locks, and a snapshot of a map is just a pointer to its root. Clojure made persistent HAMTs its default map and set in 2007, and used them together with atomic references to the root to build its concurrency model[3]. Prokopec, Bronson, Bagwell and Odersky's Ctrie is a mutable, lock-free concurrent HAMT that updates nodes with compare-and-swap and supports constant-time snapshots[5].
Implementations add a transient mode for bulk construction, in which nodes created by the current operation may be modified in place until the result is published, avoiding repeated path copying when building a map from many keys.
Variants
Steindorfer and Vinju's CHAMP (compressed hash-array mapped prefix tree) keeps two bitmaps per node, one for leaves stored inline and one for child nodes, and stores the two kinds at opposite ends of the array. This makes nodes smaller, iteration faster and structural equality cheaper, and guarantees a canonical shape after deletions, since a node reduced to a single leaf is always merged into its parent[4]. Scala's immutable HashMap has used CHAMP since version 2.13.
Bagwell's earlier work applied the bitmap-and-popcount idea to tries over the characters of string keys, as a space-efficient alternative to ternary search trees[2].
History
Bagwell described array mapped tries for string keys in 2000[2] and hash array mapped tries in "Ideal hash trees" in 2001[1]. Hickey adopted them as the persistent maps of Clojure, released in 2007[3], from where they spread to Scala, to Haskell's unordered-containers[9] and to immutable-collection libraries for JavaScript and other languages. Prokopec and his coauthors published the concurrent Ctrie in 2012[5], and Steindorfer and Vinju CHAMP in 2015[4].
see also
- TrieA trie, or prefix tree, is a tree for a set of strings in which each edge is labelled with a character and each key is the path from the root to a node; keys with a common prefix share the nodes of that prefix. Lookup and insertion take time proportional to the length of the key, independent of the number of keys, and all keys with a given prefix are found in one subtree, in sorted order. Radix and Patricia trees compress chains of single-child nodes, and the same idea applies to integers, read bit by bit.
- Finger treeA persistent sequence with amortized constant-time access at both ends, and concatenation and splitting in logarithmic time. The ends are kept in buffers of one to four elements, and the middle is a finger tree of 2-3 nodes, one level deeper at each step down the spine.
- Red-black treeA red-black tree is a binary search tree whose nodes are coloured red or black so that no red node has a red child and every path from the root to a leaf passes through the same number of black nodes. The two rules keep the height at most 2 log2(n + 1), so search, insertion and deletion take O(log n) time. Red-black trees are an encoding of 2-3-4 trees as binary trees, and Okasaki's functional version reduces insertion to one rebalancing rule with four cases.
- RopeA rope is a binary tree whose leaves are strings and whose internal nodes represent the concatenation of their children, with each node caching the length of its left subtree. Concatenation creates one node instead of copying, and indexing, splitting, insertion and deletion in the middle take time logarithmic in the length for a balanced rope. Ropes are immutable and share structure between versions, which makes them suited to text editors with undo and to programs that build long strings piece by piece.
further reading
- [1]P. Bagwell, “Ideal hash trees”, Technical Report, École Polytechnique Fédérale de Lausanne (2001).
- [2]P. Bagwell, “Fast and space efficient trie searches”, Technical Report, École Polytechnique Fédérale de Lausanne (2000).
- [3]R. Hickey, “A history of Clojure”, Proceedings of the ACM on Programming Languages 4, HOPL (2020).
- [4]M. J. Steindorfer, J. J. Vinju, “Optimizing hash-array mapped tries for fast and lean immutable JVM collections”, OOPSLA (2015).
- [5]A. Prokopec, N. G. Bronson, P. Bagwell, M. Odersky, “Concurrent tries with efficient non-blocking snapshots”, PPoPP (2012).
- [6]J. R. Driscoll, N. Sarnak, D. D. Sleator, R. E. Tarjan, “Making data structures persistent”, Journal of Computer and System Sciences 38 (1989).
- [7]H. S. Warren Jr., Hacker’s Delight, ch. 5, Addison-Wesley (2nd ed., 2012).
- [8]C. Okasaki, Purely Functional Data Structures, Cambridge University Press (1998).
- [9]J. Tibell, unordered-containers, Haskell library (2011).
last updated