Алгоритмы и структуры данных для начинающих — иллюстрация

Представь, что ты собираешь шкаф. У тебя есть инструкция и набор деталей. Можно закручивать шурупы отвёрткой, а можно молотком. Шкаф вроде бы соберётся в обоих случаях. Но во втором - криво, долго и с сорванными шурупами.

В программировании так же. Одну и ту же задачу можно решить по-разному. Быстро и чисто. Или медленно и вслепую.

Ты уже умеешь компилировать и запускать код на C++. Мы прошли это в статье «Первые шаги в C++: ставим среду, пишем и запускаем код». Твой код работает. Но ты чувствуешь: что-то не так. Может, он тормозит. Может, ты решаешь задачу «в лоб» и подозреваешь, что есть путь лучше. Это нормально. Значит, ты готов к следующему шагу.

Теперь мы научимся писать так, чтобы код не тормозил в Docker-контейнере и не создавал проблем в CI/CD из-за долгих тестов.

К концу этой статьи ты поймёшь, что такое Big O. Узнаешь массив и связный список в лицо. Напишешь свой первый алгоритм линейного поиска на C++. И научишься измерять его скорость. Это твой первый шаг к эффективному коду.

Мы работаем на C++. Python появится только в одном месте - в аккордеоне для сравнения синтаксиса. Учить его не нужно.

1. Что такое алгоритм и структура данных

Алгоритм

Простыми словами, алгоритм - это проверенный рецепт. Пошаговая инструкция для решения конкретной задачи.

Как рецепт борща. Нарезал, обжарил, залил, варил 40 минут. Переставил шаги местами - получил не борщ, а недоразумение. В программировании алгоритм работает так же. Это последовательность действий, которая получает входные данные и выдаёт результат.

Примеры алгоритмов окружают нас повсюду. Утренний сбор на работу - алгоритм. Рецепт блинов - алгоритм. Путь от дома до метро - тоже алгоритм. Ты пользуешься алгоритмами каждый день. Просто не называешь их так.

Структура данных

Простыми словами, структура данных - это способ организовать и хранить наши «ингредиенты». Чтобы повару было удобно с ними работать.

Муку - в банку. Специи - на полку. Овощи - в ящик. Каждому продукту - своё место. В программировании структура данных - это контейнер для хранения данных. С определёнными правилами доступа и операциями.

Алгоритм без правильной структуры данных - как повар без организованной кухни. Вроде готовит, но половину времени ищет ингредиенты. Одно без другого работает плохо.

Что мы узнали:
  • Алгоритм - пошаговая инструкция для решения задачи.
  • Структура данных - способ организации и хранения данных.
  • Одно без другого работает плохо.
Проверь себя: Утренняя чистка зубов - это алгоритм или структура данных? Почему?

Ответ: Алгоритм. Это последовательность действий (взять щётку, выдавить пасту, чистить 2 минуты, прополоскать), которая приводит к результату (чистые зубы). Данные - это щётка и паста. Но организованы они не в структуру, а просто лежат в стакане.

2. Как измерить эффективность: Big O Notation

Ты в библиотеке. Тебе нужна книга. Можно передавать её по одной странице - это долго. А можно отдать всю книгу целиком - это быстро.

Big O - это не точное время в секундах. Это ответ на вопрос: «Как быстро растёт время выполнения, если данных становится больше?»

Big O измеряет не процессорное время и не такты. Он измеряет количество операций, которые совершает алгоритм, когда размер входных данных стремится к бесконечности. Это «верхняя планка». Худший сценарий.

Давай разберём три главные нотации. Без формул. На аналогиях.

O(1) - константное время. «Мгновенно»

Ты знаешь, что книга - на полке №15, третья слева. Подошёл и взял. Не важно, 100 книг в библиотеке или 100 000. Ты всё равно подходишь к нужной полке. Количество книг не влияет на время.

В коде это выглядит как доступ к элементу массива по индексу. arr[5]. Одна операция. Всегда.

O(n) - линейное время. «Пропорционально»

Ты ищешь книгу с дарственной надписью от бабушки. Но не помнишь, где она. Приходится просматривать каждую книгу на полке. Это долго. 10 книг - 10 проверок. 1000 книг - 1000 проверок. Видишь закономерность?

В 100 раз больше книг - в 100 раз дольше. Это линейный поиск. Простой перебор массива в цикле for.

O(n²) - квадратичное время. «Лавина»

Ты сравниваешь каждый подарок с каждым. Чтобы никому не подарить одинаковое. 5 подарков - 25 сравнений. 100 подарков - 10 000 сравнений. Лавина.

