算法面试题之统计词频前k大

有一个文本文件,大约有一万行,每行一个词,要求统计出其中最频繁出现的前十个词。

算法1:将这10W个单词读入,并排序,然后顺序枚举,每当一个单词和左边的单词相同,则计数器num++,否则,将这个单词还有其词频加入新的数组,最后按频率排序输出前10大。代码如下:

<span style="font-size:14px;">sort(s,s+n);
int num=0;
for (int i=0;i<n;i++)
{
	if (i+1==n || s[i]!=s[i+1])
	{
		node x;
		x.fre=num; x.s=s[i];
		ans.push_back(x)
		num=1;
	}
	else
		num++;
}
sort(ans.begin(),ans.end(),cmp);
for (int i=0;i<n;i++) cout<<ans.fre<<' '<<ans.s<<endl;</span>

这个算法的时间复杂度是O(NLOGN*K),其中K是比较两个单词大小的均摊复杂度。空间复杂度是O(2N),s数组大小是N,ans这个vector最大也是N。

算法2:读入这10W个单词的时候,用一个map<string,int> vis来给其编号,再用一个struct来统计其频率,最后按词频排序输出前10大即可。代码如下:

<span style="font-size:14px;">map<string,int> vis;
struct fre{
	int x,f; //x是其编号,
}Num[n+1];
int now=0;
for (int i=1;i<=n;i++)
{
	cin>>s;
	if (vis[s]==0)
	{ 
		vis[s]=++now;
		str[now]=s;
		Num[now].x=now;
		Num[now].f=0;
	}
	Num[vis[s]].f++;
}
sort(Num+1,Num+1+now,cmp); //按照f作为关键字排序
for (int i=1;i<=now;i++) cout<<Num[i].x<<' '<<Num[i].f<<endl;</span>
这个算法的时间复杂度是O(NLOGN*K),map更新还是sort的复杂度都是这个,空间复杂度也是O(2N),也是一个对于此题可以接受的算法。

以上两个算法的一个改进:

对于一个序列,要取其前K大元素(这里是前10大),排序的时候其实并不需要把所有的元素都排序,例如在快速排序时,一开始调用的是sort(1,n),通过它又会调用sort(1,l),sort(r,n),假设这里的l和r分别是100和101,那么此时数组中前100大的元素必然都在arr[1]...arr[100]中,因此完全没有必要再去调用sort(r,n)。加上这个优化,可以把快速排序的复杂度降低一个log维度。详见代码:

void quickSort(int s[], int l, int r)  
{  
    if (l< r)  
    {        
        int i = l, j = r, x = s[l];  
        while (i < j)  
        {  
            while(i < j && s[j]>= x) // 从右向左找第一个小于x的数  
                j--;   
            if(i < j)  
                s[i++] = s[j];  
            while(i < j && s[i]< x) // 从左向右找第一个大于等于x的数  
                i++;   
            if(i < j)  
                s[j--] = s[i];  
        }  
        s[i] = x;  
        quickSort(s, l, i - 1); // 递归调用  
        if (i+1<=K)				//仅仅加上这一个优化,求前10大的排序速度会从O(NlogN)降低到O(N)
        	quickSort(s, i + 1, r);  
    }  
}  
其实在各种编程语言中都有提供类似的部分排序功能,在C++中,函数的名字叫做partial_sort。

对于这道题目来说,仅仅用以上算法解决其实并不能答出彩,应该想想当读入的单词个数从10^4变成了10^9,前10大变成了前10000大,这时候又该怎么样去做呢?

这个时候,算法2显然是不适合的,假设所有的单词都不相同,那么map会有10^9个不同的string,计算机内存必然会爆掉。而算法1看似也是不适合的,因为调用快速排序的时候也是要读入所有的元素进来的。

此时需要先了解这样一个排序算法:归并排序。

假设现在有两个班的学生各自按身高从低到高已经排好了,他们的身高分别是:

160,162,164,165,165,166

160,163,166,178,179

