JavaRush /Курсы /C++ SELF /Выбор структуры данных: std::vector или std::map

Выбор структуры данных: std::vector или std::map

C++ SELF
60 уровень , 4 лекция
Открыта

1. Введение

Когда вы только начинаете программировать, хочется найти «универсальный контейнер» и использовать его везде: как универсальный пульт от всего. В C++ таким кандидатом часто кажется std::map: поиск быстрый, порядок есть, выглядит серьёзно. Но на практике вы довольно быстро замечаете странное: где-то map внезапно «тяжёлый», а где-то обычный std::vector неожиданно выигрывает даже в поиске. И это не шаманство, а обычная инженерия.

Если упростить до одной идеи, то выбор контейнера — это выбор набора компромиссов. Вам нужно понять, что именно важнее в вашем сценарии: скорость поиска, скорость вставки/удаления, память, стабильный порядок, простота поддержки инвариантов. Сегодня мы сравним два подхода, которые постоянно конкурируют в реальных проектах: дерево (std::map) и плотный массив (std::vector), но отсортированный.

Учебный кейс: «справочник контактов» и find by id

Чтобы сравнение было не теоретическим, будем держать в голове одну и ту же задачу. Представьте простое консольное приложение «Справочник контактов»: у каждого контакта есть id, имя и телефон. Мы хотим уметь быстро находить контакт по id, иногда добавлять новые контакты и иногда печатать список.

Заметьте, мы сейчас сознательно берём поиск по целочисленному ключу: это самый понятный пример, где можно сосредоточиться на контейнерах, а не на особенностях строк, локалей и прочей жизни взрослого программиста. Сами модели мы уже много раз делали через struct, так что ничего нового и страшного тут нет.

Вот базовая модель:


#include <string>

struct Contact {
    int id{};
    std::string name;
    std::string phone;
};

С этого места начинается развилка: как хранить коллекцию контактов так, чтобы поиск по id был «приятным»?

2. Базовый вариант: std::vector и линейный поиск

Перед тем как «улучшать», полезно честно посмотреть на базовую версию. Если контактов мало, можно хранить их в std::vector<Contact> и искать перебором. Это читается элементарно, отлаживается элементарно, и часто… работает быстрее, чем ожидают новички.

Пример линейного поиска:

#include <vector>

const Contact* find_by_id_linear(const std::vector<Contact>& contacts, int id) {
    for (const auto& c : contacts) {
        if (c.id == id) return &c;
    }
    return nullptr;
}

В Big‑O это O(N), и формально это «хуже», чем O(log N). Но в реальном мире при маленьких N (например, 30–200 элементов) линейный поиск по плотному vector может быть очень бодрым из‑за хорошей локальности данных и минимальных накладных расходов.

Проблема в другом: как только данных становится заметно больше, вы начинаете чувствовать O(N). И тут у нас появляются два взрослых варианта: сортированный vector и map.

4. Сортированный std::vector: быстрый поиск ценой вставки

Инвариант сортировки и представление данных

Сортированный vector — это почти философия. Мы берём контейнер, который хорош своей плотностью и простотой, и добавляем к нему одно правило-инвариант: элементы всегда отсортированы по ключу. Как только вы принимаете это правило, у вас появляется возможность искать за O(log N) с помощью идеи бинарного поиска.

Важно уловить момент: сортированный vector — это не «вектор плюс магия», это «вектор плюс дисциплина». Цена дисциплины проявится позже, когда мы будем вставлять и удалять элементы. Но пока звучит заманчиво: поиск быстрый, память компактная, обход по порядку естественный.

Для удобства часто хранят не сами объекты «как попало», а пары key → value, то есть std::pair<int, Contact>. Тогда по first мы поддерживаем порядок.

#include <utility>
#include <vector>

using ContactRow = std::pair<int, Contact>; // first = id
using ContactTable = std::vector<ContactRow>;

Да, это немного «похоже на базу данных из каменного века», но именно такими вещами на практике и строятся многие быстрые индексы: просто, понятно, предсказуемо.

Бинарный поиск по отсортированному vector

