⚙️ C++

Алгоритмы поиска в C++: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представьте, что вы стоите перед огромным стеллажом в библиотеке, на котором в строгом алфавитном порядке расставлены 10 000 книг. Вам нужно найти книгу «Программирование на C++ для начинающих». Как вы будете действовать?

Первый способ (наивный): Вы начинаете просматривать книги одну за другой, начиная с первой полки. Вы смотрите на корешок, читаете название, переходите к следующей. Рано или поздно вы найдёте нужную книгу, но на это может уйти несколько часов. Это — линейный поиск.

Второй способ (умный): Вы открываете каталог, видите, что книга находится в секции «Языки программирования», подсекции «C++», на полке №7. Вы сразу идёте к нужной полке и находите книгу за 2 минуты. Это — индексный поиск (как в базах данных).

Третий способ (гениальный): Вы знаете, что книги стоят по алфавиту. Вы открываете книгу примерно посередине стеллажа. Видите, что это буква «М». Ваша книга на букву «П» — она правее. Вы отбрасываете левую половину и повторяете процесс с правой половиной. Снова делите пополам, снова отбрасываете ненужную часть. Всего за 14 шагов (потому что 214 ≈ 16 000) вы гарантированно найдёте любую книгу. Это — бинарный поиск.

💡 Важно: Алгоритмы поиска — это не просто «найти элемент в массиве». Это фундамент, на котором строятся базы данных, поисковые системы (Google), антивирусы, игры и даже искусственный интеллект. Если вы научитесь понимать поиск, вы поймёте, как работает половина всей компьютерной науки.

В программировании мы постоянно ищем: пользователя в базе данных, слово в тексте, файл в папке, кратчайший путь на карте. Без эффективных алгоритмов поиска современный мир просто остановился бы — ваш браузер загружал бы страницу несколько часов, а не секунд.

Теория: как это работает изнутри

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

1. Линейный поиск (Linear Search)

Суть: Мы проходим по каждому элементу массива от начала до конца и сравниваем с искомым значением. Как только нашли — возвращаем индекс. Если дошли до конца и не нашли — возвращаем -1 (или другой сигнал «не найдено»).

Сложность: O(n) — в худшем случае (когда элемента нет) мы проверим все n элементов. В лучшем случае (элемент первый) — O(1).

Когда использовать: Когда массив маленький (до 100 элементов) или когда массив не отсортирован. Это единственный универсальный поиск.

2. Бинарный поиск (Binary Search)

Суть: Работает ТОЛЬКО на отсортированных данных. Мы смотрим на середину массива. Если искомое значение меньше среднего — ищем в левой половине. Если больше — в правой. Повторяем, пока не найдём или пока границы не сойдутся.

Сложность: O(log n) — логарифмическая. Для массива из 1 000 000 элементов нужно всего 20 сравнений (потому что 220 ≈ 1 000 000).

Важное условие: Массив должен быть отсортирован по возрастанию (или убыванию). Если данные не отсортированы — бинарный поиск выдаст мусор.

3. Поиск в глубину и ширину (для графов)

Это уже продвинутая тема, но упомянуть стоит. Если у нас есть граф (например, карта метро или социальная сеть), мы можем искать:

  • DFS (Depth-First Search) — идём до конца одной ветки, потом возвращаемся и идём по другой. Как исследователь пещеры: идёшь в самый дальний зал, потом возвращаешься к развилке.
  • BFS (Breadth-First Search) — идём по всем соседям, потом по соседям соседей. Как волна от брошенного камня: сначала круг, потом шире.

4. Хеш-поиск (Hash Search)

Самый быстрый поиск — O(1) в среднем. Мы используем хеш-таблицу (unordered_map в C++). Каждому ключу соответствует уникальный адрес (хеш). Мы сразу знаем, где лежит значение, без перебора. Как гардеробщик, который помнит, на какой вешалке ваше пальто, потому что у вас номерок.

Синтаксис в C++: все формы и варианты

В C++ есть несколько способов реализовать поиск. Давайте разберём каждый.

