Рубрики
Без рубрики

Функциональный подход к алгоритму Mergeort

Автор оригинала: FreeCodeCapm Team.

Джо Часинга

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

Очень часто мясо алгоритма (как вы решаете конкретную проблему логически без компьютерного кодирования) выглядит очень просто и понятно, когда описано графически. Однако удивительно, однако, это часто не переводится в код, написанные на языках, таких как Python, Java или C ++. Поэтому это становится намного сложнее понять.

Другими словами, алгоритмическая концепция не отображается непосредственно, как код должен быть написан и прочитать Отказ

Почему алгоритмы так сложно код?

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

Чтобы сделать вопросы хуже, на верхней части уже микроактивных языков, кому-то пришлось изобрести API для лучшего микро-менеджмента. Они назвали это объектно-ориентированным программированием (OOP) и добавила концепцию классов на программирование – но я думаю, что модули и функции могут справиться с той же всеми вещами, очень хорошо, спасибо.

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

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

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

Основные этапы получения сортировки слияния мало и просто. На самом деле, я могу объяснить это, используя номера номеров моей дочери (полезно следовать за этим, возвращаясь к анимированной диаграмме для справки):

  • Во-первых, нам нужно поддерживать подразделение списка чисел (или букв или любого типа сортировков) на половину, пока мы не получим множество одноэлементных списков. Список с одним элементом технически отсортирован. Это называется тривиально отсортирован.
  • Затем мы создаем новый пустой список, в котором мы могли бы начать повторно устроить элементы и помещать их по одному в соответствии с тем, кто менее/меньше, чем другой.
  • Затем нам нужно «объединить» каждую пару списков обратно вместе, эффективно изменяя шаги подразделения. Но на этот раз на каждом этапе пути мы должны убедиться, что меньший элемент в вопросе пары встает в пустой список первым.

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

def merge(a, b):    out = []
    while (len(a) > 0 and len(b) > 0):         if (a[0] <= b[0]):            out.append(a[0])            del a[0]        else:            out.append(b[0])            del b[0]
    while (len(a) > 0):        out.append(a[0])        del a[0]    while (len(b) > 0):        out.append(b[0])        del b[0]
    return out

Продолжайте и проводите время, глядя на это. Вы можете заметить, что с императивным кодом Python он предназначен для того, чтобы говорить, а затем поняли. Это очень понятно на английском, но не в логике.

Наша первая попытка

Вот одна попытка (что вы могли бы использовать в сеансе в доске):

Список слияния А и B нам придется сначала создать пустой список с именем OUT Для ясности (потому что в Python мы не можем быть уверены, что это действительно будет «вне» в конце концов). Затем, если (или во время) оба списка не пусты, мы продолжим вкладывать голову обоих списков в бой. Что бы ни было меньше или равно противнику выигрывает и попадает в поступление OUT первый. Проигравший придется остаться и ждать там для нового участника. Разачиты продолжаются до первого в то время как петли ломаются.

Теперь в какой-то момент либо А или B Буду бы пустым, оставляя другого с одним или несколькими элементами висит. Без каких-либо участников осталось в другом списке, два в то время как петли должны быстро отслеживать эти плохие элементы в OUT Таким образом, оба списка исчерпаны. Тогда, когда все это сделано, мы возвращаем OUT Отказ

И это тестовые случаи для слияния:

assert(merge([1], [2]) == [1, 2])assert(merge([2], [1]) == [1, 2])assert(merge([4, 1], [3, 0, 2]) == [3, 0, 2, 4, 1])

На этот момент я надеюсь, что вам понятно, почему мы в конечном итоге в конечном итоге. Если это не так, попробуйте рисовать на доске или листе бумаги и моделировать объяснение.

Разделить и покорить

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

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

def half(arr):    mid = len(arr) / 2    return arr[:mid], arr[mid:]

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

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

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

