一些搜索方法的处理办法是一次一个小片段地检查搜索键,而不是每一步在键之间进行全面比较。这些方法称做基数搜索方法(radix-search mothod),它们的运算方式与基数排序方式完全类似。当搜索键片段易于访问时,这些方法非常有效,而且它们可以为各种实际搜索任务提供高效的解决方案。
根据上下文的不同,键可能为字(定长字节序列)或者是串(变长字节序列)。我们将字键看作为以R为基数的数值系统中表示的数,R值(即基数)可以不同,再处理数值的单个位。可以将C串看作以特殊符号终止的变长数值。因此,对于定长或变长键,我们可以让所有的算法以抽象运算“从键中提取第i个位”为基础,还包括一条处理键少于i个位的情况的约定。
基数搜索方法的主要优点是,它们提供了最坏情况的合理性能,而不必使用复杂的平衡树;它们提供了处理变长键的简单方式;某些方法可以在搜索结构中排序部分键以节省空间;它们还提供了数据的快速访问,其速度可以与二叉搜索树与哈希方法匹敌。缺点是,某些方法低效使用空间,而且像基数排序一样,如果不能有效地访问键的字节,性能可能会下降。
15.1 位搜索树
最简单的基数搜索方法是基于位搜索树(digital search tree, DST)的运用。搜索和插入算法等同于二叉搜索树,唯一的区别是:不是根据完全键间的比较结果,而是根据选定的键位让树分支。在第一层,使用前导位;在第二层,使用第二前导位;依次类推,直到碰到一个外部节点。
15.1.1 位搜索树的操作
typedef struct Node *link;
//DST
struct Node{
Item item;
link l;
link r;
};
void Init()
{
head = z = NEW(0,0,0);
}
link NEW(Item v, link l, link r)
{
link x = (link)malloc(sizeof(*x));
x->l = l;
x->r = r;
x->item = v;
return x;
}
15.1.1.1 位搜索树的插入操作
/*
位搜索树的插入操作根据对应位为0还是为1来决定新插入项为左孩子还是右孩子
*/
link Insert(link h, Item v, int w)
{
if (z == h) return NEW (v,z,z);
Key tempkey = key(h->item);
if (digit (v, w) == 0)
{
h->l = Insert(h->l, v, w + 1);
} else {
h->r = Insert(h->r, v, w + 1);
}
return h;
}
void RSInsert(Item v)
{
head = Insert (head, v, 0);
}
15.1.1.2 位搜索树的查找操作
/*
在此程序中,不是进行完全键比较,而是在键的单个位(前导位)测试的基础上决定是否从左移到右。这个递归调用函数有第三个参数,当沿树下移时,我们可以将被测试的位移到右边。使用digit运算来测试位。
*/
Item searchR(link h, Item v, int w)
{
Key t = key(h->item);
if (h == z) return NULLitem;
if (eq(v, t)) return h->item;
if (digit(v, w) == 0)
{
return searchR(h->l, v, w + 1);
} else {
return searchR(h->r, v, w + 1);
}
}
Item RSsearchR(Item v)
{
return searchR (head, v, 0);
}
15.1.1.3 位搜索树的性能分析
l 请注意,DST没有BST所特有的有序性。也就是说,不是必须让已知节点左边的节点具有较小的键,或右边的节点具有较大的键,而在具有不同键的BST中要求如此。已知节点左边的键小于右边的键(如果节点在k层,则它们都与第k个位一致,但左边的下一位为0,右边键的下一个位为1),不过,节点的键本身可能在该节点子树的所有键中为最小、最大或任意值。
l DST具有以下特征:每个键在沿着键的位所指定路径(按从左到右顺序)的某个地方。
l 如果键数庞大而且键长相对于键数较小时,位搜索生成树的最坏情况比二叉搜索树要好得多。在一棵位搜索树中,最长路径的长度可能在许多应用中相当短(例如,如果键包含随机位的时候)。特别是,最长路径要受到最长键的限制;而且,如果键为定长,则搜索时间受长度的限制。
l DST在许多实际应用中时理想的方法,因为它们即使对于大型问题,也能通过很少的代码实现近最优的性能。例如,一棵根据32位键(或4个8位字符)生成的位搜索树能确保只需要32次以下的比较,一棵根据64位键(或8个8位字符)生成的位搜索树能确保需要64次以下的比较,即使多达几十亿个键也是如此。对于大N值,这些保障与红黑树提供的保障相当,但所需的实现努力却与标准BST相当(标准BST只能保证N2成比例的性能)。这个特征使得在实现搜索和插入符号表函数的实践中,在可以有效访问键位的情况下,位搜索树成为替换使用平衡树的理想之选。
15.2 Trie
Trie是一棵使用键位引导搜索的搜索树,其方式与DST相同,但让树中的键有序。因此,可以支持排序和其他符号表函数的递归实现,就像以前对待BST一样。
其思想是仅在树底部、在叶节点中保存键。得到的数据结构中有很多有用的性质,被用作许多有效搜索算法的基础。
15.2.1 Trie的定义
trie是一棵具有与它的叶关联的键的二叉树,可以递归定义如下:空键集的trie为一个空链接;单键的trie为一个包含该键的叶;多键集的trie为一个内部节点,其左链接指向初始位为0的键的trie,右链接指向初始位为1的键的trie,为了构建子树,将前导位删除。
trie中的每个键保存在叶中,位于键的前导位模式所描述的路径上。在trie中每个叶包含唯一的键,该键由从根起直至该片叶子的路径所定义。不为叶的节点中的空链接对应于没有在trie的任何键中出现的前导位模式中。
15.2.2 Trie的操作
typedef struct NodeTrie *tlink;
struct NodeTrie{
Item item;
tlink l;
tlink r;
int N;
};
tlink TNEW(Item v, tlink l, tlink r,int n)
{
tlink x = (tlink)malloc (sizeof (*x));
x->item = v;
x->l = l;
x->r = l;
x->N = n;
return x;
}
void tInit()
{
thead = tz = TNEW(0,0,0,0);
}
#define leaf(A) ((A->l == tz) && (A->r == tz))
15.2.2.1 Trie的插入运算
在一棵trie中插入新节点,像往常一样搜索,然后区分两种可能导致搜索失败的情况。如果失败不是在叶上,则照常用新节点的链接替换导致我们发现失败的空链接。如果失败发生在叶子上,使用函数split产生一个内部节点,该内部节点的每一位都与搜索和已知键相同。即最终在两种不同位的最左边处产生该内部节点。split中的switch语句将被测试的两个位转换成一个数,来处理4种可能的情况。如果相同(002 = 0 或112 = 3的情况),则继续分解;如果位不同(012 = 1 或112 = 3的情况),则停止分解。
tlink tsplit (tlink p, tlink q, int w)
{
tlink t = TNEW(NULLitem, tz,tz,2);
switch (digit( ) * 2 + digit(q->item,w))
{
case 0: t->l = tsplit (p, q, w + 1);break;
case 1: t->l = p; t->r = q; break;
case 2: t->r = p; t->l = q; break;
case 3: t->r = tsplit(p, q, w + 1); break;
}
return t;
}
tlink tinsertR(tlink h, Item item, int w)
{
Key v = key(item);
Key t = key(h->item);
if (tz == h) return TNEW (item, tz, tz, 1);
if (leaf(h))
{
if (eq(v,t))
{
return h;
} else {
return tsplit(TNEW(item, tz, tz, 1), h, w);
}
}
if (digit(v, w) == 0)
h->l = tinsertR(h->l, item, w + 1);
else h->r = tinsertR(h->r, item, w + 1);
return h;
}
void TinsertR(Item item)
{
thead = tinsertR(thead, item, 0);
}
15.2.2.2 Trie的搜索运算
这个函数使用键的位来控制沿树向下推进的分枝,其方式与DST一样。有三种可能结果:如果搜索到达一个叶(具有两个空链接),于是它是trie中唯一包含记录键v的节点,因此,测试该节点确实包含v(搜索命中),还是包含其前导位匹配v的键(搜索失败)。如果搜索到达一个空链接,则父亲的其他链接必定不为空,因此,在trie中存在其他键与搜索键的对于位不同,即搜索失败。这段代码假定这些键不同,而且(如果键可能为不同的长度)不存在一个键位其他键前缀的情况。item域在非叶节点中没有使用。
Item tsearchR(tlink h, Key v, int w)
{
if (tz == h) return NULLitem;
Key key = key(h->item);
if (leaf(h))
{
if (eq(v, key)) return h->item;
else return NULLitem;
}
if (digit(v, w) == 0)
{
return tsearchR(h->l, v, w + 1);
} else {
return tsearchR(h->r, v, w + 1);
}
}
Item Tsearch(Key v)
{
return tsearchR(thead, v, 0);
}
15.2.2.3 Trie 的排序运算
void tSort(tlink h)
{
if (tz == h) return ;
Key key = key(h->item);
if (leaf(h))
{
printf("%c", h->item);
}
tSort(h->l);
tSort(h->r);
}
void TSort()
{
tSort(thead);
}
15.2.2.4 Trie的性质
l 在一棵根据N个随机(不同)位串生成的trie中,对随机键的插入或者搜索平均大约需要lgN个位的比较。位比较的最坏情况仅受搜索键中位数的限制;
l 用相同的键集来生成,Trie的节点比BST或DST多44%,但却平衡良好,其搜索开销接近最优。但是会浪费很多空间;
l Trie的结构独立于键插入顺序:任何已知的不同键集存在唯一的trie;
l Trie的缺陷:
1. 一路分枝法导致在trie中创建额外的节点,这似乎是不必要的;
2. 在trie中有两种不同类型的节点,这在一定程度上将代码复杂化了。
15.3 Patricia Tries
15.3.1 p-Trie的定义
从标准trie数据结构开始,我们通过一种简单的设计来避免一路分枝:将被测试位的索引放到每个节点,以决定哪一条路径取出该节点。于是,直接跳到作出这个重要决策的位,而略过子树中所有键具有相同位值得节点处的位比较。而且,通过另一种简单设计来避免外部节点:在外部节点中保存数据,并用向上回指trie中正确内部节点的链接替换指向外部节点的链接。这两种改变可以让我们用二叉树表达trie,此二叉树包含的节点具有一个键与两个链接(还有一个用于索引的额外域),我们称之为p-trie。
typedef struct NodepTrie *ptlink;
struct NodepTrie{
Item item;
ptlink l;
ptlink r;
int bit;
};
ptlink PNEW(Item v, ptlink l, ptlink r, int w)
{
ptlink x = (ptlink)malloc(sizeof (*x));
x->item = v;
x->l = l;
x->r = r;
x->bit = w;
return x;
}
void PTinit()
{
pthead = PNEW(NULLitem, 0, 0, -1);
pthead->l = pthead;
pthead->r = pthead;
}
15.3.2 p-Tries的插入运算
要在一棵p-trie中插入键,先进行搜索。函数searchR让我们找到树中的唯一键,它必须与插入键不同。判断区分这个键与搜索键的最左边位的位置,然后使用递归函数tstinsertT来向下遍历树,并在包含v的地方插入新节点。
在tstinsertR中,有两种情况。新节点可以替换内部链接(如果搜索键在跳过的位发现的键不同),或者替换外部链接(如果区分搜索键与发现键的位在区分发现键与trie中的所有其他键中不需要)。
ptlink ptinsertR(ptlink h, Item item, int w, ptlink p)
{
ptlink x;
Key v = key(item);
if ((h->bit >= w) || (h->bit <= p->bit))//
{
x = PNEW(item, 0, 0, w);
x->l = digit(v, x->bit) ? h : x;
x->r = digit(v, x->bit) ? x : h;
return x;
}
if (digit(v, h->bit) == 0)
h->l = ptinsertR(h->l, item, w, h);
else h->r = ptinsertR(h->r, item, w, h);
return h;
}
void PTinsert(Item item)
{
int i;
Key v = key(item);
Key t = key(ptsearchR(pthead->l, v, -1));
if (v == t) return;
for (i = 0; digit(v, i) == digit(t, i); i++);
// printf("i == %d\n", i);
pthead->l = ptinsertR(pthead->l, item, i, pthead);
}
15.3.3 p-Tries的搜索运算
递归函数ptsearchR返回可能包含具有键v的记录的唯一节点。它向下遍历trie,使用树的位来控制搜索,但每碰到的节点只测试1个位---即bit域中指示的位。当它碰到一个外部链接(在树中上指)时,终止搜索。搜索函数PTsearch调用ptsearchR,然后测试该节点中的键,判断搜索是命中还是失败。
Item ptsearchR(ptlink h, Key v, int w)
{
if (h->bit <= w) return h->item;
if (digit(v, h->bit) == 0)
return ptsearchR(h->l, v, h->bit);
else return ptsearchR(h->r, v, h->bit);
}
Item PTsearch(Key v)
{
Item t = ptsearchR(pthead->l, v, -1);
return eq(v, key(t)) ? t : NULLitem;
}
15.3.4 p-Tries的排序运算
/*这个递归过程按键序访问p-trie中的所有记录。我们想象这些项在(虚拟的)外部节点中,当当前节点的命中索引不大于它的父亲节点的索引时,可以通过测试来确定它们。否则这个程序是一个标准无序遍历。
树中每个节点必定有一个满足h->bit <= w的链接指向它,否则不能进行排序操作。
*/
void sortR(ptlink h, void (*visit)(Item), int w)
{
if (h->bit <= w) {visit(h->item); return;}
sortR(h->l, visit, h->bit);
sortR(h->r, visit, h->bit);
}
void STsort(void (*visit)(Item))
{
sortR(pthead->l, visit, -1);
}
15.3.5 p-Trie的性质
patricia是基数搜索方法的精髓:它设法确定那些区分搜索键的位,并用它们生成一个数据结构(带有多余的节点),可以快速根据任何搜索键得到数据结构中可能等于搜索键的唯一键。所生成的树不仅比标准trie的节点少44%,而且几乎完全平衡。
当键为整数时,DST、标准二叉trie和patricia trie性能相当(而且,它们提供的搜索时间与平衡树方法相当或更短)。
从N个随机位串构建一棵patricia trie,插入或搜索某个随机键平均约需要lgN次位比较,在最坏情况下需要2lgN次位比较。位比较树永远不会多于键长。该性质给出的搜索开销不随键长的增长而增大。相比之下,在标准trie中的搜索开销一般情况下要依赖于键长—区分两个已知键的位位置可能在键中的任意远处。我们介绍过的所有基于比较的搜索方法也依赖于键长—如果键仅在最右边位不同,则比较它们需要的时间与其长度成比例。而且,哈希方法主公的一次搜索总是需要与键长成比例的诗句来计算哈希函数。但patricia可以立即将我们带到目标位,一般情况下只对其中不到lgN个位进行测试。当搜索键较长时,这种效果使得patricia成为首选的搜索方法。
15.4 多路trie和TST
15.4.1 多路trie
对于基数搜索,通过一次检查r个位,可以把搜索速度提高r倍。不过,与基数排序相比,存在一个难题,使得我们在应用此思想时必须更加小心。该问题就是:一次考虑r个位对应于使用具有R = 2r个链接的树节点,未用链接可能会浪费大量空间。
二叉trie中,对应于键位的节点有两个链接:一个针对键位为0的情况,另一个针对键位为1的情况。适当推广得到R叉trie,其中包含R个对应于键位的节点,一个链接对应于一个可能的键值。键保存在叶中(具有所有空链接的节点)。要在R路trie中搜索,从根和最左边键位开始,并使用键位来引导我们在树中向下行进。如果位值为i,则行进到第i个链接(并移到下一个位)。如果到达一个叶,它包含trie中前导位对应于已经遍历的路径的唯一键,因此,可以比较该键与搜索键来判断是搜索失败,还是搜索命中。如果达到一个空链接,则知道是搜索失败,因为该链接对应于一个在trie中的任何键中都不会发现的前导位模式。
鉴于多路trie的这些缺陷,我们引出另一种trie的表达方式,即三叉搜索trie(ternary search trie ,TST)。
15.4.2 三叉搜索(TST)
在一棵TST中,每个节点有1个字符以及3个链接,对应于当前位小于、等于或大于节点字符的键。
一棵完全TST中的搜索或插入运算需要的时间与键长成比例。TST中的链接数至多为所有键中字符数的3倍。
typedef struct TSTnode * tstlink;
struct TSTnode {Item item; int d; tstlink l, m, r;};
static tstlink tsthead;
void TSTinit()
{
tsthead = NULL;
}
tstlink TSTNEW(int d)
{
tstlink x = (tstlink)malloc (sizeof (*x));
x->d = d;
x->l = NULL;
x->m = NULL;
x->r = NULL;
return x;
}
15.4.2.1 TST的插入运算
这段代码实现了与多路trie相同的抽象trie算法,但每个节点包含一个位和三个链接:每个链接分别对应下一个位小于、等于或大于搜索键中对应位的键。
tstlink tstinsertR(tstlink h, TSTItem item, int w)
{
TSTKey v= key(item);
int i = tstdigit(v, w);
if (NULL == h) h = TSTNEW(i);
if (NULLdigit == i) return h;
if (i < h->d) h->l = tstinsertR(h->l, v, w);
if (i == h->d) h->m = tstinsertR(h->m, v, w + 1);
if (i > h->d) h->r = tstinsertR(h->r, v, w);
return h;
}
void TSTinsert(TSTKey key)
{
tsthead = tstinsertR(tsthead, key, 0);
}
15.4.2.2 TST的搜索运算
TSTItem tstsearchR(tstlink h, TSTKey v, int w)
{
int i = tstdigit(v, w);
if (NULL == h) return NULLitem;
if ('#' == i) return v;
if (i < h->d) return tstsearchR (h->l, v, w);
if (i == h->d) return tstsearchR (h->m, v, w + 1);
if (i > h->d) return tstsearchR(h->r, v, w);
}
TSTItem TSTsearchR(TSTKey v)
{
return tstsearchR(tsthead, v, 0);
}
15.4.2.3 TST的性质
使用TST的优点是,它们非常适合于搜索键中的不规则性,这种情况在实际应用中可能出现。它的主要作用如下:
第一, 实际应用中的键来自庞大的字符集,字符集中特殊字符的使用很不一致---例如,一个特定串集可能只使用少部分字符。通过TST,我们可以使用一个128位或256位字符编码,而不必担心具有128路或256路分枝带来的过多开销,也不必决定哪一个字符集有关。
第二, 搜索失败可能极其高效,即使键较长时也是如此。算法通常使用少数字节比较(跟踪少数指针)来完成一次失败搜索。
第三, 与我们讲过的符号表运算相比,它支持更一般的运算。例如,它允许搜索键中的特殊字符未定义,并打印与搜索键中的特定位匹配的数据结构中的所有键
TST与patrica也有几个相同的优点;与patricia trie相比,TST的主要实际优点是,它访问的是键字节也不是键位。这个区别使之成为优点的原因之一是,在许多机器中存在此用途的机器操作,而且C提供了直接访问字符串中字节的功能。另一个原因是,在某些应用中,处理数据结构中的字节自然反映了数据本身在应用中的字节方向性。例如,在前一段中讨论的部分匹配搜索问题就是如此。
15.5 文本串索引算法
对于典型的文本,标准BST是我们第一个要旋转的实现,因为它易于实现。对于一般应用,这种解决方案可以提供优良的性能。键的相互依赖的一个副产品是(尤其是为每个字符位置构建一个串索引时),BST的最坏情况与庞大文本没有特殊关系,因为不平衡BST只在奇异构造中出现。
patricia 最先是为串索引应用而设计。我们只需要提供一个称做bit的实现,即给定串指针和整数i后,返回串的第i个位。在实际中,实现文本串索引的patricia trie高度为对数关系。而且,patricia trie 将提供快速的搜索失败实现,因为我们不需要检查键中的所有字节。
TST具有patricia的若干性能优点,如易于实现,而且利用现代机器上一般可以找到的内置字节访问运算。可以实现比完全匹配搜索键更复杂的搜索问题。
尽管上述所有优点,但当我们在典型文本串索引应用中考虑使用BST、patricia trie或TST时,忽略了一个重要的事实;文本本身通常是固定的,所以,我们不需要支持以前所习惯具备的动态插入运算。适合于处理这种情况的基本算法是使用带有串指针的二分搜索。