Определите ограничения для конкретной задачи точного покрытия

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

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

Я подумал, что это хорошо переводится в проблему с точным прикрытием:

  • Все игроки представлены в виде столбца матрицы
  • Каждая группа из четырех игроков (независимо от их порядка) составляет одну строку в матрице.
  • Таким образом, 1 записывается в ячейке матрицы, когда игрок является членом этой группы.

После настройки для 20 игроков у меня есть матрица инцидентности с 20 столбцами и 4845 строками (969 строк на игрока/столбец).

Алгоритм X найдет решение очень хорошо, но это покроет только один (первый) раунд. Если алгоритм продолжит работу, он выдаст больше альтернативных решений для одного и того же раунда, что меня не интересует. Поэтому я создаю итератор вокруг алгоритма, который будет принимать решение и удалять строки из матрицы инцидентности на основе перекрытия игроков: всякий раз, когда группа из решения имеет пересечение не менее 2 с любой строкой матрицы, эта строка удаляется. . После первого запуска алгоритма матрица сокращается до 1280 строк. Запуск алгоритма X найдет следующее решение и т. д., пока не перестанет.

Короче говоря, этот подход не является правильным применением проблемы точного покрытия - мне пришлось итеративно находить частичные решения. Правильная задача о точном покрытии должна каким-то образом включать в себя последовательность игровых раундов. Почему? Потому что сейчас я не исследую весь спектр возможных решений! Количество игроков, равное 20, является лучшим примером для этого. Алгоритм X найдет решения всего за 3 последовательных раунда. Тем не менее, я знаю, что есть по крайней мере 5, когда выбираются разные промежуточные решения. Я надеялся, что именно эту задачу сможет решить за меня алгоритм X. При описанном выше подходе между игровыми раундами не происходит возврата.

Несмотря на то, что вопрос достаточно абстрактный, чтобы в коде не было необходимости, вот моя реализация DLX Кнута (алгоритм X с Танцевальные ссылки) в Python:

from itertools import combinations
def dancing_links (players):
    """
    Implement the dancing links algorithm as described by Donald Knuth, which
    attemts to solve an exact cover problem exhaustively in an efficient way.
    Adapted for my use case, I define the incidence matrix as follows:
        * Columns are players.
        * Rows are groups of players.
        * The intersection of groups and players mean that that player is a
          member of that group.
        * One group contains exactly four players, i.e. each row has
          exactly four 1s.
        * Repeatedly solve the exact cover problem for a reduced set of groups,
          where each round the total set of groups is filtered for illegal
          groups. An illegal group features at least two players that
          have already played together in a round.
    """

    class FoundSolution (Exception):
        "Use the exception to abort recursive stacks"
        pass

    # Dancing links is based on "doubly linked lists" intersecting
    # with each other. Python doesn't have this kind of data structure
    # and implementing it is quite expensive. E.g. each field of the incidence
    # matrix could be a Node which has pointers in all four directions,
    # The Node class with 6 attributes (four pointers, a name and arbitrary
    # data) needs to undergo countless name lookups, which is a slow process
    # in Python. So instead, I represent each node without a class definition
    # as a dict.
    # 
    # Since we're walking over so many doubly linked lists, starting from
    # any of its nodes, we need to remember where we started and iterate
    # through all of them. That clutters our code later on a lot without
    # this iterator function.
    def iter_dll (start, direction='right'):
        next = start[direction]
        # Need to explicitly compare object ids. Otherwise Python
        # would try to do a deep comparison of two dicts. which is impossible
        # due to the circular referencing.
        while id(start) != id(next):
            yield next
            next = next[direction]

    def cover (column):
        """
        Cover a column by removing its head node from the control row and
        removing each of its rows from other columns that intersect.
        """
        column['left']['right'] = column['right']
        column['right']['left'] = column['left']
        for r in iter_dll(column, 'down'):
            for c in iter_dll(r):
                c['up']['down'] = c['down']
                c['down']['up'] = c['up']

    def uncover (column):
        # Undo the changes caused by a call to cover(dll) by injecting the
        # linked nodes with the remembered predecessor and successor.
        for r in iter_dll(column, 'up'):
            for c in iter_dll(r, 'left'):
                c['up']['down'] = c['down']['up'] = c
        else:
            column['left']['right'] = column['right']['left'] = column

    def search (i, root):
        if id(root['right']) == id(root):
            # The only way to exit the complete recursion stack is an exception.
            raise FoundSolution
        for c in iter_dll(root):
            cover(c)
            for r in iter_dll(c, 'down'):
                lineup.append(r)
                for j in iter_dll(r):
                    cover(j['data'])
                search(i+1, root)
                lineup.pop()
                for j in iter_dll(r, 'left'):
                    uncover(j['data'])
            else:
                uncover(c)

    def generate_incidence_matrix (groups):
        # The gateway to our web of doubly linked lists.
        root = {'name': 'root', 'data': None}
        # Close the circle in left and right dirctions, so we can keep the
        # circle closed while injecting new nodes.
        root['right'] = root['left'] = root
        # The control row of column headers is created by attaching each new
        # Header with the previous one.
        for player in players:
            n = {
                'name': 'Headnode {}'.format(player),
                'data': player,
                'right': root,
                'left': root['left'],
            }
            n['up'] = n['down'] = n
            root['left']['right'] = root['left'] = n

        # Now append nodes to each column header node in our control row -
        # one for each player of a distinct group of four players.
        rnmbr = 0
        # Seed for new row nodes
        seed = {'name': 'seed', 'data': None}
        for g in groups:
            rnmbr += 1
            seed['right'] = seed['left'] = seed
            # Iterate through the control nodes for each of the four players.
            for header in (m for m in iter_dll(root) for p in g if m['data'] == p):
                n = {
                    # Chose a name that identifies the row and colum for this
                    # new node properly.
                    'name': 'R-{},C-{}'.format(rnmbr, header['data']),
                    'data': header,
                    'up': header['up'],
                    'down': header,
                    'left': seed,
                    'right': seed['right']
                }
                header['up']['down'] = header['up'] = n
                seed['right']['left'] = seed['right'] = n
            else:
                # Extract the seed from this row
                seed['right']['left'] = seed['left']
                seed['left']['right'] = seed['right']

        return root

    groups = tuple(combinations(players, 4))    
    groups_per_round = len(players)/4
    lineups = []

    while len(groups) >= groups_per_round:
        root = generate_incidence_matrix(groups)
        lineup = []
        try:
            search(0, root)
        except FoundSolution:
            lineup = reduce(list.__add__, ([r['data']['data']] + [n['data']['data'] for n in iter_dll(r)] for r in lineup))
            lineup = tuple(tuple(sorted(lineup[i:i + 4])) for i in xrange(0, len(lineup), 4))
            lineups.append(lineup)
            groups = tuple(group for group in groups if all(len(g.intersection(set(group))) < 2 for g in (set(s) for s in lineup))) 
        else:
            break

    return lineups

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

