N-Queens II
Ферзь на шахматной доске атакует все клетки в своей строке, в своём столбце и на обеих диагоналях, независимо от расстояния. Дан целый номер n. Верните количество способов разместить n ферзей на доске размером n × n так, чтобы ни один ферзь не атаковал другого.
Два способа считаются разными, если в каком-либо способе на некоторой клетке стоит ферзь, а в другом эта клетка пуста. Поэтому доска и её зеркальное отражение считаются двумя способами, даже если выглядят одинаково.
Функция
- ninteger
- размер доски и количество ферзей
- Возвращаетinteger
- количество способов расставить ферзей так, чтобы ни один не атаковал другого
Ограничения
1 ≤ n ≤ 12- Ответ для
n = 12— 14,200, поэтому это значение помещается в 32-битное целое число.
Примеры
- Ввод
- n = 4
- Вывод
- 2
- Пояснение
- Записывая столбец ферзя в каждой строке сверху вниз, получаем две доски:
1, 3, 0, 2и2, 0, 3, 1. Каждая из них является зеркальным отражением другой, и они считаются двумя способами. Любой другой вариант размещает двух ферзей в одном столбце или на одной диагонали.
- Ввод
- n = 3
- Вывод
- 0
- Пояснение
- Ферзь в верхнем левом углу оставляет свободной только правую клетку среднего ряда, и тогда в нижнем ряду не остаётся безопасных клеток. Верхний правый угол приводит к тому же результату, а ферзь в верхней средней клетке атакует все три клетки среднего ряда. Поэтому ни один вариант доски не подходит.
+10 скрытых тестов при отправке
Дополнительный вопрос
Можешь посчитать только те доски, которые остаются различными после поворота и отражения доски? При n = 8 92 доски образуют 12 таких групп.
Подсказки
Открывайте по одной. Каждая подсказывает чуть больше.
Две ферзя в одной строке атакуют друг друга, поэтому в каждой строке находится ровно один ферзь. Что остаётся выбрать, когда ты это знаешь?
Заполняй доску по одной строке, начиная сверху. Как только новой ферзе угрожает фигура, отбрось эту частично заполненную доску, поскольку всё, что ты добавишь ниже, уже не сможет её исправить. Чтобы проверить клетку, не просматривая всю доску, запоминай, в каких столбцах и на каких диагоналях уже стоит ферзь. В одном направлении диагонали
row + colодинаково для каждой клетки, а в другом —row - col.Напиши
place(row), которая возвращает количество полных досок, которые можно завершить отсюда. Она возвращает 1, когдаrow == n. В противном случае она пробует каждый столбецc, для которого столбец, диагональrow + cи диагональrow - cсвободны: отметь все три, прибавьplace(row + 1)к текущей сумме, затем сними отметки. Ответ —place(0).
Решение
Расстановка определяется выбором одного столбца для каждой строки, поскольку две ферзя в одной строке всегда атакуют друг друга. Но это всё ещё n^n вариантов, около 8.9 × 10^12 при n = 12, поэтому перечислить их все невозможно. Эту задачу решают два приёма. Стройте доску построчно и отбрасывайте частичную доску сразу, как только ферзь оказывается под атакой. Так поиск сокращается до менее чем миллиона частичных досок при n = 12. Кроме того, отмечайте занятые столбцы и диагонали, чтобы для проверки клетки требовалось три обращения вместо перебора всех уже поставленных ферзей.
Попробуйте все варианты размещения, по одной ферзе в каждой строке
Верно, но не успевает на самых больших тестах
Идея
В каждой строке должна быть ровно одна ферзь, поэтому расстановка — это список cols, в котором cols[r] — столбец с ферзём в строке r. Каждая запись может указывать на любой из n столбцов, поэтому существует n^n списков. Перебирайте их, как считает одометр: увеличивайте последнюю запись на единицу, а когда она становится больше n-1, сбрасывайте её до 0 и переносите единицу на предыдущую запись.
Для каждого списка сравните каждую пару строк i < j. Два ферзя атакуют друг друга, если они стоят в одном столбце — cols[i] == cols[j] — или на одной диагонали. На диагонали при перемещении вниз на одну строку вы перемещаетесь на один столбец влево или вправо, поэтому два ферзя стоят на одной диагонали тогда и только тогда, когда разница между столбцами равна разнице между строками: |cols[i] - cols[j]| == j - i. Список, который проходит все проверки пар, задаёт допустимую доску. Поскольку проверяется каждый список, ни один вариант не пропускается и ни один не учитывается дважды.
Этот способ работает медленно, потому что он никогда не останавливается раньше времени. Если два ферзя стоят на одной диагонали в первых двух строках, доска уже заведомо не подходит, однако одометр всё равно перебирает все n^(n-2) вариантов заполнения остальных строк. Для n = 8 это 16,777,216 списков, чтобы найти 92 доски. Для n = 12 это около 8.9 × 10^12 списков. Даже если проверять один список за одну наносекунду, на это уйдёт около 2.5 часа.
Алгоритм
- Начни с
cols, состоящего из нулей: каждая ферзь находится в столбце 0. - Проверь каждую пару строк
i < j: список недопустим, еслиcols[i] == cols[j]или|cols[i] - cols[j]| == j - i. - Если ни одна пара не конфликтует, увеличь счётчик на 1.
- Продвигай
colsкак одометр: начиная с последней строки и двигаясь вверх, сбрасывай каждую запись со значениемn-1в 0, а затем увеличивай на 1 первую запись, у которой значение не равноn-1. - Когда каждая запись была равна
n-1, все спискиn^nуже проверены: верни счётчик.
def totalNQueens(n):
def is_valid(cols):
# cols[r] is the column of the queen in row r, so rows never clash.
for i in range(n):
for j in range(i + 1, n):
if cols[i] == cols[j] or abs(cols[i] - cols[j]) == j - i:
return False
return True
cols = [0] * n
count = 0
while True:
if is_valid(cols):
count += 1
# Move to the next placement, like an odometer with n digits in base n.
row = n - 1
while row >= 0 and cols[row] == n - 1:
cols[row] = 0
row -= 1
if row < 0:
return count
cols[row] += 1Возврат с наборами столбцов и диагоналей
Идея
Размещайте ферзей построчно, сверху вниз, и проверяйте каждого нового ферзя сразу после размещения. Если он под боем, никакое заполнение строк ниже не исправит это, поэтому сразу пропустите эту клетку. Если клетка безопасна, рекурсивно переходите к следующей строке, а когда этот вызов завершится, уберите ферзя и попробуйте следующий столбец. Вызов, достигший строки n, разместил n безопасных ферзей и засчитывается как одна доска. Это алгоритм с возвратом, и он активно отсекает варианты: при n = 12 он посещает 856,189 частичных досок вместо 8.9 × 10^12 полных.
Вторая часть — быстро проверить клетку. Строки ниже пусты, а в строке нового ферзя нет других ферзей, поэтому атаковать клетку (row, c) могут только три линии: её столбец, диагональ / и диагональ \. У всех клеток одной диагонали / одинаковая величина row + c, от 0 до 2n-2. У всех клеток одной диагонали \ одинаковая величина row - c, от -(n-1) до n-1, поэтому прибавьте n-1, чтобы получить индекс от 0 до 2n-2. Используйте три массива флагов: cols размером n, а также diag и anti размером 2n-1. Клетка безопасна, только если все три флага выключены: три обращения, O(1), тогда как сравнение со всеми уже размещёнными ферзями потребовало бы O(n).
На одной линии может находиться не более одного ферзя, поэтому при размещении ферзя включите три его флага, а при удалении снова выключите их — массивы вернутся в исходное состояние. На доске 4 на 4 ферзь в клетке (0, 0) устанавливает cols[0], diag[0] и anti[3]. Во второй строке клетка в столбце 1 лежит на диагонали anti[3], поэтому её пропускают, не проверяя самого ферзя.
В первой строке есть n столбцов, во второй — не более n-1, и так далее, поэтому поиск ограничен величиной O(n!), а диагонали значительно сокращают количество вариантов. При n = 12 циклы проверяют в общей сложности 10,103,868 клеток. Рекурсия достигает глубины в n вызовов, а массивы содержат около 5n флагов, поэтому пространственная сложность равна O(n).
Алгоритм
- Создайте три массива флагов, все выключены:
colsсnэлементами, аdiagиanti— по2n-1элементов каждый. - Напишите
place(row). Еслиrow == n, верните 1: в каждой строке стоит безопасная ферзь. - В противном случае для каждого столбца
cпропустите его, если включёнcols[c],diag[row + c]илиanti[row - c + n - 1]. - Для безопасного столбца включите три флага, прибавьте
place(row + 1)к общей сумме, затем выключите их. - Верните общую сумму. Ответ —
place(0).
def totalNQueens(n):
cols = [False] * n # cols[c]: column c holds a queen
diag = [False] * (2 * n - 1) # diag[r + c]: that / diagonal holds a queen
anti = [False] * (2 * n - 1) # anti[r - c + n - 1]: that \ diagonal holds a queen
def place(row):
if row == n:
return 1 # a queen in every row: one complete board
count = 0
for c in range(n):
if cols[c] or diag[row + c] or anti[row - c + n - 1]:
continue # attacked: three lookups, no scan of the board
cols[c] = diag[row + c] = anti[row - c + n - 1] = True
count += place(row + 1)
cols[c] = diag[row + c] = anti[row - c + n - 1] = False # take it back
return count
return place(0)Поиск с возвратом с помощью битовых масок
Идея
Поиск с множествами работает быстро, но в каждой строке всё равно проверяет все n столбцов, и большинство из них атакованы. Битовая маска позволяет сразу перейти к свободным клеткам. Пусть бит c целого числа обозначает столбец c строки, которую вы собираетесь заполнить, и будем хранить три маски: cols — уже занятые столбцы, left — клетки этой строки, атакованные по диагонали в одном направлении, а right — атакованные по диагонали в другом направлении.
Тогда свободные клетки задаются одним выражением: free = ~(cols | left | right) & full, где в full установлены младшие n битов. Выражение free & -free выделяет самую младшую свободную клетку, а вычитание её маски позволяет перейти к следующей. Когда вы ставите ферзя на bit и переходите на строку ниже, его столбец остаётся занятым, а каждая диагональная атака смещается на один столбец. Поэтому следующая строка получает cols | bit, ((left | bit) << 1) & full и (right | bit) >> 1. Отменять изменения не нужно: каждый вызов получает собственные три целых числа. Когда cols == full, все n ферзей расставлены.
Возьмём n = 4 и поставим первого ферзя в столбец 1: bit = 0010, считая столбец 0 крайним справа битом. Для строки 1 получаем cols = 0010, left = 0100 и right = 0001, поэтому free = 1000: столбец 3 — единственный возможный вариант, найденный без проверки столбцов 0, 1 или 2.
Поиск посещает те же частичные доски, что и версия с множествами, но теперь на каждом шаге цикла ставится ферзь. Для n = 12 это 856,188 шагов вместо 10,103,868 проверок клеток, причём на каждом шаге выполняется лишь несколько целочисленных операций. Время работы по-прежнему ограничено сверху величиной O(n!), а глубина рекурсии составляет n вызовов. Код на R выполняет те же операции с масками без рекурсии: он хранит в векторе все частичные доски для одной строки и расширяет их все на одну строку за раз, поэтому в памяти хранится целый уровень досок вместо n вызовов.
Алгоритм
- Задайте
full = (1 << n) - 1— маску всехnстолбцов. - Напишите
count(cols, left, right). Еслиcols == full, верните 1. - Вычислите
free = ~(cols | left | right) & full. - Пока
freeне равно 0, возьмитеbit = free & -free, удалите его изfreeи прибавьтеcount(cols | bit, ((left | bit) << 1) & full, (right | bit) >> 1)к общей сумме. - Верните общую сумму. Ответ —
count(0, 0, 0).
def totalNQueens(n):
full = (1 << n) - 1 # bit c stands for column c of the current row
def count(cols, left, right):
# cols: columns taken. left, right: squares of this row hit along a diagonal.
if cols == full:
return 1 # every column used: n queens placed
total = 0
free = full & ~(cols | left | right)
while free:
bit = free & -free # the lowest free square
free -= bit
# Moving down a row shifts each diagonal attack one column over.
total += count(cols | bit, ((left | bit) << 1) & full, (right | bit) >> 1)
return total
return count(0, 0, 0)
Ловушки и крайние случаи
Сам поиск короткий. Большинство ошибок связано с арифметикой диагоналей и отменой хода.
- Использование
row - cв качестве индекса без прибавленияn-1. В Java это вызывает исключение, в C происходит чтение памяти за пределами массива, а в Pythonanti[-2]незаметно читает флаг другой диагонали, поэтому получается неверное количество без какой-либо ошибки. - Создание диагональных массивов размером в
nэлементов. На доскеn × nв каждом направлении2n-1диагоналей. - Проверка только одного направления диагоналей или только столбцов. Ферзи бьют по диагоналям в обоих направлениях.
- Забыть выключить флаги после возврата рекурсивного вызова. Тогда в каждой последующей ветви учитываются ферзи, которых уже нет на доске, и количество уменьшается.
- Пропуск
& fullпри вычисленииfree.~xтакже устанавливает все биты выше столбцаn-1, поэтому цикл выбирает клетки за пределами доски, а в Python или Ruby, где целые числа не имеют фиксированной ширины,freeстановится отрицательным и цикл никогда не заканчивается. - Считать зеркальные отражения одной и той же доской. В задаче они считаются отдельно: при
n = 4есть 2 доски, и они являются зеркальными отражениями друг друга. - Неправильно обрабатывать отдельным условием доски небольшого размера. При
n = 1есть 1 доска, а приn = 2иn = 3решений нет. Поиск правильно обрабатывает все три случая без отдельных условий.
Частые вопросы4
Какова временная сложность задачи «Задача о N ферзях II»?
Перебор с возвратом ограничен сверху величиной O(n!): в первой строке есть n вариантов, в следующей — не более n-1, и так далее. Проверки диагоналей сокращают число вариантов намного ниже этой границы — до 856 189 частичных досок при n = 12. Полиномиальный метод подсчёта решений неизвестен, поэтому такой поиск — стандартный подход. Пространственная сложность — O(n).
Как определить, на какой диагонали находится квадрат?
При перемещении на один шаг по диагонали / к номеру строки прибавляется 1, а из номера столбца вычитается 1, поэтому row + col никогда не меняется. При перемещении по диагонали \ к обоим номерам прибавляется 1, поэтому row - col никогда не меняется. Каждая сумма задаёт одну диагональ, а прибавление n-1 к разности превращает её в индекс массива от 0 до 2n-2.
В чём разница между N-Queens и N-Queens II?
Задача N-Queens требует вывести каждую доску в виде строк текста. Задача N-Queens II спрашивает только, сколько их. Поиск выполняется тем же методом возврата с проверкой, но для подсчёта не нужно хранить доску в памяти — достаточно множеств столбцов и диагоналей, поэтому он работает быстрее и требует меньше ресурсов. Именно поэтому здесь естественно использовать версию с битовой маской.
Можно ли использовать симметрию, чтобы ускорить решение задачи «Задача о восьми ферзях II»?
Да. Отражение доски слева направо даёт другую допустимую доску, поэтому доски, у которых первый ферзь находится в левой половине, соответствуют доскам, у которых он находится в правой половине. Посчитай доски, у которых первый ферзь находится в столбцах от 0 до n/2 - 1, и удвой это число. Когда n нечётно, один раз добавь доски, у которых первый ферзь находится в среднем столбце. Это сокращает поиск вдвое.
Похожие задачи
Задачи на те же идеи. Решите две или три, и приём запомнится.
Python
def totalNQueens(n):
# Напишите код здесьСлучай 1
Случай 2
Ввод
n = 4
Ожидается
2