1. Ручная реализация (для понимания)

Вы пишете цикл сами. Это лучший способ понять, как работает алгоритм.

2. Стандартная библиотека (STL)

C++ предоставляет готовые функции. Их нужно знать и использовать в реальных проектах.

Функция Заголовочный файл Что делает Сложность Требования к данным
std::find <algorithm> Линейный поиск первого вхождения O(n) Нет
std::binary_search <algorithm> Проверяет, есть ли элемент (да/нет) O(log n) Отсортирован
std::lower_bound <algorithm> Ищет первый элемент НЕ МЕНЬШЕ искомого O(log n) Отсортирован
std::upper_bound <algorithm> Ищет первый элемент БОЛЬШЕ искомого O(log n) Отсортирован
std::find_if <algorithm> Ищет элемент по условию (предикату) O(n) Нет
std::search <algorithm> Ищет подпоследовательность в последовательности O(n*m) Нет

3. Поиск в контейнерах

У разных контейнеров свои методы поиска:

  • std::map::find() — O(log n), бинарный поиск по ключу
  • std::unordered_map::find() — O(1) в среднем, хеш-поиск
  • std::set::find() — O(log n)
  • std::string::find() — O(n), поиск подстроки

Примеры кода — от простого к сложному

Пример 1: Линейный поиск вручную (база)

#include <iostream>
using namespace std;

// Функция линейного поиска
int linearSearch(int arr[], int size, int target) {
    // Проходим по каждому элементу массива
    for (int i = 0; i < size; i++) {
        // Если нашли искомое значение
        if (arr[i] == target) {
            return i;  // Возвращаем индекс
        }
    }
    return -1;  // Если не нашли, возвращаем -1
}

int main() {
    int numbers[] = {5, 3, 8, 1, 9, 2, 7};
    int size = sizeof(numbers) / sizeof(numbers[0]);  // Вычисляем размер массива
    int target = 9;
    
    int result = linearSearch(numbers, size, target);
    
    if (result != -1) {
        cout << "Элемент " << target << " найден на индексе " << result << endl;
    } else {
        cout << "Элемент не найден" << endl;
    }
    
    return 0;
}

Объяснение: Мы просто идём по массиву слева направо. Это самый простой, но и самый медленный способ. Подходит для маленьких массивов.

Пример 2: Бинарный поиск вручную

#include <iostream>
using namespace std;

// Функция бинарного поиска (массив должен быть отсортирован!)
int binarySearch(int arr[], int left, int right, int target) {
    while (left <= right) {
        // Находим середину (безопасно, без переполнения)
        int mid = left + (right - left) / 2;
        
        // Если нашли элемент в середине
        if (arr[mid] == target) {
            return mid;
        }
        
        // Если искомое меньше среднего — ищем в левой половине
        if (arr[mid] > target) {
            right = mid - 1;
        }
        // Если искомое больше среднего — ищем в правой половине
        else {
            left = mid + 1;
        }
    }
    return -1;  // Элемент не найден
}

int main() {
    // ВАЖНО: массив отсортирован по возрастанию!
    int numbers[] = {1, 3, 5, 7, 9, 11, 13, 15};
    int size = sizeof(numbers) / sizeof(numbers[0]);
    int target = 7;
    
    int result = binarySearch(numbers, 0, size - 1, target);
    
    if (result != -1) {
        cout << "Элемент " << target << " найден на индексе " << result << endl;
    } else {
        cout << "Элемент не найден" << endl;
    }
    
    return 0;
}

Объяснение: Мы постоянно делим массив пополам. Обратите внимание на формулу mid = left + (right - left) / 2 — это безопасный способ избежать переполнения при больших числах.

Пример 3: Использование std::find (STL)

#include <iostream>
#include <algorithm>  // Для std::find
#include <vector>
using namespace std;

