高效检索海量信息(经典查找算法)是现代信息世界的基础设施。我们使用符号表描述一张抽象的表格,将信息(值)存储在其中,然后按照指定的键来搜索并获取这些信息。键和值的具体意义取决于不同的应用。符号表中可能会保存很多键和很多信息,因此实现一张高效的符号表是很重要的任务。
符号表有时被称为字典,有时被称为索引。
1.符号表
符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到相应的值。符号表最主要的目的就是将一个健和一个值联系起来。
构造符号表的方法有很多,它们不光能够高效地插入和查找,还可以进行其他几种方便的操作。要实现符号表,首先要定义其背后的数据结构,并指明创建并操作这种数据结构以实现插入,查找等操作所需的算法。
API
public interface ISymbolTableswhere Key : IComparable { int CompareCount { get; set; } /// /// 将键值对存入表中(若值未空则将键key从表中删除) /// /// /// void Put(Key key, Value value); /// /// 获取键 key 对应的值(若键不存在则返回 null) /// /// /// Value Get(Key key); /// /// 从表中删去键 key /// /// void Delete(Key key); /// /// 键 key 是否在表中存在 /// /// /// bool Contains(Key key); /// /// 表是否未空 /// /// bool IsEmpty(); /// /// 表中的键值对数量 /// /// int Size(); /// /// 表中所有键的集合 /// /// IEnumerable Keys(); /// /// 最小的键 /// /// Key Min(); /// /// 最大的键 /// /// Key Max(); /// /// 小于等于 key 的键 /// /// /// Key Floor(Key key); /// /// 大于等于 key 的键 /// /// /// Key Ceilling(Key key); /// ///小于 key 的键的数量(key 的排名) /// /// /// int Rank(Key key); /// /// 排名为 k 的键 /// /// /// Key Select(int k); /// /// 删除最小的键 /// void DeleteMin(); /// /// 删除最大的键 /// void DeleteMax(); /// /// [lo ... hi]之间的键的数量 /// /// /// /// int Size(Key lo,Key hi); /// /// [lo ... hi]之间的键 /// /// /// /// IEnumerable Keys(Key lo, Key hi); }
基本实现:
////// 符号表基类 /// /// /// public class BaseSymbolTables : ISymbolTables where Key : IComparable { public int CompareCount { get; set; } /// /// 将键值对存入表中(若值未空则将键key从表中删除) /// /// /// public virtual void Put(Key key, Value value) { } /// /// 获取键 key 对应的值(若键不存在则返回 null) /// /// /// public virtual Value Get(Key key) { return default(Value); } /// /// 从表中删去键 key /// /// public void Delete(Key key) { } /// /// 键 key 是否在表中存在 /// /// /// public bool Contains(Key key) { return false; } /// /// 表是否未空 /// /// public bool IsEmpty() { return Size()==0; } /// /// 表中的键值对数量 /// /// public virtual int Size() { return 0; } /// /// 表中所有键的集合 /// /// public virtual IEnumerable Keys() { return new List (); } /// /// 最小的键 /// /// public virtual Key Min() { return default(Key); } /// /// 最大的键 /// /// public virtual Key Max() { return default(Key); } /// /// 小于等于 key 的键 /// /// /// public virtual Key Floor(Key key) { return default(Key); } /// /// 大于等于 key 的键 /// /// /// public virtual Key Ceilling(Key key) { return default(Key); } /// ///小于 key 的键的数量(key 的排名) /// /// /// public virtual int Rank(Key key) { return 0; } /// /// 排名为 k 的键 /// /// /// public virtual Key Select(int k) { return default(Key); } /// /// 删除最小的键 /// public virtual void DeleteMin() { } /// /// 删除最大的键 /// public virtual void DeleteMax() { } /// /// [lo ... hi]之间的键的数量 /// /// /// /// public virtual int Size(Key lo, Key hi) { return 0; } /// /// [lo ... hi]之间的键 /// /// /// /// public virtual IEnumerable Keys(Key lo, Key hi) { return new List (); } }
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 方法的实现也是遍历,如果匹配就更新相应的值,否则就用给定的键值对创建一个新的结点并将其插入链表的开头。这种方法称为顺序查找。
////// 顺序查找 /// /// /// public class SequentialSearchST :BaseSymbolTables 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: BaseSymbolTables 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 Keys() { return keys; } }
下面是该算法的用例移动轨迹:
对二分查找的分析
Rank 方法的递归实现使用了二分查找,二分查找比顺序查找快很多。在 N 个键的有序数组中进行二分查找最多需要 (lgN + 1)次比较。
尽管该算法能够保证查找所需的时间是对数级别的,但 Put 方法还是太慢,需要移动数组。对于随机数组,构造一个基于有序数组的符号表所需访问数组的次数是数组长度的平方级别。
向大小为 N 的有序数组中插入一个新的键值对在最坏情况下需要访问 ~2N 次数组,因此向一个空符号表中插入 N 个元素在最坏情况下需要访问 ~N^2 次数组。
对于一个静态表(不允许插入)来说,将其在初始化时就排序是值得的。下面是符号表简单实现的总结:
算法 | 最坏情况下的成本 (N 次插入后) |
平均情况下的成本 (N 次随机插入后) |
是否支持有序性相关的操作 | ||
查找 | 插入 | 查找 | 插入 | ||
顺序查找(无序链表) | N | N | N/2 | N | 否 |
二分查找(有序数组) | lgN | 2N | lgN | N | 是 |