Слово «бинарный поиск» часто звучит как заклинание из учебника, хотя по сути это просто аккуратная игра в «угадай середину». Мы поддерживаем диапазон [left, right) и каждый раз выкидываем половину. Главный смысл: без сортировки бинарный поиск не работает вообще. Он не «даёт неправильный ответ», он просто превращается в генератор бессмысленных действий.

Реализуем поиск позиции по id. Мы вернём std::optional<std::size_t>: либо индекс найденного элемента, либо «не найдено». Этот стиль вы уже должны узнавать по темам про «ошибка как значение».

#include <cstddef>
#include <optional>

std::optional<std::size_t> find_pos_by_id_sorted(const ContactTable& t, int id) {
    std::size_t left = 0;
    std::size_t right = t.size(); // [left, right)

    while (left < right) {
        std::size_t mid = left + (right - left) / 2;
        if (t[mid].first < id) left = mid + 1;
        else right = mid;
    }

    if (left < t.size() && t[left].first == id) return left;
    return std::nullopt;
}

Здесь важно привыкнуть к одной мысли: бинарный поиск обычно сначала находит место «где должно быть», а потом проверяет, совпало ли. Это очень удобно, потому что то же самое «место» можно использовать для вставки, и вы не пишете две разные логики.

Вставка в сортированный vector

Пока мы радовались O(log N), всё было красиво. Теперь реальность: если vector отсортирован, то вставка нового элемента требует сохранить порядок. А vector — это непрерывный массив. Значит, чтобы вставить в середину, надо сдвинуть хвост. И это уже обычно O(N).

То есть вы получаете классический компромисс: поиск быстрый, вставка дорогая. Это идеально для сценариев «данные почти не меняются, но часто читаются». Например: справочник кодов стран, список тарифов, таблица констант, словарь команд, загруженный один раз на старте приложения.

Сделаем вставку так: найдём позицию «куда вставлять», проверим дубликат ключа, затем вставим.

#include <utility> // std::pair

bool insert_sorted(ContactTable& t, const Contact& c) {
    // Находим место, где должен стоять id
    std::size_t left = 0;
    std::size_t right = t.size();

    while (left < right) {
        std::size_t mid = left + (right - left) / 2;
        if (t[mid].first < c.id) left = mid + 1;
        else right = mid;
    }

    if (left < t.size() && t[left].first == c.id) return false; // уже есть

    t.insert(t.begin() + static_cast<std::ptrdiff_t>(left), {c.id, c});
    return true;
}

Обратите внимание на деталь: t.begin() + left требует индекс типа, совместимого с разностью итераторов (std::ptrdiff_t). Да, это один из тех моментов, где C++ напоминает: «я мощный, но ты давай аккуратнее». Мы не углубляемся, просто делаем явное приведение.

5. std::map: порядок без сдвигов массива

Теперь посмотрим на std::map. Важная интуиция: map — это не «вектор, который умеет искать». Это другая структура: дерево (обычно сбалансированное). Оно хранит пары key → value так, чтобы поддерживать порядок ключей и делать поиск/вставку/удаление примерно за O(log N).

С практической точки зрения map хорош тем, что вставка не требует сдвига половины контейнера. Вы добавляете новый узел дерева, дерево перестраивается внутри себя, и порядок сохраняется.

Пример: хранить контакты прямо в map<int, Contact>.

#include <map>

using ContactMap = std::map<int, Contact>;

const Contact* find_by_id_map(const ContactMap& m, int id) {
    if (auto it = m.find(id); it != m.end()) {
        return &it->second;
    }
    return nullptr;
}

Вставка тоже выглядит спокойно:

bool insert_map(ContactMap& m, const Contact& c) {
    auto [it, inserted] = m.insert({c.id, c});
    return inserted; // false, если такой ключ уже был
}

И вот тут часто случается «моральная ловушка новичка»: код с map получается короче и проще, чем наш ручной бинарный поиск + вставка. Очень хочется объявить победителя. Но давайте не будем судить контейнеры по количеству строк — C++ за такое обычно мстит производительностью или памятью.

