DFS实现回溯算法

在算法学习的过程中,深度优先搜索(DFS)和回溯算法可以说是每个程序员都必须掌握的经典内容。它们像是一对孪生兄弟,经常一起出现,解决各种组合、排列、搜索类问题。

今天,我们就来深入探讨如何用DFS实现回溯算法

一.什么是DFS:

深度优先搜索(Depth-First Search)是一种用于遍历或搜索树或图的算法。它的核心思想是:

从一个节点出发,沿着一条路径走到底,如果无法继续前进,就回退到上一个节点,尝试另一条路径。

你可以把它想象成走迷宫:遇到岔路就先选一条,一条路走到底,走不通了再退回来换另一条。

二.什么是回溯:

回溯的意思是,我们在搜索的过程中,有可能会做出很多决策

在做决策的时候,有一些情况,是不会对全局有影响的

而有些题目的决策是会影响到全局的情况的

回溯算法本质上是一种试错的思想。它尝试分步解决问题,当发现当前选择无法得到正确解时,就取消上一步甚至多步选择,回溯到之前的状态,尝试其他可能性。

三.经典样例1------全排列

信息学奥赛一本通1199:

给定一个由不同的小写字母组成的字符串,输出这个字符串的所有全排列。

思路:

假设我们先考虑第一位,因为要保证字典序最小,从小到大去枚举。假设只有abc,那么先考虑第一位放a,后面放什么先不考虑,这样字典序肯定是从小到大的

因为每个字母我们都只能使用一次,而且我们每次决策都会在全局标记某个字母已经被使用过了,假设我们考虑某一位置选择了字母c,如果cur后面的情况搜索完了,我们会继续考虑这个位置去选择别的字母

实际上当我们搜索完回到cur这个位置的时候,选择字母c的决策已经被撤销掉了,所以我们选择字母c产生的影响(全局标记c已经被访问)必须一起撤销掉(全局标记c为未被访问)。

代码实现:

cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

int n;
string str;
char ans[10];
int vis[10];

void dfs(int cur) {
    //当cur超过输入的字符串长度时停止递归并输出
    if (cur == n + 1) {
        for (int i = 1;i <= n;i++) {
            cout << ans[i];
        }
        cout << endl;
        return;
    }
    //遍历进行搜索
    for (int i = 0;i < n;i++) {
        //搜索未被标记的字母
        if (vis[i] == 0) {
            //找到时需要做的事
            //将该字母加入答案中
            //标记已访问
            //递归寻找下一位字母
            //递归结束后要撤销之前的决策,即将其重新标记为未被访问
            ans[cur] = str[i];
            vis[i] = 1;
            dfs(cur + 1);
            vis[i] = 0;
        }
    }
}

int main() {
    cin >> str;
    n = str.size();
    dfs(1);
    return 0;
}

三.经典样例2------组合

首先我们要明白组合和排列的区别

排列是指从若干数字中,有顺序的取出若干个数字

组合是指从若干数字中,一次性取出多少个数字

比如{1,2,3},{2,3,1}因为顺序不同是两种排列,但是如果不关注顺序,因为组成数字都相同,所以这是同一种组合

信息学奥赛一本通1317

排列与组合是常用的数学方法,其中组合就是从n个元素中抽出r个元素(不分顺序且r≤n),我们可以简单地将n个元素理解为自然数1,2,...,n,从中任取r个数。输出所有组合

思路:

这题要我们求组合,不能和上一题排列一样搜索

假设当前在选择第i个元素是谁,只需要保证i选择的位置一定在第i-1个数字之后即可

代码实现:

cpp 复制代码
#include<iostream>
using namespace std;

int n, r;
int ans[25];
int vis[25];

void dfs(int cur,int id) {
    //如果搜索完后就输出结果
    if (cur == r + 1) {
        for (int i = 1;i <= r;i++) {
            cout << ans[i];
        }
        cout << endl;
        return;
    }
    //遍历进行递归
    for (int i = id;i <= n;i++) {
        //每次递归做的事
        //将当前位上填充相应的数字
        //递归进行下一位,并且必须是下一位数字
        //这样可以保证第i位选择的数字一定在第i-1位的数字之后
        ans[cur] = i;
        dfs(cur + 1, i + 1);
    }
}

int main() {
    cin >> n >> r;
    dfs(1, 1);
    return 0;
}

四.经典题目------路径之谜

题目:

小明冒充X星球的骑士,进入了一个奇怪的城堡。城堡地面是 n×n 个方格。骑士要从西北角(左上角) 走到东南角(右下角)

移动规则:

  • 可以横向或纵向移动,不能斜着走

  • 每走到一个新方格,就要向正北方正西方各射一箭

  • 同一个方格只允许经过一次

  • 不必走完所有的方格

城堡的西墙和北墙内各有 n 个靶子。题目会给出每个靶子上的箭的数目,要求推断出骑士的行走路线。

题目理解:

这题是蓝桥杯国赛中的一道经典回溯题目

这道题的关键在于理解"射箭"的含义:

  • 骑士每到达一个格子 (x, y)(x表示行,y表示列),就会向正北方射一箭 → 影响第 y 列(北墙靶子)

  • 同时向正西方射一箭 → 影响第 x 行(西墙靶子)