那么,在这个时候,要把这两个班的学生排成有序的一行,可以这么比较:
首先,从两个队列的第一个人中,挑选一个最低的出来(如果身高一样则随便挑选哪一个都可以),此时,队列的情况是这样的:
新队列: 160
队列1: 162,164,165,165,166
队列2: 160,163,166,178,179
然后,再从两个队列的第一个人中,挑选一个最低的出来,此时,队列的情况是这样的:
新队列: 160,160
队列1: 162,164,165,165,166
队列2: 163,166,178,179

可以看到,每比较一次,都有一个人到了新的队列里边,这样,只需要进行N+M-1次比较(N是队列1的长度,M是队列2的长度),就可以把两个本来有序的队列合并成一个队列。

这就是归并排序的思想,对于一个长度为N的序列,首先递归的去把它前N/2和后N/2个序列排成有序的,再把这两个有序的序列合并成一个序列,就可以了。伪代码如下:

void merge_sort(int l,int r)
{
	if (l!=r)
	{
		int mid=(l+r)/2;
		merge_sort(l,mid);
		merge_sort(mid+1,r);
	}
	//此刻l到mid以及mid+1到r是两个有序的序列,再对他俩进行合并
	合并两个序列;
	return ;
}

这一段代码,把所有的单词放在内存中去排序当然是可以的,但是显然不可能把数组开到10^9那么大,因此,需要考虑如何把开数组的空间省去。在我们的代码中,每次只是在两个有序的队列中,查看他们的第一个元素,那么,用文件存储是否也可以做到呢?答案是肯定的。我们可以把排序的中间过程都存放在文件中,每次把两个文件同时打开,同步的去读他们当前第一个元素就可以了!现在,排序的时间复杂度依然是NLOGN,但是在内存中使用的空间大小却降低到了O(1)!

10^9 * LOG(10^9),并不是一个不可以接受的数字,对这样规模的数据进行排序,普通的计算机仅仅使用数个小时也可以完成了。

这样一来,算法1的排序部分已经可以使用,接下来来考虑如何在统计频率的时候,如何不占用O(N)的空间。

假设现在的单词个数是N,但是我们只需要频率前K大的单词。假设现在已经通过顺序枚举,找到了2K个不同的单词了,那么对于频率在第K+1和2K之间的单词,我们是完全没有必要保存的,因为它根本不可能是前K大。

例如:现在有10个同学,要找出他们成绩中第一大和第二大的。前三个同学的成绩分别是90,95,96。

当我们的代码扫到第三个同学的时候,此时的第一大和第二大是96,95,90显然不可能是前两大,因此,在此时直接将90丢弃掉。那么丢弃的顺序是什么呢?

假设当前的前两大分别是96,95,有一个97到来的时候,我们丢弃的必然是95,也就是保存的数字里面最小的那一个。怎样快速在保存的若干个数字里面找到最小的那一个呢?这里又涉及到一个新的算法,小根堆!

小根堆是一棵二叉树,满足这样一个特性:任何一个节点,他的所有儿子的值<=它的值。这样一来,这棵二叉树的根必然是整棵树里面最小的。

详细的解释参见百度百科:百度百科-最小堆

因此,我们的策略可以修改成这样:

顺序扫描排好序之后的每一个单词,如果这个单词跟下一个一样,则计数器num++,否则的话,我们找到了一个新的单词S,它的频率是num。现在来考虑是否要把它插入到这个小根堆里面:

1. 如果这个小根堆里面的元素个数小于K个,那么必然是要插入的,因为还没有找到前K个。

2.如果这个小根堆里面的元素个数等于K,但最小的元素的频率比num小,那么就把这个小根堆的最小元素删掉,把这个单词加进来。此时,这个小根堆的元素个数还是K个。

每次查找插入的复杂度都是LOGK,因此这一段算法的复杂度是O(NLOGK)

至此,这个题得到了较好的解决,相信面试官更愿意看到的是最后这个算法。答面试题,仅仅回答正确还不够,答的精彩,漂亮,才能够从众多面试者中脱颖而出,被面试官记住。

写的比较仓促,文中如有错误之处请指正,内容讲的较为啰嗦,目的是为了让基础较差的读者能够看懂,请见谅!

发布了21 篇原创文章 · 获赞 46 · 访问量 8万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览