1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8

# теория · шаг 2 из 8

Перестановки: n! и почему обмен на месте дешевле флагов

Коротко

Перестановок у n элементов ровно n!, и это совсем другой порядок роста, чем 2ⁿ: при n = 20 подмножеств миллион, а перестановок 2,4·10¹⁸. Способов перечислить их три. Флаги «кто уже использован» дают лексикографический порядок (как слова в словаре: раньше тот список, у которого на первом различающемся месте стоит меньший элемент), но пересобирают набор на каждом уровне. Обмен на месте дешевле и порядок ломает. Итеративная «следующая перестановка» не нуждается в рекурсии вовсе. Замер движка при n = 6: флаги 45 036 шагов, обмен 16 889 - в 2,7 раза дешевле. Узлов в дереве перебора не n!, а примерно e·n! - проверено: 1957 при n = 6.

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

Как думаешь, во сколько раз перестановок больше, чем подмножеств

Двадцать городов, надо объехать все и вернуться. Маршрут - это порядок, в котором города посещаются, то есть перестановка.

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

На что это похоже

Представь, что рассаживаешь двадцать гостей за длинный стол. Первое место можно отдать любому из двадцати. Второе - любому из девятнадцати оставшихся. Третье - из восемнадцати. Умножаем: 20 · 19 · 18 · ... · 1, и это и есть 20!.

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

Механизм: дерево, у которого ветвление убывает

Дерево перебора перестановок устроено иначе, чем дерево подмножеств. Там из каждого узла выходило ровно две ветки; здесь из корня выходит n ветвей, с первого уровня - n − 1, со второго - n − 2, и на дне ветвление кончается.

пусто 123 1213 2123 3132 123132 213231 312321 3 ветви по 2 ветви по 1 ветви листьев 6 = 3!, узлов 16 ≈ e·3!
Ветвление убывает: 3, затем 2, затем 1. Поэтому листьев ровно n!, а всех узлов - сумма 1 + n + n(n−1) + ... , которая стремится к e·n!, то есть к 2,72 вызова на один готовый ответ.

Число узлов полезно уметь считать: это 1 + n + n(n−1) + n(n−1)(n−2) + ..., то есть сумма n!/(n−k)! по всем уровням k. Если вынести n!, останется 1/0! + 1/1! + ... + 1/n! - начало того самого ряда, который сходится к числу e ≈ 2,718. Значит узлов примерно 2,72·n!. Проверка счётчиком вызовов движка: 16 при n = 3, 65 при n = 4, 326 при n = 5, 1957 при n = 6 - и 720 · 2,71805... = 1957 ровно.

Правило

Перестановок у n элементов ровно n!, а узлов в дереве их перебора около e·n! ≈ 2,72·n!, то есть меньше трёх вызовов на готовый ответ. Три способа перечисления дают один и тот же набор ответов и разные свойства. Флаги «занято» (zanyato[i]) выдают перестановки в ЛЕКСИКОГРАФИЧЕСКОМ порядке, если исходный список отсортирован. Обмен на месте (a[k], a[i] = a[i], a[k] перед спуском и назад после) не копирует набор вовсе и потому дешевле (в 2,7 раза при n = 6, и разрыв растёт с n), но порядок выдачи у него НЕ лексикографический. Итеративная «следующая перестановка» идёт лексикографически и обходится без рекурсии и без лишней памяти. Если в списке есть равные элементы, оба рекурсивных способа выдают дубли, и убираются они пропуском: на одном уровне не брать второй экземпляр равного значения, пока не взят первый. «Следующая перестановка» дублей не даёт сама.

Разбор: флаги против обмена на шести элементах

Версия с флагами. Массив zanyato помнит, какие элементы уже стоят в собранной части:

def rec(sobrano):
    if len(sobrano) == len(a):
        print(sobrano)
        return 0
    i = 0
    while i < len(a):
        if zanyato[i] == 0:
            zanyato[i] = 1
            rec(sobrano + [a[i]])
            zanyato[i] = 0
        i = i + 1
    return 0

zanyato = [0] * len(a)
rec([])

Версия с обменом. Собранная часть - это первые k элементов самого списка a; чтобы поставить на место k элемент с места i, их меняют местами, а после возврата из рекурсии обмен откатывают:

