最少转弯路径算法:从BFS到0-1 BFS与Dijkstra的优化实践

📅 2026/8/1 3:37:53 👁️ 阅读次数 📝 编程学习
最少转弯路径算法:从BFS到0-1 BFS与Dijkstra的优化实践

1. 问题引入:从地图导航到算法核心

最近在复盘一些经典的图论与搜索问题时,我又把“最少转弯问题”(Minimum Turns Problem)拿出来琢磨了一番。这个问题听起来很直白:在一个二维网格(比如城市地图、游戏地图)上,从起点到终点,找到一条路径,使得转弯的次数最少。它不像最短路径问题那样追求总距离最短,而是追求“行驶最顺滑”。这在实际生活中太常见了——当你开车用导航时,系统除了给你算最短距离、最快时间,是不是也常常提供“大路优先”或“转弯少”的选项?这背后考量的就是驾驶的便捷性和安全性,频繁转弯不仅让司机手忙脚乱,也增加了出错和事故的风险。

在算法竞赛和面试中,这个问题也堪称经典。它考察的不仅仅是对广度优先搜索(BFS)的基本掌握,更是对状态定义和搜索策略的深刻理解。很多朋友第一次遇到这个问题时,会下意识地套用标准BFS求最短路径的模板,把每个网格点当作一个状态,然后发现结果不对,或者效率极低。这是因为标准BFS的“步数”在这里对应的是“移动的格子数”,而我们关心的“代价”是“转弯的次数”,这两个维度并不直接等价。如何将“转弯”这个动作量化并融入搜索过程,就是解决这个问题的钥匙。

今天,我就结合自己多次实现和优化这个问题的经验,从头到尾拆解一下“最少转弯问题”的解决思路。我们会从最直观但低效的暴力思路开始,逐步深入到高效的双重BFS状态建模,并探讨一些常见的变体和优化技巧。无论你是正在准备面试,还是对算法优化感兴趣,相信这篇内容都能给你带来一些实用的启发。

2. 问题定义与初步分析:为什么标准BFS会失效?

首先,我们需要把问题描述得更精确一些。通常,我们会得到一个M x N的网格。其中:

  • ‘.’0表示可以通行的空地。
  • ‘#’1表示障碍物,不可通行。
  • 给定起点坐标(start_x, start_y)和终点坐标(end_x, end_y)
  • 移动规则:每次可以向上、下、左、右四个方向之一移动一格,但不能斜向移动,且不能走出网格或进入障碍物格子。
  • 目标:找到一条从起点到终点的可行路径,使得整条路径中方向改变的次数最少。注意,从起点开始的第一步不算一次转弯。

为什么传统的、用于求最短步数(曼哈顿距离类)的BFS在这里不直接适用呢?我们来看一个简单的例子。

假设有一个 3x3 的空网格,起点在(0,0),终点在(2,2)

S . . . . . . . E

一条路径是:(0,0) -> (0,1) -> (0,2) -> (1,2) -> (2,2)。这条路径转了两次弯:在(0,2)处向右转变成向下,在(1,2)处继续向下。 另一条路径是:(0,0) -> (1,0) -> (2,0) -> (2,1) -> (2,2)。同样转了两次弯。 还有一条路径是:(0,0) -> (1,1) -> (2,2)?不,这需要斜向移动,规则不允许。 那么有没有只转一次弯的路径呢?有,(0,0) -> (0,1) -> (1,1) -> (2,1) -> (2,2)。看,在(0,1)处向下转,然后在(2,1)处向右转。等等,这还是两次。 实际上,在这个例子中,最少转弯次数就是2。标准BFS会逐层扩展,它首先找到的所有路径的“步数”(移动格数)都是4,但它无法在步数相同的路径中区分出谁的转弯更少。BFS的队列保证的是“当第一次访问某个坐标时,所用的步数是最少的”,但它没有记录方向信息,因此无法判断这次访问是否是以“更少转弯”的方式到达的。

