Java-挑战-全-

Java 挑战(全)

原文:Java Challenges

协议:CC BY-NC-SA 4.0

一、介绍

欢迎使用本工作簿!在你开始之前,我想简要地概述一下你在阅读它时可以期待什么。

这本书涵盖了广泛的与实践相关的主题,以不同难度的练习为代表。这些练习(在很大程度上)是相互独立的,可以按照任何顺序解决,这取决于你的心情或兴趣。

除了练习之外,你还会找到相应的答案,包括对用于解决方案的算法的简短描述和实际的源代码,在关键点上有注释。

1.1 各章的结构

每一章都有相同的结构,所以你会很快找到路。

导言

每章以对主题的介绍开始,以使那些可能还不熟悉主题领域的读者或让你对接下来的任务有正确的情绪。

练习

简介之后是一系列练习和以下结构:

  • 任务:每个练习首先会有一个作业。在此,用几句话描述了要实现的期望功能。通常,方法签名已经作为解决方案的线索包含在内。

  • 示例:补充示例几乎总是用于说明输入和预期结果。对于一些非常简单的任务,主要是为了了解一个 API,例子有时会被省略。

通常,输入参数的不同赋值以及预期结果显示在表格中,例如,如下所示:

|

输入一个 1 个

|

输入 B

|

结果

|
| --- | --- | --- |
| [1, 2, 4, 7, 8] | [2, 3, 7, 9] | [2, 7] |

以下符号样式适用于规范:

  • “AB”代表文本规格

  • 真/假代表布尔值

  • 123 代表数值

  • [值 1,值 2,....]表示集合,如集合或列表,但也表示数组

  • {关键字 1:值 1,关键字 2:值 2,...}描述地图

解决方案

这些解决方案遵循下面描述的结构。

  • 任务定义及实例:首先,我把任务描述再重复一遍,这样你就不用在任务和解决方案之间不断来回翻转了。相反,解决方案的描述是独立的。

  • 算法:所选算法的描述如下。对于教学,我有时会有意识地提出一个错误的方法或一个非最优的解决方案,然后发现缺陷,反复改进。事实上,这种或那种强力解决方案有时甚至是可用的,但是提供了优化潜力。举例来说,我会在相应的时候提出,有时简单得惊人,但往往非常有效的改进。

  • 考试:有些任务相当简单,或者只是为了习惯语法或 API 功能。为此,在 JShell 中直接执行几个调用似乎就足够了。这就是为什么我不使用单元测试的原因。如果解决方案的图形化表示更好(例如,显示数独板),并且相应的单元测试可能更难理解,这同样适用。

然而,算法变得越复杂,存在的错误来源就越多,例如,错误的索引值、意外或遗漏的否定,或者忽略的边缘情况。因此,在单元测试的帮助下检查功能是有意义的——在本书中,由于篇幅的原因,只对重要的输入进行检查。伴随资源包含了超过 90 个单元测试和大约 750 个测试用例——这是一个很好的开始。然而,在实践中,如果可能的话,单元测试和测试用例的网络应该更加庞大。

1.2 Eclipse 项目的基本结构

包含的 Eclipse 项目紧密地遵循了这本书的结构。它为每个相关章节(那些有练习的章节)提供了一个单独的包,比如ch02_mathch08_recursion_advanced。我非常不同于包的命名约定,因为我认为下划线,在这种情况下,是一种可读的符号。

各个介绍中的一些源代码片段位于名为intro的子包中。所提供的(示例)解决方案被收集在它们自己的名为solutions的子包中,这些类根据任务命名如下:Ex<No>_<taskdescription>.java

整个项目遵循 Maven 标准目录结构。因此,您将在src/main/java下找到源代码,在src/test/java下找到测试。

来源——src/main/java:图 1-1 显示第二章的轮廓;

img/519691_1_En_1_Fig1_HTML.jpg

图 1-1

第二章的大纲

测试类别-src/test/java:图 1-2 显示了一些相关的测试;

img/519691_1_En_1_Fig2_HTML.jpg

图 1-2

相关测试

在各个章节中开发的所有便利的实用方法都以实用类的形式包含在所提供的 Eclipse 项目中。例如,在第五章中,你实现了一些有用的方法,包括swap()find()(都在 5.1.1 节中描述)。然后它们被组合成一个名为ArrayUtils的类,这个类存储在它自己的子包util中——对于数组这一章,它在子包ch05_arrays.util中。这同样适用于其他章节和主题。

1.3 单元测试的基本框架

为了不超出本书的范围,举例说明的单元测试只显示了测试方法,而没有显示测试类和导入。为了向您提供一个基本框架,您可以在其中插入测试方法,并作为您自己实验的起点,一个典型的测试类如下所示:

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.LocalDate;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.MethodSource;

public class SomeUnitTests
{
    @ParameterizedTest(name = "value at pos {index} ==> {0} should be perfect")
    @ValueSource(ints = { 6, 28, 496, 8128 } )
    void testIsPerfectNumberSimple(int value)
    {
        assertTrue(Ex03_PerfectNumbers.isPerfectNumberSimple(value));
    }

    @ParameterizedTest
    @CsvSource({"2017-01-01, 2018-01-01, 53", "2019-01-01, 2019-02-07, 5"})
    void testAllSundaysBetween(LocalDate start, LocalDate end, int expected)
    {
        var result = Ex09_CountSundaysExample.allSundaysBetween(start, end);

        assertEquals(expected, result.count());
    }

    @ParameterizedTest(name = "calcPrimes({0}) = {1}")
    @MethodSource("argumentProvider")
    void testCalcPrimesBelow(int n, List<Integer> expected)
    {
        List<Integer> result = Ex04_PrimNumber.calcPrimesBelow(n);

        assertEquals(expected, result);
    }

    // if parameters are lists of values => Stream<Arguments>
    static Stream<Arguments> argumentProvider()
    {
        return Stream.of(Arguments.of(2, List.of(2)),
                         Arguments.of(3, List.of(2, 3)),
                         Arguments.of(10, List.of(2, 3, 5, 7)),
                         Arguments.of(15, List.of(2, 3, 5, 7, 11, 13)));
    }
}

除了允许以简单方式测试多个值组合的导入和广泛使用的参数化测试之外,此处显示了通过@CsvSource@MethodSource结合Stream<Arguments>提供测试输入。详情请见附录 b。

1.4 编程风格说明

在讨论过程中,不时有人提出这样一个问题:某些东西是否应该做得更紧凑。这就是为什么我想提前提及本书中使用的编程风格。

1.4.1 关于源代码紧凑性的思考

对我来说,编程时最重要的事情,尤其是本书中的实现,是容易理解和清晰的结构。这导致了简化的可维护性和可变性。因此,所示的实现被编程为尽可能容易理解,并避免使用例如更复杂表达式的?-操作符。对于(x < y) ? x : y这种简单的东西当然是可以接受的。遵循这些规则,并不是每个构造都是最大限度紧凑的,但结果是,它是可以理解的。我喜欢这本书的这一方面。在实践中,这通常比可维护性差但编程更紧凑更容易接受。

1.4.2 示例 1

让我们看一个小例子来说明。首先,检查用于反转字符串内容的易读、易懂的变体,它也很好地展示了递归终止和下降的两个重要元素:

static String reverseString(final String input)
{
    // recursive termination
    if (input.length() <= 1)
        return input;

    final char firstChar = input.charAt(0);
    final String remaining = input.substring(1);

    // recursive descent
    return reverseString(remaining) + firstChar;
}

以下更紧凑的变型不具备这些优势:

static String reverseStringShort(final String input)
{
    return input.length() <= 1 ? input :
           reverseStringShort(input.substring(1)) + input.charAt(0);
}

简单想想这两种方法中的哪一种是你觉得可以安全地进行后续更改的。如果您想添加单元测试呢?如何找到合适的值集和检查?

请记住,上面的变体是由 JVM 自动转换成类似于下面的变体,要么在编译期间(转换成字节码),要么在以后的执行和优化期间——只是在编程期间具有更好的可读性。

例 2

让我举另一个例子来说明我的观点。关于下面的方法countSubstrings(),它计算一个字符串在另一个字符串中出现的次数,对于两个输入“helloha”和“ha”,它返回结果 2。

首先,您可以合理、直接地实现它,如下所示:

static int countSubstrings(final String input, final String valueToFind)
{
    // recursive termination
    if (input.length() < valueToFind.length())
        return 0;

    int count;
    String remaining;

    // does the text start with the search string?
    if (input.startsWith(valueToFind))
    {
        // match: continue the search for the found
        // term after the occurrence
        remaining = input.substring(valueToFind.length());
        count = 1;
    }
    else
    {
        // remove first character and search again
        remaining = input.substring(1);
        count = 0;
    }

    // recursive descent
    return countSubstrings(remaining, valueToFind) + count;
}

让我们来看看如何才能简洁地认识到这一点:

static int countSubstringsShort(final String input, final String valueToFind)
{
    return input.length() < valueToFind.length() ? 0 :
           (input.startsWith(valueToFind) ? 1 : 0) +
           countSubstringsShort(input.substring(1), valueToFind);
}

您希望改变这种方法还是之前显示的方法?

对了,下面那个还包含一个细微的功能偏差!对于“XXXX”和“XX”的输入,第一个变体总是消耗字符并找到两个出现。然而,下限一次只移动一个字符,因此找到三个出现的位置。

此外,将先前实现的通过整个搜索字符串前进的功能集成到第二变体中将导致更晦涩的源代码。另一方面,通过简单地调整上面的substring(valueToFind.length())调用,你可以很容易地只移动一个字符,然后甚至从if中取出这个功能。

1.4.4 对finalvar的思考

通常我更喜欢将不可变变量标记为final。在本书中,我不时地省略这一点,特别是在单元测试中,以使它们尽可能简短。另一个原因是,JShell 并不是在任何地方都支持final关键字,但幸运的是在重要的地方,即对于参数和局部变量。

局部变量类型推理( var ) 从 Java 10 开始,所谓的局部变量类型推理就有了,更好的说法是var。这允许省略变量定义左侧的显式类型规范,前提是编译器可以从赋值右侧的定义中确定局部变量的具体类型:

var name = "Peter";                // var => String
var chars = name.toCharArray();    // var => char[]

var mike = new Person("Mike", 47); // var => Person
var hash = mike.hashCode();        // var => int

特别是在通用容器的上下文中,局部变量类型推理显示了它的优势:

// var => ArrayList<String>
var names = new ArrayList<String>();
names.add("Tim");
names.add("Tom");
names.add("Jerry");

// var => Map<String, Long>
var personAgeMapping = Map.of("Tim", 47L, "Tom", 12L,
                              "Michael", 47L, "Max", 25L);

约定: var 如果可读性更强只要不影响可理解性,我会在适当的地方使用var,让源代码更短更清晰。然而,如果类型规范对于理解更重要,我更喜欢具体类型,避免var——但是界限是模糊的。

约定:final 或 var 还有一点注意:虽然可以组合finalvar两个词,但我不喜欢这种风格,也不喜欢使用其中一个。

1.4.5 关于方法可见性的注释

您可能想知道为什么给出的方法通常不标记为public。本书中介绍的方法通常不带可见性修饰符,因为它们主要在定义它们的包和上下文中被调用。因此,可以毫无问题地调用它们来进行附带的单元测试,并且可以用于 JShell 中的实验。有时我会从练习的实现中提取一些辅助方法,以获得更好的结构和可读性。这些然后通常是private,相应地表达这种情况。

事实上,我在专用的实用程序类中组合了最重要的实用程序方法,对于其他包,它们当然是允许访问的publicstatic

1.4.6 阻止列表中的注释

请注意,清单中有各种块注释,这些注释是为了便于理解。建议谨慎使用这样的注释,并且在实践中最好将单独的源代码部分提取到方法中。然而,对于本书的例子来说,这些评论只是作为参考点,因为介绍或呈现的事实对于读者来说可能仍然是新的和不熟悉的。

// create process
final String command = "sleep 60s";
final String commandWin = "cmd timeout 60";
final Process sleeper = Runtime.getRuntime().exec(command);
//    ...

// Process => ProcessHandle
final ProcessHandle sleeperHandle = ProcessHandle.of(sleeper.pid()).
                                    orElseThrow(IllegalStateException::new);
//    ...

1.4.7 格式化的想法

清单中使用的格式与 Oracle 的编码惯例略有不同。 1 我赞同斯科特·安布勒的观点, 2 特别建议(开)括号各占一行。为此,我创建了一个名为Michaelis_CodeFormat的特殊格式。这被集成到项目下载中。

1.5 尝试示例和解决方案

基本上,我更喜欢使用尽可能容易理解的结构,而不是特殊 Java 版本的花哨语法或 API 特性。如果文本中没有明确提到,这些示例和解决方案甚至可以在以前的 LTS 版本 Java 11 上使用。然而,在少数例外情况下,我使用现代和当前 Java 17 LTS 的语法扩展,因为它们使日常编程生活更加容易和愉快。

【Java 17 预览版的特殊特性由于 Java 发布间隔时间很短,只有六个月,一些特性会以预览版的形式呈现给开发者社区。如果要使用它们,必须在 ide 和构建工具中设置某些参数。这对于编译和执行都是必要的,大致如下例所示:

java --enable-preview -cp build/libs/Java17Examples.jar \
     java17.SwitchExamples

I 在我的书 Java 中提供了有关 Java 14 的更多详细信息–版本 9 到 14 中的新增功能:模块化、语法和 API 增强功能 [Ind20b]。

尝试使用 JShell、Eclipse,并作为 JUnit 测试在许多情况下,您可以将显示的源代码片段复制到 JShell 中并执行它们。或者,您可以在随书附带的 Eclipse 项目中找到所有相关的资料。程序可以通过main()方法启动,或者——如果有的话——通过相应的单元测试启动。

1.6 行动起来:探索 Java 挑战

因此,现在已经有了足够的序言,您可能已经对练习中的第一个挑战感到兴奋了。因此,我希望你会喜欢这本书,并在解决练习和实验算法的同时获得一些新的见解。

如果您需要复习 JUnit、JShell 或 O-notation,您可能想先看看附录。

二、数学问题

在这一章中,你将学习一些数学运算的基础知识,包括素数和罗马数字系统。此外,我还提出了一些数字游戏的想法。有了这些知识,你应该为大量的练习做好准备。

2.1 导言

2.1.1 除法和模运算简介

除了乘法和除法,模运算(%)也是非常常用的。它用于确定除法的余数。让我们用下面的例子来说明除法余数落在表下的整数:

(57+3)/7 = 38/7 = 5

(5’7+3)% 7 = 38% 7 = 3

即使只有这几个操作,也可以解决各种任务。请回忆以下关于(整数)数的操作:

  • n% 10—确定除以 10 的余数,从而确定最后一位数字。

  • n/10—显然除以值 10,因此允许截断最后一位数字。

提取数字要提取一个数的数字,只要余数大于 0,就要结合模和除:

void extractDigits(final int startValue)
{
    int remainingValue = startValue;
    while (remainingValue > 0)
    {
          final int digit = remainingValue % 10;
          System.out.print(digit + " ");

          remainingValue = remainingValue / 10;
    }
    System.out.println();
}

调用此方法一次以理解其工作方式—请注意数字是以相反的顺序输出的:

jshell> extractDigits(123)
3 2 1

确定位数除了提取单个数字,您还可以使用重复除法来确定十进制数的位数,只需除以 10,直到没有余数:

int countDigits(final int number)
{
    int count = 0;

    int remainingValue = number;
    while (remainingValue > 0)
    {
        remainingValue = remainingValue / 10;
        count++;
    }

    return count;
}

2.1.2 分频器简介

在下文中,您将研究如何确定一个数的所有实数约数(即,那些没有数本身的约数)。算法非常简单:遍历所有数值的一半(所有更高的数值都不能是整数的除数,因为 2 已经是除数了),并检查它们是否除以给定的数而没有余数。如果是这种情况,那么这个数就是一个除数,并包含在结果列表中。您将整个事情实现如下:

List<Integer> findProperDivisors(final int value)
{
    final List<Integer> divisors = new ArrayList<>();

    for (int i = 1; i <= value / 2; i++)
    {
        if (value % i == 0)
        {
            divisors.add(i);
        }
    }

    return divisors;
}

调用此方法一次以了解其操作,并根据符合预期的输出确认其工作正常:

jshell> findProperDivisors(6)
$2 ==> [1, 2, 3]

jshell> findProperDivisors(24)
$3 ==> [1, 2, 3, 4, 6, 8, 12]

jshell> findProperDivisors(7)
$4 ==> [1]

2.1.3 素数简介

质数是大于 1 的自然数,并且只能被自身和 1 整除。有两种非常容易理解的算法,用于检查一个给定的数是否是质数,或者用于计算达到给定最大值的质数。

素数的蛮力算法一个数是否是素数的判定方法如下:你从 2 开始最多到该数的一半寻找要检查的数,当前数是否是原数的除数。 1 那样的话,就不是质数了。否则,需要进一步检查。在 Java 中,可以这样写:

boolean isPrime(final int potentiallyPrime)
{
    // check for all relevant numbers if they represent a divisor
    for (int i = 2; i <= potentiallyPrime / 2; i++)
    {
        if (potentiallyPrime % i == 0)
            return false;
    }
    return true;
}

为了进行试验,在一个循环中运行该方法,并确定所有最大值为 25 的质数。程序输出表明功能运行正常。

jshell> var primes = new ArrayList<>()
primes ==> []

jshell> for (int i = 2; i < 25; i++)
   ...> {
   ...>      if (isPrime(i))
   ...>          primes.add(i);
   ...> }

jshell> System.out.println("Primes < 25: " + primes)
Primes < 25: [2, 3, 5, 7, 11, 13, 17, 19, 23]

优化:厄拉多塞的筛子另一种确定达到给定最大值的素数的算法叫做厄拉多塞 ?? 的筛子。它可以追溯到同名的希腊数学家。

整个过程如下:最初,从值 2 开始直到给定最大值的所有数字都被写下来,例如:

2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15

所有的数字最初都被认为是质数的潜在候选者。现在那些不可能是质数的数字被一步步淘汰。取最小的未标记数,在这种情况下,取数字 2,它对应于第一个质数。现在它的所有倍数都消除了(例 4 6 8 10 12 14):

img/519691_1_En_2_Figa_HTML.png

此外,你继续数 3,这是第二个质数。现在,再次消除倍数。分别是数字 6 9 12 15:

img/519691_1_En_2_Figb_HTML.png

下一个未标记的数,也就是质数是 5。只要在当前素数之后还有未标记的数,就重复该过程。

img/519691_1_En_2_Figc_HTML.png

对于所有小于 15 的质数,这会导致以下结果:

2, 3, 5, 7, 11, 13

使用以下值检查该算法:

|

限制

|

结果

|
| --- | --- |
| Fifteen | [2, 3, 5, 7, 11, 13] |
| Twenty-five | [2, 3, 5, 7, 11, 13, 17, 19, 23] |
| Fifty | [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47] |

Hint: Possible Optimizations

如你所见,数字经常被划掉几次。如果你在数学方面更有经验,你可以证明一个合数中至少有一个质因数必须总是小于等于该数本身的根。原因是如果 x 是大于sqrt(n)的除数,那么它认为p = n/x小于sqrt(n),因此这个值已经被尝试过了。因此,您可以优化多次波的消除。首先,你可以从质数的平方开始消去,因为所有较小的倍数都已经消去了。其次,计算只能进行到上限的根。

2.1.4 罗马数字

罗马数字系统使用特殊字母及其组合来表示数字。以下基本映射适用: 2

| **罗马数字** | 我 | V | X | L | C | D | M | | **值** | one | five | Ten | Fifty | One hundred | Five hundred | One thousand |

相应的值通常通过从左到右将各个数字的值相加来计算。通常情况下(参见以下规则),最大的数字在左边,最小的数字在右边,例如,值 16 为 XVI。

2.1.5 规则

罗马数字是根据一定的规则组成的:

  • 加法规则 : 相邻相同的数字相加,例如 XXX = 30。同样,这也适用于较大数字之后的较小数字,因此 XII = 12。

  • 重复规则 : 最多三个相同的数字可以连续出现。根据规则 1,你可以把数字 4 写成 IIII,这是规则 2 禁止的。这就是减法规则发挥作用的地方。

  • 减法法则 : 如果一个较小的数字符号出现在一个较大的数字符号前面,则减去相应的数值。让我们再来看看 4:这可以表示为减法 51。这在罗马数字系统中用 IV 表示。以下规则适用于减法:

    • I 只在 V 和 x 之前。

    • x 仅在 L 和 c 之前。

    • c 只在 D 和 m 之前。

2.1.6 示例

为了更好的理解和澄清上述规则,我们来看看罗马数字的一些记法及其对应的值:VII= 5+1+1 = 7MDC lxi= 1000+500+100+50+10+5+1 = 1666MMS XVIII= 1000+1000+10+5+1+1+1 = 2018mmsix= 1000

值得注意的

现代世界中常见的阿拉伯数字依赖于十进制。数字的位置决定了它们的值。因此,7 可以是数字本身,但也可以代表 70 或 700。然而,在罗马数字系统中,V 总是代表 5,不管位置如何。

由于罗马数字的特殊结构,许多数学运算都很复杂;甚至一个简单的加法也可能引起一个更大的,有时甚至是完全的数字变化。这对于数字 2018 和 2019 或者加法 III + II = V 变得非常明显,更糟糕的是:乘法或除法要复杂得多。有人推测这是罗马帝国崩溃的原因之一。

Note: Larger Numbers

表示较大的罗马数字(在一万及以上的范围内)有特殊的表示法,因为不允许四个或更多的 m 相互跟随。这与本书的任务无关。如果有兴趣,读者可以参考互联网或其他来源。

2.1.8 数字游戏

在这一部分,你会看到一些特殊的数字星座:

  • 完全数

  • 阿姆斯特朗数字

  • 总和检查(checksum 的复数形式)

在下面使用的许多算法中,您将数字细分成它们的数字,以便能够执行相应的数字游戏。

完美的数字

根据定义,如果一个数的值等于它的实数约数之和(即不包括它本身),那么这个数被称为一个完全数。这听起来可能有点奇怪,但是很简单。让我们以数字 6 为例。它拥有数字 1、2 和 3 作为实数的约数。有趣的是,它现在仍然成立

1 + 2 + 3 = 6

让我们来看看另一个对应的数字:数字 20,它有实数约数 1、2、4、5 和 10,但它们的和是 22 而不是 20:

1 + 2 + 4 + 5 + 10 = 22

阿姆斯特朗编号

接下来,你将研究所谓的阿姆斯特朗数。这些数字的单个数字首先按数字的位数取幂,然后相加。如果这个和对应于原始数字的值,它被称为阿姆斯特朗数。为了使事情简单一点,让我们看看三位数的特殊情况。要成为阿姆斯特朗数,该数必须满足以下等式:

$$ x\ast 100+y\ast 10+z={x}³+{y}³+{z}³ $$

数字的位数模型为 xyz ,范围均为 0-9。

让我们考虑这个公式满足的两个例子:

$$ {\displaystyle \begin{array}{l}153=1\ast 100+5\ast 10+3\kern0.5em =\kern0.5em {1}³+{5}³+{3}³=1+125+27=153\ {}371=3\ast 100+7\ast 10+1\kern0.5em =\kern0.5em {3}³+{7}³+{1}³=27+343+1=371\end{array}} $$

变化作为一种修改,满足以下等式的哪些数字也是非常有趣的:

$$ x\ast 100+y\ast 10+z={x}¹+{y}²+{z}³ $$

或者

$$ x\ast 100+y\ast 10+z={x}³+{y}²+{z}¹ $$

对于第一个方程,有以下解:

[135 175 518 598]

第二个方程, xyz 在最大到 100 的范围内无解。如果你愿意,你可以在执行任务 9 的奖励部分时验证这一点,或者看看解决方案。

简单校验和的算法

校验和被编码成不同的数字,因此很容易证明其有效性。例如,这适用于信用卡号码和通过特殊协议进行的数据传输。

让我们假设必须为一个四位数的数计算校验和(以下建模为 ad )。然后,您可以根据位置执行以下计算:

$$ abcd\Rightarrow \left(a\ast 1+b\ast 2+c\ast 3+d\ast 4\right)%10 $$

我将再次举例说明计算:

|

投入

|

位置计算

|

价值

|

校验和

|
| --- | --- | --- | --- |
| One thousand one hundred and eleven | 1 * 1 + 1 * 2 + 1 * 3 + 1 * 4 | 1 + 2 + 3 + 4 = 10 | 10 % 10 = 0 |
| One thousand two hundred and thirty-four | 1 * 1 + 2 * 2 + 3 * 3 + 4 * 4 | 1 + 4 + 9 + 16 = 30 | 30 % 10 = 0 |
| Four thousand three hundred and twenty-one | 4 * 1 + 3 * 2 + 2 * 3 + 1 * 4 | 4 + 6 + 6 + 4 = 20 | 20 % 10 = 0 |
| Seven thousand two hundred and seventy-one | 7 * 1 + 2 * 2 + 7 * 3 + 1 * 4 | 7 + 4 + 21 + 4 = 36 | 36 % 10 = 6 |
| 0815 | 0 * 1 + 8 * 2 + 1 * 3 + 5 * 4 | 0 + 16 + 3 + 20 = 39 | 39 % 10 = 9 |
| Five thousand one hundred and eighty | 5 * 1 + 1 * 2 + 8 * 3 + 0 * 4 | 5 + 2 + 24 + 0 = 31 | 31 % 10 = 1 |

2.2 练习

2.2.1 练习 1:基本算术(★✩✩✩✩)

练习 1a:基本算术运算(★✩✩✩✩)

编写方法int calc(int, int),将两个变量intmn 相乘,然后将乘积除以 2,并输出关于除以 7 的余数。

例子

|

m

|

n

|

男*女

|

m * n / 2

|

结果((n * m / 2) % 7)

|
| --- | --- | --- | --- | --- |
| six | seven | forty-two | Twenty-one | Zero |
| three | four | Twelve | six | six |
| five | five | Twenty-five | Twelve | five |

这里有一个简短的提醒:在整数除法中,余数被截断。因此,25 / 2 得出值 12。

练习 1b:统计(★★✩✩✩)

找出该数字以及自然数的和,这些自然数可以被 2 或 7 整除,直到给定的最大值(不包括最大值),并将其输出到控制台。写方法void calcSumAndCountAllNumbersDivBy_2_Or_7(int)。扩展它,使它返回这两个值,而不是执行控制台输出。

例子

|

最高的

|

能被 2 整除

|

能被 7 整除

|

结果

|
| --- | --- | --- | --- |
|

数数

|

总额

|
| --- | --- |
| three | Two | -/- | one | Two |
| eight | 2, 4, 6 | seven | four | Nineteen |
| Fifteen | 2, 4, 6, 8, 10, 12, 14 | 7, 14 | eight | Sixty-three |

练习 1c:偶数或奇数(★✩✩✩✩)

创建方法boolean isEven(n)boolean isOdd(n),分别检查传递的整数是偶数还是奇数。

2.2.2 练习 2:数字作为文本(★★✩✩✩)

编写方法String numberAsText(int),对于给定的正数,将相应的数字转换成相应的文本。

以下面的片段作为数字的最后一位开始:

static String numberAsText(final int n)
{
    final int remainder = n % 10;
    String valueAsText = "";

    if (remainder == 0)
        valueAsText = "ZERO";
    if (remainder == 1)
        valueAsText = "ONE";

    // ...

    return valueAsText;
}

例子

|

投入

|

结果

|
| --- | --- |
| seven | “七” |
| forty-two | “四二” |
| Twenty-four thousand six hundred and eighty | “二四六八零” |
| Thirteen thousand five hundred and seventy-nine | “一三五七九” |

2.2.3 练习 3:完全数(★★✩✩✩)

根据定义,如果一个自然数的值等于它的实数约数之和,那么这个自然数就叫做完全数。例如,对于数字 6 和 28 来说是这样的:

1 + 2 + 3 = 6

1 + 2 + 4 + 7 + 14 = 28

编写方法List<Integer> calcPerfectNumbers(int),计算最大值的完全数,比如 10,000。

例子

|

投入

|

结果

|
| --- | --- |
| One thousand | [6, 28, 496] |
| ten thousand | [6, 28, 496, 8128] |

2.2.4 练习 4:素数(★★✩✩✩)

编写方法List<Integer> calcPrimesUpTo(int)来计算所有素数直到给定值。提醒一下,质数是大于 1 的自然数,并且只能被自身和 1 整除。为了计算一个质数,之前描述过所谓的厄拉多塞筛。

例子

使用以下值检查您的算法:

|

投入

|

结果

|
| --- | --- |
| Fifteen | [2, 3, 5, 7, 11, 13] |
| Twenty-five | [2, 3, 5, 7, 11, 13, 17, 19, 23] |
| Fifty | [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47] |

2.2.5 练习 5:素数对(★★✩✩✩)

计算距离为 2(孪生)、4(近亲)和 6(性感)的所有素数对,直到 n 的上界。对于双胞胎来说,以下是正确的:

$$ isPrime(n)&amp;&amp; isPrime\left(n+2\right) $$

例子

对于限值 50,预计会出现以下结果:

|

类型

|

结果

|
| --- | --- |
| 双胞胎之一 | 3=5, 5=7, 11=13, 17=19, 29=31, 41=43 |
| 远亲 | 3=7, 7=11, 13=17, 19=23, 37=41, 43=47 |
| 性感的 | 5=11, 7=13, 11=17, 13=19, 17=23, 23=29, 31=37, 37=43, 41=47, 47=53 |

2.2.6 练习 6:校验和(★★✩✩✩)

创建方法int calcChecksum(String),该方法对以字符串形式给出的任意长度的数字的校验和执行以下基于位置的计算,其中 n 位数建模为z1 到 z n :

$$ {z}_1{z}_2{z}_3\dots {z}_n\Rightarrow \left(1\ast {z}_1+2\ast {z}_2+3\ast {z}_3+\dots +n\ast {z}_n\right)%10 $$

例子

|

投入

|

总额

|

结果

|
| --- | --- | --- |
| “11111” | 1 + 2 + 3 + 4 + 5 = 15 | 15 % 10 = 5 |
| “87654321” | 8 + 14 + 18 + 20 + 20 + 18 + 14 + 8 = 120 | 120 % 10 = 0 |

2.2.7 练习 7:罗马数字(★★★★✩)

练习 7a:罗马数字→十进制数字(★★★✩✩)

编写方法int fromRomanNumber(String),从文本有效的罗马数字计算相应的十进制数。3

练习 7b:十进制数字→罗马数字(★★★★✩)

编写将十进制数字转换成(有效的)罗马数字的方法String toRomanNumber(int)

例子

|

阿拉伯语

|

古罗马的

|
| --- | --- |
| Seventeen | “十七” |
| Four hundred and forty-four | " CDXLIV " |
| One thousand nine hundred and seventy-one | " MCMLXXI " |
| Two thousand and twenty | " MMXX " |

2.2.8 练习 8:组合学(★★✩✩✩)

练习 8a:计算a2+b2=c2

计算值 abc 的所有组合(每个组合从 1 开始且小于 100),以下公式成立:

$$ {a}²+{b}²={c}² $$

加成( ★★★✩✩ ) 减少o(n3)到o(n2)的运行时间。如果需要的话,参考附录 C 中关于 O 符号的介绍。

练习 8b:计算a2+b2=c2+d2

计算值 abcd 的所有组合(每个组合从 1 开始且小于 100),以下公式成立:

$$ {a}²+{b}²={c}²+{d}² $$

加成( ★★★✩✩ ) 减少o(n4)到o(n3)的运行时间。

2.2.9 练习 9: Armstrong 数字(★★✩✩✩)

这个练习处理三位数的阿姆斯特朗数。根据定义,这些数字的数字 xyz 从 1 到 9 满足以下等式:

$$ x\ast 100+y\ast 10+z={x}³+{y}³+{z}³ $$

编写方法List<Integer> calcArmstrongNumbers()计算出 xy、 z 的所有阿姆斯特朗数(每个< 10)。

例子

$$ {\displaystyle \begin{array}{l}153=1\ast 100+5\ast 10+3\kern0.5em =\kern0.5em {1}³+{5}³+{3}³=1+125+27=153\ {}371=3\ast 100+7\ast 10+1\kern0.5em =\kern0.5em {3}³+{7}³+{1}³=27+343+1=371\end{array}} $$

奖金找到一个带有 lambdas 的通用版本,然后尝试以下三个公式:

$$ {\displaystyle \begin{array}{l}x\ast 100+y\ast 10+z={x}³+{y}³+{z}³\ \ {}x\ast 100+y\ast 10+z={x}¹+{y}²+{z}³\ \ {}x\ast 100+y\ast 10+z={x}³+{y}²+{z}¹\end{array}} $$

2.2.10 练习 10:最大变化计算器(★★★★✩)

假设你收集了不同价值的硬币。编写方法calcMaxPossibleChange(values),确定对于正整数,从值 1 开始,用它可以无缝地产生多少数量。结果应该是返回最大值。

*#### 例子

|

投入

|

可能的值

|

最高的

|
| --- | --- | --- |
| one | one | one |
| 1, 1 | 1, 2 | Two |
| 1, 5 | one | one |
| 1, 2, 4 | 1, 2, 3, 4, 5, 6, 7 | seven |
| 1, 2, 3, 7 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 | Thirteen |
| 1, 1, 1, 1, 5, 10, 20, 50 | 1, 2, 3, 4, 5, 6, ... , 30, ... , 39 | Thirty-nine |

2.2.11 练习 11:相关数字(★★✩✩✩)

如果两个数n1 和n2 的约数之和等于另一个数,则称这两个数为朋友:

sum(约数(n1)=n2

sum(约数(n2)=n1

编写方法Map<Integer, Integer> calcFriends(int)来计算所有朋友的数量,直到达到一个传递的最大值。

例子

|

投入

|

除数

|
| --- | --- |
| ∑( 约数 (220)) = 284 | div(220) = 1,2,4,5,10,11,20,22,44,55,110 |
| ∑( 约数 (284)) = 220 | div(284) = 1,2,4,71,142 |
| ∑( 约数 (1184)) = 1210 | div(1184) = 1,2,4,8,16,32,37,74,148,296,592 |
| ∑( 约数 (1210)) = 1184 | div(1210) = 1,2,5,10,11,22,55,110,121,242,605 |

2.2.12 练习 12:质因数分解(★★★✩✩)

任何大于 1 的自然数都可以表示为素数的乘积——记住 2 也是素数的事实。编写方法List<Integer> calcPrimeFactors(int),返回一个素数列表,这些素数的乘法运算产生所需的数。

例子

|

投入

|

主要因素

|

结果

|
| --- | --- | --- |
| eight | 2 * 2 * 2 | [2, 2, 2] |
| Fourteen | 2 * 7 | [2, 7] |
| forty-two | 2 * 3 * 7 | [2, 3, 7] |
| One thousand one hundred and fifty-five | 3 * 5 * 7 * 11 | [3, 5, 7, 11] |
| Two thousand two hundred and twenty-two | 2 * 11 * 101 | [2, 11, 101] |

2.3 解决方案

2.3.1 解决方案 1:基本算术(★✩✩✩✩)

解决方案 1a:基本算术运算(★✩✩✩✩)

编写方法int calc(int, int),将两个变量intmn 相乘,然后将乘积除以 2,并输出关于除以 7 的余数。

例子

|

m

|

n

|

男*女

|

m * n / 2

|

结果((n * m / 2) % 7)

|
| --- | --- | --- | --- | --- |
| six | seven | forty-two | Twenty-one | Zero |
| three | four | Twelve | six | six |
| five | five | Twenty-five | Twelve | five |

这里有一个简短的提醒:在整数除法中,余数被截断。因此,25 / 2 得出值 12。

算法该实现直接遵循数学运算:

public int calc(final int m, final int n)
{
     return (m * n / 2) % 7;
}

解决方案 1b:统计(★★✩✩✩)

找出该数字以及自然数的和,这些自然数可以被 2 或 7 整除,直到给定的最大值(不包括最大值),并将其输出到控制台。写方法void calcSumAndCountAllNumbersDivBy_2_Or_7(int)。扩展它,使它返回这两个值,而不是执行控制台输出。

例子

|

最高的

|

能被 2 整除

|

能被 7 整除

|

结果

|
| --- | --- | --- | --- |
|

数数

|

总额

|
| --- | --- |
| three | Two | -/- | one | Two |
| eight | 2, 4, 6 | seven | four | Nineteen |
| Fifteen | 2, 4, 6, 8, 10, 12, 14 | 7, 14 | eight | Sixty-three |

算法实现比以前稍微复杂一点。它使用两个变量计数和总和以及一个循环。模用于检查是否给出了整除性。为了更好的可读性,定义了一个所谓的解释变量divisibleBy2or7。在这里,o 故意写成小写,以区别于数字 0:

void calcSumAndCountAllNumbersDivBy_2_Or_7(final int max)
{
    int count = 0;
    int sum = 0;

    for (int i = 1; i < max; i++)
    {
        final boolean divisibleBy2or7 = i % 2 == 0 || i % 7 == 0;
        if (divisibleBy2or7)
        {
            count++;
            sum += i;
        }
    }

    System.out.println("count: " + count);
    System.out.println("sum: " + sum);
}

返回这两个值的更棘手的部分仍然存在。不幸的是,Java 不允许使用元组作为返回,但既然 Java 14 使用记录 4 是一种轻量级的替代,那么在 Java 13 之前(包括 Java 13)你能做些什么呢?

需要一些创造力。除了定义一个类Pair,你可以在这里返回一个带有两个固定键的 map。这是使方法可以用单元测试来测试的重要一步。

enum ReturnCode { SUM, COUNT }

Map<ReturnCode, Integer> calcSumAndCountAllNumbersDivBy_2_Or_7(final int max)
{
    int count = 0;
    int sum = 0;

    for (int i = 1; i < max; i++)
    {
        final boolean divisibleBy2or7 = i % 2 == 0 || i % 7 == 0;
        if (divisibleBy2or7)
        {
            count++;
            sum += i;
        }
    }

    return Map.of(ReturnCode.SUM, sum, ReturnCode.COUNT, count);
}

解决方案 1c:偶数或奇数(★✩✩✩✩)

创建方法boolean isEven(n)boolean isOdd(n),分别检查传递的整数是偶数还是奇数。

算法该实现在每种情况下都使用模运算符。一个数即使被 2 整除也没有余数;否则就奇怪了:

boolean isEven(final int n)
{
    return n % 2 == 0;
}

boolean isOdd(final int n)
{
    return n % 2 != 0;
}

Hint: Special Aspects Modulo

虽然boolean isEven()的实现非常简单,没有任何陷阱,但在检查奇数时有一个特殊的方面需要考虑。你可能认为你可以只检查剩余的 1。从数学上来说,这是正确的,但在 Java 中,模运算符也考虑符号,因此下面的方法对负数不正确:

static boolean isOdd_WRONG(final int n)
{
    return n % 2 == 1; // wrong implementation
}

确认

为了测试练习 1a 的解决方案,您使用了一个参数化测试,并指定了 mn 的输入值和结果,您使用了一个带@CsvSource的逗号分隔枚举。为了更新您对 JUnit 5 的了解,我建议您看一看附录 b。

@ParameterizedTest
@CsvSource({ "6, 7, 0", "3, 4, 6", "5, 5, 5" })
void calc(int m, int n, int expected)
{
    int result = Ex01_Basiscs.calc(m, n);

    assertEquals(expected, result);
}

回顾练习的第 1b 部分,JShell 中的调用是合适的:

jshell> calcSumAndCountAllNumbersDivBy_2_Or_7(8)
count: 4
sum: 19

所以,你首先掌握了组合返回值这个坎。您仍然需要能够适当地测试这个地图。为此,结合使用 JUnit 5 的@MethodSourceStream<Arguments>提供了如下好处:

@ParameterizedTest(name = "sum and count for {0} = {1}")
@MethodSource("argumentProvider")
void calcSumAndCountAllNumbersDivBy_2_Or_7(int max, Map<Ex01_Basiscs.ReturnCode, Integer> expected)
{
   var result = Ex01_Basiscs.calcSumAndCountAllNumbersDivBy_2_Or_7(max);

   assertEquals(expected, result);
}

private static Stream<Arguments> argumentProvider()
{
   return Stream.of(Arguments.of(3, Map.of(Ex01_Basiscs.ReturnCode.SUM, 2,
                                           Ex01_Basiscs.ReturnCode.COUNT, 1)),
                    Arguments.of(8, Map.of(Ex01_Basiscs.ReturnCode.SUM, 19,
                                           Ex01_Basiscs.ReturnCode.COUNT, 4)),
                    Arguments.of(15, Map.of(Ex01_Basiscs.ReturnCode.SUM, 63,
                                            Ex01_Basiscs.ReturnCode.COUNT, 8)));
}

测试练习 1c 中的偶数或奇数是如此简单,以至于我们在这里将它限制为 JShell 中的两个示例性调用:

jshell> isEven(2)
$7 ==> true

jshell> isOdd(7)
$8 ==> true

2.3.2 解决方案 2:数字作为文本(★★✩✩✩)

编写方法String numberAsText(int),对于给定的正数,将相应的数字转换成相应的文本。

例子

|

投入

|

结果

|
| --- | --- |
| seven | “七” |
| forty-two | “四二” |
| Twenty-four thousand six hundred and eighty | “二四六八零” |
| Thirteen thousand five hundred and seventy-nine | “一三五七九” |

算法总是计算余数(即最后一位数),打印出来,然后除以十。重复此操作,直到不再有余数。请注意,数字的表示必须附加到文本的前面,因为最后一个数字总是被提取。否则,数字会以错误的顺序出现。

static String numberAsText(final int n)
{
    String value = "";
    int remainingValue = n;
    while (remainingValue > 0)
    {
        String remainderAsText = digitAsText(remainingValue % 10);
        value = remainderAsText + " " + value;
        remainingValue /= 10;
    }
    return value.trim();
}

使用查找映射实现从数字到文本的映射,如下所示:

static Map<Integer, String> valueToTextMap =
       Map.of(0, "ZERO", 1, "ONE", 2, "TWO", 3, "THREE", 4, "FOUR",
              5, "FIVE", 6, "SIX", 7, "SEVEN", 8, "EIGHT", 9, "NINE");

static String digitAsText(final int n)
{
    return valueToTextMap.get(n % 10);
}

Hint: Notes on String Concatenations

请记住,在字符串上使用+的连接可能不太有效。然而,这只对大量呼叫有影响。这里应该不重要,加号通常更容易阅读。

确认

对于测试,使用一个参数化的测试,它可以用 JUnit 5:

@ParameterizedTest
@CsvSource({"7, SEVEN", "42, FOUR TWO", "7271, SEVEN TWO SEVEN ONE",
            "24680, TWO FOUR SIX EIGHT ZERO",
            "13579, ONE THREE FIVE SEVEN NINE"})
void numberAsText(int number, String expected)
{
    String result = Ex02_NumberAsText.numberAsText(number);

    assertEquals(expected, result);
}

2.3.3 解决方案 3:完全数(★★✩✩✩)

根据定义,如果一个自然数的值等于它的实数约数之和,那么这个自然数就叫做完全数。例如,对于数字 6 和 28 来说是这样的:

1 + 2 + 3 = 6

1 + 2 + 4 + 7 + 14 = 28

编写方法List<Integer> calcPerfectNumbers(int),计算最大值的完全数,比如 10,000。

例子

|

投入

|

结果

|
| --- | --- |
| One thousand | [6, 28, 496] |
| ten thousand | [6, 28, 496, 8128] |

算法最简单的变体是检查从 2 到所需最大值一半的所有数字,看它们是否代表原始数字的除数。在这种情况下,除数之和正好增加该值。总和从值 1 开始,因为这总是有效的除数。最后,你只需要将确定的总和与实际数字进行比较。

static boolean isPerfectNumberSimple(final int number)
{
    // always divisible by 1
    int sumOfMultipliers = 1;

    for (int i = 2; i <= number / 2; i++)
    {
        if (number % i == 0)
            sumOfMultipliers += i;
    }

    return sumOfMultipliers == number;
}

基于此,实际的方法很容易实现:

static List<Integer> calcPerfectNumbers(final int maxExclusive)
{
    final List<Integer> results = new ArrayList<>();

    for (int i = 2; i < maxExclusive; i++)
    {
        if (isPerfectNumberSimple(i))
            results.add(i);
    }

    return results;
}

确认

对于测试,您使用以下输入,这些输入显示了专用号码的正确操作:

@ParameterizedTest(name = "{0} should be perfect")
@ValueSource(ints = { 6, 28, 496, 8128 })
void isPerfectNumberSimple(int value)
{
    boolean result = Ex03_PerfectNumbers.isPerfectNumberSimple(value);

    assertTrue(result);
}

现在,您已经测试了考试的基本构件。但是,您仍然应该确保除了完全数之外没有提供其他值,事实上,只有这些值—用于测试。因此,前四个完全数就是数字 6、28、496 和 8128。

@ParameterizedTest(name = "calcPerfectNumbers({0}) = {1}")
@MethodSource("maxAndPerfectNumbers")
void calcPerfectNumbers(int maxExclusive, List<Integer> expected)
{
    List<Integer> result = Ex03_PerfectNumbers.calcPerfectNumbers(maxExclusive);

    assertEquals(expected, result);
}

private static Stream<Arguments> maxAndPerfectNumbers()
{
    return Stream.of(Arguments.of(1000, List.of(6, 28, 496)),
                     Arguments.of(10000, List.of(6, 28, 496, 8128)));
}

实现优化

基于本章介绍部分已经介绍的查找所有真约数的findProperDivisors(int)方法,您可以将检查简化如下:

static boolean isPerfectNumberBasedOnProperDivisors(final int number)
{
    final List<Integer> divisors = findProperDivisors(number);

    return sum(divisors) == number;
}

您仍然需要一个 helper 方法来汇总列表中的元素。解决这个问题最简单的方法是使用 Stream API,如下所示:

static int sum(final List<Integer> values)
{
    return values.stream().mapToInt(n -> n).sum();
}

2.3.4 解决方案 4:质数(★★✩✩✩)

编写方法List<Integer> calcPrimesUpTo(int)来计算所有素数直到给定值。提醒一下,质数是大于 1 的自然数,并且只能被自身和 1 整除。为了计算一个质数,之前描述过所谓的厄拉多塞筛。

例子

使用以下值检查您的算法:

|

投入

|

结果

|
| --- | --- |
| Fifteen | [2, 3, 5, 7, 11, 13] |
| Twenty-five | [2, 3, 5, 7, 11, 13, 17, 19, 23] |
| Fifty | [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47] |

算法该算法遵循厄拉多塞的筛子。首先创建一个由boolean组成的数组并用true初始化,因为所有的数字都被认为是潜在的质数。在心理上,这类似于最初写下数字 2 3 4、...达到给定的最大值,如

2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15

现在,从值 2 开始,筛选开始。因为数字 2 没有被划掉,所以它包含在素数列表中。后来,它的每个倍数都被划掉了,因为它们不可能是质数:

img/519691_1_En_2_Figd_HTML.png

迭代地寻找下一个未消去的数。在这种情况下,它是第二个质数 3。同样,该数字的所有倍数都被消除:

img/519691_1_En_2_Fige_HTML.png

重复该过程,直到达到最大值的一半。这个质数计算在 Java 中实现如下:

static List<Integer> calcPrimesUpTo(final int maxValue)
{
    // initially mark all values as potential prime number
    final boolean[] isPotentiallyPrime = new boolean[maxValue+1];
    Arrays.fill(isPotentiallyPrime, true);

    // run through all numbers starting at 2, optimization only up to half
    for (int i = 2; i <= maxValue / 2; i++)
    {
       if (isPotentiallyPrime[i])
           eraseMultiplesOfCurrent(isPotentiallyPrime, i);
    }

    return buildPrimesList(isPotentiallyPrime);
}

划掉或擦除倍数的功能被提取到下面的帮助器方法eraseMultiplesOfCurrent()。作为一个技巧,你一方面使用 i 的步长,另一方面通过增加起始值来确定第一个倍数。对于初次尝试,带注释的控制台输出可能会有所帮助:

private static void eraseMultiplesOfCurrent(final boolean[] values, final int i)
{
    for (int n = i + i; n < values.length; n = n + i)
    {
        values[n] = false;
        // System.out.println("Eliminating " + n);
    }
}

最后,您需要从boolean[]中重建一个数字列表,如下所示:

private static List<Integer> buildPrimesList(final boolean[] isPotentiallyPrime)
{
    final List<Integer> primes = new ArrayList<>();
    for (int i = 2; i < isPotentiallyPrime.length; i++)
    {
        if (isPotentiallyPrime[i])
            primes.add(i);
    }
    return primes;
}

确认

对于测试,您使用以下显示正确操作的输入。为了提供结果列表,您依赖于Stream<Arguments>:

@ParameterizedTest(name = "calcPrimes({0}) = {1}")
@MethodSource("argumentProvider")
void calcPrimesUpTo(int n, List<Integer> expected)
{
    List<Integer> result = Ex04_PrimeNumber.calcPrimesUpTo(n);

    assertEquals(expected, result);
}

static Stream<Arguments> argumentProvider()
{
    return Stream.of(Arguments.of(2, List.of(2)),
                     Arguments.of(3, List.of(2, 3)),
                     Arguments.of(10, List.of(2, 3, 5, 7)),
                     Arguments.of(15, List.of(2, 3, 5, 7, 11, 13)),
                     Arguments.of(25, List.of(2, 3, 5, 7, 11, 13,
                                              17, 19, 23)),
                     Arguments.of(50, List.of(2, 3, 5, 7, 11, 13,
                                              17, 19, 23, 29, 31,
                                              37, 41, 43, 47)));
}

2.3.5 解决方案 5:素数对(★★✩✩✩)

计算距离为 2(孪生)、4(近亲)和 6(性感)的所有素数对,直到 n 的上界。对于双胞胎来说,以下是正确的:

is prime(n)&&isPrime(n+2

*#### 例子

对于限值 50,预计会出现以下结果:

|

类型

|

结果

|
| --- | --- |
| 双胞胎之一 | 3=5, 5=7, 11=13, 17=19, 29=31, 41=43 |
| 远亲 | 3=7, 7=11, 13=17, 19=23, 37=41, 43=47 |
| 性感的 | 5=11, 7=13, 11=17, 13=19, 17=23, 23=29, 31=37, 37=43, 41=47, 47=53 |

算法作为第一步,你需要定义配对的条件。这可以通过if语句显式地完成,或者更优雅地通过合适谓词的定义来完成。对于从 2 开始一直到所需上限的所有数字,您必须检查该数字本身以及相应的另一个数字加上 2、4 或 6 是否是质数。为此,您可以调用一个方法isPrime(int),该方法反过来使用之前编写的方法来确定质数。有关孪生素数的更多详细信息,请参见。 https://en.wikipedia.org/wiki/Twin_prime

public static void main(final String[] args)
{
    final Predicate<Integer> isTwinPair = n -> isPrime(n) && isPrime(n + 2);
    final Predicate<Integer> isCousinPair = n -> isPrime(n) && isPrime(n + 4);
    final Predicate<Integer> isSexyPair = n -> isPrime(n) && isPrime(n + 6);

    final Map<Integer, Integer> twinPairs = new TreeMap<>();
    final Map<Integer, Integer> cousinPairs = new TreeMap<>();
    final Map<Integer, Integer> sexyPairs = new TreeMap<>();

    for (int i = 1; i < 50; i++)
    {
        if (isTwinPair.test(i))
            twinPairs.put(i, i+2);

        if (isCousinPair.test(i))
            cousinPairs.put(i, i+4);

        if (isSexyPair.test(i))
            sexyPairs.put(i, i+6);
    }

    System.out.println("Twins: " + twinPairs);
    System.out.println("Cousins: " + cousinPairs);
    System.out.println("Sexy: " + sexyPairs);
}

private static boolean isPrime(int n)
{
    // non-optimal call
    return Ex04_PrimeNumber.calcPrimesUpTo(n).contains(n);
}

这里显示的实现使用了已经实现的功能,这在原则上是更可取的,但在这种情况下有两个缺点:

  1. 每次所有素数都被重新计算,直到给定的最大值。这可以通过只执行一次计算并适当地缓存结果来优化。

  2. 目前,这些支票仍然交织在一起。使用只检查一个条件并只返回一个结果的验证函数更清楚。

实现的优化

漏洞 1:重复调用首先,你应该只计算一次素数的最大值。在这种情况下,您需要将限制提高 6 倍,以便能够正确映射所有对:

public static void calcPrimePairs(final int maxValue)
{
    final List<Integer> primes = Ex04_PrimeNumber.calcPrimesUpTo(maxValue + 6);

    final Predicate<Integer> isTwinPair = n -> isPrime(primes, n) &&
                                               isPrime(primes, n + 2);
    final Predicate<Integer> isCousinPair = n -> isPrime(primes, n) &&
                                                 isPrime(primes, n + 4);
    final Predicate<Integer> isSexyPair = n -> isPrime(primes, n) &&
                                               isPrime(primes, n + 6);

    final Map<Integer, Integer> twinPairs = new TreeMap<>();
    final Map<Integer, Integer> cousinPairs = new TreeMap<>();
    final Map<Integer, Integer> sexyPairs = new TreeMap<>();

    for (int i = 1; i < maxValue; i++)
    {
        if (isTwinPair.test(i))
            twinPairs.put(i, i + 2);

        if (isCousinPair.test(i))
            cousinPairs.put(i, i + 4);

        if (isSexyPair.test(i))
            sexyPairs.put(i, i + 6);
    }

    System.out.println("Twins: " + twinPairs);
    System.out.println("Cousins: " + cousinPairs);
    System.out.println("Sexy: " + sexyPairs);
}

在该方法开始时,计算素数被执行一次。因此,您获得了显著的性能改进。

最后,将质数检查转移到以下方法:

static boolean isPrime(final List<Integer> primes, final int n)
{
    return primes.contains(n);
}

漏洞 2:不清晰的程序结构你的目标是编写更多通用的方法。您已经创建了基本的构建块。但是,对线对的确定应移到方法calcPairs()中。这样,你就可以更清楚、更容易理解地把它写成如下:

public static void calcPrimePairsImproved(final int maxValue)
{
    final Map<Integer, Integer> twinPairs = calcPairs(maxValue, 2);
    final Map<Integer, Integer> cousinsPairs = calcPairs(maxValue, 4);
    final Map<Integer, Integer> sexyPairs = calcPairs(maxValue, 6);

    System.out.println("Twins: " + twinPairs);
    System.out.println("Cousins: " + cousinsPairs);
    System.out.println("Sexy: " + sexyPairs);
}

static Map<Integer, Integer> calcPairs(final int maxValue, final int distance)
{
    final List<Integer> primes =
                        Ex04_PrimeNumber.calcPrimesUpTo(maxValue + distance);

    final Map<Integer, Integer> resultPairs = new TreeMap<>();
    for (int n = 1; n < maxValue; n++)
    {
        if (isPrime(primes, n) && isPrime(primes, n + distance))
        {
            resultPairs.put(n, n + distance);
        }
    }
    return resultPairs;
}

这种转换也为能够用单元测试来测试整个事情打下了基础。

确认

如果调用最大值为 50 的方法,将得到以下结果:

Twins: {3=5, 5=7, 11=13, 17=19, 29=31, 41=43}
Cousins: {3=7, 7=11, 13=17, 19=23, 37=41, 43=47}
Sexy: {5=11, 7=13, 11=17, 13=19, 17=23, 23=29, 31=37, 37=43, 41=47, 47=53}

现在,让我们创建另一个单元测试,每个特例使用一个测试方法:

int maxValue = 50;

@ParameterizedTest(name = "primepairs({0}, {1}) = {2}")
@MethodSource("distanceAndExpectd")
void calcPairs(int distance, String info,
               Map<Integer, Integer> expected)
{
    var result = Ex05_PrimePairs_Improved.calcPairs(maxValue, distance);

    assertEquals(expected, result);
}

private static Stream<Arguments> distanceAndExpectd()
{
    return Stream.of(Arguments.of(2, "twin",
                                  Map.of(3, 5, 5, 7, 11, 13, 17,
                                         19, 29, 31, 41, 43)),
                     Arguments.of(4, "cousin",
                                  Map.of(3, 7, 7, 11, 13, 17,
                                         19, 23, 37, 41, 43, 47)),
                     Arguments.of(6, "sexy",
                                  Map.of(5, 11, 7, 13, 11, 17, 13,
                                         19, 17, 23, 23, 29, 31, 37,
                                         37, 43, 41, 47, 47, 53)));
}

2.3.6 解决方案 6:校验和(★★✩✩✩)

创建方法int calcChecksum(String),该方法对以字符串形式给出的任意长度的数字的校验和执行以下基于位置的计算,其中 n 位被建模为z1 到zn:

$$ {z}_1{z}_2{z}_3\dots {z}_n\Rightarrow \left(1\ast {z}_1+2\ast {z}_2+3\ast {z}_3+\dots +n\ast {z}_n\right)%10 $$

例子

|

投入

|

总额

|

结果

|
| --- | --- | --- |
| “11111” | 1 + 2 + 3 + 4 + 5 = 15 | 15 % 10 = 5 |
| “87654321” | 8 + 14 + 18 + 20 + 20 + 18 + 14 + 8 = 120 | 120 % 10 = 0 |

算法从前面到最后一个位置遍历所有数字,提取给定位置的数字,将其数值乘以当前位置。把这个加到总数上。最后,模运算将总和映射为一个数字:

static int calcChecksum(final String input)
{
    int crc = 0;
    for (int i = 0; i < input.length(); i++)
    {
        final char currentChar = input.charAt(i);

        if (Character.isDigit(currentChar))
        {
            final int pos = i + 1;
            final int value = (currentChar - '0') * pos;

            crc += value;
        }
        else
            throw new IllegalArgumentException("illegal char: " + currentChar);
    }
    return crc % 10;
}

验证测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name="checksum({0}) = {1}")
@CsvSource({ "11111, 5", "22222, 0", "111111, 1",
"12345678, 4", "87654321, 0" })
void testCalcChecksum(String input, int expected)
{
    final int result = Ex10_CheckSumCalculator.calcChecksum(input);

    assertEquals(expected, result);
}

2.3.7 解决方案 7:罗马数字(★★★★✩)

解答 7a:罗马数字→十进制数字(★★★✩✩)

编写方法int fromRomanNumber(String),从文本有效的罗马数字计算相应的十进制数。 5

例子

|

阿拉伯语

|

古罗马的

|
| --- | --- |
| Seventeen | “十七” |
| Four hundred and forty-four | " CDXLIV " |
| One thousand nine hundred and seventy-one | " MCMLXXI " |
| Two thousand and twenty | " MMXX " |

算法您必须特别注意第 2.1.1 节中描述的加法规则:每当一个较大的字符位于一个较小的字符之前时,通常通过从左到右将各个数字的值相加来获得相关值。但是,如果一个较小的数字字符在一个较大的数字字符之前,则相应的值会被减去。

有了这些知识,您可以从右到左遍历字符,并在查找映射中查找相关的值。为了决定是加还是减,你要记住最后一个相关的字符。

static int fromRomanNumber(final String romanNumber)
{
    int value = 0;
    int lastDigitValue = 0;

    for (int i = romanNumber.length() - 1; i >= 0; i−)
    {
        final char romanDigit = romanNumber.charAt(i);
        final int digitValue = valueMap.getOrDefault(romanDigit, 0);

        final boolean addMode = digitValue >= lastDigitValue;
        if (addMode)
        {
            value += digitValue;
            lastDigitValue = digitValue;
        }
        else
            value −= digitValue;
    }
    return value;
}
static Map<Character, Integer> valueMap =
                               Map.of('I', 1, 'V', 5, 'X', 10, 'L', 50,
                                      'C', 100, 'D', 500, 'M', 1000);

解决方案 7b:十进制数字→罗马数字(★★★★✩)

编写将十进制数字转换成(有效的)罗马数字的方法String toRomanNumber(int)

算法当将十进制数字转换为罗马数字时,您再次使用映射。您按降序对其进行排序,使最大值(即 1000)位于开头。当前数值除以该因子。这就产生了这个值需要重复的次数。现在余数由模决定。重复该过程,直到检查完所有值,并且余数大于 0。在下文中,针对数字 7 示例性地示出了该过程:

7 => 7 / 1000 => 0 => 0 x 'M'
         ...
         7 / 5 = 1 => 1 x 'V'
         7 % 5 = 2
         2 / 1 = 2 => 2 x 'I'
         2 % 1 = 0
    => 'VII'

该过程在 Java 中实现如下:

static String toRomanNumber(final int value)
{
    String result = "";
    int remainder = value;

    // descending order
    final Comparator<Integer> reversed = Comparator.reverseOrder();
    final Map<Integer, String> sortedIntToRomanDigit = new TreeMap<>(reversed);
    sortedIntToRomanDigit.putAll(intToRomanDigitMap);

    // start with largest value
    var it = sortedIntToRomanDigit.entrySet().iterator();
    while (it.hasNext() && remainder > 0)
    {
       final Map.Entry<Integer, String> entry = it.next();

       final int multiplier = entry.getKey();
       final char romanDigit = entry.getValue();

       final int times = remainder / multiplier;
       remainder = remainder % multiplier;

       result += repeatCharSequence(romanDigit, times);
    }
    return result;
}
static Map<Integer, String> intToRomanDigitMap =
                            Map.of(1, "I", 5, "V", 10, "X", 50, "L",
                                   100, "C", 500, "D", 1000, "M");

然而,这种转换还不是 100%正确的,因为它不遵守三的规则。相反,它将数字重复四次。

要解决这个问题,您可以实现特殊的处理方法,这些方法仅在下面有所提示:

final int multiplier = entry.getKey();
final char romanDigit = entry.getValue();

if (remainder >= 900 && romanDigit == 'D')
{
    result += "CM";
    remainder -= 900;
}
// ...
else if (remainder >= 4 && romanDigit == 'I')
{
    result += "IV";
    remainder -= 4;
}
else
{
    final int times = remainder / multiplier;
    remainder = remainder % multiplier;
    result += repeatCharSequence(romanDigit, times);
}

这很快变得令人困惑。为特殊情况插入进一步的查找值更加优雅。然而,您不再调用of()方法,因为它们最多只定义了 10 个参数对。

static Map<Integer, String> intToRomanDigitMap = new TreeMap<>()
{{
     put(1, "I");
     put(4, "IV");
     put(5, "V");
     put(9, "IX");
     put(10, "X");
     put(40, "XL");
     put(50, "L");
     put(90, "XC");
     put(100, "C");
     put(400, "CD");
     put(500, "D");
     put(900, "CM");
     put(1000, "M");
}};

Hint: Repeating Character Sequences

要重复字符序列,可以使用 Java 11 以后的String.repeat()方法。对于较旧的 Java 版本,这个助手方法是合适的:

static String repeatCharSequence(final String value, final int times)
{
    final StringBuilder result = new StringBuilder();
    for (int i = 0; i < times; i++) result.append(value);

    return result.toString();
}

确认

让我们从显示正确转换的不同值开始单元测试,特别是包括示例中的四个值 17、444、1971 和 2020:

@ParameterizedTest(name = "fromRomanNumber(''{1}'') => {0}")
@CsvSource({ "1, I", "2, II", "3, III", "4, IV", "5, V", "7, VII", "9, IX",
             "17, XVII", "40, XL", "90, XC", "400, CD", "444, CDXLIV", "500, D",
             "900, CM", "1000, M", "1666, MDCLXVI", "1971, MCMLXXI",
             "2018, MMXVIII", "2019, MMXIX", "2020, MMXX", "3000, MMM"})
void fromRomanNumber(int arabicNumber, String romanNumber)
{
    int result = Ex06_RomanNumbers.fromRomanNumber(romanNumber);

    assertEquals(arabicNumber, result);
}

现在让我们来看看反向测试是如何完成的:

@ParameterizedTest(name = "toRomanNumber(''{0}'') => {1}")
@CsvSource({ "1, I", "2, II", "3, III", "4, IV", "5, V", "7, VII", "9, IX",
             "17, XVII", "40, XL", "90, XC", "400, CD", "444, CDXLIV", "500, D",
             "900, CM", "1000, M", "1666, MDCLXVI", "1971, MCMLXXI",
             "2018, MMXVIII", "2019, MMXIX", "2020, MMXX", "3000, MMM"})
void toRomanNumber(int arabicNumber, String romanNumber)
{
    String result = Ex06_RomanNumbers.toRomanNumber(arabicNumber);

    assertEquals(romanNumber, result);
}

这里您会遇到@CsvSource中的规范的重复,因为它被认为是双向映射。为了避免重复,您还可以从文件中导入值:

@ParameterizedTest(name = "toRomanNumber(''{0}'') => {1}")
@CsvFileSource(resources = "arabicroman.csv", numLinesToSkip = 1)
void toRomanNumber(int arabicNumber, String romanNumber)
{
    String result = Ex06_RomanNumbers.toRomanNumber(arabicNumber);

    assertEquals(romanNumber, result);
}

CSV 文件将如下所示:

arabic,roman 1, I
2, II
3, III
4, IV
5, V
7, VII
9, IX
17, XVII
40, XL
90, XC
...

2.3.8 解答 8:组合学(★★✩✩✩)

解 8a:计算a2+b2=c2

计算值 abc 的所有组合(每个组合从 1 开始且小于 100),以下公式成立:

$$ {a}²+{b}²={c}² $$

算法蛮力解法使用三个嵌套循环,然后检查是否满足上述公式。对于平方,简单乘法比使用注释中暗示的Math.pow()提供了更好的可读性:

// brute force, three nested loops
for (int a = 1; a < 100; a++)
{
    for (int b = 1; b < 100; b++)
    {
        for (int c = 1; c < 100; c++)
        {
            if (a * a + b * b == c * c)
            // if ((Math.pow(a, 2)) + Math.pow(b, 2) == Math.pow(c, 2))
            {
                System.out.println("a = " + a + " / b = " + b + " / c = " + c);
            }
        }
    }
}

奖励:减少 ( n 3 )的o(n2)(★★★✩✩)的运行时间

你看到上解中有三个嵌套的循环,导致运行时间为 O ( n 3 )。现在我们把这个简化为O(n2)。为此,应用以下转换(解析为 c ):

$$ c=\sqrt{a\ast a+b\ast b} $$

基于这个方程到 c 的变换或求解,计算平方根,然后验证公式。此外,你必须确保 c 低于 100。

public void solveQuadratic()
{
    for (int a = 1; a < 100; a++)
    {
        for (int b = 1; b < 100; b++)
        {
            final int c = (int) Math.sqrt(a * a + b * b);
            if (c < 100 && a * a + b * b == c * c)
            {
                System.out.println("a = " + a + " / b = " + b + " / c = " + c);
            }
        }
    }
}

确认

对于测试,您调用solveQuadratic()方法并对一些值执行计算:

jshell> solveQuadratic()
a = 3 / b = 4 / c = 5
a = 4 / b = 3 / c = 5
a = 5 / b = 12 / c = 13
a = 6 / b = 8 / c = 10
a = 7 / b = 24 / c = 25
a = 8 / b = 6 / c = 10
a = 8 / b = 15 / c = 17
a = 9 / b = 12 / c = 15
a = 9 / b = 40 / c = 41
a = 10 / b = 24 / c = 26
a = 11 / b = 60 / c = 61
a = 12 / b = 5 / c = 13
a = 12 / b = 9 / c = 15
a = 12 / b = 16 / c = 20

a = 12 / b = 35 / c = 37
...

Note: Why Does the Computation Work at all?

简单地看一下转换,人们可能会奇怪为什么计算不能对所有值进行成功的比较。事实上,这完全是数学上的情况,因为你是从 ab 推导出 c 。但是,您也可以使用强制转换来转换到一个int:

final int c = (int) Math.sqrt(a * a + b * b);
if (c < 100 && a * a + b * b == c * c)
{
    System.out.println("a = " + a + " / b = " + b + " / c = " + c);
}

因此,十进制数字被截断。这反过来导致比较只对某些值成功。

解 8b:计算a2+b2=c2+d2

计算值 abcd 的所有组合(每个组合从 1 开始且小于 100),以下公式成立:

$$ {a}²+{b}²={c}²+{d}² $$

算法类比上一部分的练习,蛮力解法是使用四个嵌套循环,然后检查是否满足上述公式。同样,简单的乘法比使用Math.pow()更具可读性:

public void solveCubicEquationSimple()
{
    // brute force, four nested loops
    for (int a = 1; a < 100; a++)
    {
         for (int b = 1; b < 100; b++)
         {
             for (int c = 1; c < 100; c++)
             {
                 for (int d = 1; d < 100; d++)
                 {
                      if (a * a + b * b == c * c + d * d)
                      {
                          System.out.println("a = " + a + " / b = " + b +
                                             " / c = " + c + " / d = " + d);
                      }
                 }
             }
         }
    }
}

奖励:减少(n4)的o(n3)(★★★✩✩)的运行时间

显而易见,该解决方案使用了四个嵌套循环,运行时间为 O ( n 4 )。现在你要把这个减少到O(n3)。为此,您需要使用转换。首先,你分离到 d ,然后你分解到 d :

$$ d\ast d=a\ast a+b\ast b-c\ast c\Rightarrow d=\kern0.5em \sqrt{a\ast a+b\ast b-c\ast c} $$

基于这个等式到 d 的变换或解析,您可以计算平方根,然后验证公式。此外,您必须确保该值不为负,并且得到的 d 小于 100。

private static void solveCubicEquation()
{
    for (int a = 1; a < 100; a++)
    {
        for (int b = 1; b < 100; b++)
        {
            for (int c = 1; c < 100; c++)
            {
                final int value = a * a + b * b - c * c;
                if (value > 0)
                {
                    final int d = (int) Math.sqrt(value);
                    if (d < 100 && a * a + b * b == c * c + d * d)
                    {
                        System.out.println("a = " + a + " / b = " + b +
                                           " / c = " + c + " / d = " + d);
                    }
                }
            }
        }
    }
}

确认

对于测试,您使用方法调用并检查一些值:

jshell> solveCubicEquation()
a = 1 / b = 1 / c = 1 / d = 1
a = 1 / b = 2 / c = 1 / d = 2
a = 1 / b = 2 / c = 2 / d = 1
a = 1 / b = 3 / c = 1 / d = 3
a = 1 / b = 3 / c = 3 / d = 1
...

2.3.9 解决方案 9: Armstrong 数字(★★✩✩✩)

这个练习处理三位数的阿姆斯特朗数。根据定义,这些数字的数字 xyz 从 1 到 9 满足以下等式:

$$ x\ast 100+y\ast 10+z={x}³+{y}³+{z}³ $$

编写方法List<Integer> calcArmstrongNumbers()计算出 xy、 z 的所有阿姆斯特朗数(每个< 10)。

例子

$$ {\displaystyle \begin{array}{l}153=1\ast 100+5\ast 10+3\kern0.5em =\kern0.5em {1}³+{5}³+{3}³=1+125+27=153\ {}371=3\ast 100+7\ast 10+1\kern0.5em =\kern0.5em {3}³+{7}³+{1}³=27+343+1=371\end{array}} $$

算法使用三个嵌套循环遍历三位数的所有组合。使用公式x∫100+y∫10+z根据位置计算数值。此外,计算每个数字的三次幂,将它们相加,并检查总和是否与数字匹配。

static List<Integer> calcArmstrongNumbers()
{
    final List<Integer> results = new ArrayList<>();

    for (int x = 1; x < 10; x++)
    {
        for (int y = 1; y < 10; y++)
        {
            for (int z = 1; z < 10; z++)
            {
                final int numericValue = x * 100 + y * 10 + z;
                final int cubicValue = (int) (Math.pow(x, 3) + Math.pow(y, 3) +
                                              Math.pow(z, 3));

                if (numericValue == cubicValue)
                    results.add(numericValue);
            }
        }
    }
    return results;
}

Note: Why don’t the Loops Start at 0?

虽然您也可以使用值 0,但这对于第一个位置来说并不常见,并且经常用于表示八进制数,这也是您在这里不使用它的原因。

确认

要进行测试,您需要调用上述方法,并检查作为示例给出的两个值组合是否包含在结果列表中:

@Test
public void calcArmstrongNumbers()
{
     final List<Integer> result = Ex09_SpecialNumbers.calcArmstrongNumbers();

     assertEquals(List.of(153, 371), result);
}

奖金(澳门币)

找到带有 lambdas 的通用版本,然后尝试以下三个公式:

$$ {\displaystyle \begin{array}{l}x\ast 100+y\ast 10+z={x}³+{y}³+{z}³\ \ {}x\ast 100+y\ast 10+z={x}¹+{y}²+{z}³\ \ {}x\ast 100+y\ast 10+z={x}³+{y}²+{z}¹\end{array}} $$

算法代替具体的计算,你通过一个函数接口CubicFunction添加一个调用进行计算:

static List<Integer> calcNumbers(final CubicFunction func)
{
    final List<Integer> results = new ArrayList<>();

    for (int x = 1; x < 10; x++)
    {
        for (int y = 1; y < 10; y++)
        {
            for (int z = 1; z < 10; z++)
            {
                final int numericValue = x * 100 + y * 10 + z;
                final int cubicValue = func.calc(x, y, z);

                if (numericValue == cubicValue)
                    results.add(numericValue);
            }
        }
    }
    return results;
}

现在定义一个合适的函数接口来抽象计算:

@FunctionalInterface
interface CubicFunction
{
    int calc(int x, int y, int z);
}

因此,计算可以表示为λ:

CubicFunction special = (x, y, z) -> (int) (Math.pow(x, 3) +
                                            Math.pow(y, 3) +
                                            Math.pow(z, 3));

基于这个更通用的解决方案,您现在可以毫不费力地尝试计算规则的其他变体:

CubicFunction special2 = (x, y, z) -> (int) (Math.pow(x, 1) +
                                             Math.pow(y, 2) +
                                             Math.pow(z, 3));

同样,您最终定义了以下内容:

CubicFunction special3 = (x, y, z) -> (int) (Math.pow(x, 3) +
                                             Math.pow(y, 2) +
                                             Math.pow(z, 1));

确认

为了进行测试,您使用不同的计算规则调用上述方法,并查找 Armstrong 数的计算规则,看作为示例给出的两个值组合是否包含在结果列表中:

jshell> CubicFunction special = (x, y, z) -> (int) (Math.pow(x, 3) +
   ...>                                             Math.pow(y, 3) +
   ...>                                             Math.pow(z, 3));
   ...> List<Integer> calcSpecialNumbers = calcNumbers(special); calcSpecialNumbers ==> [153, 371]

jshell> CubicFunction special2 = (x, y, z) -> (int) (Math.pow(x, 1) +
   ...>                                              Math.pow(y, 2) +
   ...>                                              Math.pow(z, 3));
   ...> List<Integer> specialNumbers2 = calcNumbers(special2); specialNumbers2 ==> [135, 175, 518, 598]

jshell> CubicFunction special3 = (x, y, z) -> (int) (Math.pow(x, 3) +
   ...>                                              Math.pow(y, 2) +
   ...>                                              Math.pow(z, 1));
jshell> List<Integer> specialNumbers3 = calcNumbers(special3) specialNumbers3 ==> []

2.3.10 解决方案 10:最大变化计算器(★★★★✩)

假设你收集了不同价值的硬币。编写方法calcMaxPossibleChange(values),确定对于正整数,从值 1 开始,用它可以无缝地产生多少数量。结果应该是返回最大值。

*#### 例子

|

投入

|

可能的值

|

最高的

|
| --- | --- | --- |
| one | one | one |
| 1, 1 | 1, 2 | Two |
| 1, 5 | one | one |
| 1, 2, 4 | 1, 2, 3, 4, 5, 6, 7 | seven |
| 1, 2, 3, 7 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 | Thirteen |
| 1, 1, 1, 1, 5, 10, 20, 50 | 1, 2, 3, 4, 5, 6, ... , 30, ... , 39 | Thirty-nine |

算法你可以尝试通过计算所有数字和排列的映射来解决这个问题,但这很快就会变得复杂。让我们考虑另一种方法。

|

投入

|

可能的值

|

最高的

|
| --- | --- | --- |
| 1, 2, 3, 7 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 | Thirteen |
| 1, 2, 3, 8 | 1, 2, 3, 4, 5, 6, => _ <= , 8, 9, 10, 11, 12, 13, 14 | six |

如果你看一看这两个例子,你可能会对情况 1 2 3 7 和 1 2 3 8 认识到果断简化计算的线索。不是总是计算所有的排列,然后检查数字行中的间隙,这里用下划线(_)表示,而是可以从第一个数字开始,总是将这些数字加到前面的总和上,并重复这个过程,直到 nextNumber > sum + 1 变为真。

让我们将此应用于 Java。首先,对输入值进行排序。首先假设最初没有什么需要改变的,所以 maxPossibleChange = 0。现在检查每个值的以下条件。如果当前值> maxPossibleChange + 1 成立,那么就不可能改变。否则,将当前值加到 maxPossibleChange 中。重复此操作,直到处理完所有值或满足终止条件。这导致了以下实现:

static int calcMaxPossibleChange(final List<Integer> values)
{
    // wrapping necessary if input values are generated by List.of()
    final List<Integer> sortedNumbers = new ArrayList<>(values);
    sortedNumbers.sort(Integer::compareTo);

    int maxPossibleChange = 0;

    for (int currentValue : sortedNumbers)
    {
        if (currentValue > maxPossibleChange + 1)
            break;

        maxPossibleChange += currentValue;
    }

    return maxPossibleChange;
}

确认

对于测试,您使用以下输入,这些输入显示了正确的操作:

@ParameterizedTest(name = "calcMaxPossibleChange({0}) = {1}")
@MethodSource("inputsAndMaxChange")
void calcMaxPossibleChange(List<Integer> values, int expected)
{
     int result = Ex11_MaxChangeCalculator.calcMaxPossibleChange(values);

     assertEquals(expected, result);
}

private static Stream<Arguments> inputsAndMaxChange()
{
     return Stream.of(Arguments.of(List.of(1), 1),
                      Arguments.of(List.of(1,1), 2),
                      Arguments.of(List.of(1, 5), 1),
                      Arguments.of(List.of(1, 2, 4), 7),
                      Arguments.of(List.of(1, 2, 3, 7), 13),
                      Arguments.of(List.of(1, 2, 3, 8), 6),
                      Arguments.of(List.of(1, 1, 1, 1, 5, 10, 20, 50), 39));
}

2.3.11 解决方案 11:相关数字(★★✩✩✩)

如果两个数n1 和n2 的约数之和等于另一个数,则称这两个数为朋友:

sum(约数(n1)=n2

sum(约数(n2)=n1

编写方法Map<Integer, Integer> calcFriends(int)来计算所有朋友的数量,直到达到一个传递的最大值。

例子

|

投入

|

除数

|
| --- | --- |
| ∑( 约数 (220)) = 284 | div(220) = 1,2,4,5,10,11,20,22,44,55,110 |
| ∑( 约数 (284)) = 220 | div(284) = 1,2,4,71,142 |
| ∑( 约数 (1184)) = 1210 | div(1184) = 1,2,4,8,16,32,37,74,148,296,592 |
| ∑( 约数 (1210)) = 1184 | div(1210) = 1,2,5,10,11,22,55,110,121,242,605 |

算法通过确定每个数字的约数和总和,很容易检查两个数字是否是朋友。现在约数可以由这个和确定,然后加在一起。如果第二个和等于原来的数,那么这两个数就是朋友。

static Map<Integer, Integer> calcFriends(final int max)
{
    final Map<Integer, Integer> friends = new TreeMap<>();

    for (int number= 2; number < max; number++)
    {
        final List<Integer> divisors1 = findProperDivisors(number);
        final int sumDiv1 = sum(divisors1);

        final List<Integer> divisors2 = findProperDivisors(sumDiv1);
        final int sumDiv2 = sum(divisors2);

        if (number == sumDiv2 && sumDiv1 != sumDiv2)
        {
            friends.put(number, sumDiv1);
        }
    }

    return friends;
}

static int sum(final List<Integer> values)
{
    return values.stream().mapToInt(n -> n).sum();
}

结果按TreeMap<K,V>排序。为了实现,您还可以调用简介中已经介绍过的findProperDivisors()方法来查找所有的真除数。同样,您使用 Stream API 进行求和,而不是对所有元素进行循环的自定义实现。你再一次看到了将软件分解成更小的独立功能的好处。

确认

对于测试,您使用以下输入,这些输入显示了正确的操作。在这种情况下,再次使用Stream<Arguments>,提供最大值和两个数字的映射:

@ParameterizedTest(name = "calcFriends({0}) = {1}")
@MethodSource("upperBoundAndExpectedFriends")
void calcFriends(int maxValue, Map<Integer, Integer> expected)
{
    Map<Integer, Integer> result = Ex12_BefreundeteZahlen.calcFriends(maxValue);

    assertEquals(expected, result);
}

private static Stream<Arguments> upperBoundAndExpectedFriends()
{
    return Stream.of(Arguments.of(250, Map.of(220, 284)),
                     Arguments.of(300, Map.of(220, 284,
                                              284, 220)),
                     Arguments.of(2_000, Map.of(220, 284,
                                                284, 220,
                                                1184, 1210,
                                                1210,1184)));
}

2.3.12 解决方案 12:质因数分解(★★★✩✩)

任何大于 1 的自然数都可以表示为素数的乘积。记住 2 也是质数的事实。编写方法List<Integer> calcPrimeFactors(int),返回一个素数列表,这些素数的乘法运算产生所需的数。

例子

|

投入

|

主要因素

|

结果

|
| --- | --- | --- |
| eight | 2 * 2 * 2 | [2, 2, 2] |
| Fourteen | 2 * 7 | [2, 7] |
| forty-two | 2 * 3 * 7 | [2, 3, 7] |
| One thousand one hundred and fifty-five | 3 * 5 * 7 * 11 | [3, 5, 7, 11] |
| Two thousand two hundred and twenty-two | 2 * 11 * 101 | [2, 11, 101] |

算法只要数字是偶数且大于 2,就从数字除以 2 开始。然后,在某个时候,你会得到一个奇数。如果这是 1,就完成了(参见数字 8 的情况)。否则,你检查奇数是否是质数,并收集它。在这种情况下,您就完成了(例如,上面对于数字 14 的描述)。如果没有,就要进一步拆分奇数。我们以 50 为例。首先,你除以 2,所以剩下 25,这不是一个质数。为此,您要检查所有的质数是否代表除数。你继续这个过程,直到你达到数字 1,这意味着所有的除数都已收集。更多信息,请参见 https://en.wikipedia.org/wiki/Integer_factorization

static List<Integer> calcPrimeFactors(final int n)
{
    final List<Integer> allPrimes = Ex04_PrimeNumber.calcPrimesUpTo(n);
    final List<Integer> primeFactors = new ArrayList<>();

    int remainingValue = n;

    // as long as even, divide by 2 again and again
    while (remainingValue % 2 == 0 && remainingValue >= 2)
    {
        remainingValue = remainingValue / 2;
        primeFactors.add(2);
    }

    // check remainder for prime
    if (isPrime(allPrimes, remainingValue))
    {
        primeFactors.add(remainingValue);
    }
    else
    {
        // remainder is not a prime number, further check
        while (remainingValue > 1)
        {
            for (final Integer currentPrime : allPrimes)
            {
                if (remainingValue % currentPrime == 0)
                {
                    remainingValue = remainingValue / currentPrime;
                    primeFactors.add(currentPrime);
                    // start again from the beginning, because every divisor
                    // may occur more than once
                    break;
                }
            }
        }
    }

    return primeFactors;
}

static boolean isPrime(final List<Integer> allPrimes, final int n)
{
    return allPrimes.contains(n);
}

优化算法如果你看看刚刚开发的算法,你可能会被所有的特殊处理所困扰。稍加思考,你可能会得出结论,你不需要单独检查数字 2,因为它也是一个质数。因此,这被while循环所覆盖。代替重复检查相同数字的break,可以用一个while循环以一种更具风格的方式来表达。有了这些初步的考虑,您会得到以下实现:

static List<Integer> calcPrimeFactorsOptimized(final int n)
{
    final List<Integer> allPrimes = calcPrimesUpTo(n);
    final List<Integer> primeFactors = new ArrayList<>();

    int remainingValue = n;
    while (remainingValue > 1)
    {
        for (final Integer currentPrime : allPrimes)
        {
            while (remainingValue % currentPrime == 0)
            {
                remainingValue = remainingValue / currentPrime;
                primeFactors.add(currentPrime);
             }
         }
    }
    return primeFactors;
}

确认

对于测试,您使用以下输入,这些输入显示了正确的操作:

@ParameterizedTest(name = "calcPrimeFactors({0}) = {1}")
@MethodSource("valueAndPrimeFactors")
void calcPrimeFactors(int value, List<Integer> expected)
{
    var result = Ex13_PrimeFactors.calcPrimeFactors(value);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "calcPrimeFactorsOptimized({0}) = {1}")
@MethodSource("valueAndPrimeFactors")
void calcPrimeFactorsOpt(final int value, final List<Integer> expected)
{
    var result = Ex13_PrimeFactors.calcPrimeFactorsOptimized(value);

    assertEquals(expected, result);
}

private static Stream<Arguments> valueAndPrimeFactors()
{
    return Stream.of(Arguments.of(8, List.of(2, 2, 2)),
                     Arguments.of(14, List.of(2, 7)),
                     Arguments.of(42, List.of(2, 3, 7)),
                     Arguments.of(1155, List.of(3, 5, 7, 11)),
                     Arguments.of(2222, List.of(2, 11, 101)));
}

***

三、递归

在自然和数学中,你可以找到题目 自相似 或者循环结构,比如雪花或者分形和 Julia 集,这些都是有趣的图形构成。在这种情况下,人们说 递归 ,意思是事物重复或彼此相似。与方法相关,这意味着它们调用自己。因此,重要的是特殊输入值形式的终止条件,它导致自调用的结束。

3.1 导言

各种计算可以很好地描述为递归函数。目标是将一个更复杂的任务分解成几个更简单的子任务。

数学示例

下面你将看到阶乘、求和以及斐波那契数的计算,这是递归定义的三个介绍性例子。

示例 1:阶乘

数学上,正数 n阶乘 定义为从 1 到 n 的所有自然数的乘积(即乘法运算),包括 1 和 ??。对于表示法,感叹号放在相应数字的后面。比如 5!代表数字 5 的阶乘:

五个!= 5’4’3’2’1 = 120

这可以概括如下:

n!=n**n-【1】n-【2】-我...。∑2′1

基于此,推导出递归定义:

$$ n!=\left{{}_{n.\left(n-1\right)!,\kern0.37em \forall n>1}^{1,\kern3.359999em n=0,n=1}\right. $$

这里,倒 A (∀)表示所有的

对于第一个 n ,您会得到以下值级数:

| **n** | one | Two | three | four | five | six | seven | eight | | **n!** | one | Two | six | Twenty-four | One hundred and twenty | Seven hundred and twenty | Five thousand and forty | Forty thousand three hundred and twenty |

Java 中阶乘的计算让我们快速看一下阶乘的递归计算公式是如何转移到同类方法中的:

public static int factorial(final int n)
{
    if (n < 0)
        throw new IllegalArgumentException("n must be >= 0");

    // recursive termination
    if (n == 0 || n == 1)
        return 1;

    // recursive descent
    return n * factorial(n – 1);
}

让我们弄清楚这个递归定义在调用方面产生了什么。见图 3-1 。

img/519691_1_En_3_Fig1_HTML.jpg

图 3-1

对 factorial(5)的递归调用

示例 2:直到 n 的数的和的计算

数学上,数字 n被定义为从 1 上升到并包括 n 的所有自然数的和:

$$ \sum \limits_1^ni=n+n-1+n-2+\dots +2+1 $$

这可以递归定义如下:

$$ \sum \limits_1ni=\left{{}_{n+{\sum}_1{n-1};i,\forall n>1}^{1,\kern3.239999em n=1}\right. $$

对于第一个 n ,您得到以下值级数:

| **n** | one | Two | three | four | five | six | seven | eight | | **sum(n)** | one | three | six | Ten | Fifteen | Twenty-one | Twenty-eight | Thirty-six |

Java 中求和的计算再次,你把求和的递归计算公式转换成递归方法:

public static int sum(final int n)
{
    if (n <= 0)
        throw new IllegalArgumentException("n must be >= 1");

    // recursive termination
    if (n == 1)
        return 1;

    // recursive descent
    return n + sum(n – 1);
}

Attention: Limited Call Depth

请记住,对于求和来说,自呼总是会发生的。因此,只能传递 10.000–20.000 左右的值。较大的值将导致StackOverflowError。对于其他递归方法,类似的限制也适用于自调用的数量。

示例 3:斐波那契数列

斐波纳契数 也非常适合递归定义,尽管公式已经稍微复杂了一点:

$$ fib(n)=\left{\begin{array}{c}1,\kern6.119996em n=1\ {}1,\kern5.999996em n=2\ {} fib\left(n-1\right)+ fib\left(n-2\right),\forall n>2\end{array}\right. $$

对于第一个 n ,您会得到以下值级数:

| **n** | one | Two | three | four | five | six | seven | eight | | **纤维(n)** | one | one | Two | three | five | eight | Thirteen | Twenty-one |

如果计算公式以图形方式可视化,那么自我调用树的潜在跨度会很快变得很明显。对于一个更大的 n ,调用树会更大,如图 3-2 中的虚线箭头所示。

img/519691_1_En_3_Fig2_HTML.jpg

图 3-2

斐波那契递归

即使有了这个示例性的调用,很明显各种调用被进行了几次,例如对于fib(n-4)和fib(n-2),但是特别是对于fib(n-3)的三次调用。这很快会导致昂贵而繁琐的计算。您将在 8.1 节中了解如何对此进行优化。

Hint: Different Definition with zero as Start Value

还应该注意,存在从值 0 开始的变化。然后 fib (0) = 0 和 fib (1) = 1 为基值,之后根据递归定义得到fib(n)=fib(n1)+fib(n2)。这产生了与上述定义相同的数字序列,只是增加了 0 的值。

算法示例

在介绍中,你看了数学例子。但是递归也非常适合算法任务。例如,可以检查数组中存储的值是否形成了回文。回文是一个前后读起来都一样的词,比如 OTTO 或者 ABBA。这意味着元素从正面和背面成对匹配。这适用于例如具有以下值的int[]:{ 1, 2, 3, 2, 1 }

示例 1:回文——递归变量

你可以很容易地递归测试回文属性。在我简单描述了算法之后,你会把它看作一个程序。

算法如果数组的长度为 0 或 1,那么根据定义它是一个回文。如果长度等于或大于 2,则必须检查外部左元素和外部右元素是否匹配。之后,创建数组的副本,在前面缩短一个位置,在后面缩短一个位置。然后对数组的剩余部分执行进一步的检查,如下所示:

static boolean isPalindromeSimpleRecursive(final int[] values)
{
    // recursive termination
    if (values.length <= 1)
        return true;

    int left = 0;
    int right = values.length – 1;

    if (values[left] == values[right])
    {
        // attention: copyOfRange() is exclusive end
        final int[] remainder = Arrays.copyOfRange(values, left + 1, right);

        // recursive descent
        return isPalindromeSimpleRecursive(remainder);
    }

    return false;
}

然而,所描述和实现的方法导致子阵列的许多复制和提取。通过保留这个想法,但通过使用一个技巧最小程度地修改算法,可以避免这种努力。

优化算法不使用副本,仍然使用原数组。您包括两个位置标记,leftright,它们最初跨越整个数组。现在检查这些位置引用的左右值是否匹配。如果是这种情况,位置标记在两侧向内移动一个位置,并递归调用整个过程。如此重复,直到左位置指针到达或跳过右位置指针。

实现更改如下:

static boolean isPalindromeRecursive(final int[] values)
{
    return isPalindromeRecursive(values, 0, values.length – 1);
}

static boolean isPalindromeRecursive(final int[] values,
                                     final int left, final int right)
{
    // recursive termination
    if (left >= right)
        return true;

    if (values[left] == values[right])
    {
        // recursive descent
        return isPalindromeRecursive(values, left + 1, right – 1);
    }

    return false;
}

也许你想知道为什么我不把过程写得更简洁,甚至使用更少的语句。我在介绍算法时主要关心的是可理解性。如果方法非常长并且令人困惑,那么多个return确实是个问题。

Hint: Auxiliary Methods for Facilitating Recursion

数组或字符串中位置指针的概念是递归解决方案中常用的工具,用于优化和避免数组复制。为了防止整个事情变得对调用者不方便,让一个高级方法调用一个具有附加参数的 helper 方法是一个好主意。这允许您在递归下降中包含某些信息。在本例中,这些是左右限制,因此可以消除潜在的高成本复制。许多随后的例子将利用这个一般的想法。

例 1:回文——迭代变体

尽管算法的递归定义有时非常优雅,但递归下降会产生自调用。这可能会产生相当大的开销。方便的是,任何递归算法都可以转化为迭代算法。让我们看看这个回文计算。您使用两个位置指针进行迭代转换,而不是递归下降,您使用一个while循环。当检查完所有元素或之前检测到不匹配时,此操作终止。

private static boolean isPalindromeIter(int[] values)
{
    int left = 0;
    int right = values.length – 1;
    boolean sameValue = true;

    while (left < right && sameValue)
    {
        sameValue = values[left] == values[right];

        left++;        right––;
    }

    return sameValue;
}

同样,关于紧凑性的一个注意事项:这个方法可以写成如下形式,省略辅助变量:

static boolean isPalindromeIterativeCompact(final int[] values)
{
    int left = 0;
    int right = values.length – 1;

    while (left < right && values[left] == values[right])
    {
        left++;        right––;
    }
    // left >= right || values[left] != values[right]
    return left >= right;
}

返回值由注释隐含的条件决定,如果 > = 成立,那么values不是回文。然而,对于这种变体,您必须更多地考虑回报——同样,比起简洁或性能,我更喜欢可理解性和可维护性。

例 2:以分形为例

正如开始提到的,递归也允许你创建图形。下面显示了一个简单的图形变体,它基于标尺的细分:

-
==
-
===
-
==
-

事实上,借助 Java 11 板载方法(repeat(n)),这可以很容易地递归实现如下,其中递归下降出现两次:

static void fractalGenerator(final int n)
{
    if (n < 1)
        return;

    if (n == 1)
        System.out.println("–");
    else
    {
        fractalGenerator(n − 1);
        System.out.println("=".repeat(n));
        fractalGenerator(n − 1);
    }
}

如果 Java 11 和方法repeat()不可用,您只需编写辅助方法repeatCharSequence()(参见第 2.3.7 节)。

当使用更复杂的绘图函数而不是 ASCII 字符时,递归提供了创建有趣和吸引人的形状的方法,如图 3-3 中嵌入在 Swing 应用程序中的雪花。

img/519691_1_En_3_Fig3_HTML.jpg

图 3-3

带 drawSnowflake()的递归图形

雪花的这种风格化表示可以按如下方式实现:

public static void drawSnowflake(final Graphics graphics,
                                 final int startX, final int startY,
                                 final int length, final int depth)
{
    for (int degree = 0; degree < 360; degree += 60)
    {
        final double rad = degree * Math.PI / 180;
        final int endX = (int) (startX + Math.cos(rad) * length);
        final int endY = (int) (startY + Math.sin(rad) * length);

        graphics.drawLine(startX, startY, endX, endY);

        // recursive descent
        if (depth > 0)
        {
            drawSnowflake(graphics, endX, endY, length / 4, depth – 1);
        }
    }
}

3.1.3 数字相乘的步骤

作为算法示例的总结,我想再次澄清一下各个步骤和自我调用。作为一个人工示例,让我们使用一个数字的数字乘法,也称为叉积,例如值 257 2∫5∫7 = 10∫7 = 70。使用模运算,单个数字的提取及其乘法可以非常简单地实现如下:

static int multiplyAllDigits(final int value)
{
    final int remainder = value / 10;
    final int digitValue = value % 10;

    System.out.printf("multiplyAllDigits: %–10d | remainder: %d, digit: %d%n", value, remainder, digitValue);

    if (remainder > 0)
    {
        final int result = multiplyAllDigits(remainder);

        System.out.printf("-> %d * %d = %d%n",
                          digitValue, result, digitValue * result);
        return digitValue * result;
    }
    else
    {
        System.out.println("-> " + value);
        return value;
    }
}

让我们看看 1234 和 257 这两个数字的输出:

jshell> multiplyAllDigits(1234)
multiplyAllDigits: 1234    | remainder: 123, digit: 4
multiplyAllDigits: 123     | remainder: 12, digit: 3
multiplyAllDigits: 12      | remainder: 1, digit: 2
multiplyAllDigits: 1       | remainder: 0, digit: 1
-> 1
-> 2 * 1 = 2
-> 3 * 2 = 6
-> 4 * 6 = 24
$2 ==> 24

jshell> multiplyAllDigits(257)
multiplyAllDigits: 257     | remainder: 25, digit: 7
multiplyAllDigits: 25      | remainder: 2, digit: 5
multiplyAllDigits: 2       | remainder: 0, digit: 2
-> 2
-> 5 * 2 = 10
-> 7 * 10 = 70
$3 ==> 70

可以清楚地看到递归调用是如何在一个连续变短的数字序列中发生的。最后,根据另一个方向的最后一位数字来构造或计算结果。

典型问题

递归通常允许你以一种可理解的方式来制定和实现问题的解决方案。然而,有两件事要记住,我将在下面讨论。

无尽的呼叫和堆栈错误

值得了解的一个细节是,自调用导致它们被临时存储在堆栈上。对于每个方法调用,包含关于被调用方法及其参数的信息的所谓堆栈帧被存储在堆栈上。然而,堆栈的大小是有限的。因此,只能进行有限次数的嵌套方法调用——然而,通常远远超过 10,000 次。这已经在实用技巧中简要讨论过了。

大量的递归调用会导致一个StackOverflowError。有时,出现问题是因为递归中没有终止条件,或者条件的表述不正确:

// attention: deliberately wrong for demonstration
static void infiniteRecursion(final String value)
{
    infiniteRecursion(value);
}

static int factorialNoAbortion(final int number)
{
    return number * factorialNoAbortion(number – 1);
}

有时调用也是错误的,仅仅是因为没有传递减少的值:

// deliberately wrong for demonstration
static int factorialWrongCall(final int n)
{
    if (n == 0)
        return 1;
    if (n == 1)
        return 1;

    return n * factorialWrongCall(n);
}

你可能仍然能很好地识别出直接的无休止的自我呼叫。但是随着行数的增加,这变得更加困难。有了一些递归方面的经验和实践,即使是方法factorialNoAbortion()中缺失的终止条件也是很容易识别的。但是,在方法factorialWrongCall()中,这并不容易确定。在这里,你必须更准确地知道是什么意图。

你应该从例子中去掉两件事:

  1. 终止条件:递归方法必须至少包含一个终止条件。但是,即使定义正确,也有可能没有检查不允许的负值范围。对于factorial(int),具有负值的调用将导致StackOverflowError

  2. 复杂度降低:递归方法必须总是将原问题细分成一个或多个更小的子问题。有时,这已经通过将参数值减少 1 来实现。

意外的参数值

在方法调用中,尤其是递归调用中,当您希望将参数值递增或递减 1 时,可能会出现一个非常严重的错误,因为这个错误很容易犯,也很难发现。出于习惯,您可能会尝试使用后递增或后递减(++––),如下例所示,用于字符串长度的递归(有点笨拙)计算,其中参数count应该包含当前长度:

static int calcLengthParameterValues(final String value, int count)
{
    if (value.length() == 0)
        return count;

    System.out.println("Count: " + count);
    final String remaining = value.substring(1);

    return calcLengthParameterValues(remaining, count++);
}

查看呼叫的输出时

final int length = calcLengthParameterValues("ABC", 0);System.out.println("length: " + length);

您可能会感到惊讶,因为该值没有增加 1,而是保持不变:

Count: 0
Count: 0
Count: 0
length: 0

在这个例子中,只有输入的缩短会终止递归。

有趣的是,如果您遵循良好的编程传统,将参数声明为final,那么可以更快地发现思维中的错误。这样,编译器会直接产生一条错误消息,并指出该变量是不可变的。因此,您可能更愿意选择表达式count + 1而不是传递变量。有了这些知识,纠正就容易了:

static int calcLengthParameterValues(final String value, final int count)
{
    if (value.length() == 0)
        return count;

    System.out.println("Count: " + count);
    final String remaining = value.substring(1);
    return calcLengthParameterValues(remaining, count + 1);
}

更严重的影响将参数定义为final的好习惯可以缓解出现的问题。然而,假设你在已经给出的递归回文检查中省略了final,并且也有点粗心地使用了++ resp。--:

static boolean isPalindromeRecursive(int[] values, int left, int right)
{
    // recursive termination
    if (left >= right)
        return true;

    if (values[left] == values[right])
    {
        // recursive descent
        return isPalindromeRecursive(values, left++, right−−);
    }
    return false;
}

其效果甚至比上一个例子更糟,上一个例子至少终止了,但返回了一个错误的值。使用这个回文检查,过一段时间后你会得到一个StackOverflowError

3.2 练习

3.2.1 练习 1:斐波那契(★★✩✩✩)

练习 1a:斐波那契递归(★✩✩✩✩)

编写基于以下定义递归计算斐波那契数的方法long fibRec(int):

$$ fib(n)=\left{\begin{array}{c}1,\kern6.119996em n=1\ {}1,\kern5.999996em n=2\ {} fib\left(n-1\right)+ fib\left(n-2\right),\forall n>2\end{array}\right. $$

示例例如,使用以下值级数检查实现情况:

| **n** | one | Two | three | four | five | six | seven | eight | | **纤维(n)** | one | one | Two | three | five | eight | Thirteen | Twenty-one |

练习 1b:斐波那契迭代(★★✩✩✩)

斐波那契数列的递归计算效率不高,从第 40 到 50 个斐波那契数列开始,运行时间会大幅增加。为计算编写一个迭代版本。第 1000 个斐波那契数有什么要考虑的?

3.2.2 练习 2:处理数字(★★✩✩✩)

练习 2a:计算数字(★★✩✩✩)

编写递归方法int countDigits(int),找出正自然数的位数。我在第二章 2 第 2.1 节讨论了如何提取数字。

例子

|

投入

|

数字位数

|

交叉求和

|
| --- | --- | --- |
| One thousand two hundred and thirty-four | four | 1 + 2 + 3 + 4 = 10 |
| One million two hundred and thirty-four thousand five hundred and sixty-seven | seven | 1 + 2 + 3 + 4 + 5 + 6 + 7 = 28 |

练习 2b:交叉求和(★★✩✩✩)

递归计算一个数的位数之和。为此编写递归方法int calcSumOfDigits(int)

3.2.3 练习 3: GCD (★★✩✩✩)

练习 3a: GCD 递归(★✩✩✩✩)

写出计算最大公约数的方法int gcd(int, int)1。对于两个自然数 ab ,GCD 可以数学递归地表示如下:

$$ \gcd \left(a,b\right)=\left{\begin{array}{c}a,\kern2.999999em b=0\ {}\gcd \left(b,a%b\right),b\ne 0\end{array}\right. $$

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| forty-two | seven | seven |
| forty-two | Twenty-eight | Fourteen |
| forty-two | Fourteen | Fourteen |

练习 3b: GCD 迭代(★★✩✩✩)

为 GCD 计算创建迭代版本。

练习 3c: LCM (★✩✩✩✩)

编写方法int lcm(int, int)来计算最小公倍数(LCM)。对于两个自然数 ab ,您可以使用以下公式基于 GCD 计算:

lcm(a,b) = a * b / gcd(a,b);

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| Two | seven | Fourteen |
| seven | Fourteen | Fourteen |
| forty-two | Fourteen | forty-two |

3.2.4 练习 4:反串(★★✩✩✩)

编写递归方法String reverseString(String),翻转传入的输入文本的字母。

例子

|

投入

|

结果

|
| --- | --- |
| “一个” | “一个” |
| “ABC” | “CBA” |
| " abcdefghi " | " ihgfedcba " |

3.2.5 练习 5:数组求和(★★✩✩✩)

编写递归计算给定数组值之和的方法int sum(int[])

例子

|

投入

|

结果

|
| --- | --- |
| [1, 2, 3] | six |
| [1, 2, 3, -7] | -1 |

3.2.6 练习 6:数组最小值(★★✩✩✩)

编写方法int min(int[]),该方法使用递归来查找传递的数组的最小值。对于空数组,应该返回值Integer.MAX_VALUE

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 1, 9, 7, 1] | one |
| [11, 2, 33, 44, 55, 6, 7] | Two |
| [1, 2, 3, -7] | -7 |

3.2.7 练习 7:转换(★★✩✩✩)

练习 7a:二进制(★★✩✩✩)

编写方法String toBinary(int),它将给定的正整数递归地转换成文本二进制表示。

例子

|

投入

|

结果

|
| --- | --- |
| five | “101” |
| seven | “111” |
| Twenty-two | “10110” |
| forty-two | “101010” |
| Two hundred and fifty-six | “100000000” |

练习 7b:八进制和十六进制数(★★✩✩✩)

通过实现相应的方法String toOctal(int)String toHex(int),将转换结果写入八进制和十六进制数。

例子

|

投入

|

方法

|

结果

|
| --- | --- | --- |
| seven | 八进制的 | “7” |
| eight | 八进制的 | “10” |
| forty-two | 八进制的 | “52” |
| Fifteen | 十六进制的 | “F” |
| Seventy-seven | 十六进制的 | 《4D》 |

3.2.8 练习 8:指数函数(★★✩✩✩)

练习 8a:二的幂(★★✩✩✩)

编写递归方法boolean isPowerOf2(int),评估给定的正整数,看它是否是 2 的幂。

例子

|

投入

|

结果

|
| --- | --- |
| Two | 真实的 |
| Ten | 错误的 |
| Sixteen | 真实的 |

练习 8b:递归幂运算(★★✩✩✩)

编写递归方法long powerOf(int, int),用指定为第二个参数的正数对给定的正整数求幂。例如,调用powerOf(4, 2)应该返回 4 的平方,所以计算4 2 = 16

练习 8c:指数迭代(★★✩✩✩)

编写这个求幂函数的迭代版本。

例子

|

输入基础

|

输入指数

|

结果

|
| --- | --- | --- |
| Two | Two | four |
| Two | eight | Two hundred and fifty-six |
| four | four | Two hundred and fifty-six |

3.2.9 练习 9:帕斯卡三角形(★★✩✩✩)

写出帕斯卡三角形的方法void printPascal(int)。对于值 5,应该生成以下输出:

[1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]

从第三行开始,后面的每一行都是在前一行的基础上借助加法计算出来的,如下面定义的最后一行所示。对于每一行,这些值的前面和后面都有一个 1。由于这是一个二维结构,递归定义稍微复杂一些:

$$ pascal\left( row, col\right)=\left{\begin{array}{c}1,\kern16.75em row=1\ \mathrm{and}\ col=1\left(\mathrm{top}\right)\ {}1,\kern16.75em row\ \left{1,n\right}\ \mathrm{and}\ col=1\ {}1,\kern15.5em row\ \left{1,n\right}\ \mathrm{and}\ col= row\ {} pascal\left( row-1, col\right)+\kern18.75em \ {} pascal\left( row-1, col-1\right),\kern1em \mathrm{otherwise}\ \left(\mathrm{based}\ \mathrm{on}\ \mathrm{predecessors}\right)\end{array}\right. $$

Tip

你可以在这里找到背景资料和深入的解释: https://en.wikipedia.org/wiki/Pascal's_triangle

3.2.10 练习 10:数字回文(★★★★✩)

回文是一个从正面和背面读起来都一样的单词。我们可以把这个定义扩展到数字。编写递归方法boolean isNumberPalindrome(int),但不求助于类String中的函数。

例子

|

投入

|

结果

|
| --- | --- |
| seven | 真实的 |
| Thirteen | 错误的 |
| One hundred and seventy-one | 真实的 |
| Forty-seven thousand seven hundred and forty-two | 错误的 |

3.2.11 练习 11:排列(★★★✩✩)

计算给定为字符串的字母序列的所有排列;这意味着这些字母的所有可能的组合。在方法Set<String> calcPermutations(String)中实现该计算。也考虑重复字母的情况。

例子

|

投入

|

结果

|
| --- | --- |
| “一个” | “一个” |
| " AA " | " AA " |
| “AB” | “AB”,“BA” |
| “ABC” | ABC、BAC、ACB、CAB、CBA、BCA |
| " AAC " | “AAC”、“ACA”、“CAA” |

3.2.12 练习 12:计算子字符串(★★✩✩✩)

编写方法int countSubstrings(String, String),计算给定子串的所有出现次数。因此,当发现一个模式时,它应该被消耗(即,它不应该再次被命中)。这在下表中显示为最后一种情况。

例子

|

投入

|

搜索词

|

结果

|
| --- | --- | --- |
| " xhixhix " | “x” | three |
| " xhixhix " | “嗨” | Two |
| "麦克风" | "麦克风" | one |
| “哈哈” | “嗬” | Zero |
| " xxxxyz " | " xx " | Two |

练习 13:尺子(★★✩✩✩)

在介绍中,我展示了如何使用递归绘制一个简单的尺子形状以及一个风格化的雪花(见图 3-3 )。在这个练习中,你要模仿一个英式尺子。这涉及到将一英寸的面积分成 1/2、1/4 和 1/8。这样,笔画的长度每次减少一个。

示例输出应该如下所示:

---- 0
-
--
-
---
-
--
-
---- 1
-
--
-
---
-
--
-
---- 2

3.3 解决方案

3.3.1 备选案文 1: Fibonacci

解决方案 1a:斐波那契递归(★✩✩✩✩)

编写基于以下定义递归计算斐波那契数的方法long fibRec(int):

$$ fib(n)=\left{\begin{array}{c}1,\kern6.119996em n=1\ {}1,\kern5.999996em n=2\ {} fib\left(n-1\right)+ fib\left(n-2\right),\forall n>2\end{array}\right. $$

示例例如,使用以下值级数检查实现情况:

| **n** | one | Two | three | four | five | six | seven | eight | | **纤维(n)** | one | one | Two | three | five | eight | Thirteen | Twenty-one |

算法它在 Java 中的实现正是来源于数学定义:

static long fibRec(final int n)
{
    if (n <= 0)
        throw new IllegalArgumentException("n must be >= 1");

    // recursive termination
    if (n == 1 || n == 2)
        return 1;

    // recursive descent
    return fibRec(n – 1) + fibRec(n – 2);
}

解决方案 1b:斐波那契迭代(★★✩✩✩)

斐波那契数列的递归计算效率不高,从第 40 到 50 个斐波那契数列开始,运行时间会大幅增加。为计算编写一个迭代版本。第 1000 个斐波那契数有什么要考虑的?

算法与递归版本类似,迭代实现首先检查输入的有效性,然后检查值为 1 或 2 的调用的特殊情况。之后,使用两个辅助变量和一个从 2 到 n 的循环,然后从两个辅助变量之和计算相应的斐波那契数。之后,适当地分配两个辅助变量。这导致了以下实现:

static long fibIterative(final int n)
{
    if (n <= 0)
        throw new IllegalArgumentException("n must be >= 1");

    if (n==1 || n == 2)
        return 1;

    long fibN_2 = 1;
    long fibN_1 = 1;

    for (int count = 2; count < n; count++)
    {
        long fibN = fibN_1 + fibN_2;

        // move forward by one
        fibN_2 = fibN_1;
        fibN_1 = fibN;
    }

    return fibN;
}

斐波那契对于更大的数比如你要计算第 1000 个斐波那契数,一个long的取值范围远远不够。作为一个修正,计算必须使用BigInteger类来执行。

确认

对于测试,您使用以下输入,这些输入显示了正确的功能:

@ParameterizedTest(name = "fibRec({0}) = {1}")
@CsvSource({ "1, 1", "2, 1", "3, 2", "4, 3", "5, 5", "6, 8", "7, 13", "8, 21" })
void fibRec(int n, long expectedFibN)
{
    long result1 = Ex01_Fibonacci.fibRec(n);

    assertEquals(expectedFibN, result1);
}

@ParameterizedTest(name = "fibIterative({0}) =  {1}")
@CsvSource({ "1, 1", "2, 1", "3, 2", "4, 3", "5, 5", "6, 8", "7, 13", "8, 21" })
void fibIterative(int n, long expectedFibN)
{
    long result = Ex01_Fibonacci.fibIterative(n);

    assertEquals(expectedFibN, result);
}

3.3.2 解决方案 2:处理数字(★★✩✩✩)

解决方案 2a:计数位数(★★✩✩✩)

编写递归方法int countDigits(int),找出正自然数的位数。我在第二章 2 第 2.1 节讨论了如何提取数字。

例子

|

投入

|

数字位数

|

交叉求和

|
| --- | --- | --- |
| One thousand two hundred and thirty-four | four | 1 + 2 + 3 + 4 = 10 |
| One million two hundred and thirty-four thousand five hundred and sixty-seven | seven | 1 + 2 + 3 + 4 + 5 + 6 + 7 = 28 |

算法如果数字小于 10,那么返回值 1,因为这对应一个数字。否则,通过将数字除以 10 来计算剩余的值。这将递归调用计数方法,如下所示:

static int countDigits(final int value)
{
    if (value < 0)
        throw new IllegalArgumentException("value must be >= 0");

    // recursive termination
    if (value < 10)
        return 1;

    final int remainder = value / 10;

    // recursive descent
    return countDigits(remainder) + 1;
}

解决方案 2b:交叉求和(★★✩✩✩)

递归计算一个数的位数之和。为此编写递归方法int calcSumOfDigits(int)

算法根据第一个子任务的解决方案,您只需改变数字以及加法和自调用的返回值,如下所示:

static int calcSumOfDigits(final int value)
{
    if (value < 0)
        throw new IllegalArgumentException("value must be >= 0");

    // recursive termination
    if (value < 10)
        return value;

    final int remainder = value / 10;
    final int lastDigit = value % 10;

    // recursive descent
    return calcSumOfDigits(remainder) + lastDigit;
}

确认

对于测试,您使用以下输入,这些输入显示了正确的操作:

@ParameterizedTest(name = "countDigits({0}) = {1}")
@CsvSource({ "1234, 4", "1234567, 7" })
void countDigits(int number, int expected)
{
    long result = Ex02_CalcDigits.countDigits(number);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "calcSumOfDigits({0}) = {1}")
@CsvSource({ "1234, 10", "1234567, 28" })
void calcSumOfDigits(int number, int expected)
{
    long result = Ex02_CalcDigits.calcSumOfDigits(number)

;

    assertEquals(expected, result);
}

3.3.3 解决方案 3: GCD

解决方案 3a: GCD 递归(★✩✩✩✩)

写出计算最大公约数的方法int gcd(int, int)2。对于两个自然数 ab ,GCD 可以数学递归地表示如下:

$$ \mathit{\gcd}\left(a,b\right)=\left{{}_{\mathit{\gcd}\left(b,a%b\right),\kern0.36em b\ne 0}^{a,\kern3.839998em b=0}\right. $$

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| forty-two | seven | seven |
| forty-two | Twenty-eight | Fourteen |
| forty-two | Fourteen | Fourteen |

算法最大公约数的计算可以相当直接地从数学定义用 Java 编码:

static int gcd(final int a, final int b)
{
    // recursive termination
    if (b == 0)
        return a;

    // recursive descent
    return gcd(b, a % b);
}

解决方案 3b: GCD 迭代(★★✩✩✩)

为 GCD 计算创建迭代版本。

算法自调用被转换成一个循环,一直执行到满足递归终止的条件。诀窍是按照递归定义的规定重新分配变量:

static int gcdIterative(int a, int b)
{
    while (b != 0)
    {
        final int remainder = a % b;

        a = b;
        b = remainder;
    }

    // here applies b == 0
    return a;
}

确认

对于测试,您使用以下输入,这些输入显示了正确的操作:

@ParameterizedTest(name = "gcd({0}, {1}) = {2}")
@CsvSource({ "42, 7, 7", "42, 28, 14", "42, 14, 14" })
void gcd(int a, int b, int expected)
{
    int result = Ex03_GCD.gcd(a, b);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "gcdIterative({0}, {1}) = {2}")
@CsvSource({ "42, 7, 7", "42, 28, 14", "42, 14, 14" })
void gcdIterative(int a, int b, int expected)
{
    int result = Ex03_GCD.gcdIterative(a, b);

    assertEquals(expected, result)

;
}

解决方案 3c: LCM

编写方法int lcm(int, int)来计算最小公倍数(LCM)。对于两个自然数 ab ,您可以使用以下公式基于 GCD 计算:

lcm ( ab=【a】**【b】/【gcd】

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| Two | seven | Fourteen |
| seven | Fourteen | Fourteen |
| forty-two | Fourteen | forty-two |

算法最小公倍数的计算也可以从数学定义上直接实现,只要你已经完成了 GCD 的功能:

static int lcm(final int a, final int b)
{
    return a * b / gcd(a, b);
}

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "lcm({0}, {1}) = {2}")
@CsvSource({ "2, 7, 14", "7, 14, 14", "42, 14, 42" })
void lcm(int a, int b, int expected)
{
    int result = Ex03_GCD.lcm(a, b);

    assertEquals(expected, result);
}

3.3.4 解决方案 4:反串(★★✩✩✩)

编写递归方法String reverseString(String),翻转传入的输入文本的字母。

例子

|

投入

|

结果

|
| --- | --- |
| “一个” | “一个” |
| “ABC” | “CBA” |
| " abcdefghi " | " ihgfedcba " |

算法提取第一个字符,直到得到长度为 1 的字符串,然后以相反的顺序连接整个字符串:

static String reverseString(final String input)
{
    // recursive termination
    if (input.length() <= 1)
        return input;

    final char firstChar = input.charAt(0);
    final String remaining = input.substring(1);

    // recursive descent
    return reverseString(remaining) + firstChar;
}

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "reverseString({0}) => {1}")
@CsvSource({ "A, A", "ABC, CBA", "abcdefghi, ihgfedcba" })
void reverseString(String input, String expected)
{
    String result = Ex04_ReverseString.reverseString(input);

    assertEquals(expected, result);
}

3.3.5 解决方案 5:数组和(★★✩✩✩)

编写递归计算给定数组值之和的方法int sum(int[])

例子

|

投入

|

结果

|
| --- | --- |
| [1, 2, 3] | six |
| [1, 2, 3, -7] | -1 |

算法用递归定义计算部分和,只要

总和 ( (0))= [0]

总和 ( (0...n)=数值【0】+总和 ( 数值 (1... n ))

直到只剩下一个元素。正如简介中提到的,帮助器方法是有用的,它包含实际的处理和逻辑。这里,数组中的当前值被添加到递归确定的结果中:

static int sum(final int[] values)
{
    return sum(values, 0);
}

static int sum(final int[] values, final int pos)
{
    // recursive termination
    if (pos >= values.length)
        return 0;

    int value = values[pos];

    // recursive descent
    return value + sum(values, pos + 1);
}

或者,也可以让pos计数器从length – 1运行到 0,因此递归反向如下:

总和 ( (0...n)=总和 ( (0...n-1)+ [ n ]

这将通过方法sumTail(int[], int)实现如下:

static int sumTail(final int[] values, final int pos)
{
    if (pos < 0)
        return 0;

    int value = values[pos];

    // recursive descent
    return sumTail(values, pos – 1) + value;
}

确认

对于测试,使用注释@MethodSource和对方法valuesAndResult()的引用来提供期望的输入和结果:

@ParameterizedTest(name="sum({0}) = {1}")
@MethodSource("valuesAndResult")
void sum(int[] values, int expected)
{
    int result = Ex05_ArraySum.sum(values);

    assertEquals(expected, result);
}

除了之前使用的注释@CsvSource, @MethodSource是另一种将数据传递给测试用例的方法。这变得很有必要,因为您不能使用@CsvSource来提供int[],而这正是您在这里所需要的。为此,可以使用Stream<Arguments>:

private static Stream<Arguments> valuesAndResult()
{
    return Stream.of(Arguments.of(new int[] { 1 }, 1),
                     Arguments.of(new int[] { 1, 2, 3 }, 6),
                     Arguments.of(new int[] { 1, 2, 3, –7 }, –1));
}

3.3.6 解决方案 6:阵列最小值(★★✩✩✩)

编写方法int min(int[]),该方法使用递归来查找传递的数组的最小值。对于空数组,应该返回值Integer.MAX_VALUE

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 1, 9, 7, 1] | one |
| [11, 2, 33, 44, 55, 6, 7] | Two |
| [1, 2, 3, -7] | -7 |

算法从第一个元素开始检查数组,并将其与初始设置为Integer.MAX_VALUE的最小值进行比较。如果当前元素更小,它将成为新的最小值。对缩短了一个位置的数组重复此过程,直到该位置位于数组的末尾。

static int min(final int[] values)
{
    return min(values, 0, Integer.MAX_VALUE);
}

static int min(final int[] values, final int pos, int currentMin)
{
    // recursive termination
    if (pos >= values.length)
        return currentMin;

    final int current = values[pos];
    if (current < currentMin)
        currentMin = current;

    // recursive descent
    return min(values, pos + 1, currentMin);
}

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name="min({0}) = {1}")
@MethodSource("valuesAndMinimum")
void min(int[] input, int expected)
{
    int result = Ex06_ArrayMin.min(input);

    assertEquals(expected, result);
}

private static Stream<Arguments> valuesAndMinimum()
{
    return Stream.of(Arguments.of(new int[] { 7, 2, 1, 9, 7, 1 }, 1),
                     Arguments.of(new int[] { 11, 2, 33, 44, 55, 6, 7 }, 2),
                     Arguments.of(new int[] { 1, 2, 3, –7 }, –7),
                     Arguments.of(new int[] { }, Integer.MAX_VALUE));
}

3.3.7 解决方案 7:转换(★★✩✩✩)

解决方案 7a:二进制(★★✩✩✩)

编写方法String toBinary(int),它将给定的正整数递归地转换成文本二进制表示。

例子

|

投入

|

结果

|
| --- | --- |
| five | “101” |
| seven | “111” |
| Twenty-two | “10110” |
| forty-two | “101010” |
| Two hundred and fifty-six | “100000000” |

算法转换基于已知的最后一位数字的提取和余数的确定,如第 2.1 节所述。要将十进制数转换为二进制数,需要检查传递的数是否可以用二进制中的一个数字来表示(即是否小于 2)。否则,首先使用模运算符提取最后一位数字,然后提取余数。为此,您递归调用方法,然后连接最后一个数字的字符串表示形式。这导致值 22 的以下序列:

|

祈祷

|

顺序

|

结果

|
| --- | --- | --- |
| 托比纳(22 岁) | toBinary(22/2)+value of(22% 2)= > toBinary(11)+" 0 " | “10110” |
| 托比纳(11) | toBinary(11/2)+value of(11% 2)= > toBinary(5)+1 | “1011” |
| 托比纳(5) | toBinary(5/2)+value of(5% 2)= > toBinary(2)+1 | “101” |
| 二进制(2) | toBinary(2/2)+value of(2% 2)= > toBinary(1)+" 0 " | “10” |
| 二进制(1) | value of(1)= > 1 | “1” |

现在让我们用 Java 实现整个过程,如下所示:

static String toBinary(final int n)
{
    if (n < 0)
        throw new IllegalArgumentException("n must be >= 0");

    // recursive termination: check for digit in binary system
    if (n <= 1)
        return String.valueOf(n);

    final int lastDigit = n % 2;
    final int remainder = n / 2;

    // recursive descent
    return toBinary(remainder) + lastDigit

;
}

解答 7b:八进制和十六进制数字(★★✩✩✩)

通过实现相应的方法String toOctal(int)String toHex(int),将转换结果写入八进制和十六进制数。

例子

|

投入

|

方法

|

结果

|
| --- | --- | --- |
| seven | 八进制的 | “7” |
| eight | 八进制的 | “10” |
| forty-two | 八进制的 | “52” |
| Fifteen | 十六进制的 | “F” |
| Seventy-seven | 十六进制的 | 《4D》 |

算法算法基本保持不变。您检查传递的数字是否可以由所需数字系统的单个数字表示,因此小于 8(八进制)或 16(十六进制)。否则,您首先使用模运算提取最后一位数字以及余数。对于剩余部分,递归地调用这个方法,然后连接最后一个数字的字符串表示。

public String toOctal(final int n)
{
    if (n < 0)
        throw new IllegalArgumentException("n must be >= 0");

    // recursive termination: check for digit in octal system
    if (n < 8)
        return String.valueOf(n);

    final int lastDigit = n % 8;
    final int remainder = n / 8;

    // recursive descent
    return toOctal(remainder) + String.valueOf(lastDigit);
}

public String toHex(final int n)
{
    if (n < 0)
        throw new IllegalArgumentException("n must be >= 0");

    // recursive termination: check for digit in hexadecimal system
    if (n <= 15)
        return asHexDigit(n);

    final int lastDigit = n % 16;
    final int remainder = n / 16;

    // recursive descent
    return toHex(remainder) + asHexDigit(lastDigit);
}

// easier handling of hexadecimal conversion
static String asHexDigit(final int n)
{
    if (n < 0)
        throw new IllegalArgumentException("n must be >= 0");

    if (n < 9)
        return String.valueOf(n);

    if (n <= 15)
        return Character.toString(n – 10 + 'A');

    throw new IllegalArgumentException("value not in range 0 – 15, " +
                                       "but is: " + n);
}

Hint: Possible Optimizations

虽然所示的将单个十六进制数字转换成字符串的实现相当简单,但是有一个非常优雅的替代方法,也很容易阅读和理解。它使用charAt(int)根据一组给定的字符验证一个数字:

static String asHexDigit(final int n)
{
    if (n < 0)
        throw new IllegalArgumentException("n must be >= 0");
    if (n <= 15)
    {
        final char hexdigit = "0123456789ABCDEF".charAt(n);
        return String.valueOf(hexdigit);
    }

    throw new IllegalArgumentException("value not in range 0 – 15, " + "but is: " + n);
}

使用Character.getNumericvalue(char)可以实现从字符到数字的反方向转换。您将在第 4.1.3 节中了解到这一点。

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "toBinary({0}) => {1}")
@CsvSource({ "5, 101", "7, 111", "22, 10110", "42, 101010", "256, 100000000" })
void toBinary(int value, String expected)
{
    String result = Ex07_NumberConversions.toBinary(value);

    assertEquals(expected, result);

}

@ParameterizedTest(name = "toOctal({0}) => {1}")
@CsvSource({ "42, 52", "7, 7", "8, 10" })
void toOctal(int value, String expected)
{
    String result = Ex07_NumberConversions.toOctal(value);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "toHex({0}) => {1}")
@CsvSource({ "77, 4D", "15, F", "16, 10" })
void toHex(int value, String expected)
{
    String result = Ex07_NumberConversions.toHex(value);

    assertEquals(expected, result);
}

3.3.8 解决方案 8:指数函数(★★✩✩✩)

解决方案 8a:二的幂(★★✩✩✩)

编写递归方法boolean isPowerOf2(int),评估给定的正整数,看它是否是 2 的幂。

例子

|

投入

|

结果

|
| --- | --- |
| Two | 真实的 |
| Ten | 错误的 |
| Sixteen | 真实的 |

算法如果给定数小于值 2,则只有值 1 对应一个幂,即 0 的次方(即2 0 )。现在你要检查它是否是奇数。如果是这样,它不可能是倍数,因此不是 2 的幂。如果数字是偶数,那么用数字除以 2 进行递归检查:

static boolean isPowerOf2(final int n)
{
    // recursive termination
    if (n < 2)
        return n == 1;

    if (n %  2 != 0)
        return false;

    // recursive descent
    return isPowerOf2(n / 2);
}

对于初始检查,您使用了一个小技巧return n==1,其效果如下:

n0:false(负数,所以从不取值 1)

n= 0:false(0≥1)

n= 1:true(1 = 1)

让我们来看一个简短版本的实现。在我看来,上面那个更容易理解。此外,在第一个版本中,递归终止和下降更加清晰:

static boolean isPowerOf2Short(final int n)
{
    return n == 1 || n > 0 && n % 2 == 0 && isPowerOf2Short(n / 2);
}

解决方案 8b:幂递归(★★✩✩✩)

编写递归方法long powerOf(int, int),用指定为第二个参数的正数对给定的正整数求幂。例如,调用powerOf(4, 2)应该返回 4 的平方,所以计算4 2 = 16

算法现在,递归调用该方法,将数字乘以自调用的结果,直到指数达到 0 或 1。此外,每次调用都必须将指数减 1。

static long powerOf(int value, int exponent)
{
    if (exponent < 0)
        throw new IllegalArgumentException("exponent must be >= 0");

    // recursive termination
    if (exponent == 0)
        return 1;

    if (exponent == 1)
        return value;

    // recursive descent
    return value * powerOf(value, exponent – 1);
}

这个替代方案的成本为 O(n) 。但是对其进行优化并将其简化为 O(log(n)) 是相当容易的。

优化算法为了优化,你使用平方值的技巧,从而将指数减半。这就只剩下对奇指数的特殊处理,这需要另一次乘法。为了避免数值范围溢出,您必须将数值类型从int更改为long:

static long powerOfOptimized(final long value, final int exponent)
{
    if (exponent < 0)
        throw new IllegalArgumentException("exponent must be >= 0");

    // recursive termination
    if (exponent == 0)
        return 1;

    if (exponent == 1)
        return value;

    // recursive descent
    final long result = powerOfOptimized(value * value, exponent / 2);
    if (exponent % 2 == 1)
        return value * result;

    return result;
}

解决方案 8c:指数迭代(★★✩✩✩)

编写这个求幂函数的迭代版本。

例子

|

输入基础

|

输入指数

|

结果

|
| --- | --- | --- |
| Two | Two | four |
| Two | eight | Two hundred and fifty-six |
| four | four | Two hundred and fifty-six |

算法对于递归版本,您可能会从两个检查开始。况且自调用还得转换成循环,数字还要乘以前面的中间结果。此外,在每一遍中,指数必须减少。但是,仔细观察很快就会发现,这两项初步检查已经包含在一般情况中,因此不再包括在清单中。

long powerOfIterative(int value, int exponent)
{
    long result = 1;
    while (exponent > 0)
    {
        result *= value;
        exponent––;
    }

    return result;
}

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "isPowerOf2({0}) => {1}")
@CsvSource({ "2, true", "3, false", "4, true", "10, false", "16, true" })
void isPowerOf2(int number, boolean expected)
{
    boolean result = Ex08_Exponentation.isPowerOf2(number);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "powerOf({0}) => {1}")
@CsvSource({ "2, 2, 4", "4, 2, 16", "16, 2, 256", "4, 4, 256", "2, 8, 256" })
void powerOf(int number, int exponent, long expected)
{
    long result = Ex08_Exponentation.powerOf(number, exponent);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "powerOfIterative({0}) => {1}")
@CsvSource({ "2, 2, 4", "4, 2, 16", "16, 2, 256", "4, 4, 256", "2, 8, 256" })
void powerOfIterative(int number, int exponent, long expected)
{
    long result = Ex08_Exponentation.powerOfIterative(number, exponent);

    assertEquals(expected, result);
}

3.3.9 解答 9:帕斯卡三角形(★★✩✩✩)

写出帕斯卡三角形的方法void printPascal(int)。对于值 5,应该生成以下输出:

[1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]

从第三行开始,后面的每一行都是在前一行的基础上借助加法计算出来的,如下面定义的最后一行所示。对于每一行,这些值的前面和后面都有一个 1。由于这是一个二维结构,递归定义稍微复杂一点。

$$ pascal\left( row, col\right)=\left{\begin{array}{c}1,\kern16.75em row=1\ \mathrm{and}\ col=1\left(\mathrm{top}\right)\ {}1,\kern16.75em row\ \left{1,n\right}\ \mathrm{and}\ col=1\ {}1,\kern15.5em row\ \left{1,n\right}\ \mathrm{and}\ col= row\ {} pascal\left( row-1, col\right)+\kern18.75em \ {} pascal\left( row-1, col-1\right),\kern1em \mathrm{otherwise}\ \left(\mathrm{based}\ \mathrm{on}\ \mathrm{predecessors}\right)\end{array}\right. $$

Tip

你可以在这里找到背景资料和深入的解释: https://en.wikipedia.org/wiki/Pascal's_triangle

算法你实现递归定义的方法如下:

static int calcPascal(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 calcPascal(row – 1, col) + calcPascal(row – 1, col – 1);
}

实际上,不需要为顶部设置单独的终止条件。然而,为了更好地理解,这里显示了这一点——当然,这是个人喜好问题。

为了计算帕斯卡三角形,现在必须使用覆盖所有行和列的两个嵌套循环对三角形中的每个位置调用前面的方法:

static void printPascal(final int n)
{
    for (int row = 1; row <= n; row++)
    {
        for (int col = 1; col <= row; col++)
             System.out.print(calcPascal(row, col));

        System.out.println();
    }
}

测试时,使用以下显示正确操作的输入:

jshell> printPascal(4) [1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]

优化算法纯递归定义导致计算量相当大。如果你一行一行地工作,它会变得更容易理解,更容易领会,更有表现力。

起点是第一行,它只包含值 1。对于所有其他值,您必须调用方法本身 n 次,然后使用助手方法List<Integer> calcLine(List<Integer>)来计算新行。但是为了避免混合计算和控制台输出,您添加了一个能够执行操作的参数,例如将中间步骤记录到控制台:

static List<Integer> calcPascal(final int n,
                                final Consumer<List<Integer>> action)
{
    // recursive termination
    if (n == 1)
    {
        action.accept(List.of(1));
        return List.of(1);
    }
    else
    {
        // recursive descent
        final List<Integer> previousLineValues = calcPascal(n – 1, action);

        final List<Integer> newLine = calcLine(previousLineValues);

        action.accept(newLine);
        return newLine;
    }
}

在基于前一行计算新行的值的帮助器方法calcLine(List<Integer>)中有一点复杂。请务必记住,前一行至少包含两个值,您不会对最后一个元素求和,而只会对倒数第二个元素求和:

static List<Integer> calcLine(final List<Integer> previousLine)
{
    final List<Integer> currentLine = new ArrayList<>();
    currentLine.add(1);

    for (int i = 0; i < previousLine.size() – 1; i++)
    {
        // value is calculated based on the two values of the predecessor line
        final int newValue = previousLine.get(i) + previousLine.get(i + 1);
        currentLine.add(newValue);
    }

    currentLine.add(1);
    return currentLine;
}

确认

对于测试,使用下面的调用,它显示了正确的操作:

jshell> calcPascal(7, System.out::println) [1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]
[1, 5, 10, 10, 5, 1]
[1, 6, 15, 20, 15, 6, 1]

然后,您可以用单元测试检查一些更正式的东西:

@ParameterizedTest(name = "calcPascal({0}) = {1}")
@MethodSource("valuesAndResults")
void calcPascal(int n, List<Integer> expected)
{
    final Consumer<List<Integer>> NOOP = whatever -> { };
    List<Integer> result = Ex09_PascalTriangle.calcPascal(n, NOOP);

    assertEquals(expected, result);
}

private static Stream<Arguments> valuesAndResults()
{
    return Stream.of(Arguments.of(1, List.of(1)),
                     Arguments.of(2, List.of(1,1)),
                     Arguments.of(3, List.of(1,2,1)),
                     Arguments.of(4, List.of(1,3,3,1)),
                     Arguments.of(5, List.of(1,4,6,4,1)));
}

3.3.10 解决办法 10:回文数目

回文是一个从正面和背面读起来都一样的单词。你可以把这个定义扩展到数字。编写递归方法boolean isNumberPalindrome(int),但不求助于类String中的函数。

例子

|

投入

|

结果

|
| --- | --- |
| seven | 真实的 |
| Thirteen | 错误的 |
| One hundred and seventy-one | 真实的 |
| Forty-seven thousand seven hundred and forty-two | 错误的 |

算法由于练习中要求的限制,无法逐个字符进行比较。但是,模运算和除法运算是合适的,您已经在类似的任务中使用过了。你用这两个数字来区分和比较左边和右边的数字。

让我们通过示例来探讨解决方案:

# of Digits    Value     Calculation
------------------------------------------------------------------------
1 digit                  => special case, is always palindrome

2 digits       11        divisor = 10
< 100                      1 % 10 = 1
                          11 / 10 = 1      palindrome
               13
                           3 % 10 = 3
                          13 / 10 = 1      X

3 digits       171       divisor = 100
< 1000                     1 % 10 = 1
                         171 / 100 = 1
                         remainder: 7 (171 / 10 = 17 % 10 = 7) => check
                               recursively

4 digits       4774      divisor = 1000
< 10000                     4 % 10 = 4
                         4774 / 1000 = 4   ok
                         remainder: 77 (4774 / 10 = 477 % 100 = 77) =>
                               check recursively

必须提取一个数字的左右两位。如果这些匹配,则通过首先除以 10(截掉最后一位数字)然后使用具有适当选择的位数的模运算符来确定余数(即截掉前面的数字)来确定新值。特别是,你必须算出数字的长度是 10 的幂,才能得到正确的除数。

static boolean isNumberPalindrome(final int number)
{
    if (number < 10)
        return true;

    final int factor = MathUtils.calcPowOfTen(number);
    final int divisor = (int)Math.pow(10, factor);

    if (number < divisor * 10)
    {
        final int leftNumber = number / divisor;
        final int rightNumber = number % 10;

        final int remainingNumber = (number / 10) % (divisor / 10);
        return leftNumber == rightNumber && isNumberPalindrome(remainingNumber);
    }

    return false;
}

在下文中,10 的幂的计算以及数字的计数被显示为辅助方法,它驻留在实用程序类MathUtils中:

static int calcPowOfTen(final int number)
{
    return countDigits(number) – 1;
}

static int countDigits(int number)
{
    int count = 0;

    while (number > 0)
    {
        number = number / 10;        count++;
    }

    return count;
}

所示的解决方案决不是最佳的,因为必须不断地确定这些因素。此外,即使已经提取了 helper 方法,从源代码中理解整个过程仍然非常困难。

优化算法作为优化,你实现如下版本:始终分隔最后一位数字,除以 10,用新值调用方法。事先,通过将当前值乘以 10 并追加最后一位数字,从当前值和最后一位数字计算新值。如果是回文,那么原始值对应计算值。当不再有数字或只有一个数字时,递归终止发生。诀窍是你从后面重建数字,最后和原始值比较。与目前介绍的其他递归帮助器方法相比,这里需要两个缓冲区,一个用于当前值,一个用于剩余值:

static boolean isNumberPalindromeRec(final int number)
{
    return isNumberPalindromeRec(number, 0, number);
}

static boolean isNumberPalindromeRec(final int origNumber,
                                     final int currentValue,
                                     final int remainingValue)
{
    // recursive termination
    if (origNumber == currentValue)
        return true;

    // recursive termination
    if (remainingValue < 1)
        return false;

    final int lastDigit = remainingValue % 10;
    final int newCurrent = currentValue * 10 + lastDigit;
    final int newRemaining = remainingValue / 10;

    System.out.println(String.format("lastDigit: %,4d, " +
                       "newCurrent: %,4d, newRemaining: %,4d",
                       lastDigit, newCurrent, newRemaining));

    // recursive descent
    return isNumberPalindromeRec(origNumber, newCurrent, newRemaining);
}

对值 121 的调用可以如下所示:

isNumberPalindromeRec(121, 0, 121) =>
lastDigit:   1, newCurrent:    1, newRemaining:  12
isNumberPalindromeRec(121, 1, 12) =>
lastDigit:   2, newCurrent:   12, newRemaining:   1
isNumberPalindromeRec(121, 12, 1) =>
lastDigit:   1, newCurrent:  121, newRemaining:   0
isNumberPalindromeRec(121, 121, 0)
true

当然,看看整个过程如何处理一个数字是很有趣的,例如 123,它不是一个回文:

isNumberPalindromeRec(123, 0, 123) =>
lastDigit:   3, newCurrent:    3, newRemaining:  12
isNumberPalindromeRec(123, 3, 12) =>
lastDigit:   2, newCurrent:   32, newRemaining:   1
isNumberPalindromeRec(123, 32, 1) =>
lastDigit:   1, newCurrent:  321, newRemaining:   0
isNumberPalindromeRec(123, 321, 0)
false

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "isNumberPalindrome({0}) => {1}")
@CsvSource({ "7, true", "13, false", "171, true", "47742, false",
             "123321, true", "1234554321, true" })
void isNumberPalindrome(int number, boolean expected)
{
    boolean result = Ex11_NumberPalindrome.isNumberPalindrome(number);

    assertEquals(expected, result);
}

3.3.11 解决办法 11:交换

计算给定为字符串的字母序列的所有排列;这意味着这些字母的所有可能的组合。在方法Set<String> calcPermutations(String)中实现该计算。也考虑重复字母的情况。

例子

|

投入

|

结果

|
| --- | --- |
| “一个” | “一个” |
| " AA " | " AA " |
| “AB” | “AB”,“BA” |
| “ABC” | ABC、ACB、BAC、BCA、CAB、CBA |
| " AAC " | “AAC”、“ACA”、“CAA” |

算法计算给定字符串的所有排列的最佳方式是查看递归定义,然后实现它:

$$ A\kern1.25em \Rightarrow perm(A)\kern19.25em =A $$

$$ AA\kern0.5em \Rightarrow A+ perm(A)\cup A+ perm(A)\kern10em = AA\cup AA= AA $$

$$ AB\kern0.5em \Rightarrow A+ perm(B)\cup B+ perm(A)\kern10em = AB\cup BA $$

$$ ABC\Rightarrow A+ perm(BC)\cup B+ perm(AC)\cup C+ perm(AB)= ABC\cup ACB\cup \dots $$

你认识到对于一个字符,排列是由字符本身组成的。对于多个字符,排列的计算方法是找到没有字符的剩余字符串的排列,然后将它们与字符适当地组合起来——稍后将详细介绍。原始问题从长度为 n 的字符串简化为长度为n1 的字符串的 n 问题。因此,对于字符串 ABC,你得到了如图 3-4 所示的解。

img/519691_1_En_3_Fig4_HTML.png

图 3-4

ABC 置换的计算

有了这些知识,实现将变得容易得多,您可以将以下步骤转换成 Java:

  • 选择并提取第 I 个字符。

  • 建立剩余的字符串,并计算它的排列。

  • 将整个事情重新组合在一起,实现如下:

static Set<String> calcPermutations(final String input)
{
    // recursive termination
    if (input.isEmpty() || input.length() == 1)
        return Set.of(input);

    final Set<String> combinations = new HashSet<>();
    for (int i = 0; i < input.length(); i++)
    {
        // extract i-th character as new first character
        final String newFirst = "" + input.charAt(i);

        // rest without i-th character
        final String newInput = input.substring(0, i) + input.substring(i + 1);

        // recursive descent
        final Set<String> permutations = calcPermutations(newInput);

        // adding the extracted character to all partial solutions
        for (final String perm : permutations)
        {
            combinations.add(newFirst + perm);
        }
    }
    return combinations;
}

这种实现导致创建相当多的字符串和集合实例作为中间缓冲区。这怎么改善?

优化算法上面提到的缺点对于短字符串来说可以忽略不计。然而,字符串越长,创建所有临时对象和执行字符串操作就变得越明显。如何避免这种情况?

在这里,您将重温在其他解决方案中已经看到的想法。您可以巧妙地将它们作为参数传递,而不是将每个字符串组装起来。其中一个定义剩余的字符串,另一个定义当前已经计算的前缀。

static Set<String> calcPermutationsMiniOpt(final String input)
{
    return calcPermutationsMiniOptImpl(input, "");
}
static Set<String> calcPermutationsMiniOptImpl(final String remaining,
                                               final String prefix)
{
    if (remaining.length() == 0)
        return Set.of(prefix);

    final Set<String> candidates = new HashSet<>();

    for (int i = 0; i < remaining.length(); i++)
    {
        final String newPrefix = prefix + remaining.charAt(i);
        final String newRemaining = remaining.substring(0, i) +
                                    remaining.substring(i + 1);

        candidates.addAll(calcPermutationsMiniOptImpl(newRemaining, newPrefix));
    }

    return candidates;
}

让我评论一下优化。用我的 iMac (i7 4 Ghz)调用方法calcPermutations("abcdefghij")需要大约 7 到 8 秒,calcPermutationsMiniOpt("abcdefghij")只需要大约 4 秒就完成了。这是由于调用的数量非常大,因此较小的优化可能是值得的。

然而,如果您只添加一个额外的字符,开销会大幅增加到大约 111 秒,而对于优化版本,增加到大约 85 秒。运行时间的这种增加当然是绝对不希望的。在阅读了涵盖更高级递归技术的第八章后,你可能想再看一下排列的计算,试图借助记忆来改进。然而,这将以所需的内存为代价。

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "calcPermutations({0}) = {1}")
@MethodSource("inputAndPermutations")
void calcPermutations(String input, Set<String> expected)
{
    Set<String> result = Ex12_Permutations.calcPermutations(input);

    assertEquals(expected, result);
}

private static Stream<Arguments> inputAndPermutations()
{
    return Stream.of(Arguments.of("A", Set.of("A")),
                     Arguments.of("AA", Set.of("AA")),
                     Arguments.of("AB", Set.of("AB", "BA")),
                     Arguments.of("ABC", Set.of("ABC", "BAC", "ACB",
                                                "CAB", "CBA", "BCA")),
                     Arguments.of("AAC", Set.of("AAC", "ACA", "CAA")));
}

3.3.12 解决方案 12:计算子字符串(★★✩✩✩)

编写方法int countSubstrings(String, String),计算给定子串的所有出现次数。因此,当一个模式被发现时,它应该被消耗(即,它不应该再次被命中)。这在下表中显示为最后一种情况。

例子

|

投入

|

搜索词

|

结果

|
| --- | --- | --- |
| " xhixhix " | “x” | three |
| " xhixhix " | “嗨” | Two |
| "麦克风" | "麦克风" | one |
| “哈哈” | “嗬” | Zero |
| " xxxxyz " | " xx " | Two |

算法首先,检查来自源文本的第一个字符和搜索字符串是否匹配。如果是这种情况,则增加数量,并继续搜索。如果没有匹配,那么源文本将被缩短第一个字符。该过程如前所述递归地继续。终止标准是给定输入的长度小于搜索文本的长度。

static int countSubstrings(final String input, final String valueToFind)
{
    // recursive termination
    if (input.length() < valueToFind.length())
        return 0;

    final int count;
    final String remaining;

    // does the text start with the search string?
    if (input.startsWith(valueToFind))
    {
        // hit: continue the search for the found
        // term after the occurrence
        remaining = input.substring(valueToFind.length());        count = 1;
    }
    else
    {
        // remove first character and search again
        remaining = input.substring(1);        count = 0;
    }

    // recursive descent
    return countSubstrings(remaining, valueToFind) + count;
}

Hint: Possible Variation

您可以想象,现在对需求的一个小修改是找到所有潜在的子字符串,而不是在找到一个子字符串后继续在它们后面搜索。有趣的是,这简化了实现:

static int countSubstringV2(final String input, final String valueToFind)
{
    if (input.length() < valueToFind.length())
        return 0;

    int count = 0;
    if (input.startsWith(valueToFind))        count = 1;

    // remove first character and search again
    final String remaining = input.substring(1);

    return countSubstringV2(remaining, valueToFind) + count;
}

优化算法在原算法中,调用substring()不断创建新的字符串。对于短输入值来说,这并没有那么引人注目。但是如果你假设一个非常长的文本,这可能已经是不利的了。

那么,优化可能是什么样的呢?您仍然从左到右遍历输入。但与其缩短输入,不如使用位置指针left更可行。这将导致以下调整:

  1. 由于文本没有变短,现在必须从原始长度中减去left的值。

  2. 您已使用startsWith()进行匹配比较。方便的是,有一种允许提供偏移的变体。

  3. 如果有匹配的话,你要把位置指针移动搜索模式中的字符数;否则将减少一个位置。

这导致了以下实现:

static int countSubstringOpt(final String input, final String valueToFind)
{
    return countSubstringOpt(input, valueToFind, 0);
}

static int countSubstringOpt(final String input, final String valueToFind,
                             int left)
{
    if (input.length() – left < valueToFind.length())
        return 0;

    int count = 0;
    if (input.startsWith(valueToFind, left))
    {
        left += valueToFind.length();
        count = 1;
    }
    else
    {
        // überspringe Zeichen und suche erneut
        left++;
    }

    return countSubstringOpt(input, valueToFind, left) + count;
}

确认

以下输入显示了三种变型的正确操作:

@ParameterizedTest(name = "countSubstring({0}) = {1}")
@CsvSource({ "xhixhix, x, 3", "xhixhix, hi, 2", "mic, mic, 1",
             "haha, ho, 0", "xxxxyz, xx, 2", "xxxx, xx, 2",
             "xx-xxx-xxxx-xxxxx-xxxxxx, xx, 9",
             "xx-xxx-xxxx-xxxxx-xxxxxx, xxx, 5"})
void countSubstring(String input, String searchFor, int expected)
{
    int result = Ex12_CountSubstrings.countSubstring(input, searchFor);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "countSubstringV2({0}) = {1}")
@CsvSource({ "xhixhix, x, 3", "xhixhix, hi, 2", "mic, mic, 1",
             "haha, ho, 0", "xxxxyz, xx, 3", "xxxx, xx, 3",
             "xx-xxx-xxxx-xxxxx-xxxxxx, xx, 15",
             "xx-xxx-xxxx-xxxxx-xxxxxx, xxx, 10"})
void countSubstringV2(String input, String searchFor, int expected)
{
    int result = Ex12_CountSubstrings.countSubstringV2(input, searchFor);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "countSubstringOpt({0}) = {1}")
@CsvSource({ "xhixhix, x, 3", "xhixhix, hi, 2", "mic, mic, 1",
             "haha, ho, 0", "xxxxyz, xx, 2", "xxxx, xx, 2",
             "xx-xxx-xxxx-xxxxx-xxxxxx, xx, 9",

             "xx-xxx-xxxx-xxxxx-xxxxxx, xxx, 5"})
void countSubstringOpt(String input, String searchFor, int expected)
{
    int result = Ex12_CountSubstrings.countSubstringOpt(input, searchFor, 0);

    assertEquals(expected, result);
}

在第一个和最后一个测试案例中,相同的信息用于@CsvSource。也可以将它们放在一个文件中,并按如下方式引用它们:

@ParameterizedTest(name="countSubstringOpt({0}) = {1}" )
@CsvFileSource(resources = "countsubstringinputs.csv", numLinesToSkip = 1)
void countSubstringOpt2(String input, String searchFor, int expected)
{
    int result = Ex12_CountSubstrings.countSubstringOpt(input, searchFor, 0);

    assertEquals(expected, result);
}

CSV 文件将如下所示:

# input,searchFor,expectedxhixhix,x,3
xhixhix,hi,2mic,mic,1
...

3.3.13 解决方案 13:统治者(★★✩✩✩)

在介绍中,我展示了如何使用递归绘制一个简单的尺子形状以及一个风格化的雪花(见图 3-3 )。在这个练习中,你要模仿一个英式尺子。这涉及到将一英寸的面积分成 1/2、1/4 和 1/8。这样做,冲程的长度每次减少一。

示例输出应该如下所示:

---- 0
-
--
-
---
-
--
-
---- 1
-
--
-
---
-
--
-
---- 2

算法完整英寸标记的绘制在一个循环中完成。中间线在方法drawInterval()中生成。这又利用了线分布的递归性质。围绕每条稍长的中心线画一条较短的线。只要行长度大于或等于 1,就会重复这一过程。

static void drawRuler(final int majorTickCount, final int maxLength)
{
    drawLine(maxLength, "0");

    for (int i = 1; i <= majorTickCount; i++)
    {
        drawInterval(maxLength – 1);
        drawLine(maxLength, "" + i);
    }
}

最后,您需要两个助手方法来绘制一个区间和一条指定长度的线,包括一个可选的标记(用于完整的英寸数)。您可以使用来自类String的 Java 11 功能repeat()来实现:

static void drawInterval(final int centerLength)
{
    if (centerLength > 0)
    {
        drawInterval(centerLength – 1);
        drawLine(centerLength, "");
        drawInterval(centerLength – 1);
    }
}

static void drawLine(final int count, final String label)
{
    System.out.println("–".repeat(count) + " " + label);
}

确认

对于测试,调用drawRuler()方法如下:

jshell> drawRuler(3, 4)
---- 0
-
--
-
---
-
--
-
---- 1
-
--
-
---
-
--
-
---- 2
-
--
-
---
-
--
-
---- 3

四、字符串

字符串是提供多种方法的字符序列。在这一章中,你将学习这个主题,并通过各种练习进行练习。

4.1 导言

对于处理字符串,有类StringStringBuffer,StringBuilder。三者都满足接口java.lang.CharSequence

接口字符序列

只读接口CharSequence主要提供对类型为char的单个字符以及类型为CharSequence的字符序列的索引访问。为此,特别声明了以下方法:

public interface CharSequence
{
    public char charAt(int index);
    public int length();
    public CharSequence subSequence(int start, int end);
    public String toString();
}

因此,接口CharSequence使得以索引方式处理字符串成为可能,而无需具体了解具体类型。这使得接口更加通用。

Attention: Things to know about the Interface Charsequence

接口CharSequence没有断言equals(Object)hashCode()的行为。因此,equals(Object)比较两个CharSequence实例的结果是不确定的。例如,如果两个实例都是类型String,如果文本内容相同,结果将为真。然而,CharSequence实例也可以是StringBuffer类型。类型StringStringBuffer的比较总是产生false。但是,从 Java 11 开始,CharSequence.compare(CharSequence, CharSequence)对于在文本上比较两个 CharSequences 很有用。

类别字符串

java.lang.String表示字符序列,在 Java 8 之前,这些字符序列由 Unicode 字符组成,并将它们的内容存储为数组char。从 Java 9 开始,内容被建模为byte[],并根据编码进行评估。字符串可以通过类string的构造函数调用来创建,也可以作为引用字符串来创建,如下面两行所示:

final String stringObject = new String("New String Object");
final String stringLiteral = "Stringliteral";

与实践相关的方法

以下方法对于练习和练习尤为重要:

  • length()获取字符串的长度。

  • isEmpty()检查字符串是否为空。

  • trim()删除文本开头和结尾的空白。

  • toUpperCase()/toLowerCase()创建一个仅由大写和小写字母组成的新字符串。

  • c harAt(index)提供对单个字符的基于索引的访问。

  • toCharArray()根据字符串创建相应的char[]

  • chars()基于字符串创建一个IntStream。之后,stream API 的丰富功能就可用了。

  • substring(startIndexIncl)将一个部分提取到一个新字符串中,该新字符串由从给定起始索引到原始字符串末尾的原始字母组成。

  • substring(startIndexIncl, endIndexExcl)从给定的起始索引到结束索引(不包括)提取由原始字母组成的新子串。

  • indexOf(String)检查提供的子字符串是否包含在主字符串中,并返回位置(如果没有找到匹配项,则返回-1)。

Java 11 提供了其他有趣的方法,例如,将一个字符串重复 n 次(repeat(n)),基于 Unicode 的空格测试(isBlank()),以及删除空格的方法(strip()stripLeading()stripTrailing())。

4.1.3 类 StringBuffer 和 StringBuilder

通常,在处理文本信息时,需要对现有的字符串进行修改。然而,对于String类的实例,由于它们的不变性,这只能通过诸如构造新对象的技巧来实现。或者,StringBufferStringBuilder类可以用于字符串操作。两者都有相同的 API,不同之处在于StringBuffer类的方法是同步的。对于StringBuilder类的方法来说,情况并非如此,只要只有一个线程在处理它,这并不重要。

Attention: String Concatenations and Performance

一个常见的技巧是在准备大文本输出时使用StringBufferStringBuilder而不是+运算符。由于 Java 编译器和 JVM 都自动执行各种优化,您应该更喜欢使用+运算符的 1 简单字符串连接的可读性。例如,

String example = "text start" + "XXX" + "text end";

比像这样的结构更容易阅读

final String example = new StringBuilder().append("text start").
                                           append("XXX").
                                           append("text end").toString();

因此,只有对于真正性能关键的部分,建议使用StringBuilder对象实现字符串连接。对于这本书,我更喜欢可读性和可理解性,因此很少使用StringBuilder,只有当String中没有提供的特殊功能令人感兴趣时。

附加功能和与类字符串的比较

方便的是,StringBufferStringBuilder类都有删除操作。通过方法deleteCharAt()delete(),可以从字符串表示中删除字符。一个名为insert()的类似方法允许你插入字符。

不过StringBuilderStringBuffer都有一定的缺点。他们缺乏各种方法,如toLowerCase()toUpperCase()。相反,它们提供了(不太常用的)reverse()方法,以相反的顺序返回内容。表 4-1 显示了String类的一些重要方法以及到StringBufferStringBuilder的映射。

表 4-1

重要字符串方法的映射

|

字符串 1

|

字符串缓冲区/字符串生成器

|
| --- | --- |
| +, +=, concat() | append() |
| replace(), subString() | replace(), subString() |
| indexOf(), startsWith() | indexOf() |
| endsWith() | lastIndexOf() |
| -没有可用方法!—— | reverse() |
| -没有可用方法!—— | insert() |
| -没有可用方法!—— | delete(), deleteCharAt() |
| toUpperCase()/toLowerCase() | -没有可用方法!—— |

4.1.4 阶级性

Character类抽象出单个字符,并可以通过各种助手方法提供某些信息,比如它是字母还是空格:

  • isLetter()检查字符是否代表字母。

  • isDigit()检查字符是否对应十进制数字。

  • isUpperCase()/isLowerCase()检查字符是代表大写字母还是小写字母。

  • 将给定字符转换成大写或小写字母。

  • isWhiteSpace()检查字符是否被解释为空白(即空格、制表符等)。).

  • getNumericValue()获取数值。这对于数字非常方便,对于十六进制数字也是如此。

例子

让我们看一个小例子:

System.out.println(Character.getNumericValue('0'));
System.out.println(Character.getNumericValue('7'));
System.out.println(Character.getNumericValue('9'));

System.out.println(Character.getNumericValue('A'));
System.out.println(Character.getNumericValue('a'));
System.out.println(Character.getNumericValue('F'));
System.out.println(Character.getNumericValue('f'));

System.out.println(Character.getNumericValue('Z'));
System.out.println(Character.getNumericValue('z'));

上面的行提供了以下输出:

0
7
9
10
10
15
15
35
35

正如您很容易看到的,如果您必须在不同的数字系统之间执行转换,或者如果您想要获得一个字母的数值,那么getNumericValue()方法非常有用。

4.1.5 与字符和字符串相关的示例

为了结束介绍,让我们看两个使用CharacterString classes的例子。

自制字符转换

使用Character类,您可以执行从文本数字到数字值的转换。对于十进制数字,以下转换是常见的。类似的东西可以用于字母表中的字母。

int digitValue = digitAsChar - '0';
int posOfChar = currentChar - 'A';

对于十六进制数,则需要进一步区分。下面以十六进制数转换为十进制数为例来说明getNumericValue()的优势。下面我展示了哪些步骤是必要的——首先使用方法getNumericValue(),其次使用自定义创建hexDigitToDecimal()。请注意,自定义变量不支持小写十六进制数!

static int convertToDecimal(final String hexDigits)
{
    int valueOldStyle = 0;
    int valueNewStyle = 0;

    for (int i = 0; i < hexDigits.length(); i++)
    {
        final char currentChar = hexDigits.charAt(i);

        // OLD and cumbersome: invoking own method
        int digitValueOld = hexDigitToDecimal(currentChar);
        valueOldStyle = valueOldStyle * 16 + digitValueOld;
        // NEW and short and crisp: JDK Method
        int digitValue = Character.getNumericValue(currentChar);
        valueNewStyle = valueNewStyle * 16 + digitValue;
    }
    return valueNewStyle;
}

// OLD and cumbersome: Implementation of own method
static int hexDigitToDecimal(final char currentChar)
{
    if (Character.isDigit(currentChar))
        return currentChar - '0';

    // "Optimistische" Annahme: A ... F
    return currentChar - 'A' + 10;
}

自制的转换是脆弱的,尤其依赖于正确的字符——所以理想情况下,您应该事先进行有效性检查。

可能的可读选择要将一个十六进制数的字符转换成十进制值,可以使用indexOf()来确定在预定义值集中的位置。然而,前面提到的小写字母作为数字的弱点仍然存在。

static int hexDigitToDecimalAlternative(final char hexDigit)
{
    final int position = "0123456789ABCDEF".indexOf(hexDigit);
    if (position < 0)
        throw new IllegalArgumentException("invalid char: " + hexDigit);

    return position;
}

其他特点很多时候,我们只会想到普通的汉字,或许还有元音字母。因此,人们可以直观地假设isDigit()只检查数字 0 到 9 的 ASCII 字符。但事实并非如此!还有其他(更有趣的)数字可以正确转换。这里getNumericValue()的优势是显而易见的:

System.out.println("\u0669");
System.out.println(Character.isDigit('\u0669'));
System.out.println(Character.getNumericValue('\u0669'));
System.out.println(hexDigitToDecimal('\u0669'));

这导致了图 4-1 所示的输出。

img/519691_1_En_4_Fig1_HTML.jpg

图 4-1

数字的特殊表示

示例:字符串处理

以类String为例,您想要计算每个字母出现的次数,同等对待小写和大写字母。对于文本“Otto ”,通过将其转换为小写字母,您会得到 2 x t 和 2 x o。这种处理也被称为直方图。直方图是对象分布的表示,通常是数值。从摄影中已知图像的亮度分布。在下文中,它涉及文本的字母频率的分布和/或确定:

static Map<Character, Integer> generateCharacterHistogram(final String word)
{
    final Map<Character, Integer> charCountMap = new TreeMap<>();

    final char[] chars = word.toLowerCase().toCharArray();
    for (char currentChar : chars)
    {
        if (Character.isLetter(currentChar))
        {
           // Trick, but attention to the order!
           charCountMap.putIfAbsent(currentChar,  0);
           charCountMap.computeIfPresent(currentChar,
                                         (key, value) -> value + 1);

           // Alternative
           // final int count = charCountMap.getOrDefault(currentChar, 0);
           // charCountMap.put(currentChar, count + 1);
        }
    }
    return charCountMap;
}

让我们在 JShell 中尝试一下:

jshell> generateCharacterHistogram("Otto")
$9 ==> {o=2, t=2}

jshell> generateCharacterHistogram("Hello Michael")
$10 ==> {a=1, c=1, e=2, h=2, i=1, l=3, m=1, o=1}

jshell> generateCharacterHistogram("Java Challenge, Your Java-Training")
$11 ==> {a=6, c=1, e=2, g=2, h=1, i=2, j=2, l=2, n=3, o=1, r=2, t=1, u=1, v=2, y=1}

Note: Assistance in Java 8

顺便说一下,在 Java 8 中,几个有用的方法被添加到了Map<K,V>接口中,其中包括:

  • putIfAbsent()

  • computeIfPresent()

它们通常允许更容易地编写算法。请记住,调用的顺序很重要。

4.2 练习

4.2.1 练习 1:数字转换(★★✩✩✩)

基于一个字符串,实现二进制数的验证、转换以及十六进制数的验证。

Note

转换可以用Integer.parseInt(value, radix)解决,二进制数以 2 为基数,十六进制数以 16 为基数。不要显式使用这些,而是自己实现。

例子

|

投入

|

方法

|

结果

|
| --- | --- | --- |
| “10101” | isBinaryNumber() | 真实的 |
| “111” | binarutodecimal_) | seven |
| “AB” | 十六进制() | One hundred and seventy-one |

练习 1a (★✩✩✩✩)

编写方法boolean isBinaryNumber(String),检查给定的字符串是否只由字符 0 和 1 组成(即表示一个二进制数)。

练习 1b (★★✩✩✩)

编写方法int binaryToDecimal(String),将表示为字符串的(有效)二进制数转换为相应的十进制数。

练习 1c (★★✩✩✩)

再次编写整个转换,但这次是十六进制数。

4.2.2 练习 2:加入者(★✩✩✩✩)

练习 2a (★✩✩✩✩)

编写方法String join(List<String>, String),用指定的分隔符字符串连接字符串列表,并将其作为一个字符串返回。最初自己实现,不使用任何特殊的 JDK 功能。

练习 2b (★✩✩✩✩)

在方法String joinStrings(List<String>, String)中使用流 API 中的适当方法实现字符串连接。

例子

|

投入

|

分离器

|

结果

|
| --- | --- | --- |
| [“你好”、“世界”、“消息”] | " +++ " | " hello ++ world ++ message " |
| [“米迦”、“苏黎世”] | “喜欢” | “米莎喜欢苏黎世” |

4.2.3 练习 3:反串(★★✩✩✩)

Write 方法String reverse(String),它反转字符串中的字母并返回结果。自己实现它,不要使用任何特殊的 JDK 功能,比如来自StringBuilder类的reverse()方法。

例子

|

投入

|

结果

|
| --- | --- |
| “ABCD” | " DCBA " |
| “奥托” | “奥托” |
| “彼得 | " RETEP " |

4.2.4 练习 4:回文(★★★✩✩)

练习 4a (★★✩✩✩)

编写方法boolean isPalindrome(String),不管大小写,检查给定的字符串是否是回文。回文是一个从正面和背面读起来都一样的单词。

Note

StringBuilder.reverse()就可以轻松解决验证。明确地不要使用 JDK 组件,而是自己实现功能。

例子

|

投入

|

结果

|
| --- | --- |
| “奥托” | 真实的 |
| " ABCBX " | 错误的 |
| " ABCXcba " | 真实的 |

练习 4b (★★★✩✩)

编写一个扩展,也不考虑空格和标点符号的相关性,允许检查整个句子,如下所示:

Was it a car or a cat I saw?

4.2.5 练习 5:无重复字符(★★★✩✩)

确定给定的字符串是否不包含重复的字母。大写和小写字母不应该有任何区别。为此编写方法boolean checkNoDuplicateChars(String)

例子

|

投入

|

结果

|
| --- | --- |
| “奥托” | 错误的 |
| "阿德里安" | 错误的 |
| “米莎” | 真实的 |
| 《ABCDEFG》 | 真实的 |

4.2.6 练习 6:删除重复的字母(★★★✩✩)

Write 方法String removeDuplicates(String),在给定的文本中每个字母只保留一次,因此删除所有后续的重复字母,而不考虑大小写。但是,应该保留字母的原始顺序。

例子

|

投入

|

结果

|
| --- | --- |
| “香蕉” | “禁令” |
| “拉拉妈妈” | “林” |
| “迈克尔” | “迈克尔” |

4.2.7 练习 7:资本化(★★✩✩✩)

练习 7a (★★✩✩✩)

Write 方法String capitalize(String)将给定文本转换成英文标题格式,其中每个单词都以大写字母开头。

例子

|

投入

|

结果

|
| --- | --- |
| “这是一个非常特别的标题” | “这是一个非常特别的标题” |
| “有效的 java 很棒” | “有效的 Java 很棒” |

练习 7b:修改(★★✩✩✩)

现在假设输入是一个字符串列表,应该返回一个字符串列表,每个单词以大写字母开始。使用以下签名作为起点:

List<String>  capitalize(List<String>  words)

练习 7c:特殊待遇(★★✩✩✩)

在标题中,经常会遇到像“is”或“a”这样的非大写单词的特殊处理。将它实现为方法List<String> capitalizeSpecial(List<String>, List<String>,该方法获取要从转换中排除的单词作为第二个参数。

例子

|

投入

|

例外

|

结果

|
| --- | --- | --- |
| 【“这个”、“是”、“一个”、“标题”】 | [“是”,“一个”] | 【“这个”、“是”、“一个”、“标题”】 |

4.2.8 练习 8:轮换(★★✩✩✩)

考虑两个字符串,str1str2,其中第一个字符串应该比第二个长。弄清楚第一个是否包含另一个。这样做时,第一个字符串中的字符也可以被旋转。字符可以从开头或结尾移动到相反的位置(甚至重复)。为此,创建方法boolean containsRotation(String, String),它在检查过程中不区分大小写。

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| “ABCD” | “ABC” | 真实的 |
| “ABCDEF | 《EFAB》 | 真(“abcdef”↢x 2“CD EFAB”包含“efab”) |
| 《BCDE》 | "欧共体" | 错误的 |
| “挑战” | "壁虎" | 真实的 |

4.2.9 练习 9:格式良好的大括号(★★✩✩✩)

编写方法boolean checkBraces(String),它检查作为字符串传递的圆括号序列是否包含匹配的(正确嵌套的)括号对。

例子

|

投入

|

结果

|

评论

|
| --- | --- | --- |
| "(())" | 真实的 |   |
| "()()" | 真实的 |   |
| "(()))((())" | 错误的 | 虽然相同数量的左大括号和右大括号,但它们没有正确嵌套 |
| "((()" | 错误的 | 没有合适的支撑 |

4.2.10 练习 10:字谜(★★✩✩✩)

术语变位词用于描述两个包含相同频率的相同字母的字符串。在这里,大写和小写不应该有任何区别。写方法boolean isAnagram(String, String)

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| “奥托” | “托托” | 真实的 |
| “玛丽 | “军队” | 真实的 |
| “阿纳纳斯” | “香蕉” | 错误的 |

4.2.11 练习 11:莫尔斯电码(★★✩✩✩)

能够将给定文本翻译成莫尔斯电码字符的书写方法String toMorseCode(String)。它们由每个字母一至四个长短音调的序列组成,用句号(.)或连字符(-)。为了更容易区分,希望在每个音调之间放置一个空格,在每个字母音调序列之间放置三个空格。否则,S(...)和 EEE(...)将无法彼此区分。

为简单起见,将自己限制为字母 E、O、S、T 和 W,编码如下:

|

|

莫尔斯电码

|
| --- | --- |
| E | 。 |
| O | - - - |
| S | ... |
| T | - |
| W | 。- - |

例子

|

投入

|

结果

|
| --- | --- |
| 无线电紧急呼救信号 | ...- - - ... |
| 自录音再现装置发出的高音 | - .- - .。- |
| 西方的 | 。- - ....- |

奖励试着找出字母表中所有字母对应的莫尔斯电码,(比如转换你的名字)。你可以在 https://en.wikipedia.org/wiki/Morse_code 找到必要的提示。

4.2.12 练习 12:模式检查器(★★★✩✩)

编写方法boolean matchesPattern(String, String),该方法根据以单个字符的形式作为第一个参数传递的模式的结构来检查空格分隔的字符串(第二个参数)。

例子

|

输入模式

|

输入文本

|

结果

|
| --- | --- | --- |
| " xyyx " | 《蒂姆·迈克·迈克·蒂姆》 | 真实的 |
| " xyyx " | 《蒂姆·迈克·汤姆·蒂姆》 | 错误的 |
| " xyxx " | 《蒂姆·迈克·迈克·蒂姆》 | 错误的 |
| " xxxx " | "团队团队团队" | 真实的 |

4.2.13 练习 13:网球比分(★★★✩✩)

编写方法String tennisScore(String, String, String),根据两个玩家 PL1 和 PL2 的文本分数,以熟悉的风格发布公告,如十五爱优势玩家 X 。因此,他们的分数以格式< PL1 分> : < PL2 分>给出。

以下计数规则适用于网球比赛:

  • 当玩家达到 4 分或更多,并且领先至少 2 分时,游戏获胜(游戏)。

  • 从 0 到 3 的分数被命名为爱,十五,三十和四十。

  • 在至少 3 分和平局的情况下,这被称为平手。

  • 至少有 3 分和 1 分的差距,对于多一分的人来说,这叫优势

例子

|

投入

|

得分

|
| --- | --- |
| 1:0 米夏蒂姆 | 《十五个爱》 |
| 2:2 米夏蒂姆 | “三点半” |
| 2:3 米夏蒂姆 | “三点四十” |
| 3:3 米夏蒂姆 | “平手” |
| 4:3 米夏蒂姆 | “优势米查” |
| 4:4 米夏蒂姆 | “平手” |
| 5:4 米夏蒂姆 | “优势米查” |
| 6:4 米夏蒂姆 | 《游戏米莎》 |

4.2.14 练习 14:版本号(★★✩✩✩)

编写方法int compareVersions(String, String),允许你以的格式比较版本号。小调互相贴片——因此贴片的规格是可选的。特别是,返回值应该与来自Comparator<T>接口的int compare(T, T)方法兼容。

例子

|

版本 1

|

版本 2

|

结果

|
| --- | --- | --- |
| 1.11.17 | 2.3.5 | < |
| Two point one | 2.1.3 | < |
| 2.3.5 | Two point four | < |
| Three point one | Two point four | > |
| Three point three | 3.2.9 | > |
| 7.2.71 | 7.2.71 | = |

奖励使用接口Comparator<T>实现功能。

4.2.15 练习 15:转换 strToLong (★★✩✩✩)

将一个字符串转换成一个long。自己写方法long strToLong(String)

Note

使用long.parseLong(value)可以轻松实现转换。不要显式使用它,而是自己实现整个转换。

例子

|

投入

|

结果

|
| --- | --- |
| “+123” | One hundred and twenty-three |
| “-123” | -123 |
| “7271” | Seven thousand two hundred and seventy-one |
| “ABC” | IllegalArgumentException |
| “0123” | 83(对于奖励任务) |
| “-0123” | -83(对于奖励任务) |
| “0128” | IllegalArgumentException(奖励任务) |

奖励启用八进制数解析。

4.2.16 练习 16:打印塔(★★★✩✩)

编写方法void printTower(int),将堆叠在一起的 n 片的塔表示为 ASCII 图形,用字符#表示,并绘制一条下边界线。

示例高度为 3 的塔应该是这样的:

    |
  # | #
 ## | ##
### | ###
---------

4.3 解决方案

4.3.1 解决方案 1:数字转换(★★✩✩✩)

基于一个字符串,实现二进制数的验证、转换以及十六进制数的验证。

Note

转换可以用Integer.parseInt(value, radix)解决,二进制数以 2 为基数,十六进制数以 16 为基数。不要显式地使用它们,而是自己实现它们。

例子

|

投入

|

方法

|

结果

|
| --- | --- | --- |
| “10101” | isBinaryNumber() | 真实的 |
| “111” | binarutodecimal_) | seven |
| “AB” | 十六进制() | One hundred and seventy-one |

解决方案 1a (★✩✩✩✩)

编写方法boolean isBinaryNumber(String),检查给定的字符串是否只由字符 0 和 1 组成(即表示一个二进制数)。

算法Java 中的实现从头到尾一个字符一个字符的遍历字符串,检查当前字符是 0 还是 1。如果检测到另一个字符,循环终止,然后返回false:

public static boolean isBinaryNumber(final String number)
{
    boolean isBinary = true;

    int i = 0;
    while (i < number.length() && isBinary)
    {
        final char currentChar = number.charAt(i);
        isBinary = (currentChar == '0' || currentChar == '1');

        i++;
    }

    return isBinary;
}

备选案文 1b

编写方法int binaryToDecimal(String),将表示为字符串的(有效)二进制数转换为相应的十进制数。

算法从左到右逐个字符地遍历字符串,并将每个字符作为二进制数字处理。当前字符用于通过将先前转换的值乘以 2 并加上当前值来计算值。后者由减法number.charAt(i) - '0'决定,正如你在十进制数的介绍部分所学的。可以更清楚地制定算法,这意味着无需特殊处理,因为有效输入由之前实现的方法isBinaryNumber()确保。

public static int binaryToDecimal(final String number)
{
    if (!isBinaryNumber(number))
        throw new IllegalArgumentException(number + " is not a binary number");

    int decimalValue = 0;
    for (int i = 0; i < number.length(); i++)
    {
        final int current = number.charAt(i) - '0';
        decimalValue = decimalValue * 2 + current;
    }

    return decimalValue;
}

解决方案 1c

再次编写整个转换,但这次是十六进制数。

算法对于十六进制数,因子必须改为 16。另外,getNumericValue()适用于确定值。

public static int hexToDecimal(final String number)
{
    if (!isHexNumber(number))
        throw new IllegalArgumentException(number + " is not a hex number");

    int decimalValue = 0;
    for (int i = 0; i < number.length(); i++)
    {
        final char currentChar = number.charAt(i);
        final int value = Character.getNumericValue(currentChar);
        decimalValue = decimalValue * 16 + value;
    }

    return decimalValue;
}

有效十六进制数的检查使用十进制数介绍部分介绍的isDigit()方法,并手动检查从 A 到 F 的字母:

public static boolean isHexNumber(final String number)
{
    boolean isHex = true;
    final String upperCaseNumber = number.toUpperCase();

    int i = 0;
    while (i < upperCaseNumber.length() && isHex)
    {
        final char currentChar = upperCaseNumber.charAt(i);
        isHex = Character.isDigit(currentChar) ||
                currentChar >= 'A' && currentChar <= 'F';

        i++;
    }

    return isHex;
}

这个挑战是一个搜索问题。搜索字符串中第一个出现的字母,对于二进制数,该字母不是 0 或 1,对于十六进制数,该字母不在 0 到 F 的范围内。像这样的搜索问题也可以使用一个while循环来解决——那么i >= length() || !condition就适用了。

int i = 0;
while (i < input.length() && condition)
{
    // teste Bedingung
    i++;
    }

Hint: Possible Alternatives and Optimizations

尽管所示的实现相当简单,但有一些非常优雅的替代方法也很容易阅读和理解,即检查字母 A 到 F 的序列,以查看是否包含该字符:

isHex = Character.isDigit(currentChar) || "ABEDEF".contains(currentChar);

实际上,基于最后一个想法,完整的过程可以被缩短:

isHex = "0123456789ABCDEF".indexOf(currentChar) >= 0;

或者,通过使用正则表达式,甚至可以使整个检查大大缩短:

static boolean isHexNumber(final String number)
{
    return number.matches("^[0-9a-fA-F]+$");
}

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "isBinaryNumber({0}) => {1}")
@CsvSource({ "10101, true", "222, false", "12345, false" })
public void isBinaryNumber(String value, boolean expected)
{
    boolean result = Ex01_BasicNumberChecks.isBinaryNumber(value);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "binaryToDecimal({0}) => {1}")
@CsvSource({ "111, 7", "1010, 10", "1111, 15", "10000, 16" })
public void binaryToDecimal(String value, int expected)
{
    int result = Ex01_BasicNumberChecks.binaryToDecimal(value);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "hexToDecimal({0}) => {1}")
@CsvSource({ "7, 7", "A, 10", "F, 15", "10, 16" })
public void hexToDecimal(String value, int expected)
{
    int result = Ex01_BasicNumberChecks.hexToDecimal(value);

    assertEquals(expected, result);
}

4.3.2 解决方案 2:加入者(★✩✩✩✩)

解决方案 2a (★✩✩✩✩)

编写方法String join(List<String>, String),用指定的分隔符字符串连接字符串列表,并将其作为一个字符串返回。最初自己实现,不使用任何特殊的 JDK 功能。

例子

|

投入

|

分离器

|

结果

|
| --- | --- | --- |
| [“你好”、“世界”、“消息”] | " +++ " | " hello ++ world ++ message " |
| [“米迦”、“苏黎世”] | “喜欢” | “米莎喜欢苏黎世” |

算法从前到后遍历值列表。在每种情况下,将文本插入到StringBuilder中,添加分隔符字符串,并重复此操作,直到最后一个值。作为特殊处理,不要在最后一个字符串后添加分隔符。

static String join(final List<String> values, final String delimiter)
{
    var sb = new StringBuilder();
    for (int i = 0; i < values.size(); i++)
    {
        sb.append(values.get(i));

        // No separator after last occurrence
        if (i < values.size() - 1)
        {
            sb.append(delimiter);
        }
    }
    return sb.toString();
}

解决方案 2b (★✩✩✩✩)

在方法String joinStrings(List<String>, String)中使用流 API 中的适当方法实现字符串连接。

算法通过使用流 API,可以用简洁易懂的方式很好地表达挑战,无需任何特殊处理,如下所示:

static String joinStrings(final List<String> values, final String delimiter)
{
    return values.stream().collect(Collectors.joining(delimiter));
}

确认

测试时,使用以下显示正确操作的输入:

@Test
public void testJoinLowLevel()
{
    var result = Ex02_StringJoiner.join(List.of("hello", "world", "message")," +++ ");

    assertEquals("hello +++ world +++ message", result);
}

@Test
public void testJoinStringsWithStream()
{
    var result = Ex02_StringJoiner.joinStrings(List.of("Micha", "Zurich")," likes ");

    assertEquals("Micha likes Zurich", result);
}

4.3.3 解决方案 3:反串(★★✩✩✩)

Write 方法String reverse(String),它反转字符串中的字母并返回结果。自己实现它,不要使用任何特殊的 JDK 功能,比如来自StringBuilder类的reverse()方法。

例子

|

投入

|

结果

|
| --- | --- |
| “ABCD” | " DCBA " |
| “奥托” | “奥托” |
| “彼得 | " RETEP " |

算法最初,一个想法可以是从末尾开始逐个字符地遍历原始字符串,并将相应的字符添加到结果中:

static String reverse(final String original)
{
    String reversed = "";

    for (int i = original.length() - 1; i >= 0; i--)
    {
        char currentChar = original.charAt(i);
        reversed += currentChar;
    }

    return reversed;
}

然而,存在一个小问题:用+=连接字符串可能开销很大,因为这样会创建新的字符串对象。出于这个原因,对于更复杂的操作,使用一个StringBuilder的实例可能会更好。我将在解决方案的第二部分讨论进一步的可能性。

优化算法简单地问自己:例如,如果非常长的字符串需要非常频繁地反转,如何才能更有效地利用内存?

想法是使用toCharArray()将字符串转换为char[],并直接在char[]上工作。此外,还使用了两个名为 leftright 的位置指针,最初指向第一个和最后一个字符。现在你交换相应的字母,位置指针向内移动。只要左右<有效,重复整个过程;如果左>* = 该过程被中止。下面举例说明文本ABCD的流程,其中l代表r代表😗

A B C D
l     r
D B C A
  l r
D C B A
  r l     => end

您可以按如下方式实现所描述的过程:

static String reverseInplace(final String original)
{
    final char[] originalChars = original.toCharArray();

    int left = 0;
    int right = originalChars.length - 1;

    while (left < right)
    {
        final char leftChar = originalChars[left];
        final char rightChar = originalChars[right];

        // swap
        originalChars[left] = rightChar;
        originalChars[right] = leftChar;

        left++;
        right--;
    }

    return String.valueOf(originalChars);
}

确认

让我们编写一个单元测试来验证所需的功能:

@ParameterizedTest(name = "reverse({0}) => {1}")
@CsvSource({ "ABCD, DCBA", "OTTO, OTTO", "PETER, RETEP" })
void testReverse(final String input, final String expectedOutput)
{
    final String result = Ex03_ReverseString.reverse(input);

    assertEquals(expectedOutput, result);
}

您可以对 inplace 版本做类似的事情,但是这里您只使用了 JShell 中的两个调用:

jshell> reverseInplace("ABCD")
$29 ==> "DCBA"

jshell> reverseInplace("PETER")
$30 ==> "RETEP"

4.3.4 解决办法 4:回文

解决方案 4a (★★✩✩✩)

编写方法boolean isPalindrome(String),不管大小写,检查给定的字符串是否是回文。回文是一个从正面和背面读起来都一样的单词。

Note

StringBuilder.reverse()就可以轻松解决验证。明确地不要使用 JDK 组件,而是自己实现功能。

例子

|

投入

|

结果

|
| --- | --- |
| “奥托” | 真实的 |
| " ABCBX " | 错误的 |
| " ABCXcba " | 真实的 |

Job Interview Tips

作为求职面试的一个例子,我再次列出了你可能会问的问题,以澄清任务的范围:

  • 应该区分大小写吗?回答:没有,任何

  • 空格相关吗?回答:先是有,后来没有,然后被忽略

算法如练习 3 反串,字符串表示为char[],你从左向内推进一个位置,从右向内推进一个位置,只要字符匹配,只要左位置仍然小于右位置:

static boolean isPalindrome(final String input)
{
    final char[] chars = input.toLowerCase().toCharArray();

    int left = 0;
    int right = chars.length-1;

    boolean isSameChar = true;
    while (left < right && isSameChar)
    {
        isSameChar = (chars[left] == chars[right]);

        left++;
        right--;
    }

    return isSameChar;
}

带递归的算法:不使用char[]作为辅助数据结构,如何递归求解回文问题?在阅读了第三章并解决了那里给出的一些递归练习题后,你应该能够很容易地实现它。考虑到 helper 方法的策略或习惯用法,下面的递归实现出现了,它从外部开始,总是检查两个字符。只要字符匹配,这就向内继续,并且左边的位置比右边的位置小。

public static boolean isPalindromeRec(final String input)
{
    return isPalindromeRec(input.toLowerCase(), 0, input.length() - 1);
}
static boolean isPalindromeRec(final String input,
                               final int left, final int right)
{
    if (left >= right)
        return true;

    if (input.charAt(left) == input.charAt(right))
    {
        return isPalindromeRec(input, left + 1, right - 1);
    }

    return false;
}

另一种方法是总是用字符来缩短字符串。为什么这个逻辑上的解决方案实际上不那么好?答案是显而易见的:这会导致创建许多临时字符串对象。此外,必须进行大量的复制操作。

备选案文 4b

编写一个扩展,也不考虑空格和标点符号的相关性,允许检查整个句子,如下所示:

Was it a car or a cat I saw?

算法你可以在算法中加入特殊的空格检查。尽管如此,创建一个方法版本并在调用原始方法之前预先替换掉所有不需要的标点和空格还是比较容易的:

public static boolean isPalindrome(final String input,
                                   final boolean ignoreSpacesAndPunctuation)
{
    String adjustedInput = input.toLowerCase();
    if (ignoreSpacesAndPunctuation)
        adjustedInput = input.replaceAll(" |!|\\.", "");

    return isPalindromeRec(adjustedInput);
}

请注意,replaceAll()中使用了正则表达式来从要检查的文本中删除字符,即空格、感叹号和句点。点必须特别屏蔽,因为它代表正则表达式中的任何字符。

确认

为了进行验证,您再次使用以下显示正确操作的输入编写单元测试:

@ParameterizedTest(name = "isPalindromeRec({0} => {1}")
@CsvSource({ "Otto, true",
             "ABCBX, false",
             "ABCXcba, true" })
void isPalindromeRec(String value, boolean expected)
{
    boolean result = Ex04_Palindrome.isPalindromeRec(value);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "''{0}'' should be {1}")
@CsvSource( { "Dreh mal am Herd., true",
              "Das ist kein Palindrom!, false"} )
void isPalindrome(String value, boolean expected)
{
    boolean result = Ex04_Palindrome.isPalindrome(value, true);

    assertEquals(expected, result);
}

Findings: Pay Attention to Comprehensibility

由于其 API 和基于位置/索引的访问,字符串选择迭代解决方案是绝对自然的。如果必须确定数字的回文属性,这将不再方便。这可以通过递归和一些考虑来完成,即使没有 3.3.10 节中练习 10 的解决方案所示的通过转换的迂回方法。在上一个练习中开发了功能reverse(),您可以按如下方式使用它:

static boolean isPalindrome(final String input)
{
    final String upperInput = input.toUpperCase();

    return upperInput.equals(reverse(upperInput));
}

这证明了问题和上下文感知编程能够创建可理解和可维护的解决方案。可理解的、可维护的和可改变的属性在实践中非常重要,因为由于变化的或新的需求,源代码通常比完全从零开始创建更频繁地被修改。

4.3.5 解决办法 5:无重复气体

确定给定的字符串是否不包含重复的字母。大写和小写字母不应该有任何区别。为此编写方法boolean checkNoDuplicateChars(String)

例子

|

投入

|

结果

|
| --- | --- |
| “奥托” | 错误的 |
| "阿德里安" | 错误的 |
| “米莎” | 真实的 |
| 《ABCDEFG》 | 真实的 |

算法在解决这个问题时,你可能会想到将单个字符存储在Set<E>中。从前到后一次遍历输入的一个字符。对于每个字符,检查它是否已经存在于Set<E>中。如果是这样,您遇到了重复的字符并中止处理。否则,您将字符插入到Set<E>中,并继续处理下一个字符。

static boolean checkNoDuplicateChars(final String input)
{
    final char[] allCharsOfInput = input.toLowerCase().toCharArray();

    final Set<Character> containedChars = new HashSet<>();
    for (char currentChar : allCharsOfInput)
    {
        if (containedChars.contains(currentChar))
            return false;

        containedChars.add(currentChar);
    }
    return true;
}

Hint: Possible Alternatives and Optimizations

尽管所示的实现非常清楚,但是通过利用任何字符串都可以使用chars()方法转换为IntStream这一事实,可以找到其他更紧凑的替代方法。试图保持逻辑与实际算法结果相当接近可能如下——其中您需要boxed()int的值转换成Integer。这是将值插入Set<Integer>并以这种方式删除重复值的唯一方法。如果不存在重复项,Set<Integer>的计数必须等于字符串的长度。

static boolean checkNoDuplicateCharsStreamV1(final String input)
{
    return input.toLowerCase().chars().
                               boxed().
                               collect(Collectors.toSet()).
                               size() == input.length();
}

这是更紧凑,但可能不太容易理解的经典版本。但是有一个合适的替代方法:您可以使用distinct()删除所有重复的元素,使用count()获得流中元素的数量。如果不存在重复项,计数必须等于字符串的长度。话多,指令少...整个事情可以用如下一行程序来表达:

boolean checkNoDuplicateCharsWithStreamOpt(final String input)
{
    return input.toLowerCase().chars().distinct().count() == input.length();
}

确认

您再次使用单元测试来验证所需的功能:

@ParameterizedTest(name = "checkNoDuplicateChars({0}) => {1}")
@CsvSource({ "Otto, false", "Adrian, false", "Micha, true", "ABCDEFG, true" })
void checkNoDuplicateChars(final String input, final boolean expected)
{
    var result = Ex05_CheckNoDuplicateChars.checkNoDuplicateChars(input);

    assertEquals(expected, result);
}

4.3.6 解决方案 6:删除重复的信件(★★★✩✩)

Write 方法String removeDuplicates(String),在给定的文本中每个字母只保留一次,因此删除所有后续的重复字母,而不考虑大小写。但是,应该保留字母的原始顺序。

例子

|

投入

|

结果

|
| --- | --- |
| “香蕉” | “禁令” |
| “拉拉妈妈” | “林” |
| “迈克尔 | “迈克尔” |

算法再次,您逐个字符地遍历字符串,并将相应的字符存储在名为alreadySeenSet<E>中。如果当前字符尚未包含在内,它将被添加到Set<E>和结果文本中。但是,如果这样一个字符已经存在,您将继续输入下一个字符。

static String removeDuplicates(final String input)
{
    var result = new StringBuilder();
    var alreadySeen = new HashSet<>();

    for (int i = 0; i < input.length(); i++)
    {
         final char currentChar = input.charAt(i);
         if (!alreadySeen.contains(currentChar))
         {
             alreadySeen.add(currentChar);
             result.append(currentChar);
         }
    }

    return result.toString();
}

优化算法使用 Java 8 板载工具可以更优雅地解决整个练习。使用chars()方法将字符串转换成IntStream的可能性让您受益匪浅。stream API 允许您简单地通过使用distinct()来删除重复项。之后,将数值转换回char类型,最后转换成简短的单字符字符串。反过来,这些通过joining()组合成一个结果。

static String removeDuplicatesImproved(final String input)
{
    return input.chars().distinct().
                         mapToObj(i -> (char) i + "").
                         collect(Collectors.joining());
}

确认

您可以使用以下单元测试来检查重复字母的删除情况:

@ParameterizedTest(name = "removeDuplicates({0}) => {1}")
@CsvSource({ "bananas, bans", "lalalamama, lam", "MICHAEL, MICHAEL" })
void testRemoveDuplicates(final String input, final String expected)
{
    var result = Ex06_DuplicateCharsRemoval.removeDuplicates(input);

    assertEquals(expected, result);
}

对于优化版本,这是以相同的方式完成的。

4.3.7 解决方案 7:资本化(★★✩✩✩)

溶液 7a (★★✩✩✩)

Write 方法String capitalize(String)将给定文本转换成英文标题格式,其中每个单词都以大写字母开头。

例子

|

投入

|

结果

|
| --- | --- |
| “这是一个非常特别的标题” | “这是一个非常特别的标题” |
| “有效的 java 很棒” | “有效的 Java 很棒” |

算法因为字符串是不可变的,所以最初你将内容复制到一个char[]中,在此基础上进行修改。你从前到后遍历这个数组,寻找一个新单词的开头。作为一个指标,你可以使用一面booleancapitalizeNextChar。这表示下一个单词的第一个字母必须大写。最初,这个标志是true,所以当前(第一个)字符被转换成大写字母。这只会发生在字母上,不会发生在数字上。转换后,标志被重置,字母被跳过,直到找到一个空格。然后,您将标志重置为true。重复这个过程,直到到达数组的末尾。最后,从包含修改的数组中创建一个新的字符串。

static String capitalize(final String input)
{
    final char[] inputChars = input.toCharArray();

    boolean capitalizeNextChar = true;
    for (int i = 0; i < inputChars.length; i++)
    {
         var currentChar = inputChars[i];
         if (Character.isWhitespace(currentChar))
         {
             capitalizeNextChar = true;
         }
         else
         {
              if (capitalizeNextChar && Character.isLetter(currentChar))
              {
                  inputChars[i] = Character.toUpperCase(currentChar);
                  capitalizeNextChar = false;
              }
         }
    }
    return new String(inputChars);
}

让我们在 JShell 中尝试整个事情:

jshell> capitalize("everything seems fine")
$14 ==> "Everything Seems Fine"

但是,现在您可能想知道数字后面的字母或其他非字母应该出现的行为:

jshell> capitalize("what happens to -a +b 1c")
$15 ==> "What Happens To -A +B 1C"

Hint: Special Treatment Variant

刚才,我提出了另一个特例。如何处理它是一个定义问题。如果特殊字符后的字母不应转换为大写,这很容易实现。与之前相比,差别是微妙的:在每种情况下,您都删除了isLetter()检查并调用toUpperCase()。这是可能的,因为该方法不仅可以处理字母,还可以处理其他字符。

static String capitalize(final String input)
{
   // ...
        if (Character.isWhitespace(currentChar))
        {
            capitalizeNextChar = true;
        }
        else
        {
            if (capitalizeNextChar)
            {
                // convert to uppercase
                inputChars[i] = Character.toUpperCase(currentChar);
                capitalizeNextChar = false;
            }
        }
   // ...
}

然后,您将获得以下输出:

jshell> capitalize("what happens to -a +b 1c")
$16 ==> "What Happens To -a +b 1c"

解决方案 7b:修改(★★✩✩✩)

现在假设输入是一个字符串列表,应该返回一个字符串列表,每个单词以大写字母开始。使用以下签名作为起点:

List<String> capitalize(List<String> words)

算法首先创建一个列表来存储转换后的单词。然后遍历所有传递的列表元素,并通过调用capitalizeWord()方法来处理每个元素。要将第一个字符转换成大写字母,请用substring(0, 1)分隔。其余字符由substring(1)返回。从两者中构建一个新单词,然后插入到结果中。为了容错,capitalizeWord()方法通过健全性检查来处理空输入,以避免在对substring()的后续调用中出现StringIndexOutOfBoundsException

static List<String> capitalize(final List<String> words)
{
    final List<String> capitalizedWords = new ArrayList<>();

    for (final String word: words)
        capitalizedWords.add(capitalizeWord(word));

    return capitalizedWords;
}

static String capitalizeWord(final String word)
{
    if (word.isEmpty())
       return "";

    final String upperCaseFirstChar = word.substring(0, 1).toUpperCase();
    final String remainingChars = word.substring(1);

    return upperCaseFirstChar + remainingChars;
}

您也可以使用流 API 来表达功能,而不是传统的for循环。有时这增加了源代码的可读性。为了实现这一点,我更喜欢管道布局,其中流方法一个接一个地编写,以反映流中的处理步骤:

static List<String> capitalizeWithStream(final List<String> words)
{
    return words.stream().map(word -> capitalizeWord(word)).
                          collect(Collectors.toList());
}

解决方案 7c:特殊待遇(★★✩✩✩)

在标题中,经常会遇到像“is”或“a”这样的非大写单词的特殊处理。将它实现为一个方法List<String> capitalizeSpecial(List<String>, List<String>,该方法获取要从转换中排除的单词作为第二个参数。

例子

|

投入

|

例外

|

结果

|
| --- | --- | --- |
| 【“这个”、“是”、“一个”、“标题”】 | [“是”,“一个”] | 【“这个”、“是”、“一个”、“标题”】 |

算法之前开发的功能,用一个不应该转换的单词列表来扩展。遍历时,检查当前单词是否是否定列表中的一个。如果是,则不加修改地添加到结果中。否则,您可以像以前一样执行操作。

static List<String> capitalizeSpecial(final List<String> words,
                                      final List<String> ignorableWords)
{
    final List<String> capitalizedWords = new ArrayList<>();

    for (final String word : words)
    {
        if (word.length() > 0)
        {
            if (ignorableWords.contains(word))
                capitalizedWords.add(word);
            else
                capitalizedWords.add(capitalizeWord(word));
        }
    }
    return capitalizedWords;
}

确认

对于测试,您使用以下输入,这些输入显示了正确的操作:

@ParameterizedTest(name = "capitalize({0}) => {1}")
@CsvSource({ "this is a very special title, This Is A Very Special Title", "effective java is great, Effective Java Is Great" })
void capitalize(String input, String expected)
{
    var result = Ex07_Capitalize.capitalize(input);

    assertEquals(expected, result);
}

@Test
void capitalizeWithList()
{
    List<String> input = List.of("this", "is", "a", "special", "title");
    var result = Ex07_Capitalize.capitalize(input);

    assertEquals(List.of("This", "Is", "A", "Special", "Title"), result);
}

@Test
void capitalizeSpecial()
{
    List<String> input = List.of("this", "is", "a", "special", "title");
    var result = Ex07_Capitalize.capitalizeSpecial(input, List.of("is", "a"));

    assertEquals(List.of("This", "is", "a", "Special", "Title"), result);
}

4.3.8 解决方案 8:轮换(★★✩✩✩)

考虑两个字符串,str1str2,其中第一个字符串应该比第二个长。弄清楚第一个是否包含另一个。这样做时,第一字符串内的字符也可以被旋转;字符可以从开头或结尾移动到相反的位置(甚至重复)。为此,创建方法boolean containsRotation(String, String),它在检查过程中不区分大小写。

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| “ABCD” | “ABC” | 真实的 |
| “ABCDEF | 《EFAB》 | 真(“abcdef”↢x 2“CD EFAB”包含“efab”) |
| 《BCDE》 | "欧共体" | 错误的 |
| “挑战” | "壁虎" | 真实的 |

Job Interview Tips: Possible Questions and Solution Ideas

作为求职面试的一个例子,在这里,我再次提到你可能会问一些问题来澄清任务:

  • 旋转的方向是否已知 / ?回答:不,任意

  • 旋转检查应该区分大小写吗?回答:不,同等对待

想法一:暴力:作为第一个想法,你可以尝试所有的组合。不旋转启动。然后向左旋转弦str1并检查该旋转的弦是否包含在str2中。在最坏的情况下,这个过程重复 n 次。这是极其低效的。

想法 2:首先检查旋转是否有意义:解决这个问题的另一个想法是预先收集每个字符串中的所有字符,然后使用containsAll()检查是否包含所有需要的字母。但是即使这样也很费力,并且不能很好地反映要解决的问题。

想法三:现实中的程序:思考一会儿,考虑你可能如何在一张纸上解决问题。在某些时候,你会想把这个单词按顺序写两次:

ABCDEF       EFAB
ABCDEFABCDEF EFAB

算法检查一个字符串是否可以出现在另一个字符串中(如果旋转的话),可以通过在另一个字符串后面写入更长的字符串的简单技巧非常优雅地解决。在组合中,检查要搜索的字符串是否包含在其中。使用这种方法,解决方案非常短而且非常简单:

static boolean containsRotation(final String str1, final String str2)
{
    final String newDoubledStr1 = (str1 + str1).toLowerCase();

    return newDoubledStr1.indexOf(str2.toLowerCase()) != -1;
}

确认

测试时,使用以下显示正确操作的输入:

@ParameterizedTest(name = "{1} in {0}{0} => {2}")
@CsvSource({ "ABCD, ABC, true", "ABCDEF, EFAB, true", "BCDE, EC, false",
             "Challenge, GECH, true"})
void containsRotation(String value, String rotatedSub, boolean expected)
{
    boolean result = Ex08_RotationV2.containsRotation(value, rotatedSub);

    assertEquals(expected, result);
}

4.3.9 解决方案 9:格式良好的括号(★★✩✩✩)

编写方法boolean checkBraces(String),它检查作为字符串传递的圆括号序列是否包含匹配的(正确嵌套的)括号对。

例子

|

投入

|

结果

|

评论

|
| --- | --- | --- |
| "(())" | 真实的 |   |
| "()()" | 真实的 |   |
| "(()))((())" | 错误的 | 虽然相同数量的左大括号和右大括号,但它们没有正确嵌套 |
| "((()" | 错误的 | 没有合适的支撑 |

不经过太多考虑,人们可能会尝试所有可能的组合。经过一番思考,你大概得出了以下优化:你只统计左大括号的个数,并适当地与右大括号的个数进行比较。在开始大括号之前,您必须考虑结束大括号的细节。如下进行:从前到后遍历字符串。如果当前字符是左大括号,则将左大括号的计数器加 1。如果是右大括号,则将计数器减一。如果计数器低于 0,您将遇到一个没有相应的左大括号的右大括号。最后,计数器必须等于 0,以便它表示正确的支撑。

static boolean checkBraces(final String input)
{
    int openingCount = 0;

    for (int i = 0; i < input.length(); i++)
    {
        final char ch = input.charAt(i);

        if (ch == '(')
        {
            openingCount++;
        }
        else if (ch == ')')
        {
            openingCount--;
            if (openingCount < 0)
                return false;
        }
    }

    return openingCount == 0;
}

确认

使用参数化测试的以下输入来测试您新开发的检查是否正确——使用附加提示参数作为技巧,这不用于测试,而仅用于准备信息性 JUnit 输出:

@ParameterizedTest(name = "checkBraces(''{0}'') -- hint: {2}")
@CsvSource({ "(()), true, ok",
             "()(), true, ok",
             "(()))((()), false, not properly nested",
             "((), false, no suitable bracing" })
void checkBraces(String input, boolean expected, String hint)
{
    boolean result = Ex09_SimpleBracesChecker.checkBraces(input);

    assertEquals(expected, result);
}

4.3.10 解决办法 10: Anagram

术语变位词用于描述两个包含相同频率的相同字母的字符串。在这里,大写和小写不应该有任何区别。写方法boolean isAnagram(String, String)

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| “奥托” | “托托” | 真实的 |
| “玛丽 | “军队” | 真实的 |
| “阿纳纳斯” | “香蕉” | 错误的 |

算法练习的描述已经为你如何进行提供了线索。首先,用方法calcCharFrequencies(String)将单词转换成直方图。在这里,您逐个字符地遍历相应的单词,并填充一个Map<K,V>。这是为两个单词做的。之后,您可以轻松地比较这两张地图:

static boolean isAnagram(final String str1, final String str2)
{
    final Map<Character, Integer> charCounts1 = calcCharFrequencies(str1);
    final Map<Character, Integer> charCounts2 = calcCharFrequencies(str2);

    return charCounts1.equals(charCounts2);
}

static Map<Character, Integer> calcCharFrequencies(final String input)
{
    final Map<Character, Integer> charCounts = new TreeMap<>();

    for (char currentChar : input.toUpperCase().toCharArray())
    {
        charCounts.putIfAbsent(currentChar, 0);
        charCounts.computeIfPresent(currentChar, (key, value) -> value + 1);
    }

    return charCounts;
}

确认

对于测试,使用以下显示正确功能的输入:

@ParameterizedTest(name = "isAnagram({0}, {1}) => {2}")
@CsvSource({ "Otto, Toto, true", "Mary, Army, true",
             "Ananas, Bananas, false" })
void testIsAnagram(String value1, String value2, boolean expected)
{
    boolean result = Ex10_AnagramChecker.isAnagram(value1, value2);

    assertEquals(expected, result);
}

4.3.11 解决方案 11:莫尔斯电码(★★✩✩✩)

能够将给定文本翻译成莫尔斯电码字符的书写方法String toMorseCode(String)。它们由每个字母一至四个长短音调的序列组成,用句点(.)或连字符(-)。为了更容易区分,希望在每个音调之间放置一个空格,在每个字母音调序列之间放置三个空格。否则,S(...)和 EEE(...)将无法彼此区分。

为简单起见,将自己限制为字母 E、O、S、T 和 W,编码如下:

|

|

莫尔斯电码

|
| --- | --- |
| E | 。 |
| O | - - - |
| S | ... |
| T | - |
| W | 。- - |

例子

|

投入

|

结果

|
| --- | --- |
| 无线电紧急呼救信号 | 。。。- - - .。。 |
| 自录音再现装置发出的高音 | - .- - .。- |
| 西方的 | 。- - .。。。- |

算法一个字符一个字符地遍历字符串,当前字符被映射到相应的莫尔斯电码。方法convertToMorseCode(char)执行此任务:

static String toMorseCode(final String input)
{
    final StringBuilder convertedMsg = new StringBuilder();

    final String upperCaseInput = input.toUpperCase();
    for (int i = 0; i < upperCaseInput.length(); i++)
    {
        var currentChar = upperCaseInput.charAt(i);
        var convertedLetter = convertToMorseCode(currentChar);

        convertedMsg.append(convertedLetter);
        convertedMsg.append("   ");
    }

    return convertedMsg.toString().trim();
}

要映射单个字母,使用switch——Java 14 中新引入的语法比以前更加优雅地实现了这一点。 2

static String convertToMorseCode(char currentChar)
{
    return switch (currentChar)
    {
       case 'E' -> ".";
       case 'O' -> "- - -";
       case 'S' -> ". . .";
       case 'T' -> "-";
       case 'W' -> ". - -";
       default -> "?";
    };
}

奖金

尝试找出字母表中所有字母对应的莫尔斯电码,(例如,转换你的名字)。你可以在 https://en.wikipedia.org/wiki/Morse_code 找到必要的提示。

算法您可以方便地用一个查找映射替换switch语句,然后只访问那个映射,而不是使用方法convertToMorseCode():

static Map<Character, String> lookupMap = new HashMap<>()
{{
    put('A', ". -");
    put('B', "- . . .");
    put('C', "- . - .");
    put('D', "- . .");
    put('E', ".");
    put('F', ". . - .");
    put('G', "- - .");
    put('H', ". . . .");
    put('I', ". .");
    // ..
    put('R', ". - .");
    put('O', "- - -");
    put('S', ". . .");
    put('T', "-");
    put('W', ". - -");
    // ...
}};
static String convertToMorseCode(char currentChar)
{
    return lookupMap.getOrDefault(currentChar, "?");
}

为了进行实验,请使用互联网上的以下网站来验证您的尝试: https://gc.de/gc/morse/ 。它允许你把普通的明文转换成莫尔斯电码,但最重要的是,把莫尔斯电码转换成明文(反过来)。

确认

让我们使用单元测试进行检查,如下所示:

@ParameterizedTest(name = "toMorseCode({0}) => ''{1}''")
@CsvSource({ "SOS, . . .   - - -   . . .", "TWEET, -   . - -   .   .   -",
             "OST, - - -   . . .   -", "WEST, . - -   .   . . .   -" })
void testToMorseCode(String input, String expected)
{
    var result = Ex11_MorseCode.toMorseCode(input);

    assertEquals(expected, result);
}

4.3.12 解决方案 12:模式检查器(★★★✩✩)

编写方法boolean matchesPattern(String, String),该方法根据以单个字符的形式作为第一个参数传递的模式的结构来检查空格分隔的字符串(第二个参数)。

例子

|

输入模式

|

输入文本

|

结果

|
| --- | --- | --- |
| " xyyx " | 《蒂姆·迈克·迈克·蒂姆》 | 真实的 |
| " xyyx " | 《蒂姆·迈克·汤姆·蒂姆》 | 错误的 |
| " xyxx " | 《蒂姆·迈克·迈克·蒂姆》 | 错误的 |
| " xxxx " | "团队团队团队" | 真实的 |

Job Interview Tips: Problem Solving Strategies

像这样的练习,你应该总是问几个问题,以澄清背景,并获得更好的理解。对于此示例,可能的问题包括:

  1. 模式仅限于字符 x 和 y 吗?回答:没有,但每个字符只有一个字母作为占位符

  2. 模式总是只有四个字符长吗?回答:不,任意

  3. 图案不包含空格吗?回答:是的,从来没有

  4. 输入是否总是用一个空格分隔?回答:是的

算法和往常一样,首先理解问题并识别适当的数据结构是很重要的。您将模式规范识别为字符序列,将输入值识别为空格分隔的单词。这些可以使用split()转换成相应的单值数组。首先,检查模式的长度和输入值的数组是否匹配。只有在这种情况下,才能像以前多次做的那样,一个字符一个字符地遍历模式。作为一个辅助数据结构,您使用一个Map<K,V>将模式的单个字符映射到单词。现在检查是否已经为模式字符插入了另一个单词。通过使用这个技巧,您可以很容易地检测到映射错误。

static boolean matchesPattern(final String pattern, final String input)
{
   // preparation
   final int patternLength = pattern.length();
   final String[] values = input.split(" ");
   final int valuesLength = values.length;

    if (valuesLength != patternLength ||
       (values.length == 1 && values[0].isEmpty()))
       return false;

   final Map<Character, String> placeholderToValueMap = new HashMap<>();

   // run through all characters of the pattern
   for (int i = 0; i< pattern.length(); i++)
   {
       final char patternChar = pattern.charAt(i);
       final String value = values[i];

       // add, if not already there
       placeholderToValueMap.putIfAbsent(patternChar, value);

       // does stored value match current string?
       final String assignedValue = placeholderToValueMap.get(patternChar);
       if (!assignedValue.equals(value))
           return false;
   }
   return true;
}

在代码中,在实际检查之前,您仍然需要显式验证空输入的特殊情况,因为" ".split(" ")会产生一个长度为 1 的数组。

该实现还允许以下规范,其中不同的通配符(以下称为yz)被赋予相同的值(黑色):

matchesPattern("xyzx", "red black black red") => true

为了正确处理这种特殊情况,建议在第一次检查后执行以下查询:

// test for uniqueness of value
if (placeholderToValueMap.values().stream().
                                  filter(str -> str.equals(value)).count() > 1)
    return false;

确认

对于测试,您使用以下输入,这些输入显示了正确的操作:

@ParameterizedTest(name = "pattern ''{0}'' matches ''{1}'' => {2}")
@CsvSource( {"xyyx, tim mike mike tim, true",
             "xyyx, time mike tom tim, false",
             "xyxx, tim mike mike tim, false",
             "xxxx, tim tim tim tim, true" })
void testInputMatchesPattern(String pattern, String input, boolean expected)
{
    boolean result = Ex12_PatternChecker.matchesPattern(pattern, input);

    assertEquals(expected, result);
}

4.3.13 解决方案 13:网球比分(★★★✩✩)

编写方法String tennisScore(String, String, String),根据两个玩家 PL1 和 PL2 的文本分数,以熟悉的风格发布公告,如十五爱优势玩家 X 。因此,他们的分数以格式< PL1 分> : < PL2 分>给出。

以下计数规则适用于网球比赛:

  • 当玩家达到 4 分或更多,并且领先至少 2 分时,游戏获胜(游戏)。

  • 从 0 到 3 的分数被命名为爱,十五,三十和四十。

  • 在至少 3 分和平局的情况下,这被称为平手。

  • 至少有 3 分和 1 分的差距,对于多一分的人来说,这叫优势

例子

|

投入

|

得分

|
| --- | --- |
| 1:0 米夏蒂姆 | 《十五个爱》 |
| 2:2 米夏蒂姆 | “三点半” |
| 2:3 米夏蒂姆 | “三点四十” |
| 3:3 米夏蒂姆 | “平手” |
| 4:3 米夏蒂姆 | “优势米查” |
| 4:4 米夏蒂姆 | “平手” |
| 5:4 米夏蒂姆 | “优势米查” |
| 6:4 米夏蒂姆 | 《游戏米莎》 |

算法在这种情况下,它是一个两步算法:

  1. 首先,应该从文本表示中获得两个int值的分数。

  2. 然后,您的任务是根据这些值生成相应的文本分数名称。

在解析乐谱时,您可以依赖标准的 JDK 功能,比如String.split()Integer.parseInt()。此外,对于可重用的功能,包含某些安全检查是合理的。首先,两个值都应该是正数。之后,分数上的细节将被测试:首先达到 4 分的玩家赢得比赛,但前提是他至少领先 2 分。如果两名球员都有 3 分或更多,那么分差必须小于 3 分。否则就不是网球中的有效状态。您将解析和检查提取到方法extractPoints(String)中。

private static int[] extractPoints(final String score)
{
    final String[] values = score.trim().split(":");

    if (values.length != 2)
        throw new IllegalArgumentException("illegal format -- score has not
              format <points>:<points>, e.g. 7:6");

    final int score1 = Integer.parseInt(values[0]);
    final int score2 = Integer.parseInt(values[1]);

    // sanity check
    if (score1 < 0 || score2 < 0)
        throw new IllegalArgumentException("points must be > 0");

    // verhindert sowohl z. B. 6:3 aber auch 5:1
    if ((score1 > 4 || score2 > 4) && Math.abs(score1 - score2) > 2)
        throw new IllegalArgumentException("point difference must be < 3, " +
                                          "otherwise invalid score");

    return new int[] { score1, score2 };
}

从输入中提取出用分号分隔的两个分数后,就可以继续进行转换了。同样,你使用一个多步骤决策程序。根据规则,一个简单的映射在分数低于 3 时起作用。这在地图上得到了完美的描述。从 3 分开始,可能会出现平局、优势或赢得比赛。如果一个玩家最多得 2 分,另一个玩家也有可能以 4 分获胜。对于获胜的消息,只需要确定两个玩家谁的点数多就可以了。所描述的逻辑实现如下:

static String calculateScore(final String score,
                             final String player1Name,
                             final String player2Name)
{
    final int[] points = extractPoints(score);

    final int score1 = points[0];
    final int score2 = points[1];

    if (score1 >= 3 && score2 >= 3)
    {
        return generateInfo(score1, score2, player1Name, player2Name);
    }
    else if (score1 >= 4 || score2 >= 4)
    {
        var playerName = (score1 > score2 ? player1Name : player2Name);
        return "Game " + playerName;
    }
    else
    {
        // special naming
        var pointNames = Map.of(0, "Love", 1, "Fifteen",
                                2, "Thirty", 3, "Forty");

        return pointNames.get(score1) + " " + pointNames.get(score2);
    }
}

只剩下最后一个细节,即优势或胜利提示文本的生成:

static String generateInfo(final int score1,
                           final int score2,
                           final String player1Name,
                           final String player2Name)
{
    final int scoreDifference = Math.abs(score1 - score2);

    final String playerName = (score1 > score2 ? player1Name : player2Name);

    if (score1 == score2)
        return "Deuce";
    if (scoreDifference == 1)
        return "Advantage " + playerName;
    if (scoreDifference == 2)
        return "Game " + playerName;

    throw new IllegalStateException("Unexpected difference: " + scoreDifference);
}

确认

让我们用一个假想的游戏来测试网球得分功能:

@ParameterizedTest(name = "''{0}'' => ''{1}''")
@CsvSource({ "1:0, Fifteen Love", "2:2, Thirty Thirty", "2:3, Thirty Forty",
             "3:3, Deuce", "4:3, Advantage Micha", "4:4, Deuce",
             "5:4, Advantage Micha", "6:4, Game Micha" }
void calculateScore(String score, String expected)
{
    String result = Ex13_TennisPoints.calculateScore(score, "Micha", "Tim");

    assertEquals(expected, result);
}

图 4-2 显示了 Eclipse 中测试执行的输出。

img/519691_1_En_4_Fig2_HTML.jpg

图 4-2

在 Eclipse 中测试网球比分的执行

你应该添加更多想象中的游戏序列,以巧妙地涵盖势均力敌和无可争议的胜利的边缘情况:

@ParameterizedTest(name = "''{0}'' => ''{1}''")
@CsvSource({ "1:0, Fifteen Love", "2:2, Thirty Thirty",
             "3:2, Forty Thirty", "4:2, Game Micha" })
void calculateScoreWin(String score, String expected)
{
    String result = Ex13_TennisPoints.calculateScore(score, "Micha", "Tim");

    assertEquals(expected, result);
}

@ParameterizedTest(name = "''{0}'' => ''{1}''")
@CsvSource({ "1:0, Fifteen Love", "2:0, Thirty Love",
             "3:0, Forty Love", "4:0, Game Micha"} )
void calculateScoreStraightWin(String score, String expected)
{
    String result = Ex13_TennisPoints.calculateScore(score, "Micha", "Tim");

    assertEquals(expected, result);
}

4.3.14 解决方案 14:版本号(★★✩✩✩)

编写方法int compareVersions(String, String),允许你以的格式比较版本号。小调互相贴片——因此贴片的规格是可选的。特别是,返回值应该与来自Comparator<T>接口的int compare(T, T)方法兼容。

例子

|

版本 1

|

版本 2

|

结果

|
| --- | --- | --- |
| 1.11.17 | 2.3.5 | < |
| Two point one | 2.1.3 | < |
| 2.3.5 | Two point four | < |
| Three point one | Two point four | > |
| Three point three | 3.2.9 | > |
| 7.2.71 | 7.2.71 | = |

算法通过调用split()将文本版本号细分为一个String[]。迭代提取的组件,并使用Integer.valueOf()将它们转换成版本号。然后从大调开始与Integer.compare()成对比较,然后根据需要从小调、补丁开始比较。如果一个输入的值比另一个多,则不使用最后一个数字,除非版本号与该组件匹配,例如 3.1 和 3.1.7:

static int compareVersions(final String v1, final String v2)
{
    var v1Numbers = v1.split("\\."); // caution: Reg-Ex, therefore
    var v2Numbers = v2.split("\\."); // would be '.' for each character

    int pos = 0;
    int compareResult = 0;

    while (pos < v1Numbers.length &&
           pos < v2Numbers.length && compareResult == 0)
    {
        final int currentV1 = Integer.valueOf(v1Numbers[pos]);
        final int currentV2 = Integer.valueOf(v2Numbers[pos]);

        compareResult = Integer.compare(currentV1, currentV2);
        pos++;
    }

    if (compareResult == 0) // same beginning for example 3.1 and 3.1.7
        return Integer.compare(v1Numbers.length, v2Numbers.length);

    return compareResult;
}

Pitfall: Regular Expression in Split()

当您将版本号分割成单独的组件时,也许您最初只是通过指定一个句点(.)作为split()中的人物。然而,我担心这是不对的,因为规范要求正则表达式,而句点(.)代表任何字符。

确认

您将使用参数化测试的以下输入来测试版本号的比较——这里再次使用附加提示参数的技巧:

@ParameterizedTest(name = "''{0}'' {3} ''{1}''")
@CsvSource({ "1.11.17, 2.3.5, -1, <", "2.3.5, 2.4, -1, <",
             "2.1, 2.1.3, -1, <", "3.1, 2.4, 1, >",
             "3.3, 3.2.9, 1, >", "7.2.71, 7.2.71, 0, =" })
void compareVersions(String v1, String v2, int expected, String hint)
{
    int result = Ex14_VersionNumberComparator.compareVersions(v1, v2);

    assertEquals(expected, result);
}

让我们来看看 Eclipse 中测试执行的易于理解的输出,如图 4-3 所示。

img/519691_1_En_4_Fig3_HTML.jpg

图 4-3

Eclipse 中的测试执行

奖金

使用Comparator<T>接口实现功能。

算法比较功能可以通过以下一行程序几乎完全转换成比较器:

static Comparator<String> versioNumberComparator =
                          (v1, v2) -> compareVersions(v1, v2);

4.3.15 解决方案 15:转换 strToLong (★★✩✩✩)

将一个字符串转换成一个long。自己写方法long strToLong(String)

Note

使用long.parseLong(value)可以轻松实现转换。不要显式使用它,而是自己实现整个转换。

例子

|

投入

|

结果

|
| --- | --- |
| “+123” | One hundred and twenty-three |
| “-123” | -123 |
| “7271” | Seven thousand two hundred and seventy-one |
| “ABC” | IllegalArgumentException |
| “0123” | 83(对于奖励任务) |
| “-0123” | -83(对于奖励任务) |
| “0128” | IllegalArgumentException(奖励任务) |

算法检查第一个字符是否为+/-并相应地设置一个标志isNegative。然后遍历所有字符,将其转换为数字。前一个值每次乘以 10,最后得到对应的数值。

static long strToLongV1(final String number)
{
    final boolean isNegative = number.charAt(0) == '-';
    long value = 0;

    int pos = startsWithSign(number) ? 1 : 0;

    while (pos < number.length())
    {
        final int digitValue = number.charAt(pos) - '0';
        value = value * 10 + digitValue;

        pos++;
    }

    return isNegative ? -value : value;
}

static boolean startsWithSign(final String number)
{
    return number.charAt(0) == '-' || number.charAt(0) == '+';
}

修正算法即使不进行更深入的分析,也很明显上面的版本在混合字母和数字时无法正常工作。在这种情况下,通过使用isDigit()进行检查来抛出IllegalArgumentException是合理的,如下所示:

static long strToLongV2(final String number)
{
    final boolean isNegative = number.charAt(0) == '-';
    long value = 0;

    int pos = startsWithSign(number) ? 1 : 0;

    while (pos < number.length())
    {
        if (!Character.isDigit(number.charAt(pos)))
            throw new IllegalArgumentException(number +
                                               " contains not only digits");

        final int digitValue = number.charAt(pos) - '0';
        value = value * 10 + digitValue;

        pos++;
    }

    return isNegative ? -value : value;
}

确认

要测试功能,您需要使用三个数字,带一个正号和一个负号,不带。在转换过程中,可以忽略正号。您分别检查输入字母而不是数字的反应,并期待出现异常。

@ParameterizedTest(name = "strToLongV2(\"{0}\") => {1}")
@CsvSource({ "+123, 123", "-123, -123", "123, 123", "7271, 7271" })
void testStrToLongV2(String number, long expected)
{
    long result = Ex15_StrToLong.strToLongV2(number);

    assertEquals(expected, result);
}

@Test
void testStrToLongV2Error()
{
    assertThrows(IllegalArgumentException.class,
                 () -> Ex15_StrToLong.strToLongV2("ABC"));
}

额外收获:支持八进制数的解析

在 Java 中,八进制数由前导零标记。顾名思义,它们的基数是 8,而不是 10。为了支持八进制数,首先需要确定前导零是否存在。在这种情况下,数字系统中的位置系数更改为 8。最后,以 8 为基数,当然不再允许 8 和 9 这两个数字。因此,您在循环中添加了另一个检查来处理这些值。总而言之,由于特殊处理,源代码有点臃肿——复杂性只是可管理的——特别是因为这里使用了带有说话名称的问题适应帮助器方法。

static long strToLongBonus(final String number)
{
    final boolean isNegative = number.charAt(0) == '-';
    final boolean isOctal = number.charAt(0) == '0' ||
                  (startsWithSign(number) && number.charAt(1) == '0');

    long value = 0;

    final int factor = isOctal ? 8 : 10;

    int pos = calcStartPos(number, isOctal);

    while (pos < number.length())
    {
        if (!Character.isDigit(number.charAt(pos)))
            throw new IllegalArgumentException(number + " contains not only
                  digits");

        final int digitValue = number.charAt(pos) - '0';
        if (isOctal && digitValue >= 8)
            throw new IllegalArgumentException(number + " found digit >= 8");

        value = value * factor + digitValue;

        pos++;
    }

    return isNegative ? -value : value;
}

private static int calcStartPos(final String number, final boolean isOctal)
{
    int pos = 0;
    if (startsWithSign(number) && isOctal)
    {
        pos = 2;
    }
    else if (startsWithSign(number) || isOctal)
    {
        pos = 1;
    }
    return pos;
}

确认

要测试功能,请使用三个数字,带一个正号和一个负号,不带。在转换过程中,可以忽略正号。此外,检查一个正负八进制数。在单独的测试中,确保大于或等于 8 的数字不能出现在八进制数中。

@ParameterizedTest(name = "strToLongBonus(\"{0}\") => {1}")
@CsvSource({ "+123, 123", "-123, -123", "123, 123", "7271, 7271",
             "+077, 63", "-077, -63", "077, 63",
             "+0123, 83", "-0123, -83", "0123, 83" })
void testStrToLongBonus(String number, long expected)
{
    long result = Ex15_StrToLong.strToLongBonus(number);

    assertEquals(expected, result);
}

@Test
void strToLongBonus_should_raise_exception_for_invalid_octal_number()
{
    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                                  () -> Ex15_StrToLong.strToLongBonus("0128"));

    assertTrue(ex.getMessage().contains("found digit >= 8"));
}

4.3.16 解决方案 16:印刷塔(★★★✩✩)

编写方法void printTower(int),将堆叠在一起的 n 片的塔表示为 ASCII 图形,用字符#表示,并绘制一条下边界线。

示例高度为 3 的塔应该是这样的:

    |
   #|#
  ##|##
 ###|###
---------

算法你可以把画图分为三步:画顶条,画切片,然后画底界。因此,可以使用三个方法调用来描述该算法:

static void printTower(final int height)
{
    drawTop(height);
    drawSlices(height);
    drawBottom(height);
}

如前所述,您可以用两种辅助方法来绘制这座塔的各个组件:

static void drawTop(final int height)
{
    System.out.println(repeatCharSequence(" ", height + 1) + "|");
}

static void drawBottom(final int height)
{
    System.out.println(repeatCharSequence("-", (height + 1) * 2 + 1));
}

特别是这里使用了 helper 方法repeatCharSequence(),重复输出字符。

绘制塔的切片有点复杂,因为它们的大小不同,并且需要计算左右两侧的自由空间:

static void drawSlices(final int height)
{
    for (int i = height - 1; i >= 0; i--)
    {
        final int value = height - i;
        final int padding = i + 1;

        final String line = repeatCharSequence(" ", padding) +
                            repeatCharSequence("#", value) +
                            "|" +
                            repeatCharSequence("#", value);

        System.out.println(line);
    }
}

static String repeatCharSequence(final String character, final int length)
{
    String str = "";
    for (int i = 0; i < length; i++)
    {
        str += character;
    }
    return str;
}

很明显,这个问题可以分解成越来越小的子问题。因此,每个方法都变得简短,并且通常也是可测试的(如果没有控制台输出,但是有返回的计算发生)。

对于 Java 11,不要使用上面的repeatCharSequence()方法,建议使用 JDK 的String.repeat()方法。为了尽可能少地改变,建议采用以下步骤。首先,不要调用自己的实现,只需调用repeatCharSequence()中的repeat()方法,如下所示:

static String repeatCharSequence(final String character, final int length)
{
    return character.repeat(length);
}

为了清理不必要的委托,内联重构是有帮助的。它的使用移除了方法repeatCharSequence(),因此repeat()现在在所有地方都被直接调用。

确认

为了检查功能,再次使用 JShell 这里打印一个高度为 4 的塔:

jshell> printTower(4)
     |
    #|#
   ##|##
  ###|###
 ####|####
-----------

Hint: Modification with Recursion

有趣的是,塔的单个切片的绘制也可以递归地表达如下:

private static void drawSlices(final int slice, final int height)
{
    if (slice > 1)
    {
        drawSlices(slice - 1, height);

        System.out.println(repeatCharSequence(" ", height - slice + 1) +
                           repeatCharSequence("#", slice) +
                           "|" +
                           repeatCharSequence("#", slice));
    }
}

然后,必须对调用进行最小程度的修改:

static void printTower(final int height)
{
    drawTop(height);
    drawSlices(height, height);
    drawBottom(height);
}

五、数组

数组是在连续存储区域中存储原始数据类型的值或对象引用的数据结构。类型为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

对于这样的作业,你应该经常问几个问题来澄清背景,获得更好的理解。对于此示例,可能的问题包括:

  1. 移除/删除时到底应该发生什么?

  2. 什么值代表无条目

  3. 有必要保持数字的顺序/排序吗?

  4. 是否可以创建一个新的数组,或者必须在原始数组中就地执行操作?

  5. 对于原地,还有进一步的问题:

例 3 的解决方案 1:新数组和排序输入:假设你在消除重复项时返回一个新数组作为结果。然后,该算法分为两个阶段:

  1. 你从收集一个TreeSet<E>中的数字开始。这样,重复项会被自动删除,排序和原始顺序也会保留。 1

  2. 第二步是基于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 };

所有重复项都将被删除,但这一次不允许创建新数组。这个实现有点困难。算法如下:遍历数组并检查每个元素,看它是否已经存在以及是否重复。这种检查可以通过将当前元素与其前一个元素进行比较来执行。这种简化是可能的,因为排序是存在的——如果没有排序,解决起来会复杂得多。您从最前面的位置开始处理,一步一步地进行。因此,您收集了数组左侧没有重复的所有数字。为了知道在数组中的什么地方读或写,你分别使用名为readPoswritePos,的位置指针。如果发现重复的数字,读指针继续移动;写指针保持不动。

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. 您应该返回有效范围的长度。

  2. 你应该用一个特殊的值删除后面的位置,比如-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 个位置,然后元素将分别在开始或结束时循环移位,如此处所示,中间的数组是起点:

img/519691_1_En_5_Figa_HTML.png

向右旋转一个元素的算法很简单:记住最后一个元素,然后重复地将旋转方向上的前一个元素复制到后一个元素。最后,缓存的最后一个元素被插入到最前面的位置。

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

让我们试着把程序正式化一点。实现循环的最简单方法是创建一个新数组,然后适当地填充它。对于公式的确定,您使用具体的示例数据,这有助于理解(xnyn代表新坐标——在下文中,向左旋转和向右旋转显示在左/右):

          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 的数组。

旋转基于以下计算规则,其中maxXmaxY是各自的最大坐标:

               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 显示了另一个例子。

img/519691_1_En_5_Fig1_HTML.png

图 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 所示。

img/519691_1_En_5_Fig2_HTML.png

图 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[][])进行检查。

例子

有效的解决方案如下所示。

img/519691_1_En_5_Figb_HTML.png

奖励虽然能够检查一个完全由数字填充的数独棋盘的有效性已经很好了,但是能够预测一个有缺口(即仍然缺少数字)的棋盘是否能产生有效的解决方案就更好了。如果您想开发一种解决数独难题的算法,这是非常有趣的。

例子

基于上面给出的有效数独游戏场的例子,我删除了随机出现的数字。这肯定会产生一个有效的解决方案。

img/519691_1_En_5_Figc_HTML.png

练习 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 )实际上相当简单。如同其他问题的许多解决方案一样,使用了两个位置标记,这里是nextEvennextOdd。开始时,假设第一个元素是偶数,最后一个是奇数。现在检查前面的数是不是真的偶数,位置右移。如果遇到第一个奇数,则与最后一个元素交换。即使最后一个元素是奇数,它也会在下一步中再次交换。

与前面的解决方案相反,这个解决方案不保持偶数的顺序;它还可能在更大程度上打乱奇数。

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)的方法。下面的eo分别代表nextEvennextOdd的位置指针。

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 dxrigid 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 所示,从左到右、从上到下进行逻辑复制。

img/519691_1_En_5_Fig3_HTML.png

图 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 显示了另一个例子。

img/519691_1_En_5_Fig4_HTML.png

图 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[][],从中您可以很好地跟踪各种删除和迭代。然而,您必须分别为类型charchar[][]重写一些方法(参见下面的实用技巧):

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 所示。

img/519691_1_En_5_Fig5_HTML.png

图 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(),该方法指定了每种情况下的后续方向。同样,在那里定义偏移值dxdy

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[][])进行检查。

例子

这里显示了一个有效的解决方案:

img/519691_1_En_5_Figd_HTML.png

算法在数独游戏中,要进行三种不同的检查。这些检查可以很好地分为三种相应的方法。首先是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

奖金

虽然能够检查一个完全充满数字的数独棋盘的有效性已经很好了,但能够预测一个有个缺口(即仍然缺少数字)的棋盘是否能产生有效的解决方案就更好了。如果您想开发一种解决数独难题的算法,这是非常有趣的。

例子

基于上面给出的有效数独游戏域的例子,我已经删除了任意位置的数字。这肯定会产生一个有效的解决方案。

img/519691_1_En_5_Fige_HTML.png

如果你以先前的实现为基础,部分填充的运动场可以很容易地被检查有效性。首先,您需要为空白字段建模。在这种情况下,值 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)来实现该功能。允许创建新的数据结构,如列表。

算法要将一个关于参考元素的数组分成两部分,其值小于或大于或等于参考值,定义两个结果列表,分别命名为lesserbiggerOrEqual。然后,遍历数组,根据当前元素与引用元素的比较,为每个元素填充两个列表中的一个。最后,您只需要将列表和引用元素组合成一个结果列表,并将其转换成一个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

*

六、日期处理

在 Java 8 中,JDK 扩展了一些数据处理功能。首先,存在相当不直观的机器时间,它是线性进行的,由类java.time.Instant表示。但是,各种类更适合人类的思维方式。例如,包java.time中的类LocalDateLocalTimeLocalDateTime以日期、时间及其组合的形式表示没有时区的日期值。

6.1 导言

接下来,在继续练习之前,我将在java.time下描述不同包中的一些枚举、类和接口。

6.1.1 星期和月份的计数

使用枚举java.time.DayOfWeekjava.time.Month提供了良好的可读性并避免了错误,因为您可以使用类型安全的常量来代替幻数。此外,可以使用这些枚举类型进行计算。我将在MonthAndDayOfTheWeekExample中演示这一点,如下所示,通过调用plus(),给一个星期天增加 5 天,给二月增加 13 个月:

public static void main(final String[] args)
{
    final DayOfWeek sunday = DayOfWeek.SUNDAY;
    final Month february = Month.FEBRUARY;

    System.out.println(sunday.plus(5));
    System.out.println(february.plus(13));
}

正如所料,如果你执行这个程序,你会在星期五或三月结束:

FRIDAY
MARCH

6 . 1 . 2 local date、LocalTime 和 LocalDateTime 类

如前所述,以毫秒为单位表示时间信息,有助于计算机处理,但与人类的思维方式和他们在时间系统中的取向关系不大。人类更喜欢用时间段或循环日期来思考,例如,12/24 代表平安夜,12/31 代表新年前夜,等等。(即没有时间和年份的日期)。有时你需要不完整的时间信息,比如不涉及日期的时间,比如下班后 6 点,或者作为组合,比如周二和周四晚上 7 点的空手道训练。 1 用 Java 8 之前就有的 API 表达类似的东西是相当困难的。现在让我们来看看自 JDK 8 以来的可能性。

LocalDate表示一条日期信息,只包含年、月和日,没有时间信息。类别LocalTime模拟没有日期信息的时间(例如 6:00)。LocalDateTime是两者的结合。LocalDateAndTimeExample程序展示了这些类的用法,以及如何简单而有意义地实现日期算法。您会看到一个工作日的查询以及一个月或一年中某一天的查询,它们在每种情况下都有明确的方法名称:

public static void main(final String[] args)
{
    final LocalDate michasBirthday = LocalDate.of(1971, Month.FEBRUARY, 7);
    final LocalDate barbarasBirthday = michasBirthday.plusYears(2).
                                                      plusMonths(1).
                                                      plusDays(17);
    final LocalDate lastDayInFebruary = michasBirthday.with(TemporalAdjusters.                                                          lastDayOfMonth());

    System.out.println("michasBirthday:    " + michasBirthday);
    System.out.println("barbarasBirthday:  " + barbarasBirthday);
    System.out.println("lastDayInFebruary: " + lastDayInFebruary);

    final LocalTime atTen = LocalTime.of(10,00,00);
    final LocalTime tenFifteen = atTen.plusMinutes(15);
    final LocalTime breakfastTime = tenFifteen.minusHours(2);

    System.out.println("\natTen:       " + atTen);
    System.out.println("tenFifteen:    " + tenFifteen);
    System.out.println("breakfastTime: " + breakfastTime);

    System.out.println("\nDay Of Week: " + michasBirthday.getDayOfWeek());
    System.out.println("Day Of Month:  " + michasBirthday.getDayOfMonth());
    System.out.println("Day Of Year:   " + michasBirthday.getDayOfYear());
}

代码显示了使用plusXyz()minusXyz()方法的几个计算。您还可以使用实用程序类TemporalAdjusters,其中定义了各种实用程序方法。这就是例如lastDayOfMonth()确定一个月最后一天的方法,这里计算的是 1971 年 2 月的最后一天。执行该程序会产生以下输出:

michasBirthday:       1971-02-07
barbarasBirthday:     1973-03-24
lastDayInFebruary:    1971-02-28

atTen:                10:00
tenFifteen:           10:15
breakfastTime:        08:15

Day Of Week:     SUNDAY
Day Of Month:    7
Day Of Year:     38

Java 9 中 LocalDate 类的扩展

在 JDK 9 中,类LocalDate得到了一个名为datesUntil()的重载方法。它在两个LocalDate实例之间创建了一个Stream<LocalDate>,并允许您随意指定步长。

DatesUntilExample用作者的生日和同年的平安夜来演示用datesUntil()方法的计算:

public static void main(final String[] args)
{
    final LocalDate myBirthday = LocalDate.of(1971, Month.FEBRUARY, 7);
    final LocalDate christmas = LocalDate.of(1971, Month.DECEMBER, 24);

    System.out.println("Day-Stream");
    final Stream<LocalDate> daysUntil = myBirthday.datesUntil(christmas);
    daysUntil.skip(150).limit(4).forEach(System.out::println);

    System.out.println("\n3-Month-Stream");
    final Stream<LocalDate> monthsUntil =
                    myBirthday.datesUntil(christmas, Period.ofMonths(3));
    monthsUntil.limit(3).forEach(System.out::println);
}

如果你执行这个程序,它从 2 月 7 日开始,跳到 150 天以后,也就是 7 月 7 日。然后你从一系列的日子里得到四个值:

Day-Stream 1971-07-07
1971-07-08
1971-07-09
1971-07-10

除此之外,第二个输出显示了增量的默认值,这里是三个月。从 2 月 7 日开始,除了这个日期之外,还列出了未来的两个日期:

3-Month-Stream 1971-02-07
1971-05-07
1971-08-07

6.1.3 类 ZonedDateTime

除了用于表示没有时区引用的日期和时间的类LocalDateTime之外,还有一个名为java.time. ZonedDateTime的类。它包括一个时区,计算不仅考虑了时区,还考虑了夏令时和冬令时的影响。

ZonedDateTimeExample 程序显示了使用类ZonedDateTime进行计算的几个示例,特别是还显示了年、月和日在两个变量中的变化以及不同时区的变化:

public static void main(final String[] args)
{
    // determine current time as ZonedDateTime object
    final ZonedDateTime someDay = ZonedDateTime.of(LocalDate.parse("2020-02-07"),
                                  LocalTime.parse("17:30:15"),
                                  ZoneId.of("Europe/Zurich"));

    // modify the time and save it in a new object
    final ZonedDateTime someDayChangedTime = someDay.withHour(11).withMinute(44);

    // create new object with completely changed date
    final ZonedDateTime dateAndTime = someDayChangedTime.withYear(2008).
                                                         withMonth(9).
                                                         withDayOfMonth(29);

    // using a month constant and changing the time zone
    final ZonedDateTime dateAndTime2 = someDayChangedTime.withYear(2008).
                                     withMonth(Month.SEPTEMBER.getValue()).
                                     withDayOfMonth(29).
                                     withZoneSameInstant(ZoneId.of("GMT"));

    System.out.println("someDay:       " + someDay);
    System.out.println("-> 11:44:      " + someDayChangedTime);
    System.out.println("-> 29.9.2008:  " + dateAndTime);
    System.out.println("-> 29.9.2008:  " + dateAndTime2);
}

运行该程序最初将在 2020 年开始,但随后更改为 2008 年,这将产生以下输出。它们特别显示了夏季和冬季时间的影响,因此在 2008 年 9 月,显示了+02:00 的偏差。这是什么意思,可以通过将时区改为 GMT 来识别。

someDay:      2020-02-07T17:30:15+01:00[Europe/Zurich]
-> 11:44:     2020-02-07T11:44:15+01:00[Europe/Zurich]
-> 29.9.2008: 2008-09-29T11:44:15+02:00[Europe/Zurich]
-> 29.9.2008: 2008-09-29T09:44:15Z[GMT]

6.1.4 类区域 Id

现在让我们通过一个例子来了解时区处理。首先,基于一些文本时区标识符,通过调用ZoneId.of(String)并从每个实例中构造一个ZonedDateTime对象来确定相应的ZoneId实例。然后您调用ZoneId.getAvailableZoneIds()来检索所有可用的时区。使用 streams 和两种方法filter()limit()你会得到三个来自欧洲的候选人:

public static void main(final String[] args)
{
    final Stream<String> zoneIdNames = Stream.of("Africa/Nairobi",
                                                 "Europe/Zurich",
                                                 "America/Los_Angeles");

    zoneIdNames.forEach(zoneIdName ->
    {
       final ZoneId zoneId = ZoneId.of(zoneIdName);
       var someDay = ZonedDateTime.of(LocalDate.parse("2020-04-05"),
                                      LocalTime.parse("17:30:15"),
                                      zoneId);

       System.out.println(zoneIdName + ": " + someDay);
    });

    final Set<String> allZones = ZoneId.getAvailableZoneIds();
    final Predicate<String> inEurope = name -> name.startsWith("Europe/");
    final List<String> threeFromEurope = allZones.stream().
                                         filter(inEurope).limit(3).
                                         collect(Collectors.toList());

    System.out.println("\nSome timezones in europe:");
    threeFromEurope.forEach(System.out::println);
}

程序ZoneIdExample产生以下输出:

Africa/Nairobi: 2020-04-05T17:30:15+03:00[Africa/Nairobi] Europe/Zurich: 2020-04-05T17:30:15+02:00[Europe/Zurich] America/Los_Angeles: 2020-04-05T17:30:15-07:00[America/Los_Angeles]

Some timezones in europe:
Europe/London
Europe/Brussels
Europe/Warsaw

课程持续时间

java.time.Duration允许以纳秒为单位指定持续时间。类别Duration的实例可以通过调用各种方法(例如,从不同时间单位 2 的值)来构建,如下所示:

public static void main(final String[] args)
{
    // creation with ofXyz() methods
    final Duration durationFromNanos = Duration.ofNanos(3);
    final Duration durationFromMillis = Duration.ofMillis(7);
    final Duration durationFromSeconds = Duration.ofSeconds(15);
    final Duration durationFromMinutes = Duration.ofMinutes(30);
    final Duration durationFromHours = Duration.ofHours(45);
    final Duration durationFromDays = Duration.ofDays(60);

    System.out.println("From Nanos:    " + durationFromNanos);
    System.out.println("From Millis:   " + durationFromMillis);;
    System.out.println("From Seconds:  " + durationFromSeconds);
    System.out.println("From Minutes:  " + durationFromMinutes);
    System.out.println("From Hours:    " + durationFromHours);
    System.out.println("From Days:     " + durationFromDays);
}

如果您运行这个程序DurationExample,您将得到如下所示的输出,其中特别感兴趣的是以下内容:时差显然以秒为时间单位的最小值和以小时为时间单位的最大值表示,结果是 60 天的值为 1440 小时:

From Nanos:     PT0.000000003S
From Millis:    PT0.007S
From Secs:      PT15S
From Minutes:   PT30M
From Hours:     PT45H
From Days:      PT1440H

当查看这个输出时,您可能会对Duration的字符串表示感到恼火。乍一看,这似乎有点不寻常。然而,它遵循 ISO 8601 标准。输出总是以缩写PT开始。 3 之后是小时(H)、分钟(M)、秒(S)的分段。如果需要,毫秒甚至纳秒都显示为十进制数。

此外,可以使用between()方法进行简单的计算,该方法从两个Instant对象的差异中计算出一个Duration。作为另一个特性,有一些with()方法可以返回一个Duration的新实例,并适当修改计时。相比之下,虽然对ofXyz()的多次级联调用是可能的,但它们导致最后的赢得,因此只有那一次是决定性的。有关这方面的更多信息,请参见下一节。

6.1.6 上课时间

与类Duration相似,类java.time.Period模拟一段时间,但是持续时间更长。例如 2 个月3 天。让我们构建几个Period的实例:

public static void main(final String[] args)
{
    // create a Period with 1 year, 6 months and 3 days
    final Period oneYear_sixMonths_ThreeDays = Period.ofYears(1).withMonths(6).
                                                              withDays(3);

    // chaining of() works differently than you might expect!
    // results in a Period with 3 days instead of 2 months, 1 week and 3 days
    final Period twoMonths_OneWeek_ThreeDays = Period.ofMonths(2).ofWeeks(1).
                                                                ofDays(3);

    final Period twoMonths_TenDays = Period.ofMonths(2).withDays(10);
    final Period sevenWeeks = Period.ofWeeks(7);
    final Period threeDays = Period.ofDays(3);

    System.out.println("1 year 6 months ...:     " + oneYear_sixMonths_ThreeDays);
    System.out.println("Surprise just 3 days:    " + twoMonths_OneWeek_ThreeDays);
    System.out.println("2 months 10 days:        " + twoMonths_TenDays);
    System.out.println("sevenWeeks:              " + sevenWeeks);
    System.out.println("threeDays:               " + threeDays);
}

如果运行这个程序,PeriodExample,输出如下:

1 year 6 months ...:  P1Y6M3D
Surprise just 3 days: P3D
2 months 10 days:     P2M10D
sevenWeeks:           P49D
threeDays:            P3D

从这个例子及其输出中,您可以了解到关于类Period的一些事情。首先,在 ISO 8601 之后还有一个有点神秘的字符串表示。这里P是开始的缩写(代表周期),然后Y代表年,M代表月,D代表日。作为一个特色,还有星期的换算:P14D代表 2 周。例如,它可以由Period.ofWeeks(2)生成。此外,还允许负偏移,例如P-2M4D

除了输出的这些细节之外,您可以看到对ofXyz()的调用可以连续执行——但是最后调用的那个会胜出,如果您知道ofXyz()是静态方法,这是合乎逻辑的。因此,您不能以这种方式组合时间段;相反,您可以指定一个初始时间段。如果你想添加更多的时间段,你必须使用不同的withXyz()方法。这揭示了实现细节。类Period管理三个单一值,即年、月和日,但不管理周。因此没有方法withWeeks(),只有一个ofWeeks(),它在内部执行到天数的转换。

日期算法

为了完善你的知识,你会接触到更复杂的计算,比如跳到月初或者跳到未来或过去的几天甚至几个月。方便的是,日期运算的各种有用操作被捆绑在java.time.temporal包的TemporalAdjusters实用程序类中。类别LocalDateLocalTimeLocalDateTime的例子给出了可能性的第一印象。现在你想扩展这方面的知识。

预定义的临时调节器

实用程序类TemporalAdjusters提供了一组常见的日期算术运算。一些例子如下:

  • firstDayOfMonth()firstDayOfNextMonth()lastDayOfMonth()计算(下个)月的第一天或最后一天。

  • firstDayOfYear()firstDayOfNextYear()lastDayOfYear()确定(下一年)的第一天或最后一天。

  • firstInMonth(DayOfWeek)lastInMonth(DayOfWeek)跳转到当月一周的第一天或最后一天。

  • next(DayOfWeek)nextOrSame(DayOfWeek)previous(DayOfWeek)previousOrSame(DayOfWeek)计算一周的下一天或前一天,例如下一个星期五。他们也可能会考虑你是否已经在那一天了。在这种情况下,当然不会发生调整。

更具体的预定义临时调节器

前面提到的TemporalAdjuster对于许多用例来说已经足够了。如果您需要更大的灵活性,还有两种方法:

  • dayOfWeekInMonth(int, DayOfWeek)计算一个月中一周的第 n 天。因此,例如,如果试图确定一个月的第 7 个星期二(不存在),它也会跳过月份边界。此外,负值是允许的,因此值 0 和-1 具有特殊的含义。0 确定上个月的最后一个工作日,而-1 确定本月的最后一个工作日。负值从该月的最后一个工作日开始向后移动给定的周数。

  • ofDateAdjuster(UnaryOperator<LocalDate>)创建TemporalAdjusters。使用UnaryOperator<LocalDate>描述所需的计算。这允许许多计算,例如,使用 lambda date -> date.plusDays(5)跳跃到未来五天。

例子

为了澄清,让我们再看一个例子。在这里,您可以执行一些时间跳转到月初和月末,以及一周中的不同日子:

public static void main(final String[] args)
{
    final LocalDate michasBirthday = LocalDate.of(1971, Month.FEBRUARY, 7);

    var firstDayInFebruary =
        michasBirthday.with(TemporalAdjusters.firstDayOfMonth());
    var lastDayInFebruary =
        michasBirthday.with(TemporalAdjusters.lastDayOfMonth());
    var previousMonday =
        michasBirthday.with(TemporalAdjusters.previous(DayOfWeek.MONDAY));
    var nextFriday =
        michasBirthday.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));

    System.out.println("michasBirthday:     " + michasBirthday);
    System.out.println("firstDayInFebruary: " + firstDayInFebruary);
    System.out.println("lastDayInFebruary:  " + lastDayInFebruary);
    System.out.println("previousMonday:     " + previousMonday);
    System.out.println("nextFriday:         " + nextFriday);
}

在代码中,您可以看到使用TemporalAdjusters的各种计算,比如调用方法lastDayOfMonth()来确定一个月中的最后一天,这里是确定 1971 年 2 月的最后一天。这些计算应用于在各自的LocalDate上调用with()

运行TemporalAdjustersExample程序产生以下输出:

michasBirthday:      1971-02-07
firstDayInFebruary:  1971-02-01
lastDayInFebruary:   1971-02-28
previousMonday:      1971-02-01
nextFriday:          1971-02-12

示例:自己定义临时调整

事实上,预定义的TemporalAdjuster的可能性已经令人印象深刻,应该足以满足各种用例。然而,有时你可能想创建自己的变体。我们来看看例子FridayAfterMidOfMonth。在这里,从一个LocalDate开始,你要跳到月中后的星期五。为此,必须适当实现方法Temporal adjustInto(Temporal)。唯一的障碍是从通过的Temporal中获得一个LocalDate。这可以通过调用LocalDate.from()来实现。之后,您必须跳转到该月的第 15 天(或者 2 月的第 14 天),这很容易通过调用withDayOfMonth()来完成。最后,用nextOrSame(DayOfWeek)应用一个预定义的调整器。

public class FridayAfterMidOfMonth implements TemporalAdjuster
{
    @Override
    public Temporal adjustInto(final Temporal temporal)
    {
       final LocalDate startday = LocalDate.from(temporal);

       final int dayOfMonth = startday.getMonth() == Month.FEBRUARY ? 14 : 15;

       return startday.withDayOfMonth(dayOfMonth).
                        with(TemporalAdjusters.nextOrSame(DayOfWeek.FRIDAY));
    }
}

如果使用适当的 API,实现功能是多么容易,这是非常值得注意的。

要在 JShell 中试用它,您需要以下导入:

jshell> import java.time.*

jshell> import java.time.temporal

.*

现在,您可以在那里定义上面的类并调用它,例如,如下所示:

jshell> LocalDate feb7 = LocalDate.of(2020, 2, 7) feb7 ==> 2020-02-07

jshell> var adjustedDay = feb7.with(new FridayAfterMidOfMonth()) adjustedDay ==> 2020-02-14

jshell> adjustedDay.getDayOfWeek()
$17 ==> FRIDAY

jshell> LocalDate mar24 = LocalDate.of(2020, 3, 24) mar24 ==> 2020-03-24

jshell> var adjustedDay2 = mar24.with(new FridayAfterMidOfMonth()) adjustedDay2 ==> 2020-03-20

jshell> adjustedDay2.getDayOfWeek()
$22 ==> FRIDAY

格式化和解析

java.time.format.DateTimeFormatter对于格式化输出和解析日期值很有用。除了各种预定义的格式,您还可以提供各种自己的变体。这可以通过调用方法ofPattern()以及ofLocalizedDate()parse()并指定格式来实现。在这里,可以方便地指定更复杂的格式化模式,甚至用它来进行解析。下面的代码演示了这一点,代码可作为FormattingAndParsingExample执行。各种其他的可能性存在,但不能在这里提出。因此,建议查看 JDK 的详细文档。

public static void main(final String[] args)
{
    // definition of some special formatters
    final DateTimeFormatter ddMMyyyyFormat = ofPattern("dd.MM.yyyy");
    final DateTimeFormatter italiandMMMMy = ofPattern("d.MMMM y",
                                                      Locale.ITALIAN);
    final DateTimeFormatter shortGerman =
                       DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).
                       withLocale(Locale.GERMAN);

    // attention: the textual parts are to be enclosed in quotation marks
    final String customPattern = "'Der 'dd'. Tag im 'MMMM' im Jahr 'yy'.'";
    final DateTimeFormatter customFormat = ofPattern(customPattern);

    System.out.println("Formatting:\n");
    final LocalDate february7th = LocalDate.of(1971, 2, 7);

    System.out.println("ddMMyyyyFormat: " + ddMMyyyyFormat.format(february7th));
    System.out.println("italiandMMMMy:  " + italiandMMMMy.format(february7th));
    System.out.println("shortGerman:    " + shortGerman.format(february7th));
    System.out.println("customFormat:   " + customFormat.format(february7th));

    // Parsing date values
    System.out.println("\nParsing:\n");

    final LocalDate fromIsoDate = LocalDate.parse("1971-02-07");
    final LocalDate fromddMMyyyyFormat = LocalDate.parse("18.03.2014",
                                                        ddMMyyyyFormat);
    final LocalDate fromShortGerman = LocalDate.parse("18.03.14",
                                                      shortGerman);
    final LocalDate fromCustomFormat =
                      LocalDate.parse("Der 31\. Tag im Dezember im Jahr 19.",
                                      customFormat);

    System.out.println("From ISO Date:        " + fromIsoDate);
    System.out.println("From ddMMyyyyFormat:  " + fromddMMyyyyFormat);
    System.out.println("From short german:    " + fromShortGerman);
    System.out.println("From custom format:   " + fromCustomFormat);
}

该程序产生以下输出,这提供了对格式化和解析的初步理解:

Formatting:

ddMMyyyyFormat:  07.02.1971
italian_dMMMMy:  7.febbraio 1971
shortGerman:     07.02.71
customFormat:    Der 07\. Tag im Februar im Jahr 71.
Parsing:
From ISO Date:         1971-02-07
From ddMMyyyyFormat:   2014-03-18
From short german:     2014-03-18
From custom format:    2019-12-31

6.2 练习

6.2.1 练习 1:闰年(★✩✩✩✩)

虽然在我们的日历中,一年通常被分为 365 天,但这在天文学上并不完全正确。一年大约有 365.25 天。因此,通过使用 366 天的闰年,几乎每 4 年就需要进行一次校正。有两个特殊方面需要考虑:

  • 能被 100 整除的年份叫世俗年,不是闰年。

  • 但是,同样能被 400 整除的世俗年份毕竟是闰年。

详见 https://en.wikipedia.org/wiki/Leap_year 。写方法boolean isLeap(int)—当然不用java.time.Year.isLeap()

例题

|

投入

|

规则

|

结果

|
| --- | --- | --- |
| One thousand nine hundred | 世俗年 | 没有闰年 |
| Two thousand | 世俗的岁月,但能被 400 整除 | 闰年 |
| Two thousand and twenty | 能被 4 整除 | 闰年 |

6.2.2 练习 2:基础知识数据-API (★★✩✩✩)

练习 2a:创造(★✩✩✩✩)

LocalDate表示你的生日,用LocalTime表示下午 5:30 下班,现在用LocalDateTime表示。此外,建模时间跨度为 1 年 10 个月 20 天,持续时间为 7 小时 15 分钟。

练习 2b:持续时间(★★✩✩✩)

计算今天和你生日之间的时间长度,反之亦然。

6.2.3 练习 3:月份长度(★★✩✩✩)

练习 3a:计算(★✩✩✩✩)

使用类LocalDate的方法plusMonths()从 2012 年 2 月 2 日和 2014 年 2 月 2 日以及 2014 年 4 月 4 日和 2014 年 5 月 5 日跳转到未来一个月,并输出每种情况下的日期。

练习 3b:月份长度(★★✩✩✩)

对于计算,如果使用方法plusDays()而不是plusMonths()会发生什么?什么对应于一个月:28、29、30 或 31 天?你如何确定一个月的正确长度?

6.2.4 练习 4:时区(★★✩✩✩)

获取以America/LEurope/S开始的所有时区,并相应地填充一个排序集。为此,写方法Set<String> selectedAmericanAndEuropeanTimeZones()

例子

计算集应包含以下值:

[America/La_Paz, America/Lima, America/Los_Angeles, America/Louisville, America/Lower_Princes, Europe/Samara, Europe/San_Marino, Europe/Sarajevo, Europe/Saratov, Europe/Simferopol, Europe/Skopje, Europe/Sofia, Europe/Stockholm]

Tip

使用类ZoneId和它的方法getAvailableZoneIds()。利用 streams 和 filter-map-reduce 框架来查找与之前指定的前缀相匹配的时区 id。

6.2.5 练习 5:时区计算(★★✩✩✩)

如果从苏黎世到旧金山的航班需要 11 小时 50 分钟,于 2019 年 9 月 15 日下午 1 点 10 分起飞,到达旧金山的当地时间是几点?出发地点的时间是几点?

例子

应该为该航班确定以下到达时间:

2019-09-16T01:00+02:00[Europe/Zurich]
2019-09-15T16:00-07:00[America/Los_Angeles]

Tip

使用类Duration来模拟飞行时间。旧金山的时区是America/Los_Angeles。使用类ZonedDateTime中的withZoneSameInstant()方法。

6.2.6 练习 6:使用本地日期进行计算

练习 6a:13 号星期五(★★✩✩✩)

计算由两个LocalDate定义的范围内 13 号星期五的所有事件。编写通用方法List<LocalDate> allFriday13th(LocalDate, LocalDate),指定开始日期(含)和结束日期(不含)。

例子

对于 2013 年 1 月 1 日至 2015 年 12 月 31 日期间,应确定以下日期值:

|

时期

|

结果

|
| --- | --- |
| 2013 – 2015 | [2013-09-13, 2013-12-13, 2014-06-13, 2015-02-13, 2015-03-13, 2015-11-13] |

练习 6b:13 号星期五(★★✩✩✩)的几次出现

13 号星期五在哪一年出现过几次?要回答这个问题,请计算一张地图,其中对应的星期五与每年相关联。为此编写方法Map<Integer, List<LocalDate>> friday13thGrouped(LocalDate, LocalDate)

例子

|

|

结果

|
| --- | --- |
| Two thousand and thirteen | [2013-09-13, 2013-12-13] |
| Two thousand and fourteen | [2014-06-13] |
| Two thousand and fifteen | [2015-02-13, 2015-03-13, 2015-11-13] |

6.2.7 练习 7:日历输出(★★★✩✩)

编写方法void printCalendar(Month, int),对于给定的月份和年份,将日历页面打印到控制台。

例子

对于 2020 年 4 月,您预计会得到以下结果:

Mon Tue Wed Thu Fri Sat Sun
..  ..  01  02  03  04  05
06  07  08  09  10  11  12
13  14  15  16  17  18  19
20  21  22  23  24  25  26
27  28  29  30  --  --  --

对于结束于周日的示例,您可以选择 2020 年 5 月。要验证在星期一开始,请使用 2020 年 6 月。

6.2.8 练习 8:工作日(★✩✩✩✩)

练习 8a:工作日(★✩✩✩✩)

平安夜 2019 年(2019 年 12 月 24 日)是星期几?2019 年 12 月的第一天和最后一天是星期几?

例子

一周中的以下几天应该是结果:

|

投入

|

结果

|
| --- | --- |
| 2019 年 12 月 24 日 | 星期二 |
| 2019 年 12 月 01 日 | 在星期日 |
| 2019 年 12 月 31 日 | 星期二 |

练习 8b:日期(★✩✩✩✩)

用写法Map<String, LocalDate> firstAndLastFridayAndSunday(YearMonth)计算出 2019 年 3 月第一个和最后一个星期五和星期天各自的日期。一路上,了解用于建模年份和月份的YearMonth类。

例子

计算出的映射应包含以下值,并按键排序:

{firstFriday=2019-03-01, firstSunday=2019-03-03,
 lastFriday=2019-03-29, lastSunday=2019-03-31}

练习 8c:月份或年份中的某一天(★✩✩✩✩)

在练习部分 8b 中,您确定了 2019 年 3 月的四个日期。这两种情况下都是在三月的哪一天?一年中的哪一天?

例子

|

投入

|

一个月中的第几天

|

一年中的每一天

|
| --- | --- | --- |
| 第一个星期五=2019-03-01 | one | Sixty |
| 第一个星期天=2019-03-03 | three | Sixty-two |
| lastFriday=2019-03-29 | Twenty-nine | Eighty-eight |
| 载荷空间=2019-03-31 | Thirty-one | Ninety |

6.2.9 练习 9:星期日和闰年(★★✩✩✩)

练习 9a:周日

在两个LocalDate给定的范围内计算星期日的数量。为此,编写方法Stream<LocalDate> allSundaysBetween(LocalDate, LocalDate),其中开始日期包含在内,结束日期不包含在内。

例子

|

时期

|

结果

|
| --- | --- |
| 1.1.2017 – 1.1.2018 | Fifty-three |
| 1.1.2019 – 7.2.2019 | five |

练习 9b:闰年

Year实例给定的范围内计算闰年数。为此,编写方法long countLeapYears(Year, Year),其中起始年包含,结束年不包含。

例子

|

时期

|

结果

|
| --- | --- |
| 2010 – 2019 | Two |
| 2000 – 2019 | five |

6.2.10 练习 10:临时助理(★★★✩✩)

编写一个TemporalAdjuster,将日期值移动到每个季度的开始,比如从 2 月 7 日到 1 月 1 日。

例子

|

投入

|

结果

|
| --- | --- |
| LocalDate.of(2014,3,15) | 2014-01-01 |
| LocalDate.of(2014 年 6 月 15 日) | 2014-04-01 |
| LocalDate.of(2014,9,15) | 2014-07-01 |
| LocalDate.of(2014,11,15) | 2014-10-01 |

Tip

MonthIsoFields类中四处看看。

6.2.11 练习 11:第 n 周工作日调整(★★★✩✩)

编写跳转到一周第 n 天的类NthWeekdayAdjuster,比如第三个星期五。这要从 a LocalDate给的月初说起。对于较大的 n,值,它应该跳到随后的月份。

例子

使用以下开始日期为 2015 年 8 月 15 日的时间跳转来验证此课:

|

开始日期

|

跳跃目标

|

结果

|
| --- | --- | --- |
| 2015-08-15  (2015-08-15) | 第二个星期五 | 2015-08-14 |
| 2015-08-15  (2015-08-15) | 第三个星期天 | 2015-08-16 |
| 2015-08-15  (2015-08-15) | 第四个星期二 | 2015-08-25 |

6.2.12 练习 12:发薪日临时助理(★★★✩✩)

实现计算瑞士典型发薪日的类Ex12_NextPaydayAdjuster。这通常是每月的 25 号。如果这一天适逢周末,前一个星期五作为发薪日。如果你在付款日之后,那么这被认为是在下个月。作为一个自由式,你还是要对 12 月份的工资发放实行一个特殊的规则,这个时候发放应该在月中。如有必要,如果发薪日是周末,付款将被移到下一个星期一。

例子

基于以下日期值验证此类:

|

投入

|

结果

|

规则

|
| --- | --- | --- |
| 2019-07-21 | 2019-07-25 | 正常调整 |
| 2019-06-27 | 2019-07-25 | 正常调整,下个月 |
| 2019-08-21 | 2019-08-23 | 星期五,如果 25 号是周末 |
| 2019-12-06 | 2019-12-16 | 十二月:月中,周末后的星期一 |
| 2019-12-23 | 2020-01-24 | 下个月和星期五,如果 25 号是周末 |

练习 13:格式化和解析(★★✩✩✩)

创建一个LocalDateTime对象,并以各种格式打印。格式化并解析这些格式:dd.MM.yyy HHdd.MM.yy HH:mmISO_LOCAL_DATE_TIME、SHORT 和美国地区。

例子

对于 2017 年 7 月 27 日 13 时 14 分 15 秒,输出应如下所示:

|

格式化

|

从语法上分析

|

格式

|
| --- | --- | --- |
| 27 07 2017 13 | 2017-07-27T13:00 | dd MM yyyy HH |
| 27.07.17 13:14 | 2017-07-27T13:14 | dd。嗯。yy 时:分 |
| 2017-07-27T13:14:15 | 2017-07-27T13:14:15 | ISO_LOCAL_DATE_TIME |
| 2017 年 7 月 27 日下午 1 点 14 分 | 2017-07-27T13:14 | 短+区域设置。美国 |

Tip

使用DateTimeFormatter类和它的常量以及它的方法,比如ofPattern()ofLocalizedDateTime()

练习 14:容错解析(★★✩✩✩)

评估用户输入时,容错通常很重要。您的任务是创建方法Optional<LocalDate> faultTolerantParse(String, Set<DateTimeFormatter>),它允许您解析以下日期格式:dd.MM.yydd.MM.yyyMM/dd/yyyyyyyy-MM-dd

例子

下表显示了解析不同输入的预期结果。请务必注意,对于模式中的两位数年份,有时它不会生成正确或预期的日期!

|

投入

|

结果

|
| --- | --- |
| “07.02.71” | 2071-02-07 |
| “07.02.1971” | 1971-02-07 |
| “02/07/1971” | 1971-02-07 |
| “1971-02-07” | 1971-02-07 |

6.3 解决方案

6.3.1 解决方案 1:闰年(★✩✩✩✩)

虽然在我们的日历中,一年通常被分为 365 天,但这在天文学上并不完全正确。一年大约有 365.25 天。因此,通过使用 366 天的闰年,几乎每 4 年就需要进行一次校正。有两个特殊方面需要考虑:

  • 能被 100 整除的年份叫世俗年,不是闰年。

  • 但是,同样能被 400 整除的世俗年份毕竟是闰年。

详见 https://en.wikipedia.org/wiki/Leap_year 。写方法boolean isLeap(int)——当然不用java.time.Year.isLeap()

例子

|

投入

|

规则

|

结果

|
| --- | --- | --- |
| One thousand nine hundred | 世俗年 | 没有闰年 |
| Two thousand | 世俗的岁月,但能被 400 整除 | 闰年 |
| Two thousand and twenty | 能被 4 整除 | 闰年 |

算法用模运算计算每个条件,并适当地组合它们:

static boolean isLeap(final int year)
{
    final boolean everyFourthYear = year % 4 == 0;
    final boolean isSecular = year % 100 ==  0;
    final boolean isSecularSpecial = year % 400 == 0;

    return everyFourthYear && (!isSecular || isSecularSpecial)
}

如前所述,Year类已经有了一个预定义的闰年检查:

static boolean isLeap_Jdk8(final int year)
{
    return Year.of(year).isLeap();
}

确认

让我们把它作为一个单元测试来尝试一下:

@ParameterizedTest(name = "isLeap({0} => {2}, Hinweis: {1}")
@CsvSource({ "1900, Secular, false",
             "2000, Secular (but rule of 400), true",
             "2020, Every 4th year, true" })
void testIsLeap(int year, String hint, boolean expected)
{
    boolean result = Ex01_LeapYear.isLeap(year);

    assertEquals(expected, result);
}

6.3.2 解决方案 2:基础知识日期-API (★★✩✩✩)

解决方案 2a:创造(★✩✩✩✩)

LocalDate表示你的生日,用LocalTime表示下午 5:30 下班,现在用LocalDateTime表示。此外,建模时间跨度为 1 年 10 个月 20 天,持续时间为 7 小时 15 分钟。

算法适当使用 API:

final LocalDate myBirthday = LocalDate.of(1971, 2, 7);
final LocalTime time = LocalTime.of(17, 30);
final LocalDateTime now = LocalDateTime.now();

final Period oneYear10Month20Days = Period.of(1, 10, 20);
final Duration sevenHours15Minutes = Duration.ofHours(7).plusMinutes(15);

解决方案 2b:持续时间(★★✩✩✩)

计算今天和你生日之间的时间长度,反之亦然。

算法适当使用 API,尤其是until()between()方法:

final LocalDate now = LocalDate.now();
final LocalDate birthday = LocalDate.of(1971, 2, 7);

System.out.println("Using until()");
System.out.println("now -> birthday: " + now.until(birthday)); System.out.println("birthday -> now: " + birthday.until(now)); System.out.println("\nUsing Period.between()");
System.out.println("now -> birthday: " + Period.between(now, birthday));
System.out.println("birthday -> now: " + Period.between(birthday, now));

6.3.3 解决方案 3:月份长度(★★✩✩✩)

解决方案 3a:计算(★✩✩✩✩)

使用类LocalDate的方法plusMonths()从 2012 年 2 月 2 日和 2014 年 2 月 2 日以及 2014 年 4 月 4 日和 2014 年 5 月 5 日跳转到未来一个月,并输出每种情况下的日期。

算法适当调用上述方法:

final LocalDate february_2_2012 = LocalDate.of(2012, 2, 2);
final LocalDate february_2_2014 = LocalDate.of(2014, 2, 2);
final LocalDate april_4_2014 = LocalDate.of(2014, 4, 4);
final LocalDate may_5_2014 = LocalDate.of(2014, 5, 5);

System.out.println("2/2/2012 + 1 month = " + february_2_2012.plusMonths(1));
System.out.println("2/2/2014 + 1 month = " + february_2_2014.plusMonths(1));
System.out.println("4/4/2014 + 1 month = " + april_4_2014.plusMonths(1));
System.out.println("5/5/2014 + 1 month = " + may_5_2014.plusMonths(1));

解决方案 3b:月份长度(★★✩✩✩)

对于计算,如果不使用plusMonths()而是使用方法plusDays()?会发生什么呢?对应于一个月的是:28、29、30 还是 31 天?你如何确定一个月的正确长度?

算法让我们为不同的值调用plusDays():

System.out.println("2/2/2012 + 28 days = " + february_2_2012.plusDays(28));
System.out.println("2/2/2014 + 28 days = " + february_2_2014.plusDays(28));
System.out.println("4/4/2014 + 30 days = " + april_4_2014.plusDays(30));
System.out.println("5/5/2014 + 31 days = " + may_5_2014.plusDays(31));

确认

使用plusMonths(),总是会添加相应月份的正确长度(也考虑到潜在的闰年)。但是,这不适用于plusDays()方法。这将添加指定的天数,强制您始终正确传递该值。为此,Month类提供了length(boolean)方法。或者,还有方法LocalDate.lengthOfMonth()。让我们来看看程序输出:

2/2/2012 + 1 month = 2012-03-02
2/2/2014 + 1 month = 2014-03-02
4/4/2014 + 1 month = 2014-05-04
5/5/2014 + 1 month = 2014-06-05
2/2/2012 + 28 days = 2012-03-01
2/2/2014 + 28 days = 2014-03-02
4/4/2014 + 30 days = 2014-05-04
5/5/2014 + 31 days = 2014-06-05

6.3.4 解决方案 4:时区(★★✩✩✩)

获取以America/LEurope/S开始的所有时区,并相应地填充一个排序集。为此写法Set<String> selectedAmericanAndEuropeanTimeZones()

例子

计算集应包含以下值:

[America/La_Paz, America/Lima, America/Los_Angeles, America/Louisville, America/Lower_Princes, Europe/Samara, Europe/San_Marino, Europe/Sarajevo, Europe/Saratov, Europe/Simferopol, Europe/Skopje, Europe/Sofia, Europe/Stockholm]

Tip

使用类ZoneId和它的方法getAvailableZoneIds()。利用 streams 和 filter-map-reduce 框架来查找与之前指定的前缀相匹配的时区 id。

算法首先,你确定所有时区 id。然后定义两个过滤条件,最后是它们的组合。这允许您使用流 API 的基本功能:

static Set<String> selectedAmericanAndEuropeanTimeZones()
{
    final Set<String> allZones = ZoneId.getAvailableZoneIds();

    final Predicate<String> inEuropeS = name -> name.startsWith("Europe/S");
    final Predicate<String> inAmericaL = name -> name.startsWith("America/L");
    final Predicate<String> europeOrAmerica = inEuropeS.or(inAmericaL);

    return allZones.stream().
                    filter(europeOrAmerica).
                    collect(Collectors.toCollection(TreeSet::new));
}

确认

若要进行验证,请运行以下单元测试:

@Test
public void selectedAmericanAndEuropeanTimeZones()
{
    var expected = Set.of("America/La_Paz", "America/Lima",
                   "America/Los_Angeles", "America/Louisville",
                   "America/Lower_Princes", "Europe/Samara",
                   "Europe/San_Marino", "Europe/Sarajevo",
                   "Europe/Saratov", "Europe/Simferopol",
                   "Europe/Skopje", "Europe/Sofia",
                   "Europe/Stockholm");

    Set<String> result = Ex04_ZoneIds.selectedAmericanAndEuropeanTimeZones();

    assertEquals(expected, result);
}

6.3.5 解决方案 5:时区计算(★★✩✩✩)

如果从苏黎世到旧金山的航班需要 11 小时 50 分钟,于 2019 年 9 月 15 日下午 1 点 10 分起飞,到达旧金山的当地时间是几点?出发地点的时间是几点?

例子

应该为该航班确定以下到达时间:

2019-09-16T01:00+02:00[Europe/Zurich]
2019-09-15T16:00-07:00[America/Los_Angeles]

Tip

使用类Duration来模拟飞行时间。旧金山的时区是America/Los_Angeles。使用类ZonedDateTime中的withZoneSameInstant()方法。

算法首先,构建出发日期和时间以及时区的对象,以创建相应的ZonedDateTime对象。然后你用一个Duration来定义飞行时间。简单地加上这个时差,你就可以得到到达时间,就像在欧洲一样。使用withZoneSameInstant()和时区America/Los_Angeles,您最终获得与美国时区相关的到达时间:

public static void main(final String[] args)
{
    final LocalDate departureDate = LocalDate.of(2019, 9, 15);
    final LocalTime departureTime = LocalTime.of(13, 10);
    final ZoneId zoneEurope = ZoneId.of("Europe/Zurich");

    // departure time
    final ZonedDateTime departure = ZonedDateTime.of(departureDate,
                                    departureTime, zoneEurope);

    // flight duration
    final Duration flightDuration = Duration.ofHours(11).plusMinutes(50);

    // arrival time based on flight duration (and time zone)
    final ZonedDateTime arrival1 = departure.plus(flightDuration);
    final ZoneId zoneAmerica = ZoneId.of("America/Los_Angeles");
    final ZonedDateTime arrival2 = arrival1.withZoneSameInstant(zoneAmerica);

    System.out.println(arrival1);    System.out.println(arrival2);
}

确认

让我们通过一个简单的程序运行来检查计算结果——作者又记起了那次飞行:-)

2019-09-16T01:00+02:00[Europe/Zurich]
2019-09-15T16:00-07:00[America/Los_Angeles]

6.3.6 解决方案 6:使用本地日期进行计算

练习 6a:13 号星期五(★★✩✩✩)

计算由两个LocalDate定义的范围内 13 号星期五的所有事件。编写通用方法List<LocalDate> allFriday13th(LocalDate, LocalDate),指定开始日期(含)和结束日期(不含)。

例子

对于 2013 年 1 月 1 日至 2015 年 12 月 31 日(含)这段时间,应确定以下日期值:

|

时期

|

结果

|
| --- | --- |
| 2013 – 2015 | [2013-09-13, 2013-12-13, 2014-06-13, 2015-02-13, 2015-03-13, 2015-11-13] |

算法在这个练习中,您使用两个谓词来测试星期五和十三号。为了提供要测试的相应日期,Java 9 中引入的datesUntil()方法可以帮助您。然后使用流 API 中的标准方法执行过滤。

static List<LocalDate> allFriday13th(final LocalDate start,
                                     final LocalDate end)
{
    final Predicate<LocalDate> isFriday = day -> day.getDayOfWeek() ==
                                                 DayOfWeek.FRIDAY;

    final Predicate<LocalDate> is13th = day -> day.getDayOfMonth() == 13;

    final List<LocalDate> allFriday13th = start.datesUntil(end).
                                          filter(isFriday).
                                          filter(is13th).
                                          collect(Collectors.toList());
    return allFriday13th;
}

或者,您可以从星期五开始,然后以 7 天为周期调用datesUntil(end, period)。这可能会更有效率一点。

让我们在 JShell 或一个main()方法中使用适当的日期值执行上面的行,如下所示:

var allFriday13th = allFriday13th(LocalDate.of(2013, 1, 1),
                                  LocalDate.of(2016, 1, 1));

System.out.println("allFriday13th: " + allFriday13th);

这提供了以下输出(这里的格式更好一点):

allFriday13th: [2013-09-13, 2013-12-13, 2014-06-13, 2015-02-13, 2015-03-13,
                2015-11-13]

解决方案 6b:13 号星期五(★★✩✩✩)的多次出现

13 号星期五在哪一年出现过几次?要回答这个问题,请计算一张地图,其中对应的星期五与每年相关联。为此编写方法Map<Integer, List<LocalDate>> friday13thGrouped(LocalDate, LocalDate)

例子

|

|

结果

|
| --- | --- |
| Two thousand and thirteen | [2013-09-13, 2013-12-13] |
| Two thousand and fourteen | [2014-06-13] |
| Two thousand and fifteen | [2015-02-13, 2015-03-13, 2015-11-13] |

算法基于之前定义的方法,您可以再次使用 Stream API 通过groupingBy()方法按年份进行分组。最后,您希望对年份进行排序,这就是为什么您要将结果传输到一个TreeMap<K,V>中。

static Map<Integer, List<LocalDate>> friday13thGrouped(final LocalDate start,
                                                     final LocalDate end)
{
    return new TreeMap<>(allFriday13th(start, end).stream().
                         collect(Collectors.groupingBy(LocalDate::getYear)));
}

确认

让我们快速看一下关联的单元测试可能是什么样子的:

@Test
void testAllFriday13th()
{
    final LocalDate start = LocalDate.of(2013, 1, 1);
    final LocalDate end = LocalDate.of(2016, 1, 1);

    var result = Ex06_Friday13thExample.allFriday13th(start, end);

    // trick: Use Stream.of() and map() to have less typing work
    var expected = Stream.of("2013-09-13", "2013-12-13", "2014-06-13",
                             "2015-02-13", "2015-03-13", "2015-11-13").
                          map(str -> LocalDate.parse(str)).
                          collect(Collectors.toList());

    assertEquals(expected, result);
}

您可以按如下方式测试分组:

@Test
void testFriday13thGrouped()
{
    final LocalDate start = LocalDate.of(2013, 1, 1);
    final LocalDate end = LocalDate.of(2016, 1, 1);

    var result = Ex06_Friday13thExample.friday13thGrouped(start, end);

    var expected = Map.of(2013, List.of(LocalDate.parse("2013-09-13"),
                                        LocalDate.parse("2013-12-13")),
                          2014,  List.of(LocalDate.parse("2014-06-13")),
                          2015,  List.of(LocalDate.parse("2015-02-13"),
                                         LocalDate.parse("2015-03-13"),
                                         LocalDate.parse("2015-11-13")));

    assertEquals(expected, result);
}

6.3.7 解决方案 7:日历输出(★★★✩✩)

编写方法void printCalendar(Month, int),对于给定的月份和年份,将日历页面打印到控制台。

例子

对于 2020 年 4 月,您预计会得到以下结果:

Mon Tue Wed Thu Fri Sat Sun
..  ..  01  02  03  04  05
06  07  08  09  10  11  12
13  14  15  16  17  18  19
20  21  22  23  24  25  26
27  28  29  30  --  --  --

对于结束于周日的示例,您可以选择 2020 年 5 月。要验证在星期一开始,请使用 2020 年 6 月。

算法的初步考虑:从 2020 年 4 月的日历页面,您已经可以看到一些等待您的特殊方面和挑战:

  • 第一周和最后一周可能不完整

  • 星期天总是有换行。

  • 你如何把工作日和天数联系起来?

算法在开始时,你显示一个带有工作日的标题行。之后,您确定一个月开始的星期几,然后如果必要的话用skipTillFirstDayOfMonth()跳过一些星期几。要输出各个日子,您需要使用length()根据闰年信息指定月份长度。接下来是有一些技巧的部分。您遍历所有的日期值,并以格式化的方式打印数字。当你到了一个星期天,你继续下一行。要移动星期几,可以使用助手方法calcNextWeekDay()。最后,所有不再属于该月的日子都应该标上--。这就是fillFromMonthEndToSunday()法的任务。

static void printCalendar(final Month month, final int year)
{

    System.out.println("Mon Tue Wed Thu Fri Sat Sun");

    LocalDate cur = LocalDate.of(year, month, 1);
    DayOfWeek firstInMonth = cur.getDayOfWeek();

    skipTillFirstDayOfMonth(firstInMonth);

    DayOfWeek currentWeekDay = firstInMonth;
    int lengthOfMonth = month.length(Year.of(year).isLeap());
    for (int i = 1; i <= lengthOfMonth; i++)
    {
         System.out.print(String.format("%03d", i) + " ");
         if (currentWeekDay == DayOfWeek.SUNDAY) System.out.println();

       currentWeekDay = nextWeekDay(currentWeekDay);
    }

    fillFromMonthEndToSunday(currentWeekDay);

    // last day not Sunday, then pagination
    if (currentWeekDay != DayOfWeek.MONDAY) System.out.println();
}

现在我们来看 helper 方法,它将一周中的某一天移动到下一天,并从周日循环到周一。这可以基于枚举DayOfWeek和模运算来实现。使用自动实现这种特殊处理的方法plus()甚至更容易。

static DayOfWeek calcNextWeekDay(final DayOfWeek nextWeekDay)
{
    return nextWeekDay.plus(1);
}

打印不属于一个月的一周的第一天和最后一天的方法保持不变。对于前者,您使用所示的帮助器方法,从星期一进行到该月第一天的工作日:

static void skipTillFirstDayOfMonth(final DayOfWeek firstInMonth)
{
    DayOfWeek currentWeekDay = DayOfWeek.MONDAY;
    while (currentWeekDay != firstInMonth)
    {
         System.out.print("..  ");
         currentWeekDay = nextWeekDay(currentWeekDay);
    }
}

最后,为了完成日历的输出,从一个月的最后一天开始,用--填充直到到达一个星期天:

static void fillFromMonthEndToSunday(final DayOfWeek currentWeekDay)
{
    DayOfWeek nextWeekDay = currentWeekDay;
    while (nextWeekDay != DayOfWeek.MONDAY)
    {
         System.out.print("--  ");
         nextWeekDay = nextWeekDay(nextWeekDay);
    }
}

Hint: Helper Methods and Proper Structuring

正如多次演示的那样,创建适当的助手方法简化了问题的解决(通常是显著的)。这允许您在算法的实现中更多地停留在逻辑级别,并避免因实现细节而分心。

确认

将方法复制到 JShell 中,并记住导入import java.time.*。之后,您只需浏览三个示例性案例一次:

jshell> printCalendar(Month.APRIL, 2020)
Mon Tue Wed Thu Fri Sat Sun
..  ..  01  02  03  04  05
06  07  08  09  10  11  12
13  14  15  16  17  18  19
20  21  22  23  24  25  26
27  28  29  30  --  --  --

jshell> printCalendar(Month.MAY, 2020)
Mon Tue Wed Thu Fri Sat Sun
..  ..  ..  ..  01  02  03
04  05  06  07  08  09  10
11  12  13  14  15  16  17
18  19  20  21  22  23  24
25  26  27  28  29  30  31

jshell> printCalendar(Month.JUNE, 2020)
Mon Tue Wed Thu Fri Sat Sun
01  02  03  04  05  06  07
08  09  10  11  12  13  14
15  16  17  18  19  20  21
22  23  24  25  26  27  28
29  30  --  --  --  --  --

6.3.8 解决方案 8:工作日(★✩✩✩✩)

解决方案 8a:工作日(★✩✩✩✩)

平安夜 2019 年(2019 年 12 月 24 日)是星期几?2019 年 12 月的第一天和最后一天是星期几?

例子

一周中的以下几天应该是结果:

|

投入

|

结果

|
| --- | --- |
| 2019 年 12 月 24 日 | 星期二 |
| 2019 年 12 月 01 日 | 在星期日 |
| 2019 年 12 月 31 日 | 星期二 |

算法你创建一个LocalDate对象并使用方法getDayOfWeek()DayOfWeek.from()来确定相应的工作日。在使用匹配的TemporalAdjuster跳转到 12 月的第一天或最后一天之后,重复这个操作。

LocalDate christmasEve = LocalDate.of(2019, 12, 24); System.out.println("Dec. 24, 2019 = " + christmasEve.getDayOfWeek());
System.out.println("Dec. 24, 2019 = " + DayOfWeek.from(christmasEve));

var decemberFirst = christmasEve.with(TemporalAdjusters.firstDayOfMonth()); System.out.println("Dec. 01, 2019 = " + ecemberFirst.getDayOfWeek());

var decemberLast = christmasEve.with(TemporalAdjusters.lastDayOfMonth()); System.out.println("Dec. 31, 2019 = " + decemberLast.getDayOfWeek());

确认

上述行输出以下内容:

Dec. 24, 2019 = TUESDAY
Dec. 24, 2019 = TUESDAY
Dec. 01, 2019 = SUNDAY
Dec. 31, 2019 = TUESDAY

请不要忘记在 JShell 中执行以下导入操作:

jshell> import java.time.temporal.*

解决办法 8b:日期

用写法Map<String, LocalDate> firstAndLastFridayAndSunday(YearMonth)计算出 2019 年 3 月第一个和最后一个星期五和星期天各自的日期。一路上,了解用于建模年份和月份的YearMonth类。

例子

计算出的映射应包含以下值,并按键排序:

{firstFriday=2019-03-01, firstSunday=2019-03-03, lastFriday=2019-03-29, lastSunday=2019-03-31}

算法通过适当选择实用程序类TemporalAdjusters的方法,您可以通过指定所需的工作日来轻松定义适当的时间跳转:

static Map<String, LocalDate>
       firstAndLastFridayAndSunday(final YearMonth yearMonth)
{
    var toFirstFriday = TemporalAdjusters.firstInMonth(DayOfWeek.FRIDAY);
    var toFirstSunday = TemporalAdjusters.firstInMonth(DayOfWeek.SUNDAY);
    var toLastFriday = TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY);
    var toLastSunday = TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY);

    var day = LocalDate.of(yearMonth.getYear(), yearMonth.getMonth(), 15);

    return new TreeMap<>(Map.of("firstFriday", day.with(toFirstFriday),
                                "firstSunday", day.with(toFirstSunday),
                                "lastFriday", day.with(toLastFriday),
                                "lastSunday", day.with(toLastSunday)));
}

确认

让我们为验证创建一个单元测试:

@Test
void testFirstAndLastFridayAndSunday()
{
    final YearMonth march2019 = YearMonth.of(2019, Month.MARCH);

    var expected = Map.of("firstFriday", LocalDate.of(2019, 3, 1),
                          "firstSunday", LocalDate.of(2019, 3, 3),
                          "lastFriday",  LocalDate.of(2019, 3, 29),
                          "lastSunday",  LocalDate.of(2019, 3, 31));

    var result = Ex08_WeekDays.firstAndLastFridayAndSunday(march2019);

    assertEquals(expected, result);
}

解决方案 8c:月份或年份中的某一天(★✩✩✩✩)

在练习部分 8b 中,您确定了 2019 年 3 月的四个日期。这两种情况下都是在三月的哪一天?一年中的哪一天?

例子

|

投入

|

一个月中的第几天

|

一年中的每一天

|
| --- | --- | --- |
| 第一个星期五=2019-03-01 | one | Sixty |
| 第一个星期天=2019-03-03 | three | Sixty-two |
| lastFriday=2019-03-29 | Twenty-nine | Eighty-eight |
| 载荷空间=2019-03-31 | Thirty-one | Ninety |

算法通过适当的调用,分配很容易实现。为了获得吸引人的输出,您进一步使用具有适当格式规范的String.format():

static void dayOfMonthAndDayInYear(final Map<String, LocalDate> days)
{
    System.out.println("Day          Of Month / Of Year");

    for (final String key : List.of("firstFriday", "firstSunday",
                                    "lastFriday", "lastSunday"))
    {
       final LocalDate day = days.getOrDefault(key, LocalDate.now());

       System.out.println(String.format("%-12s %9d %9d",
                                        key,
                                        day.getDayOfMonth(),
                                        day.getDayOfYear()));
    }
}

确认

当使用练习部分 8b 中计算的映射作为输入进行调用时,上述行输出以下内容:

Day           Of Month / Of Year
firstFriday         1         60
firstSunday         3         62
lastFriday         29         88
lastSunday         31         90

6.3.9 解决方案 9:星期日和闰年(★★✩✩✩)

解决方案 9a:周日

计算由两个LocalDate给定的范围内的星期日数。为此,编写方法Stream<LocalDate> allSundaysBetween(LocalDate, LocalDate),其中开始日期包含在内,结束日期不包含在内。

例子

|

时期

|

结果

|
| --- | --- |
| 1.1.2017 – 1.1.2018 | Fifty-three |
| 1.1.2019 – 7.2.2019 | five |

算法基于流 API,赋值可以很容易实现:

static Stream<LocalDate> allSundaysBetween(final LocalDate start,
                                           final LocalDate end)
{
    final Predicate<LocalDate> isSunday =
                             day -> day.getDayOfWeek() == DayOfWeek.SUNDAY;

    return start.datesUntil(end).filter(isSunday);
}

确认

对于 2017 年,确定了 53 个星期日。从 2019 年 1 月 1 日到 2019 年 7 月 2 日,数字是 5,根据预期。下面的单元测试可以确保:

@ParameterizedTest(name = "allSundaysBetween({0}, {1}) => {2}")
@CsvSource({ "2017-01-01, 2018-01-01, 53", "2019-01-01, 2019-02-07, 5" })
void allSundaysBetween(LocalDate start, LocalDate end, int expected)
{
    var result = Ex09_CountSundays.allSundaysBetween(start, end);

    assertEquals(expected, result.count());
}

您可以再次看到集成到 JUnit 5 中的参数转换器是多么有用。有了它们,日期被自动转换成一个LocalDate。这导致非常可读和可理解的测试。

解决方案 9b:闰年

Year实例给定的范围内计算闰年数。为此,编写方法long countLeapYears(Year, Year),其中起始年包含,结束年不包含。

例子

|

时期

|

结果

|
| --- | --- |
| 2010 – 2019 | Two |
| 2000 – 2019 | five |

算法虽然通过一些思考和技巧你可以回到datesUntil()方法,但这里你想采取不同的方法。使用流 API 和方法range(),可以将数值创建为流。现在你过滤所有闰年。之后,你只需要调用count()就可以从 Stream API 中统计出剩余年限。

static long countLeapYears(final Year start, final Year end)
{
    return IntStream.range(start.getValue(), end.getValue()).
                     filter(Year::isLeap).
                     count();
}

确认

如果您检查 2010 年到 2019 年期间的功能,结果应该是两个闰年。从 2000 年到 2019 年,有五个。

@ParameterizedTest(name = "countLeepYears({0}, {1}) => {2}")
@CsvSource({ "2010, 2019, 2", "2000, 2019, 5" })
public void countLeapYears(Year start, Year end, int expected)
{
    long result = Ex09_CountSundays.countLeapYears(start, end);

    assertEquals(expected, result);
}

同样,JUnit 5 内置的参数处理自动化可以帮助您将数字转换成类型为Year的对象。这使得这个测试又好又短,简洁又精确。

6.3.10 解决方案 10:临时助理(★★★✩✩)

编写一个TemporalAdjuster,将日期值移动到每个季度的开始,比如从 2 月 7 日到 1 月 1 日。

例子

|

投入

|

结果

|
| --- | --- |
| LocalDate.of(2014,3,15) | 2014-01-01 |
| LocalDate.of(2014 年 6 月 15 日) | 2014-04-01 |
| LocalDate.of(2014,9,15) | 2014-07-01 |
| LocalDate.of(2014,11,15) | 2014-10-01 |

Tip

MonthIsoFields类中四处看看。

算法如果您稍微研究一下日期和时间 API,您可能会发现在Month类中有一个方法getLong(TemporalField),可用于获取一个月的季度。现在,基于此,您可以在数组中确定该季度合适的开始月份。

public class Ex10_FirstDayOfQuarter implements TemporalAdjuster
{
    private static final Month[] startMonthOfQuarter = { Month.JANUARY,
                                                         Month.APRIL,
                                                         Month.JULY,
                                                          Month.OCTOBER };

    @Override
    public Temporal adjustInto(final Temporal temporal)
    {
        final int currentQuarter = getQuarter(temporal);

        final Month startMonth = startMonthOfQuarter[currentQuarter - 1];

        return LocalDate.from(temporal).
                         withMonth(startMonth.getValue()).
                         with(firstDayOfMonth());
    }

    private int getQuarter(final Temporal temporal)
    {
        return (int)Month.from(temporal).
                          getLong(IsoFields.QUARTER_OF_YEAR);
    }
}

优化算法刚才展示的解决方案是通用的,可以适应其他需求。对日期和时间 API 的进一步实验为我们带来了一个非常简洁但同样有说服力的解决方案:

public class Ex10_FirstDayOfQuarterOptimized implements TemporalAdjuster
{
    @Override
    public Temporal adjustInto(final Temporal temporal)
    {
         return LocalDate.from(temporal).with(IsoFields.DAY_OF_QUARTER, 1);
    }
}

确认

使用这两种变体的单元测试如下所示:

@ParameterizedTest(name = "move {0} to first of quarter: {1}")
@CsvSource({ "2014-03-15, 2014-01-01", "2014-06-16, 2014-04-01",
             "2014-09-15, 2014-07-01", "2014-11-15, 2014-10-01" })
void adjustToFirstDayOfQuarter(LocalDate startDate, LocalDate expected)
{
    var result = new Ex10_FirstDayOfQuarter().adjustInto(startDate);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "move {0} to first of quarter: {1}")
@CsvSource({ "2014-03-15, 2014-01-01", "2014-06-16, 2014-04-01",
             "2014-09-15, 2014-07-01", "2014-11-15, 2014-10-01" })
void adjustToFirstDayOfQuarterOptimized(LocalDate startDate, LocalDate expected)
{
    var result = new Ex10_FirstDayOfQuarterOptimized().adjustInto(startDate);

    assertEquals(expected, result);
}

6.3.11 解决方案 11:第 n 周工作日调整(★★★✩✩)

编写跳转到一周第 n 天的类NthWeekdayAdjuster,比如第三个星期五。这要从 a LocalDate给的月初说起。对于较大的 n,值,它应该跳到随后的月份。

例子

使用以下开始日期为 2015 年 8 月 15 日的时间跳转来验证此课:

|

开始日期

|

跳跃目标

|

结果

|
| --- | --- | --- |
| 2015-08-15  (2015-08-15) | 第二个星期五 | 2015-08-14 |
| 2015-08-15  (2015-08-15) | 第三个星期天 | 2015-08-16 |
| 2015-08-15  (2015-08-15) | 第四个星期二 | 2015-08-25 |

算法定义类Ex11_NthWeekdayAdjuster,向其传递所需的一周中的某一天及其编号,以调整到第 n 个工作日。通过从LocalDate类调用from(TemporalAccessor)方法,您创建了一个对应于传入的Temporal对象的LocalDate实例,您使用它的firstInMonth(DayOfWeek)方法将它移动到一周的第一天。之后,您使用next(DayOfWeek)方法,这将使您前进到下一个指定的工作日。为了计数,你必须记住你说的是第四个星期天,但是当然,计数从 1 开始。

public class Ex11_NthWeekdayAdjuster implements TemporalAdjuster
{
    private final DayOfWeek dayToAdjust;
    private final int count;

    public Ex11_NthWeekdayAdjuster(final DayOfWeek dayToAdjust, final int count)
    {
        this.dayToAdjust = dayToAdjust;
        this.count = count;
    }

    public Temporal adjustInto(final Temporal input)
    {
        final LocalDate startday = LocalDate.from(input);

        LocalDate adjustedDay =
                  startday.with(TemporalAdjusters.firstInMonth(dayToAdjust));

        for (int i = 1; i < count; i++)
        {
            adjustedDay = adjustedDay.with(TemporalAdjusters.next(dayToAdjust));
        }

        return input.with(adjustedDay);
    }
}

当实现移动到一周的第 n 天时,记得从值 1 开始,因为人类的思维模式(通常)不是从 0 开始的!

确认

让我们使用以下单元测试来验证您的解决方案:

@ParameterizedTest(name = "adjusting {0} to {1}. {2} => {3}")
@CsvSource({ "2015-08-15, 2, FRIDAY, 2015-08-14",
             "2015-08-15, 3, SUNDAY, 2015-08-16",
             "2015-08-15, 4, TUESDAY, 2015-08-25" })
void adjustToFirstDayOfQuarter(LocalDate startDay, int count,
                               DayOfWeek dayOfWeek, LocalDate expected)
{
    var nthWeekdayAdjuster = new Ex11_NthWeekdayAdjuster(dayOfWeek, count);

    var result = nthWeekdayAdjuster.adjustInto(startDay);

    assertEquals(expected, result);
}

6.3.12 解决方案 12:发薪日临时助理(★★★✩✩)

实现计算瑞士典型发薪日的类Ex12_NextPaydayAdjuster。这通常是每月的 25 号。如果这一天适逢周末,前一个星期五作为发薪日。如果你在付款日之后,那么这被认为是在下个月。作为一个自由式,你仍然需要对 12 月份的工资支付执行一个特殊的规则。在这里,付款应该在月中进行。如有必要,如果发薪日是周末,付款将被移到下一个星期一。

例子

基于以下日期值验证此类:

|

投入

|

结果

|

规则

|
| --- | --- | --- |
| 2019-07-21 | 2019-07-25 | 正常调整 |
| 2019-06-27 | 2019-07-25 | 正常调整,下个月 |
| 2019-08-21 | 2019-08-23 | 星期五,如果 25 号是周末 |
| 2019-12-06 | 2019-12-16 | 十二月:月中,周末后的星期一 |
| 2019-12-23 | 2020-01-24 | 下个月和星期五,如果 25 号是周末 |

算法让我们试着一步步开发需求。首先将任意日期调整为该月的第 25 天。

计算一个月的 25 号要实现工作日的期望调整,您需要依赖接口TemporalAdjuster,特别是方法adjustInto(Temporal)。首先,通过从类LocalDate中调用方法from(TemporalAccessor),您确定了一个对应于传递的Temporal对象的LocalDate实例。要将此移动到第 25 个到第个,使用codeLocalDate的方法withDayOfMonth(int):

class Ex12_NextPaydayAdjuster implements TemporalAdjuster
{
    @Override
    public Temporal adjustInto(final Temporal temporal)
    {
       LocalDate date = LocalDate.from(temporal);

       date = date.withDayOfMonth(25);

       return temporal.with(date);
    }
}

让我们在 JShell 中尝试一下:

jshell> import java.time.*
jshell> import java.time.temporal.*

jshell> var jan31 = LocalDate.of(2015, Month.JANUARY, 31)

jshell> jan31.with(new Ex12_NextPaydayAdjuster())
$24 ==> 2015-01-25

jshell> var feb7 = LocalDate.of(2015, Month.FEBRUARY, 7);

jshell> feb7.with(new Ex12_NextPaydayAdjuster())
$26 ==> 2015-02-25

如此处所示,调整已经非常有效。然而,有一件小事需要考虑:在 25 号以后的日子里,下一个发薪日不会在同一个月。从 26 号到月底的天数将计入下个月。因此,您可以按如下方式调整计算:

@Override
public Temporal adjustInto(final Temporal temporal)
{
    LocalDate date = LocalDate.from(temporal);

    if (date.getDayOfMonth() > 25)
    {
        date = date.plusMonths(1);
    }
    date = date.withDayOfMonth(25);

    return temporal.with(date);
}

这是很好的一步。你仍然缺少的是周末的考虑。

周末特别修正如果发薪日是在周末,选择它之前的周五。这导致以粗体标记的添加:

@Override
public Temporal adjustInto(final Temporal temporal)
{
    LocalDate date = LocalDate.from(temporal);

    if (date.getDayOfMonth() > 25)
    {
        date = date.plusMonths(1);
    }
    date = date.withDayOfMonth(25);

    if (date.getDayOfWeek() == SATURDAY ||
        date.getDayOfWeek() == SUNDAY)
    {
        date = date.with(TemporalAdjusters.previous(FRIDAY));
    }

    return temporal.with(date);
}

您可以看到,通过整合现实世界的需求,原本简单的方法变得有点复杂。尽管如此,由于有了日期和时间 API,它仍然具有很好的可读性!

12 月的特殊待遇您现在整合了 12 月的特殊待遇。在那里,工资在月中支付,如果 15 号是周末,则在下一个星期一支付。

当思考问题和解决方案时,可能会想到预定义的TemporalAdjuster类。这里,方法nextOrSame(DayOfWeek)previousOrSame(DayOfWeek)以及匹配的TemporalAdjuster实例可以如下使用:

if (isDecember)
{
    date = date.with(TemporalAdjusters.nextOrSame(MONDAY));
}
else
{
    date = date.with(TemporalAdjusters.previousOrSame(FRIDAY));
}

完成实现在完成这些单独的步骤后,让我们来看看最终的完整实现,以便更好地理解:

public class Ex12_NextPaydayAdjuster implements TemporalAdjuster
{
    @Override
    public Temporal adjustInto(final Temporal temporal)
    {
        LocalDate date = LocalDate.from(temporal);

        boolean isDecember = date.getMonth() == Month.DECEMBER;
        int paymentDay = isDecember ? 15 : 25;

        if (date.getDayOfMonth() > paymentDay)
        {
          date = date.plusMonths(1);

          // queries necessary again, as possibly postponed by one month
          isDecember = date.getMonth().equals(Month.DECEMBER);
          paymentDay = isDecember ? 15 : 25;
        }

        date = date.withDayOfMonth(paymentDay);

        if (date.getDayOfWeek() == DayOfWeek.SATURDAY ||
            date.getDayOfWeek() == DayOfWeek.SUNDAY)
        {
            if (isDecember)
              date = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
            else
              date = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY));
        }
        return temporal.with(date);
    }
}

确认

因为这个实现已经相当复杂了,所以强烈建议用几个测试用例进行彻底的单元测试。下面您将学习如何使用 JUnit 5 创建重要的测试。为此,您将使用以下技巧:

  • 您包括了一个没有用于测试用例的附加参数,它提供了关于测试用例的信息。

  • 到目前为止,您已经使用了@CsvSource来指定逗号分隔的值。这在这里不太可能,因为提示文本中使用了逗号。作为一种变通方法,@CsvSource允许通过提供delimiter来指定所需的分隔符。

@ParameterizedTest(name="adjustToPayday({0}) => {1}, {2}")
@CsvSource(value= { "2019-07-21; 2019-07-25; nnormal adjustment",
        "2019-06-27; 2019-07-25; normal adjustment, next month",
        "2019-08-21; 2019-08-23; Friday, if 25th in weekend",
        "2019-12-06; 2019-12-16; December: mid of month, Monday after weekend",
        "2019-12-23; 2020-01-24; next month and Friday if 25th on weekend" }},
        delimiterString=";")
public void adjustInto(LocalDate startDay, LocalDate expected, String info)
{
    final TemporalAdjuster paydayAdjuster = new Ex12_NextPaydayAdjuster();

    final Temporal result = paydayAdjuster.adjustInto(startDay);

    assertEquals(expected, result);
}

因为整个事情应该真正说服甚至最后的测试马弗,我仍然想在 Eclipse 中映射信息输出(见图 6-1 )。

img/519691_1_En_6_Fig1_HTML.jpg

图 6-1

Ex12_NextPaydayAdjuster 的单元测试输出

6.3.13 解决方案 13:格式化和解析(★★✩✩✩)

创建一个LocalDateTime对象,并以各种格式打印。格式化并解析这些格式:dd.MM.yyy HHdd.MM.yy HH:mmISO_LOCAL_DATE_TIME、SHORT 和美国地区。

例子

对于 2017 年 7 月 27 日 13 时 14 分 15 秒,输出应如下所示:

|

格式化

|

从语法上分析

|

格式

|
| --- | --- | --- |
| 27 07 2017 13 | 2017-07-27T13:00 | dd MM yyyy HH |
| 27.07.17 13:14 | 2017-07-27T13:14 | dd。嗯。yy 时:分 |
| 2017-07-27T13:14:15 | 2017-07-27T13:14:15 | ISO_LOCAL_DATE_TIME |
| 2017 年 7 月 27 日下午 1 点 14 分 | 2017-07-27T13:14 | 短+区域设置。美国 |

Tip

使用DateTimeFormatter类和它的常量以及它的方法,比如ofPattern()ofLocalizedDateTime()

算法用于格式化和解析,使用了类DateTimeFormatter及其各种实用方法。首先转换为所需格式,然后从中进行解析的实用程序方法实现如下:

static void applyFormatters(final LocalDateTime base,
                            final List<DateTimeFormatter> formatters)
{
    formatters.forEach((formatter) -> {

       final String formatted = base.format(formatter);

       try
       {
          // attention: pitfall
          // TemporalAccessor parsed = formatter.parse(formatted);

          LocalDateTime parsed = LocalDateTime.parse(formatted, formatter);

          System.out.println("Formatted: " + formatted + " / " +
                             "Parsed: " + parsed);
       }
       catch (DateTimeParseException ignore)
       {

       }
    });
}

因此,你认为这是一个暗示,一个人们喜欢上当的陷阱。格式化程序提供了一个用于解析的方法parse(),但是这返回了一个通用的TemporalAccessor对象,而不是所需的专门化。

确认

要在 JShell 中试用它,除了众所周知的第一个导入之外,还需要另一个导入:

import java.time.*
import java.time.format.*

然后,您可以键入以下几行并理解不同的格式:

jshell> var someday = LocalDateTime.of(2017, 7, 27, 13, 14, 15)
   ...>
   ...> var format1 = DateTimeFormatter.ofPattern("dd.MM.yyyy HH")
   ...> var format2 = DateTimeFormatter.ofPattern("dd.MM.yy HH:mm")
   ...> var format3 = DateTimeFormatter.ISO_LOCAL_DATE_TIME
   ...> var format4 = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT,
   ...>                                                     FormatStyle.SHORT)
   ...>             .withLocale(Locale.US);
   ...>
   ...> applyFormatters(someday, List.of(format1, format2,
   ...>                                  format3, format4));

Formatted: 27.07.2017 13 / Parsed: 2017-07-27T13:00
Formatted: 27.07.17 13:14 / Parsed: 2017-07-27T13:14
Formatted: 2017-07-27T13:14:15 / Parsed: 2017-07-27T13:14:15
Formatted: 7/27/17, 1:14 PM / Parsed: 2017-07-27T13:14

6.3.14 解决方案 14:容错解析(★★✩✩✩)

评估用户输入时,容错通常很重要。您的任务是创建方法Optional<LocalDate> faultTolerantParse(String, Set<DateTimeFormatter>),它允许您解析以下日期格式:dd.MM.yydd.MM.yyyMM/dd/yyyyyyyy-MM-dd

例子

下表显示了解析不同输入的预期结果。请务必注意,对于模式中的两位数年份,有时它不会生成正确或预期的日期!

|

投入

|

结果

|
| --- | --- |
| “07.02.71” | 2071-02-07 |
| “07.02.1971” | 1971-02-07 |
| “02/07/1971” | 1971-02-07 |
| “1971-02-07” | 1971-02-07 |

算法容错解析涉及一次尝试解析一组提供的DateTimeFormatter s。如果出现异常,则该格式不合适,只要有更多的可用格式,就会尝试下一个格式。任务所需的格式化程序是在一个单独的方法中创建的:

static Optional<LocalDate> faultTolerantParse(final CharSequence input,
                                              final Set<DateTimeFormatter>
                                                    formatters)
{
    LocalDate result = null;

    var it = formatters.iterator();
    while (result == null && it.hasNext())
    {
       var entry = it.next();
       try
       {
          result = LocalDate.parse(input, entry);
       }
       catch (DateTimeParseException ignore)
       {
          // try next
       }
    }

    return Optional.ofNullable(result);
}

static Set<DateTimeFormatter> populateFormatters()
{
    final Set<DateTimeFormatter> formatters = new LinkedHashSet<>();
    formatters.add(DateTimeFormatter.ofPattern("dd.MM.yy"));
    formatters.add(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
    formatters.add(DateTimeFormatter.ofPattern("MM/dd/yyyy"));
    formatters.add(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    return formatters;
}

确认

您定义了要支持的四种不同的日期格式,然后定义了一些与这些格式相对应的日期符号。测试表明,四种格式的输入都能被成功识别和解析:

@ParameterizedTest(name = "faultTolerantParse({0}) expected date {1}")
@CsvSource({ "07.02.71, 2071-02-07", "07.02.1971, 1971-02-07",
             "02/07/1971, 1971-02-07", "1971-02-07, 1971-02-07" })
void faultTolerantParse(String dateAsString, LocalDate expected)
{
    var formatters = Ex14_FaultTolerantParser.populateFormatters();

    var optParsedLocalDate =
        Ex14_FaultTolerantParser.faultTolerantParse(dateAsString, formatters);

    assertTrue(optParsedLocalDate.isPresent());
    assertEquals(expected, optParsedLocalDate.get());
}

最后,让我们看看容错解析对于两种不同的格式是如何表现的。您认为由于不匹配的格式,您不能提供一个LocalDate:

@ParameterizedTest(name = "faultTolerantParse({0}) expected empty")
@CsvSource({ "31-01-1940", "1940/01/31" })
void faultTolerantParseInvalidFormats(String dateAsString)
{
    var formatters = Ex14_FaultTolerantParser.populateFormatters();

    var optParsedLocalDate =
        Ex14_FaultTolerantParser.faultTolerantParse(dateAsString, formatters);

    assertTrue(optParsedLocalDate.isEmpty());
}

七、基本数据结构:列表、集合和映射

在集合框架中,列表、集合和映射作为键值存储由所谓的容器类实现。它们管理其他类的对象。包java.util中的接口List<E>Set<E>Map<K,V>作为容器类的基础。它们存储对象引用。因此,处理原语类型只有在它们被转换成包装器对象(如ByteIntegerDouble)时才有可能。从 Java 5 开始,这是通过隐式的自动装箱/拆箱自动完成的。

7.1 导言

7.1.1 接口集合

接口java.util.Collection<E>定义了实现接口List<E>Set<E>的各种容器类的基础。接口Collection<E>提供了以下方法,但是没有索引访问:

  • int size()获取存储在集合中的元素数量。

  • boolean isEmpty()检查元素是否存在。

  • boolean add(E element)向集合中添加元素。

  • boolean addAll(Collection<? extends E> collection)是一个批量操作,它将所有传递的元素添加到集合中。

  • boolean remove(Object object)从集合中移除元素。

  • 从集合中移除所有传递的元素(如果有的话)。

  • boolean contains(Object object)检查元素是否包含在集合中。

  • boolean containsAll(Collection<?> collection)检查集合中是否包含所有指定的元素。

  • boolean retainAll(Collection<?> collection)保留给定集合中包含的集合的所有元素。

add()remove()这样的修改方法,如果集合发生了变化,返回值true,否则返回值false。这对器械包尤其重要。

Hint: Signature of Contains(), Containsall(), and Others

一个小注意:为什么方法contains()containsAll()retainAll()以及remove()removeAll()不使用通用类型E而是使用Object?根据方法契约,equals(Object)将输入对象与集合的元素进行比较,但是类型只是通过equals(Object)的实现间接相关。

7.1.2 列表和接口列表

列表是按位置排序的元素序列。也允许重复。集合框架定义了接口List<E>来描述列表。这允许索引访问,以及添加和删除元素。已知的实现是类ArrayList<E>LinkedList<E>

界面列表

接口List<E>构成了所有列表的基础,并为接口Collection<E>的方法提供了附加的以下基于 0 的索引访问:

  • E get(int index)获取位置index的元素。

  • void add(int index, E element)在位置索引处添加元素element

  • E set(int index, E element)用传递的元素element替换位置index的元素,并返回先前存储在该位置的元素。

  • E remove(int index)删除列表中index位置的元素,并返回被删除的元素。

  • int indexOf(Object object)lastIndexOf(Object)返回被搜索元素的位置。使用equals(Object)方法检查搜索元素和列表中各个元素之间的相等性。搜索从列表的开头(indexOf(Object))或末尾(lastIndexOf(Object))开始。

7.1.3 集合和接口集合

在以下章节中,您将继续学习器械包和界面Set<E>。集合的数学概念表明它们不包含重复项。

界面设置

接口Set<E>基于接口Collection<E>。与接口List<E>不同的是,在接口Set<E>中,除了Collection<E>接口的方法之外,没有其他方法。然而,add(E)addAll(Collection<? extends E>)方法的行为被指定为不可能重复。这是保证无重复所必需的,即使同一个对象被多次添加到集合中。

7.1.4 键值映射和接口映射

现在让我们考虑一下接口Map<K,V>。它的实现实现了从键到值的映射。通常地图也被称为字典、?? 或查找表、??。基本思想是为每个存储值分配一个唯一的键。一个直观的例子是电话簿,其中姓名被映射到电话号码。搜索姓名(关键字)通常会很快返回电话号码(值)。如果没有从电话号码到姓名的映射,查找姓名到电话号码就变得非常耗时。

界面图

映射将数据存储为键值对。每个条目由内部接口Map.Entry<K,V>表示,它实现了键(类型参数K)和值(类型参数V)之间的映射。因此,Map<K,V>接口中的方法是为这种存储键值映射的特殊方式而设计的。尽管如此,它们还是和Collection<E>界面中的相似。但是,Collection<E>不作为Map<K,V>的基础,?? 是一个独立的基础类型。

接口Map<K,V>提供了以下方法,其中包括:

  • V put(K key, V value)将映射(从键到值)作为条目添加到该映射。如果已经为传递的键存储了一个值,它将被新值覆盖。如果有这样的条目,则该方法返回以前与该键关联的值;否则,它返回 null。

  • 将传递的地图中的所有条目插入到该地图中。类似于方法put(K,V)的操作,已经存在的条目的值被覆盖。

  • V remove(Object key)从映射中删除一个条目(关键字和相关值)。返回值是与键keynull相关联的值,如果没有为这个键存储条目的话。

  • V get(Object key)获取键key的相关值。如果该项不存在,则返回null

  • boolean containsKey(Object key)检查键key是否存储在映射中,如果是,则返回 true。

  • boolean containsValue(Object value)检查值value是否存储在地图中,如果是,则返回true

  • void clear()删除地图的所有条目。

  • int size()获取存储在地图中的条目数。

  • boolean isEmpty()检查地图是否为空。

除了刚才介绍的方法之外,还有以下方法提供对存储的键、值和条目的访问:

  • Set<K> keySet()返回包含所有键的集合。

  • Collection<V> values()将值作为集合返回。

  • Set<Map.Entry<K,V>> entrySet()返回所有条目的集合。这提供了对键和值的访问。

这三种方法都提供了数据视图。如果对基础地图进行了更改,这些更改会反映在视图中。 请注意,各自视图中的变化也会转移到 地图上。

简单地说,我想讨论一下keySet()values()的不同返回类型。keySet()方法返回一个Set<K>,因为映射中的键必须是惟一的。这些值可能出现多次,所以values()返回一个Collection<V>

7.1.5 作为 LIFO 数据结构的堆栈

堆栈数据结构由类java.util.Stack<E>在 JDK 中实现。

一叠类似于一叠纸或一个桌面托盘。你把东西放在上面,你只能从上面拿东西。此外,还可以看到顶部元素。除此之外,还存在大小信息或至少检查元素是否存在。这导致形成 API 的以下方法:

  • void push(E)在顶部添加元素。

  • E pop()从顶部获取元素。

  • E peek()查看顶部元素。

  • boolean isEmpty()检查堆栈是否为空。

这四种方法足以在实践中为各种任务和算法有效地使用堆栈,例如,当序列必须反转时。这种特性在计算机科学中用后进先出的术语来描述。

例子

例如,您可以将一些元素放在堆栈上,查看顶部的元素,从顶部获取元素,最后,根据预期检查堆栈是否为空:

final Stack<String> stack = new Stack<>(); stack.push("first");
stack.push("second");

System.out.println("PEEK: " + stack.peek());
System.out.println("POP: " + stack.pop());
System.out.println("POP: " + stack.pop());
System.out.println("ISEMPTY: " + stack.isEmpty());

这提供了以下输出:

PEEK: second
POP: second
POP: first
ISEMPTY: true

7.1.6 作为 FIFO 数据结构的队列

为了结束对重要数据结构的介绍,我想提一下数据结构队列,它是由接口java.util.Queue<E>在 JDK 中建模的。排队类似于收银机前的排队。人们排队,谁先来谁先服务,在计算机科学中被称为先进先出。

不幸的是,JDK 中的Queue<E>接口非常混乱,因为它是从Collection<E>派生出来的——这是与 JDK 堆栈类似的设计缺陷。在这两种情况下,API 都被许多不相关的方法污染了,这意味着误用是不可避免的。 1

通常,只需要几个操作就可以从队列中获益,比如添加和删除元素。此外,可以查看开头的元素。除此之外,它还提供大小信息或至少检查元素是否存在。这导致形成 API 的以下方法:

  • 将一个元素添加到队列的末尾。

  • E poll()查看队列开头的元素。

  • E peek()查看队列开头的元素。

  • boolean isEmpty()检查队列是否为空。

这四种方法足以为实践中的各种任务和算法创建队列,例如,如果您打算将递归算法转换为迭代算法。

下面还有一个小注意事项:插入和移除的方法名是从 Java API 借用的。如果您自己开发数据容器,那么使用void enqueue(E)插入和E dequeue()移除肯定是更好的名字,因为它们更清楚地反映了这个概念。

例子

例如,您可以向队列中添加一些元素,然后只要有元素就处理它们。为了显示特殊处理,对条目迈克尔进行重新处理。类似的习惯用法存在于各种使用队列作为数据结构的算法中。

public static void main(final String[] args)
{
    final Queue<String> waitingPersons = new LinkedList<>();

    waitingPersons.offer("Marcello");
    waitingPersons.offer("Michael");
    waitingPersons.offer("Karthi");

    while (!waitingPersons.isEmpty())
    {
        if (waitingPersons.peek().equals("Michael"))
        {
           // At the end " queue again" and process
           waitingPersons.offer("Michael again");
           waitingPersons.offer("Last Man");
        }
        final String nextPerson = waitingPersons.poll();
        System.out.println("Processing " + nextPerson);
    }
}

小样本程序提供以下输出:

Processing Marcello
Processing Michael
Processing Karthi
Processing Michael again
Processing Last Man

7.2 练习

7.2.1 练习 1:设置操作(★★✩✩✩)

练习 1a:共同要素(★★✩✩✩)

找到两个集合 AB 的公共元素,使用和不使用集合框架的匹配方法。写方法Set<T> findCommon(Collection<T>, Collection<T>)

例子

|

输入 A

|

输入 B

|

结果

|
| --- | --- | --- |
| [1, 2, 4, 7, 8] | [2, 3, 7, 9] | [2, 7] |
| [1, 2, 7, 4, 7, 8] | [7, 7, 3, 2, 9] | [2, 7] |
| [2, 4, 6, 8] | [1, 3, 5, 7, 9] | * =[] |

练习 1b:集合运算(★★✩✩✩)

给定两个集合 AB ,分别计算差集ABBA,并集AB,交集 AB

例子

|

输入 A

|

输入 B

|

美国罗克韦尔

|

B - A

|

A ∪ B

|

A ∩ B

|
| --- | --- | --- | --- | --- | --- |
| [1, 2, 3, 4, 5] | [2, 4, 6, 7] | [1, 3, 5] | [6, 7] | [1, 2, 3, 4, 5, 6, 7] | [2, 4] |
| [2, 4, 6] | [1, 3, 5, 7] | [2, 4, 6] | [1, 3, 5, 7] | [1, 2, 3, 4, 5, 6, 7] | * =[] |

7.2.2 练习 2:列表反转(★★✩✩✩)

练习 2a:列表反转(★★✩✩✩)

编写方法List<T> reverse(List<T>),它创建一个结果列表并以相反的顺序返回原始列表的元素。

例子

|

投入

|

结果

|
| --- | --- |
| [1, 2, 3, 4] | [4, 3, 2, 1] |
| ["A "、" BB "、" CCC "、" DDDD"] | ["DDDD "、" CCC "、" BB "、" A"] |

练习 2b:原地列出反转(★★✩✩✩)

如果您想要实现就地反转顺序,以便对非常大的数据集进行内存优化,会有什么不同?那么应该给什么呢?

练习 2c:没有性能索引访问的反向列表(★★✩✩✩)

现在让我们假设没有可用的高性能随机索引访问。如果你想颠倒一个LinkedList<E>的顺序会怎么样?在那里,任何基于位置的访问都将导致列表遍历到所需的元素。如何避免这种情况?

Tip

使用堆栈。

7.2.3 练习 3:删除重复项(★★✩✩✩)

你应该从列表中删除重复的条目。约束条件是应该保留原始顺序。因此写方法List<T> removeDuplicates(List<T>)

例子

|

投入

|

结果

|
| --- | --- |
| [1, 1, 2, 3, 4, 1, 2, 3] | [1, 2, 3, 4] |
| [7, 5, 3, 5, 1] | [7, 5, 3, 1] |
| [1, 1, 1, 1] | [1] |

7.2.4 练习 4:最大利润(★★★✩✩)

让我们假设你有一个按时间顺序排列的价格序列,你想计算最大利润。挑战在于确定在什么时间(或者在这种情况下是什么价值)买入和卖出是最理想的。为此编写方法int maxRevenue(List<Integer>),其中时间顺序由列表中的索引表示。

例子

|

投入

|

结果

|
| --- | --- |
| [250, 270, 230, 240, 222, 260, 294, 210] | seventy-two |
| [0, 10, 20, 30, 40, 50, 60, 70] | Seventy |
| [70, 60, 50, 40, 30, 20, 10, 0] | Zero |
| [ ] | Zero |

7.2.5 练习 5:最长序列(★★★✩✩)

假设你正在用一系列数字来模拟股票价格或一条轨迹的高度。找出值递增或至少保持不变的最长的数字序列。写方法List<Integer> findLongestGrowingSequence(List<Integer>)

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 7, 1, 2, 5, 7, 1] | [1, 2, 5, 7] |
| [7, 2, 7, 1, 2, 3, 8, 1, 2, 3, 4, 5] | [1, 2, 3, 4, 5] |
| [1, 1, 2, 2, 2, 3, 3, 3, 3] | [1, 1, 2, 2, 2, 3, 3, 3, 3] |

7.2.6 练习 6:拥有堆栈(★★✩✩✩)

以 JDK 的班级为例。执行设计分析并识别可能的漏洞。定义栈的基本需求,并基于这些需求,使用List<E>实现类Ex06_Stack<E>

7.2.7 练习 7:格式良好的大括号(★★✩✩✩)

编写方法boolean checkParentheses(String),检查大括号序列是否整齐地嵌套在每种情况下。这应该接受圆括号、方括号和花括号,但不接受其他字符。

例子

|

投入

|

结果

|

评论

|
| --- | --- | --- |
| "(( ))" | 真实的 |   |
| "({[ ]})" | 真实的 |   |
| "((( ))" | 错误的 | 大括号的数量为奇数 |
| "((一)" | 错误的 | 错别字,没有花括号 |
| "(( ] )" | 错误的 | 没有匹配的大括号 |

奖励扩展解决方案,提供错误原因的清晰映射。从下面的enum枚举开始:

static enum CheckResult
{
   OK, ODD_LENGTH, CLOSING_BEFORE_OPENING, MISMATCHING, INVALID_CHAR,
   REMAINING_OPENING
}

7.2.8 练习 8:检查一个神奇的三角形(★★★✩✩)

编写方法boolean isMagicTriangle(List<Integer>),检查一系列数字是否形成一个神奇的三角形。这样的三角形被定义为三条边的值之和必须相等的三角形。

例子

下面显示了边长分别为 3 和 4 的一个三角形的情况:

  1         2
 6 5       8 5
2 4 3     4   9
         3 7 6 1

这导致以下方面和总和:

|

投入

|

价值观 1

|

价值观 2

|
| --- | --- | --- |
| 第一面 | 1 + 5 + 3 = 9 | 2 + 5 + 9 + 1 = 17 |
| 第二面 | 3 + 4 + 2 = 9 | 1 + 6 + 7 + 3 = 17 |
| 第三面 | 2 + 6 + 1 = 9 | 3 + 4 + 8 + 2 = 17 |

Tip

将三角形的各个边表示为子列表,并使用List<E> subList(int startInclusive, int endExclusive)方法提取各个边。

7.2.9 练习 9:帕斯卡三角形(★★★✩✩)

编写方法List<List<Integer>> pascal(int),根据嵌套列表计算帕斯卡三角形。正如你所知道的,每一个新的行都是由前一行产生的。如果其中有两个以上的元素,则将两个值相加,求和得到新行的值。在每种情况下,前面和后面都会附加一个 1。

例子

对于值 5,所需的表示如下:

[1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]

7.2.10 练习 10:最常见的元素(★★✩✩✩)

编写计算直方图的方法Map<Integer, long> valueCount(List<Integer>),即传递的列表中数字的频率分布。此外,编写方法Map<Integer, Long> sortByValue(Map<Integer, Long>),根据值而不是键对地图进行排序。必须实现降序排序,最高值列在开头。

例子

|

投入

|

结果

|

最常见的

|
| --- | --- | --- |
| [1, 2, 3, 4, 4, 4, 3, 3, 2, 4] | {1=1, 2=2, 3=3, 4=4} | 4=4 |
| [1, 1, 1, 2, 2, 2, 3, 3, 3] | {1=3, 2=3, 3=3} | 根据查询,逻辑上所有 |

7.2.11 练习 11:数字加法(★★★✩✩)

考虑两个要相加的十进制数。听起来很简单,但是对于这个任务,这些数字有趣地被表示为一个数字列表。写方法List<Integer> listAdd(List<Integer>, List<Integer>)。此外,考虑溢出的特殊情况。

练习 11a:加法(★★★✩✩)

在任务的第一部分,数字按照它们在列表中出现的顺序存储。

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| 123 = [1, 2, 3] | 456 = [4, 5, 6] | 579 = [5, 7, 9] |
| 927 = [9, 2, 7] | 135 = [1, 3, 5] | 1062 = [1, 0, 6, 2] |

练习 11b:加法逆运算(★★★✩✩)

如果数字在列表中逆序存储会有什么变化?

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| 123 = [3, 2, 1] | 456 = [6, 5, 4] | 579 = [9, 7, 5] |
| 927 = [7, 2, 9] | 135 = [5, 3, 1] | 1062 = [2, 6, 0, 1] |

7.2.12 练习 12:复合键(★★✩✩✩)

假设您想在一个映射中使用两个或更多的值作为一个键。也就是你要用一个所谓的复合键。例如,这样的键由两个int值组成,或者由一个String和一个int组成。如何实现这一点?

例子

|

复合键(月、年)

|

会议

|
| --- | --- |
| (2019 年 9 月) | ch 开放苏黎世,甲骨文代码一 SF |
| (2019 年 10 月) | JAX 伦敦 |
| (2019 年 11 月) | W-JAX 慕尼黑 |

|

复合关键字(姓名、年龄)

|

业余爱好

|
| --- | --- |
| (彼得,22 岁) | 电视 |
| (迈克,48 岁) | Java,骑自行车,电影 |

7.2.13 练习 13:列表合并(★★✩✩✩)

给定两个数字列表,每个列表都按升序排序,根据它们的顺序将它们合并成一个结果列表。写方法List<Integer> merge(List<Integer>, List<Integer>)

例子

|

输入 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] |

7.2.14 练习 14: Excel 魔术选择(★★✩✩✩)

如果你用过一点 Excel,那么你可能用过所谓的魔法选择。它会根据先前的值,在选定的区域中不断填充值。例如,这适用于数字、工作日或日期。为了自己实现类似的东西,编写方法List<Integer> generateFollowingValues(int, int),为数字实现这个。创建一个适合工作日的变体,并使用以下签名:List<DayOfWeek> generateFollowingValues(DayOfWeek, int)

例子

|

基础资料

|

数数

|

结果

|
| --- | --- | --- |
| one | seven | [1, 2, 3, 4, 5, 6, 7] |
| five | four | [5, 6, 7, 8] |
| 星期五 | eight | [星期五、星期六、星期日、星期一、星期二、星期三、星期四、星期五] |

7.3 解决方案

7.3.1 解决方案 1:设置操作(★★✩✩✩)

解决方案 1a:共同要素(★★✩✩✩)

找到两个集合 AB 的公共元素,使用和不使用集合框架的匹配方法。写方法Set<T> findCommon(Collection<T>, Collection<T>)

例子

|

输入 A

|

输入 B

|

结果

|
| --- | --- | --- |
| [1, 2, 4, 7, 8] | [2, 3, 7, 9] | [2, 7] |
| [1, 2, 7, 4, 7, 8] | [7, 7, 3, 2, 9] | [2, 7] |
| [2, 4, 6, 8] | [1, 3, 5, 7, 9] | * =[] |

算法集合框架提供了retainAll()方法来确定公共元素。这需要为结果创建一个新的Set<E>,以便在原始集合中不发生修改:

static <T> Set<T> findCommon(final Collection<T> collection1,
                             final Collection<T> collection2)
{
    final Set<T> results = new HashSet<>(collection1);
    results.retainAll(collection2);

    return results;
}

你现在不用 JDK 方法,而是用地图。您维护一个包含在集合 1 或 2 中的计数器。为了适当地填充它们,首先要遍历集合 1 中的所有元素,并在映射中输入值 1。现在您遍历第二个集合的所有元素。使用computeIfPresent(),如果值在映射中已经有一个条目,则增加计数器。因此,两个集合中的所有元素都接收值 2,并且多次出现时接收更高的值。另一方面,来自集合 2 的专有元素从不被存储。最后,您只保留那些数量大于或等于 2 的条目。

static <T> Set<T> findCommonNoJdk(final Collection<T> collection1,
                                  final Collection<T> collection2)
{
    final Map<T, Long> results = new HashMap<>();
    populateFromCollection1(collection1, results);
    markIfAlsoInSecond(collection2, results);

    return removeAllJustInOneCollection(results);
}

static <T> void populateFromCollection1(final Collection<T> collection1,
                                        final Map<T, Long> results)
{
    for (T elem1 : collection1)
    {
        results.put(elem1, 1L);
    }
}

static <T> void markIfAlsoInSecond(final Collection<T> collection2,
                                   final Map<T, Long> results)
{
    for (T elem2 : collection2)
    {
        results.computeIfPresent(elem2, (key, value) -> value + 1);
    }
}

static <T> Set<T> removeAllJustInOneCollection(final Map<T, Long> results)
{
    return results.entrySet().stream().filter(entry -> entry.getValue() >= 2).
                                      map(entry -> entry.getKey()).
                                      collect(Collectors.toSet());
}

看着看着,显然显得太复杂了。那么如何做得更好呢?

优化算法事实上,通过检查第一个集合中的所有元素是否包含在第二个集合中,然后将该值包含在以下情况的结果集中,可以以更简洁、更容易理解的方式解决该问题:

static <T> Set<T> findCommonTwoLoops(final Collection<T> collection1,
                                     final Collection<T> collection2)
{
    final Set<T> results = new HashSet<>();

    for (T elem1 : collection1)
    {
        for (T elem2 : collection2)
        {
            if (elem1.equals(elem2))
                results.add(elem1);
        }
    }
    return results;
}

确认

您可以通过以下单元测试来检查实现——这里只显示了最复杂变体的摘录:

@ParameterizedTest(name = "findCommonNoJdk({0}, {1}) = {2}")
@MethodSource("createCollectionsAndExpected")
void testFindCommonNoJdk(Collection<Integer>  col1,
                         Collection<Integer> col2, Set<Integer> expected)
{
    final Set<Integer> result = Ex01_SetOperations.findCommonNoJdk(col1, col2);

    assertEquals(expected, result);
}

private static Stream<Arguments> createCollectionsAndExpected()
{
    return Stream.of(Arguments.of(List.of(1, 2, 4, 7, 8),
                                  List.of(2, 3, 7, 9), Set.of(2, 7)),
                     Arguments.of(List.of(1, 2, 7, 4, 7, 8),
                                  List.of(7, 7, 3, 2, 9), Set.of(2, 7)),
                     Arguments.of(List.of(2, 4, 6, 8),
                                  List.of(1, 3, 5, 7, 9), Set.of()));
}

解决方案 1b:集合运算(★★✩✩✩)

给定两个集合 AB ,分别计算差集ABBA,并集AB,交集 AB

例子

|

输入 A

|

输入 B

|

美国罗克韦尔

|

B - A

|

A ∪ B

|

A ∩ B

|
| --- | --- | --- | --- | --- | --- |
| [1, 2, 3, 4, 5] | [2, 4, 6, 7] | [1, 3, 5] | [6, 7] | [1, 2, 3, 4, 5, 6, 7] | [2, 4] |
| [2, 4, 6] | [1, 3, 5, 7] | [2, 4, 6] | [1, 3, 5, 7] | [1, 2, 3, 4, 5, 6, 7] | * =[] |

算法例如,你已经知道集合框架的方法能够确定交集。进一步是可能的:在removeAll()addAll()的帮助下,差集和并集可以被确定。

必须适当地调用这些方法来实现赋值。首先,用第一个集合的值填充一个HashSet<E>。之后,执行所需的操作,以便删除或添加元素。您的方法被命名为difference()union()intersection(),这样它们的使用应该是直观的。

static <T> Set<T> difference(final Collection<T> collection1,
                             final Collection<T> collection2)
{
    final Set<T> results = new HashSet<>(collection1);
    results.removeAll(collection2);
    return results;
}

static <T> Set<T> union(final Collection<T> collection1,
                        final Collection<T> collection2)
{
    final Set<T> results = new HashSet<>(collection1);
    results.addAll(collection2);
    return results;
}

static <T>  Set<T> intersection(final Collection<T> collection1,
                                final Collection<T> collection2)
{
    final Set<T> results = new HashSet<>(collection1);
    results.retainAll(collection2);
    return results;
}

优化算法尽管所示的实现非常容易理解,而且简洁明了,但您可能仍然会被一个细节所困扰:您总是必须创建一个新的结果集合,以避免原始集合发生变化。

让我们看看流 API 的替代方案。虽然difference()intersection()对于filter()来说非常明显,但是union()实现了流 API 中可能很少使用并且不太为人所知的方法concat()来连接两个流。结果流将包含第一个流的所有元素,然后包含第二个流的所有元素。

static <T> Set<T> differenceV2(final Collection<T> collection1,
                               final Collection<T> collection2)
{
    return collection1.stream().
                       filter(element -> !collection2.contains(element)).
                       collect(Collectors.toSet());
}

static <T> Set<T> unionV2(final Collection<T> collection1,
                          final Collection<T> collection2)
{
    return Stream.concat(collection1.stream(), collection2.stream()).
                  collect(Collectors.toSet());
}

static <T> Set<T> intersectionV2(final Collection<T> collection1,
                                 final Collection<T> collection2)
{
    return collection1.stream().
                       filter(element -> collection2.contains(element)).
                       collect(Collectors.toSet());
}

确认

为了理解集合操作,您定义了两个列表,每个列表包含简介中的值,然后调用方法performSetOperations(),该方法执行四个集合操作:

jshell> performSetOperations(List.of(1, 2, 3, 4, 5), List.of(2, 4, 6, 7, 8))
A: [1, 2, 3, 4, 5]
B: [2, 4, 6, 7, 8]
dif A-B: [1, 3, 5]
dif B-A: [6, 7, 8]
uni A+B: [1, 2, 3, 4, 5, 6, 7, 8]
sec A+B: [2, 4]

jshell> performSetOperations(List.of(2, 4, 6, 8), List.of(1, 3, 5, 7))
A: [2, 4, 6, 8]
B: [1, 3, 5, 7]
dif A-B: [2, 4, 6, 8]
dif B-A: [1, 3, 5, 7]
uni A+B: [1, 2, 3, 4, 5, 6, 7, 8]
sec A+B: []

您按如下方式实现 helper 方法:

static void performSetOperations(final Collection<Integer> colA,
                                 final Collection<Integer> colB)
{
    System.out.println("A: " + colA);
    System.out.println("B: " + colB);
    System.out.println("dif A-B: " + difference(colA, colB));
    System.out.println("dif B-A: " + difference(colB, colA));
    System.out.println("uni A+B: " + union(colA, colB));
    System.out.println("sec A+B: " + intersection(colA, colB));
    System.out.println();
}

让我们以联合集的测试用例为例——其他测试的实现类似:

@ParameterizedTest(name = "unionV2({0}, {1}) = {2}")
@MethodSource("createCollectionsAndUnionExpected")
void testUnionV2(Set<Integer> colA, Set<Integer> colB,
                 Set<Integer> expectedUnion)
{
    final Set<Integer> result = Ex01_SetOperations.unionV2(colA, colB);

    assertEquals(expectedUnion, result);
}

private static Stream<Arguments> createCollectionsAndUnionExpected()
{
    return Stream.of(Arguments.of(Set.of(1, 2, 3, 4, 5), Set.of(2, 4, 6, 7, 8),
                                  Set.of(1, 2, 3, 4, 5, 6, 7, 8)),
                     Arguments.of(Set.of(2, 4, 6), Set.of(1, 3, 5, 7),
                                  Set.of(1, 2, 3, 4, 5, 6, 7)));
}

7.3.2 解决方案 2:反向列表(★★✩✩✩)

解决方案 2a:反向列表(★★✩✩✩)

编写方法List<T> reverse(List<T>),它创建一个结果列表并以相反的顺序返回原始列表的元素。

例子

|

投入

|

结果

|
| --- | --- |
| [1, 2, 3, 4] | [4, 3, 2, 1] |
| ["A "、" BB "、" CCC "、" DDDD"] | ["DDDD "、" CCC "、" BB "、" A"] |

算法一个简单的解决方案是从后向前遍历一个列表,对于每个位置,将当前元素添加到一个结果列表中。这可以基于索引实现,如下所示:

static <T> List<T> reverse(final List<T> values)
{
    final List<T> result = new ArrayList<>();

    for (int i = values.size() - 1; i >= 0; i--)
    {
        result.add(values.get(i));
    }
    return result;
}

这里,在每种情况下都使用get(i)执行基于索引的访问。这样的访问对于具有 O(1)ArrayList<E>是有效的,但是对于具有(On)LinkedList<E>则明显更差。更优雅的方式是使用ListIterator<T>。一方面,运行时间与列表实现无关,因为根据具体类型,ArrayList<E>或者通过索引导航,或者LinkedList<E>通过其节点导航。另一方面,ListIterator<T>允许通过参数指定开始位置。随后,这个位置被放置在从末尾开始遍历的最后一个元素之后。最后,除了正常的Iterator<T>之外,还可以使用方法hasPrevious()previous()向后导航。

static <T> List<T> listReverseWithListIterator(final List<T> values)
{
    final List<T> result = new ArrayList<>();

    final ListIterator<T> it = values.listIterator(values.size()));
    while (it.hasPrevious())
    {
        result.add(it.previous());
    }
    return result;
}

解决方案 2b:原地反向列表(★★✩✩✩)

如果您想实现逆序,以便对庞大的数据集进行内存优化,会有什么不同呢?那么应该给什么呢?

算法基于索引访问,从开头和结尾向内进行,交换元素:

// only performant with indexed access with O(1)
static <T> void listReverseInplace(final List<T> inputs)
{
    // run from the left and right, swap the elements based on their positions
    int left = 0;
    int right = inputs.size() - 1;

    while (left < right)
    {
        final T leftValue = inputs.get(left);
        final T rightValue = inputs.get(right);

        inputs.set(left, rightValue);
        inputs.set(right, leftValue);

        left++;
        right--;
    }
}

然而,这仅在给定运行时间为O的基于索引的访问时才有效。对于LinkedList<E>来说,这并不适用。因此,所示的解决方案性能不佳,因此不太适合。此外,传递的列表必须是可变的。否则,不能通过调用set()来改变元素。

解决方案 2c:没有性能索引访问的列表反转(★★✩✩✩)

现在让我们假设没有可用的高性能随机索引访问。如果你想颠倒一个LinkedList<E>的顺序会怎么样?在那里,任何基于位置的访问都将导致列表遍历到所需的元素。如何避免这种情况?

Tip

使用堆栈。

算法在没有基于性能索引的访问可用的情况下,你仍然必须以运行时间复杂度 O ( n )来反转顺序,栈开始发挥作用——就像其他各种算法一样,包括这个。您从前到后遍历列表,每次都将当前元素放入堆栈。然后,迭代地从堆栈中移除顶部元素,并将其添加到结果列表中,直到堆栈为空。

static <T> List<T> listReverseWithStack(final List<T> inputs)
{
    // idea: Run through the list from front to back (performant) and
    // fill a stack
    final Stack<T> allValues = new Stack<>();
    final Iterator<T> it = inputs.iterator();
    while (it.hasNext())
    {
        allValues.push(it.next());
    }

    // empty the stack and fill a result list
    final List<T> result = new ArrayList<>();
    while (!allValues.isEmpty())
    {
        result.add(allValues.pop());
    }

    return result;
}

这种解决方案需要两次传递和额外的内存。尽管如此,它还是比在循环中调用基于索引的方法get()的版本有效得多。为什么呢?当使用一个LinkedList<E>时,对于每个索引访问,它遍历其元素到期望的位置,产生O(n2)

确认

让我们用示例中的输入值进行实验,并调用您之前创建的方法:

@ParameterizedTest(name = "listReverse({0}) = {1}")
@MethodSource("listInputsAndExpected")
<T> void listReverse(List<T> inputs, List<T> expected)
{
    final List<T> result = Ex02_ListReverse.listReverse(inputs);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "listReverseInplace({0}) = {1}")
@MethodSource("listInputsAndExpected")
<T> void listReverseInplace(List<T> inputs, List<T> expected)
{
    // allow modification of the list by wrapping
    final List<T> modifiableInputs = new ArrayList<>(inputs);
    Ex02_ListReverse.listReverseInplace(modifiableInputs);

    assertEquals(expected, modifiableInputs);
}

static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(1, 2, 3, 4),
                                  List.of(4, 3, 2, 1)),
                     Arguments.of(List.of("A", "BB", "CCC", "DDDD"),
                                  List.of("DDDD", "CCC", "BB", "A")));
}

7.3.3 解决方案 3:消除重复(★★✩✩✩)

你应该从列表中删除重复的条目。约束条件是应该保留原始顺序。因此写方法List<T> removeDuplicates(List<T>)

例子

|

投入

|

结果

|
| --- | --- |
| [1, 1, 2, 3, 4, 1, 2, 3] | [1, 2, 3, 4] |
| [7, 5, 3, 5, 1] | [7, 5, 3, 1] |
| [1, 1, 1, 1] | [1] |

算法首先,复制输入以允许修改。然后从前到后遍历列表,并用列表中包含的条目连续填充一个集合。对于列表中的每个元素,检查它是否已经包含在找到的条目集中。如果发现重复,使用Iteratorremove()方法将其从结果列表中删除。

static <T> List<T> removeDuplicates(final List<T> inputs)
{
    final List<T> result = new ArrayList<>(inputs);
    final Set<T> numbers = new HashSet<>();

    final Iterator<T> it = result.iterator();
    while (it.hasNext())
    {
        final T elem = it.next();

        if (numbers.contains(elem))
            it.remove(); // remove duplicate
        else
            numbers.add(elem);
    }

    return result;
}

优化算法在实现它的时候,你可能已经有了删除重复项的想法,只要把它们重新填充到一个集合中就行了。这是可行的,但是HashSet<E>TreeSet<E>可能会混淆元素的顺序。推荐的解决方法是使用LinkedHashSet<E>类,它保留了插入顺序。这使得重复删除实现变得轻而易举。

static List<Integer> removeDuplicatesV2(final List<Integer> inputs)
{
    return new ArrayList<>(new LinkedHashSet<>(inputs));
}

几乎总是有替代方案。我想介绍一个使用流 API 的例子。在这个实现中,您利用了这样一个事实,即 Stream API 提供了distinct()方法来删除重复项。之后,你只需要把结果转换成一个列表。

static List<Integer> removeDuplicatesV3(final List<Integer> inputs)
{
    return inputs.stream().distinct().collect(Collectors.toList());
}

确认

同样,您使用介绍性示例的值来验证实现。下面没有显示这两个优化版本的测试,因为除了方法调用之外,它们是相同的。

@ParameterizedTest(name = "removeDuplicates({0}) = {1}")
@MethodSource("listInputsAndExpected")
void removeDuplicates(List<Integer> inputs, List<Integer> expected)
{
    List<Integer> result = Ex03_ListRemove.removeDuplicates(inputs);

    assertEquals(expected, result);
}

private static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(1, 1, 2, 3, 4, 1, 2, 3),
                                  List.of(1, 2, 3, 4)),
                     Arguments.of(List.of(7, 5, 3, 5, 1), List.of(7, 5, 3, 1)),
                     Arguments.of(List.of(1, 1, 1, 1), List.of(1)));
}

7.3.4 解决方案 4:最大利润(★★★✩✩)

让我们假设你有一个按时间顺序排列的价格序列,你想计算最大利润。挑战在于确定在什么时间(或者在这个例子中是什么价值)买入和卖出是最理想的。为此编写方法int maxRevenue(List<Integer>),其中时间顺序由列表中的索引表示。

例子

|

投入

|

结果

|
| --- | --- |
| [250, 270, 230, 240, 222, 260, 294, 210] | seventy-two |
| [0, 10, 20, 30, 40, 50, 60, 70] | Seventy |
| [70, 60, 50, 40, 30, 20, 10, 0] | Zero |
| [ ] | Zero |

算法最初,您可能想确定最小值和最大值,并简单地返回它们的差值。经过短暂的思考,很明显,在这种情况下,必须考虑时间因素。首先,必须先买入,然后以更高的价格卖出,才能实现利润。

下一个想法是运行列表两次。首先,通过查看当前值是否小于当前最小值来确定所有最小值。然后将其添加到当时有效的最小值列表中。在第二次运行时,通过逐个元素的比较来确定最大的差异。如果当前值大于当前有效的最小值,那么由此获得的利润是当前值和在该位置确定的最小值之间的差。最后,从当前最大值和当前利润中的最大值计算最大利润。对于示例 1,结果如下:

|

|

Two hundred and fifty

|

Two hundred and seventy

|

Two hundred and thirty

|

Two hundred and forty

|

Two hundred and twenty-two

|

Two hundred and sixty

|

Two hundred and ninety-four

|

Two hundred and ten

|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 最小值差异max。差异 | Two hundred and fifty | Two hundred and fifty | Two hundred and thirty | Two hundred and thirty | Two hundred and twenty-two | Two hundred and twenty-two | Two hundred and twenty-two | Two hundred and ten |
| Zero | Twenty | Zero | Ten | Zero | Thirty-eight | seventy-two | Zero |
| Zero | Twenty | Twenty | Twenty | Twenty | Thirty-eight | seventy-two | seventy-two |

基于这个想法,您用 Java 实现它,如下所示:

static int maxRevenue(final List<Integer> prices)
{
    final List<Integer> relevantMins = calcRelevantMins(prices);
    return calcMaxRevenue(prices, relevantMins);
}

static List<Integer> calcRelevantMins(final List<Integer> prices)
{
    final List<Integer> relevantMins = new ArrayList<>();

    int currentMin = Integer.MAX_VALUE;
    for (int currentPrice : prices)
    {
        currentMin = Math.min(currentMin, currentPrice);
        relevantMins.add(currentMin);
    }
    return relevantMins;
}

static int calcMaxRevenue(final List<Integer> prices,
                          final List<Integer> relevantMins)
{
    int maxRevenue = 0;
    for (int i = 0; i < prices.size(); i++)
    {
        if (prices.get(i) > relevantMins.get(i))
        {
            final int currentRevenue = prices.get(i) - relevantMins.get(i);
            maxRevenue = Math.max(maxRevenue, currentRevenue);
        }
    }
    return maxRevenue;
}

优化算法刚刚显示的变化需要两遍。只要访问是在内存中进行的,这几乎不会对性能产生重要影响。如果每次都确定数据,例如,通过 REST 调用或从数据库中确定数据,情况会有所不同。

事实上,可以减少必要的调用和循环迭代的次数。然而,这种优化可能只有在先前的实现已经首先完成的情况下才能实现。

static int maxRevenueV2(final List<Integer> prices)
{
    int currentMin = Integer.MAX_VALUE;
    int maxRevenue = 0;

    for (int currentPrice : prices)
    {
        currentMin = Math.min(currentMin, currentPrice);
        final int currentRevenue = currentPrice - currentMin;
        maxRevenue = Math.max(maxRevenue, currentRevenue);
    }
    return maxRevenue;
}

循环中的语句甚至可以进行一些优化。如果调用min()的成本很高,那么这一步可能是值得的。作为优化,必须调整最小值(那么maxRevenue不能变)或利润,前提是最小值不变。

if (currentPrice < currentMin)
    currentMin = currentPrice;
else
    maxRevenue = Math.max(maxRevenue, currentPrice - currentMin);

确认

为了进行测试,您再次使用介绍性示例中的值:

@ParameterizedTest(name = "maxRevenue({0}) = {1}")
@MethodSource("listInputsAndExpected")
void maxRevenue(List<Integer> inputs, int expected)
{
    int result = Ex04_FindMaxRevenue.maxRevenue(inputs);

    assertEquals(expected, result);
}

private static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(250, 270, 230, 240,
                                          222, 260, 294, 210), 72),
                     Arguments.of(List.of(0, 10, 20, 30, 40, 50, 60, 70), 70),
                     Arguments.of(List.of(70, 60, 50, 40, 30, 20, 10, 0), 0),
                     Arguments.of(List.of(), 0));
}

7.3.5 解决方案 5:最长序列(★★★✩✩)

假设你正在用一系列数字来模拟股票价格或一条轨迹的高度。找出值递增或至少保持不变的最长的数字序列。写方法List<Integer> findLongestGrowingSequence(List<Integer>)

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 7, 1, 2, 5, 7, 1] | [1, 2, 5, 7] |
| [7, 2, 7, 1, 2, 3, 8, 1, 2, 3, 4, 5] | [1, 2, 3, 4, 5] |
| [1, 1, 2, 2, 2, 3, 3, 3, 3] | [1, 1, 2, 2, 2, 3, 3, 3, 3] |

算法这里使用了所谓的贪心算法。其思想是从一个元素开始收集后续元素,直到下一个元素小于当前元素。临时列表和结果列表用于此目的。这两个元素最初都是空的,随后被填充:每个元素读取的大于或等于前一个元素的临时列表,以及每当找到较小的后续值时的结果列表。如果值较小,临时列表将被清除,并以当前值作为单元素列表开始。如果侧翼变化处的结果列表比具有先前收集的元素的临时列表短,则临时列表成为新的结果列表。重复此过程,直到到达初始列表的末尾。

让我们看一下输入 1272134572 的过程:

|

投入

|

当前字符

|

临时列表

|

结果列表

|
| --- | --- | --- | --- |
| 1 272134572 | one | one |   |
| 1 2 72134572 | Two | Twelve |   |
| 12 7 2134572 | seven | One hundred and twenty-seven |   |
| 127 2 134572 | Two | Two | One hundred and twenty-seven |
| 1272 1 34572 | one | one | One hundred and twenty-seven |
| 12721 3 4572 | three | Thirteen | One hundred and twenty-seven |
| 127213 4 572 | four | One hundred and thirty-four | One hundred and twenty-seven |
| 1272134 5 72 | five | One thousand three hundred and forty-five | One hundred and twenty-seven |
| 12721345 7 7 | seven | Thirteen thousand four hundred and fifty-seven | One hundred and twenty-seven |
| 127213457 2 | Two | Two | Thirteen thousand four hundred and fifty-seven |

static List<Integer> findLongestGrowingSequence(final List<Integer> values)
{
    List<Integer> longestSubsequence = List.of();
    List<Integer> currentSubsequence = new ArrayList<>();

    int lastValue = Integer.MIN_VALUE;

    for (int currentValue : values)
    {
        if (currentValue >= lastValue)
        {
            lastValue = currentValue;
            currentSubsequence.add(currentValue);
        }
        else
        {
            // end of this sequence, start new sequence
            if (currentSubsequence.size() >= longestSubsequence.size())
            {
                longestSubsequence = currentSubsequence;
            }
            currentSubsequence = new ArrayList<>();
            lastValue = currentValue;
            currentSubsequence.add(currentValue);
        }
    }

    // important, because otherwise the last sequence might not be considered
    if (currentSubsequence.size() >= longestSubsequence.size())
    {
        longestSubsequence = currentSubsequence;
    }
    return longestSubsequence;
}

务必注意在for循环后的附加检查——否则,最终序列将不会正确返回。

迷你优化检查应该进一步优化。正如您所看到的,赋值并将其添加到当前的临时列表在每种情况下都会发生。因此,这些动作可以从条件中分离出来,并写成如下形式:

for (int currentValue : values)
{
    if (currentValue < lastValue)
    {
        // end of this sequence, start new sequence
        // check the length, possibly new longest sequence
        if (currentSubsequence.size() >= longestSubsequence.size())
        {
            longestSubsequence = currentSubsequence;
        }
        currentSubsequence = new ArrayList<>();
    }

    lastValue = currentValue;
    currentSubsequence.add(currentValue);
}

等长段的程序检查最长序列时,可以与>>=进行比较。如果有两个或更多长度相同的序列,在第一种情况下,使用>将第一个作为结果,使用>=总是最后一个。

替代和优化算法有时创建临时数据结构可能是相当不可取的,例如,当子部分变得很大时。在这种情况下,它只确定各自的索引边界。最后一步,提取适当的部分。即使在这里,您也可以在使用subList()时不创建新列表。在下面的代码中,您使用现代 Java 的语言特性record来表示最长部分的索引范围:

static record StartEndPair(int start, int end)
{
    int length()
    {
        return end - start;
    }
}

static List<Integer> findLongestGrowingSequenceOptimized(List<Integer> values)
{
    if (values.isEmpty())
        return values;

    StartEndPair longest = new StartEndPair(0, 0);
    int startCurrent = 0;
    int endCurrent;

    for (endCurrent = 1; endCurrent < values.size(); endCurrent++)
    {
        if (values.get(endCurrent) < values.get(endCurrent - 1))
        {
            if (endCurrent - startCurrent > longest.length())
            {
                longest = new StartEndPair(startCurrent, endCurrent);
            }
            startCurrent = endCurrent;
        }
    }

    if (endCurrent - startCurrent > longest.length())
    {
        longest = new StartEndPair(startCurrent, endCurrent);
    }

    return values.subList(longest.start, longest.end);
}

确认

使用简介中的值序列将计算结果与您的预期进行比较:

@ParameterizedTest(name = "findLongestGrowingSequence({0}) = {1}")
@MethodSource("listInputsAndExpected")
void findLongestGrowingSequence(List<Integer> inputs,
                                List<Integer> expected)
{
    List<Integer> result =
                  Ex05_Sequence.findLongestGrowingSequence(inputs);

    assertEquals(expected, result);
}

private static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(7, 2, 7, 1, 2, 5, 7, 1),
                                  List.of(1, 2, 5, 7)),
                     Arguments.of(List.of(7, 2, 7, 1, 2, 3, 8, 1, 2, 3, 4, 5),
                                  List.of(1, 2, 3, 4, 5)),
                     Arguments.of(List.of(1, 1, 2, 2, 2, 3, 3, 3, 3),
                                  List.of(1, 1, 2, 2, 2, 3, 3, 3, 3)));
}

7.3.6 解决方案 6:自有堆栈(★★✩✩✩)

以 JDK 的班级为例。执行设计分析并识别可能的漏洞。定义栈的基本需求,并在此基础上,使用List<E>实现类Ex06_Stack<E>

设计评审Stack<E>是从类Vector<E>派生出来的。不幸的是,这允许对Stack<E>类的方法调用与栈的工作方式相矛盾,比如方法get(int)indexOf(Object),甚至是那些具有修改访问的方法,比如add(int, E)remove(int)。此外,还有几个名称略有不同的方法做同样的事情,例如empty()isEmpty()。这些例子应该让你意识到实现继承的问题。

当同时使用基类Vector<E>和类Stack<E>的方法时,由此产生的陷阱变得清晰可见。假设添加了一些元素,而不是通过调用方法add(E)来调用push(E)。直觉上,人们期望在最后一个位置插入。事实上,栈顶的元素对应于底层基类Vector<E>的最后一个元素。

这种不直观的 API 使编程变得复杂,并分散了对手头任务的注意力。因此,通过使用对类Vector<E>(或任何列表)和委托的引用,正确的实现将是容易的。这将在下面显示。

算法你可以自己实现一个堆栈,使用一个列表作为数据存储,但不提供对它的外部直接访问。用户只能通过以下典型的堆栈方法进行访问:

  • void push(E)在顶部添加元素。

  • E pop()从顶部获取元素。

  • E peek()查看顶部元素。

  • boolean isEmpty()检查堆栈是否为空。

每次调用push()都会在列表的开头添加一个元素。这样,您可以模拟堆栈。当访问顶层元素时,检查堆栈是否为空,在这种情况下抛出一个Ex06_StackIsEmptyException。否则,返回顶部元素。

public class Ex06_Stack<E>
{
    private final List<E> values = new LinkedList<>();

    public void push(final E elem)
    {
        values.add(0, elem);
    }

    public E pop()
    {
        if (isEmpty())
            throw new Ex06_StackIsEmptyException();

        return values.remove(0);
    }

    public E peek()
    {
        if (isEmpty())
            throw new Ex06_StackIsEmptyException();

        return values.get(0);
    }

    public boolean isEmpty()
    {
        return values.isEmpty();
    }

    static class Ex06_StackIsEmptyException extends RuntimeException
    {
    }
}

与 JDK 的Stack<E>类不同,您的实现不是线程安全的。实现这一点的一个方法是为每个方法添加synchronized

还请注意,这个实现依赖于一个LinkedList<E>,它允许在前端执行插入和删除。对于ArrayList<E>来说,这不是一个合适的解决方案。那么最好使用最后一个位置来插入和移除,但是这需要一些索引数学。

确认

您使用预定义的流程来验证刚刚实现的堆栈是否正常工作。首先,插入两个元素。然后你看最上面那个带peek()的。之后,用pop()删除元素两次。它们应以相反的插入顺序提供。最后,检查堆栈是否为空。因为是这样,对最顶层元素的后续检查应该抛出一个Ex06_StackIsEmptyException

Ex06_Stack<String> stack = new Ex06_Stack<>();
stack.push("first");
stack.push("second");

System.out.println("PEEK: " + stack.peek());
System.out.println("POP: " + stack.pop());
System.out.println("POP: " + stack.pop());
System.out.println("ISEMPTY: " + stack.isEmpty());
System.out.println("POP: " + stack.pop());

这会产生以下输出:

PEEK: second
POP: second
POP:   first
ISEMPTY: true
|  Exception REPL.$JShell$11$Ex06_Stack$StackIsEmptyException
|        at Ex06_Stack.pop (#1:21)

7.3.7 解决方案 7:格式良好的括号(★★✩✩✩)

编写方法boolean checkParentheses(String),检查大括号序列是否整齐地嵌套在每种情况下。这应该接受圆括号、方括号和花括号,但不接受其他字符。

例子

|

投入

|

结果

|

评论

|
| --- | --- | --- |
| "(( ))" | 真实的 |   |
| "({[ ]})" | 真实的 |   |
| "((( ))" | 错误的 | 大括号的数量为奇数 |
| "((一)" | 错误的 | 错别字,没有花括号 |
| "(( ] )" | 错误的 | 没有匹配的大括号 |

算法从前到后遍历字符串。如果当前字符是一个左括号,即字符(、[、或{)中的一个,则将它存储在一个堆栈中。如果它是一个右大括号,请尝试将其与最后一个左大括号匹配。如果还没有左大括号,或者大括号类型不匹配,则返回false。如果匹配,则读取下一个字符。如果是左括号,请像前面一样继续操作。如果是右大括号,从堆栈中获取顶部的元素,并将其与刚刚读取的字符进行比较。检查括号的类型是否匹配: (和)、[和]以及{和}。让我们看一下输入的流程(())。

|

投入

|

当前字符

|

|

评论

|
| --- | --- | --- | --- |
| (( )] |   |   | 开始 |
| ( ( )] | ( | ( | 商店 |
| ( ( )] | ( | (( | 商店 |
| (( ) ) | ) | ( | 比赛 |
| (() ] | ] | ( | 错配 |

该实现使用堆栈并执行上述检查和操作:

static boolean checkParentheses(final String input)
{
    // odd length cannot be a well-formed bracing
    if (input.length() % 2 != 0)
        return false;

    final Stack<Character> openingParentheses = new Stack<>();

    for (int i = 0; i < input.length(); i++)
    {
        final char currentChar = input.charAt(i);
        if (isOpeningParenthesis(currentChar))
        {
            openingParentheses.push(currentChar);
        }
        else if (isClosingParenthesis(currentChar))
        {
            if (openingParentheses.isEmpty())
            {
               // closing before opening brace
               return false;
            }

            final char lastOpeningParens = openingParentheses.pop();
            if (!isMatchingParenthesisPair(lastOpeningParens, currentChar))
            {
                // different pairs of braces
                return false;
            }
        }
        else
        {
            // invalid character
            return false;
        }
    }

    return openingParentheses.isEmpty();
}

同样,建议提取像isOpeningParenthesis(char)这样的辅助方法,以便能够在更高的抽象层次上实现实际的算法,从而更加清晰。最后,让我们来检查一下三个助手方法:

static boolean isOpeningParenthesis(final char ch)
{
    return ch == '(' || ch == '[' || ch == '{';
}

static boolean isClosingParenthesis(final char ch)
{
    return ch == ')' || ch == ']' || ch == '}';
}

static boolean isMatchingParenthesisPair(final char opening, final char closing)
{
    return (opening == '(' && closing == ')') ||
           (opening == '[' && closing == ']') ||
           (opening == '{' && closing == '}');
}

让我们回到实现和返回值是如何提供的。关于为什么返回truefalse,这里出现了几个注释。用一个合适的enum作为回报来表达这个不是更直观吗?你会在奖金里看到这个。

确认

您可以使用简介中的值来查看您刚刚实现的功能的运行情况:

@ParameterizedTest(name = "checkParentheses(''{0}'') should be valid")
@CsvSource({ "()", "()[]{}", "[((()[]{}))]" })
void checkParentheses_ValidInput_Success(String input)
{
    boolean result = Ex07_ParenthesisExample.checkParentheses(input);

    assertTrue(result);
}

@ParameterizedTest(name = "checkParentheses(''{0}'') should be invalid")
@CsvSource({ "(()", "((})", "(()}", ")()(", "()((", "()A(" })
void checkParentheses_InvalidInputs_Should_Fail(String input)
{
    boolean result = Ex07_ParenthesisExample.checkParentheses(input);

    assertFalse(result);
}

奖金

扩展解决方案以提供错误原因的清晰映射。从下面的enum开始:

static enum CheckResult
{
    OK,
    ODD_LENGTH,
    CLOSING_BEFORE_OPENING,
    MISMATCHING_PARENTHESIS,
    INVALID_CHAR,
    REMAINING_OPENING
}

通过使用枚举,可以更清楚地传达可能的错误原因。此外,您可以省略源代码中对返回值的注释,因为枚举值已经充分描述了它们。

static CheckResult checkParenthesesV2(final String input)
{
    if (input.length() % 2 != 0)
        return CheckResult.ODD_LENGTH;

    final Stack<Character> openingParens = new Stack<>();

    for (int i = 0; i < input.length(); i++)
    {
        final char currentChar = input.charAt(i);
        if (isOpeningParenthesis(currentChar))
        {
            openingParens.push(currentChar);
        }
        else if (isClosingParenthesis(currentChar))
        {
            if (openingParens.isEmpty())
                return CheckResult.CLOSING_BEFORE_OPENING;

            final char lastOpeningParens = openingParens.pop();
            if (!isMatchingParenthesisPair(lastOpeningParens, currentChar))
                return CheckResult.MISMATCHING_PARENTHESIS;
        }
        else
            return CheckResult.INVALID_CHAR;
    }

    if (openingParens.isEmpty())
        return CheckResult.OK;

    return CheckResult.REMAINING_OPENING_PARENS;
}

Trick: Enum with Variable Content

几乎所有的 Java 开发人员都知道enum的简单形式。在enum中定义一个或多个属性是很常见的。这些几乎总是final,并且被分配一次。

正如所见,enum作为一个回报已经是一个进步。有时,您希望向调用者报告有关错误原因的更多信息。为此,我将向您展示一个不为人知的技巧,它允许在运行时将动态信息赋给静态枚举值。然而,这仅对于本地返回是安全的,并且不受多线程的影响,因为否则值可能被意外地改变。

static enum CheckResultV2
{
    ODD_LENGTH, CLOSING_BEFORE_OPENING, MISMATCHING,
    INVALID_CHAR, OK, REMAINING_OPENING;

    private String additionalInfo;

    public CheckResultV2 withInfo(String info)
    {
        this.additionalInfo = info;
        return this;
    }

    @Override
    public String toString()
    {
        return super.toString() + " / Additional Info: " + additionalInfo;
    }
}

现在你来到结果枚举。首先,各种结果被定义为常数。诀窍是提供一个附加属性和相应的修改方法来改变动态信息运行时的状态。使用此分机,可以在相应位置拨打以下电话:

return CheckResultV2.CLOSING_BEFORE_OPENING.withInfo("" + currentChar);

return CheckResultV2.MISMATCHING_PARENTHESIS.withInfo("" +
                     lastOpeningParens + " <> " + currentChar);

return CheckResultV2.INVALID_CHAR.withInfo("" + currentChar);

请注意使用的局限性和优点。正如已经指出的,使用多线程或缓存结果存在潜在的困难。因此,这种技术主要用于本地结果返回。

7.3.8 解决方案 8:检查一个神奇的三角形(★★★✩✩)

编写方法boolean isMagicTriangle(List<Integer>),检查一系列数字是否形成一个神奇的三角形。这样的三角形被定义为三条边的值之和必须相等的三角形。

例子

下面显示了边长分别为 3 和 4 的一个三角形的情况:

 1               2
 6 5            8 5
2 4 3          4   9
              3 7 6 1

这导致以下方面和总和:

|

投入

|

价值观 1

|

价值观 2

|
| --- | --- | --- |
| 第一面 | 1 + 5 + 3 = 9 | 2 + 5 + 9 + 1 = 17 |
| 第二面 | 3 + 4 + 2 = 9 | 1 + 6 + 7 + 3 = 17 |
| 第三面 | 2 + 6 + 1 = 9 | 3 + 4 + 8 + 2 = 17 |

Tip

将三角形的各个边表示为子列表,并使用List<E> subList(int startInclusive, int endExclusive)方法提取各个边。

Hint: Problem Solving Strategies for the Job Interview

如果问题最初不清楚,建议将问题简化为一两个具体的赋值,并基于它们找到适当的抽象。

以边长为 3 的三角形为例,您可以构建如上所示的边。如果你想一想,你会发现边可以用方法subList()表示为子列表。但是,最后一面需要特殊处理。为了再次关闭该图,必须考虑位置 0 的值。然而,它不是子列表的一部分。这里有两个可供选择的窍门。第一个是复制列表,并将其扩展到第 0 个元素:

final List<Integer> valuesWithLoop = new ArrayList<>(values);
// close the triangle
valuesWithLoop.add(values.get(0));

List<Integer> side1 = valuesWithLoop.subList(0, 3);
List<Integer> side2 = valuesWithLoop.subList(2, 5);
List<Integer> side3 = valuesWithLoop.subList(4, 7));

在第二个技巧中,您适当地添加了第三方——但是请记住,在这种情况下,subList()方法返回一个不可变的视图,因此您必须进行包装才能添加一个元素:

List<Integer> side1 = values.subList(0, 3);
List<Integer> side2 = values.subList(2, 5);
List<Integer> side3 = new ArrayList<>(values.subList(4, 6)); // immutable
side3.add(side1.get(0)); // error, if only subList()

算法:对于边长为 3 的三角形有了之前收集的知识,你开始实现边长为 3 的三角形的特例的检查。因此,您首先确定边,然后构建并比较其中包含的数字的部分和:

static boolean isMagic6(final List<Integer> values)
{
    final List<Integer> valuesWithLoop = new ArrayList<>(values);
    // close the triangle
    valuesWithLoop.add(values.get(0));

    final List<Integer> side1 = valuesWithLoop.subList(0, 3);
    final List<Integer> side2 = valuesWithLoop.subList(2, 5);
    final List<Integer> side3 = valuesWithLoop.subList(4, 7);

    return compareSumOfSides(side1, side2, side3);
}

您已经提取了边值的总和以及它们与方法compareSumOfSides()的比较:

static boolean compareSumOfSides(final List<Integer> side1,
                                 final List<Integer> side2,
                                 final List<Integer> side3)
{
    final int sum1 = sum(side1);
    final int sum2 = sum(side2);
    final int sum3 = sum(side3);

    return sum1 == sum2 && sum2 == sum3;
}

您仍然需要一个助手方法来计算列表元素的总和。解决这个问题最简单的方法是使用 Stream API,如下所示:

static int sum(final List<Integer> values)
{
    return values.stream().mapToInt(n -> n).sum();
}

中间检查现在,在进行归纳之前,您至少应该用一些值来检查实现:

jshell> isMagic6(List.of(1, 5, 3, 4, 2, 6))
$55 ==> true

jshell> isMagic6(List.of(1, 2, 3, 4, 5, 6))
$56 ==> false

算法:通用变体利用从具体实例中获得的知识,可以创建通用变体。差异在于计算三角形各边的指数。此外,您在方法的开头添加了健全性检查。这可以防止您处理可能无效的数据星座。

static boolean isMagic(final List<Integer> values)
{
    if (values.size() % 3 != 0)
        throw new IllegalArgumentException("Not a triangle: " + values.size());

    final int sideLength = 1 + values.size() / 3;

    final List<Integer> valuesWithLoop = new ArrayList<>(values);
    // close the triangle
    valuesWithLoop.add(values.get(0));

    final List<Integer> side1 = valuesWithLoop.subList(0, sideLength);
    final List<Integer> side2 = valuesWithLoop.subList(sideLength - 1,
                                                       sideLength * 2 - 1);
    final List<Integer> side3 = valuesWithLoop.subList((sideLength - 1) * 2,
                                                     sideLength * 3 - 2);

    return compareSumOfSides(side1, side2, side3);
}

确认

让我们用下面的单元测试来检查实现:

@ParameterizedTest(name = "isMagic({0})? {1}")
@MethodSource("listInputsAndExpected")
void isMagic(List<Integer> inputs, boolean expected)
{
    boolean result = Ex08_MagicTriangle.isMagic(inputs);

    assertEquals(expected, result);
}

private static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(1, 5, 3, 4, 2, 6), true),
                     Arguments.of(List.of(1, 2, 3, 4, 5, 6), false),
                     Arguments.of(List.of(2, 5, 9, 1, 6, 7, 3, 4, 8), true),
                     Arguments.of(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9), false));
}

替代算法基于已经完成的归纳,您可以更进一步,省略子列表的提取。

该算法再次使用位置计数器的思想,并在两个循环中遍历原始列表。外环表示当前端;在内部循环中,处理相应的位置。因此使用了两个技巧:

  1. 变量pos模拟列表中的当前位置。新位置通过加 1 来确定。但是,您需要在列表末尾重新访问列表的第一个值,所以这里使用了模运算。

  2. 将一边的值相加后,你必须返回一个位置,因为三角形一边的结束值也是下一边的开始值。

像往常一样,在方法的开头添加一个健全性检查。这将防止潜在的无效数据星座。

static boolean isMagicV2(final List<Integer> values)
{
    if (values.size() % 3 != 0)
        throw new IllegalArgumentException("Not a triangle: " + values.size());

    final int[] sumOfSides = new int[3];

    final int sideLength = values.size() / 3 + 1;
    int pos = 0;
    for (int currentSide = 0; currentSide < 3; currentSide++)
    {
        for (int i = 0; i < sideLength; i++)
        {
             sumOfSides[currentSide] += values.get(pos);

             // trick 1: with modulo => no special treatment
             // for last value needed
             pos = (pos + 1) % values.size();
        }

        // trick 2: The sides overlap, end field = next start field
        pos--;
    }

    return sumOfSides[0] == sumOfSides[1] && sumOfSides[1] == sumOfSides[2];
}

确认

该测试通过类似于前一个的单元测试来执行,因此不再显示。

7.3.9 解决办法 9: Pascal's Triangle

编写方法List<List<Integer>> pascal(int),根据嵌套列表计算帕斯卡三角形。正如您已经知道的,每一个新的行都是由前一行产生的。如果其中有两个以上的元素,则将两个值相加,求和得到新行的值。在每种情况下,前面和后面都会附加一个 1。

例子

对于值 5,所需的表示如下:

[1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]

算法单个线条的确定是递归完成的。对于第一行,生成一个值为 1 的单元素列表。对于所有其他的,您通过调用基于前置行的 helper 方法calcLine(List<Integer>)来计算值,然后将中间结果添加到您的总结果中。这个调用是从 1 开始的,这可能有点令人恼火,但是列表索引当然是从 0 开始的。

static List<List<Integer>> pascal(final int n)
{
    final List<List<Integer>> result = new ArrayList<>();
    pascal(n, result);
    return result;
}

static List<Integer> pascal(final int n, final List<List<Integer>> results)
{
    // recursive termination
    if (n == 1)
    {
        results.add(List.of(1));
    }
    else
    {
        // recursive descent: compute based on predecessor line
        final List<Integer> previousLine = pascal(n - 1, results);

        final List<Integer> currentLine = calcLine(previousLine);

        results.add(currentLine);
    }

    return results.get(n - 1);
}

对所有具有 n ≥ 2 的行执行基于前任行的行值计算,如下所示:如果前任行列表中存储了多个值,则对其进行迭代并对每个值求和。为了完成计算,值 1 被附加在前面和后面。

更正式地说,它可以写成如下形式,其中行和列的索引从 1 开始,而不像 Java 那样从 0 开始:

$$ pascal\left( row, col\right)=\left{\begin{array}{c}1,\kern16.25em row=1\ \mathrm{and}\ col=1\left(\mathrm{top}\right)\ {}1,\kern16.5em row\ \left{1,n\right}\ \mathrm{and}\ col=1\ {}1,\kern15em row\ \left{1,n\right}\ \mathrm{and}\ col= row\ {} pascal\left( row-1, col\right)+\kern18.75em \ {} pascal\left( row-1, col-1\right),\mathrm{otherwise}\ \left(\mathrm{based}\ \mathrm{on}\ \mathrm{predecessors}\right)\end{array}\right. $$

实现是直接完成的,比第 3.3.9 节中已经介绍的每个值的纯递归定义更容易理解:

// each row is calculated from the values of the row above it,
// flanked in each case by a 1
static List<Integer> calcLine(final List<Integer> previousLine)
{
    final List<Integer> currentLine = new ArrayList<>();
    currentLine.add(1);

    for (int i = 0; i < previousLine.size() - 1; i++)
    {
        final int newValue = previousLine.get(i) + previousLine.get(i + 1);
        currentLine.add(newValue);
    }

    currentLine.add(1);
    return currentLine;
}

确认

为了测试实现,您计算值为 5 的帕斯卡三角形,然后适当地打印出来:

jshell> pascal(5).forEach(System.out::println) [1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]

如果您喜欢它更正式一点,示例项目中提供了一个匹配的单元测试。

7.3.10 解决方案 10:最常见的元素(★★✩✩✩)

编写计算直方图的方法Map<Integer, long> valueCount(List<Integer>),即传递的列表中数字的频率分布。此外,编写方法Map<Integer, Long> sortByValue(Map<Integer, Long>),根据值而不是键对地图进行排序。必须实现降序排序,最高值列在开头。

例子

|

投入

|

结果

|

最常见的

|
| --- | --- | --- |
| [1, 2, 3, 4, 4, 4, 3, 3, 2, 4] | {1=1, 2=2, 3=3, 4=4} | 4=4 |
| [1, 1, 1, 2, 2, 2, 3, 3, 3] | {1=3, 2=3, 3=3} | 根据查询,逻辑上所有 |

算法根据输入值,计算一个直方图,作为带有频率值的图:

static Map<Integer, Long> valueCount(final List<Integer> values)
{
    final Map<Integer,Long> valueToCount = new TreeMap<>();

    values.forEach(value ->
    {
        valueToCount.putIfAbsent(value, 0L);
        valueToCount.computeIfPresent(value, (orig, count) -> ++count);
    });

    return valueToCount;
}

或者,您也可以利用 Stream API 进行简化,尽管问题是所选择的身份映射(n -> n)不幸地不允许编译器正确地确定键的类型,因此这里使用了Object并不吸引人:

static Map<Object, Long> valueCountWrong(final List<Integer> values)
{
    return values.stream().
                  collect(Collectors.groupingBy(n -> n, Collectors.counting()));
}

您通过使用Integer.valueOf(int)实现了改进。通过静态导入收集器以及使用方法引用,它变得更具可读性(使用 IDE 的快速修复可以方便地实现这一点):

static Map<Integer, Long> valueCountV2(final List<Integer> values)
{
    return values.stream().collect(groupingBy(Integer::valueOf, counting()));
}

最后一步是按值对生成的地图进行排序。方便的是,从 Java 8 开始,这很容易通过预定义的比较器来实现:

static Map<Integer, Long> sortByValue(final Map<Integer, Long> counts)
{
    return counts.entrySet().stream().
           sorted(Map.Entry.<Integer, Long>comparingByValue().reversed())
           collect(Collectors.toMap(Map.Entry::getKey,
                                    Map.Entry::getValue,
                                    (e1, e2) -> e1, LinkedHashMap::new));
}

确认

像往常一样,您使用简介中的值,通过单元测试来检查您刚刚实现的功能:

@ParameterizedTest(name = "valueCountV2({0}) = {1}")
@MethodSource("listInputsAndExpected")
void valueCountV2(List<Integer> inputs, Map<Integer, Long> expected)
{
    Map<Integer, Long> result = Ex10_HaeufigstesElement.valueCountV2(inputs);

    assertEquals(expected, result);
}

private static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(1, 2, 3, 4, 4, 4, 3, 3, 2, 4),
                                  Map.of(1, 1L, 2, 2L, 3, 3L, 4, 4L)),
                     Arguments.of(List.of(1, 1, 1, 2, 2, 2, 3, 3, 3),
                                  Map.of(1, 3L, 2, 3L, 3, 3L)));
}

@Test
public void sortByValue()
{
    Map<Integer, Long> counts = Map.of(1, 1L, 2, 2L, 3, 3L, 4, 4L);
    Map<Integer, Long> expected = new LinkedHashMap<>();
    expected.put(4, 4L);
    expected.put(3, 3L);
    expected.put(2, 2L);
    expected.put(1, 1L);

    Map<Integer, Long> result = Ex10_HaeufigstesElement.sortByValue(counts);

    assertIterableEquals(expected.entrySet(),  result.entrySet());
}

要测试按值排序,您必须耍点小花招。如果有相同的条目,通过assertEquals()比较地图总是返回true,但是这里您关心的是顺序。首先,您会想到用两个迭代器遍历各自的entrySet()。但是有一个更好的方法,即使用assertIterableEquals()。如图所示,它不仅漂亮短小,而且相当优雅。

7.3.11 解决方案 11:数字相加(★★★✩✩)

考虑两个要相加的十进制数。听起来很简单,但是对于这个任务,这些数字有趣地被表示为一个数字列表。写方法List<Integer> listAdd(List<Integer>, List<Integer>)。此外,考虑溢出的特殊情况。

解决方案 11a:添加(★★★✩✩)

在任务的第一部分,数字按照它们在列表中出现的顺序存储。

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| 123 = [1, 2, 3] | 456 = [4, 5, 6] | 579 = [5, 7, 9] |
| 927 = [9, 2, 7] | 135 = [1, 3, 5] | 1062 = [1, 0, 6, 2] |

算法你从一个简化开始,即数字有相同的位数。类似于黑板上的加法,你从后到前,从一个位置到另一个位置,将每种情况下的数字相加。可能会有进位,这一点你在下面的加法中要考虑到。如果在处理的最后还有一个进位(所以对于你在最前面的位置),你必须在前面的位置把值 1 加到结果上。见图 7-1 。

img/519691_1_En_7_Fig1_HTML.jpg

图 7-1

带进位的加法示例

您将此过程应用于两个数字列表,并从后向前遍历它们,开始时仍然简化等长的列表,这避免了特殊处理:

static List<Integer> listAdd(final List<Integer> values1,
                             final List<Integer> values2)
{
    final List<Integer> result = new ArrayList<>();

    int carry = 0;
    for (int i = values1.size() - 1; i >= 0; i--)
    {
        final int value1 = values1.get(i);
        final int value2 = values2.get(i);
        final int sum = value1 + value2 + carry;
        result.add(0, sum % 10);

        carry = sum >= 10 ? 1 : 0;
    }

    // add a 1 at the front of a carryover
    if (carry == 1)
        result.add(0, 1);

    return result;
}

改进算法如果你想提供一个普遍有效的加法,你必须从后面开始重新添加数字。然而,如果长度不相等,那么在某一点上就不再可能访问任何数字,因为一个数字比另一个数字的位数少。辅助方法safeGetAt()有助于处理潜在的失败访问,并在这种情况下提供 0 的回退。

static List<Integer> listAddImproved(final List<Integer> inputs1,
                                     final List<Integer> inputs2)
{
    final List<Integer> result = new ArrayList<>();

    int carry = 0;
    int idx1 = values1.size() - 1;
    int idx2 = values2.size() - 1;

    while (idx1 >= 0 || idx2 >= 0)
    {
        final int value1 = safeGetAt(values1, idx1);
        final int value2 = safeGetAt(values2, idx2);
        final int sum = value1 + value2 + carry;
        result.add(0, sum % 10);

        carry = sum >= 10 ? 1 : 0;

        idx1--;
        idx2--;
    }

    // add a 1 at the front of a carryover
    if (carry == 1)
        result.add(0, 1);

    return result;
}

让我们快速看一下安全索引访问的实现,它将允许的索引范围之外的访问映射到值 0:

static int safeGetAt(final List<Integer> inputs, final int pos)
{
    if (pos >=0 && pos < inputs.size())
        return inputs.get(pos);

    return 0;
}

确认

您可以使用单元测试来验证实现是否为给定的数字序列产生了预期的结果:

@Test
void listAdd_for_Values1()
{
    List<Integer> result = Ex11_ListAdder.listAddImproved(List.of(1, 2, 3),
                                                          List.of(4, 5, 6));
    assertEquals(List.of(5, 7, 9), result);
}

@Test
void listAdd_for_Values2()
{
    List<Integer> result = Ex11_ListAdder.listAddImproved(List.of(9, 2, 7),
                                                          List.of(1, 3, 5));
    assertEquals(List.of(1, 0, 6, 2), result);
}

让我们考虑两种实现中数字长度不相等的特殊情况。只有第二个改进的变体正确地处理了这个问题:

jshell> var result3 = listAdd(List.of(7,2,1), List.of(1,2,7,0,0,0)) result3 ==> [8, 4, 8]

jshell> var result4 = listAddImproved(List.of(7,2,1), List.of(1,2,7,0,0,0)) result4 ==> [1, 2, 7, 7, 2, 1]

解决方案 11b:加法逆运算(★★★✩✩)

如果数字在列表中逆序存储会有什么变化?

例子

|

输入 1

|

输入 2

|

结果

|
| --- | --- | --- |
| 123 = [3, 2, 1] | 456 = [6, 5, 4] | 579 = [9, 7, 5] |
| 927 = [7, 2, 9] | 135 = [5, 3, 1] | 1062 = [2, 6, 0, 1] |

算法如果列表中数字的顺序与数字内部的顺序相反,事情就变得简单了。然后可以直接相加,位数不等的数字的处理变得更容易。同样,您使用了safeGetAt()方法。此外,在溢出的情况下,只需要在自然方向上添加。

static List<Integer> listAddV2(final List<Integer> inputs1,
                               final List<Integer> inputs2)
{
    final List<Integer> result = new ArrayList<>();

    int carry = 0;
    for (int i = 0; i < inputs1.size() || i < inputs2.size(); i++)
    {
        final int value1 = safeGetAt(inputs1, i);
        final int value2 = safeGetAt(inputs2, i);
        final int sum = value1 + value2 + carry; result.add(sum % 10);

        carry = sum >= 10 ? 1 : 0;
    }

    // add a 1 to a carry "front"
    if (carry == 1)
        result.add(1);

    return result;
}

确认

考虑两个一位数列表形式的数字。数值的书写方式与数字相反。特别是,这个变体允许不同长度的数字相加,而不必处理两个索引值。

@ParameterizedTest(name = "listAddImproved({0} + {1}) = {2}")
@MethodSource("reverseOrderInputs")
void listAddImproved(List<Integer> inputs1, List<Integer> inputs2,
                     List<Integer> expected)
{
    List<Integer> result = Ex11_ListAdder.listAddImproved(inputs1, inputs2);

    assertEquals(expected, result);
}

private static Stream<Arguments> reverseOrderInputs()
{
    return Stream.of(Arguments.of(List.of(3, 2, 1), List.of(6, 5, 4),
                                  List.of(9, 7, 5)),
                     Arguments.of(List.of(7, 2, 9), List.of(5, 3, 1),
                                  List.of(2, 6, 0, 1)),
                     Arguments.of(List.of(5, 3, 1), List.of(0, 0, 0, 1, 3, 5),
                                  List.of(5, 3, 1, 1, 3, 5)));
}

7.3.12 解决方案 12:复合键(★★✩✩✩)

假设您想在一个映射中使用两个或更多的值作为一个键。也就是你要用一个所谓的复合键。例如,这样的键由两个int值组成,或者由一个String和一个int组成。如何实现这一点?

例子

|

复合键(月、年)

|

会议

|
| --- | --- |
| (2019 年 9 月) | ch 开放苏黎世,甲骨文代码一 SF |
| (2019 年 10 月) | JAX 伦敦 |
| (2019 年 11 月) | W-JAX 慕尼黑 |

|

复合关键字(姓名、年龄)

|

业余爱好

|
| --- | --- |
| (彼得,22 岁) | 电视 |
| (迈克,48 岁) | Java,骑自行车,电影 |

算法定义了一个合适的类XyKey,比如将Stringint定义为类StringIntKey.,尤其重要的是方法boolean equals(Object)int hashCode(),它们符合契约的实现确保了正确的功能。 2 为了以后在调试或输出时得到可读的表示,让 IDE 生成方法String toString()并稍加修改。这导致了以下实现:

static class StringIntKey
{
    public final String strValue;
    public final int intValue;

    public static StringIntKey of(final String strValue, final int intValue)
    {
        return new StringIntKey(strValue, intValue);
    }

    private StringIntKey(final String strValue, final int intValue)
    {
        this.strValue = strValue;
        this.intValue = intValue;
    }

    @Override
    public int hashCode()
    {
        return Objects.hash(intValue,  strValue);
    }

    @Override
    public boolean equals(Object obj)
    {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;

        StringIntKey other = (StringIntKey) obj;
        return intValue == other.intValue &&
               Objects.equals(strValue, other.strValue);
    }

    @Override
    public String toString()
    {
        return "StringIntKey [" + strValue + " / " + intValue + "]";
    }
}

现代 Java 引入了所谓的记录。这是一个简单明了地实现复合键的好方法: 3

record StringIntKey(String strValue, int intValue)
{
    public static StringIntKey of(final String strValue, final int intValue)
    {
        return new StringIntKey(strValue, intValue);
    }
}

确认

首先,您定义几个由人名和年龄组成的组合键,然后使用它们映射到一个爱好列表:

public static void main(final String[] args)
{
    // definition of the keys
    final StringIntKey key1 = new StringIntKey("Peter", 22);
    final StringIntKey key2 = new StringIntKey("Mike", 48);
    final StringIntKey key3 = new StringIntKey("Tom", 33);

    // alternative definition
    final StringIntKey mike48 = StringIntKey.of("Mike", 48);
    final StringIntKey tom33 = StringIntKey.of("Tom", 33);
    final StringIntKey michael48 = StringIntKey.of("Michael", 48);

    // usage in the map
    final Map<StringIntKey, List<String>> personToHobbies = new HashMap<>();
    personToHobbies.put(key1, List.of("TV"));
    personToHobbies.put(key2, List.of("Java", "Cycling", "Movies"));
    personToHobbies.put(michael48, List.of("Java", "Cycling"));
    personToHobbies.put(tom33, List.of("Running", "Movies"));

    // access
    System.out.println(mike48 + " => " + personToHobbies.get(mike48));
    final StringIntKey newTom33 = StringIntKey.of("Tom", 33);
    System.out.println(newTom33 + " => " + personToHobbies.get(newTom33));
}

因为equals()hashCode()方法是根据契约定义的,所以您可以在访问新的键对象时使用它们:

StringIntKey [Mike / 48] => [Java, Cycling, Movies]
StringIntKey [Tom / 33] => [Running, Movies]

7.3.13 解决方案 13:列表合并(★★✩✩✩)

给定两个数字列表,每个列表都按升序排序,根据它们的顺序将它们合并成一个结果列表。写方法List<Integer> merge(List<Integer>, List<Integer>)

例子

|

输入 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] |

算法乍一看,这个问题似乎相当容易解决。你从两个列表的开头开始。然后比较各自位置的值,将较小的值插入结果中,并增加元素在列表中的起始位置。这看起来像下面这样:

static List<Integer> mergeFirstTry(final List<Integer> values1,
                                   final List<Integer> values2)
{
    final List<Integer> result = new ArrayList<>();

    int idx1 = 0;
    int idx2 = 0;

    while (idx1 < values1.size() || idx2 < values2.size())
    {
        final int value1 = values1.get(idx1);
        final int value2 = values2.get(idx2);

        if (value1 < value2)
        {
            result.add(value1);
            idx1++;
        }
        else
        {
            result.add(value2);
            idx2++;
        }
    }
    return result;
}

虽然这个解决方案看起来很直观,也很好,但它仍然存在问题。为了识别它们,让我们对第二个值组合尝试一次该方法:

jshell> mergeFirstTry(List.of(2, 3, 5, 7), List.of(11, 13, 17))
| Exception java.lang.ArrayIndexOutOfBoundsException: Index 4 out of bounds
     for length 4

作为一种快速解决方法,您可以用 AND (&&)替换 OR (||),这样可以消除 exception 的问题。但是这导致了另一个问题:不是两个列表中的所有元素都被处理了,这通常取决于值的分布,甚至是不同的数字。所以这不是一个万能的解决方案,但仍然是一个好的开始。您只需要适当地满足列表中剩余元素的特殊需求。出于以下目的,将它们添加到结果中:

static List<Integer> merge(final List<Integer> values1,
                           final List<Integer> values2)
{
    final List<Integer> result = new ArrayList<>();

    int idx1 = 0;
    int idx2 = 0;

    while (idx1 < values1.size() && idx2 < values2.size())
    {
        final int value1 = values1.get(idx1);
        final int value2 = values2.get(idx2);

        if (value1 < value2)
        {
            result.add(value1);
            idx1++;
        }
        else
        {
            result.add(value2);
            idx2++;
        }
    }

    addRemaining(result, values1, idx1);
    addRemaining(result, values2, idx2);

    return result;
}

您将把附加剩余元素的功能移到方法addRemaining()中。有趣的是,在调用它之前不需要特殊的检查。这是通过在for循环中提供相应的索引和终止条件间接给出的:

static void addRemaining(final List<Integer> result,
                         final List<Integer> values, final int idx)
{
    for (int i = idx; i < values.size(); i++)
    {
       result.add(values.get(i));
    }
}

确认

要检查功能,您可以像往常一样使用简介中的值组合:

@ParameterizedTest(name = "merge({0}, {1}) = {2}")
@MethodSource("listInputsAndExpected")
void listMerge(List<Integer> inputs1, List<Integer> inputs2, List<Integer> expected)
{
    List<Integer> result = Ex13_ListMerger.merge(inputs1, inputs2);

    assertEquals(expected, result);
}

private static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(1, 4, 7, 12, 20),
                                  List.of(10, 15, 17, 33),
                                  List.of(1, 4, 7, 10, 12, 15, 17, 20, 33)),
                     Arguments.of(List.of(2, 3, 5, 7), List.of(11, 13, 17),
                                  List.of(2, 3, 5, 7, 11, 13, 17)),
                     Arguments.of(List.of( 1, 2, 3), List.of(),
                                  List.of( 1, 2, 3)));
}

7.3.14 解决方案 14: Excel Magic Select (★★✩✩✩)

如果你用过一点 Excel,那么你可能用过所谓的魔法选择。它会根据先前的值,在选定的区域中不断填充值。例如,这适用于数字、工作日或日期。为了自己实现类似的东西,编写方法List<Integer> generateFollowingValues(int, int),为数字实现这个。创建一个适合工作日的变体,并使用以下签名:List<DayOfWeek> generateFollowingValues(DayOfWeek, int).

例子

|

基础资料

|

数数

|

结果

|
| --- | --- | --- |
| one | seven | [1, 2, 3, 4, 5, 6, 7] |
| five | four | [5, 6, 7, 8] |
| 星期五 | eight | [星期五、星期六、星期日、星期一、星期二、星期三、星期四、星期五] |

算法一开始,你可能会认为这是基于非常复杂的东西。但是,当再次考虑该算法时,您很快意识到您所需要的只是一个作为结果数据结构的列表和一个填充它的循环:

static List<Integer> generateFollowingValues(int currentValue,
                                             int sequenceLength)
{
    final List<Integer> result = new ArrayList<>();

    while (sequenceLength-- > 0)
    {
        result.add(currentValue);
        currentValue++;
    }
    return result;
}

同样,用工作日填充也很容易,与数值不同,工作日总是在 7 个值之后重复。有两件事对此有帮助:

  1. 所有枚举(enum)在调用它们的values()方法时都返回一个数组表示。

  2. 寻找一个值(见第五章的介绍)以及循环遍历数组,你提取到助手方法nextCyclic()

有了这些知识,您只需对先前使用的算法进行最小程度的修改:

static List<DayOfWeek> generateFollowingValues(final DayOfWeek startDay,
                                               int sequenceLength)
{
    final DayOfWeek[] allWeekDays = DayOfWeek.values();
    int currentPos = find(allWeekDays, startDay);

    final List<DayOfWeek> result = new ArrayList<>();

    DayOfWeek nextDay = startDay;
    while (sequenceLength-- > 0)
    {
        result.add(nextDay);
        nextDay = nextCyclic(allWeekDays, currentPos);
        currentPos++;
    }
    return result;
}

您的实现使用搜索作为基于阵列的功能:

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;
}

但是,因为您在这里操作的是一个固定范围的枚举值,所以您可以将其缩写如下,而不是上面的方法:

int currentPos = startDay.getValue() - 1;

第二种方法旨在通过在传递最后一个元素后从头开始,允许向前循环遍历数组:

static <T> T nextCyclic(final T[] values, final int currentPos)
{
    final int nextPos = (currentPos + 1) % values.length;

    return values[nextPos];
}

简化由于DayOfWeek是一个本地提供该功能的枚举,您可以如下编写该方法:

static List<DayOfWeek> generateFollowingValuesSimpler(final DayOfWeek
                                                      startDay, int sequenceLength)
{
    final List<DayOfWeek> result = new ArrayList<>();

    DayOfWeek nextDay = startDay;
    while (sequenceLength > 0)
    {
        result.add(nextDay);
        nextDay = day.plus(1);
        sequenceLength--;
    }

    return result;
}

确认

为了跟踪刚刚实现的神奇完成的功能,您再次定义参数化测试,例如,一个测试从星期五开始,然后生成八个值:

@ParameterizedTest(name = "generateFollowingValues({0}, {1}) = {2}")
@MethodSource("simpleInputs")
void generateFollowingValues(int startValue, int sequenceLength,
                             List<Integer> expected)
{
    var result =
        Ex14_ExcelMagicSelection.generateFollowingValues(startValue,
                                                         sequenceLength);

    assertEquals(expected, result);
}

@ParameterizedTest(name = "generateFollowingValues({0}, {1}) = {2}")
@MethodSource("enumInputs")
void generateFollowingValues(DayOfWeek startValue, int sequenceLength,
                             List<DayOfWeek> expected)
{
    var result =
    Ex14_ExcelMagicSelection.generateFollowingValues(startValue,
                                                     sequenceLength);

assertEquals(expected, result);
}

private static Stream<Arguments> simpleInputs()
{
    return Stream.of(Arguments.of(1, 7, List.of(1, 2, 3, 4, 5, 6, 7)),
                     Arguments.of(5, 4, List.of(5, 6, 7, 8)));
}

private static Stream<Arguments> enumInputs()
{
    return Stream.of(Arguments.of(DayOfWeek.MONDAY, 3,
                     List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY,
                             DayOfWeek.WEDNESDAY)),
                     Arguments.of(DayOfWeek.FRIDAY, 8,
                     List.of(DayOfWeek.FRIDAY, DayOfWeek.SATURDAY,
                             DayOfWeek.SUNDAY, DayOfWeek.MONDAY,
                             DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
                             DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)));
}

Hint: Freestyle with the Stream API

你已经看到了魔术的背后并没有太多东西。事实上,通过使用 Stream API,特别是iterate()方法,您可以编写得更加优雅,该方法基于一个计算规则创建一个无限流。为了将流限制到期望的值数量,limit()可以帮助您。在第一种情况下,您使用一个对类型int进行操作的IntStream。因此,必须调用boxed()将 int 值转换为相应的Integer,然后允许转换为 List

static List<Integer> generateFollowingValues(final int startValue,
                                             final int sequenceLength)
{
    return IntStream.iterate(startValue, n -> n + 1).
                      limit(sequenceLength).boxed().
                      collect(Collectors.toList());
}

static List<DayOfWeek> generateFollowingValues(final DayOfWeek startDay,
                                               final int sequenceLength)
{
   return Stream.iterate(startDay, day -> day.plus(1)).
                 limit(sequenceLength).
                 collect(Collectors.toList());
}

static List<LocalDate> generateFollowingValues(final LocalDate startValue,
                                               final int sequenceLength)
{
     return Stream.iterate(startValue, day -> day.plusDays(1)).
                   limit(sequenceLength).
                   collect(Collectors.toList());
}

除了调用的方法之外,相关的 JUnit 测试与前面的测试类似。对于使用LocalDate作为参数的方法,您可以稍微修改测试,如下所示:

@ParameterizedTest(name = "generateFollowingValuesLocalDate({0}, {1}) " +
                          "last day should be {2}")
@CsvSource({ "2020-03-13, 8, 2020-03-20", "2010-02-07, 366, 2011-02-07" })
void generateFollowingValuesLocalDate(LocalDate startValue,
                                      int sequenceLength,
                                      LocalDate expectedEndDate)
{
    var expected =
        startValue.datesUntil(startValue.plusDays(sequenceLength)).
                   collect(Collectors.toList());

    var result =
        Ex14_ExcelMagicSelection.generateFollowingValues(startValue,
                                                         sequenceLength);

    assertAll(() -> assertEquals(expected, result),
              () -> assertEquals(expectedEndDate,
                                 result.get(result.size() - 1)));
}

八、高级递归

在这一章中,你将探索递归的一些更高级的方面。你从称为记忆化的优化技术开始。之后,你将回溯视为一种解决问题的策略,它依赖于试错法,并尝试可能的解决方案。尽管这在性能方面不是最佳的,但它可以使各种实现易于理解。

8.1 记忆化

在第三章中,你学到了递归是可行的,可以用一种可理解的,同时又优雅的方式来描述许多算法和计算。然而,您也注意到递归有时会导致许多自调用,这会损害性能。例如,这适用于斐波那契数或帕斯卡三角形的计算。如何克服这个问题?

为此,有一种有用的技术叫做记忆化。它遵循与缓存或缓冲先前计算的值相同的思想。它通过为后续操作重用已经计算的结果来避免多次执行。

8.1.1 斐波那契数的记忆

方便地,记忆化通常可以容易地添加到现有算法中,并且只需要最小的修改。让我们复制这个来计算斐波那契数。

让我们简单重复一下斐波那契数的递归定义:

img/519691_1_En_8_Figa_HTML.png

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的取值范围也不够,而 a BigInteger作为返回和查找映射的类型是个不错的选择。

  • 递归终止:出于实现的目的,在进行记忆化处理之前,有必要考虑一下递归终止。这可能最低限度地提高了性能,但是算法不能从现有的算法中清晰地重构。特别是如果你还不熟悉记忆,显示的变体似乎更吸引人。

8.1.2 帕斯卡三角形的记忆

帕斯卡三角形是递归定义的,斐波那契数列也是如此:

$$ pascal\left( row, col\right)=\left{\begin{array}{c}1,\kern16.25em row=1\ \mathrm{and}\ col=1\left(\mathrm{top}\right)\ {}1,\kern16.5em row\ \left{1,n\right}\ \mathrm{and}\ col=1\ {}1,\kern15.5em row\ \left{1,n\right}\ \mathrm{and}\ col= row\ {} pascal\left( row-1, col\right)+\kern18.75em \ {} pascal\left( row-1, col-1\right),\mathrm{otherwise}\ \left(\mathrm{based}\ \mathrm{on}\ \mathrm{predecessors}\right)\end{array}\right. $$

让我们首先再次看看纯递归实现:

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 个位置(左上角)的皇后开始。每次放置后,都要进行检查,以确保在垂直方向和向上的左右对角线方向上没有与已经放置的皇后发生碰撞。向下检查是没有必要的,因为没有皇后可以放在那里,因为填充是从上到下完成的。这也是不需要水平方向检查的原因。

如果位置有效,你移动到下一行,尝试从0n1 的所有位置。重复这个过程,直到你最后把皇后放在最后一排。如果定位一个皇后有问题,你用回溯。你移走最后放置的皇后,然后在下一个可能的位置再试一次。如果到达行的末尾而没有解,这是一个无效的星座,前面的皇后也必须再次放置。您可以观察到回溯有时会回溯到一行,在极端情况下会回溯到第一行。

举例回溯:我们来看一下求解的步骤,其中在水平层面上,部分省略了一些中间步骤,无效位置用 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 。

img/519691_1_En_8_Fig1_HTML.png

图 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 | img/519691_1_En_8_Figg_HTML.gif |
| “绳降” | "表格" | four | img/519691_1_En_8_Figh_HTML.gif |

奖励( ★★★✩✩ ) 通过记忆优化编辑距离

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[][]),为作为参数传递的部分初始化的运动场确定有效的解决方案(如果有的话)。

例子

带有一些空白的有效运动场如下:

img/519691_1_En_8_Fige_HTML.png

应完成以下解决方案:

img/519691_1_En_8_Figf_HTML.png

8.3.6 练习 6:数学运算符检查器(★★★★✩)

这个作业是关于一个数学上的难题。对于一组数字和另一组可能的运算符,您希望找到产生所需值的所有组合。数字的顺序不能改变。尽管如此,除了第一个数字之前,仍然可以在数字之间插入可能的运算符中的任何运算符。编写方法Set<String> allCombinationsWithValue(List<Integer>, int),该方法确定导致作为参数传递的值的所有组合。检查数字19以及运算+组合数字。从方法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:水壶问题(★★★✩✩)

考虑两个容量分别为 mn 升的水壶。不幸的是,这些水壶没有标记或指示他们的填充水平。挑战在于测量 x 升,其中 x 小于 mn 。在程序结束时,一个壶应该包含 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 。

img/519691_1_En_8_Fig2_HTML.png

图 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. 首先,将小一片的塔从源运输到辅助棒。

  2. 然后,将最后一个也是最大的存储片从源存储片移动到目标存储片。

  3. 最后,剩下的塔必须从辅助杆移动到目标杆。

当高度为 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 | img/519691_1_En_8_Figi_HTML.gif |
| “绳降” | "表格" | four | img/519691_1_En_8_Figj_HTML.gif |

算法让我们开始考虑你在这里如何进行。如果两个字符串匹配,则编辑距离为 0。如果两个字符串中的一个不包含(更多)字符,那么到另一个的距离就是另一个字符串中剩余的字符数。这意味着要多次插入相应的字符。这定义了递归终止。

否则,您将从头开始检查这两个字符串,并逐字符进行比较。如果它们是相同的,你就向字符串的末尾移动一个位置。如果它们不同,您将检查三种不同的修改:

  1. 插入:递归调用下一个字符

  2. 删除:递归调用下一个字符

  3. 替换:递归调用下一个字符

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对象,考虑使用位置指针,在本例中为 pos1pos2 。作为算法的一个小修改,比较从字符串的末尾开始。这样,你就可以从末尾一个字符一个字符地进行比较,并朝着字符串的开头前进。以下内容适用于:

  1. 插入:递归调用下一个字符,即位置 1 和位置21

  2. 删除:递归调用下一个字符,即位置11 和位置 2

  3. 替换:递归调用下一个字符,即位置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[][]),为作为参数传递的部分初始化的运动场确定有效的解决方案(如果有的话)。

例子

此处显示了一个带有一些空白的有效操场:

img/519691_1_En_8_Figc_HTML.png

应完成以下解决方案:

img/519691_1_En_8_Figd_HTML.png

算法解数独,你用回溯。和其他回溯问题一样,数独可以通过一步一步的试错来解决。在这种情况下,这意味着为每个空方块尝试不同的数字。根据数独规则,当前数字必须不存在于水平、垂直或 3 × 3 块中。如果你找到一个有效的赋值,你可以在下一个位置继续递归,以测试你是否得到一个解。如果找不到,则尝试下一个数字。但是,如果从 1 到 9 没有一个数字导致解决方案,您需要回溯来检查解决方案的其他可能路径。

在实现过程中,您按如下方式进行:

  1. 找到下一个空字段。为此,请跳过所有已填写的字段。这样也能变线。

  2. 如果直到最后一行都没有空字段,那么您已经找到了解决方案。

  3. 否则,您可以尝试从 1 到 9 的数字:

    1. 有冲突吗?然后你必须试下一个数字。

    2. 该数字是可能的候选数字。你递归调用你的方法到下一个位置(下一列甚至下一行)。

    3. 如果递归返回 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),该方法确定导致作为参数传递的值的所有组合。检查数字19和操作+以及组合数字。从方法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 + rlrlr ,其中 lr 代表输入的左右部分。你用方法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:水壶问题(★★★✩✩)

考虑两个容量分别为 mn 升的水壶。不幸的是,这些水壶没有标记或指示他们的填充水平。挑战在于测量 x 升,其中 x 小于 mn 。在程序结束时,一个壶应该包含 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 的文本,该问题被分解为三个子问题:

  1. 整篇课文是回文吗?

  2. 左边缩短的部分是回文吗?(适用于右侧的所有位置)

  3. 右边缩短的部分是回文吗?(适用于从左侧开始的所有位置)

为了更好地理解,请看初始值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 个位置(左上角)的皇后开始。每次放置后,进行检查以确保在垂直和向上的左右对角线方向上没有与已经放置的皇后发生碰撞。在任何情况下,逻辑上向下检查都是不必要的,因为还没有皇后可以放在那里。毕竟填充是从上到下完成的。因为您也是一行一行地进行,所以水平方向的检查是不必要的。

如果位置有效,移动到下一行,尝试从0n1的所有位置。重复这个过程,直到你最后把皇后放在最后一排。如果定位皇后有问题,使用回溯。移除最后放置的皇后,并在下一个可能的位置重试。如果到达行尾无解,这是无效星座,前一个皇后也必须重新放置。您可以看到,回溯有时会返回到上一行,在极端情况下会返回到第一行。

让我们从简单的部分开始,即概括介绍和创建运动场,并调用方法来解决它:

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);
}

结果与之前的结果相同,因此不再显示。

九、二叉树

虽然集合框架提供了丰富多样的真实世界的数据结构,但不幸的是,它不包括直接使用的树。 1 然而,树对各种用例都有帮助,因此是可取的。因为树的主题相当广泛,并且不超出本书的范围,你将主要处理二叉树和二分搜索法树(BST)作为特例。

在您更详细地了解树木之前,我想提一下一些使用领域:

  • 文件系统是分层结构的,可以建模为一棵树。这里,节点对应于目录,树叶对应于文件。

  • 数学计算可以用树来表示。您将在后面的练习中看到这一点。

  • 在数据库领域,所谓的 B 树用于有效的存储和搜索。

  • 在编译器构造中,人们使用所谓的抽象语法树(AST)来表示源代码。 2

9.1 导言

在本简介中,在简要探讨二叉树和二叉查找树之前,您首先要学习一些术语。之后,我讨论了遍历和树的一些性质。最后,我介绍了在课文和作业中反复使用的三棵树。

9.1.1 结构、术语和使用示例

树既允许结构化存储,也允许高效访问在那里管理的数据。出于这个目的,是严格分等级的,和真正的树一样,树枝不会长回树干。分支点被称为节点并存储一个值。一个分支末端的节点被称为,在那里也可以找到值。连接的分支件称为。图 9-1 给人的第一印象。

img/519691_1_En_9_Fig1_HTML.png

图 9-1

有一些节点和叶子的树

图 9-1 说明了树由分层组织的节点组成。它们从一个根开始(有趣的是,它位于计算机科学的顶端)并分支成几个子节点,这些子节点又可以有任意数量的子节点。因此它们是父树,代表子树的根。每个节点正好被一个其他节点引用。

9.1.2 二叉树

二叉树是一种特殊的树,其中每个节点存储一个值,每个节点最多拥有两个后继,通常称为左和右。这种限制使得许多算法的表达更加容易。因此,二叉树在计算机科学中被广泛使用。

家酿软件中的二叉树二叉树只需要一点点努力就能实现(由类BinaryTreeNode<T>本身实现),这里甚至简化了更多,无需数据封装(信息隐藏):

public class BinaryTreeNode<T>
{
    public final T item;

    public BinaryTreeNode<T> left;
    public BinaryTreeNode<T> right;

    public BinaryTreeNode(final T item)
    {
        this.item = item;
    }

    @Override
    public String toString()
    {
        return String.format("BinaryTreeNode [item=%s, left=%s, right=%s]",
                             item, left, right);
    }

    public boolean isLeaf()
    {
        return left == null && right == null;
    }

    // other methods like equals(), hashCode(), ...
}

对于本书中的例子,你不需要提供一个二叉树模型作为一个独立的类,但是你总是使用一个特殊的节点作为根。然而,为了简化您自己的处理,尤其是更复杂的业务应用程序,定义一个名为BinaryTree<T>的单独类是一个好主意。这也允许您在那里提供各种有用的功能。

9.1.3 有序二叉树:二分搜索法树

有时术语二叉树二叉查找树可以互换使用,但这是不正确的。二叉查找树确实是一棵二叉树,但是它有一个额外的属性,即节点是根据它们的值排列的。约束条件是根的值大于左后继的值,小于右后继的值。这个约束递归地应用于所有子树,如图 9-2 所示。因此,BST 不会多次包含任何值。

img/519691_1_En_9_Fig2_HTML.png

图 9-2

带有字母的二叉查找树示例

BST 中的搜索由于值的排序,BST 中的搜索可以在对数时间内执行。为此,您实现了方法BinaryTreeNode<T> find(BinaryTreeNode<T>, T)。根据该值与当前节点值的比较,在树的适当部分继续搜索,直到找到该值。如果没有找到,则返回null:

<T extends Comparable<T>> BinaryTreeNode<T> find(BinaryTreeNode<T> startNode,
                                               T searchFor)
{
    // recursive termination
    if (startNode == null)
        return null;

    // recursive descent to the left or right depending on the comparison
    final int compareResult = startNode.item.compareTo(searchFor);
    if (compareResult < 0)
        return find(startNode.right, searchFor);
    if (compareResult > 0)
        return find(startNode.left, searchFor);

    return startNode;
}

插入到 BST 中插入到 BST 中也可以递归地表达。插入必须从根开始,这样才能确保 BST 中值的顺序:

static <T extends Comparable<T>> BinaryTreeNode<T>
       insert(final BinaryTreeNode<T> root, final T value)
{
    // recursive termination
    if (root == null)
        return new BinaryTreeNode<>(value);

    // recursive descent: to the left or right depending on the comparison
    final int compareResult = root.item.compareTo(value);
    if (compareResult > 0)
        root.left = insert(root.left, value);
    else if (compareResult < 0)
        root.right = insert(root.right, value);

    return root;
}

BST的例子前面显示的方法也是本章的工具类TreeUtils的一部分。有了这个类,BST 就可以非常容易和易读地构建了。在下面的代码中,您使用下划线作为前缀,以使节点的名称尽可能不言自明。此外,如果想继续使用节点,只需要给变量赋值。然而,特别地,根总是被返回。

final BinaryTreeNode<Integer> _3 = new BinaryTreeNode<>(3);
TreeUtils.insert(_3, 1).
TreeUtils.insert(_3, 2);
TreeUtils.insert(_3, 4);

TreeUtils.nicePrint(_3);
System.out.println("tree contains 2? " + find(_3, 2));
System.out.println("tree contains 13? " + find(_3, 13));

这将生成以下输出:

            3
      |-----+-----|
      1           4
      |--|
         2
tree contains 2?  BinaryTreeNode [item=2, left=null, right=null]
tree contains 13? null

有问题的插入顺序请注意,添加元素的顺序会极大地影响后续操作(如搜索)的性能。我将在 9.1.5 节中简要介绍这一点。下面的示例演示了树退化为列表的速度:

final BinaryTreeNode<Integer> _4 = new BinaryTreeNode<>(4);
BinaryTreeNode.insert(_4, 3);
BinaryTreeNode.insert(_4, 2);
BinaryTreeNode.insert(_4, 1);
TreeUtils.nicePrint(_4);

这将生成以下输出:

                        4
            |-----------|
            3
      |-----|
      2
   |--|
   1

Hint: ASCII Output of Trees

对于示例和练习中的树的输出,调用方法nicePrint()。这里暂时不进一步展示它的实现。尽管如此,它仍将作为练习 13 执行。相关的解决方案从第 657 页开始逐步展开。

旅行

当遍历一棵树时,深度优先和宽度优先搜索是有区别的。图 9-3 说明了这两种情况。

img/519691_1_En_9_Fig3_HTML.png

图 9-3

深度优先搜索和宽度优先搜索的过程

在深度优先搜索中,尽可能深入地遍历树。使用广度优先搜索,您可以从根开始在树中一层一层地移动。这就是为什么它也被称为水平顺序或广度优先。

广度优先/级别顺序

然后,当从根向下遍历各级时,对于示例中的树产生以下序列。实现是练习 5 的主题。

a b c d e f g h i j k

从树到列表的转换层次顺序遍历的一个很大的优点是它良好的可追溯性和可理解性。如果您心中有一棵树,您可以很容易地预测这个遍历及其结果。这是一个重要而有用的特性,尤其是在测试的时候。

让我们假设您已经解决了练习 5,因此可以访问方法levelorder(BinaryTreeNode<T>, Consumer<T>的实现。基于它,您可以将树转换为列表,如下所示:

static List<String> convertToList(final BinaryTreeNode<String> node)
{
    final List<String> result = new ArrayList<>();

    levelorder(node, result::add);

    return result;
}

深度优先搜索

三种已知的深度优先搜索方法是前序、中序和后序。Preorder 首先处理节点本身,然后处理来自左边和右边子树的节点。对于 inorder,处理顺序首先是左子树,然后是节点本身,最后是右子树。Postorder 首先处理左边的子树,然后是右边的子树,最后是节点本身。三种深度优先搜索方法通过前面显示的值,如下所示:

Preorder:     a b d h e i j c f k g
Inorder:      h d b i e j a f k c g
Postorder:    h d i j e b k f g c a

输出不太直观。对于 BST,inorder 遍历根据节点值的顺序返回节点值。这为下面的树产生 1 2 3 4 5 6 7:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

深度优先搜索的递归实现有趣的是,这些遍历可以很容易地递归实现。在每种情况下,该操作都以粗体突出显示:

static <T> void preorder(final BinaryTreeNode<T> currentNode)
{
    if (currentNode != null)
    {
        System.out.println(currentNode.item);
        preorder(currentNode.left);
        preorder(currentNode.right);
    }
}

static <T> void inorder(final BinaryTreeNode<T> currentNode)
{
    if (currentNode != null)
    {
        inorder(currentNode.left);
        System.out.println(currentNode.item);
        inorder(currentNode.right);
    }
}

static <T> void postorder(final BinaryTreeNode<T> currentNode)
{
    if (currentNode != null)
    {
        postorder(currentNode.left);
        postorder(currentNode.right);
        System.out.println(currentNode.item);
    }
}

Note: Practical Relevance of Postorders

后序是一种重要的树遍历类型,尤其是在以下用例中:

  • 删除:删除子树的根节点时,必须始终确保子节点也被正确删除。后序遍历是做到这一点的好方法。

  • 数学表达式:为了评估所谓的反向波兰符号(RPN) 中的数学表达式,一个后序遍历是一个合适的选择。

  • 大小的计算:要确定一个目录的大小或一个层次项目的持续时间,postorder 是最合适的。

一个一个An example is the expression (5 + 2) * 6, which in RPN is 5 2 + 6 *. Interestingly, in RPN, parenthesis can be omitted, since those elements up to the next operator are always suitably concatenated. However, the RPN is quite poorly readable for more complex expressions.

9.1.5 平衡树和其他属性

如果在一棵二叉树中,两个子树的高度最多相差 1(有时相差某个常量),那么就称之为平衡树。相反的是一棵退化树,这是由于以对树来说不方便的方式插入数据而产生的,特别是当数字以有序的方式添加到二叉查找树中时。这导致树退化成一个线性列表,正如你在 9.1.3 节的例子中看到的。

有时一次或多次旋转可以恢复平衡。对于引言中的树,可以看到向左旋转和向右旋转。在中间,你可以看到平衡的起始位置。参见图 9-4 。

img/519691_1_En_9_Fig4_HTML.png

图 9-4

向左旋转,原稿,向右旋转

属性级别和高度

如导言中所述,树是分层结构的,由节点组成,这些节点可选地具有子节点,并且可以嵌套任意深度。为了描述这一点,存在两个术语水平高度。级别通常从 0 开始计数,从根开始,然后向下到最低的叶子。对于高度,以下情况适用:对于单个节点,它是 1。它是由一个子树向下到最低叶子的节点数决定的。这在图 9-5 中可以看到,其中一些被标记为子节点的节点实际上也是其他节点的父节点。

img/519691_1_En_9_Fig5_HTML.png

图 9-5

树的水平和高度

性质的完备性和完美性

一个完整的二叉树的特点是,除了最后一层,所有层都必须完全填充。此外,在最后一层中,所有节点都必须尽可能靠左,这样就没有间隙,或者所有节点都存在。

完整的二叉树中,右边的值可能会丢失(在算法中,这也被称为左满):

     4
   /   \
  2     6
 / \   /
1   3 5

如果所有的位置都被占据,那么这被称为完美树

     4
   /   \
  2     6
 / \   / \
1   3 5   7

在完全性的上下文中,在二叉树中不允许以下星座(这里是来自上部树的缺失的 5)(因为树不是左满):

     4
   /   \
  2     6
 / \     \
1   3     7

让我们试试更正式的:

  • 一棵完美二叉树是这样的树,其中所有的叶子都在同一层,并且所有的节点都有两个后继者。

  • 一棵完全二叉树是其中所有层都被完全填充,除了最后一层,在最后一层节点可能会丢失,但只是尽可能地向右。

  • 然后你有了一个全二叉树的定义,这意味着每个节点要么没有子节点,要么有两个子节点,如下图所示:

     4
   /   \
  2     6
       / \
      5   7

这是来自最弱的需求。在 www.programiz.com/dsa/complete-binary-tree 可以在线找到图解说明。

9.1.6 示例和练习的树

因为您将在下面重复引用一些典型的树结构,所以您在实用程序类ExampleTrees中实现了三种创建方法。

有字母和数字的树

为了尝试树遍历和其他操作,您构建了一个包含七个节点的树。因此,您定义了类型为BinaryTreeNode<T>的对象,这些对象在创建后仍然需要适当地连接。为了简单起见,这里的例子是在没有信息隐藏的情况下实现的。因此,您可以直接访问属性leftright

static BinaryTreeNode<String> createExampleTree()
{
    final BinaryTreeNode<String> a1 = new BinaryTreeNode<>("a1");
    final BinaryTreeNode<String> b2 = new BinaryTreeNode<>("b2");
    final BinaryTreeNode<String> c3 = new BinaryTreeNode<>("c3");
    final BinaryTreeNode<String> d4 = new BinaryTreeNode<>("d4");
    final BinaryTreeNode<String> e5 = new BinaryTreeNode<>("e5");
    final BinaryTreeNode<String> f6 = new BinaryTreeNode<>("f6");
    final BinaryTreeNode<String> g7 = new BinaryTreeNode<>("g7");

    d4.left = b2;
    d4.right = f6;
    b2.left = a1;
    b2.right = c3;
    f6.left = e5;
    f6.right = g7;

    return d4;
}

这导致了以下带有根d4的树:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

你可能会对字母和数字的组合感到惊讶。我有意选择这个,因为它可以让理解一些算法变得更容易一些——例如,检查遍历的顺序。

具有文本和真实数字的树

对于一些练习,您还需要一个树,其中节点的值只由数字组成(但在文本上是字符串)。因为不可能用数字来命名单个节点的变量,所以再次使用以下划线开始变量名的技巧。

为了构建树,您利用了方法insert(),该方法将要插入的值放在适当的位置。只有当您使用 BST 及其顺序时,这才是可能的。正如你可以很容易地看到的,这将比前面显示的手动链接容易得多。

static BinaryTreeNode<String> createNumberTree()
{
    final BinaryTreeNode<String> _4 = new BinaryTreeNode<>("4");
    TreeUtils.insert(_4, "2");
    TreeUtils.insert(_4, "1");
    TreeUtils.insert(_4, "3");
    TreeUtils.insert(_4, "6");
    TreeUtils.insert(_4, "5");
    TreeUtils.insert(_4, "7");

    return _4;
}

这会产生以下树:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

整数变量:所示的树是作为整数变量生成的,如下所示:

static BinaryTreeNode<Integer> createIntegerNumberTree()
{
    final BinaryTreeNode<Integer> _4 = new BinaryTreeNode<>(4);
    TreeUtils.insert(_4, 2);
    TreeUtils.insert(_4, 1);
    TreeUtils.insert(_4, 3);
    TreeUtils.insert(_4, 6);
    TreeUtils.insert(_4, 5);
    TreeUtils.insert(_4, 7);

    return _4

;
}

9.2 练习

9.2.1 练习 1:树遍历(★★✩✩✩)

扩展引言中介绍的树遍历方法,以便在遍历时对当前节点执行操作。将Consumer<T>添加到各自的方法签名中,例如 for order:void inorder(BinaryTreeNode<T>Consumer<T>)

奖励:将树填充到列表中用节点的值填充列表。因此,编写方法List<T> toList(BinaryTreeNode<T>)进行有序遍历。此外,方法List<T> toListPreorder(BinaryTreeNode<T>)List<T> toListPostorder(BinaryTreeNode<T>)分别基于前序和后序遍历。

9.2.2 练习 2:前序、中序和后序迭代(★★★★✩)

在简介中,您了解了作为递归变量的前序、中序和后序。现在迭代实现这些类型的遍历。

例子

使用以下树:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

三种深度优先搜索方法遍历该树,如下所示:

Preorder:     d4 b2 a1 c3 f6 e5 g7
Inorder:      a1 b2 c3 d4 e5 f6 g7
Postorder:    a1 c3 b2 e5 g7 f6 d4

9.2.3 练习 3:树的高度(★★✩✩✩)

实现方法int getHeight(BinaryTreeNode<T>)来确定树和以单个节点为根的子树的高度。

例子

以下高度为 4 的树用作起点:

                        E
            |-----------+-----------|
            C                       G
      |-----|                 |-----+-----|
      A                       F           H
                                          |--|
                                             I

9.2.4 练习 4:最低共同祖先(★★★✩✩)

计算托管在任意二叉查找树中的两个节点 A 和 B 的最低共同祖先(LCA)。LCA 表示作为 A 和 B 的祖先的节点,并且位于树中尽可能深的位置(根总是 A 和 B 的祖先)。编写方法BinaryTreeNode<Integer> findLCA(BinaryTreeNode<Integer>, int, int),除了搜索的开始节点(通常是根节点)之外,还接收下限和上限,间接描述了最接近这些值的节点。如果限制的值在值的范围之外,那么就没有 LCA,它应该被返回null

例子

下面显示了一个二叉树。如果为值为 1 和 5 的节点确定了最不常见的祖先,则这是值为 4 的节点。在树中,各个节点被圈起来,并且祖先被额外地用粗体标记。

                        6
            |-----------+-----------|
             ➃                        7
      |-----+-----|
      2            ➄
   |--+--|
    ➀      3  

9.2.5 练习 5:广度优先(★★★✩✩)

在本练习中,您应该使用方法void levelorder(BinaryTreeNode<T>, Consumer<T>)实现广度优先搜索,也称为级别顺序。广度优先搜索从给定的节点(通常是根节点)开始,然后逐层遍历树。

Note

使用一个Queue<E>来存储待访问节点上的数据。迭代的变体比递归的稍微容易一点。

例子

对于以下两棵树,序列 1 2 3 4 5 6 7(用于左侧)和 M I C H A E L(用于右侧)将被确定为结果。

            1                         M
      |-----+-----|             |-----+-----|
      2           3             I           C
   |--+--|     |--+--|       |--+--|     |--+--|
   4     5     6     7       H     A     E     L

9.2.6 练习 6:水平求和(★★★★✩)

在上一个练习中,您实现了广度优先搜索。现在,您想对树的每一层的值进行求和。为此,让我们假设这些值属于类型Integer。写方法Map<Integer, Integer> levelSum(BinaryTreeNode<Integer>)

例子

对于所示的树,应该计算每个级别的节点值的总和,并返回以下结果:{0=4,1=8,2=17,3=16}。

|

水平

|

价值

|

结果

|
| --- | --- | --- |
| Zero | four | four |
| one | 2, 6 | eight |
| Two | 1, 3, 5, 8 | Seventeen |
| three | 7, 9 | Sixteen |

                        4
            |-----------+-----------|
            2                       6
      |-----+-----|           |-----+-----|
      1           3           5           8
                                       |--+--|
                                       7     9

9.2.7 练习 7:树旋转(★★★✩✩)

如果只按升序或降序插入值,二叉树,尤其是二分搜索法树,可能会退化为列表。不平衡可以通过旋转树的部分来解决。编写方法BinaryTreeNode<T> rotateLeft(BinaryTreeNode<T>)BinaryTreeNode<T> rotateRight(BinaryTreeNode<T>),这将分别围绕作为参数传递的节点向左或向右旋转树。

例子

图 9-6 显示向左旋转和向右旋转,平衡起始位置在中间。

img/519691_1_En_9_Fig6_HTML.png

图 9-6

向左旋转,原稿,向右旋转

9.2.8 练习 8:重建(★★★✩✩)

练习 8a:从阵列重建(★★✩✩✩)

在本练习中,您希望从升序排序的int[]中重建一个尽可能平衡的二叉查找树。

例子

给定的int值为

final int[] values = { 1, 2, 3, 4, 5, 6, 7 };

那么下面的树应该从它重新构建:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

练习 8b:从预订/未预订重建(★★★✩✩)

假设您有一系列的值,分别是 preorder 和 inorder,每一个都准备为一个列表。这个关于任意二叉树的信息应该被用来从其重建相应的树。写方法BinaryTreeNode<T> reconstruct(List<T>, List<T>)

例子

下面给出了两个遍历值序列。根据这些值,您应该重新构建本练习上一部分中显示的树。

var preorderValues = List.of(4, 2, 1, 3, 6, 5, 7);
var inorderValues = List.of(1, 2, 3, 4, 5, 6, 7);

9.2.9 练习 9:数学评估(★★✩✩✩)

考虑使用一棵树来模拟带有四个运算符+、-、 / 和∑的数学表达式。您的任务是计算单个节点的值,特别是包括根节点的值。为此,编写方法int evaluate(BinaryTreeNode<String>)

例子

用下面的树表示表达式 3+7∫(71)来计算根节点的值 45:

                        +
            |-----------+-----------|
            3                       *
                              |-----+-----|
                              7           -
                                       |--+--|
                                       7     1

练习 10:对称性(★★✩✩✩)

检查任意二叉树的结构是否对称。因此写方法boolean isSymmetric(BinaryTreeNode<T>)。除了结构检查之外,您还可以检查值是否相等。

例子

为了检查对称性,使用一个结构对称的二叉树(左)和一个关于值也对称的二叉树(右)。

            4                            1
      |-----+-----|                    /   \
      2           6                   2     2
   |--+--|     |--+--|               /       \
   1     3     5     7              3         3

Note: The Symmetry Property

在对称二叉树中,左右子树通过根沿着假想的垂直线(由|)镜像:

     1
   / | \
  2  |  2
 /   |   \
3    |    3

根据定义,对于对称性,可以省略值的比较。在这种情况下,只有结构组织可以算作相关。

奖励:镜像树在提示框中,我通过树根指示了一个镜像轴。创建方法BinaryTreeNode<T> invert(BinaryTreeNode<T>),该方法通过根在这条隐含线上镜像树的节点。

例子

镜像看起来像这样:

            4                              4
      |-----+-----|                  |-----+-----|
      2           6        =>        6           2
   |--+--|     |--+--|            |--+--|     |--+--|
   1     3     5     7            7     5     3     1

练习 11:检查★★✩✩✩的二叉查找树

在本练习中,您将检查任意二叉树是否满足二叉查找树的属性(即,左子树中的值小于根节点的值,而右子树中的值大于根节点的值,这适用于从根节点开始的每个子树)。为了简化,假设int值。写方法boolean isBST(BinaryTreeNode<Integer>)

例子

使用下面的二叉树,它也是二叉查找树。例如,如果你把左边的数字 1 换成一个更大的数字,它就不再是二叉查找树了。但是,6 下面的右子树仍然是二叉查找树。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

9.2.12 练习 12:完整性(★★★★)

在本练习中,要求您检查树的完整性。要做到这一点,您首先要解决练习的前两部分中的基本问题,然后进行更复杂的完整性检查。

练习 12a:节点数(★✩✩✩✩)

计算任何二叉树中包含多少个节点。为此,编写方法int nodeCount(BinaryTreeNode<T>)

例子

对于所示的二叉树,应该确定值 7。如果删除右边的子树,树只包含 4 个节点。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

练习 12b:检查完整/完美(★★✩✩✩)

对于一个任意的二叉树,检查是否所有的节点都有两个后继或叶子,因此树是满的。为了完美,所有的叶子必须在同一高度。写方法boolean isFull(BinaryTreeNode<T>)boolean isPerfect(BinaryTreeNode<T>)

例子

所示的二叉树既完美又完整。如果去掉 2 下面的两片叶子,就不再完美但依然饱满。

     Full and perfect         Full but not perfect
            4                          4
      |-----+-----|              |-----+-----|
      2           6              2           6
   |--+--|     |--+--|                    |--+--|
   1     3     5     7                    5     7

练习 12c:完整性(★★★★✩)

在该子任务中,要求您检查树是否如简介中所定义的那样完整(即,所有级别都被完全填充的二叉树,最后一级允许的例外是节点可能缺失,但只有尽可能靠右的间隙)。

例子

除了目前为止使用的完美树,下面的树根据定义也是完整的。但是,如果您从节点 H 中移除子节点,树就不再完整。

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

练习 12d:完备性递归(★★★★)

在这最后一个子任务中,下面的挑战仍然需要作为特殊对待来处理:在没有额外的数据结构和纯递归的情况下解决检查。起初,这听起来几乎不可行,所以我给个提示。

Tip

逐步开发解决方案。创建一个boolean[]作为辅助数据结构,模拟某个位置是否存在节点。然后遍历树并适当标记位置。将这个实现转换成没有boolean[]的纯递归实现。

例子

和以前一样,下面的树根据定义是完整的:

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

9.2.13 练习 13:树打印机(★★★★)

在本练习中,您将实现一个二叉树的图形输出,正如您在前面的示例中看到的那样。

因此,你首先要解决作业前三部分的基础问题,然后再进行更复杂的树的图形展示。

Tip

使用宽度为 3 的块的固定网格。这大大有助于平衡表示并降低复杂性。

例子

下面的树应该涵盖各种特殊情况:

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C

练习 13a:子树的宽度(★★✩✩✩)

在练习的这一部分中,要求您使用方法int subtreeWidth(int)找出给定高度的子树的最大宽度。为简单起见,假设最多三个字符代表节点。而且,它们之间至少有三个字符的距离。树满了叶子也是如此。在更高的层次上,两个子树的节点之间自然有更多的空间。

例子

在左边,你看到一棵高度为 2 的树,在右边,一棵高度为 3 的树。基于三个网格,宽度为 9 和 21。参见图 9-7 。

img/519691_1_En_9_Fig7_HTML.png

图 9-7

树宽

|

高度

|

总宽度

|

子树宽度

|
| --- | --- | --- |
| one | three | 0(不存在子树) |
| Two | nine | three |
| three | Twenty-one | nine |
| four | Forty-five | Twenty-one |

练习 13b:绘制节点(★★✩✩✩)

编写方法String drawNode(BinaryTreeNode<T>, int),创建一个节点的图形输出,适当地生成给定的一组空间。节点值最多应该有三个数字,并放在中间。

Tip

请记住,如果当前节点有一个左后继节点,则下面层的表示从左边开始,以字符串'|-'开始。

例子

图 9-8 中的例子显示了一个间距为 5 个字符的单个节点。此外,节点值在三个字符的框中居中对齐。

img/519691_1_En_9_Fig8_HTML.png

图 9-8

绘制节点时的尺寸

练习 13c:画连接线(★★✩✩✩)

编写方法String drawConnections(BinaryTreeNode<T>, int) to构建一个节点到它的两个后继节点的连接线的图形输出。必须正确处理缺失的继任者。

Tip

行长度指的是节点表示之间的字符。在每种情况下,代表末端的部分以及中间的连接器仍需适当附加。

例子

下图显示了绘图中所有相关的情况,例如无后续、一个后续和两个后续。

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C

示意图如图 9-9 所示。

img/519691_1_En_9_Fig9_HTML.png

图 9-9

连接线的示意图

练习 13d:树的表示(★★★★)

组合练习各部分的所有解决方案,并完成必要的步骤,以便能够在控制台上适当地打印任意二叉树。为此,编写方法nicePrint(BinaryTreeNode<T>)

例子

通过nicePrint(),介绍性示例中显示的树的输出也应该类似如下:

                            F
                |-----------+-----------|
                D                       H
          |-----+                       +-----|
          B                                   I
       |--+--|
       A     C

另外,用源代码中可以找到的一棵真正的树来检查你的算法。这是一个瘦了很多的代表:

                                       BIG
                |-----------------------+---------------------|
               b2                                            f6
    |----------+-----------|                      |-----------+---------|
   a1                     d4                     d4                    g7
                     |-----+-----|           |-----+-----|
                     c3          f6         b2          e5
                              |--+--|    |--+---|
                             e5    g7   a1     c3

9.3 解决方案

9.3.1 解决方案 1:树遍历(★★✩✩✩)

扩展介绍中介绍的树遍历方法,以便在遍历时对当前节点执行操作。将Consumer<T>添加到各自的方法签名中,例如 for inorder: void inorder(BinaryTreeNode<T>, Consumer<T>)

算法通过这个扩展,每个遍历树的方法都接收一个Consumer<T>类型的附加参数。然后在适当的地方调用方法accept(T),而不是控制台输出。

static <T> void inorder(final BinaryTreeNode<T> node,
                        final Consumer<T> action)
{
    if (node == null)
        return;

    inorder(node.left, action);
    action.accept(node.item);
    inorder(node.right, action);
}

static <T> void preorder(final BinaryTreeNode<T> node,
                         final Consumer<T> action)
{
    if (node == null)
        return;

    action.accept(node.item);
    preorder(node.left, action);
    preorder(node.right, action);
}

static <T> void postorder(final BinaryTreeNode<T> node,
                          final Consumer<T> action)
{
    if (node == null)
        return;

    postorder(node.left, action);
    postorder(node.right, action);
    action.accept(node.item);
}

额外收获:将一棵树填充到列表中

用节点的值填充列表。因此,编写方法List<T> toList(BinaryTreeNode<T>)进行有序遍历。此外,方法List<T> toListPreorder(BinaryTreeNode<T>)List<T> toListPostorder(BinaryTreeNode<T>)分别基于前序和后序遍历。

算法代替目前作为动作使用的控制台输出,根据所选的遍历策略添加当前值。对于递归下降,然后使用addAll()添加部分结果,并使用来自List<E>的方法add()获取当前节点的值。

static <T> List<T> toList(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return List.of();

    final List<T> result = new ArrayList<>();
    result.addAll(toList(startNode.left));
    result.add(startNode.item);
    result.addAll(toList(startNode.right));

    return result;
}

static <T> List<T> toListPreorder(final BinaryTreeNode<T> startNode)
{
    final List<T> result = new ArrayList<>();
    preorder(startNode, result::add);
    return result;
}

static <T> List<T> toListPostorder(final BinaryTreeNode<T> startNode)
{
    final List<T> result = new ArrayList<>();
    postorder(startNode, result::add);
    return result;
}

您使用前面实现的带有Consumer<T>的遍历(用于前序和后序)来适当地填充一个列表。但是,请记住,通常您应该避免在 lambdas 或方法引用中进行状态更改,因为这与函数式思维方式相矛盾。在这种本地环境中,尤其是在没有多线程的情况下,在这里破例是可以接受的。

确认

首先,定义一个树,然后通过传递的Consumer<T>执行一个 inorder 遍历,最后从树中填充另外两个列表:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    TreeUtils.nicePrint(root);

    System.out.println("\nInorder with consumer: ");
    inorder(root, str -> System.out.print(str + " "));
    System.out.println();

    System.out.println("\ntpList: " + toList(root));
    System.out.println("toListPreorder: " + toListPreorder(root));
}

如果您执行这个main()方法,您将获得以下输出,这表明您的实现按预期工作:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

Inorder with consumer:
a1 b2 c3 d4 e5 f6 g7

toList: [a1, b2, c3, d4, e5, f6, g7]
toListPreorder: [d4, b2, a1, c3, f6, e5, g7]

9.3.2 解决方案 2:前序、中序和后序迭代(★★★★✩)

在简介中,您了解了作为递归变量的前序、中序和后序。现在迭代实现这些类型的遍历。

例子

使用以下树:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

三种深度优先搜索方法遍历该树,如下所示:

Preorder:     d4 b2 a1 c3 f6 e5 g7
Inorder:      a1 b2 c3 d4 e5 f6 g7
Postorder:    a1 c3 b2 e5 g7 f6 d4

算法的初步考虑对于每个迭代实现,都需要一个辅助数据结构。这就是我现在将详细讨论的三种变体。

预排序的算法( ★★✩✩✩ ) 有趣的是,预排序相当简单,因为总是先处理一个子树的根。然后处理左右子树。为此,您使用一个堆栈,最初用当前节点填充它。只要堆栈不为空,就可以确定顶层元素并执行所需的操作。然后,如果左右后继节点存在,就将它们放在堆栈上。需要注意的是,加法的顺序与阅读的顺序相反。为了首先处理左边的子树,必须将右边的节点放在左边的节点之前。重复这一过程,直到堆栈为空。示例中的树的以下序列结果:

|

电流节点

|

|

行动

|
| --- | --- | --- |
|   | [d4] | 开始:按下 d4 |
| d4 | [b2,f6] | 弹出+动作 d4,按 f6,按 b2 |
| b2 | [a1、c3、f6] | 弹出+动作 b2,按 c3,按 a1 |
| 第一等的 | [c3,f6] | Pop +动作 a1 |
| c3 | [f6] | 弹出+动作 c3 |
| f6 | [e5,g7] | 弹出+动作 f6,按 g7,按 e5 |
| e5 | [七国集团] | Pop + action e5 |
| 七国集团 | [] | 弹出+动作 g7 |
| 空 | [] | 目标 |

这导致以下迭代前序实现,其在结构上非常类似于递归变体:

static <T> void preorderIterative(final BinaryTreeNode<T> startNode,
                                  final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Stack<BinaryTreeNode<T>> nodesToProcess = new Stack<>();
    nodesToProcess.push(startNode);

    while (!nodesToProcess.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = nodesToProcess.pop();
        if (currentNode != null)
        {
            action.accept(currentNode.item);

            // so that left is processed first, here order reversed
            nodesToProcess.push(currentNode.right);
            nodesToProcess.push(currentNode.left);
        }
    }
}

为了保持尽可能强的类比性,集合还可以存储null值是很有帮助的。这允许您在从堆栈中提取时执行一次null检查,否则保持源代码免于特殊处理。

Inorder 的算法( ★★★✩✩ ) 在实现 in order 遍历时,使用一个堆栈来临时存储稍后要处理的节点,使用变量currentNode来存储当前节点。基本思想是从根开始,移动到树的左下方,将当前节点放入堆栈,直到没有后继节点。然后从栈中取出最上面的节点并处理它(通过Consumer<T>)。现在你继续找合适的接班人。同样:如果没有后继节点,处理堆栈中的顶层节点。

示例中的树的以下序列结果:

|

电流节点

|

|

行动

|

下降方向

|
| --- | --- | --- | --- |
| d4 | [ ] | 按下 d4 | (六) |
| b2 | [d4] | 按 b2 | (六) |
| 第一等的 | [b2、d4] | 按 a1 | (六) |
| 空 | [a1、b2、d4] | Pop +动作 a1 | *本文件迟交 |
| 空 | [b2、d4] | Pop +动作 b2 | *本文件迟交 |
| c3 | [d4] | 按 c3 | (六) |
| 空 | [c3、d4] | 弹出+动作 c3 | *本文件迟交 |
| 空 | [d4] | pop+D4 操作 | *本文件迟交 |
| f6 | [] | 按 f6 键 | (六) |
| e5 | [f6] | 按 e5 | (六) |
| 空 | [e5,f6] | Pop + action e5 | *本文件迟交 |
| 空 | [f6] | 弹出+操作 f6 | *本文件迟交 |
| 七国集团 | [] | 推动 g7 | (六) |
| 空 | [七国集团] | 弹出+动作 g7 | *本文件迟交 |
| 空 | [] | 目标 |   |

基于此,inorder 的迭代实现如下所示:

static <T> void inorderIterative(final BinaryTreeNode<T> startNode,
                                 final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Stack<BinaryTreeNode<T>> nodesToProcess = new Stack<>();
    BinaryTreeNode<T> currentNode = startNode;

    // are there still nodes on the stack or is the current node not null?
    while (!nodesToProcess.isEmpty() || currentNode != null)
    {
        if (currentNode != null)
        {
            // descent to the left
            nodesToProcess.push(currentNode);
            currentNode = currentNode.left;
        }
        else
        {
            // no left successor, then process current node
            currentNode = nodesToProcess.pop();
            action.accept(currentNode.item);

            // continue with right successor
            currentNode = currentNode.right;
        }
    }
}

后序的算法( ★★★★✩ ) 有了后序,你还用一个栈来做后面要处理的节点的中间存储。然而,在这三种算法中,这种算法是最具挑战性的一种,并且很难实现。因为使用 postorder,虽然遍历从根开始,但动作必须在访问左、右子树后执行。因此,与前两个算法相比,你有一个有趣的变化。在那些算法中,如果从堆栈中取出一个元素,那么它被处理并且不再被触及。对于后序实现,一个元素可能要用peek()检查两次或更多次,之后才移除。

这一次,您将首先查看源代码,然后我将给出进一步的解释:

static <T> void postorderIterative(final BinaryTreeNode<T> startNode,
                                   final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Stack<BinaryTreeNode<T>> nodesToProcess = new Stack<>();
    BinaryTreeNode<T> currentNode = startNode;
    BinaryTreeNode<T> lastNodeVisited = null;

    while (!nodesToProcess.isEmpty() || currentNode != null)
    {
        if (currentNode != null)
        {
            // descent to the left
            nodesToProcess.push(currentNode);
            currentNode = currentNode.left;
        }
        else
        {
            final BinaryTreeNode<T> peekNode = nodesToProcess.peek();
            if (peekNode.right != null && lastNodeVisited != peekNode.right)
            {
                // descent to the right
                currentNode = peekNode.right;
            }
            else
            {
                // sub root or leaf processing
                lastNodeVisited = nodesToProcess.pop();
                action.accept(lastNodeVisited.item);
            }
        }
    }
}

这个过程是这样工作的:从根节点开始,把它放到堆栈上,然后继续左子树。你重复这个过程,直到你再也找不到左边的后继者。现在你必须找一个合适的继任者。只有在此之后,才能对根进行处理。因为您已经保存了堆栈上的所有节点,所以现在从堆栈中检查节点。如果这个节点没有正确的子节点,并且您没有访问过它,那么您将执行传递的操作,并将这个节点记为最后访问的节点。对于另一种情况,即有一个右子树,您也可以像刚才描述的那样遍历它。重复此过程,直到堆栈为空。

|

电流节点

|

|

peekNode

|

行动

|
| --- | --- | --- | --- |
| d4 | [d4] |   | 按下 d4 |
| b2 | [b2、d4] |   | 按 b2 |
| 第一等的 | [a1、b2、d4] |   | 按 a1 |
| 空 | [a1、b2、d4] | 第一等的 | 行动 a1 |
| 空 | [b2、d4] | b2 | 窥视+向右 |
| c3 | [c3、b2、d4] |   | 按 c3 |
| 空 | [c3、b2、d4] | c3 | 行动 c3 |
| 空 | [b2、d4] | b2 | 行动 b2 |
| f6 | [f6,d4] |   | 按 f6 键 |
| e5 | [e5,f6,d4] |   | 按 e5 |
| 空 | [f6,d4] | e5 | 行动 e5 |
| 空 | [f6,d4] | f6 | 窥视+向右 |
| 七国集团 | [g7、f6、d4] |   | 推动 g7 |
| 空 | [g7、f6、d4] | 七国集团 | 行动 g7 |
| 空 | [f6,d4] | f6 | 动作 f6 |
| 空 | [d4] | d4 | 行动 d4 |
| 空 | [] |   |   |

Note: Iterative Implementation of Postorder

虽然这三种遍历的递归变体的实现都同样简单,并且每个都不是很复杂,但这并不适用于迭代实现。Preorder 和 inorder 仍然可以通过一点思考实现,没有大的困难。然而,对于后序,你真的必须战斗。因此,在那里需要几次尝试并不得不应用错误修正并不可耻。

别担心。并不总是那么棘手。即使是后面讨论的逐层遍历的广度优先遍历,在我看来也比迭代后序实现起来简单得多。

在某些情况下,递归是简单的关键。但是,对于很多问题,有非常好理解的迭代变体。

确认

从介绍性示例中定义树,然后每次都使用所需的过程遍历树:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> d4 = ExampleTrees.createExampleTree();
    TreeUtils.nicePrint(d4);

    System.out.println("\ninorder iterative");
    inorderIterative(d4, str -> System.out.print(str + " "));

    System.out.println("\npreorder iterative");
    preorderIterative(d4, str -> System.out.print(str + " "));

    System.out.println("\npostorder iterative");
    postorderIterative(d4, str -> System.out.print(str + " "));
}

如果您执行main()方法,您将获得以下输出,这表明您的实现按预期工作:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

inorder iterative
a1 b2 c3 d4 e5 f6 g7
preorder iterative
d4 b2 a1 c3 f6 e5 g7
postorder iterative
a1 c3 b2 e5 g7 f6 d4

作为一个单元测试,我开始展示如何测试一个有序的遍历。为此,在遍历过程中将当前值填充到一个列表中,然后对照预期值进行检查:

@Test
void testInorderIterative()
{
    var root = ExampleTrees.createExampleTree();
    var expected = List.of("a1", "b2", "c3", "d4", "e5", "f6", "g7");

    final List<String> result = new ArrayList<>();
    Ex02_IterativeTreeTraversals.inorderIterative(root, result::add);

    assertEquals(expected, result);
}

为了完整起见,我将展示前序和后序的两个测试。尤其是后一种遍历应该更仔细地检查,因为实现已经对您提出了很多要求。

@Test
void testPreorderIterative()
{
    var root = ExampleTrees.createExampleTree();
    var expected = List.of("d4", "b2", "a1", "c3", "f6", "e5", "g7");

    final List<String> result = new ArrayList<>();
    Ex02_IterativeTreeTraversals.preorderIterative(root, result::add);

    assertEquals(expected, result);
}

@Test
void testPostOrderIterative() throws Exception
{
    var root = ExampleTrees.createExampleTree();
    var expected = List.of("a1", "c3", "b2", "e5", "g7", "f6", "d4");

    final List<String> result = new ArrayList<>();
    Ex02_IterativeTreeTraversals.postorderIterative(root, result::add);

    assertEquals(expected, result);
}

树的创建是在每个测试方法中完成,还是被转移到一个带@Before注释的设置方法中,这是一个风格问题。有了上面的变体,你总是能看到一切。对于带有@Before的变型,初始化被移到测试夹具中。如有必要,只需调整一次。

惊奇算法

虽然前序很容易迭代设计,但对于 inorder 就变得有点困难,对于后序甚至非常棘手。

但是后来我从张秀坤·格兰茨教授那里得到了一个关于如何迭代简化整个过程的提示。非常感谢张秀坤这个伟大的算法建议。这是因为您保持了类似于递归序列的序列,但是顺序相反,因为您使用的是堆栈。此外,您还集成了人工的新树节点。

static <T> void inorder(final BinaryTreeNode<T> root)
{
    final Stack<BinaryTreeNode<T>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = stack.pop();
        if (currentNode != null)
        {
            if (currentNode.isLeaf())
                System.out.print(currentNode.item + " ");
            else
            {
                stack.push(currentNode.right);
                stack.push(new BinaryTreeNode<T>(currentNode.item));
                stack.push(currentNode.left);
            }
        }
    }
    System.out.println();
}

更好的是,您可以把它变成一个通用的方法,允许所有三种遍历变化。为此,首先定义一个枚举,然后使用方法traverse()在序列中的每个适当点创建一个带有树节点的人工条目。如上所述,这些特殊的节点确保处理发生在正确的位置。

enum Order {
    PREORDER, INORDER, POSTORDER
}

static <T> void traverse(final BinaryTreeNode<T> root, final Order order)
{
    final Stack<BinaryTreeNode<T>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = stack.pop();
        if (currentNode != null)
        {
            if (currentNode.isLeaf())
                System.out.print(currentNode.item + " ");
            else
            {
                if (order == Order.POSTORDER)
                    stack.push(new BinaryTreeNode<T>(currentNode.item));

                stack.push(currentNode.right);

                if (order == Order.INORDER)
                    stack.push(new BinaryTreeNode<T>(currentNode.item));

                stack.push(currentNode.left);

                if (order == Order.PREORDER)
                     stack.push(new BinaryTreeNode<T>(currentNode.item));
            }
        }
    }
    System.out.println();
}

Hint: Insight

在这个例子的帮助下,很容易理解对一个问题的透彻思考可以导致一个更简单、更容易理解和不太复杂的源代码。此外,如果解决方案比预期的更复杂,听取第二个或第三个意见总是好的。

9.3.3 解决方案 3:树高(★★✩✩✩)

实现method int getHeight(BinaryTreeNode<T>)来确定树和以单个节点为根的子树的高度。

例子

以下高度为 4 的树用作起点:

                        E
            |-----------+-----------|
            C                       G
      |-----|                 |-----+-----|
      A                       F           H
                                          |--|
                                             I

算法树高计算使用递归算法,确定左右子树的高度。最后,您必须由此计算最大值,然后为当前级别加上值 1。

static <T> int getHeight(final BinaryTreeNode<T> node)
{
    // recursive termination
    if (node == null)
        return 0;

    // recursive descent
    final int leftHeight = getHeight(node.left);
    final int rightHeight = getHeight(node.right);

    return 1 + Math.max(leftHeight, rightHeight);
}

确认

从示例中构造树,然后计算一些选定节点的高度:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> e = createHeightExampleTree();
    TreeUtils.nicePrint(e);

    printInfo(e.left, e, e.right, e.right.right.right);
}

protected static BinaryTreeNode<String> createHeightExampleTree()
{
    final BinaryTreeNode<String> e = new BinaryTreeNode<>("E");
    TreeUtils.insert(e, "C");
    TreeUtils.insert(e, "A");
    TreeUtils.insert(e, "G");
    TreeUtils.insert(e, "F");
    TreeUtils.insert(e, "H");
    TreeUtils.insert(e, "I");
    return e;
}

private static void printInfos(final BinaryTreeNode<String> c,
                               final BinaryTreeNode<String> e,
                               final BinaryTreeNode<String> g,
                               final BinaryTreeNode<String> i)
{
    System.out.println("\nHeight of root E: " + getHeight(e));
    System.out.println("Height from leftParent C:  " + getHeight(c));
    System.out.println("Height from rightParent G: " + getHeight(g));
    System.out.println("Height from rightChild I:  " + getHeight(i));
}

出现以下输出:

                        E
            |-----------+-----------|
            C                       G
      |-----|                 |-----+-----|
      A                       F           H
                                          |--|
                                             I

Height of root E: 4
Height from leftParent C:  2
Height from rightParent G: 3
Height from rightChild I:  1

9.3.4 解决方案 4:最低共同祖先(★★★✩✩)

计算托管在任意二叉查找树中的两个节点 A 和 B 的最低共同祖先(LCA)。LCA 表示作为 A 和 B 的祖先的节点,并且位于树中尽可能深的位置(根总是 A 和 B 的祖先)。编写方法BinaryTreeNode<Integer> findLCA(BinaryTreeNode<Integer>, int, int),除了搜索的开始节点(通常是根节点)之外,还接收下限和上限,间接描述了最接近这些值的节点。如果限制的值在值的范围之外,那么就没有 LCA,它应该被返回null

例子

下面显示了一个二叉树。如果为值为 1 和 5 的节点确定了最不常见的祖先,则这是值为 4 的节点。各个节点被圈起来,并且祖先被额外地用粗体标记。

                        6
            |-----------+-----------|
                                     7
      |-----+-----|
      2            ➄
   |--+--|
    ➀      3

算法直觉上,你很想从两个节点往上走,直到路径交叉。然而,这是不可能的,只要节点中不存在到父节点的向后方向,就像这里一样。

但是有一个从根开始的简单实现。从那里,您继续如下:让currentValue是当前节点的值。另外,设value1value2为传递的节点值(即潜在后继的两个节点的值)。如果value1value2小于currentValue,那么由于二叉查找树内的排序属性,两者都必须位于左子树中,因此继续在那里搜索。如果value1value2都大于currentValue,则继续在右侧搜索。否则对于value1 < currentValue < value2value2 < currentValue < value1的情况,你已经找到了 LCA 这是当前节点。

static BinaryTreeNode<Integer> findLCA(final BinaryTreeNode<Integer> startNode,
                                       final int value1, final int value2)
{
    // recursive termination
    if (startNode == null)
        return null;

    final int currentValue = startNode.item;

    // recursive descent
    if (value1 < currentValue && value2 < currentValue)
        return lca(startNode.left, value1, value2);

    if (value1 > currentValue && value2 > currentValue)
        return lca(startNode.right, value1, value2);

    // recursive termination
    // value1 < currentValue && currentValue < value2  bzw.
    // value2 < currentValue && currentValue < value1
    return startNode;
}

确认

您构建示例中所示的树并调用您的方法:

@ParameterizedTest(name = "findLCA({0}, {1}) = {2}")
@CsvSource({ "1, 3, 2", "1, 5, 4", "2, 5, 4",
             "3, 5, 4", "1, 7, 6" })
void findLCA(int value1, int value2, int expected)
{
    var root = createLcaExampleTree();

    var result = Ex04_LowestCommonAncestor.findLCA(root, value1, value2);

    assertEquals(expected, result.item);
}

@Test
void findLCASpecial()
{
    var root = createLcaExampleTree();

    var result = Ex04_LowestCommonAncestor.findLCA(root, 1, 2);

    assertEquals(2, result.item);
}

如果您只检查非常明显的情况,一切都很好。如果您考虑检查父子关系中的两个节点,即值为 1 和 2 的节点,您将直观地期待值为 4 的节点。但是,会计算值为 2 的节点。根据定义(维基百科中的其他定义),每个节点也被视为自身的继承者。因此,在这种情况下,值为 2 的节点确实是 LCA。

为了完整起见,树的结构如下所示:

static BinaryTreeNode<Integer> createLcaExampleTree()
{
    final BinaryTreeNode<Integer> _6 = new BinaryTreeNode<>(6);
    TreeUtils.insert(_6, 7);
    TreeUtils.insert(_6, 4);
    TreeUtils.insert(_6, 5);
    TreeUtils.insert(_6, 2);
    TreeUtils.insert(_6, 1);
    TreeUtils.insert(_6, 3);

    return _6;
}

Hint: Modification for any Comparable Types

所示的算法有点复杂,但是稍加思考就很容易理解。很遗憾,我在这里仅限于具体类型。能普遍适用就更好了。

实际上,这只需要一些改变。首先,您一般地定义方法findLCA(),并要求类型满足Comparable<T>。如果这是给定的,而不是使用<和>的数字,你必须比较这里的值与compareTo(T)

static <T extends Comparable<T>> BinaryTreeNode<T>
                                 findLCA(final BinaryTreeNode<T> startNode,
                                         final T value1, final T value2)
{
    if (startNode == null)
        return null;

    final T currentValue = startNode.item;

    if (value1.compareTo(currentValue) < 0 &&
        value2.compareTo(currentValue) < 0)
    {
        return findLCA(startNode.left, value1, value2);
    }

    if (value1.compareTo(currentValue) > 0 &&
        value2.compareTo(currentValue) > 0)
    {
        return findLCA(startNode.right, value1, value2);
    }

    return startNode;
}

这可以通过向左旋转现在众所周知的示例树来容易地理解,这导致该树作为基础:

                       f6
            |-----------+-----------|
           d4                      g7
      |-----+-----|
     b2          e5
   |--+--|
  a1    c3

旋转和一些检查的实现如下:

final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
final BinaryTreeNode<String> rotatedRoot = TreeUtils.rotateLeft(root);
TreeUtils.nicePrint(rotatedRoot);

System.out.println("LCA(a, c) = " + findLCA(rotatedRoot, "a", "c")); // b2
System.out.println("LCA(a, e) = " + findLCA(rotatedRoot, "a", "e")); // d4
System.out.println("LCA(b, e) = " + findLCA(rotatedRoot, "b", "e")); // d4
System.out.println("LCA(a, g) = " + findLCA(rotatedRoot, "a", "g")); // f6

9.3.5 解决方案 5:广度优先(★★★✩✩)

在本练习中,您应该使用方法void levelorder(BinaryTreeNode<T>, Consumer<T>)实现广度优先搜索,也称为级别顺序。广度优先搜索从给定的节点(通常是根节点)开始,然后一层一层地遍历树。

Note

使用一个Queue<E>来存储待访问节点上的数据。迭代的变体比递归的稍微容易一点。

例子

对于以下两棵树,序列 1 2 3 4 5 6 7(用于左侧)和 M I C H A E L(用于右侧)将被确定为结果。

            1                         M
      |-----+-----|             |-----+-----|
      2           3             I           C
   |--+--|     |--+--|       |--+--|     |--+--|
   4     5     6     7       H     A     E     L

算法对于广度优先搜索,您使用一个队列作为稍后要处理的节点的缓存。首先,将根目录插入队列。然后,只要队列中有元素,就处理元素。这分为以下几个步骤:首先,为每个元素执行所需的操作。然后将左右后继节点放入队列中(如果这样的节点存在的话)。

static <T> void levelorder(final BinaryTreeNode<T> startNode,
                           final Consumer<T> action)
{
    if (startNode == null)
        return;

    final Queue<BinaryTreeNode<T>> toProcess = new LinkedList<>();
    toProcess.offer(startNode);

    while (!toProcess.isEmpty())
    {
        final BinaryTreeNode<T> currentNode = toProcess.poll();
        if (currentNode != null)
        {
            action.accept(currentNode.item);

            toProcess.offer(currentNode.left);
            toProcess.offer(currentNode.right);
        }
    }
}

为了尽量减少特殊处理和避免null检查,集合还可以存储null值是很有帮助的。这允许您在从堆栈中移除值时执行一次null检查,而不必在添加子节点时检查它。

除了while循环,您还可以使用递归调用来解决这个问题。如果您感兴趣,请研究配套项目中的源代码。

下面详细说一下流程。

|

长队

|

行动

|
| --- | --- |
| [1] | one |
| [3, 2] | Two |
| [5, 4, 3] | three |
| [7, 6, 5, 4] | four |
| [7, 6, 5] | five |
| [7, 6] | six |
| [7] | seven |
| [] | 目标 |

确认

构建树并调用刚刚创建的方法:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> _1 = createExampleLevelorderTree();
    TreeUtils.nicePrint(_1);

    System.out.print("\nLevelorder: ");
    levelorder(_1, str -> System.out.print(str + " "));
}

static BinaryTreeNode<String> createExampleLevelorderTree()
{
    final BinaryTreeNode<String> _1 = new BinaryTreeNode<>("1");
    final BinaryTreeNode<String> _2 = new BinaryTreeNode<>("2");
    final BinaryTreeNode<String> _3 = new BinaryTreeNode<>("3");
    final BinaryTreeNode<String> _4 = new BinaryTreeNode<>("4");
    final BinaryTreeNode<String> _5 = new BinaryTreeNode<>("5");
    final BinaryTreeNode<String> _6 = new BinaryTreeNode<>("6");
    final BinaryTreeNode<String> _7 = new BinaryTreeNode<>("7");

    _1.left = _2;
    _1.right = _3;
    _2.left = _4;
    _2.right = _5;
    _3.left = _6;
    _3.right = _7;

    return _1;
}

出现以下输出:

            1
      |-----+-----|
      2           3
   |--+--|     |--+--|
   4     5     6     7

Levelorder: 1 2 3 4 5 6 7

用单元测试验证当前值被填入一个列表,并作为一个单元测试对照期望值进行检查:

@Test
void testLevelorder()
{
    var root = Ex05_BreadthFirst.createExampleLevelorderTree();
    var expected = List.of("1", "2", "3", "4", "5", "6", "7");

    final List<String> result = new ArrayList<>();
    Ex05_BreadthFirst.levelorder(root, result::add);

    assertEquals(expected, result);
}

9.3.6 解决方案 6:级别和(★★★★✩)

在上一个练习中,您实现了广度优先搜索。现在,您想对树的每一层的值进行求和。为此,让我们假设这些值属于类型Integer。写方法Map<Integer, Integer> levelSum(BinaryTreeNode<Integer>)

例子

对于所示的树,应该计算每个级别的节点值的总和,并返回以下结果:{0=4,1=8,2=17,3=16}。

|

水平

|

价值

|

结果

|
| --- | --- | --- |
| Zero | four | four |
| one | 2, 6 | eight |
| Two | 1, 3, 5, 8 | Seventeen |
| three | 7, 9 | Sixteen |

                        4
            |-----------+-----------|
            2                       6
      |-----+-----|           |-----+-----|
      1           3           5           8
                                       |--+--|
                                       7     9

算法广度优先搜索提供了很好的基础。您仍然缺少一个合适的数据结构和一种方法来确定完成解决方案的当前级别。稍加思考,您就会想到使用地图作为结果数据结构。当前级别作为密钥。该值由下面显示的类(或者更准确地说,记录)Pair<BinaryTreeNode<Integer>, Integer>表示。您像处理等级顺序一样遍历树。为了确定等级,你作弊。由于您是从根(子树的根)开始的,所以可以假定级别为 0。每降低一个级别都会增加该值。为此,您使用来自Pair<BinaryTreeNode<Integer>, Integer>的第二个值。这样,您总是知道当前处理的节点位于哪个级别。通过位于接口Map<K,V>中的两种方法putIfAbsent()computeIfPresent(),求和可以很容易地公式化。

static Map<Integer, Integer> levelSum(final BinaryTreeNode<Integer> startNode)
{
    if (startNode == null)
        return Map.of();

    final Map<Integer, Integer> result = new TreeMap<>();

    final Queue<TreeNodeLevelPair> toProcess = new LinkedList<>();
    toProcess.offer(new TreeNodeLevelPair(startNode, 0));

    while (!toProcess.isEmpty())
    {
        final TreeNodeLevelPair current = toProcess.poll();

        final BinaryTreeNode<Integer> currentNode = current.first;
        final int nodeValue = currentNode.item;
        final int level = current.second;

        result.putIfAbsent(level, 0);
        result.computeIfPresent(level, (key, value) -> value + nodeValue);

        if (currentNode.left != null)
            toProcess.offer(new TreeNodeLevelPair(currentNode.left, level + 1));

        if (currentNode.right != null)
            toProcess.offer(new TreeNodeLevelPair(currentNode.right, level + 1));
    }

    return result;
}

作为一个辅助数据结构,您使用记录作为现代 Java 的语言特性,为自己定义了一对极小值,如下所示:

record TreeNodeLevelPair(BinaryTreeNode<Integer> first, Integer second)
{
}

具有深度优先搜索的算法有趣的是,使用深度优先搜索可以很容易地实现这一点,而不管遍历的类型。在下文中,它是用 inorder 实现的,前序和后序的变体表示为注释:

static Map<Integer, Integer>
       levelSumDepthFirst(final BinaryTreeNode<Integer> root)
{
    final Map<Integer, Integer> result = new TreeMap<>();

    traverseDepthFirst(root, 0, result);

    return result;
}

static void traverseDepthFirst(final BinaryTreeNode<Integer> currentNode,
                               final int level,
                               final Map<Integer, Integer> result)
{
    if (currentNode != null)
    {
        // PREORDER
        //result.put(level, result.getOrDefault(level, 0) + currentNode.item);
        traverseDepthFirst(currentNode.left, level + 1, result);

        // INORDER
        result.put(level, result.getOrDefault(level, 0) + currentNode.item);

        traverseDepthFirst(currentNode.right, level + 1, result);

        // POSTORDER
        //map.result(level, result.getOrDefault(level, 0) + currentNode.item);
    }
}

作为一种数据结构,您使用的映射的关键字是级别。如果该级别已经有一个条目,则添加当前节点的值。否则,通过使用getOrDefault(),您可以提供 0 的起始值。

确认

让我们像往常一样从示例中构造树,并调用您刚刚实现的方法:

public static void main(final String[] args)
{
    final BinaryTreeNode<Integer> root = createExampleLevelSumTree();
    TreeUtils.nicePrint(root);

    final Map<Integer, Integer> result = levelSum(root);
    System.out.println("\nlevelSum: " + result);
}

static BinaryTreeNode<Integer> createExampleLevelSumTree()
{
    final BinaryTreeNode<Integer> _4 = new BinaryTreeNode<>(4);
    TreeUtils.insert(_4, 2);
    TreeUtils.insert(_4, 1);
    TreeUtils.insert(_4, 3);
    TreeUtils.insert(_4, 6);
    TreeUtils.insert(_4, 5);
    TreeUtils.insert(_4, 8);
    TreeUtils.insert(_4, 7);
    TreeUtils.insert(_4, 9);
    return _4;
}

然后,您会得到以下输出:

                        4
            |-----------+-----------|
            2                       6
      |-----+-----|           |-----+-----|
      1           3           5           8
                                       |--+--|
                                       7     9
levelSum: {0=4, 1=8, 2=17, 3=16}

用单元测试进行验证这也可以简单地表述为单元测试。这里您使用集合工厂方法Map.ofEntries(),因为这减轻了键和值之间的区别:

@Test
public void testLevelSum()
{
    var root = Ex06_LevelSum.createExampleLevelSumTree();
    var expected = Map.ofEntries(Map.entry(0, 4), Map.entry(1, 8),
                                 Map.entry(2, 17), Map.entry(3, 16));

    var resultBreadthFirst = Ex06_LevelSum.levelSum(root);
    var resultDepthFirst = Ex06_LevelSum.levelSumDepthFirst(root);

    assertAll(() -> assertEquals(expected, resultBreadthFirst),
              () -> assertEquals(expected, resultDepthFirst));
}

9.3.7 解决方案 7:树木轮换(★★★✩✩)

如果只按升序或降序插入值,二叉树,尤其是二分搜索法树,可能会退化为列表。不平衡可以通过旋转树的部分来解决。编写方法BinaryTreeNode<T> rotateLeft(BinaryTreeNode<T>)BinaryTreeNode<T> rotateRight(BinaryTreeNode<T>),这两个方法将分别围绕作为参数传递的节点向左或向右旋转树。

例子

图 9-10 显示向左旋转和向右旋转,平衡起始位置在中间。

img/519691_1_En_9_Fig10_HTML.png

图 9-10

向左旋转,原稿,向右旋转

起初,你可能会被预期的,但实际上只是假设的任务的复杂性吓到。总的来说,用一个简单的例子,比如上面的例子,在心里经历这个过程是一个好主意。很快,您就会意识到涉及的节点比预期的少得多,需要的操作也比预期的少得多。为了执行相应的旋转,你实际上只需要考虑根和左或右邻居以及下一层的一个节点,如图 9-11 所示。

img/519691_1_En_9_Fig11_HTML.png

图 9-11

受影响的旋转节点

该图说明了您只需重新分配树中的两个链接即可完成旋转。为了更好地理解这一点,相应地命名相关节点。在图 9-11 中,LC 和 RC 代表左儿童和右儿童,LLC 和 LRC 代表左左儿童和左右儿童,RLC 和 RRC 代表右左儿童和右右儿童。

考虑到这些初步因素,循环的实现完全遵循图表中所示的顺序:

static <T> BinaryTreeNode<T> rotateLeft(final BinaryTreeNode<T> rootNode)
{
    if (rootNode.right == null)
        throw new IllegalStateException("can't rotate left, no valid root");

    final BinaryTreeNode<T> rc = rootNode.right;
    final BinaryTreeNode<T> rlc = rootNode.right.left;
    rootNode.right = rlc;
    rc.left = rootNode;

    return rc;
}

static <T> BinaryTreeNode<T> rotateRight(final BinaryTreeNode<T> rootNode)
{
    if (rootNode.left == null)
        throw new IllegalStateException("can't rotate right, no valid root");

    final BinaryTreeNode<T> lc = rootNode.left;
    final BinaryTreeNode<T> lrc = rootNode.left.right;
    rootNode.left = lrc;
    lc.right = rootNode;

    return lc;
}

请记住,这些方法会改变子树的引用,因此可能会影响以前缓存的节点。然后,根突然不再是根,而是位于下一层。

确认

首先,像示例中那样定义中间的树。然后你首先向左旋转它,然后向右旋转两次,这应该相当于从中间的树开始向右旋转。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    TreeUtils.nicePrint(root);

    System.out.println("\nRotate left");
    var leftRotatedRoot = rotateLeft(root);
    TreeUtils.nicePrint(leftRotatedRoot);

    System.out.println("\nRotate right");
    var rightRotatedRoot = rotateRight(rotateRight(leftRotatedRoot));
    TreeUtils.nicePrint(rightRotatedRoot);
}

首先,树显示为未旋转:

           d4
      |-----+-----|
     b2          f6
   |--+--|     |--+--|
  a1    c3    e5    g7

之后,执行旋转并产生以下输出:

Rotate left
                       f6
            |-----------+-----------|
           d4                      g7
      |-----+-----|
     b2          e5
   |--+--|
  a1    c3

Rotate right
                       b2
            |-----------+-----------|
           a1                      d4
                              |-----+-----|
                             c3          f6
                                       |--+--|
                                      e5    g7

用单元测试进行验证让我们考虑一下如何使用单元测试来测试它。同样,这取决于适当的想法和数据结构。检查生成的树在结构上的一致性是困难和昂贵的。如果将遍历的结果与预期值进行比较,就会容易得多。但是要注意。这样做时,您必须避免使用 inorder 遍历,因为它总是为任意的二叉查找树产生相同的节点顺序,而不管树的结构如何!这里,无论是前序还是后序,或者更好的是,一个级别顺序的通行证是合适的。后者有很大的优势,顺序可以很容易地从树的图形表示中导出,因此,最适合单元测试,因为这是可以理解的。您已经在第 9.1.4 节的开头将转换实现为方法convertToList()

@Test
void testRotateLeft()
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    var expected = List.of("f6", "d4", "g7", "b2", "e5", "a1", "c3");

    var leftRotatedRoot = Ex07_RotateBinaryTree.rotateLeft(root);
    final List<String> result = convertToList(leftRotatedRoot);

    assertEquals(expected, result);
}

@Test
void testRotateRight()
{
    final BinaryTreeNode<String> root = ExampleTrees.createExampleTree();
    var expected = List.of("b2", "a1", "d4", "c3", "f6", "e5", "g7");

    var rightRotatedRoot = Ex07_RotateBinaryTree.rotateRight(root);
    final List<String> result = convertToList(rightRotatedRoot);

    assertEquals(expected, result);
}

9.3.8 解决方案 8:重建(★★★✩✩)

解决方案 8a:从阵列重建(★★✩✩✩)

在本练习中,您希望从升序排序的int[]中重建一个尽可能平衡的二叉查找树。

例子

给定的int值为

final int[] values = { 1, 2, 3, 4, 5, 6, 7 };

那么下面的树应该从它们中重建:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法从一个按升序排序的数组中重构一个二叉查找树并没有那么难。由于排序的原因,您可以将数组一分为二,并使用中间的值作为新节点的基础。分别从数组的左右部分递归构造左右子树。继续对分,直到子数组的大小只有 0 或 1。

static BinaryTreeNode<Integer> reconstruct(final int[] values)
{
    // recursive termination
    if (values.length == 0)
        return null;

    final int midIdx = values.length / 2;

    final int midValue = values[midIdx];
    final BinaryTreeNode<Integer> newNode = new BinaryTreeNode<>(midValue);

    // recursive termination
    if (values.length == 1)
        return newNode;

    // recursive descent
    final int[] leftPart = Arrays.copyOfRange(values, 0, midIdx);
    final int[] rightPart = Arrays.copyOfRange(values, midIdx + 1,
                                               values.length);

    newNode.left = reconstruct(leftPart);
    newNode.right = reconstruct(rightPart);

    return newNode;
}

您可以在方法中间省略长度为 1 的查询,而不改变功能。然后,该方法将简单地为空数组调用两次,从而直接终止。对我来说,这种特殊待遇更容易理解,但这是个人喜好的问题。

确认

让我们看看您的实际实现,并提供一个任意但适当排序的int值数组。这样,您就调用了您的方法,该方法返回树的根作为结果。最后,您通过将各种信息打印到控制台来验证树确实被正确地重构了。

public static void main(final String[] args)
{
    final int[][] inputs = { { 1, 2, 3, 4, 5, 6, 7 },
                             { 1, 2, 3, 4, 5, 6, 7, 8 } };

    for (int[] values : inputs)
    {
        final BinaryTreeNode<Integer> root = reconstruct(values);
        printInfo(root);
    }
}

输出方法实现起来很简单:

private static void printInfo(final BinaryTreeNode<Integer> root)
{
    TreeUtils.nicePrint(root);

    System.out.println("Root:  " + root);
    System.out.println("Left:  " + root.left);
    System.out.println("Right: " + root.right);
    System.out.println();
}

以下简短的输出显示这两棵树被正确地重建了:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
Root:  BinaryTreeNode [item=4, left=BinaryTreeNode [item=2, ..
Left:  BinaryTreeNode [item=2, left=BinaryTreeNode [item=1, ...
Right: BinaryTreeNode [item=6, left=BinaryTreeNode [item=5, ...

                        5
            |-----------+-----------|
            3                       7
      |-----+-----|           |-----+-----|
      2           4           6           8
   |--|
   1
Root:  BinaryTreeNode [item=5, left=BinaryTreeNode [item=3, ...
Left:  BinaryTreeNode [item=3, left=BinaryTreeNode [item=2, ...
Right: BinaryTreeNode [item=7, left=BinaryTreeNode [item=6, ...

用单元测试进行验证再次使用单元测试的层次顺序遍历来验证重构:

@Test
void testReconstructFromIntArray()
{
    final int[] inputs = { 1, 2, 3, 4, 5, 6, 7 };

    var expected = List.of(4, 2, 6, 1, 3, 5, 7);

    var resultRoot = Ex08_ReconstructTree.reconstruct(inputs);
    var result = convertToList(resultRoot);

    assertEquals(expected, result);
}

9.1.4 节中已经实现了树到列表的转换,为了便于理解,这里再次显示:

private List<String> convertToList(final BinaryTreeNode<String> node)
{
    final List<String> result = new ArrayList<>();
    Ex05_BreadthFirst.levelorder(node, result::add);
    return result;
}

解决方案 8b:根据预订/未预订进行重建(★★★✩✩)

假设您有一系列的值,分别是 preorder 和 inorder,每一个都准备为一个列表。这个关于任意二叉树的信息应该被用来从其重建相应的树。写方法BinaryTreeNode<T> reconstruct(List<T>, List<T>)

例子

下面给出了两个遍历值序列。根据这些值,您应该重新构建本练习上一部分中显示的树。

var preorderValues = List.of(4, 2, 1, 3, 6, 5, 7);
var inorderValues = List.of(1, 2, 3, 4, 5, 6, 7);

这里再次显示:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法为了更好地理解对两个输入和算法的需求,让我们再看一下前序和中序遍历的值,以粗体突出显示的根的值为例:

Preorder    4 2 1 3 6 5 7
Inorder     1 2 3 4 5 6 7

前序遍历总是从根开始,所以基于第一个值,可以先创建根。通过在有序遍历的值序列中搜索根的值,可以确定如何将这些值分成左右两个子树。根的值左边的顺序中的所有内容表示左子树的值。类似地,这也适用于它右边的值和右边的子树。这会产生以下子列表:

Left:   1 2 3
Right:  5 6 7

要递归调用您的方法,您需要为 preorder 找到相应的值序列。你是怎么做到的?

让我们详细看看一个预订单和一个订单的价值。通过仔细观察,您可以看到以下模式:

$$ {\displaystyle \begin{array}{l} Preorder;\overset{root}{\overbrace{4}};\overset{left}{\overbrace{2;1;3}};\overset{right}{\overbrace{6;5;7}}\ {} Inorder\kern0.72em \underset{left}{\underbrace{1;2;3}}\kern0.24em \underset{root}{\underbrace{4}};\underset{right}{\underbrace{5;6;7}}\end{array}} $$

有了这些知识,算法可以如下实现,利用List.subList()方法从原始部分生成适当的子部分,并将它们用于递归下降:

static <T> BinaryTreeNode<T> reconstruct(final List<T> preorderValues,
                                         final List<T> inorderValues)
{
    if (preorderValues.size() != inorderValues.size())
        throw new IllegalStateException("inputs differ in length");

    // recursive termination
    if (preorderValues.size() == 0 || inorderValues.size() == 0)
        return null;

    final T rootValue = preorderValues.get(0);
    final BinaryTreeNode<T> root = new BinaryTreeNode<>(rootValue);

    // recursive termination
    if (preorderValues.size() == 1 && inorderValues.size() == 1)
        return root;

    // recursive descent
    final int index = inorderValues.indexOf(rootValue);

    // left and right part for preorder
    root.left = reconstruct(preorderValues.subList(1, index + 1),
                            inorderValues.subList(0, index));
    root.right = reconstruct(preorderValues.subList(index + 1,
                                                    preorderValues.size()),
                            inorderValues.subList(index + 1,
                                                 inorderValues.size()));

    return root;
}

同样,可以在方法中间省略长度为 1 的查询,而不改变功能。然后,该方法将简单地为空数组调用两次,从而直接终止。对我来说,这种特殊待遇更容易理解,但这是个人喜好的问题。

确认

为了跟踪重构,您在一个Stream<Arguments>中以三个嵌套列表的形式提供匹配的值序列。像往常一样,JUnit 自动从这些输入中提取 preorder 和 inorder 值。结果作为层次顺序遍历传入。我提到过,这种方式基于图形表示提供了良好的可追溯性。因此,也在控制台上打印单元测试中的树。

@ParameterizedTest(name = "reconstruct(pre={0}, in={2}) => levelorder: {2}")
@MethodSource("preInorderAndResult")
void testReconstructFromLists(List<Integer> preorderValues,
                              List<Integer> inorderValues,
                              List<Integer> expectedLevelorder)
{
    var resultRoot = Ex08_ReconstructTree2.reconstruct(preorderValues,
                                                       inorderValues);
    TreeUtils.nicePrint(resultRoot);

    var result = convertToList(resultRoot);

    assertEquals(expectedLevelorder, result);
}

private static Stream<Arguments> preInorderAndResult()
{
    return Stream.of(Arguments.of(List.of(4, 2, 1, 3, 6, 5, 7),
                                  List.of(1, 2, 3, 4, 5, 6, 7),
                                  List.of(4, 2, 6, 1, 3, 5, 7)),
                     Arguments.of(List.of(5, 4, 2, 1, 3, 7, 6, 8),
                                  List.of(1, 2, 3, 4, 5, 6, 7, 8),
                                  List.of(5, 4, 7, 2, 6, 8, 1, 3)));
}

因此,在单元测试的执行期间,相应生成的树的以下输出出现,这支持了正确的重建:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
                        5
            |-----------+-----------|
            4                       7
      |-----|                 |-----+-----|
      2                       6           8
   |--+--|
   1     3

Hint: Things to know about Reconstruction

有趣的是,使用所示的算法,任何二叉树都可以被重建,不管它是否也是二叉查找树(其节点遵循一个顺序)。但更值得注意的是:在前序遍历的值源自二叉查找树的情况下,可以仅基于此来重建它,如下所示:

static <T extends Comparable<T>>
       BinaryTreeNode<T> reconstruct(final List<T> preorderValues)
{
    // recursive termination
    if (preorderValues.isEmpty())
        return null;

    final T rootValue = preorderValues.get(0);
    final BinaryTreeNode<T> root = new BinaryTreeNode<>(rootValue);

    // filtering
    final List<T> leftValues = new ArrayList<>(preorderValues);
    leftValues.removeIf(value -> value.compareTo(rootValue) >= 0);

    final List<T> rightValues = new ArrayList<>(preorderValues);
    rightValues.removeIf(value -> value.compareTo(rootValue) <= 0);

    // recursive descent
    root.left = reconstruct(leftValues);
    root.right = reconstruct(rightValues);

    return root;
}

这是可能的,因为在二叉查找树中,先序遍历的值首先是根的值,然后是小于根的值,最后是右子树的值,它们也大于根的值。这个条件也递归适用。在两个过滤条件的帮助下,可以很容易地提取所有左右子树的值——如上所示——并用作递归调用的输入。

何不试试用下面的main()方法重建:

public static void main(final String[] args)
{
    var root1 = reconstruct(List.of(4, 2, 1, 3, 6, 5, 7));
    TreeUtils.nicePrint(root1);

    var root2 = reconstruct(List.of(5, 4, 2, 1, 3, 7, 6, 8));
    TreeUtils.nicePrint(root2);
}

然后,您将获得单元测试中已经显示的树的输出。

9.3.9 解决方案 9:数学评估(★★✩✩✩)

考虑使用一棵树来模拟带有四个运算符+、-、 / 和∑的数学表达式。您的任务是计算单个节点的值,特别是包括根节点的值。为此,编写方法int evaluate(BinaryTreeNode<String>)

例子

用下面的树表示表达式 3+7∫(71)来计算根节点的值 45:

                        +
            |-----------+-----------|
            3                       *
                              |-----+-----|
                              7           -
                                       |--+--|
                                       7     1

算法有趣的是,在 Java 14 的switch final 中,通过递归调用结合适当的操作符,可以非常容易和清晰地解决赋值问题,如下所示:

static int evaluate(final BinaryTreeNode<String> node)
{
    final String value = node.item;

    return switch (value) {
    case "+" -> evaluate(node.left) + evaluate(node.right);
    case "-" -> evaluate(node.left) - evaluate(node.right);
    case "*" -> evaluate(node.left) * evaluate(node.right);
    case "/" -> evaluate(node.left) / evaluate(node.right);
    default -> Integer.valueOf(value);
    };
}

确认

让我们从示例中构造树并调用上面的方法:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> plus = new BinaryTreeNode<>("+");
    final BinaryTreeNode<String> _3 = new BinaryTreeNode<>("3");
    final BinaryTreeNode<String> mult = new BinaryTreeNode<>("*");
    final BinaryTreeNode<String> _7 = new BinaryTreeNode<>("7");
    final BinaryTreeNode<String> minus = new BinaryTreeNode<>("-");
    final BinaryTreeNode<String> _1 = new BinaryTreeNode<>("1");

    plus.left = _3;
    plus.right = mult;
    mult.left = _7;
    mult.right = minus;
    minus.left = _7;
    minus.right = _1;

    TreeUtils.nicePrint(plus);
    System.out.println("+: " + evaluate(plus));
    System.out.println("*: " + evaluate(mult));
    System.out.println("-: " + evaluate(minus));
}

如果您执行这个main()方法,一方面您会得到树的输出以及所选择的单个节点的结果:

                        +
            |-----------+-----------|
            3                       *
                              |-----+-----|
                              7           -
                                       |--+--|
                                       7     1
+: 45
*: 42
-: 6

解决方案 10:对称性(★★✩✩✩)

检查任意二叉树的结构是否对称。因此写方法boolean isSymmetric(BinaryTreeNode<T>)。除了结构检查之外,您还可以检查值是否相等。

例子

为了检查对称性,使用一个结构对称的二叉树(左)和一个值对称的二叉树(右):

            4                            1
      |-----+-----|                    /   \
      2           6                   2     2
   |--+--|     |--+--|               /       \
   1     3     5     7              3         3

Note: The Symmetry Property

在对称二叉树中,左右子树通过根沿着假想的垂直线(由|)镜像:

     1
   / | \
  2  |  2
 /   |   \
3    |    3

根据定义,对于对称性,可以省略值的比较。在这种情况下,只有结构组织可以算作相关。

再一次,你受益于良好的递归基础知识。没有后继节点的节点总是对称的。如果一个节点只有一个后继节点,那么树就不可能是对称的。因此,只需要考虑两个后继节点的情况。因此,左和右子树必须镜像反转。为此,您递归地检查左侧的右侧子树和右侧节点的左侧子树在结构上是否匹配,以及左侧节点的右侧子树和右侧子树是否匹配:

static <T> boolean isSymmetric(final BinaryTreeNode<T> parent)
{
    if (parent == null)
        return true;

    return isSymmetric(parent.left, parent.right);
}

static <T> boolean isSymmetric(final BinaryTreeNode<T> left,
                               final BinaryTreeNode<T> right)
{
    if (left == null && right == null)
        return true;
    if (left == null || right == null)
            return false;

    // descend both subtrees
    return isSymmetric(left.right, right.left) &&
           isSymmetric(left.left, right.right);
}

高级算法:值对称事实上,如果您正确实现了前面的练习,对值检查的扩展就很简单。在递归下降之前,只有一个布尔参数checkValue必须被添加到签名中并在适当的位置被评估:

static <T> boolean isSymmetric(final BinaryTreeNode<T> left,
                               final BinaryTreeNode<T> right,
                               final boolean checkValue)
{
    if (left == null && right == null)
        return true;
    if (left == null || right == null)
            return false;

    // check the values
    if (checkValue && !left.item.equals(right.item))
        return false;

    return isSymmetric(left.right, right.left, checkValue) &&
           isSymmetric(left.left, right.right, checkValue);
}

确认

从简介中构造两棵树,并调用刚刚创建的方法。第一棵树是已知的代表。另一个是用createSymmetricNumberTree()为这个例子显式创建的。您创建了一个根,然后创建了带有值为 2 和 3 的节点的对称结构。之后,添加值为 4 的节点,这样就破坏了对称性。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createNumberTree();
    TreeUtils.nicePrint(root);
    System.out.println("symmetric: " + isSymmetric(root));

    final BinaryTreeNode<String> root2 = createSymmetricNumberTree();
    TreeUtils.nicePrint(root2);
    System.out.println("symmetric: " + isSymmetric(root2));

    // Modifizierter Baum: Füge eine 4 hinzu
    root2.right.left = new BinaryTreeNode<>("4");
    TreeUtils.nicePrint(root2);
    System.out.println("symmetric: " + isSymmetric(root2));
}

static BinaryTreeNode<String> createSymmetricNumberTree()
{
    final BinaryTreeNode<String> root = new BinaryTreeNode<>("1");
    root.left = new BinaryTreeNode<>("2");
    root.right = new BinaryTreeNode<>("2");
    root.left.left = new BinaryTreeNode<>("3");
    root.right.right = new BinaryTreeNode<>("3");
    return root;
}

如果您执行这个main()方法,您会得到预期的结果:

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
symmetric: true
            1
      |-----+-----|
      2           2
   |--|           |--|
   3                 3
symmetric: true
            1
      |-----+-----|
      2           2
   |--|        |--+--|
   3           4     3
symmetric: false

奖励:镜像树

在提示框中,我指出了通过根的镜像轴。创建方法BinaryTreeNode<T> invert(BinaryTreeNode<T>),该方法通过根在这条隐含线上镜像树的节点。

例子

镜像看起来像这样:

            4                              4
      |-----+-----|                  |-----+-----|
      2           6        =>        6           2
   |--+--|     |--+--|            |--+--|     |--+--|
   1     3     5     7            7     5     3     1

算法起初,你可能会再次假设这个挑战很难解决。但事实上,在递归的帮助下实现比人们最初想象的要容易得多。

该算法从根开始向下进行,并交换左右子树。为此,您将这些子树存储在临时变量中,然后将它们赋给另一端。真的就是这么回事!

static <T> BinaryTreeNode<T> invert(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return null;

    final BinaryTreeNode<T> invertedRight = invert(startNode.right);
    final BinaryTreeNode<T> invertedLeft = invert(startNode.left);

    startNode.left = invertedRight;
    startNode.right = invertedLeft;

    return startNode;
}

确认

您从简介中构造左边的树,并调用您刚刚创建的方法。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> root = ExampleTrees.createNumberTree();
    final BinaryTreeNode<String> newRoot = invert(root);
    TreeUtils.nicePrint(newRoot);
}

如果您执行这个main()方法,您将得到预期的镜像:

            4
      |-----+-----|
      6           2
   |--+--|     |--+--|
   7     5     3     1

9.3.11 解决方案 11:检查★★✩✩✩二叉查找树

在本练习中,您将检查任意二叉树是否满足二叉查找树的属性(即,左子树中的值小于根节点的值,而右子树中的值大于根节点的值——这适用于从根节点开始的每个子树)。为了简化,假设int值。写方法boolean isBST(BinaryTreeNode<Integer>)

例子

使用下面的二叉树,它也是二叉查找树。例如,如果你把左边的数字 1 换成一个更大的数字,它就不再是二叉查找树了。但是,6 下面的右子树仍然是二叉查找树。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法从作业中,你认识到一个递归设计。只有一个节点的树总是二叉查找树。如果有左或右后继,或者两者都有,则检查它们的值是否符合值关系,并对它们的后继递归地执行这个操作(如果它们存在的话)。

static boolean isBST(final BinaryTreeNode<Integer> node)
{
    // recursive termination
    if (node == null)
        return true;

    if (node.isLeaf())
        return true;

    // recursive descent
    boolean isLeftBST = true;
    boolean isRightBST = true;
    if (node.left != null)
        isLeftBST = node.left.item < node.item && isBST(node.left);

    if (node.right != null)
        isRightBST = node.right.item > node.item && isBST(node.right);

    return isLeftBST && isRightBST;
}

Note: Compare Other Types

为了支持泛型类型T,它必须满足Comparable<T>并且你调用compareTo(T)方法进行比较,而不是使用>和<./>

确认

从示例中构造树,并调用刚刚创建的方法。您还应用了两个修改并再次检查。

public static void main(final String[] args)
{
    final BinaryTreeNode<Integer> _4 = ExampleTrees.createIntegerNumberTree();
    TreeUtils.nicePrint(_4);

    final BinaryTreeNode<Integer> _2 = _4.left;
    final BinaryTreeNode<Integer> _6 = _4.right;

    // change the tree on the left in a wrong way
    // and on the right in a correct way
    _2.left = new BinaryTreeNode<>(13);
    _6.right = null;

    TreeUtils.nicePrint(_4);
    System.out.println("isBST(_4): " + isBST(_4));
    System.out.println("isBST(_2): " + isBST(_2));
    System.out.println("isBST(_6): " + isBST(_6));
}

如果您执行这个main()方法,您将获得树的输出和所选单个节点的结果,无论这些节点本身是否代表一个二叉查找树。

但是,如果您不小心在左子树中存储了一个更大的值(例如 13),那么整个树和以节点 2 为根的部分都不是 BST。对于右边的子树,如果删除了值为 7 的节点,那么节点值为 6 的右边的子树仍然是 BST。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7
isBST(_4): true
isBST(_2): true
isBST(_6): true

            4
      |-----+-----|
      2           6
   |--+--|     |--|
  13     3     5
isBST(_4): false
isBST(_2): false
isBST(_6): true

9.3.12 解决方案 12:完备性(★★★★)

在本练习中,要求您检查树的完整性。为了做到这一点,您首先在练习的前两个部分解决一些基本问题,然后进行更棘手的完整性检查。

解决方案 12a:节点数量(★✩✩✩✩)

计算任何二叉树中包含多少个节点。为此,编写方法int nodeCount(BinaryTreeNode<T>)

例子

对于所示的二叉树,应该确定值 7。如果删除右边的子树,树只包含 4 个节点。

            4
      |-----+-----|
      2           6
   |--+--|     |--+--|
   1     3     5     7

算法如果你递归地表达,这个算法真的非常简单。每个节点计 1。然后,继续在它的左右子树中计数,并将它们的结果相加,直到找到一片叶子:

static <T> int nodeCount(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return 0;

    return 1 + nodeCount(startNode.left) + nodeCount(startNode.right);
}

解决方案 12b:检查完整/完美(★★✩✩✩)

对于一个任意的二叉树,检查是否所有的节点都有两个后继或叶子,因此树是满的。为了完美,所有的叶子必须在同一高度。写方法boolean isFull(BinaryTreeNode<T>)boolean isPerfect(BinaryTreeNode<T>)

例子

所示的二叉树既完美又完整。如果去掉 2 下面的两片叶子,就不再完美但依然饱满。

     Full and perfect         Full but not perfect
            4                          4
      |-----+-----|              |-----+-----|
      2           6              2           6
   |--+--|     |--+--|                    |--+--|
   1     3     5     7                    5     7

如果递归实现的话,检查一棵树是否满并不困难。对于每个节点,您检查它是没有后继还是有两个后继。否则,它不能是完整的树。

static <T> boolean isFull(final BinaryTreeNode<T> currentNode)
{
    if (currentNode == null)
        return true;

    return isFull(currentNode.left, currentNode.right);
}

static <T> boolean isFull(final BinaryTreeNode<T> leftNode,
                          final BinaryTreeNode<T> rightNode)
{
    if (leftNode == null && rightNode == null)
        return true;

    if (leftNode != null && rightNode != null)
        return isFull(leftNode) && isFull(rightNode);

    return false;
}

这已经是一个好的开始。基于此,您需要一些更小的扩展来检查完美性。首先,你要确定整棵树的高度,从根部开始。在那之后,你进行与isFull()非常相似的操作,但是现在每个节点必须有两个后继节点。在叶子的水平上,你还必须检查它们是否在正确的水平上。因此,你会发现一片叶子的高度是 1。因此,您仍然需要它们所在的级别。为此,您在方法中添加了一个额外的参数currentLevel。这导致了以下实现:

static <T> boolean isPerfect(final BinaryTreeNode<T> parent)
{
    if (parent == null)
        return true;

    final int height = Ex03_TreeHeight.getHeight(parent);

    return isPerfect(parent.left, parent.right, height, 1);
}

static <T> boolean isPerfect(final BinaryTreeNode<T> leftNode,
                             final BinaryTreeNode<T> rightNode,
                             final int height, final int currentLevel)
{
    if (leftNode == null || rightNode == null)
        return false;

    if (leftNode.isLeaf() && rightNode.isLeaf())
        return onSameHeight(leftNode, rightNode, height, currentLevel);

    return isPerfect(leftNode.left, leftNode.right, height, currentLevel + 1) && isPerfect(rightNode.left,
           rightNode.right, height, currentLevel + 1);
}

static <T> boolean onSameHeight(final BinaryTreeNode<T> leftNode,
                                final BinaryTreeNode<T> rightNode,
                                final int height, final int currentLevel)
{
    return Ex03_TreeHeight.getHeight(leftNode) + currentLevel == height &&
           Ex03_TreeHeight.getHeight(rightNode) + currentLevel == height;
}

确认

您用简介中的数字构建树,并调用刚刚创建的方法。此外,通过删除对右边子树的引用来修改树。然后再次调用这些方法。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> _4 = ExampleTrees.createNumberTree();
    printInfo(_4);

    // modify the tree
    _4.left.left = null;
    _4.left.right = null;
    printInfo(_4);
}

protected static void printInfo(final BinaryTreeNode<String> root)
{
    TreeUtils.nicePrint(root);
    System.out.println("#nodes:  " + nodeCount(root));
    System.out.println("isFull?: " + isFull(root));
    System.out.println("isPerfect?: " + isPerfect(root));
    System.out.println();
}

如果您执行这个main()方法,您会得到预期的结果:

                4
          |-----+-----|
          2           6
       |--+--|     |--+--|
       1     3     5     7
#nodes:  7
isFull?: true
isPerfect?: true

                4
          |-----+-----|
          2           6
                   |--+--|
                   5     7

#nodes:  5
isFull?: true
isPerfect?: false

解决方案 12c:完整性(★★★★✩)

在该子任务中,要求您检查树是否如简介中所定义的那样完整(即,所有级别都被完全填充的二叉树,最后一级允许的例外是节点可能缺失,但只有尽可能靠右的间隙)。

例子

除了目前为止使用的完美树,下面的树根据定义也是完整的。但是,如果您从节点 H 中移除子节点,树就不再完整。

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

算法起初,这似乎是一个相当棘手的任务,在任何情况下都比之前显示的检查复杂得多。如果您再次研究定义,树应该包含成对的后继者。此外,树中必须没有间隙(即,没有左后继者缺失但有右后继者的节点)。如果树没有被完全填满,那么只有右边的叶子可能会丢失。仔细观察,可以发现您可以逐层遍历,但是只有在最后一层可能会缺少节点。

现在我想到了关卡。您在这里使用它,只需添加一些检查。对于每个节点,没有左后继节点就没有右后继节点。此外,您还要检查是否同时发现了一个缺失的节点。怎么会这样?每当您想要将一个节点的后继节点添加到队列中,但是只有一个左边或右边的后继节点时,这是可能的。这由标志missingNode表示。因此,如果已经检测到丢失的后继,那么随后处理的节点必须仅仅是叶子。

static <T> boolean levelorderIsComplete(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return false;

    final Queue<BinaryTreeNode<T>> toProcess = new LinkedList<>();
    toProcess.offer(startNode);

    // indicator that a node does not have two successors
    boolean missingNode = false;

    while (!toProcess.isEmpty())
    {
        final BinaryTreeNode<T> current = toProcess.poll();

        // only descendants on the right side
        if (current.left == null && current.right != null)
            return false;

        // if a missing node was previously detected,
        // then the next may be only a leaf
        if (missingNode && !current.isLeaf())
            return false;

        // include sub-elements, mark if not complete
        if (current.left != null)
            toProcess.offer(current.left);
        else
            missingNode = true;

        if (current.right != null)
            toProcess.offer(current.right);
        else
            missingNode = true;
    }

    // all nodes successfully tested
    return true;
}

确认

从示例中构造树,并调用刚刚创建的方法。此外,通过删除 H 节点下的叶子来修改树。然后你再检查一遍。

public static void main(final String[] args)
{
    final BinaryTreeNode<String> F = createCompletenessExampleTree();
    TreeUtils.nicePrint(F);
    System.out.println("levelorderIsComplete? " + levelorderIsComplete(F));

    // modification: remove leaves under H
    F.right.left = null;
    F.right.right = null;
    TreeUtils.nicePrint(F);
    System.out.println("levelorderIsComplete? " + levelorderIsComplete(F));
}

protected static BinaryTreeNode<String> createCompletenessExampleTree()
{
    final BinaryTreeNode<String> F = new BinaryTreeNode<>("F");
    TreeUtils.insert(F, "D");
    TreeUtils.insert(F, "H");
    TreeUtils.insert(F, "B");
    TreeUtils.insert(F, "E");
    TreeUtils.insert(F, "A");
    TreeUtils.insert(F, "C");
    TreeUtils.insert(F, "G");
    TreeUtils.insert(F, "I");
    return F;
}

如果您执行这个main()方法,您会得到预期的结果:

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C
levelorderIsComplete? true
                        F
            |-----------+-----------|
            D                       H
      |-----+-----|
      B           E
   |--+--|
   A     C
levelorderIsComplete? false

解决方案 12d:完全递归(★★★★)

在这最后一个子任务中,下面的挑战仍然需要作为特殊对待来处理:在没有额外的数据结构和纯递归的情况下解决检查。起初,这听起来几乎不可行,所以我给个提示。

Tip

逐步开发解决方案。创建一个boolean[]作为辅助数据结构,模拟某个位置是否存在节点。然后遍历树并适当标记位置。将这个实现转换成没有boolean[]的纯递归实现。

例子

和以前一样,下面的树根据定义是完整的:

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C

事实上,这项任务听起来很难管理,但这就是为什么它是一项艰巨的挑战。正如经常发生的那样,从开发一个尚未满足所有要求的特性的版本开始,并逐步完善它是值得的。你从提示中的想法开始。

想法是这样的:遍历树,对于存在的每个节点,在一个boolean[]中精确地标记。执行此操作时,请按照从左到右和从上到下的级别顺序对职位进行编号。为了确定当前节点在数组中的位置,您执行以下计算:对于位置 i ,左边的后继者具有位置I∫2+1,右边的后继者具有位置I∫2+2。 3 图 9-12 对此进行了说明。

img/519691_1_En_9_Fig12_HTML.png

图 9-12

将树映射到数组

现在,您仍然需要知道数组需要有多大。理论上最多可以包含 2 个 高度 元素。然而,对于非常深且不断扩展的树来说,许多叶子可能根本就不存在。为了优化内存,需要计算节点的数量来确定实际需要的大小。这就是练习 12a 帮助你的地方。然后使用traverseAndMark()方法遍历所有的树元素。最后,您使用allAssigned()汇总数据。

static <T> boolean isComplete(final BinaryTreeNode<T> startNode)
{
    final int nodeCount = nodeCount(startNode);

    final boolean[] nodeExists = new boolean[nodeCount];

    // now you traverse the tree from the root downwards
    traverseAndMark(startNode, nodeExists, 0);

    return allAssigned(nodeExists);
}

让我们继续遍历树并填充数组。有趣的是,你在这里使用前序、中序还是后序并不重要。唯一重要的是,位置是根据上述计算规则确定的:

static <T> void traverseAndMark(final BinaryTreeNode<T> startNode,
                                final boolean[] nodeExists, final int pos)
{
    // recursive termination
    if (startNode == null)
        return;
    if (pos >= nodeExists.length)
        return;

    // perform action
    nodeExists[pos] = true;

    // recursive descent
    traverseAndMark(startNode.left, nodeExists, pos * 2 + 1);
    traverseAndMark(startNode.right, nodeExists, pos * 2 + 2);
}

最后,你需要检查数组中是否有位置没有被true占据。在这种情况下,您发现树是不完整的。这通过以下方法确定:

private static boolean allAssigned(final boolean[] nodeExists)
{
    for (boolean exists : nodeExists)
        if (!exists)
            return false;

    return true;
}

Hint: Why is Allassigned() Implemented in such an old School Way?

如果您快速浏览一下方法allAssigned(),您可能会倾向于用下面更优雅的结构来替换它:

private static boolean allAssigned(final boolean[] nodeExists)
{
    return Arrays.stream(nodeExists).noneMatch(value -> value == false);
}

不幸的是,尽管这很好,但它无法编译,因为没有为boolean[]定义Arrays.stream()

唷,到目前为止已经做了相当多的工作,而且你需要一些技巧。从积极的方面来看,这种算法是可行的。稍后,我将展示基于这些想法,将算法转换为纯粹的递归处理。

然而,不利的一面是,根据树的大小,您需要相当多的额外内存。让我们看看如何通过使用纯递归变量来避免这种情况。

递归算法你的目标是消除数组的使用,只递归工作。因此,traverseAndMark()方法是一个很好的起点。如果不允许使用数组作为数据存储,则需要将节点数作为参数。该方法不是每次都递归填充数组,而是简单地调用自身:

public static <T> boolean isCompleteRec(final BinaryTreeNode<T> startNode)
{
    return isCompleteRec(startNode, 0, nodeCount(startNode));
}

public static <T> boolean isCompleteRec(final BinaryTreeNode<T> startNode,
                                        final int pos, final int nodeCount)
{
    if (startNode == null)
        return true;
    if (pos >= nodeCount)
        return false;

    if (!isCompleteRec(startNode.left, 2* pos + 1, nodeCount))
        return false;

    if (!isCompleteRec(startNode.right, 2* pos + 2, nodeCount))
        return false;

    return true;
}

如果没有中间步骤,递归地制定任务将会很有挑战性,至少对我来说是这样,因为如果不考虑数组,很难得到位置计算中的逻辑技巧。这几行字所达到的效果令人印象深刻。

确认

同样,构建树并在测试后修改它:

public static void main(final String[] args)
{
    final BinaryTreeNode<String> F = createCompletenessExampleTree();
    TreeUtils.nicePrint(F);
    System.out.println("isComplete? " + isComplete(F));
    System.out.println("isCompleteRec? " + isCompleteRec(F));

    // modification: remove leaves under H
    F.right.left = null;
    F.right.right = null;
    TreeUtils.nicePrint(F);
    System.out.println("isComplete? " + isComplete(F));
    System.out.println("isCompleteRec? " + isCompleteRec(F));
}

protected static BinaryTreeNode<String> createCompletenessExampleTree()
{
    final BinaryTreeNode<String> F = new BinaryTreeNode<>("F");
    TreeUtils.insert(F, "D");
    TreeUtils.insert(F, "H");
    TreeUtils.insert(F, "B");
    TreeUtils.insert(F, "E");
    TreeUtils.insert(F, "A");
    TreeUtils.insert(F, "C");
    TreeUtils.insert(F, "G");
    TreeUtils.insert(F, "I");
    return F;
}

如果您执行这个main()方法,您将得到预期的结果。此外,它们对于方法变化是一致的。

                        F
            |-----------+-----------|
            D                       H
      |-----+-----|           |-----+-----|
      B           E           G           I
   |--+--|
   A     C
isComplete? true
isCompleteRec? true
                        F
            |-----------+-----------|
            D                       H
      |-----+-----|
      B           E
   |--+--|
   A     C
isComplete? false
isCompleteRec? false

9.3.13 解决方案 13:树打印机

在本练习中,您将实现一个二叉树的图形输出,正如您在前面的示例中看到的那样。

因此,你首先要解决作业前三部分的基础问题,然后再进行更复杂的树的图形展示。

Tip

使用宽度为 3 的块的固定网格。这大大有助于平衡表示并降低复杂性。

例子

下面的树应该涵盖各种特殊情况:

                            F
                |-----------+-----------|
                D                       H
          |-----+                       +-----|
          B                                   I
       |--+--|
       A     C

解答 13a:子树的宽度(★★✩✩✩)

在练习的这一部分中,要求您使用方法int subtreeWidth(int)找出给定高度的子树的最大宽度。为简单起见,假设一个节点最多由三个字符表示。而且,它们之间至少有三个字符的距离。树满了叶子也是如此。在更高的层次上,两个子树的节点之间自然有更多的空间。

例子

在左边,你看到一棵高度为 2 的树,在右边,一棵高度为 3 的树。基于三个网格,宽度为 9 和 21。参见图 9-13

|

高度

|

总宽度

|

子树宽度

|
| --- | --- | --- |
| one | three | 0(不存在子树) |
| Two | nine | three |
| three | Twenty-one | nine |
| four | Forty-five | Twenty-one |

img/519691_1_En_9_Fig13_HTML.png

图 9-13

树宽

算法在图中,你认识到一棵二叉树的最低层最多可以包含 2 个 n 个 节点,以 n 作为树的高度。为了不超出范围,您希望忽略节点的可变宽度。要确定高度的最大宽度,总宽度结果如下:

$$ maxNumOfLeaves\ast leafWidth+\left( maxNumOfLeaves-1\right)\ast spacing $$

这是以下实现的基础。也许最后的计算有点棘手。您必须减去间距并除以 2,因为您只想确定子树的最大宽度:

static int subtreeWidth(final int height)
{
    if (height <= 0)
        return 0;

    final int leafWidth = 3;
    final int spacing = 3;

    final int maxNumOfLeaves = (int) Math.pow(2, height - 1);
    final int widthOfTree = maxNumOfLeaves * leafWidth +
                            (maxNumOfLeaves - 1) * spacing;
    final int widthOfSubtree = (widthOfTree - spacing) / 2;

    return widthOfSubtree;
}

解决方案 13b:绘制节点(★★✩✩✩)

编写方法String drawNode(BinaryTreeNode<T>, int),创建一个节点的图形输出,适当地生成给定的一组空间。节点值最多应该有三个数字,并放在中间。

Tip

请记住,如果当前节点有一个左后继节点,则下面层的表示从左边开始,以字符串'|-'开始。

例子

图 9-14 显示了间距为 5 个字符的单个节点。此外,节点值在三个字符的框中居中对齐。

img/519691_1_En_9_Fig14_HTML.png

图 9-14

绘制节点时的尺寸

算法像往常一样,通过将一项任务细分成几个更小的子任务来降低复杂性是个好主意。使用方法spacing(int)在节点表示的左边和右边创建所需的间距。它的准备首先检查节点中不存在或没有值的特殊情况。那么这在图形上对应于三个字符的自由空间。否则,如果转换为字符串的值少于三个字符,则用空格填充该值。如果它更长,您可以将文本截断为三个字符。这在方法String stringifyNodeValue(BinaryTreeNode<T>)中完成。因为随后的行以文本'|-'开始,如果存在左后继,那么在字符串表示的前面再添加三个空格。

static <T> String drawNode(final BinaryTreeNode<T> currentNode,
                           final int lineLength)
{
    String strNode = "   ";
    strNode += spacing(lineLength);
    strNode += stringifyNodeValue(currentNode);
    strNode += spacing(lineLength);

    return strNode;
}

static <T> String stringifyNodeValue(final BinaryTreeNode<T> node)
{
    if (node == null || node.item == null)
        return "   ";

    final String nodeValue = "" + node.item;
    if (nodeValue.length() == 1)
        return " " + nodeValue + " ";
    if (nodeValue.length() == 2)
        return nodeValue + " ";

    return nodeValue.substring(0, 3);
}

static String spacing(final int lineLength)
{
    return " ".repeat(lineLength);
}

解决方案 13c:绘制连接线(★★✩✩✩)

编写方法String drawConnections(BinaryTreeNode<T>, int)构建一个节点到它的两个后继节点的连接线的图形输出。必须正确处理缺失的继任者。

Tip

行长度指的是节点表示之间的字符。在每种情况下,代表末端的部分以及中间的连接器仍需适当附加。

例子

此图显示了绘图中所有相关的情况,因此没有、有一个和两个后继者:

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C

示意图如图 9-15 所示。

img/519691_1_En_9_Fig15_HTML.png

图 9-15

连接线的示意图

算法在一个节点下面画连接线的时候,有无左右后继的三个变体都要覆盖。更有趣的是,一个不存在的节点也必须产生相应的空白输出。如果左侧没有孩子,这是必需的。否则,右侧的节点将无法正确缩进。

你把画分成三部分。首先,用drawLeftConnectionPart()准备输出的左边部分。之后,在drawJunction(node)中,你创建了所有特殊情况的连接点。最后,你和drawRightConnectionPart()一起准备正确的部分。

static <T> String drawConnections(final BinaryTreeNode<T> currentNode,
                                  final int lineLength)
{
    if (currentNode == null)
       return " " + spacing(lineLength) + " " + spacing(lineLength) + " ";

    String connection = drawLeftConnectionPart(currentNode, lineLength);
    connection += drawJunction(currentNode);
    connection += drawRightConnectionPart(currentNode, lineLength);
    return connection;
}

static <T> String drawLeftConnectionPart(final BinaryTreeNode<T> currentNode,
                                         final int lineLength)
{
    if (currentNode.left == null)
         return "   " + spacing(lineLength);

    return " |-" + drawLine(lineLength);
}

static <T> String drawJunction(final BinaryTreeNode<T> currentNode)
{
    if(currentNode.left == null && currentNode.right == null)
        return "   ";
    else if (currentNode.left == null)
        return " +-";
    else if (currentNode.right == null)
        return "-+ ";

    return "-+-";
}

static <T> String drawRightConnectionPart(final BinaryTreeNode<T> currentNode,
                                         final int lineLength)
{
    if (currentNode.right == null)
         return spacing(lineLength) + "   ";

    return drawLine(lineLength) + "-| ";
}

static String drawLine(int lineLength)
{
    return "-".repeat(lineLength);
}

解 13d:树表示(★★★★)

组合练习各部分的所有解决方案,并完成必要的步骤,以便能够在控制台上适当地打印任意二叉树。为此,编写一个方法nicePrint(BinaryTreeNode<T>)

例子

通过nicePrint(),介绍性示例中显示的树的输出也应该类似如下:

                            F
                |-----------+-----------|
                D                       H
          |-----+                       +-----|
          B                                   I
       |--+--|
       A     C

另外,用源代码中可以找到的一棵真正的树来检查你的算法。这是一个瘦了很多的代表:

                                     BIG
              |-----------------------+-----------------------|
             b2                                              f6
    |---------+-----------|                       |-----------+-----------|
   a1                    d4                      d4                      g7
                    |-----+-----|           |-----+-----|
                   c3          f6          b2          e5
                             |--+--|     |--+--|
                            e5    g7    a1    c3

算法在之前的任务中,你学习了如何将二叉树映射到数组。这里,这必须稍微修改,因为在树中,与完整性相反,节点可以在任意位置丢失。为了计算数组的大小,您需要树的高度。这对于计算相应的距离和线长度也很重要。在这种情况下,这个技巧还有助于确定子树的最大宽度,并恰当地使用它。

可以采用前面提到的这些想法来创建一个合适的数组,其中的节点以分散的方式存储。以下方法将帮助您做到这一点:

static <T> List<BinaryTreeNode<T>>
           fillNodeArray(final BinaryTreeNode<T> startNode)
{
    final int height = Ex03_TreeHeight.getHeight(startNode);
    final int maxNodeCount = (int) Math.pow(2, height);

    final List<BinaryTreeNode<T>> nodes =
          new ArrayList<>(Collections.nCopies(maxNodeCount, null));

    traverseAndMark(startNode, nodes, 0);

    return nodes;
}

static <T> void traverseAndMark(final BinaryTreeNode<T> startNode,
                                final List<BinaryTreeNode<T>> nodes,
                                final int pos)
{
    // recursive termination
    if (startNode == null)
        return;
    if (pos >= nodes.size())
        return;

    // perform action
    nodes.set(pos, startNode);

    // recursive descent
    traverseAndMark(startNode.left, nodes, pos * 2 + 1);
    traverseAndMark(startNode.right, nodes, pos * 2 + 2);
}

对于绘图,逐层遍历树和数组,并准备图形表示。然而,这样做的缺点是,非常大的树在绘制时也需要相当多的额外内存,因为它们是作为数组或列表保存的。

仍有一些挑战等待着你:

  • 当您从顶部开始绘制时,您需要将之前为每个新级别准备的线条向右移动适当的位置。

  • 节点之间的距离和连接线的长度必须根据总高度、当前标高和位置进行计算和保存。因此,最低级别仍然需要特殊处理。

图 9-16 显示了网格和每层节点之间以及从一层到下一层的不同距离。

img/519691_1_En_9_Fig16_HTML.png

图 9-16

节点间距

相关的实现受益于助手方法的使用:

static <T> void nicePrintV1(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return;

    final int treeHeight = Ex03_TreeHeight.getHeight(startNode);
    final List<BinaryTreeNode<T>> allNodes = fillNodeArray(startNode);

    int offset = 0;
    final List<String> lines = new ArrayList<>();
    for (int level = 0; level < treeHeight; level++)
    {
        final int lineLength = subtreeWidth(treeHeight - 1 - level);

        // indent predecessor lines to the right
        for (int i = 0; i < lines.size(); i++)
        {
            lines.set(i, "   " + spacing(lineLength) + lines.get(i));
        }

        final int nodesPerLevel = (int) Math.pow(2, level);
        String nodeLine = "";
        String connectionLine = "";

        for (int pos = 0; pos < nodesPerLevel; pos++)
        {
            final BinaryTreeNode<T> currentNode = allNodes.get(offset + pos);

            nodeLine += drawNode(currentNode, lineLength);
            nodeLine += spacingBetweenNodes(treeHeight, level);
            connectionLine += drawConnections(currentNode, lineLength);
            connectionLine += spacingBetweenConnections(treeHeight, level);
        }

       lines.add(nodeLine.stripTrailing());
       lines.add(connectionLine.stripTrailing());

        // jump in the array further
        offset += nodesPerLevel;
    }

    lines.forEach(System.out::println);
}

此外,您还需要两种方法来分别提供节点之间的距离和连接线的距离:

static String spacingBetweenNodes(final int treeHeight, final int level)
{
    final int spacingLength = subtreeWidth(treeHeight - level);
    String spacing = " ".repeat(spacingLength);
    if (spacingLength > 0)
        spacing += "   ";
    return spacing;
}

static String spacingBetweenConnections(final int treeHeight, final int level)
{
    final int spacingLength = subtreeWidth(treeHeight - level);
    return " ".repeat(spacingLength);
}

内存优化算法:下面,我想提出一个修改,它不需要任何额外的内存。相反,它用层次顺序遍历来呈现树的图形表示。这里使用的是单行列表,其中包含节点和连接线的列表交替出现。在我看来,之前展示的版本在某种程度上更加清晰,尤其是因为下面的版本需要改变级别的特殊处理,这在第一个版本中表现得更加自然。

然而,总的来说,它仍然是一个清晰的层次顺序遍历,在这种情况下,它的动作更广泛一些。

static <T> void nicePrint(final BinaryTreeNode<T> startNode)
{
    if (startNode == null)
        return;

    final int treeHeight = Ex03_TreeHeight.getHeight(startNode);
    final List<String> lines = new ArrayList<>();

    int level = 0;
    String nodeLine = "";
    String connectionLine = "";

    final Queue<Pair<BinaryTreeNode<T>, Integer>> toProcess = new LinkedList<>(); toProcess.offer(new Pair<>(startNode, 0));

    while (!toProcess.isEmpty() && level < treeHeight)
    {
        // levelorder
        final Pair<BinaryTreeNode<T>, Integer> current = toProcess.poll();
        final BinaryTreeNode<T> currentNode = current.first;
        final int nodelevel = current.second;

        // perform action
        int lineLength = subtreeWidth(treeHeight - 1 - level);

        // Wechsel in der Ebene
        if (level != nodelevel)
        {
            level = nodelevel;
            lineLength = subtreeWidth(treeHeight - 1 - level);

            lines.add(nodeLine.stripTrailing());
            lines.add(connectionLine.stripTrailing());
            nodeLine = "";
            connectionLine = "";

            // indent predecessor lines to the right
            for (int i = 0; i < lines.size(); i++)
            {
                lines.set(i, "   " + spacing(lineLength) + lines.get(i));
            }
        }

        nodeLine += drawNode(currentNode, lineLength);
        nodeLine += spacingBetweenNodes(treeHeight, level);
        connectionLine += drawConnections(currentNode, lineLength);
        connectionLine += spacingBetweenConnections(treeHeight, level);

        // levelorder
        if (currentNode != null)
        {
            toProcess.offer(new Pair<>(currentNode.left, level + 1));
            toProcess.offer(new Pair<>(currentNode.right, level + 1));
        }
        else
        {
            // artificial placeholders for correct layout
            toProcess.offer(new Pair<>(null, level + 1));
            toProcess.offer(new Pair<>(null, level + 1));
        }
    }

    lines.forEach(System.out::println);
}

省略辅助数据结构导致更复杂的实现。有必要添加人工的null-节点作为占位符,以便在缺少左侧节点的情况下正确绘制树。这将导致层次顺序遍历不再终止,因为总是在添加新的节点。为了防止这种情况,不仅要查询队列,还要查询当前级别。

确认

你成长了不少。现在你想看看你的劳动成果。为此,您可以使用介绍性示例中的树。第一棵树很好地展示了工作的主要方式。第二个是前面的示例树的组合,但是向左和向右旋转,并合并在一个新的根下,值为BIG

protected static BinaryTreeNode<String> createTreePrintExampleTree()
{
    final BinaryTreeNode<String> F = new BinaryTreeNode<>("F");
    TreeUtils.insert(F, "D");
    TreeUtils.insert(F, "H");
    TreeUtils.insert(F, "B");
    TreeUtils.insert(F, "A");
    TreeUtils.insert(F, "C");
    TreeUtils.insert(F, "I");
    return F;
}

protected static BinaryTreeNode<String> createBigTree()
{
    var d4a = ExampleTrees.createExampleTree();
    var d4b = ExampleTrees.createExampleTree();
    var BIG = new BinaryTreeNode<>("BIG");
    BIG.left = Ex07_RotateBinaryTree.rotateRight(d4a);
    BIG.right = Ex07_RotateBinaryTree.rotateLeft(d4b);
    return BIG;
}

这些方法创建以下树:

                         F
             |-----------+-----------|
             D                       H
       |-----+                       +-----|
       B                                   I
    |--+--|
    A     C
                                    BIG
             |-----------------------+-------------------|
            b2                                          f6
     |-------+---------|                       |---------+---------|
    a1                d4                      d4                   g7
                 |-----+-----|           |-----+-----|
                 c3          f6          b2          e5
                          |--+--|     |--+--|
                          e5    g7    a1    c3

如果您想看看真正的大树呈现得有多美,请为以下构造调用方法:

protected static BinaryTreeNode<String> createMonsterTree()
{
    final var mon = new BinaryTreeNode<>("MON");
    mon.left = createBigTree();
    mon.right = createBigTree();
    return mon;
}

在配套项目中,你会发现这个怪物树的双重组合,为了好玩我把它命名为金刚

十、搜索和排序

在算法和数据结构领域,搜索和排序是计算机科学的两个基本主题。集合框架为这两者提供了高效的实现,从而减轻了您的负担。然而,理解底层算法有助于为特定用例选择最合适的变体。我在这里只是略读了一下搜索的主题,所以请参阅我的书Der Weg zum Java-ProfiInd20a】以获得更详细的处理。

在这一章中,你主要致力于一些基本的排序算法,因为你可以同时学习一些算法技巧。

10.1 简介搜索

在管理数据时,有时您需要搜索项目,例如名字为 Carsten 的客户或具有特定订单日期的发票。

10.1.1 在集合和数组中搜索

方便的是,所有容器类都有搜索元素和检查元素是否在容器中的方法。

使用 contains()搜索

如果容器类是通过通用类型Collection<E>访问的,那么可以通过调用方法contains(Object)来确定所需的元素是否存在。另外,containsAll(Collection<?>)可以用来检查一组元素是否被包含。内部实现遍历集合的元素,并根据equals(Object)检查每个元素与给定元素的相等性。对于地图,有相应的方法containsKey(Object)containsValue(Object)

Hint: Signature of Contains()

还有一个问题:为什么contains()不使用泛型类型,而使用Object?根据方法契约,使用equals(Object)将任何类型的输入对象与集合元素进行比较。由于equals(Object),的签名,具体类型只是通过实现及其内部类型检查间接重要。

使用 indexOf()和 lastIndexOf()进行搜索

对于列表,除了contains(Object)之外,还有方法indexOf(Object)lastIndexOf(Object)来确定所需元素的位置。第一种方法从列表的开头开始搜索,第二种方法从列表的结尾开始搜索。这样,可以确定第一次或最后一次出现(如果有的话)。如果没有找到元素,返回值为-1。由equals(Object)再次检查是否相等。

自己编程在数组中搜索

正如您刚才看到的,接口Collection<E>List<E>提供了各种类型的搜索,比如通过被包含或者通过索引。不幸的是,在辅助类java.util.Arrays中没有类似的东西。然而,自己为数组实现这一功能是相当容易的。因为主题上合适,我也为在第 [5 章中开发的ArrayUtils类中的数组包含了下面的方法。

基于索引的搜索的实现或者返回所需元素的位置,或者为未找到的返回值-1。您示例性地实现了对类型int[]的搜索。搜索的实现如下(一次从数组的开头开始,一次从数组的结尾开始):

static int indexOf(final int[] values, final int searchFor)
{
    for (int pos = 0; pos < values.length; pos++)
    {
        if (values[pos] == searchFor)
            return pos;
    }
    return -1;
}

static int lastIndexOf(final int[] values, final int searchFor)
{
    for (int pos = values.length - 1; pos >= 0; pos--)
    {
        if (values[pos] == searchFor)
            return pos;
    }
    return -1;
}

依靠前面展示的基于索引的搜索,您可以非常容易地为类型int[]创建方法contains(int[], int)(同样类似于为Collection<E>创建的方法):

static boolean contains(final int[] values, final int searchFor)
{
    return indexOf(values, searchFor) != -1;
}

10.1.2 带二进制搜索的二分搜索法()

除了刚刚提到的搜索方法(迭代地查看数据结构的所有元素,直到它们找到它们所寻找的内容)之外,在 JDK 中为数据结构数组和数据结构提供了一种有效的搜索,即所谓的二分搜索法这需要排序的数据,理想情况下通过选择适当的数据结构来提供,比如总是保持元素排序的TreeSet<E>。如果您必须首先显式地对数据进行排序,那么相对于线性搜索的优势就很难体现出来,尤其是对于小数据集。

然而,对于更大的数据量,二分搜索法的对数运行时间明显优于线性搜索。低运行时间是通过算法在每种情况下将待处理的区域分成两半,然后在适当的块中继续搜索来实现的。所描述的二分搜索法过程在 JDK 中由实用程序类ArraysCollections中的重载方法binarySearch()实现。图 10-1 显示了原理程序,废弃的零件用灰色标记。

img/519691_1_En_10_Fig1_HTML.png

图 10-1

二分搜索法的流程示意图

在图中,箭头指向第一步中的元素之间。根据binarySearch()的实现,如果数字是偶数,则使用与中心直接相邻的左侧或右侧元素进行比较。

10.2 简介排序

在这一节中,我将介绍一些排序算法,它们是后面练习的基础。

插入排序

在纸牌游戏中,通过对你手中的一副牌进行排序,可以最好地说明插入排序。通常你从左边开始,把下一张卡片拿到右边,把它适当地插入已经排序的左边部分,这通常会导致一些卡片移到右边。在此过程中,您可以跳过第一张卡片,因为它是单独排序的,并从第二张卡片开始。我们来看看这个数列 4,2,7,9,1。为此,标记要排序的相应新元素。左边已经排序的部分用||与右边未排序的部分隔开:

4 || ② 7 9 1
2 4 || ⑦ 9 1
2 4 7 || ⑨ 1
2 4 7 9 || ①
1 2 4 7 9

在本例中,您从值 2 开始。对于每个数字,您必须确定正确的插入位置。有两种方法可以做到这一点,如下所述。

确定插入位置

从当前位置开始,只要比较的值较大,就向左移动。或者,只要比较的值较小,您也可以从头开始并向右移动一个位置。

static int findInsertPosFromCurrent(final int[] numbers, final int currentPos)
{
    int insertPos = currentPos;
    while (insertPos > 0 && numbers[insertPos - 1] > numbers[currentPos])
        insertPos--;

    return insertPos;
}

static int findInsertPosFromStart(final int[] numbers, final int currentPos)
{
    int insertPos = 0;
    while (insertPos < currentPos &&  numbers[insertPos] < numbers[currentPos])
        insertPos++;

    return insertPos;
}

Hint: Stable Sorting

当对相同值的元素进行排序时,保持它们在集合中的原始顺序称为稳定排序。这通常是一种更好的行为,因为它可以防止与元素相关联的数据出现混乱。

**例如,findInsertPosFromCurrent(int[], int)产生了稳定的排序,但是第二个没有。但是,如果您用< = there 替换<,得到的排序算法也会变得稳定:

while (insertPos < currentPos && numbers[insertPos] <= numbers[currentPos])

这是因为最近找到的相同值的元素总是放在所有相同值的元素之后。

插入排序的实现

在确定值的正确插入位置后,所有值(直到当前考虑的值)都必须向右移动一个位置。最后,在确定的位置插入值。

static void insertionSort(final int[] numbers)
{
    for (int currentPos = 1; currentPos < numbers.length; currentPos++)
    {
        final int currentVal = numbers[currentPos];
        final int insertPos = findInsertPosFromCurrent(numbers, currentPos);

        moveRight(numbers, currentPos, insertPos);

        numbers[insertPos] = currentVal;
    }
}

static void moveRight(final int[] numbers,
                      final int currentPos, final int insertPos)
{
    int movePos = currentPos;
    while (movePos > insertPos)
    {
        numbers[movePos] = numbers[movePos - 1];
        movePos--;
    }
}

代码展示了一个易于理解的实现,它关注的是可理解性而不是速度。事实上,将一些动作巧妙地结合起来,从而避免多次运行是可能的。稍后,在练习 4 中,您将处理这种优化。

选择排序

选择排序是另一种直观的排序方法。它提供了两种变体,一种基于最小值,另一种基于最大值。在最小版本中,要排序的数组是从前到后遍历的。在每一步中,从仍未排序的部分中确定最小值。通过将它与当前元素交换来向前移动。这导致已排序区域从前面开始增长,剩余的未排序部分收缩。对于基于最大值的版本,要排序的数据是从后向前处理的。相应的最大值放在末尾,以便排序后的区域从后面开始增长。

为了获得更好的理解,让我们为一小组值重现这个。为此,特别标记了相应的电流最小值或最大值。已排序的部分与未排序的部分用||隔开。您可以很容易地观察到排序后的部分是如何增长的。

     MIN                              MAX
     ->                                <-
     || 4    2  7   9   ①     4   2  7  ⑨   1  ||
1:   1  || ②   7   9    4    4   2  ⑦   1  || 9
2:   1  2  ||   7   9   ④    ④   2  1   || 7  9
3:   1  2    4  ||  9   ⑦   1   ② ||   4  7  9
4:   1  2    4    7  || 9    1  || 2    4  7  9

关于最小值的版本的实现如下:

static void selectionSortMin(final int[] values)
{
    for (int i = 0; i < values.length - 1; i++)
    {
        int minIdx = i;

        // find minimum
        for (int j = i + 1; j < values.length; j++)
        {
            if (values[j] < values[minIdx])
                minIdx = j;
        }

        // swap current value with minimum
        int tmp = values[minIdx];
        values[minIdx] = values[i];
        values[i] = tmp;
    }
}

如果只看这种低级别的算法,通常很难理解和领悟。当然,框架中使用的最终算法必须尽可能优化。这需要用 O 符号进行估计。这在低层次上比在高层次上更容易执行,因为这样就必须考虑所有的构造,包括调用的方法。但要学习入门,先全面编程,再进一步优化就合适多了。

Opinion: Start with Comprehensibility

如何在更高的抽象层次上描述选择排序?为此,我依赖于我们在相应章节的介绍中为数组创建的一些辅助方法,例如用于交换元素的方法swap(),以及用于查找最小元素位置的方法findMinPos(),这是作为第 5.3.12 节练习 12 的解决方案创建的。通过使用这些方法,实际的过程变得几乎立即明显。您从头开始遍历数组,在每种情况下,找到剩余部分的最小值,并将其与当前位置的值交换:

static void selectionSortMinReadable(final int[] values)
{
    for (int curIdx = 0; curIdx < values.length - 1; curIdx++)
    {
        final int minIdx = findMinPos(values, curIdx, values.length);

        swap(values, minIdx, curIdx);
    }
}

为了使 JShell 中的实验更容易,这里再次显示了在ArrayUtils类中定义的两个助手方法:

static int findMinPos(int[] values, int startPos, int endPos)
{
    int minPos = startPos;
    for (int i = startPos + 1; i < endPos; i++)
    {
        if (values[i] < values[minPos])
            minPos = i;
    }
    return minPos;
}

static void swap(final int[] values, final int pos1, final int pos2)
{
    final int temp = values[pos1];
    values[pos1] = values[pos2];
    values[pos2] = temp;
}

让我们在 JShell 中尝试一下:

jshell> int[] values = { 4, 2, 7, 9, 1 };

jshell> selectionSortMinReadable(values)

jshell> values
values ==> int[5] { 1, 2, 4, 7, 9 }

合并排序

合并排序基于分治法。它对待排序的数组递归地分割成越来越小的子数组,大约是原始大小的一半,直到它们只包含一个元素或者可能没有元素。之后,子阵列被再次组合。在这个合并步骤中,通过基于相应值的适当合并来完成排序。该过程如图 10-2 所示。

img/519691_1_En_10_Fig2_HTML.png

图 10-2

合并排序过程

只要允许你创建新的数组,分裂算法可以递归实现,也很容易理解,虽然效率也有点低。方法merge(int[], int[])的实现已经在第 5.3.11 节中作为练习 11 的解决方案提出。它用在这里:

static int[] mergesort(final int[] toSort)
{
    // recursive termination: length 0 (only if initially empty array) or 1
    if (toSort.length <= 1)
        return toSort;

    // recursive descent: divide into two halves
    final int midPos = toSort.length / 2;
    final int[] left = Arrays.copyOfRange(toSort, 0, midPos);
    final int[] resultLeft = mergesort(left);

    final int[] right = Arrays.copyOfRange(toSort, midPos, toSort.length);
    final int[] resultRight = mergesort(right);

    // combine the partial results into larger sorted array
    return merge(resultLeft, resultRight);
}

Hint: Analogy from Real Life Leads to Optimization

对一副卡片进行排序的类比也适用于合并排序。如果你需要对一大堆卡片进行排序,你可以把它分成许多小得多的堆,分别排序,然后依次合并。然而,与其将堆减少到一张卡片,不如使用另一种方法(通常是插入排序)对较小的堆进行排序,这是一个好主意,对于小的、理想的有序阵列,插入排序的运行时间为 O ( n )。这对于微调很有用。巧妙的是,合并排序使这变得非常简单:

static int[] mergesortWithInsertionsort(final int[] toSort)
{
    // recursive termination including mini-optimization
    if (toSort.length < 5)
    {
        InsertionSortExample.insertionSort(toSort);
        return toSort;
    }

    // recursive descent: divide into two halves
    final int midPos = toSort.length / 2;
    final int[] left = Arrays.copyOfRange(toSort, 0, midPos);
    final int[] resultLeft = mergesortWithInsertionsort(left);

    final int[] right = Arrays.copyOfRange(toSort, midPos, toSort.length);
    final int[] resultRight = mergesortWithInsertionsort(right);

    // combine the partial results into larger sorted array
    return merge(resultLeft, resultRight);
}

最后,我想指出的是,切换到插入排序的限制在这里被任意设置为值 5。据推测,10 到 20 个元素之间的值是非常实用的。然而,如果您依赖于算法专业人员的知识,这将是最好的,这些算法专业人员为运行时间创建数学上合理的估计。

快速排序

就像合并排序一样,快速排序基于分治法,将待排序的数组分割成越来越小的子数组。选择一个特殊元素(称为枢轴)来确定分组或处理。为了简单起见,您可以选择要排序的子数组的第一个元素作为 pivot 元素,但是也可以考虑其他方法。在快速排序中,排序是基于此 pivot 元素完成的,方法是根据元素值将部件的所有元素排列在 pivot 的左侧(小于或等于)或右侧(大于)。这样,枢轴元素被放置在正确的位置。对左右部分递归地重复整个过程,直到这些部分只包含一个元素。其过程如图 10-3 所示。

img/519691_1_En_10_Fig3_HTML.png

图 10-3

快速排序的过程

让我们从列表的实现开始,因为它更容易访问和理解。因此,将列表的内容分解成更小和更大的元素很容易实现。稍后,组合递归计算的结果也很简单。整个实现都是有意的。不是为了速度优化,而是为了可理解性。

对于分区,您将小于、等于或大于 pivot 元素值的所有元素收集到一个单独的结果列表中。为了实现这一点,您使用 Stream API 和下面的技巧:您将原始列表转换成一个Stream<Integer>,跳过第一个元素,因为它是 pivot 元素,然后应用适当的Predicate<Integer> s:

static List<Integer> quicksort(final List<Integer> values)
{
    // recursive termination
    if (values.size() <= 1)
        return values;

    // pick up less than or equal to / greater than pivot
    final Integer pivot = values.get(0);
    final List<Integer> belowOrEquals = collectAll(values.stream().skip(1),
                                                   cur -> cur <= pivot);
    final List<Integer> aboves = collectAll(values.stream().skip(1),
                                            cur -> cur > pivot);

    // recursive descent
    final List<Integer> sortedLowersPart = quicksort(belowOrEquals);
    final List<Integer> sortedUppersPart = quicksort(aboves);

    // assemble

    final List<Integer> result = new ArrayList<>();
    result.addAll(sortedLowersPart); result.add(pivot);
    result.addAll(sortedUppersPart);

    return result;
}

最后,收集元素并应用Predicate<Integer> s 由辅助方法collectAll()实现,使用来自流 API 的filter()collect(),如下所示:

static List<Integer> collectAll(final Stream<Integer> values,
                                final Predicate<Integer> condition)
{
    return values.filter(condition).collect(Collectors.toList());
}

对于列表来说,如果没有针对性能进行优化,这一切都是非常直观的。如果您想要就地实现数组的分区(即,直接在原始数组本身中),这将变得相当困难。你可以稍后在解决练习 6 时自己看到这一点。现在,您将了解基本程序。

数组的就地实现

基本算法可以如下实现,尽管如前所述,分区的实现将是一个练习:

static void quicksort(final int values[])
{
    quicksort(values, 0, values.length - 1);
}

public static void quicksort(final int values[], final int begin, final int end)
{
    // recursive termination

    if (begin >= end)
        return;

    final int partitionIndex = partition(values, begin, end);

    // recursive descent
    quicksort(values, begin, partitionIndex - 1);
    quicksort(values, partitionIndex + 1, end);
}

Hint: Avoiding Side Effects by Copying

如果原始数组应该保持不变,可以先创建它的副本,然后调用 inplace 方法:

static int[] quicksortWithCopy(final int[] values)
{
    final int[] copy = Arrays.copyOf(values, values.length);

    quicksort(copy);

    return copy;
}

桶排序

桶排序是一种有趣的排序方法,其算法仅在下面概述,因为实现是练习 7 的主题。

存储桶排序是对数据进行排序的两步过程。首先,将值收集在特殊的容器中(称为)。然后,这些值被适当地转移到一个排序数组中。为了使算法可行,要排序的元素必须有一组有限的值。例如,这适用于人员的年龄信息,您可以假定一个从 0 到 150 的值范围。

int[] ages = { 10, 50, 22, 7, 42, 111, 50, 7 };

不同值的最大数量的定义意味着相应数量的容器(桶)可以存储这些值,或者更准确地说,可以存储它们的频率。为每个可能的值提供一个存储桶。

步骤 1:分配到桶首先,遍历初始数据集,并在桶中记录它们的出现。对于上述年龄信息,分布如下:

bucket[7] = 2
bucket[10] = 1
bucket[22] = 1
bucket[42] = 1
bucket[50] = 2
bucket[111] = 1

所有其他存储桶存储值 0。

步骤 2:排序结果的准备在最后一步中,从开始遍历桶。各个值被插入到结果中的次数与它们在桶中存储的次数一样多。这产生了这种排序:

int[] result = { 7, 7, 10, 22, 42, 50, 50, 111 };

最后的想法

许多更直观的算法,如插入排序和选择排序,都有运行时间为 O ( n 2 )的缺点。然而,插入排序有一个积极而显著的特性:只要输出数据是(几乎)排序的,插入排序就会变得非常高效,并且具有 O ( n )。

快速排序和归并排序通常非常高效,运行时间为O(n**log(n))。尽管如此,它们也有更高的源代码复杂性,尤其是在现场工作时。对于框架和更大的数据集,性能是必不可少的。另一方面,对合并排序潜在不利的是子范围的许多副本的创建。这同样适用于快速排序及其分区。然而,对这两者来说,有些变体却能做到这一点。有趣的是,要排序的子范围的各个划分很容易通过递归来表达,但是划分或合并部分则更复杂,并且更难以实现。尤其是,如果你在一个地方工作。对于合并排序,您将在所提供的 Eclipse 项目中找到一个例子。对于快速排序,您可以在练习 6 中尝试。

桶排序仍然存在。该算法有时甚至在线性运行时间中运行。然而,与提出的其他排序算法相比,它并不普遍适用,因为它具有已经提到的关于允许值的数量的限制。

10.3 练习

10.3.1 练习 1:包含全部(★★✩✩✩)

类似于 JDK 中用于Collection<E>的方法boolean containsAll(Collection<?>),您的任务是为数组实现一个boolean containsAll(int[], int...)方法。它应该检查作为变量参数传递的所有值是否都出现在数组中。

例子

|

投入

|

搜索值

|

结果

|
| --- | --- | --- |
| [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] | [7, 2] | 真实的 |
| [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] | [5, 11] | 错误的 |

10.3.2 练习 2:分区(★★★★✩)

挑战是在一次传递中适当地排序或排列字母 A 和 B 的混合序列,使得所有的 A 都出现在 Bs 之前。这也可以扩展到三个字母。

例子

|

投入

|

结果

|
| --- | --- |
| "阿巴布巴巴" | " AAAAAAABBBBBBB " |
| " ABACCBBCAACCBBA " | " AAAAABBBBBCCCCC " |

练习 2a:划分两个字母(★★★✩✩)

编写方法String partition2(String),它采用由两个字母 A 和 B 组成的给定序列作为Stringchar[],并将其转换为有序序列,其中所有的 A 都出现在 Bs 之前。

练习 2b:划分三个字母(★★★★✩)

编写方法String partition3(String),将由三个字母 A、B 和 C 组成的序列划分为一个有序序列,其中所有的 A 出现在 Bs 之前,它们依次出现在 Cs 之前。代替字母,这可以被认为是旗帜的颜色。然后就是众所周知的荷兰国旗问题。

10.3.3 练习 3:★★✩✩✩二分搜索法

练习 3a:★★✩✩✩二分搜索法递归

编写递归方法boolean binarySearch(int[], int),在一个排序数组中搜索所需的值。

例子

|

投入

|

搜索值

|

结果

|
| --- | --- | --- |
| [1, 2, 3, 4, 5, 7, 8, 9] | five | 真实的 |
| [1, 2, 3, 4, 5, 7, 8, 9] | six | 错误的 |

练习 3b:★★✩✩✩二分搜索法迭代

将递归方法转换为名为int binarySearchIterative(int[], int)的迭代方法,修改为返回搜索值的位置或-1,而不是分别返回truefalse

例子

|

投入

|

搜索值

|

结果

|
| --- | --- | --- |
| [1, 2, 3, 4, 5, 7, 8, 9] | five | four |
| [1, 2, 3, 4, 5, 7, 8, 9] | six | -1 |

10.3.4 练习 4:插入排序(★★✩✩✩)

介绍部分 10.2.1 展示了插入排序的一个简化的、易于理解的实现。在本练习中,我们的目标是通过找到插入位置并一次性执行必要的交换和插入来优化整个过程。写一个优化版的int[] insertionSort(int[])

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 5, 1, 6, 8, 9, 4, 3] | [1, 2, 3, 4, 5, 6, 7, 8, 9] |

10.3.5 练习 5:选择排序(★★✩✩✩)

编写一个选择排序的变体,它使用最大值而不是最小值,并具有以下签名:void selectionSortMaxInplace(int[])

需要修改什么才能使排序算法保持原始数据不变并返回一个新的排序后的数组?为此编写方法int[] selectionSortMaxCopy(int[])

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 5, 1, 6, 8, 9, 4, 3] | [1, 2, 3, 4, 5, 6, 7, 8, 9] |

10.3.6 练习 6:快速排序(★★★✩✩)

我在介绍性章节 10.2.4 中描述了快速排序。虽然用列表可以很容易地实现将值小于或等于 pivot 元素的两个范围分开,但这对数组来说更具挑战性。现在要用方法int partition(int[], int, int)来实现分区。在下文中,再次显示了已经存在的源代码:

static int[] quicksort(final int values[])
{
    quicksort(values, 0, values.length - 1);
    return values;
}

static void quicksort(final int values[], final int begin, final int end)
{
    // recursive termination
    if (begin >= end)
        return;

    final int partitionIndex = partition(values, begin, end);

    // recursive descent
    quicksort(values, begin, partitionIndex - 1);
    quicksort(values, partitionIndex + 1, end);
}

例子

|

投入

|

结果

|
| --- | --- |
| [5, 2, 7, 1, 4, 3, 6, 8] | [1, 2, 3, 4, 5, 6, 7, 8] |
| [5, 2, 7, 9, 6, 3, 1, 4, 8] | [1, 2, 3, 4, 5, 6, 7, 8, 9] |
| [5, 2, 7, 9, 6, 3, 1, 4, 2, 3, 8] | [1, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9] |

10.3.7 练习 7:桶排序(★★✩✩✩)

在介绍性章节 10.2.5 中,描述了桶排序算法。在本练习中,您想要创建方法int[] bucketSort(int[], int),该方法实现了针对int[]和预期最大值的排序算法。

例子

|

投入

|

最大值

|

结果

|
| --- | --- | --- |
| [10, 50, 22, 7, 42, 111, 50, 7] | One hundred and fifty | [7, 7, 10, 22, 42, 50, 50, 111] |

10.3.8 练习 8:在循环数据中搜索(★★★★✩)

在本练习中,您的任务是在整数值的排序序列中实现搜索。挑战在于,这些值是有序的,但在它们自己内部是旋转的。据此,最小的元素可能不在数据的前面。此外,最大的元素通常不位于数据的末尾(除了旋转 0 个位置的特殊情况)。

Tip

还要注意检查旋转 0 或数组长度倍数的特殊情况,这将再次对应于值 0 的旋转。

练习 8a:侧面高效变换(★★★★✩)

编写方法int findFlankPos(int[]),该方法在对数时间内有效地在给定的排序序列的 n 个整数值中找到齿侧变化的位置,比如 25、33、47、1、2、3、5、11,即O(log(n)。基于int findFlankPos(int[])编写两个名为int minValue(int[])int maxValue(int[])的方法,根据它们的名字,从给定的排序但旋转的值序列中分别确定最小值和最大值。

例子

|

投入

|

侧翼位置

|

最低限度

|

最高的

|
| --- | --- | --- | --- |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | one | Forty-seven |
| [5, 11, 17, 25, 1, 2] | four | one | Twenty-five |
| [6, 1, 2, 3, 4, 5] | one | one | six |
| [1, 2, 3, 4, 5, 6] | 0(特殊情况) | one | six |

练习 8b:旋转数据中的二分搜索法(★★★★✩)

编写方法int binarySearchRotated(int[], int),在整数值的排序序列中有效地搜索给定值,比如说数字序列 25,33,47,1,2,3,5,11,如果没有找到,返回它的位置或-1。

例子

|

投入

|

侧翼位置

|

搜索值

|

结果

|
| --- | --- | --- | --- |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | Forty-seven | Two |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | three | five |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | Thirteen | -1 |
| [1, 2, 3, 4, 5, 6, 7] | 0(特殊情况) | five | four |
| [1, 2, 3, 4, 5, 6, 7] | 0(特殊情况) | Thirteen | -1 |

10.4 解决方案

10.4.1 解决方案 1:包含全部(★★✩✩✩)

类似于 JDK 中用于Collection<E>的方法boolean containsAll(Collection<?>),您的任务是为数组实现一个boolean containsAll(int[], int...)方法。它应该检查作为变量参数传递的所有值是否都出现在数组中。

例子

|

投入

|

搜索值

|

结果

|
| --- | --- | --- |
| [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] | [7, 2] | 真实的 |
| [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] | [5, 11] | 错误的 |

算法在第 10.1.1 节中,方法boolean contains(int[], int)被创建。对于您的实现,您可以对传入的所有元素重复调用它来检查包含性:

static boolean containsAll(final int[] values, final int... searchFor)
{
    for (int current : searchFor)
    {
        if (!ArrayUtils.contains(values, current))
            return false;
    }
    return true;
}

确认

让我们用从 0 到 9 的数字定义一个int[],并检查值 7 和 2,以及 5 和 11 是否存在:

@ParameterizedTest(name = "containsAll({0}, {1}) => {2}")
@MethodSource("createInputsAndExpected")
void containsAll(int[] values, int[] searchvalues, boolean expected)
{
    boolean result = Ex01_ContainsAll.containsAll(values, searchvalues);

    assertEquals(expected, result);
}

private static Stream<Arguments> createInputsAndExpected()
{
    final int[] values = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    final int[] searchValues1 = { 7, 2 };
    final int[] searchValues2 = { 5, 11 };

    return Stream.of(Arguments.of(values, searchValues1, true),
                     Arguments.of(values, searchValues2, false));
}

10.4.2 解决方案 2:分区(★★★✩✩)

挑战是在一次传递中适当地排序或排列字母 A 和 B 的混合序列,使得所有的 A 都出现在 Bs 之前。这也可以扩展到三个字母。

例子

|

投入

|

结果

|
| --- | --- |
| "阿巴布巴巴" | " AAAAAAABBBBBBB " |
| " ABACCBBCAACCBBA " | " AAAAABBBBBCCCCC " |

解决方案 2a:分割两个字母(★★✩✩✩)

编写方法String partition2(String),它从两个字母 A 和 B 构建出一个给定的序列,称为Stringchar[],并将其转换为一个有序序列,其中所有的 A 都出现在 Bs 之前。

虽然一开始人们会尝试比较所有可能的位置,但是有一个巧妙的、高性能的解决方案,可以一次性解决这个问题。您使用两个位置指针,,它们标记前后位置,在这种情况下,有效范围由最后面的 As 和最前面的 Bs 给出。当找到 A 时,它的位置会递增。当找到一个 B 时,它被交换到后面。之后,Bs 的位置指针减小,扩展了已经正确划分的区域。

static String partition2(final String charValues)
{
    return partition2(charValues.toCharArray());
}

static String partition2(final char[] charValues)
{
    int low = 0;
    int high = charValues.length - 1;

    while (low <= high)
    {
        if (charValues[low] == 'A')
            low++;
        else
        {
            ArraysUtils.swap(charValues, low, high);
            high--;
        }
    }
    return new String(charValues);

}

因为交换时 a B 也可能移到前面,所以低位指针必须保持不变。在接下来的一个步骤中,B 将再次移动到后面。

这种复杂的算法使得有可能在单次通过时将所有 As 排列在 Bs 的前面。

解决方案 2b:分割三个字母(★★★✩✩)

编写方法String partition3(String),将三个字母 A、B 和 C 组成的序列划分为一个有序序列,其中所有的 A 出现在 Bs 之前,它们依次出现在 Cs 之前。代替字母,这可以被认为是旗帜的颜色。然后就是众所周知的荷兰国旗问题。

从两个字母(或颜色)扩展到三个字母(或颜色)采用了和以前类似的想法,但是增加了一些技巧和特殊处理。你再次从数组的开头开始,但是使用三个位置标记。最初,它们位于位置 0 的第一个和中间的字符;的在末端位置。如果找到 A,则低位中间的位置向右移动一位。在此之前,较低范围的最后一个字符与当前(中间)的字符交换。如果读一个 B,只有中间位置向末端移动。如果当前字符是一个 C,这是交换到后面。然后,上部区域的位置标记减少 1。

static String partition3(final String input)
{
    final char[] charValues = input.toCharArray();
    int low = 0;
    int mid = 0;
    int high = charValues.length - 1;

    while (mid <= high)
    {
        if (charValues[mid] == 'A')
        {
            ArraysUtils.swap(charValues, low, mid);

            low++;
            mid++;
        }

        else if (charValues[mid] == 'B')
            mid++;
        else
        {
            ArraysUtils.swap(charValues, mid, high);

            high--;
            // low, mid must remain unchanged, because also a B or C
            // can be swapped to the front
        }
    }
    return new String(charValues);

}

确认

为了检查功能,您使用两个字符串,分别由字母 A 和 B 或 A、B 和 C 的混洗序列组成:

@Test
public void testPartition2()
{
    final String result = Ex02_FlagPartitioning.partition2("ABAABBBAAABBBA");

    assertEquals("AAAAAAABBBBBBB", result);
}

@Test
public void testPartition3()
{
    final String result = Ex02_FlagPartitioning.partition3("ABACCBBCAACCBBA");

    assertEquals("AAAAABBBBBCCCCC", result);
}

10.4.3 解决方案 3:★★✩✩✩二分搜索法

解决方案 3a:★★✩✩✩二分搜索法递归

编写递归方法boolean binarySearch(int[], int),在一个排序数组中搜索所需的值。

例子

|

投入

|

搜索值

|

结果

|
| --- | --- | --- |
| [1, 2, 3, 4, 5, 7, 8, 9] | five | 真实的 |
| [1, 2, 3, 4, 5, 7, 8, 9] | six | 错误的 |

算法将数组分成两半。确定中间的值,看看是否需要进一步搜索上半部分或下半部分。这可以根据给定的排序顺序轻松确定:

居中 == 搜索值 →找到,结束

居中 < 搜索值继续在顶部搜索

中间 > 搜索值继续在底部搜索

Java 中的实现严格遵循描述。像往常一样,要特别注意数组的边界,以避免犯粗心的错误。

static boolean binarySearch(final int[] sortedValues, final int searchValue)
{
    final int midPos = sortedValues.length / 2;

    // recursive termination
    if (searchValue == sortedValues[midPos])
        return true;

    if (sortedValues.length > 1) // there are still at least 2 numbers
    {
        if (searchValue < sortedValues[midPos])
        {
            // recursive descent: search further in the lower part
            final int[] lowerHalf = Arrays.copyOfRange(sortedValues, 0, midPos);

            return binarySearch(lowerHalf, searchValue);

        }
        if (searchValue > sortedValues[midPos])
        {

            // recursive descent: continue search in the upper part
            final int[] upperHalf = Arrays.copyOfRange(sortedValues,
                                    midPos + 1, sortedValues.length);

            return binarySearch(upperHalf, searchValue);
        }
    }
    return false;
}

优化算法所示的解决方案并不是真正的最优,因为原始数组的部分被永久复制以执行进一步的搜索。在两个索引变量的帮助下,整个过程可以完全完成,而不需要潜在的耗时的数组复制。以下解决方案当然是首选:

static boolean binarySearchOptimized(final int[] values, final int searchValue)
{
    return binarySearchOptimized(values, searchValue, 0, values.length - 1);
}

static boolean binarySearchOptimized(final int[] values, final int searchValue,
                                   final int left, final int right)
{
    if (right >= left)
    {
        final int midIdx = (left + right) / 2;

        if (searchValue == values[midIdx])
            return true;

        // recursive descent: search in the lower / upper part further
        if (searchValue < values[midIdx])
            return binarySearchOptimized(values, searchValue, left, midIdx - 1);
        else
            return binarySearchOptimized(values, searchValue, midIdx + 1, right);
    }
    return false;
}

解决方案 3b:★★✩✩✩二分搜索法迭代

将递归方法转换为名为int binarySearchIterative(int[], int)的迭代方法,修改为返回搜索值的位置或-1,而不是分别返回truefalse

例子

|

投入

|

搜索值

|

结果

|
| --- | --- | --- |
| [1, 2, 3, 4, 5, 7, 8, 9] | five | four |
| [1, 2, 3, 4, 5, 7, 8, 9] | six | -1 |

算法基于刚刚示出的递归版本,迭代实现可以相当容易地导出。您使用两个位置标记,用于左和右,它们最初开始于起点和终点(位置0长度-1)。这两个标记决定了执行进一步搜索的相应索引边界。首先,您将中间的值与搜索到的值进行比较。如果值相等,则返回索引。否则,您将搜索区域分为两部分,并继续搜索,直到搜索成功或左右位置标记相互交叉。

static int binarySearchIterative(final int[] values, final int searchValue)
{
    int left = 0;
    int right = values.length - 1;

    while (right >= left)
    {
        int midIdx = (left + right) / 2;

        if (searchValue == values[midIdx])
            return midIdx;

        if (searchValue < values[midIdx])
            right = midIdx - 1;
        else
            left = midIdx + 1;
    }

    return -1;
}

确认

对于测试,您使用以下输入,这些输入显示了正确的操作:

@ParameterizedTest(name = "searching in {0} for {1} => {2}")
@MethodSource("createInputsAndExpected")
void containsAll(int[] values, int searchFor, boolean expected)
{
    boolean result = Ex03_BinarySearch.binarySearch(values, searchFor);

    assertEquals(expected, result);
}

private static Stream<Arguments> createInputsAndExpected()
{
    final int[] values = { 1, 2, 3, 4, 5, 7, 8, 9 };

    return Stream.of(Arguments.of(values, 5, true),
                     Arguments.of(values, 6, false));
}

@Test
void binarySearchIterative_should_return_pos()
{
    final int[] values = { 1, 2, 3, 4, 5, 7, 8, 9 };

    int result = Ex03_BinarySearch.binarySearchIterative(values, 5);

    assertEquals(4, result);
}

@Test
void binarySearchIterative_no_value()
{
    final int[] values = { 1, 2, 3, 4, 5, 7, 8, 9 };

    int result = Ex03_BinarySearch.binarySearchIterative(values, 6);

    assertEquals(-1, result);
}

为了让事情变得更有趣一点,让我们来看看单元测试及其在 IDE 中的执行。见图 10-4 。

img/519691_1_En_10_Fig4_HTML.jpg

图 10-4

二分搜索法测试执行的结果

10.4.4 解决方案 4:插入排序(★★✩✩✩)

介绍部分 10.2.1 展示了插入排序的一个简化的、易于理解的实现。在本练习中,我们的目标是通过找到插入位置并一次性执行必要的交换和插入来优化整个过程。写一个优化版的int[] insertionSort(int[])

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 5, 1, 6, 8, 9, 4, 3] | [1, 2, 3, 4, 5, 6, 7, 8, 9] |

算法对于所有的元素,你执行下面的过程,这个过程是以值序列24 3 17为例描述的:我们假设 3 是要排序的值。只要邻居的值大于当前值,您就必须从其位置开始与左侧邻居交换。在这种情况下,您还没有到达数组的最前面,所以您只将 3 与 4 交换。接下来,你需要把 1 一直交换到最前面。最后,7 已经在正确的位置。

static void insertionSort(final int[] values)
{
    for (int i = 1; i < values.length; i++)
    {
        // check if current element is larger than predecessor
        int currentIdx = i;
        while (currentIdx > 0 && values[currentIdx - 1] > values[currentIdx])
        {
            swap(values, currentIdx - 1, currentIdx); currentIdx--;
        }
    }
}

确认

您使用单元测试来验证实现是否为给定的数字序列产生了所需的结果:

@Test
void testInsertionSort()
{
    final int[] values = { 7, 2, 5, 1, 6, 8, 9, 4, 3 };
    final int[] expected = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    Ex04_InsertionSort.insertionSort(values);

    assertArrayEquals(expected, values);
}

10.4.5 解决方案 5:选择排序(★★✩✩✩)

编写一个选择排序的变体,它使用最大值而不是最小值,并具有以下签名:void selectionSortMaxInplace(int[])

需要修改什么才能使排序算法保持原始数据不变并返回一个新的排序后的数组?为此编写方法int[] selectionSortMaxCopy(int[])

例子

|

投入

|

结果

|
| --- | --- |
| [7, 2, 5, 1, 6, 8, 9, 4, 3] | [1, 2, 3, 4, 5, 6, 7, 8, 9] |

算法待排序的数组从后向前遍历,每种情况下最大的元素移回当前位置。通过调用方法findMaxPos(),您可以从剩余的未排序子范围中确定最大值的位置。该方法是作为第 5.3.12 节中练习 12 的解决方案创建的。随后,通过与当前元素交换,该元素相应地移动到后面。这将减少剩余的尚未排序的部分的大小,直到它只包含最前面的元素。

static void selectionSortMaxInplace(final int[] values)
{
    for (int i = values.length - 1; i > 0 ; i--)
    {
        final int maxPos = ArraysUtils.findMaxPos(values, 0, i + 1);

        ArraysUtils.swap(values, maxPos, i);
    }
}

如果您已经创建了前面的方法,则具有复制功能的方法实现起来很简单:

static int[] selectionSortMaxCopy(final int[] values)
{
    final int[] copy = Arrays.copyOf(values, values.length);

    selectionSortMaxInplace(copy);

    return copy;
}

确认

您使用单元测试来验证实现是否为给定的数字序列产生了所需的结果:

@Test
void selectionSortMaxInplace()
{
    final int[] values = { 7, 2, 5, 1, 6, 8, 9, 4, 3 };
    final int[] expected = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    Ex05_SelectionSort.selectionSortMaxInplace(values);

    assertArrayEquals(expected, values);
}

10.4.6 解决方案 6:快速分类(★★★✩✩)

我在介绍性章节 10.2.4 中描述了快速排序。虽然用列表可以很容易地实现将值小于或等于 pivot 元素的两个范围分开,但这对数组来说更具挑战性。现在要用方法int partition(int[], int, int)来实现分区。在下文中,再次显示了已经存在的源代码:

static void quicksort(final int values[])
{
    quicksort(values, 0, values.length - 1);
}

static void quicksort(final int values[], final int begin, final int end)
{
    // recursive termination
    if (begin >= end)
        return;

    final int partitionIndex = partition(values, begin, end);

    // recursive descent
    quicksort(values, begin, partitionIndex - 1);
    quicksort(values, partitionIndex + 1, end);
}

例子

|

投入

|

结果

|
| --- | --- |
| [5, 2, 7, 1, 4, 3, 6, 8] | [1, 2, 3, 4, 5, 6, 7, 8] |
| [5, 2, 7, 9, 6, 3, 1, 4, 8] | [1, 2, 3, 4, 5, 6, 7, 8, 9] |
| [5, 2, 7, 9, 6, 3, 1, 4, 2, 3, 8] | [1, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9] |

算法你的目标是将一个数组(或者一个数组的范围)细分成两部分,通过传递下限开始和上限索引,选择一个特殊位置的值(比如最前面的元素)作为枢纽元素。现在这两部分被重新安排。值小于或等于 pivot 元素的所有元素都应该位于下部。此外,值大于 pivot 元素的所有元素都应该位于上部。这里,只要条件left index<=pivothold 为左, pivot <值**right index为右,两个索引 leftIndexrightIndex 各自向内移动。如果在左侧发现一个排序不当的元素,则从右侧开始检查。如果在这里也发现了一个排序不正确的元素,那么这两个元素将被交换。只要位置标记没有相互交叉,就重复这个过程。最后,来自右索引位置的元素与枢轴元素交换。还有一种特殊情况是数组只有两个元素。在这种情况下,您还必须确保正确的值实际上大于轴的值。

static int partition(final int[] values, final int left, final int right)
{
    final int pivot = values[left];

    int leftIndex = left + 1;
    int rightIndex = right;

    while (leftIndex < rightIndex)
    {
        // move the position leftIndex to the right, as long as value
        // less than or equal to pivot and left limit less than right limit
        while (values[leftIndex] <= pivot && leftIndex < rightIndex)
        {
            leftIndex++;
        }

        // move the position rightIndex to the left, as long as value greater
        // than pivot and right limit greater than or equal to left limit
        while (pivot < values[rightIndex] && rightIndex >= leftIndex)
        {
         rightIndex--;
        }

        if (leftIndex < rightIndex)
            ArrayUtils.swap(values, leftIndex, rightIndex);
    }

    // special case 2-element array with wrong sorting, but no
    // pass (leftIndex == rightIndex) as well as normal case at the very end
    if (values[rightIndex] < pivot)
        ArrayUtils.swap(values, left, rightIndex);

    return rightIndex;
}

确认

从介绍性示例中定义三个数组,并使用它们来检查快速排序的实现:

@ParameterizedTest(name = "{0} should be sorted to {1}")
@MethodSource("createInputAndExpected")
void testQuicksort(int[] values, int[] expected)
{
    // inplace
    Ex06_Quicksort.quicksort(values);

    assertArrayEquals(expected, values);
}

private static Stream<Arguments> createInputAndExpected()
{
    return Stream.of(Arguments.of(new int[] { 5, 2, 7, 1, 4, 3, 6, 8 },
                                  new int[] { 1, 2, 3, 4, 5, 6, 7, 8 }),
                     Arguments.of(new int[] { 5, 2, 7, 9, 6, 3, 1, 4, 8 },
                                  new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }),
                     Arguments.of(new int[] { 5, 2, 7, 9, 6, 3, 1, 4, 2, 3, 8 },
                                  new int[] { 1, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9 }));
}

10.4.7 解决方案 7:桶排序(★★✩✩✩)

在介绍性章节 10.2.5 中,描述了桶排序算法。在本练习中,您想要创建方法int[] bucketSort(int[], int),该方法实现了针对int[]和预期最大值的排序算法。

例子

|

投入

|

最大值

|

结果

|
| --- | --- | --- |
| [10, 50, 22, 7, 42, 111, 50, 7] | One hundred and fifty | [7, 7, 10, 22, 42, 50, 50, 111] |

算法桶排序是实现起来最简单的排序算法之一,也是线性运行时间最快的算法之一,但前提是值的范围有限。

首先,创建所有存储值计数的存储桶。之后,分两步实现桶排序:

  1. 遍历所有输入值,并将它们分配给相应的存储桶。如果有几个相同的元素,你必须增加计数器。

  2. 最后一步是基于计数器值重建值。

所描述的过程在 Java 中实现如下:

static int[] bucketSort(final int[] values, final int expectedMax)
{
    final int[] buckets = new int[expectedMax + 1];
    collectIntoBuckets(values, buckets);

    final int[] results = new int[values.length];
    fillResultFromBuckets(buckets, results);

    return results;
}

从而描述了该算法的基本特征。只剩下两个助手方法的实现,这也是直接完成的。要计算各个数字的计数,您必须遍历原始数组,并根据当前值递增桶中的计数器:

static void collectIntoBuckets(final int[] values, final int[] buckets)
{
    for (int current : values)
    {
        buckets[current]++;
    }
}

基于桶中的数量,结果的生成稍微复杂一点。为此,您将遍历所有存储桶。如果索引 i 包含一个大于 0 的数量,则该索引值必须按照那里指定的频率复制到目标。在这种情况下,它由while循环解决。你只需要单独携带目标数组中的位置。

static void fillResultFromBuckets(final int[] buckets, final int[] results)
{
    int resultPos = 0;
    for (int i = 0; i < buckets.length; i++)
    {
        int count = buckets[i];

        while (count > 0)
        {
            results[resultPos] = i;

            count--;
            resultPos++;
        }
    }
}

确认

您编写一个简短的测试方法,用一些值来检查桶排序的实现:

@Test
void testBucketSort()
{
    final int[] values = { 10, 50, 22, 7, 42, 111, 50, 7 };
    final int max = 150;
    final int[] expected = { 7, 7, 10, 22, 42, 50, 50, 111 };

    final int[] result = Ex07_BucketSort.bucketSort(values, max);

    assertArrayEquals(expected, result);
}

10.4.8 解决方案 8:在循环数据中搜索(★★★★✩)

在本练习中,您的任务是在整数值的排序序列中实现搜索。挑战在于,这些值是有序的,但在它们自己内部是旋转的。据此,最小的元素可能不在数据的前面。此外,最大的元素通常不位于数据的末尾(除了旋转 0 个位置的特殊情况)。

Tip

还要注意检查旋转 0 或数组长度倍数的特殊情况,这将再次对应于值 0 的旋转。

解决方案 8a:侧面更换高效(★★★★✩)

编写方法int findFlankPos(int[]),该方法在对数时间内有效地在给定的排序序列的 n 个整数值中找到齿侧变化的位置,比如 25、33、47、1、2、3、5、11,即O(log(n)。基于int findFlankPos(int[])编写两个名为int minValue(int[])int maxValue(int[])的方法,根据它们的名字,从给定的排序但旋转的值序列中分别确定最小值和最大值。

例子

|

投入

|

侧翼位置

|

最低限度

|

最高的

|
| --- | --- | --- | --- |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | one | Forty-seven |
| [5, 11, 17, 25, 1, 2] | four | one | Twenty-five |
| [6, 1, 2, 3, 4, 5] | one | one | six |
| [1, 2, 3, 4, 5, 6] | 0(特殊情况) | one | six |

算法的初步考虑:让我们从线性搜索的强力版本开始,稍后对照它检查您的优化版本。对于搜索,您只需从前到后检查每个元素,以确定值的后续元素是否小于当前元素:

static int findFlankPosSimple(final int[] values)
{
    for (int i = 0; i < values.length; i++)
    {
        final int nextIdx = (i + 1) % values.length;
        if (values[i] > values[nextIdx])
            return nextIdx;
    }
    throw new IllegalStateException("should never reach here!");
}

当然,在遍历的时候,你还必须考虑一个特殊的情况,侧翼的变化发生在数组的最末端,所以你有一个非旋转的数组作为基础。

算法那么,你如何着手实现一个对数运行时间呢?在这种情况下,您利用了值序列是排序的这一事实。按照二分搜索法的想法,搜索范围总是可以分成两半。然而,因为存在旋转,所以必须小心索引。

这里有三个比较:

  • 案例 A:带前任:如果大一点,你已经发现侧翼变化了。

  • 情况 B:用最左边的元素:如果大于当前元素,那么侧翼变化一定发生在两者之间的某个地方。所以,你可以排除右半边。

  • 情况 C:用最右边的元素:如果这个更小,侧面变化一定发生在右边。可以排除左半部分。

一开始,检查非旋转初始数据集的特殊情况是至关重要的。这可以通过最左边的值小于最右边的值来确定。

有了这些初步的考虑,下面的实现出现了:

static int findFlankPos(final int[] values)
{
    return findFlankPosRec(values, 0, values.length - 1);
}

static int findFlankPosRec(final int[] values, final int left, final int right)
{
    final int midPos = left + (right - left) / 2;
    final int midValue = values[midPos];

    // special case no rotation
    if (values[left] < values[right])
        return 0;

    // case A: value to the left of this is larger, then you got a flank change
    int prevIndex = midPos - 1;
    if (prevIndex < 0)
        prevIndex = values.length - 1;

    if (values[prevIndex] > midValue)
        return midPos;

    if (values[left] > midValue)
    {
        // case B: flank change must be on the left, since first value
        // larger than in the middle
        return findFlankPosRec(values, left, midPos + 1);
    }
    if (values[right] < midValue)
    {
        // case C: flank change must be on the right, as last value
        // smaller than in the middle
        return findFlankPosRec(values, midPos + 1, right);
    }
    throw new IllegalStateException("should never reach here!");
}

基于这种方法,在知道齿侧面变化的位置包含最小值并且最大值的位置在其左侧的情况下,可以将确定最小值和最大值的方法简单地写成如下。对于 0°的旋转,仍然必须进行小的校正。

static int minValue(final int[] values)
{
    final int flankpos = findFlankPos(values);
    return values[flankpos];
}

static int maxValue(final int[] values)
{
    int flankpos = findFlankPos(values);
    // for rotation0 move after the end
    if (flankpos == 0)
        flankpos = values.length;
    return values[flankpos - 1];
}

确认

使用以下参数化测试来测试齿侧变化的确定。特别地,非旋转输入值的特殊情况也被验证。同样,您测试了确定最小值和最大值的两种方法。

@ParameterizedTest(name = "findFlankPos({0}) => {2}")
@MethodSource("createInputAndExpected")
void findFlankPos(int[] values, int expected)
{
    int flankpos = Ex08_RotatedSearch.findFlankPos(values);

    assertEquals(expected, flankpos);
}
private static Stream<Arguments> createInputAndExpected()
{
    return Stream.of(Arguments.of(new int[] { 25, 33, 47, 1, 2, 3, 5, 11 }, 3),
                    Arguments.of(new int[] { 6, 7, 1, 2, 3, 4, 5 }, 2),
                    Arguments.of(new int[] { 1, 2, 3, 4, 5, 6, 7 }, 0));
}

@ParameterizedTest(name = "minmax({0}) => min: {1} / max: {2}")
@MethodSource("createInputAndExpectedMinAndMax")
void minmax(int[] values, int expectedMin, int expectedMax)
{
    int min = Ex08_RotatedSearch.minValue(values);
    int max = Ex08_RotatedSearch.maxValue(values);

    assertAll(() -> assertEquals(expectedMin, min),
              () -> assertEquals(expectedMax, max));
}

private static Stream<Arguments> createInputAndExpectedMinAndMax()
{
    return Stream.of(Arguments.of(new int[] { 25, 33, 47, 1, 2, 3, 5, 11 },
                                  1, 47),
                     Arguments.of(new int[] { 6, 7, 1, 2, 3, 4, 5 }, 1, 7),
                     Arguments.of(new int[] { 1, 2, 3, 4, 5, 6, 7 }, 1, 7));
}

解决方案 8b:旋转数据中的二分搜索法(★★★★✩)

编写方法int binarySearchRotated(int[], int),在整数值的排序序列中有效地搜索给定值,比如说数字序列 25,33,47,1,2,3,5,11,如果没有找到,返回它的位置或-1。

例子

|

投入

|

侧翼位置

|

搜索值

|

结果

|
| --- | --- | --- | --- |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | Forty-seven | Two |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | three | five |
| [25, 33, 47, 1, 2, 3, 5, 11] | three | Thirteen | -1 |
| [1, 2, 3, 4, 5, 6, 7] | 0(特殊情况) | five | four |
| [1, 2, 3, 4, 5, 6, 7] | 0(特殊情况) | Thirteen | -1 |

算法在能够高效确定 O ( log ( n ))中的侧翼变化后,一种可能就是放大数组。因此,可以切掉数组的前面部分,并将其附加在末尾(这对于中等大小的数组是可行的)。然后,您可以调用在练习 3 中开发的二分搜索法。

25 | 27 | 33 | 2 | 3 | 5    =>    | 2 | 3 | 5 | 25 | 27 | 33

然而,这个过程需要相当大的努力。那么怎么才能改善呢?

为此,您需要修改二分搜索法来指定一个下限和上限。您获得了阵列扩展的想法,但将其虚拟化。让我们看一下在练习中显示的数字序列中搜索 47 的例子;见图 10-5 。

img/519691_1_En_10_Fig5_HTML.png

图 10-5

旋转二分搜索法程序

基于这些初步的想法,你继续进行二分搜索法。首先,确定侧面变化的位置,并使用它来指定搜索值范围。现在您执行一个普通的二分搜索法,但是您使用模运算符将扩展的值范围带回数组边界,并基于此确定比较值。

static int binarySearchRotated(final int[] values, final int searchFor)
{
    final int flankpos = findFlankPos(values);

    return binarySearchRotatedHelper(values, searchFor,
                                     flankpos, flankpos - 1 + values.length);
}

static int binarySearchRotatedHelper(final int[] values, final int searchFor,
                                     final int start, final int end)
{
    if (start > end)
        return -1;

    final int midPos = start + (end - start) / 2;
    final int midValue = values[midPos % values.length];

    if (midValue == searchFor)
        return adjustedMid;

    if (searchFor < midValue)
    {

        return binarySearchRotatedHelper(values, searchFor,
                                         start, midPos - 1);
    }
    if (searchFor > midValue)
    {
        return binarySearchRotatedHelper(values, searchFor,
                                         midPos + 1, end);
    }
    throw new IllegalStateException("should never reach here!");
}

确认

要检查功能,请使用介绍性示例中的值组合:

@ParameterizedTest(name = "binarySearchRotated({0}) => {2}")
@MethodSource("createInputsAndExpected")
void binarySearchRotated(int[] values, int searchFor, int expected)
{
    int flankpos = Ex08_RotatedSearch.binarySearchRotated(values, searchFor);

    assertEquals(expected, flankpos);
}

private static Stream<Arguments> createInputsAndExpected()
{
    int[] inputs1 = { 25, 33, 47, 1, 2, 3, 5, 11 };
    int[] inputs2 = { 1, 2, 3, 4, 5, 6, 7 };

    return Stream.of(Arguments.of(inputs1, 47, 2),
                     Arguments.of(inputs1, 3, 5),
                     Arguments.of(inputs1, 13, -1),
                     Arguments.of(inputs2, 5, 4),
                     Arguments.of(inputs2, 13, -1));
}

```***

# 十一、总结和补充文献

现在,已经到了练习册的末尾,我们得出结论。然而,在我们看补充文献之前,我将提出另外两个难题。

## 11.1 结论

通过阅读这本书,尤其是通过解决和实现练习,你应该已经获得了丰富的经验。有了这些知识,日常实践中的各种任务现在应该更容易完成了。当然,如果你不只是遵循给出的解决方案,而且还要试验和修改它们,你会获益最大。

### 11.1.1 每章的经验教训

让我们回顾一下每一章和每一个主题都教了些什么,以及你应该学了些什么。

**数学**关于基础数学知识的章节介绍了模运算符,这是非常重要的,例如,用于提取数字和计算校验和。组合学的练习已经展示了小技巧是如何轻松地将运行时间减少几个数量级的。此外,素数提供了一些有趣的方面,例如,他们的计算变量。回想起来,这可能比最初想象的要容易得多。一般来说,对于每一个问题,算法和方法应该被粗略地理解,因为这样,例如,即使分解成质因数也失去了它可能的恐怖。

递归的介绍性章节为很好的理解打下了基础。许多练习扩大了你的知识面。此外,在接下来的章节中,你能够有效地运用所学的基础知识。一个主要的例子是树上的各种算法,这些算法通常可以很容易地递归表达——迭代,例如,后序遍历已经很有挑战性了,而使用递归,这是毫不费力的。

然而,您也认识到简单递归不仅有优势,而且由于运行时间长,有时需要一些耐心。在递归的高级章节中,你用记忆和回溯极大地扩展了你的工具箱。这可以让你提高性能,解决有趣的难题,如数独或 n 皇后问题。找到走出迷宫的方法也是可能的。所有这些都需要更多的编程工作,但实现起来不会太复杂。

字符串几乎是每个程序不可或缺的一部分。除了简单的回文检查或字符串反转任务之外,一些任务可以通过使用合适的辅助数据结构来大大简化,比如检查格式良好的大括号、将单词转换成莫尔斯电码等等。总的来说,这里已经很明显,由于你在不同领域有了更多的基础知识,解决问题变得越来越容易。

**数组**就像字符串一样,数组是许多程序中的基本构建块。特别是,避免棘手的一个接一个的错误是很重要的。在这一章中,你创建了一些小的辅助方法,如果使用得当,可以使算法更容易理解。对于二维数组,您学习了如何对方向建模,以及如何用模式填充区域。更具挑战性的任务是螺旋遍历以及删除和填充珠宝或扫雷操场。最后,您开发了一些合并数组的功能。这是合并排序的基本组件。

**日期处理**虽然在 Java 8 之前,日期值的处理相当麻烦,但是日期和时间 API 让它变得很方便。除了 API 的基础,你还学习了计算,特别是基于`TemporalAdjuster-`的*时间机器*的各种可能性。使用适当的助手方法甚至可以全面地实现日历页面的输出。

基本数据结构本章加深了你对基本数据结构的了解,比如列表、集合和映射。这些知识在商业应用中是必不可少的。但它们不仅可以单独使用,也可以组合使用,对于解决许多任务都很有用,比如从列表中删除重复项。另外,比如魔三角的任务,训练抽象思维。一个小细节是对 Excel 本身的自动完成进行编程。令人惊讶的是,这会产生一个多么优雅的实现。

**二叉树**大概这本书最复杂的题目就是二叉树了。因为集合框架没有提供它们,所以可能不是每个 Java 开发人员都熟悉它们。然而,因为二叉树适合优雅地解决许多问题,这一章给出了一个介绍。这些练习帮助你了解二叉树及其可能性。除了像旋转这样简单的事情之外,例如数学计算,也可以用二叉树来非常灵活地表示和处理。令人困惑的是最不常见祖先的确定。对于检查完整性和二叉树的图形输出来说尤其如此。

**搜索和排序**如今,你几乎不会自己编写一个搜索或排序算法。尽管如此,处理一次这个问题对算法理解还是有帮助的。虽然简单实现的运行时间通常为 *O* ( *n* <sup>2</sup> ),但这通常可以通过合并排序和快速排序减少到*O*(*n**log*(*n*))。看到一个固定范围的价值观如何产生重大影响是很有趣的。运行时间为 *O* ( *n* )的桶排序在这些约束条件下发挥了它的优势。

### 值得注意的是

在提出解决方案时,我有时会故意展示一个错误的方法或一个次优的暴力变体,以展示在进行改进时的学习效果。同样,在日常工作中,迭代地进行通常是更可取的,因为需求可能不是 100 %精确的,新的请求会出现,等等。因此,从任务的可理解的实现开始是一个好主意,这允许它在以后被修改而没有任何问题。甚至可以接受采用一种尚未达到最佳的解决方案,以一种概念上正确的方式处理问题。

#### 关于维修性的思考

我们还观察到以下情况:源代码被阅读的次数通常比它被编写的次数多得多。想想你的日常工作。通常,您不是从绿地开始,而是用一些功能扩展现有系统或修复一个 bug。如果原程序作者选择了可理解的解决方案和程序结构,你会很感激。理想情况下,即使是单元测试也是一个安全网。

让我们回到发展上来。确保您提前考虑了问题,而不是直接从实现开始。你对一个问题思考得越有条理、越精确,你的实现就越清晰。一旦*一分钱一分货*,创建或改进一个可理解的、结构良好的解决方案通常不是太大的一步。然而,如果您过早地将一个实现简单地作为源代码开始,那么不幸的是,这往往会以灾难和失败而告终。因此,有些东西仍然是半生不熟的,以有意义的方式添加功能变得更加困难。

我喜欢指出,尤其是可追溯性和后来简化的可维护性在编程中非常重要。这通常是通过小的、可理解的、实用的构件来实现的。由于潜在的(大概也是唯一的)最低限度的较差性能以及较低的紧凑性,这通常比相当确定的较差的可维护性更容易接受。

#### 关于表演的思考

请记住,在当今的分布式应用程序世界中,单个指令或未优化的方法对性能的影响可以忽略不计。相比之下,过于频繁或过于细粒度的 REST 调用或数据库访问可能会对算法的执行时间产生更严重的影响,因为算法还没有优化到最后一个细节。请注意:我的陈述主要适用于业务应用程序中自己编写的功能。然而,对于经历数百万次(或更多)调用的框架和算法来说,内在美可能没有它们的性能重要。在这两个极端之间可能总会有某种权衡:要么是紧凑和性能优化,要么是可以理解但有时有点慢。

#### 单元测试的优势

即使只创建简单的程序,人们也经常会再次注意到以下事实:如果您纯粹基于控制台输出来测试算法的实现,错误通常会被忽略——主要是针对特殊情况、限制等。此外,没有支持单元测试,人们倾向于较少考虑类和方法签名的接口。但这恰恰有助于提高其他人的可管理性。从 JUnit 5 开始,编写单元测试变得非常有趣和流畅。这主要是由于令人愉快和有益的参数化测试。

通过阅读这本书并回顾解决方案,除了所涉及的主题中的技能之外,您应该已经对单元测试有了很好的理解。更重要的是,在开发解决方案时,当单元测试通过时,会有一种安全感。

## 11.2 谜题

在您处理了各种各样的编程难题之后,我向您呈现两个与编程无关的最终难题。尽管如此,通过回答这些问题,你可以学到很多解决问题的策略。有时候,有些事情一开始看起来不可能,然后就有了直截了当的解决办法。如果你喜欢,试试下面的谜题:

*   黄金包:检测假货

*   赛马:决定最快的三匹马

### 11.2.1 黄金包:检测假货

这个拼图大概是 10 个黄金袋子,每个袋子里装 10 个硬币,每个硬币重 10g。因此,每个金袋应重 100 克。参见图 11-1 。

![img/519691_1_En_11_Fig1_HTML.png](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/java-chall/img/519691_1_En_11_Fig1_HTML.png)

图 11-1

黄金包

但是一个冒名顶替者把装在袋子里的金币换成了赝品,每枚 9 克而不是 10 克,比假的要轻一些。找到只有一次称重的装有假货的金袋子。然而,你可以从任何一个袋子里取出不同数量的硬币,然后一起称重。

#### 解决办法

起初,这项任务听起来几乎不可能,因为不允许多次称重和分隔。稍加思考,你可能会想出下面这个窍门:把袋子排成一行,从 1 到 10 给它们编号。现在基于位置工作,从每个相应的袋子中放入与位置匹配的硬币,然后一起称重,如图 11-2 所示。

![img/519691_1_En_11_Fig2_HTML.png](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/java-chall/img/519691_1_En_11_Fig2_HTML.png)

图 11-2

称量金币

如果没有假货,结果将如下:

        1 × 10 + 2 × 10 + 3 × 10 + 4 × 10 + 5 × 10 + 6 × 10 + 7 × 10 + 8 × 10 + 9 × 10 + 10 × 10

    =    10 + 20 + 30 + 40 + 50 + 60 + 70 + 80 + 90 + 100

    =    550

让我们假设袋子 5 装有假货,看看结果:

1×10+2×10+3×10+4×10+**5**×**9**+6×10+7×10+8×10+9×10+10×10

= 10+20+30+40+**45**+60+70+80+90+100

    =    545

现在让我们假设袋子 2 包含假货,并确定结果:

1×10+**2**×**9**+3×10+4×10+5×10+6×10+7×10+8×10+9×10+10×10

= 10+**18**+30+40+50+60+70+80+90+100

    =    548

据此,您可以根据与 550 的差异来识别相应的包:

*550 重量=位置*

### 11.2.2 赛马:决定最快的三匹马

这个谜题是关于解决以下问题:有 25 匹赛马出售,你想买最快的三匹。有一个最多可容纳五匹马的跑道。尽管如此,你既没有秒表,也没有其他测量时间的方法。然而,这些马可以在比赛中相互竞争,你可能会注意到顺序。在这些限制下,你如何确定最快的三个,你如何进行?你最多要用哪些马组织几场比赛?

作为一种简化,让我们在这里假设马不会因为比赛而筋疲力尽,在每场比赛中以完全相同的速度奔跑,并且没有两匹马是相同的速度(就像在照片结束时一样,总是有顺序和赢家)。

#### 解决办法

在这里,你也必须首先考虑通过一个聪明的排除程序来安排正确的比赛,并且尽可能的少。事实上,只需要七场比赛就可以决定最快的三匹马。你如何着手此事?

首先,你让五匹马在任意五场比赛中相互竞争,从而决定这些比赛的获胜者。为了更好的可追溯性,所有的马都有一个 1 到 25 之间的数字,这通常与位置无关。在图 11-3 中,为了更好的区分,我使用了数字。也可以用 A,B,C,...但是你需要进一步区分比赛的获胜者。

因此,您可以从所有五场比赛中确定获胜者,并可以通过排除程序直接将第四名和第五名的所有马匹从您的下一场比赛中删除。

![img/519691_1_En_11_Fig3_HTML.png](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/java-chall/img/519691_1_En_11_Fig3_HTML.png)

图 11-3

比赛 1 至 5

结果,剩下 15 匹马,如果你想相互比较,在这五场比赛之后,至少还需要三场比赛。然而,根据我的声明,总共七场比赛就足够了,因此仍然只允许两场比赛。因此,你仍然必须减少要比较的马的数量。

**第二步**要让剩下的 15 匹马少得多,你需要再进行一场比赛,所有的获胜者都要参加。为什么呢?到目前为止,你只知道群体内部的一些事情,但不知道群体之间的事情。为了获得一些关于获胜者相对速度的信息,你让他们互相比赛。同样,后两匹马不可能是最快的三匹马。见图 11-4 。

![img/519691_1_En_11_Fig4_HTML.png](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/java-chall/img/519691_1_En_11_Fig4_HTML.png)

图 11-4

胜利者的竞赛

但是,这将自动排除编号为 17 和 18 的马(比编号为 16 的马慢)以及编号为 22 和 23 的马(比编号为 21 的马慢)作为候选马。

**第 3 步**你在矩阵中标记排除项,然后结合获得的知识进行下一个排除项。要做到这一点,您需要在马的矩阵中插入一个表示“快于”的>符号。因为 1 号马在冠军赛中也赢了,你确定 1 号马肯定是最快的。见图 11-5 。

![img/519691_1_En_11_Fig5_HTML.png](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/java-chall/img/519691_1_En_11_Fig5_HTML.png)

图 11-5

赛马最佳 9 人

然而,还剩下 9 匹马——实际上只有 8 匹,因为 1 号马是最快的。这意味着至少还有两场比赛。现在让我们考虑一下。你知道前几场比赛的顺序。由于您只想确定最快的三匹马,编号为 8、12 和 13 的马被淘汰,现在剩下五匹马,即编号为 2、3、6、7 和 11 的马。见图 11-6 。

![img/519691_1_En_11_Fig6_HTML.png](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/java-chall/img/519691_1_En_11_Fig6_HTML.png)

图 11-6

赛马的最终排斥

因此,你只需要让其他的马(也就是 2、3、6、7 和 11)互相竞争。这场比赛的冠军和亚军是总的第二和第三匹马。这导致以下可能的组合作为最终结果:

*   1, 2, 3

*   1, 2, 6

*   1, 6, 2

*   1, 6, 7

*   1, 6, 11

## 11.3 补充文献

在本书中,我的主要意图是为您提供一些编程练习和脑筋急转弯,以及解决它们的娱乐时间。如果这些任务在大多数时候对你来说是可以很好解决的,你会发现下面的各种书籍是补充读物。

有趣的是,当处理一个话题时,人们总是会碰到以前不知道的文献。有些书启发了我,我想推荐给你。我按主题对这些书进行了分类,这应该是进一步行动的良好开端。

### 11.3.1 算法和数据结构介绍

有各种各样的书可以帮助你开始学习算法和数据结构。我可以推荐以下内容或不同的观点:

*   ***搜索算法***【BHA 16】Aditya y . Bhargava 著。一本小而精的书,它提供了一个可读性强、易懂且有趣的介绍,并配有许多插图。然而,这些例子是用 Python 编写的。

*   数据结构和算法的常识指南 【文 17】杰伊·文格罗著。一本精彩的,易于理解的书,从算法和数据结构开始。广泛的插图使它容易跟随算法的步骤。同样,示例是用 Python 编写的。

*   ***用 Java 解决数据结构和算法中的问题***【jai 18】赫曼特·贾恩著。在这里列出的书籍中,这本书是最全面的,在主题方面远远超过了以前的书。然而,它提供了更少的解释性插图,也不像其他的那样直观。这本书有助于(直接)理解解决方案,因为 Java 用于实现。

### 基础书籍

如果你喜欢更科学地深入算法和数据结构的主题,从零开始学习,喜欢更正式一点的东西,那么看看下面的书:

*   ***算法***【sed 11】作者罗伯特·塞奇威克。这本书为你提供了一个易于阅读和理解的主题介绍。早在 20 世纪 90 年代,我在大学学习的时候,就有一个旧版本陪伴着我。

*   ***Java 中面向对象设计模式的数据结构和算法***【pre 00】Bruno r . Preiss。这本书提供了常见数据结构的坚实概述,并展示了如何用 Java 实现它们。因为它是在 2000 年编写的,所以没有使用泛型。然而,关于 Java 和数据结构,它是我最喜欢的。

*   ***数据结构和使用 Java 解决问题**马克·艾伦·维斯著。马克·艾伦·维斯的这本书提供了一个比前面提到的稍微更实用的方法。由于是在 2010 年出版,它使用了更现代的概念,比如泛型来实现数据结构。*

### 11.3.3 专门研究面试问题

除了前面提到的基础书籍,还有一些主要关注面试问题或小型编程任务的书籍:

*   ***前 30 名 Java 面试编码任务***【urb 18】作者马修·厄本。如果你没有很多时间,并且对背景信息不感兴趣,这本小册子绝对适合你。它使用单元测试来检查实现;不幸的是,它基于 JUnit 4,而不是更新的 JUnit 5。

*   ***日常编码问题***【MW19】亚历克斯·米勒和劳伦斯·吴。这是另一本书,提供了大量的信息和练习,包括算法和数据结构的解决方案。它专注于日常的小型编程任务,并且基于 Python。

### 11.3.4 顶级公司工作面试的补充材料

为了准备在一家顶级公司,即亚马逊、苹果、脸书、谷歌和微软的面试,我推荐以下几本书作为我的书的补充。其中一些书更深入,提供更棘手的任务或更多的背景知识。此外,他们都描述了面试过程本身以及如何准备面试。

*   ***破解编码采访***【MCD 16】盖尔·拉克曼·麦克道尔。这是一本非常有能力的作者写的伟大的书。不过,最好事先看一本关于算法的书,这样你更容易理解其中的解释。某些任务的难度在某些方面具有挑战性。

*   ***编程访谈曝光***【mkg 18】作者:约翰·蒙根、诺亚·金德勒、埃里克·吉古尔。除了算法和数据结构,这本书还涵盖了诸如并发性、设计模式和数据库等主题。它包含较少的练习,但很好的解释。这些解决方案以不同的编程语言呈现。

*   Adnan Aziz、Tsung-Hsien Lee 和 Amit Prakash 的***【Java 编程元素访谈】***【ALP 15】。这本书涵盖了许多不同的主题,尤其是数据结构和算法。

# 十二、JShell 快速入门

在这本书里,各种例子都是直接在控制台上尝试的。这主要是因为从版本 9 开始,Java 提供了交互式命令行应用程序 JShell 作为 REPL。在本附录中,您将简要了解 JShell。

## 1 Java + REPL => `jshell`

工具`jshell`与 Java 9 集成在 JDK 中。这个工具允许交互式的工作方式和小的源代码片段的执行,因为它已经为各种其他编程语言所熟悉。这也被称为 REPL(读取-评估-打印-循环)。因此,无需启动 IDE 和创建项目,就可以快速编写一些 Java 源代码并进行尝试。 <sup>1</sup> 受益的幅度当然值得商榷。然而,在`jshell`中输入分号是可选的,这很方便。对于第一次实验和原型开发,更大的优势是您不必担心处理异常,甚至不必担心检查异常。 <sup>2</sup> 这个你以后会看到的。

### 1.1 介绍性示例

让我们开始`jshell`并尝试一些动作和计算。一个 Hello World 示例的变体可以作为起点:

```java
> jshell
|  Welcome to JShell -- Version 14
|  For an introduction type: /help intro

jshell> System.out.println("Hello JShell")
Hello JShell

之后,您添加两个数字:

jshell> 2 + 2
$1 ==> 4

根据输出,您会看到jshell将计算结果分配给一个 shell 变量,该变量以$开始,这里是$1

您也可以定义自己的方法,如下所示:

jshell> int add(int a, int b) {
   ...>     return a + b;
   ...> }
|  created method add(int,int)

方便的是,jshell识别出语句不完整,在后续行中需要更多的输入。只有在完成后,消息created method add(int,int)才会出现。

定义完成后,可以按预期调用这样的方法。作为一项特殊功能,使用$1可获得之前计算的结果,如下所示:

jshell> add(3, $1)
$3 ==> 7

1.2 更多命令和可能性

命令/vars列出了当前定义的变量:

jshell> /vars
|    int $1 = 4
|    int $3 = 7

命令/methods显示定义的方法,这里是刚刚创建的方法add():

jshell> /methods
|    add (int,int)int

此外,jshell提供了命令的历史记录,这对于重复执行之前的命令很有用。要再次执行上一个命令,使用/!。当进入/list时,你会得到一个概览,从这里你可以用/<nr>执行<nr>th命令:

jshell> /list

   1 : System.out.println("Hello JShell")
   2 : 2+2
   3 :  int add(int a, int b) {
       return a+b;
       }
   4 : add(3, $1)

以下键盘快捷键简化了jshell中的编辑和导航:

  • Ctrl + A/E:跳到一行的开头/结尾

  • ↑/↓:光标键允许您浏览命令的历史记录。

  • /reset:清除命令历史

您可能不熟悉每种可能的调用变体,所以制表符补全非常方便。它提供了一组类似于 IDE 的可能的完成:

jshell> String.
CASE_INSENSITIVE_ORDER   class   copyValueOf(   format(   join(    valueOf(

jshell> Class.
class      forName(

1.3 使用语法特性和现代 Java 特性

指令

jshell> Thread.sleep(500)

证明了两件事。一方面,您认识到,和以前一样,可以省略jshell中语句的分号,另一方面,Thread.sleep()抛出的InterruptedException不必处理。

列表、集合和映射的定义也是可能的,甚至可以很轻松地使用新的集合工厂方法:

jshell> List<Integer> numbers = List.of(1,2,3,4,5,6,7)
numbers ==> [1, 2, 3, 4, 5, 6, 7]

jshell> Set<String> names = Set.of("Tim", "Mike", "Max")
names ==> [Tim, Max, Mike]

jshell> Map<String, Integer> nameToAge = Map.of("Tim", 41, "Mike", 42)
nameToAge ==> {Tim=41, Mike=42}

完成这些定义后,让我们再来看看变量:

jshell> /vars
|    int $1 = 4
|    int $3 = 7
|    List<Integer> numbers = [1, 2, 3, 4, 5, 6, 7]
|    Set<String> names = [Tim, Max, Mike]
|    Map<String, Integer> nameToAge = {Tim=41, Mike=42}

1.4 更复杂的动作

除了上面显示的相当琐碎的动作之外,jshell还允许更复杂的计算,甚至是类的定义。

包括其他 JDK 类默认情况下,jshell只能从模块java.base中访问类型,但这对于几次初始实验来说通常已经足够了。但是如果你想使用 Swing 或 JavaFX 类,例如,你需要一个导入,像JFrame等类型的import javax.swing.*。如果没有此导入,您会收到以下错误消息:

jshell> new JFrame("Hello World")
|  Error:
|  cannot find symbol
|    symbol:   class JFrame
|  new JFrame("Hello World")
|      ^----^

这就是为什么您现在开始导入:

import javax.swing.*

jshell> new JFrame("Hello World")
$2 ==> javax.swing.JFrame[frame0,0,23,0x0,invalid,hidden,layout=java.awt.BorderLayout,title=Hello World,resizable,normal,defaultCloseOperation=HIDE_ON_CLOSE,rootPane=javax.swing.JRootPane[,0,0,0x0,invalid,layout=javax.swing.JRootPane$RootLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=16777673,maximumSize=,minimumSize=,preferredSize=],rootPaneCheckingEnabled=true]

$2.setSize(200, 50)
$2.show()

上面的命令序列将构建并显示一个大小为 200 x 50 的窗口。它应该看起来像图 A-1 。

img/519691_1_En_12_Fig1_HTML.jpg

图 A-1

从 jshell 开始的简单 Swing 窗口

1.5 退出 JShell

最后可以用/ exit退出jshell

Hint: The Jshell and Some Special Characteristics

jshell不完全支持final关键字。对于publicstatic来说也是如此。

jshell> final URI uri = new URI("https://www.oracle.com/index.html");
|  Warning:
|  Modifier 'final' not permitted in top-level declarations, ignored
|  final URI uri = new URI("https://www.oracle.com/index.html");
|  ^---^
uri ==> https://www.oracle.com/index.html

十三、JUnit 5 简介

JUnit 是用 Java 编写的框架,支持测试用例的创建和自动化。它很容易学习,并且需要大量的工作来编写和管理测试用例。特别是,只需要实现测试用例本身的逻辑。因此,该框架支持各种方法,利用这些方法可以建立和评估测试断言。

1 编写和运行测试

1.1 示例:第一个单元测试

为了测试一个应用程序类,通常会编写一个相应的测试类。通常,您会通过测试一些核心方法来验证自己的类的重要功能。这是可取的逐步延长。测试用例被表示为特殊的测试方法,必须用注释@Test标记,并且不能定义返回类型。否则,它们不会被 JUnit 视为测试用例,在测试执行过程中会被忽略。

让我们看一个简单的例子,它仅仅说明了所说的内容,但是还没有测试任何功能;相反,它只是提供了一个基本框架:

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

class FirstTestWithJUunit5
{
    @Test
    void test()
    {
        fail("Not yet implemented");
    }
}

注释@Test来自包org.junit.jupiter.api,而fail()是从类org.junit.jupiter.api.Assertions导入的。后者是静态导入的,以便在调用测试方法时允许更短的符号和更好的可读性。

1.2 编写和运行测试的基础

现在你知道评估条件的方法了。类Assertions提供了一组测试方法,可以用来表达条件,从而检查关于被测源代码的断言:

  • 重载方法assertTrue()assertFalse()允许你检查布尔条件。前一种方法假设条件的计算结果为true。反之对assertFalse()有效。

  • 使用重载方法assertNull()assertNotNull()方法,可以检查null或不等于null的对象引用。

  • 重载方法assertEquals()检查两个对象的内容是否相等(调用equals(Object))或者两个原始类型的变量是否相等。由于floatdouble类型的计算中可能存在舍入误差,因此可以注意到与预期值的最大偏差。

  • 使用重载方法assertSame()assertNotSame()根据==检查对象引用是否相等。

  • 使用fail(),有可能故意让测试用例失败。这有时对于能够对意外情况做出反应是有用的。

  • JUnit 5 通过使用assertThrows()方法提供了一种检查预期测试用例是否失败的简洁方法。

下面的代码(JUnit5ExampeTest)展示了一些正在使用的方法。请注意,在本例中,各种测试方法会故意引发错误:

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

import java.util.List;

public class JUnit5ExampleTest
{
    @Test
    public void testAssertTrue()
    {
        final List<String> names = List.of("Max", "Moritz", "Tom");

        assertTrue(names.size() > 2);
    }

    @Test
    public void testAssertFalse()
    {
        final List<Integer> primes = List.of(2, 3, 5, 7);

        // an error is intentionally provoked here
        assertFalse(primes.isEmpty());
    }

    @Test
    public void testAssertNull()
    {
        assertNull(null);
    }

    @Test
    public void testAssertNotNull()
    {
        // an error is intentionally provoked here
        assertNotNull(null, "Unexpected null value");
    }

    @Test
    public void testAssertEquals()
    {
        assertEquals("EXPECTED", "expected".toUpperCase());
    }

    @Test
    public void testAssertEqualsWithPrecision()
    {
        assertEquals(2.75, 2.74999, 0.1);
    }

    @Test
    public void testFailWithExceptionJUnit5()
    {
        assertThrows(java.lang.NumberFormatException.class, () ->
        {
            // an error is intentionally provoked here
            final int value = Integer.parseInt("Fehler simulieren!");
        });
    }
}

用 assertAll()测试多个断言

当制定测试用例时,经常需要检查多个条件,例如地址的各个部分。在 JUnit 5 中,这种语义分类和所有断言的执行都可以通过方法assertAll()来实现:

@Test
void assertAachenZipAndCityAndCountry()
{
    final Address address = // ...

     assertAll("Address components",
               () -> assertEquals(52070, address.getZipCode()),
               () -> assertEquals("Aachen", address.getCity()),
               () -> assertEquals("Deutschland", address.getCountry()));
}

测试执行

JUnit 与流行的 ide 完美集成。这允许直接从 IDE 中执行测试。要执行测试,您可以使用 GUI 中的上下文菜单或按钮。输出类似于图 B-1 中所示的输出。红色条表示错误。理想情况下,你会看到一个令人放心的绿色,报告所有测试用例成功完成。

img/519691_1_En_13_Fig1_HTML.jpg

图 B-1

从 IDE 的 GUI 执行测试

Eclipse 插件 MoreUnit

即使 JUnit 与 Eclipse 很好地集成,测试用例不仅可以执行,甚至可以调试,仍然有改进的空间。比如执行单元测试的键盘快捷键(Alt+Shift+X,T)就相当笨拙。Eclipse 插件 MoreUnit 解决了这个问题和其他问题。它可以在 Eclipse Marketplace 中免费安装,并提供以下特性:

  • MoreUnit 提供了执行(Ctrl+R)和在类的实现和单元测试之间切换(Ctrl+J)的键盘快捷键。如果没有可用的测试,Ctrl+J 会打开一个对话框来创建相应的单元测试。

  • 会显示一个图标装饰,以便您可以直接在包资源管理器中看到某个类是否存在测试(由绿点指示)。

  • 在重构过程中,类和相应的测试类彼此同步移动或重命名。

1.3 用 assertThrows()处理预期异常

有时,测试用例应该检查处理过程中异常的出现,缺少测试用例就意味着错误。一个例子是故意访问数组中不存在的元素。一个ArrayIndexOutOfBoundsException应该是结果。为了处理测试用例中的预期异常,使它们代表测试成功而不是失败,有几种替代方法。

从 JUnit 5 开始,通过使用方法assertThrows(),处理测试用例中的异常变得更加容易。如果执行的方法没有引发预期的异常,它将失败(产生测试失败)。此外,该方法返回触发的异常,以便可以执行进一步的检查,例如,异常的文本中是否包含期望的和预期的信息。以下代码可作为AssertThrowsTest执行:

public class AssertThrowsTest
{
    @Test
    public void arrayIndexOutOfBoundsExceptionExpected()
    {
        var numbers = new int[] { 1, 2, 3, 4, 5, 6, 7 };

        final Executable action = () ->
        {
             numbers[1_000] = 13;
        };

        assertThrows(ArrayIndexOutOfBoundsException.class, action);
    }

    @Test
    public void illegalStateExceptionWithMessageTextExpected()
    {
        final String errorMsg = "XYZ is not initialized";

        final Executable action = () ->
        {
            throw new IllegalStateException(errorMsg,
                                            new IOException("IO"));

        };

        final IllegalStateException exception =
                                    assertThrows(IllegalStateException.class,
                                                action);
        assertEquals(errorMsg, exception.getMessage());
        assertEquals(IOException.class, exception.getCause().getClass());
    }
}

在第二个测试案例中,很明显访问异常的内容是多么容易(例如,检查文本或其他细节)。

使用 JUnit 5 的 2 个参数化测试

在某些情况下,您必须测试大量的值。如果您必须为它们中的每一个创建单独的测试方法,这将会使测试类变得相当臃肿和混乱。为了更优雅地解决这个问题,有几种变体。所有这些都有其特定的优势和劣势。

在下文中,假设计算使用固定范围的值或一组选定的输入。 1

2.1 JUnit 5 参数化测试简介

使用 JUnit 5,定义参数化测试相当简单。让我们从这样一个场景开始,您只想为您的测试方法指定参数,而不想传递结果。当只需要测试一个条件时,例如一个字符串是否为非空或者一个数字是否为质数,这是很方便的。您可以使用注释@ParameterizedTest@ValueSource对一小组给定的输入进行这两种操作,如下所示:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

// a few errors are produced for demonstration purposest
public class FirstParameterizedTest
{
    @ParameterizedTest(name = "run {index}: ''{0}'' is not empty")
    @ValueSource(strings = { "Tim", "Tom", "", "Mike" })
    void isNotEmpty(String value)
    {
        assertFalse(value.isEmpty());
    }

    @ParameterizedTest(name = "run {index}: {0} is a prime")
    @ValueSource(ints = { 1, 2, 3, 4, 5, 6, 7 })
    void ensureIsPrime(int value)
    {
        assertTrue(MathUtils.isPrime(value));
    }
}

代码显示每个参数化测试必须用@ParameterizedTest进行注释。作为参数,您可以在name属性中使用带有占位符的字符串。占位符的含义如下:{index}对应测试数据中的指标,{0}{1}{2}等。所有都分别引用参数和相应的数据元素。更常见的是,会有几个输入,正如您将在下面看到的。此外,测试生成器需要知道要测试哪些输入。该信息可以由@ValueSource指定。在这个例子中,您使用了字符串和int值的专门化。此外,longdouble还有预定义的变体。

让我们快速看一下这一切是如何进行的。为每个指定的参数创建并执行一个单独的测试用例。从图 B-2 中,您可以看到这在 Eclipse 中的样子。请记住,出于演示目的,我们已经包含了一些测试错误。

img/519691_1_En_13_Fig2_HTML.jpg

图 B-2

程序的参数化测试用例FirstParameterizedTest

2.2 更实用的参数化测试

然而,实际上,几乎所有的测试都需要一组输入和结果。注释@CsvSource可以对此有所帮助。可以为测试方法的各个输入或参数的所需组合创建独立的逗号分隔数据。合理地,第一个或者最好是最后一个参数代表预期的结果。如果使用最后一个参数,这更符合(欧洲)从左到右的思维方式。

在下面,我展示了一个可能的参数化来反转一个字符串:

@ParameterizedTest(name = "reverse({0}) => {1}")
@CsvSource({ "ABCD, DCBA", "OTTO, OTTO", "PETER, RETEP" })
void testReverse(final String input, final String expectedOutput)
{
    final String result = Ex03_ReverseStringV1.reverse(input);

    assertEquals(expectedOutput, result);
}

另一个例子是两个值相加,包括预期结果。在这里,您会发现文本值会自动转换为参数所使用的类型:

@ParameterizedTest(name = "{index}: {0} + {1} = {2}")
@CsvSource({ "1, 1, 2", "2, -2, 0", "3, 4, 7" })
void testAdd(int first, int second, int expected)
{
    int sum = first + second;

    assertEquals(expected, sum);
}

例如,对于日期和时间 API 的类型,有一些巧妙的预定义转换,您可以用它们编写可理解的测试。在下面的代码中,这是为确定季度中的第一天而显示的:

@ParameterizedTest
@CsvSource({ "2014-03-15, 2014-01-01", "2014-06-16, 2014-04-01",
             "2014-09-15, 2014-07-01", "2014-11-15, 2014-10-01"})
void adjustToFirstDayOfQuarter(LocalDate startDate, LocalDate expected)
{
    final Temporal result = new Ex10_FirstDayOfQuarter().adjustInto(startDate);

    assertEquals(expected, result);

}

2.3 使用@MethodSource 的 JUnit 参数化测试

提供这些值还有一个潜在的困难。有时,值的文本规范会变得混乱,或者是不可能的(例如,对于列表)。这是为输入值列表和预期结果显示的。因此,有另一种方法来提供数据作为一个静态方法返回的Stream<Arguments>。方法名用@MethodSource指定。不幸的是,这只能在文本上实现,但链接仍然非常直观。

@ParameterizedTest(name = "removeDuplicates({0}) = {1}")
@MethodSource("listInputsAndExpected")
void removeDuplicates(List<Integer> inputs, List<Integer> expected)
{
    List<Integer> result = Ex02_ListRemove.removeDuplicates(inputs);

    assertEquals(expected, result);
}

static Stream<Arguments> listInputsAndExpected()
{
    return Stream.of(Arguments.of(List.of(1, 1, 2, 3, 4, 1, 2, 3),
                                  List.of(1, 2, 3, 4)),
                     Arguments.of(List.of(1, 3, 5, 7),
                                  List.of(1, 3, 5, 7)),
                     Arguments.of(List.of(1, 1, 1, 1),
                                  List.of(1)));
}

十四、O 符号快速入门

在本书中,所谓的 O-notation 是用来对算法的运行时间进行分类的。这允许对算法的复杂性进行更正式的分类。

1 使用 O 符号的估计值

要估计和描述算法的复杂性并对它们的运行时行为进行分类,总是进行度量是不切实际的。此外,测量仅反映在硬件(处理器时钟、存储器等)的某些限制下的运行时间行为。).为了能够独立于这些细节并在更抽象的层面上对设计决策的后果进行分类,计算机科学使用了所谓的 O-notation ,这表明了算法复杂性的上限。为了做到这一点,人们希望能够回答下面的问题:当不是处理 1000 个输入值,例如,处理 10000 或 100000 个输入值时,程序如何执行?要回答这个问题,必须考虑算法的各个步骤并进行分类。目的是形式化复杂性的计算,以估计输入数据数量的变化对程序运行时间的影响。

考虑以下while循环作为介绍性示例:

int i = 0;                      // O(1)
while (i < n)                   // O(n)
{
    createPersonInDb(i);        // O(1)
    i++;                        // O(1)
}

任何单个指令都被赋予了 O (1)的复杂度。由于循环体的 n 执行,循环本身被赋予复杂度 O ( n )。 1 将这些值加在一起,那么运行程序的成本就是O(1)+O(n)∫(O(1)+O(1))=O(1)+O(n)∫对于复杂性的估计,常数和因子并不重要。只对 n 的最高功率感兴趣。因此,对于程序的图示部分,您得到的复杂度为 O ( n )。这种简化是允许的,因为对于较大的 n 值,因子和较小复杂度等级的影响是不明显的。为了理解以下各节中的注意事项,这个非正式的定义应该足够了。

下面,我想引用罗伯特·塞奇威克的两句话来描述 O-符号,这两句话摘自他的标准著作算法[sed 92]:“...O 符号是指定运行时间上限的有用工具,它独立于输入数据的细节和实现。”它进一步指出,“在帮助分析师根据算法的性能对算法进行分类方面,以及在帮助算法搜索最佳算法方面,O 符号被证明是非常有用的。”

1.1 复杂性类别

为了能够相互比较不同算法的运行时行为,七个不同的复杂性类别通常就足够了。下表列出了各自的复杂性类别和一些示例:

  • O (1):恒定复杂度导致复杂度与输入数据的数量 n 无关。这种复杂性通常代表一条指令或者由几个计算步骤组成的简单计算。

  • O ( log ( n ):对数复杂度下,输入数据集 n 的平方时运行时间翻倍。这种复杂性的一个众所周知的例子是二分搜索法。

  • O ( n ):在线性复杂度的情况下,运行时间与元素数量 n 成正比增长。简单的循环和迭代就是这种情况,比如在数组或列表中进行搜索。

  • O ( n )。 log ( n ):这种复杂性是线性和对数增长的结合。一些最快的排序算法(例如 Mergesort)显示了这种复杂性。

  • O(n2):当输入数据量翻倍 n 时,二次复杂度导致运行时间翻两番。输入数据的十倍增长已经导致运行时间的百倍增长。实际上,这种复杂性是用两个嵌套的 for while 循环发现的。简单的排序算法通常具有这种复杂性。

  • O ( n 3 ):以立方复杂度来说, n 翻倍已经导致运行时间增加八倍。矩阵的简单乘法就是这种复杂性的一个例子。

  • O (2 n ):指数复杂度导致在运行时间的平方中 n 翻倍。起初,这听起来并不多。但是,如果增加 10 倍,运行时间将增加 200 亿倍!指数复杂性经常出现在优化问题中,例如所谓的旅行推销员问题,其目标是在访问所有城市的同时找到不同城市之间的最短路径。为了解决运行时间过长的问题,程序使用试探法,这种方法可能找不到最优解,只是一个近似解,但是复杂度低得多,运行时间也短得多。

表 [C-1 令人印象深刻地显示了对于不同的输入数据组 n 所提到的复杂性类别的影响。22

表 C-1

不同时间复杂性的影响

|

N

|

O ( 日志 ( n ))

|

O ( n

|

O ( n )。日志 ( n ))

|

O(n2

|

O(n3

|
| --- | --- | --- | --- | --- | --- |
| Ten | one | Ten | Ten | One hundred | One |
| One hundred | Two | One hundred | Two hundred | Ten | 1.000.000 |
| One | three | One | Three | 1.000.000 | 1.000.000.000 |
| Ten | four | Ten | Forty | 100.000.000 | 1.000.000.000.000 |
| One hundred | five | One hundred | Five hundred | 10.000.000.000 | 1.000.000.000.000.000 |
| 1.000.000 | six | 1.000.000 | 6.000.000 | 1.000.000.000.000 | 1.000.000.000.000.000.000 |

根据显示的值,您可以感受不同复杂性的影响。大约到了 O ( n )。 log ( n ))复杂度等级有利。虽然对于许多算法来说是无法实现的,但最佳和理想的是复杂度 O (1)和O(log(n))。已经O(n2)通常不适合较大的输入集,但它可以用于简单的计算和较小的 n 值,没有任何问题。

NOTE: INFLUENCE OF INPUT DATA

根据输入数据的不同,某些算法的行为会有所不同。对于快速排序,一般情况下的复杂度为 nlog ( n ,但这在极端情况下可以增加到n2。由于 O 符号描述了最差情况,快速排序被赋予了复杂度O(n2)。

*### 1.2 复杂性和程序运行时间

对于一组输入值 n ,通过特殊的 O 复杂度计算出的数字有时可能令人望而生畏。尽管如此,他们并没有提到实际的执行时间,而只是提到了当输入集增加时它的增长。正如已经基于介绍性示例的那样,O-符号没有声明单个计算步骤的持续时间:增量i++和数据库访问createPersonInDb(i)都被评定为 O (1),即使数据库访问比关于执行时间的增量贵几个数量级。

对于普通指令,无需访问外部系统,如文件系统、网络或数据库(即添加、分配等)。),在许多情况下, n 的影响对于今天具有用户交互的典型商业应用的计算机来说不是决定性的。对于小的 n ( < 1000)在复杂度 O ( n )或O(n2)甚至有时在O(n3)时,对实际运行时间的影响几乎无关紧要——但这并不意味着你不应该使用尽可能优化的算法相反,反之亦然:您也可以从第一个功能正确的实现开始,并将其投入生产。优化版本可能会在稍后推出。

总而言之,我想再次强调,即使是复杂度为 O ( n 2 )或 O ( n 3 )的多重嵌套循环,从绝对意义上来说,其执行速度通常也比网络上一些复杂度为 O ( n )的数据库查询快得多。对于数组中的搜索( O ( n ))和对基于散列的数据结构的元素的访问( O (1))也是如此。对于小的 n ,哈希值的计算可能比线性搜索花费更长的时间。然而, n 越大,越差的复杂度类对实际运行时间的影响就越大。

*

第一部分:基础知识

第二部分:更高级、更棘手的话题

第三部分:附录

posted @ 2024-08-06 16:39  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报