JavaRush /Курсы /C++ SELF /Лямбды в алгоритмах: sort, remove_if, transform

Лямбды в алгоритмах: sort, remove_if, transform

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

1. Введение

Если вы раньше писали циклы руками, то алгоритмы STL могут казаться магией уровня «а где тут вообще происходит работа?». Но на практике идея очень человеческая: стандартная библиотека даёт вам готовый двигатель (алгоритм), а вы передаёте ему правило поведения (callable). И вот это правило чаще всего удобнее всего выразить лямбдой — маленькой, локальной и читаемой прямо рядом с местом использования.

Представьте, что std::sort — это “робот-сортировщик”, который умеет переставлять элементы как угодно быстро, но он не умеет читать ваши мысли. Ему нужно объяснить, что «раньше»: меньше по числу, раньше по алфавиту, выше приоритет, сначала невыполненные задачи… Это правило и есть компаратор — функция вида bool comp(a, b).

Аналогично, std::remove_if — это “робот-уборщик”, который проходит по контейнеру и отделяет нужное от ненужного по вашему критерию. А std::transform — “робот-преобразователь”, который берёт элемент и превращает его в другой элемент по вашему рецепту.

Чтобы не потеряться, держите в голове простую схему:

flowchart LR
    A[Контейнер] --> B[Алгоритм STL]
    C[Лямбда = правило] --> B
    B --> D[Результат: изменённый контейнер или новый набор данных]

Алгоритм — это “как делать”, лямбда — это “по какому правилу”.

2. std::sort: компаратор-лямбда и правило “строго раньше”

Когда вы впервые видите std::sort(v.begin(), v.end(), ...), хочется спросить: «а третий параметр — это кто?». Третий параметр — компаратор, то есть callable, который говорит, должен ли a идти раньше b.

Важно: компаратор должен отвечать именно “строго раньше”, то есть обычно используется <, а не <=. Это не придирка, а фундаментальная логика сортировки: если вы начнёте говорить, что a <= b означает “a раньше b”, сортировка получит странные противоречивые правила.

Самый простой пример: сортировка чисел по убыванию

Начнём с чего-то максимально приземлённого. Вектор чисел сортируется по возрастанию “из коробки”, но по убыванию — уже нужен компаратор:

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{5, 1, 7, 2};

    std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });

    for (int x : v) std::cout << x << ' ';
    std::cout << '\n'; // 7 5 2 1
}

Здесь лямбда [](int a, int b) { return a > b; } означает: “считай, что a должно идти раньше b, если a больше”.

Сортировка наших моделей: мини-приложение “Список задач”

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

#include <string>

struct Task {
    std::string title;
    int priority{};   // чем больше, тем важнее
    bool done{};
};

Теперь отсортируем задачи по убыванию приоритета:

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{
        {"Buy milk", 2, false},
        {"Finish report", 5, false},
        {"Clean room", 1, true}
    };

    std::sort(tasks.begin(), tasks.end(),
              [](const Task& a, const Task& b) {
                  return a.priority > b.priority;
              });

    for (const auto& t : tasks) {
        std::cout << t.priority << " - " << t.title << '\n';
    }
}

Обратите внимание на параметры компаратора: const Task&. Это почти всегда хороший тон: мы не копируем большие объекты и не меняем их при сравнении.

Сортировка по нескольким полям: “сначала приоритет, потом название”

В реальности одного поля часто мало. Если два задания имеют одинаковый приоритет, хочется, чтобы порядок был стабильным и понятным. Например: одинаковый приоритет — сортируем по названию.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{
        {"Email client", 3, false},
        {"Buy milk", 3, false},
        {"Finish report", 5, false}
    };

    std::sort(tasks.begin(), tasks.end(),
              [](const Task& a, const Task& b) {
                  if (a.priority != b.priority) return a.priority > b.priority;
                  return a.title < b.title;
              });

    for (const auto& t : tasks) {
        std::cout << t.priority << " - " << t.title << '\n';
    }
}

Такой компаратор читается как обычное правило: “если приоритеты разные — сравни по приоритету, иначе — по названию”.

Очень важная мысль про компаратор

std::sort ожидает, что ваш компаратор задаёт “нормальный” порядок. Если компаратор начинает “врать” (например, иногда говорит, что a раньше b, а иногда наоборот для тех же значений), то сортировка может вести себя непредсказуемо. Это как пытаться построить очередь людей, если вы каждый раз по-новому решаете, кто выше ростом.

Чтобы закрепить, вот мини-табличка:

