数据结构选型:针对路径规划算法的轻量级数据结构设计原则

做嵌入式路径规划,说白了就是跟内存较劲。我见过太多工程师,算法写得漂亮,一上板子就崩——内存爆了。嗯,这里要聊的,就是怎么用最轻量的数据结构,把路径规划跑起来。

为什么数据结构这么重要?

你想想看,一个典型的A*算法,要维护open list和closed list。如果直接用标准库的vector或者list,在资源受限的MCU上,分分钟把堆栈撑爆。我个人习惯,在选型之前先问三个问题:

  • 这个结构要存多少数据?——预估最大节点数
  • 访问模式是什么?——频繁插入删除?还是只读遍历?
  • 内存是静态分配还是动态分配?——嵌入式里我尽量不用malloc

我在项目中遇到过,一个同事用std::priority_queue做A*的open list,结果在STM32F4上跑了不到100个节点就hard fault了。为什么?动态内存碎片化,加上priority_queue内部用vector,频繁realloc。

原则一:能用数组就别用链表

很多教科书喜欢用链表讲图论。但在嵌入式里,链表是魔鬼。每个节点多一个指针,就是4字节(32位机)或8字节(64位机)。1000个节点,光指针就多占4KB——这在很多MCU上已经是半个RAM了。

我建议用静态数组+索引的方式模拟链表。比如:

// 轻量级邻接表
#define MAX_NODES 128
#define MAX_EDGES 512

typedef struct {
    int16_t next;      // 下一条边的索引,-1表示结束
    int16_t to;        // 目标节点
    uint16_t cost;     // 代价
} Edge;

Edge edges[MAX_EDGES];
int16_t head[MAX_NODES];  // 每个节点的第一条边索引

// 初始化
void graph_init() {
    memset(head, -1, sizeof(head));
    // edge_count 全局维护
}

你看,没有malloc,没有指针,全是int16_t。一个边结构才6字节。我曾经用这个结构在Cortex-M0上跑Dijkstra,512条边只用了3KB内存,稳得很。

原则二:优先队列用二叉堆,别用红黑树

路径规划里,open list需要频繁取最小代价节点。很多人第一反应是红黑树或平衡树。但嵌入式里,二叉堆就够了

为什么?

  • 二叉堆插入O(log n),删除最小O(log n),和红黑树一样
  • 但二叉堆用数组实现,没有指针开销
  • 红黑树每个节点要存颜色、左右子指针、父指针——至少多占12字节

我常用的最小堆实现:

typedef struct {
    int16_t node_id;
    uint16_t f_cost;   // 启发式代价
} HeapNode;

typedef struct {
    HeapNode data[MAX_NODES];
    int16_t size;
} MinHeap;

// 上浮操作
void heap_push(MinHeap* heap, int16_t node, uint16_t cost) {
    int16_t i = heap->size++;
    heap->data[i].node_id = node;
    heap->data[i].f_cost = cost;
    
    while (i > 0) {
        int16_t parent = (i - 1) / 2;
        if (heap->data[parent].f_cost <= heap->data[i].f_cost) break;
        // 交换
        HeapNode tmp = heap->data[parent];
        heap->data[parent] = heap->data[i];
        heap->data[i] = tmp;
        i = parent;
    }
}

注意看,我用int16_t做索引,最大支持32767个节点。如果节点数不超过255,甚至可以用uint8_t。这就是轻量化的细节。

核心原则:能用小整数类型就别用int。一个int16_t比int32_t省一半内存。1000个节点就是2KB的差距。

原则三:closed list用位图,别用哈希表

closed list只需要回答一个问题:这个节点访问过没有?

哈希表?太奢侈了。每个节点要存key、value、next指针。嵌入式里,位图(bitmap)是最优解

// 位图实现closed list
#define NODE_BITS 128  // 支持128个节点
uint8_t closed_bitmap[(NODE_BITS + 7) / 8];

inline void closed_set(uint8_t node) {
    closed_bitmap[node / 8] |= (1 << (node % 8));
}

