Алгоритмы – одна из центральных тем в программировании, они повсюду (особенно на собеседованиях, ха-ха).

image
(Разве можно обойтись в таком посте без «баяна»?)

Одним из самых известных является так называемый алгоритм Евклида – пожалуй, самый распространенный способ нахождения наибольшего общего делителя (НОД) двух целых неотрицательных чисел. С него также зачастую любят начинать изучение (и обучение) соответствующих разделов математики и информатики.

А Дональд Кнут, небезызвестный автор трактата “Искусство программирования” (и не только), и вовсе считает алгоритм первым в истории (по крайней мере, относительно современных определений). Потому что, не смотря на то, что алгоритм был придуман и использовался еще до, собственно, Евклида, который жил в IV-III вв. до нашей эры (он упоминается уже у Аристотеля, жившего веком ранее), Евклид описывает процесс итеративно, что согласуется с современным значением слова.

Само слово “алгоритм” восходит к имени арабского математика Аль-Хорезми, жившего примерно в VIII-IX вв. уже нашей эры. А началом его использования в смысле, близком современному, считается уже лишь XX век, точнее – его первые десятилетия, восход информационных технологий.

Алгоритм Евклида


Любопытства ради предлагаю ознакомиться с евклидовским описанием алгоритма в редактуре Кнута. Оно довольно длинное, поэтому спрятано под катом:

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

Пусть A и C – два заданных положительных целых числа; требуется найти их НОД. Если число A делится на C, то число C есть общий делитель чисел C и A, поскольку оно делит самое себя. И очевидно, что оно будет и наибольшим делителем, поскольку нет числа большего, чем число C, которое бы делило C.

Но если C не делит число A, то будем непрерывно вычитать меньшее из чисел A и C из большего до тех пор, пока не получим число, которое нацело делит предыдущее вычитаемое. Это должно рано или поздно произойти, потому что, если разность будет равна единице, то единица будет делить предыдущее вычитаемое.

Теперь положим, что E – положительный остаток от деления числа A на C; пусть F – положительный остаток от деления числа C на число E и пусть F делит E. Так как F делит E, а E делит C — F, F также делит C — F. Но оно делит и самое себя, поэтому F делит C, а C делит A — E; поэтому F делит также A — E, но оно делит и E; поэтому F делит A. Следовательно F является общим делителем чисел A и C.

Теперь я утверждаю, что оно является и НОД. Действительно, если F – не наибольший общий делитель чисел A и C, то найдется большее число, которое будет делить оба этих числа. Пусть таким числом будет G.

Так как число G делит число C, а число C – делит A — E, то G также делит число A — E. Число G делит также все число A, поэтому оно делит и остаток E. Но E делит C — F, поэтому G также делит C — F. А число G также делит все число C, так как оно делит и остаток F; таким образом, большее число делит меньшее, а это невозможно.

Таким образом, нет такого числа, большего, чем F, которое бы делило A и C; значит, число F является НОД.

Следствие. Это рассуждение делает очевидным предположение, что всякое число, делящее два числа, делит и их НОД. Ч.т.д.


Описание приводит два способа нахождения НОД – вычитанием и делением. Собственно, и в наши дни широко известны эти два способа реализации алгоритма.

Вот пример функции, написанной на «Swift», реализации первого способа:

func subtractionGCD(_ firstNumber: Int, _ secondNumber: Int) -> Int {
    if let simpleGCD = simpleCasesGCD(firstNumber, secondNumber) {
        return simpleGCD
    }

    var firstNumber = firstNumber
    var secondNumber = secondNumber
    while firstNumber != 0, secondNumber != 0 {
        if firstNumber > secondNumber {
            firstNumber = firstNumber - secondNumber
        } else {
            secondNumber = secondNumber - firstNumber
        }
    }

    return firstNumber + secondNumber // One of them is 0.
}

Здесь, переиспользования ради, я вынес в отдельную функцию случаи поиска НОД, когда он известен сразу, без необходимости следования какому-либо алгоритму:

func simpleCasesGCD(_ firstNumber: Int, _ secondNumber: Int) -> Int? {
    if firstNumber == secondNumber {
        return firstNumber // Any.
    }

    if firstNumber == 0 {
        return secondNumber
    }
    if secondNumber == 0 {
        return firstNumber
    }

    return nil
}