6. Сравнение: скорость, память и порядок

Чтобы сравнить подходы, полезно смотреть сразу на несколько осей. Big‑O — это хорошая карта местности, но карта не показывает, что в лесу комары. У map есть накладные расходы на узлы и указатели, у vector — великолепная плотность и кэш-дружелюбность, но дорогие вставки.

Сведём сравнение в таблицу. Не как «закон природы», а как практическую шпаргалку.

Свойство / операция Сортированный vector<pair<K,V>> std::map<K,V>
Поиск по ключу O(log N), очень быстрый на практике из-за плотной памяти O(log N), но больше «прыжков по памяти»
Вставка нового ключа обычно O(N) из-за сдвигов O(log N), без сдвигов массива
Удаление обычно O(N) из-за сдвигов O(log N)
Порядок обхода естественный по ключу (как лежит в массиве) тоже по ключу (задаётся компаратором)
Память на элемент минимальная (почти только данные) больше (узлы, ссылки внутри структуры)
Лучший сценарий «прочитать много раз, изменить редко» «часто вставлять/удалять + нужен порядок»

Теперь маленькая визуальная аналогия, чтобы запомнить. vector — это как коробка с карточками, сложенными по возрастанию: искать удобно, но вставить новую карточку в середину — надо раздвигать пачку. map — как картотека с ящиками и перегородками: вставлять проще, но доступ к соседним карточкам физически «скачет» по разным местам.

Можно представить это и схемой:

flowchart LR
    A[Поиск по ключу] --> B{Данные часто меняются?}
    B -->|Редко| C[Сортированный vector: быстрый поиск, дешёвая память]
    B -->|Часто| D{Нужен порядок по ключу?}
    D -->|Да| E[map: вставка/удаление логарифмические]
    D -->|Нет| F["unordered_map: обычно O(1) в среднем"]

Мы здесь не углубляемся в unordered_map (это соседняя тема дня), но полезно видеть «ветвление решения» целиком.

7. Правило выбора: задайте себе 3 вопроса

Когда разработчик выбирает контейнер, он редко сидит с мелом у доски и доказывает теоремы. Обычно он задаёт себе несколько практических вопросов и выбирает то, что проще поддерживать и сложно сломать. Сейчас мы сформулируем эти вопросы в нормальном человеческом стиле.

Первый вопрос: данные загружаются один раз и почти не меняются, или вы постоянно добавляете/удаляете элементы? Если изменения редкие, сортированный vector часто оказывается неожиданно сильным решением. Если изменения частые, map обычно даёт меньше боли.

Второй вопрос: нужен ли гарантированный порядок обхода по ключу? Если да — map и сортированный vector оба подходят. Если порядок не нужен, часто выгоднее смотреть на unordered_map, но это уже другая часть дня.

Третий вопрос: насколько важна память и «простой» layout данных? На больших объёмах разница между плотным массивом и кучей узлов дерева становится заметной не только по памяти, но и по скорости из-за кэша.

8. Как спрятать контейнер за API

Чтобы не превращать программу в «зоопарк контейнеров», полезно стремиться к одному стилю API. Например, нам может быть всё равно, где лежат контакты, если мы хотим просто «найти по id и напечатать».

Сделаем функцию печати, которая принимает указатель на контакт. Это простая техника, но она хорошо показывает идею: поиск может вернуть «ничего», и это надо корректно обработать.

#include <iostream>

void print_contact(const Contact* c) {
    if (!c) {
        std::cout << "Contact not found\n";
        return;
    }
    std::cout << c->id << ": " << c->name << " (" << c->phone << ")\n";
}

Теперь можно писать:

// Для map
// print_contact(find_by_id_map(m, 10));

// Для vector (линейно)
// print_contact(find_by_id_linear(v, 10));

Да, это «маленькая архитектура», но именно так вы со временем учитесь проектировать код так, чтобы замена контейнера не ломала половину программы.

9. Когда какой вариант выигрывает

Когда сортированный vector — хороший выбор

