JavaRush /Курсы /C++ SELF /Вспоминаем Big‑O

Вспоминаем Big‑O

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

1. Введение

Когда вы только начинаете программировать, кажется, что скорость — это что-то для «очень больших компаний» и «очень больших серверов». На практике всё проще: вы можете написать консольное приложение, которое на 100 элементах летает, а на 100 000 начинает задумчиво смотреть в стену. И самое обидное — оно при этом не ломается, ошибок нет, просто «почему-то стало медленно».

Big‑O — это способ заранее понять, как алгоритм или операция масштабируется, то есть что будет, когда данных станет больше. Он не обещает точных миллисекунд, зато честно отвечает на вопрос: «При увеличении N в 10 раз времени станет примерно в 10 раз больше, в 100 раз больше, или почти не изменится?». Для разработчика это как умение читать прогноз погоды: не гарантирует солнце, но помогает не выйти в шортах в январе.

Big‑O на пальцах: что такое N и почему мы «не замечаем» константы

Big‑O описывает рост стоимости в зависимости от размера входа. Размер входа обычно обозначают N: количество элементов в контейнере, длина строки, число запросов — в общем, «сколько данных мы обрабатываем». Когда мы говорим O(N), это значит: «если N увеличится в 2 раза, то работы станет примерно в 2 раза больше». Когда говорим O(N²), это значит: «если N увеличится в 2 раза, работы станет примерно в 4 раза больше» — и вот тут уже становится не смешно.

Важно понять одну вещь: Big‑O специально игнорирует константы и «мелкие улучшения». Если у вас два алгоритма O(N), но один в 2 раза быстрее, Big‑O скажет «они одинаковые». И это нормально: Big‑O отвечает не про конкретный компьютер, а про тенденцию роста.

Ниже — маленькая «карта местности», чтобы было легче ориентироваться:

Обозначение Интуитивный смысл Пример «в быту»
O(1)
почти не зависит от N взять пульт со стола
O(log N)
растёт медленно искать слово в бумажном словаре, деля пополам
O(N)
линейно пролистать список целиком
O(N²)
квадратично сравнить каждого со всеми

И ещё один нюанс, который часто пропускают: мы оцениваем не «весь контейнер», а конкретную операцию. Даже в одном и том же std::vector v[i] и v.insert(v.begin(), x) — это два разных мира по стоимости.

2. Доступ к элементу: индекс, «взять i‑й» и почему list не дружит с []

Когда вы пишете v[i], вы ожидаете, что доступ будет быстрым. И у std::vector это ожидание оправдано: элементы лежат «плотно», и i‑й элемент можно вычислить по адресу. У std::deque тоже есть operator[], и доступ обычно считают O(1) по интуитивной модели (внутри там блоки, но интерфейс даёт случайный доступ).

А вот std::list устроен как цепочка узлов: чтобы дойти до i‑го, нужно пройти i шагов по ссылкам. Поэтому у списка нет operator[] — не потому что разработчики STL «забыли», а потому что это был бы очень коварный интерфейс: выглядел бы как быстрый доступ, а на деле был бы линейным проходом.

Представим, что в нашем учебном приложении TaskFlow (мини‑трекер задач) мы хотим «показать 5‑ю задачу». Для vector это естественно, для list — нет.

#include <iostream>
#include <vector>
#include <string>

struct Task {
    int id{};
    std::string title{};
};

int main() {
    std::vector<Task> tasks{{1, "Read"}, {2, "Code"}, {3, "Sleep"}};

    std::cout << tasks[1].title << '\n'; // Code
}

А теперь тот же смысл для list — уже через итератор и std::next:

#include <iostream>
#include <list>
#include <string>
#include <iterator>

struct Task {
    int id{};
    std::string title{};
};

int main() {
    std::list<Task> tasks{{1, "Read"}, {2, "Code"}, {3, "Sleep"}};

    auto it = std::next(tasks.begin(), 1); // пройти 1 шаг вперёд
    std::cout << it->title << '\n';        // Code
}

По Big‑O разница такая: доступ vector[i] — это «почти константно», а «дойти до i‑го в list» — это линейно по i, то есть в худшем случае O(N).

И тут важная мысль: «доступ к элементу» — это не только чтение, это ещё и то, как часто вы делаете этот доступ. Если вы в цикле много раз берёте list-элемент по «номеру», вы не просто делаете O(N), вы часто случайно строите O(N²).

4. Вставка: конец, начало, середина — три разных сценария с разной ценой

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

Начнём с самого приятного: добавить в конец.