int main() {
    vector<int> numbers = {10, 20, 30, 40, 50, 60};
    int target = 40;
    
    // std::find возвращает итератор на найденный элемент
    auto it = find(numbers.begin(), numbers.end(), target);
    
    // Проверяем, нашли ли мы элемент
    if (it != numbers.end()) {
        // Вычисляем индекс через разность итераторов
        int index = distance(numbers.begin(), it);
        cout << "Элемент " << target << " найден на индексе " << index << endl;
    } else {
        cout << "Элемент не найден" << endl;
    }
    
    return 0;
}

Объяснение: std::find — это готовая функция линейного поиска. Она работает с любыми контейнерами (vector, list, array). Итератор — это как «указатель» на элемент.

Пример 4: std::binary_search и std::lower_bound

#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

int main() {
    vector<int> data = {1, 3, 3, 3, 5, 7, 9, 11};
    int target = 3;
    
    // 1. binary_search — просто проверяет, есть ли элемент
    bool exists = binary_search(data.begin(), data.end(), target);
    cout << "Элемент " << target << " существует: " << boolalpha << exists << endl;
    
    // 2. lower_bound — первый элемент НЕ МЕНЬШЕ target
    auto low = lower_bound(data.begin(), data.end(), target);
    if (low != data.end()) {
        int index = distance(data.begin(), low);
        cout << "Первый элемент не меньше " << target << " на индексе " << index << endl;
        cout << "Значение: " << *low << endl;
    }
    
    // 3. upper_bound — первый элемент БОЛЬШЕ target
    auto up = upper_bound(data.begin(), data.end(), target);
    if (up != data.end()) {
        int index = distance(data.begin(), up);
        cout << "Первый элемент больше " << target << " на индексе " << index << endl;
        cout << "Значение: " << *up << endl;
    }
    
    // 4. Количество вхождений через lower_bound и upper_bound
    int count = distance(low, up);
    cout << "Количество вхождений " << target << ": " << count << endl;
    
    return 0;
}

Объяснение: lower_bound и upper_bound — мощные инструменты для работы с отсортированными данными. Они позволяют найти диапазон одинаковых элементов.

Пример 5: Поиск в map (ассоциативный массив)

#include <iostream>
#include <map>
#include <string>
using namespace std;

int main() {
    // Создаём телефонную книгу: имя -> номер телефона
    map<string, string> phoneBook;
    
    // Добавляем записи
    phoneBook["Иван"] = "+7-999-123-45-67";
    phoneBook["Мария"] = "+7-999-765-43-21";
    phoneBook["Пётр"] = "+7-999-555-55-55";
    
    string name = "Мария";
    
    // Поиск по ключу через find()
    auto it = phoneBook.find(name);
    
    if (it != phoneBook.end()) {
        // it->first — ключ, it->second — значение
        cout << "Телефон " << name << ": " << it->second << endl;
    } else {
        cout << "Абонент " << name << " не найден" << endl;
    }
    
    // Альтернатива: count() — проверяет, есть ли ключ
    if (phoneBook.count("Пётр") > 0) {
        cout << "Пётр есть в книге" << endl;
    }
    
    return 0;
}

Объяснение: std::map хранит пары ключ-значение в отсортированном порядке. Поиск по ключу занимает O(log n). Это как словарь: вы ищете слово (ключ) и получаете определение (значение).

Пример 6: Поиск с условием (std::find_if)

#include <iostream>
#include <algorithm>
#include <vector>
#include <string>
using namespace std;

int main() {
    vector<string> fruits = {"яблоко", "груша", "апельсин", "банан", "арбуз"};
    
    // Ищем первый фрукт, который начинается на 'а'
    // Лямбда-функция: [](const string& s) { return s[0] == 'а'; }
    auto it = find_if(fruits.begin(), fruits.end(), 
                      [](const string& s) { 
                          return s[0] == 'а';  // Условие: первый символ 'а'
                      });
    
    if (it != fruits.end()) {
        cout << "Первый фрукт на 'а': " << *it << endl;
    } else {
        cout << "Таких фруктов нет" << endl;
    }
    
    // Ищем все фрукты длиннее 5 букв
    cout << "Фрукты длиннее 5 букв: ";
    for_each(fruits.begin(), fruits.end(), [](const string& s) {
        if (s.length() > 5) {
            cout << s << " ";
        }
    });
    cout << endl;
    
    return 0;
}