inline uint8_t closed_test(uint8_t node) {
    return (closed_bitmap[node / 8] >> (node % 8)) & 0x01;
}

128个节点,位图只占16字节。如果用哈希表,至少几百字节。我记得有一次做AGV路径规划,地图有2000个栅格,位图只用了250字节。同事用unordered_set,直接吃了8KB——板子内存总共才64KB。

避坑指南:我曾经在项目里用位图时,忘了处理节点ID从1开始的情况。结果节点0一直没被标记,导致算法死循环。嗯,记得检查你的节点编号范围。

原则四:路径存储用环形缓冲区

路径规划的结果是一条节点序列。传统做法是用动态数组,push_back一路追加。但嵌入式里,路径长度通常有上限(比如最大100个节点)。用环形缓冲区(ring buffer)更合适。

#define PATH_MAX 64
typedef struct {
    uint8_t nodes[PATH_MAX];
    uint8_t head;
    uint8_t tail;
    uint8_t count;
} PathBuffer;

// 从尾部添加节点
uint8_t path_push_back(PathBuffer* pb, uint8_t node) {
    if (pb->count >= PATH_MAX) return 0;  // 满了
    pb->nodes[pb->tail] = node;
    pb->tail = (pb->tail + 1) % PATH_MAX;
    pb->count++;
    return 1;
}

// 从头部取出节点
uint8_t path_pop_front(PathBuffer* pb, uint8_t* node) {
    if (pb->count == 0) return 0;
    *node = pb->nodes[pb->head];
    pb->head = (pb->head + 1) % PATH_MAX;
    pb->count--;
    return 1;
}

好处是什么?

  • 固定内存,没有动态分配
  • 支持双向操作(从起点到终点,或者反向回溯)
  • 覆盖旧路径时,不需要释放内存

我一般在路径规划完成后,把结果从环形缓冲区拷贝到全局路径数组里。这样规划模块和路径执行模块解耦,各自用各自的数据结构。

原则五:启发式代价用查表法

路径规划里经常要算欧几里得距离或曼哈顿距离。每次实时计算sqrt或者乘法,既慢又费电。我建议预计算+查表

比如,对于栅格地图,可以预计算每个栅格到目标点的启发式代价:

// 预计算曼哈顿距离
uint16_t heuristic_table[MAX_NODES];

void precompute_heuristic(uint8_t goal_x, uint8_t goal_y, 
                          uint8_t* node_x, uint8_t* node_y, 
                          uint16_t node_count) {
    for (uint16_t i = 0; i < node_count; i++) {
        int16_t dx = (int16_t)node_x[i] - (int16_t)goal_x;
        int16_t dy = (int16_t)node_y[i] - (int16_t)goal_y;
        heuristic_table[i] = (uint16_t)(abs(dx) + abs(dy));
    }
}

这样在A*运行时,取启发式代价就是一次数组访问,O(1)。代价是多了几百字节的表格。但比起每次算sqrt,省下的CPU时间可以跑更多控制逻辑。

注意:如果地图是动态的(目标点变化),查表法就不适用了。这时候可以用近似计算,比如用max(dx, dy)代替欧几里得距离,避免开方。

总结一下我的选型清单

数据结构 推荐方案 内存开销(1000节点) 适用场景
图/邻接表 静态数组+索引 ~6KB(边) 固定拓扑地图
优先队列 二叉堆(数组) ~4KB A*/Dijkstra
Closed list 位图 ~125字节 节点访问标记
路径存储 环形缓冲区 ~64字节 路径结果缓存
启发式代价 查表法 ~2KB 静态目标点

你看,全部加起来不到12KB。在大多数Cortex-M系列芯片上,都能跑得很舒服。我做过一个项目,用STM32F103(20KB RAM)跑A*,地图200个节点,内存占用不到8KB,还留了12KB给其他任务。

最后说一句:数据结构选型不是炫技,是妥协的艺术。在嵌入式里,每字节都珍贵。你省下的内存,就是留给系统稳定性的余量。嗯,今天就聊到这里,下一章我们聊聊内存池的设计。