数据结构基础:树形结构、链表结构、数组与哈希表在菜单中的应用
聊到菜单系统的数据结构,我得先坦白一件事——我早年做第一个后台管理系统时,直接用数组硬编码了三级菜单。结果呢?产品经理说要在第二层和第三层之间插一个新层级,我改代码改到凌晨三点。嗯,从那以后我彻底明白了:选对数据结构,比写多少行代码都重要。
今天咱们就把菜单系统里最常用的四种数据结构掰开揉碎讲清楚。你想想看,一个菜单本质上就是个「导航地图」,不同的数据结构决定了这张地图怎么画、怎么走、怎么改。
树形结构:菜单的天然形态
菜单天生就是树。为什么?因为菜单有层级关系——一级菜单下面挂二级,二级下面挂三级。这不就是树吗?
我个人习惯用「多叉树」来表示菜单。每个节点就是一个菜单项,节点里存着:菜单ID、名称、图标、路由地址,还有最重要的——子节点列表。
// 树形菜单节点的典型结构
class MenuNode {
id: number; // 菜单ID
name: string; // 菜单名称
icon: string; // 图标
path: string; // 路由地址
children: MenuNode[]; // 子菜单列表
parentId: number; // 父级ID(用于回溯)
}
我在项目中遇到过一个问题:后端返回的菜单数据是扁平的列表,每个节点只记录了 parentId。这时候就需要我们自己构建树。说白了,就是把扁平数据「挂」到对应的父节点下面去。
// 扁平列表转树形结构(经典写法)
function buildTree(flatList: MenuItem[]): MenuNode[] {
const map = new Map<number, MenuNode>();
const roots: MenuNode[] = [];
// 先建立ID到节点的映射
flatList.forEach(item => {
map.set(item.id, { ...item, children: [] });
});
// 再挂载子节点
flatList.forEach(item => {
const node = map.get(item.id)!;
if (item.parentId === 0) {
roots.push(node); // 根节点
} else {
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(node);
}
}
});
return roots;
}
链表结构:菜单的「兄弟连」
树形结构解决了「上下级」关系,但「同级菜单」之间的顺序怎么办?这时候链表就派上用场了。
其实,每个菜单项的兄弟节点天然就是一个链表。你想想看,「系统管理」下面有「用户管理」、「角色管理」、「权限管理」——这三个就是兄弟节点,它们之间有先后顺序。
我建议用「双向链表」来管理同级菜单。为什么是双向?因为菜单经常需要「上移」、「下移」、「置顶」这些操作。双向链表做这些操作,时间复杂度是 O(1)。
// 双向链表节点
class LinkedMenuItem {
id: number;
name: string;
prev: LinkedMenuItem | null; // 前一个兄弟
next: LinkedMenuItem | null; // 后一个兄弟
children: LinkedMenuItem | null; // 第一个子节点(链表形式)
}
我曾经在一个权限管理系统中,用双向链表实现了菜单的拖拽排序。用户拖拽一个菜单项到新位置,我只需要修改前后节点的指针就行了。如果用数组来做,每次排序都要 splice 和重新索引,性能差很多。
数组与哈希表:性能的「加速器」
树和链表解决了「结构」问题,但「查找」问题还得靠数组和哈希表。
你想想看,用户点击一个菜单项,系统需要快速找到这个菜单的详细信息——比如路由地址、权限标识。如果每次都要递归遍历整棵树,那性能就太差了。
我的做法是:在构建树的同时,维护一个「哈希表」。哈希表的 key 是菜单ID,value 是菜单节点的引用。这样查找任意菜单的时间复杂度就是 O(1)。
// 菜单系统的「双结构」设计
class MenuSystem {
private tree: MenuNode[]; // 树形结构(用于渲染)
private map: Map<number, MenuNode>; // 哈希表(用于查找)
private flatList: MenuNode[]; // 数组(用于遍历)
constructor(menuData: MenuItem[]) {
this.flatList = menuData.map(item => ({ ...item, children: [] }));
this.map = new Map();
this.flatList.forEach(node => this.map.set(node.id, node));
this.tree = this.buildTree();
}
// 根据ID快速查找菜单
findById(id: number): MenuNode | undefined {
return this.map.get(id);
}
// 获取所有叶子节点(没有子菜单的项)
getLeafNodes(): MenuNode[] {
return this.flatList.filter(node => node.children.length === 0);
}
}
数组还有一个妙用:做「菜单面包屑」。用户当前在三级菜单,面包屑需要显示「首页 > 系统管理 > 用户管理」。这时候,从当前节点沿着 parentId 一路回溯到根节点,把路径上的菜单名存到数组里,反转一下就是面包屑。
// 生成面包屑路径
function getBreadcrumb(menuId: number, map: Map<number, MenuNode>): string[] {
const path: string[] = [];
let current = map.get(menuId);
while (current) {
path.push(current.name);
current = current.parentId ? map.get(current.parentId) : null;
}
return path.reverse(); // 从根到当前节点
}
四种数据结构的对比与选择
说了这么多,到底什么时候用哪种?我整理了一张表,你直接照着用就行。
| 数据结构 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| 树 | 菜单渲染、层级展示 | 结构清晰,递归友好 | 查找慢,需要遍历 |
| 链表 | 同级菜单排序、拖拽 | 插入/删除快 | 随机访问慢 |
| 数组 | 遍历、面包屑、批量操作 | 索引访问快 | 插入/删除慢 |
| 哈希表 | 快速查找、权限校验 | O(1) 查找 | 无序,无法排序 |
最后说一句,数据结构的选择没有银弹。你想想看,如果你的菜单只有两级、总共不到20个菜单项,用数组硬编码都行。但如果你在做企业级应用,菜单可能有五级、上千个节点,那树 + 哈希表 + 链表的组合就是必须的。
我曾经接手过一个老项目,菜单数据存在一个巨大的 JSON 对象里,每次查找都要递归遍历。用户切换页面时,菜单高亮要等几百毫秒才反应过来。后来我改成哈希表查找,瞬间就定位到了。嗯,有时候性能优化就是这么简单——选对数据结构就够了。