В коде это вложенный цикл for внутри for. Каждый новый элемент умножает работу, а не прибавляет.

График сложности алгоритмов Big O Notation - O(1), O(n), O(n²)
График Big O Notation. Зелёная горизонтальная прямая - O(1). Жёлтая наклонная - O(n). Красная парабола - O(n²).
Слева: библиотекарь протягивает целую книгу - O(1). Справа: передаёт страницу через щель - O(n).
Метафора Big O Notation - библиотекарь передаёт книгу целиком (O(1)) и постранично (O(n))
Что мы узнали:
  • Big O показывает, как растёт время работы алгоритма при росте данных.
  • O(1) - мгновенно, не зависит от размера данных.
  • O(n) - пропорционально размеру данных.
  • O(n²) - лавинообразно, каждый новый элемент умножает работу.
Проверь себя: Алгоритм проверяет каждый элемент списка и для каждого элемента снова проверяет весь список. Какая это сложность?

Ответ: O(n²). Внешний цикл даёт n операций, внутренний - ещё n. Итого n × n = n² операций.

3. Инструмент №1 - Массив

Ряд пронумерованных почтовых ящиков в подъезде. Все ящики одного размера. Стоят ровно в ряд. Ты сразу знаешь номер нужного. Ящик №0, ящик №1, ящик №2. Подошёл - и ты там.

Массив устроен так же. Элементы одного типа - int, double, char. Расположены в памяти подряд, друг за другом. У каждого элемента есть индекс. Начинается с 0. Размер фиксирован - ты должен сразу знать, сколько ящиков понадобится.

Плюсы. Доступ по индексу за O(1). Хочешь пятый элемент - arr[5] - и ты там. Просто. Понятно даже новичку.

Минусы. Вставка и удаление в середину - дорого. Приходится сдвигать все элементы справа. Размер изменить нельзя для статического массива C++.

Реальный кейс. Хранение пикселей изображения. Фотография 1000×1000 пикселей - это массив из миллиона элементов. Чтобы осветлить картинку, программа проходит по каждому пикселю и меняет его значение. Доступ к пикселю по координатам - это доступ к массиву по индексу. O(1) для каждого пикселя.

#include <iostream>
using namespace std;

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    cout << arr[2] << endl; // 30 - доступ за O(1)
    return 0;
}
Ряд из 5 пронумерованных почтовых ящиков. Рука тянется к ящику №2.
Массив как ряд пронумерованных почтовых ящиков - структура данных
Что мы узнали:
  • Массив - фиксированный набор элементов одного типа, расположенных в памяти подряд.
  • Доступ по индексу - O(1).
  • Вставка и удаление в середину - дорогие операции.
Проверь себя: У тебя массив из 5 элементов. Ты хочешь вставить новый элемент на позицию 0. Сколько элементов придётся сдвинуть?

Ответ: 5 элементов. Все существующие элементы сдвинутся на одну позицию вправо. Новый элемент займёт индекс 0. Старый элемент с индекса 0 переедет на индекс 1. И так далее.

4. Инструмент №2 - Связный список

Квест. Охота за сокровищами. Ты находишь первую записку: «Иди к старому дубу». Под дубом - вторая записка: «Ищи под скамейкой у фонтана».

Каждая записка знает только о следующей. Нет общего списка всех тайников. Ты идёшь по цепочке. Пятая записка может быть где угодно. Чтобы до неё добраться, надо пройти через первые четыре.

Связный список устроен так же. Он состоит из узлов. Каждый узел содержит данные и указатель на следующий узел. Узлы могут быть разбросаны по памяти. Они не лежат подряд. Последний узел указывает в «никуда».

Плюсы. Вставка и удаление в любое место - быстро. Достаточно переставить указатели. Не нужно двигать тонну данных. Размер не фиксирован. Можно добавлять узлы, пока есть память.

Минусы. Доступ по индексу - O(n). Чтобы добраться до пятого узла, надо пройти через первые четыре. Индекса list[5] нет. Занимает больше памяти: под каждый элемент хранится ещё и указатель на следующий.

Реальный кейс. История действий с отменой в текстовом редакторе. Каждое действие пользователя - это узел. Когда ты нажимаешь Ctrl+Z, редактор идёт по цепочке обратно и восстанавливает предыдущее состояние. Добавить новое действие - быстро, O(1). Надо только переставить указатели. А искать конкретное действие по номеру почти никогда не нужно. Мы ходим вперёд-назад.

Не будем изобретать велосипед. В C++ уже есть готовая реализация связного списка - std::list. Давай посмотрим, как с ним работать:

