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