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



Задача точного покрытия множества $S$ некоторым набором $M$ его подмножеств состоит в выборе попарно непересекающихся подмножеств $\lbrace M_i \rbrace$, в сумме составляющих $S$. Например, если множество $S$ — это геометрическая фигура, а $M_i$ — это кусочки "паркета", то задача точного покрытия спрашивает, можно ли замостить фигуру таким паркетом.


Как пример можно рассмотреть складывание геометрических фигур из фигурок пентамино
image


Некоторые решения для прямоугольников (из Википедии):
image


Для конечного множества $S$ и конечного числа подмножеств $M$ задача может быть представлена как матрица логических значений (0/1), где в $i$-й строке единицы соответствуют элементам множества $S$, содержащимся в $M_i$. Задача тогда состоит в выборе таких строк, чтобы в получившейся матрице в каждом столбце была ровно одна единица.


Пример матрицы:


№ строки A B C D E F G
1 0 0 1 0 1 1 0
2 1 0 0 1 0 0 1
3 0 1 1 0 0 1 0
4 1 0 0 1 0 0 0
5 0 1 0 0 0 0 1
6 0 0 0 1 1 0 1

Строки 1, 4 и 5 составляют точное покрытие.


Задача является NP-полной, поэтому единственным гарантированным способом решения является поиск с возвратом. Особенности задачи позволяют построить специальную структуру данных для такого поиска, популяризованную Дональдом Кнутом, — систему "танцующих ссылок".


"Алгоритм X"


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


Вход:
    `matr` - матрица из непокрытых столбцов
    `solution` - массив, содержащий строки решения
    `k` - указатель на текущий индекс массива

Процедура `Algorithm_x`
    Если `matr` не содержит непокрытых столбцов:
        Вернуть (или вывести) `solution`.
    Иначе выбрать столбец `c`.
    Покрыть столбец `c`.
    Для всех строк `r`, пересекающихся со стобцом `c`:
        Записать `solution[k] = r`.
        Для всех столбцов `j`, пересекающихся с `r`:
            Покрыть столбец `j`.
        Вызвать `Algorithm_x(matr, solution, k+1)`.
        Если алгоритм нашёл решение, вернуть его.
        (бэктрекинг)
        Для всех столбцов `j`, пересекающихся с `r`:
            Восстановить столбец `j`.
    Восстановить столбец `c`.
    Возврат из процедуры.

"Покрытие" столбца c означает вычёркивание из матрицы его и всех строк, с которыми он пересекается (поскольку если столбец покрыт какой-либо строкой r, то другими строками его покрыть уже нельзя). Восстановление означает вставку удалённого столбца и строк обратно.


Танцующие ссылки


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


Идея танцующих ссылок основана на том, что удаление узла node из двусвязного списка делается очень просто: если элемент имеет ссылку prev на предыдущий и next на следующий элемент, то удаление — это


node.next.prev = node.prev
node.prev.next = node.next

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


node.next.prev = node
node.prev.next = node

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


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


h — входной узел для структуры, в узлах AG хранятся ссылки на соседей и число строк, пересекающих столбец, в остальных узлах хранятся только ссылки на ненулевые элементы в той же строке и в том же столбце. Также внутренние узлы хранят ссылки на заголовки столбцов, на рисунке не показанные.


Процедура покрытия столбца в этой структуре требует следующих действий:


  • Отцепить заголовок столбца от правого и левого соседей.
  • Проходя по ссылкам вниз, для каждой пересекаемой строки $r$ отцепить от соседей сверху и снизу все ссылки в той же строке, кроме собственно $r$.

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


Рассмотрим реализацию этой структуры и алгоритма поиска точного покрытия на языке Julia.


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


mutable struct LinkNode
    left
    right
    above
    below
    col
    # конструктор с привязкой к столбцу
    function LinkNode(col)
        self = new()
        self.above = self.below = self.right = self.left = self
        self.col = col
        return self
    end
    # конструктор с пустым столбцом (для входного узла)
    function LinkNode()
        self = new()
        self.above = self.below = self.right = self.left = self
        return self
    end
end

Головной узел столбца содержит ссылки на соседние элементы, размер и идентификатор.


mutable struct LinkColumn
    links
    size
    id
    function LinkColumn(id)
        self = new()
        links = LinkNode(self)
        self.links = links
        self.size = 0
        self.id = id
        return self
    end
end

Сама матрица представляется входным узлом, от которого можно перейти к списку матриц.