Что пишет компаратор Что это означает Хорошо?
return a < b;
“a раньше b, если меньше” Да
return a <= b;
“a раньше b, если меньше или равно” Плохо (нестрого)
return a > b;
“a раньше b, если больше” Да (сортировка по убыванию)
return a != b;
“a раньше b, если не равно” Очень плохо (хаос)

3. std::remove_if: “логическое удаление” по предикату

Слово remove_if звучит как «удали, пожалуйста», но у него есть характер. Он не “вырезает” элементы из контейнера сам по себе (контейнер не умеет менять размер от одного только алгоритма).

Вместо этого std::remove_if делает хитрый, но полезный трюк: он переставляет элементы так, чтобы “хорошие” оказались в начале, а “плохие” — в конце, и возвращает итератор на границу между ними — так называемый new end.

Если говорить проще: remove_if делает “уборку с переносом мебели”, но мусорный пакет (фактическое уменьшение размера) вы выносите отдельной операцией контейнера.

Предикат для remove_if

remove_if принимает предикат — callable, который возвращает true, если элемент надо считать “лишним”.

Например, удалим из vector<int> все отрицательные:

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{3, -1, 5, -7, 2};

    auto new_end = std::remove_if(v.begin(), v.end(),
                                 [](int x) { return x < 0; });

    v.erase(new_end, v.end()); // теперь реально удалили хвост

    for (int x : v) std::cout << x << ' ';
    std::cout << '\n'; // 3 5 2
}

Здесь важны две строки, которые живут парой: сначала remove_if, потом erase(new_end, end).

Удаляем выполненные задачи из нашего списка

Вернёмся к Task. Удалим все задачи, которые уже сделаны (done == true):

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{
        {"Buy milk", 2, true},
        {"Finish report", 5, false},
        {"Clean room", 1, true}
    };

    auto new_end = std::remove_if(tasks.begin(), tasks.end(),
                                 [](const Task& t) { return t.done; });

    tasks.erase(new_end, tasks.end());

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

Лямбда-предикат здесь максимально честная: “если задача выполнена — считаем её кандидатом на удаление”.

Захват в remove_if: удаляем задачи с приоритетом ниже порога

Иногда предикат зависит от настройки: например, мы хотим оставить только задачи с приоритетом не ниже min_priority. Это прекрасный случай для захвата по значению (порог — это “настройка”, её безопасно копировать).

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{
        {"Buy milk", 2, false},
        {"Finish report", 5, false},
        {"Clean room", 1, false}
    };

    int min_priority = 2;

    auto new_end = std::remove_if(tasks.begin(), tasks.end(),
                                 [min_priority](const Task& t) {
                                     return t.priority < min_priority;
                                 });

    tasks.erase(new_end, tasks.end());

    for (const auto& t : tasks) {
        std::cout << t.priority << " - " << t.title << '\n';
    }
    // 2 - Buy milk
    // 5 - Finish report
}

Почему тут приятно именно [min_priority], а не [&]? Потому что так вы явно фиксируете, что порог взят “как число” на момент создания предиката, и случайное изменение min_priority позже не изменит смысл уже написанного правила.

4. std::transform: “перевести элементы” в новый вид

std::transform часто недооценивают, потому что “ну можно же циклом”. Можно. Но transform делает намерение очень читабельным: “я беру каждый элемент и превращаю его по правилу”.

Это особенно удобно, когда вы строите производный массив данных: длины строк, только названия, только приоритеты, форматированные подписи. В базовой форме transform принимает входной диапазон, выходной итератор и callable, который возвращает новый элемент.

Преобразуем числа: возвести в квадрат

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3, 4};
    std::vector<int> squares(v.size());

    std::transform(v.begin(), v.end(), squares.begin(),
                   [](int x) { return x * x; });

    for (int x : squares) std::cout << x << ' ';
    std::cout << '\n'; // 1 4 9 16
}

Обратите внимание: выходной вектор squares мы заранее сделали нужного размера. transform не делает push_back сам — ему нужен “куда писать”.

Из задач делаем список названий

Теперь сделаем из vector<Task> список названий задач vector<string>. Это очень жизненный сценарий: например, хотим вывести только заголовки или отдать их другой функции.

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

int main() {
    std::vector<Task> tasks{
        {"Buy milk", 2, false},
        {"Finish report", 5, false}
    };

    std::vector<std::string> titles(tasks.size());

    std::transform(tasks.begin(), tasks.end(), titles.begin(),
                   [](const Task& t) { return t.title; });

    for (const auto& s : titles) std::cout << s << '\n';
    // Buy milk
    // Finish report
}