换句话说:

  • 北墙第 y 个靶子上的箭数 = 骑士访问过的所有列坐标为 y 的格子数量

  • 西墙第 x 个靶子上的箭数 = 骑士访问过的所有行坐标为 x 的格子数量

最终,所有靶子上的箭数必须刚好用完(全部归零),且骑士到达终点。

思路:

我们可以使用回溯算法,从入口开始,尝试四个方向移动。每走一格就判断是否满足条件(不能越界,每个方格只允许经过一次),每移动到一个新方格,就记录下对箭靶的影响。

如果移动到的方格是出口,且箭靶上的箭数与给出的一致,则找到了一条可行的路线,否则就要回溯到上一步,尝试其他方向(对箭靶的影响需要恢复)

代码实现:

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;

//方向数组
int dx[] = { 1,-1,0,0 };
int dy[] = { 0,0,1,-1 };

int n;
int mp[15][15];
int a[15], b[15];//记录箭靶上箭的数量
int k1, k2;//记录一个方向上箭的总数
int vis[15][15];//判断是否到达过该点
int s[600];//记录最终路径
int num;//记录步数
bool flag = false;//判断是否找到一条可行路径

void dfs(int x, int y, int cnt) {
    //判断是否越界
	if (x<1 || x>n || y<1 || y>n) {
		return;
	}

    //判断是否已经找到一条路径
    //或者当前路径是否已经无法继续走下去(即箭靶上的箭已经为0)
	if (flag || a[y] < 0 || b[x] < 0) {
		return;
	}

    //记录路径
	s[cnt] = (x - 1) * n + (y - 1);

    //判断是否到达终点
    //注意:因为我们是在后面的处理中才将箭的数量-1
    //当我们到终点时,这一步的两根箭还没减去
    //所以应该还有一根箭才对
	if (x == n && y == n&&k1==1&&k2==1) {
		flag = true;
		num = cnt;//记录总步数
		return;
	}

    //尝试四个方向
	for (int i = 0;i < 4;i++) {
		int nxtx = x + dx[i];
		int nxty = y + dy[i];
		if (vis[nxtx][nxty] == 0) {
            //将相应的数据减去
			a[nxty]--;
			b[nxtx]--;
			k1--;
			k2--;
			vis[nxtx][nxty] = 1;
			dfs(nxtx, nxty, cnt + 1);
            //回溯将相应的数据重新加上
			a[nxty]++;
			b[nxtx]++;
			k1++;
			k2++;
			vis[nxtx][nxty] = 0;
		}
	}
}

int main() {
	cin >> n;
    //记录西和北两个方向上的每个箭靶的箭数和箭的总数
	for (int i = 1;i <= n;i++) {
		cin >> a[i];
		k1 += a[i];
	}
	for (int i = 1;i <= n;i++) {
		cin >> b[i];
		k2 += b[i];
	}
    //将起点两根箭减去,并将起点标记为已到达
	a[1]--;
	b[1]--;
	vis[1][1] = 1;
	dfs(1, 1, 1);

    //输出结果数组
	for (int i = 1;i <= num;i++) {
		cout << s[i] << " ";
	}
	return 0;
}

五.常见回溯问题分类

类型 典型题目 核心要点
排列问题 全排列、字符串排列 顺序有关,需要used数组
组合问题 组合总和、子集 顺序无关,通常用start参数
分割问题 分割回文串、IP地址复原 在字符串上切分
棋盘问题 N皇后、解数独 二维网格 + 合法性判断
路径问题 迷宫、单词搜索 方向数组 + 边界判断

六.复杂度分析

  • 时间复杂度:通常为 O(选择数 ^ 深度) 级别,但剪枝会大幅降低实际运行时间

  • 空间复杂度:O(深度),主要是递归栈的开销

以全排列为例:时间复杂度 O(n × n!),空间复杂度 O(n)

七.总结

1.做选择 → 递归 → 撤销选择,这是回溯的骨架

2.剪枝思想:能提前判断的不要等到最后

3.撤销选择这一步最容易遗漏,不要忘记

相关推荐
汀、人工智能2 小时前
[特殊字符] 第17课:滑动窗口最大值
数据结构·算法·数据库架构·图论·bfs·滑动窗口最大值
楼田莉子2 小时前
设计模式:设计模式的相关概念与原则
c++·学习·设计模式
XiYang-DING2 小时前
【LeetCode】232. 用栈实现队列
算法·leetcode·职场和发展
人道领域2 小时前
【LeetCode刷题日记】142.环形链表Ⅱ
算法·leetcode·链表
2301_822703202 小时前
开源鸿蒙跨平台Flutter开发:基因序列比对基础:Needleman-Wunsch 算法的 Dart 实现
算法·flutter·开源·鸿蒙
Book思议-2 小时前
【数据结构】「树」专题:树、森林与二叉树遍历之间的关系+408真题
数据结构·算法·二叉树··森林
Fcy6482 小时前
算法基础详解(4)双指针算法
开发语言·算法·双指针
zk_ken2 小时前
优化图像拼接算法思路
算法
xwz小王子2 小时前
Nature Communications从结构到功能:基于Kresling折纸的多模态微型机器人设计
人工智能·算法·机器人