#include <iostream>
#include <list>
using namespace std;

int main() {
    list<int> myList = {10, 20, 30};

    // Пройти по списку
    for (int item : myList) {
        cout << item << " ";
    }
    // Вывод: 10 20 30

    // Вставить элемент в середину - быстро
    auto it = myList.begin();
    advance(it, 1); // Перемещаемся ко второму элементу
    myList.insert(it, 15);
    // Теперь список: 10 15 20 30

    return 0;
}

Это продвинутый момент. Если он покажется сложным - ничего страшного. Вернись к нему через пару недель, когда будешь увереннее работать с указателями. Главное, что ты уже понял идею: цепочка узлов, каждый указывает на следующий.

#include <iostream>
using namespace std;

struct Node {
    int data;
    Node* next; // Указатель на следующий узел. Если следующего нет - nullptr
};

int main() {
    Node* head = new Node{10, nullptr};
    head->next = new Node{20, nullptr};
    head->next->next = new Node{30, nullptr};

    // Пройти по списку
    Node* current = head;
    while (current != nullptr) {
        cout << current->data << " ";
        current = current->next;
    }
    // Вывод: 10 20 30

    // Не забудь удалить память
    delete head->next->next;
    delete head->next;
    delete head;

    return 0;
}
Цепочка из записок, прикреплённых к дубу, скамейке, фонарю. На каждой записке стрелка к следующей.
Связный список как квест с записками - структура данных
Что мы узнали:
  • Связный список - цепочка узлов, каждый хранит данные и указатель на следующий.
  • Вставка и удаление - O(1), если есть указатель на место вставки.
  • Доступ по индексу - O(n), надо пройти по цепочке.
  • Занимает больше памяти, чем массив.
  • В C++ есть готовая реализация - std::list.
Проверь себя: В каком случае связный список будет эффективнее массива?

Ответ: Когда нужно часто вставлять и удалять элементы в середине структуры. Например, список задач, куда постоянно добавляются новые пункты и удаляются выполненные. Или очередь воспроизведения в музыкальном плеере, где можно вставить песню в любое место без перестроения всего списка.

5. Попробуй сам - линейный поиск на C++

Пришло время написать свой первый алгоритм. Это линейный поиск. Простой. Понятный. Но это твой первый шаг к эффективному коду.

Задача. Написать функцию, которая получает массив имён и искомое имя. Возвращает индекс первого вхождения или -1, если имя не найдено.

Конкретные данные:

  • Массив: ["Анна", "Борис", "Вера", "Глеб", "Диана"]
  • Ищем: "Вера"
  • Ожидаемый результат: индекс 2

Почему линейный поиск? Это простейший алгоритм сложности O(n). Идеальный первый алгоритм для новичка. Ты уже писал циклы в статье «Первые шаги в C++: ставим среду, пишем и запускаем код». Теперь ты даёшь циклу имя и измеряешь его скорость.

Пошаговая инструкция:

  1. Создай массив строк из 5 имён.
  2. Напиши функцию linearSearch, которая принимает массив, его размер и строку для поиска.
  3. В функции пройди циклом по массиву и сравнивай каждый элемент с искомым.
  4. Если нашёл - верни индекс. Не нашёл - верни -1.
  5. Выведи результат на экран.
Код на C++:
#include <iostream>
#include <string>
using namespace std;

int linearSearch(string arr[], int size, string target) {
    for (int i = 0; i < size; i++) {
        if (arr[i] == target) {
            return i; // Нашли - возвращаем индекс
        }
    }
    return -1; // Не нашли
}

int main() {
    string names[5] = {"Анна", "Борис", "Вера", "Глеб", "Диана"};
    string target = "Вера";

    int result = linearSearch(names, 5, target);

    if (result != -1) {
        cout << "Имя \"" << target << "\" найдено на позиции " << result << endl;
    } else {
        cout << "Имя \"" << target << "\" не найдено" << endl;
    }

    return 0;
}

Критерии успеха:

  • Программа компилируется без ошибок: g++ -std=c++17 -o search search.cpp.
  • Для «Вера» выводит Имя "Вера" найдено на позиции 2.
  • Если поменять target на "Елена", выводит Имя "Елена" не найдено.
  • Проверка крайнего случая: в статическом массиве C++ размер 0 невозможен. Для проверки крайнего случая используй массив из 1 элемента и ищи то, чего в нём нет - алгоритм должен корректно вернуть -1. Это важно. Мы говорили об этом в ошибке №6.
  • Проверка отсутствующего элемента: это уже сделано через "Елена".
