第三章 路径规划基础:图搜索算法与栅格地图

各位同学,今天我们来聊聊路径规划里最基础、也最核心的一块——图搜索算法。说实话,我刚入行那会儿,觉得路径规划不就是找条路嘛,有啥难的?后来真上了项目才发现,这里面的坑,一个比一个深。

这一章,我会带着大家把Dijkstra和A*这两个经典算法掰开揉碎讲清楚。同时,栅格地图怎么建、路径怎么平滑,这些实战中躲不开的问题,咱们也一并解决。

3.1 栅格地图构建——先把世界数字化

做路径规划,第一步就是把环境变成计算机能理解的东西。栅格地图,说白了就是把空间切成一个个小格子。每个格子要么是“空地”,要么是“障碍物”。

我在项目中遇到过一个问题:栅格分辨率设多大合适?设小了,地图精细但计算量爆炸;设大了,计算快但路径可能穿墙而过。嗯,这里有个经验值——对于四旋翼飞行器,室内场景用0.1米,室外开阔场景用0.5米,基本够用。

栅格地图构建要点:

  • 分辨率选择:权衡精度与计算量
  • 障碍物膨胀:给飞行器留出安全距离
  • 连通性判断:4邻域还是8邻域?

来看一段简单的栅格地图构建代码:

import numpy as np

def build_grid_map(width, height, resolution, obstacles):
    """
    构建栅格地图
    width, height: 实际空间尺寸(米)
    resolution: 栅格分辨率(米/格)
    obstacles: 障碍物坐标列表 [(x1,y1), (x2,y2), ...]
    """
    grid_w = int(width / resolution)
    grid_h = int(height / resolution)
    grid = np.zeros((grid_w, grid_h), dtype=np.uint8)
    
    # 标记障碍物
    for ox, oy in obstacles:
        gx = int(ox / resolution)
        gy = int(oy / resolution)
        if 0 <= gx < grid_w and 0 <= gy < grid_h:
            grid[gx][gy] = 1  # 1表示障碍物
    
    return grid

个人小技巧:建图时别忘了做障碍物膨胀。我曾经因为没做膨胀,规划出的路径贴着墙飞,结果飞行器撞上了凸出的门把手。膨胀半径至少是飞行器半径的一半。

3.2 Dijkstra算法——稳扎稳打的“老实人”

Dijkstra算法,你想想看,它就像个老实人——从起点开始,一层层往外扩,直到找到终点。它保证能找到最短路径,但代价是慢。

核心思想其实很简单:

  1. 维护一个“未访问节点”集合
  2. 每次从集合中取出距离起点最近的节点
  3. 更新该节点邻居的距离
  4. 重复直到终点被访问

为什么会慢?因为它不知道终点在哪,只能像个无头苍蝇一样四面八方都搜。我做过测试,在100x100的栅格地图上,Dijkstra平均要探索70%的节点才能找到路径。

来看实现:

import heapq

def dijkstra(grid, start, goal):
    """Dijkstra路径搜索"""
    rows, cols = grid.shape
    # 8邻域移动
    moves = [(-1,0), (1,0), (0,-1), (0,1), 
             (-1,-1), (-1,1), (1,-1), (1,1)]
    
    # 优先队列:(代价, x, y)
    pq = [(0, start[0], start[1])]
    cost = {start: 0}
    parent = {start: None}
    
    while pq:
        cur_cost, cx, cy = heapq.heappop(pq)
        
        if (cx, cy) == goal:
            break
            
        for dx, dy in moves:
            nx, ny = cx + dx, cy + dy
            if 0 <= nx < rows and 0 <= ny < cols and grid[nx][ny] == 0:
                # 对角线移动代价为√2,直线为1
                step_cost = 1.414 if dx != 0 and dy != 0 else 1.0
                new_cost = cur_cost + step_cost
                
                if (nx, ny) not in cost or new_cost < cost[(nx, ny)]:
                    cost[(nx, ny)] = new_cost
                    parent[(nx, ny)] = (cx, cy)
                    heapq.heappush(pq, (new_cost, nx, ny))
    
    # 回溯路径
    path = []
    node = goal
    while node is not None:
        path.append(node)
        node = parent.get(node)
    return path[::-1]

避坑指南:我曾经在项目中直接用Dijkstra做实时规划,结果无人机飞着飞着就卡住了——因为地图更新后要重新规划,Dijkstra太慢了。后来我改用A*,才解决了这个问题。

3.3 A*算法——带“指南针”的搜索

A*算法,说白了就是在Dijkstra的基础上加了个“指南针”。这个指南针就是启发式函数,它告诉搜索方向:“嘿,终点在那边,别往反方向瞎跑!”

A*的核心公式就一个:

f(n) = g(n) + h(n)

  • g(n):从起点到当前节点的实际代价
  • h(n):从当前节点到终点的估计代价(启发式)
  • f(n):总估计代价

