程序员编程艺术第二十七章:不改变正负数相对顺序重新排列数组(无解?)

  第二十七章:不改变正负数之间相对顺序重新排列数组.时间O(N),空间O(1)


前言

    在这篇文章:九月腾讯,创新工场,淘宝等公司最新面试十三题的第5题(一个未排序整数数组,有正负数,重新排列使负数排在正数前面,并且要求不改变原来的正负数之间相对顺序),自从去年九月收录了此题至今,一直未曾看到令人满意的答案,为何呢?

    因为一般达不到题目所要求的:时间复杂度O(N),空间O(1),且保证原来正负数之间的相对位置不变。本编程艺术系列第27章就来阐述这个问题,若有任何漏洞,欢迎随时不吝指正。谢谢。

重新排列使负数排在正数前面

原题是这样的:

一个未排序整数数组,有正负数,重新排列使负数排在正数前面,并且要求不改变原来的正负数之间相对顺序。
比如: input: 1,7,-5,9,-12,15 ,ans: -5,-12,1,7,9,15 。且要求时间复杂度O(N),空间O(1) 。

    OK,下面咱们就来试着一步一步解这道题,如下5种思路(从复杂度O(N^2)到O(N*logN),从不符合题目条件到一步步趋近于条件)):

  1. 最简单的,如果不考虑时间复杂度,最简单的思路是从头扫描这个数组,每碰到一个正数时,拿出这个数字,并把位于这个数字后面的所有数字往前挪动一位。挪完之后在数组的末尾有一个空位,这时把该正数放入这个空位。由于碰到一个正,需要移动O(n)个数字,因此总的时间复杂度是O(n2)。
  2. 既然题目要求的是把负数放在数组的前半部分,正数放在数组的后半部分,因此所有的负数应该位于正数的前面。也就是说我们在扫描这个数组的时候,如果发现有正数出现在负数的前面,我们可以交换他们的顺序,交换之后就符合要求了。
    因此我们可以维护两个指针,第一个指针初始化为数组的第一个数字,它只向后移动;第二个指针初始化为数组的最后一个数字,它只向前移动。在两个指针相遇之前,第一个指针总是位于第二个指针的前面。如果第一个指针指向的数字是正而第二个指针指向的数字是负数,我们就交换这两个数字。
    但遗憾的是上述方法改变了原来正负数之间的相对顺序。所以,咱们得另寻良策
  3. 首先,定义这样一个过程为“翻转”:(a1,a2,...,am,b1,b2,...,bn) --> (b1,b2,...,bn,a1,a2,...,am)。其次,对于待处理的未排序整数数组,从头到尾进行扫描,寻找(正正...正负...负负)串;每找到这样一个串,则计数器加1;若计数为奇数,则对当前串做一个“翻转”;反复扫描,直到再也找不到(正正...正负...负负)串。

    此思路来自朋友胡果果,空间复杂度虽为O(1),但其时间复杂度O(N*logN)。更多具体细节参看原文:http://qing.weibo.com/1570303725/5d98eeed33000hcb.html。故,不符合题目要求,继续寻找。

  4. 我们可以这样, 设置一个起始点j, 一个翻转点k,一个终止点L,从右侧起,起始点在第一个出现的负数, 翻转点在起始点后第一个出现的正数,终止点在翻转点后出现的第一个负数(或结束)。

    如果无翻转点, 则不操作,如果有翻转点, 则待终止点出现后, 做翻转, 即ab => ba 这样的操作。翻转后, 负数串一定在左侧, 然后从负数串的右侧开始记录起始点, 继续往下找下一个翻转点。

    例子中的就是(下划线代表要交换顺序的两个数字):

    1, 7, -5, 9-12, 15  
    第一次翻转: 1, 7, -5, -12,9, 15   =>  1, -12, -5, 7, 9, 15
    第二次翻转: -5, -12, 1, 7, 9, 15

    此思路2果真解决了么?NO,用下面这个例子试一下,我们就能立马看出了漏洞:
    1, 7, -5, -6, 9, -12, 15(此种情况未能处理)
    1 7 -5 -6 -12 9 15
    1 -12 -5 -6 7 9 15
    -6 -12 -5 1 7 9 15   (此时,正负数之间的相对顺序已经改变,本应该是-5,-6,-12,而现在是-6 -12 -5)
  5. 看来这个问题的确有点麻烦,不过我们最终貌似还是找到了另外一种解决办法,正如朋友超越神所说的:从后往前扫描,遇到负数,开始记录负数区间,然后遇到正数,记录前面的正数区间,然后把整个负数区间与前面的正数区间进行交换,交换区间但保序的算法类似(a,bc->bc,a)的字符串原地翻转算法。交换完之后要继续向前一直扫描下去,每次碰到负数区间在正数区间后面,就翻转区间。下面,将详细阐述此思路4。