Одна отчетливая ирония вот что, даже на языке с итеративным циклом, как Python, он все еще не может полностью избежать рекурсии (это может уйти без рекурсии, но я уверен, что сделает его еще более причудливым). Рекурсия – это территория, где итеративные конструкции, такие как и во время петель, становятся совершенно бесполезными.

Более того, рекурсия не натуральная в Python. Он не чувствует себя естественным и прозрачным, а скорее чувствует себя довольно запеченным тем, как его лямбда. Попытка озвучивающейся над рекурциями в Python была бы похожей на: «Тогда мы делаем это рекурсивно и просто молимся все это разработано и попадают в базовый случай в конце, чтобы он не спираль в бесконечную тьму переполнения стека». Вау, верно?

Итак, вот МЕРГОРТ Функция:

def mergesort(arr):
    if (len(arr) <= 1):        return arr
    left, right = half(arr)    L = mergesort(left)    R = mergesort(right)
    return merge(L, R)

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

Единственный способ визуализации этого процесса Mergeort, вероятно, для отслеживания изменений в сублистах на каждом шаге:

input: [0, 3, 1, 3, 2, 6, 5]A alias for mergesort / halfB alias for merge
## subdividing by half ...
                 A([0, 3, 1, 3, 2, 6, 5])              A([0, 3, 1])    A([3, 2, 6, 5])          A([0])  A([3, 1])  A([3, 2])   A([6, 5])    A([]) A([0]) A([3])  A([1]) A([3]) A([2]) A([6]) A([5]) 
## base case reached, start merging ...               B([], [0]) B([3], [1]) B([3], [2]) B([6], [5])          B([0], [1, 3])         B([2, 3], [5, 6])                B([0, 1, 3], [2, 3, 5, 6])                 B([0, 1, 2, 3, 3, 5, 6])
output: [0, 1, 2, 3, 3, 5, 6]

На асимптотической стороне заметок, деление и завоевание почти всегда вступает в логарифмическое время выполнения. Когда вы продолжаете разделить коллекцию в N Подколаки, содержит ли он 10 или 100 000 000 элементов, количество шагов, предпринимаемых в последнем случае, увеличивается на уровне базы журнала N Отказ

Например, требуется около 3 шагов, чтобы продолжать разделить 10 на 2 до тех пор, пока он не получен так близко к 1, как может быть (или ровно 3 шага для достижения 1 в целочисленном разделении). Но требуется всего около 26 шагов, чтобы сделать то же самое и разделить 100 000 000 до 2, пока вы не достигнете 1. Этот факт может быть выражено следующим образом:

2^3.321928 = 102^6.643856 = 100...2^26.575425 = 100000000
or 
log base 2 of 100000000 = 26.575425

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

Почему между концептуальными процессами самого алгоритма и кодексом, который указывает на компьютер вычислить такие процессы?

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

Дайвинг глубже в код

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

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

Допустим, вы хотите создать базовый случай в Python. В нем вы хотите вернуть вопрос о том, когда у него есть только один элемент, а пустой список, когда есть два элемента. Так что вам нужно будет написать что-то вроде этого:

if (len(arr) == 1):    return arrelif (len(arr) == 2):    return []

Или сделать это хуже, но интереснее, вы можете попытаться получить доступ к первому элементу по индексу 0 и второй элемент по индексу 1 и будьте готовы обрабатывать IndexError исключение.

На функциональном языке, как ErLang – это то, что я буду использовать в этой статье для своей системы динамической типы, такой как Python – вы более или менее сделали бы что-то подобное:

case Arr of  [_] -> Arr;  [_,_] -> []end.

Это дает вам более четкий вид на состояние данных. Как только он достаточно обучен, это требует гораздо меньше познавательной силы для чтения и понимания того, что делает код, чем ЛЕН (ARR) Отказ Просто имейте в виду: программист, который не говорит по-английски, может спросить: «Что Лен?» Затем вы отвлекаетесь от буквального значения функции вместо значения этого выражения.

