JavaRush /Курси /C++ SELF /Порівняння double ...

Порівняння double через epsilon: abs( a - b) < eps

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

1. Вступ

Коли ви вперше бачите double, рука сама тягнеться написати if (a == b). Це природно: мозок звик, що «дорівнює» — це просто «дорівнює». Проблема в тому, що для дійсних чисел == перевіряє не «математичну рівність», а збіг конкретних значень, які можна зберегти в памʼяті. А числа на кшталт 0.1 у двійковій системі часто зберігаються як найближче значення, а не як «ідеальні 0.1». Тому в реальних обчисленнях == легко стає генератором випадкових сюрпризів.

Найвідоміший приклад:

#include <iostream>

int main() {
    double a = 0.1 + 0.2;
    double b = 0.3;

    std::cout << (a == b) << '\n'; // зазвичай 0
}

Чому «зазвичай»? Тому що точний результат залежить від деталей подання й виведення, але загальна ідея незмінна: не варто розраховувати на точну рівність результатів обчислень із дробами.

Варто зауважити одну тонкість: якщо ви порівнюєте значення, які самі задали однаковими літералами (наприклад, double x = 1.5; double y = 1.5;), == цілком може бути доречним. Проблеми починаються там, де є арифметика, цикли, формули, перетворення, уведення користувача тощо — тобто майже всюди, де програма справді робить щось корисне.

2. Epsilon-порівняння: що означає abs(a - b) < eps

Щоб не боротися з похибкою, ми вводимо просту «інженерну» ідею: числа вважаються рівними, якщо вони достатньо близькі. Цю «достатню близькість» ми задаємо числом eps (epsilon). У цей момент математика трохи зітхає, зате інженерія радісно потирає руки: тепер ми порівнюємо не ідеал, а допуск — як на кресленнях деталей.

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

\[ |a - b| < eps \]

У коді це виглядає так (поки без <cmath>, бо наступна лекція саме про нього):

#include <iostream>

int main() {
    double a = 0.1 + 0.2;
    double b = 0.3;

    double diff = a - b;
    if (diff < 0.0) diff = -diff;   // власний abs

    const double eps = 1e-12;
    std::cout << (diff < eps) << '\n'; // зазвичай 1
}

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

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

3. Як вибрати eps: домовленість, а не «магічне число»

Вибираючи eps, новачки зазвичай впадають у дві крайнощі: або ставлять 0.0000000000000000000001 (щоб уже напевно!), або беруть 0.1 (бо «яка різниця»). Насправді eps — це частина умов вашої задачі. Він залежить від того, у яких одиницях ви рахуєте, які у вас масштаби чисел і яка точність узагалі має сенс для цих даних.

Уявіть, що ви вимірюєте відстань між містами в кілометрах. Там eps = 1e-12 — точність без практичного сенсу: так не працює ані введення, ані карта, ані GPS. А якщо ви виконуєте обчислення у фізиці з малими величинами, навпаки, eps = 1e-3 може виявитися надто грубим.

Зручно тримати в голові таку табличку. Це не закон, а радше орієнтир для новачка:

Контекст задачі (приблизно) Розумний порядок eps
«шкільна математика», прості обчислення на double 1e-121e-9
координати/геометрія у «звичайних» одиницях 1e-91e-6
гроші/ціни (якщо вже ви рахуєте в double, що доволі спірно) 1e-61e-2 (залежить від валюти/округлення)
значення з шумом (вимірювання, датчики) залежить від рівня шуму в даних

Ключовий принцип: eps має бути співмірним зі змістом задачі. Якщо ви виводите число з точністю до 2 знаків після коми, то eps = 1e-12 виглядає як спроба виміряти температуру чайника мікроскопом.

Дещо пізніше, уже на більш просунутому рівні, зʼявиться поняття відносної похибки, коли eps залежить від масштабу чисел. Але сьогодні наша мета — навчитися базової стійкості без перевантаження теорією.

