Перейти к содержанию

Практика 2 Как люди придумывают алгоритмы?#

Алгоритмы лежат в основе всех вычислительных процессов и являются неотъемлемой частью решения многих задач в программировании. На практике разработка алгоритма требует не только теоретических знаний, но и творческого подхода, способности искать оптимальные пути решения. Алгоритмы могут быть различными по сложности и области применения, однако все они стремятся к одной цели — эффективному решению проблемы.

Во время этой практики мы будем исследовать, как люди подходят к созданию алгоритмов, с какими трудностями сталкиваются при их разработке, и какие методы помогают найти решение. Мы изучим основные шаги создания алгоритмов, включая формулировку проблемы, выбор подходящих данных и структур, а также методы оптимизации.

Разминка#

Наше восприятие задачи сильно зависит от того, встречались ли мы раньше с чем-то похожим или нет. Зачастую мы склонны выбирать наиболее понятной интуитивное решение. Например, на прошлой практике мы говорили об оценке алгоритмов. Итуитивно многие сразу сказали, что основным критерием оценки будет время. Однако, как выяснилось, алгоритмы можно оценить по использованной памяти.

Что кроме времени выполнения и ресурсов компьютера может быть также важно? Ответ, казалось бы очевиден и в тоже время невероятен - точность алгоритма является не менее интересным критерием качества в реальной жизни.

Однако как может быть "неточным" точное устройство?

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

Во-вторых, для целей вычислений мы можем намеренно создавать такой алгоритм, который округляет, огрубляет значения. Причин тому великое множетсво : от невозможности решить задачу точно до проблем, связанных со временем необходимых вычислений.

Давайте рассмотрим классичекие алгоритмы вычисления Площадей фигур.

Площадь тривиальных фигур#

Постановка задачи

Пусть дан квадрат со сторонами а и b. Необходимо вычислить площадь. Ввод параметров с клавиатуры.

Хотя постановка задачи проста, вспомним некоторые важные команды: input для Python и cin для С++, которые обеспечивают чтение данных с клавиатуры.

    # Ввод параметров
    a = float(input("Введите длину стороны a: "))

    # Вычисление площади
    area = a * a

    # Вывод результата
    print(f"Площадь квадрата: {area}")
    #include <iostream>
    using namespace std;

    int main() {
        double a, b;

        // Ввод параметров
        cout << "Введите длину стороны a: ";
        cin >> a;

        // Вычисление площади
        double area = a * a;

        // Вывод результата
        cout << "Площадь квадрата: " << area << endl;

        return 0;
    }

Можно привести еще некоторое количество подобных задач на вычисление площади: Трапеции, Треугольника, Окружности и проч.
Однако представим себе, что наша задача усложнилась.

Постановка задачи

Пусть дан квадрат, описанный точками. Необходимо вычислить площадь квадрата. Ввод параметров с клавиатуры.

Что изменилось для нас в постановке задачи? Изменились входные данные и теперь мы, как разработчики, должны понять, как вычислить расстояние между точками.
Не правда ли, что это потребует от нас некоторых знаний в области геометрии. Как программисты давай всопмним операцию возведения в степень и извлечение корня.

    # Ввод координат точек квадрата
    x1 = float(input("Введите координату x1: "))
    y1 = float(input("Введите координату y1: "))
    x2 = float(input("Введите координату x2: "))
    y2 = float(input("Введите координату y2: "))

    # Вычисление длины стороны квадрата через расстояние между двумя точками
    side_length = # ваше предположение

    # Вычисление площади
    area = side_length ** 2

    # Вывод результата
    print(f"Площадь квадрата: {area}")
    #include <iostream>
    #include <cmath>
    using namespace std;

    int main() {
        double x1, y1, x2, y2;

        // Ввод координат точек
        cout << "Введите координату x1: ";
        cin >> x1;
        cout << "Введите координату y1: ";
        cin >> y1;
        cout << "Введите координату x2: ";
        cin >> x2;
        cout << "Введите координату y2: ";
        cin >> y2;

        // Вычисление длины стороны квадрата через расстояние между двумя точками
        double side_length = // ваше предположение

        // Вычисление площади
        double area = pow(side_length, 2);

        // Вывод результата
        cout << "Площадь квадрата: " << area << endl;

        return 0;
    }

Площадь криволинейных фигур#

