Files
obsidian/Kotlin/Коллекции/List, Set, Map.md
T

16 KiB
Raw Blame History

title, description, tags
title description tags
List, Set, Map Три семейства коллекций, их гарантии, стоимость операций и выбор между ними
kotlin
collections
List Set Map
порядок по индексу вставки (у LinkedHashSet) вставки (у LinkedHashMap)
дубликаты разрешены запрещены ключи уникальны
доступ по индексу, O(1) только поиск, O(1) по ключу, O(1)
поиск элемента O(n) O(1) O(1) по ключу
основной вопрос «что по порядку» «есть ли такой» «что соответствует»

List

fun main() {
    val list = listOf("a", "b", "c", "b")

    println(list[1])                     // b — доступ по индексу
    println(list.getOrNull(10))          // null
    println(list.getOrElse(10) { "?" })  // ?
    println(list.indexOf("b"))           // 1 — первое вхождение
    println(list.lastIndexOf("b"))       // 3
    println(list.subList(1, 3))          // [b, c]
    println(list.size)                   // 4 — дубликат сохранён
}

Доступ к краям и срезы:

fun main() {
    val list = listOf(1, 2, 3, 4, 5)

    println("${list.first()} ${list.last()}")        // 1 5
    println(list.firstOrNull { it > 3 })             // 4
    println(list.take(2))                             // [1, 2]
    println(list.drop(2))                             // [3, 4, 5]
    println(list.takeLast(2))                         // [4, 5]
    println(list.slice(1..3))                         // [2, 3, 4]
    println(list.chunked(2))                          // [[1, 2], [3, 4], [5]]
    println(list.windowed(2))                         // [[1, 2], [2, 3], [3, 4], [4, 5]]
    println(list.reversed())                          // [5, 4, 3, 2, 1]
}

Изменение через MutableList:

fun main() {
    val list = mutableListOf(1, 2, 3)

    list.add(4)
    list.add(0, 0)                // по индексу
    list[1] = 10
    list.removeAt(0)
    list.remove(4)                // по значению
    println(list)                 // [10, 2, 3]

    list += 5                     // оператор += работает как add
    list -= 2
    println(list)                 // [10, 3, 5]

    list.sortDescending()         // сортировка на месте
    println(list)                 // [10, 5, 3]
}

Set

fun main() {
    val set = setOf(1, 2, 3)

    println(2 in set)                    // true — O(1)
    println(set.contains(5))             // false
    println(setOf(1, 2, 2, 3).size)      // 3 — дубликат отброшен
}

Операции над множествами:

fun main() {
    val a = setOf(1, 2, 3)
    val b = setOf(3, 4, 5)

    println(a union b)         // [1, 2, 3, 4, 5]
    println(a intersect b)     // [3]
    println(a subtract b)      // [1, 2]
    println(a + b)             // [1, 2, 3, 4, 5] — как union
    println(a - b)             // [1, 2]
}

Эти же функции работают на List, но результат всегда Set:

fun main() {
    println(listOf(1, 1, 2) union listOf(2, 3))    // [1, 2, 3]
    println(listOf(1, 1, 2).distinct())             // [1, 2] — а так останется List
}

Три реализации Set

fun main() {
    val linked = linkedSetOf(3, 1, 2)      // порядок вставки
    val hash = hashSetOf(3, 1, 2)          // порядок не определён
    val sorted = sortedSetOf(3, 1, 2)      // отсортирован

    println(linked)        // [3, 1, 2]
    println(sorted)        // [1, 2, 3]
    println(hash.size)     // 3
}
Реализация Порядок Поиск Когда
LinkedHashSet (setOf) вставки O(1) по умолчанию
HashSet (hashSetOf) не определён O(1) экономия памяти, порядок не нужен
TreeSet (sortedSetOf) сортировки O(log n) нужен отсортированный обход

Элементы Set обязаны корректно реализовывать equals и hashCode - иначе множество работает неправильно. Подробнее: equals, hashCode и toString

Map

fun main() {
    val map = mapOf("a" to 1, "b" to 2)

    println(map["a"])                      // 1
    println(map["z"])                      // null
    println(map.getOrDefault("z", 0))      // 0
    println(map.getOrElse("z") { -1 })     // -1
    println(map.getValue("a"))             // 1, бросает при отсутствии
    println("a" in map)                    // true — проверка ключа
    println(map.containsValue(2))          // true
    println("${map.keys} ${map.values}")   // [a, b] [1, 2]
}

