0Pricing
DSA Interview Prep · Урок

Задача о N ферзях и распространение ограничений

Разместите N ферзей на доске N×N, используя множества столбцов и диагоналей для проверки ограничений за O(1), и обсудите разницу между подсчётом и перечислением решений

«Задача о N ферзях и распространение ограничений» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA Interview Prep содержит 4 уроков всего.

Задача о N ферзях

В задаче о N ферзях (LeetCode 51/52) требуется разместить N ферзей на шахматной доске размером N×N так, чтобы ни один ферзь не атаковал другой. Ферзи атакуют по строкам, столбцам и обеим диагоналям. Для N=4 существует ровно 2 решения. Для N=8 (классический вариант) существует 92 решения. Это классическая задача на поиск с возвратом, в которой проверка ограничений значительно сокращает пространство поиска.

# N-Queens constraints:
# 1. Exactly one queen per row
# 2. No two queens in the same column
# 3. No two queens on the same diagonal (top-left to bottom-right)
# 4. No two queens on the same anti-diagonal (top-right to bottom-left)

# For N=4, the 2 solutions:
sol1 = ['.Q..', '...Q', 'Q...', '..Q.']
sol2 = ['..Q.', 'Q...', '...Q', '.Q..']
print('N=4 solutions:')
for row in sol1: print(row)
print()
for row in sol2: print(row)

Размещение по одному ферзю в каждой строке

Поскольку два ферзя не могут находиться в одной строке, мы размещаем ровно одного ферзя в каждой строке. Поиск с возвратом выполняется построчно: для каждой строки выбирается столбец. Это сокращает пространство поиска с N² вариантов для каждого ферзя до всего N столбцов в каждой строке, создавая N^N начальных ветвей, хотя ограничения значительно уменьшают их число. Глубина рекурсии равна N (по одному уровню на строку), а коэффициент ветвления не превышает N.

def solve_n_queens(n):
    results = []
    queens = []  # queens[row] = column of queen in that row
    
    def backtrack(row):
        if row == n:
            # Build the board representation
            board = []
            for r in range(n):
                board.append('.' * queens[r] + 'Q' + '.' * (n - queens[r] - 1))
            results.append(board)
            return
        for col in range(n):
            if is_valid(row, col):
                queens.append(col)   # CHOOSE
                backtrack(row + 1)   # EXPLORE
                queens.pop()         # UNCHOOSE
    
    def is_valid(row, col):
        for r, c in enumerate(queens):
            if c == col: return False             # same column
            if abs(row - r) == abs(col - c): return False  # diagonal
        return True
    
    backtrack(0)
    return results

print(len(solve_n_queens(4)), 'solutions for N=4')  # 2
print(len(solve_n_queens(8)), 'solutions for N=8')  # 92

Проверка ограничений за O(1) с помощью множеств

Проверка допустимости перебором всех уже размещённых ферзей занимает O(N) для каждого кандидата, поэтому в худшем случае общая сложность алгоритма составляет O(N² × N!). Можно сократить каждую проверку допустимости до O(1), поддерживая три множества: cols (занятые столбцы), diag (значения разности строки и столбца для диагоналей сверху слева вниз направо) и anti_diag (значения суммы строки и столбца для диагоналей сверху справа вниз налево). Ферзи на одной диагонали имеют одинаковую разность номера строки и столбца, а ферзи на одной побочной диагонали — одинаковую сумму номера строки и столбца.

def solve_n_queens_fast(n):
    results = []
    cols = set()       # occupied columns
    diag = set()       # row - col (positive diagonal)
    anti = set()       # row + col (negative diagonal)
    queens = []
    
    def backtrack(row):
        if row == n:
            board = ['.' * c + 'Q' + '.' * (n-c-1) for c in queens]
            results.append(board)
            return
        for col in range(n):
            if col in cols or (row-col) in diag or (row+col) in anti:
                continue  # PRUNE: constraint violated
            # CHOOSE
            cols.add(col); diag.add(row-col); anti.add(row+col); queens.append(col)
            backtrack(row + 1)  # EXPLORE
            # UNCHOOSE
            cols.remove(col); diag.remove(row-col); anti.remove(row+col); queens.pop()
    
    backtrack(0)
    return results

