Задача о ферзях
Расставить n ферзей, не бьющих друг друга. Как свести доску к перестановке и почему проверять на лету втрое выгоднее, чем в конце.
4 мин
На доске расставить ферзей так, чтобы никакие два не били друг друга. Ферзь бьёт по горизонтали, вертикали и обеим диагоналям.
Классическая задача перебора, на которой удобно разобрать сразу три вещи: правильное представление состояния, отсечение на лету и сравнение подходов.
Доска — это перестановка
Первое наблюдение снимает половину задачи. Ферзей ровно , строк тоже , а в одной строке двух ферзей быть не может — значит, в каждой строке ровно один ферзь. То же для столбцов.
Следовательно, расстановка полностью описывается массивом , где — столбец ферзя в строке , и этот массив — перестановка.
Вместо перебора расстановок (для это ) мы перебираем перестановок (). Пять порядков разницы — и всё из одного наблюдения.
Это общий приём: прежде чем писать перебор, найдите представление, в котором заведомо неверные варианты не существуют.
Диагонали
Осталось проверить диагонали. Ферзи в строках и бьют друг друга по диагонали, когда
Первое — диагонали «вниз-вправо», второе — «вниз-влево». Эквивалентная запись, которую чаще пишут в коде: .
Проверять в конце или на лету
Вариант первый: сгенерировать все перестановки и каждую проверить.
// перебрали перестановку целиком, теперь проверяем
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
if (abs(col[i] - col[j]) == j - i) return false;
Стоимость: . Для это около трёх миллионов операций — пройдёт. Для уже — на грани.
Вариант второй: проверять каждого ферзя сразу при постановке и не спускаться в заведомо плохую ветку.
void place(int row) {
if (row == n) { answer++; return; }
for (int c = 0; c < n; c++) {
bool ok = true;
for (int r = 0; r < row; r++)
if (col[r] == c || abs(col[r] - c) == row - r) { ok = false; break; }
if (!ok) continue;
col.push_back(c);
place(row + 1);
col.pop_back();
}
}
Здесь отпала даже надобность в массиве «использовано»: проверка col[r] == c заодно ловит и повтор столбца.
Второй вариант быстрее по двум причинам сразу: проверка короче (сравниваем нового ферзя только с уже поставленными) и, главное, целые поддеревья не обходятся. Конфликт, найденный на второй строке, отсекает вариантов.
Проверено: количество расстановок для от 1 до 10 равно 1, 0, 0, 2, 10, 4, 40, 92, 352, 724 — это известная последовательность, и она совпала.
Обратите внимание на нули при и : расстановок не существует. Хороший тест — код, забывший про диагонали, выдаст здесь 2 и 6.
Ускорение через множества занятых
Внутренняя проверка стоит . Её можно сделать за константу, если хранить три массива занятости: по столбцам и по двум диагоналям.
Диагональ «вниз-вправо» нумеруется числом (сдвиг, чтобы индекс был неотрицательным), «вниз-влево» — числом .
vector<char> usedCol(n), usedDiag1(2 * n), usedDiag2(2 * n);
void place(int row) {
if (row == n) { answer++; return; }
for (int c = 0; c < n; c++) {
int d1 = row - c + n, d2 = row + c;
if (usedCol[c] || usedDiag1[d1] || usedDiag2[d2]) continue;
usedCol[c] = usedDiag1[d1] = usedDiag2[d2] = 1;
place(row + 1);
usedCol[c] = usedDiag1[d1] = usedDiag2[d2] = 0;
}
}
Три установки перед спуском и три снятия после — тот же ритуал «изменить, углубиться, откатить», что и в остальных переборах.
Дальше это ускоряется до битовых масок: три числа вместо трёх массивов, и ~(cols | diag1 | diag2) сразу даёт множество допустимых столбцов. Так задача решается до за разумное время.
Чему учит задача
Три вывода, полезные далеко за её пределами:
- Представление важнее алгоритма. Переход от «клеток» к «перестановке» дал пять порядков, а перебор остался тем же.
- Проверять надо как можно раньше. Отсечение на второй строке ценнее любой оптимизации проверки на восьмой.
- Инкрементальная проверка бьёт полную. Не пересчитывайте всё состояние — поддерживайте его.
И практическое: количество расстановок для малых известно и легко гуглится. Это готовый набор тестов, которым стоит проверить решение до отправки.