Java 挑战(七)
八、高级递归
在这一章中,你将探索递归的一些更高级的方面。你从称为记忆化的优化技术开始。之后,你将回溯视为一种解决问题的策略,它依赖于试错法,并尝试可能的解决方案。尽管这在性能方面不是最佳的,但它可以使各种实现易于理解。
8.1 记忆化
在第三章中,你学到了递归是可行的,可以用一种可理解的,同时又优雅的方式来描述许多算法和计算。然而,您也注意到递归有时会导致许多自调用,这会损害性能。例如,这适用于斐波那契数或帕斯卡三角形的计算。如何克服这个问题?
为此,有一种有用的技术叫做记忆化。它遵循与缓存或缓冲先前计算的值相同的思想。它通过为后续操作重用已经计算的结果来避免多次执行。
8.1.1 斐波那契数的记忆
方便地,记忆化通常可以容易地添加到现有算法中,并且只需要最小的修改。让我们复制这个来计算斐波那契数。
让我们简单重复一下斐波那契数的递归定义:
Java 中的递归实现完全遵循数学定义:
static long fibRec(final int n)
{
if (n <= 0)
throw new IllegalArgumentException("n must be > 0");
// recursive termination
if (n == 1 || n == 2)
return 1;
// recursive descent
return fibRec(n - 1) + fibRec(n - 2);
}
那么如何添加记忆化呢?其实这并不太难。您需要一个 helper 方法来调用实际的计算方法,最重要的是,需要一个数据结构来存储中间结果。在这种情况下,使用传递给计算方法的映射:
static long fibonacciOptimized(final int n)
{
return fibonacciMemo(n, new HashMap<>());
}
在最初的方法中,你用记忆的动作来包围实际的计算。对于每一个计算步骤,首先在映射中查看结果是否已经存在,如果存在,则返回结果。否则,您可以像以前一样执行该算法,只需进行最小的修改,将计算结果存储在一个变量中,以便能够将其适当地存放在查找映射的末尾:
static long fibonacciMemo(final int n, final Map<Integer, Long> lookupMap)
{
if (n <= 0)
throw new IllegalArgumentException("n must be > 0");
// MEMOIZATION: check if precalculated result exists
if (lookupMap.containsKey(n))
return lookupMap.get(n);
// normal algorithm with auxiliary variable for result
long result = 0;
// recursive termination
if (n == 1 || n == 2)
result = 1;
// recursive descent
else
result = fibonacciMemo(n - 1, lookUpMap) +
fibonacciMemo(n - 2, lookUpMap);
// MEMOIZATION: save calculated result
lookupMap.put(n, result);
return result;
}
性能对比如果对第 47 个斐波那契数运行两种变体,我的 iMac 4 GHz 上的纯递归变体在大约 7 秒后返回结果,而另一个带有记忆化的变体在几毫秒后返回结果。
注意应该注意的是,斐波那契计算有一个变体,从值0开始。然后 fib (0) = 0 成立, fib (1) = 1 成立,之后递归fib(n)=fib(n—1)+fib(n—2)。这产生了与初始定义相同的数字序列,只是增加了0的值。
此外,还有以下几点需要考虑:
-
数据类型:计算出的斐波那契数会很快变大,所以即使是 a
long的取值范围也不够,而 aBigInteger作为返回和查找映射的类型是个不错的选择。 -
递归终止:出于实现的目的,在进行记忆化处理之前,有必要考虑一下递归终止。这可能最低限度地提高了性能,但是算法不能从现有的算法中清晰地重构。特别是如果你还不熟悉记忆,显示的变体似乎更吸引人。
8.1.2 帕斯卡三角形的记忆
帕斯卡三角形是递归定义的,斐波那契数列也是如此:
让我们首先再次看看纯递归实现:
static int pascalRec(final int row, final int col)
{
// recursive termination: top
if (col == 1 && row == 1)
return 1;
// recursive termination: borders
if (col == 1 || col == row)
return 1;
// recursive descent
return pascalRec(row - 1, col) + pascalRec(row - 1, col - 1);
}
即使是用记忆方法计算帕斯卡三角形,原算法也几乎没有变化。您只需要用对查找映射和存储的访问来包围它:
static int pascalOptimized(final int row, final int col)
{
return calcPascalMemo(row, col, new HashMap<>());
}
static int calcPascalMemo(final int row, final int col,
final Map<IntIntKey, Integer> lookupMap)
{
// MEMOIZATION
final IntIntKey key = new IntIntKey(row, col);
if (lookupMap.containsKey(key))
return lookupMap.get(key);
int result;
// recursive termination: top
if (col == 1 && row == 1)
result = 1;
// recursive termination: borders
else if (col == 1 || col == row)
result = 1;
else
// recursive descent
result = calcPascalMemo(row - 1, col, lookupMap) +
calcPascalMemo(row - 1, col - 1, lookupMap);
// MEMOIZATION
lookupMap.put(key, result);
return result;
}
仔细观察就会发现,您不能使用标准的键类型,而是需要一个更特殊的变量,由一行和一列组成,这是因为它是二维布局。为此,您定义了以下名为IntIntKey的记录。现代 Java 17 使得使用关键字record定义简单的数据容器类变得可行,如下所示:
static record IntIntKey(int value1, int value2)
{
}
性能比较为了比较性能,您选择一个调用,其参数为第 42 行和第 15 列。对于 4 GHz iMac 上的选定值,纯递归变体需要大约 80 秒的相当长的运行时间。优化的变体在几毫秒后完成。
结论
对于这里给出的两个例子,纯粹的递归定义会导致许多自我调用。如果没有记忆,它们会导致相同的中间结果被反复计算和丢弃。这是不必要的,而且会降低性能。
记忆是一种既简单又巧妙有效的补救方法。此外,在递归算法的帮助下,许多问题仍然可以很好地解决,但不需要接受性能方面的缺点。总而言之,记忆化通常可以(非常)显著地减少运行时间。
Note: Background Knowledge on Memoization
记忆化这个词,似乎有点奇怪,要追溯到唐纳德·米基( https://en.wikipedia.org/wiki/Memoization )。如前所述,这是一种通过缓存部分结果来优化计算处理的技术。通过这种方式,具有相同输入的嵌套调用可以显著加速。然而,为了使用记忆化,包装的递归函数必须是所谓的纯函数。这意味着,这样一个稳定的函数如果用特定的输入调用,将返回相同的值。此外,这些功能必须没有任何副作用。
8.2 回溯
回溯是一种基于试错的问题解决策略,它调查所有可能的解决方案。当检测到错误时,先前的步骤被重置,因此得名 回溯 。目标是逐步达成解决方案。当出现错误时,您尝试另一种解决方案。因此,潜在的所有可能的(因此可能也有很多)方法都被遵循。然而,这也有一个缺点,即直到问题被解决需要相当长的运行时间。
为了使实现易于管理,回溯通常与递归结合使用,以解决以下问题:
-
解决 n 皇后问题
-
寻找数独谜题的答案
-
找到一条走出迷宫的路,给出一个 2D 阵列
8.2.1 n 皇后问题
n 皇后问题是一个要在 n×n 棋盘上解决的难题。根据国际象棋规则,皇后(来自国际象棋游戏)必须放置在没有两个皇后可以击败对方的位置。因此,其他王后既不能被放在同一行、同一列,也不能放在对角线上。例如,下面是 4 × 4 电路板的解决方案,其中皇后用Q(代表皇后)表示:
---------
| |Q| | |
---------
| | | |Q|
---------
|Q| | | |
---------
| | |Q| |
---------
算法
您从第 0 行第 0 个位置(左上角)的皇后开始。每次放置后,都要进行检查,以确保在垂直方向和向上的左右对角线方向上没有与已经放置的皇后发生碰撞。向下检查是没有必要的,因为没有皇后可以放在那里,因为填充是从上到下完成的。这也是不需要水平方向检查的原因。
如果位置有效,你移动到下一行,尝试从0到n1 的所有位置。重复这个过程,直到你最后把皇后放在最后一排。如果定位一个皇后有问题,你用回溯。你移走最后放置的皇后,然后在下一个可能的位置再试一次。如果到达行的末尾而没有解,这是一个无效的星座,前面的皇后也必须再次放置。您可以观察到回溯有时会回溯到一行,在极端情况下会回溯到第一行。
**举例回溯:**我们来看一下求解的步骤,其中在水平层面上,部分省略了一些中间步骤,无效位置用 x 标记:
--------------- --------------- ---------------
Q | | | Q | | | Q | | |
--------------- --------------- ---------------
| | | x | x | Q | | | Q |
--------------- => --------------- => ---------------
| | | | | | x | x | x | x
--------------- --------------- ---------------
| | | | | | | | |
--------------- --------------- ---------------
=> Backtracking
因为第二行中没有皇后的有效位置,所以您继续在第一行的下一个位置寻找解决方案,如下所示:
--------------- --------------- ---------------
Q | | | Q | | | Q | | |
--------------- --------------- ---------------
x | x | x | Q | | | Q | | | Q
--------------- => --------------- => ---------------
| | | x | Q | | | Q | |
--------------- --------------- ---------------
| | | | | | x | x | x | x
--------------- --------------- ---------------
=> Backtracking
即使皇后在第一排的第三个位置,星座中也不存在第二排皇后的有效位置。所以你不仅要返回一行,还要返回两行,从第 0 行的皇后开始搜索:
--------------- --------------- --------------- ---------------
| Q | | | Q | | | Q | | | Q | |
--------------- --------------- --------------- ---------------
x | x | x | Q | | | Q | | | Q | | | Q
--------------- => --------------- => --------------- => ---------------
| | | | | | Q | | | Q | | |
--------------- --------------- --------------- ---------------
| | | | | | | | | x | x | Q |
--------------- --------------- --------------- ---------------
=> Solution found
你会发现,通过采取一些反复试验的步骤,你找到了一个解决方案。
回溯的实现你再次将之前描述的用于解决 n 皇后问题的算法细分为几个方法,一次解决一个子问题。
首先,你要考虑你想如何模拟运动场。A char[][]是个不错的选择。一个Q代表一个皇后,一个空白代表一个空场。最初创建一个空板,你写方法initializeBoard()。然后您调用实际的递归回溯方法solveNQueens(),它决定了char[][]上的解。如果找到一个,helper 方法返回值true,否则返回false。为了让调用者容易评估,如果有解决方案,您可以将它包装在一个Optional<T>中。否则返回一个空的可选。
static Optional<char[][]> solveNQueens(final int size)
{
final char[][] board = initializeBoard(size);
// start the recursive solution discovery process
if (solveNQueens(board, 0))
return Optional.of(board);
return Optional.empty();
}
现在让我们回到主要任务,使用递归和回溯找到一个解决方案。如上所述,该算法逐行进行,然后尝试各个列:
static boolean solveNQueens(final char[][] board, final int row)
{
final int maxRow = board.length;
final int maxCol = board[0].length;
// recursive termination
if (row >= maxRow)
return true;
boolean solved = false;
int col = 0;
while (!solved && col < maxCol)
{
if (isValidPosition(board, col, row))
{
placeQueen(board, col, row);
// recursive descent
solved = solveNQueens(board, row + 1);
// backtracking, if no solution
if (!solved)
removeQueen(board, col, row);
}
col++;
}
return solved;
}
为了使算法尽可能地不涉及细节和数组访问,从而易于理解,您定义了两个名为placeQueen()和removeQueen()的助手方法:
static void placeQueen(final char[][] board, final int col, final int row)
{
board[row][col] = 'Q';
}
static void removeQueen(final char[][] board, final int col, final int row)
{
board[row][col] = ' ';
}
另外,我想提一下如何用回溯来处理算法中的修改。在这里使用的一个变体中,在递归步骤之前进行的修改被还原。作为第二种变化,您可以在递归步骤中传递副本,并在副本中执行修改。那么不再需要撤销或删除。
为了完整起见,游戏场初始化的实现如下:
private static char[][] initializeBoard(final int size)
{
final char[][] board = new char[size][size];
for (int row = 0; row < size; row++)
{
for (int col = 0; col < size; col++)
{
board[row][col] = ' ';
}
}
return board;
}
实现中还缺少什么?下一步是什么?
作为 8.3.9 节中的一个练习,你的任务是实现isValidposition(char[][], int, int)方法。这是为了检查一个运动场是否有效。由于所选择的逐行方法的算法,并且因为每行只能放置一个皇后,所以只能垂直和对角地排除碰撞。
8.3 练习
8.3.1 练习 1:河内塔(★★★✩✩)
在河内问题的塔中,有三个塔或棒,分别命名为 A、B、c,开始时,在棒 A 上按大小顺序放置几个穿孔圆盘,最大的在底部。现在的目标是将整个堆叠(即所有光盘)从 A 移动到 c。光盘必须放在堆叠的顶部。目标是一次移动一个磁盘,不要将较小的磁盘放在较大的磁盘下面。这就是为什么你需要 helper stick B .编写方法void solveTowersOfHanoi(int)把解以要执行的动作的形式打印在控制台上。
例子
整个事情看起来有点像图 8-1 。
图 8-1
汉诺塔问题的任务定义
应为三个切片提供以下解决方案:
Tower Of Hanoi 3
A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C
奖励创建基于控制台的图形格式。对于两个切片,它看起来像这样:
Tower Of Hanoi 2
A B C
| | |
#|# | |
##|## | |
------- ------- -------
Moving slice 1: Tower [A] -> Tower [B]
A B C
| | |
| | |
##|## #|# |
------- ------- -------
Moving slice 2: Tower [A] -> Tower [C]
A B C
| | |
| | |
| #|# ##|##
------- ------- -------
Moving slice 1: Tower [B] -> Tower [C]
A B C
| | |
| | #|#
| | ##|##
------- ------- -------
8.3.2 练习 2:编辑距离(★★★★✩)
对于两个字符串,计算它们有多少变化,不区分大小写。也就是说,通过一次或多次应用以下任一操作,了解如何将一个字符串转换为另一个字符串:
-
添加一个字符(+)。
-
删除一个字符()。
-
改变一个角色(⤳).
编写方法int editDistance(String, String),逐个字符地尝试这三个动作,并递归地检查另一部分。
例子
所示输入需要进行以下修改:
|输入 1
|
输入 2
|
结果
|
行动
|
| --- | --- | --- | --- |
| “米莎” | “迈克尔” | Two | |
| “绳降” | "表格" | four |
|
奖励( ★★★✩✩ ) 通过记忆优化编辑距离
8.3.3 练习 3:最长公共子序列(★★★✩✩)
前面的练习是关于两个给定的字符串相互转换需要多少变化。另一个有趣的问题是找到两个字符串中最长的公共但不一定是连续的字母序列,该序列出现在同一序列的两个字符串中。写方法String lcs(String, String),从后面递归处理字符串,如果两部分长度相同,就用第二个。
例子
|输入 1
|
输入 2
|
结果
| | --- | --- | --- | | "脓肿" | “扎塞夫” | “王牌” | | "脓毒症" | " XYACB " | “AB” | | " ABCMIXCHXAEL " | “迈克尔” | “迈克尔” | | “周日上午” | “周六夜派对” | 苏代-搞笑 |
对最长的公共子序列使用记忆
8.3.4 练习 4:走出迷宫(★★★✩✩)
在这个作业中,你被要求找到走出迷宫的路。假设迷宫是一个二维数组,墙壁用#符号表示,目标位置(出口)用X符号表示。从任何位置看,通向所有出口的路径都应该是确定的。如果一排有两个出口,只需提供两个出口中的第一个。你只能向四个方向移动,但不能对角移动。编写方法boolean findWayOut(char[][], int, int),用FOUND EXIT at ...记录每个找到的出口。
例子
下图是一个更大的操场,有四个目标场地。下图显示了由点(.))指示的每条路径。在这两者之间,你可以看到出口位置的记录。对于这个例子,搜索从左上角开始,坐标 x=1,y=1。
##################################
# # # # # # X#X#
# ##### #### ## ## # # ### #
# ## # # ## ## # # # #
# # ### # ## ## # ### # #
# # #### ## ## ### # #
#### # #### # # #### #
###### ######### ## # ### #
## # X X####X # # # ### ##
##################################
FOUND EXIT: x: 30, y: 1
FOUND EXIT: x: 17, y: 8
FOUND EXIT: x: 10, y: 8
##################################
#.# #....#.....# #...X#X#
#..##### ####.##...##..# #.### #
# .## # #..## ## .# #.. # #
# ...# ###..#.## ## ..#...### # #
# # ..####.....## ##.... ### # #
#### ..#... #### ....# # #### #
######...#########...## # ### #
## #..X X####X.# # # ### ##
##################################
基于输出,同样清楚的是,从开始位置没有检测到标有X的两个目标场。一个是右上角的X,由于缺少一个链接而无法到达。另一个是中下X,在另一个出口后面。
8.3.5 练习 5:数独求解器(★★★★✩)
编写方法boolean solveSudoku(int[][]),为作为参数传递的部分初始化的运动场确定有效的解决方案(如果有的话)。
例子
带有一些空白的有效运动场如下:
应完成以下解决方案:
8.3.6 练习 6:数学运算符检查器(★★★★✩)
这个作业是关于一个数学上的难题。对于一组数字和另一组可能的运算符,您希望找到产生所需值的所有组合。数字的顺序不能改变。尽管如此,除了第一个数字之前,仍然可以在数字之间插入可能的运算符中的任何运算符。编写方法Set<String> allCombinationsWithValue(List<Integer>, int),该方法确定导致作为参数传递的值的所有组合。检查数字1到9以及运算+和组合数字。从方法Map<String, Long> allCombinations(List<Integer>)开始,它被传递了相应的数字。
例子
让我们只考虑数字 1、2 和 3 的两种组合:
1 + 2 + 3 = 6
1 + 23 = 24
总之,这些数字允许形成以下不同的组合:
|投入
|
结果(allCombinations())
| | --- | --- | | [1, 2, 3] | {12-3=9, 123=123, 1+2+3=6, 1+2-3=0, 1-2+3=2, 1-23=-22,1-2-3=-4, 1+23=24, 12+3=15} |
假设您想要从给定的数字 1 到 9 以及一组可用的运算符(+、组合数字)生成值 100。这是可能的,例如,如下所示:
1 + 2 + 3 − 4 + 5 + 6 + 78 + 9 = 100
总之,应确定以下变量:
|投入
|
结果(allCombinationsWithValue())
| | --- | --- | | One hundred | [1+23-4+5+6+78-9, 123+4-5+67-89, 123-45-67+89,12+3-4+5+67+8+9, 1+23-4+56+7+8+9, 12-3-4+5-6+7+89,123-4-5-6-7+8-9, 1+2+34-5+67-8+9, 12+3+4+5-6-7+89,123+45-67+8-9, 1+2+3-4+5+6+78+9] |
Tip
在 Java 中,只需要一些技巧就可以在运行时执行动态计算。但是,如果使用 Java 的内置 JavaScript 引擎,计算表达式是相当容易的:
jshell> import javax.script.*
jshell> ScriptEngineManager manager = new ScriptEngineManager()
jshell> ScriptEngine jsEngine = manager.getEngineByName("js")
jshell> jsEngine.eval("7+2")
$63 ==> 9
编写方法int evaluate(String),用数字和运算符+和-来计算表达式。
8.3.7 练习 7:水壶问题(★★★✩✩)
考虑两个容量分别为 m 和 n 升的水壶。不幸的是,这些水壶没有标记或指示他们的填充水平。挑战在于测量 x 升,其中 x 小于 m 或 n 。在程序结束时,一个壶应该包含 x 升,另一个应该是空的。编写方法boolean solveWaterJugs(int, int, int) that在控制台上显示解决方案,如果成功,返回true,否则返回false。
例子
对于两个水壶,一个容量为 4 升,另一个容量为 3 升,您可以通过以下方式测量 2 升:
|状态
|
行动
| | --- | --- | | 壶 1:0/壶 2: 0 | 两个罐子最初都是空的 | | 壶 1:4/壶 2: 0 | 填充罐 1(不必要,但由于算法) | | 约 1:4/约 2: 3 | 装满水壶 2 | | Jug 1: 0/Jug 2: 3 | 空水壶 1 | | 壶 1:3/壶 2: 0 | 将水壶 2 倒入水壶 1 | | 约 1:3/约 2: 3 | 装满水壶 2 | | 壶 1:4/壶 2: 2 | 倒南 2 和南 1 | | 壶 1:0/壶 2: 2 | 空水壶 1 | | 解决 | |
另一方面,用两个容量分别为 4 升的水壶测量 2 升是不可能的。
8.3.8 练习 8:所有回文子字符串(★★★★✩)
在这个作业中,给定一个单词,你要确定它是否包含回文,如果包含,是哪些。编写递归方法Set<String> allPalindromePartsRec(String),确定传递的字符串中至少有两个字母的所有回文,并按字母顺序返回。1
例子
|投入
|
结果
| | --- | --- | | " BCDEDCB " | (BCDEDCB、CDC、DD) | | “鲍鱼” | aba、LL、lottol、OTTO、TT] | | “赛车” | ["aceca "," cec "," racecar"] |
找到所有回文子串中最长的一个
这一次没有对最高性能的要求。
8.3.9 练习 9: n 皇后问题(★★★✩✩)
在 n 皇后问题中,n 个皇后被放置在一个 n×n 的棋盘上,根据国际象棋规则,没有两个皇后可以击败对方。因此,其他皇后不能放在同一行、列或对角线上。为此,扩展第 8.2.1 节中所示的解决方案并实现方法boolean isValidPosition(char[][], int, int)。还要编写方法void printBoard(char[][])来显示面板,并将解决方案输出到控制台。
例子
对于一个 4 × 4 的运动场,有下面的解决方案,皇后用一个Q来表示。
---------
| |Q| | |
---------
| | | |Q|
---------
|Q| | | |
---------
| | |Q| |
---------
8.4 解决方案
8.4.1 解决方案 1:河内塔(★★★✩✩)
在河内问题的塔中,有三个塔或棒,分别命名为 A、B、c,开始时,在棒 A 上按大小顺序放置几个穿孔圆盘,最大的在底部。现在的目标是将整个堆叠(即所有光盘)从 A 移动到 c。光盘必须放在堆叠的顶部。目标是一次移动一个磁盘,不要将较小的磁盘放在较大的磁盘下面。这就是为什么你需要 helper stick B .编写方法void solveTowersOfHanoi(int)把解以要执行的动作的形式打印在控制台上。
例子
整个事情看起来有点像图 8-2 。
图 8-2
汉诺塔问题的任务定义
应为三个切片提供以下解决方案:
Tower Of Hanoi 3
A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C
算法磁盘的移动在方法void moveTower(int, char, char, char)中实现,该方法获得要移动的切片数量、初始源棒、辅助棒和目标棒。最初,您使用 n 和'A'、' ?? '、' ?? '作为初始参数。moveTower()方法将问题分成三个更小的问题:
-
首先,将小一片的塔从源运输到辅助棒。
-
然后,将最后一个也是最大的存储片从源存储片移动到目标存储片。
-
最后,剩下的塔必须从辅助杆移动到目标杆。
当高度为 1 时,动作将源移动到目标用作递归终止。当在动作过程中交换源、目标和辅助杆时,有点棘手。
void moveTower(final int n, final char source,
final char helper, final char destination)
{
if (n == 1)
System.out.println(source + " -> " + destination);
else
{
// move all but last slice from source to auxiliary stick
// destination thus becomes the new auxiliary stick
moveTower(n - 1, source, destination, helper);
// move the largest slice from source to target
moveTower(1, source, helper, destination);
// from auxiliary stick to targetl
moveTower(n - 1, helper, source, destination);
}
}
为了显示更少的细节,定义以下方法是有用的:
void solveTowersOfHanoi(final int n)
{
System.out.println("Tower Of Hanoi " + n);
moveTower(n, 'A', 'B', 'C');
}
要解决这个问题,必须使用所需数量的切片来调用该方法,如下所示:
jshell> solveTowersOfHanoi(3)
Tower Of Hanoi 3
A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C
Hint: Recursion as a Tool
虽然这个问题一开始听起来很棘手,但是用递归可以很容易地解决。这个赋值再次表明递归通过将一个问题分解成几个不太难解决的更小的子问题来降低难度。
好处:创建基于控制台的图形格式
对于两个切片,这看起来像这样:
Tower Of Hanoi 2
A B C
| | |
#|# | |
##|## | |
------- ------- -------
Moving slice 1: Tower [A] -> Tower [B]
A B C
| | |
| | |
##|## #|# |
------- ------- -------
Moving slice 2: Tower [A] -> Tower [C]
A B C
| | |
| | |
| #|# ##|##
------- ------- -------
Moving slice 1: Tower [B] -> Tower [C]
A B C
| | |
| | #|#
| | ##|##
------- ------- -------
首先,让我们看看图形输出算法是如何变化的。寻找解决方案的这一部分完全保持不变。您只需将类Tower添加到您的实现和一个在求解时作为 lambda 表达式传递的动作中。您修改方法solveTowersOfHanoi(int),在那里创建三个Tower对象,并相应地在输出塔上放置所需数量的磁盘。
void solveTowersOfHanoi(final int n)
{
System.out.println("Tower Of Hanoi " + n);
final Tower source = new Tower("A");
final Tower helper = new Tower("B");
final Tower destination = new Tower("C");
// Attention: reverse order: largest slice first
for (int i = n; i > 0; i--)
source.push(i);
final Runnable action =
() -> printTowers(n + 1, source, helper, destination);
action.run();
moveTower(n, source, helper, destination, action);
}
moveTower()的实现接收一个Runnable作为附加参数,它允许在递归结束时执行一个动作:
void moveTower(final int n, final Tower source, final Tower helper,
final Tower destination, final Runnable action)
{
if (n == 1)
{
final Integer elemToMove = source.pop();
destination.push(elemToMove);
System.out.println("Moving slice " + elementToMove +
": " + source + " -> " + destination);
action.run();
}
else
{
moveTower(n - 1, source, destination, helper, action);
moveTower(1, source, helper, destination, action);
moveTower(n - 1, helper, source, destination, action);
}
}
类Tower:``Tower类用一个字符串来标识,用一个Stack<E>来存储切片:
class Tower
{
private final String name;
private final Stack<Integer> values = new Stack<>();
public Tower(final String name)
{
this.name = name;
}
@Override
public String toString()
{
return "Tower [" + name + "]";
}
public void push(final Integer item)
{
values.push(item);
}
public Integer pop()
{
return values.pop();
}
...
塔的控制台输出在关于琴弦的第四章中,你在练习 16 的第 4.2.16 节中学习了绘制塔的第一种变体。利用在那里获得的知识,您可以适当地修改实现。首先,你用drawTop()画出塔的顶部。然后你用drawSlices()画切片,最后用drawBottom()画一条底部边界线:
static List<String> printTower(final int maxHeight)
{
final int height = values.size() - 1;
final List<String> visual = new ArrayList<>();
visual.addAll(drawTop(maxHeight, height));
visual.addAll(drawSlices(maxHeight, height));
visual.add(drawBottom(maxHeight));
return visual;
}
private List<String> drawTop(final int maxHeight, final int height)
{
final List<String> visual = new ArrayList<>();
final String nameLine = repeatCharSequence(" ", maxHeight) + name +
repeatCharSequence(" ", maxHeight);
visual.add(nameLine);
for (int i = maxHeight - height - 1; i > 0; i--)
{
final String line = repeatCharSequence(" ", maxHeight) + "|" +
repeatCharSequence(" ", maxHeight);
visual.add(line);
}
return visual;
}
static List<String> drawSlices(final int maxHeight, final int height)
{
final List<String> visual = new ArrayList<>();
for (int i = height; i >= 0; i--)
{
final int value = values.get(i);
final int padding = maxHeight - value;
final String line = repeatCharSequence(" ", padding) +
repeatCharSequence("#", value) + "|" +
repeatCharSequence("#", value);
visual.add(line);
}
return visual;
}
static String drawBottom(final int height)
{
return repeatCharSequence("-", height * 2 + 1);
}
正如在其他练习中已经演示的那样,将功能转移到单独的帮助器方法通常是有益的,这里是为了重复一个字符。在 Java 11 和更高版本中,可以使用由String类提供的方法repeat()。否则你就写一个类似repeatCharSequence()的帮助器方法(参见 2.3.7 节)。
输出所有的塔最后,您将输出功能结合到下面的方法中,以并排的三个列表的形式打印塔:
static void printTowers(final int maxHeight,
final Tower source,
final Tower helper,
final Tower destination)
{
final List<String> tower1 = source.printTower(maxHeight);
final List<String> tower2 = helper.printTower(maxHeight);
final List<String> tower3 = destination.printTower(maxHeight);
for (int i = 0; i < tower1.size(); i++)
{
final String line = tower1.get(i) + " " +
tower2.get(i) + " " +
tower3.get(i);
System.out.println(line);
}
}
确认
对于测试,您调用方法,输出显示正确的操作:
jshell> solveHanoi(2)
Tower Of Hanoi 2
A B C
| | |
#|# | |
##|## | |
------- ------- -------
Moving slice 1: Tower [A] -> Tower [B]
A B C
| | |
| | |
##|## #|# |
------- ------- -------
Moving slice 2: Tower [A] -> Tower [C]
A B C
| | |
| | |
| #|# ##|##
------- ------- -------
Moving slice 1: Tower [B] -> Tower [C]
A B C
| | |
| | #|#
| | ##|##
------- ------- -------
8.4.2 解决方案 2:编辑距离
对于两个字符串,计算它们有多少变化,不区分大小写。也就是说,如何通过一次或多次应用以下任何操作将一个字符串转换为另一个字符串:
-
添加一个字符(+)。
-
删除一个字符()。
-
改变一个角色(⤳).
编写方法int editDistance(String, String),逐个字符地尝试这三个动作,并递归地检查另一部分。
例子
所示输入需要进行以下修改:
|输入 1
|
输入 2
|
结果
|
行动
|
| --- | --- | --- | --- |
| “米莎” | “迈克尔” | Two | |
| “绳降” | "表格" | four |
|
算法让我们开始考虑你在这里如何进行。如果两个字符串匹配,则编辑距离为 0。如果两个字符串中的一个不包含(更多)字符,那么到另一个的距离就是另一个字符串中剩余的字符数。这意味着要多次插入相应的字符。这定义了递归终止。
否则,您将从头开始检查这两个字符串,并逐字符进行比较。如果它们是相同的,你就向字符串的末尾移动一个位置。如果它们不同,您将检查三种不同的修改:
-
插入:递归调用下一个字符
-
删除:递归调用下一个字符
-
替换:递归调用下一个字符
static int editDistance(final String str1, final String str2)
{
return editDistanceRec(str1.toLowerCase(), str2.toLowerCase());
}
static int editDistanceRec(final String str1, final String str2)
{
// recursive termination
// both match
if (str1.equals(str2))
return 0;
// if one of the strings is at the beginning and the other is
// not yet, then take the length of the remaining string
if (str1.length() == 0)
return str2.length();
if (str2.length() == 0)
return str1.length();
// check if the characters match and then advance to the next one
if (str1.charAt(0) == str2.charAt(0))
{
// recursive descent
return editDistance(str1.substring(1), str2.substring(1));
}
else
{
// recursive descent: check for insert, delete, change
final int insertInFirst = editDistanceRec(str1.substring(1), str2);
final int deleteInFirst = editDistanceRec(str1, str2.substring(1));
final int change = editDistanceRec(str1.substring(1), str2.substring(1));
// minimum from all three variants + 1
return 1 + minOf3(insertInFirst, deleteInFirst, change);
}
}
static int minOf3(final int x, final int y, final int z)
{
return Math.min(x, Math.min(y, z));
}
所示的实现非常容易理解,这本身就是一个优势。然而,对substring()的调用临时创建了相当多的子字符串。你如何能做得更好?
在考虑优化和开始修改源代码之前,您应该首先衡量一下是否有必要这样做。此外,创建单元测试是非常有意义的,一方面,最初检查您的实现是否按预期工作,另一方面,可以在优化期间形成一个安全网,并显示您是否错误地引入了任何错误。
确认
接下来,您调用刚刚为单元测试中的一些输入值创建的方法:
@ParameterizedTest(name = "edit distance between {0} and {1} is {2}")
@CsvSource({ "Micha, Michael, 2", "rapple, tables, 4" })
void editDistance(String input1, String input2, int expected)
{
var result = Ex02_EditDistance.editDistance(input1, input2);
assertEquals(expected, result);
}
还有,我们来看看表现。因为只有粗略的分类是重要的,所以currentTimeMillis(()的精确度在这里是绝对足够的:
public static void main(final String args[])
{
final String[][] inputs_tuples = { { "Micha", "Michael"},
{ "sunday-Morning",
"saturday-Night" },
{ "sunday-Morning-Breakfast",
"saturday-Night-Party" } };
for (final String[] inputs : inputs_tuples)
{
final long start = System.currentTimeMillis();
System.out.println(inputs[0] + " -> " + inputs[1] +
" edits: " + editDistance(inputs[0], inputs[1]));
final long end = System.currentTimeMillis();
System.out.println("editDist took " + (end - start) + " ms");
}
}
在(非常)耐心地执行上面几行代码时,您大概会得到以下输出(实际上,我在几分钟后停止了最后一次计算,所以这里没有显示):
Micha -> Michael edits: 2
editDist took 0 ms
sunday-Morning -> saturday-Night
edits: 9 editDist took 6445 ms
两个输入的差异越大,运行时间就会显著增加。在第三次比较仍然很短的字符串时,你已经有大约 6 秒的运行时间。
让我们采取两步走的方法来改善这一点。首先,看看如何避免许多临时字符串及其影响。最后,记忆化总是一个很好的优化方法。奖金任务的解决方案显示了这是如何工作的。
优化算法为了实现优化以避免创建许多String对象,考虑使用位置指针,在本例中为 pos1 和 pos2 。作为算法的一个小修改,比较从字符串的末尾开始。这样,你就可以从末尾一个字符一个字符地进行比较,并朝着字符串的开头前进。以下内容适用于:
-
插入:递归调用下一个字符,即位置 1 和位置21
-
删除:递归调用下一个字符,即位置11 和位置 2
-
替换:递归调用下一个字符,即位置11 和位置21
这导致了以下实现:
static int editDistance(final String str1, final String str2)
{
return editDistance(str1.toLowerCase(), str2.toLowerCase(),
str1.length() - 1, str2.length() - 1);
}
static int editDistance(final String str1, final String str2,
final int pos1, final int pos2)
{
// recursive termination
// if one of the strings is at the beginning and the other one
// not yet, then take the length of the remaining string (which is pos + 1)
if (pos1 < 0)
return pos2 + 1;
if (pos2 < 0)
return pos1 + 1;
// check if the characters match and then advance to the next one
if (str1.charAt(pos1) == str2.charAt(pos2))
{
// recursive descent
return editDistance(str1, str2, pos1 - 1, pos2 - 1);
}
else
{
// recursive descent: check for insert, delete, change
final int insertInFirst = editDistance(str1, str2, pos1, pos2 - 1);
final int deleteInFirst = editDistance(str1, str2, pos1 - 1, pos2);
final int change = editDistance(str1, str2, pos1 - 1, pos2 - 1);
// minimum from all three variants + 1
return 1 + minOf3(insertInFirst, deleteInFirst, change);
}
}
确认
源代码变得有点复杂。同样,您创建了一个单元测试,看起来和以前一样。它的执行表明,上述实现继续产生预期的结果。
现在让我们使用最初显示的程序框架,看看运行时间是否有所改进。这将产生以下运行时间:
Micha -> Michael edits: 2
editDist took 0 ms
sunday-Morning -> saturday-Night edits: 9
editDist took 634 ms
事实上,第三种情况要快得多。然而,我也在几分钟后停止了对第四对值的计算。
您可以看到微优化可能会带来改进,但它们不会导致显著的变化。我在我的书Der Weg zum Java-Profi【Ind20a】中对此进行了广泛的讨论。在这篇文章中,我还展示了更高级别的优化(即算法、设计和架构)应该是首选的。现在,记忆化作为算法的一种改进。
额外收获:通过记忆优化编辑距离(★★★✩✩)
在介绍中,我将记忆化描述为一种技术,并提到地图经常被用作缓存。因为您已经了解了这一点,所以我想在一个简短的解决方案草图之后展示一个记忆化的变体,您可以在配套项目的源代码中找到它的具体实现。
对于第一种变体,您可以使用带有映射的 memoization,但之后您需要一个两个字符串的复合键作为 key,这导致了一些直到 Java 14 的源代码。在 Java 14 中,您使用一个记录:
record StringPair(String frist, String second)
{
}
在源代码中,这看起来是示范性的,如下所示:
// MEMOIZATION
final StringPair key = new StringPair(str1, str2);
if (memodata.containsKey(key))
return memodata.get(key);
现在效果如何?通过使用内存化,您可以在运行时间方面获得极大的改进:
Micha -> Michael edits: 2
editDist took 3 ms
sunday-Morning -> saturday-Night edits: 9
editDist took 4 ms
sunday-Morning-Breakfast -> saturday-Night-Party edits: 16
editDist took 4 ms
请记住currentTimeMillis()稍有不准确。这就是为什么输出在一次运行中可以是 17 ms,而在另一次运行中可以是 5 ms。重要的是幅度,你在这里已经显著提高了。
**第二实现方式的变体:**对于已经优化的与位置一起工作的实现方式,a int[][]更适合于记忆的数据存储。请注意,您使用-1 来预初始化数组。否则,您将无法识别两个位置的编辑距离为 0。
static int editDistanceOptimized(final String str1, final String str2)
{
final int length1 = str1.length();
final int length2 = str2.length();
var memodata = new int[length1][length2];
for (int i = 0; i < length1; i++)
for (int j = 0; j < length2; j++)
memodata[i][j] = -1;
return editDistanceWithMemo(str1.toLowerCase(), str2.toLowerCase(),
length1 - 1, length2 - 1, memodata);
}
static int editDistanceWithMemo(final String str1, final String str2,
final int pos1, final int pos2,
final int[][] values)
{
// recursive termination
// if one of the strings is at the beginning and the other one
// not yet, then take the length of the remaining string (which is pos + 1)
if (pos1 < 0)
return pos2 + 1;
if (pos2 < 0)
return pos1 + 1;
// MEMOIZATION
if (memodata[pos1][pos2] != -1)
return memodata[pos1][pos2];
int result = 0;
// check if the characters match and then advance to the next one
if (str1.charAt(pos1) == str2.charAt(pos2))
{
// recursive descent
result = editDistanceWithMemo(str1, str2, pos1 - 1, pos2 - 1, values);
}
else
{
// recursive descent: check for insert, delete, change
final int insertInFirst =
editDistanceWithMemo(str1, str2, pos1, pos2 - 1, values);
final int deleteInFirst =
editDistanceWithMemo(str1, str2, pos1 - 1, pos2, values);
final int change =
editDistanceWithMemo(str1, str2, pos1 - 1, pos2 - 1, values);
// minimum from all three variants + 1
result = 1 + minOf3(insertInFirst, deleteInFirst, change);
}
// MEMOIZATION
memodata[pos1][pos2] = result;
return result;
}
如果您像以前一样运行相同的检查,这比第一个有记忆的变体稍微快一点,即使最后计算的编辑距离为 16,导致运行时间仅为 1 毫秒。
Micha -> Michael edits: 2
editDist took 0 ms
sunday-Morning -> saturday-Night edits: 9
editDist took 0 ms
sunday-Morning-Breakfast -> saturday-Night-Party edits: 16
editDist took 1 ms
8.4.3 解决方案 3:最长公共子序列(★★★✩✩)
前面的练习是关于两个给定的字符串相互转换需要多少变化。另一个有趣的问题是找到两个字符串中最长的常见但不一定是连续的字母序列。写方法String lcs(String, String),从后面递归处理字符串,如果两部分长度相同,就用第二个。
例子
|输入 1
|
输入 2
|
结果
| | --- | --- | --- | | "脓肿" | “扎塞夫” | “王牌” | | "脓毒症" | " XYACB " | “AB” | | " ABCMIXCHXAEL " | “迈克尔” | “迈克尔” | | “周日上午” | “周六夜派对” | 苏代-搞笑 |
算法你从后面走到前面。如果字符匹配,该字符将包含在结果中。如果字符不同,必须对缩短了一个字符的字符串递归地重复检查。
static String lcs(final String str1, final String str2)
{
return lcs(str1, str2, str1.length() - 1, str2.length() - 1);
}
static String lcs(final String str1, final String str2,
final int pos1, final int pos2)
{
// recursive termination
if (pos1 < 0 || pos2 < 0)
return "";
// are the characters the same?
if (str1.charAt(pos1) == str2.charAt(pos2))
{
// recursive descent
return lcs(str1, str2, pos1 - 1, pos2 - 1) + str1.charAt(pos1);
}
else
{
// otherwise take away one of both letters and try it
// again, but neither letter belongs in the result
final String lcs1 = lcs(str1, str2, pos1, pos2 - 1);
final String lcs2 = lcs(str1, str2, pos1 - 1, pos2);
if (lcs1.length() > lcs2.length())
return lcs1;
return lcs2;
}
}
确认
对于测试,您使用以下输入,这些输入显示了正确的操作:
@ParameterizedTest(name = "lcs({0}, {1}) = {2}")
@CsvSource({ "ABCE, ZACEF, ACE",
"ABCXY, XYACB, AB",
"ABCMIXCHXAEL, MICHAEL, MICHAEL" })
void lcs(String input1, String input2, String expected)
{
var result = Ex03_LCS.lcs(input1, input2);
assertEquals(expected, result);
}
同样,您需要检查性能。这里也适用于currentTimeMillis()足以分类的情况:
public static void main(final String args[])
{
final String[][] inputs_tuples = { { "ABCMIXCHXAEL", "MICHAEL"},
{ "sunday-Morning",
"saturday-Night-Party" },
{ "sunday-Morning-Wakeup",
"saturday-Night" } }; };
for (String[] inputs : inputs_tuples)
{
final long start = System.currentTimeMillis();
System.out.println(inputs[0] + " -> " + inputs[1] +
" lcs: " + lcs(inputs[0], inputs[1]));
final long end = System.currentTimeMillis();
System.out.println("lcs took " + (end - start) + " ms");
}
}
您测量以下执行时间(它们会因您而略有不同):
ABCMIXCHXAEL -> MICHAEL lcs: MICHAEL
lcs took 1 ms
sunday-Morning -> saturday-Night-Party lcs: suday-ig
lcs took 3318 ms
sunday-Morning-Wakeup -> saturday-Night lcs: suday-ig
lcs took 6151 ms
额外收获:对最长的公共序列使用记忆
当计算 LCS 时,您会注意到以下情况:两个输入的差异越大,运行时间就越长,因为有这么多可能的子序列。因此,纯递归不是真正的高性能的。那么如何做得更好呢?同样,您使用内存化来优化性能。这次您使用一个String[][]来存储数据:
static String lcsOptimized(final String str1, final String str2)
{
return lcsWithMemo(str1, str2, str1.length() - 1, str2.length() - 1,
new String[str1.length() - 1][str2.length() - 1]);
}
实际实现使用如下记忆:
static String lcsWithMemo(final String str1, final String str2,
final int pos1, final int pos2,
final String[][] values)
{
// recursive termination
if (pos1 < 0 || pos2 < 0)
return "";
// MEMOIZATION
if (values[pos1][pos2] != null)
return values[pos1][pos2];
String lcs;
// are the characters the same?
if (str1.charAt(pos1) == str2.charAt(pos2))
{
// recursive descent
final char sameChar = str1.charAt(pos1);
lcs = lcsWithMemo(str1, str2, pos1 - 1, pos2 - 1, values) + sameChar;
}
else
{
// otherwise take away one of both letters and try it
// again, but neither letter belongs in the result
final String lcs1 = lcsWithMemo(str1, str2, pos1, pos2 - 1, values);
final String lcs2 = lcsWithMemo(str1, str2, pos1 - 1, pos2, values);
if (lcs1.length() > lcs2.length())
lcs = lcs1;
else
lcs = lcs2;
}
// MEMOIZATION
values[pos1][pos2] = lcs;
return lcs;
}
通过这种优化,执行时间减少到了几毫秒。启动程序 EX03_LCSWithMemo 进行评估。
8.4.4 解决方案 4:走出迷宫(★★★✩✩)
在这个作业中,你被要求找到走出迷宫的路。假设迷宫是一个二维数组,墙壁用#符号表示,目标位置(出口)用X符号表示。从任何位置看,通向所有出口的路径都应该是确定的。如果一排有两个出口,只需提供两个出口中的第一个。您只能在四个罗盘方向上移动,而不能沿对角线移动。编写方法boolean findWayOut(char[][], int, int),用FOUND EXIT at ...记录每个找到的出口。
例子
一个更大的运动场有四个目标区,如下图所示。下图显示了由点(.))指示的每条路径。在这两者之间,你可以看到出口位置的记录。对于这个例子,搜索从左上角开始,坐标 x=1,y=1。
##################################
# # # # # # X#X#
# ##### #### ## ## # # ### #
# ## # # ## ## # # # #
# # ### # ## ## # ### # #
# # #### ## ## ### # #
#### # #### # # #### #
###### ######### ## # ### #
## # X X####X # # # ### ##
##################################
FOUND EXIT: x: 30, y: 1
FOUND EXIT: x: 17, y: 8
FOUND EXIT: x: 10, y: 8
##################################
#.# #....#.....# #...X#X#
#..##### ####.##...##..# #.### #
# .## # #..## ## .# #.. # #
# ...# ###..#.## ## ..#...### # #
# # ..####.....## ##.... ### # #
#### ..#... #### ....# # #### #
######...#########...## # ### #
## #..X X####X.# # # ### ##
##################################
基于输出,同样清楚的是,从开始位置没有检测到标有X的两个目标场。一个是右上角的X,由于缺少一个链接而无法到达。另一个是中下X,在另一个出口后面。
算法寻找迷宫出路的算法从当前位置开始,检查四个罗盘方向是否有路。为此,已经访问过的相邻字段标有圆点(。)字符,就像你在现实中用小石头做的那样。试错一直持续到你找到一个X作为解决方案,一堵墙的形式是一个#,或者一个已经被访问过的领域(用一个点标记)。如果一个位置没有可能的方向,你使用回溯,恢复最后选择的路径,并从那里尝试剩余的路径。这是通过以下方式实现的:
static boolean findWayOut(final char[][] values, final int x, final int y)
{
if (x < 0 || y < 0 || x > values[0].length || y >= values.length)
return false;
// recursive termination
if (values[y][x] == 'X')
{
System.out.println(String.format("FOUND EXIT: x: %d, y: %d", x, y));
return true;
}
// wall or already visited?
if (values[y][x] == '#' || values[y][x] == '.')
return false;
// recursive descent
if (values[y][x] == ' ')
{
// mark as visited
values[y][x] = '.';
// try all 4 cardinal directions
final boolean up = findWayOut(values, x, y - 1);
final boolean left = findWayOut(values, x + 1, y);
final boolean down = findWayOut(values, x, y + 1);
final boolean right = findWayOut(values, x - 1, y);
// backtracking because no valid solution
final boolean foundAWay = up || left || down || right;
if (!foundAWay)
{
// wrong path, thus delete field marker
values[y][x] = ' ';
}
return foundAWay;
}
throw new IllegalStateException("wrong char in labyrinth");
}
请注意,您在方法中使用了 x 和 y 坐标的自然对齐。然而,当访问数组时,顺序是[y][x],因为你是在行中工作的,正如我在 5.1.2 节数组一章的介绍部分中所讨论的。
确认
对于测试,您从介绍中定义迷宫。接下来,调用findWayOut()方法,然后记录前面显示的迷宫出口,最后用点(.):
char[][] world_big = { "##################################".toCharArray(),
"# # # # # # X#X#".toCharArray(),
"# ##### #### ## ## # # ### #".toCharArray(),
"# ## # # ## ## # # # #".toCharArray(),
"# # ### # ## ## # ### # #".toCharArray(),
"# # #### ## ## ### # #".toCharArray(),
"#### # #### # # #### #".toCharArray(),
"###### ######### ## # ### #".toCharArray(),
"## # X X####X # # # ### ##".toCharArray(),
"##################################".toCharArray() };
printArray(world_big);
if (findWayOut(world_big, 1, 1))
printArray(world_big);
供选择的
所示的实现非常好地以图形方式准备了到目标字段的路径。然而,它有两个小缺点。一方面,当遇到出口时,它直接中断,从而在其后面找不到出口。另一方面,如果一个目标字段有多条路径,程序也会多次记录出口的发现。后者可以通过将所有解路径收集在一个集合中而很容易地解决。您可以在附带的参考资料中找到这个方法的实现findWayOutWithResultSet()。
如果您想找到所有可到达的出口,可以修改前面显示的方法,使访问过的字段用#标记。然而,通过这种方式,字段最终被完全填满,不再显示路径,这是最初变体的一个优点。
static boolean findWayOutV2(final char[][] board,
final int x, final int y)
{
if (board[y][x] == '#')
return false;
boolean found = board[y][x] == 'X';
if (found)
System.out.printf("FOUND EXIT: x: %d, y: %d%n", x, y);
board[y][x] = '#';
found = found | findWayOutV2(board, x + 1, y);
found = found | findWayOutV2(board, x - 1, y);
found = found | findWayOutV2(board, x, y + 1);
found = found | findWayOutV2(board, x, y - 1);
return found;
}
8.4.5 解决方案 5:数独求解器
编写方法boolean solveSudoku(int[][]),为作为参数传递的部分初始化的运动场确定有效的解决方案(如果有的话)。
例子
此处显示了一个带有一些空白的有效操场:
应完成以下解决方案:
算法解数独,你用回溯。和其他回溯问题一样,数独可以通过一步一步的试错来解决。在这种情况下,这意味着为每个空方块尝试不同的数字。根据数独规则,当前数字必须不存在于水平、垂直或 3 × 3 块中。如果你找到一个有效的赋值,你可以在下一个位置继续递归,以测试你是否得到一个解。如果找不到,则尝试下一个数字。但是,如果从 1 到 9 没有一个数字导致解决方案,您需要回溯来检查解决方案的其他可能路径。
在实现过程中,您按如下方式进行:
-
找到下一个空字段。为此,请跳过所有已填写的字段。这样也能变线。
-
如果直到最后一行都没有空字段,那么您已经找到了解决方案。
-
否则,您可以尝试从 1 到 9 的数字:
-
有冲突吗?然后你必须试下一个数字。
-
该数字是可能的候选数字。你递归调用你的方法到下一个位置(下一列甚至下一行)。
-
如果递归返回 false ,这个数字不会导致一个解决方案,你使用回溯。
-
static boolean solveSudoku(final int[][] board)
{
return solveSudoku(board, 0, 0);
}
static boolean solveSudoku(final int[][] board,
final int startRow, final int startCol)
{
int row = startRow;
int col = startCol;
// 1) skip fields with numbers until you reach the next empty field
while (row < 9 && board[row][col] != 0)
{
col++;
if (col > 8)
{
col = 0;
row++;
}
}
// 2) already processed all lines?
if (row > 8)
return true;
// 3) try for the current field all digits from 1 to 9 through
for (int num = 1; num <= 9; num++)
{
// set digit tentatively in the field
board[row][col] = num;
// 3a) check if the whole field with the digit is still valid
if (isValidPosition(board))
{
// 3b) recursive descent for the following field
boolean solved = false;
if (col < 8)
solved = solveSudoku(board, row, col + 1);
else
solved = solveSudoku(board, row + 1, 0);
// 3c) backtracking if recursion is not successful
if (!solved)
board[row][col] = 0;
else
return true;
}
else
{
// try next digit
board[row][col] = 0;
}
}
return false;
}
static boolean isValidPosition(final int[][] board)
{
return checkHorizontally(board) &&
checkVertically(board) &&
checkBoxes(board);
}
看着这个实现,您可能已经怀疑这个变体是否真的是最佳的,甚至不知道下面显示的 helper 方法的细节。为什么?你在每一步都要检查整个游戏场的有效性,更糟糕的是,还要结合回溯!稍后我将对此进行更详细的讨论。
我们先考虑三种方法checkHorizontally(int[][])、checkVertically(int[][]),和checkBoxes(int[][])。您在 5.3.9 节的练习 9 中实现了它们。为了完整起见,这里再次显示了它们:
static boolean checkHorizontally(final int[][] board)
{
for (int row = 0; row < 9; row++)
{
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;
}
static boolean checkVertically(final int[][] board)
{
for (int x = 0; x < 9; x++)
{
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;
}
static boolean checkBoxes(final int[][] board)
{
// 3 x 3-Boxes
for (int yBox = 0; yBox < 3; yBox++)
{
for (int xBox = 0; xBox < 3; xBox++)
{
final List<Integer> boxValues = collectBoxValues(board, yBox, xBox);
if (!allDesiredNumbers(boxValues))
return false;
}
}
return true;
}
static List<Integer> collectBoxValues(final int[][] board,
final int yBox, final int xBox)
{
final List<Integer> boxValues = new ArrayList<>();
// inside the boxes each 3 x 3 fields
for (int y = 0; y < 3; y++)
{
for (int x = 0; x < 3; x++)
{
boxValues.add(board[yBox * 3 + y][xBox * 3 + x]);
}
}
return boxValues;
}
static boolean allDesiredNumbers(final List<Integer> allCollectedValues)
{
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;
// just 1 to 9?
final Set<Integer> oneToNine = Set.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
return oneToNine.containsAll(valuesSet);
}
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();
}
}
确认
您可以使用简介中的示例来测试这个实现:
jshell> int[][] boardExample = new int[][] {
{ 1, 2, 0, 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 } }
jshell> if (solveSudoku(boardExample))
...> {
...> System.out.println("Solved: ");
...> printArray(boardExample);
...> }
Solved:
1 2 3 4 5 6 7 8 9
4 5 6 7 8 9 1 2 3
7 8 9 1 2 3 4 5 6
2 1 4 3 6 5 8 9 7
3 6 5 8 9 7 2 1 4
8 9 7 2 1 4 3 6 5
5 3 1 6 4 2 9 7 8
6 4 2 9 7 8 5 3 1
9 7 8 5 3 1 6 4 2
答案在几分之一秒内就显示出来了。到目前为止,一切都很顺利。但是,如果给定的游戏场几乎不包含任何数字,而是包含大量空白字段,会发生什么呢?
有更多空白的运动场当你试图解决只有几个给定数字的运动场的挑战时,有许多变化可以尝试,并且大量回溯开始发挥作用。假设你想解决类似下面这样的问题:
final int[][] board2 = { { 6, 0, 2, 0, 5, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 4, 0, 3, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 4, 3, 0, 0, 0, 8, 0, 0, 0 },
{ 0, 1, 0, 0, 0, 0, 2, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 7, 0, 0 },
{ 5, 0, 0, 2, 7, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 8, 1 },
{ 0, 0, 0, 6, 0, 0, 0, 0, 0 } };
原则上,这已经可以用你的算法实现了,但是需要几分钟。虽然这是相当长的时间,你可能仍然无法在这个时间跨度内用手解决困难的难题,但有了计算机,应该会更快。那么你能改善什么呢?
合理的优化
想法 1:检查的优化:在每一步检查整个游戏场的有效性是没有用的,没有必要的,也不可行的。作为一种优化,您可以修改检查,以便一次只检查一列、一行和一个相关的框。为此,首先稍微修改方法isValidPosition(),使其接收列和行作为参数:
static boolean isValidPosition(final int[][] board,
final int row, final int col)
{
return checkSingleHorizontally(board, row) &&
checkSingleVertically(board, col) &&
checkSingleBox(board, row, col);
}
此外,您可以创建特定的测试方法,如下所示:
static boolean checkSingleHorizontally(final int[][] board, final int row)
{
final List<Integer> columnValues = new ArrayList<>();
for (int x = 0; x < 9; x++)
{
columnValues.add(board[row][x]);
}
return allDesiredNumbers(columnValues);
}
这种优化导致运行时间在几秒钟的范围内(对于复杂的运动场在 20 到 50 秒之间)。这已经好得多了,但它还可以更有性能。
**想法 2:更聪明的测试:**如果你观察过程,你会注意到你尝试了所有的数字。这违反了一点常识。只使用潜在有效的路径,并提前检查当前数字在上下文中是否可用,不是更有意义吗?然后,您可以直接排除行、列或框中已经存在的所有数字。为此,您需要按如下方式修改检查,并将潜在数字作为参数传递:
private static boolean isValidPosition(final int[][] board,
final int row, final int col,
final int num)
{
return checkNumNotInColumn(board, col, num) &&
checkNumNotInRow(board, row, num) &&
checkNumNotInBox(board, row, col, num);
}
static boolean checkNumNotInRow(final int[][] board,
final int row, final int num)
{
for (int col = 0; col < 9; col++)
{
if (board[row][col] == num)
return false;
}
return true;
}
static boolean checkNumNotInColumn(final int[][] board,
final int col, final int num)
{
for (int row = 0; row < 9; row++)
{
if (board[row][col] == num)
return false;
}
return true;
}
static boolean checkNumNotInBox(final int[][] board,
final int row, final int col, final int num)
{
final int adjustedRow = (row / 3) * 3;
final int adjustedCol = (col / 3) * 3;
for (int y = 0; y < 3; y++)
{
for (int x = 0; x < 3; x++)
{
if (board[adjustedRow + y][adjustedCol + x] == num)
return false;
}
}
return true;
}
想法 3:设置和检查的优化顺序最后,您修改试错法,以便只有在确定该数字有效后,它才被放置在操场上。到目前为止,在步骤 3 的solveSudoku()方法中,您已经尝试了如下所有数字:
def solveSudokuHelper(board, startRow, startCol):
// ...
solved = False
# 3) for the current field, try all digits from 1 to 9
for num in range(1, 10): board[row][col] = num
# 3a) check if the whole playfield containing the digit is still valid
if isValidPosition(board, row, col, num):
...
您优化了这个测试两次。首先,将方法更改为isValidPosition(board, row, col),这样它也可以获得行和列。作为进一步的改进,您传递要检查的数字isValidPosition(board, row, col, num)。
现在,您更进一步,更改插入值和检查的顺序。因此,您只需切换两行,即赋值和调用有效性检查的优化变量的if:
# 3) for the current field, try all digits from 1 to 9
for num in range(1, 10):
# board[row][col] = num
# 3a) check if the whole playfield containing the digit is still valid
if isValidPosition(board, row, col, num):
board[row][col] = num
优化的结果使你的优化(顺便说一句,它不会导致可读性或可理解性的任何限制)也使你不必尝试太多永远达不到目标的解决方案。即使对于更复杂的领域,在我的 iMac (i7 4 GHz)上,解决方案也总是在 1 秒内确定。
8.4.6 解决方案 6:数学运算符检查器(★★★★✩)
这个作业是关于一个数学上的难题。对于一组数字和另一组可能的运算符,您希望找到产生所需值的所有组合。数字的顺序不能改变。尽管如此,除了第一个数字之前,仍然可以在数字之间插入可能的运算符中的任何运算符。编写方法Set<String> allCombinationsWithValue(List<Integer>, int),该方法确定导致作为参数传递的值的所有组合。检查数字1到9和操作+以及组合数字。从方法Map<String, Long> allCombinations(List<Integer>)开始,它被传递了相应的数字。
例子
让我们只考虑数字 1、2 和 3 的两种组合:
1 + 2 + 3 = 6
1 + 23 = 24
总之,这些数字允许形成以下不同的组合:
|投入
|
结果(allCombinations())
| | --- | --- | | [1, 2, 3] | {12-3=9, 123=123, 1+2+3=6, 1+2-3=0, 1-2+3=2, 1-23=-22, 1-2-3=-4, 1+23=24, 12+3=15} |
假设您想要从给定的数字 1 到 9 以及一组可用的运算符(+、组合数字)生成值 100。那么这是可能的,例如,如下所示:
1 + 2 + 3 − 4 + 5 + 6 + 78 + 9 = 100
总之,应确定以下变量:
|投入
|
结果(allCombinationsWithValue())
| | --- | --- | | One hundred | [1+23-4+5+6+78-9, 123+4-5+67-89, 123-45-67+89,12+3-4+5+67+8+9, 1+23-4+56+7+8+9, 12-3-4+5-6+7+89,123-4-5-6-7+8-9, 1+2+34-5+67-8+9, 12+3+4+5-6-7+89,123+45-67+8-9, 1+2+3-4+5+6+78+9] |
Tip
在 Java 中,只需要一些技巧就可以在运行时执行动态计算。但是,如果使用 Java 的内置 JavaScript 引擎,计算表达式是相当容易的:
jshell> import javax.script.*
jshell> ScriptEngineManager manager = new ScriptEngineManager()
jshell> ScriptEngine jsEngine = manager.getEngineByName("js")
jshell> jsEngine.eval("7+2")
$63 ==> 9
编写方法int evaluate(String),用数字和运算符+和-来计算表达式。
算法首先,通过调用allCombinations()方法计算所有可能的组合,在高层次上细分问题,然后使用findByValue()搜索那些评估产生默认值的组合。这可以使用流 API 和合适的过滤条件来容易地表达:
static Set<String> allCombinationsWithValue(final List<Integer> baseValues,
final int desiredValue)
{
final Map<String, Long> allCombinations = allCombinations(baseValues);
return findByValue(allCombinations, desiredValue);
}
static Set<String> findByValue(final Map<String, Long> results,
final int desiredValue)
{
return results.entrySet().stream().
filter(entry -> entry.getValue() == desiredValue).
map(entry -> entry.getKey()).
collect(Collectors.toSet());
}
事实上,通过使用 Java 8 中引入的removeIf()方法,可以大大简化事情:
static Set<String> allCombinationsWithValueShort(final List<Integer> baseValues,
final int desiredValue)
{
final Map<String, Long> allCombinations = allCombinations(baseValues);
allCombinations.entrySet().
removeIf(entry -> entry.getValue() != desiredValue);
return allCombinations.keySet();
}
当您只想计算一次组合时,方法findByValue()对实验很有用。现在你将看到这个计算。
为了计算组合,输入被分成左部分和右部分。这导致了三个子问题需要解决,即 l + r 、l—r和 lr ,其中 l 和 r 代表输入的左右部分。你用方法evaluate()计算他们的结果。如果只剩下一个数字,这就是结果并构成递归终止:
static Map<String, Long> allCombinations(final List<Integer> digits)
{
return allCombinations(digits, 0);
}
static Map<String, Long> allCombinations(final List<Integer> digits,
final int pos)
{
// recursive termination
if (pos >= digits.size() - 1)
{
final long lastDigit = digits.get(digits.size() - 1);
return Map.of("" + lastDigit, lastDigit);
}
// recursive descent
final Map<String, Long> results = allCombinations(digits, pos + 1);
// check combinations
final Map<String, Long> solutions = new HashMap<>();
results.forEach((key, value) ->
{
final long currentDigit = digits.get(pos);
solutions.put(currentDigit + "+" + key,
evaluate("" + currentDigit + "+" + key));
solutions.put(currentDigit + "-" + key,
evaluate("" + currentDigit + "-" + key));
solutions.put(currentDigit + key,
evaluate("" + currentDigit + key));
});
return solutions;
}
使用 JavaScript 实现执行,如下所示:
static long evaluate(final String expression)
{
try
{
return (long) jsEngine.eval(expression);
}
catch (final ScriptException e)
{
throw new RuntimeException("unprocessable expression " + expression);
}
}
因此,变量jsEngine必须在 JShell 中定义,如介绍中所示,或者在 Java 程序中定义,例如,作为静态变量。请想到 JShell 中的import javax.script.*。
带流的变体
通常有许多可能的解决方案。在 Stream API 的帮助下,这个任务也可以很好地解决。这些操作与前面显示的变体类似,但这次您将从前到后处理这些数字。作为一个特殊的特性,您必须创建三次流来计算结果。对于合并它们来说concat()是一个不错的选择:
static Map<String, Long> allCombinationsWithStreams(final List<Integer> digits)
{
return allExpressions(digits).collect(Collectors.toMap(Function.identity(),
Ex06_MathOperationChecker::evaluate));
}
private static Stream<String> allExpressions(final List<Integer> digits)
{
if (digits.size() == 1)
return Stream.of("" + digits.get(0));
final long first = digits.get(0);
final List<Integer> remainingDigits = digits.subList(1, digits.size());
var resultCombine =
allExpressions(remainingDigits).map(expr -> first + expr);
var resultAdd =
allExpressions(remainingDigits).map(expr -> first + "+" + expr);
var resultMinus =
allExpressions(remainingDigits).map(expr -> first + "-" + expr);
return Stream.concat(resultCombine, Stream.concat(resultAdd, resultMinus));
}
从 Java 15 开始的变体
从 Java 15 开始,JDK 不再支持 JavaScript 引擎,所以您必须自己编写评估。幸运的是,由于这里只需要计算加法和减法,所以您可以用一些关于正则表达式的知识来解决它,如下所示:
static long evaluate(final String expression)
{
final String[] values = expression.split("\\+|-");
// use numbers as separators
final String[] tmpoperators = expression.split("\\d+");
// filter out empty elements, limit to the real operators
final String[] operators = Arrays.stream(tmpoperators).
filter(str -> !str.isEmpty()).
toArray(String[]::new);
long result = Long.parseLong(values[0]);
for (int i = 1; i < values.length; i++)
{
final String nextOp = operators[i - 1];
final long nextValue = Long.parseLong(values[i]);
if (nextOp.equals("+"))
result = result + nextValue;
else if (nextOp.equals("-"))
result = result - nextValue;
else
throw new IllegalStateException("unsupported operator " + nextOp);
}
return result;
}
确认
首先,编写一个单元测试,检查输入 1 到 3,可以构建哪些组合。此外,您希望验证结果值 100 的功能。
@ParameterizedTest(name = "allCombinations({0}) = {1}")
@MethodSource("digitsAndCombinations")
void allCombinations(List<Integer> digits, Map<String, Integer> expected)
{
var result = Ex06_MathOperationChecker.allCombinations(digits);
assertEquals(expected, result);
}
private static Stream<Arguments> digitsAndCombinations()
{
var results = Map.ofEntries(Map.entry("12-3", 9L), Map.entry("123", 123L),
Map.entry("1+2+3", 6L), Map.entry("1+2-3", 0L),
Map.entry("1-2+3", 2L), Map.entry("1-23", -22L),
Map.entry("1-2-3", -4L), Map.entry("1+23", 24L),
Map.entry("12+3", 15L));
return Stream.of(Arguments.of(List.of(1, 2, 3), results));
}
@ParameterizedTest(name = "allCombinationsWithValue({0}, {1}) = {2}")
@MethodSource("digitsAndCombinationsWithResult100")
void allCombinationsWithValue(List<Integer> numbers, int desiredValue,
Set<String> expected)
{
var result =
Ex06_MathOperationChecker.allCombinationsWithValue(numbers,
desiredValue);
assertEquals(expected, result);
}
private static Stream<Arguments> digitsAndCombinationsWithResult100()
{
var results = Set.of("1+23-4+5+6+78-9", "12+3+4+5-6-7+89", "123-45-67+89",
"123+4-5+67-89", "123-4-5-6-7+8-9", "123+45-67+8-9",
"1+2+3-4+5+6+78+9", "12+3-4+5+67+8+9",
"1+23-4+56+7+8+9", "1+2+34-5+67-8+9",
"12-3-4+5-6+7+89");
return Stream.of(Arguments.of(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9), 100,
results));
}
8.4.7 解决方案 7:水壶问题(★★★✩✩)
考虑两个容量分别为 m 和 n 升的水壶。不幸的是,这些水壶没有标记或指示他们的填充水平。挑战在于测量 x 升,其中 x 小于 m 或 n 。在程序结束时,一个壶应该包含 x 升,另一个应该是空的。编写方法boolean solveWaterJugs(int, int, int),在控制台上显示解决方案,如果成功,返回true,否则返回false。
例子
对于两个水壶,一个容量为 4 升,另一个容量为 3 升,您可以通过以下方式测量 2 升:
|状态
|
行动
| | --- | --- | | 壶 1:0/壶 2: 0 | 两个罐子最初都是空的 | | 壶 1:4/壶 2: 0 | 填充罐 1(不必要,但由于算法) | | 约 1:4/约 2: 3 | 装满水壶 2 | | Jug 1: 0/Jug 2: 3 | 空水壶 1 | | 壶 1:3/壶 2: 0 | 将水壶 2 倒入水壶 1 | | 约 1:3/约 2: 3 | 装满水壶 2 | | 壶 1:4/壶 2: 2 | 倒南 2 和南 1 | | 壶 1:0/壶 2: 2 | 空水壶 1 | | 解决 | |
另一方面,用两个容量分别为 4 升的水壶测量 2 升是不可能的。
算法为了解决水罐问题,你使用递归和贪婪算法。这里,在每个时间点,您都有以下可能的后续操作:
-
完全清空水壶 1。
-
完全清空水壶 2。
-
将水壶 1 装满。
-
将水壶 2 装满。
-
从罐 2 填充罐 1,直到源罐为空或者要填充的罐为满。
-
从罐 1 填充罐 2,直到源罐为空或者要填充的罐为满。
你一步一步的尝试这六个变种,直到其中一个成功。要做到这一点,你需要每次测试一个壶里是否有想要的升数,另一个壶里是否是空的。
static boolean isSolved(final int currentJug1, final int currentJug2,
final int desiredLiters)
{
return (currentJug1 == desiredLiters && currentJug2 == 0) ||
(currentJug2 == desiredLiters && currentJug1 == 0);
}
因为尝试许多解决方案可能相当耗时,所以您使用已知的记忆化技术进行优化。在这种情况下,它可以防止循环(即重复执行相同的操作)。已经计算的级别以复合键的形式建模。这里使用了类别IntIntKey(参见第 8.1.2 节)。为了找到解决方案,你从两个空罐子开始:
static boolean solveWaterJugs(final int size1, final int size2,
final int desiredLiters)
{
return solveWaterJugsRec(size1, size2, desiredLiters, 0, 0,
new HashMap<>());
}
static boolean solveWaterJugsRec(final int size1, final int size2,
final int desiredLiters,
final int currentJug1, final int currentJug2,
final Map<IntIntKey, Boolean> alreadyTried)
{
if (isSolved(currentJug1, currentJug2, desiredLiters))
{
System.out.println("Solved Jug 1: " + currentJug1 +
" / Jug 2: " + currentJug2);;
return true;
}
final IntIntKey key = new IntIntKey(currentJug1, currentJug2);
if (!alreadyTried.containsKey(key))
{
alreadyTried.put(key, true);
// Try all 6 variants
System.out.println("Jug 1: " + currentJug1 + " / Jug 2: " + currentJug2);
final int min_2_1 = Math.min(currentJug2, (size1 - currentJug1));
final int min_1_2 = Math.min(currentJug1, (size2 - currentJug2));
boolean result = solveWaterJugsRec(size1, size2, desiredLiters,
0, currentJug2, alreadyTried) ||
solveWaterJugsRec(size1, size2, desiredLiters,
currentJug1, 0, alreadyTried) ||
solveWaterJugsRec(size1, size2, desiredLiters,
size1, currentJug2, alreadyTried) ||
solveWaterJugsRec(size1, size2, desiredLiters,
currentJug1, size2, alreadyTried) ||
solveWaterJugsRec(size1, size2, desiredLiters,
currentJug1 + min_2_1,
currentJug2 - min_2_1,
alreadyTried) ||
solveWaterJugsRec(size1, size2, desiredLiters
,
currentJug1 - min_1_2,
currentJug2 + min_1_2,
alreadyTried);
alreadyTried.put(key, result);
return result;
}
return false;
}
Attention: Possible Pitfall
当实现这一点时,您可能会想到简单地独立检查所有六个变量,例如,确定迷宫的所有出口。然而,恐怕这是不对的,因为它允许在一个步骤中进行多个操作。因此,一次只需检查一个步骤。只有在失败的情况下,你才能进行下一次。因此,下面显示的变体是不正确的。它检测解决方案,但是执行额外的、部分令人困惑的步骤:
// Intuitive, BUT WRONG, because 2 or more steps possible
boolean actionEmpty1 = solveWaterJugsRec(size1, size2, desiredLiters,
0, currentJug2, alreadyTried);
boolean actionEmpty2 = solveWaterJugsRec(size1, size2, desiredLiters,
currentJug1, 0, alreadyTried);
boolean actionFill1 = solveWaterJugsRec(size1, size2, desiredLiters,
size1, currentJug2, alreadyTried);
boolean actionFill2 = solveWaterJugsRec(size1, size2, desiredLiters,
currentJug1, size2, alreadyTried);
int min_2_1 = Math.min(currentJug2, (size1 - currentJug1));
boolean actionFillUp1From2 = solveWaterJugsRec(size1, size2, desiredLiters,
currentJug1 + min_2_1),
currentJug2 - min_2_1);
int min_1_2 = Math.min(currentJug1, (size2 - currentJug2));
boolean actionFillUp2From1 = solveWaterJugsRec(size1, size2, desiredLiters
,
currentJug1 - min_1_2),
currentJug2 + min_1_2);
确认
让我们在 JShell 中为示例中的组合确定解决方案:
jshell> solveWaterJugs(4, 3, 2)
Jug 1: 0 / Jug 2: 0
Jug 1: 4 / Jug 2: 0
Jug 1: 4 / Jug 2: 3
Jug 1: 0 / Jug 2: 3
Jug 1: 3 / Jug 2: 0
Jug 1: 3 / Jug 2: 3
Jug 1: 4 / Jug 2: 2
Solved Jug 1: 0 / Jug 2: 2
$5 ==> true
8.4.8 解决方案 8:所有回文子字符串(★★★★✩)
在这个作业中,给定一个单词,你要确定它是否包含回文,如果包含,是哪些。编写递归方法Set<String> allPalindromePartsRec(String),确定传递的字符串中至少有两个字母的所有回文,并按字母顺序返回。 2
例子
|投入
|
结果
| | --- | --- | | " BCDEDCB " | (BCDEDCB、CDC、DD) | | “鲍鱼” | aba、LL、lottol、OTTO、TT] | | “赛车” | ["aceca "," cec "," racecar"] |
对于长度至少为 2 的文本,该问题被分解为三个子问题:
-
整篇课文是回文吗?
-
左边缩短的部分是回文吗?(适用于右侧的所有位置)
-
右边缩短的部分是回文吗?(适用于从左侧开始的所有位置)
为了更好地理解,请看初始值LOTTOL的程序:
1) LOTTOL
2) OTTOL, TTOL, TOL, OL
3) LOTTO, LOTT, LOT, LO
之后,您向左和向右向内移动一个字符,并重复检查和这一程序,直到位置重叠。例如,检查将按如下方式继续:
1) OTTO
2) TTO, TO
3) OTT, OT
最后,在最后一步中,只剩下一个检查,因为其他子字符串只包含一个字符:
1) TT
2) T
3) T
正如前面多次应用的那样,这里再次使用两步变体。这里,第一个方法主要初始化结果对象,然后适当地开始递归调用。
基于这个逐步的过程,让我们实现回文子串的检查,如下所示:
static Set<String> allPalindromeParts(final String input)
{
final Set<String> allPalindromsParts = new TreeSet<>();
allPalindromePartsRec(input, 0, input.length() - 1, allPalindromsParts);
return allPalindromsParts;
}
static void allPalindromePartsRec(final String input,
final int left, final int right,
final Set<String> results)
{
// recursive termination
if (left >= right)
return;
// 1) check if the whole string is a palindrome
final boolean completeIsPalindrome = isPalindromeRec(input, left, right);
if (completeIsPalindrome)
{
final String newCandidate = input.substring(left, right + 1);
results.add(newCandidate);
}
// 2) check text shortened from left
for (int i = left + 1; i < right; i++)
{
final boolean leftPartIsPalindrome = isPalindromeRec(input, i, right);
if (leftIsPalindrome)
{
final String newCandidate = input.substring(i, right + 1);
results.add(newCandidate);
}
}
// 3) check text shortened from right
for (int i = right - 1; i > left; i--)
{
final boolean rightPartIsPalindrome = isPalindromeRec(input, left, i);
if (rightIsPalindrome)
{
final String newCandidate = input.substring(left, i + 1);
results.add(newCandidate);
}
}
// recursive descent
allPalindromePartsRec(input, left + 1, right - 1, results);
}
这里,您使用在练习 4 的 4.2.4 节中已经创建的方法boolean isPalindromeRec(String, int, int)来检查字符串范围上的回文。为了完整起见,这里再次显示了这一点:
static boolean isPalindromeRec(final String input,
final int left, final int right)
{
// recursive termination
if (left >= right)
return true;
if (input.charAt(left) == input.charAt(right))
{
// recursive descent
return isPalindromeRec(input, left + 1, right - 1);
}
return false;
}
尽管所示的算法很容易理解,但对于所有的循环和索引访问来说,它似乎相当笨拙。事实上,有一个很好的解决方案。
优化算法通过递归调用缩短部分的方法,您可以做得更好,而不是费力地尝试所有缩短的子字符串:
static void allPalindromePartsRecOpt(final String input, final int left,
final int right, final Set<String> results)
{
// recursive termination
if (left >= right)
return;
// 1) check if the whole string is a palindrome
if (isPalindromeRec(input, left, right))
results.add(input.substring(left, right + 1;);
// recursive descent: 2) + 3) test from left / right
allPalindromePartsRecOpt(input, left + 1, right, results);
allPalindromePartsRecOpt(input, left, right - 1, results);
}
这可以提高可读性,但是由于创建了子字符串,性能会(稍微)变差:
static void allPalindromePartsRecV3(final String input,
final Set<String> result)
{
// recursive termination
if (input.length() < 2)
return;
// 1) check if the whole string is a palindrome
if (isPalindromeRec(input)) result.add(input);
// recursive descent: 2) + 3) test from left / right
allPalindromePartsRecV3(input.substring(1), result);
allPalindromePartsRecV3(input.substring(0, input.length() - 1), result);
}
奖励:找到所有回文子串中最长的一个
这一次没有对最高性能的要求。
算法计算完所有回文子串后,找到最长的一个只是定义合适的比较器,然后应用 stream API 及其max(Comparator<T>)方法的问题:
static Optional<String> longestPalindromePart(final String input)
{
final Set<String> allPalindromeParts = allPalindromePartsRec(input);
final Comparator<String> byLength = Comparator.comparing(String::length);
return allPalindromeParts.stream().max(byLength);
}
确认
对于测试,您使用以下输入,这些输入显示了正确的操作:
@ParameterizedTest(name = "allPalindromeParts({0}) = {1}")
@MethodSource("inputAndPalindromeParts")
void allPalindromePartsRec(String input, List<String> expected)
{
Set<String> result = Ex08_AllPalindromeParts.allPalindromeParts(input);
assertIterableEquals(expected, result);
}
@ParameterizedTest(name = "allPalindromePartsOpt({0}) = {1}")
@MethodSource("inputAndPalindromeParts")
void allPalindromePartsOpt(final String input, final List<String> expected)
{
Set<String> result = Ex08_AllPalindromeParts.allPalindromePartsOpt(input);
assertIterableEquals(expected, result);
}
private static Stream<Arguments> inputAndPalindromeParts()
{
return Stream.of(Arguments.of("BCDEDCB",
List.of("BCDEDCB", "CDEDC", "DED")),
Arguments.of("ABALOTTOLL",
List.of("ABA", "LL", "LOTTOL", "OTTO", "TT")),
Arguments.of("racecar",
List.of("aceca", "cec", "racecar")));
}
这里有两件事值得一提:首先,测试排序结果很重要。为了实现这一点,你要么需要一个TreeSet<E>要么切换到一个List<E>,因为你想简化测试设计。此外,在这个修改之后,你还需要和assertIterableEquals()核实一下,以便考虑元素的顺序。
8.4.9 解决方案 9: n 皇后问题(★★★✩✩)
在 n 皇后问题中,n 个皇后被放置在一个 n×n 的棋盘上,根据国际象棋规则,没有两个皇后可以击败对方。因此,其他皇后不能放在同一行、列或对角线上。为此,扩展第 8.2.1 节中所示的解决方案并实现方法boolean isValidPosition(char[][], int, int)。还要编写方法void printBoard(char[][])来显示面板,并将解决方案输出到控制台。
例子
对于一个 4 × 4 的运动场,有以下解决方案,皇后用一个Q符号表示:
---------
| |Q| | |
---------
| | | |Q|
---------
|Q| | | |
---------
| | |Q| |
---------
算法你试图把皇后一个接一个地放在不同的位置。您从第 0 行第 0 个位置(左上角)的皇后开始。每次放置后,进行检查以确保在垂直和向上的左右对角线方向上没有与已经放置的皇后发生碰撞。在任何情况下,逻辑上向下检查都是不必要的,因为还没有皇后可以放在那里。毕竟填充是从上到下完成的。因为您也是一行一行地进行,所以水平方向的检查是不必要的。
如果位置有效,移动到下一行,尝试从0到 n 到1的所有位置。重复这个过程,直到你最后把皇后放在最后一排。如果定位皇后有问题,使用回溯。移除最后放置的皇后,并在下一个可能的位置重试。如果到达行尾无解,这是无效星座,前一个皇后也必须重新放置。您可以看到,回溯有时会返回到上一行,在极端情况下会返回到第一行。
让我们从简单的部分开始,即概括介绍和创建运动场,并调用方法来解决它:
static Optional<char[][]> solveNQueens(final int size)
{
final char[][] board = initializeBoard(size);
if (solveNQueens(board, 0))
return Optional.of(board);
return Optional.empty();
}
private static char[][] initializeBoard(final int size)
{
final char[][] board = new char[size][size];
for (int row = 0; row < size; row++)
{
for (int col = 0; col < size; col++)
{
board[row][col] = ' ';
}
}
return board;
}
使用char[][]来模拟运动场。一个 Q 代表一个皇后,一个空格代表一个自由场。为了让算法易于理解,您提取了下面显示的两个方法,placeQueen()和removeQueen(),用于放置和删除皇后:
static boolean solveNQueens(final char[][] board, final int row)
{
final int maxRow = board.length;
final int maxCol = board[0].length;
// recursive termination
if (row >= maxRow)
return true;
boolean solved = false;
int col = 0;
while (!solved && col < maxCol)
{
if (isValidPosition(board, col, row))
{
placeQueen(board, col, row);
// recursive descent
solved = solveNQueens(board, row + 1);
// backtracking, if no solution
if (!solved)
removeQueen(board, col, row);
}
col++;
}
return solved;
}
以下两种方法的提取导致了更好的可读性:
static void placeQueen(final char[][] board, final int col, final int row)
{
board[row][col] = 'Q';
}
static void removeQueen(final char[][] board, final int col, final int row)
{
board[row][col] = ' ';
}
现在让我们开始实现 helper 方法。首先,检查星座是否有效:
static boolean isValidPosition(final char[][] board,
final int col, final int row)
{
final int maxRow = board.length;
final int maxCol = board[0].length;
return checkHorizontally(board, row, maxCol) &&
checkVertically(board, col, maxRow) &&
checkDiagonallyLeftUp(board, col, row) &&
checkDiagonallyRightUp(board, col, row, maxCol);
}
事实上,水平检查是多余的,因为你只是检查一个新的行,那里还没有其他皇后可以放置。为了便于说明,无论如何都要实现并调用方法。
在实现中,使用以下助手方法在 x 和 y 方向进行检查:
static boolean checkHorizontally(final char[][] board,
final int row, final int maxX)
{
boolean xfree = true;
for (int x = 0; x < maxX; x++)
xfree = xfree && board[row][x] == ' ';
return xfree;
}
这种实现留下了改进的空间。事实上,检查不必扫描所有字段。当找到被占用的字段时,您已经可以停止:
static boolean checkHorizontally(final char[][] board,
final int row, final int maxX)
{
int x = 0;
while (x < maxX && board[row][x] == ' ')
x++;
return x >= maxX;
}
static boolean checkVertically(final char[][] board,
final int col, final int maxY)
{
int y = 0;
while (y < maxY && board[y][col] == ' ')
y++;
return y >= maxY;
}
对于对角线,也可以进行优化。不用往下查了。因为你从上到下填满了棋盘,所以还没有王后能被放在当前的位置下。因此,您将自己限制在左上角和右上角的相关对角线上:
static boolean checkDiagonallyRightUp(final char[][] board, final int col,
final int row, final int maxX)
{
int x = col;
int y = row;
boolean diagRUfree = true;
while (diagRUfree && x < maxX && y >= 0)
{
diagRUfree = board[y][x] == ' ';
y--;
x++;
}
return diagRUfree;
}
对于第二个对角线检查,我将演示循环的语法变化。刚刚展示的变体很容易理解,但是有点长。for循环允许你定义多个循环变量,检查它们,并改变它们。这使得对角线遍历可以简洁地写成如下形式:
static boolean checkDiagonallyLeftUp(final char[][] board,
final int col, final int row)
{
boolean diagLUfree = true;
for (int y = row, x = col; y >= 0 && x >= 0; y--, x--)
diagLUfree = diagLUfree && board[y][x] == ' ';
return diagLUfree;
}
通过在检测到一个被占用的字段时直接中止循环,这也可以得到最低限度的优化。然后甚至有可能进一步压缩for loop,但是以可读性为代价,因此这里没有显示。
在我看来,最容易理解的结构是已经用while loop表示的结构。
您实现了具有 n × n 个方块的风格化棋盘的输出,如下所示,这里只有网格和交叉线的计算稍微特殊一点:
static void printBoard(final char[][] values)
{
final String line = "-".repeat(values[0].length * 2 + 1);
System.out.println(line);
for (int y = 0; y < values.length; y++)
{
System.out.print("|");
for (int x = 0; x < values[y].length; x++)
{
final Object value = values[y][x];
System.out.print(value + "|");
}
System.out.println(); System.out.println(line);
}
}
确认
对于两个不同大小的运动场,使用solveNQueens()计算 n 皇后问题的解。最后,您在控制台上显示每种情况下确定为解决方案的运动场。
public static void main(final String[] args)
{
solveAndPrint(4);
solveAndPrint(8);
}
private static void solveAndPrint(final int size)
{
final Optional<char[][]> optBoards = solveNQueens(size);
optBoards.ifPresentOrElse(board -> printBoard(board),
() -> System.out.println("No Solution!"));
}
下面只显示了 4 × 4 大小字段的输出:
---------
| |Q| | |
---------
| | | |Q|
---------
|Q| | | |
---------
| | |Q| |
---------
替代解决方案方法
虽然之前选择的二维数组表示非常吸引人,但是有一个优化。因为每行只能放置一个皇后,所以可以使用一个列表来建模游戏场和皇后的位置,这就简化了很多。一开始听起来很奇怪。它应该如何工作?
对于 n 皇后问题的解决方案,在每种情况下都需要 x 和 y 坐标。你可以通过下面的技巧重建它们。y 坐标来自列表中的位置。对于 x 坐标,在列表中存储一个相应的数值。先前由字符Q指示的女王的存在现在可以被间接确定。如果列表在 y 坐标的位置包含大于或等于 0 的数值,则存在皇后。
有了这些知识,您可以在适当的地方调整算法的实现。事实上,基本逻辑没有改变,但是方法签名和位置处理改变了。方便的是,你也不再需要提前生成char[][]。但是让我们先看看实际的算法:
static List<Integer> solveNQueens(final int size)
{
final List<Integer> board = new ArrayList<>();
if (solveNQueens(board, 0, size))
return board;
return List.of();
}
static boolean solveNQueens(final List<Integer> board,
final int row, final int size)
{
// recursive termination
if (row >= size)
return true;
boolean solved = false;
int col = 0;
while (!solved && col < size)
{
if (isValidPosition(board, col, row, size))
{
placeQueen(board, col, row);
// recursive descent
solved = solveNQueens(board, row + 1, size);
// backtracking, if no solution
if (!solved)
removeQueen(board, col, row);
}
col++;
}
return solved;
}
为了提高可读性,您可以适当地修改以下方法:
static void placeQueen(final List<Integer> board, final int col, final int row)
{
board.add(col);
}
static void removeQueen(final List<Integer> board, final int col, final int row)
{
board.remove(row);
}
检查星座是否有效的实现变得非常简单。对于垂直方向,检查列表是否已经包含相同的列。只有对角线的检查仍然在一个单独的辅助方法中完成。
static boolean isValidPosition(final List<Integer> board,
final int col, final int row, final int size)
{
final boolean yfree = !board.contains(col);
return yfree && checkDiagonally(board, col, row, size);
}
同样,对于对角线,您可以应用以下技巧:对于位于对角线上的皇后,x 方向上的差异必须对应于 y 方向上的差异。为此,从当前位置开始,只需计算和比较坐标。
(x - 2, y - 2) X X (x + 2, y - 2)
\ /
(x - 1, y - 1) X X (x + 1, y - 1)
\ /
X
(x,y)
您可以按如下方式实现它:
static boolean checkDiagonally(final List<Integer> board,
final int col, final int row,
final int size)
{
boolean diagLUfree = true;
boolean diagRUfree = true;
for (int y = 0; y < row; y++)
{
int xPosLU = col - (row - y);
int xPosRU = col + (row - y);
if (xPosLU >= 0)
diagLUfree = diagLUfree && board.get(y) != xPosLU;
if (xPosRU < size)
diagRUfree = diagRUfree && board.get(y) != xPosRU;
}
return diagRUfree && diagLUfree;
}
具有 n × n 个方块的程式化棋盘的输出最低限度地适应新的数据结构:
static void printBoard(final List<Integer> board, final int size)
{
final String line = "-".repeat(size * 2 + 1);
System.out.println(line);
for (int y = 0; y < size; y++)
{
System.out.print("|");
for (int x = 0; x < size; x++)
{
Object value = ' ';
if (x == board.get(y)) value = 'Q';
System.out.print(value + "|");
}
System.out.println("\n" + line);
}
}
确认
同样,对于两个运动场,使用solveNQueens()计算 n 皇后问题的解。这里以列表的形式提供了解决方案。这一次,省略了用Optional<T>包装,因为解决方案的存在也可以使用列表巧妙地编码。若空,无解;否则,列表包含解决方案。然后,解决方案会打印在控制台上。
public static void main(final String[] args)
{
solveAndPrint(4); solveAndPrint(8);
}
private static void solveAndPrint(final int size)
{
final List<Integer> board = solveNQueens(size);
if (board.isEmpty())
System.out.println("No Solution!");
else
printBoard(board, size);
}
结果与之前的结果相同,因此不再显示。
Footnotes 1当然,你对这个作业中的空字符串和单个字符不感兴趣,尽管严格来说,它们也是回文。
2
当然,你对这个作业中的空字符串和单个字符不感兴趣,尽管严格来说,它们也是回文。