12.1 定义
12.1.1 符号表
符号表(symbol table):符号表是一种具有键的项数据结构,它支持两种基本运算:插入新项与返回包含已知键的项。
符号表有时又称做字典(dictionary)
符号表的操作如下:
Void STinit(int);//初始化
Int STcount();//返回项计数
Void STinsert(Item);//添加新项
Item STsearch(Key);//查找具有已知键的项
Void STdelete(Item);//删除具有已知键的项
Item STselect(int);//选择第k个最小项
Void STsort(void (*visit)(Item));//按键的顺序访问项
12.1.2 常用的符号表实现方式
12.1.2.1 键索引搜索
如果键值为小于M的正整数,且项具有不同的键,则符号表数据类型可以通过项的键索引数组来实现,让插入、搜索以及删除运算所需的时间恒定;同时,只要任何一种运算作用于某N个项的表上,初始化、选择以及排序所需的时间与M成比例。
键索引数组对于许多应用而言非常有用,但如果键不在一个小范围内,则它们不适用。
12.1.2.2 键顺序搜索
(1). 有序
实现符号表的一种简单方法是按顺序在数组中保存项。当插入新项时,将较大元素移过一个位置(像插入排序中一样)把它放到数组中;当执行搜索时,按顺序查找数组。因为数组时有序的,所以,当我们碰到某个大于搜索键的值时,就可以报告搜索失败。而且因为数组是有序的,选择和排序运算都很容易实现。
(2). 无序
另外一种简单实现方案是,编制一种并不要求数组中的项保持有序的实现。当插入新项时,将它放到数组的末尾;当执行搜索时,按顺序查找数组。这种方式的特征是,插入运算较快,但选择和排序则需要更多的工作量。通过搜索具有指定键的项,再将它移到数组的末尾,同时,将数组的大小减小1,则可以删除该项;也可以通过重复以上运算,删除具有指定键的所有项。如果数组中某索引的句柄可以使用,则不必要进行搜索,而且删除的时间为恒定。
以上两种形式的符号表使用链表同样可以实现。
在频繁需要排序运算的应用中,我们将选择一种有序(数组或表)的表达方式,因为选定的表结构使排序函数容易实现,与之相反,它需要完整的排序实现过程。在我们知道选择运算可能会频繁执行的应用中,就可以选择一种有序数组表达方式,因为这种表结构使选择运算时间恒定。与之相比,在链表中,选择运算的时间为线性时间,即使在有序链表中也是如此。
12.1.2.3二分搜索
在顺序搜索的数组实现中,使用基于标准分治范例的过程,我们可以大大降低庞大项集合的总搜索时间。分治的过程为,将项分成两部分,判断搜索键属于哪一部分,然后重点考虑该部分。分解项集合的合理方式是让这些项有序,然后使用排序数组的索引来划分被处理的数组部分。这种搜索技术被称做二分搜索(binary search)。
(1). 二分搜索递归实现代码:
Item search (int l, int r, Key v)
{
int m = (l + r)/2;
if (l > r) return NULLitem;
if (eq(v, key(st[m]))) return st[m];
if (l == r) return NULLitem;
if (less (v, key(st[m])))
return search (l, m – 1, v);
else return search (m + 1, r, v);
}
Item STsearch(Key v)
{
return search (0, N -1, v);
}
12.1.2.4二分搜索非递归实现代码:
int search (int a[], int v, int l, int r)
{
while(r >= l)
{
int m = (l + r) /2;
if (v == a[m]) return m;
if (v < a[m]) r = m – 1;
else l = m + 1;
}
return -1;
}
12.1.2.5二分搜索的特点分析
二分搜索只能用于数组实现的有序符号表的搜索,更新表的高开销可能性是使用二分搜索的最大缺点。但是在很多应用中,静态表(static table)可以预排序,在这种情况下,快速访问使二分搜索成为首选。
如果我们需要动态插入新项,似乎需要链式结构,但单个链表不会得到有效的实现,因为二分搜索的效率取决于我们通过索引快速获得子数组中间元素的能力,获取单链表中间元素的唯一途径是跟踪链接。为了综合二分搜索的效率与链式结构的灵活性,我们需要更复杂的数据结构。
二分搜索算法进行的比较序列是预先决定的:使用的特定序列取决于搜索的键值以及N的值。可以使用一个二叉树结构来描述比较结构。这棵树类似与归并排序中子文件大小的树。在二分搜索中,我们用一条路径来通过树,在归并排序中,使用所有的路径来通过树。这棵树为静态和隐式的。在BST中,我们将看到一个使用动态、显示二叉树结构的算法来引导搜索。
12.1.2.6二分搜索的改进(只在数组元素为类整数元素时有用):
我们可以对二分搜索进行一个改进,让二分搜索可以更准确地猜测搜索键在当前范围内位于何处(而不是在每一步盲目地与中间元素进行测试比较)。这种策略模拟我们在电话薄中查找名字或者在字典中查找单词的方式。如果查找目标的开头字母接近于字母表的开头,则在电话薄或者字典的起始附近开始查找,但是如果开头字母接近于字母表的末尾,则从电话薄或者字典的结尾附近开始查找。此方法被称做插值搜索(interpolation search)。为了实现插值搜索,我们修改程序中的代码如下:
m = (r + r) /2
替换成
m = l + (v – key(a[l])) * (r – l) / (key(a[r]) – key(a[l]))
这种计算是以键值为数字并均匀分布的假设为基础的。
注意:插值搜索极大依赖于以下前提:键在区间之中分布均匀。分布不均匀的键可能导致性能极低,这种情况在世界情况中常常出现。而且,它也需要额外的计算。对于小N,直接二分搜索的开销lgN与插值搜索的开销lglgN已相当接近,所以不值得采用插值搜索。另一方面,对于庞大文件,对于比较开销特别大的应用,以及对于设计到高访问开销的外部方法必须要考虑插值搜索。
12.1.3 二叉搜索树(BST)
要克服插入开销昂贵的问题,我们将使用一个显示树结构作为符号表实现的基础。通过相应的数据结构,我们可以开发出搜索、插入、选择和排序符号表运算具有一般情况下快速执行的性能。它是许多应用的首选方案,也是计算机科学中最基本的算法之一。
二叉搜索树:二叉搜索树(BST)是指这样一颗二叉树;它有一个与其每个内部节点关联的键,还具有一个额外的性质就是,任何节点中的键大于(或等于)该节点左子树种所有节点的键,并小于(或等于)该节点右子树中所有节点的键。
12.1 BST的相关操作
12.2.1 操作接口函数定义
#ifndef TEXTBST_H
#define TEXTBST_H
#include "item.h"
#define maxN 10000
typedef struct STnode *link;
struct STnode {Item item; link l, r; int N;}; //项(节点)结构体
static link head, z;//BST头指针和空链接
link NEW (Item item, link l, link r, int N);//生成一个新节点
void STinit();//初始化BST
int STcount();//节点计数
Item STsearch (Key v);// 查找具有已知键的项
void STinsert(Item item);// 插入已知项
link rotR (link h); //节点右旋操作
link rotL (link h);//节点左旋操作
Item STselect (int k);//选择第k小的元素
link STpart(int k);//将BST按指定第k小元素进行划分
link joinLR (link a, link b); //在删除操作中合并删除节点的两个子树
void STdelete (Key v); //删除具有指定项的节点
link STjoin (link a, link b); //合并两个BST
#endif
12.2.2 相关函数实现如下:
#include <stdlib.h>
#include <stdio.h>
link NEW (Item item, link l, link r, int N)
{
link x = (link)malloc(sizeof (*x));
x -> item = item;
x -> l = l;
x -> r = r;
x -> N = N;
return x;
}
void STinit()
{
head = (z = NEW (NULLitem, 0, 0 , 0));
}
int STcount()
{
return head -> N;
}
Item searchR (link h, Key v)
{
if (h == z) return NULLitem;
Key t = key (h -> item);
if (eq (v, t)) return h -> item;
if (less (v, t)) return searchR(h -> l, v);
else return searchR(h -> r, v);
}
Item STsearch (Key v)
{
return searchR(head, v);
}
link insertR(link h, Item item)
{
if (h == z) return NEW (item, z,z,1);
Key v = key (item);
Key t = key (h -> item);
if (less (v, t))
{
h -> l = insertR (h -> l, item);
}//printf ("left\n");
else {
h -> r = insertR (h -> r, item);
}//printf ("right\n");
(h -> N)++;
// printf ("h -> N ==%d\n", h -> N);
return h;
}
link insertT(link h, Item item)
{
if (h == z) return NEW (item, z,z,1);
Key v = key (item);
Key t = key (h -> item);
if (less (v, t))
{
h -> l = insertR (h -> l, item);
h = rotR(h);
}//printf ("left\n");
else {
h -> r = insertR (h -> r, item);
h = rotL(h);
}//printf ("right\n");
(h -> N)++;
// printf ("h -> N ==%d\n", h -> N);
return h;
}
void STinsert(Item item)
{
head = insertR (head, item);
}
int STHeight()
{
return HeightR (head);
}
int HeightR(link h)
{
if (h == z) return -1;
int templeft = HeightR(h -> l);
if (templeft != -1) printf ("Height left !\n");
int tempright = HeightR(h -> r);
printf ("Height right = %d !\n", tempright);
return ((tempright > templeft ? tempright : templeft) + 1);
}
/*
*旋转操作在更新节点计数值时,注意先更新旋转后的子树计数值再更新根节点的计数值。
*/
link rotR (link h)
{
link x = h -> l;
h -> l = x -> r;
x -> r = h;
h -> N = h -> l ->N + h -> r -> N + 1;
x -> N = x -> l ->N + x -> r -> N + 1;
return x;
}
link rotL (link h)
{
link x = h -> r;
h -> r = x -> l;
x -> l = h;
h -> N = h -> l ->N + h -> r -> N + 1;
x -> N = x -> l ->N + x -> r -> N + 1;
return x;
}
/*选择第k大的元素
*先判断左孩子的计数值根k的关系,再决定下一步在往左孩子分支前进还是右孩子分支前进还是直接返回当前节点的值。
*/
Item selectR (link h, int k)
{
if (h == z) return NULLitem;
int t = h -> l -> N;//
if (t > k) return selectR (h -> l, k);
if (t < k) return selectR (h -> r, k - t - 1);
return h -> item;
}
Item STselect (int k)
{
return selectR (head, k - 1);
}
//使用划分法选择第k大的元素
link partR (link h, int k)
{
if (h == z) return z;
int t = h -> l -> N;
if (t > k)
{
h -> l = partR (h -> l, k);
h = rotR (h);
}
if (t < k)
{
h -> r = partR (h -> r, k - t -1);
h = rotL (h);
}
return h;
}
link STpart(int k)
{
head = partR (head, k - 1);
return head;
}
void printlink (link h)
{
if (h == z) return;
printlink (h -> l);
printf ("%d | ", h -> N);
printlink (h -> r);
}
void STprintlink ()
{
printlink (head);
printf ("\n");
}
link joinLR (link a, link b)
{
if (b == z) return a;
b = partR (b, 0);
b -> l = a;
b -> N += a -> N;
return b;
}
link deleteR (link h, Key v)
{
if (h == z) return z;
link x;
Key t = key (h -> item);
if (less (v, t)) h -> l = deleteR (h -> l, v);
if (less (t, v)) h -> r = deleteR (h -> r, v);
if (eq(v, t))
{
x = h;
h = joinLR(h -> l, h -> r);
free (x);
x = NULL;
}
return h;
}
void STdelete(Key v)
{
head = deleteR (head, v);
}
link STjoin (link a, link b)
{
if (b == z) return a;
if (a == z) return b;
b = insertR (b, a -> item);
b -> l = STjoin (a -> l, b -> l);
b -> r = STjoin (a -> r, b -> r);
free (a);
return b;
}
12.3 BST的排序操作
只需要将BST进行中序遍历就可以得到有序的输出。
void sortR(link h, void (*visit)(Item))
{
if (h == z) return;
sorR(h ->l, visit);
visit(h->item);
sortR(h->r, visit);
}
void STsort(void (*visit)(Item))
{
sortR(head,visit);
}
基于标准BST(不使用根插法建立树)的排序为稳定排序。
12.4 BST性能分析
二叉搜索树算法的运行时间取决于树的形状。最佳情况下,树可能完全平衡,在根与每个外部节点间约有lgN个节点,但是在最坏情况下,在搜索路径上可能有N个节点。比如插入序列为一个有序序列。
二叉排序树能有效的支持选择和排序的能力是其在许多应用中出类拔萃的一个原因。
尽管BST具有实用性,但在应用中使用BST有两个主要的缺陷。
第一个缺陷是,它们需要相当大的空间来维护链接。通常将链接和记录看作大小相当。如果这样,则BST实现将所分配的内存的三分之二用于链接,只剩三分之一用于键。在记录庞大的应用中,这种影响不太重要,在指针庞大的环境中,这种影响更重要。如果内存非常珍贵,我们可能宁愿选择开发寻址哈希方法,而不是BST。
第二个缺陷是,生成的树显然可能变得很不平衡,并导致底下的性能。如同快速排序算法一样,如果算法使用者在实际应用中稍不小心,标准BST算法也会就会出现糟糕的最坏情况。已排序文件、具有大量重复键的文件、逆序文件、大小键交替的文件,或者其中庞大片段具有简单结构的文件,这些文件都能导致二次性BST构建次数以及线性搜索次数。诸如此类的情况可以使用比如红黑树来等平衡树来解决。
以下是各种符号表实现方式的性能比较对照表:
| 最坏情况 | 一般情况 | ||||
插入 | 搜索 | 选择 | 插入 | 搜索命中 | 搜索失败 | |
键索引数组 | 1 | 1 | M | 1 | 1 | 1 |
有序数组 | N | N | 1 | N/2 | N/2 | N/2 |
有序链表 | N | N | N | N/2 | N/2 | N/2 |
无序数组 | 1 | N | NlgN | 1 | N/2 | N |
无序链表 | 1 | N | NlgN | 1 | N/2 | N |
二分搜索 | N | lgN | 1 | N/2 | lgN | lgN |
二叉搜索树 | N | N | N | lgN | lgN | lgN |
红黑树 | lgN | lgN | lgN | lgN | lgN | lgN |
随机数 | N* | N* | N* | lgN | lgN | lgN |
哈希 | 1 | N* | NlgN | 1 | 1 | 1 |