Тем не менее, это поставляется с ценой: у вас нет роскоши петлиной конструкции. Язык, как Эрланг, является рекурсионным – родным. Почти каждая значимая программа ERLANG будет использовать строгие рекурсивные вызовы функций. И именно поэтому он сопоставлен ближе к алгоритмическим концепциям, которые обычно состоят из рекурсии.

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

merge([], [], Acc) -> Acc;merge([], [H | T], Acc) -> [H | merge([], T, Acc)];merge([H | T], [], Acc) -> [H | merge(T, [], Acc)];merge([Ha | Ta], [Hb | Tb], Acc) ->  case Ha =< Hb of    true  -> [Ha | merge(Ta, [Hb | Tb], Acc)];    false -> [Hb | merge([Ha | Ta], Tb, Acc)]  end.

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

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

  • Каждый блок слияние считается предложением функции такой же функции. Они разделены ; Отказ Когда выражение заканчивается в Erlang, он заканчивается с периодом ( . ). Это конвенция, чтобы отделить функцию на несколько пунктов для разных случаев. Например, Слияние ([], [], ACC) -> A CC; Пуск отображает случай, когда первые два аргумента являются пустыми списками к значению последнего аргумента.
  • Арити играет важную роль в Эрланге. Две функции с тем же именем и активностью считаются одной и той же функцией. В противном случае они нет. Например, Merge/1 и Merge/3 (Как адресаты функции и их артерии в Erlang) являются двумя различными функциями.
  • Эрланг использует строгое образец сопоставления (Это используется на многих других функциональных языках, но особенно в Erlang). Поскольку значения в чистого функциональных языках неизменяются, безопасно связывать переменные в аналогичной форме данных к существующему с соответствующей формой. Вот тривиальный пример:
{X, Y} = {0.5, 0.13}.X.  %% 0.5Y.  %% 0.13
[A, B, C | _] = [alice, jane, bob, kent, ollie].[A, B, C].  %% [alice, jane, bob]
  • Обратите внимание, что мы будем редко говорить о возвращении значений, когда мы работаем с функциями Erlang, потому что они на самом деле не «возвращают» все как можно. Это отображает входное значение для нового значения. Это не совпадает с выводом или возвращением его из функции. Само по себе функции это Значение. Например, если Добавить (N1, N2) -> N1 + N2 , Добавить (1, 2) равно 3. Нет возможности, чтобы он вернул что-либо, кроме 3, следовательно, мы можем сказать, что это 3. Вот почему вы можете легко сделать (1) и Th en add_one . (2) это 3, ADD_ONE (5) 6 и так далее.

Для тех, кто заинтересован, посмотри Ссылательная прозрачность Отказ Чтобы сделать этот момент яснее и рискуя избыточность, вот-то подумать:

Теперь пришло время смешаться в неизвестное.

Ковка вперед с Эрлангом

merge([], [], Acc) -> Acc;

Первый пункт Merge/3 Функция означает, что когда первые два аргумента являются пустыми списками, сопоставьте все выражение для (или «возврата») третий аргумент ACC Отказ

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

Здесь ACC Стенды для аккумулятора, который вы можете думать как о состоянии контейнера. В случае Merge/3 , ACC это список, который начинается пустой. Но в качестве рекурсивных звонков включаются, он накапливает значения из первых двух списков, используя программу логики, которую мы рассмотрим следующую).

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

merge([], [H | T], Acc) -> [H | merge([], T, Acc)];

Второе предложение соответствует корпусу, когда первый список уже пуст, но все еще есть хотя бы еще один элемент во втором списке. [H |. T] означает, что список имеет элемент головы H который Минусы на другой список T Отказ В Erlang список является связанным списком, и головка имеет указатель на остальную часть списка. Так что список [1, 2, 3, 4] можно подумать как:

%% match A, B, C, and D to 1, 2, 3, and 4, respectively
[A | [B | [C | [D | []]]]] = [1, 2, 3, 4].

