記一次帶層級結構列表數據計算性能優化

一、背景前端

  最近,負責一個類財務軟件數據計算的性能優化工做。先說下=這項目的狀況,一套表格,幾十張表格,每張表格數據都是層級結構的,經過序號肯定父子級關係,如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多張表格的計算,由原來的十幾分鍾,改進到幾十秒。好了,本次分享就到這裏,但願能幫助到你們。

相關文章
相關標籤/搜索