1. 程式人生 > >資料結構之基本查詢與樹表查詢(上)

資料結構之基本查詢與樹表查詢(上)

只要你開啟電腦,就會涉及到查詢技術。如炒股軟體中查股票資訊、硬碟檔案中找照片、在光碟中搜DVD,甚至玩遊戲時在記憶體中查詢攻擊力、魅力值等資料修改用來作弊等,都要涉及到查詢。當然,在網際網路上查詢資訊就更加是家常便飯。查詢是計算機應用中最常用的操作之一,也是許多程式中最耗時的一部分,查詢方法的優劣對於系統的執行效率影響極大。因此,本篇討論一些查詢方法。

一、順序查詢

1.1 基本思想

  順序查詢(Sequential Search)又叫線性查詢,是最基本的查詢技術,它的查詢過程是:從表中第一個(或最後一個)記錄開始,逐個進行記錄的關鍵字和給定值比較,若某個記錄的關鍵字和給定值相等,則查詢成功,找到所查的記錄;如果直到最後一個(或第一個)記錄,其關鍵字和給定值比較都不等時,則表中沒有所查的記錄,查詢不成功。

順序查詢所用時間與查詢關鍵字Key線上性表中的位置有關,其時間複雜度為O(n)。順序查詢的優點在於:演算法簡單易行,且對錶的結構無任何要求(無論是順序表還是連結串列,也無論是按關鍵字有序還是無序存放)。當然,其缺點也比較明顯:演算法效率較低,在較大規模的資料集合中進行查詢時,不宜採用順序查詢

1.2 程式碼實現

複製程式碼

        static void SequenceSearchDemo()
        {
            int[] seqList = { 2, 8, 10, 13, 21, 36, 51, 57, 62, 69 };

            Console.WriteLine("-------------基本順序查詢-------------");
            Console.WriteLine("查詢51:{0}", SequenceSearch(seqList, 51));
            Console.WriteLine("查詢8:{0}", SequenceSearch(seqList, 8));
            Console.WriteLine("查詢15:{0}", SequenceSearch(seqList, 15));
        }

        static int SequenceSearch(int[] seqList, int key)
        {
            int index = -1;
            for (int i = 0; i < seqList.Length; i++)
            {
                if (seqList[i] == key)
                {
                    index = i;
                    break;
                }
            }

            return index;
        }

複製程式碼

  執行結果為:

二、二分查詢

2.1 基本思想

  折半查詢(Binary Search)技術,又稱為二分查詢。它的前提是線性表中的記錄必須是關鍵碼有序(通常從小到大有序),線性表必須採用順序儲存,其時間複雜度為O(logn)。

折半查詢的基本思想是:在有序表中,取中間記錄作為比較物件,若給定值與中間記錄的關鍵字相等,則查詢成功;若給定值小於中間記錄的關鍵字,則在中間記錄的左半區繼續查詢;若給定值大於中間記錄的關鍵字,則在中間記錄的右半區繼續查詢。不斷重複上述過程,直到查詢成功,或所有查詢區域無記錄,查詢失敗為止。

2.2 程式碼實現

複製程式碼

        static void SeqSearchDemo()
        {
            int[] seqList = { 2, 8, 10, 13, 21, 36, 51, 57, 62, 69 };

            Console.WriteLine("-------------基本二分查詢-------------");
            Console.WriteLine("查詢51:{0}", SeqSearch(seqList, 51));
            Console.WriteLine("查詢8:{0}", SeqSearch(seqList, 8));
            Console.WriteLine("查詢15:{0}", SeqSearch(seqList, 15));
        }

        static int SeqSearch(int[] seqList, int key)
        {
            int low = 0;
            int high = seqList.Length - 1;
            int mid;

            while (low <= high)
            {
                mid = (low + high) / 2;
                if (seqList[mid] == key)
                {
                    return mid;
                }
                else if (seqList[mid] < key)
                {
                    low = mid + 1;
                }
                else
                {
                    high = mid - 1;
                }
            }

            return -1;
        }

複製程式碼

  執行結果為:

2.3 Array.BinarySearch方法

  在.NET中的陣列類Array中,內建了一個二分查詢的方法—Array.BinarySearch,它是一個靜態方法。需要注意的是:在呼叫這個方法前,需要確保作為引數的查詢表內的關鍵字已經有序,否則就需要手動呼叫Array.Sort()方法進行排序。