to - инфиксная функция, создающая Pair:

fun main() {
    val pair: Pair<String, Int> = "a" to 1
    println("${pair.first} ${pair.second}")      // a 1

    val (k, v) = pair                             // деструктуризация
    println("$k=$v")                              // a=1

    println(listOf("a" to 1, "b" to 2).toMap())   // {a=1, b=2}
}

Изменение через MutableMap:

fun main() {
    val map = mutableMapOf("a" to 1)

    map["b"] = 2
    map.put("c", 3)
    map.putAll(mapOf("d" to 4))
    map.remove("a")
    println(map)                                  // {b=2, c=3, d=4}

    println(map.getOrPut("e") { 5 })              // 5 — вычислит и положит
    println(map.getOrPut("e") { 99 })             // 5 — уже есть, лямбда не вызвана
    map.merge("b", 10) { old, new -> old + new }  // метод JDK
    println(map)                                  // {b=12, c=3, d=4, e=5}
}

getOrPut - основной инструмент кеширования: одна операция вместо проверки и вставки.

Обход:

fun main() {
    val map = mapOf("a" to 1, "b" to 2)

    for ((key, value) in map) print("$key=$value ")     // a=1 b=2
    println()
    map.forEach { (key, value) -> print("$key$value ") } // a1 b2
    println()
    println(map.entries.first().key)                      // a
}

Деструктуризация в for и в лямбде - главная причина, почему обход Map в Kotlin короче, чем в Java.

Преобразования:

fun main() {
    val map = mapOf("a" to 1, "b" to 2, "c" to 3)

    println(map.filter { it.value > 1 })                  // {b=2, c=3}
    println(map.filterKeys { it != "a" })                 // {b=2, c=3}
    println(map.mapValues { it.value * 10 })              // {a=10, b=20, c=30}
    println(map.mapKeys { it.key.uppercase() })           // {A=1, B=2, C=3}
    println(map.map { "${it.key}${it.value}" })           // [a1, b2, c3] — это List
    println(map.toList())                                  // [(a, 1), (b, 2), (c, 3)]
    println(map.entries.sortedByDescending { it.value }.first().key)   // c
}

Три реализации Map

Реализация Порядок Доступ Когда
LinkedHashMap (mapOf) вставки O(1) по умолчанию
HashMap (hashMapOf) не определён O(1) экономия памяти
TreeMap (sortedMapOf) сортировки ключей O(log n) диапазонные запросы

Выбор структуры

graph TD
    A["Нужна коллекция"] --> B{"Пары ключ-значение?"}
    B -->|да| M["Map"]
    B -->|нет| C{"Дубликаты важны<br/>или нужен индекс?"}
    C -->|да| L["List"]
    C -->|нет| D{"Частый поиск<br/>элемента?"}
    D -->|да| S["Set"]
    D -->|нет| L

Практический критерий: если в коде есть list.contains(x) внутри цикла - это Set. Такая замена превращает O(n²) в O(n):

fun main() {
    val ids = List(1000) { it }
    val allowedList = List(1000) { it * 2 }
    val allowedSet = allowedList.toSet()

    println(ids.count { it in allowedList })    // O(n*m) — 1000*1000 сравнений
    println(ids.count { it in allowedSet })     // O(n) — то же 500, но быстро
}

Специальные коллекции

fun main() {
    val deque = ArrayDeque(listOf(2, 3))
    deque.addFirst(1)
    deque.addLast(4)
    println("$deque ${deque.removeFirst()} ${deque.removeLast()}")   // [1, 2, 3, 4] 1 4

    val grouped = listOf("яблоко", "арбуз", "банан").groupBy { it.first() }
    println(grouped)       // {я=[яблоко], а=[арбуз], б=[банан]}

    val assoc = listOf("a", "bb", "ccc").associateWith { it.length }
    println(assoc)         // {a=1, bb=2, ccc=3}
}

ArrayDeque в Kotlin - своя реализация (не java.util.ArrayDeque), работает как стек и как очередь. Подробнее об groupBy и associate: Операции

[!info] Отличия от Java