Зададимся вопросо : часто ли мы встречаем в природе идеальный квадрат?
Обычно живые существа, да и прочие предметы вокруг нас, далеки от простых форм.
И допустим, есть у меня ужасная нужда посчитаь площадь амебы. Кстати, вот и она

Фигура

Если долго смотреть на амебу, то можно заметить, что её плавные контуры жутко напоминают непрерывную функцию

Фигура

Тогда, возможно, стоит попытаться описать компьютеру такую функцию и указать, что необхожимо посчитать площадь прямо под этой кривой.
Проблема заключается в том, что компьютер не воспринимает не дискретные величины и поэтому нам нужно разбить нашу фигуру на фрагменты.

Фигура

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

Идея целочисленного интегрирования#

Численное интегрирование (историческое название: (численная) квадратура) — вычисление значения определённого интеграла (как правило, приближённое). Под численным интегрированием понимают набор численных методов для нахождения значения определённого интеграла.

Численное интегрирование применяется, когда:

  1. Сама подынтегральная функция не задана аналитически. Например, она представлена в виде таблицы (массива) значений в узлах некоторой расчётной сетки.
  2. Аналитическое представление подынтегральной функции известно, но её первообразная не выражается через аналитические функции. Например, f(x) = \exp(-x^2) .

В этих двух случаях невозможно вычисление интеграла по формуле Ньютона — Лейбница. Также возможна ситуация, когда вид первообразной настолько сложен, что быстрее вычислить значение интеграла численным методом.

Основная идея большинства методов численного интегрирования состоит в замене подынтегральной функции на более простую, интеграл от которой легко вычисляется аналитически. При этом для оценки значения интеграла получаются формулы вида:

I \approx \sum_{i=1}^{n} w_i f(x_i),

где n — число точек, в которых вычисляется значение подынтегральной функции. Точки x_i называются узлами метода, числа w_i — весами узлов.

Фигура

При замене подынтегральной функции на полином нулевой, первой и второй степени получаются соответственно методы прямоугольников, трапеций и парабол (Симпсона). Часто формулы для оценки значения интеграла называют квадратурными формулами.

Метод прямоугольников#

Метод прямоугольников является одним из самых простых методов численного интегрирования. Суть метода заключается в том, чтобы заменить область под графиком функции прямоугольниками, площадь которых легко вычисляется.

Для нахождения значения интеграла с помощью метода прямоугольников, подынтегральная функция f(x) аппроксимируется прямыми отрезками, соединяющими точки функции на определённых интервалах.

Квадратурная формула метода прямоугольников выглядит следующим образом:

I \approx \sum_{i=1}^{n} w_i f(x_i),

где:
- n — количество узлов (точек),
- x_i — значения точек, в которых вычисляется функция,
- w_i — веса узлов, обычно для метода прямоугольников w_i = \Delta x , где \Delta x — шаг между узлами.

Часто метод прямоугольников применяется на равномерно расположенных точках, и шаг \Delta x вычисляется как:

\Delta x = \frac{b - a}{n},

где a и b — границы интегрирования, а n — количество интервалов.

Фигура

    # Метод прямоугольников для численного интегрирования

    def rectangle_method(f, a, b, n):
        # Вычисляем шаг
        delta_x = (b - a) / n

        # Суммируем значения функции в узловых точках
        integral = 0
        for i in range(n):
            x_i = a + i * delta_x  # Левая точка интервала
            integral += f(x_i)

        # Умножаем на шаг
        integral *= delta_x
        return integral

    # Пример использования: интегрирование функции exp(-x^2) на интервале [0, 1]
    import math

    def func(x):
        return math.exp(-x**2)

    a = 0  # Начало интервала
    b = 1  # Конец интервала
    n = 1000  # Количество интервалов

    result = rectangle_method(func, a, b, n)
    print(f"Численное значение интеграла: {result}")
#include <iostream>
#include <cmath>
using namespace std;

// Метод прямоугольников для численного интегрирования
double rectangle_method(double (*f)(double), double a, double b, int n) {
    // Вычисляем шаг
    double delta_x = (b - a) / n;

    // Суммируем значения функции в узловых точках
    double integral = 0;
    for (int i = 0; i < n; i++) {
        double x_i = a + i * delta_x;  // Левая точка интервала
        integral += f(x_i);
    }

    // Умножаем на шаг
    integral *= delta_x;
    return integral;
}

// Пример использования: интегрирование функции exp(-x^2) на интервале [0, 1]
double func(double x) {
    return exp(-x * x);
}