Лямбда возвращает t.title. Да, это копия строки — и это нормально, потому что мы создаём новый список строк. Если бы нам нужен был “вид” без копий, это уже другая история (и другая тема), а сегодня держим всё просто и честно.

“Сделать красиво”: форматируем строку отчёта

Ещё один приятный пример: превратим каждую задачу в строку вида "[ ] Title (p=5)".

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

int main() {
    std::vector<Task> tasks{
        {"Buy milk", 2, true},
        {"Finish report", 5, false}
    };

    std::vector<std::string> lines(tasks.size());

    std::transform(tasks.begin(), tasks.end(), lines.begin(),
                   [](const Task& t) {
                       std::string mark = t.done ? "[x] " : "[ ] ";
                       return mark + t.title + " (p=" + std::to_string(t.priority) + ")";
                   });

    for (const auto& line : lines) std::cout << line << '\n';
    // [x] Buy milk (p=2)
    // [ ] Finish report (p=5)
}

Здесь лямбда чуть длиннее, но всё ещё остаётся “одной мыслью”: собрать строку. Если вы ловите себя на том, что внутри лямбды уже хочется if/else if/else на 30 строк — это знак, что пора вынести в обычную функцию.

Мини-сценарий в одном main: сортировка → удаление → отчёт

Когда смотришь на sort/remove_if/transform по отдельности, кажется, что это три разных мира. На практике они отлично складываются в маленький “конвейер” обработки данных: сначала приводим порядок к удобному, потом чистим мусор, потом готовим данные для вывода.

Вот компактный пример “жизненного” main, который делает три шага. Обратите внимание: кусочки маленькие, но они уже похожи на настоящую программу.

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

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

int main() {
    std::vector<Task> tasks{
        {"Buy milk", 2, true},
        {"Finish report", 5, false},
        {"Clean room", 1, true},
        {"Call mom", 3, false}
    };

    std::sort(tasks.begin(), tasks.end(),
              [](const Task& a, const Task& b) { return a.priority > b.priority; });

    auto new_end = std::remove_if(tasks.begin(), tasks.end(),
                                 [](const Task& t) { return t.done; });
    tasks.erase(new_end, tasks.end());

    std::vector<std::string> lines(tasks.size());
    std::transform(tasks.begin(), tasks.end(), lines.begin(),
                   [](const Task& t) {
                       return t.title + " (p=" + std::to_string(t.priority) + ")";
                   });

    for (const auto& s : lines) std::cout << s << '\n';
    // Finish report (p=5)
    // Call mom (p=3)
}

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

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

Ошибка №1: компаратор для sort пишут через <= или >=.
Это выглядит логично (“ну я же хочу порядок”), но для сортировки нужен строгий порядок. Компаратор должен отвечать на вопрос “a раньше b?” и обычно строится на < или >. Если использовать <=, вы говорите, что “a раньше b” даже когда они равны — и алгоритму становится сложнее жить в логически непротиворечивом мире.

Ошибка №2: предикат в remove_if воспринимают как “оставить”, а не как “удалить”.
У remove_if предикат возвращает true для элементов, которые считаются “лишними”. Если перепутать смысл, можно удалить всё нужное и оставить мусор, а потом долго подозревать заговор стандартной библиотеки. Хорошая привычка — называть предикат мысленно как is_bad(element).

Ошибка №3: забывают, что после remove_if у vector размер не меняется автоматически.
Новички часто делают std::remove_if(...) и сразу печатают вектор, удивляясь “призракам” в конце. Правильная модель: remove_if возвращает new_end, а затем вы физически уменьшаете контейнер через erase(new_end, end).

Ошибка №4: для transform не готовят место под результат.
transform не делает контейнер “больше”. Если вы передали out.begin() в пустой out, вы получите запись “мимо диапазона” (а это уже территория очень неприятных ошибок). Минимальная дисциплина: std::vector<Result> out(input.size()); и только потом transform.

Ошибка №5: делают в лямбдах побочные эффекты там, где ожидается чистое правило.
Особенно часто это случается в компараторах и предикатах: кто-то печатает в cout, кто-то меняет глобальные переменные, кто-то пытается удалять элементы контейнера прямо внутри предиката. Такие лямбды превращают алгоритм в непредсказуемое приключение. В идеале компаратор и предикат должны быть “чистыми”: только проверка/сравнение, без вмешательства в мир вокруг.

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