记一次带层级结构列表数据计算性能优化

1、背景

  最近,负责一个类财务软件数据计算的性能优化工作。先说下=这项目的情况,一套表格,几十张表格,每张表格数据都是层级结构的,通过序号确定父子级关系,如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金额。。。很明显,只能先计算叶子级,再逐级往上计算父级,而且是自底向上的。

  自然而然的,你会想到递归,而且之前项目中也是这么整的,递归调用自底向上计算。问题是,每张表格数据量都很大,实际环境中,最多的出现了30W条。我们按照递归调用顺序去分析下这个过程:首先,从30W里找根级(虽然最终需要自底向上计算,但系统本身它是不知道谁是子级的,只能由父级往下去逐个找),找到之后,根据根级Id从30W数据中找到其所有子级,循环每个子级,根据每个子级ID,从30W数据找到该子级对应的子级。。。只到最终叶子级,可以计算了,该层递归出栈,计算其父级,父级完了计算父级的父级。。。

  那么,问题来了:首先,递归本身就是极耗空间的,这么大数据量,内存浪费更是了不得,而且,数据检索也把CPU给占尽了;更严重的,这还只是一张,系统有30多张表格。。。实际测试也发现,计算一开启,i5 CPU,8G的机器,CPU直接打满,内存也飙升(要不是Windows对进程内存做限制,我估计内存也打满了,实际测试出现过OutOfMemory异常。。。)。运气好,30W数据,花个大几分钟能算完,运气不好就等个大几分钟,OutOfMemory。。。这么搞,肯定是不行的,开发机都不行,更别提客户环境千差万别,有些客户机配置很恶劣,老旧XP,2G内存,32位。。。

2、方案

  一把辛酸一把泪,问题给出来了,自然是要解决。上述方案的问题在于,查找每个节点,都需要从30W数据里边遍历,能不能访问每个节点时候,不用去遍历这30W数据呢?本身,这30W数据就是一个树状结构,假如事先把这30W数据构造成一颗树,那么只需要按照后续遍历,岂不就避免了频繁的30W遍历?

  好,确定了用树遍历解决,那是用普通树,还是二叉树(是不是好奇,为什么会想到这个问题)?答案是,二叉树,因为最开始,我就用的普通树,但测试发现,虽然性能极大提升(几分钟到几十秒),但还是有点儿难以接受,用VS性能探查器发现,普通树需要跟踪某级别未访问节点(通俗点儿说就是,访问完某个节点,需要从同根的子级中遍历寻找下一个未访问的节点),这个特别耗时,假如该级节点特别多,则会遇到上述同样的问题,从大批量数据中检索,虽然这个数据范围已经比30W极大减少了。用二叉树,就左子树右子树,是不需要这个的。

3、实现

  首先,树节点的定义:

 /// <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; }
    }

  节点,节点数据,左子树节点,右子树节点,父级节点,比较简单。这里唯一需要说明的是,节点对应的子级数据集合,因为原始数据,是一个普通树,最终我们是要把它转化为一个二叉树的,转化之后,我们需要记录某个数据节点它对应的原始子级数据集合是哪些,便于后续跟踪和计算。

  好,二叉树节点定义好了,对二叉树进行处理的前提,是先要构造二叉树。数据结构中,有一种普通树状结构转为二叉树的方式是,第一个子节点作为左子树,剩余兄弟节点,都作为上一个子节点的右子树存在,也就是说,左子树子节点,右子树兄弟节点。假如我们有这么几个数据节点: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;
        }

  典型的前序遍历,比较简单,不多说。

4、总结

  这么一套解决方案下来,全套30多张表格的计算,由原来的十几分钟,改进到几十秒。好了,本次分享就到这里,希望能帮助到大家。

 

posted @ 2019-03-10 16:24  GUOKUN  阅读(990)  评论(4编辑  收藏  举报