高效檢索海量資訊(經典查詢演算法)是現代資訊世界的基礎設施。我們使用符號表描述一張抽象的表格,將資訊(值)儲存在其中,然後按照指定的鍵來搜尋並獲取這些資訊。鍵和值的具體意義取決於不同的應用。符號表中可能會儲存很多鍵和很多資訊,因此實現一張高效的符號表是很重要的任務。
符號表有時被稱為字典,有時被稱為索引。
1.符號表
符號表是一種儲存鍵值對的資料結構,支援兩種操作:插入(put),即將一組新的鍵值對存入表中;查詢(get),即根據給定的鍵得到相應的值。符號表最主要的目的就是將一個健和一個值聯絡起來。
構造符號表的方法有很多,它們不光能夠高效地插入和查詢,還可以進行其他幾種方便的操作。要實現符號表,首先要定義其背後的資料結構,並指明建立並操作這種資料結構以實現插入,查詢等操作所需的演算法。
API
public interface ISymbolTables<Key,Value> where Key : IComparable { int CompareCount { get; set; } /// <summary> /// 將鍵值對存入表中(若值未空則將鍵key從表中刪除) /// </summary> /// <param name="key"></param> /// <param name="value"></param> void Put(Key key, Value value); /// <summary> /// 獲取鍵 key 對應的值(若鍵不存在則返回 null) /// </summary> /// <param name="key"></param> /// <returns></returns> Value Get(Key key); /// <summary> /// 從表中刪去鍵 key /// </summary> /// <param name="key"></param> void Delete(Key key); /// <summary> /// 鍵 key 是否在表中存在 /// </summary> /// <param name="key"></param> /// <returns></returns> bool Contains(Key key); /// <summary> /// 表是否未空 /// </summary> /// <returns></returns> bool IsEmpty(); /// <summary> /// 表中的鍵值對數量 /// </summary> /// <returns></returns> int Size(); /// <summary> /// 表中所有鍵的集合 /// </summary> /// <returns></returns> IEnumerable<Key> Keys(); /// <summary> /// 最小的鍵 /// </summary> /// <returns></returns> Key Min(); /// <summary> /// 最大的鍵 /// </summary> /// <returns></returns> Key Max(); /// <summary> /// 小於等於 key 的鍵 /// </summary> /// <param name="key"></param> /// <returns></returns> Key Floor(Key key); /// <summary> /// 大於等於 key 的鍵 /// </summary> /// <param name="key"></param> /// <returns></returns> Key Ceilling(Key key); /// <summary> ///小於 key 的鍵的數量(key 的排名) /// </summary> /// <param name="key"></param> /// <returns></returns> int Rank(Key key); /// <summary> /// 排名為 k 的鍵 /// </summary> /// <param name="k"></param> /// <returns></returns> Key Select(int k); /// <summary> /// 刪除最小的鍵 /// </summary> void DeleteMin(); /// <summary> /// 刪除最大的鍵 /// </summary> void DeleteMax(); /// <summary> /// [lo ... hi]之間的鍵的數量 /// </summary> /// <param name="lo"></param> /// <param name="hi"></param> /// <returns></returns> int Size(Key lo,Key hi); /// <summary> /// [lo ... hi]之間的鍵 /// </summary> /// <param name="lo"></param> /// <param name="hi"></param> /// <returns></returns> IEnumerable<Key> Keys(Key lo, Key hi); }
基本實現:
/// <summary> /// 符號表基類 /// </summary> /// <typeparam name="Key"></typeparam> /// <typeparam name="Value"></typeparam> public class BaseSymbolTables<Key, Value>: ISymbolTables<Key, Value> where Key : IComparable { public int CompareCount { get; set; } /// <summary> /// 將鍵值對存入表中(若值未空則將鍵key從表中刪除) /// </summary> /// <param name="key"></param> /// <param name="value"></param> public virtual void Put(Key key, Value value) { } /// <summary> /// 獲取鍵 key 對應的值(若鍵不存在則返回 null) /// </summary> /// <param name="key"></param> /// <returns></returns> public virtual Value Get(Key key) { return default(Value); } /// <summary> /// 從表中刪去鍵 key /// </summary> /// <param name="key"></param> public void Delete(Key key) { } /// <summary> /// 鍵 key 是否在表中存在 /// </summary> /// <param name="key"></param> /// <returns></returns> public bool Contains(Key key) { return false; } /// <summary> /// 表是否未空 /// </summary> /// <returns></returns> public bool IsEmpty() { return Size()==0; } /// <summary> /// 表中的鍵值對數量 /// </summary> /// <returns></returns> public virtual int Size() { return 0; } /// <summary> /// 表中所有鍵的集合 /// </summary> /// <returns></returns> public virtual IEnumerable<Key> Keys() { return new List<Key>(); } /// <summary> /// 最小的鍵 /// </summary> /// <returns></returns> public virtual Key Min() { return default(Key); } /// <summary> /// 最大的鍵 /// </summary> /// <returns></returns> public virtual Key Max() { return default(Key); } /// <summary> /// 小於等於 key 的鍵 /// </summary> /// <param name="key"></param> /// <returns></returns> public virtual Key Floor(Key key) { return default(Key); } /// <summary> /// 大於等於 key 的鍵 /// </summary> /// <param name="key"></param> /// <returns></returns> public virtual Key Ceilling(Key key) { return default(Key); } /// <summary> ///小於 key 的鍵的數量(key 的排名) /// </summary> /// <param name="key"></param> /// <returns></returns> public virtual int Rank(Key key) { return 0; } /// <summary> /// 排名為 k 的鍵 /// </summary> /// <param name="k"></param> /// <returns></returns> public virtual Key Select(int k) { return default(Key); } /// <summary> /// 刪除最小的鍵 /// </summary> public virtual void DeleteMin() { } /// <summary> /// 刪除最大的鍵 /// </summary> public virtual void DeleteMax() { } /// <summary> /// [lo ... hi]之間的鍵的數量 /// </summary> /// <param name="lo"></param> /// <param name="hi"></param> /// <returns></returns> public virtual int Size(Key lo, Key hi) { return 0; } /// <summary> /// [lo ... hi]之間的鍵 /// </summary> /// <param name="lo"></param> /// <param name="hi"></param> /// <returns></returns> public virtual IEnumerable<Key> Keys(Key lo, Key hi) { return new List<Key>(); } }
2.有序符號表
典型的應用程式中,鍵都是 IComparable 物件,因此可以使用 a.CompareTo( b ) 來比較 a 和 b 兩個鍵。符號表保持鍵的有序性,可以擴充套件它的API,根據鍵的相對位置定義更多實用操作。例如,最大和最小的鍵。
排名(Rank 方法)和選擇 (Select 方法)
檢查一個新的鍵是否插入合適位置的基本操作是排名(Rank,找出小於指定鍵的鍵的數量)和選擇(Select,找出排名為 k 的鍵)。對於 0 到 Size()-1 的所有 i 都有 i == Rank( Select(i) ),且所有的鍵都滿足 key == Select( Rank( key ) ) 。
鍵的等價性
IComparable 型別中 CompareTo 和 Equals 方法是一致的,但為了避免任何潛在的二義性,這裡只是用 a.CompareTo( b ) == 0 來判斷 a 和 b 是否相等。
成本模型
查詢的成本模型:在符號表的實現中,將比較的次數作為成本模型。在內迴圈不進行比較的情況下,使用陣列的訪問次數。
符號表實現的重點在於其中使用的資料結構和 Get() , Put() 方法。
3.無序連結串列中的順序查詢
符號表中使用的資料結構的一個簡單選擇是連結串列,每個結點儲存一個鍵值對。
Get 方法的實現即為遍歷連結串列,用 Equals 方法比較需要查詢的鍵和每個結點中鍵。如果匹配就返回相應的值,否則返回 null。Put 方法的實現也是遍歷,如果匹配就更新相應的值,否則就用給定的鍵值對建立一個新的結點並將其插入連結串列的開頭。這種方法稱為順序查詢。
/// <summary> /// 順序查詢 /// </summary> /// <typeparam name="Key"></typeparam> /// <typeparam name="Value"></typeparam> public class SequentialSearchST<Key,Value>:BaseSymbolTables<Key, Value> where Key : IComparable { private Node First; private class Node { public Key key; public Value value; public Node next; public Node(Key _key,Value _value,Node _next) { key = _key; value = _value; next = _next; } } public override Value Get(Key key) { for (Node x = First; x != null; x = x.next) { if (key.Equals(x.key)) return x.value; } return default(Value); } public override void Put(Key key, Value value) { for (Node x = First; x != null; x = x.next) { CompareCount++; if (key.Equals(x.key)) { x.value = value; return; } } First = new Node(key,value,First); } }
這裡我們使用一個字串陣列來測試上面的演算法,鍵是陣列中的值,值是插入的索引:
string[] strs = new string[] { "S", "E", "A", "R", "C", "H", "E", "X", "A", "M", "P", "L", "E" };
下面是順序查詢的索引用例的軌跡:
分析符號表演算法比排序演算法更困難,因為不同的用例所進行的操作序列各不相同。常見的情形是雖然查詢和插入的使用模式是不可預測的,但它們的使用肯定不是隨機的。因此我們主要研究最壞情況下的效能。我們使用命中表示一次成功的查詢,未命中表示一次失敗的查詢。
在含有 N 對鍵值的基於連結串列的符號表中,未命中的查詢和插入操作都需要 N 次比較。命中的查詢在最壞情況下需要 N 次比較。特別地,向一個空表中插入 N 個不同的鍵需要 ~ N^2 /2 次比較。
查詢一個已經存在的鍵並不需要線性級別的時間。一種度量方法是查詢表中的每個鍵,並將總時間除以 N 。在查詢表中每個鍵的可能性都相同的情況下,這個結果就是一次查詢平均所需的比較次數。稱它為隨機命中。由上面的演算法可以得到平均比較次數為 ~N/2: 查詢第一個鍵需要比較一次,查詢第二個鍵需要比較兩次 ...... 平均比較次數為(1+2+3.....+N)/ N = (N+1)/2。
這證明基於連結串列的實現以及順序查詢是低效的。
4.有序陣列中的二分查詢
有序符號表API的實現使用的資料結構是一對平行的陣列,一個儲存鍵一個儲存值。下面的程式碼保證陣列中 IComparable 型別的鍵有序,然後使用陣列的索引來高效地實現 Get 和其他操作。
下面演算法的核心是 Rank 方法(它使用二分查詢的演算法),它返回表中小於給定鍵的鍵的數量。對於 Get 方法,只要給定的鍵存在於表中就返回,否則返回空。
Put 方法,只要給定的鍵存在於表中,Rank 方法就能夠精確告訴我們它的為並去更新,以及當鍵不存在時我們也能直到將鍵儲存到什麼位置。插入鍵值對時,將更大的鍵和值向後移一格,並將給定的鍵值對分別插入到陣列。
public class BinarySearchST<Key,Value> : BaseSymbolTables<Key, Value> where Key : IComparable { private Key[] keys; private Value[] vals; private int N; public BinarySearchST(int capacity) { keys = new Key[capacity]; vals = new Value[capacity]; } public override int Size() { return N; } public override Value Get(Key key) { if (IsEmpty()) return default(Value); int i = Rank(key); if (i < N && keys[i].CompareTo(key) == 0) return vals[i]; else return default(Value); } public override int Rank(Key key) { int lo = 0, hi = N - 1; while (lo <= hi) { int mid = lo + (hi-lo) / 2; CompareCount++; int cmp = key.CompareTo(keys[mid]); if (cmp < 0) hi = mid - 1; else if (cmp > 0) lo = mid + 1; else return mid; } return lo; } public override void Put(Key key, Value value) { int i = Rank(key); if (i < N && keys[i].CompareTo(key) == 0) { vals[i] = value; return; } for (int j = N; j > i; j--) { keys[j] = keys[j-1]; vals[j] = vals[j-1]; } keys[i] = key; vals[i] = value; N++; } public override Key Min() { return keys[0]; } public override Key Max() { return keys[N-1]; } public override Key Select(int k) { return keys[k]; } public override Key Ceilling(Key key) { int i = Rank(key); return keys[i]; } public override IEnumerable<Key> Keys() { return keys; } }
下面是該演算法的用例移動軌跡:
對二分查詢的分析
Rank 方法的遞迴實現使用了二分查詢,二分查詢比順序查詢快很多。在 N 個鍵的有序陣列中進行二分查詢最多需要 (lgN + 1)次比較。
儘管該演算法能夠保證查詢所需的時間是對數級別的,但 Put 方法還是太慢,需要移動陣列。對於隨機陣列,構造一個基於有序陣列的符號表所需訪問陣列的次數是陣列長度的平方級別。
向大小為 N 的有序陣列中插入一個新的鍵值對在最壞情況下需要訪問 ~2N 次陣列,因此向一個空符號表中插入 N 個元素在最壞情況下需要訪問 ~N^2 次陣列。
對於一個靜態表(不允許插入)來說,將其在初始化時就排序是值得的。下面是符號表簡單實現的總結:
演算法 |
最壞情況下的成本 (N 次插入後) |
平均情況下的成本 (N 次隨機插入後) |
是否支援有序性相關的操作 | ||
查詢 | 插入 | 查詢 | 插入 | ||
順序查詢(無序連結串列) | N | N | N/2 | N | 否 |
二分查詢(有序陣列) | lgN | 2N | lgN | N | 是 |