struct LinkMatrix
    root_node
    #=
    конструктор создает список столбцов из итерируемого объекта,
    содержащего идентификаторы столбцов
    =#
    function LinkMatrix(ids)
        root = LinkNode()
        self = new(root)
        prev = root
        for id in ids
            col = LinkColumn(id)
            collinks = col.links
            collinks.left = prev
            collinks.right = root
            prev.right = collinks
            root.left = collinks
            prev = collinks
        end
        return self
    end
end

Алгоритм поиска точного покрытия записывается в соответствии с псевдокодом:


function algorithm_x!(matr, solution=FullRow[])
    if iscovered(matr)
        return solution
    end
    col = choose_col(matr)
    cover!(col)
    for node in col
        push!(solution, FullRow(node))
        for j in RestRow(node)
            cover!(j.col)
        end
        if !isnothing(algorithm_x!(matr, solution))
            return solution
        end
        node = pop!(solution).node
        # важен порядок восстановления
        for j in Iterators.reverse(RestRow(node))
            uncover!(j.col)
        end
    end
    uncover!(col)
    return
end

Вспомогательные структуры данных и процедуры:


# проверка, покрыта ли матрица полностью
iscovered(matr) = matr.root === matr.root.right

# итератор по столбцам матрицы
function Base.iterate(matr::LinkMatrix, next = matr.root.right)
    next === matr.root && return
    return (next.col, next.right)
end

#=
структура данных, представляющая все узлы в строке,
кроме заданного
=#
struct RestRow
    node
end

# итерация по всем узлам в строке, кроме заданного
function Base.iterate(row::RestRow, next = row.node.right)
    next === row.node && return
    return (next, next.right)
end

# итерация справа налево
function Base.iterate(row::Iterators.Reverse{<:RestRow}, next = row.itr.node.left)
    next === row.itr.node && return
    return (next, next.left)
end

# строка, содержащая заданный узел
struct FullRow
    node
end

# итерация по строке, начиная с заданного узла
function Base.iterate(row::FullRow)
    node = row.node
    return (node, node.right)
end

function Base.iterate(row::FullRow, next)
    next === row.node && return
    return (next, next.right)
end

Выбор столбца, который пытаемся накрыть следующим, делается на основе "S-эвристики" — выбирается столбец, для которого меньше всего возможных накрывающих его строк:


function choose_col(matr)
    bestcol = matr.root.right.col
    s = bestcol.size
    for col in matr
        l = col.size
        l < 2 && return col # если возможен только 1 вариант или ни одного, выбираем сразу
        if l < s
            s, bestcol = l, col
        end
    end
    return bestcol
end

Процедура cover!(col) "отцепляет" столбец от правого и левого соседей (что важно — оставляя связи по вертикали без изменений, чтобы можно было потом восстановить), затем проходится по каждой строке и отцепляет узлы от соседей сверху и снизу (кроме тех ссылок, которые по цепочке ведут к столбцу col). Когда узел отцепляется от соседей по вертикали, размер столбца, где он находился, уменьшаем на единицу.


function cover!(col)
    detach_rl!(col.links)
    for node in col
        for elt in RestRow(node)
            detach_ab!(elt)
        end
    end
end

# отсоединяет от соседей слева и справа
function detach_rl!(node)
    node.right.left = node.left
    node.left.right = node.right
    return node
end

# отсоединяет от соседей сверху и снизу
function detach_ab!(node)
    node.above.below = node.below
    node.below.above = node.above
    node.col.size -= 1
    return node
end

Процедура восстановления, uncover!(col) возвращает всё назад:


function uncover!(col)
    # важен порядок прохода по столбцу
    for node in Iterators.reverse(col)
        # порядок прохода по строке не важен
        for elt in RestRow(node)
            restore_ab!(elt)
        end
    end
    restore_rl!(col.links)
end

function restore_rl!(node)
    node.right.left = node
    node.left.right = node
    return node
end

function restore_ab!(node)
    node.above.below = node
    node.below.above = node
    node.col.size += 1
    return node
end

Всё, что осталось для удобной жизни — это добавить процедуру добавления строки, которая накрывает столбцы с заданными идентификаторами:


#=
matr - матрица, куда вставляется строка
col_ids - коллекция с идентификаторами
=#
function insert_row!(matr, col_ids)
    isempty(col_ids) && return
    num_inserted = 0
    first_in_row = nothing
    prevnode = nothing
    # идём по столбцам, если id подходит - создаём новый узел, прицепляем к строке
    for col in matr
        if col.id in col_ids
            newnode = LinkNode(col)
            if isnothing(first_in_row)
                first_in_row = newnode
            end
            first_in_row.left = newnode
            newnode.right = first_in_row
            if !isnothing(prevnode)
                prevnode.right = newnode
                newnode.left = prevnode
            end
            prevnode = newnode
            num_inserted += 1
            # если все идентификаторы найдены - подцепляем строку к матрице
            if num_inserted == length(col_ids)
                for node in FullRow(first_in_row)
                    col = node.col
                    collinks = col.links
                    lastnode = collinks.above
                    col.size += 1
                    node.above = lastnode
                    node.below = collinks
                    lastnode.below = node
                    collinks.above = node
                end
                return first_in_row
            end
        end
    end
    #=
    сюда попадаем, если не нашли все идентификаторы
    в этом случае строка содержит идентификатор, которого нет в столбцах
    игнорируем эту строку и выходим
    =#
    return
end

Скорость работы


Проверим алгоритм на решении судоку и замерим скорость.


Судоку преобразуется в следующую задачу полного покрытия:


  • (:row, row, num) — в строке row есть число num
  • (:col, col, num) — в столбце col есть число num
  • (:block, col, num) — в столбце block есть число num
  • (самое хитрое условие) (:fill, row, col) — на пересечении столбца col и строки row есть число

Наличие уже заполненных клеток приводит к тому, что часть столбцов уже "накрыта", и их в матрицу задачи вносить не нужно (или можно внести и сразу же "закрыть").


function sudoku2xcover(fill)
    ncells = size(fill)
    side = ncells[1]
    side == ncells[2] || throw(ArgumentError("Invalid matrix size $ncells"))
    blockside = isqrt(side)
    blockside^2 == side || throw(ArgumentError("Side $side is not a full square"))
    row_constr = trues(ncells)
    col_constr = trues(ncells)
    blk_constr = trues(ncells)
    for col in 1:side, row in 1:side
        num = fill[row,col]
        if num in 1:side
            block = col - (col - 1) % blockside + (row - 1) ? blockside
            row_constr[row, num] = false
            col_constr[col, num] = false
            blk_constr[block, num] = false
        end
    end
    constr = collect((:row, row, num) for row in 1:side, num in 1:side if row_constr[row,num])
    append!(constr, (:col, col, num) for col in 1:side, num in 1:side if col_constr[col,num])
    append!(constr, (:block, block, num) for block in 1:side, num in 1:side if blk_constr[block,num])
    append!(constr, (:fill, row, col) for row in 1:side, col in 1:side if !(fill[row,col] in 1:side))
    matr = LinkMatrix(constr)
    for row in 1:side, col in 1:side, num in 1:side
        block = col - (col - 1) % blockside + (row - 1) ? blockside
        if row_constr[row,num] && col_constr[col,num] && blk_constr[block,num] && !(fill[row,col] in 1:side)
            col_ids = (:row, row, num), (:col, col, num), (:block, block, num), (:fill, row, col)
            insert_row!(matr, col_ids)
        end
    end
    return matr
end

function sol2matr(soln, fieldsize)
    ans = zeros(Int, fieldsize)
    for node in soln
        indval = row2indval(node)
        ans[indval.ind] = indval.val
    end
    return ans
end

function row2indval(dlrow)
    row, col, n = 0,0,0
    for j in dlrow
        constr = j.col.id
        if constr[1] === :fill
            _, row, col = constr
        else
            _, _, n = constr
        end
        all(>(0), (row, col, n)) && break
    end
    return (ind = CartesianIndex(row, col), val = n)
end

function sudoku(fill)
    cover = sudoku2xcover(fill)
    soln = sol2matr(algorithm_x!(cover), size(fill))
    soln .+= fill
    return soln
end

Посмотрим на время решения "самого сложного судоку" под названием "Золотой самородок"


const testpuzzle = [
        0 0 0 0 0 0 0 3 9;
        0 0 0 0 1 0 0 0 5;
        0 0 3 0 0 5 8 0 0;
        0 0 8 0 0 9 0 0 6;
        0 7 0 0 2 0 0 0 0;
        1 0 0 4 0 0 0 0 0;
        0 0 9 0 0 8 0 5 0;
        0 2 0 0 0 0 6 0 0;
        4 0 0 7 0 0 0 0 0
    ]
julia> using BenchmarkTools

julia> @btime sudoku(testpuzzle);
  33.012 ms (115869 allocations: 3.40 MiB)

(ответ не показан, чтобы не спойлить)
Решение требует порядка 0.1 секунды. Быстрее, чем вручную, но как-то не впечатляет. Попробуем "переписать всё в сишном стиле", чтобы ублажить компилятор и ускорить выполнение.