Иногда сортированный vector воспринимают как трюк: мол, «ну это для олимпиадников» или «это когда нельзя map». На практике это нормальная инженерная опция, когда данные либо неизменяемые, либо меняются пачками. Например, вы загружаете список контактов из файла, сортируете один раз и дальше только ищете. Или вы строите таблицу команд приложения при старте и потом только выполняете.

В таких сценариях сортированный vector часто выигрывает ещё и тем, что его легко сериализовать, легко копировать, легко «прогнать» линейными алгоритмами, и он практически не расходует память на служебные структуры. Вы платите за вставки, но если вставок почти нет — вы почти ничего не платите.

Когда std::map выигрывает

map — не «устаревший» контейнер и не «контейнер для тех, кто не понял unordered_map». Он хорош, когда у вас есть поток изменений: добавления, удаления, обновления. Он хорош, когда вы хотите, чтобы структура сама поддерживала порядок, и вам не нужно вручную следить за инвариантом сортировки после каждой операции.

Ещё map часто выигрывает в ситуации, когда вы хотите делать операции типа «найти ближайший ключ слева/справа» или «обойти диапазон ключей». Мы не развиваем эту мысль слишком далеко (у нас сейчас фокус на выборе структуры в целом), но полезно помнить: дерево — это не только поиск, это ещё и «порядок как инфраструктура».

10. Типичные ошибки

Ошибка №1: делать бинарный поиск по неотсортированному vector.
Это одна из самых коварных ошибок: код компилируется, программа работает, но ответы периодически неправильные. Причина простая: бинарный поиск опирается на инвариант сортировки. Если вы хоть раз добавили элемент «куда попало» и не восстановили порядок, алгоритм начинает принимать неверные решения о том, какую половину диапазона выбросить.

Ошибка №2: забыть, что вставка в середину vector — это сдвиги, и внезапно получить тормоза.
Новичок видит O(log N) на поиск и думает, что «всё стало быстро». Потом появляются частые вставки, и программа начинает странно тормозить, хотя формально «мы же сделали бинарный поиск». Тут важно помнить: стоимость решения — это стоимость полного сценария. Если у вас много вставок, сортированный vector может стать дорогим именно из-за перемещений элементов.

Ошибка №3: выбирать map «по умолчанию», не формулируя требования.
Такой выбор часто приводит к тому, что код выглядит «солидно», но расходует больше памяти и иногда работает медленнее на поиске, чем ожидалось. Это особенно заметно на данных среднего размера, где vector благодаря плотности может быть очень сильным. Правильная привычка — сначала понять, что важнее: редкие изменения или частые, нужен ли порядок, какой примерно объём данных.

Ошибка №4: путать «удобно писать» и «удобно сопровождать».
map часто даёт короткий код, а сортированный vector требует ручной дисциплины. Но сопровождение — это не только длина функции, а ещё и ясность инвариантов. Если команда не готова постоянно помнить про сортировку и аккуратные вставки, сортированный vector может стать источником тихих багов. В этом случае лучше выбрать map, даже если он не идеален по накладным расходам.

Ошибка №5: игнорировать стоимость памяти и локальность данных, сравнивая только Big‑O.
На бумаге O(log N) и там, и там. А в реальности один контейнер прыгает по куче узлов, а другой читает память линейно и предсказуемо. Поэтому корректный подход — помнить, что «асимптотика» не отменяет архитектуру памяти. На небольших и средних объёмах данных это иногда решает больше, чем красивые формулы.

1
Задача
C++ SELF, 60 уровень, 4 лекция
Недоступна
Поиск контакта
Поиск контакта
1
Задача
C++ SELF, 60 уровень, 4 лекция
Недоступна
Быстрый запрос
Быстрый запрос
1
Задача
C++ SELF, 60 уровень, 4 лекция
Недоступна
Вставка ключа
Вставка ключа
1
Задача
C++ SELF, 60 уровень, 4 лекция
Недоступна
Контакты в map
Контакты в map
1
Опрос
Контейнеры глубже, 60 уровень, 4 лекция
Недоступен
Контейнеры глубже
Контейнеры глубже
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