这是我之前接的一个私活里遇到的一个问题:根据关键字搜索新浪微博,并获取发表这些微博的用户信息,然后再筛选出这些用户的共同关注对象(估计是想根据共同关注对象来投放广告吧),整个项目难度不是很大,利用网络爬虫去抓取微博网页的信息即可(微博提供的API有极大的限制,所以不采用),whatever,这不是重点,本文要讲的还是这个“筛选用户的共同关注用户”------换个说法就是本文的标题了:多个集合求交集。
这个问题说难不难,说容易也不容易,刚好适合我这样的菜鸟。实现的方法有多种,但是我们的目的是寻找出一种较好的算法。
首先,java中的集合框架就提供了相应的方法,即Collection的retainAll方法,我查看了一下它的源码:如下
public boolean retainAll(Collection> c) {
boolean modified = false;
Iterator it = iterator();
while (it.hasNext()) {
if (!c.contains(it.next())) {
it.remove();
modified = true;
}
}
return modified;
}
原理很简单,遍历集合的元素,查看此元素是否在另一个集合里存在,如果不存在,则移除,遍历完后该集合就只剩下交集了。
好了,有了这个方法,求多个集合的交集就简单了,只需让一个集合依次和其他集合retainAll,这个集合剩余的数就是交集的数了。
代码如下:
int setNum = 20; // 集合总数
int setSize = 1200000; // 每个集合包含的数字数量
int randomRange = 2000000; // 生成随机数的范围
// 用set保存
Set[] setArray = new HashSet[setNum];
// 随机生成数据
for(int i = 0; i < setNum; i ++ ){
Set set = new HashSet();
for(int j = 0; j < setSize; j ++){
/**
* 随机生成 randomRange 内的数
* 每个set存放 setSize 个数
*/
int randomNum = new Random().nextInt(randomRange);
// 保证不重复
while(set.contains(randomNum))
randomNum = new Random().nextInt(randomRange);
set.add(randomNum);
}
setArray[i] = set;
}
/**
* 方案一:使用jdk自身提供的retainAll方法
*/
long beginTime = System.nanoTime();
Set referSet = new HashSet();
referSet.addAll(setArray[0]);
for(int i = 1; i < setNum; i ++){
referSet.retainAll(setArray[i]);
}
long endTime = System.nanoTime();
System.out.println("使用retainAll方法,运行时间:" + (endTime - beginTime));
Iterator iter = referSet.iterator();
System.out.println("交集里的数有" + referSet.size() + "个。如下:");
while(iter.hasNext()){
System.out.print(iter.next() + " ");
}
运行结果如下:
循环调用retainAll并不是一个很好的选择,因为用于参照的那个集合(即代码中的referSet)每次调用retainAll都要从头遍历,注意到多个交集的集合必定是任一个集合的子集,所以只需遍历一个集合,查看这个集合里的数在剩余的集合存不存在,如果剩余的任一集合不包含这个数,那肯定不属于交集,这样就可以减少判断的次数。
所以上面的代码可以这样优化:
int setNum = 20; // 集合总数
int setSize = 1200000; // 每个集合包含的数字数量
int randomRange = 2000000; // 生成随机数的范围
// 用set保存
Set[] setArray = new HashSet[setNum];
// 随机生成数据
for(int i = 0; i < setNum; i ++ ){
Set set = new HashSet();
for(int j = 0; j < setSize; j ++){
/**
* 随机生成 randomRange 内的数
* 每个set存放 setSize 个数
*/
int randomNum = new Random().nextInt(randomRange);
// 保证不重复
while(set.contains(randomNum))
randomNum = new Random().nextInt(randomRange);
set.add(randomNum);
}
setArray[i] = set;
}
/**
* 方案二: 以第一个集合作为参照集合,遍历之,
* 依次与剩余集合比较,如果剩余的set集合里中任意一个set都不包含这个数,
* 那么可以断定这个数一定不属于交集
*/
long beginTime = System.nanoTime();
Set referSet = new HashSet();
referSet.addAll(setArray[0]);
iter = referSet.iterator();
// 遍历这个集合
while(iter.hasNext()){
Integer i = iter.next();
boolean belongToSection = true;
// 依次与剩余集合比较
for(int index = 1; index < setNum; index ++){
Set set = setArray[index];
// 如果剩余的set集合里中任意一个set都不包含这个数,
// 那么可以断定这个数一定不属于交集
if(!set.contains(i)){
belongToSection = false;
break;
}
}
// 移除这个不属于交集的数
if(!belongToSection)
iter.remove();
}
// 当遍历完第一个集合时,里面剩余的数就是交集里的数
long endTime = System.nanoTime();
System.out.println();
System.out.println("使用遍历筛选方法,运行时间:" + (endTime - beginTime));
iter = referSet.iterator();
System.out.println("交集里的数有" + referSet.size() + "个。如下:");
while(iter.hasNext()){
System.out.print(iter.next() + " ");
}
运行结果如下:
唔····貌似有点进步了,花费时间提升了大概0.1秒··,但这个结果还是不能让人满意,如果换成占用内存较大的对象,那么妥妥的内存要爆。有没有更优的办法呢···当然了,不然我也不写这篇博客了。。。
这个想法也是从我的一个面试题中得到的灵感,即如何从40亿个数中判断一个数存不存在,40亿个整形数大概占用内存1G左右,一次性读取进内存是不可取得,所以要想办法压缩整形的占用空间------即用一个二进制位表示一个数。
如何用一个二进制位表示一个整数呢?举个栗子就明白了:
比如1001001这个二进制数所表示的数集就为{1,4,7}-------因为这个二进制数的第1位和第4位和第7位为1,所以压缩的思想就是,根据二进制数的某一位是否为1来表示这个数是否存在,第一位为1就说明1存在数集里,第2位为0就表示2不存在数集里,java也提供了相应的工具类--即BitSet。
这样一个数集就可以用一个二进制数来表示了,而且也不用怕出现内存不够用的情况了(相比整形最多压缩了31倍的空间)
用二进制数表示数集后,求交集简直就是太简单了,直接用两个表示数集的二进制数做AND操作就行了,得到的二进制数就表示了交集。
还是举个栗子:例如求s1:{1, 2, 4, 8, 10, 11, 20}和s2:{3, 8, 10, 11, 15, 17, 20}的交集
这两个用二进制数表示即为
s1:011010001011000000001
s2:000100001011000101001;
s1 AND s2 = 000000001011000000001;
这个集合表示数集{8, 10, 11, 20};
下面是代码:
int setNum = 20; // 集合总数
int setSize = 1200000; // 每个集合包含的数字数量
int randomRange = 2000000; // 生成随机数的范围
// 用bitSet保存
BitSet[] bitSetArray = new BitSet[setNum];
// 随机生成数据
for(int i = 0; i < setNum; i ++ ){
BitSet bitSet = new BitSet(randomRange);
for(int j = 0; j < setSize; j ++){
/**
* 随机生成 randomRange 内的数
* 每个set存放 setSize 个数
*/
int randomNum = new Random().nextInt(randomRange);
// 保证不重复
while(set.contains(randomNum))
randomNum = new Random().nextInt(randomRange);
// 把bitset对应的位设为true
bitSet.set(randomNum, true);
}
bitSetArray[i] = bitSet;
}
/**
* 方案三
* 使用bitSet查找,只需要用一个bitSet依次与剩余的做逻辑与(AND)操作即可。
* 例如求s1:{1, 2, 4, 8, 10, 11, 20}和s2:{3, 8, 10, 11, 15, 17, 20}的交集
* 这两个用bitSet表示即为
* s1:011010001011000000001
* s2:000100001011000101001;
* s1 AND s2 = 000000001011000000001;
* 这个集合表示数集{8, 10, 11, 20};
* 用bitSet来存储可以压缩存储空间,一位即可表示一个数字,且求交集简单,运算速度快,
* 缺点是当集合的数比较散列时(即不是集中在某一个范围),则会占用比较多的空间。
* 计算后的bitSet即保存了交集的结果---即为true的位所对应的数。
*/
long beginTime = System.nanoTime();
BitSet resultSet = bitSetArray[0];
// 用第一个bitSet依次与剩下的bitSet做逻辑与操作
for(int i = 1; i < setNum; i ++){
resultSet.and(bitSetArray[i]);
}
long endTime = System.nanoTime();
System.out.println();
System.out.println("使用bitSet方法,运行时间:" + (endTime - beginTime));
System.out.println("交集里的数有" + resultSet.cardinality() + "个。 如下:");
for(int i = 0; i < resultSet.size(); i ++){
if(resultSet.get(i))
System.out.print(i + " ");
}
}
运行结果如下:
可以看出,运行时间直接提升了2个数量级···而且也不会出现java.lang.outOfMemoryError了。