(Если два числа равны, то, естественно, их НОД также равен им. Если какое-то из чисел равно нулю, то НОД будет равняться второму числу, т.к. ноль делится любым числом (с результатом, понятное дело, тоже ноль).)

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

А вот так может выглядеть реализация версии алгоритма делением:

func divisionGCD(_ firstNumber: Int, _ secondNumber: Int) -> Int {
    if let simpleGCD = simpleCasesGCD(firstNumber, secondNumber) {
        return simpleGCD
    }

    var firstNumber = firstNumber
    var secondNumber = secondNumber
    while firstNumber != 0, secondNumber != 0 {
        if firstNumber > secondNumber {
            firstNumber = firstNumber % secondNumber
        } else {
            secondNumber = secondNumber % firstNumber
        }
    }

    return firstNumber + secondNumber // One of them is 0.
}

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

Чтобы немного их сравнить, я произвел несколько замеров с использованием любимого мной метода measure(_:) класса XCTestCase «нативного» фреймворка для тестирования кода в Xcode-проектах XCTest.

В качестве входных данных я использовал массив пар случайных чисел. Замеры производились, естественно, с использованием одного и того же массива для каждого способа. Разброс чисел для пар я взял от нуля до 9999. Замеры производились на количестве вычислений (пар чисел): одно, десять, 100, 1000, 10000, 100000, 1000000 и 10000000. Последнее заставляло ожидать результата уже несколько минут, поэтому на нем я решил остановиться.

Вот простой код генерации входных данных:

let pairs = (0..<100).map { _ in (Int.random(in: 0..<10000), Int.random(in: 0..<10000)) } // Generates 100 pairs.

Сам замер выглядит, например, так:

func testSubstractionGCDPerformance() {
    measure() {
        _ = pairs.map { substractionGCD($0, $1) }
    }
}

А вот так выглядят результаты запуска на моем компьютере:

image
(Subtraction – вычитание, division – деление.)

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

«Улучшенная» версия алгоритма Евклида


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

func improvedDivisionGCD(_ firstNumber: Int, _ secondNumber: Int) -> Int {
    if let simpleGCD = GCD.simpleCasesGCD(firstNumber, secondNumber) {
        return simpleGCD
    }

    var firstNumber = firstNumber
    var secondNumber = secondNumber
    while firstNumber != 0, secondNumber != 0 {
        if firstNumber > secondNumber {
            let firstNumberClaim = firstNumber % secondNumber
            if firstNumberClaim > secondNumber / 2 {
                firstNumber = abs(firstNumberClaim - secondNumber)
            } else {
                firstNumber = firstNumberClaim
            }
        } else {
            let secondNumberClaim = secondNumber % firstNumber
            if secondNumberClaim > firstNumber / 2 {
                secondNumber = abs(secondNumberClaim - firstNumber)
            } else {
                secondNumber = secondNumberClaim
            }
        }
    }

    return firstNumber + secondNumber // One of them is 0.
}

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

image
(Improved – «улучшенная» версия.)

Еще немного о значимости алгоритма Евклида


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

Алгоритм был, конечно, обощен и для нахождения НОД любого количества чисел, не только двух. В двух словах идея такова: если обозначить функцию поиска НОД двух чисел как gcd(a, b), то, скажем, НОД трех чисел gcd(a, b, c) равен gcd(gcd(a, b), c). И так далее, для любого количества чисел НОД находится последовательным вычислением НОД НОД-а предыдущей пары чисел и следующего числа. Хотя, конечно, это касается поиска НОД вообще, а не только алгоритма Евклида.

Существует также обощение алгоритма для нахождения НОД полиномов. Но это уже выходит за рамки этого несложного поста, а в некоторой степени, и моих познаний в математике.

Сложность алгоритма Евклида


Временная сложность алгоритма исследовалась давно, не быстро и гораздо более учеными мужами, чем ваш покорный слуга. Тем не менее, вопрос давно закрыт и ответ получен. Собственно, еще в середине позапрошлого века. Габриэлем Ламе.

