程序员编程艺术:第二章、字符串是否包含及匹配/查找/转换/拷贝问题
程序员编程艺术:第二章、字符串是否包含及匹配/查找/转换/拷贝问题
作者:July,yansha。
时间:二零一一年四月二十三日。
致谢:老梦,nossiac,Hession,Oliver,luuillu,雨翔,啊菜,及微软100题实现小组所有成员。
微博:http://weibo.com/julyweibo。
出处:http://blog.csdn.net/v_JULY_v。
-------------------------------------------
目录
曲之前奏
第一节、一道俩个字符串是否包含的问题
1.1、O(n*m)的轮询方法
1.2、O(mlogm)+O(nlogn)+O(m+n)的排序方法
1.3、O(n+m)的计数排序方法
第二节
2.1、O(n+m)的hashtable的方法
2.2、O(n+m)的数组存储方法
第三节、O(n)到O(n+m)的素数方法
第四节、字符串是否包含问题的继续补充
4.1、Bit-map
4.2、移位操作
第五节、字符串相关问题扩展
5.1、字符串匹配问题
5.2、在字符串中查找子串
扩展:在一个字符串中找到第一个只出现一次的字符
5.3、字符串转换为整数
5.4、字符串拷贝
前奏
前一章,请见这:程序员面试题狂想曲:第一章、左旋转字符串。本章里出现的所有代码及所有思路的实现,在此之前,整个网上都是没有的。
文中的思路,聪明点点的都能想到,巧的思路,大师也已奉献了。如果你有更好的思路,欢迎提供。如果你对此狂想曲系列有任何建议,欢迎微博上交流或来信指导。任何人,有任何问题,欢迎随时不吝指正。
如果此狂想曲系列对你有所帮助,我会非常之高兴,并将让我有了永久坚持写下去的动力。谢谢。
第一节、一道俩个字符串是否包含的问题
1.0、题目描述:
假设这有一个各种字母组成的字符串,假设这还有另外一个字符串,而且这个字符串里的字母数相对少一些。从算法是讲,什么方法能最快的查出所有小字符串里的字母在大字符串里都有?
比如,如果是下面两个字符串:
String 1: ABCDEFGHLMNOPQRS
String 2: DCGSRQPOM
答案是true,所有在string2里的字母string1也都有。
如果是下面两个字符串:
String 1: ABCDEFGHLMNOPQRS
String 2: DCGSRQPOZ
答案是false,因为第二个字符串里的Z字母不在第一个字符串里。
点评:
1、题目描述虽长,但题意简单明了,就是给定一长一短的俩个字符串A,B,假设A长B短,现在,要你判断B是否包含在字符串A中,即B?(-A。
2、题意虽简单,但实现起来并不轻松,且当如果面试官步步紧逼,一个一个否决你能想到的方法,要你给出更好、最好的方案时,你恐怕就要伤不少脑筋了。
ok,在继续往下阅读之前,您最好先想个几分钟,看你能想到的最好方案是什么,是否与本文最后实现的方法一致。
1.1、O(n*m)的轮询方法
判断string2中的字符是否在string1中?:
String 1: ABCDEFGHLMNOPQRS
String 2: DCGSRQPOM
判断一个字符串是否在另一个字符串中,最直观也是最简单的思路是,针对第二个字符串string2中每一个字符,一一与第一个字符串string1中每个字符依次轮询比较,看它是否在第一个字符串string1中。
假设n是字符串string1的长度,m是字符串string2的长度,那么此算法,需要O(n*m)次操作,拿上面的例子来说,最坏的情况下将会有16*8 = 128次操作。
我们不难写出以下代码:
上述代码的时间复杂度为O(n*m),显然,时间开销太大,我们需要找到一种更好的办法。
(网友acs713在本文评论下指出:个人的代码风格不规范,的确如此,后来看过<<代码大全>>之后,此感尤甚。个人会不断完善和规范此类代码风格。有任何问题,欢迎随时指正。谢谢大家。)
1.2、O(mlogm)+O(nlogn)+O(m+n)的排序方法
一个稍微好一点的方案是先对这两个字符串的字母进行排序,然后同时对两个字串依次轮询。两个字串的排序需要(常规情况)O(m log m) + O(n log n)次操作,之后的线性扫描需要O(m+n)次操作。
同样拿上面的字串做例子,将会需要16*4 + 8*3 = 88加上对两个字串线性扫描的16 + 8 = 24的操作。(随着字串长度的增长,你会发现这个算法的效果会越来越好)
关于采用何种排序方法,我们采用最常用的快速排序,下面的快速排序的代码用的是以前写的,比较好懂,并且,我执意不用库函数的qsort代码。唯一的问题是,此前写的代码是针对整数进行排序的,不过,难不倒我们,稍微改一下参数,即可,如下:
1.3、O(n+m)的计数排序方法
此方案与上述思路相比,就是在排序的时候采用线性时间的计数排序方法,排序O(n+m),线性扫描O(n+m),总计时间复杂度为:O(n+m)+O(n+m)=O(n+m)。
代码如下:
不过上述方法,空间复杂度为O(n+m),即消耗了一定的空间。有没有在线性时间,且空间复杂度较小的方案列?
第二节、寻求线性时间的解法
2.1、O(n+m)的hashtable的方法
上述方案中,较好的方法是先对字符串进行排序,然后再线性扫描,总的时间复杂度已经优化到了:O(m+n),貌似到了极限,还有没有更好的办法列?
我们可以对短字串进行轮询(此思路的叙述可能与网上的一些叙述有出入,因为我们最好是应该把短的先存储,那样,会降低题目的时间复杂度),把其中的每个字母都放入一个Hashtable里(我们始终设m为短字符串的长度,那么此项操作成本是O(m)或8次操作)。然后轮询长字符串,在Hashtable里查询短字符串的每个字符,看能否找到。如果找不到,说明没有匹配成功,轮询长字符串将消耗掉16次操作,这样两项操作加起来一共只有8+16=24次。
当然,理想情况是如果长字串的前缀就为短字串,只需消耗8次操作,这样总共只需8+8=16次。
或如梦想天窗所说: 我之前用散列表做过一次,算法如下:
1、hash[26],先全部清零,然后扫描短的字符串,若有相应的置1,
2、计算hash[26]中1的个数,记为m
3、扫描长字符串的每个字符a;若原来hash[a] == 1 ,则修改hash[a] = 0,并将m减1;若hash[a] == 0,则不做处理
4、若m == 0 or 扫描结束,退出循环。
代码实现,也不难,如下:
2.2、O(n+m)的数组存储方法
有两个字符串short_str和long_str。
第一步:你标记short_str中有哪些字符,在store数组中标记为true。(store数组起一个映射的作用,如果有A,则将第1个单元标记true,如果有B,则将第2个单元标记true,... 如果有Z, 则将第26个单元标记true)
第二步:遍历long_str,如果long_str中的字符包括short_str中的字符则将store数组中对应位置标记为false。(如果有A,则将第1个单元标记false,如果有B,则将第2个单元标记false,... 如果有Z, 则将第26个单元标记false),如果没有,则不作处理。
第三步:此后,遍历store数组,如果所有的元素都是false,也就说明store_str中字符都包含在long_str内,输出true。否则,输出false。
举个简单的例子好了,如abcd,abcdefg俩个字符串,
1、先遍历短字符串abcd,在store数组中想对应的abcd的位置上的单元元素置为true,
2、然后遍历abcdefg,在store数组中相应的abcd位置上,发现已经有了abcd,则前4个的单元元素都置为false,当我们已经遍历了4个元素,等于了短字符串abcd的4个数目,所以,满足条件,退出。
(不然,继续遍历的话,我们会发现efg在store数组中没有元素,不作处理。最后,自然,就会发现store数组中的元素单元都是false的。)
3、遍历store数组,发现所有的元素都已被置为false,所以程序输出true。
其实,这个思路和上一节中,O(n+m)的hashtable的方法代码,原理是完全一致的,且本质上都采用的数组存储(hash表也是一个数组),但我并不认为此思路多此一举,所以仍然贴出来。ok,代码如下:
第三节、O(n)到O(n+m)的素数方法
我想问的是,还有更好的方案么?
你可能会这么想:O(n+m)是你能得到的最好的结果了,至少要对每个字母至少访问一次才能完成这项操作,而上一节最后的俩个方案是刚好是对每个字母只访问一次。
ok,下面给出一个更好的方案:
假设我们有一个一定个数的字母组成字串,我给每个字母分配一个素数,从2开始,往后类推。这样A将会是2,B将会是3,C将会是5,等等。现在我遍历第一个字串,把每个字母代表的素数相乘。你最终会得到一个很大的整数,对吧?
然后——轮询第二个字符串,用每个字母除它。如果除的结果有余数,这说明有不匹配的字母。如果整个过程中没有余数,你应该知道它是第一个字串恰好的子集了。
思路总结如下:
1.定义最小的26个素数分别与字符'A'到'Z'对应。
2.遍历长字符串,求得每个字符对应素数的乘积。
3.遍历短字符串,判断乘积能否被短字符串中的字符对应的素数整除。
4.输出结果。
至此,如上所述,上述算法的时间复杂度为O(m+n),时间复杂度最好的情况为O(n)(遍历短的字符串的第一个数,与长字符串素数的乘积相除,即出现余数,便可退出程序,返回false),n为长字串的长度,空间复杂度为O(1)。如你所见,我们已经优化到了最好的程度。
不过,正如原文中所述:“现在我想告诉你 —— Guy的方案(不消说,我并不认为Guy是第一个想出这招的人)在算法上并不能说就比我的好。而且在实际操作中,你很可能仍会使用我的方案,因为它更通用,无需跟麻烦的大型数字打交道。但从”巧妙水平“上讲,Guy提供的是一种更、更、更有趣的方案。”
ok,如果你有更好的思路,欢迎在本文的评论中给出,非常感谢。 上述程序待改进的地方:
1.只考虑大些字符,如果考虑小写字符和数组的话,素数数组需要更多素数
2.没有考虑重复的字符,可以加入判断重复字符的辅助数组。
以下的大整数除法的代码,虽然与本题目无多大关系,但为了保证文章的完整性,我还是决定把它贴出来,
代码如下(点击展开):
说明:此次的判断字符串是否包含问题,来自一位外国网友提供的gofish、google面试题,这个题目出自此篇文章:http://www.aqee.net/2011/04/11/google-interviewing-story/,文章记录了整个面试的过程,比较有趣,值得一读。
扩展:正如网友安逸所说:其实这个问题还可以转换为:a和b两个字符串,求b串包含a串的最小长度。包含指的就是b的字串包含a中每个字符。
第四节、字符串是否包含问题的继续补充
updated:本文发布后,得到很多朋友的建议和意见,其中nossiac,luuillu等俩位网友除了给出具体的思路之外,还给出了代码,征得同意,下面,我将引用他们的的思路及代码,继续就这个字符串是否包含问题深入阐述。
4.1、在引用nossiac的思路之前,我得先给你介绍下什么是Bit-map?
Oliver:所谓的Bit-map就是用一个bit位来标记某个元素对应的Value, 而Key即是该元素。由于采用了Bit为单位来存储数据,因此在存储空间方面,可以大大节省。
如果看了以上说的还没明白什么是Bit-map,那么我们来看一个具体的例子,假设我们要对0-7内的5个元素(4,7,2,5,3)排序(这里假设这些元素没有重复)。那么我们就可以采用Bit-map的方法来达到排序的目的。要表示8个数,我们就只需要8个Bit(1Bytes),首先我们开辟1Byte的空间,将这些空间的所有Bit位都置为0,如下图:
然后遍历这5个元素,首先第一个元素是4,那么就把4对应的位置为1(可以这样操作:p+(i/8)|(0x01<<(i%8))当然了这里的操作涉及到Big-ending和Little-ending的情况,这里默认为Big-ending),因为是从零开始的,所以要把第五位置为一(如下图):
接着再处理第二个元素7,将第八位置为1,,接着再处理第三个元素,一直到最后处理完所有的元素,将相应的位置为1,这时候的内存的Bit位的状态如下:
最后我们现在遍历一遍Bit区域,将该位是一的位的编号输出(2,3,4,5,7),这样就达到了排序的目的。
代码示例:
位图总结:
1、可进行数据的快速查找,判重,删除,一般来说数据范围是int的10倍以下
2、使用bit数组来表示某些元素是否存在,比如8位电话号码
3、Bloom filter(日后介绍)可以看做是对bit-map的扩展
问题实例:
1)已知某个文件内包含一些电话号码,每个号码为8位数字,统计不同号码的个数。
8位最多99 999 999,大概需要99m个bit,大概10几m字节的内存即可。 (可以理解为从0-99 999 999的数字,每个数字对应一个Bit位,所以只需要99M个Bit==12MBytes,这样,就用了小小的12M左右的内存表示了所有的8位数的电话)
2)2.5亿个整数中找出不重复的整数的个数,内存空间不足以容纳这2.5亿个整数。
将bit-map扩展一下,用2bit表示一个数即可,0表示未出现,1表示出现一次,2表示出现2次及以上,在遍历这些数的时候,如果对应位置的值是0,则将其置为1;如果是1,将其置为2;如果是2,则保持不变。或者我们不用2bit来进行表示,我们用两个bit-map即可模拟实现这个2bit-map,都是一样的道理。
ok,介绍完了什么是bit-map,接下来,咱们回到正题,来看下nossiac关于此字符串是否包含问题的思路(http://www.shello.name/me/?p=64):
每个字母的ASCII码值,可以对应一个位图中的位。
先遍历第一个字符串,生成一个“位图字典”。
用伪代码表示就是:
dictionary = 0
for x in String1:
dictionary |= 0x01<<x-'a'
红色部分就是构造位图字典的过程,刚好能运用ASCII码值完成,取巧,呵呵,比较惬意。
然后,我们遍历第二个字符串,用查字典的方式较检,伪代码为:
for x in String2:
if dictionary != dictionary|0x01<<x-'a':
print("NO")
else:
print("YES")
what?还不够明白,ok,看yansha对此思路的具体阐述吧:
此思路是位操作的典型应用:
dictionary = 0
for x in String1:
dictionary |= 0x01 << (x - 'a');
分析如下:
dictionary是一个32位的int,初始化为0
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
dictionary |= 0x01 << (x - 'a')则是把字符映射到dictionary当中的某一位;
比方String1 = "abde";
1、当x为‘a’时,x-‘a’为0,所以0x01<<0 为0x01。
那么dictionary |= 0x01,也就是将dictionary的第一位置1。
此时dictionary为:
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
2、当x为‘b’时,x-‘b’为1,所以0x01<<1 为0x02。
那么dictionary |= 0x02,也就是将dictionary的第二位置1。
此时dictionary为:
1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
3、当x为‘d’时,x-‘d’为3,所以0x01<<3 为0x08。
那么dictionary |= 0x08,也就是将dictionary的第四位置1。
此时dictionary为:
1 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
4、当x为‘e’时,x-‘e’为4,所以0x01<<4 为0x10。
那么dictionary |= 0x10,也就是将dictionary的第五位置1。
此时dictionary为:
1 | 1 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
其他字符依此类推,比较过程也类似。对于128个字符的ASCII码而言,显然一个32位的整形是不够的。
OK,算法完成。时间复杂度为 O(m+n),空间复杂度为O(1)。
然后,代码可以编写如下:
4.2、还可以如luuillu所说,判断字符串是否包含,采用移位的方法(此帖子第745楼:http://topic.csdn.net/u/20101126/10/b4f12a00-6280-492f-b785-cb6835a63dc9_8.html?seed=423056362&r=72955051#r_72955051),他的代码编写如下:
第四节总结:正如十一文章在本文评论里所提到的那样,上面的位图法,hash,还有bitmap三者之间并没有本质上的区别,只是形式上不同而已。
第五节、字符串相关问题扩展与阐述
5.1、字符串匹配问题
题目描述:
假设两个字符串中所含有的字符和个数都相同我们就叫这两个字符串匹配,比如:abcda和adabc,
由于出现的字符个数都是相同,只是顺序不同,所以这两个字符串是匹配的。
要求高效实现下面的函数: boolen Is_Match(char *str1,char *str2)。
分析:可以看出,此字符串的匹配问题,是与上述字符串包含的问题相类似的,这个问题可以先排序再比较,也可以利用hash表进行判断。这里给出一种hash表的方法,原理已在上文中阐明了,代码如下:
5.2、在字符串中查找子串
题目描述:
给定一个字符串A,要求在A中查找一个子串B。
如A="ABCDF",要你在A中查找子串B=“CD”。
分析:比较简单,相当于实现strstr库函数,主体代码如下:
//copyright@ 2011 July && luoqitai //2013/4/16 updated //string为模式串,substring为要查找的子串 int strstr(char *string,char *substring) { int len1=strlen(string); int len2=strlen(substring); for (int i=0; i<=len1-len2; i++) //复杂度为O(m*n) { for (int j=0; j<len2; j++) { if (string[i+j]!=substring[j]) break; //多谢小明_ML指正 if (j==len2-1) return 1; } } return 0; }
上述程序已经实现了在字符串中查找第一个子串的功能,时间复杂度为O(n*m),继续的优化可以先对两个字符串进行排序,然后再查找,也可以用KMP算法,复杂度为O(m+n)。具体的,在此不再赘述(多谢hlm_87指正)。
扩展:还有个类似的问题:第17题(字符串):题目:在一个字符串中找到第一个只出现一次的字符。如输入abaccdeff,则输出b。代码,可编写如下(测试正确):
当然,代码也可以这么写(测试正确):
5.3、字符串转换为整数
题目:输入一个表示整数的字符串,把该字符串转换成整数并输出。
例如输入字符串"345",则输出整数345。
分析:此题看起来,比较简单,每扫描到一个字符,我们把在之前得到的数字乘以10再加上当前字符表示的数字。这个思路用循环不难实现。然其背后却隐藏着不少陷阱,正如zhedahht 所说,有以下几点需要你注意:
1、由于整数可能不仅仅之含有数字,还有可能以'+'或者'-'开头,表示整数的正负。如果第一个字符是'+'号,则不需要做任何操作;如果第一个字符是'-'号,则表明这个整数是个负数,在最后的时候我们要把得到的数值变成负数。
2、如果使用的是指针的话,在使用指针之前,我们要做的第一件是判断这个指针是不是为空。如果试着去访问空指针,将不可避免地导致程序崩溃(此第2点在下面的程序不需注意,因为没有用到指针)。
3、输入的字符串中可能含有不是数字的字符。
每当碰到这些非法的字符,我们就没有必要再继续转换。
4、溢出问题。由于输入的数字是以字符串的形式输入,因此有可能输入一个很大的数字转换之后会超过能够表示的最大的整数而溢出。
总结以上四点,代码可以如下编写:
@helloword:这个的实现非常不好,当输入字符串参数为非法时,不是抛出异常不是返回error code,而是直接exit了。直接把进程给终止了,想必现实应用中的实现都不会这样。建议您改改,不然拿到面试官那,会被人喷死的。ok,听从他的建议,借用zhedahht的代码了:
updated:yansha看到了上述helloword的所说的后,修改如下:
5.4、字符串拷贝
题目描述:
要求实现库函数strcpy,
原型声明:extern char *strcpy(char *dest,char *src);
功能:把src所指由NULL结束的字符串复制到dest所指的数组中。
说明:src和dest所指内存区域不可以重叠且dest必须有足够的空间来容纳src的字符串。
返回指向dest的指针。
分析:如果编写一个标准strcpy函数的总分值为10,下面给出几个不同得分的答案:
勘误:上述字符串拷贝的写法有点问题,正如本文评论下留言的读者paranoid2006所述:字符串拷贝的问题,虽然限定源字符串为const,但是不能防止src和dest有overlap,层叠的问题。也就是在拷贝src到dest的中途会导致dest覆盖部分src的字符串。
当dest+strlen(src)大于src或者src+strlen(src)大于dest均有可能导致覆盖。
除了此题外,本文其它函数的编写也存在不少问题,日后统一修正! 欢迎读者继续批评指正,谢谢。July、二零一二年九月二十三日。
联系作者:
微博:http://weibo.com/julyweibo @ July,http://weibo.com/yanshazi @ yansha。
邮箱:zhoulei0907@yahoo.cn @ July,yansha0@hotmail.com @ yansha。
预告:程序员面试题狂想曲、第三章,4月底之前发布。
ok,以上,有任何问题,欢迎任何人不吝指正。谢谢。完。
版权声明:1、严禁用于任何商业用途;2、未经许可,严禁出版;3、转载,务必注明出处。