Осторожно: Если программа не компилируется с ошибкой undefined reference to std::__cxx11::basic_string, проверь, что используешь компилятор с поддержкой C++17 (GCC 8 и новее). Команда g++ --version покажет версию твоего компилятора.

  • Windows (MinGW-w64): команда компиляции та же. Если используешь Visual Studio, создай консольное приложение и скопируй код.
  • macOS (Homebrew): g++-14 -std=c++17 -o search search.cpp.
  • Linux: g++ -std=c++17 -o search search.cpp.
  • Запуск везде: ./search (Windows: search.exe).
Схема массива из 5 имён. Имя «Вера» подсвечено зелёным, стрелка указывает на индекс 2.
Результат работы алгоритма линейного поиска - имя найдено на позиции 2

Мини-задание - почувствуй O(n) на практике

Увеличь массив до 10 имён. Проверь, сколько сравнений делает алгоритм для первого элемента и для последнего. Как это сделать - вот готовая подсказка:

int linearSearch(string arr[], int size, string target) {
    int counter = 0; // Счётчик сравнений
    for (int i = 0; i < size; i++) {
        counter++; // Увеличиваем при каждом сравнении
        if (arr[i] == target) {
            cout << "Сравнений: " << counter << endl;
            return i;
        }
    }
    cout << "Сравнений: " << counter << endl;
    return -1;
}

Проверь для «Анна» - первого элемента. Проверь для последнего элемента в массиве из 10 имён. Сравни цифры.

Это O(n) в действии. Чем дальше элемент, тем больше работы. В лучшем случае - 1 сравнение. В худшем - все 10. В среднем - 5. Прямая зависимость. Никакой магии.

Мы работаем на C++. Python здесь только для сравнения синтаксиса - учить его не нужно. Посмотри, как одна и та же логика выглядит в более лаконичном языке.

def linear_search(arr, target):
    for i in range(len(arr)):
        if arr[i] == target:
            return i
    return -1

names = ["Анна", "Борис", "Вера", "Глеб", "Диана"]
target = "Вера"

result = linear_search(names, target)
if result != -1:
    print(f'Имя "{target}" найдено на позиции {result}')
else:
    print(f'Имя "{target}" не найдено')

Заметь: синтаксис другой, но логика та же. Это и есть алгоритм. Он не зависит от языка.

Задание со счётчиком сравнений.

Сделай то, что описано в мини-задании выше: добавь счётчик и сравни количество операций для первого элемента, для последнего и для отсутствующего. Убедись, что для отсутствующего элемента алгоритм проходит весь массив. Для 10 имён это всегда 10 сравнений. Для 100 имён - 100. Прямая зависимость.

Позже мы разберём сортировку пузырьком - алгоритм сложности O(n²), и ты увидишь разницу с линейным поиском на практике. Для 10 элементов сортировка сделает около 45 сравнений. А для 1000? Почти полмиллиона. Вот почему O(n²) называют «лавиной».

6. Типичные ошибки начинающих

Ошибка 1. Пытаться выучить все алгоритмы наизусть

  • Как выглядит: читатель скачивает список «100 алгоритмов, которые должен знать каждый» и пытается запомнить код.
  • Как понять: ты можешь написать код по памяти, но не можешь объяснить, почему он работает.
  • Индикатор: ты не можешь сказать, какова сложность алгоритма, который только что написал.
  • Как исправить: начни с понимания идеи. Линейный поиск - это «посмотри каждый». Код пишется под идею, а не наоборот.

Ошибка 2. Начинать изучение со сложных тем

  • Как выглядит: первая тема - динамическое программирование или графы.
  • Как понять: ты открыл статью и через 5 минут закрыл с мыслью «это не для меня».
  • Индикатор: ты не можешь написать линейный поиск, но уже читаешь про деревья отрезков.
  • Как исправить: линейный поиск → бинарный поиск → сортировка пузырьком → сортировка слиянием. Ступенька за ступенькой.

Ошибка 3. Игнорировать Big O

  • Как выглядит: «Мой код работает, и ладно».
  • Как понять: на 100 элементах код летает, на 100 000 - задумывается, на 1 000 000 - вечность.
  • Индикатор: ты никогда не задавал себе вопрос «как поведёт себя этот код, если данных станет в 100 раз больше».
  • Как исправить: перед каждым написанным циклом спрашивай: «Это O(n) или O(n²)?»

