HOWTO · Java
在 Java 中移动数组
这篇文章演示了在 Java 中移动数组
本页内容
移动数组可能指两种不同操作。旋转会让元素循环移动并保留所有元素;向右旋转两位会把末尾两个元素移到开头。删除开头元素则会返回较短的序列,并丢弃这些值。如果需要保留数组中的所有内容,应选择旋转;如果只想处理后缀,则应选择跳过元素。
除非另有说明,下面的示例都执行向右旋转。正数表示向右旋转,负数表示向左旋转;大于数组长度的距离可以对数组长度取模来归一化。数组长度固定,因此返回新数组的代码只有在不重新赋值或修改原数组时,才会让调用方的原数组保持不变。
下面的示例都是独立的 Java 程序,循环置换算法的示例还附有输出。
先判断你要的是旋转,还是删除开头元素
如果需要 int[] 这样的基本类型数组,可以使用循环或临时数组。如果数据已经是 List,或者引用类型数组可以作为列表使用,则可以用 Collections.rotate()。如果需要直接修改数组、不分配另一个数组,并且能够接受较复杂的代码,可以使用循环置换算法。如果要丢弃流开头的前 n 个元素,可以使用 skip();这不是旋转,也不会保留原来的长度。
用循环原地旋转数组
这种方法每次向右移动一位。它先保存末尾的值,再将其他值向右移动,最后把保存的值放到索引零处。示例从包含五个整数的数组开始,向右旋转两位。此方法会修改传入的数组,只使用常数级额外空间;数组长度为 n、归一化后的距离为 k 时,运行时间为 O(n × k)。
运行此示例:
本节中的代码以及显示的标签或输出仍为英文。
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] values = {1, 2, 3, 4, 5};
int distance = 2;
if (values.length > 0) {
int steps = Math.floorMod(distance, values.length);
for (int step = 0; step < steps; step++) {
int last = values[values.length - 1];
for (int i = values.length - 1; i > 0; i--) {
values[i] = values[i - 1];
}
values[0] = last;
}
}
System.out.println(Arrays.toString(values));
}
}
Math.floorMod 会将正数和负数距离归一化到从零到数组长度减一的范围内。长度检查可以避免除以零,并让空数组保持不变。归一化后的距离为零时,数组同样不会改变。对于较小的距离,这种重复移动的方法很简单;对于较大的数组或距离,后面两种数组操作都只需移动每个元素一次。
使用临时数组进行旋转
如果需要直接的索引映射,而且可以接受 O(n) 的额外存储空间,可以分配第二个数组。向右旋转前,先将距离归一化为 steps,这样负数和超出数组长度的距离也能正确处理。旧索引 i 处的元素会移动到 ((long) i + steps) % n;中间值使用 long,可避免相加有效的 int 索引时溢出。空数组应在进行取模之前直接返回。
本节中的代码以及显示的标签或输出仍为英文。
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] values = {1, 2, 3, 4, 5};
int distance = 2;
int[] rotated = new int[values.length];
if (values.length > 0) {
int steps = Math.floorMod(distance, values.length);
for (int i = 0; i < values.length; i++) {
int destination = (int) (((long) i + steps) % values.length);
rotated[destination] = values[i];
}
}
System.out.println(Arrays.toString(rotated));
}
}
目标索引公式会将超出末尾位置的索引绕回开头。对于任何有效的 Java 数组长度,long 类型的和都不会超出范围;取模后,目标索引会落在零到 values.length - 1 之间。对数组长度取模后为每个索引加上相同的归一化 steps,构成一一映射,因此每个源索引都会对应一个不同的目标索引,元素的相对顺序也会保留。源数组不会改变;如果希望用旋转后的数组替代原数组,请使用返回值。
使用 Collections.rotate() 旋转列表
Collections.rotate() 操作的是 List:正数距离表示向右旋转,负数距离表示向左旋转。对于引用类型数组,Arrays.asList(array) 会创建一个由同一数组支持的定长列表:可以替换元素,也可以旋转,但不支持添加或删除列表元素。将列表转换回数组会得到一个独立数组。int[] 这样的基本类型数组无法通过 Arrays.asList 变成 List<Integer>;应先将值装箱,或改用专门处理数组的算法。
本节中的代码以及显示的标签或输出仍为英文。
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
Integer[] values = {1, 2, 3, 4, 5};
List<Integer> list = Arrays.asList(values);
Collections.rotate(list, 2);
Integer[] rotated = list.toArray(new Integer[0]);
System.out.println(Arrays.toString(rotated));
}
}
这里的列表和 values 引用同一块元素存储空间,因此旋转列表也会改变数组中元素的顺序。另行创建的 rotated 数组也包含旋转后的值。这种方式处理对象数组和列表时很简洁,但如果把基本类型数据转换成包装类型对象,就会发生装箱。
使用循环置换算法原地旋转
循环置换算法会把数组划分成多个循环。向左旋转 d 位时,每个当前位置会从索引 current + d 读取元素,超过数组长度时绕回开头。为了避免计算这个回绕索引时溢出,可以使用:current >= n - d ? current - (n - d) : current + d。由于 d 已归一化到零至 n - 1 的范围,结果也会落在零至 n - 1 之间;当 d 为零时,结果就是 current。独立循环的数量为 gcd(n, d):每个循环会沿途传递值,直到回到起始索引。该算法需要 O(n) 次移动和 O(1) 的额外存储空间;如果不希望分配另一个数组,这一点可能很有用。
本节中的代码以及显示的标签或输出仍为英文。
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] values = {1, 2, 3, 4, 5};
int rightDistance = 2;
int n = values.length;
if (n > 0) {
int rightSteps = Math.floorMod(rightDistance, n);
int leftDistance = (n - rightSteps) % n;
int cycles = gcd(n, leftDistance);
for (int start = 0; start < cycles; start++) {
int saved = values[start];
int current = start;
while (true) {
int next = current >= n - leftDistance
? current - (n - leftDistance)
: current + leftDistance;
if (next == start) break;
values[current] = values[next];
current = next;
}
values[current] = saved;
}
}
System.out.println(Arrays.toString(values));
}
private static int gcd(int a, int b) {
while (b != 0) {
int remainder = a % b;
a = b;
b = remainder;
}
return a;
}
}
[4, 5, 1, 2, 3]
代码先将请求的向右旋转距离归一化为 rightSteps,再用 (n - rightSteps) % n 转换成等效的向左旋转距离。对于包含五个元素的数组,向右旋转距离 2 和 -2 分别归一化为两步和三步,因此对应的向左旋转距离分别为三步和两步;由于先进行归一化,Integer.MIN_VALUE 也能安全处理。向左旋转时,每个循环都会从下一个索引读取元素,并在数组末尾绕回开头。代码中的条件计算避免了在 current 与 leftDistance 之和会超过 n - 1 时直接相加;无论走哪个分支,结果都会在零至 n - 1 之间,即使未回绕的和超过 Integer.MAX_VALUE 也一样。gcd(n, leftDistance) 就是循环数。归一化后的距离为零时,gcd(n, 0) 等于 n,条件计算会返回 current,因此每个值都会原样写回。长度检查会在计算取模之前处理空数组。循环和临时数组方法更容易维护;只有确实需要常数级额外空间时,才优先考虑循环置换算法。
使用 Stream API 跳过开头元素
如果要从结果中省略开头的值,而不是把它们绕到末尾,请使用 skip()。它会返回仅包含剩余元素的流,因此将该流转换为数组后,得到的数组会更短,源数组也不会改变。跳过数量等于或大于元素数量时,结果为空。skip() 要求数量不能为负数;如果调用方提供的数量可能为负数,请先验证或将其限制在有效范围内。
本节中的代码以及显示的标签或输出仍为英文。
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] values = {1, 2, 3, 4, 5};
long count = 2;
int[] remainder = Arrays.stream(values).skip(count).toArray();
System.out.println(Arrays.toString(remainder));
}
}
Arrays.stream(values) 会创建一个整数流,skip(count) 会省略前 count 个元素,而 toArray() 会将剩余元素收集到一个新的基本类型数组中。这种方式适合处理后缀,但会分配结果数组;如果必须保留所有元素,它就不能替代旋转。