字符串之全文索引
字符串,我现在正在写的就是一个字符串。我们的源代码就是一个字符串,计算机科学里面,一大部分问题都是字符串处理的问题。比如,编译器,就是一个字符串处理程序。还有,搜索引擎,也在处理一个字符串问题。数据库,最难处理的还是字符串部分。索引,一般是一种预处理的中间程序。在我们写代码的时候,往往需要对一个对象进行预处理。这个预处理时间可能比较长,但是,处理完了以后,就能很快的多次的在上面进行查询。比如,你要在一组数里面进行查找,可能先要进行排序,这样速度就会快一些, 排序可以看做是建立索引的一个过程。
字符串的全文索引,怎么样才能非常的省空间,查找速度也还可以,我这里介绍一种数据结构,叫做后缀数组。
概念不多说,我就在例子中说明什么东西是后缀数组吧。
step 1. 所有的后缀:
string a = ‘aabbaa’;
找到所有的后缀:
0 aabbaa
1 abbaa
2 bbaa
3 baa
4 aa
5 a
step 2. 对所有的后缀进行排序:
0 (5)a
1 (4)aa
2 (0)aabbaa
3 (1)abbaa
4 (3)baa
5 (2)bbaa
小括号里面的就是原来的索引值。
排序后的这个数组就叫做后缀数组。
下面这个程序,我想让大家更加感性的认识一下后缀数组是什么东西:
#include <stdio.h>
#include <stdlib.h>
//5M
#define MAX_LEN 1024 * 1024 * 5
char str[MAX_LEN + 1], *suffix_array[MAX_LEN + 32];
int readstr();
int cmpnum, charcmpnum;
int pstrcmp(const void *a, const void *b);int main()
{int n, i;
n = readstr();printf("string = %s\n", str);
printf("All Suffix:\n");
for (i = 0; i < n; i++)
{suffix_array[i] = str + i;printf("%d %s\n", i, suffix_array[i]);
}qsort(suffix_array, n , sizeof(char *), pstrcmp);printf("Suffix Array:\n");
for (i = 0; i < n; i++)
{printf("%d (%d) %s\n", i, suffix_array[i] - str, suffix_array[i]);
}return 0;
}int readstr()
{int ch;
int n = 0;
while ((ch = getchar()) != EOF)
{str[n++] = (char)ch;
if (n >= MAX_LEN) {
break;
}}while (str[n-1] == '\r' || str[n-1] == '\n'){n--;}str[n] = 0;return n;
}int pstrcmp(const void *a, const void *b){unsigned char *p = *((unsigned char **)a), *q = *((unsigned char **)b);unsigned char c1 , c2;cmpnum++;do {
c1 = (unsigned char)*p++;c2 = (unsigned char)*q++;charcmpnum++;if (c1 == '\0') {return c1 - c2;
}} while (c1 == c2);
return c1 - c2;
}运行这个程序的方法是输入一个字符串,然后输入EOF 字符串
比如输入 aabbaa[回车][ctrl+z][回车] 的结果是
后缀数组就是将所有的字符串后缀进行排序,注意,代码空间复杂度,每一个后缀只是保存了一个指针,并没有复制整个字符串。
啰嗦了半天,到底这个东西怎么做全文索引呢?你看非常简单的代码,核心的代码就几行,你肯定觉得这个东西没有用。编程珠玑的 第15章 字符串 有关于这个话题的讨论,大家可以去看看。介绍算法不是我写这篇博客的目的,我想写一些超越算法了一些东西,这篇只是开一个头。
这个后缀数组的所有前缀就是所有的substring(子串)。用过数据库的人可能知道数据库里面的 like 查询 查前缀(like prefix% )要比查后缀(like %suffix )或者任意的查询(like %query%)快很多.原因就是数据库里面是按照字母顺序存储的,这样前缀查询可以二分查找,速度就快了。后缀数组的原理也是这样。一个查询问题转换为一个前缀查询的问题,但是转换的方法却是通过后缀, 很有老庄哲学的韵味。
下面的程序是读入一个很长的字符串,我测试的是一本23万个英语单词的字典。先随机从这本英文字典里面抽取了5000个单词,然后,把正本字典看做一个字符串,在这本字典里面进行查询,分别用系统自带的 strstr (kMP算法) 和 我们的全文索引(后缀数组进行查询) 看看性能会差多少:
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <windows.h>
//5M
#define MAX_LEN 1024 * 1024 * 5
//一个单词的最大长度
#define MAX_WORD 255
#define MAX_DICT 500000
//路径的最大长度
#ifndef MAX_PATH
#define MAX_PATH 256
#endif
#define TEST_NUM 5000
//函数列表
static int read_str();static int read_dict(const char * filepath);static int pstrcmp(const void *a, const void *b);static char * dirname(const char *path, int count);static int range_rand(int min, int max);static int prefixcmp(const void *a, const void *b);static void test_full_index();static void test_strstr();//全局变量
char str[MAX_LEN + 1]; //原始字符串char *suffix_array[MAX_LEN + 32]; //后缀数组char *dict[MAX_DICT]; //测试字典int cmpnum, charcmpnum; //以后可能用来测试性能的计数char *query[TEST_NUM]; //查询表达式int dictn, strn;
int main(int argc, char *argv[]){int i;
if (argc < 2) {
printf("usage: %s dict_path", argv[0]);
exit(0);}//读取字典,构建测试查询
printf("dict path is: %s\n", argv[1]);
dictn = read_dict(argv[1]);for (i = 0; i < TEST_NUM; i++)
{query[i] = dict[range_rand(0, dictn)];}//最好free掉dict的内存,对于这样简单的程序,没有多少必要,程序结束以后,自动释放。
//创建后缀数组, 从stdin读取
strn = read_str();for (i = 0; i < strn; i++)
{suffix_array[i] = str + i;}qsort(suffix_array, strn, sizeof(char *), pstrcmp);//测试通过后缀数组建立的索引进行全文查找
test_full_index();//测试通过普通子串查询, 这些算法一般用KMP算法。
test_strstr();return 0;
}static void test_full_index(){int i , t, nofind = 0, find = 0;
t = clock();for (i = 0; i < TEST_NUM; i++)
{char **index;
index = (char **)bsearch(query[i], suffix_array, strn, sizeof(char *), prefixcmp);if (index == NULL) {
nofind++;} else {
find++;}}printf("full index, find : %d , not find: %d, cost: %d ms\n", find, nofind, clock() - t);
}static void test_strstr(){int i , t, nofind = 0, find = 0;
t = clock();for (i = 0; i < TEST_NUM; i++)
{char *index;
index = strstr(str, query[i]);if (index == NULL) {
nofind++;} else {
find++;}}printf("strstr, find : %d , not find: %d, cost: %d ms\n", find, nofind, clock() - t);
}static int read_str(){int ch;
int n = 0;
while ((ch = getchar()) != EOF)
{str[n++] = (char)ch;
if (n >= MAX_LEN) {
break;
}}while (str[n-1] == '\r' || str[n-1] == '\n'){n--;}str[n] = 0;return n;
}static int read_dict(const char * filepath){char buffer[MAX_WORD];
FILE *fp = fopen(filepath, "r");
int n = 0;
if (fp == NULL) return 0;while (fscanf(fp, "%s", buffer) != EOF){int word_len = strlen(buffer) + 1;
if (n >= MAX_DICT) {
break;
}dict[n++] = (char *)malloc(word_len);
memcpy(dict[n-1], buffer, word_len);}return n;
}static int pstrcmp(const void *a, const void *b){unsigned char *p = *((unsigned char **)a), *q = *((unsigned char **)b);unsigned char c1 , c2;cmpnum++;do {
c1 = (unsigned char)*p++;c2 = (unsigned char)*q++;charcmpnum++;if (c1 == '\0') {return c1 - c2;
}} while (c1 == c2);
return c1 - c2;
}static int prefixcmp(const void *a, const void *b){unsigned char *p = (unsigned char *)a, *q = *(unsigned char **)b;unsigned char c1 , c2;do {
c1 = (unsigned char)*p++;c2 = (unsigned char)*q++;if (c1 == '\0') {return 0; //match}} while (c1 == c2);
return c1 - c2;
}static int range_rand(int min, int max){double r = 0;
int i;
double mul = 1;
for (i = 0; i < 3; i++)
{mul *= 0.0001;r += (rand() % 10000) * mul;}//0 - 1 中的一个随机数
return (int)(r * (max - min)) + min;}后面的一些小函数大概比较多,其实主要看main函数就可以了。
命令行运行:suffix_array dict.txt < dict.txt , 用了一个文件重定向到stdin,附件中有这本测试字典。
测试结果是:
可以发现,性能差的挺多了,有1000倍。如果字符串更加的长,差别会更加的大。
这篇博客还只是个引子,实际上,理论上来说,我们采用qsort的方法来排序后缀数组性能比较低。但是,实际用起来这几乎是最好的方法。这是一个典型的一行顶一万行的例子。后缀数组的应用也不仅仅是做全文索引这样一种功能,欲知详情,请关注下一篇博客:字符串之后缀数组倍增算法。