четверг, 5 сентября 2013 г.

Нахождение всех перестановок на PHP в функциональном стиле

Во время недавнего гугления по какому-то поводу попалась мне на глаза заметка, в которой автор приводил реализацию алгоритма получения всех перестановок (permutations) на PHP. Видимо, по той причине, что обработка входного массива выполнялась "по месту", что существенно экономило память,  реализация алгоритма получилась весьма витиеватой. То есть, чтобы разобраться, как он работает, пришлось натурально браться за ручку и бумагу ))

И вот задумался я, а можно ли как-то все это реализовать в более понятном ключе ?....


Ведь что такое множество перестановок для некоторого массива размерности N ? Алгоритм его получения можно условно представить таким псевдокодом:

Функция ВсеПерестановки(ВходнойМассив)
       Результат = ПустойМассив();
       Цикл по I от 1 до РазмерВходногоМассиваN
             ПромежуточныйМассив = ВходнойМассив за вычетом элемента с индексом I;
             ПромежуточныеПерестановки = ВсеПерестановки(ПромежуточныйМассив);
             Цикл ПромежуточныеПерестановки как Перестановка
                       Перестановка = ЭлементВходногоМассива с индексом I + Перестановка; 
             КонецЦикла;
             Результат = Результат + ПромежуточныеПерестановки;
       КонецЦикла;
       Вернуть Результат;
КонецФункции;

То есть, налицо рекурсивный алгоритм, где каждое решение для массива размерности N основывается на решении задачи для массива размерности N-1. Для того, чтобы получить решение для массива размерности N,  нужно для каждого элемента этого массива выполнить следующие действия: получить промежуточный массив, который не содержит этого элемента
(размерность этого массива будет на 1 меньше), затем вызвать рекурсивно нашу функцию ВсеПерестановки() для этого промежуточного массива меньшей размерности, к полученным результатам функции "доклеить" в самое начало тот элемент, который мы удаляли из входного массива на первом шаге, а затем обработанный таким образом результат добавить для накопления в аккумулятор, значение которого будет возвращено по выходу из функции.

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

===
function permutations($input) {
    if (count($input) <= 1) return array($input);
    $result = array();  
    foreach ($input as $v) {
          $reduce = array_filter($input,
                                 function ($e) use($v) { return $e !== $v; }
                    );
          $result = array_merge( $result, 
                        array_map(
                             function ($e) use ($v) { array_unshift( $e, $v); return $e; },
                             permutations( $reduce )
                        )
                    );
    }
    return $result;
}===
Вызов функции выглядит следующим образом:

$input = range(1, 5, 1);
$result = permutations($input);

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

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

Теперь давайте разберем, как работает permutations(). Она всегда возвращает массив массивов (даже для одного элемента). Это сделано специально, чтобы чуть упростить обработку результатов вложенного вызова самой себя. Итак, если на входе у нас пустой массив или массив, состоящий из одного элемента, мы возвращаем его же, но упакованным в массив.
То есть, на входе array(5), на выходе - array ( array (5) ).
Если же массив имеет более одного элемента, мы выполняем следующее: для каждого элемента $v массива мы формируем при помощи array_filter() промежуточный массив $reduce, который содержит все элементы входного массива за исключением этого элемента $v. После этого, мы вызываем permutations() для массива $reduce. Полученный результат обрабатываем при помощи array_map(), которая к каждому элементу массива перестановок добавляет в начало
элемент $v функцией array_unshift(). Далее, мы просто накапливаем полученные таким образом результаты в массиве $result, и после прохода по всему массиву $input возвращаем $result.
Все достаточно очевидно. Так как permutations() использует анонимные функции, то
требуется PHP версии не ниже 5.3 (это ограничение обходится написанием внешних функций, которые будут подаваться как callback в те же array_map() или array_filter().
 
Конечно, у такого подхода есть большой минус - он весьма "жруч" в плане памяти. Объясняется это, как минимум тем, что и array_map(), и array_merge(), и array_filter() в результате своей работы формируют новые массивы (функциональная иммутабельность! =)) Плюс еще и невозможность явно освобождать память в процессе работы. Я пытался вызывать внутри цикла unset(), но пользы это не принесло, к сожалению.
В общем, при попытке вызвать permutations() для входного массива, содержащего 10 элементов
(чуть более 3 с половиной миллионов перестановок), вываливается сообщение о нехватке ОЗУ:
==
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 36 bytes)
==
Так что функцию можно применять либо как учебную, либо на массивах весьма ограниченной размерности.

