1. Контейнер «ключ → значення»
Якщо у std::vector усе влаштовано за принципом «елементи лежать підряд, і кожен має індекс», то в реальних задачах часто потрібно інше: знайти не «елемент № 7», а «користувача з id=42», «налаштування з імʼям "theme"», «товар за артикулом» або «лічильник для слова "apple"». І зробити це хочеться швидко, без перегляду всього списку щоразу.
Уявіть, що ви ведете невеликий консольний застосунок TaskBook — наш навчальний мініпроєкт. Ви зберігаєте задачі, у кожної є id і текст. Якщо задач лише 10, їх іще можна шукати лінійно. А коли їх 10 000, лінійний пошук перетворюється на обовʼязкову ранкову зарядку для процесора. Він, звісно, радий, але користувач — не завжди.
Ідея асоціативних контейнерів у C++ проста: ми зберігаємо дані так, щоб швидко відповідати на запитання «чи є такий ключ?» і «яке значення відповідає цьому ключу?».
Модель: «словник», де елемент — пара
Перш ніж порівнювати map і unordered_map, важливо домовитися про базову модель. В обох контейнерах ми зберігаємо пари: ключ і значення. Ключ — це те, за чим ми шукаємо, а значення — те, що отримуємо.
Наприклад: «імʼя → вік», «id → задача», «слово → кількість», «код_помилки → текст». У памʼяті це приблизно виглядає як набір записів такого виду:
key1 -> value1
key2 -> value2
...
У C++ всередині це зазвичай зберігається як std::pair<const Key, Value>. Не потрібно завчати це як заклинання, але корисно памʼятати: під час обходу контейнера ми отримуємо і ключ, і значення. Тому тут особливо зручні structured bindings: менше стрілочок first/second, більше людської мови.
2. std::map: впорядкований словник
std::map<Key, Value> — це асоціативний контейнер, який зберігає елементи у порядку ключів. З погляду поведінки він схожий на телефонну книгу: ви можете швидко знайти запис за імʼям, а коли «гортаєте» книгу, імена йдуть за абеткою.
Під капотом map зазвичай реалізовано як збалансоване дерево (часто згадують червоно-чорне дерево). Зараз вам не потрібно вміти малювати його на співбесіді, але корисно розуміти наслідки: операції пошуку, вставки й видалення мають складність O(log N), оскільки на кожному кроці дерево звужує область пошуку.
Мінікартинка: інтуїтивно
Якщо дуже спрощено, дерево схоже на «опитувальник»:
- ключ менший за поточний — ідемо ліворуч
- більший — ідемо праворуч
- збігся — знайшли
І так кілька кроків, доки не знайдемо потрібний ключ або не впремося в «порожньо».
Коли map справді зручний
map обирають, коли:
- важливий відсортований обхід за ключами (наприклад, друкуємо звіт «за зростанням id»);
- потрібні операції на кшталт «знайти найближчий ключ» (у дусі нижня межа/верхня межа);
- ви хочете, щоб порядок був стабільною частиною логіки, а не «як пощастило сьогодні».
4. std::unordered_map: словник без порядку
std::unordered_map<Key, Value> розвʼязує ту саму задачу «ключ → значення», але робить це інакше: замість дерева він зазвичай використовує хеш-таблицю. У стандарті це сімейство контейнерів так і називається — unordered associative containers.
Головна ідея така: ми беремо ключ, обчислюємо для нього число (хеш) і за цим числом приблизно визначаємо, де шукати. Якщо все складається вдало, пошук, вставка й видалення в середньому працюють за O(1) (тобто це «майже константа», яка не залежить лінійно від кількості елементів). На практиці це часто означає просто «дуже швидко».
Мінікартинка: інтуїтивно
Хеш-таблицю можна уявити як масив «кошиків» (buckets):
- ключ перетворюється на число (хеш)
- за ним вибирається кошик
- усередині кошика уточнюємо результат (на випадок колізій)
Колізії — це ситуація, коли різні ключі потрапляють в один кошик. Це нормально: контейнер однаково перевірить рівність ключів і знайде правильний.
Коли unordered_map справді зручний
unordered_map обирають, коли:
- потрібен швидкий доступ за ключем і порядок не важливий;
- ви робите лічильники, індекси або таблиці відповідностей;
- вам важлива швидкість на великих обсягах даних.
5. Порівняння map і unordered_map: таблиця
Щоб не намагатися тримати все це в оперативній памʼяті одночасно, зведімо відмінності в таблицю:
| Характеристика | |
|
|---|---|---|
| Внутрішня ідея | дерево (впорядкована структура) | хеш-таблиця (кошики за хешем) |
| Порядок обходу | завжди за ключами (від меншого до більшого) | не визначений, тобто це не порядок «за ключем» |
| Пошук/вставка/видалення | зазвичай O(log N) | зазвичай O(1) у середньому |
| Коли обирати | потрібен порядок ключів, «гарний» відсортований вивід | потрібен швидкий доступ, а порядок не важливий |
6. Практика: операції та поведінка
Тепер приємна частина: користуватися map і unordered_map дуже схоже. Так зроблено навмисно, щоб вам не доводилося страждати й переписувати пів проєкту, коли захочеться замінити один контейнер на інший. Але є нюанс: однаковий синтаксис інколи породжує різні очікування, особливо навколо operator[].
Нижче — операції, які ви використовуватимете найчастіше: find, contains, insert/emplace, erase, operator[].
Обхід: map виводить у порядку ключів
Зробімо невеликий приклад «імʼя → вік». Зверніть увагу на обхід: імена будуть відсортовані (зазвичай лексикографічно, тобто як у словнику).
#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> ages;
ages.emplace("Bob", 25);
ages.emplace("Ann", 20);
for (const auto& [name, age] : ages) {
std::cout << name << " -> " << age << '\n';
// Ann -> 20
// Bob -> 25
}
}
Обхід: unordered_map — швидко, але без гарантії порядку
Той самий приклад, але з unordered_map. Важливий момент: вивід може бути в будь-якому порядку. Не намагайтеся «впіймати закономірність» — це як намагатися передбачити, де опиниться шкарпетка після прання. Іноді здається, що порядок є. А потім раптом виявляється, що його немає.
#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> ages;
ages.emplace("Bob", 25);
ages.emplace("Ann", 20);
for (const auto& [name, age] : ages) {
std::cout << name << " -> " << age << '\n';
// Порядок НЕ гарантовано
}
}
find: безпечне читання без побічних ефектів
Коли ви хочете прочитати значення за ключем і водночас не змінювати контейнер, найнадійніший шлях — find.
Зазвичай це роблять так:
- auto it = m.find(key);
- якщо it == m.end() — ключа немає
- інакше it->second — це значення
#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> score{{"Ann", 10}, {"Bob", 12}};
if (auto it = score.find("Eve"); it != score.end()) {
std::cout << it->second << '\n';
} else {
std::cout << "немає такого ключа\n"; // немає такого ключа
}
}
Чому це важливо: find не створює нових елементів. Він лише шукає.
contains: коли потрібно просто «є/немає»
У C++20 асоціативні контейнери мають contains(key) — це коротка форма перевірки. Повертає true/false. Для початківців це часто читається простіше, ніж find, особливо коли саме значення вам не потрібне.
#include <iostream>
#include <map>
int main() {
std::map<int, int> m{{1, 100}, {2, 200}};
std::cout << m.contains(2) << '\n'; // 1
std::cout << m.contains(3) << '\n'; // 0
}
Якщо вам потрібне значення — беріть find. Якщо потрібно лише відповісти на запитання «чи існує ключ», contains зазвичай ідеальний.
operator[]: дуже зручно… і тому небезпечно
Ось головний «мем» (у доброму сенсі) про map і unordered_map.
m[key] — це операція «отримати або створити»:
- якщо ключ вже є — ви отримуєте посилання на значення;
- якщо ключа немає — він створюється, а значення стає «за замовчуванням».
Через це operator[] чудово підходить для лічильників, але погано — для сценарію «просто перевірити наявність».
Приклад: лічильник слів — operator[] чудовий
#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> cnt;
cnt["apple"] += 1; // було 0 (створилося), стало 1
cnt["apple"] += 1; // стало 2
std::cout << cnt["apple"] << '\n'; // 2
}
Приклад: «просто прочитаю, що там» — і раптово змінив контейнер
#include <iostream>
#include <unordered_map>
int main() {
std::unordered_map<int, int> m{{1, 10}};
std::cout << m[2] << '\n'; // 0 (і ключ 2 тепер існує!)
std::cout << m.size() << '\n'; // 2
}
Саме така зміна розміру — типова причина запитання «чому в мене в контейнері раптом зʼявилися зайві ключі?».
insert/emplace: вставка без перезапису та результат операції
Коли ви вставляєте елемент у map/unordered_map, часто хочеться знати: «вставилося чи ключ уже був?». І контейнер чесно повертає результат: std::pair<iterator, bool>.
bool показує, чи справді відбулася вставка.
#include <iostream>
#include <unordered_map>
int main() {
std::unordered_map<int, int> m;
auto [it1, ok1] = m.emplace(1, 10);
auto [it2, ok2] = m.emplace(1, 99);
std::cout << ok1 << ' ' << ok2 << '\n'; // 1 0
std::cout << m[1] << '\n'; // 10
}
Зверніть увагу: другий emplace не перезаписав значення, тому що ключ уже існував.
erase: видалення за ключем і «скільки реально видалили»
erase(key) повертає кількість видалених елементів. Для map/unordered_map це зазвичай 0 або 1, тому що ключі унікальні.
#include <iostream>
#include <unordered_map>
int main() {
std::unordered_map<int, int> m{{1, 10}, {2, 20}};
std::size_t removed = m.erase(2);
std::cout << removed << '\n'; // 1
}
Це зручно: можна не робити окрему перевірку через contains, якщо вам важливо лише зрозуміти, чи було що видаляти.
7. Як «дерево» відрізняється від «хеш-таблиці»: схема на пальцях
Іноді допомагає візуалізація, особливо якщо ви поки що не любите абстракції. І це нормально: самі собою вони не надто «тактильні».
flowchart LR
A[Ключ] --> B{map: порівняння}
B --> C[вліво/вправо по дереву]
C --> D[знайшли значення]
A --> E{unordered_map: хеш}
E --> F[номер кошика]
F --> G[пошук у кошику]
G --> D
У map головний інструмент — порівняння ключів і логарифмічна навігація структурою. У unordered_map головний інструмент — хешування й потрапляння «приблизно в потрібне місце».
8. TaskBook: швидкий доступ до задачі за id
Тепер привʼяжімо це до нашого навчального застосунку. Припустімо, що маємо модель:
#include <string>
struct Task {
int id{};
std::string text;
};
Раніше ми могли зберігати задачі у std::vector<Task> tasks; і шукати їх за id лінійно.
Лінійний пошук: працює, але може бути повільним
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> ids{10, 20, 30};
int want = 20;
auto it = std::find(ids.begin(), ids.end(), want);
std::cout << (it != ids.end()) << '\n'; // 1
}
Для задач усе буде приблизно так само, тільки з find_if і лямбдою — ви це вже зустрічали.
Індекс id → Task на unordered_map
Якщо наша типова операція — «за id швидко знайти задачу», логічніше зберігати індекс:
#include <iostream>
#include <string>
#include <unordered_map>
struct Task {
int id{};
std::string text;
};
int main() {
std::unordered_map<int, Task> by_id;
by_id.emplace(1, Task{1, "Почитати книгу з C++"});
by_id.emplace(2, Task{2, "Виправити баги (або створити нові)"});
std::cout << by_id[2].text << '\n'; // Виправити баги (або створити нові)
}
Тут я навмисно використав operator[] для читання, щоб ви відчули «слизьке місце»: це безпечно лише тоді, коли ви впевнені, що ключ існує. Інакше ви випадково створите порожню задачу. Тому в реальному коді значення частіше читають через find.
Безпечне читання задачі за id: через find
#include <iostream>
#include <string>
#include <unordered_map>
struct Task {
int id{};
std::string text;
};
int main() {
std::unordered_map<int, Task> by_id{{1, {1, "Читати"}}, {2, {2, "Писати"}}};
int id = 3;
if (auto it = by_id.find(id); it != by_id.end()) {
std::cout << it->second.text << '\n';
} else {
std::cout << "Немає задачі з id=" << id << '\n'; // Немає задачі з id=3
}
}
9. Що обрати: map чи unordered_map?
Зробімо чесне прикладне правило — без містики.
Якщо вам потрібен упорядкований вивід і ви хочете, щоб контейнер сам тримав усе «за ключами», беріть map. Наприклад, якщо ви завжди друкуєте задачі за id, користувачеві так буде просто зручніше.
Якщо вам потрібен швидкий доступ і ви не хочете платити log N за кожне звернення, а порядок не важливий, беріть unordered_map.
Якщо ви вагаєтеся, то на практиці часто починають з unordered_map, тому що це «словник за замовчуванням» для швидкого доступу. А коли раптом зʼявляється вимога «виводити за ключем у відсортованому порядку», переходять на map (або сортують окремо, але це вже інша історія).
І ще невелика формальність: у unordered_map є вимоги до ключа (хеш і рівність), а у map — до порядку (порівняння). Ми детально розберемо ці «контракти ключів» у наступних лекціях цього дня; у матеріалах стандарту для unordered-контейнерів для них навіть виділяють окремі вимоги.
10. Типові помилки під час вибору та використання map/unordered_map
Помилка № 1: очікувати від unordered_map «порядку під час обходу».
Новачок створює unordered_map, наповнює його даними, друкує, бачить «майже відсортовано» й починає покладатися на цей порядок. Потім додається ще один елемент, змінюється компілятор, режим збирання або просто фаза місяця — і порядок уже інший. На порядок обходу unordered_map покладатися не можна: якщо він важливий, обирайте map.
Помилка № 2: використовувати operator[] для перевірки наявності ключа.
Класика: пишуть if (m[key] == ...) або просто m[key], щоб «подивитися, чи є». У підсумку ключ створюється зі значенням за замовчуванням, контейнер змінюється, зʼявляються «порожні» записи. Для перевірки використовуйте contains або find, а operator[] залишайте для сценаріїв «створити/оновити».
Помилка № 3: розіменувати результат find без перевірки на end().
Після auto it = m.find(key); не можна одразу звертатися до it->second, тому що it може виявитися рівним m.end(). У найкращому разі ви отримаєте падіння, у найгіршому — дивну поведінку. Корисно виробити простий ритуал: спочатку перевірка, потім розіменування.
Помилка № 4: обирати контейнер «за смаком», а не за вимогами задачі.
Іноді map беруть «бо звучить солідно», а unordered_map — «бо швидше». Правильніше починати із запитань: чи важливий порядок, чи потрібні часті пошуки, чи буде багато елементів. Якщо порядок важливий, map розвʼяже задачу простіше. Якщо порядок не важливий, але важлива швидкість, unordered_map зазвичай буде природнішим вибором.
Помилка № 5: зберігати дані так, що ключі «дублюють» сенс структури.
Наприклад, ви робите unordered_map<int, Task>, де всередині Task ще є поле id, а потім починаєте оновлювати одне, забуваючи про інше. Це не завжди помилка (іноді id в обʼєкті справді зручний), але це місце, де легко отримати розсинхрон. Добра звичка така: якщо id — ключ, стежте, щоб Task.id або завжди збігався з ним, або взагалі не використовувався як джерело істини. Інакше ви самі собі влаштуєте квест.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