4. Функції для стійких порівнянь

Порівняння через epsilon зручно винести в окрему функцію, бо інакше код швидко перетворюється на набір змінних diff і випадкових констант. Почнемо розвивати наш сьогоднішній мініпроєкт: консольний «Triangle Inspector», який зчитує сторони трикутника й визначає його тип. Сьогодні нам потрібна перша цеглинка — функція порівняння.

Майже рівні: almostEqual і abs власноруч

Спочатку зробимо власний abs для double (лише на базових операторах):

double absDouble(double x) {
    if (x < 0.0) return -x;
    return x;
}

Тепер — «майже дорівнює»:

bool almostEqual(double a, double b, double eps) {
    return absDouble(a - b) < eps;
}

Мініперевірка в main:

#include <iostream>

int main() {
    const double eps = 1e-12;
    double a = 0.1 + 0.2;

    std::cout << almostEqual(a, 0.3, eps) << '\n'; // 1 (true)
}

Зверніть увагу: eps ми передаємо параметром, а не ховаємо всередині функції. Це зручно, бо різні частини програми можуть по-різному розуміти «достатню точність».

Невелика примітка на майбутнє: у стандартній бібліотеці є std::abs, і вона має багато перевантажень для різних типів. Але сьогодні ми свідомо пишемо «ручний» варіант, щоб не залежати від наступної лекції про <cmath>.

Порівняння на межі: менше/більше із «зазором»

Після того як ви прийняли ідею «майже дорівнює», виникає інше питання: а як бути з умовами на кшталт x < limit? Адже поблизу межі може виникнути ситуація: «має бути рівно limit», але вийшло limit + 1e-16, і програма раптом переходить у «більше», хоча за змістом це «те саме». Саме тому, коли порівняння відбувається біля порога, ми часто залишаємо «зазор» через epsilon.

Є простий практичний набір правил:

  • «строго менше» робимо як x < limit - eps
  • «строго більше» робимо як x > limit + eps
  • «приблизно дорівнює» — як abs(x - limit) < eps

Це можна оформити у вигляді функцій, щоб if читалися майже як звичайний текст:

bool definitelyLess(double a, double b, double eps) {
    return a < b - eps;
}

bool definitelyGreater(double a, double b, double eps) {
    return a > b + eps;
}

І короткий приклад:

#include <iostream>

int main() {
    const double eps = 1e-12;
    double x = 1.0;
    double limit = 1.0;

    std::cout << definitelyLess(x, limit, eps) << '\n'; // 0
}

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

5. Практичний приклад: визначаємо прямокутний трикутник

Тепер зберемо все в нашому мініпроєкті «Triangle Inspector». Формулювання просте: трикутник прямокутний, якщо виконується теорема Піфагора. Але якщо сторони мають тип double, перевірка через == може підвести, особливо якщо сторони отримані з обчислень (або введені з десятковою крапкою, або надійшли з попередніх формул).

Нам потрібно перевірити:

\[ a^2 + b^2 \approx c^2 \]

Спочатку зробимо невелику «нормалізацію»: знайдемо найбільшу сторону й назвемо її c. Без сортування й алгоритмів — лише парою if, бо ми поки що на базовому C++.

void sort3(double& a, double& b, double& c) {
    if (a > b) { double t = a; a = b; b = t; }
    if (b > c) { double t = b; b = c; c = t; }
    if (a > b) { double t = a; a = b; b = t; }
}

Перевірка прямокутності через epsilon:

bool isRightTriangle(double a, double b, double c, double eps) {
    sort3(a, b, c);
    return almostEqual(a * a + b * b, c * c, eps);
}

А тепер — main, який зчитує сторони й виводить результат:

#include <iostream>

int main() {
    double a = 0.0, b = 0.0, c = 0.0;
    std::cin >> a >> b >> c;

    const double eps = 1e-9;
    std::cout << isRightTriangle(a, b, c, eps) << '\n'; // 1 для 3 4 5
}

