Hierarchy, Lists, Sets, Maps, Arrays, Sequences, and the full operation toolkit
Choosing the right collection is one of the most consequential micro-decisions an Android engineer makes. The wrong choice silently degrades performance — an O(N) lookup inside onBindViewHolder drops frames; an unnecessary intermediate list allocates megabytes. This chapter covers Kotlin’s collection framework end to end: the type hierarchy, every major collection with its internal structure and complexity, lazy Sequences, and the complete operation toolkit you are expected to know fluently in interviews and on the job.
The Collection Hierarchy
Kotlin’s collection framework is built on a hierarchy whose defining feature is mutability separation at the type level. Read-only and mutable interfaces are distinct types. A function that accepts a **List
The type hierarchy
1
2
3
4
5
6
7
8
9
Iterable
Collection (read-only)
List<out E> -- ordered, indexed, allows duplicates
Set<out E> -- unordered, no duplicates
(Map is NOT a Collection -- it is a separate hierarchy)
MutableCollection
MutableList<E>
MutableSet<E>
(MutableMap is separate too)
Warning:
Read-only is not the same as immutable. A **List
** reference simply does not *expose* mutation methods — but the underlying object may still be a MutableList. If you hold a List reference to a MutableList and cast it back, you can mutate it at runtime. To get true immutability guarantees, never leak a mutable reference, and expose only the read-only interface from your public API.
Read-only vs immutable — a subtle but important distinction
1
2
3
4
5
6
7
val readOnly: List<String> = mutableListOf("A", "B")
// readOnly.add("C") // COMPILE ERROR -- List has no add()
// But the underlying object IS mutable:
val backdoor = readOnly as MutableList<String>
backdoor.add("C") // works at runtime!
println(readOnly) // [A, B, C] -- the 'read-only' view changed
Lists — Ordered Collections
Lists are ordered, indexed collections that allow duplicates. Kotlin offers two backing implementations with very different performance characteristics: ArrayList and LinkedList. Understanding which is which is a perennial interview question.
| Type | Mutability | Backed By | Variance |
|---|---|---|---|
| List |
Read-only | Wrapper over an array | Covariant |
| MutableList |
Mutable | ArrayList (default) | Invariant |
| ArrayList |
Mutable | Dynamic array | Invariant |
| LinkedList |
Mutable | Doubly linked list | Invariant |
Creating lists
1
2
3
4
5
6
7
8
9
// Immutable (read-only) -- preferred default
val fruits = listOf("Apple", "Banana", "Cherry")
// Mutable
val numbers = mutableListOf(1, 2, 3)
numbers.add(4)
// ArrayList explicitly (rarely needed -- mutableListOf returns one)
val arrayList = ArrayList<String>()
ArrayList — fast random access, slow mid-list insertion
ArrayList is backed by a dynamic array that grows automatically as elements are added. On the JVM, Kotlin’s ArrayList is literally a typealias for java.util.ArrayList. Its defining strength is O(1) random access by index: the address of element i is computed arithmetically as base_address + i × element_size, requiring no traversal. Its weakness is mid-list insertion and removal, which is O(n) because every subsequent element must be shifted to fill or open the gap. Detecting an element by value (contains, indexOf) is also O(n) because there is no index to jump to.
ArrayList complexity
1
2
3
4
5
Access by index O(1) -- base + index * element_size
Add at end O(1) amort. -- occasional resize amortised away
Insert at position O(n) -- shifts all following elements
Remove at position O(n) -- shifts all following elements
Search (contains) O(n) -- linear scan, no index available
LinkedList — slow random access, fast end insertion
LinkedList is backed by a doubly linked list: a chain of nodes where each node holds three things — the element (the data), a reference to the next node, and a reference to the previous node. This structure inverts ArrayList’s trade-offs. Insertion and removal at the beginning or end is O(1) — only a couple of pointer references change, with no shifting. But random access by index is O(n): there is no address arithmetic, so to reach element i the list must be traversed node by node from one end.
LinkedList node structure (conceptual)
1
2
3
4
5
6
7
8
9
Node {
data: E // the element value
next: Node? // reference to the following node
prev: Node? // reference to the preceding node
}
// [prev|A|next] <-> [prev|B|next] <-> [prev|C|next]
// Inserting at the head: create node, fix two pointers -- O(1)
// Getting element at index 500: walk 500 nodes -- O(n)
| Operation | ArrayList | LinkedList | Why the difference |
|---|---|---|---|
| get(index) | O(1) | O(n) | Array does address arithmetic; list must traverse |
| add at end | O(1) amort. | O(1) | ArrayList may resize; list updates the tail pointer |
| insert at start | O(n) | O(1) | ArrayList shifts everything; list fixes 2 pointers |
| remove by index | O(n) | O(n)* | Both must locate it; ArrayList then shifts |
| contains(value) | O(n) | O(n) | Neither has an index into values; both scan |
- LinkedList insertion/removal is O(1) only when you already hold a reference to the target node. Finding the position first is O(n). In practice on Android, ArrayList is almost always the right default — cache locality and O(1) indexing dominate real workloads.
Sets — Unique Elements
Sets enforce uniqueness: each element appears at most once. Membership is decided by equals() and hashCode() — which is why data classes (with auto-generated equals/hashCode) work so well as set elements. The headline benefit of a hash-based set is O(1) membership testing, compared to O(n) for a List.
| Type | Order | Backed By | Lookup |
|---|---|---|---|
| hashSetOf() | None | Hash table | O(1) average |
| linkedSetOf() | Insertion order | Hash table + linked list | O(1) average |
| TreeSet (java.util) | Sorted | Red-Black tree | O(log n) |
Set behaviour
1
2
3
4
5
6
7
8
9
val set = mutableSetOf("A", "B", "A")
println(set) // [A, B] -- duplicate silently dropped
println(set.size) // 2
val ordered = linkedSetOf(3, 1, 2)
println(ordered) // [3, 1, 2] -- insertion order preserved
val sorted = java.util.TreeSet(listOf(3, 1, 2))
println(sorted) // [1, 2, 3] -- automatically sorted
Interview Tip:
The classic optimisation: if you repeatedly check membership against a large list, convert it to a HashSet once up front. list.contains(x) is O(n) per call; set.contains(x) is O(1). Inside a RecyclerView bind callback, that is the difference between smooth scrolling and dropped frames.
Maps — Key-Value Pairs
Maps store associations between unique keys and values; each key maps to exactly one value. Maps are not Collections — they live in a separate part of the hierarchy — but they share the same hash-based performance story.
| Type | Order | Null Keys | Lookup |
|---|---|---|---|
| HashMap | None | One null key allowed | O(1) average |
| LinkedHashMap | Insertion order | One null key allowed | O(1) average |
| TreeMap (java.util) | Sorted by key | Not allowed (NPE) | O(log n) |
How HashMap permits exactly one null key
A common interview curiosity: HashMap allows a single null key, even though calling null.hashCode() would throw. This works because the hash function special-cases null and assigns it hash 0, while TreeMap (which must call compareTo on keys to sort them) cannot, and throws a NullPointerException.
The HashMap hash function (Java implementation)
1
2
3
4
5
6
7
8
9
10
11
static final int hash(Object key) {
int h;
// null -> hash 0; otherwise spread the bits to reduce collisions
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
val map = mutableMapOf<String?, Int>()
map[null] = 42 // OK -- the single null key, hashed to bucket 0
map["key"] = 100
println(map[null]) // 42
println(map.size) // 2