Вот, пожалуй, и все на данный момент.

Дополнение. 

Кстати, проверив работу функции, о которой я писал в самом начале этого поста, на размерности 10 элементов, я получил то же сообщение о нехватке памяти.  То есть, проблема, в первую очередь, определяется не тем, что array_* функции иммутабельны и в PHP нет явного управления выделением и освобождением памяти,  а тем, что все перестановки накапливаются в одном результирующем массиве, и уже этот массив целиком подается на выход функции permutations(). Решением проблемы может стать использование генераторов в PHP, которые появились в версии 5.5. То есть, вместо накопления результатов в массиве $result можно будет выдавать наверх каждую отдельную перестановку вызовом yield. По идее, это должно работать. Для более ранних версий PHP можно предусмотреть явный вызов из permutations() некоторой callback-функции с передачей в неё последней полученной перестановки  как параметра. Это, конечно, будет не так изящно, как использование генератора, но тоже вполне приемлемо.



20 комментариев:

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

    ОтветитьУдалить
  2. Спасибо за отзыв ) Рад, что мои заметки читают люди (хоть и редко =)
    Псевдокод действительно часто облегчает жизнь при объяснении логики работы алгоритмов.

    ОтветитьУдалить
  3. Я очень долго был в медитации перед разными реализациями его этого алгоритма, честно скажу, я не мог понять :)
    Луч света появился только, когда увидел ваш псевдокод на русском, сегодня еще раз перечитал и понял суть.

    Однако придумал еще такой вариант, фактически, без рекурсии. Идея заключается в том, чтобы сначала сгенерировать все размещения, например в файл (да, это долго, работает до 7 эл включительно :)), а потом вынуть те, строки, в которых символ повторяется более одного раза, пройтись по всем входным символам.
    Вот, набросал статейку:
    http://forum.php.su/topic.php?forum=79&topic=5421&v=l#1410012311

    ОтветитьУдалить
  4. Ну, тут все дело в требовании к ресурсам (производительности и расходу памяти). В коде из статьи основная проблема - это высокие требования к памяти. Но её можно обойти, если использовать PHP-генераторы, например.
    В этом случае не нужно будет накапливать данные в массив. Рекурсия просто делает легким понимание алгоритма. А вообще, конечно, PHP - не самый лучший язык для такого рода вычислений, как мне кажется.
    Статью по вашей ссылке прочитал. В принципе, можно и так - исключением из полного списка всех возможных комбинаций тех, где есть дубликаты элементов. Просто это добавляет дополнительных вычислений.
    Кстати, когда формируется первый файл с полным списком все вариантов, то почему цикл идет до 3124 ? Если у нас 4 позиции по 4 возможных значения в каждой, то всего должно быть 4^4 = 256 значений ?
    Или я где-то неправильно считаю ?

    ОтветитьУдалить
  5. Да, я там ошибся. Делал сначала для 5^5. Да и инкремент там сатоял постфиксный.
    Вот еще нашел такой вариант в связи с другой задачей:

    http://phpclub.ru/talk/threads/%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC-%D0%BD%D0%B0%D1%85%D0%BE%D0%B6%D0%B4%D0%B5%D0%BD%D0%B8%D1%8F-%D0%BF%D0%B5%D1%80%D0%B5%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BE%D0%BA.12483/

    В принципе нерекурсивный получился у человека алгортим, эксплуатируется поиск по массиву (in_array).

    ОтветитьУдалить
  6. Очевидная польза рекурсивного алгоритма - его легче понять (в частности, решение, приведенное по последней ссылке, не так очевидно, на мой взгляд). Хотя, конечно, рекурсивные алгоритмы, как правило, более затратны по времени и ресурсам.

    ОтветитьУдалить
  7. Соглсен, я даже скажу, что пока не понимаю (пошагово), как работает алгоритм по ссылке, но я не знаю всех средств php. И тот код на 9 элементах выбрасывает can't allocate memory.
    Вот еще мой ночной вариант, все недочеты описаны, там можно много выкинуть: http://habrahabr.ru/post/260111/
    Однако даже с грязными приемами 9 чисел я сгенерировал, самый верхний цикл убрал для наглядности. Сначала вывел на бумажке его, потом уже прочитал, что алгоритм Троттера использует ту же идею. В принципе, если его оптимизировать, то операций там немного.

    Сейчас вывел еще такое на бумажке, на бумажке работает:
    https://toster.ru/q/224635
    Но я не уверен, поэтому задал вопрос.

    C уважением, dcc0

    ОтветитьУдалить
  8. Прочитал статью на хабре. В принципе, это и есть объяснение работы рекурсивного алгоритма, когда решение задачи размерности N получается через использование решения размерности N-1.
    Что касается вопроса на тостере, то самый надеждый способ проверить работоспособность алгоритма - это написать программку, реализующую псевдокод, и уже ее прогнать на тестовых данных. Для сортировки части массива можно сделать так: выделить эту часть в отдельный массив, его уже отсортировать и затем после сортировки заменить старый фрагмент отсортированным.

    ОтветитьУдалить
    Ответы
    1. Да, все же собрал полный алгоритм на PHP без рекурсии и в лексикографическом порядке. 9 цифр берет. Но операций довольно много.

      http://habrahabr.ru/post/260997/

      Удалить
  9. Спасибо, попробую.
    Пробовал так: array_slice до нужного числа, затем в обратном порядке array_unshift (но получается, что надо в цикле), т.е. все опять усложняется.

    dcc0

    ОтветитьУдалить
  10. Для вставки отcортированного массива обратно посмотрите в сторону функции array_splice:
    http://php.net/manual/ru/function.array-splice.php

    ОтветитьУдалить
  11. Нашел на bash медленный, но интересный код перестановок в лексикографическом порядке, код алгоритм рекурсивный, но очень компактный.
    http://stackoverflow.com/questions/3846123/generating-permutations-using-bash

    Правда, до конца еще его не понял.
    perm "${items:0:i}${items:i+1}" "$out${items:i:1}" )
    Как понимаю, - функция в цикле меняет позиции отображения для "субаргументов" основного аргумента, если так можно выразиться :), т.е. если первый, т.е. если один обрзается, то один другой увеличивается.
    Но непонятно вот это "$out${items:i:1}".
    Разобрать бы его подробно на примере

    dcc0



    ОтветитьУдалить
  12. Да, красивое решение. Я практически не знаю баш, но чисто интуитивно переписал алгоритм на PHP:
    ====
    function perm($input, $output = '') {
    if(!$input) {
    echo $output . "<br>";
    return;
    }
    for($i=0; $i < strlen($input); ++$i) {
    $newInput = substr_replace($input, '', $i, 1);
    $newOutput = $output . $input{$i};
    perm($newInput, $newOutput);
    }
    }
    perm('abcd');
    ===
    "$out${items:i:1}" - это, по сути, склейка двух переменных в строку: $out - то, что функция perm() получила вторым параметром, а ${items:i:1} - это один символ (текущий) из входной строки $items по индексу i. Если я правильно понимаю.

    ОтветитьУдалить
  13. Первым делом подумал про substr. Правда, функция переставляет символы по одному, т.е. можно больше 9 символов, а вот с цифрами уже сложнее, так как если брать 10 это уже два символа, но решение действительно красивое.
    Спасибо за объяснение.
    dcc0

    ОтветитьУдалить
  14. Понял, кажется, на вашем примере. Если правильно понял:
    При первом запуске вывода нет, так как условие. Так ли?

    $newInput = substr_replace($input, '', $i, 1);
    Каждый раз заменяется одна позиция в input, т.е. символ вырезается. Для 123 получается смещение. Вырезали первый, 23, второй 13, третий 12, остаётся только дописать символ в нужную позицию.


    ОтветитьУдалить
  15. Да. Если представить себе процесс работы этого алгоритма в виде дерева, то операция вывода (echo) будет выполняться для самых нижних узлов (листьев).

    ОтветитьУдалить
  16. Спасибо еще раз.
    Вот странно, нигде пока не нашел реализации инверсионного способа нахождения перестановок. Метод интересный, хоть и довольно затяжной.

    dcc0

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

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

    Вот еще один способ генерации нашел, наиболее прозрачен на мой взгляд.
    И, кстати, субъективно наглядность лучше проступает там, где код написан с применением минимального количества "встроенных функций" функций.
    http://forum.ru-board.com/topic.cgi?forum=31&topic=16348

    dcc0

    ОтветитьУдалить
  19. Посмотрел пост по ссылке. Да, тоже интересный вариант. Там, как я понял, в каждый вызов BackTrace() передается массив масок, в котором уже отмечены используемые на данный момент элементы.

    ОтветитьУдалить