友情提示:本文中的代码只为展示思考逻辑,本身并不严谨,而且没有处理极值等特殊情况,默认参数都在正常范围内,请勿在正式场合直接拷贝使用!!!
问题:
把字符串前面若干个字符移动到字符串的尾部,例如:字符串abcdef左旋2位,得到cdefab。
请实现左旋函数,要求对长度为n的字符串操作的时间复杂度为O(n),空间复杂度为O(1)。
思路一 : 直接位移
此题要求空间复杂度为O(1), 所以我们按照最简单自然的想法,每次移动一位,左旋m位只要循环m次就可以了。先写个左旋一位的函数:
private char[] rotateLeftOne(char[] s) {
char temp = s[0];
int length = s.length;
for (int i = 1; i < length; i++) {
s[i - 1] = s[i];
}
s[length - 1] = temp;
return s;
}
然后,左移m位的话就是:
private char[] rotateLeft(char[] s, int m) {
while (m > 0) {
s = rotateLeftOne(s);
m--;
}
return s;
}
很容易可以计算出 时间复杂度为O(mn)* 空间复杂度为O(1)
思路二 : 三步翻转法
我们换一种思路来看这个问题,跳出向左移动这个想法。
将这个字符串拆成两部分来看,左旋出去的那段设为X,剩余部分设为Y。要得到YX就可以先将X反转,再将Y反转,最后将整个字符串反转。举个例子:
假如现在要将abcdef这个字符串左旋3位,按照上面的想法需要三步:
1.需要左旋的3位反转得到:cba def
2.剩余部分反转得到:cba fed
3.再整体反转得到:defabc
盗张图给大家看的更清晰一些:
根据三步翻转法的思路,我们写一下代码,翻转某一段的函数可以这么写:
private static char[] reverseCharArray(char[] s, int start, int end) {
int length = (end - start) == 0 ? 0 : end - start + 1; // 起始结束同一位置不处理
int n = length >> 1;
for (int i = 0; i < n; i++) {
char temp = s[start + i];
s[start + i] = s[end - i];
s[end - i] = temp;
}
return s;
}
然后,左旋的函数可以写成:
private static char[] rotateString(char[] s, int m) {
if(m==0)
return s;
s = reverseCharArray(s, 0, m - 1);
s = reverseCharArray(s, m, s.length - 1);
s = reverseCharArray(s, 0, s.length - 1);
return s;
}
最后我们看一下效率问题:第一步循环了 m/2 次,第二步循环了(n-m)/2次,第三次循环了n/2次
时间复杂度为O(n) 空间复杂度为O(1)
很高效的方法了
当然我们的探索不能只到这里,继续
思路三 : 指针翻转法
思考一下,对字符串的左旋,我们能不能看作是前几位和后面几位交换位置呢?举个特殊的例子来理解一下:
假如有abcdefg这个串需要左旋1位,那么我们可以把需要移动的字符a向后移动,与他之后的每个元素交换位置,直到它到达末尾。这样我们就得到了需要的结果(bcdefa)。
当然,一个字符很容易处理,我们只需要把它和后面的元素依次交换位置就可以,如果是多个字符需要移动呢?比如上面的例子,需要左旋2位,该怎么处理呢 ?
按照我们之前的方法来走一下:
1.字符ab后移,和后面的元素换位:cdabefg
2.继续后移:cdefabg
问题来了,后面位置不够了怎么破?
3.先把有位置的移走,这样左旋剩余的字符已经全部移动到正确位置,我们只需要对左旋的串做一下位置调整:cdefgba
4.位置调整,可以看作是ba这个字符串,左旋1位,重复上面的操作就可以:cdefgab
还不理解?再盗图一张:
明白一些了吧,有了思路我们开始撸代码
private static char[] forwardIteratorReverse(char[] s, int m) {
return forwardIteratorReverse(s, 0, m);
}
private static char[] forwardIteratorReverse(char[] s, int start, int middle) {
if (middle <= start)
return s;
int mod = (s.length - middle) % (middle - start); // 最后会剩余几位
while (middle < s.length) {
char temp = s[start];
s[start] = s[middle];
s[middle] = temp;
middle++;
start++;
}
// 不需要再调整位置了
if (mod == 0)
return s;
else {
return forwardIteratorReverse(s, start, s.length - mod);
}
}
当然也可以严格按照盗图上的思路,每次移动m个位置标记为一次,在每次开始之前进行特殊情况处理:
写法二:
private static char[] forwardIteratorReverse2(char[] s, int m) {
return m == 0||m==s.length ? s : forwardIteratorReverse2(s, 0, m);
}
private static char[] forwardIteratorReverse2(char[] s, int start, int middle) {
int forepartLength = middle - start;
int secondHalfLength = s.length - middle;
// 前段小于后段,可以安全移动
if (forepartLength < secondHalfLength) {
for (int i = 0; i < forepartLength; i++) {
char temp = s[start];
s[start] = s[middle];
s[middle] = temp;
middle++;
start++;
}
}
// 前段等于后段,交换完成即结束
else if (forepartLength == secondHalfLength) {
for (int i = 0; i < forepartLength; i++) {
char temp = s[start];
s[start] = s[middle];
s[middle] = temp;
middle++;
start++;
}
return s;
}
// 前段大于后段,middle位置不变,进行交换
else {
int middleCopy = middle;
for (int i = 0; i < secondHalfLength; i++) {
char temp = s[start];
s[start] = s[middleCopy];
s[middleCopy] = temp;
middleCopy++;
start++;
}
}
return forwardIteratorReverse2(s, start, middle);
}
根据代码逻辑,可计算出 时间复杂度为O(n) 空间复杂度为O(1) ,满足题目的要求。
思路四 : 循环位移法
下面我们从整体角度来考虑一下。一个字符串,实质发生左旋m位后,每个字符都变动了位置。需要左旋的几位会移动到字符串末尾,而剩余的位置会向左移动m位。既然每个位置都做了变动,那么能不能找出一个位置会被哪个位置的元素替代呢?举个例子:
abcd这个字符串左旋1位:
1.我们先把需要左移的位置a取出来->_bcd
2.a空出来的位置会被b占了->b_cd
3.而b空出来的位置会被c占了,c空的位置会被d占了->bc_d ->bcd_
4.最后d空出来的位置是被a占了,把之前移出去的a放到d原来的位置 ->bcda
这样就得到了我们想要的结果。
再来举个稍微复杂一点的例子帮助理解:
abcde左旋2位:
1.还是老步骤,将a移出去 ->_bcde
2.a的位置被2个位置后的c占了 ->cb_de
3.c原来的位置被2位后的e占了 ->cbed_
4.e原来的位置被2位后的b占了(因为e是最后一位,所以被左旋的b占了) ->c_edb
5.b原来的位置被2位后的d占了 ->cde_b
6.d原来的位置被2位后的a占了,把a移回来 ->cdeab
也得到了结果。很容易发现规律,每次移动都把一个元素放到了正确的位置上,每个元素移动的距离都是需要左旋的m位。根据上面的规律,我们不难写出计算的公式{m%n,2m%n...}。
到这里当然没有结束,刚才上面的问题是一种特殊情况(m和n互为质数)。如果不是互为质数会发生什么问题呢? 还是第一个例子,我们改成左旋2位。按照上面的思路,a的位置被c占了,c的位置被a占了。这样就形成了一个闭环,不管b和c的事了。这样的话,我们想要讲所有的元素都放到正确位置就需要2次循环。这里需要仔细理解清楚。
这里其实使用了一个数学定理:若有两个正整数m、n,且gcd(m,n)=d,那么序列{m%n,2m%n, 3m%n,..., nm%n}一定是{0, d, 2d,..., n-d}的某个排列并重复出现d次。比如若m=6,n=8,d=gcd(m,n)=2,那么{6%8, 12%8,18%8,..., 48%8}即为{0,2,4,6}的某个排列并重复两次,事实上也正是{6,4,2,0, 6,4,2, 0}。特别地,若m、n互素,d=1,那么序列{m%n,2m%n,3m%n,...,(n-1)m%n}实际上就是{1, 2,3,..., n-1}的某个排列。
gcd(m,n) 是求m和n的最大公约数的一种方式:辗转相除法,这里我们不做讨论,后面会有一篇专门探讨求最大公约数的实现。
有了上面的思考和理论,终于可以下手写代码了。
private static char[] randomInterator(char[] s, int m) {
int length = s.length;
int d = gcd(m, length);
for (int i = 0; i < d; i++) {
int n = 2;
int start = i + m % length;
int lastPosition = start;
int position = i + (n * m) % length;
char temp = s[start];
while (position != start) {
s[lastPosition] = s[position];
lastPosition = position;
n++;
position = i + (n * m) % length;
}
s[lastPosition] = temp;
}
return s;
}
有任何问题欢迎联系我改正。
参考:
程序员编程艺术:第一章、左旋转字符串
【STL源码剖析读书笔记】【第6章】算法之rotate算法