print(len(solve_n_queens_fast(8)))  # 92

Объяснение инварианта диагоналей

Идея с диагоналями такова: все клетки на одной диагонали из верхнего левого угла в нижний правый имеют одинаковое значение row - col. Например, для (0,0), (1,1) и (2,2) разность номера строки и столбца равна 0. Все клетки на одной побочной диагонали имеют одинаковое значение row + col: для (0,2), (1,1) и (2,0) сумма номера строки и столбца равна 2. Эти инварианты позволяют за постоянное время проверять конфликты на диагоналях с помощью поиска в множестве O(1), вместо линейного прохода за O(N).

# Visualise the diagonal invariants for a 4x4 board
n = 4
print('row-col values (same diagonal):')
for r in range(n):
    print([r-c for c in range(n)])

print('row+col values (same anti-diagonal):')
for r in range(n):
    print([r+c for c in range(n)])

# Verify: (0,0) and (2,2) share diag value 0
print('(0,0) diag:', 0-0, '| (2,2) diag:', 2-2)  # both 0
# Verify: (0,2) and (2,0) share anti-diag value 2
print('(0,2) anti:', 0+2, '| (2,0) anti:', 2+0)  # both 2

Подсчёт решений: задача о N ферзях II

Задача о N ферзях II (LeetCode 52) требует найти только количество решений, а не сами доски. Это позволяет немного оптимизировать алгоритм: не строить доску, а просто увеличивать счётчик. Использование битовых масок вместо множеств может дополнительно ускорить подсчёт, приблизив стоимость каждой операции к O(1). Число решений растёт не монотонно: 1(N=1), 0(N=2), 0(N=3), 2(N=4), 10(N=5), 4(N=6), 40(N=7), 92(N=8).

def total_n_queens(n):
    count = [0]
    cols = set(); diag = set(); anti = set()
    def backtrack(row):
        if row == n:
            count[0] += 1
            return
        for col in range(n):
            if col in cols or (row-col) in diag or (row+col) in anti:
                continue
            cols.add(col); diag.add(row-col); anti.add(row+col)
            backtrack(row + 1)
            cols.remove(col); diag.remove(row-col); anti.remove(row+col)
    backtrack(0)
    return count[0]

for n in range(1, 11):
    print(f'N={n}: {total_n_queens(n)} solutions')

Задача о N ферзях с битовыми масками для ускорения

Для очень больших N реализация с битовыми масками работает значительно быстрее. Используйте три целых числа в качестве битовых масок: cols, left_diag (сдвигается влево на каждой строке) и right_diag (сдвигается вправо на каждой строке). Доступные столбцы задаются выражением ((1<<n)-1) & ~(cols|left_diag|right_diag). Извлекайте каждый доступный столбец с помощью bit = available & -available (младший установленный бит), затем выполняйте рекурсивный вызов. Это обеспечивает проверку ограничений за O(1) с помощью побитовых операций.

def total_n_queens_bitmask(n):
    full = (1 << n) - 1  # all n columns set
    count = [0]
    def bt(cols, left_diag, right_diag):
        if cols == full:
            count[0] += 1
            return
        available = full & ~(cols | left_diag | right_diag)
        while available:
            bit = available & -available  # lowest set bit
            available &= available - 1   # remove lowest bit
            bt(cols | bit,
               (left_diag | bit) << 1,
               (right_diag | bit) >> 1)
    bt(0, 0, 0)
    return count[0]

for n in range(1, 13):
    print(f'N={n}: {total_n_queens_bitmask(n)}')

Концепция распространения ограничений