В этом случае, как вы можете видеть в приведенном виде, T может просто быть пустым хвостовым списком. Таким образом, во втором случае мы рассмотрим его значение новым списком, в котором H Элемент второго списка подключен к рекурсивному результату вызова Merge/3 Когда T это второй аргумент.

merge([H | T], [], Acc) -> [H | merge(T, [], Acc)];

Третий случай – это просто откидная сторона второго случая. Это соответствует корпусу, когда первый список не пуст, но второй. Это предложение отображает значение в аналогичной форме, кроме его звонков Merge/3 С хвостом первого списка как первый аргумент и сохраняет второй список пустой.

merge([Ha | Ta], [Hb | Tb], Acc) ->  case Ha =< Hb of    true  -> [Ha | merge(Ta, [Hb | Tb], Acc)];    false -> [Hb | merge([Ha | Ta], Tb, Acc)]  end.

Давайте начнем с мяса Merge/3 первый. Этот пункт соответствует корпусу, когда первые и вторые аргументы являются непустыми списками. В этом случае мы вводим дело ... из Пункт (эквивалентно переключательным примером на других языках) для проверки, если главный элемент первого списка ( HA ) меньше или равен элементу головы второго списка ( HB ).

Если это правда, мы bu Ха В полученный список следующего рекурсивного вызова для слияния с списком хвоста предыдущего первого списка ( Ta ) как новый первый аргумент. Мы держим второй и третий аргументы одинаковы.

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

Однако все получило немного волосаты для подразделения, которая нуждается в двух функциях: половина/1 и половина/3 Отказ

half([]) -> {[], []};half([X]) -> {[X], []};half([X,Y]) -> {[X], [Y]};half(L) ->  Len = length(L),  half(L, {0, Len}, {[], []}).
half([], _, {Acc1, Acc2}) ->  {lists:reverse(Acc1), lists:reverse(Acc2)};half([X], _, {Acc1, Acc2}) ->  {lists:reverse(Acc1), lists:reverse([X | Acc2])};half([H|T], {Cnt, Len}, {Acc1, Acc2}) ->  case Cnt >= (Len div 2) of      true -> half(T, {Cnt + 1, Len}, {Acc1, [H|Acc2]});      false -> half(T, {Cnt + 1, Len}, {[H|Acc1], Acc2})  end.

Это где вы пропустите Python и его разрушительный характер. В чистом функциональном языке списки связаны списки. Когда вы работаете с ними, не оглядывается назад. Там нет логики, которая говорит: «Я хочу разделить список пополам, поэтому я собираюсь получить средний индекс и нарезать его на два левый и правый порции».

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

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

половина/1 Функция, хотя на самом деле не нужно, есть для удобства.

half([]) -> {[], []};half([X]) -> {[X], []};half([X,Y]) -> {[X], [Y]};half(L) ->  Len = length(L),  half(L, {0, Len}, {[], []}).

К настоящему времени вы должны получить смысл того, что делает каждый пункт функции ERLANG. Новые пары кронштейна здесь представляют кортежи в Эрланге. Да, мы возвращаем левую и правильную валюту, как в версии Python. половина/1 Функция здесь для обработки простых, явных базовых случаев, которые не гарантируют достойность прохождения в других аргументах.

Однако обратите внимание на последний случай, когда аргумент имеет список с более чем двумя элементами. (Примечание. Те, с которыми менее или равные двум элементам уже обрабатываются первые три пункта.) Это просто вычисляет следующее:

  • Длина списка Л и звонки половина/3 с Л Как первый аргумент
  • Пара переменных счетчиков и длины списка, которая будет использоваться для сигнализации переключения из списка один в список двух
  • И, конечно же, пара пустых списков, чтобы заполнить элементы из Л в.
half([], _, {Acc1, Acc2}) ->  {lists:reverse(Acc1), lists:reverse(Acc2)};

