JavaRush /Курси /C++ SELF /std::accumulate: підсумовування та прості агрегати

std::accumulate: підсумовування та прості агрегати

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

1. Вступ

Якщо вже зовсім чесно, програмісти люблять перетворювати все на числа. Навіть те, що числами не є. У вас є список оцінок — і ви хочете знайти суму. Є список задач — і ви хочете зрозуміти, скільки часу все разом займе. Є список рядків — і ви хочете зібрати з них один рядок звіту. У всіх цих випадках логіка однакова: ми беремо елементи по черзі й накопичуємо спільний результат.

Цю ідею називають згорткою (reduction / fold). Вручну ви робили це вже багато разів: створювали змінну sum = 0, проходилися циклом і щось додавали. std::accumulate — це стандартний, читабельний і доволі безпечний спосіб виразити таку операцію без службових змінних і зайвого шуму. Жодної магії тут немає: усередині це все одно звичайний цикл, просто акуратно запакований.

Щоб не сприймати accumulate як заклинання, тримайте в голові дуже просту модель:

flowchart LR
    A[init] --> B{беремо наступний елемент}
    B --> C["оновлюємо накопичувач: acc = op(acc, x)"]
    C --> B
    B --> D[кінець діапазону]
    D --> E[повертаємо acc]

2. Підключення <numeric> і сигнатура std::accumulate

Зараз буде важливий практичний момент: багато алгоритмів містяться в <algorithm>, але std::accumulate — в іншому заголовку. І це одна з найчастіших причин запитання «чому воно не компілюється?!» у початківців. Тому запамʼятайте: accumulate міститься в <numeric>.

Базова форма має такий вигляд:

std::accumulate(first, last, init)

Вона проходить по діапазону [first, last) і підсумовує елементи, починаючи з init. Є й розширена форма — із власною операцією:

std::accumulate(first, last, init, op)

Тут op — це функція, яка визначає, як саме накопичувати результат.

Мініприклад: сума цілих чисел

#include <iostream>
#include <numeric>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3, 4};
    int sum = std::accumulate(v.begin(), v.end(), 0);

    std::cout << sum << '\n'; // 10
}

Тут важливо, що 0 — це стартове значення. Якби вектор був порожнім, результат однаково був би 0. І це дуже корисна поведінка.

Чому init впливає на тип результату

Коли початківці вперше чують, що init впливає на тип, реакція часто така: «Та годі, це ж просто старт». Але в C++ тип результату справді привʼязаний до init, тому що накопичувач починається саме з нього. Якщо ви стартуєте з 0 (це int), то й накопичення, найімовірніше, відбуватиметься в int. Якщо ж стартуєте з 0LL, то накопичувач буде типу long long.

Це особливо важливо, коли сума може не вміститися в int. І так, це та сама «шкільна» проблема переповнення, яка в реальному коді раптом обертається на баг: «у нас загальний бюджет став відʼємним, зате красиво».

Мініприклад: переповнення int і акуратний long long

#include <iostream>
#include <numeric>
#include <vector>