Ошибка 4. Путать индекс элемента и его значение

  • Как выглядит: в массиве [10, 20, 30] элемент с индексом 2 - это 20.
  • Как понять: ты стабильно ошибаешься на единицу при работе с массивами.
  • Индикатор: ты говоришь «второй элемент», имея в виду arr[1], а потом удивляешься результату.
  • Как исправить: запомнить мантру: «Индексы с нуля. Элемент с индексом 0 - первый. Элемент с индексом 2 - третий».

Ошибка 5. Использовать список там, где нужен массив

  • Как выглядит: тебе нужен частый доступ по индексу, но ты выбрал связный список.
  • Как понять: программа тормозит на ровном месте при, казалось бы, простых операциях.
  • Индикатор: ты не можешь объяснить, почему выбрал именно эту структуру.
  • Как исправить: перед выбором структуры спроси: «Что я буду делать чаще - читать по индексу или вставлять/удалять элементы?»

Ошибка 6. Забывать про крайние случаи

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

7. Что дальше

В следующей статье «Тестирование кода: юнит-тесты на Python/Java/C#» ты напишешь свой первый юнит-тест. Ты проверишь алгоритм линейного поиска, который создал сегодня, и убедишься, что он работает именно так, как ты задумал. Без ручного перезапуска программы. Автоматически. Один запуск - и ты знаешь, что всё в порядке.

Эта статья открывает модуль «Фундаментальные знания» из дорожной карты «Путь начинающего разработчика: дорожная карта от нуля до первой работы». Алгоритмы и структуры данных - это 90% технических собеседований на позицию начинающего разработчика. Ты только что сделал первый шаг в сторону, которую многие новички обходят стороной. А потом удивляются, почему их не берут на работу. Теперь ты знаешь то, что выделяет думающего разработчика из толпы.

А знаете ли вы?

В стандартной библиотеке C++ (<algorithm>) уже есть готовая функция std::find, которая делает линейный поиск. Выглядит это так: auto it = find(begin(names), end(names), target);. Но понимать, как она работает внутри, нужно обязательно. Собеседование проверяет не умение вызывать std::find. А умение объяснить, что происходит под капотом. Твой написанный linearSearch - это и есть то, что под капотом.

Полезные внешние ресурсы:

  • Хочешь увидеть, как разные алгоритмы ведут себя на разных данных? Зайди на visualgo.net - это визуализатор алгоритмов и структур данных. Введи свои числа и смотри, как сортировка или поиск работают по шагам.
  • Хочешь системно изучить алгоритмы дальше? Начни с бесплатного курса Algorithms, Part I от Принстонского университета (Кевин Уэйн, Роберт Седжвик). Он на английском, но с русскими субтитрами на YouTube. Это следующий уровень после нашей статьи.

8. Шпаргалка

Термин
Простое объяснение
Big O
Массив
Ряд пронумерованных почтовых ящиков
Доступ - O(1), поиск - O(n)
Связный список
Квест с записками, каждая указывает на следующую
Доступ - O(n), поиск - O(n)
Линейный поиск
Просматриваем каждый элемент по очереди
O(n)
O(1)
Не зависит от количества данных
Константное время
O(n)
Растёт прямо пропорционально данным
Линейное время
O(n²)
Растёт как квадрат от количества данных
Квадратичное время
Скачай PDF-шпаргалку «Big O и основные структуры данных»

Расширенная PDF-памятка с графиками сложности, сравнением 4 структур данных (массив, связный список, а также стек и очередь - о них ты узнаешь в следующих статьях) и шпаргалкой по нотациям Big O. Распечатай и повесь над рабочим столом.

Скачать PDF

Заключение

Ты только что разобрался с тем, что пугает многих начинающих разработчиков. Алгоритмы и структуры данных - это не магия и не удел гениев. Это набор инструментов. Такой же, как отвёртка и молоток. Big O - это твой измерительный прибор. Теперь ты смотришь на код не просто как на набор инструкций. А как на систему, которую можно измерить и улучшить.

Ты написал свой первый алгоритм. Не выучил чужой. Не скопировал. Написал сам. Понимая, что такое O(n). Это огромный шаг.

В следующей статье ты научишься проверять свой код автоматически - юнит-тестами. Твой линейный поиск больше никогда не сломается незаметно. Ты будешь знать об этом сразу. Прямо во время написания кода. Алгоритмы без тестов - это полдела. Алгоритмы с тестами - это профессиональный уровень.

Открой свой редактор кода. Запусти линейный поиск. Поиграйся с массивом - добавь 10 имён, 20 имён. Почувствуй, как работает O(n) на практике. Проверь крайние случаи: массив с одним элементом, отсутствующий элемент. Это лучшее, что ты можешь сделать прямо сейчас для своего роста как разработчика.