Нужна ли программисту математика? Разбираем на примерах

f3af932baaada78b712ac3c31503fd2e.png

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

Меня зовут Пётр — я разработчик и автор курса «Java-разработчик» в Яндекс Практикуме. В этом материале я покажу примеры будничного кода программиста, в которых порой математики не меньше, чем разработки, — и вопрос из заголовка отпадёт сам собой.

Работа с циклами

Какие задачи: задачи, связанные с циклами, проходами по спискам, итеративными вычислениями и кванторами.

Кто решает: все программисты.

Больше всего мы (программисты) любим совершать проходы и обходы, этому нас учат с первых дней.

Самый простой пример:

# Создаём список
список = ['яблоко', 'банан', 'вишня']

# Проходим по элементам списка
for фрукт in список:
    print(фрукт)

И пример посложнее:

// Умножение матриц
for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
        for (int k = 0; k < 3; k++) {
            result[i][j] += matrix1[i][k] * matrix2[k][j];
        }
    }
}

Удобно представлять различные задачи по обработке чего-то, что можно разбить на элементы, как цикл, то есть повторение одних и тех же действий и движение по списку элементов. Работая с таким фрагментом кода, мы описываем действие или последовательность действий, выполняемые над элементом, а также условия, по которым цикл продолжается или останавливается.

Работу компьютеров и программ мы представляем в виде бесконечных циклов с различными ветвями внутри. Так мы описываем правила обработки для разных элементов виртуального бесконечного списка (потока) событий.

В общем, мы любим последовательности, циклы и ветвления.

Относительные вычисления

Какие задачи: поиск подстроки в строке, работа с индексами, арифметические задачи.

Кто решает: бэкенд-разработчики.

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

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

Ещё программисты любят тексты в различных формах: в виде исходных кодов, которые надо распарсить, в виде команд консоли, которые можно комбинировать. А тексты это последовательности символов с порядковым номером.

Поэтому пример:

def naive_search(text, pattern):
    n = len(text)
    m = len(pattern)
    results = []

    for i in range(n - m + 1):
        match = True
        for j in range(m):
            if text[i + j] != pattern[j]:
                match = False
                break
        if match:
            results.append(i)

    return results

# Пример использования
text = "abracadabra"
pattern = "abra"
print("Подстрока найдена в индексах:", naive_search(text, pattern))

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

Если мы будем думать об индексах дальше, мы увидим, как через них просвечивает математика отрезков, открытых и закрытых диапазонов. А ещё в массиве индексов можно построить свои индексы, так в наших руках появляется инструментарий ссылок и арифметики с ними.

Работа с алгоритмами

Какие задачи: сортировка пузырьком, асимптотические оценки, работа с «О» большим и «о» малым.

Кто решает: алгоритмисты.

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

Например, мы пишем простой код по наитию, просто переводя словесные рассуждения в алгоритм сортировки.

def bubble_sort(arr):
    n = len(arr)
    # Проходим по всем элементам массива
    for i in range(n):
        # Последние i элементов уже отсортированы
        for j in range(0, n - i - 1):
            # Сравниваем каждый элемент с соседним
            if arr[j] > arr[j + 1]:
                # Если он больше, то меняем их местами
                arr[j], arr[j + 1] = arr[j + 1], arr[j]

# Пример использования
array = [64, 34, 25, 12, 22, 11, 90]
bubble_sort(array)
print("Отсортированный массив:", array)

Узнали? Согласны? А вот компьютер не согласен, и данный алгоритм оказывается не слишком оптимальным с вычислительной точки зрения. Оказывается, что мы наговорили лишнего, а компьютер, повторяя по нашим указаниям этот алгоритм, будет тратить на массив размера N примерно N^2 операций. Для разного количества элементов число операций, конечно, будет разным, но верхний предел количества операций будет N^2.

Поэтому программистам надо уметь видеть усложнения в алгоритмах, неоправданные увеличения количества операций из-за наивных решений, которые легко придумать. Немного подумав над своими словами, можно превратить алгоритм сортировки пузырьком в алгоритм quicksort, а он показывает совсем другой уровень эффективности (N*log (N) вместо N^2).

Работа с графикой

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

Кто решает: фронтенд- и мобильные разработчики.

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

Поэтому программистам приходится работать с 2D- и 3D-графикой. В этой работе есть свои особенности. Графика в компьютерах изначально растровая. То есть создаётся из единичных точек, но уже в двумерном массиве из строк и колонок.