int main() {
    std::vector<int> prices{1'000'000'000, 1'000'000'000, 1'000'000'000};

    long long total = std::accumulate(prices.begin(), prices.end(), 0LL);
    std::cout << total << '\n'; // 3000000000
}

Якби ми написали 0 замість 0LL, накопичення відбувалося б у int, і результат міг би «зламатися» — залежно від платформи та компілятора. Тому практичне правило просте: якщо є шанс, що сума велика, — стартуйте з 0LL.

Табличка-шпаргалка щодо init

Що рахуємо Типовий init Чому саме він
Сума int
0
нейтральний елемент додавання і тип int
Сума великих значень
0LL
нейтральний елемент, але тип long long
Сума double
0.0
щоб накопичення відбувалося в double
Добуток
1
нейтральний елемент множення
Склеювання рядків
std::string{}
порожній рядок як нейтральний елемент

3. Користувацька операція: рахуємо за полем struct

Досі ми користувалися «типовою поведінкою»: accumulate додавав елементи. Але насправді accumulate вміє обчислювати майже будь-який осмислений агрегат, якщо ви підкажете, як оновлювати накопичувач. Для початківців зручний проміжний варіант — підсумовувати не самі елементи, а поле всередині struct.

Уявімо, що в нас є задачі з оцінкою часу. Продовжимо приклад з умовним навчальним застосунком «мінітрекер задач».

Модель даних

#include <string>

struct Task {
    int id{};
    std::string title;
    int minutes{};   // оцінка часу на задачу
    bool done{};
};

Тепер хочемо порахувати, скільки хвилин займають усі задачі разом. Вектор задач — це std::vector<Task>, а результат — число. Отже, операція має вміти взяти acc (число) і Task та повернути оновлене число.

Мініприклад: підсумовуємо час задач через функцію

#include <numeric>
#include <vector>

int add_minutes(int acc, const Task& t) {
    return acc + t.minutes;
}

А ось який це має вигляд на практиці:

#include <iostream>
#include <numeric>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Read", 30, true}, {2, "Code", 50, false}};

    int total = std::accumulate(tasks.begin(), tasks.end(), 0, add_minutes);
    std::cout << total << '\n'; // 80
}

Зверніть увагу на порядок параметрів: для accumulate функцію зручно уявляти як op(accumulator, element), тобто «онови накопичувач на основі чергового елемента».

4. Інші агрегати: добуток, конкатенація, лічильник

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

Добуток: нейтральний елемент — 1

Якщо ви почнете добуток із 0, то назавжди отримаєте нуль, бо 0 * що завгодно = 0. Тому правильний старт тут — 1.

#include <iostream>
#include <numeric>
#include <vector>

int mul(int acc, int x) {
    return acc * x;
}

int main() {
    std::vector<int> v{2, 3, 4};
    int product = std::accumulate(v.begin(), v.end(), 1, mul);

    std::cout << product << '\n'; // 24
}

Конкатенація рядків: накопичуємо std::string

Так, accumulate вміє збирати рядок. Але тут є одна тонкість: часта конкатенація може коштувати дорого через перевиділення памʼяті. Ми поки не заглиблюватимемося в оптимізацію, але чесно скажемо: для невеликих обсягів даних це нормально, а для дуже великих уже варто замислитися.

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

std::string join_with_space(std::string acc, const std::string& s) {
    if (!acc.empty()) acc += ' ';
    acc += s;
    return acc;
}

int main() {
    std::vector<std::string> words{"C++", "is", "fine"};
    std::string line = std::accumulate(words.begin(), words.end(), std::string{}, join_with_space);

    std::cout << line << '\n'; // C++ is fine
}

Тут функція приймає acc за значенням, бо все одно його змінює і повертає. Для початківця це хороший компроміс між зрозумілістю та простотою: «беремо поточне накопичене, додаємо шматочок, повертаємо».

Лічильник виконаних задач через accumulate

Формально для цього є count_if, і найчастіше він виглядає природніше. Але як вправа на розуміння згортки це чудовий приклад: ваш накопичувач буде числом, а елемент — задачею.

#include <numeric>
#include <vector>

int add_done_count(int acc, const Task& t) {
    return acc + (t.done ? 1 : 0);
}

Використання:

#include <iostream>
#include <numeric>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Read", 30, true}, {2, "Code", 50, false}, {3, "Sleep", 10, true}};
    int done_cnt = std::accumulate(tasks.begin(), tasks.end(), 0, add_done_count);

    std::cout << done_cnt << '\n'; // 2
}

5. Використовуємо accumulate у мінізастосунку задач

Зараз зробимо найпрактичніший крок: використаємо accumulate не як окремий «демотрюк», а як частину логіки застосунку. Уявіть, що в нас є команда «stats», яка виводить коротку статистику: скільки задач усього, скільки виконано і скільки хвилин сумарно. Кількість задач — це tasks.size(), а от виконані задачі та хвилини — уже агрегати.

