В этой статье я (опять) хочу рассмотреть алгоритм поиска решения задачи полного покрытия, теперь уже с нормальной реализацией через структуру "танцующих ссылок". Заодно на этом примере хочу показать, где и зачем указание типов в Julia критично для производительности, а где оно не обязательно.
Задача точного покрытия множества некоторым набором его подмножеств состоит в выборе попарно непересекающихся подмножеств , в сумме составляющих . Например, если множество — это геометрическая фигура, а — это кусочки "паркета", то задача точного покрытия спрашивает, можно ли замостить фигуру таким паркетом.
Как пример можно рассмотреть складывание геометрических фигур из фигурок пентамино
Некоторые решения для прямоугольников (из Википедии):
Для конечного множества и конечного числа подмножеств задача может быть представлена как матрица логических значений (0/1), где в -й строке единицы соответствуют элементам множества , содержащимся в . Задача тогда состоит в выборе таких строк, чтобы в получившейся матрице в каждом столбце была ровно одна единица.
Пример матрицы:
№ строки | 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 — входной узел для структуры, в узлах A — G хранятся ссылки на соседей и число строк, пересекающих столбец, в остальных узлах хранятся только ссылки на ненулевые элементы в той же строке и в том же столбце. Также внутренние узлы хранят ссылки на заголовки столбцов, на рисунке не показанные.
Процедура покрытия столбца в этой структуре требует следующих действий:
- Отцепить заголовок столбца от правого и левого соседей.
- Проходя по ссылкам вниз, для каждой пересекаемой строки $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 (и тем, кто пробует язык после других языков с динамической типизацией), стоит потратить некоторое время на понимание концепций абстрактных и конкретных типов, параметрических типов, боксинга и устойчивости типов.
ссылка на оригинал статьи https://habr.com/ru/post/504624/
Добавить комментарий