回溯算法:从八皇后到TSP的思维跃迁

回溯算法,说白了就是一种「试错」的思维。你想想看,生活中我们做决策时,不也经常是走一步看一步吗?这条路走不通,退回来换一条。回溯算法干的就是这件事——它把问题的所有可能解空间想象成一棵树,从根节点出发,沿着一条路径往下走,走到某个节点发现不对劲,就掉头回去,换条路再试。

我个人习惯把回溯算法叫做「有智慧的暴力」。为什么这么说?因为它本质上还是穷举,但比纯粹的暴力搜索聪明得多——它会在搜索过程中剪掉那些明显不可能的分支。嗯,这里要注意,剪枝的好坏直接决定了回溯算法的效率。

核心思想:回溯 = 深度优先搜索 + 剪枝策略。每一步都尝试所有可能的选择,如果当前选择导致后续无法得到解,就撤销这个选择,回到上一步重新尝试。

回溯算法的基本框架

我在项目中用过不少回溯算法,总结下来,它的代码结构其实非常固定。你只要记住这个模板,大部分回溯问题都能往里套:

void backtrack(路径, 选择列表) {
    if (满足结束条件) {
        记录结果;
        return;
    }
    
    for (选择 : 选择列表) {
        // 做选择
        路径.add(选择);
        // 进入下一层决策树
        backtrack(路径, 选择列表);
        // 撤销选择(回溯的核心)
        路径.remove(选择);
    }
}

这个模板里,最关键的就是「做选择」和「撤销选择」这两步。我曾经见过不少新手写回溯时,只记得做选择,忘了撤销,结果路径越走越长,永远找不到解。说白了,回溯的精髓就在于「能进能退」。

八皇后问题:回溯的经典入门

八皇后问题,你应该听说过。在一个8×8的棋盘上放8个皇后,要求任意两个皇后都不能在同一行、同一列或同一对角线上。我第一次看到这个问题时,觉得挺简单的,结果自己动手写代码才发现——嗯,坑不少。

先来看代码实现:

#include <stdio.h>
#include <stdbool.h>

#define N 8

int count = 0;  // 解的总数

// 检查位置 (row, col) 是否安全
bool isSafe(int board[N][N], int row, int col) {
    int i, j;
    
    // 检查当前列的上方
    for (i = 0; i < row; i++) {
        if (board[i][col] == 1)
            return false;
    }
    
    // 检查左上对角线
    for (i = row, j = col; i >= 0 && j >= 0; i--, j--) {
        if (board[i][j] == 1)
            return false;
    }
    
    // 检查右上对角线
    for (i = row, j = col; i >= 0 && j < N; i--, j++) {
        if (board[i][j] == 1)
            return false;
    }
    
    return true;
}

void solveNQueens(int board[N][N], int row) {
    // 所有行都放完了,找到一个解
    if (row == N) {
        count++;
        printf("解法 %d:\n", count);
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                printf("%c ", board[i][j] ? 'Q' : '.');
            }
            printf("\n");
        }
        printf("\n");
        return;
    }
    
    // 尝试在当前行的每一列放置皇后
    for (int col = 0; col < N; col++) {
        if (isSafe(board, row, col)) {
            board[row][col] = 1;  // 放置皇后
            solveNQueens(board, row + 1);  // 递归处理下一行
            board[row][col] = 0;  // 撤销皇后(回溯)
        }
    }
}

int main() {
    int board[N][N] = {0};
    solveNQueens(board, 0);
    printf("八皇后问题共有 %d 种解法\n", count);
    return 0;
}
我的经验:八皇后问题的剪枝其实很简单——我们按行放置皇后,每行只放一个,这样天然避免了行冲突。列冲突和对角线冲突通过 isSafe() 函数检查。我曾经在嵌入式项目中用类似的思路解决过「资源分配冲突检测」的问题,效果不错。

运行这段代码,你会发现八皇后问题一共有92种解法。你想想看,如果不用回溯,纯暴力枚举8^8种可能,那得算到什么时候?回溯通过剪枝,把搜索空间大大缩小了。

图的着色问题:给地图涂颜色

图的着色问题,说白了就是:给定一个无向图,用m种颜色给每个顶点涂色,要求相邻顶点颜色不同。这个问题在实际中很有用,比如地图染色、寄存器分配、排课表等等。

我记得有一次做嵌入式系统的任务调度,多个任务共享一些硬件资源,不能同时访问。我当时就把这个问题建模成了图的着色问题——每个任务是一个顶点,冲突的任务之间连一条边,然后用回溯算法找一种可行的调度方案。

#include <stdio.h>
#include <stdbool.h>

#define V 4  // 顶点数

// 检查顶点v是否可以涂颜色c
bool isSafe(int graph[V][V], int color[], int v, int c) {
    for (int i = 0; i < V; i++) {
        if (graph[v][i] && color[i] == c)
            return false;
    }
    return true;
}

bool graphColoringUtil(int graph[V][V], int m, int color[], int v) {
    // 所有顶点都涂色完毕
    if (v == V)
        return true;
    
    // 尝试给顶点v涂上各种颜色
    for (int c = 1; c <= m; c++) {
        if (isSafe(graph, color, v, c)) {
            color[v] = c;  // 涂色
            if (graphColoringUtil(graph, m, color, v + 1))
                return true;
            color[v] = 0;  // 撤销涂色(回溯)
        }
    }
    
    return false;  // 没有可用的颜色
}