Вызов функции dancing_links(), как определено выше, даст следующий результат...

>>> pprint.pprint(dancing_links(range(1,21)))
[((1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12), (13, 14, 15, 16), (17, 18, 19, 20)),
 ((1, 5, 9, 13), (2, 6, 10, 17), (3, 7, 14, 18), (4, 11, 15, 19), (8, 12, 16, 20)),
 ((1, 6, 11, 14), (2, 5, 12, 18), (3, 8, 13, 19), (4, 9, 16, 17), (7, 10, 15, 20))]

То, что я ожидал, больше похоже на...

[((1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12), (13, 14, 15, 16), (17, 18, 19, 20)),
 ((1, 5, 9, 13), (2, 6, 10, 17), (3, 7, 14, 18), (4, 11, 15, 19), (8, 12, 16, 20)),
 ((1, 12, 15, 18), (2, 5, 16, 19), (3, 6, 9, 20), (4, 7, 10, 13), (8, 11, 14, 17)),
 ((1, 7, 11, 20), (2, 8, 13, 18), (3, 5, 10, 15), (4, 9, 16, 17), (6, 12, 14, 19)),
 ((1, 8, 10, 19), (2, 7, 9, 15), (3, 12, 13, 17), (4, 5, 14, 20), (6, 11, 16, 18))]

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


person Xavier Mol    schedule 02.08.2019    source источник
comment
Добро пожаловать в Stack Overflow и удачи в турнире спортивного клуба!   -  person ShreevatsaR    schedule 02.08.2019


Ответы (1)


(Ответ полностью переписан.)

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


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

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

Но на самом деле вам это вообще не нужно, так как (при отсутствии дальнейших ограничений) вы можете записать все решения явно: существует (20 выбрать 4) = 4845 способов выбрать первую группу из 4, тогда (16 выбирают 4) = 1820 способов выбрать другую группу из 4 из оставшихся и т. д., и, наконец, вас не волнует порядок среди пяти найденных групп из 4, поэтому количество способов вы можете разделить группу из 20 человек на пять групп по 4 человека.

(выбрать (20, 4) * выбрать (16, 4) * выбрать (12, 4) * выбрать (8, 4) * выбрать (4, 4)) / (5 * 4 * 3 * 2 * 1) = 2546168625 .