核心矛盾在于:同一个坐标点,可以从不同的方向以不同的“转弯代价”到达。标准BFS的状态(x, y)丢失了“从哪个方向来”这个关键信息,而这对计算转弯代价至关重要。

注意:这里容易产生一个误区,认为可以先用BFS求出所有最短路径(步数最少的),然后再从中选转弯最少的。这在某些简单场景可能可行,但并不可靠。因为“转弯最少”的路径,其“步数”可能比“最短步数”要长。我们的目标是转弯最少,步数可以牺牲。所以,必须修改搜索策略,将“转弯次数”作为首要优化目标。

3. 解决方案一:带权BFS(0-1 BFS)的巧妙应用

第一个高效的解决方案是利用0-1 BFS算法。这个算法适用于边权只有0和1两种值的图。在我们的问题中,我们可以巧妙地定义“边权”:

  • 如果沿着当前方向继续直走,则移动的代价为0(因为没有发生转弯)。
  • 如果改变方向(包括从起点开始的第一步,虽然第一步不算转弯,但我们可以将其视为一个“初始化”方向后的直走),则移动的代价为1(因为发生了一次转弯)。

这样一来,问题就转化为了:在一个图上,求从起点状态到终点状态的路径,使得路径的总权重(即总转弯次数)最小。这正是0-1 BFS的用武之地。

3.1 状态设计与图建模

首先,我们需要重新定义搜索过程中的“状态”。一个完整的状态必须包含位置方向。 我们可以定义状态为(x, y, dir),其中:

  • (x, y)是当前所在的网格坐标。
  • dir是当前的前进方向,通常用0,1,2,3代表上、右、下、左。

那么,状态之间的转移(即图的边)如何定义呢? 从状态(x, y, dir)出发,我们可以尝试两个动作:

  1. 直走:沿着当前dir方向走一格,到达新位置(nx, ny)。这个动作的代价是0。新状态的方向ndir保持不变(仍是dir)。
  2. 转弯:在当前点(x, y)改变方向。这本身不改变位置,但产生了一个新方向ndirndir可以是除了当前dir及其反方向以外的其他方向?这里需要仔细考虑)。这个动作的代价是1。新状态是(x, y, ndir)

这里有一个关键细节:“转弯”这个动作,是否应该设计为一个停留在原地的状态转移?在实际实现中,更高效的做法不是显式地进行“原地转弯”,而是在处理“直走”动作时,允许从任何状态(x, y, dir)向四个方向尝试直走。如果尝试的方向ndir与当前状态的方向dir相同,则代价为0;如果不同,则代价为1。这样,我们就把“转弯”和“移动”合并到了一个动作里。

3.2 算法流程与实现细节

0-1 BFS使用一个双端队列(deque)来实现。算法流程如下:

  1. 初始化:将起点的所有可能初始状态加入队列。起点没有前驱方向,所以我们需要枚举从起点出发的第一步可能的方向。对于每个方向d,状态(start_x, start_y, d)的初始转弯次数是0(因为第一步不算转弯)。将这些状态的距离设为0,并加入双端队列的前端(因为代价为0)。
  2. 队列循环:当队列不为空时,从队列前端弹出状态(x, y, dir)及其当前代价cost
  3. 终点判断:如果(x, y)等于终点坐标,我们可以记录cost为一种可能答案。但由于0-1 BFS的特性,第一次弹出终点坐标的状态时,其cost就是到达该状态的最小转弯次数。注意,是弹出时确定,而不是入队时。
  4. 状态扩展:对于当前状态(x, y, dir),枚举四个方向ndir(0,1,2,3)。
    • 计算沿ndir走一格后的新坐标(nx, ny)
    • 检查(nx, ny)是否合法(在网格内且不是障碍物)。
    • 计算本次移动的代价delta:如果ndir == dir,则delta = 0,否则delta = 1
    • 计算新代价new_cost = cost + delta
    • 查询状态(nx, ny, ndir)的历史最小代价dist[nx][ny][ndir]。如果new_cost更小,则更新dist,并根据delta的值决定新状态的入队位置:
      • 如果delta == 0,将(nx, ny, ndir)从队列前端加入。
      • 如果delta == 1,将(nx, ny, ndir)从队列后端加入。
  5. 结果:算法结束后,检查所有终点坐标(end_x, end_y)对应的四个方向状态(end_x, end_y, dir),取其中dist值最小的一个,即为最少转弯次数。如果均为无穷大,则不可达。

