6.5 自引用结构
假定我们需要处理一个更一般化的问题:统计输入中所有单词的出现次数
因为预先不知道出现的单词列表,所以无法方便地排序,并使用折半查找
也不能分别对输入中的每个单词都执行一次线性查找,看它在前面是否已经出现,这样做,程序的执行将花费太长的时间
更准确地说,程序的执行时间是与输入单词数目的二次方成比例的
一种解决方法是,在读取输入中任意单词的同时,就将它放置到正确的位置,从而始终保证所有单词是按顺序排列的
虽然这可以不用通过在线性数组中移动单词来实现,但它仍然会导致程序执行的时间过长
我们可以使用一种称为二叉树的数据结构来取而代之
每个不同的单词在树中都是一个节点,每个节点包含:
- 一个指向该单词内容的指针
- 一个统计出现次数的计数值
- 一个指向左子树的指针
- 一个指向右子树的指针
任何节点最多拥有两个子树,也可能只有一个子树或一个都没有
对节点的所有操作要保证,任何节点的左子树只包含按字典序小于该节点中单词的那些单词
右子树只包含按字典序大于该节点中单词的那些单词
图 6-3 是按序插入句子 now is the time for all good men to come to the aid of their party
中各单词后生成的树
要查找一个新单词是否已经在树中,可以从根节点开始,比较新单词与该节点中的单词
若匹配,则得到肯定的答案,若新单词小于该节点中的单词,则在左子树中继续查找,否则在右子树中查找
如在搜寻方向上无子树,则说明新单词不在树中,并且,当前的空位置就是存放新加入单词的正确位置
因为从任意节点出发的查找都要按照同样的方式查找它的一个子树,所以该过程是递归的
相应地,在插入和打印操作中使用递归过程也是很自然的事情
节点最方便的表示方法是表示为包括 4 个成员的结构:
struct tnode { /* the tree node: */
char *word; /* points to the text */
int count; /* number of occurrences */
struct tnode *left; /* left child */
struct tnode *right; /* right child */
};
这种对节点的递归的声明方式看上去好像是不确定的,但它的确是正确的
一个包含其自身实例的结构是非法的,但是声明 struct tnode *left;
是合法的
它将 left
声明为指向 tnode
的指针,而不是 tnode
实例本身
如果结构的成员是结构自己,则编译时递归地分配成员空间会导致死循环,为结构分配的空间将无穷大
但如果结构的成员是指向自己的指针,则编译时分配的成员空间大小是固定的,即指针的大小,没有死循环问题
我们偶尔也会使用自引用结构的一种变体,两个结构相互引用:
struct t {
...
struct s *p; /* p points to an s */
};
struct s {
...
struct t *q; /* q points to a t */
};
如下所示,整个程序的代码非常短小
当然,它需要我们前面编写的一些程序的支持,比如 getword
等
主函数通过 getword
读入单词,并通过 addtree
函数将它们插入到树中
#include <stdio.h>
#include <ctype.h>
#include <string.h>
#define MAXWORD 100
struct tnode *addtree(struct tnode *, char *);
void treeprint(struct tnode *);
int getword(char *, int);
/* word frequency count */
main()
{
struct tnode *root;
char word[MAXWORD];
root = NULL;
while (getword(word, MAXWORD) != EOF)
if (isalpha(word[0]))
root = addtree(root, word);
treeprint(root);
return 0;
}
函数 addtree
是递归的
主函数 main
以参数的方式传递给该函数的一个单词将作为树的最顶层(即树的根)
在每一步中,新单词与节点中存储的单词进行比较,随后,通过递归调用 addtree
而转向左子树或右子树
该单词最终将与树中的某节点匹配(这种情况下计数值加 1),或遇到一个空指针(表明必须创建一个节点并加入到树中)
addtree
返回指向根节点的指针
struct tnode *talloc(void);
char *strdup(char *);
/* addtree: add a node with w, at or below p */
struct treenode *addtree(struct tnode *p, char *w)
{
int cond;
if (p == NULL) { /* a new word has arrived */
p = talloc(); /* make a new node */
p->word = strdup(w);
p->count = 1;
p->left = p->right = NULL;
} else if ((cond = strcmp(w, p->word)) == 0)
p->count++; /* repeated word */
else if (cond < 0) /* less than into left subtree */
p->left = addtree(p->left, w);
else /* greater than into right subtree */
p->right = addtree(p->right, w);
return p;
}
新节点的存储空间由子程序 talloc
获得
talloc
函数返回一个指针,指向能容纳一个树节点的空闲空间
函数 strdup
将新单词复制到某个隐藏位置(稍后将讨论这些子程序)
计数值将被初始化,两个子树被置为空(NULL
)
该程序忽略了对 strdup
和 talloc
返回值的出错检查(这显然是不完善的)
treeprint
函数按顺序打印树
在每个节点,它先打印左子树(小于该单词的所有单词),然后是该单词本身,最后是右子树(大于该单词的所有单词)
/* treeprint: in-order print of tree p */
void treeprint(struct tnode *p)
{
if (p != NULL) {
treeprint(p->left);
printf("%4d %s\n", p->count, p->word);
treeprint(p->right);
}
}
如果单词分布不随机,树将变得不平衡,这种情况下,程序的运行时间将大大增加
最坏的情况下,若单词已经排好序,则程序将模拟线性查找,开销将非常大
某些广义二叉树不受这种最坏情况的影响,在此我们不讨论
在结束该例子之前,我们简单讨论一下有关存储分配程序的问题
尽管存储分配程序需要为不同的对象分配存储空间,但显然,程序中只会有一个存储分配程序
但是,假定用一个分配程序来处理多种类型的请求
比如指向 char
类型的指针和指向 struct tnode
类型的指针,则会出现两个问题
第一,它如何在大多数实际机器上满足各种类型对象的对齐要求(例如,整型通常必须分配在偶数地址上)
第二,使用什么样的声明能处理分配程序必须能返回不同类型的指针的问题
对齐是指某种类型的数据的地址必须是这种类型数据大小的倍数
之所以要对齐,是因为 CPU 从内存加载数据到寄存器时,是以数据块为单位加载的,即一次加载一个数据块大小的字节数组
对齐可以保证一个没有超过数据块大小的数据不会被分配在两个数据块上
这样 CPU 在加载数据时,只需要一次加载动作加载一个数据块而不是两个
对齐要求一般比较容易满足,只需要确保分配程序始终返回满足所有对齐限制要求的指针就可以了,其代价是牺牲一些存储空间
第 5 章介绍的 alloc
函数不保证任何特定类型的对齐,所以,我们使用标准库函数 malloc
,它能够满足对齐要求
第 8 章将介绍实现 malloc
函数的一种方法
对于任何执行严格类型检查的语言来说,像 malloc
这样的函数的类型声明总是很令人头疼的问题
在 C 语言中,一种合适的方法是将 malloc
的返回值声明为一个指向 void
类型的指针,然后再显式地将该指针强制转换为所需类型
malloc
及相关函数声明在标准头文件 <stdlib.h>
中
因此,可以把 talloc
函数写成下列形式:
#include <stdlib.h>
/* talloc: make a tnode */
struct tnode *talloc(void)
{
return (struct tnode *) malloc(sizeof(struct tnode));
}
strdup
函数只是把通过其参数传入的字符串复制到某个安全的位置
它是通过调用 malloc
函数实现的:
char *strdup(char *s) /* make a duplicate of s */
{
char *p;
p = (char *) malloc(strlen(s) + 1); /* +1 for '\0' */
if (p != NULL)
strcpy(p, s);
return p;
}
在没有可用空间时,malloc
函数返回 NULL
,同时,strdup
函数也将返回 NULL
strdup
函数的调用者负责出错处理
调用 malloc
函数得到的存储空间可以通过调用 free
函数释放以重用,详细信息见第 7 章和第 8 章