int main() {
    double a = 0;  // Начало интервала
    double b = 1;  // Конец интервала
    int n = 1000;   // Количество интервалов

    double result = rectangle_method(func, a, b, n);
    cout << "Численное значение интеграла: " << result << endl;

    return 0;
}

Формула Симпсона#

Формула Симпсона является более точным методом численного интегрирования по сравнению с методами прямоугольников и трапеций. Метод Симпсона использует аппроксимацию подынтегральной функции полиномом второй степени (параболой), что позволяет значительно улучшить точность вычислений.

Формула Симпсона используется для вычисления интегралов на интервале [a, b], разбитом на чётное количество подынтервалов. Для расчёта интеграла на одном интервале формула имеет вид:

I \approx \frac{\Delta x}{3} \left( f(a) + 4 \cdot f\left( \frac{a+b}{2} \right) + f(b) \right),

где:
- \Delta x = \frac{b - a}{2} — шаг между узлами (с учетом, что количество разбиений чётное),
- f(a) , f(b) , и f\left( \frac{a+b}{2} \right) — значения функции в точках на концах интервала и в середине.

Для вычисления интеграла на большем интервале с использованием нескольких подинтервалов, используется обобщённая формула Симпсона:

I \approx \frac{\Delta x}{3} \left( f(x_0) + 4 \cdot \sum_{i=1,3,5,...,n-1} f(x_i) + 2 \cdot \sum_{i=2,4,6,...,n-2} f(x_i) + f(x_n) \right),

где:
- x_0 = a , x_n = b — концы интервала,
- x_i — узлы метода, которые расположены через шаг \Delta x .

Таким образом, метод Симпсона требует, чтобы количество подинтервалов было чётным, и использует более точную аппроксимацию функции, чем методы прямоугольников и трапеций.

   # Метод Симпсона для численного интегрирования

    def simpson_method(f, a, b, n):
        # Проверяем, что n чётное
        if n % 2 == 1:
            n += 1

        # Вычисляем шаг
        delta_x = (b - a) / n

        # Вычисление значений функции в узловых точках
        integral = f(a) + f(b)

        # Суммируем для нечётных и чётных индексов
        for i in range(1, n, 2):
            integral += 4 * f(a + i * delta_x)
        for i in range(2, n-1, 2):
            integral += 2 * f(a + i * delta_x)

        # Умножаем на шаг / 3
        integral *= delta_x / 3
        return integral

    # Пример использования: интегрирование функции exp(-x^2) на интервале [0, 1]
    import math

    def func(x):
        return math.exp(-x**2)

    a = 0  # Начало интервала
    b = 1  # Конец интервала
    n = 1000  # Количество разбиений (должно быть чётным)

    result = simpson_method(func, a, b, n)
    print(f"Численное значение интеграла: {result}")
    #include <iostream>
    #include <cmath>
    using namespace std;

    // Метод Симпсона для численного интегрирования
    double simpson_method(double (*f)(double), double a, double b, int n) {
        // Проверяем, что n чётное
        if (n % 2 == 1) {
            n += 1;  // Увеличиваем n, если оно нечётное
        }

        // Вычисляем шаг
        double delta_x = (b - a) / n;

        // Вычисление значений функции в узловых точках
        double integral = f(a) + f(b);

        // Суммируем для нечётных и чётных индексов
        for (int i = 1; i < n; i += 2) {
            integral += 4 * f(a + i * delta_x);
        }
        for (int i = 2; i < n - 1; i += 2) {
            integral += 2 * f(a + i * delta_x);
        }

        // Умножаем на шаг / 3
        integral *= delta_x / 3;
        return integral;
    }

    // Пример использования: интегрирование функции exp(-x^2) на интервале [0, 1]
    double func(double x) {
        return exp(-x * x);
    }

    int main() {
        double a = 0;  // Начало интервала
        double b = 1;  // Конец интервала
        int n = 1000;   // Количество разбиений (должно быть чётным)

        double result = simpson_method(func, a, b, n);
        cout << "Численное значение интеграла: " << result << endl;

        return 0;
    }

Прогрессирующая сложность#

Вычисление площади фигур в реальности может быть сложным из-за необходимости точного описания формы объекта. Для того чтобы вычислить площадь, его форма должна быть представлена математически, однако многие реальные объекты и фигуры не имеют простых аналитических выражений и могут быть либо нерегулярными, либо слишком сложными для точного математического представления. В некоторых случаях геометрия объектов оказывается слишком сложной для традиционных аналитических методов. Например, фигуры могут иметь изломанные, фрактальные или многогранные формы, которые трудно выразить с помощью простых формул или вычислить с использованием классических методов.