Java Kotlin
map.get(k) возвращает V map[k] возвращает V?
map.put(k, v) map[k] = v
new AbstractMap.SimpleEntry<>(k, v) k to v
for (Map.Entry<K,V> e : map.entrySet()) for ((k, v) in map)
map.computeIfAbsent(k, f) map.getOrPut(k) { }
Collections.unmodifiableList тип List
set.retainAll(other) a intersect b
list.get(i) list[i]

map[k] возвращает nullable - и это правильно. В Java map.get(k) имеет тип V, поэтому int x = map.get("нет") компилируется и даёт NPE при распаковке. Kotlin делает отсутствие ключа видимым в типе:

fun main() {
    val map = mapOf("a" to 1)
    val x: Int = map["z"] ?: 0        // компилятор требует обработать null
    println(x)                        // 0
}

Обратная сторона: map[k]!! появляется в коде часто. Когда ключ гарантированно есть, честнее getValue(k) - он бросит NoSuchElementException с внятным сообщением.

Деструктуризация Map.Entry. Java требует e.getKey()/e.getValue(); в Kotlin for ((k, v) in map) работает потому, что для Map.Entry объявлены функции-расширения component1 и component2. Подробнее: Деструктуризация

Операции над множествами как инфиксные функции. В Java union - это addAll на копии, intersect - retainAll, то есть операции мутирующие и требуют ручного копирования. В Kotlin a union b возвращает новое множество и читается как математическая запись.

Сравнить: HashMap в Java

[!warning] Подводные камни map[k] не различает «нет ключа» и «значение null»:

fun main() {
    val map = mapOf("a" to null, "b" to 1)
    println(map["a"])                 // null
    println(map["z"])                 // null — то же самое!
    println("a" in map)               // true
    println("z" in map)               // false — вот так различать
}

getValue бросает, get - нет. Это легко перепутать:

fun main() {
    val map = mapOf("a" to 1)
    println(map["z"])                              // null
    try {
        map.getValue("z")
    } catch (e: NoSuchElementException) {
        println("getValue бросает")                 // getValue бросает
    }
}

Изменяемый элемент в Set или ключ в Map ломает коллекцию:

data class Key(var id: Int)

fun main() {
    val key = Key(1)
    val set = mutableSetOf(key)
    println(key in set)        // true
    key.id = 2                  // hashCode изменился!
    println(key in set)        // false — элемент потерян
    println(set.size)          // 1 — но он всё ещё внутри
}

Ключи и элементы множеств обязаны быть неизменяемыми. data class с var в этой роли - ошибка.

subList - это вид на исходный список, а не копия:

fun main() {
    val list = mutableListOf(1, 2, 3, 4)
    val sub = list.subList(1, 3)
    list[1] = 99
    println(sub)              // [99, 3] — изменился
    list.add(5)
    try {
        println(sub)
    } catch (e: ConcurrentModificationException) {
        println("структурное изменение списка сломало subList")
    }
}

Для независимой копии нужен slice или toList().

remove на MutableList<Int> неоднозначен:

fun main() {
    val list = mutableListOf(10, 20, 30)
    list.removeAt(1)                  // по индексу
    println(list)                     // [10, 30]
    list.remove(10)                   // по значению
    println(list)                     // [30]
}

В Kotlin, в отличие от Java, перегрузки по индексу у remove нет - только removeAt. Это как раз устраняет классическую Java-ловушку list.remove(1).

list.contains(x) в цикле - это O(n²). Самая частая проблема производительности с коллекциями. Если проверка повторяется, предварительно постройте Set.

hashSetOf и hashMapOf не сохраняют порядок - вывод может отличаться между запусками и версиями JDK. Тест, завязанный на порядок, будет нестабильным.

sortedSetOf требует Comparable или компаратор и бросит ClassCastException при вставке несравнимого элемента - в рантайме, а не при компиляции.

+= на List работает, но создаёт новую коллекцию:

fun main() {
    var list = listOf(1, 2)           // var!
    list += 3                          // новый список, не add
    println(list)                      // [1, 2, 3]

    val mutable = mutableListOf(1, 2)
    mutable += 3                       // здесь настоящий add
    println(mutable)                   // [1, 2, 3]
}

На val list: List<Int> оператор += не скомпилируется. В цикле по большому списку += на read-only коллекции даёт O(n²) копирований.

Далее: Изменяемость