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 и понять: «ага, вот почему условие не сработало».

1
Задача
C++ SELF, 10 уровень, 3 лекция
Недоступна
Ручной модуль
Ручной модуль
1
Задача
C++ SELF, 10 уровень, 3 лекция
Недоступна
Поиск NaN
Поиск NaN
1
Задача
C++ SELF, 10 уровень, 3 лекция
Недоступна
Почти равные
Почти равные
1
Задача
C++ SELF, 10 уровень, 3 лекция
Недоступна
Треугольник Пифагора
Треугольник Пифагора
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Anonymous #2923722 Уровень 17
19 мая 2026
Требование: Программа должна переставить значения sideA, sideB, sideC так, чтобы после перестановки sideC было наибольшей стороной, используя только if и обмен через временную переменную (без sort, контейнеров и без подхода “просто найти max без перестановки”). ////////////////////////// Мой вариант решения if(sideA >= sideB) { if(sideC < sideA) { double sideC2 = sideC; sideC = sideA; sideA = sideC2; } } else { if(sideC < sideB) { double sideC2 = sideC; sideC = sideB; sideB = sideC2; } } /////////// Валидатор Перестановка должна гарантировать, что после выполнения sideC — наибольшая сторона для любых входных значений. Текущая логика не меняет порядок во всех случаях (например, когда sideC уже не наибольшая, но меньше одного из sideA или sideB в комбинированных ситуациях), рекомендуем реализовать полную перестановку через три пары сравнений/обменов: если sideA > sideB обмен, затем если sideA > sideC обмен, затем если sideB > sideC обмен. /////// Вопрос ???? При каких вариантах входных данных мой вариант не сработает корректно (не сделает sideC наибольшей стороной)??????