Объяснение: find_if принимает функцию-предикат (условие). Лямбда-функция — это анонимная функция, которую можно создать прямо в месте вызова. Это очень гибкий инструмент.

Пример 7: Рекурсивный бинарный поиск

#include <iostream>
using namespace std;

// Рекурсивная версия бинарного поиска
int binarySearchRecursive(int arr[], int left, int right, int target) {
    // Базовый случай: границы сошлись, элемент не найден
    if (left > right) {
        return -1;
    }
    
    int mid = left + (right - left) / 2;
    
    // Если нашли — возвращаем индекс
    if (arr[mid] == target) {
        return mid;
    }
    
    // Рекурсивный случай: ищем в левой или правой половине
    if (arr[mid] > target) {
        return binarySearchRecursive(arr, left, mid - 1, target);
    } else {
        return binarySearchRecursive(arr, mid + 1, right, target);
    }
}

int main() {
    int numbers[] = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20};
    int size = sizeof(numbers) / sizeof(numbers[0]);
    int target = 14;
    
    int result = binarySearchRecursive(numbers, 0, size - 1, target);
    
    if (result != -1) {
        cout << "Элемент " << target << " найден на индексе " << result << endl;
    } else {
        cout << "Элемент не найден" << endl;
    }
    
    return 0;
}

Объяснение: Рекурсия — это когда функция вызывает саму себя. Каждый вызов работает с уменьшенной половиной массива. Базовый случай (left > right) останавливает рекурсию.

Пример 8: Поиск подстроки в строке

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

int main() {
    string text = "Быстрая коричневая лиса прыгает через ленивую собаку";
    string pattern = "лиса";
    
    // 1. Простой поиск подстроки
    size_t pos = text.find(pattern);
    if (pos != string::npos) {
        cout << "Подстрока '" << pattern << "' найдена на позиции " << pos << endl;
    } else {
        cout << "Подстрока не найдена" << endl;
    }
    
    // 2. Поиск с указанием начальной позиции
    string word = "а";
    pos = text.find(word, 10);  // Ищем 'а' начиная с 10-й позиции
    cout << "Первая 'а' после 10-й позиции: " << pos << endl;
    
    // 3. Поиск с конца (rfind)
    pos = text.rfind("а");  // Ищем последнее вхождение 'а'
    cout << "Последняя 'а' на позиции: " << pos << endl;
    
    // 4. Поиск любого символа из набора (find_first_of)
    pos = text.find_first_of("0123456789");  // Ищем первую цифру
    if (pos == string::npos) {
        cout << "Цифр в тексте нет" << endl;
    }
    
    return 0;
}

Объяснение: string::find() — это мощный инструмент для работы с текстом. string::npos — специальная константа, означающая «не найдено».

Сравнение с Python: чем похоже и чем отличается

Аспект C++ Python
Линейный поиск std::find(vec.begin(), vec.end(), val) val in list или list.index(val)
Бинарный поиск std::binary_search(vec.begin(), vec.end(), val) bisect.bisect_left(list, val) (модуль bisect)
Поиск в словаре map.find(key) или map.count(key) key in dict или dict.get(key)
Поиск подстроки str.find(sub) sub in str или str.find(sub)
Скорость Очень быстрый (компилируемый язык) Медленнее (интерпретируемый язык)
Сложность синтаксиса Высокая (итераторы, шаблоны, указатели) Низкая (всё просто и интуитивно)
Контроль памяти Полный контроль (ручное управление) Автоматический (сборщик мусора)
💡 Совет: Если вы новичок — начните с Python, чтобы понять концепции. Если хотите стать профессиональным разработчиком — переходите на C++. В C++ вы будете лучше понимать, как работает память и почему алгоритмы работают именно так.

Главное отличие: В Python вы пишете if x in list: и не задумываетесь, как это работает. В C++ вы обязаны понимать, какой алгоритм используете, потому что от этого зависит производительность вашей программы.

Типичные ошибки и подводные камни