3.3 代码实现示例(Python)

from collections import deque def min_turns(grid, start, end): """ :param grid: List[List[str]], '.' 可通行,'#' 障碍 :param start: (int, int) 起点坐标 :param end: (int, int) 终点坐标 :return: 最少转弯次数,若不可达返回 -1 """ if not grid or grid[start[0]][start[1]] == '#' or grid[end[0]][end[1]] == '#': return -1 m, n = len(grid), len(grid[0]) # 方向数组:上,右,下,左 dirs = [(-1, 0), (0, 1), (1, 0), (0, -1)] # 距离数组,初始化为无穷大 dist[x][y][dir] INF = float('inf') dist = [[[INF] * 4 for _ in range(n)] for _ in range(m)] dq = deque() sx, sy = start ex, ey = end # 初始化:将起点向四个方向出发的状态入队,代价为0 for d in range(4): dist[sx][sy][d] = 0 dq.appendleft((sx, sy, d, 0)) # (x, y, dir, cost) while dq: x, y, d, cost = dq.popleft() # 如果弹出的是终点,由于0-1 BFS的特性,此时cost即为最小值之一 # 但为了严谨,我们等循环结束后再统一取最小值 if (x, y) == (ex, ey): # 可以直接返回cost,因为队列是单调的,先弹出的代价小 # 但为了处理所有状态,我们选择更新答案,最后再取最小 pass # 尝试向四个方向移动 for nd in range(4): nx, ny = x + dirs[nd][0], y + dirs[nd][1] # 检查新位置合法性 if 0 <= nx < m and 0 <= ny < n and grid[nx][ny] == '.': delta = 0 if nd == d else 1 new_cost = cost + delta if new_cost < dist[nx][ny][nd]: dist[nx][ny][nd] = new_cost if delta == 0: dq.appendleft((nx, ny, nd, new_cost)) else: dq.append((nx, ny, nd, new_cost)) # 找出到达终点的最小代价 ans = min(dist[ex][ey]) return -1 if ans == INF else ans # 测试用例 grid = [ ['.', '.', '.'], ['.', '.', '.'], ['.', '.', '.'] ] start = (0, 0) end = (2, 2) print(min_turns(grid, start, end)) # 输出应为 2

实操心得与注意事项:

  • 状态去重dist数组是三维的,这至关重要。它确保了即使到达同一个坐标(x, y),如果来自不同的方向,且代价更优,仍然会被更新和再次扩展。这是与标准BFS使用二维visited数组最本质的区别。
  • 双端队列操作appendleftappend的使用必须与代价delta严格对应。代价为0的优先处理,保证了算法的正确性(类似于Dijkstra算法)。
  • 起点初始化:起点没有前驱方向,所以我们需要虚拟四个初始方向。这相当于允许起点以0代价“选择”任何一个方向作为第一步的方向。
  • 空间复杂度:状态数是O(M * N * 4),对于大多数比赛和面试场景是可以接受的。如果网格非常大(例如上亿单元格),则需要考虑其他优化或算法。

4. 解决方案二:Dijkstra算法与更一般的思路

0-1 BFS是Dijkstra算法在边权仅为0或1时的特化和优化。实际上,我们可以把这个问题直接建模为一个普通的有权图最短路径问题,然后使用Dijkstra算法求解。这对于理解问题本质更有帮助,也更容易扩展到边权更复杂的情况(例如,不同方向转弯代价不同)。

4.1 图模型构建