Якщо ввести 3 4 5, ви очікуєте 1 (true). Якщо ввести щось на кшталт 1 1 1, очікуєте 0. І головне: якщо ви отримаєте числа з якихось обчислень, наприклад із масштабування, нормалізації або ділення, цей код поводитиметься передбачувано, а не за принципом «сьогодні так, завтра ні».

Чому eps тут 1e-9, а не 1e-12? Тому що ми працюємо з квадратами, і похибка тут може візуально «зрости». Це не строгий розрахунок помилки, а практичний вибір, який часто виявляється цілком доречним для геометрії з введенням користувача.

6. Що робити з NaN і чому раптом усе false

Після лекції про NaN важливо розуміти: epsilon-порівняння не є «анти-NaN-закляттям». Якщо десь в обчисленнях у вас зʼявився NaN, то вираз a - b теж стане NaN, а далі absDouble(NaN) залишиться NaN, і порівняння виду NaN < eps дасть false. Збоку це схоже на «нічого не працює», але насправді така поведінка чесна: NaN не можна порівнювати як звичайне число.

На базовому рівні можна провести дуже просту перевірку: число x є NaN, якщо x != x. Будь-яке нормальне число дорівнює самому собі, а NaN — ні.

Наприклад, так можна захистити наші функції, не ускладнюючи дизайн:

bool isNaN(double x) {
    return x != x;
}

А перед важливими перевірками робити ранній вихід:

bool safeAlmostEqual(double a, double b, double eps) {
    if (isNaN(a) || isNaN(b)) return false;
    return almostEqual(a, b, eps);
}

Це не «повний захист від усього» (у нас ще є Infinity, та й узагалі — домени функцій), але така перевірка вже робить поведінку програми зрозумілішою: якщо вхідні дані погані, ми не вдаємо, що все нормально.

У наступній лекції, коли ми підключимо <cmath>, зʼявляться більш стандартні інструменти для подібних перевірок, але поки що тримаємося в межах базового матеріалу.

7. Типові помилки під час epsilon-порівняння

Помилка № 1: замінити == на epsilon один раз — і вважати, що проблему розвʼязано назавжди.
Іноді роблять abs(a - b) < 1e-12 в одному місці, а в іншому залишають if (x == 0.0) або if (y == limit). У результаті програма й далі «тремтить» на межах, просто тепер трохи рідше. Якщо ви вже прийняли ідею похибки, усі порівняння поруч з обчисленнями варто робити послідовно.

Помилка № 2: вибрати eps без звʼязку зі змістом задачі.
Часто буває так: хтось побачив в інтернеті 1e-9 і тепер ставить 1e-9 всюди — для відсотків, відстаней, цін і геометрії. eps — це частина вимог до точності. Якщо ви виводите значення з двома знаками після коми, допуск у 1e-12 не робить програму розумнішою — він робить її лише самовпевненішою.

Помилка № 3: забути про модуль різниці й порівнювати просто a - b < eps.
Якщо a менше за b, різниця відʼємна, і умова майже завжди буде істинною (наприклад, -100 < 1e-12 — так). Тому в epsilon-порівнянні завжди потрібен модуль: |a - b|, хай ручний, хай бібліотечний.

Помилка № 4: використовувати epsilon лише для «дорівнює», але не враховувати межі в x < limit.
Навіть якщо ви порівнюєте a і b через epsilon, порогові умови теж можуть ламатися на межі. У реальних задачах x < limit - eps і x > limit + eps часто дають значно стійкішу логіку, ніж «чесне» x < limit.

Помилка № 5: не розуміти, що NaN перетворює порівняння на «усе false».
Якщо десь зʼявився NaN, то і abs(a - b) < eps, і a < b, і a >= b можуть раптом почати повертати false, ламаючи розгалуження. Це не містика і не «компʼютер зламався», а специфіка NaN. Корисно хоча б уміти швидко перевіряти x != x і розуміти: «ага, ось чому умова не спрацювала».

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