Skip to content

intro

回溯算法(Backtracking)是一种用于搜索所有解空间的算法,通过逐步构建解的路径,并在发现当前路径无法导致有效解时回退(撤销)并尝试其他可能的路径。回溯算法通过递归尝试不同的选择,通常用于解决组合、排列、分配等问题。

回溯的核心思想是:

  • 选择:在当前阶段选择一个选项(子问题的一部分)。
  • 约束:检查当前选择是否符合问题的约束条件。
  • 递归:继续处理剩余部分。
  • 回退:如果发现当前选择不符合条件或无法得到有效解,则回退并尝试其他选择。

回溯算法的基本步骤

  1. 从一个初始状态开始,递归地尝试所有可能的选择。
  2. 如果某个选择满足条件,就进入下一步,继续做选择。
  3. 如果发现当前路径不合法或无法继续,就撤销上一步的选择(回退),并尝试其他选择。
  4. 直到找到一个解,或者所有可能的路径都被尝试过。

回溯算法的经典问题

1. 八皇后问题(N-Queens Problem)

八皇后问题是经典的回溯问题,要求在 8x8 的棋盘上放置 8 个皇后,使得它们互不攻击。即任何两个皇后不在同一行、同一列或同一对角线。

算法思路:

  1. 从第一行开始,逐行放置皇后。
  2. 在每一行中,尝试将皇后放在不同的列,检查该列是否冲突。
  3. 如果没有冲突,则递归放置下一个皇后。
  4. 如果某一步不能放置皇后,则回溯,尝试其他可能的列。

代码示例(Go):

go
package main

import "fmt"

// 检查是否可以在棋盘上放置皇后
func isSafe(board [][]int, row, col int) bool {
    for i := 0; i < row; i++ {
        if board[i][col] == 1 { // 同列
            return false
        }
        if col-i-1 >= 0 && board[row-i-1][col-i-1] == 1 { // 左对角线
            return false
        }
        if col+i+1 < len(board) && board[row-i-1][col+i+1] == 1 { // 右对角线
            return false
        }
    }
    return true
}

// 回溯法解决八皇后问题
func solveNQueens(board [][]int, row int) bool {
    if row == len(board) {
        return true
    }
    for col := 0; col < len(board); col++ {
        if isSafe(board, row, col) {
            board[row][col] = 1
            if solveNQueens(board, row+1) {
                return true
            }
            board[row][col] = 0 // 回溯
        }
    }
    return false
}

func main() {
    n := 8
    board := make([][]int, n)
    for i := 0; i < n; i++ {
        board[i] = make([]int, n)
    }

    if solveNQueens(board, 0) {
        fmt.Println("Solution:")
        for i := 0; i < n; i++ {
            for j := 0; j < n; j++ {
                if board[i][j] == 1 {
                    fmt.Print("Q ")
                } else {
                    fmt.Print(". ")
                }
            }
            fmt.Println()
        }
    } else {
        fmt.Println("No solution exists")
    }
}

2. 全排列问题(Permutations)

全排列问题要求给定一个数组,生成所有可能的排列组合。回溯算法可以通过递归尝试每个位置的数,来生成所有的排列。

算法思路:

  1. 从数组的第一个元素开始,递归地交换当前元素与其他元素。
  2. 每交换一次,记录当前排列。
  3. 当所有元素都被处理后,返回并回退,尝试其他排列。

代码示例(Go):

go
package main

import "fmt"

// 生成全排列
func permute(nums []int, start int, result *[][]int) {
    if start == len(nums) {
        temp := make([]int, len(nums))
        copy(temp, nums)
        *result = append(*result, temp)
        return
    }
    for i := start; i < len(nums); i++ {
        nums[start], nums[i] = nums[i], nums[start] // 交换
        permute(nums, start+1, result)
        nums[start], nums[i] = nums[i], nums[start] // 回溯
    }
}

func main() {
    nums := []int{1, 2, 3}
    var result [][]int
    permute(nums, 0, &result)
    fmt.Println("All permutations:", result)
}

3. 数独问题(Sudoku)

数独是一种填充 9x9 数字格的游戏,要求每行、每列和每个 3x3 子格中都包含 1 至 9 的数字。回溯算法可以用于解决这个问题。