图的节点(顶点)就是我们定义的状态(x, y, dir)。 图的边和权重的定义与0-1 BFS中完全一致:

  • 从节点(x, y, dir)到节点(nx, ny, ndir)有一条有向边,其中(nx, ny)(x, y)ndir方向移动一格后的位置。
  • 该边的权重为:0如果ndir == dir,否则为1

这样,问题就转化为在这个有向加权图中,求从任意一个起点初始状态(start_x, start_y, d)d为0~3)到任意一个终点状态(end_x, end_y, d’)的最短路径(权重和最小)。最后取所有起点状态到所有终点状态的最小距离即可。

4.2 Dijkstra算法实现

Dijkstra算法使用优先队列(最小堆)来保证每次扩展的都是当前已知距离最小的节点。

import heapq def min_turns_dijkstra(grid, start, end): m, n = len(grid), len(grid[0]) dirs = [(-1, 0), (0, 1), (1, 0), (0, -1)] INF = float('inf') dist = [[[INF] * 4 for _ in range(n)] for _ in range(m)] sx, sy = start ex, ey = end pq = [] # 优先队列,(cost, x, y, dir) # 初始化起点 for d in range(4): dist[sx][sy][d] = 0 heapq.heappush(pq, (0, sx, sy, d)) while pq: cost, x, y, d = heapq.heappop(pq) # Dijkstra算法的性质:第一次从堆中弹出某个状态时,其cost就是最短距离 if (x, y) == (ex, ey): # 我们可以直接返回cost,因为终点状态第一次被弹出时即是最小值 # 但为了代码清晰,我们也可以继续运行,最后取min pass # 如果当前弹出的cost大于记录的距离,说明是旧数据,跳过 if cost > dist[x][y][d]: continue for nd in range(4): nx, ny = x + dirs[nd][0], y + dirs[nd][1] if 0 <= nx < m and 0 <= ny < n and grid[nx][ny] == '.': delta = 0 if nd == d else 1 new_cost = cost + delta if new_cost < dist[nx][ny][nd]: dist[nx][ny][nd] = new_cost heapq.heappush(pq, (new_cost, nx, ny, nd)) ans = min(dist[ex][ey]) return -1 if ans == INF else ans

方案对比与选择:

  • 时间复杂度:0-1 BFS的时间复杂度是O(V + E),其中V是状态数(M*N*4)E是边数(约V*4)。因为每个状态最多尝试4个方向。Dijkstra算法使用二叉堆的复杂度是O(E log V)。在边权仅为0和1的情况下,0-1 BFS的效率通常更高。
  • 通用性:Dijkstra算法更通用。如果问题变体中的转弯代价不再是固定的1(比如左转和右转代价不同,或者掉头代价更高),那么Dijkstra算法可以轻松处理,而0-1 BFS就不再适用。
  • 实现复杂度:两者都需要三维距离数组,逻辑复杂度相当。0-1 BFS需要小心操作双端队列,Dijkstra需要维护优先队列。

个人建议:在明确是“最少转弯问题”(即转弯代价为1,直走代价为0)时,优先使用0-1 BFS,因为它更高效。如果不确定代价模型,或者需要处理更复杂的权重,Dijkstra是更稳妥的选择。

5. 常见变体与问题排查

在实际编码和解题中,你可能会遇到这个问题的各种变体,也容易踩一些坑。

