🔙 Backtracking (Търсене с връщане)
📚 Въведение
Backtracking е алгоритмична техника за решаване на задачи чрез пълно изчерпване (brute-force), която изгражда решението стъпка по стъпка и се "връща назад" (backtracks), веднага щом установи, че текущият път не води до валидно решение.
Думата "backtrack" означава "връщане назад по следите". Представете си, че сте в лабиринт - вървите напред, докато не стигнете до задънена улица, тогава се връщате назад до последното разклонение и опитвате друг път. Това е същността на backtracking.
Техниката е особено мощна за задачи, където трябва да намерим всички възможни решения или да проверим дали съществува решение, удовлетворяващо определени ограничения.
1. Обща схема
Backtracking е по същество Дълбоко Обхождане (DFS) в дървото на състоянията. Всяко състояние представлява частично решение, а преходите между състоянията са валидни ходове.
1.1. Базов шаблон
void backtrack(State current) {
if (isSolution(current)) {
printSolution(current);
return;
}
for (Option opt : getOptions(current)) {
if (isValid(current, opt)) {
makeMove(current, opt); // Напред
backtrack(current); // Рекурсия
undoMove(current, opt); // Назад (Backtrack)
}
}
}
1.2. Ключови компоненти
Състояние (State): Представя текущата конфигурация на проблема. Може да включва: - Частично решение - Използвани ресурси - Текуща позиция в дърото на решенията
Валидиране (isValid): Проверява дали даден ход е допустим при текущото състояние. Това е критично за ефективността - колкото по-рано откажем невалидни пътища, толкова по-бързо ще работи алгоритъмът.
Решение (isSolution): Определя дали сме стигнали до валидно решение на проблема.
Ходове (makeMove/undoMove): Прилагат и отменят промени в състоянието. Важно е undoMove да възстановява състоянието точно както е било преди makeMove.
2. Класически примери
2.1. Генериране на пермутации
Да се намерят всички наредби на числата от 1 до N.
int n;
vector<int> p;
bool used[20];
void permutations() {
if (p.size() == n) {
for (int x : p) cout << x << " ";
cout << endl;
return;
}
for (int i = 1; i <= n; i++) {
if (!used[i]) {
used[i] = true;
p.push_back(i);
permutations();
p.pop_back(); // Backtrack
used[i] = false;
}
}
}
2.2. Задачата за N-те царици
Да се разположат \(N\) царици на дъска \(N \times N\), така че да не се бият.
* Използваме масиви col, diag1, diag2, за да следим заетите линии за \(O(1)\).
* diag1: \(row + col = const\)
* diag2: \(row - col + N = const\)
int n, ans = 0;
bool col[20], d1[40], d2[40];
void solve(int r) {
if (r == n) {
ans++;
return;
}
for (int c = 0; c < n; c++) {
if (!col[c] && !d1[r + c] && !d2[r - c + n]) {
col[c] = d1[r + c] = d2[r - c + n] = true;
solve(r + 1);
col[c] = d1[r + c] = d2[r - c + n] = false; // Backtrack
}
}
}
2.3. Генериране на подмножества
За всяка елемент имаме два избора: да го вземем или да не го вземем. Сложност: \(O(2^N)\).
int n;
vector<int> arr;
vector<int> current;
void generateSubsets(int idx) {
if (idx == n) {
// Принтираме текущото подмножество
for (int x : current) {
cout << x << " ";
}
cout << endl;
return;
}
// Включваме arr[idx]
current.push_back(arr[idx]);
generateSubsets(idx + 1);
current.pop_back(); // Backtrack
// Не включваме arr[idx]
generateSubsets(idx + 1);
}
2.4. Sudoku решател
Запълване на 9×9 дъска със цифри от 1 до 9, така че в всеки ред, колона и 3×3 квадрат да няма повторения.
bool isValid(int board[9][9], int row, int col, int num) {
// Проверка на реда
for (int c = 0; c < 9; c++) {
if (board[row][c] == num) return false;
}
// Проверка на колоната
for (int r = 0; r < 9; r++) {
if (board[r][col] == num) return false;
}
// Проверка на 3×3 квадрата
int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
if (board[startRow + r][startCol + c] == num) {
return false;
}
}
}
return true;
}
bool solveSudoku(int board[9][9]) {
// Намираме следващата празна клетка
int row = -1, col = -1;
for (int r = 0; r < 9 && row == -1; r++) {
for (int c = 0; c < 9; c++) {
if (board[r][c] == 0) {
row = r;
col = c;
break;
}
}
}
// Ако няма празни клетки, решението е готово
if (row == -1) return true;
// Опитваме цифрите от 1 до 9
for (int num = 1; num <= 9; num++) {
if (isValid(board, row, col, num)) {
board[row][col] = num; // Поставяме числото
if (solveSudoku(board)) {
return true; // Намерено решение
}
board[row][col] = 0; // Backtrack
}
}
return false; // Няма решение с текущата конфигурация
}
2.5. Генериране на комбинации
Избиране на \(k\) елемента от \(n\) елемента.
int n, k;
vector<int> combination;
void generateCombinations(int start) {
if (combination.size() == k) {
// Принтираме комбинацията
for (int x : combination) {
cout << x << " ";
}
cout << endl;
return;
}
for (int i = start; i <= n; i++) {
combination.push_back(i);
generateCombinations(i + 1); // Следващият елемент е по-голям
combination.pop_back(); // Backtrack
}
}
3. Окастряне (Pruning)
Най-важната част от Backtracking е оптимизацията. Ако сме сигурни, че текущият клон няма да даде решение (или по-добро от вече намереното), спираме веднага.
3.1. Видове окастряне
Feasibility Pruning (Окастряне по осъществимост): Спираме когато решението е невъзможно. * Пример: В задачата за раницата, ако текущото тегло надвишава капацитета, връщаме се.
Optimality Pruning (Окастряне по оптималност): Спираме когато не можем да постигнем по-добро решение от вече намереното. * Пример: В TSP (Търговския пътник), ако текущият път вече е по-дълъг от най-добрия намерен до момента, спираме.
Bound Pruning (Окастряне с граници): Използваме долна/горна граница за да преценим дали има смисъл да продължим. * Пример: В задачата за раницата, ако дори да вземем всички оставащи предмети (relaxation), не можем да подобрим текущия максимум, спираме.
3.2. Пример: Задача за раницата с backtracking
int n, capacity;
int weight[100], value[100];
int bestValue = 0;
void knapsack(int idx, int currentWeight, int currentValue) {
if (currentWeight > capacity) return; // Feasibility pruning
if (idx == n) {
bestValue = max(bestValue, currentValue);
return;
}
// Optimality pruning: проверка с relaxation
int remainingValue = 0;
for (int i = idx; i < n; i++) {
remainingValue += value[i];
}
if (currentValue + remainingValue <= bestValue) {
return; // Не можем да постигнем по-добро решение
}
// Взимаме предмета
knapsack(idx + 1, currentWeight + weight[idx], currentValue + value[idx]);
// Не взимаме предмета
knapsack(idx + 1, currentWeight, currentValue);
}
3.3. Heuristics (Евристики)
Редът, по който изследваме решенията, може драматично да повлияе на производителността. Добрите евристики намират решението по-рано.
Пример: Избор на променлива в Sudoku Вместо да обхождаме клетките по ред, избираме клетката с най-малко възможни стойности (Most Constrained Variable).
pair<int, int> findBestCell(int board[9][9]) {
int minOptions = 10;
pair<int, int> bestCell = {-1, -1};
for (int r = 0; r < 9; r++) {
for (int c = 0; c < 9; c++) {
if (board[r][c] != 0) continue;
int options = 0;
for (int num = 1; num <= 9; num++) {
if (isValid(board, r, c, num)) options++;
}
if (options < minOptions) {
minOptions = options;
bestCell = {r, c};
}
}
}
return bestCell;
}
4. Сложност
Backtracking алгоритмите често имат експоненциална сложност (\(O(N!)\), \(O(2^N)\), \(O(K^N)\)). * Подходящи са за \(N \le 20\) (за подмножества) или \(N \le 10\) (за пермутации). * Ако \(N\) е по-голямо, търсете Динамично програмиране или Greedy.
4.1. Анализ на сложността
Пермутации: - Брой възли: \(1 + n + n(n-1) + n(n-1)(n-2) + \dots + n!\) - Приблизително \(O(n \cdot n!)\)
Подмножества: - Брой възли: \(2^n\) (всеки елемент - вземаме/не вземаме) - Общо време: \(O(2^n \cdot T)\) където \(T\) е времето за обработка на един възел
N-царици: - Най-лош случай: \(O(n^n)\) - С pruning на практика: много по-бързо
4.2. Практически ограничения
| Тип задача | Максимален N | Време (ориентировъчно) |
|---|---|---|
| Пермутации | 10-11 | ~1 секунда |
| Подмножества | 20-25 | ~1 секунда |
| N-царици | 15-20 | ~1 секунда (с добро pruning) |
| Sudoku 9×9 | Фиксиран | Обикновено бързо с евристики |
5. Backtracking vs Динамично програмиране
Кога да използваме Backtracking?
- Трябва да намерим ВСИЧКИ решения
- Проблемът няма оптимална подструктура
- Малки constraints (\(N \leq 20\))
- Има добри възможности за pruning
Кога да използваме ДП?
- Търсим ЕДНО оптимално решение (или брой решения)
- Има overlapping subproblems
- Може да дефинираме clear state
- По-големи constraints
Пример: Subset Sum
- Backtracking: Генерира всички подмножества и проверява сумите (\(O(2^n)\) без памет)
- ДП: Пази кои суми са достижими (\(O(n \cdot sum)\) с памет)
5. Задачи
- UVa 750: 8 Queens Chess Problem.
- Sudoku Solver: Класически backtracking.
- Codeforces 1285B: Just Eat It! (може да се реши и с Greedy, но малките constraints позволяват BT).
- Knight's Tour: Обхождане на дъска с кон.
- CSES - Chessboard and Queens: N-царици вариация.
- LeetCode 51: N-Queens (класическа задача).
- LeetCode 37: Sudoku Solver.
- Codeforces 580C: Kefa and Park (DFS/Backtracking в дърво).
6. Съвети и често срещани грешки
6.1. Често срещани грешки
1. Забравяне на undoMove:
2. Модифициране на глобални променливи без възстановяване:
3. Неправилна проверка за база:
6.2. Оптимизационни техники
1. Битови маски вместо булеви масиви:
// Вместо bool used[20];
int usedMask = 0;
// Проверка
if (usedMask & (1 << i)) { /* използван */ }
// Поставяне
usedMask |= (1 << i);
// Премахване
usedMask &= ~(1 << i);
2. Предварително пресмятане: Ако проверките са скъпи, прекалкулирайте ги еднократно.
3. Сортиране на входа: Понякога сортирането позволява по-ранно pruning.
6.3. Debugging съвети
- Принтирайте пътя: Показвайте решението на всяка стъпка
- Ограничете дълбочината: Тествайте с малки входове първо
- Преброявайте извиквания: Колко рекурсивни извиквания се правят?
- Визуализирайте дървото: Начертайте първите няколко нива
🏁 Заключение
Винаги помнете да възстановите състоянието (undoMove) след връщане от рекурсията. Това е най-честата грешка.
Backtracking е мощна техника, но изисква внимание към детайлите. Ключът към успеха е: 1. Правилна дефиниция на състоянието - какво трябва да знаем на всяка стъпка? 2. Ефективна валидация - колкото по-рано откажем невалидни пътища, толкова по-бързо 3. Коректно backtracking - всяка промяна трябва да се отмени 4. Интелигентно окастряне - добрите евристики правят разликата между бързо и бавно решение
Практикувайте с класическите задачи (N-царици, Sudoku, пермутации) преди да преминете към по-сложни приложения. Разбирането на тези основи ще ви помогне да решавате много по-трудни задачи в състезанията.