程序员面试题狂想曲:第一章、左旋转字符串

                       程序员面试题狂想曲:第一章、左旋转字符串


作者:July,yansha。
时间:二零一一年四月十四日。
说明:狂想曲,有三层意思:1、思绪纷飞,行文杂乱无章,想到什么,记下什么。2、简单问题深入化,复杂问题精细化,不惧汪洋,不惧艰深,洋洋洒洒,纵横千里。3、依托一道面试题展开来,思维放任不羁,逐步深入,细致入微,反复修正,绝不含糊,以期给读者一个彻彻底底明明白白的交待。
微博:http://weibo.com/julyweibo
出处:http://blog.csdn.net/v_JULY_v
-------------------------------------------

目录

前言

第一节、左旋转字符串

第二节、左旋字符串之思路二(请着重看此思路,同时,也是为后文讲解stl rotate算法做铺垫)

第三节、通过递归转换,缩小问题之规模

第四节、stl::rotate 算法的步步深入

第五节、总结

 


    一个懒散的午夜,程序员躺在椅子上,静静点上一支烟,瞅着屏幕上那一行一行如行云流水般的代码,赏心悦目,渐觉困意,便慢慢闭上了俩眼,养神...。

    而后,冥冥中房间里似缓缓响起一首钢琴曲,叫不出名字,却铿锵有力且清脆无比,忽而激荡,忽而平静。激荡处,如波涛翻滚,怒洋咆哮,平静处,如潺潺流水,鸟语花香。

    半响,程序员突然睁开双眼,关掉编译器,打开记事本,信笔由缰,急速记录下他那杂乱无章,和奇特跳跃的思绪,他怕此刻不赶紧记下来,以后,风吹云散.....于是,世间就有了,程序员面试题狂想曲,一乐章的诞生。

    此曲终日弹奏,绵绵不绝,终至广为流传,飘进了千万人的耳中,余音不去....


前言
    本人整理微软等公司面试100题系列,包括原题整理,资源上传,帖子维护,答案整理,勘误,修正与优化工作,包括后续全新整理的80道,总计180道面试题,已有半年的时间了。

    关于这180道面试题的一切详情,请参见:横空出世,席卷Csdn [评微软等数据结构+算法面试180题]

    一直觉得,这180道题中的任何一题都值得自己反复思考,反复研究,不断修正,不断优化。之前的答案整理由于时间仓促,加之受最开始的认识局限,更兼水平有限,所以,这180道面试题的答案,有很多问题都值得进一步商榷与完善。

    特此,想针对这180道面试题,再写一个系列,叫做:程序员面试题狂想曲系列。如你所见,我一般确定要写成一个系列的东西,一般都会永久写下去的。

    “他似风儿一般奔跑,很多人渐渐的停下来了,而只有他一直在飞,一直在飞....”
   
    ok,本次系列以之前本人最初整理的微软面试100题中的第26题、左旋转字符串,为开篇,希望就此问题进行彻底而深入的阐述。然以下所有任何代码仅仅只是全部测试正确了而已,还有很多的优化工作要做。欢迎任何人,不吝赐教。谢谢。

 

第一节、左旋转字符串
题目描述:

定义字符串的左旋转操作:把字符串前面的若干个字符移动到字符串的尾部。
如把字符串abcdef左旋转2位得到字符串cdefab。
请实现字符串左旋转的函数,要求对长度为n的字符串操作的时间复杂度为O(n),空间复杂度为O(1) 

 

    一听左旋,右旋之类的,便想到了红黑树中有关左旋,右旋的操作,不过,本题显然与此无关。ok,我最初在之前上传的答案:答案V0.3版[第21-40题答案]中,整理并提供了以下俩种思路:


1.1、思路一:
以下的4点文字分析,引自网友zhedahht(http://zhedahht.blog.163.com/blog/#m)。
    分析:
    1、如果不考虑时间和空间复杂度的限制,最简单的方法莫过于把这道题看成是把字符串分成前后两部分,通过旋转操作把这两个部分交换位置。
    2、于是我们可以新开辟一块长度为n+1的辅助空间,把原字符串后半部分拷贝到新空间的前半部分,在把原字符串的前半部分拷贝到新空间的后半部分。不难看出,这种思路的时间复杂度是O(n),需要的辅助空间也是O(n
)。(为了避免版权纠纷,此后本BLOG中将尽量不再原文引用任何人的文字、思路,或原代码。July,updated,2011.05.02.

updated:
    3、对于这个问题,咱们换一个角度可以这么做:
将一个字符串分成两部分,X和Y两个部分,在字符串上定义反转的操作X^T,即把X的所有字符反转(如,X="abc",那么X^T="cba"),那么我们可以得到下面的结论:(X^TY^T)^T=YX。显然我们这就可以转化为字符串的反转的问题了。

不是么?ok,就拿abcdef 这个例子来说(非常简短的三句,请细看,一看就懂):
1、首先分为俩部分,X:abc,Y:def;
2、X->X^T,abc->cba, Y->Y^T,def->fed。
3、(X^TY^T)^T=YX,cbafed->defabc,即整个翻转。

我想,这下,你应该了然了。
然后,代码可以这么写(已测试正确):

    1.2、答案V0.3版中,第26题勘误:

    之前的答案V0.3版[第21-40题答案]中,第26题、贴的答案有误,那段代码的问题,最早是被网友Sorehead给指出来的:

第二十六题:
楼主的思路确实很巧妙,我真没想到还有这种方法,学习了。
不过楼主代码中存在问题,主要是条件判断部分:
函数LeftRotateString中 if (nLength > 0 || n == 0 || n > nLength)
函数ReverseString中 if (pStart == NULL || pEnd == NULL)

    当时,以答案整理因时间仓促,及最开始考虑问题不够周全为由,没有深入细看下去。后来,朋友达摩流浪者再次指出了上述代码的问题:

  26题 这句 if(nLength > 0 || n == 0 || n > nLength),有问题吧?
  还有一句,应该是if(!(pStart == NULL || pEnd == NULL)),吧。

    而后,修改如下(已测试正确)

上述,修正的俩处错误,如下所示:
1、如上注释中所述:       
if(nLength >0 && n<nLength) 
//nLength是整个字符串的长度吧,n是左边的一部分,所以应该是n<nLength。

2、至于之前的主函数为什么编写错误,请看下面的注释:
int main()
{
    char *ps="hello July";  //本身没错,但你不能对ps所指的字符串做任何修改。
    //这句其实等价于:const char *ps = "hello July"
    LeftShiftString( ps, 4 );  //而在这里,试图修改ps所指的字符串常量,所以将出现错误。
    puts( ps );
    return 0;
}

当然,上面的解释也不是完全正确的,正如ivan所说:从编程实践来说,不完全对。
如果在一个大的工程里面,你怎么知道ps指向的是""字符串,还是malloc出来的东西? 
那么如何决定要不要对ps进行delete?

不过,至少第26题的思路一的代码,最终完整修正完全了。

    1.3、updated:
    可能你还是感觉上述代码,有点不好理解,ok,下面再给出一段c实现的代码
然后,我们可以看到c的高效与简洁。


第二节、左旋字符串之思路二请着重看此思路,同时,也是为后文讲解stl rotate算法做铺垫):
    先看下网友litaoye 的回复:26.左旋转字符串跟panda所想,是一样的,即,
以abcdef为例
1. ab->ba
2. cdef->fedc
原字符串变为bafedc
3. 整个翻转:cdefab 
  //只要俩次翻转,且时间复杂度也为O(n)。

    2.1、在此,本人再奉献另外一种思路,即为本思路二
abc defghi,要abc移动至最后
abc defghi->def abcghi->def ghiabc

定义俩指针,p1指向ch[0],p2指向ch[m];
一下过程循环m次,交换p1和p2所指元素,然后p1++, p2++;。

第一步,交换abc 和def ,
abc defghi->def abcghi
第二步,交换abc 和 ghi,
def abcghi->def ghiabc

整个过程,看起来,就是abc 一步一步 向后移动
abc defghi
def abcghi
def ghi abc 
  //最后的 复杂度是O(m+n)  

以下是朋友颜沙针对上述过程给出的图解:

    2.2、各位读者注意了:

    由上述例子九个元素的序列abcdefghi,您已经看到,m=3时,p2恰好指到了数组最后一个元素,于是,上述思路没有问题。但如果上面例子中i 的后面还有元素列?

    即,如果是要左旋十个元素的序列:abcdefghij,ok,下面,就举这个例子,对abcdefghij序列进行左旋转操作:

如果abcdef ghij要变成defghij abc:
  abcdef ghij
1. def abc ghij
2. def ghi abc j
3. def ghi ab jc
4. def ghi a j bc
5. def ghi j abc //

 下面,再针对上述过程,画个图清晰说明下,如下所示:

  ok,咱们来好好彻底总结一下此思路二(就4点,请仔细阅读)

1、首先让p1=ch[0]p2=ch[m-1],即让p1p2相隔m的距离;

2、判断p2+m是否越界,如果没有越界转到3,否则转到4

3、不断交换*p1*p2,然后p1++p2++,循环m次,然后转到2

4、此时p2+m已经越界,在此只需处理尾巴。过程如下:

   4.1 通过n-p2得到p2与尾部之间元素个数r,即我们要前移的元素个数。

   4.2 以下过程执行r次:

       ch[p2]<->ch[p2-1]ch[p2-1]<->ch[p2-2]....ch[p1+1]<->ch[p1]p1++p2++

 

    我编写的此思路二的伪码如下(注,信手而写,不负责结果正确):
//July,此思路二的伪码如下:
   //下文已经编写出完整代码,且此伪码也是有问题的。
int rorate(int* p1,int* p2)  
{  
    int ch[n]=new int[];  
    p1=p2=ch[0];  
    for(int k=1;k<=m;k++)  
    {  
        p2++;   //p2后移m个单位  
    }  
    while((p2++)!=NULL)  
    {    
        swap(*p1,*p2);  
        p1++;  
        if(p2+1)  
            p2++;  
        else 
            break;   //此处,我让之跳出来处理。  
    }  
    for(r=m+1;r>0
;r--)  
    {  
        //只要p2-m!=p1,p2就不断继续--,然后与前面的元素交换。  
                  //....  
        //相当于j前移到abc的前面,最终序列为jabc。  
    }  

updated:
    于是,根据上述思路,及伪码,不难写出下述代码下面,马上您能见到,下述代码还是有问题的):

Update again:
    很抱歉,上述代码,后来颜沙指出,是有问题的,比如换个字符串测试,拿这个字符串"abcdefghijk",测试,则将出现错误,所以上述代码最终修正如下:

   

     2.3、或者如颜沙所说:

      最初的那个左旋转九个元素abcdefghi的思路在末尾会出现问题的(如果p2后面有元素就不能这么变,例如,如果是处理十个元素,abcdefghij 列?对的,就是这个意思),他的解决方法有俩个,分别记录如下:

    方法一:
def ghi abc jk
当p1指向a,p2指向j时,由于p2+m越界,那么此时p1,p2不要变
这里p1之后(abcjk)就是尾巴,处理尾巴只需将j,k移到abc之前,得到最终序列,即为上面总结的思路二。

    方法二:
def ghi abc jk
当p1指向a,p2指向j时,那么交换p1和p2,p1++,p2++
此时为 def ghi jbc ak
p1指向b,p2指向k,继续上面步骤得
def ghi jkc ab
p1指向c,p2指向b,p1和p2之间(cab)也就是尾巴,那么处理尾巴(cab)需要循环左移一定次数

    根据方案二,不难写出下述代码(已测试正确):

     

  注意:上文中都是假设m<n,且如果鲁棒点的话令m=m%n,这样m允许大于n。另外,各位要记得处理指针为空的情况。

 

第三节、通过递归转换,缩小问题之规模
    本文最初发布时,网友留言bluesmic说:楼主,谢谢你提出的研讨主题,很有学术和实践价值。关于思路二,本人提一个建议:思路二的代码,如果用递归的思想去简化,无论代码还是逻辑都会更加简单明了。

    就是说,把一个规模为N的问题化解为规模为M(M<N)的问题。
    举例来说,设字符串总长度为L,左侧要旋转的部分长度为s1,那么当从左向右循环交换长度为s1的小段,直到最后,由于剩余的部分长度为s2(s2==L%s1)而不能直接交换。

    该问题可以递归转化成规模为s1+s2的,方向相反(从右向左)的同一个问题。随着递归的进行,左右反复回荡,直到某一次满足条件L%s1==0而交换结束。

     举例解释一下:
    设原始问题为:将“123abcdefg”左旋转为“abcdefg123”,即总长度为10,旋转部("123")长度为3的左旋转。按照思路二的运算,演变过程为“123abcdefg”->"abc123defg"->"abcdef123g"。这时,"123"无法和"g"作对调,该问题递归转化为:将“123g”右旋转为"g123",即总长度为4,旋转部("g")长度为1的右旋转。

updated:

Ys

Bluesmic的思路没有问题,他的思路以前很少有人提出。思路是通过递归将问题规模变小。当字符串总长度为n,左侧要旋转的部分长度为m,那么当从左向右循环交换长度为m的小段直到剩余部分为m(n % m),此时m < m不能直接交换了

此后,我们换一个思路,把该问题递归转化成规模大小为m +m,方向相反的同一问题。随着递归的进行,直到满足结束条件n % m==0

 

  举个具体事例说明,如下:

1对于字符串abc def ghi gk

abc右移到def ghi gk后面,此时n = 11m = 3m = n % m = 2;

abc def ghi gk -> def ghi abc gk

2问题变成gk左移到abc前面,此时n = m + m = 5m = 2m = n % m 1;

abc gk -> a gk bc

3问题变成a右移到gk后面,此时n = m + m = 3m = 1m = n % m = 0;

a gk bc-> gk a bc。 由于此刻,n % m = 0,满足结束条件,返回结果

 

    即从左至右,后从右至左,再从左至右,如此反反复复,直到满足条件,返回退出。

    代码如下,已测试正确(有待优化):

     

 非常感谢。

    稍后,由下文,您将看到,其实上述思路二的本质即是下文将要阐述的stl rotate算法,详情,请继续往下阅读

 

第四节、stl::rotate 算法的步步深入
思路三:

3.1、数组循环移位
    下面,我将再具体深入阐述下此STL 里的rotate算法,由于stl里的rotate算法,用到了gcd的原理,下面,我将先介绍此辗转相除法,或欧几里得算法,gcd的算法思路及原理。

    gcd,即辗转相除法,又称欧几里得算法,是求最大公约数的算法,即求两个正整数之最大公因子的算法。此算法作为TAOCP第一个算法被阐述,足见此算法被重视的程度。

    gcd算法:给定俩个正整数m,n(m>=n),求它们的最大公约数。(注意,一般要求m>=n,若m<n,则要先交换m<->n。下文,会具体解释)。以下,是此算法的具体流程:
    1[求余数],令r=m%n,r为n除m所得余数(0<=r<n);
    2、[余数为0?],若r=0,算法结束,此刻,n即为所求答案,否则,继续,转到3;
    3、[重置],置m<-n,n<-r,返回步骤1.

    此算法的证明,可参考计算机程序设计艺术第一卷:基本算法。证明,此处略。

    ok,下面,举一个例子,你可能看的更明朗点。
    比如,给定m=544,n=119,
      则余数r=m%n=544%119=68; 因r!=0,所以跳过上述步骤2,执行步骤3。;
      置m<-119,n<-68,=>r=m%n=119%68=51;
      置m<-68,n<-51,=>r=m%n=68%51=17;
      置m<-51,n<-17,=>r=m%n=51%17=0,算法结束,

    此时的n=17,即为m=544,n=177所求的俩个数的最大公约数

    再解释下上述gcd(m,n)算法开头处的,要求m>=n 的原因:举这样一个例子,如m<n,即m=119,n=544的话,那么r=m%n=119%544=119,
    因为r!=0,所以执行上述步骤3,注意,看清楚了:m<-544,n<-119。看到了没,尽管刚开始给的m<n,但最终执行gcd算法时,还是会把m,n的值交换过来,以保证m>=n。

    ok,我想,现在,你已经彻底明白了此gcd算法,下面,咱们进入主题,stl里的rotate算法的具体实现。//待续。

    熟悉stl里的rotate算法的人知道,对长度为n的数组(ab)左移m位,可以用stl的rotate函数(stl针对三种不同的迭代器,提供了三个版本的rotate)。但在某些情况下,用stl的rotate效率极差。

    对数组循环移位,可以采用的方法有(也算是对上文思路一,和思路二的总结):
      flyinghearts:
      ① 动态分配一个同样长度的数组,将数据复制到该数组并改变次序,再复制回原数组。(最最普通的方法)
      ② 利用ba=(br)^T(ar)^T=(arbr)^T,通过三次反转字符串。(即上述思路一,首先对序列前部分逆序,再对序列后部分逆序,再对整个序列全部逆序)
      ③ 分组交换(尽可能使数组的前面连续几个数为所要结果):
      若a长度大于b,将ab分成a0a1b,交换a0和b,得ba1a0,只需再交换a1 和a0。
      若a长度小于b,将ab分成ab0b1,交换a和b0,得b0ab1,只需再交换a 和b0。
      通过不断将数组划分,和交换,直到不能再划分为止。分组过程与求最大公约数很相似。
      ④ 所有序号为 (j+i *m) % n (j 表示每个循环链起始位置,i 为计数变量,m表示左旋转位数,n表示字符串长度),会构成一个循环链(共有gcd(n,m)个,gcd为n、m的最大公约数),每个循环链上的元素只要移动一个位置即可,最后整个过程总共交换了n次(每一次循环链,是交换n/gcd(n,m)次,总共gcd(n,m)个循环链。所以,总共交换n次)。

    stl的rotate的三种迭代器,即是,分别采用了后三种方法。

    在给出stl rotate的源码之前,先来看下我的朋友ys对上述第④ 种方法的评论:
    ys:这条思路个人认为绝妙,也正好说明了数学对算法的重要影响。

    通过前面思路的阐述,我们知道对于循环移位,最重要的是指针所指单元不能重复。例如要使abcd循环移位变成dabc(这里m=3,n=4),经过以下一系列眼花缭乱的赋值过程就可以实现:
    ch[0]->temp, ch[3]->ch[0], ch[2]->ch[3], ch[1]->ch[2], temp->ch[1];  (*)
    字符串变化为:abcd->_bcd->dbc_->db_c->d_bc->dabc;
是不是很神奇?其实这是有规律可循的。

    请先看下面的说明再回过头来看。
 对于左旋转字符串,我们知道每个单元都需要且只需要赋值一次,什么样的序列能保证每个单元都只赋值一次呢?

      1、对于正整数m、n互为质数的情况,通过以下过程得到序列的满足上面的要求:
 for i = 0: n-1
      k = i * m % n;
 end

    举个例子来说明一下,例如对于m=3,n=4的情况,
        1、我们得到的序列:即通过上述式子求出来的k序列,是0, 3, 2, 1
        2、然后,你只要只需按这个顺序赋值一遍就达到左旋3的目的了:
    ch[0]->temp, ch[3]->ch[0], ch[2]->ch[3], ch[1]->ch[2], temp->ch[1];   (*) 

    ok,这是不是就是按上面(*)式子的顺序所依次赋值的序列阿?哈哈,很巧妙吧。当然,以上只是特例,作为一个循环链,相当于rotate算法的一次内循环。

     2、对于正整数m、n不是互为质数的情况(因为不可能所有的m,n都是互质整数对),那么我们把它分成一个个互不影响的循环链,正如flyinghearts所言,所有序号为 (j + i * m) % nj为0到gcd(n, m)-1之间的某一整数,i = 0:n-1会构成一个循环链,一共有gcd(n, m)个循环链,对每个循环链分别进行一次内循环就行了。

    综合上述两种情况,可简单编写代码如下:

   

    3.2、以下,便是摘自sgi stl v3.3版中的stl_algo_h文件里,有关rotate的实现的代码:

由于上述stl rotate源码中,方案④ 的代码,较复杂,难以阅读,下面是对上述第④ 方案的简单改写:

关于本题,不少网友也给出了他们的意见,具体请参见此帖子微软100题,维护地址

  

第五节、总结 
     如nossiac所说,对于这个数组循环移位的问题,真正最靠谱的其实只有俩种:一种是上文的思路一,前后部分逆置翻转法,第二种是思路三,即stl 里的rotate算法,其它的思路或方法,都是或多或少在向这俩种方法靠拢。

    下期更新:程序员面试题狂想曲:第二章。时间:本周周日04.24晚。非常感谢各位朋友的,支持与关注。本人宣告:本程序员面试题狂想曲系列,永久更新
本章完。


版权声明:转载本BLOG内任何文章和内容,务必以超链接形式注明出处。

posted on 2011-04-14 13:14  July_  阅读(1003)  评论(0编辑  收藏  举报