Если коротко, то ответ формулируется, собственно, теоремой Ламе, связанной с этим алгоритмом. Количество шагов алгоритма будет равно порядковому номеру ближайшего большего числа Фибоначчи наименьшему из двух чисел входных параметров минус 2. Оперируя чуть более традиционно-математическими обозначениями, то если u > v (и v > 1), то число проходов алгоритма будет равняться n — 2 при v < Fn (Fn – это некое ближайшее v число Фибоначчи, а n – это его порядковый номер).

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

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

Бинарный метод поиска НОД


Говоря о поиске НОД, стоит быть упомянутым алгоритм, предложенный уже в 60-е годы прошлого столетия неким Джозефом Стейном, о котором я не нашел в Сети вообще никакой информации. Он (алгоритм) ориентирован на двоичную арифметику и не содержит операций деления. Алгоритм оперирует только проверками четности и делением пополам, что осуществимо возможностями одной лишь бинарной арифметики.

Алгоритм основывается на четырех фактах:

  1. Если u и v оба четны, то gcd(u, v) = 2 * gcd(u / 2, v / 2);
  2. Если u четно, а v – нет, gcd(u, v) = gcd(u / 2, v);
  3. gcd(u, v) = gcd(u — v, v) (это следует из алгоритма Евклида);
  4. Если u и v оба нечетны, то u — v – четно и |u — v| < max(u, v)

На «Wikipedia» можно посмотреть рекурсивную версию алгоритма (на современных языках программирования записывается в несколько строк), я не стал ее переписывать на «Swift». А здесь я приведу вариант итеративной реализации:

func binaryGCD(_ firstNumber: Int, _ secondNumber: Int) -> Int {
    if let simpleGCD = GCD.simpleCasesGCD(firstNumber, secondNumber) {
        return simpleGCD
    }

    var firstNumber = firstNumber
    var secondNumber = secondNumber

    var shift = 0
    while (firstNumber | secondNumber) & 1 == 0 {
        shift += 1
        firstNumber >>= 1
        secondNumber >>= 1
    }
    while firstNumber & 1 == 0 {
        firstNumber >>= 1
    }
    repeat {
        while secondNumber & 1 == 0 {
            secondNumber >>= 1
        }
        if firstNumber > secondNumber {
            swap(&firstNumber, &secondNumber)
        }
        secondNumber -= firstNumber
    } while secondNumber != 0

    return firstNumber << shift
}

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

image
(Binary – бинарный алгоритм.)

(Не исключаю, что алгоритм можно записать более эффективно, чем это сделал я, и это повлияет на результат, но на что же нам тогда нужны компиляторы?!)

Кстати, этот алгоритм, безусловно, получивший свои 15 минут славы уже в век информационных технологий (в более раннюю его часть, чем текущая), был известен еще в Древнем Китае. Его описание обнаружено в трудах, датируемых I в. н.э. Конечно, в терминах вроде «половинного деления» и вычитания. А также в контексте сокращения дробей.

Заключение


Честно говоря, этим простеньким «исследованием» я не собирался ничего доказывать и не хотел делать какие-то революционные умозаключения (и ведь не сделал же!). Я всего лишь хотел удовлетворить свое любопытство, посмотреть на работу разных подходов для решения классической задачи и слегка размять пальцы. Тем не менее, я надеюсь, наблюдать результаты было любопытно и вам!

