一道可以用群论的算法


前言

一道leetcode上的题目:

给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

初级算法 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

示例 :

输入: nums =,
输出:
解释:
向右轮转 1 步:
向右轮转 2 步:
向右轮转 3 步:

显而易见这是一种轮换,但可能是多次轮换,它不改变数组本身,只改变元素的位置。也就是说,这是一个置换。

先回忆下一个置换的写法:

是n元集合上的一个双射置换,为了简单记为。令表示上的一个置换,第一行表示原象,第二行表示像的集合。

我们可以写出示例中的置换群表示:

也就是把1号位的换成5号位的,把2号位的换成6号位的,3号位的换成7号位的,依此类推。

这个置换是直观的,也是容易理解的,我们很容易就有了第一种想法。

算法尝试

置换表示

置换是非常直观的方法,但问题是,如果我直接把1号位的元素换成5号位的,那我就必须将1号位的元素放在一个临时变量里,否则直接赋值的话,1号位的元素就被覆盖了。

我们可以使用一个临时数组,先把原数组的值存放到一个临时数组中,然后再把临时数组的值重新赋给原数组,重新赋值的时候要保证每个元素都要往后移k位,如果超过数组的长度就从头开始,所以这里可以使用(i + k) % length来计算重新赋值的元素下标。

public void rotate(int nums[], int k) {
    int length = nums.length;
    int temp[] = new int[length];
    //把原数组值放到一个临时数组中,
    for (int i = 0; i < length; i++) {
        temp[i] = nums[i];
    }
    //然后在把临时数组的值重新放到原数组,并且往右移动k位
    for (int i = 0; i < length; i++) {
        nums[(i + k) % length] = temp[i];
    }
}

轮换表示

相信不用我说都看出来了,如果这个数组特别大的时候,这样做的内存消耗是很大的。

但是众所周知,一般的置换表示可以写成轮换表示。

定义:一个置换如果把数码变成变成,…,变成又变成,而使得其余的元(假如还有的话)都不变,则称为一个k-轮换(循环)置换。记为:

注:2-轮换简称为对换。恒等映射通常记为(1)

我们把上文示例的置换表示改写成轮换:

实际上,有定理保证,对于每个置换都可以写成若干个不相连的轮换之积,因此这种写法总是可行的。

我们观察轮换元的规律,似乎是将数组视作一个环形,1号位向右移3位到达4号位,4号位向右移3位到达7号位,7号位向右移3位到达10号位,由于最后一个与第一个相连,因此8号位就是1号位,10号位就是3号位。

依照这个思路,我们可以写出轮换表示的算法。

public static void rotate(int[] nums, int k) {
    int hold = nums[0];
    int index = 0;
    int length = nums.length;
    boolean[] visited = new boolean[length];
    for (int i = 0; i < length; i++) {
        index = (index + k) % length;
        if (visited[index]) {
            //如果访问过,再次访问的话,会出现原地打转的现象,
            //不能再访问当前元素了,我们直接从他的下一个元素开始
            index = (index + 1) % length;
            hold = nums[index];
            i--;
        } else {
            //把当前值保存在下一个位置,保存之前要把下一个位置的
            //值给记录下来
            visited[index] = true;
            int temp = nums[index];
            nums[index] = hold;
            hold = temp;
        }
    }
}

这里需要注意的是,如果数组长度length和轮换步长k不互质的话,将会在某一次轮换后回到原点。因此保留了一个数组visited表示这个元素有没有被访问过,如果被访问过就从他的下一个开始,防止原地打转。

此时意味着该置换群不止一个不相连轮换。

对换表示

我们还是希望在轮换的基础上更进一步。一般的置换方法中,我们需要有一个大的临时数组,一般来说会带来很大的内存消耗。轮换方法中,数组的下标需要依赖大量的求余运算,这种运算总是更耗时的。

如果我们可以找到一个简单的方法,通过对换就能实现算法,那应该既能避免大的内存消耗,也能够节省时间。我们来看看chatGPT和leetcode给的答案。

public void rotate(int[] nums, int k) {
    int length = nums.length;
    k %= length;
    reverse(nums, 0, length - 1);//先反转全部的元素
    reverse(nums, 0, k - 1);//在反转前k个元素
    reverse(nums, k, length - 1);//接着反转剩余的
}
//把数组中从[start,end]之间的元素两两交换,也就是反转
public void reverse(int[] nums, int start, int end) {
    while (start < end) {
        int temp = nums[start];
        nums[start++] = nums[end];
        nums[end--] = temp;
    }
}

leetcode和chatGPT给出的答案都是利用翻转实现的。

当然,大家都知道,反转无非就是一前一后的数组元素进行对换。也就是说反转给出的算法其实正是对换操作。

对换与轮换

显而易见地,对换方法在时间上有着得天独厚的优势。但我们在这里讨论一点更数学的问题。

假如我们采用翻转的方法去实现功能,我们需要对数组元素进行对换操作。我想谈论的问题是:

1、翻转方法中,至多几次对换操作,就能够保证对所有情况都能达到预期效果。

答案:次。(为数组长度)

2、至少几次对换操作,才能够保证对所有情况都能达到预期效果。

答案:次。(为数组长度)

(第二问的意思是,如果你只能进行次对换操作,那么无论你怎样优化算法,总有数组你无法达到预期效果)

第一问的答案是容易证明的。

证明:共进行了三次翻转,第一次对数组个元素进行翻转,第二次对数组前个元素进行翻转,第三次对数组后个元素进行翻转。

容易证明对i个元素的数组进行翻转需要次对换,则总共需要次对换