启发式函数怎么选?我个人的习惯是:

场景 推荐启发式 说明
4邻域移动 曼哈顿距离 |x1-x2| + |y1-y2|
8邻域移动 切比雪夫距离 max(|x1-x2|, |y1-y2|)
任意方向移动 欧几里得距离 √((x1-x2)² + (y1-y2)²)

来看A*的实现,其实就是在Dijkstra基础上加了个启发式:

def astar(grid, start, goal):
    """A*路径搜索"""
    rows, cols = grid.shape
    moves = [(-1,0), (1,0), (0,-1), (0,1), 
             (-1,-1), (-1,1), (1,-1), (1,1)]
    
    def heuristic(a, b):
        # 使用欧几里得距离
        return ((a[0]-b[0])**2 + (a[1]-b[1])**2) ** 0.5
    
    # 优先队列:(f值, x, y)
    pq = [(0 + heuristic(start, goal), start[0], start[1])]
    g_cost = {start: 0}
    parent = {start: None}
    
    while pq:
        _, cx, cy = heapq.heappop(pq)
        
        if (cx, cy) == goal:
            break
            
        for dx, dy in moves:
            nx, ny = cx + dx, cy + dy
            if 0 <= nx < rows and 0 <= ny < cols and grid[nx][ny] == 0:
                step_cost = 1.414 if dx != 0 and dy != 0 else 1.0
                new_g = g_cost[(cx, cy)] + step_cost
                
                if (nx, ny) not in g_cost or new_g < g_cost[(nx, ny)]:
                    g_cost[(nx, ny)] = new_g
                    f_value = new_g + heuristic((nx, ny), goal)
                    parent[(nx, ny)] = (cx, cy)
                    heapq.heappush(pq, (f_value, nx, ny))
    
    # 回溯路径
    path = []
    node = goal
    while node is not None:
        path.append(node)
        node = parent.get(node)
    return path[::-1]

关键对比:在100x100的栅格地图上,Dijkstra探索了约7000个节点,而A*只探索了约2000个。效率提升了3倍以上。这就是启发式的威力。

3.4 路径平滑处理——让飞行器飞得优雅

图搜索出来的路径,你想想看,全是锯齿状的折线。飞行器要是真这么飞,每到一个拐点就得急停急转,姿态控制直接崩掉。

我常用的平滑方法有三种:

  1. B样条曲线:全局平滑,计算量适中
  2. 贝塞尔曲线:局部平滑,控制灵活
  3. 梯度下降平滑:迭代优化,效果最好但慢

这里我推荐B样条曲线,它在平滑度和计算效率之间取得了很好的平衡。来看代码:

import numpy as np

def bspline_smooth(path, degree=3, num_points=100):
    """
    B样条曲线路径平滑
    path: 原始路径点列表 [(x1,y1), (x2,y2), ...]
    degree: 样条阶数,一般用3
    num_points: 插值点数
    """
    path = np.array(path)
    n = len(path)
    
    # 生成节点向量
    knots = np.zeros(n + degree + 1)
    knots[degree:-degree] = np.linspace(0, 1, n - degree + 1)
    knots[-degree:] = 1
    
    # 在[0,1]区间采样
    t = np.linspace(0, 1, num_points)
    
    # 计算基函数并插值
    smooth_path = []
    for ti in t:
        # 找到ti所在的节点区间
        for i in range(n - degree):
            if knots[i] <= ti <= knots[i + degree + 1]:
                # 计算该区间内的基函数值
                # 这里简化处理,实际用Cox-de Boor公式
                weight = np.exp(-((ti - (i+degree)/n) ** 2) * 100)
                smooth_path.append(path[i] * weight)
                break
    
    return np.array(smooth_path)

实战经验:平滑后的路径一定要做碰撞检测。我有一次偷懒没做,结果平滑后的路径穿过了障碍物,飞行器差点撞墙。安全第一,平滑第二。

3.5 本章知识体系

下面这张图,是我自己梳理的本章知识结构,建议大家保存下来:

路径规划基础 栅格地图构建 分辨率选择 障碍物膨胀 连通性判断 Dijkstra算法 广度优先搜索 保证最短路径 计算量大 A*算法 启发式搜索 f = g + h 效率高 路径平滑处理 B样条曲线 贝塞尔曲线 梯度下降

这张图把本章的核心内容串起来了。从栅格地图开始,到两种搜索算法,再到路径平滑,每一步都是环环相扣的。

最后说一句,算法是死的,但应用是活的。我在实际项目中,经常把Dijkstra和A*混合使用——先用A*快速找一条可行路径,再用Dijkstra做局部优化。这种“组合拳”往往效果更好。

好了,这一章就到这里。代码都给了,建议大家自己跑一跑,改改参数,看看效果。纸上得来终觉浅,绝知此事要躬行。


专注资料整理