И конечно, мы любим по этим массивам итерировать. Но это не так просто, как может показаться. Например, начало координат находится сверху слева, а вертикальная ось Y направлена вниз, а не вверх. Поэтому рисовать ель или снеговика приходится в сторону уменьшения координаты.

Кстати, о снеговике. Из пикселей довольно трудно составить идеальный круг. Приходится рисовать подобие круга и полагаться на специальные правила расстановки пикселей. Да и наклонную прямую для ели нарисовать непросто. Хорошо, что для этого есть библиотеки.

Ёлка и снег на Canvas




    
    
    Ёлка и снег на Canvas
    


    
    











А ещё нам нужно нарисовать снег. Снег просто так не нарисуешь, ведь снежинка не может быть меньше одного пикселя. А с пикселем есть интуитивная особенность, он представляется лишь точкой с двумя координатами, но на самом деле это прямоугольник. И если два пикселя снежинок нарисовать рядом, получится не снежинка, а кирпич.

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

Остаётся только проблема, связанная с масштабированием растров. Особенно остро эта проблема стоит при отображении букв. Здесь без ухищрений никуда — на помощь приходят алгоритмы сглаживания, интерполяции и прочих математических слов.

Осознав все эти сложности, программисты придумали векторную 2D- и 3D-графику, в которой нет никаких пикселей, круг действительно является кругом, а изменять масштабы можно в любую сторону и сколько угодно раз.

import numpy as np

def scale_with_aspect_ratio_using_matrix(width_original, height_original, width_target, height_target):
    # Вычисляем коэффициенты масштабирования для ширины и высоты
    scale_width = width_target / width_original
    scale_height = height_target / height_original

    # Выбираем наименьший коэффициент для сохранения соотношения сторон
    scale = min(scale_width, scale_height)

    # Определяем матрицу масштабирования
    scale_matrix = np.array([
        [scale, 0, 0],
        [0, scale, 0],
        [0, 0, 1]
    ])

    # Определяем вектор оригинальных размеров (гомогенная координата)
    original_vector = np.array([width_original, height_original, 1])

    # Применяем матрицу масштабирования
    new_vector = scale_matrix @ original_vector

    # Извлекаем новые размеры из результата
    new_width, new_height = int(new_vector[0]), int(new_vector[1])

    return new_width, new_height

def scale_with_aspect_ratio(width_original, height_original, width_target, height_target):
    # Вычисляем коэффициенты масштабирования для ширины и высоты
    scale_width = width_target / width_original
    scale_height = height_target / height_original

    # Выбираем наименьший коэффициент для сохранения соотношения сторон
    scale = min(scale_width, scale_height)

    # Вычисляем новые размеры
    new_width = int(width_original * scale)
    new_height = int(height_original * scale)

    return new_width, new_height

# Пример использования
original_width = 1920
original_height = 1080
target_width = 800
target_height = 600

new_width, new_height = scale_with_aspect_ratio(original_width, original_height, target_width, target_height)
print(f"Новые размеры: {new_width}x{new_height}")

new_width, new_height = scale_with_aspect_ratio_using_matrix(original_width, original_height, target_width, target_height)
print(f"Новые размеры: {new_width}x{new_height}")

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

Непосредственно математика

Какие задачи: задачи, связанные с прикладной математикой, статистикой, интерполяциями.

Кто решает: все программисты.

Конечно, в работе программиста встречаются и математические задачи. Любой язык программирования предполагает, что в нём будут производиться арифметические вычисления. Зачастую бóльшая часть математических функций реализована в библиотеках. Какую именно формулу мы будем описывать на языке программирования, зависит от задачи, но, как и в случае с алгоритмами, приходится помнить, что код исполняет реальный компьютер, со своими ограничениями и пределами. Например, с предельным размером целочисленных значений.

Пока наши целочисленные вычисления ограничиваются индексами в массивах (и не связаны с big data), мы можем не думать о природе целых чисел. Но когда дело доходит до вычислений, мы очень быстро можем наткнуться на неожиданный результат.

public class FactorialOverflow {
    public static void main(String[] args) {
        int number = 13;
        int factorial = 1;

        for (int i = 1; i <= number; i++) {
            factorial *= i;
            System.out.println("i = " + i + ", factorial = " + factorial);
        }

        // Обратите внимание на результат, который не является корректным значением 13!
        System.out.println("Факториал " + number + " равен " + factorial);
    }
}

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

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

int color = 0xAABBCCDD; // Пример: A=AA, R=BB, G=CC, B=DD

int alpha = (color >> 24) & 0xFF;
int red = (color >> 16) & 0xFF;
int green = (color >> 8) & 0xFF;
int blue = color & 0xFF;

