今天笔者在做算法练习题的时候做到了一道轮转数组的问题,问题详情如下所示:
给定一个整数数组
nums
,将数组中的元素向右轮转k
个位置,其中k
是非负数。
在此,笔者提供由浅入深三种解决该问题的方法。
1.轮换法
轮换法指的是通过每次将数组里的元素右移一位,向右轮转k个位置也就是将上述操作循环操作k次,即可达到我们想要的效果。
在这里,我们需要考虑一些细节。
设数组长度为n,当k小于n时,我们需要循环k次右移一位的操作,但是在k大于n时,我们循环k-n次与循环k次得到的结果是相同的,后n次循环右移将进行无效操作。由此我们可知,对数组元素的循环右移我们只需要执行k%n次。
另外若k为0,则不需要进行操作。
#include <stdio.h>
void rotate(int* nums, int numsSize, int k)
{
if(k == 0)//如果k为0,则不需要对数组进行操作
{
return;
}
//pre用来存放数组的当前元素,next用来使pre与数组的下一个元素交换
int pre,next;
for(int i = 0;i < k;i++)//共右移k个元素,所以要执行k次循环
{
pre = nums[0];//用来记录最开始的下标值
for(int j = 1;j < numsSize;j++)
{
//这里使用next使pre与数组的下一元素交换
next = nums[j];
nums[j] = pre;
pre = next;
}
//循环结束的时候数组下标0的元素应为执行前的最后一个元素
nums[0] = pre;
}
}
int main(void)
{
int nums[6]={1,2,3,4,5,6};//数组初始化为1,2,3,4,5,6
int k = 2;
int len;
len = sizeof(nums)/sizeof(nums[0]);
k = k % len;
rotate(nums,len,k);
for(int i = 0;i < 6;i++)
{
printf("%d ",nums[i]);
}
printf("\n");
return 0;
}
这种方法在数组长度为n,右移k位的情况下共要进行n*k次操作,时间复杂度为O(n*k),但是没有使用额外的空间,空间复杂度为O(1)。
2.使用额外数组空间改进轮换法
在上述方法中,我们通过k次对数组元素右移一位的操作来实现了我们想要的功能,但是时间的消耗似乎有点太高了,那么有没有一种时间消耗不那么高的方法来提高效率呢。
答案是肯定的,我们可以使用用空间换时间的思想。
在我们之前考虑的细节中,我们右移k位实际上只需要移动k%n次,每个元素的后k%n个位置就是该元素最后存放的位置。那么只要我们每次移动元素都向右移动k%n个元素不就好了!
为了解决数组元素移动时覆盖的问题,我们就需要定义额外的k个空间来存放数组最后的k个元素(此时k = k % n),在剩下n-k个元素右移k个后再讲这k个元素挨个放在数组开头的k个位置就大功告成了。
如下图所示:
#include <stdio.h>
#include <stdlib.h>
void rotate(int* nums, int numsSize, int k)
{
if(k > 0)//这里需要注意若k=0,则数组会创建失败
{
int *mov = (int *)malloc(k * sizeof(int));//使用动态分配避免空间浪费
for(int i = 0;i < k;i++)//先把后k个元素存到新数组中
{
mov[i] = nums[numsSize - k + i];
}
//把剩下n-k个元素向右移动k个单元
for(int i = numsSize - k - 1;i >= 0;i--)
{
nums[i + k] = nums[i];
}
//最后把新数组中的元素移动回原来数组的开头
for(int i = 0;i < k;i++)
{
nums[i] = mov[i];
}
free(mov);//释放动态分配的内存空间
}
}
int main(void)
{
int nums[6]={1,2,3,4,5,6};//数组初始化为1,2,3,4,5,6
int k = 2;
int len;
len = sizeof(nums)/sizeof(nums[0]);
k = k % len;
rotate(nums,len,k);
for(int i = 0;i < 6;i++)
{
printf("%d ",nums[i]);
}
printf("\n");
return 0;
}
使用此种方法改进之后,我们只需要在最开始移动后k个元素,然后将n-k个元素右移,最后再把最开始那k个元素放到数组开头,共需要进行n+k次操作,时间复杂度为O(n+k),极大提升了效率!
但是基于我们是使用空间换时间,该算法额外使用了k个空间来存放元素,空间复杂度为O(k)。
综合来看,此种方法在大大提升效率的同时没有占用大量额外空间,是一种较优的算法。
3.环状替换法
当我们理解了以上两种算法时,我们认识到第二种方法使用额外的数组来降低了轮换法的时间复杂度,但是牺牲了一部分的存储空间。那么有没有一种方法能在不牺牲那么多空间的前提下,也大大提升轮换法的效率呢?
答案是肯定的,这就是笔者即将介绍的环装替换法。
让我们先设定一种情况:数组长度n为6,我们需要向右移动k = 2个位置。
如下图所示:
我们可以发现从数组下标0开始,每次只考虑右移当前元素a,若元素a即将移动到的位置上的元素b还没有移动过,那我们就要先保存元素b的值,然后再把b用以上的方法右移。
具体一点说,在上图的例子中,我们首先移动0号元素,0号元素即将移动到的位置为2号元素,我们将2号元素保存,接着移动到4号元素,4号元素保存,此时0号元素已经移动过了,所以我们直接把4号元素移动到0号元素的位置就大功告成了。
那么还有1号、3号、5号元素没有移动过,再执行一次上述操作我们就得到了我们想要的每个元素向右移动两个元素的目标数组。
所以我们记每次从一个元素开始移动到别的元素移动到该元素的位置上我们数组下标扫过数组最后一个元素的次数为a(简单点说就是循环了a圈),在此过程中一共遍历的元素个数为b。
在上图的例子中,我们从0号元素出发,分辨遍历了0号、2号、4号元素,最后回到0号元素,一共循环了1圈,遍历了三个元素。a = 1,b = 3。
所以我们可以得出一个公式:a * n = b * k。
这可能有点不好理解,形象一点说就是
a * n:循环a圈,一圈n个元素,数组下标一共走过了a * n个元素。
b * k:遍历了b个元素,每次遍历下标向后跳k个,所以数组下标一共走过了b * k个元素。
所以在这个例子中,a = 1,b = 3,n = 6,k = 2。a * n = b * k = 6。
说了这么多,我们来再举一个例子来验证我们得出的结论
这个例子中n = 7,k = 2,数组下标从0号开始,回到0号时一共循环了2圈,一共遍历了7个元素。
所以a = 2,b = 7,n = 7,k = 2。
a * n = b * k = 14
此时我们还发现a的值不是唯一的,在n = 6的例子中循环1圈和循环4圈都可以得到我们的目标数组。所以从效率的角度去考虑的话,我们应该保证a的值是满足该式的最小值,即a * n为n和k的最小公倍数。即 b = lcm(n,k)/k。(注:lcm为最小公倍数)
所以n / b为我们需要多次操作循环a圈的次数。(注:在n = 6的例子中,n / b = 2,说明我们需要从0号和1号开始做2次循环右移的操作,同在 n= 7的例子中,n / b = 1,那么我从0号开始右移到回到0号结束时我们就可以得到目标数组了)
所以我们最后得到循环a圈的次数为b * k / lcm(n,k) = gcd(n,k)。(gcd为最大公约数)
至此,我们就可以通过gcd(n,k)次的从数组一个元素出发,下k个元素挨个右移来得到我们的目标数组了。
#include <stdio.h>
//使用地址来交换a和b的值
void swap(int * a,int * b)
{
int tmp;
tmp = *a;
*a = *b;
*b = tmp;
}
//使用递归函数来求a和b的最大公约数
int gcd(int a,int b)
{
if(a < b)
{
swap(&a,&b);
}
if(b == 0)
{
return a;
}
int tmp = gcd(b,a%b);
return tmp;
}
void rotate(int* nums, int numsSize, int k)
{
int count = gcd(k, numsSize);//count为a和b的最大公约数,也就是循环a圈的次数
for (int start = 0; start < count; ++start)
{
int current = start;//定义一个变量来辅助交换
int prev = nums[start];
do {
//数组里的元素挨个右移k个
int next = (current + k) % numsSize;
swap(&nums[next], &prev);
current = next;
} while (start != current);//先执行一次右移操作,等下次右移回到最开始元素时结束循环
}
}
int main(void)
{
int nums[6]={1,2,3,4,5,6};//数组初始化为1,2,3,4,5,6
int k = 2;
int len;
len = sizeof(nums)/sizeof(nums[0]);
k = k % len;
rotate(nums,len,k);
for(int i = 0;i < 6;i++)
{
printf("%d ",nums[i]);
}
printf("\n");
return 0;
}
由此种方法改进后,我们只需要对当前数组进行操作,不再需要额外的空间来辅助操作了,大大降低了空间复杂度,空间复杂度为O(1)。同时,每个元素仅需要移动一次就可以找到该元素在目标数组里的位置,故共移动n次,时间复杂度为O(n),相比上面两种算法也大大提升了时间上的效率。
4.总结
那么至此,三种对于实现轮转数组操作的算法就全部介绍完了。我们可得出对于算法的优化我们可以采用以时间换空间和以空间换时间的方法,但是在有些时候我们也可以想到一些即节省时间由节省空间的方法,但是会格外费脑子(<- _ <-)。没有人可以在刚拿到一个问题就得出最完美的解决方案,更好的解决方案永远都是下一个。遇到问题可以先从简单的算法开始,最后再慢慢优化成时间和空间上都高效的算法。本文到这里就告一段落了,祝大家变得更强,在今后遇到问题时能够写出更加高效的方法 。
同名文章发布于苏嵌教育公众号:使用环状替换法解决轮转数组问题