算法思路:

  1. 从空白格子开始,尝试填入数字。
  2. 对每个格子,检查数字是否符合数独的规则。
  3. 如果符合规则,继续填充下一个空白格;否则回退并尝试下一个数字。

代码示例(Go):

go
package main

import "fmt"

// 检查当前数独是否有效
func isValid(board [][]byte, row, col int, num byte) bool {
    // 检查行
    for i := 0; i < 9; i++ {
        if board[row][i] == num {
            return false
        }
    }
    // 检查列
    for i := 0; i < 9; i++ {
        if board[i][col] == num {
            return false
        }
    }
    // 检查 3x3 宫格
    startRow, startCol := row/3*3, col/3*3
    for i := startRow; i < startRow+3; i++ {
        for j := startCol; j < startCol+3; j++ {
            if board[i][j] == num {
                return false
            }
        }
    }
    return true
}

// 回溯法解数独
func solveSudoku(board [][]byte) bool {
    for i := 0; i < 9; i++ {
        for j := 0; j < 9; j++ {
            if board[i][j] == '.' {
                for num := byte('1'); num <= byte('9'); num++ {
                    if isValid(board, i, j, num) {
                        board[i][j] = num
                        if solveSudoku(board) {
                            return true
                        }
                        board[i][j] = '.' // 回溯
                    }
                }
                return false
            }
        }
    }
    return true
}

func main() {
    board := [][]byte{
        {'5', '3', '.', '.', '7', '.', '.', '.', '.'},
        {'6', '.', '.', '1', '9', '5', '.', '.', '.'},
        {'.', '9', '8', '.', '.', '.', '.', '6', '.'},
        {'8', '.', '.', '.', '6', '.', '.', '.', '3'},
        {'4', '.', '9', '.', '.', '8', '.', '.', '1'},
        {'7', '.', '.', '.', '2', '.', '.', '.', '6'},
        {'9', '6', '.', '.', '.', '.', '5', '.', '.'},
        {'2', '.', '7', '.', '.', '.', '.', '.', '8'},
        {'3', '.', '.', '8', '.', '.', '.', '7', '9'},
    }

    if solveSudoku(board) {
        fmt.Println("Solved Sudoku:")
        for _, row := range board {
            fmt.Println(string(row))
        }
    } else {
        fmt.Println("No solution exists")
    }
}

4. 迷宫问题(Maze Solving)

迷宫问题通常要求从起点找到终点,可以使用回溯算法来探索所有可能的路径,并在找到有效路径时返回。

算法思路:

  1. 从起点开始,递归尝试所有可能的路径。
  2. 如果到达终点,返回成功。
  3. 如果当前路径不可行,回退并尝试其他路径。

代码示例(Go):

go
package main

import "fmt"

// 迷宫的大小
const N = 4

// 判断当前位置是否有效
func isSafe(maze [][]int, x, y int) bool {
    return x >= 0 && x < N && y >= 0 && y < N && maze[x][y] == 1
}

// 回溯法解决迷宫问题
func solveMazeUtil(maze [][]int, x, y int, sol [][]

int) bool {
    if x == N-1 && y == N-1 {
        sol[x][y] = 1
        return true
    }
    if isSafe(maze, x, y) {
        sol[x][y] = 1
        if solveMazeUtil(maze, x+1, y, sol) {
            return true
        }
        if solveMazeUtil(maze, x, y+1, sol) {
            return true
        }
        sol[x][y] = 0 // 回溯
        return false
    }
    return false
}

func main() {
    maze := [][]int{
        {1, 0, 0, 0},
        {1, 1, 0, 1},
        {0, 1, 0, 0},
        {1, 1, 1, 1},
    }
    sol := make([][]int, N)
    for i := 0; i < N; i++ {
        sol[i] = make([]int, N)
    }

    if solveMazeUtil(maze, 0, 0, sol) {
        fmt.Println("Solution found:")
        for _, row := range sol {
            fmt.Println(row)
        }
    } else {
        fmt.Println("No solution exists")
    }
}

总结

回溯算法通过递归遍历所有可能的解决方案,逐步构建解决方案,并在碰到不可行的路径时回退。这使得回溯算法非常适合解决一些组合问题,如排列、组合、路径搜索等。虽然回溯算法可能非常耗时,但通过剪枝(提前终止无效路径)可以显著减少计算量。