Ускоряем работу, проставив типы


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


Конкретизируем типы полей данных, по возможности. И сразу сталкиваемся с весёлой проблемой. Определение


# так не выйдет
mutable struct LinkNode
    left::LinkNode
    right::LinkNode
    above::LinkNode
    below::LinkNode
    col::LinkColumn
end

mutable struct LinkColumn
    links::LinkNode
    size::Int
    id::Any
end

не проходит, поскольку LinkColumn — неизвестный тип на этапе определения типа LinkNode. По той же самой причине не проходит определение сперва LinkColumn, а потом LinkNode.


Решением в данном случае будут параметрические типы. Поставим тип поля col в структуре LinkNode как параметр, а поле links в LinkColumn будет иметь тип LinkNode{LinkColumn}. Чтобы избавиться от абстрактного типа для id, параметризуем и LinkColumn типом этого поля.


mutable struct LinkNode{C}
    left::LinkNode{C}
    right::LinkNode{C}
    above::LinkNode{C}
    below::LinkNode{C}
    col::C
    function LinkNode(col::C) where {C}
        self = new{C}()
        self.above = self.below = self.right = self.left = self
        self.col = col
        return self
    end
    function LinkNode{C}() where {C}
        self = new{C}()
        self.above = self.below = self.right = self.left = self
        return self
    end
end

mutable struct LinkColumn{T}
    links::LinkNode{LinkColumn{T}}
    size::Int
    id::T
    function LinkColumn{T}(id) where {T}
        self = new{T}()
        links = LinkNode(self)
        self.links = links
        self.size = 0
        self.id = id
        return self
    end
end

LinkColumn(id::T) where {T} = LinkColumn{T}(id)

С LinkMatrix проделываем ту же процедуру.


struct LinkMatrix{T}
    root::LinkNode{LinkColumn{T}}
    function LinkMatrix{T}(ids) where {T}
        root = LinkNode{LinkColumn{T}}()
        self = new{T}(root)
        prev = root
        for id in ids
            col = LinkColumn{T}(id)
            collinks = col.links
            collinks.left = prev
            collinks.right = root
            prev.right = collinks
            root.left = collinks
            prev = collinks
        end
        return self
    end
end

LinkMatrix(ids) = LinkMatrix{eltype(ids)}(ids)

Нужно не забыть указать типы полей во вспомогательных структурах:


struct RestRow{L<:LinkNode}
    node::L
end

struct FullRow{L<:LinkNode}
    node::L
end

Проверяем:


julia> @btime sudoku(testpuzzle);
  777.426 ?s (6803 allocations: 209.91 KiB)

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


Однако стоит дописать аннотацию типа ещё в одном месте — для аргументов процедуры algorithm_x!, где в качестве аргумента по умолчанию указан контейнер с абстрактным типом элементов. Благодаря множественной диспетчеризации можно напрямую перегрузить метод, вызываемый с одним аргументом.


function algorithm_x!(matr::LinkMatrix{T}) where {T}
    RowType = FullRow{LinkNode{LinkColumn{T}}}
    solution=RowType[]
    return algorithm_x!(matr, solution)
end

Проверяем:


julia> @btime sudoku(testpuzzle);
  547.812 ?s (3603 allocations: 129.16 KiB)

Итого получили ускорение больше чем в полсотни раз. При этом каких-то нечеловеческих усилий для расстановки типов прилагать вовсе не приходится, достаточно это сделать только в ключевых местах, в первую очередь — в полях структур (где, в большинстве случаев, о предполагаемых типах полей так и так нужно задумываться). Сильно в этом помогают параметрические типы — если заранее неизвестно, какого типа может быть поле структуры, чаще всего его стоит не оставлять без типа (т.е. с типом Any), а делать его тип параметром:


struct SomeStruct{T}
    data::T
end

Так поле data может быть чем угодно, но не придётся жертвовать скоростью выполнения.


Заключение


Дизайн языка Julia позволяет использовать его не только как "язык для математиков". Разумная система типов и активное использование аннотаций типов при JIT компиляции позволяют эффективно реализовывать любые классические Computer Science алгоритмы. Особо заморачиваться с типизацией для получения быстрого кода при этом не нужно, что показано примером реализации алгоритма DLX Кнута. Но выставление аннотаций типов в стратегически выверенных точках вполне может дать прирост скорости на 1-2 порядка, поэтому тем, кто начинает программировать на Julia (и тем, кто пробует язык после других языков с динамической типизацией), стоит потратить некоторое время на понимание концепций абстрактных и конкретных типов, параметрических типов, боксинга и устойчивости типов.