половина/3 Выглядит как беспорядок, но только для неподготовленных глаз. Первый пункт соответствует шаблону, когда источник источника истощена. В этом случае вторая пара счетчика и длины не будет иметь значения, так как она уже конца. Мы просто знаем, что ACC1 и ACC2 созрели для уступления. Но подожди, что с обращением обоих?

Применение элемента в связанный список – очень медленная операция. Он работает o (n) времена для каждого добавления, поскольку ему необходимо создать новый список, скопировать существующий на нее и создать указатель на новый элемент и назначить его на последний элемент. Это как переделать весь список. Пара это с рекурциями, и вы связаны с катастрофой.

Единственный хороший способ добавить что-то в связанный список – это добавить его на голову. Тогда все, что ему нужно сделать, это создать память для этой новой стоимости и придать ему ссылку на главу связанного списка. Простая операция O (1). Так что, хотя мы могли бы объединить списки, используя ++ как [1, 2, 3] ++ [4] Мы редко хотим сделать это таким образом, особенно с рекурциями.

Техника здесь состоит в том, чтобы сначала обратить вспять список исходных элементов, а затем подключите к этому как [4 |. [3, 2, 1]] и снова поверните их, чтобы получить правильный результат. Это может звучать ужасно, но обращаясь в список и обратное обратное движение – это операция O (2n), которая является O (n). Но между ними, сингическими элементами на список принимает только O (1), поэтому он в основном не стоит дополнительной среды выполнения.

half([H|T], {Cnt, Len}, {Acc1, Acc2}) ->  case Cnt >= (Len div 2) of      true -> half(T, {Cnt + 1, Len}, {Acc1, [H|Acc2]});      false -> half(T, {Cnt + 1, Len}, {[H|Acc1], Acc2})  end.

Возвращаясь к половина/3 Отказ Второй пункт, мясо функции, делает ровно то же самое, что кофе наливая метафора, которую мы посещали ранее. Поскольку список источников все еще «излучающие» данные, мы хотим отслеживать время, которое мы заливаем значения из него в первую кофейную чашку ACC1 Отказ

Помните, что в половина/1 Последнее предложение, мы рассчитали длину исходного списка? Это переменная лина здесь, и она остается неизменным на протяжении всех звонков. Это там, чтобы мы могли сравнить CNT Счетчик ему разделен на 2, чтобы увидеть, если мы пришли в середину списка источника и должны переключаться на заполнение ACC2 Отказ Вот где дело ... из приходит в.

Теперь давайте поставим их все вместе в Mergesort/1 Отказ Это должно быть так же просто, как версия Python, и их можно легко сравнить.

mergesort([A]) -> [A];mergesort([A, B]) ->  case A =< B of      true -> [A,B];      false -> [B,A]  end;mergesort(L) ->  {Left, Right} = half(L),  merge(mergesort(Left), mergesort(Right), []).

Это оно!

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

Обновлять

Реализация Python слияние Функция не эффективна, потому что в каждом в то время как петля Первый элемент в списке удален. Хотя это общий рисунок на функциональных языках, таких как Erlang, в Python, очень дорого снимают или вставлять элемент в любое место, кроме последней позиции, потому что в отличие от списка в Erlang, который является связанным списком, который очень эффективен для удаления или добавления элемента В главе списка Python Life ведет себя как массив, который должен перемещать все остальные элементы, когда их удаляют или добавляют, несущие o (n) время выполнения.

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

def merge(a, b):    out = []
    ai = 0    bi = 0
    while (ai <= len(a) - 1 and bi <= len(b) - 1):         if (a[ai] <= b[bi]):            out.append(a[ai])            ai += 1        else:            out.append(b[bi])                        bi += 1
    while (ai <= len(a) - 1):        out.append(a[ai])        ai += 1
    while (bi <= len(b) - 1):        out.append(b[bi])        bi += 1
    return out