У std::vector push_back обычно рассматривают как амортизированное O(1): чаще всего это просто положить элемент в свободное место, а иногда — дорогое расширение буфера. У std::deque добавление в конец тоже естественная операция, и она обычно ведёт себя стабильно. У std::list вставка по позиции вообще «дружелюбная», но позицию ещё нужно иметь.

Пример: добавим задачу в конец списка задач.

#include <iostream>
#include <vector>
#include <string>

struct Task {
    int id{};
    std::string title{};
};

int main() {
    std::vector<Task> backlog;
    backlog.push_back({1, "Write report"});
    backlog.push_back({2, "Fix bug"});

    std::cout << backlog.size() << '\n'; // 2
}

Теперь «неприятный» вариант: вставка в начало.

Для vector вставка в начало означает, что все элементы нужно сдвинуть вправо, чтобы освободить место в нулевом индексе. Это типичный O(N) по числу элементов. Для deque вставка в начало — «родная» операция (push_front) и обычно рассматривается как O(1) на уровне интуиции. Для list вставка в начало тоже дешёвая (push_front), потому что это просто привязать новый узел.

#include <iostream>
#include <deque>
#include <string>

struct Task {
    int id{};
    std::string title{};
};

int main() {
    std::deque<Task> urgent;
    urgent.push_front({1, "PROD is on fire"}); // да, так и назовём
    urgent.push_front({2, "CEO asks: why?"});

    std::cout << urgent.front().title << '\n'; // CEO asks: why?
}

Теперь третий случай — вставка в середину.

Вот здесь важно думать в два шага. Если у вас vector, то вставка в середину снова означает сдвиги, то есть O(N) (примерно «размер хвоста»). Если у вас list, то сама вставка «по итератору» дёшево (O(1)), но найти этот итератор обычно стоит O(N), если вы ищете линейно. То есть list ускоряет вставку, но не обязана ускорять поиск места.

Мини‑пример: вставим задачу перед второй.

#include <iostream>
#include <list>
#include <string>

struct Task {
    int id{};
    std::string title{};
};

int main() {
    std::list<Task> tasks{{1, "A"}, {2, "C"}};

    auto it = std::next(tasks.begin(), 1); // позиция на "C"
    tasks.insert(it, {3, "B"});            // вставка перед "C"

    for (const auto& t : tasks) {
        std::cout << t.title << ' ';
    }
    std::cout << '\n'; // A B C
}

Здесь вставка сама по себе дешёвая, но обратите внимание: мы уже «знали» позицию (получили итератор). Если бы мы искали её по названию, пришлось бы пройти список.

5. Удаление: pop_back, erase и «плата за сдвиг»

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

Самый простой случай — удалить с конца.

У vector pop_back() обычно O(1): просто уменьшаем размер, а деструктор элемента вызывается (если нужно). У deque pop_back() тоже естественен. У list pop_back() тоже.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};
    v.pop_back();

    std::cout << v.size() << '\n'; // 2
}

Удаление из начала.

У deque pop_front() — родная операция. У vector прямого pop_front() нет, потому что это означало бы сдвинуть все элементы на 1 влево (то есть O(N)). Можно сделать erase(begin()), но это тот же сдвиг, просто под другим названием.

#include <iostream>
#include <deque>

int main() {
    std::deque<int> q{10, 20, 30};
    q.pop_front();

    std::cout << q.front() << '\n'; // 20
}

Удаление из середины.

У vector удаление erase(it) приводит к сдвигу хвоста влево, то есть O(N). У list удаление по итератору — локальная операция, обычно рассматривается как O(1), но итератор должен быть валиден, и, опять же, поиск итератора может стоить O(N).

Небольшой кусочек «TaskFlow»: удаляем все выполненные задачи (например, с чётным id) безопасным паттерном. Здесь именно Big‑O подсказывает нам, что vector.erase внутри прохода может быть дорогим, потому что каждый erase потенциально двигает хвост.

#include <vector>

int main() {
    std::vector<int> ids{1, 2, 3, 4, 5};

    for (auto it = ids.begin(); it != ids.end(); ) {
        if (*it % 2 == 0) {
            it = ids.erase(it); // сдвиг элементов влево
        } else {
            ++it;
        }
    }
}

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

6. Шпаргалка: контейнеры и адаптеры

Таблица стоимости операций

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

Операция vector deque list
Доступ по индексу c[i]
O(1)
O(1)
нет
Взять первый/последний front/back
O(1)
O(1)
O(1)
Добавить в конец push_back амортиз. O(1)
O(1)
O(1)
Добавить в начало push_front нет (через insert будет O(N))
O(1)
O(1)
Вставить/удалить в середине по позиции O(N) (сдвиги) обычно O(N) O(1) по итератору
Найти позицию (линейно)
O(N)
O(N)
O(N)