Щоб не перетворювати main на простирадло, винесемо ці обчислення в маленькі функції. Це відповідає ідеї «тонкий main» із ранніх лекцій: код легше читати, перевіряти очима і змінювати.

Мініприклад: функція «усього хвилин»

#include <numeric>
#include <vector>

int total_minutes(const std::vector<Task>& tasks) {
    return std::accumulate(tasks.begin(), tasks.end(), 0, add_minutes);
}

Мініприклад: функція «усього виконаних»

#include <numeric>
#include <vector>

int done_count(const std::vector<Task>& tasks) {
    return std::accumulate(tasks.begin(), tasks.end(), 0, add_done_count);
}

Мініприклад: друк статистики

#include <iostream>
#include <vector>

void print_stats(const std::vector<Task>& tasks) {
    std::cout << "tasks: " << tasks.size() << '\n';                 // tasks: 3
    std::cout << "done: " << done_count(tasks) << '\n';             // done: 2
    std::cout << "minutes total: " << total_minutes(tasks) << '\n'; // minutes total: 90
}

Мініприклад: невеликий main, який показує stats

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Read", 30, true}, {2, "Code", 50, false}, {3, "Sleep", 10, true}};
    print_stats(tasks);
}

Зауважте, як main став майже декларативним: «ось дані, ось статистика». Логіка підрахунків живе окремо, і це хороший стиль.

Порожній діапазон: що повернеться і чому це зручно

У реальній програмі порожні списки трапляються часто. Користувач іще не додав задач, файл іще не завантажився, фільтр усе відсіяв. І саме тут проявляється дуже приємна властивість: std::accumulate на порожньому діапазоні повертає init.

Це означає, що якщо ви правильно обрали нейтральний елемент, то отримаєте «безпечну математику» без if (empty()) .... Наприклад, сума порожнього списку — 0, добуток порожнього списку — 1, а склеювання рядків — порожній рядок. Не треба писати зайвих перевірок, якщо поведінка і без того коректна.

Для початківця це справжній подарунок: менше розгалужень — менше помилок.

6. Типові помилки під час роботи зі std::accumulate

Помилка № 1: забули #include <numeric>.
У такому разі компілятор може сваритися так, ніби ви викликали неіснуючу функцію, хоча ви точно знаєте, що вона є. Лікується це просто: accumulate міститься в <numeric>, а не в <algorithm>. Якщо ви підключили тільки <algorithm>, то std::accumulate може бути «не знайдено».

Помилка № 2: неправильний init ламає зміст результату.
Найкласичніший приклад — добуток зі стартовим 0. Код компілюється, програма працює, але результат завжди 0, і ви починаєте підозрювати математику в саботажі. Тут важливо не плутати стартове значення з нейтральним елементом: для множення нейтральний елемент — 1, для додавання — 0.

Помилка № 3: не контролюють тип накопичення й отримують переповнення.
Якщо ви підсумовуєте великі числа й стартуєте з 0 (тип int), то фактично самі даєте команду «рахуй у int». Правильніше стартувати з 0LL, якщо результат може бути великим. Це не «оптимізація», а коректність.

Помилка № 4: операцію op написано з неправильним порядком аргументів.
Іноді за звичкою пишуть функцію на кшталт op(element, acc), а accumulate очікує op(acc, element). У результаті можна отримати дивні помилки компіляції або некоректну логіку, якщо типи випадково збіглися. Тримайте в голові просту модель: «перший аргумент — накопичувач, другий — поточний елемент».

Помилка № 5: намагаються зробити в op «побічні ефекти» (друк, зміну контейнера).
Технічно друкувати всередині op можна, але тоді згортка перетворюється на «цикл із побічними ефектами», який гірше читається і складніше налагоджується. До того ж операцію згортки краще уявляти як чисту функцію: вхід → вихід, без сюрпризів. Якщо хочеться друкувати — друкуйте зовні, окремим кодом.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