二分搜尋樹的原理和實現
一、文章簡介
本文將從二叉搜尋樹的定義和性質入手,帶領大家實現一個二分搜尋樹,通過程式碼實現讓大家深度認識二分搜尋樹。
後面會持續更新資料結構相關的博文。
資料結構專欄:https://www.cnblogs.com/hello-shf/category/1519192.html
git傳送門:https://github.com/hello-shf/data-structure.git
二、二叉樹
說樹這種結構之前,我們要先說一下樹這種結構存在的意義。在我們的現實場景中,比如圖書館,我們可以根據分類快速找到我們想要找到的書籍。比如我們要找一本叫做《Java程式設計思想》這本書,我們只需要根據,理工科 ==> 計算機 ==>Java語言分割槽就可以快速找到我們想要的這本書。這樣我們就不需要像陣列或者連結串列這種結構,我們需要遍歷一遍才能找到我們想要的東西。再比如,我們所使用的電腦的資料夾目錄本身也是一種樹的結構。
從上面的描述我們可知,樹這種結構具備天然的高效性可以巧妙的避開我們不關心的東西,只需要根據我們的線索快速去定位我們的目標。所以說樹代表著一種高效。
在瞭解二分搜尋樹之前,我們不得不瞭解一下二叉樹,因為二叉樹是實現二分搜尋樹的基礎。就像我們後面會詳細講解和實現AVL(平衡二叉樹),紅黑樹等樹結構,你不得不在此之前學習二分搜尋樹一樣,他們都是互為基礎的。
2.1、二叉樹的定義:
二叉樹也是一種動態的資料結構。每個節點只有兩個叉,也就是兩個孩子節點,分別叫做左孩子,右孩子,而沒有一個孩子的節點叫做葉子節點。每個節點最多有一個父親節點,最多有兩個孩子節點(也可以沒有孩子節點或者只有一個孩子節點)。對於二叉樹的定義我們不通過複雜的數學表示式來敘述,而是通過簡單的描述,讓大家瞭解一個二叉樹長什麼樣子。
1 只有一個根節點。 2 每個節點至多有兩個孩子節點,分別叫左孩子或者右孩子。(左右孩子節點沒有大小之分哦) 3 每個子樹也都是一個二叉樹
滿足以上三條定義的就是一個二叉樹。如下圖所示,就是一顆二叉樹
2.2、二叉樹的型別
根據二叉樹的節點分佈大概可以分為以下三種二叉樹:完全二叉樹,滿二叉樹,平衡二叉樹。對於以下樹的描述不使用數學表示式或者專業術語,因為那樣很難讓人想象到一棵樹到底長什麼樣子。
滿二叉樹:從根節點到每一個葉子節點所經過的節點數都是相同的。
如下圖所示就是一顆滿二叉樹。
完全二叉樹:除去最後一層葉子節點,就是一顆完全二叉樹,並且最後一層的節點只能集中在左側。
對於上面的性質,我們從另一個角度來說就是將滿二叉樹的葉子節點從右往左刪除若干個後就變成了一棵完全二叉樹,也就是說,滿二叉樹一定是一棵完全二叉樹,反之不成立。如下圖所示:除了圖3都是一棵完全二叉樹。
平衡二叉樹:平衡二叉樹又被稱為AVL樹(區別於AVL演算法),它是一棵二叉樹,又是一棵二分搜尋樹,平衡二叉樹的任意一個節點的左右兩個子樹的高度差的絕對值不超過1,即左右兩個子樹都是一棵平衡二叉樹。
三、二分搜尋樹
3.1、二分搜尋樹的定義
1 二分搜尋樹是一顆二叉樹 2 二分搜尋樹每個節點的左子樹的值都小於該節點的值,每個節點右子樹的值都大於該節點的值 3 任意一個節點的每棵子樹都滿足二分搜尋樹的定義
上面我們給出了二分搜尋樹的定義,根據定義我們可知,二分搜尋樹是一種具備可比較性的樹,左孩子 < 當前節點 < 右孩子。這種可比較性為我們提供了一種高效的查詢資料的能力。比如,對於下圖所示的二分搜尋樹,如果我們想要查詢資料14,通過比較,14 < 20 找到 10,14 > 10。只經過上面的兩步,我們就找到了14這個元素,如下面gif所示。可見二分搜尋樹的查詢是多麼的高效。
3.2、二分搜尋樹的實現
本章我們的重點是實現一個二分搜尋樹,那我們規定該二分搜尋樹應該具備以下功能:
1 以Node作為連結串列的基礎儲存結構 2 使用泛型,並要求該泛型必須實現Comparable介面 3 基本操作:增刪改查
3.2.1、基礎結構實現
通過上面的分析,我們可知,如果我們要實現一個二分搜尋樹,我們需要我們的節點有左右兩個孩子節點。
根據要求和定義,構建我們的基礎程式碼如下:
/** * 描述:二叉樹的實現 * 需要泛型是可比較的,也就是泛型必須實現Comparable介面 * * @Author shf * @Date 2019/7/22 9:53 * @Version V1.0 **/ public class BST<E extends Comparable> { /** * 節點內部類 */ private class Node{ private E e; private Node left, right;//左右孩子節點 public Node(E e){ this.e = e; this.left = right; } } /** * BST的根節點 */ private Node root; /** * 記錄BST的 size */ private int size; public BST(){ root = null; size = 0; } /** * 對外提供的獲取 size 的方法 * @return */ public int size(){ return size; } /** * 二分搜尋樹是否為空 * @return */ public boolean isEmpty(){ return size == 0; } }
對於二分搜尋樹這種結構我們要明確的是,樹是一種天然的可遞迴的結構,為什麼這麼說呢,大家想想二分搜尋樹的每一棵子樹也是一棵二分搜尋樹,剛好迎合了遞迴的思想就是將大任務無限拆分為一個個小任務,直到求出問題的解,然後再向上疊加。所以在後面的操作中,我們都通過遞迴實現。相信大家看了以下實現後會對遞迴有一個深層次的理解。
3.2.2、增
為了讓大家對二分搜尋樹有一個直觀的認識,我們向二分搜尋樹依次新增[20,10,6,14,29,25,33]7個元素。我們來看一下這個新增的過程。
增加操作和上面的搜尋操作基本是一樣的,首先我們要先找到我們要新增的元素需要放到什麼位置,這個過程其實就是搜尋的過程,比如我們要在上圖中的基礎上繼續新增一個元素15。如下圖所示,我們經過一路尋找,最終找到節點14,我們15>14所以需要將15節點放到14節點的右孩子處。
有了以上的基本認識,我們通過程式碼實現一下這個過程。
1 /** 2 * 新增元素 3 * @param e 4 */ 5 public void add(E e){ 6 root = add(root, e); 7 } 8 9 /** 10 * 新增元素 - 遞迴實現 11 * 時間複雜度 O(log n) 12 * @param node 13 * @param e 14 * @return 返回根節點 15 */ 16 public Node add(Node node, E e){ 17 if(node == null){// 如果當前節點為空,則將要新增的節點放到當前節點處 18 size ++; 19 return new Node(e); 20 } 21 if(e.compareTo(node.e) < 0){// 如果小於當前節點,遞迴左孩子 22 node.left = add(node.left, e); 23 } else if(e.compareTo(node.e) > 0){// 如果大於當前節點,遞迴右孩子 24 node.right = add(node.right, e); 25 } 26 return node; 27 }
如果你還不是很理解上面的遞迴過程,我們從巨集觀角度分析一下,首先明確 add(Node node, E e) 這個方法是幹什麼的,這個方法接收兩個引數 node和e,如果node為null,則我們將例項化node。我們的遞迴過程正是這樣,如果node不為空並按照大小關係去找到左孩子節點還是右孩子,然後對該孩子節點繼續執行 add(Node node, E e) 操作,通過按照大小規則一路查詢直到找到一個符合條件的節點並且該節點為null,執行node的例項化即可。
如果看了上面的解釋你還是有點懵,沒問題,繼續往下看。劉慈欣的《三體》不僅讓中國的硬科幻登上了世界的舞臺,更是給廣大讀者普及了諸如“降維打擊”之類的熱門概念。“降維打擊”之所以給人如此之震撼,在於它以極簡的方式,從更高的、全新的技術視角有效解決了當前困局。那麼在演算法的世界中,“遞迴”就是這種牛叉哄哄的“降維打擊”技術。遞迴思想及:當前問題的求解是否可以由規模小一點的問題求解疊加而來,後者是否可以再由更小一點的問題求解疊加而來……依此類推,直到收斂為一個極簡的出口問題的求解。如果你能從這段話歸納出遞迴就是一種將大的問題不斷的進行拆分為更小的問題,直到拆分到找到問題的解,然後再向大的問題逐層疊加而最終求得遞迴的解。
看了以上解釋相信大家應該對以上遞迴過程有了一個深層次的理解。如果大家還有疑問建議畫一畫遞迴樹,通過壓棧和出棧以及堆記憶體變化的方式詳細分析每一個步驟即可。在我之前寫的文章,在分析連結串列反轉的時候對遞迴的微觀過程進行了詳細的分析,希望對大家有所幫助。
3.2.3、查
有了上面的基礎我們實現一個查詢的方式,應該也不存在很大的難度了。我們設計一個方法叫 contains 即判斷是否存在某個元素。
1 /** 2 * 搜尋二分搜尋樹中是否包含元素 e 3 * @param e 4 * @return 5 */ 6 public boolean contains(E e){ 7 return contains(root, e); 8 } 9 10 /** 11 * 搜尋二分搜尋樹中是否包含元素 e 12 * 時間複雜度 O(log n) 13 * @param node 14 * @param e 15 * @return 16 */ 17 public boolean contains(Node node, E e){ 18 if(node == null){ 19 return false; 20 } else if(e.compareTo(node.e) == 0){ 21 return true; 22 } else if(e.compareTo(node.e) < 0){ 23 return contains(node.left, e); 24 } else { 25 return contains(node.right, e); 26 } 27 }
從上面程式碼我們不難發現其實和add方法的遞迴思想是一樣的。那在此我們就不做詳細解釋了。
為了後面程式碼的實現,我們再設計兩個方法,即查詢樹中的最大和最小元素。
通過二分搜尋樹的定義我們不難發現,左孩子 < 當前節點 < 右孩子。按照這個順序,對於一棵二分搜尋樹中最小的那個元素就是左邊的那個元素,最大的元素就是最右邊的那個元素。
通過下圖我們不難發現,最大的和最小的節點都符合我們上面的分析,最小的在最左邊,最大的在最右邊,但不一定都是葉子節點。比如圖1中的6和33元素都不是葉子節點。
通過上面的分析,我們應該能很容易的想到,查詢最小元素,就是使用遞迴從根節點開始,一直遞迴左孩子,直到一個節點的左孩子為null。我們就找到了該最小節點。查詢最大值同理。
1 /** 2 * 搜尋二分搜尋樹中以 node 為根節點的最小值所在的節點 3 * @param node 4 * @return 5 */ 6 private Node minimum(Node node){ 7 if(node.left == null){ 8 return node; 9 } 10 return minimum(node.left); 11 } 12 13 /** 14 * 搜尋二分搜尋樹中的最大值 15 * @return 16 */ 17 public E maximum(){ 18 if (size == 0){ 19 throw new IllegalArgumentException("BST is empty"); 20 } 21 return maximum(root).e; 22 } 23 24 /** 25 * 搜尋二分搜尋樹中以 node 為根節點的最大值所在的節點 26 * @param node 27 * @return 28 */ 29 private Node maximum(Node node){ 30 if(node.right == null){ 31 return node; 32 } 33 return maximum(node.right); 34 }
3.2.4、刪
刪除操作我們設計三個方法,即:刪除最小,刪除最大,刪除任意一個元素。
3.2.4.1、刪除最大最小元素
通過對上面3.2.3中的查最大和最小元素我們不難想到首先我們要找到最大或者最小元素。
如3.2.3中的圖2所示,如果待刪除的最大最小節點如果沒有葉子節點直接刪除。但是如圖1所示,如果待刪除的最大最小元素還有孩子節點,我們該如何處理呢?對於刪除最小元素,我們需要將該節點的右孩子節點提到被刪除元素的呃位置,刪除最大元素同理。然後我們再看看圖2所示的情況,使用圖1的刪除方式,也就是對於刪除最小元素,將該節點的右孩子節點提到該元素位置即可,只不過對於圖2的情況,右孩子節點為null而已。
1 /** 2 * 刪除二分搜尋樹中的最小值 3 * @return 4 */ 5 public E removeMin(){ 6 if (size == 0){ 7 throw new IllegalArgumentException("BST is empty"); 8 } 9 E e = minimum(); 10 root = removeMin(root); 11 return e; 12 } 13 14 /** 15 * 刪除二分搜尋樹中以 node 為根節點的最小節點 16 * @param node 17 * @return 刪除後新的二分搜尋樹的跟 18 */ 19 ////////////////////////////////////////////////// 20 // 12 12 // 21 // / \ / \ // 22 // 8 18 -----> 10 18 // 23 // \ / / // 24 // 10 15 15 // 25 ////////////////////////////////////////////////// 26 private Node removeMin(Node node){ 27 if(node.left == null){ 28 Node rightNode = node.right;// 將node.right(10) 賦值給 rightNode 儲存 29 node.right = null;// 將node的right與樹斷開連線 30 size --; 31 return rightNode; // rightNode(10)返回給遞迴的上一層,賦值給 12 元素的左節點。 32 } 33 node.left = removeMin(node.left); 34 return node; 35 } 36 37 public E removeMax(){ 38 E e = maximum(); 39 root = removeMax(root); 40 return e; 41 } 42 43 /** 44 * 刪除二分搜尋樹中以 node 為根節點的最小節點 45 * @param node 46 * @return 47 */ 48 ////////////////////////////////////////////////// 49 // 12 12 // 50 // / \ / \ // 51 // 8 18 -----> 8 15 // 52 // \ / \ // 53 // 10 15 10 // 54 ////////////////////////////////////////////////// 55 private Node removeMax(Node node){ 56 if(node.right == null){ 57 Node leftNode = node.left; // 將node.right(15) 賦值給 leftNode 儲存 58 node.left = null;// 將 node 的 left 與樹斷開連線 59 size --; 60 return leftNode; // leftNode (10)返回給遞迴的上一層,賦值給 12 元素的右節點。 61 } 62 node.right = removeMax(node.right); 63 return node; 64 }
3.2.4.2、刪除指定元素
待刪除元素可能存在的情況如下:
1 第一種,只有左孩子; 2 第二種,只有右孩子; 3 第三種,左右孩子都有; 4 第四種,待刪除元素為葉子節點;
第一種情況和第二種情況的樹形狀類似3.2.3中的圖1,其實他們的處理方式和刪除最大最小元素的處理方式是一樣的。這個就不過多解釋了,大家可以自己手動畫出來一棵樹試試。那對於第四種情況就是第一種或者第二種的特殊情況了,也不需要特殊處理。和3.2.3中的圖1和圖2的處理方式都是一樣的。
那我們重點說一下第三種情況,這個情況有點複雜。如上圖所示,如果我們想刪除元素10,我們該怎麼做呢?我們通過二分搜尋樹的定義分析一下,其實很簡單。首先10這個元素一定是大於他的左子樹的任意一個節點,並小於右子樹的任意一個節點。那我們刪除了10這個元素,仍然不能打破平衡二叉樹的性質。一般思路,我們得想辦法找個元素頂替下10這個元素。找誰呢?這個元素放到10元素的位置以後,仍然還能保證大於左子樹的任意元素,小於右子樹的任意元素。所以我們很容易想到找左子樹中的最大元素,或者找右子樹中的最小元素來頂替10的位置,如下圖1所示。
如下圖所示,首先我們用7頂替10的位置,如下圖2所示。我們刪除了10這個元素後,用左子樹的最大元素替代10,依然能滿足二分搜尋樹的定義。同理我們用右孩子最小的節點替換被刪除的元素也是完全可以的。在我們後面的程式碼實現中,我們使用右孩子最小的節點替換被刪除的元素。
1 /** 2 * 從二分搜尋樹中刪除元素為e的節點 3 * @param e 4 */ 5 public void remove(E e){ 6 root = remove(root, e); 7 } 8 9 /** 10 * 刪除掉以node為根的二分搜尋樹中值為e的節點, 遞迴演算法 11 * @param node 12 * @param e 13 * @return 返回刪除節點後新的二分搜尋樹的根 14 */ 15 private Node remove(Node node, E e){ 16 17 if( node == null ) 18 return null; 19 20 if( e.compareTo(node.e) < 0 ){ 21 node.left = remove(node.left , e); 22 return node; 23 } else if(e.compareTo(node.e) > 0 ){ 24 node.right = remove(node.right, e); 25 return node; 26 } else{ // e.compareTo(node.e) == 0 找到待刪除的節點 node 27 28 // 待刪除節點左子樹為空,直接將右孩子替代當前節點 29 if(node.left == null){ 30 Node rightNode = node.right; 31 node.right = null; 32 size --; 33 return rightNode; 34 } 35 36 // 待刪除節點右子樹為空,直接將左孩子替代當前節點 37 if(node.right == null){ 38 Node leftNode = node.left; 39 node.left = null; 40 size --; 41 return leftNode; 42 } 43 44 // 待刪除節點左右子樹均不為空 45 // 找到右子樹最小的元素,替代待刪除節點 46 Node successor = minimum(node.right); 47 successor.right = removeMin(node.right); 48 successor.left = node.left; 49 50 node.left = node.right = null; 51 52 return successor; 53 } 54 }
四、二分搜尋樹的遍歷
二分搜尋樹的遍歷大概可以分為一下幾種:
1,深度優先遍歷: (1)前序遍歷:父節點,左孩子,右孩子 (2)中序遍歷:左孩子,父節點,右孩子 (3)後序遍歷:左孩子,右孩子,父節點 2,廣度優先遍歷:按樹的高度從左至右進行遍歷
如上所示,大類分為深度優先和廣度優先,深度有點的三種方式,大家不難發現,其實就是遍歷父節點的時機。廣度優先呢就是按照樹的層級,一層一層的進行遍歷。
4.1、深度優先遍歷
4.1.1、前序遍歷
前序遍歷是按照:父節點,左孩子,右孩子的順序對節點進行遍歷,所以按照這個順序對於如下圖所示的一棵樹,前序遍歷,應該是按照編號所示的順序進行遍歷的。
遞迴實現:雖然看著很複雜,其實遞迴程式碼實現是十分簡單的。看程式碼吧,請別驚掉下巴。
/** * 前序遍歷 */ public void preOrder(){ preOrder(root); } /** * 前序遍歷 - 遞迴演算法 * @param node 開始遍歷的根節點 */ private void preOrder(Node node){ if(node == null){ return; } // 不做複雜的操作,僅僅將遍歷到的元素進行列印 System.out.println(node.e); preOrder(node.left); preOrder(node.right); } -------------前序遍歷------------ 20 10 6 14 29 25 33
非遞迴實現:如果我們不使用遞迴如何實現呢?可是使用棧來實現,這是一個技巧,當我們需要按照程式碼執行的順序記錄(快取)變數的時候,棧是一種再好不過的資料結構了。這也是棧的天然優勢,因為JVM的棧記憶體正是棧這種資料結構。
從根節點開始,每次迭代彈出當前棧頂元素,並將其孩子節點壓入棧中,先壓右孩子再壓左孩子。為什麼是先右孩子再左孩子?因為棧是後進先出的資料結構
1 /** 2 * 前序遍歷 - 非遞迴 3 */ 4 public void preOrderNR(){ 5 preOrderNR(root); 6 } 7 8 /** 9 * 前序遍歷 - 非遞迴實現 10 */ 11 private void preOrderNR(Node node){ 12 Stack<Node> stack = new Stack<>(); 13 stack.push(node); 14 while (!stack.isEmpty()){ 15 Node cur = stack.pop(); 16 System.out.println(cur.e); 17 if(cur.right != null){ 18 stack.push(cur.right); 19 } 20 if(cur.left != null){ 21 stack.push(cur.left); 22 } 23 } 24 }
4.1.2、中序遍歷
中序遍歷:左孩子,父節點,右孩子。按照這個順序,我們不難畫出下圖。紅色數字表示遍歷的順序。
遞迴實現:
1 /** 2 * 二分搜尋樹的中序遍歷 3 */ 4 public void inOrder(){ 5 inOrder(root); 6 } 7 8 /** 9 * 中序遍歷 - 遞迴 10 * @param node 11 */ 12 private void inOrder(Node node){ 13 if(node == null){ 14 return; 15 } 16 inOrder(node.left); 17 System.out.println(node.e); 18 inOrder(node.right); 19 }
-------------中序遍歷------------
6
10
14
20
25
29
33
我們觀察上面的遍歷結果,不難發現一個現象,列印結果正是按照從小到大的順序。其實這也是二分搜尋樹的一個性質,因為我們是按照:左孩子,父節點,右孩子。我們二分搜尋樹的其中一個定義:二分搜尋樹每個節點的左子樹的值都小於該節點的值,每個節點右子樹的值都大於該節點的值。
非遞迴實現:依然是用棧儲存。
1 /** 2 * 中序遍歷 - 非遞迴 3 */ 4 public void inOrderNR(){ 5 inOrderNR(root); 6 } 7 8 /** 9 * 中序遍歷 - 非遞迴實現 10 * 時間複雜度 O(n) 11 * @param node 12 */ 13 private void inOrderNR(Node node){ 14 Stack<Node> stack = new Stack<>(); 15 while(node != null || !stack.isEmpty()){ 16 while(node != null){ 17 stack.push(node); 18 node = node.left; 19 } 20 node = stack.pop(); 21 System.out.println(node.e); 22 node = node.right; 23 } 24 }
4.1.3、後序遍歷
後序遍歷:左孩子,右孩子,父節點。遍歷順序如下圖所示。
1 /** 2 * 後序遍歷 3 */ 4 public void postOrder(){ 5 postOrder(root); 6 } 7 8 /** 9 * 後續遍歷 - 遞迴 10 * 時間複雜度 O(n) 11 * @param node 12 */ 13 public void postOrder(Node node){ 14 if(node == null){ 15 return; 16 } 17 postOrder(node.left); 18 postOrder(node.right); 19 System.out.println(node.e); 20 } 21 -------------後序遍歷------------ 22 6 23 14 24 10 25 25 26 33 27 29 28 20
非遞迴實現:
1 /** 2 * 後序遍歷 - 非遞迴 3 */ 4 public void postOrderNR(){ 5 postOrderNR(root); 6 } 7 8 /** 9 * 後序遍歷 - 非遞迴實現 10 * 時間複雜度 O(n) 11 * @param node 12 */ 13 private void postOrderNR(Node node){ 14 Stack<Node> stack = new Stack<>(); 15 Stack<Node> out = new Stack<>(); 16 stack.push(node); 17 while(!stack.isEmpty()){ 18 Node cur = stack.pop(); 19 out.push(cur); 20 21 if(cur.left != null){ 22 stack.push(cur.left); 23 } 24 if(cur.right != null){ 25 stack.push(cur.right); 26 } 27 } 28 while(!out.isEmpty()){ 29 System.out.println(out.pop().e); 30 } 31 }
4.2、廣度優先遍歷
廣度優先遍歷:又稱為,層序遍歷,按照高度順序一層一層的訪問整棵樹,高層次的節點將會比低層次的節點先被訪問到。這種遍歷方式顯然是不適合遞迴求解的。至於為什麼,相信經過我們前面對遞迴的分析,大家已經很清楚了。
對於層序優先遍歷,我們使用佇列來實現,利用佇列的先進先出(FIFO)的的特性。
1 /** 2 * 層序優先遍歷 3 * 時間複雜度 O(n) 4 */ 5 public void levelOrder(){ 6 Queue<Node> queue = new LinkedList<>(); 7 queue.add(root); 8 while(!queue.isEmpty()){ 9 Node node = queue.remove(); 10 System.out.println(node.e); 11 if(node.left != null){ 12 queue.add(node.left); 13 } 14 if(node.right != null){ 15 queue.add(node.right); 16 } 17 } 18 }
五、二分搜尋樹存在的問題
前面我們講,二分搜尋樹是一種高效的資料結構,其實這也不是絕對的,在極端情況下,二分搜尋樹會退化成連結串列,各種操作的時間複雜度大打折扣。比如我們向我們上面實現的二分搜尋樹中按順序新增如下元素[1,2,3,4,5],如下圖所示,我們發現我們的二分搜尋樹其實已經退化成了一個連結串列。關於這個問題,我們在後面介紹平衡二叉樹(AVL)的時候會討論如何能讓二分搜尋樹保持平衡,並避免這種極端情況的發生。
《祖國》 小時候 以為你就是遠在北京的天安門 長大了 才發現原來你就在我的心裡
參考文獻:
《玩轉資料結構-從入門到進階-劉宇波》
《資料結構與演算法分析-Java語言描述》
如有錯誤的地方還請留言指正。
原創不易,轉載請註明原文地址:https://www.cnblogs.com/hello-shf/p/11342907.html
&n