从递归到极致优化:树结构构建的性能演进之路
一次简单的代码优化,性能提升 超千倍!本文通过实测数据,显现树结构构建中隐蔽的性能陷阱,并给出最佳实践。
📖 前言
在一样平常开发中,我们经常必要处置惩罚树形结构的数据:构造架构、菜单导航、商品分类、文件目次……这些场景都必要将扁平的数据库记载转换为层级树结构。
本日,让我们从最直观的递归实现开始,一步步优化到极致性能,看看这条优化之路上都有哪些坑。
本文将复兴:
- ✅ 构创建的常见方式有哪些?
- ✅ 为什么有些实如今大数据量下会瓦解?
- ✅ 怎样用一行代码实现数百倍性能提升?
- ✅ ILookup 为什么这么快?
测试情况:
- .NET 8.0
- 测试数据:10,000 ~ 100,000 个节点
- 硬件:平凡条记本电脑。11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz (2.42 GHz) | 16.0 GB
第一章:最直观的实现 - 递归方式
1.1 数据模子
起首界说我们的节点结构:- // 扁平数据(数据库记录)
- public class Node
- {
- public int Id { get; set; }
- public int ParentId { get; set; } // 0 表示根节点
- public string Name { get; set; }
- }
- // 树结构
- public class TreeItem<T>
- {
- public T Node { get; set; }
- public List<TreeItem<T>> Children { get; set; } = new();
- }
复制代码 1.2 递归实现
最直观的思绪:对每个节点,递归查找它的子节点。- public static TreeItem<Node> BuildTreeRecursive(List<Node> nodes)
- {
- var root = nodes.First(n => n.ParentId == 0);
- return BuildNode(nodes, root);
- }
- private static TreeItem<Node> BuildNode(List<Node> allNodes, Node current)
- {
- var item = new TreeItem<Node> { Node = current };
-
- // 🔴 关键:查找当前节点的所有子节点
- var children = allNodes.Where(n => n.ParentId == current.Id).ToList();
-
- foreach (var child in children)
- {
- item.Children.Add(BuildNode(allNodes, child));
- }
-
- return item;
- }
复制代码 长处:
- ✅ 代码轻便、易明白
- ✅ 符合树的天然界说
- ✅ 得当小规模数据(< 1,000 节点)
缺点:
- ❌ 性能题目:时间复杂度 O(n²)
- ❌ 栈溢出风险:深度 > 1,000 层会瓦解
让我们先聊聊第二个题目。
第二章:递归的陷阱 - 栈溢出
2.1 题目重现
测试一个链表型树(每个节点只有一个子节点,深度 = 节点数):- // 生成 10,000 个节点的链表树
- var nodes = new List<Node>();
- for (int i = 1; i <= 10000; i++)
- {
- nodes.Add(new Node
- {
- Id = i,
- ParentId = i - 1, // 形成链表:0 → 1 → 2 → ... → 10000
- Name = $"Node_{i}"
- });
- }
- var tree = BuildTreeRecursive(nodes); // 💥 StackOverflowException!
复制代码 如许至少能提前发现题目,而不是让步伐直接瓦解。
第三章:迭代方式 - DFS 与 BFS
既然递归有深度限定,我们用迭代更换递归。
3.1 DFS(深度优先搜刮)- 使用栈
- System.StackOverflowException: Exception of type 'System.StackOverflowException' was thrown.
复制代码 3.2 BFS(广度优先搜刮)- 使用队列
- private const int MAX_DEPTH = 1000;
- private static TreeItem<Node> BuildNode(List<Node> allNodes, Node current, int depth)
- {
- if (depth > MAX_DEPTH)
- throw new InvalidOperationException("树深度超过限制,建议使用迭代方式");
-
- var item = new TreeItem<Node> { Node = current };
- var children = allNodes.Where(n => n.ParentId == current.Id).ToList();
-
- foreach (var child in children)
- {
- item.Children.Add(BuildNode(allNodes, child, depth + 1));
- }
-
- return item;
- }
复制代码 3.3 迭代 vs 递归
特性递归DFS/BFS 迭代深度限定❌ ~1000层✅ 无穷定代码复杂度✅ 轻便⚠️ 稍复杂调试难度⚠️ 较难✅ 容易性能稍慢(函数调用开销)稍快看起来题目办理了?并没有! 让我们做个性能测试。
第四章:性能灾难 - O(n²) 的原形
4.1 性能测试
为了贴合现实情况,我分了三个场景来举行测试:深层树(层级很深,每层节点数目少),超宽树(层级很浅,但是每层节点数目多),均衡树(深度和宽度都相对匀称,团体结构有点类似均衡二叉树)。节点数目:10万个(节点少了也看不出什么效果)。
直接贴图看效果(基于RELEASE模式编译):
核心算法代码上面已经贴出,详细数据构建以及测试代码,这里就不贴出来了,如果有想看的朋侪,可以留言。
🚨 注意:
- 10 万节点用了 40-50秒!
- 递归方式早已栈溢出
4.2 性能分析:为什么这么慢?
让我们看看这段代码:- public static TreeItem<Node> BuildTreeDFS(List<Node> nodes)
- {
- var root = new TreeItem<Node>
- {
- Node = nodes.First(n => n.ParentId == 0)
- };
-
- var stack = new Stack<TreeItem<Node>>();
- stack.Push(root);
- while (stack.Count > 0)
- {
- var current = stack.Pop();
-
- // 🔴 仍然是线性查找
- var children = nodes
- .Where(n => n.ParentId == current.Node.Id)
- .OrderByDescending(n => n.Id)
- .ToList();
-
- foreach (var child in children)
- {
- var childItem = new TreeItem<Node> { Node = child };
- current.Children.Add(childItem);
- stack.Push(childItem);
- }
- }
- return root;
- }
复制代码 时间复杂度分析:
- 外层循环:O(n) - 遍历全部节点
- 内层 Where:O(n) - 每次扫描整个列表
- 总复杂度:O(n²)
详细盘算:
- 50,000 个节点 → 必要 25 亿次比力操纵!
- 100,000 个节点 → 必要 100 亿次比力操纵!
这就是性能杀手!
4.3 核心题目
- public static TreeItem<Node> BuildTreeBFS(List<Node> nodes)
- {
- var root = new TreeItem<Node>
- {
- Node = nodes.First(n => n.ParentId == 0)
- };
-
- var queue = new Queue<TreeItem<Node>>();
- queue.Enqueue(root);
- while (queue.Count > 0)
- {
- var current = queue.Dequeue();
-
- // 🔴 仍然是线性查找
- var children = nodes
- .Where(n => n.ParentId == current.Node.Id)
- .OrderBy(n => n.Id)
- .ToList();
-
- foreach (var child in children)
- {
- var childItem = new TreeItem<Node> { Node = child };
- current.Children.Add(childItem);
- queue.Enqueue(childItem);
- }
- }
- return root;
- }
复制代码 这是现实项目中最常见的性能陷阱!
第五章:极致优化 - ILookup 的邪术
5.1 核心思绪:空间换时间
既然每次都要查找 ParentId == xxx 的节点,为什么不提前创建索引?- while (stack.Count > 0) // 遍历所有节点:n 次
- {
- var current = stack.Pop();
-
- // 每次都要扫描整个列表!
- var children = nodes.Where(n => n.ParentId == current.Node.Id); // O(n)
- // ...
- }
复制代码 5.2 什么是 ILookup?
ILookup 是 LINQ 提供的一个接口,类似于 Dictionary,但:
- 一键多值:主动处置惩罚一对多关系
- 不会抛非常:查询不存在的键返回空聚集
- 基于哈希表:查找时间 O(1)
- 只读不可变:线程安全
语法:- // 🔴 问题代码:在循环中使用 Where
- foreach (var node in nodes) // n 次
- {
- var children = allNodes.Where(n => n.ParentId == node.Id); // 每次 O(n)
- // 总复杂度:O(n²)
- }
复制代码 5.3 优化后的代码
只需改一行!- // 🔴 原来:每次查找都要扫描整个列表
- var children = nodes.Where(n => n.ParentId == parentId); // O(n)
- // ✅ 现在:提前建立索引,查找变成 O(1)
- var lookup = nodes.ToLookup(n => n.ParentId); // 一次性建立索引 O(n)
- var children = lookup[parentId]; // 直接查找 O(1)
复制代码 改动对比:- // 创建
- ILookup<int, Node> lookup = nodes.ToLookup(n => n.ParentId);
- // 查询(即使 999 不存在也不会异常)
- IEnumerable<Node> children = lookup[999]; // 返回空集合
- // 检查是否存在
- bool exists = lookup.Contains(999);
- // 遍历所有分组
- foreach (var group in lookup)
- {
- Console.WriteLine($"ParentId: {group.Key}, Count: {group.Count()}");
- }
复制代码 就这么简单!
第六章:性能对决 - 实测数据
6.1 完备测试
测试代码:- public static TreeItem<Node> BuildTreeDFSOptimized(IEnumerable<Node> nodes)
- {
- var nodeList = nodes.ToList();
-
- // ✅ 核心优化:提前建立索引
- var lookup = nodeList.ToLookup(n => n.ParentId); // 👈 只加了这一行!
-
- var root = new TreeItem<Node>
- {
- Node = nodeList.First(n => n.ParentId == 0)
- };
-
- var stack = new Stack<TreeItem<Node>>();
- stack.Push(root);
- while (stack.Count > 0)
- {
- var current = stack.Pop();
-
- // ✅ 从 O(n) 变成 O(1)
- var children = lookup[current.Node.Id] // 👈 改这里
- .OrderByDescending(n => n.Id)
- .ToList();
-
- foreach (var child in children)
- {
- var childItem = new TreeItem<Node> { Node = child };
- current.Children.Add(childItem);
- stack.Push(childItem);
- }
- }
- return root;
- }
复制代码 6.2 测试效果
6.3 性能分析
时间复杂度对比:
实现方式建索引查找子节点总复杂度原始方式-O(n)O(n²)优化方式O(n) 一次性O(1)O(n)内存占用:
- 原始方式:O(n) - 只存储节点
- 优化方式:O(n) - 节点 + Lookup 索引(额外约 20-30%)
- public static TreeItem<Node> BuildTreeDFS(List<Node> nodes)
- {
- + var lookup = nodes.ToLookup(n => n.ParentId);
- // ...
- while (stack.Count > 0)
- {
- var current = stack.Pop();
- - var children = nodes.Where(n => n.ParentId == current.Node.Id);
- + var children = lookup[current.Node.Id];
- }
- }
复制代码 结论:
- ✅ 用 20-30% 内存调换 凌驾千倍的性能提升
- ✅ 绝对值得!
第七章:深入明白 ILookup
7.1 为什么 ILookup 这么快?
内部实现原理(简化版):- internal class TreeBuilder
- {
- private const int MAX_RECURSION_DEPTH = 1000; // 最大递归深度限制
- #region 1. 递归方式构建树(原始 - 每次线性查找,带深度保护)
- public static TreeItem<Node> BuildTreeRecursive(IEnumerable<Node> nodes)
- {
- var nodeList = nodes.ToList();
- var rootNode = nodeList.FirstOrDefault(n => n.ParentId == 0) ?? throw new InvalidOperationException("No root node found");
-
- return BuildRecursiveNode(nodeList, rootNode, 0);
- }
- private static TreeItem<Node> BuildRecursiveNode(List<Node> allNodes, Node currentNode, int depth)
- {
- // 深度保护:超过限制时抛出异常,避免真正的栈溢出
- if (depth > MAX_RECURSION_DEPTH)
- {
- throw new StackOverflowException($"递归深度超过限制 ({MAX_RECURSION_DEPTH})");
- }
- var treeItem = new TreeItem<Node> { Node = currentNode };
-
- // O(n) 线性查找子节点
- var children = allNodes.Where(n => n.ParentId == currentNode.Id).ToList();
-
- foreach (var child in children)
- {
- treeItem.Children.Add(BuildRecursiveNode(allNodes, child, depth + 1));
- }
-
- return treeItem;
- }
- #endregion
- #region 2. DFS方式构建树(原始 - 使用栈,每次线性查找)
- public static TreeItem<Node> BuildTreeDFS(IEnumerable<Node> nodes)
- {
- var nodeList = nodes.ToList();
- var rootNode = nodeList.FirstOrDefault(n => n.ParentId == 0) ?? throw new InvalidOperationException("No root node found");
-
- var root = new TreeItem<Node> { Node = rootNode };
- var stack = new Stack<TreeItem<Node>>();
- stack.Push(root);
- while (stack.Count > 0)
- {
- var current = stack.Pop();
-
- // O(n) 线性查找子节点
- var children = nodeList.Where(n => n.ParentId == current.Node.Id).OrderByDescending(n => n.Id).ToList();
-
- foreach (var child in children)
- {
- var childItem = new TreeItem<Node> { Node = child };
- current.Children.Add(childItem);
- stack.Push(childItem);
- }
- }
- return root;
- }
- #endregion
- #region 3. BFS方式构建树(原始 - 使用队列,每次线性查找)
- public static TreeItem<Node> BuildTreeBFS(IEnumerable<Node> nodes)
- {
- var nodeList = nodes.ToList();
- var rootNode = nodeList.FirstOrDefault(n => n.ParentId == 0) ?? throw new InvalidOperationException("No root node found");
-
- var root = new TreeItem<Node> { Node = rootNode };
- var queue = new Queue<TreeItem<Node>>();
- queue.Enqueue(root);
- while (queue.Count > 0)
- {
- var current = queue.Dequeue();
-
- // O(n) 线性查找子节点
- var children = nodeList.Where(n => n.ParentId == current.Node.Id).OrderBy(n => n.Id).ToList();
-
- foreach (var child in children)
- {
- var childItem = new TreeItem<Node> { Node = child };
- current.Children.Add(childItem);
- queue.Enqueue(childItem);
- }
- }
- return root;
- }
- #endregion
- #region 4. 优化的递归方式(使用字典索引 - O(n),带深度保护)
- public static TreeItem<Node> BuildTreeRecursiveOptimized(IEnumerable<Node> nodes)
- {
- var nodeList = nodes.ToList();
- var lookup = nodeList.ToLookup(n => n.ParentId);
-
- var rootNode = nodeList.FirstOrDefault(n => n.ParentId == 0) ?? throw new InvalidOperationException("No root node found");
-
- return BuildRecursiveOptimizedNode(lookup, rootNode, 0);
- }
- private static TreeItem<Node> BuildRecursiveOptimizedNode(ILookup<int, Node> lookup, Node currentNode, int depth)
- {
- // 深度保护
- if (depth > MAX_RECURSION_DEPTH)
- {
- throw new StackOverflowException($"递归深度超过限制 ({MAX_RECURSION_DEPTH})");
- }
- var treeItem = new TreeItem<Node> { Node = currentNode };
-
- // O(1) 通过索引查找子节点
- var children = lookup[currentNode.Id];
-
- foreach (var child in children)
- {
- treeItem.Children.Add(BuildRecursiveOptimizedNode(lookup, child, depth + 1));
- }
-
- return treeItem;
- }
- #endregion
- #region 5. 优化的DFS(使用字典索引 - O(n))
- public static TreeItem<Node> BuildTreeDFSOptimized(IEnumerable<Node> nodes)
- {
- var nodeList = nodes.ToList();
- var lookup = nodeList.ToLookup(n => n.ParentId);
-
- var rootNode = nodeList.FirstOrDefault(n => n.ParentId == 0) ?? throw new InvalidOperationException("No root node found");
-
- var root = new TreeItem<Node> { Node = rootNode };
- var stack = new Stack<TreeItem<Node>>();
- stack.Push(root);
- while (stack.Count > 0)
- {
- var current = stack.Pop();
-
- // O(1) 通过索引查找子节点
- var children = lookup[current.Node.Id].OrderByDescending(n => n.Id).ToList();
-
- foreach (var child in children)
- {
- var childItem = new TreeItem<Node> { Node = child };
- current.Children.Add(childItem);
- stack.Push(childItem);
- }
- }
- return root;
- }
- #endregion
- #region 6. 优化的BFS(使用字典索引 - O(n))
- public static TreeItem<Node> BuildTreeBFSOptimized(IEnumerable<Node> nodes)
- {
- var nodeList = nodes.ToList();
- var lookup = nodeList.ToLookup(n => n.ParentId);
-
- var rootNode = nodeList.FirstOrDefault(n => n.ParentId == 0) ?? throw new InvalidOperationException("No root node found");
-
- var root = new TreeItem<Node> { Node = rootNode };
- var queue = new Queue<TreeItem<Node>>();
- queue.Enqueue(root);
- while (queue.Count > 0)
- {
- var current = queue.Dequeue();
-
- // O(1) 通过索引查找子节点
- var children = lookup[current.Node.Id].OrderBy(n => n.Id).ToList();
-
- foreach (var child in children)
- {
- var childItem = new TreeItem<Node> { Node = child };
- current.Children.Add(childItem);
- queue.Enqueue(childItem);
- }
- }
- return root;
- }
- #endregion
- }
复制代码 关键点:
- 使用 Dictionary 作为底层存储(哈希表)
- 查找时间:O(1) - 直接通过哈希值定位
- 不存在的键返回空聚集,不抛非常
7.2 ILookup vs Dictionary vs GroupBy
- 为什么集中算法的最终内存都是一样的?
- 1.✅ 输入数据相同:所有算法都使用同一个 nodes 列表(100,000 个 Node)
- 2.✅ 输出结构相同:所有算法都构建相同的树结构(TreeItem 对象)
- 3.✅ 最终内存相同:树的总内存 = 节点数 × 每个 TreeItem 的大小
- // 构建后的树内存
- 100,000 个 TreeItem<Node> 对象
- ├─ 每个 TreeItem: ~80 字节
- │ ├─ Node 引用: 8 字节
- │ └─ Children List: 72 字节(包含数组、计数等)
- └─ 总计: ~7.8 MB
复制代码 对比表:
特性GroupByDictionaryILookup创建索引耽误实验手动构建✅ 一行代码一键多值✅❌ 需手动✅查找速率O(n) 每次分组O(1)✅ O(1)键不存在返回 null抛非常✅ 返回空聚集代码轻便性⚠️❌✅保举度暂时分组一对一映射✅ 父子关系第八章:实战应用
8.1 常见场景
场景1:订单-订单项
- // ToLookup 内部大致做了这些事
- public static ILookup<TKey, TElement> ToLookup<TKey, TElement>(
- this IEnumerable<TElement> source,
- Func<TElement, TKey> keySelector)
- {
- // 1. 创建哈希表
- var groups = new Dictionary<TKey, List<TElement>>();
-
- // 2. 遍历一次,建立索引 - O(n)
- foreach (var item in source)
- {
- var key = keySelector(item);
-
- if (!groups.ContainsKey(key))
- groups[key] = new List<TElement>();
-
- groups[key].Add(item);
- }
-
- // 3. 返回只读的 Lookup
- return new Lookup<TKey, TElement>(groups);
- }
- // 查找时 - O(1)
- public IEnumerable<TElement> this[TKey key]
- {
- get
- {
- return groups.ContainsKey(key)
- ? groups[key]
- : Enumerable.Empty<TElement>(); // 不存在返回空集合
- }
- }
复制代码 场景2:部门-员工
- var nodes = GetNodes();
- // 1. GroupBy - 每次查询都重新分组(慢!)
- var groups = nodes.GroupBy(n => n.ParentId);
- var children1 = groups.FirstOrDefault(g => g.Key == 100)?.ToList(); // O(n) 每次都分组
- // 2. Dictionary - 需要手动处理一对多
- var dict = new Dictionary<int, List<Node>>();
- foreach (var node in nodes)
- {
- if (!dict.ContainsKey(node.ParentId))
- dict[node.ParentId] = new List<Node>();
- dict[node.ParentId].Add(node);
- }
- var children2 = dict.ContainsKey(100) ? dict[100] : new List<Node>(); // O(1) 但需要判断
- // 3. Lookup - 一行搞定,自动处理一对多(快!)
- var lookup = nodes.ToLookup(n => n.ParentId);
- var children3 = lookup[100]; // O(1) 且不需要判断
复制代码 场景3:分类-商品
- // ❌ 慢
- foreach (var order in orders)
- {
- order.Items = orderItems.Where(oi => oi.OrderId == order.Id).ToList(); // O(n²)
- }
- // ✅ 快
- var itemsLookup = orderItems.ToLookup(oi => oi.OrderId);
- foreach (var order in orders)
- {
- order.Items = itemsLookup[order.Id].ToList(); // O(n)
- }
复制代码 8.2 辨认性能陷阱
搜刮你的代码库,找这些模式:- var empLookup = employees.ToLookup(e => e.DepartmentId);
- foreach (var dept in departments)
- {
- dept.Employees = empLookup[dept.Id].ToList();
- }
复制代码 CodeReview 清单:
- 循环中是否有 Where 查询?
- 是否多次遍历同一个聚集?
- 数据量是否大概凌驾 1,000?
- 能否用 ToLookup 或 ToDictionary 优化?
第九章:完备对比 - 六种实现
9.1 全部实现方式
实现方式时间复杂度深度限定代码复杂度保举场景递归(原始)O(n²)❌ ~1000层⭐⭐⭐⭐⭐❌ 不保举递归(优化)O(n)❌ ~1000层⭐⭐⭐⭐⭐小规模( 1000 层会栈溢出</ol>10.2 最佳实践
- var prodLookup = products.ToLookup(p => p.CategoryId);
- foreach (var category in categories)
- {
- category.Products = prodLookup[category.Id].ToList();
- }
复制代码 10.3 何时使用 ILookup
[table]场景是否使用一对多关系(父子、分类等)✅ 猛烈保举数据量 > 1,000✅ 必须使用必要频仍查找✅ 猛烈保举暂时分组(只用一次)⚠️ 思量 GroupBy一对一映射⚠️ 思量 Dictionary10.4 影象口诀
循环里有 Where,性能就完蛋;
提前建 Lookup,速率飞上天!
📚 参考资料
一点题外话
DFS(Depth-First Search 深度优先搜刮) 和BFS(Breadth-First Search 广度优先搜刮),这两个术语都来自图论,核心是搜刮(在数据结构中探求特定节点或路径)。
但我们在构创建时,只是按深度优先(大概广度优先)的次序访问节点并创建父子关系,并没有“搜刮”任何目标。严格来说,这应该叫深度优先遍历(Depth-First Traversal)或深度优先构建(Depth-First Construction),广度优先同理。
但为什么各人都风俗叫DFS/BFS?
这是范例的算法术语泛化征象:
- 劈头:图论中的DFS确实是为了搜刮/遍历
- 发展:人们发现“用栈处置惩罚树形结构”这种模式非常通用
- 泛化:任何使用栈来处置惩罚树/图结构的操纵,都被口语化地称为“用DFS的方式”
- 固化:口试、技能文章、开源代码都这么用,形成了约定俗成
类似例子另有许多:
- “用BFS爬取网页” → 实在只是广度优先遍历URL
- “递归遍历文件夹” → 并不是在搜刮什么
- “二叉树的前/中/后序遍历” → 实在也是DFS的特例,但很少人说“DFS二叉树”
为什么没人改正?
由于沟通资源高于正确性资源。当你口试时说“我用DFS把列表转成树”,全部人都秒懂。如果说“我用深度优先遍历的迭代方式,借助栈后进先出的特性来构建层级结构”——太啰嗦了,反而影响沟通服从。
但在现实开发中,“用DFS构创建”已经是行业黑话,各人都担当这个语义泛化后的寄义了。
🎯 结语
一次简单的代码优化,性能提升 凌驾千倍。
在现实项目中,如许的优化时机触目皆是。关键是:
- 丈量:用 Stopwatch 丈量,找到瓶颈
- 分析:辨认 O(n²) 的查找操纵
- 优化:用 ToLookup/ToDictionary 创建索引
- 验证:再次丈量,确认提升
记取:
过早优化是万恶之源,但该优化时别手软!
免责声明:如果侵犯了您的权益,请联系站长及时删除侵权内容,谢谢合作!qidao123.com:ToB企服之家,中国第一个企服评测及软件市场,开放入驻,技术点评得现金. |