实验——散列表(基于词频的文件相似度)详细过程
一、 实验目的
1. 掌握散列表相关内容
2. 掌握倒排索引表的应用
二、 实验内容和要求
1. 问题描述
实现一种简单原始的文件相似度计算,即以两文件的公共词汇占总词汇的比例来定义相似度。为简化问题,这里不考虑中文(因为分词太难了),只考虑长度不小于3、且不超过10 的英文单词,长度超过10的只考虑前10个字母。
2. 输入格式
输入首先给出正整数N(≤100),为文件总数。随后按以下格式给出每个文件的内容:首先给出文件正文,最后在一行中只给出一个字符'#',表示文件结束。在N个文件内容结束之后,给出查询总数M(≤10^4 ),随后M行,每行给出一对文件编号,其间以空格分隔。这里假设文件按给出的顺序从1到N编号。
3. 输出格式
针对每一条查询,在一行中输出两文件的相似度,即两文件的公共词汇量占两文件总词汇量的百分比,精确到小数点后1位。注意这里的一个“单词”只包括仅由英文字母组成的、长度不小于3、且不超过10的英文单词,长度超过10的只考虑前10个字母。单词间以任何非英文字母隔开。另外,大小写不同的同一单词被认为是相同的单词,例如“You”和“you”是同一个单词。
4. 输入样例
3 Aaa Bbb Ccc # Bbb Ccc Ddd # Aaa2 ccc Eee is at Ddd@Fff # 2 1 2 1 3
5. 输出样例
50.0% 33.3%
三、算法设计
1. 主流程设计
int main
{ 输入文件总数; 读入并存储单词,建立词汇索引表; 输入查询次数; 输入要查询的文件编号; 输出文件相似度; return 0; }
2. 散列表的创建
设计思路:存储单词以及对应所在的文件的索引,即倒排索引,通过构建针对字符串关键 字的散列表,来快速查找到某个单词。
具体实现:由于采用每个字母占5位的方法进行移位且输入总规模不超过2MB,按每个单词 最少占4 字节(3个字母+1个分隔符)计算,总词汇表中最多要存50万个单词,则可以建 立一个散列表,规模为500009(大于50万的最小素数),可保证插入不会失败,并初始化 散列表。
HashTable CreateTable(int tableSize) //建立散列表
{ HashTable H; H = (HashTable)malloc(sizeof(struct TableNode)); //H->tableSize = NextPrime(tableSize); //保证散列表最大长度是素数 H->tableSize = MAXTABLESIZE; H->heads = (HList)malloc(H->tableSize * sizeof(struct HashList));//分配链表头结点数组 for (int i = 0; i < H->tableSize; i++) //并初始化表头结点 { H->heads[i].data[0] = 0; H->heads[i].count = 0; H->heads[i].invIndex = NULL; } return H; }
3. 创建文件词汇索引表
设计思路:存储文件到单词的索引,可用文件表即为每个文件建立带头结点的索引链表, 存储每个文件的词汇量以及单词在散列表中的位置。
具体实现:创建头结点数组F[]并初始化,这样可以在在头结点中存储文件的词汇量,在链 表结点中存储词汇表中单词在散列表中的位置。
FList CreateFileIndex(int size)//初始化文件的词汇索引表 { FList F; F = (FList)malloc(sizeof(struct WordList) * size); for (int i = 0; i < size; i++) { F[i].word = 0; F[i].next = NULL; } return F; }
4. 读入文件中的每个单词
int Get(ElementType word) { 输入第一个字符(即用于跳过非字母'\n'); while (字符为非字母) { if (字符为文件结束符'#') 文件结束,结束读入单词; else 继续输入字符(直到输入字符为字母时跳出循环); } while (字符为字母) { if (小写字母) { if (单词中字母个数小于十)//长度超过10的只考虑前10个字母 将该字母存入数组中; } else if (大写字母) { if (单词中字母个数小于十)//长度超过10的只考虑前10个字母 转换成小写字母存入数组中; } 继续输入字符(直到输入字符为非字母时跳出循环); } if (单词中字母个数小于三) 该单词不符合要求,不能存入数组中,都下一个单词; else { 单词读入成功,将数组清零; 成功返回; } }
5. 查询单词
设计思路:用线性探测法处理冲突。
具体实现:先在文件词汇索引表中查询对应的文件名(取文件词汇量较少的文件名),找 到对应文件名中的词汇所在位置,根据此单词的位置到散列表中查找单词所在文件列表, 从而判断该单词是否是两文件的公共词汇。
int Find(HashTable H, ElementType key) { 初始散列位置; while (从该链表的第1个结点开始当未到表尾,并且key未找到时) { 线性探测下一个位置; if (位置没有被占用) 调整为合法地址; } return 此时指向找到的结点位置,或者为0; }
6. 自定义strcmp、strcpy函数
设计思路:根据库函数以及该题要求、需要用库函数的内容功能,对字符串单词进行比较 拷贝。
int mystrcmp(char a[], char b[]) //比较 { int i; for (i = 0; a[i] != '\0' && b[i] != '\0'; i++) { if (a[i] > b[i]) return 1; else if (a[i] < b[i]) return -1; } if (a[i] == '\0' && b[i] == '\0') return 0; else if (a[i] == '\0') return -1; else return 1; } void mystrcpy(char a[], char b[]) //拷贝 { int i = 0, j = 0; while ((a[i++] = b[j++]) != '\0'); }
7. ADT定义
#define MAXTABLESIZE 500009 //允许开辟的最大散列表长度 typedef enum { false, true } bool; typedef struct FileList* FList; struct FileList { int word; //存储文件词汇量 FList next; //单词在散列表中的位置 }; typedef struct WordList* WList; struct WordList { int count; WList next; }; typedef char ElementType[12]; //关键词类型用字符串 typedef struct HashList* HList; struct HashList { //链表结点定义 ElementType data; //存放单词 int count; //单词个数,为0时表示结点为空 WList invIndex; //倒排索引 }; typedef struct TableNode* HashTable; //散列表类型 struct TableNode { //散列表结点定义 int tableSize; //表的最大长度 HList heads; //指向链表头结点的数组 }; //自定义strcmp函数,用于比较两个单词(字符串)是否相同 int mystrcmp(char s1[], char s2[]); //自定义strcpy函数,拷贝单词(字符串) void mystrcpy(char a[], char b[]); bool IsLower(char ch); //是否为小写字母 bool IsUpper(char ch); //是否为大写字母 /*散列表初始化*/ HashTable CreateTable(int tableSize); /*建立文件的词汇索引表*/ FList CreateFileIndex(int size); /*读入文件的每个单词*/ int Get(ElementType word); /*字符串key移位法映射到整数的散列函数*/ int Hash(const char* key, int tableSize); /*返回适合key插入的位置*/ int Find(HashTable H, ElementType key); /*将key插入散列表,同时插入对应的倒排索引表*/ int InsertIndex(int count, HashTable H, ElementType key); /*将单词在散列表中的位置pos存入文件count对应的索引表*/ void FileIndex(FList file, int count, int pos); /*计算文件f1和f2的相似度*/ double Similiar(FList file, int f1, int f2, HashTable H);
8. 算法示例
文件编号 | 对应文件单词 | 对应存储单词 | 对应存储单词个数 |
1 | Aaa\Bbb\Ccc | aaa\bbb\ccc | 3 |
2 | Bbb\Ccc\Ddd | bbb\ccc\ddd | 3 |
3 | Aaa\ccc\Eee\Ddd\Fff | aaa\ccc\eee\ddd\fff | 5 |
文件单词 | 存储单词 | 对应文件编号【该单词在文件的位置】 | 对应出现在的文件个数 |
Aaa | aaa | 1[1], 3[1] | 2 |
Bbb | bbb | 1[2], 2[1] | 2 |
Ccc\ccc | ccc | 1[3], 2[2], 3[2] | 3 |
Ddd | ddd | 2[3], 3[4] | 2 |
Eee | eee | 3[5] | 1 |
Fff | fff | 3[6] | 1 |
四、算法分析
通过算法过程示例发现,时间复杂度与文件单词的读入和存储线性相关,O(n) = N^2,空间复 杂度为构建散列表和文词汇索引表空间的使用,O(n) = N。
五、总结
设计算法时,最初尝试先将一个文件的词频统计构建出来,再建立两个至多个文件的词频统计, 但因为一个到多个文件单词的读入存储出现困难、插入单词后查询复杂等因素,最终将自己的 思路弄乱了。通过实验指导书,采用倒排索引的方法重新构建散列表以及文件词汇索引表,思 路才渐渐清晰,但也出现了一些问题,例如读入文件问题,是采用一个一个文件通过读入字符 串即整个文件内容存储单词,还是采用对每个文件中一个一个的字符的读入存储,后来发现对 于一个文件的词频统计,直接读入文件整个字符串内容进行存储处理较为容易,但对于多个文 件的词频统计,依次读入文件的字符构建要求单词进行存储,为之后的插入索引创造便利,使 整个思路更加简便清晰。最后是构建散列表规模导致运行超时的问题,知道要保证散列表最大 长度是素数,但是具体的分析数值也是看了实验指导书得以解决。
六、源代码(主要)
int InsertIndex(int count, HashTable H, ElementType key) //将key插入散列表,同时插入对应的倒排索引表 { WList W; int pos = Find(H, key); //检查key是否已经存在 if (H->heads[pos].count != count) { //插入散列表 if (!H->heads[pos].count) mystrcpy(H->heads[pos].data, key); //如果单词没找到,插入新单词 H->heads[pos].count = count; //更新文件 W = malloc(sizeof(struct WordList)); //将文件编号插入倒排索引表 W->count = count; W->next = H->heads[pos].invIndex; H->heads[pos].invIndex = W; return pos; //插入成功 } else return 0; //重复单词,不插入 } void FileIndex(FList file, int count, int pos) { FList F; if (pos < 0) return ; //重复的单词不处理 F = malloc(sizeof(struct FileList)); F->word = pos; F->next = file[count - 1].next; file[count - 1].next = F; file[count - 1].word++; //头结点累计词汇量 } double Similiar(FList file, int f1, int f2, HashTable H) //计算文件f1和f2的相似度 { WList W; FList F; int i; if (file[f1 - 1].word > file[f2 - 1].word) //使f1的词汇量较小 { i = f1; f1 = f2; f2 = i; } i = 0; //相同词汇量 F = file[f1 - 1].next; while (F) { W = H->heads[F->word].invIndex; //找到当前单词在散列表中的位置 while (W) //遍历该单词的倒排索引表 { if (W->count == f2) //f2里也有该单词 break; W = W->next; } if (W) //表示f1、f2都有该单词 i++; F = F->next; } double d = (i / (double)(file[f1 - 1].word + file[f2 - 1].word - i)) * 100; return d; }
以上均为原创作品,欢迎大佬前来指正!