В чому різниця між HashMap, LinkedHashMap та TreeMap
Класи HashMap, LinkedHashMap та TreeMap — три фундаментальні реалізації інтерфейсу Map у Java. Їхні ключові відмінності полягають у внутрішній будові, порядку обходу елементів т...
🟢 Junior Level
1. Порівняльна таблиця характеристик
Класи HashMap, LinkedHashMap та TreeMap — три фундаментальні реалізації інтерфейсу Map у Java. Їхні ключові відмінності полягають у внутрішній будові, порядку обходу елементів та швидкості операцій:
| Характеристика | HashMap |
LinkedHashMap |
TreeMap |
|---|---|---|---|
| Порядок елементів | Хаотичний (залежить від хеш-кодів) | Порядок вставки (або порядок доступу) | Відсортований (Natural або Comparator) |
| Внутрішня структура | Хеш-таблиця (масив + списки/дерева) | Хеш-таблиця + двозв’язний список | Червоно-чорне дерево пошуку |
Складність get / put |
$O(1)$ у середньому | $O(1)$ у середньому | $O(\log N)$ |
| Складність ітерації | $O(\text{capacity} + N)$ | $O(N)$ | $O(N)$ |
Ключ null |
✅ Дозволено (один) | ✅ Дозволено (один) | ❌ Заборонено (NullPointerException) |
| Інтерфейси | Map |
Map, SequencedMap (Java 21+) |
Map, NavigableMap, SequencedMap |
| Основне призначення | Універсальний швидкий словник | Порядок вставки, LRU-кешування | Сортування за ключами, діапазонний пошук |
2. Різниця в порядку обходу (Code Demo)
// 1. HashMap: порядок не гарантований і може змінюватися при ресайзі
Map<String, Integer> hashMap = new HashMap<>();
hashMap.put("Z", 1); hashMap.put("A", 2); hashMap.put("M", 3);
System.out.println(hashMap.keySet()); // Наприклад: [A, Z, M] (залежить від хешу)
// 2. LinkedHashMap: строгий порядок вставки
Map<String, Integer> linkedMap = new LinkedHashMap<>();
linkedMap.put("Z", 1); linkedMap.put("A", 2); linkedMap.put("M", 3);
System.out.println(linkedMap.keySet()); // ЗАВЖДИ: [Z, A, M]
// 3. TreeMap: природний алфавітний порядок
Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("Z", 1); treeMap.put("A", 2); treeMap.put("M", 3);
System.out.println(treeMap.keySet()); // ЗАВЖДИ: [A, M, Z]
🟡 Middle Level
1. Архітектура структур даних під капотом
HashMap:
table[] ──► [0] ──► Node(k,v) ──► Node(k,v) (зв'язний список або TreeNode)
[1] ──► null
[2] ──► Node(k,v)
LinkedHashMap:
table[] ──► [0] ──► Entry(k,v) ──► Entry(k,v) (хеш-таблиця для пошуку за O(1))
│ ▲
▼ │ before / after посилання
head ─────────────► [Entry1] ◄────► [Entry2] ◄────► [Entry3] ◄─── tail (двозв'язний список)
TreeMap:
[ Root: Entry(k,v) ]
/ \
[ Left: Entry ] [ Right: Entry ] (Червоно-чорне дерево, O(log N))
HashMapрозраховує індекс бакета за формулою(capacity - 1) & hash(key). При колізіях формується однозв’язний список, що трансформується в червоно-чорне дерево при $\ge 8$ елементах у бакеті та ємності $\ge 64$.LinkedHashMapуспадковуєHashMap, проте розширює клас вузла:LinkedHashMap.Entry<K,V>містить додаткові посиланняbeforeіafter. При кожній вставці новий елемент підв’язується в загальний наскрізний двозв’язний список.TreeMapвзагалі не використовує хешування. Вузли зв’язуються через покажчикиleft,right,parentі балансуються за правилами червоно-чорного дерева.
2. Парадокс швидкості ітерації: HashMap проти LinkedHashMap
Хоча LinkedHashMap витрачає трохи більше часу на put() через оновлення покажчиків двозв’язного списку, ітерація по LinkedHashMap нерідко виконується швидше, ніж по HashMap:
- У
HashMapітератор змушений послідовно перевіряти абсолютно всі комірки масивуtable[]розміромcapacity. Якщо ви створили мапу зcapacity = 1_000_000і поклали туди лише 50 елементів,HashMapперевірить 1 мільйон порожніх комірок пам’яті ($O(\text{capacity} + N)$). - У
LinkedHashMapітератор рухається безпосередньо відheadдоtailза посиланнямиafter, виконуючи рівно $N$ переходів ($O(N)$) та повністю ігноруючи розмір масиву бакетів.
3. Режими впорядкування LinkedHashMap та LRU-кеш
LinkedHashMap має спеціальний конструктор із прапорцем accessOrder:
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
accessOrder = false(за замовчуванням): елементи обходяться в порядку додавання (insertion-order).accessOrder = true: елементи перебудовуються в порядку останнього звернення (access-order). Будь-який викликget()абоput()переміщує задіяний вузол у самий кінець (хвіст) списку.
Це дозволяє елегантно реалізувати LRU (Least Recently Used) кеш у кілька рядків:
Map<String, byte[]> lruCache = new LinkedHashMap<String, byte[]>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
return size() > 1000; // Видаляє найстаріший невикористовуваний елемент при > 1000 записів
}
};
🔴 Senior Level
1. Детальне порівняння витрат пам’яті (Memory Footprint)
На 64-бітній JVM з увімкненим Compressed OOPs (-XX:+UseCompressedOops):
HashMap$Node<K,V>:- Заголовок (12 байтів) +
hash(4 байти) + 3 посилання (key,value,nextпо 4 байти = 12 байтів) + padding (4 байти) = 32 байти.
- Заголовок (12 байтів) +
LinkedHashMap$Entry<K,V>:- Розширює
HashMap$Node, додаючи 2 посилання (before,afterпо 4 байти = 8 байтів). - $32 + 8 =$ 40 байтів.
- Розширює
TreeMap$Entry<K,V>:- Заголовок (12 байтів) + 5 посилань (
key,value,left,right,parentпо 4 байти = 20 байтів) +boolean color(1 байт) + padding (7 байтів) = 40 байтів.
- Заголовок (12 байтів) + 5 посилань (
| Метрика | HashMap |
LinkedHashMap |
TreeMap |
|---|---|---|---|
| Розмір ноди (Compressed OOPs) | 32 байти | 40 байтів | 40 байтів |
Масив бакетів (table[]) |
Так (4 байти на слот) | Так (4 байти на слот) | Немає (лише корінь) |
| Локальність кешу процесора | Середня | Низька (двозв’язний список) | Дуже низька (дерево по всій купі) |
| Оверхед на 1 000 000 записів | $\approx 36$ МБ | $\approx 44$ МБ | $\approx 40$ МБ |
2. Специфіка Java 21: SequencedMap
Інтерфейс SequencedMap об’єднав колекції з визначеним порядком обходу:
SequencedMap<String, Integer> seqMap = new LinkedHashMap<>();
seqMap.putFirst("FIRST", 1);
seqMap.putLast("LAST", 99);
// Безкоштовний розворот обходу за O(1) (Live View):
SequencedMap<String, Integer> reversed = seqMap.reversed();
Критична різниця між LinkedHashMap та TreeMap у SequencedMap:
- У
LinkedHashMapметодиputFirst()таputLast()дозволені й переміщують елемент на початок або кінець списку. - У
TreeMapметодиputFirst()таputLast()завжди викидаютьUnsupportedOperationException, оскільки позиція ноди в дереві строго диктується результатом компаратора!
3. Пастка рівності: hashCode/equals проти compareTo
HashMapтаLinkedHashMapвизначають рівність ключів за умовоюk1.hashCode() == k2.hashCode() && (k1 == k2 || k1.equals(k2)).TreeMapвзагалі не викликаєhashCode()таequals(). Він спирається виключно наcompare(k1, k2) == 0.
Якщо в об’єктів логіка compareTo неузгоджена з equals (як у BigDecimal("1.0") та BigDecimal("1.00")), поведінка мап кардинально відрізнятиметься:
- У
HashMapзбережуться обидва записи. - У
TreeMapдругий запис перезапише значення першого, оскільки для дерева ключі вважаються еквівалентними.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«
HashMap— це хеш-таблиця з амортизованим часом $O(1)$ для пошуку та вставки, що не гарантує порядку обходу.LinkedHashMapрозширюєHashMap, додаючи наскрізний двозв’язний список між вузлами, що зберігає порядок вставки або порядок доступу ($O(1)$) та дозволяє побудувати LRU-кеш.TreeMapбазується на червоно-чорному дереві, гарантує сортування ключів із логарифмічною складністю $O(\log N)$ і реалізуєNavigableMap, проте забороняєnull-ключі та визначає еквівалентність ключів строго заcompareTo() == 0, ігноруючиequals()».
3 каверзних питання на інтерв’ю з відповідями
1. Чому в багатопотоковому коді читання cache.get(key) із LinkedHashMap з accessOrder = true може призвести до пошкодження даних або зациклення?
Відповідь: При accessOrder = true операція get() не є безпечним читанням: вона перелінковує вузол у хвіст двозв’язного списку (before та after посилання), змінюючи внутрішній стан структури та інкрементуючи modCount. При паралельних викликах get() без блокувань покажчики списку можуть зациклитися, що призведе до нескінченного циклу ядра процесора (100% CPU) або викидання ConcurrentModificationException.
2. В якому випадку обхід LinkedHashMap працюватиме в рази швидше за обхід HashMap при однаковій кількості елементів?
Відповідь: У випадку так званої «розрідженої» мапи (sparse map), коли початкова місткість (capacity) була ініціалізована значно більшою за число реально збережених елементів (наприклад, capacity 1 000 000, а елементів лише 1 000). Ітератор HashMap змушений послідовно перевірити всі 1 000 000 комірок масиву бакетів ($O(\text{capacity} + N)$), спричиняючи тисячі Cache Misses, тоді як LinkedHashMap просто пройде за ланцюжком нод рівно за 1 000 кроків ($O(N)$).
3. Чому TreeMap забороняє збереження ключа null, тоді як HashMap його дозволяє?
Відповідь: HashMap обробляє null спеціальним чином: якщо ключ null, хеш примусово прирівнюється до 0, і запис завжди потрапляє в нульовий бакет. У TreeMap при вставці кожного ключа викликається метод compareTo(k) або comparator.compare(k1, k2). Оскільки на об’єкті null неможливо викликати метод compareTo(), JVM викидає NullPointerException. (Винятком є використання спеціального компаратора, що явно обробляє null, наприклад Comparator.nullsFirst(...)).
Типові помилки та Red Flags
- ❌ Стверджувати, що
TreeMapшвидший заHashMap:TreeMapпрацює за $O(\log N)$ і має гіршу локальність кешу;HashMapпрацює за $O(1)$ і в середньому значно швидший. - ❌ Спроба використовувати
LinkedHashMapдля сортування ключів:LinkedHashMapзберігає порядок вставки або доступу, але не вміє сортувати за алфавітом чи значенням ключів — для цього потрібенTreeMap. - ❌ Забувати, що
TreeMapігноруєequals(): НеузгодженістьcompareToтаequalsпризводить до перезапису або зникнення бізнес-об’єктів. - ❌ Використання
TreeMap.putFirst()у Java 21: ВикинеUnsupportedOperationException, оскільки порядок у дереві визначається виключно самими ключами.