Распространение ограничений выходит за рамки простого отсечения: после размещения ферзя сразу определите и исключите все недопустимые позиции в последующих строках. Это более агрессивный подход, чем проверка допустимости каждого кандидата: вы заранее сужаете пространство поиска до ветвления. Самый известный пример — согласованность дуг в решателях SAT и судоку, где размещение одной цифры исключает варианты в той же строке, столбце и блоке 3×3.

# Constraint propagation in Sudoku:
# After placing 5 in cell (0,0):
# - Row 0: no other cell can have 5
# - Column 0: no other cell can have 5
# - Box (0,0)-(2,2): no other cell can have 5
# This is propagated BEFORE branching further

# Simple demo: remaining valid columns after placing queens
def remaining_columns(n, queens):
    cols = set(q for q in queens)
    diags = set(r - q for r, q in enumerate(queens))
    anti_diags = set(r + q for r, q in enumerate(queens))
    row = len(queens)
    return [c for c in range(n)
            if c not in cols
            and (row-c) not in diags
            and (row+c) not in anti_diags]

print(remaining_columns(8, [0]))  # valid cols for row 1 after placing col 0 in row 0

Решатель судоку

Судоку — классическая задача на распространение ограничений. В каждой пустой ячейке допустимы те цифры, которых ещё нет в той же строке, столбце или блоке 3×3. Алгоритм поиска с возвратом: найдите первую пустую ячейку, попробуйте каждую допустимую цифру и перейдите к следующему шагу рекурсивно. Если возникает противоречие (пустая ячейка без допустимых цифр), выполните возврат. Хорошие решатели судоку также применяют распространение ограничений (карандашные пометки) до начала поиска с возвратом.