def rec(k):
    if k == len(a):
        print(a)
        return 0
    i = k
    while i < len(a):
        a[k], a[i] = a[i], a[k]
        rec(k + 1)
        a[k], a[i] = a[i], a[k]   # откат: без него список испортится
        i = i + 1
    return 0

rec(0)

Замер на одинаковых данных, вызовы совпадают до единицы, а шаги - нет:

n   вызовов   флаги, шагов   обмен, шагов   во сколько дешевле
3        16            231            134         1,7
4        65           1122            557         2,0
5       326           6561           2810         2,3
6      1957          45036          16889         2,7
7     13700      не уложился        118256          -

Причин две, и обе видны в коде. Главная - строка rec(sobrano + [a[i]]): она создаёт НОВЫЙ список на каждом узле дерева, и копирование стоит столько, сколько в наборе элементов. Обмен не создаёт ничего: он переставляет два значения в уже существующем списке. Вторая причина - цикл: у флагов он на каждом узле проходит все n позиций и проверяет zanyato[i], у обмена идёт только от k, по свободным. Поэтому разрыв растёт с n: от 1,7 раза до 2,7. На n = 7 это решает: версия с флагами не укладывается в предел шагов движка, версия с обменом укладывается.

Разбор: за что заплачено, или почему обмен портит порядок

Запусти обе версии на a = [1, 2, 3] и сравни вывод:

↳  выводдве версии перебора на a = [1, 2, 3]
флаги:  [1,2,3]  [1,3,2]  [2,1,3]  [2,3,1]  [3,1,2]  [3,2,1]
обмен:  [1,2,3]  [1,3,2]  [2,1,3]  [2,3,1]  [3,2,1]  [3,1,2]

Первые четыре совпадают, последние две переставлены. Причина механическая: обмен меняет порядок ОСТАВШИХСЯ элементов. Когда на первое место ставится тройка, обмен приводит список к [3, 2, 1], и дальше перебираются перестановки хвоста [2, 1] - то есть сначала [3, 2, 1], а уже потом [3, 1, 2]. Флаги хвост не трогают: там по-прежнему 1, 2.

На четырёх элементах расхождение видно ещё ярче: обмен выдаёт [1, 4, 3, 2] раньше, чем [1, 4, 2, 3]. Практический вывод прямой: если нужна «первая по порядку» перестановка, подходящая под условие, обмен брать нельзя - он найдёт не первую. Если порядок не важен (а при поиске лучшего маршрута он не важен), обмен дешевле и лучше.

Что ломается без этого

Первое: забыть откат. Без второй строки a[k], a[i] = a[i], a[k] список после возврата остаётся перевёрнутым, и дальше перебираются перестановки не того хвоста. Программа не падает, ответов столько же, но часть повторяется, а часть теряется. Это главный класс ошибок всего раздела: состояние, изменённое перед спуском, обязано быть восстановлено после.

Второе: дубли при равных элементах. На a = [1, 1, 2] и флаги, и обмен выдают шесть строк - [1, 1, 2], [1, 2, 1], [1, 1, 2], [1, 2, 1], [2, 1, 1], [2, 1, 1] - хотя разных перестановок только три. Разных всегда n! делённое на произведение факториалов кратностей: для [1, 1, 2, 2] это 4!/(2!·2!) = 6, а перебор выдаёт 24. В версии с флагами лечится проверкой на входе в ветку: список отсортирован, и второй экземпляр равного значения не берётся, пока не взят первый. Проверка ставится первой строкой в теле цикла, перед if zanyato[i] == 0:

    while i < len(a):
        if i > 0 and a[i] == a[i - 1] and zanyato[i - 1] == 0:
            i = i + 1          # равный предшественник свободен - эта ветка уже была
            continue
        if zanyato[i] == 0:
            ...                # дальше без изменений

Замер: на [1, 1, 2, 2] версия без проверки стоит 1122 шага и печатает 24 строки, версия с проверкой - 390 шагов и 6 строк. То есть пропуск дубля не только убирает мусор из ответа, но и почти втрое сокращает перебор. Версии с обменом этот приём не подходит: флагов у неё нет, а список после обменов не остаётся отсортированным. Там на каждом уровне запоминают, какие ЗНАЧЕНИЯ уже ставились на место k, и не ставят то же значение второй раз. А итеративная «следующая перестановка» из «Теперь сам» дублей не выдаёт вовсе: на [1, 1, 2] она печатает ровно три строки.