5.1 问题变体

  1. 输出具体路径:不仅要求最少转弯次数,还要输出一条具体的路径。这需要在状态中增加“前驱状态”的信息。在更新dist数组时,同时记录是从哪个状态(px, py, pdir)以何种代价转移过来的。搜索结束后,从终点状态反向回溯即可得到路径。注意,路径中需要还原出经过的每个坐标点。
  2. 允许“掉头”吗?在基础问题中,从方向变为方向(即掉头)通常也算作一次转弯(delta=1)。我们的代码中,ndir != dir就视为转弯,包含了掉头情况。如果某些场景规定掉头代价不同(比如代价为2),只需修改delta的计算逻辑。
  3. 起点或终点在障碍物上:这是常见的边界条件。必须在函数开始时就检查,直接返回-1或特定标识。
  4. 网格非常大,但障碍物很少:此时O(M*N*4)的状态数可能无法接受。可以考虑使用“射线法”“跳跃BFS”进行优化。思路是:从一个点出发,沿着一个方向一直走,直到撞到边界或障碍物,这整条直线路径上的所有点,如果以这个方向到达,其转弯代价是相同的。这可以大幅减少需要显式入队的状态。这通常被称为“BFS on Grid with Sliding”“0-1 BFS with Jumps”,是这道题的一个高级优化技巧。

5.2 典型错误与排查技巧

  1. 使用二维visited数组:这是最常见的错误。会导致搜索提前终止,可能错过通过更多步数但更少转弯的路径。
    • 排查:如果你的算法在某些测试用例上给出的答案比预期大,或者找不到可行解(但实际上存在),首先检查是否错误地使用了二维去重。
  2. 方向编码不一致:在移动计算新坐标(nx, ny)和判断方向是否相同时,使用的方向数组dirs必须一致。例如,dirs[0] = (-1,0)代表向上,那么状态中的dir=0也必须代表向上。
    • 排查:用一个小网格(如2x2)手动模拟算法过程,打印每个状态和转移,检查坐标变化是否符合预期。
  3. 起点/终点初始化错误:起点状态的成本必须初始化为0。对于终点,我们需要检查所有(end_x, end_y, dir)状态的最小值,因为以任何方向到达终点都算成功。
  4. 双端队列操作错误(针对0-1 BFS):误将代价为1的状态appendleft,会破坏队列的单调性,导致结果错误。
    • 排查:可以同时实现Dijkstra版本作为对照。对于同一个测试用例,比较两个算法的输出是否一致。
  5. 忽略“第一步不算转弯”:这个条件已经通过起点的初始化方式处理了。我们为起点创建了四个方向的状态,成本都为0。这意味着从起点出发,无论第一步朝哪个方向走,都不计入转弯成本,符合题意。

调试小技巧:在开发过程中,可以增加一个简单的日志输出,记录每次从队列中弹出状态的信息(x, y, dir, cost),以及每次成功更新状态的信息。用一个非常小的网格(比如3x3全通)来跟踪,很容易发现状态转移是否符合逻辑。

6. 性能优化与高级技巧:跳跃BFS (BFS with Sliding)

当网格尺寸巨大(例如10^5 x 10^5)但障碍物相对稀疏时,前面提到的O(M*N*4)的状态空间是无法接受的。这时就需要用到跳跃BFS的思想。其核心在于利用网格的规则性:只要方向不变,移动的代价就是0。因此,我们不应该一格一格地走,而应该沿着一个方向“滑行”到底,将这一整段直线路径作为一次扩展。

6.1 算法思路

我们不再定义(x, y, dir)为状态,而是定义(x, y)为状态,但BFS的层间代价是转弯次数。在标准BFS中,我们从队列取出一个点,然后尝试其四个邻居。在这里,我们从队列取出一个点(x, y)以及到达它的转弯次数turns,然后尝试四个方向。对于每个方向d,我们不是只走一格,而是沿着这个方向一直走,直到:

  1. 撞到网格边界。
  2. 撞到障碍物。
  3. 到达终点。

在这条“射线”上经过的所有可通行格子,如果它们尚未被以相同或更少的转弯次数访问过,那么它们都可以以turns(如果是从起点直接开始)或turns + 1(如果是改变方向后)的代价到达。我们将这些点标记为已访问(注意,这里需要记录到达该点的最小转弯次数),并将它们加入队列,用于下一轮扩展。