複製程式碼

    int[] seqList = { 32, 25, 8, 10, 13, 21, 36, 51, 57, 62, 69 };
    Console.WriteLine("-------------Array.BinarySearch-------------");
    Array.Sort(seqList);
    Console.WriteLine("查詢51:{0}", Array.BinarySearch(seqList, 51));
    Console.WriteLine("查詢69:{0}", Array.BinarySearch(seqList, 69));
    Console.WriteLine("查詢15:{0}", Array.BinarySearch(seqList, 15));

複製程式碼

  在Array.BinarySearch()方法內部的求mid值的公式為:mid=low+((high-low)>>1),這是因為整數右移一位相當於整數除2操作,但位移運算的速度快於除法運算

2.4 System.Collections.SortedList類

  在.NET中的System.Collections名稱空間下,SortedListSortedList<TKey,TValue>兩個類是用於存放鍵值對的集合類,它們的元素儲存於線性表中,並按鍵值進行排序。其中SortedList使用了兩個陣列來分別存放key和value,並巧妙地運用了二分查詢使得它的各項效能與ArrayList十分近似。

複製程式碼

    SortedList<string, string> studentList = new SortedList<string, string>();
    studentList.Add("005", "張三");
    studentList.Add("004", "李四");
    studentList.Add("006", "王五");
    studentList.Add("012", "馬六");
    studentList.Add("002", "錢七");
    studentList.Add("009", "劉八");

    foreach (var item in studentList)
    {
       Console.WriteLine("{0}:{1}", item.Key, item.Value);
    }

複製程式碼

  執行結果為:

  回過頭來,我們看看SortedList類的Add方法,從中可以發現,它藉助了Array.BinarySearch方法獲取儲存位置,也就是說它也使用了二分查詢方法。

三、查詢樹方法

  前面討論的幾種查詢方法中,二分查詢效率最高,但其要求表中記錄按照關鍵字有序,且只能在順序表上實現,從而需要在插入和刪除操作時移動很多的元素。如果不希望表中記錄按關鍵字有序,而又希望得到較高的插入和刪除效率,可以考慮使用幾種特殊的二叉樹或樹作為表的組織形式。

3.1 二叉查詢樹

(1)基本概念

  二叉查詢樹(Binary Search Tree,BST)又稱二叉排序樹,它是滿足如下性質的二叉樹:

  •  若它的左子樹非空,則左子樹上所有記錄的值均小於根記錄的值;
  •  若它的右子樹非空,則右子樹上所有記錄的值均大於根記錄的值;
  •  左、右子樹又各是一棵二叉查詢樹。

  假如有一個序列{62,88,58,47,35,73,51,99,37,93},那麼構造出來的二叉查詢樹如下圖所示:

  二叉查詢樹是遞迴定義的,其一般理解是:二叉查詢樹中任一節點,其值為k,只要該節點有左孩子,則左孩子的值必小於k,只要有右孩子,則右孩子的值必大於k。二叉查詢樹的一個重要的性質是:中序遍歷該樹得到的序列是一個遞增有序的序列

(2)二叉查詢樹的新增操作

(3)二叉查詢樹的刪除操作

(4)二叉查詢樹的程式碼實現

  有關二叉查詢樹的新增和刪除節點如何實現,可以閱讀《資料結構基礎溫故—4.樹(中)》一文,該文使用C#實現了二叉查詢樹。

注意:對於二叉查詢樹最糟糕的情況是插入一個有序序列,使得具有N個元素的集合生成了高度為N的單枝二叉樹,從而使其退化了一個單鏈表,其查詢效率也會會由O(logn)變為O(n)。

3.2 平衡二叉樹

  剛剛提到在二叉查詢樹中,如果插入元素的順序接近有序,那麼二叉查詢樹將退化為連結串列,從而導致二叉查詢樹的查詢效率大大降低。前蘇聯兩位科學家G.M. Adelson-Velskii和E.M. Landis在1962年的一篇論文中提出了一種自平衡二叉查詢樹。這種二叉查詢樹在插入和刪除操作中,可以通過一系列的旋轉操作來保持平衡,從而保證了二叉查詢樹的查詢效率。最終這種二叉查詢樹被命名為AVL-Tree,也被稱為平衡二叉樹。

(1)基本概念

  平衡二叉樹定義(AVL):它或者是一顆空樹,或者具有以下性質的二叉樹:它的左子樹和右子樹的深度之差的絕對值不超過1,且它的左子樹和右子樹都是一顆平衡二叉樹。

  平衡因子(BF):結點的左子樹的深度減去右子樹的深度,那麼顯然-1<=bf<=1;