Комментарии (23)


  1. u_235
    25.08.2019 19:19

    Это точно не перевод? Все ссылки на английскую википедию.


    1. hummingbirddj Автор
      25.08.2019 19:20

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


      1. podde
        25.08.2019 20:05
        -1

        Браво.


  1. zelyony
    26.08.2019 00:43

    для собеседований лучше так:


    func gcd(_ a: Int, _ b: Int) -> Int { return b==0 ? a : gcd( b, a%b ); }


    1. hummingbirddj Автор
      26.08.2019 08:43

      О, да, запись красивая, однозначно. А, учитывая, Swift 5.1 даже return можно не писать (точку с запятой, конечно, тоже, но это, наверное, опечатка). Но сразу после этого, думаю, нужно быть готовым пояснить, чем опасна рекурсия, и все же настрочить итеративную версию.


      1. neurocore
        26.08.2019 09:38

        Жаль только что красота зачастую имеет малое отношение к эффективности


        1. zelyony
          26.08.2019 10:01

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


          у меня на Винде нет последнего Swift-a.
          никакого нет.
          а в последних обычно лучше оптимизации.


          1. neurocore
            26.08.2019 11:28

            Зачем проверять плохой алгоритм на бенчмарке?


            1. zelyony
              26.08.2019 11:54

              чем же он плох то?


              1. neurocore
                26.08.2019 14:42

                Очевидно рекурсией. И не надо про то, что свифт умеет в оптимизацию хвостовой рекурсии. Какой-нибудь другой ЯП не умеет, какая-нибудь другая задача не развернётся оптимально в цикл. Не проще ли написать самостоятельно цикл?


            1. Ketovdk
              26.08.2019 14:01

              вообще, этот алгоритм намного эффективнее приведенного в статье. Единственное, что в начале нужно проверить, что a>b и если это не так, то свапнуть. Ну и его тоже можно обернуть в цикл.

              		    long c = 0;
              		    while (b != 0)
              		    {
              		        c = b;
              		        b = a % c;
              		        a = c;
              		    }
              		    return a;

              Но на самом деле, если вас смущала рекурсия, то зря, т.к. она хвостовая и также переводится в цикл автоматически в большинстве языков
              P.S. Простите, что пример не на swift, не пишу на этом языке


              1. zelyony
                26.08.2019 14:21

                1) эх ты, я хотел, чтобы они(neurocore и автор) сами нарыли выход из своих ошибочных заблуждений, в которых слишком уверены.
                2) свапать необязательно.
                только если критически важно сэкономить 0.5 деления.
                3) ненамного эффективнее.
                в цикле divisionGCD выполняется 3 сравнения вместо 1ого в gcd, но, поскольку, деление — долгая операция, то выигрыш gcd будет несколько процентов. зато по количеству текстового кода — на 1000%, а машинного — на 200%.
                improvedDivisionGCD может быть эффективнее.


                1. Ketovdk
                  26.08.2019 14:47

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


      1. zelyony
        26.08.2019 09:53

        готовы пояснить, почему именно эта рекурсия неопасна?
        дизассемблерный код gcd
        и можете ее замерить с @inlinable и без?


        доп:
        ваша рекурсивная реализация переворачивания односвязного списка опасна.
        сможете настрочить безопасную рекурсию?


        1. hummingbirddj Автор
          26.08.2019 22:59

          Не могли бы вы донести вашу мысль немного доступней?


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


          1. Ketovdk
            27.08.2019 10:58

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


  1. Deosis
    26.08.2019 07:15

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


    1. longclaps
      26.08.2019 13:02

      Какой смысл? Последнее число Фибоначчи, влезающее в свифтовский UInt64 — это №90, 0xa94fad42221f2702, оно дербанится в 90 итераций любым способом. Что тут мерять-то?


  1. WroBased
    28.08.2019 06:35

    Само слово “алгоритм” восходит к имени арабского математика Аль-Хорезми, жившего примерно в VII-IX вв. уже нашей эры.

    Аль-Хорезми является персидским учёным, а не арабским.


    1. hummingbirddj Автор
      28.08.2019 06:39

      … А место его рождения по нынешним меркам находится и вовсе в Узбекистане. Но, думаю, в данном случае погрешность простительна!


      1. WroBased
        28.08.2019 13:37

        1. Хорезм находится не «вовсе в Узбекистане», а там где ему и положено.
        2. Извините, конечно, но я бы не хотел чтобы Ньютона назвали русским, а Менделеева французом.


        1. hummingbirddj Автор
          28.08.2019 17:28

          В интернетах пишут, что он родился в Хиве: https://en.wikipedia.org/wiki/Khiva


          Аль-Хорезми писал на арабском, а не на персидском. Персами же, как я понял, считаются иранцы, для которых родной язык – персидский.


          1. WroBased
            29.08.2019 18:18

            Так Хива это столица Хорезма. Вы просто написали так, что факт того, что место рождения Аль-Хорезми находится на территории нынешнего Узбекистана как что-то удивительное. В Хорезме персы жили тысячи лет. Он перс, но писал на арабском так как этот язык являлся языком науки. Извините, за дотошность.