6.2 算法流程与实现要点

  1. 数据结构:使用一个队列queue,存储(turns, x, y)。使用一个二维数组min_turns记录到达每个点的最小转弯次数,初始化为无穷大。
  2. 初始化:起点(sx, sy)min_turns设为-1(或0,根据第一步是否算转弯调整),并将其四个方向能滑行到的所有点加入队列,转弯次数设为0。这里的关键是,从起点出发,向四个方向滑行,这“第一步”不算转弯。
  3. 队列循环: a. 弹出(turns, x, y)。 b. 如果turns > min_turns[x][y],跳过(旧数据)。 c. 尝试四个方向。对于每个方向,从(x, y)的下一个格子开始滑行。 d. 沿着方向一直走,对于沿途的每个点(nx, ny): * 如果(nx, ny)是终点,更新答案(可能是turnsturns+1,取决于是否改变方向)。 * 如果(nx, ny)是障碍物或边界,停止滑行。 * 如果turns + 1 < min_turns[nx][ny],说明找到了一条以更少转弯次数到达(nx, ny)的路径。更新min_turns[nx][ny] = turns + 1,并将(turns + 1, nx, ny)加入队列。 * 继续向该方向的下一个格子移动。 e. 注意,滑行过程中,如果遇到一个点,其min_turns值已经小于等于turns(注意,不是turns+1),那么可以提前停止吗?不可以。因为即使这个点已经以更少转弯次数被访问过,它后面的点仍然可能通过当前这条路径以turns+1的代价到达,而这可能是更优的(如果之前访问这个点的路径方向不同)。所以我们必须滑行到底,或者撞到障碍物/边界。
  4. 结果:终点的min_turns值即为答案。

6.3 代码示意与复杂度分析

from collections import deque def min_turns_jump_bfs(grid, start, end): m, n = len(grid), len(grid[0]) dirs = [(-1, 0), (0, 1), (1, 0), (0, -1)] INF = float('inf') min_turns = [[INF] * n for _ in range(m)] sx, sy = start ex, ey = end if grid[sx][sy] == '#' or grid[ex][ey] == '#': return -1 dq = deque() # 初始化起点:从起点向四个方向滑行,转弯次数为0 min_turns[sx][sy] = -1 # 用-1表示起点,方便处理,也可以设为0 for d in range(4): nx, ny = sx + dirs[d][0], sy + dirs[d][1] while 0 <= nx < m and 0 <= ny < n and grid[nx][ny] == '.': if min_turns[nx][ny] > 0: # 第一次以0次转弯到达这些点 min_turns[nx][ny] = 0 dq.append((0, nx, ny)) nx += dirs[d][0] ny += dirs[d][1] while dq: turns, x, y = dq.popleft() if turns > min_turns[x][y]: continue if (x, y) == (ex, ey): # 可以提前结束,因为队列是0-1 BFS,先弹出的代价小 return turns # 尝试改变方向(转弯) for nd in range(4): nx, ny = x + dirs[nd][0], y + dirs[nd][1] while 0 <= nx < m and 0 <= ny < n and grid[nx][ny] == '.': new_turns = turns + 1 if new_turns < min_turns[nx][ny]: min_turns[nx][ny] = new_turns dq.append((new_turns, nx, ny)) nx += dirs[nd][0] ny += dirs[nd][1] return -1 if min_turns[ex][ey] == INF else min_turns[ex][ey]

复杂度分析:在最坏情况下,每个点仍然可能被四个方向访问,但每个方向上的滑行会一次性处理一整排点。最坏时间复杂度可以认为是O(K * max(M, N)),其中K是访问的“转折点”数量。在障碍物稀疏时,K远小于M*N,效率提升显著。空间复杂度为O(M*N)

注意事项:这个版本的实现中,队列操作实际上混合了0-1 BFS的思想(因为直走代价0被隐含在初始化滑行中,转弯代价为1)。初始化部分将起点能直达的点以0代价入队,主循环中每次扩展代表一次转弯(代价+1),并滑行到底。实现时需要特别注意起点和终点的处理逻辑,以及min_turns数组的初始值。