(Эквивалентно: (20!)/((4!)^5 * 5!) = 2546168625, так как мы можем записать список из 20 игроков, затем изменить порядок внутри каждой группы из 4 человек, а также изменить порядок групп.)

Если вы хотите сгенерировать их все с помощью программы, вы можете записать каждого из них в каноническом порядке: предположим, вы называете 20 игроков от «А» до «Т», тогда вы можете записать каждую группу из 4 в лексикографическом виде. порядок (таким образом, группа {F, J, B, Q} будет записана как «BFJQ»), и, наконец, вы можете записать сами пять групп в лексикографическом порядке (таким образом, первая группа будет начинаться с «A», вторая группа с самой ранней буквой не в первой группе и т.д.).

Затем, если вы хотите охватить несколько раундов, чтобы снова сформулировать это как что-то вроде задачи точного покрытия, вам потребуется указанное выше количество (≈2,5 миллиарда) «вариантов» (строк). Неясно, что это будут за предметы, но это явно будет непрактично, поэтому не стоит продолжать эту мысль.


Вместо этого оказывается, что ваша проблема хорошо изучена, изначально под названием задача школьницы Киркмана (расписание, от 15 человек, группы по 3 человека столько раз, сколько возможно — получается 7) (см. страница Рэя Тоула здесь), а совсем недавно — как «социальная проблема игрока в гольф» (см. страница Маркуса Триски здесь):

Задача социального игрока в гольф (SGP) — это задача комбинаторной оптимизации. Задача состоит в том, чтобы составить график g × p игроков в гольф в g группах из p игроков на w недель так, чтобы никакие два игроки в гольф играют в одной и той же группе более одного раза. Экземпляр SGP обозначается тройкой g-p-w. В исходной задаче требуется максимальное значение w, при котором экземпляр 8-4-w может быть решен.

И ваш вопрос здесь требует максимального w такого, что экземпляр 5-4-w может быть решен. Ответ оказывается w=5, и страница MathWorld дает именно это 5-4-5 пример:

Mon ABCD    EFGH    IJKL    MNOP    QRST
Tue AEIM    BJOQ    CHNT    DGLS    FKPR
Wed AGKO    BIPT    CFMS    DHJR    ELNQ
Thu AHLP    BKNS    CEOR    DFIQ    GJMT
Fri AFJN    BLMR    CGPQ    DEKT    HIOS

См. также Страница Уорвика Харви, на которой есть (имели) самые известные решения для многих различных параметров. Он записывает, что 5 является как верхней, так и нижней границей для этой задачи, т. е. известно, как запланировать 5 раундов (вы сами нашли один!) и известно, что невозможно запланировать более 5 раундов.

Вы можете поискать в литературе «социальную проблему игрока в гольф», чтобы найти больше подходов к ее решению с помощью компьютера. В более общем плане подобные проблемы изучаются в обширной области комбинаторики, известной как Комбинаторные конструкции. Во время написания более раннего ответа я нашел одну замечательную ссылку на книгу Handbook of Combinatorial Designs, которая содержит такие главы, как VI.3 Сбалансированные схемы турниров и VI.51 Планирование турниров.

person ShreevatsaR    schedule 02.08.2019
comment
Спасибо за этот обширный и очень поучительный ответ, @ShreevatsaR! Я пришел к тому же выводу, что и вы, что если бы я хотел решить свою проблему с помощью подхода с точным покрытием, мне нужно было бы определить матрицу, которая слишком велика / сложна, чтобы быть практичной. Поэтому я обратился к ОС за помощью. Я изучу ссылки, которые вы предоставили в качестве домашнего задания. - person Xavier Mol; 05.08.2019
comment
Итак, мне потребовалось довольно много времени, чтобы прочитать весь материал, который вы предоставили, @ShreevatsaR. Я разочарован тем, что лучшее решение моей проблемы с sgp(5, 4, 5) основано на случайности, но после того, как я его реализовал, оно работает на удивление хорошо. Поиск решения с 5 неделями занимает около 6 минут. Поскольку 5 — это максимальное количество недель, в течение которых 20 игроков могут быть запланированы без повторения, алгоритм имеет наименьшую степень свободы для получения решения по чистой случайности, что объясняет продолжительность. С меньшим количеством недель или большим количеством игроков (даже если они не делятся на 4) процесс идет намного быстрее. - person Xavier Mol; 13.08.2019
comment
@XavierMol Рад слышать. Случайность часто является удивительно хорошей и мощной стратегией, и уметь правильно ее использовать — настоящее искусство. - person ShreevatsaR; 13.08.2019