注意到,因此,不可能同时为奇数,也不可能只有两个偶数,因此只有两种情况,两奇一偶,三偶。

三者都为偶数时,取整号可以直接拆掉,此时共次对换。

两个为奇数,一个为偶数时,可将其改写成,且此时,此时共次对换。

因此至多次对换。

第二问需要借助两个置换群的定理。

定理1:任何一个轮换都可以表示为若干个对换的乘积,且对换数等于。比如:

按从右向左的运算原则,显然有:

其他,保持不变,因此定理1成立。

定理2:将一个置换分解为不相交轮换的乘积,如果不考虑因子的次序和乘积中1轮换的个数,则这个分解式是唯一的。

可以将每一个轮换都看作一个圆,这个圆和其他圆不相交,这个圆上的点做任何拓扑上的改变都会导致置换的结果发生变化,因此置换的分解公式是唯一的。

定理3:每个置换都可以表示为对换的乘积。

根据定理2,每个置换分解为唯一的不相交轮换的乘积,每个轮换可以分解为对换的乘积。因此每个置换都可以表示为对换的乘积。

下面我们就可以开始证明了:

以下证明默认

证明:我们首先需要证明,对于题目中的全轮换,当时,其置换群的轮换表示必定包含

其实就是证明不存在数组某一元素位置没有发生改变,全轮换后,数组下标(假设由1开始)将会变成。因此轮换表示必定包含所有元素。

先将置换分解为不相交轮换的乘积,设不相交轮换数为,又定理1可知,一个轮换可以拆成个对换,因此总共有个对换。

由于置换至少可以分解成一个不相交轮换,因此至少次对换操作,才能够保证对所有情况都能达到预期效果。

我们可以根据这个思路,将置换群转换为若干个对换的乘积,原则上说,是比翻转算法至少少一次对换。

当然,如果我们先写出置换群,在分解成轮换群,最后再分解成对换的乘积,这显然是不够效率的,所幸这题是一个全轮换群,全轮换群的乘积是非常容易计算的。

以下同样默认,则其中一个不相连轮换群乘积可表示成:

其中,

我们在进行数组索引的时候,是从开始的,因此,实际写代码的时候也不需要这个恼人的了。

public void rotate(int[] nums, int k){
       k = k% nums.length;
       if(k!=0){
           Overlay(nums,k);
       }

   }
   public void Overlay(int[] nums,int k){
       int length = nums.length;
       int index1=0;
       int index2=k;
       int group_amount=1;//群元数
       int total_length=0;//群元长度
       for(int i=0;i<length & total_length<length;i++){
           total_length++;
           index1=length-i-1;
           index2=(index1+length-k)%length;
           for(int j=0;j<length-1;j++){
               if(index2==length-i-1){
                   group_amount++;
                   break;
               }
               exchange(nums,index1,index2);
               index1=index2;
               index2=(index2-k+length)%length;
               total_length++;
           }
       }
   }
   public void exchange(int[] nums,int i,int j){
       int temp=nums[j];
       nums[j]=nums[i];
       nums[i]=temp;
   }

我们唯一需要注意的也许是实际对换操作的时候,我们是右结合,因此是倒着生成对换群,倒着对换的。

数组最后一个元素的指标是,因此我们还是看到了这个恼人的

这其实类似第二种方法,唯一不同的是,第二种方法通过visited数组避免重复索引,在这里,其实就是置换群分解成了多个不相连的轮换群,而我们只在其中一个轮换群中进行轮换。这里的方法是通过索引数组角标,当回到原点的时候,跳出循环。

好吧,是个负优化。还是跟第二个方法问题一样,索引脚标的时候出现了大量的求余计算。我们为了那一次的对换提升,花费了大量的运算。

但反过来说,我们从某种程度上证明了,如果我们只通过对换解决这个问题,那么翻转算法速度上就是最优解。因为它至多用了次对换,而理论上限是,但次对换的算法需要大量求余。

对换与翻转

我们刚才说过翻转算法需要次对换,而轮换分解则需要次对换。

很自然我就有一些疑问,这个能不能被事先求出来,他们的对应关系是怎么样的。

要解答这两个问题首先需要证明在第二个方法中提到的一句话:

如果数组长度length和轮换步长k不互质的话,将会在某一次轮换后回到原点。

事实上这句话的关键在于,如果数组长度length和轮换步长k不互质,那么至少存在两个或以上的不相连轮换群。

以下证明默认

证明:首先根据轮换群乘法,本问题中可能出现的置换群的一个不相连轮换群可表示成:

这是自然的,因为对于该轮换群,其对换过程为

如果互质,则当且仅当时成立,此时该不相连轮换群长度为,已经包含了所有元素。数组中找不出与它不公共的元素,因此它是唯一的不相连轮换群。

因此当不互质时,至少存在两个或以上的不相连轮换群。

通过以上的证明我们能感受到,当时,有几个满足,置换群就能分解成几个不相连轮换群的乘积。

但是大家会注意到如果,那么同样满足,一直往上,并且一定满足。因此应该是的最小公倍数。此时,,一共有

翻转算法到底需要次还是次对换,取决于他们的奇偶分布,轮换分解则需要次对换,的最小公倍数有关,因此两者并不能直接建立起联系。

但是他们还是有点关系的,参考下面的定理。

定理4:虽然一个置换表示成对换的个数不唯一,但所用对换个数的奇偶性是唯一的。

翻转算法需要次对换时,都必须是偶数,此时不互质,因此存在两个或以上的不相连轮换群,所以必须也会偶数。这样,就都是偶数了。次的情况同理。


文章作者: LYC
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 LYC !
评论
  目录