PS:平衡二叉樹上所有結點的平衡因子只可能是-1、0和1。只要二叉樹上有一個結點的平衡因子的絕對值大於1,則該二叉樹就是不平衡的

(2)平衡二叉樹的操作

假設我們已經有棵平衡二叉樹,現在讓我們來看看插入節點後,原來節點失去平衡後,平衡二叉樹會進行不同型別(RR、LL、LR以及RL)的旋轉來保持平衡。

3.3 System.Collections.Generic.SortedDictionary類

  另一種與平衡二叉樹類似的是紅黑樹,紅黑樹和AVL樹的區別在於它使用顏色來標識節點的高度,它所追求的是區域性平衡而不是AVL樹中的非常嚴格的平衡。在.NET中的System.Collections.Generic名稱空間下,SortedDictionary類就是使用紅黑樹實現的。紅黑樹和AVL樹的原理非常接近,但是複雜度卻遠勝於AVL樹,這裡也就不做討論。園子裡也已經有了不少關於紅黑樹的比較好的介紹的文章,有興趣的可以去閱讀閱讀。

  在程式碼中,我們可以模擬100000個數字進行新增:

複製程式碼

            Random random = new Random();
            int array_count = 100000;
            List<int> intList = new List<int>();
            for (int i = 0; i <= array_count; i++)
            {
                int ran = random.Next();
                intList.Add(ran);
            }

            SortedDictionary<int, int> dic_int = new SortedDictionary<int, int>();
            foreach (var item in intList)
            {
                if (dic_int.ContainsKey(item) == false)
                {
                    dic_int.Add(item, item);
                }
            }

複製程式碼

  當然,還可以與SortedList(SortedList內部是Array,而SortedDictionary內部是紅黑樹)進行一下對比,這裡使用了老趙的CodeTimer類

  (1)新增操作對比

  由於SortedList用Array陣列儲存,每次進行插入操作時,首先用二分查詢法找到相應的位置,得到位置以後,SortedList會把該位置以後的值依次往後移一個位置,空出當前位,再把值插入,這個過程中用到了Array.Copy方法,而呼叫該方法是比較損耗效能的,該程式碼如下:

複製程式碼

private void  Insert(int  index, TKey key, TValue value)
{
    ......

    if  (index < this._size)
    {
        Array.Copy(this.keys, index, this.keys, index + 1, this._size - index);
        Array.Copy(this.values, index, this.values, index + 1, this._size - index);
    }
    
    ......
}

複製程式碼

  SortedDictionary在新增操作時,只會根據紅黑樹的特性,旋轉節點,保持平衡,並沒有對Array.Copy的呼叫。下面我們就用資料測試一下:迴圈一個int型、容量為10w的隨機陣列,分別用SortedList和SortedDictionary新增,看看效率如何:

複製程式碼

        static void SortedAddInTest()
        {
            Random random = new Random();
            int array_count = 100000;
            List<int> intList = new List<int>();
            for (int i = 0; i <= array_count; i++)
            {
                int ran = random.Next();
                intList.Add(ran);
            }

            SortedList<int, string> sortedlist_int = new SortedList<int, string>();
            SortedDictionary<int, string> dic_int = new SortedDictionary<int, string>();
            CodeTimer.Time("sortedList_Add_int", 1, () =>
            {
                foreach (var item in intList)
                {
                    if (sortedlist_int.ContainsKey(item) == false)
                        sortedlist_int.Add(item, "test" + item.ToString());
                }
            });
            CodeTimer.Time("sortedDictionary_Add_int", 1, () =>
            {
                foreach (var item in intList)
                {
                    if (dic_int.ContainsKey(item) == false)
                        dic_int.Add(item, "test" + item.ToString());
                }
            });
        }

複製程式碼

  執行結果如下圖所示:

  從上圖可以看出:在大量新增操作的情況下,SortedDictionary效能(無論是從時間消耗、CPU計算、還是GC垃圾回收次數)優於SortedList。

  (2)查詢操作對比

  兩者的查詢操作中,時間複雜度都為O(logn),且原始碼中也沒有額外的操作造成效能損失,那麼他們在查詢操作中效能如何?繼續上面一個例子進行測試。

