【算法题集锦之二】--多个集合求交集

这是我之前接的一个私活里遇到的一个问题:根据关键字搜索新浪微博,并获取发表这些微博的用户信息,然后再筛选出这些用户的共同关注对象(估计是想根据共同关注对象来投放广告吧),整个项目难度不是很大,利用网络爬虫去抓取微博网页的信息即可(微博提供的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() + " ");
		}
运行结果如下:

运行时间差强人意,20个包含120万数据的集合求交集用retainAll方法总共0.37秒,如果数据再多一点,还有可能出现java.lang.outOfMemoryError....。(这段代码运行时间会比较长,主要是生成随机数花费时间多)。

循环调用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了。



你可能感兴趣的:(算法,面试,bitset,面试题,多个集合求交集)