一、背景前端
最近,负责一个类财务软件数据计算的性能优化工做。先说下=这项目的状况,一套表格,几十张表格,每张表格数据都是层级结构的,经过序号肯定父子级关系,如1,1.1,1.1.1,1.1.2,1.1.3,1.2,1.2.1,1.2.2,1.3.。。。并且,列表数据带表内编辑功能,就跟Excel体验同样。没错,你猜对了,不出意外的,这是个CS项目,前端采用WPF,在计算以前,对应表格数据已经拉取到前端内存中,经过MVVM双向绑定到UI列表。计算公式分横向和纵向,叶子级的都是横向计算,如金额 = 单价 * 数量;父级的纵向计算,如 1.金额 = 1.1金额 + 1.2金额 + 1.3金额。。。很明显,只能先计算叶子级,再逐级往上计算父级,并且是自底向上的。node
天然而然的,你会想到递归,并且以前项目中也是这么整的,递归调用自底向上计算。问题是,每张表格数据量都很大,实际环境中,最多的出现了30W条。咱们按照递归调用顺序去分析下这个过程:首先,从30W里找根级(虽然最终须要自底向上计算,但系统自己它是不知道谁是子级的,只能由父级往下去逐个找),找到以后,根据根级Id从30W数据中找到其全部子级,循环每一个子级,根据每一个子级ID,从30W数据找到该子级对应的子级。。。只到最终叶子级,能够计算了,该层递归出栈,计算其父级,父级完了计算父级的父级。。。性能优化
那么,问题来了:首先,递归自己就是极耗空间的,这么大数据量,内存浪费更是了不起,并且,数据检索也把CPU给占尽了;更严重的,这还只是一张,系统有30多张表格。。。实际测试也发现,计算一开启,i5 CPU,8G的机器,CPU直接打满,内存也飙升(要不是Windows对进程内存作限制,我估计内存也打满了,实际测试出现过OutOfMemory异常。。。)。运气好,30W数据,花个大几分钟能算完,运气很差就等个大几分钟,OutOfMemory。。。这么搞,确定是不行的,开发机都不行,更别提客户环境千差万别,有些客户机配置很恶劣,老旧XP,2G内存,32位。。。数据结构
二、方案性能
一把辛酸一把泪,问题给出来了,天然是要解决。上述方案的问题在于,查找每一个节点,都须要从30W数据里边遍历,能不能访问每一个节点时候,不用去遍历这30W数据呢?自己,这30W数据就是一个树状结构,假如事先把这30W数据构形成一颗树,那么只须要按照后续遍历,岂不就避免了频繁的30W遍历?测试
好,肯定了用树遍历解决,那是用普通树,仍是二叉树(是否是好奇,为何会想到这个问题)?答案是,二叉树,由于最开始,我就用的普通树,但测试发现,虽然性能极大提高(几分钟到几十秒),但仍是有点儿难以接受,用VS性能探查器发现,普通树须要跟踪某级别未访问节点(通俗点儿说就是,访问完某个节点,须要从同根的子级中遍历寻找下一个未访问的节点),这个特别耗时,假如该级节点特别多,则会遇到上述一样的问题,从大批量数据中检索,虽然这个数据范围已经比30W极大减小了。用二叉树,就左子树右子树,是不须要这个的。大数据
三、实现优化
首先,树节点的定义:this
/// <summary> /// 二叉树节点 /// </summary> /// <typeparam name="T"></typeparam> public class TreeNode<T> where T : Data { public TreeNode() { this.Children = new List<T>(); } public TreeNode(T data) : this() { this.Data = data; } /// <summary> /// 节点对应数据节点 /// </summary> public T Data { get; set; } /// <summary> /// 树节点 /// </summary> public TreeNode<T> Parent { get; set; } /// <summary> /// 左子树 /// </summary> public TreeNode<T> Left { get; set; } /// <summary> /// 右子树 /// </summary> public TreeNode<T> Right { get; set; } /// <summary> /// 该节点对应业务节点的子业务节点集合 /// </summary> public List<T> Children { get; private set; } }
节点,节点数据,左子树节点,右子树节点,父级节点,比较简单。这里惟一须要说明的是,节点对应的子级数据集合,由于原始数据,是一个普通树,最终咱们是要把它转化为一个二叉树的,转化以后,咱们须要记录某个数据节点它对应的原始子级数据集合是哪些,便于后续跟踪和计算。spa
好,二叉树节点定义好了,对二叉树进行处理的前提,是先要构造二叉树。数据结构中,有一种普通树状结构转为二叉树的方式是,第一个子节点做为左子树,剩余兄弟节点,都做为上一个子节点的右子树存在,也就是说,左子树子节点,右子树兄弟节点。假如咱们有这么几个数据节点:1,1.1,1.1.1,1.1.2,1.1.3,1.2,1.2.1,1.2.2,1.3,则构建完成以后,二叉树应该是这样子的:
具体代码,怎么实现呢?这里先说下前提,系统中数据是按照对应序号排序的,好比1,1.1,1.1.1,1.1.2,1.1.3,1.2,1.2.1,1.2.2,1.3。那么,从一维列表构建二叉树的代码以下:
/// <summary> /// 根据实体列表构建二叉树 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="list"></param> /// <returns></returns> public static TreeNode<T> GenerateTree<T>(List<T> list, Func<T, bool> rootCondition, Func<T, T, bool> parentCondition) where T : Data { if (!list.Any()) { return null; } var rootData = list.FirstOrDefault(x => rootCondition(x)); TreeNode<T> root = new TreeNode<T>(rootData); Stack<TreeNode<T>> stackParentNodes = new Stack<TreeNode<T>>(); stackParentNodes.Push(root); foreach (var item in list) { if (item == rootData) { continue; } TreeNode<T> parent = stackParentNodes.Peek(); while (!parentCondition(item, parent.Data)) { stackParentNodes.Pop(); if (stackParentNodes.Count == 0) { stackParentNodes.Push(root); parent = root; break; } parent = stackParentNodes.Peek(); } var currentNode = new TreeNode<T>(item); if (parent.Left == null) { parent.Left = currentNode; currentNode.Parent = parent; } else { if (parent.Left.Right == null) { parent.Left.Right = currentNode; currentNode.Parent = parent.Left; } else { parent.Left.Right.Parent = currentNode; currentNode.Right = parent.Left.Right; currentNode.Parent = parent.Left; parent.Left.Right = currentNode; } } parent.Children.Add(item); stackParentNodes.Push(currentNode); } return root; }
这段代码,参考网上的,出处我已经找不到了,若是哪位网友看见了,麻烦告诉我,我注明出处。说下这段代码的核心思想,首先有个父级栈,用来记录上次遍历的节点及其父节点,而后开始遍历数据列表中每条记录,在这过程当中,从父节点栈中找该节点对应的父节点,不匹配的元素直接出栈,只到找到对应父节点。找到以后,若是父节点左子树不存在,直接将当前节点挂在左子树,若是左子树存在,则该节点是当前左子树的兄弟节点,须要做为该左子树的右子树去挂。这时候有个问题,若是左子树的右子树不存在,直接挂在左子树的右子树就能够,若是存在,则须要将其挂为右子树,左子树的原右子树变成当前节点的右子树。由于遍历时候,是按照顺序来的,这么一来,则兄弟节点在树上挂的顺序,是逆序的,最终效果会以下:
有点儿拧,你们知道是那么回事儿就好了。树构建好了,接下来就是遍历计算。很明显,对于这种计算,是须要后续遍历的,则实现代码以下:
/// <summary> /// 后续遍历二叉树进行计算 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="root"></param> /// <param name="leaveCompute"></param> /// <param name="branchCompute"></param> public static void Compute<T>(TreeNode<T> root, Action<T> leafCompute, Action<T, List<T>> branchCompute) where T : Data { if (root == null) { return; } TreeNode<T> currentNode = null, preNode = null; Stack<TreeNode<T>> stackParentNodes = new Stack<TreeNode<T>>(); stackParentNodes.Push(root); while (stackParentNodes.Any()) { currentNode = stackParentNodes.Peek(); if ((currentNode.Left == null && currentNode.Right == null) || (preNode != null) && (preNode == currentNode.Left || preNode == currentNode.Right)) { preNode = currentNode; if (currentNode.Children.Any()) { currentNode.Data.LeavesCount = currentNode.Children.Sum(x => x.LeavesCount); branchCompute?.Invoke(currentNode.Data, currentNode.Children); } else { currentNode.Data.LeavesCount = 1; leafCompute?.Invoke(currentNode.Data); } stackParentNodes.Pop(); } else { if (currentNode.Right != null) { stackParentNodes.Push(currentNode.Right); } if (currentNode.Left != null) { stackParentNodes.Push(currentNode.Left); } } } }
核心思想是记录遍历过程当中的父级节点及上次遍历的节点。当前节点须要被访问的条件是,当前节点左子树右子树都为空(叶子节点)或者上次访问的节点是本节点的子节点,不然当前节点不该该被访问,而是将其右子树左子树进栈以备考察。这个是全量计算的方式。还有一种状况是,改变了其中某个单元格,例如上述,我改了1.1.3其中的单价,则这时候也须要计算,但计算应该仅限于本级节点及父节点,你非要全量计算也没问题,无非性能低点儿。那么,计算本级和父级的功能,以下:
/// <summary> /// 计算指定节点极其父级 /// </summary> /// <typeparam name="T"></typeparam> /// <param name="node"></param> /// <param name="branchCompute"></param> public static void ComputeParent<T>(TreeNode<T> node, Action<T> leafCompute, Action<T, List<T>> branchCompute) where T : Data { if (node == null) { return; } TreeNode<T> currentNode = node; if (node.Children.Any()) { currentNode.Data.LeavesCount = currentNode.Children.Sum(x => x.LeavesCount); branchCompute?.Invoke(currentNode.Data, currentNode.Children); } else { currentNode.Data.LeavesCount = 1; currentNode.Data.IsLeaf = true; leafCompute?.Invoke(currentNode.Data); } while (currentNode != null) { var parentNode = currentNode.Parent; if (parentNode != null && parentNode.Left == currentNode) { branchCompute?.Invoke(parentNode.Data, parentNode.Children); } currentNode = parentNode; } }
核心思想是,首先计算当前节点,而后,根据树节点中保存的parent节点信息,逐级向上计算其父节点。比较简单,很少说。后续遍历计算有了,还有一种状况,就是要从树里边查找某个节点,这里明显是要前序遍历的,由于扎到某个节点我就直接返回了,犯不着每一个节点都过一遍布保留中途父节点信息。实现以下:
/// <summary> /// 查找符合指定条件的节点 /// </summary> /// <typeparam name="T"></typeparam> /// <returns></returns> public static TreeNode<T> FindNode<T>(TreeNode<T> node, Func<T, bool> condition) where T : Data { if (node == null) { return null; } Stack<TreeNode<T>> stackParentsNodes = new Stack<TreeNode<T>>(); TreeNode<T> currentNode = node; while (currentNode != null || stackParentsNodes.Any()) { if (currentNode != null) { if (condition(currentNode.Data)) { return currentNode; } stackParentsNodes.Push(currentNode); currentNode = currentNode.Left; } else { currentNode = stackParentsNodes.Pop().Right; } } return null; }
典型的前序遍历,比较简单,很少说。
四、总结
这么一套解决方案下来,全套30多张表格的计算,由原来的十几分钟,改进到几十秒。好了,本次分享就到这里,但愿能帮助到你们。