複製程式碼

        static void SortedQueryInTest()
        {
            Random random = new Random();
            int array_count = 100000;
            List<int> intList = new List<int>();
            for (int i = 0; i <= array_count; i++)
            {
                int ran = random.Next();
                intList.Add(ran);
            }

            SortedList<int, string> sortedlist_int = new SortedList<int, string>();
            SortedDictionary<int, string> dic_int = new SortedDictionary<int, string>();

            foreach (var item in intList)
            {
                if (sortedlist_int.ContainsKey(item) == false)
                    sortedlist_int.Add(item, "test" + item.ToString());
            }

            foreach (var item in intList)
            {
                if (dic_int.ContainsKey(item) == false)
                    dic_int.Add(item, "test" + item.ToString());
            }

            CodeTimer.Time("sortedList_Search_int", 1, () =>
            {
                foreach (var item in intList)
                {
                    sortedlist_int.ContainsKey(item);
                }
            });

            CodeTimer.Time("sortedDictionary_Search_int", 1, () =>
            {
                foreach (var item in intList)
                {
                    dic_int.ContainsKey(item);
                }
            });
        }

複製程式碼

  執行結果如下圖所示:

  從上圖可以看出:兩者在迴圈10w次的情況下,查詢操作SortedList大概為SortedDictionary的一半,這是由於SortedList已經在插入操作時已經將其轉化為了一個有序的陣列,從而在查詢時可以直接使用二分查詢提高效率。SortedDictionary則是一個二叉排序樹,查詢效率理論上也是O(logn),但其較有序陣列的二分查詢效率還是差了一點點。

  (3)刪除操作對比

  從新增操作例子可以看出,由於SortedList內部使用Array陣列進行儲存資料,而陣列本身的侷限性使得SortedList大部分的新增操作都要呼叫Array.Copy方法,從而導致了效能的損失,這種情況同樣存在於刪除操作中。

  SortedList每次刪除操作都會將刪除位置後的值往前挪動一位,以填補刪除位置的空白,這個過程剛好跟新增操作反過來,同樣也需要呼叫Array.Copy方法,相關程式碼如下:

複製程式碼

public void RemoveAt(int index)
{
    ......

    if (index < this._size)
    {
        Array.Copy(this.keys, index + 1, this.keys, index, this._size - index);
        Array.Copy(this.values, index + 1, this.values, index, this._size - index);
    }
    
    ......
}

複製程式碼

  而SortedDictionary使用紅黑樹結構儲存元素,紅黑樹本身是一棵二叉查詢樹,它的刪除和二叉查詢樹的刪除類似。首先要找到真正的刪除點,當被刪除結點n存在左右孩子時,真正的刪除點應該是n的中序遍歷的前驅,關於這一點請參考二叉查詢樹的刪除。如下圖所示,當刪除結點20時,實際被刪除的結點應該為18,結點20的資料變為18。

  這裡,我們仍然選擇上面的例子來進行一個簡單的對比測試,仍然是10w個元素的資料量:

複製程式碼

        static void SortedDeleteInTest()
        {
            Random random = new Random();
            int array_count = 100000;
            List<int> intList = new List<int>();
            for (int i = 0; i <= array_count; i++)
            {
                int ran = random.Next();
                intList.Add(ran);
            }

            SortedList<int, string> sortedlist_int = new SortedList<int, string>();
            SortedDictionary<int, string> dic_int = new SortedDictionary<int, string>();

            foreach (var item in intList)
            {
                if (sortedlist_int.ContainsKey(item) == false)
                    sortedlist_int.Add(item, "test" + item.ToString());
            }

            foreach (var item in intList)
            {
                if (dic_int.ContainsKey(item) == false)
                    dic_int.Add(item, "test" + item.ToString());
            }

            CodeTimer.Time("sortedList_Delete_String", 1, () =>
            {
                foreach (var item in intList)
                {
                    sortedlist_int.Remove(item);
                }
            });

            CodeTimer.Time("sortedDictionary_Delete_String", 1, () =>
            {
                foreach (var item in intList)
                {
                    dic_int.Remove(item);
                }
            });
        }

複製程式碼

  執行結果如下圖所示:

  從上圖也可以看出:在10w次的刪除操作中,SortedDictionary的處理速度和效能消耗較SortedList好的不是一丁半點

總結:

①SortedList用陣列儲存資料,所以對GC比較友好一點,而且對於相對比較有序的輸入源而言,操作較少(eg:List<int> intList = Enumerable.Range(0, array_count).ToList())。 ②SortedDictionary用節點鏈儲存資料,所以對GC而言,相對比較複雜。所以當可以預見到集合中的元素比較少的時候或者資料本身相對比較有序時,應該傾向於使用SortedList。

參考資料

(1)程傑,《大話資料結構》

(2)陳廣,《資料結構(C#語言描述)》

(3)段恩澤,《資料結構(C#語言版)》

特別感謝

作者:周旭龍

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連結。