Java 挑战(四)
五、数组
数组是在连续存储区域中存储原始数据类型的值或对象引用的数据结构。类型为int的数字的数组创建如下所示,类型为String的名称的数组创建有两种变体。在后一种情况下,使用直接初始化的简写符号和语法特性,其中数组的大小由编译器根据指定元素的数量自动确定。
int[] numbers = new int[100]; // definition without data
String[] names1 = new String[] { "Tim", "Mike" }; // standard notation
String[] names2 = { "Tim", "Mike" }; // short form
5.1 导言
数组只代表一个简单的数据容器,其大小由初始化指定,可以通过属性length来确定。数组不提供任何容器功能,因此既没有访问方法,也没有任何数据封装。如果需要,这些功能必须在使用的应用程序中进行编程。然而,索引访问的陷阱一直潜伏着。如果范围从 0 到length - 1 的索引无意中未被访问,则触发ArrayIndexOutOfBoundsException。
尽管有上面提到的限制,数组仍然是重要和常见的数据结构,可以在各种情况下有效地使用,因为它们是内存高效的,并通过索引访问提供最高的性能。此外,只有数组能够直接存储基元类型(没有自动装箱的间接方式)。
在本介绍中,您将了解一维数组和多维数组。
一维数组
作为用数组处理数据和建立可能的面试问题的知识的介绍,让我们看一些例子。
文本输出
数组没有附带toString()方法,这就是为什么您有时会看到这样奇怪的输出:
jshell> String[] names = { "Tim", "Mike" }
jshell> names.toString()
$40 ==> "[Ljava.lang.String;@53bd815b"
作为一种变通方法,JDK 的Arrays.toString(values)方法可能是有益的。但是,要开始,请自己实现数组的输出:
static void printArray(final String[] values)
{
for (int i = 0; i < values.length; i++)
{
final String str = values[i];
System.out.println(str);
}
}
两种变体都提供了易于理解的表示:
jshell> Arrays.toString(names)
$43 ==> "[Tim, Mike]"
jshell> printArray(names) Tim
Mike
示例 1:交换元素
一个常见的功能是在两个位置交换元素。这可以通过提供如下方法swap(int[], int, int)以简单可读的方式实现:
public static void swap(final int[] values, final int first, final int second)
{
final int value1 = values[first];
final int value2 = values[second];
values[first] = value2;
values[second] = value1;
}
当然,你也可以只用三个赋值和一个临时变量来解决这个问题。不过,我认为之前的版本更容易理解一些。
public static void swap(final int[] values, final int first, final int second)
{
final int tmp = values[first];
values[first] = values[second];
values[second] = tmp;
}
Hint: Prefer Readability and Comprehensibility
请记住,swap()的第一个版本可能会在一段时间后由 JVM 内置的实时(JIT)编译器进行优化。无论如何,可读性和可理解性是正确性和可维护性的关键。此外,这通常有利于可测试性。
虽然保存一个赋值的 helper 变量在这里很吸引人,但在其他用例中肯定有更精细的可跟踪低级优化。它们通常更难阅读,更难理解。
示例 2:阵列的基本功能
现在让我们编写方法find(T[], T)来搜索数组中的一个值,并返回它的位置,或者为未找到的返回-1:
static <T> int find(final T[] values, final T searchFor)
{
for (int i = 0; i < values.length; i++)
{
if (values[i].equals(searchFor))
return i;
}
return -1;
}
这可以作为一个典型的搜索问题用一个while循环来解决——在循环结束时,条件以注释的形式给出:
static <T> int find(final T[] values, final T searchFor)
{
int pos = 0;
while (pos < values.length && !values[pos].equals(searchFor))
{
pos++;
}
// i >= values.length || values[i].equals(searchFor)
return pos >= values.length ? -1 : pos;
}
示例 3:删除重复项
假设正数的排序数组
int[] sortedNumbers = { 1, 2, 2, 3, 3, 4, 4, 4 };
删除重复项应该会产生以下结果:
[ 1, 2, 3, 4 ]
Job Interview Tips: Problem Solving Strategies
对于这样的作业,你应该经常问几个问题来澄清背景,获得更好的理解。对于此示例,可能的问题包括:
-
移除/删除时到底应该发生什么?
-
什么值代表无条目?
-
有必要保持数字的顺序/排序吗?
-
是否可以创建一个新的数组,或者必须在原始数组中就地执行操作?
-
对于原地,还有进一步的问题:
例 3 的解决方案 1:新数组和排序输入:假设你在消除重复项时返回一个新数组作为结果。然后,该算法分为两个阶段:
-
你从收集一个
TreeSet<E>中的数字开始。这样,重复项会被自动删除,排序和原始顺序也会保留。 1 -
第二步是基于
Set<E>准备一个新的数组。
该过程可以相当直接地实现如下:
static int[] removeDuplicatesNewArray(final int[] sortedNumbers)
{
final Set<Integer> uniqueValues = collectValues(sortedNumbers);
return convertSetToArray(uniqueValues);
}
该方法由可读的、可直接理解的源代码组成,这些源代码没有显示不必要的细节,而是反映了概念。
现在是时候用上面调用的两个助手方法来完成实现了。当把Set<Integer>转换成int[]时,你只需要稍微欺骗它一下,因为Set<E>不提供索引访问。
static Set<Integer> collectValues(final int[] sortedNumbers)
{
final Set<Integer> uniqueValues = new TreeSet<>();
for (int i = 0; i < sortedNumbers.length; i++)
{
uniqueValues.add(sortedNumbers[i]);
}
return uniqueValues;
}
static int[] convertSetToArray(final Set<Integer> values)
{
return values.stream().mapToInt(n -> n).toArray();
}
这里你可以看到另一个在实践中经常发生的困难。尽管在 Java 中,通过自动装箱/取消装箱,可以在基元类型和它们对应的包装类之间进行自动转换,但这对于数组是无效的。因此,您不能将int[]转换成Integer[],反之亦然。这就是为什么您实现了convertSetToArray()方法而没有使用Set<E>的预定义toArray()方法的原因,在您的情况下,该方法将根据调用返回Object[]或Integer[]。
您巧妙地用流 API 和方法mapToInt()以及toArray()解决了这个问题。另一种方法将在下面的实用笔记中讨论。
Note: Old School
为了更好地理解算法,您可以使用Iterator<E>实现从Set<Integer>到int[]的转换,如下所示:
static int[] convertSetToArray(final Set<Integer> uniqueValues)
{
final int size = uniqueValues.size();
final int[] noDuplicates = new int[size];
int i = 0;
// set posseses no index
final Iterator<Integer> it = uniqueValues.iterator();
while (it.hasNext())
{
noDuplicates[i] = it.next();
i++;
}
return noDuplicates;
}
使用for-each循环,您甚至可以写得更短一些:
static int[] convertSetToArray(final Set<Integer> uniqueValues)
{
final int[] noDuplicates = new int[uniqueValues.size()];
int i = 0;
for (final Integer value : uniqueValues)
{
noDuplicates[i] = value;
i++;
}
return noDuplicates;
}
示例 3 的解决方案 2:未排序/任意号码的变体之前的任务是移除已排序号码中的重复号码,使用 JDK 车载设备仍然很容易解决。但是,假设必须保持原始顺序,那么应该如何处理未排序的数据呢?具体来说,右边显示的结果应该来自左边的值序列:
[1, 4, 4, 2, 2, 3, 4, 3, 4] => [1, 4, 2, 3]
有趣的是,在这种情况下,TreeSet<E>和HashSet<E>作为结果数据结构都没有意义,因为它们都会打乱原始顺序。如果您想一想,问问有经验的同事,或者浏览一本书,您可能会发现类LinkedHashSet<E>:它几乎具有HashSet<E>的属性和性能,但是可以保持插入顺序。
当使用一个LinkedHashSet<E>时,你不必改变你的基本算法。更好的是:这种变体对已经排序的数据同样有效。因此,在 helper 方法中,您只替换所使用的数据结构,其他的都保持不变:
static Set<Integer> collectValues(final int[] numbers)
{
final Set<Integer> uniqueValues = new LinkedHashSet<>();
for (int i = 0; i < numbers.length; i++)
{
final int value = numbers[i];
uniqueValues.add(value);
}
return uniqueValues;
}
这个例子说明了对独立的、遵循 SRP(单一责任原则)的小功能进行编程的优点。更重要的是:保持public方法的可理解性,并将细节转移到(最好是私有的)helper 方法中,这通常允许您将后续的更改尽可能地保持在本地。顺便说一下,我在我的书Der Weg zum Java-Profi【Ind20a】中详细讨论了LinkedHashSet<E>和 SRP。
例 3 的解决方案 3:原地变量再次给定一个正数的排序数组
int[] sortedNumbers = { 1, 2, 2, 3, 3, 3, 4, 4, 4, 4 };
所有重复项都将被删除,但这一次不允许创建新数组。这个实现有点困难。算法如下:遍历数组并检查每个元素,看它是否已经存在以及是否重复。这种检查可以通过将当前元素与其前一个元素进行比较来执行。这种简化是可能的,因为排序是存在的——如果没有排序,解决起来会复杂得多。您从最前面的位置开始处理,一步一步地进行。因此,您收集了数组左侧没有重复的所有数字。为了知道在数组中的什么地方读或写,你分别使用名为readPos和writePos,的位置指针。如果发现重复的数字,读指针继续移动;写指针保持不动。
static void removeDuplicatesFirstTry(final int[] sortedNumbers)
{
int prevValue = sortedNumbers[0];
int writePos = 1;
int readPos = 1;
while (readPos < sortedNumbers.length)
{
int currentValue = sortedNumbers[readPos];
if (prevValue != currentValue)
{
sortedNumbers[writePos] = currentValue;
writePos++;
prevValue = currentValue;
}
readPos++;
}
}
尽管这种变体在功能上是正确的,但结果却令人困惑:
[ 1, 2, 3, 4, 3, 3, 4, 4, 4, 4 ]
这是因为您在这里工作。没有提示如何分离结果(即,直到值有效的地方和无效的,移除的值开始的地方)。因此,建议做两件事:
-
您应该返回有效范围的长度。
-
你应该用一个特殊的值删除后面的位置,比如-1 表示原始数字类型或者引用类型通常是
null。该值不能是值集的一部分。否则,愤怒和矛盾是不可避免的。
下面的修改解决了这两个问题,并使用了一个for循环,这使得一切变得更优雅、更简短:
int removeDuplicatesImproved(final int[] sortedNumbers)
{
int writeIndex = 1;
for (int i = 1; i < sortedNumbers.length; i++)
{
final int currentValue = sortedNumbers[i];
final int prevValue = sortedNumbers[writeIndex - 1];
if (prevValue != currentValue)
{
sortedNumbers[writeIndex] = currentValue;
writeIndex++;
}
}
// delete the positions that are no longer needed
for (int i = writeIndex; i < sortedNumbers.length; i++)
{
sortedNumbers[i] = -1;
}
return writeIndex;
}
调用此方法将返回有效范围的长度(此外,在修改后的数组中的最后一个有效索引之后,所有值都被设置为-1):
jshell> int[] sortedNumbers = { 1, 2, 2, 3, 3, 3, 4, 4, 4, 4 };
jshell> removeDuplicatesImproved(sortedNumbers)
$4 ==> 3
jshell> Arrays.toString(sortedNumbers)
$5 ==> "[1, 2, 3, 4, -1, -1, -1, -1, -1, -1]"
临时结论这个例子说明了几个有争议的问题。首先,就地处理通常更复杂——也就是说,不创建新数组,而是直接在原始数组中处理——其次,当值保留在数组中但不再是结果的一部分时,如何处理更改。您可以返回一个计数器,或者用一个中性的特殊值删除这些值。但是,通常更容易理解,因此建议使用所示的变体,它们创建了一个新数组。
Job Interview Tips: Alternative ways of Looking at Things
尽管这项任务听起来很简单,但它确实为不同的方法和解决方案策略提供了一些可能性。当删除重复项时,您也可以想到用对象引用值 null 的 no entry 来替换元素:
[1,2,2,4,4,3,3,3,2,2,3,1] => [1,2,null,3,null,null,4,null,null,null,null]
对于未排序的数组,也可以按其最初出现的顺序保留值:
[1,2,2,4,4,3,3,3,2,2,3,1] => [1,2,4,3]
或者,可以一次仅删除连续的重复项:
[1,2,2,4,4,3,3,3,2,2,3,1] => [1,2,4,3,2,3,1]]
正如你所看到的,需要考虑的更多,即使是简单的任务。这就是为什么需求工程和需求的正确覆盖是一个真正的挑战。
示例 4:旋转一个或多个位置
让我们看看另一个问题,即将数组向左或向右旋转 n 个位置,然后元素将分别在开始或结束时循环移位,如此处所示,中间的数组是起点:
向右旋转一个元素的算法很简单:记住最后一个元素,然后重复地将旋转方向上的前一个元素复制到后一个元素。最后,缓存的最后一个元素被插入到最前面的位置。
void rotateRight(final int[] values)
{
if (values.length < 2)
return values;
final int endPos = values.length - 1;
final int temp = values[endPos];
for (int i = endPos; i > 0; i--)
values[i] = values[i - 1];
values[0] = temp;
}
向左旋转的工作方式类似:
void rotateLeft(final int[] values)
{
if (values.length < 2)
return values;
final int endPos = values.length - 1;
final int temp = values[0];
for (int i = 0; i < endPos; i++)
values[i] = values[i + 1];
values[endPos] = temp;
}
让我们在 JShell 中尝试一下:
jshell> int[] numbers = { 1, 2, 3, 4, 5, 6, 7 };
numbers ==> int[7] { 1, 2, 3, 4, 5, 6, 7 }
jshell> rotateRight(numbers);
$16 ==> int[7] { 7, 1, 2, 3, 4, 5, 6 }
jshell> rotateLeft(numbers);
$17 ==> int[7] { 1, 2, 3, 4, 5, 6, 7 }
围绕 n **位置旋转(简单)**一个明显的扩展就是旋转一定数量的位置。这可以通过调用刚刚开发的功能 n 次来解决:
static void rotateRightByN_Simple(final int[] values, final int n)
{
for (int i = 0; i < n; i++)
rotateRight(values);
}
这种解决方案原则上是可以接受的,尽管由于频繁的复制操作而不是高性能的。怎样才能更有效率?
Hint: Optimization at Large Values for n
首先,还有一个小特性需要考虑,即如果 n 大于数组的长度,你不必一直旋转,但是你可以通过使用模运算i < n % values.length将这限制在实际需要的范围内。
绕 旋转 n 个 **位置(棘手)**或者,想象一下 n 个位置被添加到原数组中。这是通过使用缓存最后的 n 个元素的独立缓冲区来实现的。它在方法fillTempWithLastN()中实现。这首先创建一个大小合适的数组,并将最后的 n 个值放在那里。然后像以前一样复制这些值,但是偏移量为 n 。最后,您只需要使用copyTempBufferToStart()从缓冲区复制回值。
static int[] rotateRightByN(final int[] values, final int n)
{
final int adjustedN = n % values.length;
final int[] tempBuffer = fillTempWithLastN(values, adjustedN);
// copy n positions to the right
for (int i = values.length - 1; i >= adjustedN; i--)
values[i] = values[i - adjustedN];
copyTempBufferToStart(tempBuffer, values);
return values;
}
static int[] fillTempWithLastN(final int[] values, final int n)
{
final int[] tempBuffer = new int[n];
for (int i = 0; i < n; i++)
tempBuffer[i] = values[values.length - n + i];
return tempBuffer;
}
static void copyTempBufferToStart(final int[] tempBuffer, final int[] values)
{
for (int i = 0; i < tempBuffer.length; i++)
values[i] = tempBuffer[i];
}
这里有另一个提示:就内存而言,刚刚介绍的旋转方法可能不是最佳的,特别是如果值 n 非常大并且数组本身也很大——但是对于我们的例子来说,这无关紧要。有趣的是,简单版本在内存方面会更好,尽管由于频繁的复制操作可能会相当慢。
多维数组
在这一节中,我将简要讨论多维数组。因为在实践中比较常见,也容易直观想象,所以我就限定为二维数组。在下文中,也经常假设矩形形状。事实上,多维数组在 Java 中被组织为数组的数组。因此它们不一定必须是矩形的。如果合适,某些赋值也支持非矩形阵列,例如用图案填充某个区域。
使用二维矩形数组,您可以模拟一个游戏场,例如,一个数独游戏或由字符表示的风景。为了更好地理解和介绍,让我们考虑一个例子。假设#代表边界墙,$代表要收集的物品,P 代表玩家,X 代表从一个关卡退出。这些字符用于描述比赛场地,如下所示:
################
## P ##
#### $ X ####
###### $ ######
################
在 Java 中,可以使用一个char[][]来对此建模(可执行为TwoDimArrayWorldExample):
public static void main(final String[] args)
{
final char[][] world = { "################".toCharArray(),
"## P ##".toCharArray(),
"#### $ X ####".toCharArray(),
"###### $ ######".toCharArray(),
"################".toCharArray() };
printArray(world);
}
public static void printArray(final char[][] values)
{
for (int y = 0; y < values.length; y++)
{
for (int x = 0; x < values[y].length; x++)
{
final char value = getAt(values, x, y);
System.out.print(value + " ");
}
System.out.println();
}
}
访问时如何指定坐标有两种变体:一种是[x][y],另一种是[y][x],如果你认为更面向行的话。在不同的开发者之间,这可能会导致误解和讨论。如果您编写类似于getAt(char[][], int, int)的访问方法并考虑各自的偏好,就可以实现一个小小的补救。我将在简介中优先使用这种访问方法,稍后切换到直接数组访问:
static char getAt(final char[][] values, final int x, final int y)
{
return values[y][x];
}
让我们运行TwoDimArrayWorldExample程序来看看运行中的输出功能。下面,我会时不时提到类似的事情。除了调试之外,控制台输出非常有用,尤其是对于多维数组。
# # # # # # # # # # # # # # # #
# # P # #
# # # # $ X # # # #
# # # # # # $ # # # # # #
# # # # # # # # # # # # # # # #
介绍性示例
你现在的任务是将数组向左或向右旋转 90 度。让我们看一下向右旋转两圈的情况:
1111 4321 4444
2222 => 4321 => 3333
3333 4321 2222
4444 4321 1111
让我们试着把程序正式化一点。实现循环的最简单方法是创建一个新数组,然后适当地填充它。对于公式的确定,您使用具体的示例数据,这有助于理解(xn和yn代表新坐标——在下文中,向左旋转和向右旋转显示在左/右):
x 0123
y ----
0 ABCD
1 EFGH
xn 01 xn 01
yn -- yn --
0 DH 0 EA
1 CG 1 FB
2 BF 2 GC
3 AE 3 HD
你看到一个 4 × 2 的数组变成了 2 × 4 的数组。
旋转基于以下计算规则,其中maxX和maxY是各自的最大坐标:
Orig -> NewX NewY
---------------------------------------------------
rotateLeft: (x,y) -> y maxX - x
rotateRight: (x,y) -> maxY - y x
有了这些知识,您就可以开始实现了。首先创建一个适当大的数组,逐行遍历原始数组,然后逐个位置进行定位。基于上面的公式,旋转可以如下实现:
enum RotationDirection { LEFT_90, RIGHT_90 }
public static Object[][] rotate(Object[][] values, RotationDirection dir)
{
int origLengthX = values[0].length;
int origLengthY = values.length;
final Object[][] rotatedArray = new Object[origLengthX][origLengthY];
// Class<?> plainType = values.getClass().componentType().componentType();
// T[][] rotatedArray = (T[][])Array.newInstance(plainType,
// origLengthX, origLengthY);
for (int y = 0; y < values.length; y++)
{
for (int x = 0; x < values[0].length; x++)
{
int maxX = values[0].length - 1;
int maxY = values.length - 1;
Object origValue = values[y][x];
if (dir == RotationDirection.LEFT_90)
{
int newX = y;
int newY = maxX - x;
rotatedArray[newY][newX] = origValue;
}
if (dir == RotationDirection.RIGHT_90)
{
int newX = maxY - y;
int newY = x;
rotatedArray[newY][newX] = origValue;
}
}
}
return rotatedArray;
}
static <T> T getAt(final T[][] values, final int x, final int y)
{
return values[y][x];
}
这里纯粹的泛型实现更复杂,因为不幸的是,泛型数组不能用new T[][]创建。这需要技巧和思考,如注释掉的所示。因为更详细的处理超出了本书的范围,我建议你参考我的书Der Weg zum Java-Profi【Ind20a】。
在了解功能之前,让我们定义一个助手方法来输出任意类型的二维数组(基本类型除外):
static <T> void printArray(final T[][] values)
{
for (int y = 0; y < values.length; y++)
{
for (int x = 0; x < values[y].length; x++)
{
final T value = values[y][x];
System.out.print(value + " ");
}
System.out.println();
}
}
Hint: Implementation Variants
与带有索引变量的经典for循环不同,从 Java 5 开始,for循环的较短语法通常提供了更好的可读性,尤其是对于数组:
static <T> void printArray(final T[][] values)
{
for (final T[] value : values)
{
for (final T v : value)
{
System.out.print(v + " ");
}
System.out.println();
}
}
一种变体是让字符串格式化由Arrays.toString()来完成。这将创建方括号和逗号分隔的表示形式:
static <T> void printArrayJdk(final T[][] values)
{
for (int i = 0; i < values.length; i++)
{
System.out.println(Arrays.toString(values[i]));
}
}
让我们来看看 JShell 中的操作:
jshell> var inputArray = new String[][]
{
{ "A", "B", "C", "D" },
{ "E", "F", "G", "H" }
}
jshell> printArray(rotate(inputArray, RotationDirection.LEFT_90))
D H
C G
B F
A E
最后,对printArrayJdk()的调用显示了练习提示中提到的向右旋转 90 度的数组的格式:
jshell> printArrayJdk(rotate(inputArray, RotationDirection.RIGHT_90))
[E, A]
[F, B]
[G, C]
[H, D]
建模方向
您会在各种用例中遇到方向。当然,它们可以简单地用枚举来建模。在二维数组的上下文中,在枚举中定义所有重要的基本方向,以及 x 和 y 方向上的偏移量,是非常方便的,并且对可读性和可理解性有很大的帮助。因为这些值是常量,所以我在这里不包括get()方法:
public enum Direction
{
N(0,-1), NE(1,-1),
E(1,0), SE(1,1),
S(0,1), SW(-1,1),
W(-1,0), NW(-1,-1);
public final int dx;
public final int dy;
private Direction(final int dx, final int dy)
{
this.dx = dx;
this.dy = dy;
}
public static Direction provideRandomDirection()
{
final Direction[] directions = values();
final int randomIndex = (int) (Math.random() * directions.length);
return directions[randomIndex];
}
}
示例:随机遍历为了更深入地处理方向,让我们开发一个运动场的遍历。每当您到达数组边界时,您会随机选择一个不等于旧方向的新方向(可执行为RandomTraversalDirectionExample):
public static void main(final String[] args)
{
final char[][] world = { "ABCDEf".toCharArray(),
"GHIJKL".toCharArray(),
"MNOPQR".toCharArray(),
"abcdef".toCharArray(),
"ghijkl".toCharArray() };
Direction dir = Direction.provideRandomDirection();
System.out.println("Direction: " + dir);
int posX = 0;
int posY = 0;
int steps = 0;
while (steps < 25)
{
System.out.print(world[posY][posX] + " ");
if (!isOnBoard(world, posX + dir.dx, posY + dir.dy))
{
dir = selectNewDir(world, dir, posX, posY);
System.out.println("\nNew Direction: " + dir);
}
posX += dir.dx;
posY += dir.dy;
steps++;
}
}
static Direction selectNewDir(final char[][] world, Direction dir,
final int posX, final int posY)
{
Direction oldDir = dir;
do
{
dir = Direction.provideRandomDirection();
}
while (oldDir == dir || !isOnBoard(world, posX + dir.dx, posY + dir.dy));
return dir;
}
在这个任务中,你会立即接触到另一个名为isOnBoard()的有用方法。它的任务是检查传递的 x-y 值对数组是否有效,这里假设数组是矩形的:
static boolean isOnBoard(final char[][] values,
final int nextPosX, final int nextPosY)
{
return nextPosX >= 0 && nextPosY >= 0 &&
nextPosX < values[0].length && nextPosY < values.length;
}
如果启动程序RandomTraversalDirectionExample,会得到如下输出,很好的显示了方向变化。输出受到最大 25 步的限制。所以最后只找到 3 个字母。
Direction: SE
A H O d k
New Direction: N
e Q K E
New Direction: SW
J O b g
New Direction: N
a M G A
New Direction: E
B C D E f
New Direction: SW
K P c
Hint: Variation with Buffer Fields at the Border
特别是对于二维数组和对相邻单元的访问,在每个边上添加一个未使用的元素以避免特殊情况可能是有用的,下面用 X 表示:
XXXXXXX
X X
X X
X X
X X
XXXXXXX
使用这个技巧,你总是有八个相邻的单元格。这有助于避免程序中的特殊处理。例如,当遍历数组时,也是如此。除了检查数组边界,您还可以限制自己只检查是否到达了边界字段。有时使用中性元素很方便,比如值 0,因为这不会影响计算。
典型错误
不仅在访问数组时,而且特别是在那里,人们会发现多种潜在的错误来源,特别是以下内容:
-
差一位:有时您在访问时会差一位,例如因为指标计算有错误,如加 1 或减 1 来修正指标,或与<、< =、>、> =比较位置。
-
数组边界:类似地,数组的边界有时会被忽略,例如,在比较长度或下限或上限时,错误地使用了<、< =或>、> =等。 2
-
尺寸:如前所述,x 和 y 的表示方式取决于选择的口味。这很快导致二维数组的 x 和 y 互换。
-
矩形属性:虽然假设一个 n × m 数组是矩形的,但在 Java 中不一定是这样。您可以为每个新行指定不同的长度,但是下面的许多示例使用矩形数组。在求职面试中,你应该通过提问来澄清这一点。对于矩形属性不太重要的赋值,我也允许非矩形数组,例如,当用一个图案填充一个区域时。
-
抽象:数组很少提供抽象。在许多情况下,您会遇到针对条件、元素交换等的测试。,它们是直接实现的,而不是作为辅助方法提取的。
-
中性元素:什么代表没有价值?是-1 还是
null?如果这些都是可能的值,你怎么处理呢? -
字符串输出:数组没有
toString()方法的覆盖版本,所以使用了从Object类继承的toString()方法,它非常隐晦地输出类型和对象引用。因此,有时您可以看到数组的输出如下:
[I@4d591d15
[I@65ae6ba4
作为一种解决方法,可以使用以下调用:
Arrays.toString(mySimpleArray);
Arrays.deepToString(myMultiDimArray);
5.2 练习
5.2.1 练习 1:奇数之前是偶数(★★✩✩✩)
写方法void orderEvenBeforeOdd(int[])。这应该是重新排列一个给定的int值数组,使偶数先出现,然后是奇数。偶数和奇数中的顺序无关紧要。
例子
|投入
|
结果
| | --- | --- | | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | [2, 4, 6, 8, 10, 3, 7, 1, 9, 5] | | [2, 4, 6, 1, 8] | [2, 4, 6, 8, 1] | | [2, 4, 6, 8, 1] | [2, 4, 6, 8, 1] |
5.2.2 练习 2:翻转(★★✩✩✩)
写一个泛型方法,用void flipHorizontally(T[][])水平翻转二维数组,用void flipVertically(T[][])垂直翻转二维数组。数组应该是矩形的,所以任何一行都不能比另一行长。
例子
下面说明了该功能应该如何工作:
flipHorizontally() flipVertically()
------------------ ----------------
123 321 1144 3366
456 => 654 2255 => 2255
789 987 3366 1144
练习 3:回文(★★✩✩✩)
编写方法boolean isPalindrome(String[]),检查字符串数组的值是否形成回文。
例子
|投入
|
结果
| | --- | --- | | [“打开”、“测试”、“测试”、“打开”] | 真实的 | | ["麦克斯","麦克","麦克","麦克斯"] | 真实的 | | [“蒂姆”、“汤姆”、“迈克”、“马克斯”] | 错误的 |
5.2.4 练习 4:原地旋转(★★★✩✩)
练习 4a:迭代(★★★✩✩)
在介绍部分,我展示了如何旋转数组。现在,这应该发生在二维正方形数组顺时针旋转 90 度的位置(不创建新数组)。编写泛型方法void rotateInplace(T[][])来迭代实现这个。
例子
对于一个 6 × 6 的阵列,可以想象如下:
1 2 3 4 5 6 F G H I J 1
J K L M N 7 E T U V K 2
I V W X O 8 => D S Z W L 3
H U Z Y P 9 C R Y X M 4
G T S R Q 0 B Q P O N 5
F E D C B A A 0 9 8 7 6
练习 4b:递归(★★★✩✩)
编写递归方法void rotateInplaceRecursive(T[][]),实现所需的 90 度顺时针旋转。
练习 5:珠宝板初始化(★★★✩✩)
练习 5a:初始化(★★★✩✩)
用随机数字初始化一个二维矩形数组,用数值表示各种类型的钻石或珠宝。约束条件是,最初不能有三个相同类型的钻石以直接顺序水平或垂直放置。编写方法int[][] initJewelsBoard(int, int, int),它将生成一个给定大小和数量的不同类型钻石的有效数组。
例子
对于四种不同的颜色和形状,用数字表示的随机分布的菱形可能看起来像这样:
2 3 3 4 4 3 2
1 3 3 1 3 4 4
4 1 4 3 3 1 3
2 2 1 1 2 3 2
3 2 4 4 3 3 4
为了说明这一点,图 5-1 显示了另一个例子。
图 5-1
宝石板的图形表示
奖励:对角线检查( ★★★✩✩ ) 增加对角线检查。这应该会使示例中的星座无效,因为对角线在右下角用粗体标记了数字 3。
练习 5b:有效性检查(★★★✩✩)
在这个子任务中,您想要验证一个现有的运动场。作为一项挑战,必须返回发现的违规列表。为矩形数组实现方法List<String> checkBoardValidity(int[][])。
例子
要尝试有效性检查,您可以使用简介中的操场,这里特别标记了:
int[][] values = {
{ 2, 3, 3, 4, 4, 3, 2 },
{ 1, 3, 3, 1, 3, 4, 4 },
{ 4, 1, 4, 3, 3, 1, 3 },
{ 2, 2, 1, 1, 2, 3, 2 },
{ 3, 2, 4, 4, 3, 3, 4 } };
由于对角线的原因,这会产生以下误差:
[Invalid at x=3 y=2 tests: hor=false, ver=false, dia=true,
Invalid at x=2 y=3 tests: hor=false, ver=false, dia=true,
Invalid at x=4 y=4 tests: hor=false, ver=false, dia=true]
5.2.6 练习 6:珠宝板擦除钻石(★★★★✩)
挑战在于从矩形游戏场地中删除所有三个或更多水平、垂直或对角连接的钻石链,随后用位于其上的钻石填充由此产生的空白空间(即,大致与重力在自然界中的作用方式相同)。下面是一个示例,说明如何重复几次擦除然后放下,直到不再发生变化为止(空格显示为 _,以便于查看):
Iteration 1:
1 1 1 2 4 4 3 erase _ _ _ _ 4 4 _ fall down _ _ _ _ _ _ _
1 2 3 4 2 4 3 => 1 2 3 4 _ 4 _ => 1 2 3 4 4 4 _
2 3 3 1 2 2 3 2 3 3 1 2 _ _ 2 3 3 1 2 4 _
Iteration 2:
_ _ _ _ _ _ _ erase _ _ _ _ _ _ _ fall down _ _ _ _ _ _ _
1 2 3 4 4 4 _ => 1 2 3 _ _ _ _ => 1 2 3 _ _ _ _
2 3 3 1 2 4 _ 2 3 3 1 2 4 _ 2 3 3 1 2 4 _
练习 6a:擦除(★★★★✩)
写入方法boolean eraseChains(int[][]),从矩形游戏场数组中擦除水平、垂直和对角线方向上三个或更多连续菱形的所有行。
例子
方法的调用将左边给出的输出数组转换为右边显示的结果:
All chains without overlap Special case: overlaps
1 2 3 3 3 4 0 0 0 0 0 0 1 1 1 2 0 0 0 2
1 3 2 4 2 4 0 3 0 4 2 0 1 1 3 4 => 0 0 3 4
1 2 4 2 4 4 => 0 0 4 0 4 0 1 2 1 3 0 2 0 3
1 2 3 5 5 5 0 0 3 0 0 0
1 2 1 3 4 4 0 0 1 3 4 4
练习 6b:摔倒(★★★✩✩)
写方法void fallDown(int[][])在从上到下放下钻石的地方工作,假设钻石位置下面有一个空间。
例子
方法的调用将左边给出的输出数组转换为右边显示的结果:
0 1 3 3 0 0 0 0 0 0 0 0
0 1 0 0 0 0 0 0 0 0 0 0
0 0 3 3 0 0 => 0 0 3 3 0 0
0 0 0 3 3 4 0 1 3 3 0 0
0 0 3 0 0 0 0 1 3 3 3 4
5.2.7 练习 7:螺旋遍历(★★★★✩)
编写泛型方法List<T> spiralTraversal(T[][]),以螺旋方式遍历一个二维矩形数组,并将其准备为一个列表。起点在左上角。首先,遍历外层,然后是下一个内层。
例子
示例如图 5-2 所示。
图 5-2
螺旋遍历的基本程序
对于以下两个数组,下面列出的数字或字母序列应该是螺旋遍历的结果:
Integer[][] numbers = { { 1, 2, 3, 4 },
{ 12, 13, 14, 5 },
{ 11, 16, 15, 6 },
{ 10, 9, 8, 7 } };
String[][] letterPairs = { { "AB", "BC", "CD", "DE" },
{ "JK", "KL", "LM", "EF" },
{ "IJ", "HI", "GH", "FG" } };
=>
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
[AB, BC, CD, DE, EF, FG, GH, HI, IJ, JK, KL, LM]
5.2.8 练习 8:将 1 作为数字添加到数组中(★★✩✩✩)
考虑一个表示十进制数的数字数组。编写方法int[] addOne(int[]),通过值 1 执行加法,并且只允许使用数组作为解决方案的数据结构。
例子
|投入
|
结果
| | --- | --- | | [1, 3, 2, 4] | [1, 3, 2, 5] | | [1, 4, 8, 9] | [1, 4, 9, 0] | | [9, 9, 9, 9] | [1, 0, 0, 0, 0] |
5.2.9 练习 9:数独检查器(★★★✩✩)
在这个挑战中,将检查一个数独谜题,看它是否是一个有效的解决方案。让我们假设一个具有int值的 9 × 9 数组。根据数独规则,每行和每列必须包含从 1 到 9 的所有数字。此外,从 1 到 9 的所有数字必须依次出现在每个 3 × 3 子阵列中。编写方法boolean isSudokuValid(int[][])进行检查。
例子
有效的解决方案如下所示。
奖励虽然能够检查一个完全由数字填充的数独棋盘的有效性已经很好了,但是能够预测一个有缺口(即仍然缺少数字)的棋盘是否能产生有效的解决方案就更好了。如果您想开发一种解决数独难题的算法,这是非常有趣的。
例子
基于上面给出的有效数独游戏场的例子,我删除了随机出现的数字。这肯定会产生一个有效的解决方案。
练习 10:洪水填充(★★✩✩✩)
练习 10a (★★✩✩✩)
用指定值填充数组中所有空闲字段的编写方法void floodFill(char[], int, int)。
例子
下面显示了字符*的填充过程。填充从给定位置开始,例如左上角。然后在所有四个罗盘方向上继续,直到找到数组的边界或由另一个字符表示的边界。
" # " "***# " " # #" " #******#"
" #" "****#" " # #" " #******#"
"# #" => "#***#" "# # #" => "# #*****#"
" # # " " #*# " " # # #" " # #*****#"
" # " " # " " # #" " #*****#"
练习 10b (★★✩✩✩)
扩展该方法以填充作为矩形数组传递的任何模式。但是,模式规范中不允许有空格。
例子
下面你可以看到一个充满图案的洪水看起来是什么样子。该图案由几行字符组成:
.|.
-*-
.|.
如果从底部中心开始填充,会得到以下结果:
x .|..|.x
# # -*--#--#
### # .|.###.|#
# ### # => #|.###.|#
# # # #*--#--*#
# # # #.#|..#
# # #.|.#
练习 11:数组合并(★★✩✩✩)
假设有两个数字数组,每个数组都按升序排序。在这个赋值中,这些数组将根据各自值的顺序合并成一个数组。写方法int[] merge(int[], int[])。
例子
|输入 1
|
输入 2
|
结果
| | --- | --- | --- | | [1, 4, 7, 12, 20] | [10, 15, 17, 33] | [1, 4, 7, 10, 12, 15, 17, 20, 33] | | [2, 3, 5, 7] | [11, 13, 17] | [2, 3, 5, 7, 11, 13, 17] | | [2, 3, 5, 7, 11] | [7, 11, 13, 17] | [2, 3, 5, 7, 7, 11 11, 13, 17] | | [1, 2, 3] | * =[] | [1, 2, 3] |
练习 12:数组最小值和最大值(★★✩✩✩)
练习 12a:最小值和最大值(★✩✩✩✩)
编写两个方法int findMin(int[])和int findMax(int[]),它们将使用自实现的搜索分别找到给定非空数组的最小值和最大值——从而消除了Math.min()、Arrays.sort()和Arrays.stream().min()等的使用
例子
|投入
|
最低限度
|
最高的
| | --- | --- | --- | | [2, 3, 4, 5, 6, 7, 8, 9, 1, 10] | one | Ten |
练习 12b:最小和最大位置(★★✩✩✩)
实现两个助手方法int findMinPos(int[], int, int)和int findMaxPos(int[], int, int),它们分别查找并返回给定非空数组的最小值和最大值的位置,以及给定为左右边界的索引范围。如果最小值或最大值有几个相同的值,应该返回第一个出现的值。为了分别找到最小值和最大值,编写两个使用辅助方法的方法int findMinByPos(int[], int, int)和int findMaxByPos(int[], int, int)。
例子
|方法
|
投入
|
范围
|
结果
|
位置
| | --- | --- | --- | --- | --- | | findminxyz() | [5, 3, 4, 2, 6, 7, 8, 9, 1, 10] | 0, 10 | one | eight | | findminxyz() | [5, 3, 4, 2, 6, 7, 8, 9, 1, 10] | 0, 7 | Two | three | | findminxyz() | [5, 3, 4, 2, 6, 7, 8, 9, 1, 10] | 2, 7 | Two | three | | findMaxXyz() | [1, 22, 3, 4, 5, 10, 7, 8, 9, 49] | 0, 10 | forty-nine | nine | | findMaxXyz() | [1, 22, 3, 4, 5, 10, 7, 8, 9, 49] | 0, 7 | Twenty-two | one | | findMaxXyz() | [1, 22, 3, 4, 5, 10, 7, 8, 9, 49] | 2, 7 | Ten | five |
练习 13:阵列分割(★★★✩✩)
考虑一个任意整数的数组。因此,要对数组进行重新排序,以便小于特定参考值的所有值都放在左边。所有大于或等于参考值的值都放在右边。子范围内的顺序是不相关的,并且可以变化。
例子
|投入
|
基准要素
|
样本结果
| | --- | --- | --- | | [4, 7, 1, 20] | nine | [1,4,7, 9 ,20] | | [3, 5, 2] | seven | [2,3,5, 7 | | [2, 14, 10, 1, 11, 12, 3, 4] | seven | [2,1,3,4, 7 ,14,10,11,12] | | [3, 5, 7, 1, 11, 13, 17, 19] | Eleven | [1,3,5,7, 11 ,11,13,17,19] |
练习 13a:数组拆分(★★✩✩✩)
编写方法int[] arraySplit(int[], int)来实现该功能。允许创建新的数据结构,如列表。
练习 13b:原地拆分阵列(★★★✩✩)
编写在源数组内部实现所述功能的方法int[] arraySplitInplace(int[], int)(即就地)。显然不希望创建新的数据结构。为了能够在结果中包含引用元素,只允许创建一次数组。因为这必须被返回,所以它被例外地允许为一个就地方法返回值——事实上,它在这里只部分地就地操作。
练习 13c:数组拆分快速排序分区(★★★✩✩)
对于排序,根据快速排序,您需要一个类似于刚刚开发的分区功能。然而,通常数组的最前面的元素被用作引用元素。
基于之前使用显式引用元素开发的两个实现,现在创建相应的替代方法,如方法int[] arraySplit(int[])和int[] arraySplitInplace(int[])。
例子
|投入
|
基准要素
|
样本结果
| | --- | --- | --- | | [ 9 ,4,7,1,20] | nine | [1,4,7, 9 ,20] | | [ 7 ,3,5,2] | seven | [2,3,5, 7 | | [ 7 ,2,14,10,1,11,12,3,4] | seven | [2,1,3,4, 7 ,14,10,11,12] | | [ 11 ,3,5,7,1,11,13,17,19] | Eleven | [1,3,5,7, 11 ,11,13,17,19] |
5.2.14 练习 14:扫雷艇委员会(★★★✩✩)
你过去很有可能玩过扫雷游戏。提醒你一下,这是一个不错的小问答游戏,有点令人费解。是关于什么的?炸弹面朝下放在操场上。玩家可以选择棋盘上的任何一个区域。如果某个字段未被覆盖,它会显示一个数字。这表明有多少炸弹藏在邻近的地里。然而,如果你运气不好,你击中了一个炸弹场,游戏就输了。这个任务是关于初始化这样一个领域,并为随后的游戏做准备。
练习 14a (★★✩✩✩)
编写方法boolean[][] placeBombsRandomly(int, int, double),通过前两个参数创建一个大小为boolean[][]的游戏场,随机填充个炸弹,概率从 0.0 到 1.0 传递为double。
例子
这里展示的是一个 16 × 7 大小的游戏场,炸弹随机放置。炸弹用*表示,空格用。如你所见。
* * * . * * . * . * * . * . . .
. * * . * . . * . * * . . . . .
. . * . . . . . . . . . * * * *
. . . * . * * . * * . * * . . .
* * . . . . * . * . . * . . . *
. . * . . * . * * . . * . * * *
. * . * * . * . * * * . . * * .
练习 14b (★★★✩✩)
编写方法int[][] calcBombCount(boolean[][]),根据作为boolean[][]传递的炸弹字段,计算相邻炸弹字段的数量,并返回相应的数组。
例子
对大小为 3 × 3 和 5 × 5 的运动场进行计算,包括随机分布的炸弹,结果如下:
* . . B 2 1 . * * . . 2 B B 3 1
. . * => 1 3 B * . * * . B 6 B B 1
. . * 0 2 B * * . . . => B B 4 3 2
* . . * . B 6 4 B 1
* * * . . B B B 2 1
练习 14c (★★✩✩✩)
编写方法void printBoard(boolean[][], char, int[][]),允许你将棋盘显示为点、星、数字和B。
例子
此处显示的是上面的 16 × 7 大小的游戏场,包含炸弹邻居的所有计算值:
B B B 4 B B 3 B 4 B B 3 B 1 0 0
3 B B 5 B 3 3 B 4 B B 4 3 4 3 2
1 3 B 4 3 3 3 3 4 4 4 4 B B B B
2 3 3 B 2 B B 4 B B 3 B B 4 4 3
B B 3 2 3 4 B 6 B 4 4 B 5 3 4 B
3 4 B 3 3 B 4 B B 5 4 B 4 B B B
1 B 3 B B 3 B 4 B B B 2 3 B B 3
5.3 解决方案
5.3.1 解决方案 1:奇数之前为偶数(★★✩✩✩)
写方法void orderEvenBeforeOdd(int[])。这应该是重新排列一个给定的int值数组,使偶数先出现,然后是奇数。偶数和奇数中的顺序无关紧要。
例子
|投入
|
结果
| | --- | --- | | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | [2, 4, 6, 8, 10, 3, 7, 1, 9, 5] | | [2, 4, 6, 1, 8] | [2, 4, 6, 8, 1] | | [2, 4, 6, 8, 1] | [2, 4, 6, 8, 1] |
算法从头遍历数组。跳过偶数。一旦找到一个奇数,就在后面的数组中搜索一个偶数。如果找到这样的号码,就用当前的奇数交换。重复该过程,直到到达数组的末尾。
void orderEvenBeforeOdd(final int[] numbers)
{
int i = 0;
while (i < numbers.length)
{
int value = numbers[i];
if (isEven(value))
{
// even number, so continue with next number
}
else
{
// odd number, jump over all odd ones, until the first even
int j = i + 1;
while (j < numbers.length && !isEven(numbers[j]))
{
j++;
}
if (j < numbers.length)
swap(numbers, i, j);
else
break; // no further numbers
}
i++;
}
}
检查和交换元素的辅助方法已经在前面的章节中实现了。这里再次显示它们是为了更容易在 JShell 中尝试这些例子。
boolean isEven(final int n)
{
return n % 2 == 0;
}
void swap(final int[] values, final int first, final int second)
{
final int tmp = values[first];
values[first] = values[second];
values[second] = tmp;
}
Note: Variation of Odd Before Even
一种变化是将所有奇数排在偶数之前。因此,可以编写方法void orderOddBeforeEven(int[]),其中奇数和偶数中的顺序也不重要。
除了倒置测试中的最小差异之外,该算法与所示的相同。这种修改非常简单,因此这里不再显示该方法。
优化算法:改进运行时间
你认识到你的支票有二次运行时间。这里O(n**m)因为使用了两个嵌套循环。虽然这对于纯计算来说并不太引人注目,但是您应该记住,在最好的情况下,将算法的运行时间减少到 O (1),最好是 O ( n )或者至少是O(n**log(n),理想情况下不会降低可读性和可理解性。关于 O 符号的介绍,请参考附录 c。
在这种情况下,将运行时间减少到 O ( n )实际上相当简单。如同其他问题的许多解决方案一样,使用了两个位置标记,这里是nextEven和nextOdd。开始时,假设第一个元素是偶数,最后一个是奇数。现在检查前面的数是不是真的偶数,位置右移。如果遇到第一个奇数,则与最后一个元素交换。即使最后一个元素是奇数,它也会在下一步中再次交换。
与前面的解决方案相反,这个解决方案不保持偶数的顺序;它还可能在更大程度上打乱奇数。
void orderEvenBeforeOddOptimized(final int[] numbers)
{
int nextEven = 0;
int nextOdd = numbers.length - 1;
while (nextEven < nextOdd)
{
final int currentValue = numbers[nextEven];
if (isEven(currentValue))
{
nextEven++;
}
else
{
swap(numbers, nextEven, nextOdd);
nextOdd--;
}
}
}
我们来看看未排序的数(2,4,3,6,1)的方法。下面的e和o分别代表nextEven和nextOdd的位置指针。
2 4 3 6 1
^ ^
e o
^ ^
e o
^ ^
e o
--------- swap
1 6 3
^ ^
e o
--------- swap
6 1 3
^
eo
最后,让我们看看已经排序的数字(1,2,3,4)会发生什么:
1 2 3 4
^ ^
e o
-------- swap
4 2 3 1
^ ^
e o
^ ^
e o
^
eo
优化算法:减少复制
前面的优化可以再进一步一点。不是只从左边跳过偶数直到遇到奇数,而是可以从两边开始跳过值,只要它们前面是偶数,后面是奇数。这需要两个额外的while循环。但是,您仍然保留了一个 O ( n )的运行时间,因为您遍历了相同的元素,并且没有多次执行步骤(这需要一些经验)。
下面的实现应用了已经说过的内容,并且仅在必要时交换元素:
void orderEvenBeforeOddOptimizedV2(final int[] numbers)
{
int left = 0;
int right = numbers.length - 1;
while (left < right)
{
// run to the first odd number or to the end of the array
while (left < numbers.length && numbers[left] % 2 == 0)
left++;
// run to the first even number or to the beginning of the array
while (right >= 0 && numbers[right] % 2 != 0)
right--;
if (left < right)
{
swap(numbers, left, right);
left++;
right--;
}
}
}
确认
为了进行试验,您可以使用以下显示其工作原理的输入:
jshell> var values1 = new int[]{ 1, 2, 3, 4, 5, 6, 7 }
jshell> var values2 = new int[]{ 1, 2, 3, 4, 5, 6, 7 }
jshell> var values3 = new int[]{ 1, 2, 3, 4, 5, 6, 7 }
jshell> orderEvenBeforeOdd(values1)
jshell> orderEvenBeforeOddOptimized(values2)
jshell> orderEvenBeforeOddOptimizedV2(values3)
jshell> values1
values1 ==> int[7] { 2, 4, 6, 1, 5, 3, 7 }
jshell> values2
values2 ==> int[7] { 6, 2, 4, 5, 3, 7, 1 }
jshell> values3
values3 ==> int[7] { 6, 2, 4, 3, 5, 1, 7 }
5.3.2 解决方案 2:翻转(★★✩✩✩)
编写一个通用方法,用void flipHorizontally(T[][])水平翻转二维数组,用void flipVertically(T[][])垂直翻转二维数组。数组应该是矩形的,所以任何一行都不能比另一行长。
例子
下面说明了该功能应该如何工作:
flipHorizontally() flipVertically()
------------------ ----------------
123 321 1144 3366
456 => 654 2255 => 2255
789 987 3366 1144
水平翻转算法从数组的左右两侧向内遍历。为此,使用两个名为left dx和rigid x的位置标记。在每一步,交换这些位置引用的值,并向内移动,直到位置重叠。终止发生在左侧 x>=右侧 x 。对所有线路重复该程序。
以下顺序显示了所描述的一行动作,其中l代表left dx,r代表right dx:
Step Array values
---------------------
1 1 2 3 4
^ ^
l r
2 4 2 3 1
^ ^
l r
3 4 3 2 1
^ ^
r l
垂直翻转的算法从顶部和底部向中心移动,直到两个位置重叠。交换值,并对所有列重复此操作。该实现在 x 方向上遍历数组,并在垂直方向上使用两个位置标记进行操作。每次交换后,这些位置标记会相向移动,直到交叉。然后继续下一个 x 位置。
该实现使用两个位置指针并交换各自的值,直到位置指针交叉:
static <T> void flipHorizontally(final T[][] values)
{
for (int y = 0; y < values.length; y++)
{
final int endPos = values[y].length;
int leftIdx = 0;
int rightIdx = endPos - 1;
while (leftIdx < rightIdx)
{
final T leftValue = values[y][leftIdx];
final T rightValue = values[y][rightIdx];
// swap
values[y][leftIdx] = rightValue;
values[y][rightIdx] = leftValue;
leftIdx++;
rightIdx--;
}
}
}
现在让我们来看看垂直翻转的相应实现:
static <T> void flipVertically(final T[][] values)
{
for (int x = 0; x < values[0].length; x++)
{
final int endPos = values.length;
int topIdx = 0;
int bottomIdx = endPos - 1;
while (topIdx < bottomIdx)
{
final T topValue = values[topIdx][x];
final T bottomValue = values[bottomIdx][x];
// swap
values[topIdx][x] = bottomValue;
values[bottomIdx][x] = topValue;
topIdx++;
bottomIdx--;
}
}
}
修改后的算法其实翻转的实现可能会简化一点。在两种情况下都可以直接计算出步数:宽度/2 或高度/2。对于奇数长度,不考虑中间元素。然而,这导致了正确的翻转。
有了这些初步的考虑,使用for循环进行水平翻转的实现如下所示:
static <T> void flipHorizontallyV2(final T[][] values)
{
for (int y = 0; y < values.length; y++)
{
final T[] row = values[y];
final int rowLength = row.length;
for (int x = 0; x < rowLength / 2; x++)
{
ArrayUtils.swap(row, x, rowLength - x - 1);
}
}
}
优化的算法虽然到目前为止显示的解决方案都是在单个元素的级别进行交换,但是您可以从重新分配整行以进行垂直翻转中受益。这在复杂性和工作量以及源代码数量方面都明显更简单,并且极大地增加了可理解性。
static <T> void flipVerticallySimplified(final T[][] values)
{
for (int y = 0; y < values.length / 2; y++)
{
// swap line based
ArrayUtils.swap(values, y, values.length - y - 1);
}
}
确认
要测试功能,您可以使用介绍性示例中的输入,这些输入显示了正确的操作:
@Test
void flipHorizontally()
{
final Integer[][] horiNumbers = { { 1, 2, 3 },
{ 4, 5, 6 },
{ 7, 8, 9 }};
final Integer[][] expected = { { 3, 2, 1 },
{ 6, 5, 4 },
{ 9, 8, 7 } };
Ex02_Flip.flipHorizontally(horiNumbers);
assertArrayEquals(expected, horiNumbers);
}
@Test
void flipVertically()
{
final Integer[][] vertNumbers = { { 1, 1, 4, 4 },
{ 2, 2, 5, 5 },
{ 3, 3, 6, 6 } };
final Integer[][] expected = { { 3, 3, 6, 6 },
{ 2, 2, 5, 5 },
{ 1, 1, 4, 4 } };
Ex02_Flip.flipVertically(vertNumbers);
assertArrayEquals(expected, vertNumbers);
}
其他两种方法的测试方式与前面的方法完全相同。这就是为什么这里不再显示相关的测试方法。
5.3.3 解决办法 3:回文
编写方法boolean isPalindrome(String[]),检查字符串数组的值是否形成回文。
例子
|投入
|
结果
| | --- | --- | | [“打开”、“测试”、“测试”、“打开”] | 真实的 | | ["麦克斯","麦克","麦克","麦克斯"] | 真实的 | | [“蒂姆”、“汤姆”、“迈克”、“马克斯”] | 错误的 |
算法回文检查可以很容易地递归表达。同样,使用了两个位置指针,它们最初位于数组的开头和结尾。检查它们引用的两个值是否相同。如果是这样,你继续递归地检查,并在每一个递归步骤中向两边的中间移动一个位置,直到位置重叠。
static boolean isPalindromeRec(final String[] values)
{
return isPalindromeRec(values, 0, values.length - 1);
}
static boolean isPalindromeRec(final String[] values,
final int left,
final int right)
{
// recursive termination
if (left >= right)
return true;
// check if left == right
if (values[left].equals(values[right]))
{
// recursive descent
return isPalindromeRec(values, left + 1, right - 1);
}
return false;
}
Pitfall: Post-Increment and Post-Decrement in Method Calls
我要再次明确指出递归下降的一个流行的粗心错误。调用方法时,后递增或后递减可能更具可读性。但是,这在语义上是不正确的!为什么呢?这两个操作在调用后执行,因此值不会增加或减少。请再看一下递归这一章。在那一章中,我会在 3.1.4 节中更详细地解释这个问题。
优化算法基于递归解,回文测试可以很容易地转化为迭代版本:
static boolean isPalindromeIterative(final String[] values)
{
int left = 0;
int right = values.length - 1;
boolean sameValue = true;
while (left < right && sameValue)
{
// check left == right and repeat until difference occurs
sameValue = values[left].equals(values[right]);
left++;
right--;
}
return sameValue;
}
除了这个变体之外,您还可以利用最大步数是已知的这一事实。这允许您在违反回文属性的情况下直接中止循环:
static boolean isPalindromeShort(final String[] values)
{
for (int i = 0; i < values.length / 2; i++)
{
if (!values[i].equals(values[values.length - 1 - i]))
return false;
}
return true;
}
确认
对于单元测试(同样,只在递归变体的摘录中显示),您使用上面例子中的输入:
@ParameterizedTest(name="isPalindromeRec({0}) => {1}")
@MethodSource("createInputArraysAndExpected")
void isPalindromeRec(String[] values, boolean expected)
{
boolean result = Ex03_Palindrome.isPalindromeRec(values);
assertEquals(expected, result);
}
private static Stream<Arguments> createInputArraysAndExpected()
{
String[] inputsOk1 = { "Ein", "Test", " -- ", "Test", "Ein" };
String[] inputsOk2 = { "Max", "Mike", "Mike", "Max" };
String[] inputsWrong = { "Tim", "Tom", "Mike", "Max" };
return Stream.of(Arguments.of(inputsOk1, true),
Arguments.of(inputsOk2, true),
Arguments.of(inputsWrong, false));
}
5.3.4 解决方案 4:原地旋转(★★★✩✩)
解决方案 4a:迭代(★★★✩✩)
在介绍部分,我展示了如何旋转数组。现在,对于顺时针旋转 90 度的二维正方形阵列,这应该就地发生(即,不创建新阵列)。编写泛型方法void rotateInplace(T[][])来迭代实现这个。
例子
对于一个 6 × 6 的阵列,可以想象如下:
1 2 3 4 5 6 F G H I J 1
J K L M N 7 E T U V K 2
I V W X O 8 => D S Z W L 3
H U Z Y P 9 C R Y X M 4
G T S R Q 0 B Q P O N 5
F E D C B A A 0 9 8 7 6
算法定义四个角位置,称为 TL、TR、BL 和 BR,对应于各个角。如图 5-3 所示,从左到右、从上到下进行逻辑复制。
图 5-3
原地旋转程序
对 ?? 的所有邻居一层一层地重复该过程,直到达到 TR(类似地对其他角的邻居)。然后一次向内移动一个位置,直到 BL 和 BR 相交。让我们一步一步地再次阐明程序。
起点给定如下数组:
1 2 3 4 5 6
J K L M N 7
I V W X O 8
H U Z Y P 9
G T S R Q 0
F E D C B A
步骤 1:首先,通过将所有值复制到各自的目标位置来旋转外层,如图所示:
F G H I J 1
E K L M N 2
D V W X O 3
C U Z Y P 4
B T S R Q 5
A 0 9 8 7 6
第二步:继续深入一层:
F G H I J 1
E T U V K 2
D S W X L 3
C R Z Y M 4
B Q P O N 5
A 0 9 8 7 6
第 3 步:这一过程一直持续到到达最内层:
F G H I J 1
E T U V K 2
D S Z W L 3
C R Y X M 4
B Q P O N 5
A 0 9 8 7 6
对于所示的加工步骤,一个变量offset决定了哪一个层在其中——需要宽度/2 步。基于该层,获得要复制的位置的数量,为此使用内部循环。数组中的相应位置是根据它们的位置计算的,如图所示。使用辅助变量也使复制变得容易。
static <T> void rotateInplace(final T[][] values)
{
final int height = values.length - 1;
final int width = values[0].length - 1;
int offset = 0;
while (offset <= width / 2)
{
final int currentWidth = width - offset * 2;
for (int idx = 0; idx < currentWidth; idx++)
{
final int tlX = offset + idx;
final int tlY = offset;
final int trX = width - offset;
final int trY = offset + idx;
final int blX = offset;
final int blY = height - offset - idx;
final int brX = width - offset - idx;
final int brY = height - offset;
final T tl = values[tlY][tlX];
final T tr = values[trY][trX];
final T bl = values[blY][blX];
final T br = values[brY][brX];
// copy around
values[trY][trX] = tl;
values[brY][brX] = tr;
values[blY][blX] = br;
values[tlY][tlX] = bl;
}
offset++;
}
}
或者,您可以省略辅助变量,只缓存左上角位置的值。然而,复制变得有些棘手,因为实现中的顺序必须正好相反。这种环形交换的变体是通过方法rotateElements()实现的。在我看来,前一个版本更容易理解。
static <T> void rotateInplaceV2(final T[][] values)
{
int sideLength = values.length;
int start = 0;
while (sideLength > 0)
{
for (int i = 0; i < sideLength - 1; i++)
{
rotateElements(values, start, sideLength, i);
}
sideLength = sideLength - 2;
start++;
}
}
static <T> void rotateElements(final T[][] array,
final int start, final int len, final int i)
{
final int end = start + len - 1;
final T tmp = array[start][start + i];
array[start][start + i] = array[end - i][start];
array[end - i][start] = array[end][end - i];
array[end][end - i] = array[start + i][end];
array[start + i][end] = tmp;
}
解决方案 4b:递归(★★★✩✩)
编写递归方法void rotateInplaceRecursive(T[][]),实现所需的 90 度顺时针旋转。
算法你已经看到,你必须一层一层地旋转,从外层进一步到内层。这就迫切需要一个递归的解决方案,它的成分层复制和以前一样。只有while循环被递归调用取代。
static <T> void rotateInplaceRecursive(final T[][] values)
{
rotateInplaceRecursive(values, 0, values.length - 1);
}
static <T> void rotateInplaceRecursive(final T[][] values,
final int left, final int right)
{
if (left >= right)
return;
final int rotCount = right - left;
for (int i = 0; i < rotCount; i++)
{
final T tl = values[left + i][left];
final T tr = values[right][left + i];
final T bl = values[left][right - i];
final T br = values[right - i][right];
values[left + i][left] = tr;
values[right][left + i] = br;
values[right - i][right] = bl;
values[left][right - i] = tl;
}
rotateInplaceRecursive(values, left + 1, right - 1);
}
确认
您定义了开头所示的二维数组。然后执行旋转,并将结果与期望值进行比较。
@Test
void rotateInplace()
{
final Character[][] board = { {'1', '2', '3', '4', '5', '6' },
{'J', 'K', 'L', 'M', 'N', '7'},
{'I', 'V', 'W', 'X', 'O', '8'},
{'H', 'U', 'Z', 'Y', 'P', '9'},
{'G', 'T', 'S', 'R', 'Q', '0'},
{'F', 'E', 'D', 'C', 'B', 'A'} };
Ex04_Rotate_Inplace.rotateInplace(board);
final Character[][] expectedBoard = { { 'F','G','H','I','J','1'},
{ 'E','T','U','V','K','2'},
{ 'D','S','Z','W','L','3'},
{ 'C','R','Y','X','M','4'},
{ 'B','Q','P','O','N','5'},
{ 'A','0','9','8','7','6'}};
assertArrayEquals(expectedBoard, board);
}
5.3.5 解决方案 5:珠宝板初始化(★★★✩✩)
解决方案 5a:初始化(★★★✩✩)
用随机数字初始化一个二维矩形数组,用数值表示各种类型的钻石或珠宝。约束条件是,最初不能有三个相同类型的钻石以直接顺序水平或垂直放置。编写方法int[][] initJewelsBoard(int, int, int),它将生成一个给定大小和数量的不同类型钻石的有效数组。
例子
对于四种不同的颜色和形状,用数字表示的随机分布的菱形可能看起来像这样:
2 3 3 4 4 3 2
1 3 3 1 3 4 4
4 1 4 3 3 1 3
2 2 1 1 2 3 2
3 2 4 4 3 3 4
为了说明这一点,图 5-4 显示了另一个例子。
图 5-4
宝石板的图形表示
算法首先,创建一个大小合适的数组。然后你用随机值一行一行一个位置地填充它,使用方法int selectValidJewel()返回钻石类型的数值。在这种方法中,您必须确保刚刚选择的随机数不会创建水平或垂直的三行。
int[][] initJewelsBoard(final int width, final int height,
final int numOfColors)
{
final int[][] board = new int[height][width];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
board[y][x] = selectValidJewel(board, x, y, numOfColors);
}
}
return board;
}
int selectValidJewel(final int[][] board, final int x, final int y,
final int numOfColors)
{
int nextJewelNr = -1;
boolean isValid = false;
while (!isValid)
{
nextJewelNr = 1 + (int) (Math.random() * numOfColors);
isValid = !checkHorizontally(board, x, y, nextJewelNr) &&
!checkVertically(board, x, y, nextJewelNr);
}
return nextJewelNr;
}
Hint: Random Numbers
要获得大于或等于 0.0 且小于 1.0 的随机数,可以使用调用Math.random()。例如,如果您想要模拟骰子的数字,可以按如下方式实现:
int diceEyes = (int)((Math.random()) * 6 + 1);
或者,类Random()存在。这可用于为类型int生成取值范围为 0 到某个最大值(不含)的随机数。对于其他原始数字类型,只有变量nextXyz(),它从相应的整个范围的值中返回一个随机数。
Random random = new Random();
// integer random number between 0 (inclusive) and 10 (exclusive)
int zufallsZahl = random.nextInt(10);
// random number in the range Double.MIN_VALUE to Double.MAX_VALUE
double randomNumber = random.nextDouble();
Attention: Things to Know About Initialization
selectValidJewel()方法还是要优化的。此时,您无法确定某个位置找不到有效的数字,例如,对于以下只有两种类型和位置*的星座,1 和 2 都不是有效的值,因为两者都会导致一行三个:
1221
2122
11*
然而,通过棋盘上黑白方块的交替分布,即使只有两个值也可以得到有效分布的事实变得显而易见。解决刚才提到的缺点的一个方法是选择一个更强大的算法,比如使用回溯的算法。
还有一个弱点:从一个小范围的值中生成随机数通常会多次产生同一个数,但这个数很可能已经被检查过了。这必须避免。为此,所有先前选择的随机数可以存储在一个集合中。此外,您还必须检查是否已经尝试了所有预期和可能的数字。这个简短的列表表明,它比您最初预期的要复杂得多。
现在让我们继续检查水平和垂直。首先,您可以假设从当前位置开始,您必须检查左右以及上下。然而,如果你更仔细地重读这个作业,它要求不允许长度为三或更长的链。因为您从上到下、从左到右填充游戏区域,所以在当前位置的右侧和下方不能存在要检查的方块。因此,您可以限制自己只查看左侧和顶部。此外,您不需要检查更长的链,因为如果您已经确定了三个链,它们就不会出现。
有了这些初步的考虑,您可以使用这两个帮助器方法,通过简单地验证它们都具有与初始字段相同的值,来水平和垂直地检查各自的相邻字段:
boolean checkHorizontally(final int[][] board, final int x, final int y,
final int jewelNr)
{
final int top1 = getAt(board, x, y - 1);
final int top2 = getAt(board, x, y - 2);
return top1 == jewelNr && top2 == jewelNr;
}
boolean checkVertically(final int[][] board, final int x, final int y,
final int jewelNr)
{
final int left1 = getAt(board, x - 1, y);
final int left2 = getAt(board, x - 2, y);
return left1 == jewelNr && left2 == jewelNr;
}
访问数组时,负偏移量可能会导致数组索引无效。因此,您实现了方法getAt(),该方法主要负责检查边界,并为不再在比赛场地上的返回值-1。这个值永远不会出现在比赛场上,因此在比较时被计为无链。
int getAt(final int[][] board, final int x, final int y)
{
if (x < 0 || x >= board[0].length || y < 0 || y >= board.length)
return -1;
return board[y][x];
}
Attention: Little Source Code vs. Small But Many Methods
在这个例子中,我还遵循了定义小助手方法的策略,这增加了源代码的数量。然而,大多数情况下,功能可以被很好地孤立地描述和测试。此外,这种方法通常允许在可理解和概念性的层面上表达源代码。在许多情况下,这允许扩展被容易地集成。虽然下面的解决方案很简洁,但如果现在需要进行对角线测试,你会怎么做?
static int[][] initJewelsBoardCompact(final int width, final int height,
final int numbers)
{
final Random r = new Random();
final int[][] board = new int[height][width];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
do
{
board[y][x] = r.nextInt(numbers);
}
while (x >= 2 && board[y][x] == board[y][x - 1] &&
board[y][x] == board[y][x - 2]
|| y >= 2 && board[y][x] == board[y - 1][x] &&
board[y][x] == board[y - 2][x]);
}
}
return board;
}
奖金任务的解决方案:对角线检查(★★★✩✩)
添加对角线检查。这应该会使示例中的星座无效,因为对角线在右下角用粗体标记了数字 3。
从一个位置检查四条对角线似乎比检查水平和垂直方向更耗时。理论上,每个位置有四个方向。几乎和往常一样,对一个问题思考得更久一点是个好主意。如果您遵循此建议,您可能会得出这样的解决方案:在这种情况下,从一个位置开始,只检查左上角和右上角就足够了,因为从上述位置的角度来看,该位置对应于下方的对角线检查,如下所示:
X X
X X
X X
因此,具有两个辅助变量的对角线检查,每个辅助变量用于西北和东北罗盘方向的位置,可以如下实现并在方法selectValidJewel()中调用:
boolean checkDiagonally(final int[][] board, final int x, final int y,
final int jewelNr)
{
final int nw1 = getAt(board, x - 1, y - 1);
final int nw2 = getAt(board, x - 2, y - 2);
final int ne1 = getAt(board, x + 1, y - 1);
final int ne2 = getAt(board, x + 2 , y - 2);
return (nw1 == jewelNr && nw2 == jewelNr) ||
(ne1 == jewelNr && ne2 == jewelNr);
}
确认
为了验证现在是否创建了正确的游戏场,让我们生成并输出一个大小为 5 × 3 的游戏场,其中包含四种类型的菱形,如下所示:
jshell> var board = initJewelsBoard(5, 3, 4)
jshell> printArray(board) 1 2 1 1 2
2 4 2 1 2
3 3 2 4 4
这里,您使用已经介绍过的方法printArray(int[][]),但为了更容易测试,再次展示:
private static void printArray(final int[][] values)
{
for (int y = 0; y < values.length; y++)
{
for (int x = 0; x < values[y].length; x++)
{
final int value = values[y][x];
System.out.print(value + " ");
}
System.out.println();
}
}
作为快速提醒,我将再次展示一个更紧凑的实现:
private static void printArrayJdk(final int[][] values)
{
for (int i = 0; i < values.length; i++)
{
System.out.println(Arrays.toString(values[i]));
}
}
这会产生以下结果:
[1, 2, 1, 1, 2]
[2, 4, 2, 1, 2]
[3, 3, 2, 4, 4]
解决方案 5b:有效性检查(★★★✩✩)
在这个子任务中,您想要验证一个现有的运动场。作为一项挑战,必须返回发现的违规列表。为矩形数组实现方法List<String> checkBoardValidity(int[][])。
例子
要尝试有效性检查,您可以使用简介中的操场,这里特别标记了:
int[][] values = {
{ 2, 3, 3, 4, 4, 3, 2 },
{ 1, 3, 3, 1, 3, 4, 4 },
{ 4, 1, 4, 3, 3, 1, 3 },
{ 2, 2, 1, 1, 2, 3, 2 },
{ 3, 2, 4, 4, 3, 3, 4 } };
由于对角线的原因,这会产生以下误差:
[Invalid at x=3 y=2 tests: hor=false, ver=false, dia=true,
Invalid at x=2 y=3 tests: hor=false, ver=false, dia=true,
Invalid at x=4 y=4 tests: hor=false, ver=false, dia=true]
算法有效性检查可以基于您之前实现的方法轻松开发。您检查每个比赛场地位置的水平、垂直和对角线三行。如果发现这样的违规,您将生成一条适当的错误消息。
List<String> checkBoardValidity(final int[][] board)
{
final List<String> errors = new ArrayList<>();
for (int y = 0; y < board.length; y++)
{
for (int x = 0; x < board[0].length; x++)
{
final int currentJewel = board[y][x];
boolean invalidHor = checkHorizontally(board, x, y, currentJewel);
boolean invalidVer = checkVertically(board, x, y, currentJewel);
boolean invalidDia = checkDiagonally(board, x, y, currentJewel);
if (invalidHor || invalidVer || invalidDia)
{
errors.add(String.format("Invalid at x=%d y=%d " +
"tests: hor=%b, ver=%b, dia=%b\n",
x, y, invalidHor, invalidVer, invalidDia));
}
}
}
return errors;
}
确认
为了测试有效性检查,您首先使用简介中的操场,由于它的对角线,应该会产生以下错误:
jshell> int[][] values = { { 2, 3, 3, 4, 4, 3, 2 },
{ 1, 3, 3, 1, 3, 4, 4 },
{ 4, 1, 4, 3, 3, 1, 3 },
{ 2, 2, 1, 1, 2, 3, 2 },
{ 3, 2, 4, 4, 3, 3, 4 } };
jshell> checkBoardValidity(values)
$37 ==> [Invalid at x=3 y=2 hor=false, ver=false, dia=true
, Invalid at x=2 y=3 hor=false, ver=false, dia=true
, Invalid at x=4 y=4 hor=false, ver=false, dia=true]
随后,将有问题的数字替换为尚未使用的数字,如数字 5,并重新测试该方法,预计不会出现冲突:
jshell> int[][] values2 = { { 2, 3, 3, 4, 4, 3, 2 },
{ 1, 3, 3, 1, 3, 4, 4 },
{ 4, 1, 4, 5, 3, 1, 3 },
{ 2, 2, 5, 1, 2, 3, 2 },
{ 3, 2, 4, 4, 5, 3, 4 } };
jshell> checkBoardValidity(values2)
$44 ==> []
5.3.6 解决方案 6:珠宝板擦除钻石(★★★★✩)
挑战在于从矩形游戏场地中删除所有三个或更多水平、垂直或对角连接的钻石链,随后用位于其上的钻石填充由此产生的空白空间(即,大致与重力在自然界中的作用方式相同)。下面是一个示例,说明如何重复几次擦除然后放下,直到不再发生变化为止(空格显示为 _,以便于查看):
Iteration 1:
1 1 1 2 4 4 3 erase _ _ _ _ 4 4 _ fall down _ _ _ _ _ _ _
1 2 3 4 2 4 3 => 1 2 3 4 _ 4 _ => 1 2 3 4 4 4 _
2 3 3 1 2 2 3 2 3 3 1 2 _ _ 2 3 3 1 2 4 _
Iteration 2:
_ _ _ _ _ _ _ erase _ _ _ _ _ _ _ fall down _ _ _ _ _ _ _
1 2 3 4 4 4 _ => 1 2 3 _ _ _ _ => 1 2 3 _ _ _ _
2 3 3 1 2 4 _ 2 3 3 1 2 4 _ 2 3 3 1 2 4 _
解决方案 6a:擦除(★★★★✩)
写入方法boolean eraseChains(int[][]),从矩形游戏场数组中擦除水平、垂直和对角线方向上三个或更多连续菱形的所有行。
例子
方法的调用将左边给出的输出数组转换为右边显示的结果:
All chains without overlap Special case: overlaps
1 2 3 3 3 4 0 0 0 0 0 0 1 1 1 2 0 0 0 2
1 3 2 4 2 4 0 3 0 4 2 0 1 1 3 4 => 0 0 3 4
1 2 4 2 4 4 => 0 0 4 0 4 0 1 2 1 3 0 2 0 3
1 2 3 5 5 5 0 0 3 0 0 0
1 2 1 3 4 4 0 0 1 3 4 4
算法:初步考虑作为第一种暴力变体,您可以在查找时直接删除值。在这种情况下,您搜索长度为 3 或更长的链,然后直接删除这些字段。然而,这有一个致命的弱点:单颗钻石可以是几个链的一部分,如上例所示。如果您立即删除,则无法找到所有匹配项。根据先完成的检查,其他两项检查会在以下情况中失败:
XXX
XX
X X
第二个想法是通过选择一个象征删除请求的中间表示(比如负数)来代替删除,从而最小程度地修改算法。在处理完数组中的所有条目后,删除操作将在一个单独的过程中进行。具体来说,通过用数值 0 替换数组中的所有负值来删除它们。
算法idea 2 的实现从使用markElementsForRemoval(int[][])方法标记所有要删除的字段开始。然后使用方法eraseAllMarked(int[][])删除它们。对于这两种方法,您可以逐个位置地工作。首先,你必须检测长度为 3 或更长的链。方法List<Direction> findChains(int[][], int, int)对此负责。一旦找到一个链,就通过调用void markDirectionsForRemoval(int[][], int, int, List<Direction>)进行标记。下一个动作是确定每个字段是否被标记为删除。在这种情况下,存储的值被替换为值 0。
public static boolean eraseChains(final int[][] values)
{
markElementsForRemoval(values);
return eraseAllMarked(values);
}
static void markElementsForRemoval(final int[][] values)
{
for (int y = 0; y < values.length; y++)
{
for (int x = 0; x < values[y].length; x++)
markDirectionsForRemoval(values, x, y, findChains(values, x, y));
}
}
static boolean eraseAllMarked(final int[][] values)
{
boolean erasedSomething = false;
for (int y = 0; y < values.length; y++)
{
for (int x = 0; x < values[y].length; x++)
{
if (isMarkedForRemoval(values[y][x]))
{
values[y][x] = 0;
erasedSomething = true;
}
}
}
return erasedSomething;
}
static boolean isMarkedForRemoval(final int value)
{
return value < 0;
}
现在让我们转到两个更复杂的实现,开始挑选和识别三个或更多相似的钻石链。为此,您检查所有相关的方向是否有一个链(同样是优化,这一次您只需检查右下角和左下角的对角线)。为此,您遍历字段,计算相似元素,并在出现偏差时停止。如果发现三个或更多相等的值,那么该方向包含在List<Direction> dirsWithChains中。作为一个特殊的特性,您在方法开始时检查当前字段是否为空——您不想收集一连串的空白。
static List<Direction> findChains(final int[][] values,
final int startX, final int startY)
{
final int origValue = values[startY][startX];
if (origValue == 0) // ATTENTION: consider such special cases
return Collections.emptyList();
final List<Direction> dirsWithChains = new ArrayList<>();
var relevantDirs = EnumSet.of(Direction.S, Direction.SW,
Direction.E, Direction.SE);
for (Direction currentDir : relevantDirs)
{
int nextPosX = startX + currentDir.dx;
int nextPosY = startY + currentDir.dy;
int length = 1;
while (isOnBoard(values, nextPosX, nextPosY) &&
isSame(origValue, values[nextPosY][nextPosX]))
{
nextPosX += currentDir.dx;
nextPosY += currentDir.dy;
length++;
}
if (length >= 3)
{
dirsWithChains.add(currentDir);
break;
}
}
return dirsWithChains;
}
static boolean isOnBoard(final int[][] values,
final int nextPosX, final int nextPosY)
{
return nextPosX >= 0 && nextPosY >= 0 &&
nextPosX < values[0].length && nextPosY < values.length;
}
static boolean isSame(final int val1, final int val2)
{
return Math.abs(val1) == Math.abs(val2);
}
事实上,你已经快成功了。唯一缺少的是标记删除的方法。你在开始的时候会想到这个任务有这么复杂吗?大概不会:-)我们开始工作吧。现在,您遍历所有链,并将原始值转换为标记为删除的值。为了实现这一点,您依赖于 helper 方法int markForRemoval(int),为了简单起见,该方法将值转换为负值(例如,对于类型char,您可以使用小写转换)。
static void markDirectionsForRemoval(final int[][] values,
final int startX, final int startY,
final List<Direction> dirsWithChains)
{
final int origValue = values[startY][startX];
for (final Direction currentDir : dirsWithChains)
{
int nextPosX = startX;
int nextPosY = startY;
while (isOnBoard(values, nextPosX, nextPosY) &&
isSame(origValue, values[nextPosY][nextPosX]))
{
values[nextPosY][nextPosX] = markForRemoval(origValue);
nextPosX += currentDir.dx;
nextPosY += currentDir.dy;
}
}
}
static int markForRemoval(final int value)
{
return value > 0 ? -value : value;
}
我想指出的是,这些功能是利用副作用解决的。在这里,您直接对传递的数据进行操作,所以这并不坏,因为数据没有被进一步传递出去。取而代之的是所有的内部功能。
确认
在这个令人疲惫的实现之后,让我们也测试一下删除:
@Test
void eraseChains()
{
int[][] board = { { 1, 1, 1, 2, 4, 4, 3 },
{ 1, 1, 3, 4, 2, 4, 3 },
{ 1, 3, 1, 1, 2, 2, 3 } };
boolean deleted = EX06_JewelsEraseDiamonds.eraseChains(board);
int[][] expectedBoard = { { 0, 0, 0, 0, 4, 4, 0 },
{ 0, 0, 3, 4, 0, 4, 0 },
{ 0, 3, 0, 1, 2, 0, 0 } };
assertTrue(deleted);
assertArrayEquals(expectedBoard, board);
}
@Test
void eraseChainsOtherBoard()
{
int[][] board = { { 1, 1, 3, 3, 4, 5 },
{ 1, 1, 0, 0, 4, 5 },
{ 1, 2, 3, 3, 4, 5 },
{ 1, 2, 0, 3, 3, 4 },
{ 1, 2, 3, 4, 4, 4 } };
boolean deleted = EX06_JewelsEraseDiamonds.eraseChains(board);
int[][] expectedBoard = { { 0, 1, 3, 3, 0, 0 },
{ 0, 1, 0, 0, 0, 0 },
{ 0, 0, 3, 3, 0, 0 },
{ 0, 0, 0, 3, 3, 4 },
{ 0, 0, 3, 0, 0, 0 } };
assertTrue(deleted);
assertArrayEquals(expectedBoard, board);
}
解决方案 6b:摔倒(★★★✩✩)
写方法void fallDown(int[][])在从上到下放下钻石的地方工作,假设钻石位置下面有一个空间。
例子
方法的调用将左边给出的输出数组转换为右边显示的结果:
0 1 3 3 0 0 0 0 0 0 0 0
0 1 0 0 0 0 0 0 0 0 0 0
0 0 3 3 0 0 => 0 0 3 3 0 0
0 0 0 3 3 4 0 1 3 3 0 0
0 0 3 0 0 0 0 1 3 3 3 4
算法起初,任务似乎相对容易解决。然而,由于一些特殊的特征,复杂性增加了。
让我们看一个可能的实现。让我们从一个暴力解决方案开始。从左到右,对垂直方向上的所有 x 位置进行以下检查:从最低的一行到第二高的一行,测试它们在每种情况下是否表示空白。如果是这种情况,则使用上一行中的值。在这种情况下,上一行的值与空白值交换(此处用 _ 表示,在模型中用值 0 表示):
1 1 _
2 => _ => 1
_ 2 2
该过程可以如下实现:
static void fallDownFirstTry(final int[][] values)
{
for (int x = 0; x < values[0].length; x++)
{
for (int y = values.length - 1; y > 0; y--)
{
final int value = values[y][x];
if (isBlank(value))
{
// fall down
values[y][x] = values[y - 1][x];
values[y - 1][x] = 0;
}
}
}
}
static boolean isBlank(final int value)
{
return value == 0;
}
这种工作方式还可以,但不幸的是,对于下面的特殊情况就不太好了:
1 _
_ => 1
_ _
您可以看到传播丢失了。
作为下一个想法,你可以从顶部开始下降,但这也不是在所有情况下都有效!而在这个过程中,以前有问题的情况
1 _
_ => _
_ 1
解决了,现在第一个星座出现了问题。以前的变体不会出现这些问题:
1 1
2 => _
_ 2
您必须认识到,所讨论的两种变体都还没有完全正确地工作。此外,使用正确的测试数据集来揭示这些特定的问题是至关重要的。
要纠正这一点,您需要实现石头的连续下落,以始终移动每列的所有值。while循环用于此:
static void fallDown(final int[][] values)
{
for (int x = 0; x < values[0].length; x++)
{
for (int y = values.length - 1; y > 0; y--)
{
int currentY = y;
// fall down until there is no more empty space under it
while (currentY < values.length && isBlank(values[currentY][x]))
{
// runterfallen
values[currentY][x] = values[currentY - 1][x];
values[currentY - 1][x] = 0;
currentY++;
}
}
}
}
确认
现在让我们把先前获得的删除结果作为下降的起点:
@Test
void fallDown()
{
int[][] board = { { 0, 1, 3, 3, 0, 0 },
{ 0, 1, 0, 0, 0, 0 },
{ 0, 0, 3, 3, 0, 0 },
{ 0, 0, 0, 3, 3, 4 },
{ 0, 0, 3, 0, 0, 0 } };
EX06_JewelsEraseDiamonds.fallDown(board);
int[][] expectedBoard = { { 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0 },
{ 0, 0, 3, 3, 0, 0 },
{ 0, 1, 3, 3, 0, 0 },
{ 0, 1, 3, 3, 3, 4 } };
assertArrayEquals(expectedBoard, board);
}
整体验证
为了查看您的方法的运行情况,除了介绍中的例子之外,您还可以使用一个准备好的char[][],从中您可以很好地跟踪各种删除和迭代。然而,您必须分别为类型char和char[][]重写一些方法(参见下面的实用技巧):
jshell> int[][] exampleBoard = { { 1, 1, 1, 2, 4, 4, 3 },
...> { 1, 2, 3, 4, 2, 4, 3 },
...> { 2, 3, 3, 1, 2, 2, 3 } };
...>
...> printArray(exampleBoard);
...> while (eraseChains(exampleBoard))
...> {
...> System.out.println("---------------------------------");
...> fallDown(exampleBoard);
...> printArray(exampleBoard);
...> }
1 1 1 2 4 4 3
1 2 3 4 2 4 3
2 3 3 1 2 2 3
---------------------------------
0 0 0 0 0 0 0
1 2 3 4 4 4 0
2 3 3 1 2 4 0
---------------------------------
0 0 0 0 0 0 0
1 2 3 0 0 0 0
2 3 3 1 2 4 0
jshell> char[][] jewelsTestDeletion = { "AACCDE".toCharArray(),
...> "AA DE".toCharArray(),
...> "ABCCDE".toCharArray(),
...> "AB CCD".toCharArray(),
...> "ABCDDD".toCharArray(), };
...>
...> printArray(jewelsTestDeletion);
...>
...> while (eraseChains(jewelsTestDeletion))
...> {
...> System.out.println("---------------------------------");
...> fallDown(jewelsTestDeletion);
...> printArray(jewelsTestDeletion);
...> }
A A C C D E
A A D E
A B C C D E
A B C C D
A B C D D D
---------------------------------
C C
A C C
A C C C D
---------------------------------
A
A D
Hint: Variants with Type Char
一些读者可能想知道为什么我实现不同的助手方法,当它们看起来非常简单的时候。原因在于,通过这种方式,可以将几乎不变的算法用于其他类型,而不是仅仅通过重新定义相应的帮助器方法来使用int,例如:
static boolean isMarkedForRemoval(final char value)
{
return Character.isLowerCase(value);
}
static boolean isBlank(final char value)
{
return value == '_' || value == ' ';
}
static boolean isSame(final char char1, final char char2)
{
return Character.toLowerCase(char1) == Character.toLowerCase(char2);
}
更概念性的操作方法,如确定要删除的链、实际删除和钻石的丢弃,必须相应地重载并适应所需的类型。在下面的代码中,显示了方法void fallDown(char[][])及其与方法char[][]的对齐:
static void fallDown(char[][] values)
{
for (int y = values.length - 1; y > 0; y--)
{
for (int x = 0; x < values[0].length; x++)
{
int currentY = y;
// fall down until there is no more empty space under it
while (currentY < values.length && isBlank(values[currentY][x]))
{
// runterfallen
values[currentY][x] = values[currentY - 1][x];
values[currentY - 1][x] = '_';
currentY++;
}
}
}
}
如果不仔细观察,与作业开始时设计的方法的区别是不明显的。其他方法也是如此,但这里不介绍。
5.3.7 解决方案 7:螺旋遍历(★★★★✩)
编写泛型方法List<T>spiralTraversal(T[][]),以螺旋方式遍历一个二维矩形数组,并将其准备为一个列表。起点在左上角。首先,遍历外层,然后是下一个内层。
例子
示例如图 5-5 所示。
图 5-5
螺旋遍历的基本程序
对于下面两个数组,下面的数字或字母序列应该是螺旋遍历的结果:
Integer[][] numbers = { { 1, 2, 3, 4 },
{ 12, 13, 14, 5 },
{ 11, 16, 15, 6 },
{ 10, 9, 8, 7 } };
String[][] letterPairs = { { "AB", "BC", "CD", "DE" },
{ "JK", "KL", "LM", "EF" },
{ "IJ", "HI", "GH", "FG" } };
=>
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
[AB, BC, CD, DE, EF, FG, GH, HI, IJ, JK, KL, LM]
Job Interview Tips: Clarify Assumptions
在着手解决方案之前,一定要通过提问来澄清任何约束或特殊要求。在这种情况下,原始数据应该是一个矩形数组。假设这里是这种情况。
算法先说一个思路:对于一个螺旋运动,你必须先向右直到到达边界,向下改变方向再次前进直到到达边界,然后向左,最后向上到达边界。为了使螺旋线变窄,在每次改变方向时,必须适当地减小各自的极限。当只对方向和边界的变化进行操作时,正确地制定终止条件是不容易的。为了确定端接标准,下面的观察是有帮助的:对于 4 × 3 阵列,总步数由宽度∫高度1 给出,因此 4∫31 = 121 = 11。有了这些初步的考虑,您可以如下实现螺旋遍历:
enum Direction
{
RIGHT, DOWN, LEFT, UP;
}
static <T> List<T> spiralTraversal(final T[][] values)
{
int posX = 0;
int posY = 0;
int minX = 0;
int maxX = values[0].length;
int minY = 1;
int maxY = values.length;
final List<T> results = new ArrayList<>();
Direction dir = Direction.RIGHT;
int steps = 0;
while (steps < maxX * maxY)
{
// perform action
results.add(values[posY][posX]);
if (dir == Direction.RIGHT)
{
if (posX < maxX - 1)
posX++;
else
{
dir = Direction.DOWN;
maxX--;
}
}
if (dir == Direction.DOWN)
{
if (posY < maxY - 1)
posY++;
else
{
dir = Direction.LEFT;
maxY--;
}
}
if (dir == Direction.LEFT)
{
if (posX > minX)
posX--;
else
{
dir = Direction.UP;
minX++;
}
}
if (dir == Direction.UP)
{
if (posY > minY)
posY--;
else
{
dir = Direction.RIGHT;
minY++;
// possible mistake: You now have to
// start at a position further to the right!
posX++;
}
}
steps++;
}
return results;
}
在一个层的完整遍历之后,你必须将位置指针向中心移动一个位置。这是很容易忘记的。
提出的算法是可行的,但是有相当多的特殊处理。此外,如果单个动作块不是如此广泛,那么switch可能是一个不错的选择。再看一遍图片,然后思考一下。
优化算法你认识到,最初,整个数组是一个有效的移动区域。在每次迭代中,外层被处理,一个继续向内。现在,您可以像以前一样通过四个位置标记来指定有效范围。然而,你在更新的时候更聪明。
您会注意到向右移动后,第一行完成了,因此您可以将计数器minY加 1。如果下移,那么最右边的就完成了,计数器maxX减一。向左移动,则处理最下面一行,计数器maxY减一。最后,向上移动时,计数器minX加 1。为了检测何时增加,您实现了一个用于范围检查的实用方法isOutside()。
此外,您仍然可以根据螺旋遍历中的顺序来定义方向常量,然后在enum中实现方法next(),该方法指定了每种情况下的后续方向。同样,在那里定义偏移值dx和dy。
enum Direction
{
RIGHT(1, 0), DOWN(0, 1), LEFT(-1, 0), UP(0, -1);
int dx;
int dy;
Direction(final int dx, final int dy)
{
this.dx = dx;
this.dy = dy;
}
Direction next()
{
return values()[(this.ordinal() + 1) % 4];
}
}
有了这些想法和预备知识,您就能够以可读和可理解的方式编写螺旋遍历的实现,如下所示:
static <T> List<T> spiralTraversalOptimized(final T[][] board)
{
int minX = 0;
int maxX = board[0].length;
int minY = 0;
int maxY = board.length;
int x = 0;
int y = 0;
Direction dir = Direction.RIGHT;
final List<T> result = new ArrayList<>();
int steps = 0;
final int allSteps = maxX * maxY;
while (steps < allSteps)
{
result.add(board[y][x]);
if (isOutside(x + dir.dx, y + dir.dy, minX, maxX, minY, maxY))
{
switch (dir)
{
case RIGHT -> minY++;
case DOWN -> maxX--;
case LEFT -> maxY--;
case UP -> minX++;
}
dir = dir.next();
}
x += dir.dx;
y += dir.dy;
steps++;
}
return result;
}
private static boolean isOutside(final int x, final int y,
final int minX, final int maxX,
final int minY, final int maxY)
{
return !(x >= minX && x < maxX && y >= minY && y < maxY);
}
}
在代码中,您在switch处使用了新的 Java 14 语法,使得整个结构更短、更优雅。3
确认
让我们检查一下您的算法以及它的优化变体是否实现了对上述示例中输入的数组的预期遍历:
@ParameterizedTest(name="spiralTraversal({0}) => {1}")
@MethodSource("createArrayAndExpected")
void spiralTraversal(Object[][] values, List<Object> expected)
{
var result = Ex07_SpiralTraversal.spiralTraversal(values);
assertEquals(expected, result);
}
@ParameterizedTest(name="spiralTraversalOptimized({0}) => {1}")
@MethodSource("createArrayAndExpected")
void spiralTraversalOptimized(Object[][] values, List<Object> expected)
{
var result = Ex07_SpiralTraversal.spiralTraversalOptimized(values);
assertEquals(expected, result);
}
private static Stream<Arguments> createArrayAndExpected()
{
String[][] letters = { { "A", "B", "C", "D" },
{ "J", "K", "L", "E" },
{ "I", "H", "G", "F" } };
Integer[][] numbers = { { 1, 2, 3, 4 },
{ 12, 13, 14, 5 },
{ 11, 16, 15, 6 },
{ 10, 9, 8, 7 } };
return Stream.of(Arguments.of(letters,
List.of("A","B", "C", "D", "E", "F",
"G", "H", "I", "J", "K", "L")),
Arguments.of(numbers,
List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16)));
}
5.3.8 解决方案 8:在数组中加 1 作为编号(★★✩✩✩)
考虑一个表示十进制数的数字数组。编写方法int[] addOne(int[]),通过值 1 执行加法,并且只允许使用数组作为解决方案的数据结构。
例子
|投入
|
结果
| | --- | --- | | [1, 3, 2, 4] | [1, 3, 2, 5] | | [1, 4, 8, 9] | [1, 4, 9, 0] | | [9, 9, 9, 9] | [1, 0, 0, 0, 0] |
算法你可能还记得你的学生时代,使用面向数字的处理:从后向前遍历数组。然后将最后一次加法的溢出值加到相应的数字值上。最初,您假设存在溢出。如果再次达到值 10,溢出必须进一步传播。在溢出传播到最前面的特殊情况下,数组必须增加一个位置以容纳新的前导 1。
static int[] addOne(final int[] values)
{
if (values.length == 0)
throw new IllegalArgumentException("must pass a valid non empty array");
final int[] result = Arrays.copyOf(values, values.length);
boolean overflow = true;
int pos = values.length - 1;
while (overflow && pos >= 0)
{
int currentValue = result[pos];
if (overflow)
currentValue += 1;
result[pos] = currentValue % 10;
overflow = currentValue >= 10;
pos--;
}
return handleOverflowAtTop(result, overflow);
}
对于前端进位的特殊处理,可采用以下方法:
static int[] handleOverflowAtTop(final int[] result, final boolean overflow)
{
if (overflow)
{
// new array and a 1 at the front
final int[] newValues = new int[result.length + 1];
newValues[0] = 1;
for (int i = 0; i < result.length; i++)
newValues[1 + i] = result[i];
return newValues;
}
return result;
}
确认
为了检查您的实现,您使用了介绍性示例中的三个值组合——涵盖了三种主要情况:不传播、传播一位数和传播所有位数。此外,您还包括两位数的传播。
@ParameterizedTest(name = "addOne({0}) => {1}")
@MethodSource("intArrays")
void addOne(int[] input, int[] expected)
{
int[] result = Ex08_AddOneToAnArrayOfNumbers.addOne(input);
assertArrayEquals(expected, result);
}
private static Stream<Arguments> intArrays()
{
int[] values1 = { 1, 3, 2, 4 };
int[] expected1 = { 1, 3, 2, 5 };
int[] values2 = { 1, 4, 8, 9 };
int[] expected2 = { 1, 4, 9, 0 };
int[] values3 = { 1, 3, 9, 9 };
int[] expected3 = { 1, 4, 0, 0 };
int[] values4 = { 9, 9, 9, 9 };
int[] expected4 = { 1, 0, 0, 0, 0 };
return Stream.of(Arguments.of(values1, expected1),
Arguments.of(values2, expected2),
Arguments.of(values3, expected3),
Arguments.of(values4, expected4));
}
5.3.9 解决方案 9:数独检查器(★★★✩✩)
在这个挑战中,将检查一个数独谜题,看它是否是一个有效的解决方案。让我们假设一个具有int值的 9 × 9 数组。根据数独规则,每行和每列必须包含从 1 到 9 的所有数字。此外,从 1 到 9 的所有数字必须依次出现在每个 3×3 子阵列中。编写方法boolean isSudokuValid(int[][])进行检查。
例子
这里显示了一个有效的解决方案:
算法在数独游戏中,要进行三种不同的检查。这些检查可以很好地分为三种相应的方法。首先是checkHorizontally()和checkVertically(),它们确保从 1 到 9 的所有数字在一行或一列中分别出现一次。为了检查这一点,您将存储在相应序列中的所有数字收集到一个列表中,并在allDesiredNumbers()方法中进行比较,以查看它们是否包含所需的数字:
boolean checkHorizontally(final int[][] board)
{
for (int row = 0; row < 9; row++)
{
// collect all values of a row in a list
final List<Integer> rowValues = new ArrayList<>();
for (int x = 0; x < 9; x++)
{
rowValues.add(board[row][x]);
}
if (!allDesiredNumbers(rowValues))
{
return false;
}
}
return true;
}
boolean checkVertically(final int[][] board)
{
for (int x = 0; x < 9; x++)
{
// collect all values of a column in a list
final List<Integer> columnValues = new ArrayList<>();
for (int row = 0; row < 9; row++)
{
columnValues.add(board[row][x]);
}
if (!allDesiredNumbers(columnValues))
{
return false;
}
}
return true;
}
您可能想知道在Set<E>中收集值是否更好。虽然这是显而易见的,并且对于完全填充的数独游戏很有效,但是在Set<E>中收集数据会使后续的检查变得复杂,如果你也允许空字段的话。
无论如何,这两种检查都依赖于下面的 helper 方法:
boolean allDesiredNumbers(final Collection<Integer> values)
{
if (values.size() != 9)
throw new IllegalStateException("implementation problem");
final Set<Integer> oneToNine = Set.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
final Set<Integer> valuesSet = new TreeSet<>(values);
return oneToNine.equals(valuesSet);
}
我想明确指出 helper 方法allDesiredNumbers()的优雅之处。它以简洁的方式统一了各种东西:实际上,您需要检查收集的值不包含重复值,并且正好有九个重复值。由于您的实现,您不需要检查长度。尽管如此,您还是要这样做,以防止出现意外的粗心错误。通过将这些值转换成一个集合,并将其与期望值的集合进行比较,这个过程既简单又快捷。
接下来,您需要检查大小为 3×3 的 9 个子场。这一开始听起来并不容易。但是让我们想一想:你可以使用两个嵌套循环来运行 3×3 的盒子。另外两个嵌套循环分别运行 x 和 y 值。简单的乘法和加法用于导出原始数组中的相应索引值。按照前面提出的想法,将值收集到一个列表中,最后对照预期的数字 1 到 9 的目标集进行检查,实现就没有了最初的恐惧。
public static boolean checkBoxes(final int[][] board)
{
// 3 x 3-boxes
for (int yBox = 0; yBox < 3; yBox ++)
{
for (int xBox = 0; xBox < 3; xBox ++)
{
// values per box
final List<Integer> boxValues = collectBoxValues(board, yBox, xBox);
if (!allDesiredNumbers(boxValues))
{
return false;
}
}
}
return true;
}
private static List<Integer> collectBoxValues(final int[][] board,
final int yBox, final int xBox)
{
final List<Integer> boxValues = new ArrayList<>();
// innerhalb der Boxen jeweils 3 x 3 Felder
for (int y=0; y < 3; y++)
{
for (int x = 0; x < 3; x++)
{
// actual index values
final int realY = yBox * 3 + y;
final int realX = xBox * 3 + x;
boxValues.add(board[realY][realX]);
}
}
return boxValues;
}
对于完整的数独检查,您需要通过 AND 将这些值组合在一起:
boolean isSudokuValid(final int[][] board)
{
return checkHorizontally(board) &&
checkVertically(board) &&
checkBoxes(board);
}
确认
首先按照我在介绍中展示的那样定义数独游戏场,然后测试所有三种变体:
jshell> int[][] board = new int[9][9];
...> board[0] = new int[]{ 1, 2, 3, 4, 5, 6, 7, 8, 9 };
...> board[1] = new int[]{ 4, 5, 6, 7, 8, 9, 1, 2, 3 };
...> board[2] = new int[]{ 7, 8, 9, 1, 2, 3, 4, 5, 6 };
...> board[3] = new int[]{ 2, 1, 4, 3, 6, 5, 8, 9, 7 };
...> board[4] = new int[]{ 3, 6, 5, 8, 9, 7, 2, 1, 4 };
...> board[5] = new int[]{ 8, 9, 7, 2, 1, 4, 3, 6, 5 };
...> board[6] = new int[]{ 5, 3, 1, 6, 4, 2, 9, 7, 8 };
...> board[7] = new int[]{ 6, 4, 2, 9, 7, 8, 5, 3, 1 };
...> board[8] = new int[]{ 9, 7, 8, 5, 3, 1, 6, 4, 2 };
jshell> System.out.println("V: " + checkVertically(board));
...> System.out.println("H: " + checkHorizontally(board));
...> System.out.println("B: " + checkBoxes(board));
V: true
H: true
B: true
jshell> isSudokuValid(board)
$72 true
奖金
虽然能够检查一个完全充满数字的数独棋盘的有效性已经很好了,但能够预测一个有个缺口(即仍然缺少数字)的棋盘是否能产生有效的解决方案就更好了。如果您想开发一种解决数独难题的算法,这是非常有趣的。
例子
基于上面给出的有效数独游戏域的例子,我已经删除了任意位置的数字。这肯定会产生一个有效的解决方案。
如果你以先前的实现为基础,部分填充的运动场可以很容易地被检查有效性。首先,您需要为空白字段建模。在这种情况下,值 0 是一个很好的选择。基于此,您可以保留水平、垂直和在框中收集值的实现。您只需稍微修改最终检查是否包括从 1 到 9 的所有值。首先,从收集的值(如果有的话)中删除值 0。然后你要确保没有重复。最后,检查收集的值是否是 1 到 9 的子集。
static boolean allDesiredNumbers(final Collection<Integer> allCollectedValues)
{
// remove irrelevant empty fields
final List<Integer> relevantValues = new ArrayList<>(allCollectedValues);
relevantValues.removeIf(val -> val == 0);
// no duplicates?
final Set<Integer> valuesSet = new TreeSet<>(relevantValues);
if (relevantValues.size() != valuesSet.size())
return false;
// only 1 to 9?
final Set<Integer> oneToNine = Set.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
return oneToNine.containsAll(valuesSet);
}
最好的出现在最后:这种方法适用于完全填充的数独游戏和那些包含空格的游戏!
Note: Deleting Elements from Lists
有时一项任务听起来比实际简单得多。在上例中,所有值为 0 的数字都将从List<Integer>中删除。有时人们会看到如下两种尝试:
// ATTENTION: wrong attempts
values.remove(0); // Attention INDEX
values.remove(Integer.valueOf(0)); // ONLY first occurrence
为什么整件事如此棘手?这是因为 API,它一方面提供了基于位置的访问,另一方面提供了删除值的能力。然而,如果您想要删除几个相同的元素,您可以使用方法removeAll(),但是您必须向它传递一个值列表——这有点笨拙,但是使用一个元素列表,您就可以实现:
values.removeAll(List.of(0));
事实上,更优雅地实现这一点是可能的。从 Java 8 开始,你可以使用方法removeIf()并提供一个合适的Predicate<T>。这是表达算法的最佳方式:
values.removeIf(value -> value == 0);
确认
再次用空格定义数独游戏场,如前所示。之后,您检查一个稍微修改过的 playfield,在位置 3 的第一行中,直接插入了一个 2,这使得 playfield 无效。
@Test
void isSudokuValid()
{
final int[][] board = createInitializedBoard();
final boolean validSudoku = Ex09_SudokuChecker.isSudokuValid(board);
assertTrue(validSudoku);
}
@Test
void isSudokuValidForInvalidBoard()
{
final int[][] board = createInitializedBoard();
board[0][2] = 2;
ArrayUtils.printArray(board);
final boolean validSudoku = Ex09_SudokuChecker.isSudokuValid(board);
assertFalse(validSudoku);
}
private int[][] createInitializedBoard()
{
final int[][] board = new int[9][9];
board[0] = new int[] { 1, 2, 0, 4, 5, 0, 7, 8, 9 };
board[1] = new int[] { 0, 5, 6, 7, 0, 9, 0, 2, 3 };
board[2] = new int[] { 7, 8, 0, 1, 2, 3, 4, 5, 6 };
board[3] = new int[] { 2, 1, 4, 0, 6, 0, 8, 0, 7 };
board[4] = new int[] { 3, 6, 0, 8, 9, 7, 2, 1, 4 };
board[5] = new int[] { 0, 9, 7, 0, 1, 4, 3, 6, 0 };
board[6] = new int[] { 5, 3, 1, 6, 0, 2, 9, 0, 8 };
board[7] = new int[] { 6, 0, 2, 9, 7, 8, 5, 3, 1 };
board[8] = new int[] { 9, 7, 0, 0, 3, 1, 6, 4, 2 };
return board;
}
第二个测试用例的错误领域如下所示——有问题的值用粗体标记:
1 2 2 4 5 0 7 8 9
0 5 6 7 0 9 0 2 3
7 8 0 1 2 3 4 5 6
2 1 4 0 6 0 8 0 7
3 6 0 8 9 7 2 1 4
0 9 7 0 1 4 3 6 0
5 3 1 6 0 2 9 0 8
6 0 2 9 7 8 5 3 1
9 7 0 0 3 1 6 4 2
5.3.10 解决方案 10:洪水填充(★★✩✩✩)
溶液 10a (★★✩✩✩)
用指定值填充数组中所有空闲字段的编写方法void floodFill(char[], int, int)。
例子
下面显示了字符*的填充过程。填充从给定位置开始,例如左上角。然后在所有四个罗盘方向上继续,直到找到数组的边界或由另一个字符表示的边界。
" # " "***# " " # #" " #******#"
" #" "****#" " # #" " #******#"
" # #" => "#***#" "# # #" => "# #*****#"
" # # " " #*# " " # # #" " # #*****#"
" # " " # " " # #" " #*****#"
算法递归检查四个基本方向上的相邻单元。如果一个字段是空的,填充它并再次检查它的四个邻居。如果到达数组边界或填充的单元格,就停下来。这可以用一种奇妙的方式递归地表达出来:
static void floodFill(final char[][] values, final int x, final int y)
{
// recursive termination
if (x < 0 || y < 0 || y >= values.length || x >= values[y].length)
return;
if (values[y][x] == ' ')
{
values[y][x] = '*';
// recursive descent: fill in all 4 directions
floodFill(values, x, y-1);
floodFill(values, x+1, y);
floodFill(values, x, y+1);
floodFill(values, x-1, y);
}
}
这里显示的版本可以通过巧妙地检查每行的宽度来处理非矩形数组。它还可以处理非矩形数组,并适当填充它们。
确认
现在让我们将介绍中显示的数组定义为起点,然后从左上角开始填充。为了验证,数组被打印在控制台上(例外地也作为单元测试的一部分)。此外,您将展示一个从底部开始在中间填充的非矩形阵列。
@ParameterizedTest(name = "{0}, {4}")
@MethodSource("createWorldAndExpectedFills")
public void testFloodFill2(char[][] world, char[][] expected,
int startX, int startY, String hint)
{
Ex10_FloodFillExample.floodFill(world, startX, startY);
ArrayUtils.printArray(world);
assertArrayEquals(expected, world);
}
private static Stream<Arguments> createWorldAndExpectedFills()
{
return Stream.of(Arguments.of(firstWorld(), firstFilled), 0, 0, "rect"),
Arguments.of(nonRectWorld(), nonRectFilled(), 4, 4,
"no rect"));
}
private static Stream<Arguments> createWorldAndExpectedFills()
{
return Stream.of(Arguments.of(firstWorld(), firstFilled(), 0, 0, "rect"),
Arguments.of(nonRectWorld(), nonRectFilled(), 4, 4,
"no rect"));
}
private static char[][] firstWorld()
{
return new char[][] { " # ".toCharArray(),
" # ".toCharArray(),
"# # ".toCharArray(),
" # # ".toCharArray(),
" # ".toCharArray()};
}
private static char[][] firstFilled()
{
return new char[][] { "***# ".toCharArray(),
"****# ".toCharArray(),
"#***# ".toCharArray(),
" #*# ".toCharArray(),
" # ".toCharArray()};
}
private static char[][] nonRectWorld()
{
return new char[][] { " # #".toCharArray(),
" # #".toCharArray(),
"# # #".toCharArray(),
" # # #".toCharArray(),
" # #".toCharArray()};
}
private static char[][] nonRectFilled()
{
return new char[][] { " #******#".toCharArray(),
" #******#".toCharArray(),
"# #*****#".toCharArray(),
" # #*****#".toCharArray(),
" #*****#".toCharArray()};
}
解决方案 10b
扩展该方法以填充作为矩形数组传递的任何模式。但是,模式规范中不允许有空格。
例子
下面你会看到一个充满图案的洪水。该图案由几行字符组成:
.|.
-*-
.|.
如果从底部中心开始填充,会得到以下结果:
x . |..|.x
# # - *--#--#
### # . |.###.|#
# ### # => # |.###.|#
# # # # *--#--*#
# # # #.# |..#
# # #. |.#
算法首先,你要把想要的模式传递给方法。有趣的是,填充算法几乎保持不变,只是在填充字符的确定方面有所修改。这里调用的不是一个固定值,而是帮助器方法findFillChar(),它决定了与位置相关的填充字符。递归下降通过使用方向的枚举作为四个单独调用的替代来优雅地公式化。
static void floodFill(final char[][] values, final int x, final int y,
final char[][] pattern)
{
// recursive termination
if (x < 0 || y < 0 || y >= values.length || x >= values[y].length)
return;
if (values[y][x] == ' ')
{
// determine appropriate fill character
values[y][x] = findFillChar(x, y, pattern);
final EnumSet<Direction> directions = EnumSet.of(Direction.N,
Direction.E,
Direction.S,
Direction.W);
// recursive descent in 4 directions
for (final Direction dir : directions)
{
floodFill(values, x + dir.dx, y + dir.dy, pattern);
}
}
}
现在,让我们根据一个简单的模数计算来确定填充字符,该计算是从与具有模式字符的数组的宽度或高度相关的当前位置开始的:
static char findFillChar(final int x, final int y, final char[][] pattern)
{
final int adjustedX = x % pattern[0].length;
final int adjustedY = y % pattern.length;
return pattern[adjustedY][adjustedX];
}
请记住,为了整个事情的工作,没有空间必须在填充模式中指定。
确认
与前面类似,您希望使用前面显示的模式用介绍中显示的分隔符填充数组。因此,首先使用以下方法生成模式:
private static char[][] generatePattern()
{
return new char[][] { ".|.".toCharArray(),
"-*-".toCharArray(),
".|.".toCharArray()};
}
private static char[][] generatePattern2()
{
return new char[][] { "---".toCharArray(),
"~~~".toCharArray(),
"===.".toCharArray()};
}
private static char[][] generateBigWorld()
{
return new char[][] {
" # |".toCharArray(),
" ## # |".toCharArray(),
" ##### # __".toCharArray(),
" ### # |".toCharArray(),
" ### # # |".toCharArray(),
" # # # |".toCharArray(),
" # # # --".toCharArray(),
" # # |".toCharArray()};
}
对于测试,您生成初始模式,并从左上方开始填充第一个模式,然后在右侧填充第二个模式:
jshell> char[][] bigworld = generateBigWorld()
jshell> floodFill(bigworld,1,1, generatePattern())
jshell> floodFill(bigworld, 14, 4, generatePattern2())
出于控制目的,现在打印出数组。这使您可以检查相应图案的填充情况:
jshell> printArray(bigworld)
.|..|..|..|#---|
-*--*--##-*-#~~~|
.|..#####.|..#===
.|..|..###|..#-----|
-###*--*#-*--#~~~~~~|
.|..#..|#.|..#=====|
.|..|#.#..|.#------
-*--*-#*--*#~~~~|
提醒一下,这里再次显示了打印二维数组的方法——在这种情况下,修改后的方法是直接打印相邻的字符:
static void printArray(final char[][] values)
{
for (int y= 0; y < values.length; y++)
{
for (int x = 0; x < values[y].length; x++)
{
final char value = values[y][x];
System.out.print(value + "");
}
System.out.println();
}
}
5.3.11 解决方案 11:阵列合并(★★✩✩✩)
假设有两个数字数组,每个数组都按升序排序。在这个赋值中,这些数组将根据各自值的顺序合并成一个数组。写方法int[] merge(int[], int[])。
例子
|输入 1
|
输入 2
|
结果
| | --- | --- | --- | | [1, 4, 7, 12, 20] | [10, 15, 17, 33] | [1, 4, 7, 10, 12, 15, 17, 20, 33] | | [2, 3, 5, 7] | [11, 13, 17] | [2, 3, 5, 7, 11, 13, 17] | | [2, 3, 5, 7, 11] | [7, 11, 13, 17] | [2, 3, 5, 7, 7, 11 11, 13, 17] | | [1, 2, 3] | * =[] | [1, 2, 3] |
算法首先,创建一个大小合适的结果数组。之后,遍历两个数组;从而比较各自的值,并将较小的元素转换为结果。为了有所帮助,您可以使用两个位置指针来引用各自的值。如果数组 1 中的值被传递到结果中,那么数组 1 的位置指针将增加,数组 2 也是如此。如果同时到达一个或另一个数组的末尾,则另一个数组中的剩余值可以轻松地传输。
static int[] merge(final int[] first, final int[] second)
{
final int length1 = first.length;
final int length2 = second.length;
final int[] result = new int[length1 + length2];
int pos1 = 0;
int pos2 = 0;
int idx = 0;
// iterate as long as the two position pointers are below the
// length of their arrays
while (pos1 < length1 && pos2 < length2)
{
int value1 = first[pos1];
int value2 = second[pos2];
if (value1 < value2)
{
result[idx] = value1;
idx++;
pos1++;
}
else
{
result[idx] = value2;
idx++;
pos2++;
}
}
// collect the remaining elementsf
while (pos1 < length1)
{
result[idx] = first[pos1];
idx++;
pos1++;
}
while (pos2 < length2)
{
result[idx] = second[pos2];
idx++;
pos2++;
}
return result;
}
迷你优化你可以清楚地看到,将数组 1 和数组 2 的余数相加得到的结果的源代码几乎没有什么不同。最好将此功能转移到下面的帮助器方法中:
static void addRemaining(final int[] values, final int[] result,
int pos, int idx)
{
while (pos < values.length)
{
result[idx] = values[pos];
idx++;
pos++;
}
}
这简化了元素的拾取,如下所示:
// Collect the remaining elements
addRemaining(first, result, pos1, idx);
addRemaining(second, result, pos2, idx);
这样做的好处是,您不必区分是否需要追加一个数组的其余部分。您只需为两个数组调用这个 helper 方法,正确的操作就会自动发生。
确认
要检查功能,您可以像往常一样使用简介中的输入:
@ParameterizedTest(name="{0} + {1} = {2}")
@MethodSource("createInputArraysAndExpected")
public void merge(int[] values1, int[] values2, int[] expected)
{
int[] result = Ex11_MergeArrays.merge(values1, values2);
assertArrayEquals(expected, result);
}
private static Stream<Arguments> createArraysAndExpected()
{
int[] values1a = { 1, 4, 7, 12, 20 };
int[] values1b = { 10, 15, 17, 33 };
int[] result1 = { 1, 4, 7, 10, 12, 15, 17, 20, 33 };
int[] values2a = { 2, 3, 5, 7 };
int[] values2b = { 11, 13, 17 };
int[] result2 = { 2, 3, 5, 7, 11, 13, 17 };
int[] values3a = { 2, 3, 5, 22 };
int[] values3b = { 7, 11, 13, 17 };
int[] result3 = { 2, 3, 5, 7, 11, 13, 17, 22 };
return Stream.of(Arguments.of(values1a, values1b, result1),
Arguments.of(values2a, values2b, result2),
Arguments.of(values3a, values3b, result3));
}
Note: Compactness vs. Comprehensibility
对我来说,编程中最重要的事情是开发小的、可理解的和可重用的构建模块。这些块通常因其清晰而易于理解,因此减少了出错的可能性。更好的是:大多数时候,这些构建块也可以很容易地通过单元测试来测试。然而,这部分是以紧凑符号为代价的。然而,这对于经常使用的库和便于算法分析是非常理想的。
现在让我们考虑一种紧凑的实现风格:
public static int[] mergeCompact(final int[] first, final int[] second)
{
final int[] result = new int[first.length + second.length];
int idx1 = 0;
int idx2 = 0;
int destIdx = 0;
while (idx1 < first.length && idx2 < second.length)
{
if (first[idx1] < second[idx2])
result[destIdx++] = first[idx1++];
else
result[destIdx++] = second[idx2++];
}
while (idx1 < first.length)
result[destIdx++] = first[idx1++];
while (idx2 < second.length)
result[destIdx++] = second[idx2++];
return result;
}
哇,真紧凑。然而,你必须更仔细地分析。有了解的推导,整个事情就很好理解了。然而,隐藏在分配中的员额增加很难跟踪。一般来说,这对于商业方法来说是相当不吸引人的,但是对于算法来说是可以忍受的。虽然单元测试几乎总是被推荐,但对于这样的实现更是如此。这应该使得检测易变错误变得容易。
5.3.12 解决方案 12:阵列最小值和最大值(★★✩✩✩)
解决方案 12a:最小值和最大值(★✩✩✩✩)
编写两个方法int findMin(int[])和int findMax(int[]),它们将使用自实现的搜索分别找到给定非空数组的最小值和最大值——从而消除了Math.min()、Arrays.sort(),和Arrays.stream().min()等的使用
例子
|投入
|
最低限度
|
最高的
| | --- | --- | --- | | [2, 3, 4, 5, 6, 7, 8, 9, 1, 10] | one | Ten |
算法从头开始遍历数组。在这两种情况下,假设第一个元素是最小值或最大值。之后,遍历数组,当找到更小或更大的元素时,重新分配最小值或最大值。
public static int findMin(final int[] values)
{
if (values.length == 0)
throw new IllegalArgumentException("values must not be empty");
int min = values[0];
for (int i = 1; i < values.length; i++)
{
if (values[i] < min)
min = values[i];
}
return min;
}
public static int findMax(final int[] values)
{
if (values.length == 0)
throw new IllegalArgumentException("values must not be empty");
int max = values[0];
for (int i = 1; i < values.length; i++)
{
if (values[i] > max)
max = values[i];
}
return max;
}
由于非空源数组的边界约束,您总是可以从第一个元素开始,作为最小值或最大值。
解决方案 12b:最小和最大位置(★★✩✩✩)
实现两个助手方法int findMinPos(int[], int, int)和int findMaxPos(int[], int, int),它们分别查找并返回给定非空数组的最小值和最大值的位置,以及给定为左右边界的索引范围。如果最小值或最大值有几个相同的值,应该返回第一个出现的值。为了分别找到最小值和最大值,编写两个使用辅助方法的方法int findMinByPos(int[], int, int)和int findMaxByPos(int[], int, int)。
例子
|方法
|
投入
|
范围
|
结果
|
位置
| | --- | --- | --- | --- | --- | | findminxyz() | [5, 3, 4, 2, 6, 7, 8, 9, 1, 10] | 0, 10 | one | eight | | findminxyz() | [5, 3, 4, 2, 6, 7, 8, 9, 1, 10] | 0, 7 | Two | three | | findminxyz() | [5, 3, 4, 2, 6, 7, 8, 9, 1, 10] | 2, 7 | Two | three | | findMaxXyz() | [1, 22, 3, 4, 5, 10, 7, 8, 9, 49] | 0, 10 | forty-nine | nine | | findMaxXyz() | [1, 22, 3, 4, 5, 10, 7, 8, 9, 49] | 0, 7 | Twenty-two | one | | findMaxXyz() | [1, 22, 3, 4, 5, 10, 7, 8, 9, 49] | 2, 7 | Ten | five |
算法根据确定的最小值或最大值的位置,相应元素的适当返回可以很容易地实现:
public static int findMinByPos(final int[] values, int start, int end)
{
final int minPos = findMinPos(values, start, end);
return values[minPos];
}
public static int findMaxByPos(int[] values, int start, int end)
{
int maxPos = findMaxPos(values, start, end);
return values[maxPos];
}
要完成这个过程,您仍然需要确定最小值和最大值的位置。为此,您按如下方式进行:要找到最小值和最大值各自的位置,您需要遍历所有元素,与最小值或最大值的当前值进行比较,如果值更小或更大,则更新位置:
static int findMinPos(final int[] values, final int startPos, final int endPos)
{
int minPos = startPos;
for (int i = startPos + 1; i < endPos; i++)
{
if (values[i] < values[minPos])
minPos = i;
}
return minPos;
}
static int findMaxPos(final int[] values, final int startPos, final int endPos)
{
int maxPos = startPos;
for (int i = startPos + 1; i < endPos; i++)
{
if (values[i] > values[maxPos])
maxPos = i;
}
return maxPos;
}
确认
您可以像往常一样使用简介中的输入来测试功能:
@Test
void findMinAndMax()
{
final int[] values = { 2, 3, 4, 5, 6, 7, 8, 9, 1, 10 };
assertAll(() -> assertEquals(1, Ex12_ArraysMinMax.findMin(values)), () ->
assertEquals(10, Ex12_ArraysMinMax.findMax(values)));
}
@ParameterizedTest(name = "findMinPos([5, 3, 4, 2, 6, 7, 8, 9, 1, 10], " +
"{0}, {1}) => {1}")
@CsvSource({ "0, 10, 8, 1", "2, 7, 3, 2", "0, 7, 3, 2" })
void findMinxPos(int lower, int upper, int expectedPos, int expectedValue)
{
final int[] values = { 5, 3, 4, 2, 6, 7, 8, 9, 1, 10 };
int resultPos = Ex12_ArraysMinMax.findMinPos(values, lower, upper);
assertEquals(expectedPos, resultPos);
assertEquals(expectedValue, values[resultPos]);
}
@ParameterizedTest(name = "findMaxPos([1, 22, 3, 4, 5, 10, 7, 8, 9, 49], " +
"{0}, {1}) => {1}")
@CsvSource({ "0, 10, 9, 49", "2, 7, 5, 10", "0, 7, 1, 22" })
void findMaxPos(int lower, int upper, int expectedPos, int expectedValue)
{
final int[] values = { 1, 22, 3, 4, 5, 10, 7, 8, 9, 49 };
int resultPos = Ex12_ArraysMinMax.findMaxPos(values, lower, upper);
assertEquals(expectedPos, resultPos);
assertEquals(expectedValue, values[resultPos]);
}
5.3.13 解决方案 13:阵列拆分(★★★✩✩)
考虑一个任意整数的数组。因此,要对数组进行重新排序,以便小于特定参考值的所有值都放在左边。所有大于或等于参考值的值都放在右边。子范围内的顺序是不相关的,并且可以变化。
例子
|投入
|
基准要素
|
样本结果
| | --- | --- | --- | | [4, 7, 1, 20] | nine | [1,4,7, 9 ,20] | | [3, 5, 2] | seven | [2,3,5, 7 | | [2, 14, 10, 1, 11, 12, 3, 4] | seven | [2,1,3,4, 7 ,14,10,11,12] | | [3, 5, 7, 1, 11, 13, 17, 19] | Eleven | [1,3,5,7, 11 ,11,13,17,19] |
解决方案 13a:阵列拆分(★★✩✩✩)
编写方法int[] arraySplit(int[], int)来实现该功能。允许创建新的数据结构,如列表。
算法要将一个关于参考元素的数组分成两部分,其值小于或大于或等于参考值,定义两个结果列表,分别命名为lesser和biggerOrEqual。然后,遍历数组,根据当前元素与引用元素的比较,为每个元素填充两个列表中的一个。最后,您只需要将列表和引用元素组合成一个结果列表,并将其转换成一个int[]。
static int[] arraySplit(final int[] values, final int referenceElement)
{
final List<Integer> lesser = new ArrayList<>();
final List<Integer> biggerOrEqual = new ArrayList<>();
for (int i = 0; i < values.length; i++)
{
final int current = values[i];
if (current < referenceElement)
lesser.add(current);
else
biggerOrEqual.add(current);
}
final List<Integer> result = new ArrayList<>();
result.addAll(lesser);
result.add(referenceElement);
result.addAll(biggerOrEqual);
return result.stream().mapToInt(i -> i).toArray();
}
解决方案 13b:就地拆分阵列(★★★✩✩)
编写在源数组内部实现所述功能的方法int[] arraySplitInplace(int[], int)(即就地)。显然不希望创建新的数据结构。为了能够在结果中包含引用元素,只允许创建一次数组。因为这必须被返回,所以它被例外地允许为一个就地方法返回值——事实上,它在这里只部分地就地操作。
算法您已经从更简单的版本开始,这提高了您对流程的理解,所以现在要敢于尝试就地版本。这里不能使用辅助数据结构,而是必须通过多次交换元素来实现逻辑。两个位置标记指示要交换哪些元素。只要找到比引用元素小的值,第一个位置标记就会增加,从开始处开始。对上部的位置标记进行同样的操作。只要这些值大于或等于引用元素,就会减少位置。最后,在找到的索引位置交换两个值,但前提是位置标记尚未交叉。在这种情况下,您不会发现更多不匹配的元素。最后要做的是根据新排列的数组在正确的位置集成参考元素。
static int[] arraySplitInplace(final int[] values, final int referenceElement)
{
int low = 0;
int high = values.length - 1;
while (low < high)
{
while (low < high && values[low] < referenceElement)
low++;
while (high > low && values[high] >= referenceElement)
high--;
if (low < high)
swap(values, low, high);
}
return integrateReferenceElement(values, high, referenceElement);
}
要集成引用元素,建议编写方法int[] integrateReferenceElement(int[], int, int)。在那里,你首先要创建一个比原来大一个元素的数组。只要尚未到达传递的位置,就会从原始数组中填充。引用元素被插入到位置本身,然后从原始数组中复制任何剩余的值。这导致了以下实现:
static int[] integrateReferenceElement(final int[] values,
final int pos, final int referenceElement)
{
final int[] result = new int[values.length + 1];
// copy lower part in
for (int i = 0; i < pos; i++)
result[i] = values[i];
// reference element
result[pos] = referenceElement;
// successor element, if available
for (int i = pos + 1; i < values.length + 1; i++)
result[i] = values[i - 1];
return result;
}
解决方案 13c:阵列拆分快速排序分区(★★★✩✩)
对于排序,根据快速排序,您需要一个类似于刚刚开发的分区功能。然而,通常数组的最前面的元素被用作引用元素。
基于之前使用显式引用元素开发的两个实现,现在创建相应的替代方法,如方法int[] arraySplit(int[])和int[] arraySplitInplace(int[])。
例子
|投入
|
基准要素
|
样本结果
| | --- | --- | --- | | [ 9 ,4,7,1,20] | nine | [1,4,7, 9 ,20] | | [ 7 ,3,5,2] | seven | [2,3,5, 7 | | [ 7 ,2,14,10,1,11,12,3,4] | seven | [2,1,3,4, 7 ,14,10,11,12] | | [ 11 ,3,5,7,1,11,13,17,19] | Eleven | [1,3,5,7, 11 ,11,13,17,19] |
算法 1 作为一种修改,在具有两个结果列表的实现中,唯一需要考虑的是参考元素存储在位置 0。因此,处理从索引 1 开始。
static int[] arraySplit(final int[] values)
{
final List<Integer> lesser = new ArrayList<>();
final List<Integer> biggerOrEqual = new ArrayList<>();
final int referenceValue = values[0];
for (int i = 1; i < values.length; i++)
{
final int current = values[i];
if (current < referenceValue)
lesser.add(current);
else
biggerOrEqual.add(current);
}
final List<Integer> result = new ArrayList<>();
result.addAll(lesser);
result.add(referenceValue);
result.addAll(biggerOrEqual);
return result.stream().mapToInt(i -> i).toArray();
}
算法 2 原位变体也像以前一样使用两个位置标记,并且如果必要的话交换元素几次。只要位置标记还没有交叉,就重复这一过程。在这种特殊的情况下,你再也找不到任何不合适的元素。最后要做的是将参考元素从其位置 0 移动到交叉点(即匹配位置)。
static void arraySplitInplace(final int[] values)
{
final int referenceValue = values[0];
int low = 1;
int high = values.length - 1;
while (low < high)
{
while (values[low] < referenceValue && low < high)
low++;
while (values[high] >= referenceValue && high >= low)
high--;
if (low < high)
swap(values, low, high);
}
// important for 1,2 => then 1 would be pivot, do not swap!
if (referenceValue > values[high])
swap(values, 0, high);
}
最后一个特例还是有点让人不安。有时特殊待遇是你可以做得更好的标志。事实上,这是可能的,如下所示:
static void arraySplitInplaceShorter(final int[] values)
{
final int referenceValue = values[0];
int left = 0;
int right = values.length - 1;
while (left < right)
{
while (values[left] < referenceValue && left < right)
left++;
while (values[right] > referenceValue && right > left)
right--;
swap(values, left, right);
}
}
主要区别在于,仅在大于的情况下移动右侧位置标记,而在大于或等于的情况下不移动。
确认
您可以像往常一样使用简介中的输入来测试功能:
jshell> int[] values = {2, 14, 10, 1, 11, 12, 3, 4}
jshell> arraySplit(values, 7)
$56 ==> int[9] { 2, 1, 3, 4, 7, 14, 10, 11, 12 }
jshell> arraySplitInplace(values, 7)
$60 ==> int[9] { 2, 4, 3, 1, 7, 11, 12, 10, 14 }
让我们来看看快速排序的变化:
jshell> int[] values2 = {7, 2, 14, 10, 1, 11, 3, 12, 4}
jshell> arraySplit(values2)
$68 ==> int[9] { 2, 1, 3, 4, 7, 14, 10, 11, 12 }
jshell> arraySplitInplace(values2)
jshell> values2
values2 ==> int[9] { 1, 2, 4, 3, 7, 11, 10, 12, 14 }
jshell> int[] values3 = {7, 2, 14, 10, 1, 11, 3, 12, 4}
jshell> arraySplitInplaceShorter(values3)
由于算法略有不同,第一个变体中的元素保持它们在原始数组中出现的顺序。原地变体交换元素,因此出现了重新洗牌。但是,所有较小的值仍然位于引用元素的左侧,所有较大的值位于右侧。
5.3.14 解决方案 14:扫雷委员会(★★★✩✩)
你过去很有可能玩过扫雷游戏。提醒你一下,这是一个不错的小问答游戏,有点令人费解。是关于什么的?炸弹面朝下放在操场上。玩家现在可以选择棋盘上的任何一个区域。如果某个字段未被覆盖,它会显示一个数字。这表明有多少炸弹藏在邻近的地里。然而,如果你运气不好,你击中了一个炸弹场,游戏就输了。这个任务是关于初始化这样一个领域,并为随后的游戏做准备。
解决方案 14a (★★✩✩✩)
编写方法boolean[][] placeBombsRandomly(int, int, double),通过前两个参数创建一个大小为boolean[][]的游戏场,随机填充个炸弹,概率从 0.0 到 1.0 传递为double。
例子
这里展示的是一个 16 × 7 大小的游戏场,炸弹随机放置。炸弹用*表示,空格用。如你所见。
* * * . * * . * . * * . * . . .
. * * . * . . * . * * . . . . .
. . * . . . . . . . . . * * * *
. . . * . * * . * * . * * . . .
* * . . . . * . * . . * . . . *
. . * . . * . * * . . * . * * *
. * . * * . * . * * * . . * * .
算法在一个游戏场上放置随机分布的炸弹的算法是这样工作的:对于每个位置,用Math.random()生成的一个随机数和一个给定的概率来决定是否应该在棋盘上放置一个炸弹。结果,产生合适的boolean[][]。在这里,我们发现了它的独特之处,即游戏场向各个方向延伸了一个位置,正如下面的实用技巧中所讨论的那样。
static boolean[][] placeBombsRandomly(final int width, final int height,
final double probability)
{
final boolean[][] bombs = new boolean[height + 2][width + 2];
for (int i = 1; i < bombs.length - 1; i++)
{
for (int j = 1; j < bombs[0].length - 1; j++)
{
bombs[i][j] = (Math.random() < probability);
}
}
return bombs;
}
Note: Playfield with Border
对于许多二维算法,需要对边缘进行特殊检查。在某些情况下,在实际运动场周围放置一个位置的边界是有帮助的。特别是,这可以简化所有罗盘方向上相邻单元的计算,就像这里的炸弹一样。但是你必须给边界单元格分配一个中性值。在这里,这只是值 0。然而,有时候,像#这样的特殊字符可以用于基于char的运动场。
有了这种人工边界单元,一些计算变得容易了。但是,您必须注意到边界的范围是从 1 到length-1——这是数组经常出现的危险的一位误差的另一个绊脚石。
确认
您在这里省略了显式测试和试错,因为一方面,您正在处理随机数,单元测试对此没有直接意义。另一方面,算法相当简单,后面会间接测试功能。
备选案文 14b
编写方法int[][] calcBombCount(boolean[][]),根据作为boolean[][]传递的炸弹字段,计算相邻炸弹字段的数量,并返回相应的数组。
例子
对大小为 3×3 和 5×5 的运动场进行计算,包括随机分布的炸弹,结果如下:
* . . B 2 1 . * * . . 2 B B 3 1
. . * => 1 3 B * . * * . B 6 B B 1
. . * 0 2 B * * . . . => B B 4 3 2
* . . * . B 6 4 B 1
* * * . . B B B 2 1
算法为了计算装有炸弹的相邻牢房的数量,你再次依次考虑每个牢房。这里你利用了特殊的利润,所以你不必做范围检查或特殊处理。首先,你初始化一个大小合适的int[][]值为 0,假设附近没有炸弹。如果一个单元格代表一个炸弹,则使用值 9 作为指示器。如果它不包含一个,你必须检查所有八个相邻的细胞,看看他们是否是一个炸弹的家。在这种情况下,计数器加 1。这通过使用已知的罗盘方向及其在 x 和 y 方向上的增量值的枚举来实现。
static int[][] calcBombCount(final boolean[][] bombs)
{
final int[][] bombCount = new int[bombs.length][bombs[0].length];
for (int y = 1; y < bombs.length - 1; y++)
{
for (int x = 1; x < bombs[0].length - 1; x++)
{
if (!bombs[y][x])
{
for (final Direction currentDir : Direction.values())
{
if (bombs[y + currentDir.dy][x + currentDir.dx])
bombCount[y][x]++;
}
}
else
bombCount[y][x] = 9;
}
}
return bombCount;
}
为了更好地理解,这里再次显示了enum枚举Direction:
enum Direction
{
N(0, -1), NE(1, -1), E(1, 0), SE(1, 1),
S(0, 1), SW(-1, 1), W(-1, 0), NW(-1, -1);
int dx;
int dy;
private Direction(int dx, int dy)
{
this.dx = dx;
this.dy = dy;
}
}
确认
为了检查实现,您使用 3×3 分布,但是您必须相应地考虑边界单元。然而,一切都是基于一个boolean[][]。处理图形表示并对其进行适当的转换不是更实际吗?让我们把这看作一个单元测试:
@ParameterizedTest
@MethodSource("createBombArrayAndExpected")
public void calcBombCount(boolean[][] bombs, int[][] expected)
{
int[][] result = Ex14_Minesweeper.calcBombCount(bombs);
assertArrayEquals(expected, result);
}
private static Stream<Arguments> createBombArrayAndExpected()
{
String[] bombs1 = { "*..",
"..*",
"..*" };
String[] result1 = { "B21",
"13B",
"02B" };
String[] bombs2 = { ".**..",
"*.**.",
"**...",
"*..*.",
"***.."};
String[] result2 = { "2BB31",
"B6BB1",
"BB432",
"B64B1",
"BBB21"};
return Stream.of(Arguments.of(toBoolArray(bombs1), toIntArray(result1)),
Arguments.of(toBoolArray(bombs2), toIntArray(result2)));
}
// hiding the border field logic and conversion
static boolean[][] toBoolArray(final String[] bombs)
{
final int width = bombs[0].length();
final int height = bombs.length;
final boolean[][] result = new boolean[height + 2][width + 2];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x ++)
{
if (bombs[y].charAt(x) == '*')
result[y + 1][x + 1] = true;
}
}
return result;
}
帮助器方法toIntArray()看起来像这样:
static int[][] toIntArray(final String[] values)
{
final int width = values[0].length();
final int height = values.length;
final int[][] result = new int[height + 2][width + 2];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x ++)
{
final char currentChar = values[y].charAt(x);
if (currentChar == 'B')
result[y + 1][x + 1] = 9;
else
result[y + 1][x + 1] = Character.getNumericValue(currentChar);
}
}
return result;
}
让我们再看一下帮助器方法:首先,您有一个炸弹分布的文本表示,使用toBoolArray()将其转换为功能所需的数组数据结构。这样做,你不必担心边界场的产生。助手方法toIntArray()更进一步,将文本数字转换成相应的int值,并特别考虑炸弹的表示为B。
Hint: Readability and Comprehensibility in Testing
这两个助手方法使得测试用例的创建简单易懂。这使得有人延长测试的可能性更大。另一方面,如果编写单元测试相当乏味甚至困难,几乎没有人会费心去扩展它们。
解决方案 14c
编写方法void printBoard(boolean[][], char, int[][]),允许你将棋盘显示为点、星、数字和B。
例子
此处显示的是上面的 16 × 7 大小的游戏场,包含炸弹邻居的所有计算值:
B B B 4 B B 3 B 4 B B 3 B 1 0 0
3 B B 5 B 3 3 B 4 B B 4 3 4 3 2
1 3 B 4 3 3 3 3 4 4 4 4 B B B B
2 3 3 B 2 B B 4 B B 3 B B 4 4 3
B B 3 2 3 4 B 6 B 4 4 B 5 3 4 B
3 4 B 3 3 B 4 B B 5 4 B 4 B B B
1 B 3 B B 3 B 4 B B B 2 3 B B 3
算法对于渲染,你使用基于位置的处理。由于您希望在该方法中实现基于boolean[][]和——如果传递的话——相邻炸弹数量值的输出,除了 x 和 y 方向的嵌套循环之外,还必须提供一些情况:
static void printBoard(final boolean[][] bombs,
final char bombSymbol,
final int[][] solution)
{
for (int y = 1; y < bombs.length - 1; y++)
{
for (int x = 1; x < bombs[0].length - 1; x++)
{
if (bombs[y][x])
System.out.print(bombSymbol + " ");
else if (solution != null && solution.length != 0)
System.out.print(solution[y][x] + " ");
else
System.out.print(". ");
}
System.out.println();
}
System.out.println();
}
确认
将您的三种方法结合起来,体验完整的功能:
jshell> var bombs = placeBombsRandomly(16, 7, 0.4)
jshell> printBoard(bombs, '*', new int[0][0])
* * * . * * . * . * * . * . . .
. * * . * . . * . * * . . . . .
. . * . . . . . . . . . * * * *
. . . * . * * . * * . * * . . .
* * . . . . * . * . . * . . . *
. . * . . * . * * . . * . * * *
. * . * * . * . * * * . . * * .
jshell> int[][] solution = calcBombCount(bombs)
jshell> printBoard(bombs, 'B', solution)
B B B 4 B B 3 B 4 B B 3 B 1 0 0
3 B B 5 B 3 3 B 4 B B 4 3 4 3 2
1 3 B 4 3 3 3 3 4 4 4 4 B B B B
2 3 3 B 2 B B 4 B B 3 B B 4 4 3
B B 3 2 3 4 B 6 B 4 4 B 5 3 4 B
3 4 B 3 3 B 4 B B 5 4 B 4 B B B
1 B 3 B B 3 B 4 B B B 2 3 B B 3
Footnotes 1
对于TreeSet<E>,有一些事情需要记住。首先,要收集的值必须具有可比性。这意味着它们必须满足Comparable<T>接口,或者TreeSet<E>必须用比较器构建。第二,对于未排序的数据,它们的顺序会被重新排列,但这不是所希望的。在这个例子中,TreeSet<E>是适用的,因为赋值明确地谈到了排序的值。
2
因此,在这两种情况下,都建议进行彻底的测试并选择好测试用例。如何实现这一点在我的书Der Weg zum Java-Profi【Ind20a】中有描述。
3
Java 14 中的新功能在我的书 Java 中有详细介绍–版本 9 到 14 中的新增功能:模块化、语法和 API 增强功能【Ind20b】。
*