void graphColoring(int graph[V][V], int m) {
    int color[V] = {0};  // 初始化为0,表示未涂色
    
    if (graphColoringUtil(graph, m, color, 0)) {
        printf("找到可行着色方案:\n");
        for (int i = 0; i < V; i++)
            printf("顶点 %d -> 颜色 %d\n", i, color[i]);
    } else {
        printf("无法用 %d 种颜色完成着色\n", m);
    }
}

int main() {
    int graph[V][V] = {
        {0, 1, 1, 1},
        {1, 0, 1, 0},
        {1, 1, 0, 1},
        {1, 0, 1, 0}
    };
    
    int m = 3;  // 3种颜色
    graphColoring(graph, m);
    return 0;
}
避坑指南:我曾经在项目中用图的着色算法做寄存器分配,结果发现颜色数量m设得太小,导致无解。后来我加了一个「动态增加颜色数」的逻辑——先尝试m=1,不行就m=2,直到找到解为止。这种做法虽然效率不高,但在实际工程中很实用。

旅行商问题(TSP):最短路线的穷举

旅行商问题,简称TSP。问题是这样的:一个商人要拜访n个城市,每个城市只去一次,最后回到出发城市,求最短的路径。这个问题看起来简单,但它是著名的NP-hard问题——随着城市数量增加,计算量呈阶乘级增长。

回溯算法解决TSP的思路很直接:从起点出发,尝试所有可能的路径,记录最短的那条。当然,我们可以加一些剪枝策略来加速——比如当前路径长度已经超过已知最短路径时,直接剪掉。

#include <stdio.h>
#include <limits.h>

#define N 4  // 城市数量

int graph[N][N] = {
    {0, 10, 15, 20},
    {10, 0, 35, 25},
    {15, 35, 0, 30},
    {20, 25, 30, 0}
};

int visited[N];
int bestPath[N];
int bestCost = INT_MAX;

void tsp(int currPos, int count, int cost, int path[]) {
    // 所有城市都访问过了,回到起点
    if (count == N && graph[currPos][0]) {
        int totalCost = cost + graph[currPos][0];
        if (totalCost < bestCost) {
            bestCost = totalCost;
            for (int i = 0; i < N; i++)
                bestPath[i] = path[i];
            bestPath[N] = 0;  // 最后回到起点
        }
        return;
    }
    
    // 尝试访问下一个城市
    for (int i = 0; i < N; i++) {
        if (!visited[i] && graph[currPos][i]) {
            // 剪枝:如果当前成本已经超过已知最优解,跳过
            if (cost + graph[currPos][i] >= bestCost)
                continue;
            
            visited[i] = 1;
            path[count] = i;
            tsp(i, count + 1, cost + graph[currPos][i], path);
            visited[i] = 0;  // 回溯
        }
    }
}

int main() {
    int path[N];
    
    visited[0] = 1;  // 从城市0出发
    path[0] = 0;
    tsp(0, 1, 0, path);
    
    printf("最短路径长度: %d\n", bestCost);
    printf("路径: ");
    for (int i = 0; i <= N; i++)
        printf("%d ", bestPath[i]);
    printf("\n");
    
    return 0;
}
我的建议:对于TSP这种NP-hard问题,回溯算法只适合城市数量较少的情况(一般不超过15个)。如果城市数量多,建议改用动态规划(Held-Karp算法)或启发式算法(如遗传算法、模拟退火)。我在实际项目中处理过20个城市的TSP,回溯跑了半天没出结果,换成遗传算法几分钟就搞定了。

回溯算法的知识体系

说了这么多,我们来梳理一下回溯算法的知识结构。下面这张图可以帮你快速建立整体认知:

回溯算法知识体系 回溯算法核心 路径 + 选择列表 结束条件判断 撤销选择(回溯) 八皇后问题 棋盘放置 + 冲突检测 图的着色问题 相邻顶点不同色 旅行商问题(TSP) 最短路径 + 剪枝 剪枝优化 约束传播 启发式排序 适用场景:组合优化、约束满足、路径规划

回溯算法的适用场景与局限

回溯算法不是万能的。我根据实际经验总结了一下,它最适合解决以下几类问题:

  • 组合优化问题:比如从n个物品中选k个,求最大价值。典型的0-1背包问题就可以用回溯。
  • 约束满足问题:比如数独、八皇后、图的着色。这类问题的特点是有一堆约束条件,需要找到满足所有条件的解。
  • 路径规划问题:比如TSP、迷宫寻路。这类问题需要找到一条满足条件的路径。

但回溯算法也有明显的局限。说白了,它本质上还是穷举,虽然加了剪枝,但最坏情况下的时间复杂度仍然是指数级的。对于规模较大的问题(比如n>30的TSP),回溯算法基本跑不动。

总结一下:回溯算法是一种「试错+回退」的搜索策略,适合解决中小规模的组合优化和约束满足问题。它的核心是「做选择-递归-撤销选择」这个循环,再加上合理的剪枝策略。掌握了这个框架,你就能解决一大类算法题了。

好了,关于回溯算法就聊到这里。代码示例我都贴出来了,建议你亲手跑一跑,改一改参数,看看效果。嗯,动手实践才是最好的学习方式。


公众号:蓝海资料掘金营,微信deep3321