Обратите внимание на последнюю строку: поиск позиции почти везде линейный, если вы ищете «вручную». Поэтому рассуждать полезно так: «сколько стоит найти место» + «сколько стоит вставить/удалить, когда место найдено».

Адаптеры и Big‑O

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

std::stack и std::queue в интуитивной модели дают операции push/pop константной стоимости. Это логично: стек работает с вершиной, очередь — с началом/концом. Вы редко думаете о Big‑O, когда делаете стек, потому что интерфейс уже «вынуждает» дешёвые операции.

std::priority_queue интереснее: она поддерживает «лучший элемент» на вершине, поэтому top() обычно воспринимается как O(1), а push/pop — как O(log N), потому что нужно поддерживать структуру «кучи».

Мини‑пример для нашего TaskFlow: пусть у задачи есть приоритет, и мы хотим всегда забирать самую «горящую».

#include <queue>
#include <string>
#include <iostream>

struct Task {
    int priority{};
    std::string title{};
};

int main() {
    std::priority_queue<int> pq;
    pq.push(3);
    pq.push(10);
    pq.push(7);

    std::cout << pq.top() << '\n'; // 10
}

В реальном приложении вместо int был бы Task и компаратор, но сейчас нам важна именно идея стоимости: быстро смотреть вершину, но вставка/удаление чуть дороже, чем у стека/очереди.

7. Как прикинуть сложность сценария

Когда вы выбираете контейнер, вы на самом деле выбираете, какие операции будут дешёвыми, а какие — дорогими. Чтобы это перестало быть «магией», полезно рассуждать через профиль операций. Представьте, что TaskFlow делает типичный цикл: добавили задачу, иногда вставили срочную в начало, иногда удалили выполненную из середины.

Упрощённая схема рассуждения такая:

flowchart TD
    A[Определить самые частые операции] --> B[Для каждой операции вспомнить Big-O]
    B --> C[Оценить общий рост: сколько раз операция выполняется]
    C --> D[Сравнить контейнеры по дорогим операциям]

На практике это выглядит почти «арифметикой для людей». Если вы знаете, что у вас будет примерно N задач, и вы будете K раз вставлять в начало, то vector вам уже подозрителен: вставка в начало O(N), и суммарно вы получите что-то близкое к O(K·N) только на эту часть. А если K само растёт вместе с N, то можно незаметно доехать до квадратичного роста.

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

И да, иногда правильный ответ звучит скучно: «давайте оставим vector, потому что у нас реально 200 элементов, и всё». Big‑O не запрещает простые решения; он запрещает слепую уверенность, что «одна строчка кода всегда дешёвая».

8. Типичные ошибки при оценке Big‑O

Ошибка №1: путать «операция на контейнере» и «сценарий из многих операций».
Часто смотрят на одну строчку (erase, insert) и думают: «ну это же одна команда». Но в реальности она может двигать десятки тысяч элементов. Полезная привычка — мысленно представить, что происходит с остальными элементами: их надо сдвигать или нет.

Ошибка №2: считать, что list автоматически делает всё быстро, потому что «вставка O(1)».
Да, вставка по итератору дешевая, но итератор не появляется из воздуха. Если вы сначала ищете место линейным проходом, вы всё равно платите O(N) за поиск. В итоге ускорение будет только в той части, где вы действительно много вставляете/удаляете по уже известным позициям.

Ошибка №3: не разделять «удалить» и «удалить много раз».
Один erase в vector может быть терпимым. Тысяча erase в середине, особенно в цикле, может превратить день в ночь (и ноутбук — в обогреватель). Если удалений много, стоит хотя бы задуматься: не лучше ли сначала построить новый контейнер без удаляемых элементов, чем тысячу раз двигать хвост.

Ошибка №4: ждать от deque поведения «улучшенного vector во всём».
deque хорош в операциях на концах, но это не значит, что он всегда выигрывает в любых вставках/удалениях в середине. Если вы часто вставляете в середину, вы всё равно будете платить за перемещения/перестройку. Контейнер выбирают под конкретные операции, а не по принципу «ну он звучит солиднее».

Ошибка №5: думать, что Big‑O — это точное время в миллисекундах.
Big‑O не говорит, что будет «ровно 12.3 ms». Он говорит, как растёт цена при росте N. Это компас, а не GPS‑трекер. Используйте его, чтобы не заблудиться в больших N, а не чтобы спорить, какая реализация быстрее на массиве из 7 элементов.

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