Например, здесь мы извлекаем компоненты цвета размером 1 байт (8 бит) из целого числа длиной 32 бит.

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

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

public class EpsilonFinder {
    public static void main(String[] args) {
        double epsilon = 1.0;

        // Пока добавление эпсилон к 1.0 дает 1.0, уменьшайте эпсилон
        while (1.0 + epsilon != 1.0) {
            epsilon /= 2.0;
        }

        // Выйдя из цикла, эпсилон будет в два раза меньше необходимого, поэтому умножаем на 2
        epsilon *= 2.0;

        System.out.println("Число эпсилон: " + epsilon);
    }
}

Поэтому код выше кажется невыполнимым, но в реальности компьютер на каком-то масштабе величин просто перестаёт их видеть.

Работа с множествами

Какие задачи: задачи, связанные с выборками, пересечениями множеств, SQL-выборками, ООП и классами.

Кто решает: все программисты.

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

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

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

Конечно, в некоторых случаях строгая логика ООП-подхода приводит к забавным парадоксам, как, например, пример ниже:

class Rectangle {
    protected int width;
    protected int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    public Square(int side) {
        super(side, side);
    }

    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Неправильная логика, специфичная для квадрата
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height; // Неправильная логика, специфичная для квадрата
    }
}

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

SELECT c.*
FROM creatures c
JOIN habr_readers hr ON c.id = hr.creature_id
WHERE c.type = 'человек'
  AND c.location = 'Европа'
  AND c.is_alive = true;

Языковые средства ООП или SQL понятны любому человеку и в то же время концентрируют в себе большое количество точных (или не очень) логических высказываний о мире вокруг нас.

Функциональное программирование

Какие задачи: работа с MapReduce, фильтрами, лямбдами, свёртками.

Кто решает: все программисты.

Мы начали эту статью с циклов, потому что все когда-то начинали с циклов. Но параллельно с циклами существует целый мир рекурсивных и родственных с ними функциональных вычислений.

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

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

def group_by_country(entities):
    def helper(entities, index, grouped):
        if index >= len(entities):
            return grouped

        entity = entities[index]
        country = entity['страна']

        if country not in grouped:
            grouped[country] = []

        grouped[country].append(entity['имя'])

        return helper(entities, index + 1, grouped)

    return helper(entities, 0, {})

# Пример использования
creatures = [
    {'имя': 'Лев', 'страна': 'Африка'},
    {'имя': 'Панда', 'страна': 'Китай'},
    {'имя': 'Коала', 'страна': 'Австралия'},
    {'имя': 'Слон', 'страна': 'Африка'},
    {'имя': 'Тигр', 'страна': 'Индия'},
]

result = group_by_country(creatures)
print(result)

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

groupHabrReadersByLocation =
    map (\\group -> (location (head group), group)) .
    groupBy ((==) `on` location) .
    filter (\\c -> readsHabr c && isAlive c && creatureType c == "человек")

Этот стиль описания изолирует нас от циклов, условий и итеративной природы вычислений внутри компьютеров.

Математика+

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

Кто решает: разработчики нейросетей, специалисты по машинному обучению и работе с большими данными.

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

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

import tensorflow as tf
from tensorflow.keras.datasets import mnist
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Flatten
from tensorflow.keras.utils import to_categorical

# Загрузка данных MNIST
(x_train, y_train), (x_test, y_test) = mnist.load_data()

# Нормализация данных
x_train = x_train / 255.0
x_test = x_test / 255.0

# Преобразование меток в категориальный формат
y_train = to_categorical(y_train, 10)
y_test = to_categorical(y_test, 10)

# Создание модели с несколькими слоями
model = Sequential([
    Flatten(input_shape=(28, 28)),  # Преобразование 28x28 изображений в 784-длинные векторы
    Dense(128, activation='relu'),  # Первый скрытый слой с 128 нейронами
    Dense(64, activation='relu'),   # Второй скрытый слой с 64 нейронами
    Dense(10, activation='softmax') # Выходной слой с 10 нейронами
])

# Компиляция модели
model.compile(optimizer='adam',
              loss='categorical_crossentropy',
              metrics=['accuracy'])

# Обучение модели
model.fit(x_train, y_train, epochs=5, batch_size=32, validation_split=0.2)

# Оценка модели
test_loss, test_acc = model.evaluate(x_test, y_test)
print(f'Точность на тестовых данных: {test_acc:.4f}')

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

Выводы

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

Habrahabr.ru прочитано 672 раза