Задача N ферзей: бэктрекинг по строкам, диагонали r−c и r+c и битовые маски
Разбор задачи N ферзей: бэктрекинг по строкам, проверка столбцов и диагоналей за O(1) через r−c и r+c, битовая версия для подсчёта решений и таблица ответов для n от 1 до 12.
Коротко
| Вопрос | Ответ |
|---|---|
| Главная идея | Ставим ровно одного ферзя в каждую строку и перебираем для него столбец |
| Проверка за | Три множества: занятые столбцы, диагонали r - c и антидиагонали r + c |
| Сложность | Грубая верхняя оценка , на практике отсечения режут дерево в сотни раз |
| Подсчёт без доски | Три битовые маски, свободные клетки строки — full & ~(cols | d1 | d2) |
| Сколько решений | 1, 0, 0, 2, 10, 4, 40, 92 для ; для уже 14 200 |
| Где спрашивают | LeetCode 51 (вывести все доски) и 52 (только количество), устные вопросы про бэктрекинг |
N ферзей — эталонная задача на перебор с возвратом. Общий шаблон бэктрекинга уже разобран в отдельной статье, там ферзи упоминаются мимоходом. Здесь займёмся только ими: откуда берутся формулы диагоналей, почему перебор идёт по строкам, как ускорить проверку до одной операции и как выглядит битовая версия, которую любят просить в продолжение.
Условие
Нужно расставить ферзей на доске так, чтобы никакие два не били друг друга. Ферзь бьёт всю свою строку, весь столбец и обе диагонали.
На LeetCode есть два варианта:
- 51. N-Queens — вернуть все расстановки, каждая доска как список строк из
.иQ; - 52. N-Queens II — вернуть только их количество.
Для решений два:
.Q.. ..Q. ...Q Q... Q... ...Q ..Q. .Q..
Почему перебор по строкам
Самый прямолинейный подход — выбрать клеток из и проверить каждую комбинацию. Для это миллиарда вариантов. Так задачу никто не решает, но с этой оценки удобно начать рассуждение на собеседовании.
Первое наблюдение: два ферзя в одной строке бьют друг друга. Значит, в каждой строке стоит ровно один ферзь, и расстановку можно записать массивом queens, где queens[r] — столбец ферзя в строке r. Вариантов становится , для восьми это около 16,7 миллиона.
Второе наблюдение: в каждом столбце тоже ровно один ферзь. Тогда queens — перестановка чисел , и вариантов . Остаётся проверить диагонали.
Бэктрекинг идёт дальше: мы не генерируем перестановку целиком, а строим её строка за строкой и бросаем ветку, как только новый ферзь оказывается под боем. Для такой перебор делает 2057 рекурсивных вызовов вместо 40 320 полных перестановок.
Как узнать диагональ клетки
Это центральная идея задачи. Возьмём клетку , где — строка, — столбец.
- Если идти по диагонали вниз-вправо, строка и столбец растут одновременно: , … Разность не меняется.
- Если идти вниз-влево, строка растёт, а столбец убывает: , … Не меняется сумма .
Получается, что у всех клеток одной диагонали одинаковое , а у всех клеток одной антидиагонали одинаковое . На доске 4×4 это выглядит так:
r - c: r + c: 0 -1 -2 -3 0 1 2 3 1 0 -1 -2 1 2 3 4 2 1 0 -1 2 3 4 5 3 2 1 0 3 4 5 6
Каждое число — это номер диагонали. Всего диагоналей каждого типа : значения лежат от до , значения — от до .
Значит, ферзь в клетке занимает четыре «ресурса»: строку , столбец , диагональ и антидиагональ . Строки мы занимаем по порядку и о них не думаем, остальные три храним в множествах.
Наивная проверка
Прежде чем вводить множества, посмотрим на вариант, который многие пишут первым: для новой клетки пройти по всем уже поставленным ферзям.
def is_safe(queens, r, c): for pr, pc in enumerate(queens): if pc == c or abs(pr - r) == abs(pc - c): return False return True def count_naive(n): queens = [] def bt(r): if r == n: return 1 total = 0 for c in range(n): if is_safe(queens, r, c): queens.append(c) total += bt(r + 1) queens.pop() return total return bt(0) print([count_naive(n) for n in range(1, 9)]) # [1, 0, 0, 2, 10, 4, 40, 92]
Условие abs(pr - r) == abs(pc - c) — та же идея диагоналей в другой записи: две клетки лежат на одной диагонали, если смещения по строке и столбцу равны по модулю. Решение правильное, но каждая проверка стоит . На собеседовании его можно назвать первым, а потом сказать, что проверку можно сделать за .
Решение с множествами
def solve_n_queens(n): cols, diag1, diag2 = set(), set(), set() queens = [] # queens[r] = столбец ферзя в строке r result = [] def backtrack(r): if r == n: result.append(["." * c + "Q" + "." * (n - c - 1) for c in queens]) return for c in range(n): if c in cols or (r - c) in diag1 or (r + c) in diag2: continue cols.add(c); diag1.add(r - c); diag2.add(r + c) queens.append(c) backtrack(r + 1) queens.pop() cols.remove(c); diag1.remove(r - c); diag2.remove(r + c) backtrack(0) return result for row in solve_n_queens(4)[0]: print(row) # .Q.. # ...Q # Q... # ..Q. print(len(solve_n_queens(4)), len(solve_n_queens(8))) # 2 92
Структура ровно та же, что в любом бэктрекинге:
- База: дошли до строки
n— значит, все ферзи стоят, записываем доску. - Выбор: перебираем столбец для текущей строки.
- Отсечение: если столбец или одна из диагоналей заняты, клетку пропускаем.
- Шаг вперёд: помечаем ресурсы и уходим в следующую строку.
- Откат: снимаем пометки, чтобы следующий выбор видел доску такой, какой она была до нас.
Доску в виде строк собираем только в момент, когда решение найдено. Держать двумерный массив символов и менять в нём клетки можно, но это лишняя работа на каждом шаге.
Массивы вместо множеств
Множества в Python удобны, но у диагоналей известный диапазон, поэтому подойдут обычные булевы массивы. Отрицательное r - c сдвигаем на n - 1:
def solve_arrays(n): cols = [False] * n diag1 = [False] * (2 * n - 1) # индекс r - c + n - 1 diag2 = [False] * (2 * n - 1) # индекс r + c queens, result = [], [] def backtrack(r): if r == n: result.append(queens[:]) return for c in range(n): a, b = r - c + n - 1, r + c if cols[c] or diag1[a] or diag2[b]: continue cols[c] = diag1[a] = diag2[b] = True queens.append(c) backtrack(r + 1) queens.pop() cols[c] = diag1[a] = diag2[b] = False backtrack(0) return result print(solve_arrays(4)) # [[1, 3, 0, 2], [2, 0, 3, 1]]
Здесь решения записаны компактно, как массивы столбцов: [1, 3, 0, 2] означает, что в строке 0 ферзь стоит в столбце 1, в строке 1 — в столбце 3 и так далее. Обратите внимание на queens[:]: без копии в result попадёт ссылка на один и тот же список, который к концу перебора опустеет.
В языках вроде Java или C++ вариант с массивами естественнее: там нет отрицательных индексов, и смещение n - 1 придётся делать в любом случае.
Разбор на примере n = 4
Пройдём начало перебора руками. Запись — ферзь в строке , столбце .
- Ставим . Заняты столбец 0, диагональ , антидиагональ .
- Строка 1: столбец 0 занят, у столбца 1 — та же диагональ. Столбец 2 свободен, ставим .
- Строка 2: столбцы 0 и 2 заняты, у столбца 1 — антидиагональ ферзя , у столбца 3 — его же диагональ. Тупик, откатываемся.
- Снимаем , пробуем . В строке 2 подходит только .
- Строка 3: свободен лишь столбец 2, но у клетки , как у . Снова тупик, и в строке 2 других вариантов нет.
- В строке 1 столбцы кончились, откатываемся до строки 0 и ставим .
- Дальше всё сходится: , , . Это первое решение
[1, 3, 0, 2].
Важно увидеть, что ферзь оказался бесперспективным, и мы выяснили это, не дойдя ни до одной полной расстановки. Именно ради таких ранних отказов бэктрекинг и нужен.
Битовая версия для подсчёта
Если нужно только количество решений (LeetCode 52), доску можно не хранить вовсе. Занятые столбцы и диагонали текущей строки укладываются в три целых числа, где бит отвечает за столбец .
def total_n_queens(n): full = (1 << n) - 1 def dfs(cols, d1, d2): if cols == full: return 1 count = 0 free = full & ~(cols | d1 | d2) while free: bit = free & -free free ^= bit count += dfs(cols | bit, ((d1 | bit) << 1) & full, (d2 | bit) >> 1) return count return dfs(0, 0, 0) print([total_n_queens(n) for n in range(1, 13)]) # [1, 0, 0, 2, 10, 4, 40, 92, 352, 724, 2680, 14200]
Разберём строку за строкой:
cols— занятые столбцы. Когда в ней все бит единичные, ферзи стоят во всех строках.d1иd2— столбцы текущей строки, которые бьются по диагоналям от уже поставленных ферзей. Это главное отличие от версии с множествами: мы храним не номера диагоналей, а их проекцию на текущую строку.free = full & ~(cols | d1 | d2)— свободные клетки строки одной операцией.& fullотрезает биты старше -го, которые появились после инверсии.free & -freeвыделяет младший единичный бит. Это тот же трюк, что в дереве Фенвика, подробнее о нём в статье про битовые операции.free ^= bitубирает этот бит из кандидатов.- При переходе на следующую строку диагональ, идущая в сторону старших битов, смещается на одну позицию:
<< 1. Диагональ в другую сторону —>> 1. Маска& fullнужна, чтобы биты, ушедшие за край доски, не накапливались.
Направление сдвигов значения не имеет: доска симметрична, поэтому можно поменять << 1 и >> 1 местами, ответ не изменится. Важно лишь, чтобы d1 всегда сдвигалась в одну сторону, а d2 — в другую.
Та же функция на JavaScript:
function totalNQueens(n) { const full = (1 << n) - 1 function dfs(cols, d1, d2) { if (cols === full) return 1 let count = 0 let free = full & ~(cols | d1 | d2) while (free) { const bit = free & -free free ^= bit count += dfs(cols | bit, ((d1 | bit) << 1) & full, (d2 | bit) >> 1) } return count } return dfs(0, 0, 0) } console.log([4, 8, 10, 12].map(totalNQueens)) // [ 2, 92, 724, 14200 ]
Битовые операции в JavaScript работают с 32-битными целыми, так что для доски больше 30×30 этот код уже не годится. На практике до таких размеров перебор всё равно не доживает.
Насколько это быстрее? На одной и той же машине в CPython для версия с множествами, которая ещё и собирает доски, работала около 0,7 с, битовая — около 0,2 с. Асимптотика не меняется, выигрыш даёт константа: вместо трёх поисков в хеш-таблице одна побитовая операция, а перебираются только свободные столбцы, а не все .
Сколько решений бывает
Числа для малых полезно помнить хотя бы частично: они помогают проверить свою реализацию.
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Решений | 1 | 0 | 0 | 2 | 10 | 4 | 40 | 92 | 352 | 724 | 2680 | 14 200 |
| Различных | 1 | 0 | 0 | 1 | 2 | 1 | 6 | 12 | 46 | 92 | — | — |
Вторая строка — число решений с точностью до поворотов и отражений доски. Знаменитые «12 решений» задачи о восьми ферзях — это именно они, а всего расстановок 92. Все значения в таблице посчитаны кодом из этой статьи и сверены с простым перебором перестановок для .
Что стоит отметить:
- Для и решений нет: любые два ферзя на такой маленькой доске бьют друг друга, если стоят в соседних строках.
- Последовательность не монотонна: для решений меньше, чем для .
- Для любого решение существует, и одну расстановку можно построить явной формулой за без перебора. Но формула неочевидна, и на собеседовании её не ждут.
- Количество растёт быстро, и простой формулы для него не известно. Это последовательность A000170 в OEIS.
Сложность
Время. Грубая верхняя оценка — : в строке 0 не больше вариантов, в строке 1 не больше и так далее. Реальное число узлов дерева гораздо меньше, потому что диагонали отсекают ветки, но точной формулы для него нет. Для LeetCode 51 к перебору добавляется сборка ответа: каждое решение превращается в строк длины .
Память. на глубину рекурсии и множества (или массивы длины ). Плюс сам ответ: в LeetCode 51 это символов, где — число решений. В битовой версии только стек глубины .
На собеседовании честный ответ звучит так: «в худшем случае , на практике значительно меньше из-за отсечений; память без учёта ответа». Пытаться вывести точную оценку не нужно.
Типичные ошибки
Забыть откат. Если после рекурсивного вызова не убрать столбец и диагонали из множеств, они останутся занятыми для всех последующих веток. Для такой код находит 0 решений вместо 92: первая ветка помечает ресурсы, а следующие уже не могут ничего поставить.
Перепутать формулы диагоналей. Используют r - c для одной и r - c для другой или пишут abs(r - c). Модуль склеивает разные диагонали: клетки и получат одинаковое значение 1, хотя лежат на разных диагоналях. Ответ становится меньше правильного.
Отрицательный индекс в массиве. В Python diag1[r - c] с отрицательным индексом не упадёт, а молча обратится к элементу с конца массива. Ошибки не будет, ответ будет неверным. Сдвигайте на n - 1 или используйте множество.
Сохранить ссылку вместо копии. result.append(queens) вместо result.append(queens[:]). Все элементы результата — один и тот же список, к концу перебора пустой.
Проверять строки. Множество занятых строк не нужно: строки заполняются строго по порядку, по одной. Лишняя проверка не ломает решение, но показывает интервьюеру, что вы не до конца поняли, зачем перебор идёт по строкам.
Забыть маску в битовой версии. Без & full в d1 копятся биты старше -го. На ответ это не влияет, пока free маскируется, но числа растут, а в языках с фиксированной разрядностью биты уходят за край слова. Привычка маскировать сразу избавляет от таких сюрпризов.
На собеседовании
N ферзей обычно дают в двух ситуациях. Первая — как задачу на кодинг уровня hard, где ждут рабочий бэктрекинг с проверкой за . Вторая — как устный вопрос «как бы вы решили», чтобы проверить, понимаете ли вы отсечения.
Хороший порядок ответа:
- Сказать, что в каждой строке ровно один ферзь, поэтому перебираем столбец для каждой строки.
- Объяснить, почему и однозначно задают диагонали.
- Написать решение с тремя множествами и явно проговорить откат.
- Оценить сложность: сверху, памяти.
- Если спросят про ускорение — рассказать про битовые маски.
Частые продолжения:
- Только количество — битовая версия, доску хранить не нужно.
- Найти любое одно решение — тот же бэктрекинг, но с выходом при первом найденном ответе (функция возвращает
True, и вызывающий код прекращает цикл). - Учесть симметрию — можно перебирать в первой строке только левую половину столбцов и удваивать ответ, отдельно обработав средний столбец при нечётном . Это ускоряет подсчёт примерно вдвое.
- Другие фигуры — ладьи (только столбцы, ответ ) или ограничения на запрещённые клетки. Шаблон не меняется, меняется проверка.
Похожие задачи
Та же схема «выбор → проверка → шаг → откат» встречается во многих задачах:
- Поиск слова в сетке — бэктрекинг по клеткам, где «ресурс» — посещённая клетка, и её тоже нужно освобождать при возврате. Задачу можно решить в тренажёре.
- Сумма комбинаций — перебор с отсечением по сумме.
- Судоку — те же множества по строкам, столбцам и квадратам 3×3 вместо диагоналей.
- Раскраска графа — перебор цветов вершин с проверкой соседей.
Если битовые маски в переборе понравились, следующий шаг — ДП по подмножествам, где те же приёмы используются уже для хранения состояний. А чтобы увереннее чувствовать себя с рекурсией в целом, пригодится статья про рекурсию.