在网上看到这篇文章:一次谷歌面试趣事。觉得其中的算法题以及作者的解决思路很有趣,就拿来分享一下吧。
问题
假设这有一个各种字母组成的字符串,假设这还有另外一个字符串,而且这个字符串里的字母数相对少一些。从算法是讲,什么方法能最快的查出所有小字符串里的字母在大字符串里都有?
比如,如果是下面两个字符串:
String 1: ABCDEFGHLMNOPQRS
String 2: DCGSRQPO
答案是true,所有在String2里的字母String1也都有。如果是下面两个字符串:
String 1: ABCDEFGHLMNOPQRS
String 2: DCGSRQPZ
答案是false,因为第二个字符串里的Z字母不在第一个字符串里。
解决方案
1. 轮询
对于这种操作最简单最幼稚的做法是轮询第二个字符串里的每个字母,看它是否同在第一个字符串里。从算法上讲,这需要O(n*m)
次操作,其中n是string1的长度,m是string2的长度。就拿上面的例子来说,最坏的情况下将会有16*8 = 128次操作。
2. 排序
一个稍微好一点的方案是先对这两个字符串的字母进行排序,然后同时对两个字串依次轮询。两个字串的排序需要O(m log m) + O(n log n)
次操作(常规情况下),之后的线性扫描需要O(m+n)
次操作。同样拿上面的字串做例子,将会需要16*4 + 8*3 = 88加上对两个字串线性扫描的16 + 8 = 24的操作。(随着字串长度的增长,你会发现这个算法的效果会越来越好)
/** * 排序方案:快速排序 */
public static boolean isSubsetByQuickSort(String a, String b){
char[] ca = a.toCharArray();
char[] cb = b.toCharArray();
quickSort(ca, 0, ca.length - 1);
quickSort(cb, 0, cb.length - 1);
//字符串String1的比较指针
int pos = 0;
for(char c : cb){
while(pos < ca.length-1 && ca[pos] < c){
pos++;
}
if(c != ca[pos]){
System.out.println("No exist char: " + c);
return false;
}
}
return true;
}
public static void quickSort(char[] arr, int low, int high){
if(arr.length <= 0) return;
if(low >= high) return;
int l = low;
int r = high;
char pivot = arr[l];
while(l < r){
while(l < r && arr[r] >= pivot){
r--;
}
arr[l] = arr[r];
while(l < r && arr[l] <= pivot){
l++;
}
arr[r] = arr[l];
}
arr[l] = pivot;
quickSort(arr, low, l - 1);
quickSort(arr, l + 1, high);
}
不过,常规排序比如快排可以达到O(n log n)
的时间复杂度,这里也可以选用用空间换时间的的基数排序、桶排序等线性时间复杂度的排序算法。
// 字母编码[A - z]:[65 - 122]
public static final int LETTER_REGION = 122 - 65 + 1;
/** * 排序方案:计数排序 */
public static boolean isSubsetByCounterSort(String a, String b){
char[] ca = a.toCharArray();
char[] cb = b.toCharArray();
ca = counterSort(ca);
cb = counterSort(cb);
//字符串String1的比较指针
int pos = 0;
for(char c : cb){
while(pos < ca.length-1 && ca[pos] < c){
pos++;
}
if(c != ca[pos]){
System.out.println("No exist char: " + c);
return false;
}
}
return true;
}
public static char[] counterSort(char[] arr){
int[] bucket = new int[LETTER_REGION];
for(char c : arr){
int index = c - 'A';
bucket[index]++;
}
for(int i = 1; i < LETTER_REGION; i++){
bucket[i] += bucket[i - 1];
}
char[] res = new char[arr.length];
for(char c : arr){
int index = c - 'A';
res[bucket[index] - 1] = c;
bucket[index]--;
}
return res;
}
3. 哈希表
哈希表Hashtable是一个只需要O(n+m)
次操作的算法。方法就是,对第一个字串进行轮询,把其中的每个字母都放入一个Hashtable里(时间成本是O(n)
,这里是16次操作)。然后轮询第二个字串,在Hashtable里查询每个字母,看能否找到。如果找不到,说明没有匹配成功。这将消耗掉8次操作 —— 这样两项操作加起来一共只有24次。不错吧,比前面两种方案都要好。
/** * 哈希表Hashset */
public static boolean isSubsetByHashset(String a, String b){
char[] ca = a.toCharArray();
char[] cb = b.toCharArray();
HashSet<Character> set = new HashSet<Character>();
for(char c : ca){
set.add(c);
}
for(char c : cb){
if(!set.contains(c)){
return false;
}
}
return true;
}
4、Bitmap位图法
这个解决方案思想和Hashtable一致,只不过使用的是位图法来为每一个字符保留一位。同样只需要O(n+m)
次操作。
// 字母编码区间[A - z]:[65 - 122]
public static final int LETTER_REGION = 122 - 65 + 1;
/** * 比特位方案 */
public static boolean isSubsetByBitmap(String a, String b){
char[] ca = a.toCharArray();
char[] cb = b.toCharArray();
byte[] bitmap = new byte[LETTER_REGION / Byte.SIZE];
for(char c : ca){
setBit(bitmap, c - 'A');
}
for(char c : cb){
if(getBit(bitmap, c - 'A') == 0){
System.out.println("No exist char in Bitmap: " + c);
return false;
}
}
return true;
}
/** * 写入指定位的比特 */
public static void setBit(byte bitmap[], int k){
bitmap[k / Byte.SIZE] |= (1 << (k % Byte.SIZE));
}
/** * 读取指定位的比特 */
public static int getBit(byte bitmap[], int k){
return (bitmap[k / Byte.SIZE] & (1 << (k % Byte.SIZE)));
}
到此为止,O(n+m)
几乎是你能得到的最好的结果了,因为至少要对每个字母至少访问一次才能完成这项操作,而上述这两个方案是刚好是对每个字母只访问一次。下面看看文章中最后的这个素数方案。
5. 素数
假设我们有一个一定个数的字母组成字串。我给每个字母分配一个素数,从2开始,往后类推。这样A将会是2,B将会是3,C将会是5,等等。现在我遍历第一个字串,把每个字母代表的素数相乘。最终会得到一个很大的整数,对吧?然后 —— 轮询第二个字符串,用每个字母除它。如果除的结果有余数,这说明有不匹配的字母。如果整个过程中没有余数,你应该知道它是第一个字串恰好的子集了。这样不行吗?
public static int primes[] = {
2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97,101,103,
107,109,113,127,131,137,139,149,151,157,163,167,173,179,181,191,193,197,199,211,
223,227,229,233,239,241,251,257,263,269,271,277,281,283,293,307,311,313,317,331,
337,347,349,353,359,367,373,379,383,389,397,401,409,419,421,431,433,439,443,449,
457,461,463,467,479,487,491,499,503,509,521,523,541,547,557,563,569,571,577,587,
593,599,601,607,613,617,619,631,641,643,647,653,659,661,673,677,683,691,701,709,
719,727,733,739,743,751,757,761,769,773,787,797,809,811,821,823,827,829,839,853,
857,859,863,877,881,883,887,907,911,919,929,937,941,947,953,967,971,977,983,991};
// 字母编码区间[A - z]:[65 - 122]
public static final int LETTER_REGION = 122 - 65 + 1;
/** * 素数方案 */
public static boolean isSubsetByPrimeNumber(String a, String b){
char[] ca = a.toCharArray();
char[] cb = b.toCharArray();
// 防止乘积int溢出,使用BigInteger存储乘积结果
BigInteger p = BigInteger.ONE;
for(char c : ca){
p = p.multiply(BigInteger.valueOf(primes[c - 'A']));
}
System.out.println("乘积结果p = " + p.toString());
for(char c : cb){
if(!p.remainder(BigInteger.valueOf(primes[c - 'A'])).equals(BigInteger.ZERO)){
System.out.println("No exist char: " + c);
return false;
}
}
return true;
}
测试代码
public class CharacterSubset {
/** * 假设你有一个一定长度的由字母组成的字符串。你还有另外一个,短些。你如何才能知道所有的在较短的字符串里的字母在长字符串里也有? */
public static void main(String args[]){
String a1 = "ABCDEFGHLMNOPQRS";
String b1 = "DCGSRQPOM";
String a2 = "ABCDEFGHLMNOPQRS";
String b2 = "DCGSRQPOZ";
System.out.println("\na1 and b1: " + isSubsetByQuickSort(a1, b1));
System.out.println("\na2 and b2: " + isSubsetByQuickSort(a2, b2));
System.out.println("\na1 and b1: " + isSubsetByCounterSort(a1, b1));
System.out.println("\na2 and b2: " + isSubsetByCounterSort(a2, b2));
System.out.println("\na1 and b1: " + isSubsetByHashset(a1, b1));
System.out.println("\na2 and b2: " + isSubsetByHashset(a2, b2));
System.out.println("\na1 and b1: " + isSubsetByPrimeNumber(a1, b1));
System.out.println("\na2 and b2: " + isSubsetByPrimeNumber(a2, b2));
System.out.println("\na1 and b1: " + isSubsetByBitmap(a1, b1));
System.out.println("\na2 and b2: " + isSubsetByBitmap(a2, b2));
}
}
总结
就如文章中所说,素数方案在算法上并不能说就比哈希表好。而且在实际操作中,你很可能仍会使用哈希表的方案,因为它更通用,无需跟麻烦的大型数字打交道。但从”巧妙水平“上讲,Guy提供的素数方案是一种更、更、更有趣的方案。