Третье: собирать ответы в версии с обменом строкой otvety.append(a). Печать print(a) работает верно - она снимает список в момент вызова. А вот append(a) кладёт ссылку: список a один, после возврата он восстановится, и все записи покажут исходный порядок. Если перестановки нужно собрать, на дне надо записывать копию - otvety.append(a[:]), та же ловушка, что в прошлом уроке.

Четвёртое: путать n! и 2ⁿ при оценке. При n = 10 это тысяча против трёх с половиной миллионов - разница уже в три с половиной тысячи раз, при n = 20 это миллион против 2,4·10¹⁸, то есть в два с лишним триллиона раз. Задачу на подмножества иногда можно добить перебором; задачу на перестановки - почти никогда.

Зачем это в работе

Перестановки появляются там, где важен ПОРЯДОК. Три сюжета.

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

Второй: маршруты. Коммивояжёр из прошлого раздела - это перебор (n−1)! маршрутов. Умение сказать «для пятнадцати городов это 14!, почти 10¹¹ вариантов, берём динамику по маскам» - результат ровно этого урока.

Третий, неожиданный: проверка чужих алгоритмов. Перебор перестановок - это эталон, с которым сравнивают быстрое решение на маленьких данных. Так в разделе про жадность искали контрпримеры: быстрый алгоритм против полного перебора на всех входах до пяти элементов. Полный перебор там медленный, но ПРАВИЛЬНЫЙ, а на пяти элементах его цена - 120 вариантов.

Теперь сам

Нужно напечатать перестановки списка a в лексикографическом порядке, но без рекурсии вовсе. Как получить следующую перестановку из текущей?

Ответ. Правило из четырёх шагов, оно же next_permutation в стандартных библиотеках:

def sleduyushchaya(p):
    i = len(p) - 2
    while i >= 0 and p[i] >= p[i + 1]:
        i = i - 1                      # ищем последний подъём
    if i < 0:
        return []                      # перестановка последняя
    j = len(p) - 1
    while p[j] <= p[i]:
        j = j - 1                      # ищем, чем заменить p[i]
    p[i], p[j] = p[j], p[i]
    l = i + 1
    r = len(p) - 1
    while l < r:                       # хвост переворачиваем
        p[l], p[r] = p[r], p[l]
        l = l + 1
        r = r - 1
    return p

p = a                                  # a отсортирован - это первая перестановка
while len(p) > 0:
    print(p)
    p = sleduyushchaya(p)              # функция переставляет p на месте

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

Почему это работает: хвост после позиции i убывает, значит он УЖЕ последний по порядку, и увеличить перестановку можно только подняв p[i]. Поднимаем до минимально возможного - до самого правого элемента, который больше p[i], - а хвост после обмена ставим в самый младший порядок, то есть переворачиваем. Замер: все 5040 перестановок семи элементов - 87 306 шагов против 118 256 у рекурсии с обменом, то есть итеративный способ ещё и на четверть дешевле, причём без стека вызовов. Сравнения в функции нестрогие (>= и <=), и поэтому равные элементы она не переставляет между собой: на [1, 1, 2] выходят три строки, а не шесть.

Главное

Перестановок n!, узлов в дереве их перебора около e·n! - меньше трёх вызовов на ответ, то есть накладные расходы перебора ничтожны по сравнению с самим числом ответов, и бороться надо с ним. Выбор способа - это выбор размена: флаги дают лексикографический порядок и платят копией набора на каждом узле (n = 7 уже не укладывается в предел движка); обмен на месте дешевле в 2,7 раза при n = 6 и выдаёт ответы в своём порядке, потому что переставляет и хвост; итеративная «следующая перестановка» даёт и порядок, и дешевизну, но не умеет отсекать ветки - а именно это понадобится в следующей главе. Равные элементы без пропуска дублей раздувают перебор: [1, 1, 2, 2] даёт 24 строки вместо 6.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

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