思路5之区间翻转

    其实上述思路5非常简单,既然单个翻转无法解决问题,那么咱们可以区间翻转阿。什么叫区间翻转?不知读者朋友们是否还记得本blog之前曾经整理过这样一道题,微软面试100题系列第10题,如下:

10、翻转句子中单词的顺序。
题目:输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变。句子中单词以空格符隔开。为简单起见,标点符号和普通字母一样处理。例如输入“I am a student.”,则输出“student. a am I”。
而此题可以在O(N)的时间复杂度内解决

    由于本题需要翻转句子,我们先颠倒句子中的所有字符。这时,不但翻转了句子中单词的顺序,而且单词内字符也被翻转了。我们再颠倒每个单词内的字符。由于单词内的字符被翻转两次,因此顺序仍然和输入时的顺序保持一致。
    以上面的输入为例:翻转“I am a student.”中所有字符得到“.tneduts a ma I”,再翻转每个单词中字符的顺序得到“students. a am I”,正是符合要求的输出(编码实现,可以参看此文:http://zhedahht.blog.163.com/blog/static/254111742007289205219/)。

    对的,上述思路3就是这个意思,单词翻转便相当于于区间翻转,既如此,咱们来验证下上述思路2中那个测试用例,如下:

1, 7, -5, -6, 9, -12, 15
1 7 -5 -6 -12 9 15
-12 -6 -5 7 1 9 15   (借用单词翻转的方法,先逐个数字翻转,后正负数整体原地翻转)
-5 -6 -12 1 7 9 15


思路5再次被质疑

    但是,我还想再问,问题至此被解决了么?真的被KO了么?NO,咱们来看这样一种情况,正如威士忌所说:

看看这个数据,+-+-+-+-+------+,假如Nminus 等于 n/2,由于前面都是+-+-+-,区间交换需要 n/2/2 = n/4次,每次交换是 T(2*(Nminus + Nplus)) >= T(n),n/4 * T(n) = T(n*n/4)=O(n^2)。

    还有一种更坏的情况,就是+-+-+-+-+------+这种数据可能,后面一大堆的负数,前面正负交替。所以,咱们的美梦再次破灭,路漫漫其修远兮,此题仍然未找到一个完全解决了的方案。


公开征集本题思路

    下面公开征集解决思路:如果你能想到完全满足和符合题目三个条件的思路(不改变相对顺序&时间O(N)&空间O(1)),欢迎随时在本文下留言或评论,或者发至我邮箱:[email protected]

    如果验证完全正确属实,或者要么你就证明在那三个条件下此题无解,当然,你若提出了本文内没有的思路,虽然严格论证下可能并不符合题目三个条件,但我将依然邀请您作为听众来参加读书会第2期(到场嘉宾可能包括为pongba,xlvector,penny,以届时现场为准),再或者,你能指出本文文末updated部分:本题思路征集中的那些思路的不符要求与错误,也行,限前10位,额满为止。

    具体时间、场地待后续确定(微博上和本blog内会届时通知)。


updated:本题思路征集中之一解一点评

      关于本题不改变正负数相对顺序重新排列数组,陆陆续续有不少朋友或发来了邮件,或在本文评论下提供了他们自己的思路或解法,思维理性碰撞,共同享受思考的乐趣,我觉挺有意思,精选其中一些解答贴出来,让大家评判、讨论,如下:

  • 第1解:from Muqi:Hi July,

    很高兴看到你的问题,真的很有意思!

    我这里想到一种解法,主要的思路是通过改变数字内容来实现保留数字之间相对的顺序:

    比方说数列 3 4 -1 -3 5 2 -7 6 1
    那负数相对顺序是: -1 -3 -7, 正数是 3 4 5 2 6 1 
    我们可以做变形:
     负数成为 -1.1 -2.3, -3.7 (整数部分为相对顺序,小数部分为原来的数字)
     同理 正数为: 1.3  2.4  3.5  4.2  5.6  7.1

    现在数组变成: 1.3  2.4  -1.1  -2.3  3.5   4.2  -3.7  4.6  5.1

    接下去 先通过置换把所有负数排到前面:具体方法为从前往后扫描数组,每碰到一个负数 就和数组最前面的正数交换, 结果如下:
       -1.1  -2.3  -3.7 2.4  3.5  4.2  1.3  4.6  5.1
    可以看到负数部分已经完成题目要求(只需要把整数部分去掉即可),接下去对于正数数列 2.4  3.5  4.2  1.3  4.6  5.1, 也用类似的方法还原先前的顺序:具体方法为一次遍历每个数字,检查其整数部分是否与其所在的位置相同,如不相同,将该数字与位置为该数字整数部分的交换, 比如说2.4 整数部分为2,但是现在位于数列第一位,所以与位于第二位的3.5交换,得到:3.5  2.4  4.2  1.3  4.6  5.1(最多只需要O(n)因为每次交换都保证一个数字回到原来的位置,而总共有n个数字),最后和前面负数的处理相同,即去掉整数部分( +1,邀请来参加读书会第2期)。

    点评:但此方法在本文评论下马上有人指出:不过,整数变成浮点数,存储空间要扩大一倍,跟申请一个大小为n的数组一个道理,空间复杂度O(N)不符要求。更多请看本文评论下第18楼。(zj060607 & topskycen,+2)。

  • 第2解form 立宋(+7):
    July巨巨,
    由于在csdn上那贴删改留言次数有点多,csdn不让留言了,就发邮件给您吧。应该是最终稿了。

    稍微改动下Muqi的方法,可以得到平均时间O(n),最坏时间O(n^2),空间复杂度O(1)的。当把负数放到数列前半部分操作时,这个负数是和前面的一个正数交换的。交换过后,把这个正数变成他的相反数(5变成-5这种)。那么当第一轮把负数放到前面过后,剩下的部分又形成了一个相同的子问题。当然,后面几轮把负数放到前面后,得把他们重新恢复成相应的正数。
    还是用3 4 -1 -3 5 2 -7 6 1为例子:
    第一轮: -1 -3 -7 [ -3 -4 2 -5 6 1].
    第二轮: -1 -3 -7 -(-3) -(-4) -(-5) [-2 6 1].
    第三轮: 1 3 7 3 4 5 2 6 1
    最终添上-号,-1 -3 -7 3 4 5 2 6 1
    平均时间复杂度(假设数组是随机的):
    T(n)=T(n/2)+O(n). T(n)=O(n).

    如果遇到+++++...+-这种情况,就会导致最坏的时间复杂度。
    这也不算是完美的解法。有点怀疑完美的解是不存在的,但不知道怎么证明。

    谢谢,
    mourisl

    点评from litaoye:我的想法(见下文之综合点评)也许同上述解法2类似,但我确实没看明白解法2的操作过程,并且我认为解法2十有八九是错的。举个例子来说,如

 1,2,-4,-5,3,-6

-4,2,1,-5,3,-6

-4,-5,1,-2,3,-6

-4,-5,-6,-2,3,-1

这样的话-2同-1的顺序就乱了。更多请看本文评论下第29楼。

  • 第3解:jiangbin00cn在其blog中提出了一种新的思路:假设全体数据为n个,正数m个分别映射到1--m,这m个数是分散分布在空间n中,利用桶排序使得其排列在n-m--n中,这个过程用到了桶排序的思想,只不过每个桶中只有一个元素。具体步骤如下:
    (1)桶排序能够在 时间O(N),空间O(1) 实现,那么能否利用桶排序解决该问题,即如何将该问题转换为桶排序问题
    (2)通过可逆的修改元素使得数组满足桶排序要求
    (3)利用桶排序实现
    (4)恢复元素
    假设原数组中的全体正数按顺序依次为:a[0],...a[n]
    (a[0],a[1],....a[n]) = f(x) => (b[0],b[1],....b[n])= g(x) => (0,1,....,n)  ==> 桶排序
      原始正数(可能相同)               (修改为全不相同正数)            
    (0,1,....,n) =g'(x)=> (b[0],b[1],....b[n])=f'(x)=> (a[0],a[1],....a[n])
                                可逆运算恢复数据               可逆运算恢复数据
    结论:
    由于桶排序能够在 时间O(N),空间O(1) 实现,若可逆函数f(x)、g(x)能够在 时间O(N),空间O(1)中找到并实现,那么就能够解决该问题(+3)。具体代码实现,请参见原文:http://blog.csdn.net/jiangbin00cn/article/details/7331387

    点评from 威士忌(+5),jiangbin00cn和Muqi的方法都很取巧。其实他们的方法都是压缩了整数值域或者扩大值域来保存附加信息,虽然符合时间O(N)和空间O(1)的要求,但是并不适用所有int值。

    这些方法的思路其实很简单,比如:
num[] = {1,7,-5,9,-12,15};
pos[]={2,3,0,4,1,5};
pos的计算扫描2遍num数组即可,有了pos数组当然排序不成问题。
关键解决pos空间问题时,两位做法分别是,Muqi保存到double浮点域,jiangbin00cn是利用进制方法保存到int高位(其实根本不需桶排了),更明显的做法就是flag(num[i])*pos[i]*1000+num[i]转换为3007,-0005,-1012。

很高兴看到如此让人眼前一亮的方法,但是仔细想想的话,就觉得还是不符合要求。

  • 第4解:from topskychen & acmerfight(+6):

    首先明确题目的题意要求空间复杂度是O(1),我的理解就是只能有一个空间来存储数据,其他的任何临时变量都不能出现,包括循环变量和临时开辟的空间(例如数字交换时)。

    下边我的解法是在 允许自己输入数据,可以利用数组大小n的情况下产生的,只包含一个额外的变量。

    pos记录正数的最最左位置减一, a[pos]记录负数最右的位置加一

    基本步骤:

    pos代表最后一个数据的位置

    然后输入一个数据存储在最后的位置 a[pos]

    如果输入的数据a[pos]是正数,我们就让pos = pos - 1;如果输入的是负数就把这个负数挪到前边,让a[pos]-1来记录负数最右的位置。

    发生相应交换

    代码在本文评论下第28楼。


综合点评

  1. from 威士忌,感觉最近的几种解法越来越倒退了,还不如之前nlogn 的来的有价值。
  2. from litaoye:只想到了n*log(n),O(1)的方法。双指针分别指向头和尾,头指针找到的正数同尾指针找到的负数交换,直到2个指针相遇。交换过程中将所有交换元素 * -1,也就是正数变负数,负数变正数。此时被换到尾部的正数(*-1后已经变为负数),顺序正好倒过来了,把这部分反转一下。整个过程O(n),把原问题转化为两个规模为n/2的子问题。因此根据主定理,整个过程应当是nlog(n)的,即最坏情况下是n^2的,不过平均情况下也只是nlog(n)的,达不到O(n)。用迭代的方法写,应该可以做到O(1)(用递归,空间复杂度就是log(n)了),感觉这个问题很难找到O(n),O(1)的解法,类似的问题有完美洗牌问题,LZ可以看一下,解法比较复杂,是通过原根构造置换群来解的。本来还有个原地归并的思路,后来发现有问题,没有继续深入。
  3. from sbwwkmyd:除非也能找到划分固定环的方法,一直没找到办法将原根的特性应用到这个问题上,完美洗牌问题 是这个问题的一个很小的子集。这个问题应该无解。
  4. from July:有friends反应,算法导论第8章线性时间排序思考题8-2:以线性时间原地置换排序,是此题原型。说运行时间为O(n)、稳定、不使用额外空间原地排序,这3个条件中,三者只能满足其二,由此推出第27章此题无解?果真如此么?如何证明?此题作为面试题,能当场K掉99%的面试者/面试官。
    也有朋友反应,根据算导第8章中定理8.1:任意一个比较排序算法在最坏情况下,都需要nlgn次比较。即给定n个不同的输入元素,对于任何确定或随机的比较排序算法,其期望运行时间都有下界O(nlgn)。由此推出此题无解。但他们忽略了:不一定非要排序非要比较。

    也就是说,尽管:
  1. 插入.归并.堆.快速排序皆是基于比较排序,且除归并排序外,皆是原地排序算法。
  2. 堆/归并排序运行时间上界皆为O(nlgn)。
  3. 计数排序非基于比较,非原地排序,但稳定,是基数排序算法的一个子过程。
  4. 计数/基数等非原地(需借助外部空间)排序,空间换时间。
    但本题统统与这些无关,因为追根究底,本题实质性上只是一个排列,重新组合问题,与排序无关
    更多还可参考此论文:《STABLE MINIMUM SPACE PARTITIONING IN LINEAR TIME》。待后续验证。

你可能感兴趣的:(程序员编程艺术第二十七章:不改变正负数相对顺序重新排列数组(无解?))