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

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

C++ SELF
Рівень 22 , Лекція 4
Відкрита

1. Вступ

Якщо раніше ви писали цикли вручну, алгоритми STL можуть здаватися магією: «А де тут узагалі відбувається робота?». Насправді ідея дуже проста: стандартна бібліотека дає вам готовий двигун — алгоритм, а ви передаєте йому правило поведінки. І саме це правило найзручніше задавати лямбдою: маленькою, локальною й читабельною, просто поруч із місцем використання.

Уявіть, що 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(), ...), так і хочеться запитати: «А що тут за третій параметр?». Третій параметр — компаратор, тобто викличний обʼєкт, який каже, чи має 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 приймає предикат — викличний обʼєкт, який повертає 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 приймає вхідний діапазон, вихідний ітератор і викличний обʼєкт, який повертає новий елемент.

Перетворюємо числа: підносимо до квадрата

#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
Опитування
Лямбди і callable-обʼєкти, рівень 22, лекція 4
Недоступний
Лямбди і callable-обʼєкти
Лямбди і callable-обʼєкти
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