Ошибка 1: Бинарный поиск на неотсортированном массиве

int arr[] = {5, 3, 8, 1, 9};  // НЕ ОТСОРТИРОВАН!
bool found = binary_search(arr, arr + 5, 3);  // Может вернуть false, хотя 3 есть!

Решение: Всегда сортируйте массив перед бинарным поиском: sort(arr, arr + n);

Ошибка 2: Переполнение при вычислении середины

int mid = (left + right) / 2;  // ОПАСНО! Если left + right > INT_MAX

Решение: Используйте int mid = left + (right - left) / 2;

Ошибка 3: Забыли проверить итератор на end()

auto it = find(vec.begin(), vec.end(), 42);
cout << *it;  // ОПАСНО! Если 42 нет, it == end(), разыменование — UB

Решение: Всегда проверяйте: if (it != vec.end()) { ... }

Ошибка 4: Путаница между lower_bound и upper_bound

lower_bound — первый элемент НЕ МЕНЬШЕ (>=). upper_bound — первый элемент БОЛЬШЕ (>). Для поиска точного значения используйте binary_search или проверку *lower_bound == target.

Ошибка 5: Использование find() на map вместо find_if

map.find(key) ищет по ключу. Если нужно найти по значению — используйте find_if с лямбдой.

Ошибка 6: Игнорирование константности

void search(const vector<int>& vec, int target) {
    // find() работает, но lower_bound() требует random access итератор
    auto it = find(vec.begin(), vec.end(), target);  // OK
}

Лучшие практики и советы профессионалов

  1. Всегда используйте STL, если можно. Не пишите велосипеды. std::find, std::binary_search — это отлаженный код, который работает быстро и правильно.
  2. Знайте сложность алгоритмов. Для маленьких данных (n < 100) линейный поиск может быть быстрее бинарного из-за накладных расходов на сортировку.
  3. Сортируйте один раз. Если вам нужно много раз искать в одном массиве — отсортируйте его один раз и используйте бинарный поиск.
  4. Используйте unordered_map для максимальной скорости. Если порядок элементов не важен, хеш-таблица даёт O(1) на поиск.
  5. Для строк используйте string::find(). Она оптимизирована производителями компиляторов (часто использует алгоритм Бойера-Мура).
  6. Пишите тесты. Проверяйте граничные случаи: пустой массив, один элемент, элемент в начале, в конце, отсутствующий элемент.
  7. Используйте constexpr для константных данных. Если массив известен на этапе компиляции, можно вычислить поиск во время компиляции.
  8. Профилируйте. Не гадайте, какой алгоритм быстрее. Используйте профайлер (например, Valgrind или встроенный в IDE).
🔥 Совет профи: В реальных проектах вы будете использовать не массивы, а базы данных. Там поиск — это SQL-запросы: SELECT * FROM users WHERE name = 'Иван'. Но понимание алгоритмов поиска поможет вам писать эффективные SQL-запросы и понимать, почему один запрос выполняется 0.1 секунды, а другой — 10 секунд.

Вопросы для самопроверки

  1. Вопрос: В чём главное отличие линейного поиска от бинарного?
    Ответ: Линейный поиск работает на любых данных, но медленный (O(n)). Бинарный поиск работает только на отсортированных данных, но быстрый (O(log n)).
  2. Вопрос: Почему формула mid = (left + right) / 2 может быть опасной?
    Ответ: При больших значениях left и right их сумма может превысить максимальное значение int (2 147 483 647), что приведёт к переполнению и отрицательному mid.
  3. Вопрос: Что вернёт std::find, если элемент не найден?
    Ответ: Итератор на end() контейнера (не на nullptr!).
  4. Вопрос: Как найти количество вхождений элемента в отсортированном массиве?
    Ответ: Использовать std::lower_bound и std::upper_bound, затем distance(lower, upper).
  5. Вопрос: В каком случае бинарный поиск будет медленнее линейного?
    Ответ: Когда массив очень маленький (n < 10-20) или когда данные не отсортированы и их нужно сначала сортировать (O(n log n)).
  6. В