题意
给出两篇文章,分析这两篇文章的文本相似度.分析的时候只统计给出的一个字典中的,且不为停用词的单词.
最后先输出相似度,再输出第一篇文章出现频率最高的前n个单词(同样不统计停用词),频率一样则按字典序排列;再输出第二篇文章中出现频率最高的前n个单词.
写法分析
整体思路
官方给出的数据规模如下:
如此大的字典规模,Trie树就不用想了,肯定慢得飞起,只能选择哈希.快排或者归并O(nlogn)的速度也有些慢,考虑到单个单词出现的次数不会特别的高,改用桶排来计算答案.
在哈希完字典后依次插入两篇文章和停用词,最后遍历一遍整个字典,使用桶排序统计答案.最后计算并输出.
完整版代码贴在了最下面.
哈希部分
字典和文章的哈希过程大同小异,这里只贴出对字典的哈希.
for (now=d_buf;now<end;++now){
sum=0; d_pool[++tot]=now;
while (low(*now)) sum=sum*131u+*now++;
sum=sum&mod; *now++='\0';
Next[tot]=hsd[sum]; hsd[sum]=tot;
}
d_buf是一个字符数组,里面存的是整个字典.now是一个字符指针,end是计算好的字符数组的末尾指针.
通过now遍历整个字典,sum变量是unsigned int类型,用来计算哈希值.d_pool是一个指针数组,负责记录字典中每一个单词的开头在哪里.
哈希函数选择了经典的BKDR哈希,魔数选择了131.哈希表的大小设为了8388608(也就是2^{23}).实际测试结果显示,对于样例中给出的大小为418666的字典,一共会发生10353次碰撞,这个碰撞概率是完全可以接受的.
在哈希结束后,now一定会指向换行符,把这个位置改为\0,就可以通过d_pool数组来完成单个单词的输出了.最后单词的储存和链式前向星很像,就不做过多介绍了.
插入部分
这里摘取插入第一篇文章的部分:
for (now=art;now<end;++now){
while (!low(*now)) ++now;
sum=0; begin=now;
while (low(*now)) sum=sum*131u+*now++;
x=sum&mod; *now='\0';
poolD=hsd[x];
if (poolD && !Next[poolD] && *d_pool[poolD]==*begin && *(d_pool[poolD]+1)==*(begin+1))
Cnt[poolD]++;
else while (poolD!=0){
if (strcmp(begin,d_pool[poolD])==0) {
Cnt[poolD]++; break;
}
poolD=Next[poolD];
}
}
在读取了文章之后,会先进行一遍扫描,把大写字母转为小写字母.low负责检测字母是不是小写.
哈希过后,要依次和当前哈希值拉出来的那条链上所有的单词进行比对,如果匹配上,则计数器+1.匹配时直接使用自带的strcmp函数就行,测试表明手写的要比自带的慢很多.
在比对的时候有个技巧,如果当前哈希值只存了一个单词,且这个单词和文章中的单词的前两位能匹配上,则直接认为两个单词就是相等的.因为碰撞的单词并不多,如果都要来一遍strcmp的话还是很慢的,这里通过一个if可以减少很多不必要的函数调用.当然这个写法是针对数据试出来的.
停用词的插入则比较独特:
for (now=art;now<end;++now){
while (!low(*now)) ++now;
begin=now; x=stop[sum]; poolD=hsd[x];
for (;low(*now);now++); *now='\0'; sum++;
while (poolD!=0){
if (strcmp(begin,d_pool[poolD])==0) {
Cnt[poolD]=Sum[poolD]=-1; break;
}
poolD=Next[poolD];
}
}
可以看到和文章的插入很像,但是没有了计算哈希函数的过程.这是因为,测试数据中的停用词表和给出的停用词表是完全一样的,也就只有320个单词.为了节省计算的时间,提前在本地算好了这些单词的哈希值,直接用就行了.开始丧心病狂了.
排序部分
for (i=1;i<=tot;++i)
if (Cnt[i]>0){
x=Cnt[i];
if (!bu[x]) bu[x]=i;
net[tail[x]]=i; tail[x]=i;
}
这部分非常的简单,就是一个桶排的过程.也是使用了类似链式前向星的方式记录,按顺序访问字典就可以保证相同频率的单词按照字典序插入,之后就不需要对比了.
桶的大小经过测试,设为20000就可以满足需求了.实际最大出现次数应该比这个值还要小上一点,不过20000和10000,哪怕1000的差别并不大,就没有继续优化的必要了.
输入&输出
通过fread将整个文本一次性全部读进去.
FILE *f1=fopen("dictionary.txt","rb");
fseek(f1,0,SEEK_END);
int Size=ftell(f1);
rewind(f1);
fread(d_buf,1,Size,f1);
fclose(f1);
//Read the dictionary
输出部分使用了fwrite函数,也是将结果一次性全部输出出去.输出的处理和答案的计算是混合在一起的:
for (i=M;i;i--)
for (int j=bu[i];j;j=net[j]){
s1+=i; S[j]=-1; sum++;
begin=d_pool[j];
while (*begin!='\0') *now++=*begin++;
*now++=' '; x=i;
while (x) z[++l]=x%10,x/=10;
while (l) *now++=z[l--]+48;
*now++='\n';
if (sum>=n) goto END;
}
END:;
前半部分是在统计出现次数最多的那些单词情况,后面分两部分把输出信息放到字符数组里.先通过一个字符指针遍历要输出的单词,把它整个放进去,然后再对数字进行拆位,倒序转成字符放进去.看上去引入了很多运算和访问,但是本地模拟了400000规模的单词与数字的混合输出,fwrite的用时平均只占fprintf的3.16%.这个数据可能在不同的评测环境下有差异,但足够说明fwrite的高效了.
常数优化
这部分主要介绍下在写法部分没有提到的常数优化方法.
奇葩的主函数:
int main(){
unsigned size=128u<<22;
char *p=(char *)malloc(size)+size;
__asm__ __volatile__
(
"movq %0, %%rsp\n"
"pushq $exit\n"
"jmp main2\n"
:: "r"(p)
);
}
强行开栈,然后汇编调用main2函数.所有的计算过程全部都位于main2函数之中,所有的变量也都开在了里面.测试显示,全局变量的访问要比局部变量慢一些,开成局部变量要有一些优势.这里是向Kevin大佬学习的.当然main2里面就不能return了,需要写exit(0).整个程序就只有main函数和main2函数,不把哈希函数分出去是为了减少函数调用的时间.
main2里的变量定义:
reg ut sum,i;
const ut mod=8388607;
reg char *now,*begin,*end;
char d_buf[20000010];
char art[20000010];
ut stop[320]={97,7411521,7411637,5370655,8098984,3028091,1632928,4741869,1678873,2217970,4725675,4725677,3820036,1829583,6964010,7682195,12816,6973768,4966101,1291768,7715104,12817,1679127,1974215,1679148,5705872,5825850,1458130,5961455,4085931,1679652,3819685,12822,12823,3886803,12939,7041409,8205530,7281663,5985264,3997132,3955712,5637983,5375262,1642066,6561931,6613561,1203934,6720525,5555164,6408147,4025271,4129281,4240047,1697221,12959,6136074,1711756,3514851,6136344,13080,6997707,1713590,4981325,4617331,1713994,13201,5515047,3878317,13211,236066,237254,1731528,5509721,2242465,13334,1437907,7873949,2254581,2433329,1390570,2197689,1350044,1748556,2603114,2603118,5464259,1672871,1799079,4426880,8251801,1763772,622541,4628254,4629027,4629281,4629806,2526760,4630330,1765077,748269,6488338,7626834,7677510,4733178,4783870,5930225,4834959,6446620,1780930,6878421,13604,1797551,1797566,3133036,600616,13725,3649531,1798089,668736,6278504,575711,576617,7015746,668750,6278001,1798608,2910632,1798614,1799404,7569178,7242022,105,13856,13857,13865,1816414,2637861,3610290,3071548,13870,13871,1817216,8197559,7411317,1203994,2860966,6917779,7023113,1272637,1868684,3450105,3451435,1883377,14380,7331712,238427,3588448,3588703,3692193,1417069,3692339,5087624,3692717,3793197,3795305,14400,5427992,5699375,4207972,3611867,790332,4670930,5769475,5836794,14521,619115,5939760,6375065,1902365,1902367,6129961,1902370,369154,14643,1918335,3902738,14651,8169249,1919382,8170448,8171486,14655,1615652,1935327,149344,1920312,8291355,7734978,1920314,8306812,1920570,1807619,1935377,8014469,4992074,1937475,4343788,15035,162614,1986847,230218,8138669,882480,4992849,3676344,6716008,3279732,1987240,8287883,283021,298723,5749730,4324269,1987390,5923587,15176,402868,1273064,1393042,6325994,706317,252954,565187,5228288,504527,7373251,2410443,2004017,2529269,2529275,2004401,4246630,2529792,6632522,2529793,2744343,4247796,2158148,7825203,3919000,7826109,4102710,4247927,2529804,4314481,2530317,4316439,2530322,4419537,180083,4469186,4649869,928921,2531503,2531894,15307,5667666,2005328,2005329,7336299,4753972,7803517,7837597,2006376,15437,1838916,2114010,15439,4916482,15442,6976206,2038850,2054981,15690,834890,835669,884940,5931258,885458,3588720,1665248,6943719,2413165,997941,998078,998984,7498521,5259812,5546129,7010401,7011577,752085,2055894,6217252,7114543,886767,7115460,2055904,903534,904578,4552123,840944,6176929,2089828,2091139,5503867,7975012,2001603,6835853};
char* d_pool[N];
int Cnt[N],Sum[N],S[N],Next[N],hsd[N],bu[100010],tail[100010],net[450010];
int n=0,tot=0,poolD;
调用频繁的几个变量都开了register.不过据Kevin大佬所说,一次最多开4个,原因好像和内存分配有关.我在上面多放了个i进去,应该没有太大的影响.大段的数字是计算好的停用词的哈希值.mod开成const可能会带来一点点的优化(?)
我知道%运算针对const变量是有优化的,但是%太慢了,后面全部替换成了&和自然溢出,但保险起见,mod还是开成了const类型.因为哈希表的大小是2的次幂,如果&上2的次幂减1的话,就相当于对这个数进行了取模,此时的&运算就和%运算等价.
在哈希时用到的low函数如下:
#define low(x) (x>='a')
可以看到并没有判断和z的大小.经过试验,文章中是有在z之后的字符的.但是字典和停用词表中都没有.为了少一次判断,我在转化小写的时候对z后面的字符做了处理:
for (end=art;*end!='\0';end++){
if ((*end<='Z') && (*end>='A')) *end=(*end)+32;
else if ((*end)>'z') *end='%';
}
这样就可以只和a比对了.如果不做处理,这条判断语句也应该先写和a的比较,再写和z的比较.因为比a大的字符几乎都是小写字母,C语言中,如果&&的前一项为假后就不会再判断后一项了,也就是几乎只有小写字母会参与和z的比较;先写小于z的话....那几乎所有的字符都会再和a比较一次,会慢一点.
在处理字典的时候可以看到开了很多数组,结构体的访问应该是要比数组访问慢一些的,能拆就拆.
当然还有神奇的O3优化:
#pragma GCC optimize(3)
还有一些减少运算次数,减少数组访问的写法,在代码中都看出来.
当然也有一些能加但是因为一些原因没有加的优化,在下面列举一下:
- 循环展开
我试着在桶排的时候展开了一下,但是效果好像并不明显,最后去掉了
- 停用词预处理
这里的预处理是指把整个停用词表提前存好,可以减少一次文件访问了.但着实有些太丧心病狂了,于是放弃(主要还是因为懒)
- 打表
大数据就只有一个测试点,二分答案很快就能出来.这样就可以不用计算答案,直接做输出处理了.可以省下很多统计和计算的步骤.但毕竟打表是不好的行为,面向数据编程不太合适.(停用词表那里不算,因为题面上只说了字典会变大,没说停用词表会变,这算是我的已知条件,不算面向数据编程)
- 内嵌汇编优化
这里指拿汇编重写循环什么的.我确实有这个打算,但是网上查到的方法都失败了,遂放弃.(还是学艺不精,回去好好学一下汇编)
- mmap重写读入
mmap来读入可以再省一点时间.但是我本地好像没有这个库(经测试OJ上有),配置起来较为繁琐.而且在OJ上测试了一下,目前的代码的时间消耗主要在哈希部分,读入的时间已经很少了,读入优化显得有一点鸡肋.
常数优化基本上就这些了.可能在大佬看来,这份代码还有很大的优化空间,但我个人目前只能做到这一步了.OJ上测试的结果是0.103s,位列第5.第一名0.078s,差距也不是很大.如果把上面的优化全部加上,多交几次,我感觉是有一战之力的.(最后还是因为懒得刷了,这个时间实在是太看评测姬心情了)
代码
#pragma GCC optimize(3)
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#define reg register
#define ut unsigned int
#define N 8388608
#define M 100000
#define low(x) (x>='a')
extern int main2(void) __asm__ ("main2");
int main2(){
reg ut sum,i;
const ut mod=8388607;
reg char *now,*begin,*end;
char d_buf[20000010];
char art[20000010];
ut stop[320]={97,7411521,7411637,5370655,8098984,3028091,1632928,4741869,1678873,2217970,4725675,4725677,3820036,1829583,6964010,7682195,12816,6973768,4966101,1291768,7715104,12817,1679127,1974215,1679148,5705872,5825850,1458130,5961455,4085931,1679652,3819685,12822,12823,3886803,12939,7041409,8205530,7281663,5985264,3997132,3955712,5637983,5375262,1642066,6561931,6613561,1203934,6720525,5555164,6408147,4025271,4129281,4240047,1697221,12959,6136074,1711756,3514851,6136344,13080,6997707,1713590,4981325,4617331,1713994,13201,5515047,3878317,13211,236066,237254,1731528,5509721,2242465,13334,1437907,7873949,2254581,2433329,1390570,2197689,1350044,1748556,2603114,2603118,5464259,1672871,1799079,4426880,8251801,1763772,622541,4628254,4629027,4629281,4629806,2526760,4630330,1765077,748269,6488338,7626834,7677510,4733178,4783870,5930225,4834959,6446620,1780930,6878421,13604,1797551,1797566,3133036,600616,13725,3649531,1798089,668736,6278504,575711,576617,7015746,668750,6278001,1798608,2910632,1798614,1799404,7569178,7242022,105,13856,13857,13865,1816414,2637861,3610290,3071548,13870,13871,1817216,8197559,7411317,1203994,2860966,6917779,7023113,1272637,1868684,3450105,3451435,1883377,14380,7331712,238427,3588448,3588703,3692193,1417069,3692339,5087624,3692717,3793197,3795305,14400,5427992,5699375,4207972,3611867,790332,4670930,5769475,5836794,14521,619115,5939760,6375065,1902365,1902367,6129961,1902370,369154,14643,1918335,3902738,14651,8169249,1919382,8170448,8171486,14655,1615652,1935327,149344,1920312,8291355,7734978,1920314,8306812,1920570,1807619,1935377,8014469,4992074,1937475,4343788,15035,162614,1986847,230218,8138669,882480,4992849,3676344,6716008,3279732,1987240,8287883,283021,298723,5749730,4324269,1987390,5923587,15176,402868,1273064,1393042,6325994,706317,252954,565187,5228288,504527,7373251,2410443,2004017,2529269,2529275,2004401,4246630,2529792,6632522,2529793,2744343,4247796,2158148,7825203,3919000,7826109,4102710,4247927,2529804,4314481,2530317,4316439,2530322,4419537,180083,4469186,4649869,928921,2531503,2531894,15307,5667666,2005328,2005329,7336299,4753972,7803517,7837597,2006376,15437,1838916,2114010,15439,4916482,15442,6976206,2038850,2054981,15690,834890,835669,884940,5931258,885458,3588720,1665248,6943719,2413165,997941,998078,998984,7498521,5259812,5546129,7010401,7011577,752085,2055894,6217252,7114543,886767,7115460,2055904,903534,904578,4552123,840944,6176929,2089828,2091139,5503867,7975012,2001603,6835853};
char* d_pool[N];
int Cnt[N],Sum[N],S[N],Next[N],hsd[N],bu[100010],tail[100010],net[450010];
int n=0,tot=0,poolD;
scanf("%d",&n);
FILE *f1=fopen("dictionary.txt","rb");
FILE *f2=fopen("results.txt","w");
fseek(f1,0,SEEK_END);
int Size=ftell(f1);
rewind(f1);
fread(d_buf,1,Size,f1);
fclose(f1);
//Read the dictionary
tot=0;
end=d_buf+Size;
for (now=d_buf;now<end;++now){
sum=0; d_pool[++tot]=now;
while (low(*now)) sum=sum*131u+*now++;
sum=sum&mod; *now++='\0';
Next[tot]=hsd[sum]; hsd[sum]=tot;
}
//Insert the dictionary into Hash map
f1=fopen("article1.txt","rb");
fseek(f1,0,SEEK_END);
Size=ftell(f1);
rewind(f1);
fread(art,1,Size,f1);
fclose(f1);
art[Size]='\0';
for (end=art;*end!='\0';end++){
if ((*end<='Z') && (*end>='A')) *end=(*end)+32;
else if ((*end)>'z') *end='%';
}
// Lower litter
while (!low(*end)) end--;
end++; *end='\0';
ut x=0;
for (now=art;now<end;++now){
while (!low(*now)) ++now;
sum=0; begin=now;
while (low(*now)) sum=sum*131u+*now++;
x=sum&mod; *now='\0';
poolD=hsd[x];
if (poolD && !Next[poolD] && *d_pool[poolD]==*begin && *(d_pool[poolD]+1)==*(begin+1))
Cnt[poolD]++;
else while (poolD!=0){
if (strcmp(begin,d_pool[poolD])==0) {
Cnt[poolD]++; break;
}
poolD=Next[poolD];
}
}
//Insert article 1
f1=fopen("article2.txt","rb");
fseek(f1,0,SEEK_END);
Size=ftell(f1);
rewind(f1);
fread(art,1,Size,f1);
fclose(f1);
art[Size]='\0';
for (end=art;*end!='\0';end++){
if ((*end<='Z') && (*end>='A')) *end=(*end)+32;
else if ((*end)>'z') *end='%';
}
// Lower litter
while (!low(*end)) end--;
end++; *end='\0';
sum=0;
for (now=art;now<end;++now){
while (!low(*now)) ++now;
sum=0; begin=now;
while (low(*now)) sum=sum*131u+*now++;
x=sum&mod; *now='\0';
poolD=hsd[x];
if (poolD && !Next[poolD] && *d_pool[poolD]==*begin && *(d_pool[poolD]+1)==*(begin+1))
Sum[poolD]++;
else while (poolD!=0){
if (strcmp(begin,d_pool[poolD])==0) {
Sum[poolD]++; break;
}
poolD=Next[poolD];
}
}
//Insert article 2
f1=fopen("stopwords.txt","rb");
fseek(f1,0,SEEK_END);
Size=ftell(f1);
rewind(f1);
fread(art,1,Size,f1);
fclose(f1);
art[Size]='\0'; end=art+Size;
sum=0;
for (now=art;now<end;++now){
while (!low(*now)) ++now;
begin=now; x=stop[sum]; poolD=hsd[x];
for (;low(*now);now++); *now='\0'; sum++;
while (poolD!=0){
if (strcmp(begin,d_pool[poolD])==0) {
Cnt[poolD]=Sum[poolD]=-1; break;
}
poolD=Next[poolD];
}
}
// Insert stopwords
int l=0;
int z[8];
double s1=0,s2=0,p1=0,p2=0;
now=art;
if (n>tot) n=tot;
for (i=1;i<=tot;++i)
if (Cnt[i]>0){
x=Cnt[i];
if (!bu[x]) bu[x]=i;
net[tail[x]]=i; tail[x]=i;
}
sum=0;
for (i=M;i;i--)
for (int j=bu[i];j;j=net[j]){
s1+=i; S[j]=-1; sum++;
begin=d_pool[j];
while (*begin!='\0') *now++=*begin++;
*now++=' '; x=i;
while (x) z[++l]=x%10,x/=10;
while (l) *now++=z[l--]+48;
*now++='\n';
if (sum>=n) goto END;
}
END:;
*now++='\n';
memset(bu,0,sizeof(bu));
memset(tail,0,sizeof(tail));
memset(net,0,sizeof(net));
for (i=1;i<=tot;++i)
if (Sum[i]>0){
x=Sum[i];
if (!bu[x]) bu[x]=i;
net[tail[x]]=i; tail[x]=i;
}
sum=0;
for (i=M;i;i--)
for (int j=bu[i];j;j=net[j]){
s2+=i;
if (S[j]==-1) p1+=Cnt[j],p2+=i;
begin=d_pool[j];
while (*begin!='\0') *now++=*begin++;
*now++=' '; x=i;
while (x) z[++l]=x%10,x/=10;
while (l) *now++=z[l--]+48;
*now++='\n'; sum++;
if (sum>=n) goto END2;
}
END2:;
*now='\0';
p1/=s1; p2/=s2;
if (p1>p2) p1=p2/p1; else p1=p1/p2;
printf("%.5lf",p1);
fprintf(f2,"%.5lf\n\n",p1);
fwrite(art,1,now-art,f2);
exit(0);
}
int main(){
unsigned size=128u<<22;
char *p=(char *)malloc(size)+size;
__asm__ __volatile__
(
"movq %0, %%rsp\n"
"pushq $exit\n"
"jmp main2\n"
:: "r"(p)
);
}
后记
整个大作业连写带优化花了差不多一周的时间吧.我第一次交的时间比较晚,大概是在大作业截止前一周左右,而且第一次交的代码里用的是快排,不知道哪里传参不太对,跑了60多秒233333.最后的优化过程基本上是想到一个点就改一次,交一次.排名也是一点点刷上去的,确实费了一番功夫.
大部分人的时间在1s以内,0.5s附近的人居多.最后的平均分可能能到1.5分左右?(没有算,瞎猜的).果然内卷要从大一抓起.现在看来作业里的那一句"同时,建议同学们用本课程所学到的知识来解决问题。"就像是一个笑话,至少最前面的一批人使用的优化手法肯定涉及到了课外的内容.
个人觉得比卡常的题目最好不要涉及大规模的输入和输出,这样可以把卡常的主要精力放在优化运算上.IO的卡常手法也就那么几个,没有运算卡常有意思.
谨以此文纪念那些为卡常而头秃的夜晚.