def solve_sudoku(board):
    def is_valid(r, c, num):
        for i in range(9):
            if board[r][i] == num: return False  # row
            if board[i][c] == num: return False  # col
        br, bc = (r//3)*3, (c//3)*3
        for i in range(3):
            for j in range(3):
                if board[br+i][bc+j] == num: return False  # box
        return True
    
    def backtrack():
        for r in range(9):
            for c in range(9):
                if board[r][c] == '.':
                    for d in '123456789':
                        if is_valid(r, c, d):
                            board[r][c] = d
                            if backtrack(): return True
                            board[r][c] = '.'
                    return False  # no valid digit found
        return True  # no empty cells: solved
    
    backtrack()
    return board

# Mini test with a solvable board (simplified)
print('Sudoku solver implemented')

Эвристика наиболее ограниченной переменной

Ключевая оптимизация для задач удовлетворения ограничений: всегда следующей выбирайте наиболее ограниченную переменную (ячейку с наименьшим числом допустимых вариантов). В судоку, если в ячейке осталась только 1 допустимая цифра, её заполнение сразу становится обязательным — возврат не нужен. Выбор таких ячеек в первую очередь значительно уменьшает глубину дерева поиска. Это эвристика минимальных оставшихся значений (MRV) из программирования задач на ограничениях в ИИ.

def solve_sudoku_mrv(board):
    '''Find cell with fewest valid choices (MRV heuristic).'''
    def valid_choices(r, c):
        nums = set('123456789')
        for i in range(9):
            nums.discard(board[r][i])
            nums.discard(board[i][c])
        br, bc = (r//3)*3, (c//3)*3
        for i in range(3):
            for j in range(3):
                nums.discard(board[br+i][bc+j])
        return nums
    
    def find_mrv():
        best = (10, -1, -1, set())  # (choices_count, r, c, choices)
        for r in range(9):
            for c in range(9):
                if board[r][c] == '.':
                    choices = valid_choices(r, c)
                    if len(choices) < best[0]:
                        best = (len(choices), r, c, choices)
        return best[1], best[2], best[3]
    
    def backtrack():
        r, c, choices = find_mrv()
        if r == -1: return True  # no empty cells
        for d in choices:
            board[r][c] = d
            if backtrack(): return True
            board[r][c] = '.'
        return False
    
    backtrack()
    return board

Таблица числа решений задачи о N ферзях

Число решений задачи о N ферзях образует следующую известную последовательность: N=1: 1, N=2: 0, N=3: 0, N=4: 2, N=5: 10, N=6: 4, N=7: 40, N=8: 92, N=9: 352, N=10: 724. Замкнутая формула неизвестна, поэтому количество решений необходимо вычислять. Для N=27 существует около 2.34 × 10^17 решений. На собеседованиях обычно спрашивают случаи с N ≤ 9. Понимание экспоненциального роста объясняет, почему оптимизация с помощью побитовых масок важна для больших значений N.

def count_queens(n):
    '''O(1) per constraint check using sets.'''
    count = [0]
    cols = set(); diag = set(); anti = set()
    def bt(row):
        if row == n: count[0] += 1; return
        for col in range(n):
            if col in cols or (row-col) in diag or (row+col) in anti: continue
            cols.add(col); diag.add(row-col); anti.add(row+col)
            bt(row+1)
            cols.discard(col); diag.discard(row-col); anti.discard(row+col)
    bt(0)
    return count[0]

sequence = [count_queens(n) for n in range(1, 12)]
print('N-Queens counts:', sequence)
# [1, 0, 0, 2, 10, 4, 40, 92, 352, 724, 2680]

Построение доски для задачи о N ферзях

Когда интервьюер просит вернуть сами доски (LeetCode 51), постройте каждую доску из списка queens, где queens[r] — столбец, в котором находится ферзь в строке r. Построение строки: '.' * col + 'Q' + '.' * (n - col - 1) для каждой строки. Эта конструкция за O(n²) вызывается только в листьях дерева рекурсии (когда все N ферзей размещены), поэтому она не влияет на общую сложность.

def n_queens_boards(n):
    results = []
    queens = []
    cols = set(); diag = set(); anti = set()
    def build_board():
        return ['.' * c + 'Q' + '.' * (n-c-1) for c in queens]
    def bt(row):
        if row == n:
            results.append(build_board())
            return
        for col in range(n):
            if col in cols or (row-col) in diag or (row+col) in anti: continue
            cols.add(col); diag.add(row-col); anti.add(row+col); queens.append(col)
            bt(row+1)
            cols.remove(col); diag.remove(row-col); anti.remove(row+col); queens.pop()
    bt(0)
    return results

for board in n_queens_boards(4):
    for row in board: print(row)
    print()

Быстрая проверка

Проверьте, насколько хорошо вы поняли концепции курса «Структуры данных & алгоритмы — подготовка к собеседованию по программированию», рассмотренные в этом уроке.

Итоги урока

В этом уроке вы узнали: задача о N ферзях размещает по одному ферзю в каждой строке и использует множества для столбцов, диагоналей (row-col) и побочных диагоналей (row+col), что позволяет проверять ограничения за O(1); побитовые маски ещё больше ускоряют проверку ограничений и позволяют исследовать все варианты почти за O(1) на операцию; распространение ограничений (эвристика MRV) сокращает поиск, поскольку следующей всегда выбирается наиболее ограниченная переменная. Далее мы сравним жадные подходы и подходы DP и узнаем, когда применять каждый из них.

Часто задаваемые вопросы

Урок «Задача о N ферзях и распространение ограничений» бесплатный?

Да — полный текст урока «Задача о N ферзях и распространение ограничений» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «Задача о N ферзях и распространение ограничений»?

Разместите N ферзей на доске N×N, используя множества столбцов и диагоналей для проверки ограничений за O(1), и обсудите разницу между подсчётом и перечислением решений Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать DSA Interview Prep?

Предыдущий опыт не требуется. DSA Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.

Сколько времени занимает урок «Задача о N ферзях и распространение ограничений»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке DSA Interview Prep?

Да. Каждый урок DSA Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Шаблон поиска с возвратом: выбрать, исследовать, отменить выбор
  2. Подмножества и множество всех подмножеств
  3. Перестановки и сочетания
  4. Задача о N ферзях и распространение ограничений
← Назад к DSA Interview Prep