Фигура

Метод Монте-Карло#

Метод Монте-Карло может быть применен для поиска площади произвольной геометрической фигуры. Пусть геометрическая фигура A находится внутри прямоугольника с размерами W \times H , и требуется найти площадь фигуры S_A .

Сгенерируем случайную точку (x, y) внутри прямоугольника равномерным образом. Тогда согласно геометрическому определению вероятности, вероятность того, что эта точка окажется внутри геометрической фигуры A , определяется соотношением:

P = \frac{S_A}{S_{\text{rectangle}}},

где S_{\text{rectangle}} — площадь прямоугольника. Таким образом, чтобы найти S_A , достаточно знать вероятность P .

Применение метода Монте-Карло заключается в том, чтобы оценить эту самую вероятность эмпирически. Для этого генерируется N случайных точек внутри прямоугольника, подсчитывается количество точек N_{\text{inside}} , оказавшихся внутри фигуры A , и принимается, что если N достаточно велико, то:

S_A \approx \frac{N_{\text{inside}}}{N} \cdot S_{\text{rectangle}}.

Этот метод работает с любыми сложными геометрическими фигурами, где аналитические способы нахождения площади трудны или невозможны.

Tip

Генерировать выборку случайных чисел, равномерно распределенных на отрезке [low, high] можно методом numpy.random.uniform.

    import random
    import math

    # Функция для вычисления площади круга методом Монте-Карло
    def monte_carlo_circle_area(R, num_points):
        points_inside = 0

        for _ in range(num_points):
            # Генерируем случайную точку в квадрате с центром в (0, 0) и стороной 2R
            x = random.uniform(-R, R)
            y = random.uniform(-R, R)

            # Проверяем, попала ли точка внутри круга
            if x**2 + y**2 <= R**2:
                points_inside += 1

        # Площадь квадрата
        square_area = (2 * R) ** 2

        # Приближенная площадь круга
        circle_area = (points_inside / num_points) * square_area
        return circle_area

    # Пример использования
    R = 1  # Радиус круга
    num_points = 1000000  # Количество случайных точек

    result = monte_carlo_circle_area(R, num_points)
    print(f"Приближенная площадь круга: {result}")
    #include <iostream>
    #include <cstdlib>
    #include <ctime>
    #include <cmath>

    using namespace std;

    // Функция для вычисления площади круга методом Монте-Карло
    double monte_carlo_circle_area(double R, int num_points) {
        int points_inside = 0;

        for (int i = 0; i < num_points; ++i) {
            // Генерируем случайную точку в квадрате с центром в (0, 0) и стороной 2R
            double x = (rand() / (RAND_MAX + 1.0)) * 2 * R - R;  // Случайное число в диапазоне [-R, R]
            double y = (rand() / (RAND_MAX + 1.0)) * 2 * R - R;  // Случайное число в диапазоне [-R, R]

            // Проверяем, попала ли точка внутри круга
            if (x * x + y * y <= R * R) {
                points_inside++;
            }
        }

        // Площадь квадрата
        double square_area = (2 * R) * (2 * R);

        // Приближенная площадь круга
        return (points_inside / static_cast<double>(num_points)) * square_area;
    }

    int main() {
        srand(time(0));  // Инициализируем генератор случайных чисел

        double R = 1;  // Радиус круга
        int num_points = 1000000;  // Количество случайных точек

        double result = monte_carlo_circle_area(R, num_points);
        cout << "Приближенная площадь круга: " << result << endl;

        return 0;
    }

Если у нас есть формальное требование точности ответа — например, получение 3 правильных значимых знаков хотя бы в 99% случаев — возникает вопрос, сколько точек необходимо проверить.

Можно показать, что для получения k правильных значимых знаков с вероятностью, произвольно близкой к единице, необходимо:

n = \Theta(10^{2k})

где n — количество проверяемых точек.

Это означает, что при увеличении требуемого числа правильных знаков k число необходимых точек растёт экспоненциально. Например:
- Для k = 3 значимых знаков потребуется n = \Theta(10^6) точек.
- Для k = 4 значимых знаков уже n = \Theta(10^8) точек.

Данный факт выводится из теории оценок (теория вероятность)