Java 代码面试完全指南(三)

原文:zh.annas-archive.org/md5/2AD78A4D85DC7F13AC021B920EE60C36

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:位操作

本章涵盖了位操作的最重要方面,这些方面在技术面试中是必须了解的。这类问题在面试中经常遇到,而且并不容易。人类的大脑并不是为了操作位而设计的;计算机是为了这个而设计的。这意味着操作位相当困难,而且极易出错。因此,建议始终仔细检查每个位操作。

掌握这些问题的两个极其重要的事情如下:

  • 您必须非常了解位的理论(例如,位运算符)

  • 您必须尽可能多地练习位操作

在我们解决以下主题时,我们需要牢记这两个陈述:

  • 理解位操作

  • 编码挑战

让我们从理论部分开始。强烈建议您从本节中提取图表。它们将是本章第二部分中最好的朋友。

技术要求

本章中的所有代码都可以在 GitHub 上找到github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter09

位操作简介

在 Java 中,我们可以操作以下数据类型的位:byte(8 位)、short(16 位)、int(32 位)、long(64 位)和char(16 位)。

例如,让我们使用正数 51。在这种情况下,我们有以下陈述:

  • 51 的二进制表示是 110011。

  • 因为 51 是一个int,它被表示为一个 32 位的值;也就是说,32 个 1 或 0 的值(从 0 到 31)。

  • 110011 左边的所有位置实际上都填满了零,总共 32 位。

  • 这意味着 51 是 00000000 00000000 00000000 00110011(我们将其渲染为 110011,因为通常不需要额外的零来显示二进制表示)。

获取 Java 整数的二进制表示

我们如何知道 110011 是 51 的二进制表示?我们如何计算 112 或任何其他 Java 整数的二进制表示?一个简单的方法是不断地将数字除以 2,直到商小于 1,并将余数解释为 0 或 1。余数为 0 被解释为 0,而大于 0 的余数被解释为 1。例如,让我们将这个应用到 51:

  1. 51/2 = 25.5,商为 25,余数为 5 -> 存储 1

  2. 25/2 = 12.5,商为 12,余数为 5 -> 存储 1

  3. 12/2 = 6,商为 6,余数为 0 -> 存储 0

  4. 6/2 = 3,商为 3,余数为 0 -> 存储 0

  5. 3/2 = 1.5,商为 1,余数为 5 -> 存储 1

  6. 1/2 = 0.5,商为 0,余数为 5 -> 存储 1

所以,我们存储了 110011,这是 51 的二进制表示。其余的 26 位都是零(00000000 00000000 00000000 00110011)。反向过程从右到左开始,涉及在位等于 1 的地方添加 2 的幂。所以这里,51 = 20+21+24+25。以下图表可以帮助我们理解这一点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.1 - 二进制到十进制(32 位整数)

在 Java 中,我们可以通过Integer#toString(int i, int radix)Integer#toBinaryString(int i)快速查看数字的二进制表示。例如,基数为 2 表示二进制:

// 110011
System.out.println("Binary: " + Integer.toString(51, 2));
System.out.println("Binary: " + Integer.toBinaryString(51));

反向过程(从二进制到十进制)可以通过Integer#parseInt(String nr, int radix)获得:

System.out.println("Decimal: " 
  + Integer.parseInt("110011", 2));  //51

接下来,让我们来解决位运算符。这些运算符允许我们操作位,因此理解它们非常重要。

位运算符

操作位涉及几个运算符。这些运算符如下:

  • 一元按位补码运算符[~]:作为一元运算符,此运算符需要一个放置在数字之前的单个操作数。此运算符取数字的每一位并翻转其值,因此 1 变为 0,反之亦然;例如,5 = 101,~5 = 010。

  • 按位与[&]:此运算符需要两个操作数,并放置在两个数字之间。此运算符逐位比较两个数字的位。它充当逻辑 AND(&&),意味着只有在比较的位都等于 1 时才返回 1;例如,5 = 101,7 = 111,5 & 7 = 101 & 111 = 101 = 5。

  • 按位或[|]:此运算符需要两个操作数,并放置在两个数字之间。此运算符逐位比较两个数字的位。它充当逻辑 OR(||),意味着如果至少有一个比较的位为 1(或两者都是),则返回 1。否则返回 0;例如,5 = 101,7 = 111,5 | 7 = 101 | 111 = 111 = 7。

  • 按位异或(XOR)[^]:此运算符需要两个操作数,并放置在两个数字之间。此运算符逐位比较两个数字的位。只有在比较的位具有不同的值时才返回 1。否则返回 0;例如,5 = 101,7 = 111,5 ^ 7 = 101 | 111 = 010 = 2。

以下图是一个方便的工具,当你需要处理位时,应该随时保持接近。基本上,它总结了位运算符的工作原理(我建议你在阅读编码挑战部分时将此表格保持在附近):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.2 - 按位操作符

此外,以下图表示了一些对于操作位非常有用的提示。0s 表示一系列零,而 1s 表示一系列 1:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.3 - 按位操作提示

慢慢来,探索每一个提示。拿一张纸和一支笔,逐个浏览。此外,也尝试发现其他提示。

位移操作符

在处理位时,移位是一种常见的操作。这里有byte(8 位)、short(16 位)、int(32 位)、long(64 位)和char(16 位);位移操作符不会抛出异常。

有符号左移[<<]

有符号左移,或简称左移,需要两个操作数。左移获取第一个操作数(左操作数)的位模式,并将其向左移动由第二个操作数(右操作数)给出的位置数。

例如,以下是将 23 左移 3 个位置的结果,23 << 3:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.4 - 有符号左移

正如我们所看到的,整数 12(10111)的每一位都向左移动 3 个位置,而右边的所有位置都自动填充为零。

重要提示

以下是在某些情况下可能非常有用的两个提示:

  1. 将一个数字左移n个位置等同于乘以 2n(例如,23 << 3 等同于 184,等同于 184 = 23 * 23)。

  2. 要移位的位置数自动减少为模 32;也就是说,23 << 35 等同于 23 << (35 % 32),等同于 23 << 3。

Java 中的负整数

首先,重要的是要记住,二进制表示本身并不能告诉我们一个数字是否为负数。这意味着计算机需要一些规则来表示负数。通常,计算机以所谓的二进制补码表示存储整数。Java 也使用这种表示。

简而言之,二进制补码表示将负数的二进制表示取反(否定)所有位。之后,加 1 并将其附加到位符号的左侧。如果最左边的位为 1,则数字为负数。否则,它为正数。

让我们以 4 位整数-5 为例。我们有一位用于符号,三位用于值。我们知道 5(正数)表示为 101,而-5(负数)表示为1011。这是通过翻转 101 得到的,使其变为 010,加 1 得到 011,并将其附加到符号位(1)的左侧以获得1011。粗体中的 1 是符号位。所以我们有一位用于符号,三位用于值。

另一种方法是知道*-Q*(负Q)的二进制表示作为n位数是通过将 1 与 2n - 1 - Q连接起来获得的。

右移签名[>>]

签名右移,或算术右移[>>],需要两个操作数。签名右移获取第一个操作数(左操作数)的位模式,并通过保留符号将其向右移动给定的位置数(右操作数)。

例如,-75 >> 1 的结果如下(-75 是一个 8 位整数,其中符号位是最高有效位MSB)):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.5 - 签名右移

正如我们所看到的,-75(10110101)的每一位都向右移动了 1 个位置(请注意最低有效位LSB)已经改变),并且位符号被保留。

重要提示

以下是在某些情况下可能非常有用的三个提示:

将一个数字向右移动n个位置等同于除以 2n(例如,24 >> 3 等于 3,这等同于 3 = 24/23)。

要移动的位置数自动减少到模 32;也就是说,23 >> 35 等同于 23 >> (35 % 32),这等同于 23 >> 3。

在(有符号)二进制术语中,一系列 1 代表十进制形式的-1。

无符号右移[>>>]

无符号右移,或逻辑右移[>>>],需要两个操作数。无符号右移获取第一个操作数(左操作数)的位模式,并通过右操作数给定的位置数将其向右移动。MSB 设置为 0。这意味着对于正数,有符号和无符号右移返回相同的结果,而负数总是变为正数。

例如,-75 >>> 1 的结果如下(-75 是一个 8 位整数,其中符号位是 MSB):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.6 - 无符号右移

重要提示

要移动的位置数自动减少到模 32;也就是说,23 >>> 35 等同于 23 >>> (35 % 32),这等同于 23 >>> 3。

现在你已经了解了位移操作符是什么,是时候去探索更多的技巧和窍门了。

技巧和窍门

当使用位操作符并知道一些技巧和窍门时,操作位需要很大的技巧。在本章的前面,你已经看到了一些技巧。现在,让我们将一些更多的技巧添加为项目符号列表:

  • 如果我们对一个数字进行偶数次异或[^],那么结果就是 0(x ^ x = 0;x ^ x ^ x^ x = (x ^ x) ^ (x ^ x) = 0 ^ 0 = 0)。

  • 如果我们对一个数字进行奇数次异或[^],那么结果就是那个数字(x ^ x ^ x = (x ^ (x ^ x)) = (x ^ 0) = x;x ^ x ^ x ^ x ^ x = (x ^ (x ^ x) ^ (x ^ x)) = (x ^ 0 ^ 0) = x)。

  • 我们可以计算表达式p % q的值,其中p > 0,q > 0,q是 2 的幂;也就是p & (q - 1)。一个简单的应用程序,你可以在ComputeModuloDivision中看到这一点。

  • 对于给定的正整数p,如果((p & 1) != 0)则我们说它是奇数,如果((p & 1) == 0)则我们说它是偶数。一个简单的应用程序,你可以在OddEven中看到这一点。

  • 对于给定的两个数字pq,我们可以说p等于q,如果((p ^ q) == 0)。一个简单的应用程序,你可以在CheckEquality中看到这一点。

  • 对于两个给定的整数pq,我们可以通过p = p ^ q ^ (q = p)来交换它们。一个简单的应用程序,你可以在SwapTwoIntegers中看到这一点。

好的,现在是时候解决一些编码挑战了。

编码挑战

在接下来的 25 个编码挑战中,我们将利用位操作的不同方面。由于这些问题确实很费脑子,所以在面试中更受青睐。理解操纵位的代码片段并不是一件容易的事情,所以请花时间分析每个问题和代码片段。这是解决这类问题的唯一方法,以获得一些模式和模板。

以下图包含了一组四个重要的位掩码,这些位掩码对于需要操作位的各种问题是有用的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.7 - 位掩码

它们对于解决需要操作位的各种问题是有用的。

编码挑战 1 - 获取位值

问题:考虑一个 32 位整数n。编写一小段代码,返回给定位置kn的位值。

解决方案:让我们假设n=423。它的二进制表示是 110100111。我们如何说出位置k=7 的位的值(位置 7 的粗体位的值为 1)?一个解决方案将包括将给定的数字右移k位(n >> k)。这样,第k位就变成了位置 0 的位(110100111 >> 7 = 000000011)。接下来,我们可以应用 AND [&]操作符,如 1 & (n >> k):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.8 - 二进制表示

如果位置 0 的位值为 1,则 AND[&]操作符将返回 1;否则,它将返回 0。在代码方面,我们有以下内容:

public static char getValue(int n, int k) {
  int result = n & (1 << k);
  if (result == 0) {
    return '0';
  }
  return '1';
}

另一种方法是用表达式n & (1 << k)替换表达式 1 & (n >> k)。花点时间来分析它。完整的应用程序称为GetBitValue

编码挑战 2 - 设置位值

亚马逊谷歌Adobe微软Flipkart

问题:考虑一个 32 位整数n。编写一小段代码,将n在给定位置k的位值设置为 0 或 1。

解决方案:让我们假设n=423。它的二进制表示是 110100111。我们如何将位置k=7 的位,现在为 1,设置为 0?将位操作符表放在我们面前有助于我们看到 AND[&]操作符是唯一一个允许我们写 1 & 0 = 0 或第 7 位 & 0 = 0 的操作符。此外,我们有 1 & 1 = 1,0 & 1 = 0 和 0 & 0 = 0,所以我们可以取一个位掩码为 1…101111111 并写如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.9 - 二进制表示

这正是我们想要的。我们想把第 7 位从 1 变成 0,其他位保持不变。但是我们如何获得 1…101111…掩码?嗯,有两个位掩码你需要知道。首先,一个位掩码,有一个 1,其余都是 0(10000…)。这可以通过将 1 左移k位来获得(例如,位掩码 1000 可以通过 1 << 3 获得,尽管如果我们将其表示为 32 位掩码,我们得到 00000000 00000000 00000000 0001000)。另一个位掩码包含一个 0,其余都是 1(01111…)。这可以通过对位掩码 10000…应用一元位求反操作符[]来获得(例如,(1000) = 0111,尽管如果我们将其表示为 32 位掩码,我们得到 11111111 11111111 11111111 1110111)。因此,我们可以将 1…101111…位掩码获得为~(1 << k)。最后,我们所要做的就是使用 AND[&]操作符,如下面的代码所示:

public static int setValueTo0(int n, int k) {       
  return n & ~(1 << k);
}

如果我们取k=3, 4, 或 6,那么我们得到 0 & 0 = 0。

让我们考虑n=295。它的二进制表示是 100100111。我们如何设置位置k=7 的位,现在是 0,变为 1?在我们面前有位运算符表有助于我们看到,OR[|]和 XOR[^]运算符是允许我们写成 0|1=1 或 0¹=1 的两个操作数的运算符。

或者,我们可以写成第 7 个|1=1 和第 7 个¹=1。

再进一步,我们可以看到在 OR[|]运算符的情况下,我们可以写成以下内容:

1|1=1,而在 XOR[^]运算符的情况下,我们写 1¹=0。

由于我们想要将第 7 位的值从 0 变为 1,我们可以使用这两个运算符中的任何一个。然而,如果k指示具有初始值 1 的位,那么 1¹=0 就不再帮助我们了,而 1|1=1 正是我们想要的。所以在这里,我们应该使用 10000…位掩码,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.10-二进制表示

在代码方面,我们有以下内容:

public static int setValueTo1(int n, int k) {       
  return n | (1 << k);
}

如果我们取k=0, 1, 2, 5, 或 8,那么我们得到 1|1=1。

完整的应用程序称为SetBitValue

编码挑战 3-清除位

亚马逊谷歌Adobe

问题:考虑一个 32 位整数n。编写一小段代码,清除n之间的位(将它们的值设置为 0)MSB 和给定的k之间。

解决方案:让我们考虑n=423。它的二进制表示是110100111。我们如何清除 MSB 和位置k=6 之间的位,以便有 110 位?在我们面前有位运算符表有助于我们看到,我们需要一个类型为 00011111 的位掩码。让我们看看如果我们在n和这个位掩码之间应用 AND[&]运算符会发生什么:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.11-二进制表示

所以,我们清除了 MSB 和k=6 之间的位。一般来说,我们需要一个包含 MSB 和k(包括)之间的 0 和k(不包括)和 LSB 之间的 1 的位掩码。我们可以通过将 1 的位左移k位(例如,对于k=6,我们得到 1000000)并减去 1 来实现这一点。这样,我们就得到了所需的位掩码,0111111。因此,在代码方面,我们有以下内容:

public static int clearFromMsb(int n, int k) {        
  return n & ((1 << k) - 1);
}

如何清除给定k和 LSB 之间的位?让我向你展示代码:

public static int clearFromPosition(int n, int k) {        
  return n & ~((1 << k) - 1);
}

现在,花点时间来分解这个解决方案。此外,我们可以用这个解决方案替换这个解决方案:n & (-1 << (k + 1))。

再次使用纸和笔一步一步地进行。完整的应用程序称为ClearBits

编码挑战 4-在纸上求二进制和

问题:考虑几个正的 32 位整数。拿一支笔和一些纸,向我展示如何求它们的二进制表示。

注意:这不完全是一个编码挑战,但了解这一点很重要。

解决方案:求和二进制数可以用几种方法来完成。一个简单的方法是做以下操作:

  1. 求当前列的所有位之和(第一列是 LSB 的列)。

  2. 将结果转换为二进制(例如,通过连续除以 2)。

  3. 保留最右边的位作为结果。

  4. 将剩余的位带入剩余的列(每列一个位)。

  5. 转到下一列并重复从步骤 1开始。

一个例子将澄清事情。让我们加 1(1)+9(1001)+29(011101)+124(1111100)=163(10100011)。

以下图表代表了这些数字相加的结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.12-求和二进制数

现在,让我们一步一步地看(粗体部分是进行的):

  • 在第 0 列上求和位:1+1+1+0=3=11 1

  • 在第 1 列上求和位:1+0+0+0=1=1 1

  • 在第 2 列上求和位:0+1+1=2=10 0

  • 在第 3 列上求和位:1+1+1+1=4=100 0

  • 在第 4 列上求和位:0+1+1=2=10 0

  • 在第 5 列上求和位:1+1+0+1=3=11 1

  • 在第 6 列上求和位:1+1=2=10 0

  • 在第 7 列上求和位:1=1=1 1

因此,结果是 10100011。

编码挑战 5 - 代码中的二进制求和

问题:考虑两个 32 位整数qp。编写一小段代码,使用它们的二进制表示来计算q + p

解决方案:我们可以尝试实现前面编码挑战中提出的算法,或者我们可以尝试另一种方法。这种方法引入了一个有用的等式:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意到 AND[&]和 XOR[^]位运算符的存在。如果我们用and表示p & q,用xor表示p ^ q,那么我们可以写成如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果pq没有共同的位,那么我们可以将其简化为以下形式:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

例如,如果p=1010,q=0101,那么p & q=0000。由于 20000=0,我们得到p* + q=xor,或者p + q=1111。

然而,如果pq有共同的位,那么我们必须处理andxor的加法。因此,如果我们强制and表达式返回 0,那么and + xor就可以解决。这可以通过递归来实现。

通过递归,我们可以将递归的第一步写成:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

或者,如果我们表示and{1} = 2 * and & xorxor{1} = 2 * and ^ xor,其中{1}表示递归的一步,那么我们可以写成这样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

但是这个递归什么时候停止呢?嗯,当两个位序列(pq)在and{n}表达式中的交集返回 0 时,它应该停止。所以,在这里,我们强制and表达式返回 0。

在代码方面,我们有以下内容:

public static int sum(int q, int p) {
  int xor;
  int and;
  int t;
  and = q & p;
  xor = q ^ p;
  // force 'and' to return 0
  while (and != 0) {
    and = and << 1; // this is multiplication by 2
    // prepare the next step of recursion
    t = xor ^ and;
    and = and & xor;
    xor = t;
  }
  return xor;
}

完整的应用程序称为SummingBinaries

编码挑战 6 - 纸上的二进制相乘

问题:考虑两个正的 32 位整数qp。拿出纸和笔,向我展示如何计算这两个数字的二进制表示(q*p)的乘法。

注意:这不完全是一个编码挑战,但了解这一点很重要。

解决方案:当我们相乘二进制数时,我们必须记住,将一个二进制数乘以 1 会得到完全相同的二进制数,而将一个二进制数乘以 0 会得到 0。相乘两个二进制数的步骤如下:

  1. 从最右边的列(第 0 列)开始,将第二个二进制数的每一位乘以第一个二进制数的每一位。

  2. 总结结果。

让我们做 124(1111100)* 29(011101)= 3596(111000001100)。

以下图表示了我们计算的结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.13 - 相乘二进制数

因此,我们将 29 的每一位与 124 的每一位相乘。接下来,我们将这些二进制数相加,就像你在编码挑战 4 - 纸上的二进制求和部分看到的那样。

编码挑战 7 - 代码中的二进制相乘

亚马逊谷歌Adobe

问题:考虑两个 32 位整数qp。编写一小段代码,使用它们的二进制表示来计算q * p

解决方案:我们可以尝试实现前面编码挑战中提出的算法,或者我们可以尝试另一种方法。这种方法首先假设p=1,所以这里,我们有q**1=q。我们知道任何q乘以 1 都是q*,所以我们可以说*q**1 遵循下一个和(我们从 0 到 30,所以我们忽略位置 31 上的符号位):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.14 - 代码中的二进制相乘

例如,如果q=5(101),那么 5 * 1 = 0230 + 0229 + …122 + 021 + 1*20 = 5。

因此,5 * 1 = 5。

到目前为止,这并不是什么大不了的事,但让我们继续 5 * 2;也就是说,101 * 10。如果我们认为 5 * 2 = 5 * 0 + 10 * 1,那么这意味着 101 * 10 = 101 * 0 + 1010 * 1。所以,我们将 5 左移了一位,将 2 右移了一位。

让我们继续进行 53。这是 101011。然而,53=51+101。因此,它就像 1011+1010*1。

让我们继续进行 54。这是 101100。然而,54=50+100+201。因此,它就像 1010+10100+10100*1。

现在,我们可以开始看到遵循这些步骤的模式(最初,result=0):

  1. 如果p的 LSB 为 1,则我们写下以下内容:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.15- p 的 LSB 为 1

  1. 我们将q左移一位,将p逻辑右移一位。

  2. 我们重复从步骤 1直到p为 0。

如果我们将这三个步骤编写成代码,那么我们将得到以下输出:

public static int multiply(int q, int p) {
  int result = 0;
  while (p != 0) {
    // we compute the value of q only when the LSB of p is 1            
    if ((p & 1) != 0) {
      result = result + q;
    }
    q = q << 1;  // q is left shifted with 1 position
    p = p >>> 1; // p is logical right shifted with 1 position
  }
  return result;
}

完整的应用程序称为MultiplyingBinaries

编码挑战 8-在纸上减去二进制数

问题:考虑两个正的 32 位整数qp。拿出纸和笔,向我展示如何减去这两个数字的二进制表示(q-p)。

注意:这不完全是一个编码挑战,但了解这一点很重要。

解决方案:减去二进制数可以简化为计算 0 减 1。主要是,我们知道 1 减 1 是 0,0 减 0 是 0,1 减 0 是 1。要计算 0 减 1,我们必须按照以下步骤进行:

  1. 从当前列开始,我们搜索左列,直到找到一个 1 位。

  2. 我们借用这个位,并将其放在前一列作为两个值为 1。

  3. 然后我们从前一列借用这两个值为 1,作为另外两个值为 1。

  4. 对每一列重复步骤 3,直到达到当前列。

  5. 现在,我们可以进行计算。

  6. 如果我们遇到另一个 0 减 1,那么我们从步骤 1重复这个过程。

让我们做 124(1111100)-29(011101)=95(1011111)。

以下图表示了我们计算的结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.16-减去二进制数

现在,让我们一步一步来看:

  1. 从第 0 列开始,所以从 0 减 1。我们在左列中搜索,直到找到一个 1 位。我们在第 2 列找到了它(这个位对应于 22=4)。我们从第 1 列借用这个位,并将其用作两个值为 1(换句话说,2 的两倍是 21+21)。我们从第 0 列借用这两个值为 1(这是 21=2),并将它们用作另外两个值为 1(换句话说,1 的两倍是 20+20)。现在,我们可以计算为 2 减 1 等于 1。我们写下 1,并移动到第 1 列。

  2. 我们继续进行第 1 列,所以是 1 减 0 等于 1。我们写下 1,然后移动到第 2 列。

  3. 然后我们继续进行第 2 列,所以是 0 减 1。我们在左列中搜索,直到找到一个 1 位。我们在第 3 列找到了它(这个位对应于 23=8)。我们从第 2 列借用这个位,并将其用作两个值为 1(换句话说,2 的两倍是 22+22)。现在,我们可以计算为 2 减 1 等于 1。我们写下 1,然后移动到第 3 列。

  4. 我们继续进行第 3 列,所以是 0 减 1。我们在左列中搜索,直到找到一个 1 位。我们在第 4 列找到了它(这个位对应于 24=16)。我们从第 3 列借用这个位,并将其用作两个值为 1(换句话说,2 的两倍是 23+23)。现在,我们可以计算为 2 减 1 等于 1。我们写下 1,然后移动到第 4 列。

  5. 我们继续进行第 4 列,所以是 0 减 1。我们在左列中搜索,直到找到一个 1 位。我们在第 5 列找到了它(这个位对应于 25=32)。我们从第 4 列借用这个位,并将其用作两个值为 1(换句话说,2 的两倍是 24+24)。现在,我们可以计算为 2 减 1 等于 1。我们写下 1,然后移动到第 5 列。

  6. 我们继续进行第 5 列,所以是 0 减 0。我们写下 0,然后移动到第 6 列。

  7. 我们继续进行第 6 列,所以是 1 减 0。我们写下 1,然后我们完成了。

因此,结果是 1011111。

编码挑战 9-在代码中减去二进制数

问题:考虑两个 32 位整数qp。编写一小段代码,使用它们的二进制表示来计算q - p

解决方案:我们已经从之前的编码挑战中知道,减去二进制数可以简化为计算 0 减 1。此外,我们知道如何使用借位技术解决 0 减 1。除了借位技术,重要的是要注意|q - p| = q ^ p;例如:

|1 - 1| = 1 ^ 1 = 0, |1 - 0| = 1 ^ 0 = 1, |0 - 1| = 0 ^ 1 = 1 和|0 - 0| = 0 ^ 0 = 0。

基于这两个陈述,我们可以实现两个二进制数的减法,如下所示:

public static int subtract(int q, int p) {
  while (p != 0) {
    // borrow the unset bits of q AND set bits of p
    int borrow = (~q) & p;
    // subtraction of bits of q and p 
    // where at least one of the bits is not set
    q = q ^ p;
    // left shift borrow by one position            
    p = borrow << 1;
  }
  return q;
}

完整的应用程序称为SubtractingBinaries

编码挑战 10 - 纸上的二进制除法

问题:考虑两个正的 32 位整数qp。拿出纸和笔,向我展示如何除以这两个数字的二进制表示(q/p)。

注意:这不完全是一个编码挑战,但了解这一点很重要。

解决方案:在二进制除法中,只有两种可能性:0 或 1。除法涉及被除数q)、除数p)、余数。例如,我们知道 11(被除数)/ 2(除数)= 5(商)1(余数)。或者,在二进制表示中,我们有 1011(被除数)/ 10(除数)= 101(商)1(余数)

我们首先将除数与被除数的最高位进行比较(让我们称之为子被除数),然后进行以下操作:

a.如果除数不适合子被除数(除数>子被除数),则我们将 0 附加到商。

a.a)我们将被除数的下一位附加到子被除数上,并从步骤 a继续)。

b.如果除数适合子被除数(除数<=子被除数),则我们将 1 附加到商。

b.a)我们从当前子被除数中减去除数。

b.b)我们将被除数的下一位附加到减法的结果(这是新的子被除数),然后从步骤 a重复)。

c.当我们处理完被除数的所有位时,我们应该得到商和余数,这是除法的结果。

c.a)我们可以在这里停下来,并用获得的商和余数表示结果。

c.b)我们可以在商中附加一个点(“.”),在当前余数中附加 0(这是新的子被除数),并继续从步骤 a,直到余数为 0 或我们对结果满意为止。

以下图表示了 11/2 的除法:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.17 - 二进制数的除法

现在,让我们一步一步来看(专注于前面图表的左侧):

  • 子被除数= 1,10 > 1,因为 2 > 1,因此我们将 0 附加到商。

  • 子被除数= 10,10 = 10,因为 2 = 2,因此我们将 1 附加到商。

  • 进行减法,10 - 10 = 0。

  • 子被除数= 01,10 > 01,因为 2 > 1,因此我们将 0 附加到商。

  • 子被除数= 011,10 < 011,因为 2 < 3,因此我们将 1 附加到商。

  • 进行减法,011 - 10 = 1。

  • 从被除数中没有更多的位需要处理,因此我们可以说 11/2 的商为 101(即 5),余数为 1。

如果您看一下前面图表的右侧,那么您将看到我们可以继续计算,直到余数为 0,通过应用给定的步骤 c.b

编码挑战 11 - 代码中的二进制除法

亚马逊谷歌Adobe

问题:考虑两个 32 位整数qp。编写一小段代码,使用它们的二进制表示来计算q/p

解决方案:我们可以使用几种方法来除两个二进制数。让我们专注于实现一个仅计算商的解决方案,这意味着我们跳过余数。

这种方法非常直接。我们知道 32 位整数包含我们在 31 和 0 之间计数的位。我们所要做的就是将除数(p)左移i个位置(i=31,30,29,…,2,1,0)并检查结果是否小于被除数(q)。每次我们找到这样的位时,我们更新第i位位置。我们累积结果并将其传递到下一个位置。以下代码不言自明:

private static final int MAX_BIT = 31;
...
public static long divideWithoutRemainder(long q, long p) {
  // obtain the sign of the division
  long sign = ((q < 0) ^ (p < 0)) ? -1 : 1;
  // ensure that q and p are positive
  q = Math.abs(q);
  p = Math.abs(p);
  long t = 0;
  long quotient = 0;
  for (int i = MAX_BIT; i >= 0; --i) {
    long halfdown = t + (p << i);
    if (halfdown <= q) {
      t = t + p << i;
      quotient = quotient | 1L << i;
    }
  }
  return sign * quotient;
}

完整的应用程序称为DividingBinaries。它还包含计算余数的实现。

编码挑战 12 - 替换位

亚马逊,谷歌,Adobe

问题:考虑两个正的 32 位整数qp,以及两个位位置ij。编写一小段代码,用p的位替换q在位置ij之间的位。您可以假设在ij之间,有足够的空间来容纳p的所有位。

解决方案:让我们考虑q=4914(二进制中为 1001100110010),p=63(二进制中为 111111),i=4,j=9。以下图表显示了我们拥有的内容以及我们想要获得的内容:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.18 - 替换 i 和 j 之间的位

正如我们所看到的,解决方案应该完成三个主要步骤。首先,我们需要清除ij之间的q位。其次,我们需要将p左移i个位置(这样,我们将p放在正确的位置)。最后,我们将pq合并到最终结果中。

为了清除ij之间的q位(将这些位设置为 0,无论它们的初始值如何),我们可以使用 AND[&]运算符。我们知道只有 1 和 1 返回 1,所以如果我们有一个包含ij之间的 0 的位掩码,那么q位掩码将导致ij之间只包含 0 的位序列,因为 1 和 0 以及 0 和 0 都是 0。此外,在 MSB 和j(不包括在内)之间,以及位掩码的 LSB 和i(不包括在内)之间,我们应该只有 1 的值。这样,q位掩码将保留q位,因为 1 和 1=1,0 和 1=0。因此,我们的位掩码应该是 1110000001111。让我们看看它是如何工作的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.19 - 位掩码(a)

但是我们如何获得这个掩码?我们可以通过 OR[|]运算符获得它,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.20 - 位掩码(b)

1110000000000 位掩码可以通过将-1 左移j+1 个位置获得,而 0000000001111 位掩码可以通过将 1 左移i个位置并减去 1 获得。

在这里,我们解决了前两个步骤。最后,我们需要把p放在正确的位置。这很容易:我们只需将p左移i个位置。最后,我们在qij之间的位清除后,应用 OR[|]运算符与移位后的p

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.21 - 二进制表示

我们完成了!现在,让我们把这个放入代码中:

public static int replace(int q, int p, int i, int j) {
  int ones = ~0; // 11111111 11111111 11111111 11111111          
  int leftShiftJ = ones << (j + 1);
  int leftShiftI = ((1 << i) - 1);
  int mask = leftShiftJ | leftShiftI;
  int applyMaskToQ = q & mask;
  int bringPInPlace = p << i;
  return applyMaskToQ | bringPInPlace;
}

完整的应用程序称为ReplaceBits

编码挑战 13 - 最长 1 序列

亚马逊Adobe微软Flipkart

问题:考虑一个 32 位整数n。101 的序列可以被视为 111。编写一小段代码,计算最长 1 序列的长度。

解决方案:我们将看几个例子(以下三列代表整数,其二进制表示和最长 1 序列的长度):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.22 - 三个例子

如果我们知道n & 1 = 1,如果n的 LSB 为 1,n & 0 = 0,如果n的 LSB 为 0,那么这个问题的解决方案就很容易实现。让我们专注于第一个例子,67534(10000011111001110)。在这里,我们做了以下事情:

  • 初始化最长序列= 0。

  • 应用 AND[&]:10000011111001110 & 1 = 0,最长序列= 0。

  • 右移并应用 AND[&]:1000001111100111 & 1 = 1,最长序列 = 1。

  • 右移并应用 AND[&]:100000111110011 & 1 = 1,最长序列 = 2。

  • 右移并应用 AND[&]:10000011111001 & 1 = 1,最长序列 = 3。

  • 右移并应用 AND[&]:1000001111100 & 1 = 0,最长序列 = 0

  • 右移并应用 AND[&]:100000111110 & 1 = 0,最长序列 = 0。

  • 右移并应用 AND[&]:10000011111 & 1 = 1,最长序列 = 1。

  • 右移并应用 AND[&]:1000001111 & 1 = 1,最长序列 = 2。

  • 右移并应用 AND[&]:100000111 & 1 = 1,最长序列 = 3。

  • 右移并应用 AND[&]:10000011 & 1 = 1,最长序列 = 4。

  • 右移并应用 AND[&]:1000001 & 1 = 1,最长序列 = 5。

  • 右移并应用 AND[&]:100000 & 1 = 0,最长序列 = 0。

因此,只要在最长的 1 序列中没有 0 交错,我们就可以实现前面的方法。然而,这种方法对于第三种情况 339809(1010010111101100001)不起作用。在这种情况下,我们需要进行一些额外的检查;否则,最长序列的长度将等于 4。但由于 101 可以被视为 111,正确的答案是 9。这意味着当n & 1 = 0 时,我们必须执行以下检查(主要是检查 0 的当前位是否由 101 这样的两位 1 保护):

  • 检查下一个位是否为 1 或 0,(n & 2) == 1 或 0

  • 如果下一个位是 1,则检查前一个位是否为 1

我们可以将这写成代码如下:

public static int sequence(int n) {
  if (~n == 0) {
    return Integer.SIZE; // 32
  }
  int currentSequence = 0;
  int longestSequence = 0;
  boolean flag = true;
  while (n != 0) {
    if ((n & 1) == 1) {
      currentSequence++;
      flag = false;
    } else if ((n & 1) == 0) {
      currentSequence = ((n & 0b10) == 0) // 0b10 = 2
        ? 0 : flag 
        ? 0 : ++currentSequence;
      flag = true;
    }
    longestSequence = Math.max(
      currentSequence, longestSequence);
    n >>>= 1;
  }
  return longestSequence;
}

完整的应用称为LongestSequence

编码挑战 14 - 下一个和上一个数字

AdobeMicrosoft

问题:考虑一个 32 位整数n。编写一段代码,返回包含完全相同数量的 1 位的下一个最大数字。

解决方案:让我们考虑n=124344(11110010110111000)。为了获得另一个具有相同数量的 1 位的数字,我们必须翻转一个 1 位以将其变为 0,并翻转另一个 0 位以将其变为 1。得到的数字将与给定的数字不同,并且包含相同数量的 1 位。现在,如果我们希望这个数字比给定的数字大,那么从 0 翻转为 1 的位应该在从 1 翻转为 0 的位的左边。换句话说,有两个位位置ij,并且翻转位i从 1 到 0 和位j从 0 到 1,如果i > j,那么新数字将比给定数字小,而如果i < j,则新数字将比给定数字大。

这意味着我们必须找到第一个不仅包含右侧全为 0 的位的 0 位(换句话说,第一个非尾随零位)。这样,如果我们将这一位从 0 翻转为 1,那么我们知道在这一位的右侧至少有一位 1 可以从 1 翻转为 0。这意味着我们可以获得一个具有相同数量的 1 位的更大数字。以下图表以图形形式显示了这些数字:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.23 - 非尾随零

因此,对于我们的数字,第一个非尾随零位于第 6 位。如果我们将这一位从 0 翻转为 1,那么得到的数字将大于给定的数字。但现在,我们必须选择一个位,从这个位的右边开始,将其从 1 翻转为 0。基本上,我们必须在位置 3、4 和 5 之间进行选择。然而,这是正确的逻辑吗?请记住,我们必须返回比给定数字大的下一个数字,而不是任何比给定数字大的数字。翻转位置 5 的位比翻转位置 3 或 4 的位更好,但这不是下一个最大的数字。查看以下关系(下标是二进制表示对应的十进制值):

![图 9.24 二进制表示对应的十进制值]

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/cpl-code-itw-gd-java/img/coding_challenge_14_(Fig_9.24).jpg)

图 9.24 - 几个关系

到目前为止,我们可以得出结论,11110010111011000124376 看起来是正确的选择。但是,我们还应该注意以下内容:

11110010111011000124376 > 11110010111000011124355

因此,下一个最大的数字是如果我们计算位置 6(不包括)和 0 之间的 1 位数(让我们用k=3),清除位置 6(不包括)和 0 之间的所有位(将它们设置为 0),并在位置k-1 和 0 之间设置k-1 位为 1。

好吧,到目前为止一切顺利!现在,让我们将这个算法编写成代码。首先,我们需要找到第一个非尾随零位的位置。这意味着我们需要将尾随零的计数与我们得到第一个 0 之前的 1 的计数相加。计算尾随零可以按以下方式进行(我们正在处理n的副本,因为我们不想移动给定数字的位):

int copyn = n;
int zeros = 0;
while ((copyn != 0) && ((copyn & 1) == 0)) {
  zeros++;
  copyn = copyn >> 1;
}

计算直到第一个 0 的 1 可以这样做:

int ones=0;
while ((copyn & 1) == 1) {
  ones++;
  copyn = copyn >> 1;
}

现在,marker = zeros + ones给出了我们搜索的位置。接下来,我们翻转从此位置到 0 的位,从 0 清除所有位:

n = n | (1 << marker);

在我们的情况下,marker=6。这行的效果产生以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.25 - 输出(1)

n = n & (-1 << marker);

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.26 - 输出(2)

最后,我们将位设置为 1,介于(ones - 1)和 0 之间:

n = n | (1 << (ones - 1)) - 1;

在我们的情况下,ones=3。这行的效果产生以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.27 - 输出(3)

因此,最终结果是 11110010111000011,即 124355。因此,最终的方法如下所示:

public static int next(int n) {
  int copyn = n;
  int zeros = 0;
  int ones = 0;
  // count trailing 0s
  while ((copyn != 0) && ((copyn & 1) == 0)) {
    zeros++;
    copyn = copyn >> 1;
  }
  // count all 1s until first 0
  while ((copyn & 1) == 1) {
    ones++;
    copyn = copyn >> 1;
  }
  // the 1111...000... is the biggest number 
  // without adding more 1
  if (zeros + ones == 0 || zeros + ones == 31) {
    return -1;
  }
  int marker = zeros + ones;
  n = n | (1 << marker);
  n = n & (-1 << marker);
  n = n | (1 << (ones - 1)) - 1;
  return n;
}

完整的应用程序称为NextNumber。它还包含一个返回包含完全相同数量的 1 位的下一个最小数字的方法。接受挑战,尝试自己提供解决方案。完成后,只需将您的解决方案与捆绑代码中的解决方案进行对比。作为提示,您将需要尾随 1 的数量(让我们用k表示)和直到达到第一个 1 的尾随 1 左侧的 0 的数量。总结这些值将给出应该从 1 翻转为 0 的位的位置。接下来,清除此位置右侧的所有位,并在此位置右侧立即设置(k + 1)位为 1。

编码挑战 15 - 转换

亚马逊谷歌Adobe

问题:考虑两个正的 32 位整数qp。编写一小段代码,以便计算我们应该在q中翻转的位数,以便将其转换为p

解决方案:如果我们观察到 XOR[^]运算符只在操作数不同时返回 1,那么这个问题的解决方案就变得清晰了。让我们考虑q = 290932(1000111000001110100)和p = 352345(1010110000001011001)。让我们应用 XOR[^]运算符:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.28 - 转换

换句话说,如果我们用xorxor = q ^ p)表示q ^ p,那么我们所要做的就是计算xor中 1 的位数(在我们的示例中,我们有 6 个 1)。这可以使用 AND[&]运算符来完成,该运算符仅在 1 & 1 = 1 时返回 1,因此我们可以为xor中的每个位计算xor & 1。在每次比较后,我们将xor右移一位。代码说明了这一点:

public static int count(int q, int p) {
  int count = 0;
  // each 1 represents a bit that is 
  // different between q and p
  int xor = q ^ p;
  while (xor != 0) {
    count += xor & 1; // only 1 & 1 = 1
    xor = xor >> 1;
  }
  return count;
}

完整的应用程序称为Conversion

编码挑战 16 - 最大化表达式

问题:考虑两个正的 32 位整数qp,其中q≠ p。最大化表达式(q AND sp AND s)的qp*之间的关系是什么,其中 AND 是逻辑运算符[&]?

解决方案:这是一种听起来很难但实际上非常简单的问题。让我们从一个简单的a * b开始。a * b何时达到最大值?好吧,让我们考虑b = 4。*a ** 4 何时达到最大值?让我们写一些测试案例:

a = 1, 1 * 4 = 4

a = 2, 2 * 4 = 8

a = 3, 3 * 4 = 12

a = 4, 4 * 4 = 16

因此,当a = b时,我们达到了最大值 16。然而,a可以是 5,5 * 4 = 20 > 16。这是正确的,但这意味着b也可以是 5,所以 5 * 5 = 25 > 20。这远远不是数学证明,但我们可以注意到如果a = b,那么a * b达到最大值。

对于那些对数学证明感兴趣的人,让我们假设我们有以下内容:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.29 - 最大化表达式(1)

这意味着我们有以下内容:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.30 - 最大化表达式(2)

此外,这意味着我们有以下内容:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.31 - 最大化表达式(3)

现在,如果我们说当a = b时,a * b是最大的,那么让我们表示a =(q AND s)和b =(p AND s)。因此,当(q AND s)=(p AND s)时,(q AND s)*(p AND s)是最大的。

让我们假设q = 822(1100110110)和p = 663(1010010111)。 q的 LSB 为 0,而p的 LSB 为 1,因此我们可以写成以下形式:

(1 AND s)=(0 AND s)→ s = 0 →(1 & 0)=(0 & 0)= 0

如果我们将qp向右移动 1 个位置,那么我们会发现q的 LSB 为 1,p的 LSB 也为 1:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.32 - 将 q 和 p 向右移动 1 个位置

在这里,我们有另外两种情况,可以直观地解释如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.33 - 两种情况

在这里,我们可以看到我们问题的答案是q & p = s。让我们看看这是如何工作的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.34 - 答案

答案是 1000010110,即 534。这意味着(822 AND 534)=(663 AND 534)。

编码挑战 17 - 交换奇数和偶数位

Adobe, Microsoft, Flipkart

问题:考虑一个正的 32 位整数n。编写一小段代码,交换这个整数的奇数位和偶数位。

解决方案:让我们假设n = 663(1010010111)。如果我们手动进行交换,那么我们应该得到 0101101011。我们可以分两步完成:

  1. 我们取奇数位并将它们向右移动一位。

  2. 我们取偶数位并将它们向左移动一位。

但我们如何做到这一点?

我们可以通过 AND[&]运算符和包含奇数位置上的 1 的位掩码来获取奇数位:10101010101010101010101010101010。让我们看看这个过程:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.35 - 交换奇数和偶数位(1)

结果显示 1010010111 包含 1 的奇数位在位置 1、7 和 9。接下来,我们将结果 1010000010 向右移动一位。这将得到 0101000001。

我们可以通过 AND[&]运算符和包含奇数位置上的 1 的位掩码来获取偶数位:1010101010101010101010101010101。让我们看看这个过程:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.36 - 交换奇数和偶数位(2)

结果显示 1010010111 包含 0、2 和 4 位置上的 1 的偶数位。接下来,我们将结果 0000010101 向左移动一位。这将得到 0000101010。

要获得最终结果,我们只需要将这两个结果应用 OR[|]运算符:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.37 - 最终结果

最终结果是 0101101011。实现遵循这些步骤ad litteram,因此这是直接的:

public static int swap(int n) {
  int moveToEvenPositions
    = (n & 0b10101010101010101010101010101010) >>> 1;
  int moveToOddPositions
    = (n & 0b1010101010101010101010101010101) << 1;
  return moveToEvenPositions | moveToOddPositions;
}

完整的应用程序称为SwapOddEven

编码挑战 18 - 旋转位

Amazon, Google, Adobe, Microsoft, Flipkart

问题:考虑一个正的 32 位整数n。编写一小段代码,将k位向左或向右旋转。通过旋转,我们理解二进制表示的一端掉落的位被发送到另一端。因此,在左旋转中,从左端掉落的位被发送到右端,而在右旋转中,从右端掉落的位被发送到左端。

解决方案:让我们专注于左旋转(通常,右旋转解决方案是左旋转解决方案的镜像)。我们已经知道,通过将k位向左移动,我们将位向左移动,空位填充为零。然而,在这些零的位置,我们必须放置从左端掉落的位。

让我们假设n= 423099897(00011001001101111111110111111001)和k=10,所以我们向左旋转 10 位。下图突出显示了掉落的位和最终结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.38 - 左旋转位

前面的图表给出了解决方案。如果我们仔细观察 b)和 c)点,我们会发现掉落的位出现在最终结果中。这个结果可以通过将掉落的位右移 32-10 = 22 位来获得。

因此,如果我们将n左移 10 位,我们将得到一个二进制表示,在右侧填充了零(如前面图表的 b 点)或下一个除法的被除数)。如果我们将n右移 22 位,我们将得到一个在左侧填充了零的二进制表示(作为下一个除法的除数)。此时,OR[|]运算符进入场景,如下例所示:

![图 9.39 OR [|] 运算符

](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/cpl-code-itw-gd-java/img/Figure_9.39_B15403.jpg)

图 9.39 - 应用 OR[|]运算符

左旋转的最终结果是 11011111111101111110010001100100。现在,我们可以轻松地将其转换为代码,如下所示:

public static int leftRotate(int n, int bits) {
  int fallBits = n << bits;
  int fallBitsShiftToRight = n >> (MAX_INT_BITS - bits);
  return fallBits | fallBitsShiftToRight;
}

现在,挑战自己,实现右旋转。

对于右旋转,代码将如下所示(你应该能够毫无问题地跟随这个解决方案):

public static int rightRotate(int n, int bits) {
  int fallBits = n >> bits;
  int fallBitsShiftToLeft = n << (MAX_INT_BITS - bits);
  return fallBits | fallBitsShiftToLeft;
}

完整的应用程序称为RotateBits

编码挑战 19 - 计算数字

问题:考虑两个位置,ijj > i),表示二进制表示中两个位的位置。编写一小段代码,返回一个 32 位整数,其中包含 1s(设置)在i(包括)和j(包括)之间,其余位为 0s(未设置)。

解决方案:让我们假设i=3 和j=7。我们知道所需的 32 位整数是 248,或者用二进制表示是 11111000(或者全部为 0 的 00000000000000000000000011111000)。

如果你注意到了编码挑战 8 - 纸上减法,那么你应该知道 0 减 1 是一个可以通过从当前位的左边借位来完成的操作。借位技术向左传播,直到找到一个 1 位。此外,如果我们记得 1 减 0 是 1,那么我们可以写出以下减法:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.40 - 减法

观察这个减法的结果。1s 恰好位于位置i=3(包括)和j=7(包括)之间。这正是我们要找的数字:248。被除数和除数分别通过将 1 左移(j+1)位和i位来获得。

有了这些陈述,很容易将它们转换为代码:

public static int setBetween(int left, int right) {
  return (1 << (right + 1)) - (1 << left);
}

完整的应用程序称为NumberWithOneInLR

编码挑战 20 - 独特元素

亚马逊谷歌Adobe微软Flipkart

问题:考虑一个给定的整数数组arr。除了一个元素只出现一次外,数组中的每个元素都恰好出现三次。这使得它是唯一的。编写一小段代码,在 O(n)复杂度时间和 O(1)额外空间中找到这个唯一的元素。

解决方案:假设给定的数组是arr={4, 4, 3, 1, 7, 7, 7, 1, 1, 4},所以 3 是唯一的元素。如果我们写出这些数字的二进制表示,我们得到以下结果:100,100,011,001,111,111,111,001,001,100。现在,让我们将相同位置的位相加,并检查结果的和是否是 3 的倍数,如下所示:

  • 第一位的和 % 3 = 0+0+1+1+1+1+1+1+1+0 = 7 % 3 = 1

  • 第二位的和 % 3 = 0+0+1+0+1+1+1+0+0+0 = 4 % 3 = 1

  • 第三位的和 % 3 = 1+1+0+0+1+1+1+0+0+1 = 6 % 3 = 0

唯一的数字是 011 = 3。

让我们看另一个例子。这次,arr={51, 14, 14, 51, 98, 7, 14, 98, 51, 98},所以 7 是唯一的元素。让我们将之前使用的逻辑应用于二进制表示:110011,1110,1110,110011,1100010,111,1110,1100010,110011,1100010。这次,让我们使用图表,因为这样更清晰:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.41 – 找到给定数组中的唯一元素

因此,基于这两个例子,我们可以详细说明以下算法:

  1. 在相同的位置上求和位。

  2. 对于每个sum,计算模 3。

  3. 如果sum % 3 = 0(sum是 3 的倍数),这意味着该位在给定元素中出现三次的元素中被设置。

  4. 如果sum % 3 != 0(sum不是 3 的倍数),这意味着该位在只出现一次的元素中被设置(但不能确定该位在出现三次的元素中是未设置还是设置)。

  5. 我们必须对所有给定的元素和所有位的位置重复步骤 123。通过这样做,我们将得到只出现一次的元素,就像你在前面的图表中看到的那样。

这段代码如下:

private static final int INT_SIZE = 32;
public static int unique(int arr[]) {
  int n = arr.length;
  int result = 0;
  int nr;
  int sumBits;
  // iterate through every bit 
  for (int i = 0; i < INT_SIZE; i++) {
    // compute the sum of set bits at 
    // ith position in all array
    sumBits = 0;
    nr = (1 << i);
    for (int j = 0; j < n; j++) {
      if ((arr[j] & nr) == 0) {
        sumBits++;
      }
    }
    // the sum not multiple of 3 are the 
    // bits of the unique number
    if ((sumBits % 3) == 0) {                
      result = result | nr;
    }
  }
  return result;
}

这是解决这个问题的一种方法。另一种方法是从异或[^]运算符的事实开始,当应用于相同的数字两次时,返回 0。此外,异或[^]运算符是可结合的(给出相同的结果,无论分组方式:1 ^ 1 ^ 2 ^ 2 = 1 ^ 2 ^ 1 ^ 2 = 0)和可交换的(与顺序无关:1 ^ 2 = 2 ^ 1)。然而,如果我们将相同的数字异或[]三次,那么结果将是相同的数字,因此在所有数字上使用异或[]在这里将没有帮助。然而,我们可以采用以下算法:

使用一个变量来记录该变量第一次出现。

  1. 对于每个新元素,将其异或[^]放入一个变量oneAppearance中。

  2. 如果元素出现第二次,那么它将从oneAppearance中移除,并将其异或[^]放入另一个变量twoAppearances中。

  3. 如果元素出现第三次,那么它将从oneAppearancetwoAppearances中移除。oneAppearancetwoAppearances变量变为 0,我们开始寻找一个新元素。

  4. 对于所有出现三次的元素,oneAppearancetwoAppearances变量将为 0。另一方面,对于只出现一次的元素,oneAppearance变量将被设置为该值。

在代码方面,看起来是这样的:

public static int unique(int arr[]) {
  int oneAppearance = 0;
  int twoAppearances = 0;
  for (int i = 0; i < arr.length; i++) {
    twoAppearances = twoAppearances
        | (oneAppearance & arr[i]);
    oneAppearance = oneAppearance ^ arr[i];
    int neutraliser = ~(oneAppearance & twoAppearances);
    oneAppearance = oneAppearance & neutraliser;
    twoAppearances = twoAppearances & neutraliser;
  }
  return oneAppearance;
}

这段代码的运行时间是 O(n),额外时间是 O(1)。完整的应用程序称为OnceTwiceThrice

编码挑战 21 – 查找重复项

亚马逊谷歌Adobe微软Flipkart

问题:假设你有一个整数数组,范围从 1 到n,其中n最多可以是 32,000。数组可能包含重复项,而且你不知道n的值。编写一小段代码,只使用 4 千字节(KB)的内存,从给定数组中打印出所有重复项。

BitSet类(这个类实现了一个根据需要增长的位向量)。

使用BitSet,我们可以遍历给定的数组,并对于每个遍历的元素,将相应索引处的位从 0 翻转为 1。如果我们尝试翻转已经为 1 的位,那么我们就找到并打印了一个重复项。这段代码非常简单:

  private static final int MAX_N = 32000;
  public static void printDuplicates(int[] arr) {
    BitSet bitArr = new BitSet(MAX_N);
    for (int i = 0; i < arr.length; i++) {
      int nr = arr[i];
      if (bitArr.get(nr)) {                
        System.out.println("Duplicate: " + nr);
      } else {
        bitArr.set(nr);
      }
    }
  }

完整的应用程序称为FindDuplicates

编码挑战 22 - 两个不重复的元素

亚马逊谷歌Adobe

问题:假设你有一个包含 2n+2 个元素的整数数组。2n个元素是n个元素重复一次。因此,2n中的每个元素在给定数组中都出现两次。剩下的两个元素只出现一次。编写一小段代码来找到这两个元素。

解决方案:让我们考虑给定的数组是arr={2, 7, 1, 5, 9, 4, 1, 2, 5, 4}。我们要找的两个数字是 7 和 9。这两个数字在数组中只出现一次,而 2、1、5 和 4 出现两次。

如果我们考虑蛮力方法,那么迭代数组并检查每个元素的出现次数是很直观的。但是面试官不会对这个解决方案印象深刻,因为它的运行时间是 O(n2)。

另一种方法是对给定数组进行排序。这样,重复的元素被分组在一起,这样我们可以计算每个组的出现次数。大小为 1 的组表示一个不重复的值。在找到更好的解决方案的过程中提到这种方法是很好的。

更好的解决方案依赖于哈希。创建一个Map<Element, Count>并用元素和出现次数填充它(例如,对于我们的数据,我们将有以下对:(2, 2),(7, 1),(1, 2),(5, 2),(9, 1)和(4, 2))。现在,遍历地图并找到计数为 1 的元素。在找到更好的解决方案的过程中提到这种方法是很好的。

在这一章中,我们处理位,因此最好的解决方案应该依赖于位操作。这个解决方案依赖于异或[^]运算符和我们在提示和技巧部分提到的技巧:

  • 如果我们对一个数字进行偶数次的异或[^],那么结果如下 0(x ^ x = 0;x ^ x ^ x^ x = (x ^ x) ^ (x ^ x) = 0 ^ 0 = 0)

另一方面,如果我们对两个不同的数字pq应用异或[^]运算符,那么结果是一个包含pq不同的位置的位(1 位)的数字。这意味着如果我们对数组中的所有元素应用异或[^](xor = arr[0]*arr*[1]arr[2] ^ … ^ arr[arr.length-1]),那么所有重复的元素将互相抵消。

因此,如果我们取结果的任何设置位(例如,最右边的位)并将数组的元素分成两组,那么一组将包含具有相同位设置的元素,另一组将包含具有相同位未设置的元素。换句话说,我们通过比较 XOR[^]的最右边的设置位与每个元素相同位置的位,将元素分成两组。通过这样做,我们将在一组中得到p,在另一组中得到q

现在,如果我们对第一组中的所有元素应用异或[^]运算符,那么我们将得到第一个不重复的元素。在另一组中做同样的操作将得到第二个不重复的元素。

让我们将这个流程应用到我们的数据arr={2, 7, 1, 5, 9, 4, 1, 2, 5, 4}。所以,7 和 9 是不重复的值。首先,我们对所有数字应用异或[^]运算符:

xor = 2 ^ 7 ^ 1 ^ 5 ^ 9 ^ 4 ^ 1 ^ 2 ^ 5 ^ 4 = 0010(2)^ 0111(7)^ 0001(1)^ 0101(5)^ 1001(9)^ 0100(4)^ 0001(1)^ 0010(2)^ 0101(5)^ 0100(4)= 1110 = 7 ^ 9 = 0111 & 1001 = 1110 = 14。

因此,7 ^ 9!= 0 如果 7!= 9。因此,至少会有一个设置位(至少一个 1 位)。我们可以取任何设置位,但是取最右边的位作为xor & ~(xor-1)相当简单。所以,我们有 1110 & ~(1101) = 1110 & 0010 = 0010。随意选择其他设置位。

到目前为止,我们在这两个数字(7 和 9)的 XOR[^]中找到了这个设置位(0010),所以这个位必须存在于 7 或 9 中(在这种情况下,它存在于 7 中)。接下来,让我们通过比较 XOR[^]的最右边的设置位与每个元素相同位置的位来将元素分成两组。我们得到第一组,包含元素{2, 7, 2},和第二组,包含元素{1, 5, 9, 4, 1, 5, 4}。由于 2、7 和 2 包含了设置位,它们在第一组中,而 1、5、9、4、1、5 和 4 不包含设置位,这意味着它们是第二组的一部分。

有了这个,我们隔离了第一个非重复元素(7)在一个集合中,并把第二个非重复元素(9)放在另一个集合中。此外,每个重复的元素都将在相同的位表示的集合中(例如,{2, 2}将始终在同一个集合中)。

最后,我们对每个集合应用 XOR[^]。因此,我们有xor_first_set = 2 ^ 7 ^ 2 = 010 ^ 111 ^ 010 = 111 = 7(第一个非重复元素)。

对于第二组,我们有:

xor_second_set = 1 ^ 5 ^ 9 ^ 4 ^ 1 ^ 5 ^ 4 = 0001 ^ 0101 ^ 1001 ^ 0100 ^ 0001 ^ 0101 ^ 0100 = 1001 = 9(第二个非重复元素)。

完成!

在代码方面,我们有以下内容:

public static void findNonRepeatable(int arr[]) {
  // get the XOR[^] of all elements in the given array
  int xor = arr[0];
  for (int i = 1; i < arr.length; i++) {
    xor ^= arr[i];
  }
  // get the rightmost set bit (you can use any other set bit)
  int setBitNo = xor & ~(xor - 1);
  // divide the elements in two sets by comparing the 
  // rightmost set bit of XOR[^] with the bit at the same 
  // position in each element
  int p = 0;
  int q = 0;
  for (int i = 0; i < arr.length; i++) {
    if ((arr[i] & setBitNo) != 0) {
      // xor of the first set
      p = p ^ arr[i];
    } else {
      // xor of the second set
      q = q ^ arr[i];
    }
  }
  System.out.println("The numbers are: " + p + " and " + q);
}

这段代码的运行时间是 O(n),辅助空间是 O(1)(n是给定数组中的元素数)。完整的应用程序称为TwoNonRepeating

编码挑战 23 - 集合的幂集

亚马逊谷歌Adobe

问题:考虑一个给定的集合S。编写一段代码,返回S的幂集。一个集合S的幂集 P(S)是S的所有可能子集的集合,包括空集和S本身。

解决方案:考虑给定的S是{a, b, c}。如果是这样,幂集包括{},{a},{b},{c},{a, b},{a, c},{a, c}和{a, b, c}。注意,对于包含三个元素的集合,幂集包含 23=8 个元素。对于包含四个元素的集合,幂集包含 24=16 个元素。一般来说,对于包含n个元素的集合,幂集包含 2n 个元素。

现在,如果我们生成从 0 到 2n-1 的所有二进制数,那么我们得到类似以下的东西(这个例子是 23-1):

20=000, 21=001, 22=010, 23=011, 24=100, 25=101, 26=110, 27=111

接下来,如果我们列出这些二进制数,并且我们认为第一个设置位(最右边的位)与a相关联,第二个设置位与b相关联,第三个设置位(最左边的位)与c相关联,那么我们得到以下结果:

20 = 000 = {}

21 = 001 = {a}

22 = 010 = {b}

23 = 011 = {a, b}

24 = 100 = {c}

25 = 101 = {a, c}

26 = 110 = {b, c}

27 = 111 = {a, b, c}

注意,如果我们用abc替换 1 的位,那么我们就得到了给定集合的幂集。基于这些陈述,我们可以为给定集合S创建以下伪代码:

Compute the Power Set size as 2 size of S
Iterate via i from 0 to Power Set size
     Iterate via j from 0 to size of S
          If jth bit in i is set then
               Add jth element from set to current subset
     Add the resulted subset to subsets
Return all subsets

因此,这个问题的解决方案可以写成如下形式:

public static Set<Set<Character>> powerSet(char[] set) {
  // total number of subsets (2^n)
  long subsetsNo = (long) Math.pow(2, set.length);
  // store subsets
  Set<Set<Character>> subsets = new HashSet<>();
  // generate each subset one by one
  for (int i = 0; i < subsetsNo; i++) {
    Set<Character> subset = new HashSet<>();
    // check every bit of i
    for (int j = 0; j < set.length; j++) {
      // if j'th bit of i is set, 
      // add set[j] to the current subset
      if ((i & (1 << j)) != 0) {                    
        subset.add(set[j]);
      }
    }
    subsets.add(subset);
  }
  return subsets;
}

完整的代码称为PowerSetOfSet

编码挑战 24 - 查找唯一设置位的位置

Adobe微软

问题:考虑一个正整数n。这个数字的二进制表示中有一个位被设置为 1。编写一段代码,返回这个位的位置。

解决方案:问题本身给了我们一个重要的细节或约束:给定的数字包含一个设置为 1 的单个位。这意味着给定的数字必须是 2 的幂。只有 20、21、22、23、24、25、…、2n 有包含一个设置为 1 的二进制表示。所有其他数字包含 0 或多个值为 1。

n & (n-1)公式可以告诉我们给定的数字是否是 2 的幂。看看下面的图表:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.42 - n & (n-1)公式给出了 2 的幂

因此,数字 0、1、2、8、16 等的二进制表示为n & (n-1)为 0000。到目前为止,我们可以说给定的数字是 2 的幂。如果不是,那么我们可以返回-1,因为没有 1 位或者有多个 1 位。

接下来,我们可以将n向右移动,直到n不为 0,同时跟踪移动的次数。当n为 0 时,这意味着我们已经移动了 1 的单个位,因此我们可以停止并返回计数的移位。基于这些陈述,这段代码非常简单:

public static int findPosition(int n) {
  int count = 0;
  if (!isPowerOfTwo(n)) {
    return -1;
  }
  while (n != 0) {
    n = n >> 1;
    ++count;
  }
  return count;
}
private static boolean isPowerOfTwo(int n) {
  return (n > 0) && ((n & (n - 1)) == 0);
}

完整的代码称为PositionOfFirstBitOfOne

编码挑战 25 - 将浮点数转换为二进制,反之亦然

float数字n。编写一小段代码,将这个float转换为 IEEE 754 单精度二进制浮点数(二进制 32),反之亦然。

float数字。IEEE 754 标准规定二进制 32 具有符号位(1 位)、指数宽度(可以表示 256 个值的 8 位)和有效精度(24 位(23 位显式存储)),也称为尾数。

以下图表代表了 IEEE 754 标准中的二进制 32:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.43 - IEEE 754 单精度二进制浮点数(二进制 32)

float值,当用给定符号、偏置指数e(8 位无符号整数)和 23 位小数表示的 32 位二进制数据时,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9.44 - 浮点值

存储在 8 位上的指数使用从 0 到 127 的值来表示负指数(例如,2-3),并使用从 128 到 255 的值来表示正指数。10-7 的负指数将具有值-7+127=120。127 值被称为指数偏差。

有了这些信息,你应该能够将float数字转换为 IEEE 754 二进制 32 表示,反之亦然。在检查名为FloatToBinaryAndBack的源代码之前,尝试使用自己的实现。

这是本章的最后一个编码挑战。让我们快速总结一下!

总结

由于本章是位操作的综合资源,所以如果你走到了这一步,你已经大大提高了你的位操作技能。我们涵盖了主要的理论方面,并解决了 25 个编码挑战,以帮助你学习解决位操作问题的模式和模板。

在下一章中,我们将继续探讨数组和字符串。

第三部分:算法和数据结构

技术面试的高潮之一是旨在发现你在算法和数据结构领域的技能的问题。通常,特别关注这一领域的问题。这是完全可以理解的,因为算法和数据结构在 Java 开发人员的各种日常任务中被使用。

本节包括以下章节:

  • 第十章,数组和字符串

  • 第十一章,链表和映射

  • 第十二章,栈和队列

  • 第十三章,树和图

  • 第十四章,排序和搜索

  • 第十五章,数学和谜题

第十章:数组和字符串

这一章涵盖了涉及字符串和数组的一系列问题。由于 Java 字符串和数组是开发人员常见的话题,我将通过几个你必须记住的标题来简要介绍它们。然而,如果你需要深入研究这个主题,那么请考虑官方的 Java 文档(docs.oracle.com/javase/tutorial/java/)。

在本章结束时,你应该能够解决涉及 Java 字符串和/或数组的任何问题。这些问题很可能会出现在技术面试中。因此,本章将涵盖的主题非常简短和清晰:

  • 数组和字符串概述

  • 编码挑战

让我们从快速回顾字符串和数组开始。

技术要求

本章中的所有代码都可以在 GitHub 上找到,网址为github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter10

数组和字符串概述

在 Java 中,数组是对象并且是动态创建的。数组可以分配给Object类型的变量。它们可以有单个维度(例如,m[])或多个维度(例如,作为三维数组,m[][][])。数组的元素从索引 0 开始存储,因此长度为n的数组将其元素存储在索引 0 和n-1(包括)之间。一旦创建了数组对象,它的长度就永远不会改变。数组除了长度为 0 的无用数组(例如,String[] immutable = new String[0])外,不能是不可变的。

在 Java 中,字符串是不可变的(String是不可变的)。字符串可以包含char数据类型(例如,调用charAt(int index)可以正常工作-index是从 0 到字符串长度 - 1 变化的索引)。超过 65,535 直到 1,114,111(0x10FFFF)的 Unicode 字符不适合 16 位(Javachar)。它们以 32 位整数值(称为代码点)存储。这一方面在编码挑战 7-提取代理对的代码点部分有详细说明。

用于操作字符串的一个非常有用的类是StringBuilder(以及线程安全的StringBuffer)。

现在,让我们来看一些编码挑战。

编码挑战

在接下来的 29 个编码挑战中,我们将解决一组在 Java 技术面试中遇到的流行问题,这些面试由中大型公司(包括 Google、Amazon、Flipkart、Adobe 和 Microsoft)进行。除了这本书中讨论的 29 个编码挑战,你可能还想查看我另一本书Java 编码问题www.amazon.com/gp/product/1789801419/)中的以下非详尽列表中的字符串和数组编码挑战,该书由 Packt 出版:

  • 计算重复字符

  • 找到第一个不重复的字符

  • 反转字母和单词

  • 检查字符串是否只包含数字

  • 计算元音和辅音

  • 计算特定字符的出现次数

  • 从字符串中删除空格

  • 用分隔符连接多个字符串

  • 检查字符串是否是回文

  • 删除重复字符

  • 删除给定字符

  • 找到出现最多次数的字符

  • 按长度对字符串数组进行排序

  • 检查字符串是否包含子字符串

  • 计算字符串中子字符串出现的次数

  • 检查两个字符串是否是变位词

  • 声明多行字符串(文本块)

  • 将相同的字符串* n *次连接

  • 删除前导和尾随空格

  • 找到最长的公共前缀

  • 应用缩进

  • 转换字符串

  • 对数组进行排序

  • 在数组中找到一个元素

  • 检查两个数组是否相等或不匹配

  • 按字典顺序比较两个数组

  • 数组的最小值、最大值和平均值

  • 反转数组

  • 填充和设置数组

  • 下一个更大的元素

  • 改变数组大小

本章中涉及的 29 个编码挑战与前面的挑战没有涉及,反之亦然。

编码挑战 1 – 唯一字符(1)

谷歌Adobe微软

true如果这个字符串包含唯一字符。空格可以忽略。

解决方案:让我们考虑以下三个有效的给定字符串:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.1 – 字符串

首先,重要的是要知道我们可以通过charAt(int index)方法获取 0 到 65,535 之间的任何字符(index是从 0 到字符串长度 - 1 变化的索引),因为这些字符在 Java 中使用 16 位的char数据类型表示。

这个问题的一个简单解决方案是使用Map<Character, Boolean>。当我们通过charAt(int index)方法循环给定字符串的字符时,我们尝试将index处的字符放入这个映射,并将相应的boolean值从false翻转为true。如果给定键(字符)没有映射,则Map#put(K k, V v)方法返回null。如果给定键(字符)有映射,则Map#put(K k, V v)返回与此键关联的先前值(在我们的情况下为true)。因此,当返回的值不是null时,我们可以得出结论至少有一个字符是重复的,因此我们可以说给定的字符串不包含唯一字符。

此外,在尝试将字符放入映射之前,我们通过String#codePointAt(index i)确保其代码在 0 到 65,535 之间。这个方法返回指定index处的 Unicode 字符作为int,这被称为代码点。让我们看看代码:

private static final int MAX_CODE = 65535;
...
public static boolean isUnique(String str) {
  Map<Character, Boolean> chars = new HashMap<>();
  // or use, for(char ch : str.toCharArray()) { ... }
  for (int i = 0; i < str.length(); i++) {
    if (str.codePointAt(i) <= MAX_CODE) {
      char ch = str.charAt(i);
      if (!Character.isWhitespace(ch)) {
        if (chars.put(ch, true) != null) {
          return false;
        }
      }
    } else {
      System.out.println("The given string 
        contains unallowed characters");
      return false;
    }
  }
  return true;
}

完整的应用程序称为UniqueCharacters

编码挑战 2 – 唯一字符(2)

谷歌Adobe微软

true如果这个字符串包含唯一字符。空格可以忽略。

解决方案:前面编码挑战中提出的解决方案也涵盖了这种情况。但是,让我们试着提出一种特定于这种情况的解决方案。给定的字符串只能包含a-z中的字符,因此它只能包含从 97(a)到 122(z)的 ASCII 码。让我们假设给定的字符串是afghnqrsuz

如果我们回顾一下第九章**,位操作中的经验,那么我们可以想象一个位掩码,它用 1 覆盖了a-z字母,如下图所示(1 的位对应于我们字符串的字母,afghnqrsuz):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.2 – 唯一字符位掩码

如果我们将a-z中的每个字母表示为 1 的位,那么我们将获得一个唯一字符的位掩码,类似于前面图像中显示的位掩码。最初,这个位掩码只包含 0(因为没有处理任何字母,我们所有的位都等于 0 或者未设置)。

接下来,我们窥视给定字符串的第一个字母,并计算其 ASCII 码和 97(a的 ASCII 码)之间的差。让我们用s表示这个。现在,我们通过将 1 左移s位来创建另一个位掩码。这将导致一个位掩码,其最高位为 1,后面跟着s位的 0(1000…)。接下来,我们可以在唯一字符的位掩码(最初为 0000…)和这个位掩码(1000…)之间应用 AND[&]运算符。结果将是 0000…,因为 0 & 1 = 0。这是预期的结果,因为这是第一个处理的字母,所以唯一字符的位掩码中没有字母被翻转。

接下来,我们通过将位掩码中的位置s的位从 0 翻转为 1 来更新唯一字符的位掩码。这是通过 OR[|]运算符完成的。现在,唯一字符的位掩码是 1000… 由于我们翻转了一个位,所以现在有一个单独的 1 位,即对应于第一个字母的 1 位。

最后,我们为给定字符串的每个字母重复此过程。如果遇到重复的字符,那么唯一字符的位掩码和当前处理的字母对应的 1000…掩码之间的 AND[&]操作将返回 1(1 & 1 = 1)。如果发生这种情况,那么我们已经找到了一个重复项,所以我们可以返回它。

在代码方面,我们有以下情况:

private static final char A_CHAR = 'a';
...
public static boolean isUnique(String str) {
  int marker = 0;
  for (int i = 0; i < str.length(); i++) {
    int s = str.charAt(i) - A_CHAR;
    int mask = 1 << s;
    if ((marker & mask) > 0) {
      return false;
    }
    marker = marker | mask;
  }
  return true;
}

完整的应用程序称为UniqueCharactersAZ

编码挑战 3 - 编码字符串

char[]str。编写一小段代码,将所有空格替换为序列*%20*。结果字符串应作为char[]返回。

char[]代表以下字符串:

char[] str = "  String   with spaces  ".toCharArray();

预期结果是*%20%20String%20%20%20with%20spaces%20%20*。

我们可以通过三个步骤解决这个问题:

  1. 我们计算给定char[]中空格的数量。

  2. 接下来,创建一个新的char[],其大小为初始char[]str的大小,加上空格的数量乘以 2(单个空格占据给定char[]中的一个元素,而*%20*序列将占据结果char[]中的三个元素)。

  3. 最后,我们循环给定的char[]并创建结果char[]

在代码方面,我们有以下情况:

public static char[] encodeWhitespaces(char[] str) {
  // count whitespaces (step 1)
  int countWhitespaces = 0;
  for (int i = 0; i < str.length; i++) {
    if (Character.isWhitespace(str[i])) {
        countWhitespaces++;
    }
  }
  if (countWhitespaces > 0) {
    // create the encoded char[] (step 2)
    char[] encodedStr = new char[str.length
      + countWhitespaces * 2];
    // populate the encoded char[] (step 3)
    int index = 0;
    for (int i = 0; i < str.length; i++) {
      if (Character.isWhitespace(str[i])) {
        encodedStr[index] = '0';
        encodedStr[index + 1] = '2';
        encodedStr[index + 2] = '%';
        index = index + 3;
      } else {
        encodedStr[index] = str[i];
        index++;
      }
    }
    return encodedStr;
  }
  return str;
}

完整的应用程序称为EncodedString

编码挑战 4 - 一个编辑的距离

GoogleMicrosoft

问题:考虑两个给定的字符串qp。编写一小段代码,确定我们是否可以通过在qp中进行单个编辑来获得两个相同的字符串。更确切地说,我们可以在qp中插入、删除或替换一个字符,q将变成等于p

解决方案:为了更好地理解要求,让我们考虑几个例子:

  • tank, tanc 一个编辑:用c替换k(反之亦然)

  • tnk, tank 一个编辑:在tnk中的tn之间插入a,或者从tank中删除a

  • tank, tinck 需要多于一个编辑!

  • tank, tankist 需要多于一个编辑!

通过检查这些例子,我们可以得出以下结论:如果发生以下情况,我们离目标只有一个编辑的距离:

  • qp之间的长度差异不大于 1

  • qp在一个地方不同

我们可以轻松地检查qp之间长度的差异,如下所示:

if (Math.abs(q.length() - p.length()) > 1) {
  return false;
}

要找出qp在一个地方是否不同,我们必须将q的每个字符与p的每个字符进行比较。如果我们找到多于一个差异,那么我们返回false;否则,我们返回true。让我们看看这在代码方面是怎样的:

public static boolean isOneEditAway(String q, String p) {
  // if the difference between the strings is bigger than 1 
  // then they are at more than one edit away
  if (Math.abs(q.length() - p.length()) > 1) {
    return false;
  }
  // get shorter and longer string
  String shorter = q.length() < p.length() ? q : p;
  String longer = q.length() < p.length() ? p : q;
  int is = 0;
  int il = 0;
  boolean marker = false;
  while (is < shorter.length() && il < longer.length()) {
    if (shorter.charAt(is) != longer.charAt(il)) {
      // first difference was found
      // at the second difference we return false
      if (marker) {
        return false;
      }
      marker = true;
      if (shorter.length() == longer.length()) {
        is++;
      }
    } else {
      is++;
    }
    il++;
  }
  return true;
}

完整的应用程序称为OneEditAway

编码挑战 5 - 缩短字符串

问题:考虑一个只包含字母a-z和空格的给定字符串。这个字符串包含很多连续重复的字符。编写一小段代码,通过计算连续重复的字符并创建另一个字符串,将这个字符串缩小。空格应该按原样复制到结果字符串中(不要缩小空格)。如果结果字符串不比给定字符串短,那么返回给定字符串。

解决方案:考虑给定的字符串是abbb vvvv s rttt rr eeee f。预期结果将是a1b3 v4 s1 r1t3 r2 e4 f1。为了计算连续的字符,我们需要逐个字符循环这个字符串:

  • 如果当前字符和下一个字符相同,那么我们增加一个计数器。

  • 如果下一个字符与当前字符不同,那么我们将当前字符和计数器值附加到最终结果,并将计数器重置为 0。

  • 最后,在处理给定字符串的所有字符之后,我们比较结果的长度与给定字符串的长度,并返回较短的字符串。

在代码方面,我们有以下情况:

public static String shrink(String str) {
  StringBuilder result = new StringBuilder();
  int count = 0;
  for (int i = 0; i < str.length(); i++) {
    count++;
    // we don't count whitespaces, we just copy them
    if (!Character.isWhitespace(str.charAt(i))) {
      // if there are no more characters
      // or the next character is different
      // from the counted one
      if ((i + 1) >= str.length()
           || str.charAt(i) != str.charAt(i + 1)) {
        // append to the final result the counted character
        // and number of consecutive occurrences
        result.append(str.charAt(i))
              .append(count);
        // reset the counter since this 
        // sequence was appended to the result
        count = 0;
      }
    } else {
      result.append(str.charAt(i));
      count = 0;
    }
  }
  // return the result only if it is 
  // shorter than the given string
  return result.length() > str.length()
              ? str : result.toString();
}

完整的应用程序称为StringShrinker

编码挑战 6 - 提取整数

问题:考虑一个包含空格和a-z0-9字符的给定字符串。编写一小段代码,从这个字符串中提取整数。您可以假设任何连续数字序列都形成一个有效的整数。

解决方案:考虑给定的字符串是cv dd 4 k 2321 2 11 k4k2 66 4d。预期结果将包含以下整数:4, 2321, 2, 11, 4, 2, 66 和 4。

一个简单的解决方案将循环给定的字符串,逐个字符连接连续数字序列。数字包含 ASCII 代码在 48(包括)和 97(包括)之间。因此,任何 ASCII 代码在[48, 97]范围内的字符都是数字。我们还可以使用Character#isDigit(char ch)方法。当连续数字序列被非数字字符中断时,我们可以将收集到的序列转换为整数并将其附加为整数列表。让我们看看代码方面的内容:

public static List<Integer> extract(String str) {
  List<Integer> result = new ArrayList<>();
  StringBuilder temp = new StringBuilder(
    String.valueOf(Integer.MAX_VALUE).length());
  for (int i = 0; i < str.length(); i++) {
    char ch = str.charAt(i);
    // or, if (((int) ch) >= 48 && ((int) ch) <= 57)
    if (Character.isDigit(ch)) { 
      temp.append(ch);
    } else {
      if (temp.length() > 0) {
        result.add(Integer.parseInt(temp.toString()));
        temp.delete(0, temp.length());
      }
    }
  }
  return result;
}

完整的应用程序称为ExtractIntegers

编码挑战 7-提取代理对的代码点

问题:考虑一个包含任何类型字符的给定字符串,包括在 Java 中表示为代理对的 Unicode 字符。编写一小段代码,从列表中提取代理对代码点

解决方案:让我们考虑给定的字符串包含以下图像中显示的 Unicode 字符(前三个 Unicode 字符在 Java 中表示为代理对,而最后一个不是):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.3-Unicode 字符(代理对)

在 Java 中,我们可以这样写这样的字符串:

char[] musicalScore = new char[]{'\uD83C', '\uDFBC'}; 
char[] smileyFace = new char[]{'\uD83D', '\uDE0D'};   
char[] twoHearts = new char[]{'\uD83D', '\uDC95'};   
char[] cyrillicZhe = new char[]{'\u04DC'};          
String str = "is" + String.valueOf(cyrillicZhe) + "zhe"
  + String.valueOf(twoHearts) + "two hearts"
  + String.valueOf(smileyFace) + "smiley face and, "
  + String.valueOf(musicalScore) + "musical score";

为了解决这个问题,我们必须了解一些事情,如下(牢记以下陈述对于解决涉及 Unicode 字符的问题至关重要):

  • 超过 65,535 直到 1,114,111(0x10FFFF)的 Unicode 字符不适合 16 位,因此 32 位值(称为代码点)被考虑用于 UTF-32 编码方案。

不幸的是,Java 不支持 UTF-32!尽管如此,Unicode 已经提出了一个解决方案,仍然使用 16 位来表示这些字符。这个解决方案意味着以下内容:

  • 16 位高代理项:1,024 个值(U+D800 到 U+DBFF)

  • 16 位低代理项:1,024 个值(U+DC00 到 U+DFFF)

  • 现在,高代理项后面跟着低代理项定义了所谓的代理对。这些代理对用于表示介于 65,536(0x10000)和 1,114,111(0x10FFFF)之间的值。

  • Java 利用这种表示并通过一系列方法公开它,例如codePointAt()codePoints()codePointCount()offsetByCodePoints()(查看 Java 文档以获取详细信息)。

  • 调用codePointAt()而不是charAt()codePoints()而不是chars()等有助于我们编写涵盖 ASCII 和 Unicode 字符的解决方案。

例如,众所周知的双心符号(前图中的第一个符号)是一个 Unicode 代理对,可以表示为包含两个值的char[]:\uD83D 和\uDC95。这个符号的代码点是 128149。要从这个代码点获取一个String对象,请调用以下内容:

String str = String.valueOf(Character.toChars(128149));

通过调用str.codePointCount(0,str.length())可以计算str中的代码点数,即使str的长度为 2,它也会返回 1。调用str.codePointAt(0)返回 128149,而调用str.codePointAt(1)返回 56469。调用Character.toChars(128149).length返回 2,因为需要两个字符来表示这个代码点作为 Unicode代理对。对于 ASCII 和 Unicode 16 位字符,它将返回 1。

基于这个例子,我们可以很容易地识别代理对,如下所示:

public static List<Integer> extract(String str) {
  List<Integer> result = new ArrayList<>();
  for (int i = 0; i < str.length(); i++) {
    int cp = str.codePointAt(i);
    if (i < str.length()-1 
        && str.codePointCount(i, i+2) == 1) {
      result.add(cp);
      result.add(str.codePointAt(i+1));
      i++;
    }
  }
  return result;
}

或者,像这样:

public static List<Integer> extract(String str) {
  List<Integer> result = new ArrayList<>();
  for (int i = 0; i < str.length(); i++) {
    int cp = str.codePointAt(i);        
    // the constant 2 means a suroggate pair       
    if (Character.charCount(cp) == 2) { 
      result.add(cp);
      result.add(str.codePointAt(i+1));
      i++;
    } 
  }
  return result;
}

完整的应用程序称为ExtractSurrogatePairs

编码挑战 8-是否旋转

亚马逊谷歌Adobe微软

问题:考虑两个给定的字符串str1str2。编写一行代码,告诉我们str2是否是str1的旋转。

解决方案:假设str1helloworldstr2orldhellow。由于str2str1的旋转,我们可以说str2是通过将str1分成两部分并重新排列得到的。以下图显示了这些单词:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.4 - 将 str1 分成两部分并重新排列

因此,基于这个图像,让我们将剪刀的左侧表示为p1,将剪刀的右侧表示为p2。有了这些表示,我们可以说p1 = hellowp2 = orld。此外,我们可以说str1 = p1+p2 = hellow + orldstr2 = p2+p1 = orld + hellow。因此,无论我们在str1的哪里进行切割,我们都可以说str1 = p1+p2str2=p2+p1。然而,这意味着str1+str2 = p1+p2+p2+p1 = hellow + orld + orld + hellow = p1+p2+p1+p2 = str1 + str1,所以p2+p1p1+p2+p1+p2的子字符串。换句话说,str2必须是str1+str1的子字符串;否则,它就不能是str1的旋转。在代码方面,我们可以写成以下形式:

public static boolean isRotation(String str1, String str2) {      
  return (str1 + str1).matches("(?i).*" 
    + Pattern.quote(str2) + ".*");
}

完整的代码称为RotateString

编码挑战 9 - 将矩阵逆时针旋转 90 度

亚马逊谷歌Adobe微软Flipkart

问题:考虑一个给定的整数n x n矩阵M。编写一小段代码,将此矩阵逆时针旋转 90 度,而不使用任何额外空间。

解决方案:对于这个问题,至少有两种解决方案。一种解决方案依赖于矩阵的转置,而另一种解决方案依赖于逐环旋转矩阵。

使用矩阵的转置

让我们来解决第一个解决方案,它依赖于找到矩阵M的转置。矩阵的转置是线性代数中的一个概念,意味着我们需要沿着其主对角线翻转矩阵,这将得到一个新的矩阵MT。例如,有矩阵M和索引ij,我们可以写出以下关系:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.5 - 矩阵转置关系

一旦我们获得了M的转置,我们可以反转转置的列。这将给我们最终结果(矩阵M逆时针旋转 90 度)。以下图像阐明了这种关系,对于一个 5x5 的矩阵:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.6 - 矩阵的转置在左边,最终结果在右边

要获得转置(MT),我们可以通过以下方法交换M[j][i]和M[i][j]:

private static void transpose(int m[][]) {
  for (int i = 0; i < m.length; i++) {
    for (int j = i; j < m[0].length; j++) {
      int temp = m[j][i];
      m[j][i] = m[i][j];
      m[i][j] = temp;
    }
  }
}

反转MT 的列可以这样做:

public static boolean rotateWithTranspose(int m[][]) {
  transpose(m);
  for (int i = 0; i < m[0].length; i++) {
    for (int j = 0, k = m[0].length - 1; j < k; j++, k--) {
      int temp = m[j][i];
      m[j][i] = m[k][i];
      m[k][i] = temp;
    }
  }
  return true;
}

这个解决方案的时间复杂度为 O(n2),空间复杂度为 O(1),因此我们满足了问题的要求。现在,让我们看看这个问题的另一个解决方案。

逐环旋转矩阵

如果我们将矩阵视为一组同心环,那么我们可以尝试旋转每个环,直到整个矩阵都被旋转。以下图像是一个 5x5 矩阵这个过程的可视化:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.7 - 逐环旋转矩阵

我们可以从最外层开始,最终逐渐向内部工作。要旋转最外层,我们从顶部(0, 0)开始逐个交换索引。这样,我们将右边缘移到顶边缘的位置,将底边缘移到右边缘的位置,将左边缘移到底边缘的位置,将顶边缘移到左边缘的位置。完成此过程后,最外层环将逆时针旋转 90 度。我们可以继续进行第二个环,从索引(1, 1)开始,并重复此过程,直到旋转第二个环。让我们看看代码方面的表现:

public static boolean rotateRing(int[][] m) {
  int len = m.length;
  // rotate counterclockwise
  for (int i = 0; i < len / 2; i++) {
    for (int j = i; j < len - i - 1; j++) {
      int temp = m[i][j];
      // right -> top 
       m[i][j] = m[j][len - 1 - i];
       // bottom -> right 
       m[j][len - 1 - i] = m[len - 1 - i][len - 1 - j];
       // left -> bottom 
       m[len - 1 - i][len - 1 - j] = m[len - 1 - j][i];
       // top -> left
       m[len - 1 - j][i] = temp;
     }
   }                 
   return true;
 }

这个解决方案的时间复杂度为 O(n2),空间复杂度为 O(1),因此我们尊重了问题的要求。

完整的应用程序称为RotateMatrix。它还包含了将矩阵顺时针旋转 90 度的解决方案。此外,它还包含了将给定矩阵旋转到一个单独矩阵的解决方案。

编码挑战 10-包含零的矩阵

GoogleAdobe

问题:考虑一个给定的n x m整数矩阵M。如果M(i, j)等于 0,则整行i和整列j应该只包含零。编写一小段代码来完成这个任务,而不使用任何额外的空间。

解决方案:一个天真的方法是循环遍历矩阵,对于每个(i, j) = 0,将行i和列j设置为零。问题在于当我们遍历这行/列的单元格时,我们会发现零并再次应用相同的逻辑。很有可能最终得到一个全是零的矩阵。

为了避免这种天真的方法,最好是拿一个例子并尝试可视化解决方案。让我们考虑一个 5x8 的矩阵,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.8-包含零的矩阵

初始矩阵在(0,4)处有一个 0,在(2,6)处有另一个 0。这意味着解决后的矩阵应该只在第 0 行和第 2 行以及第 4 列和第 6 列上包含零。

一个易于实现的方法是存储零的位置,并在对矩阵进行第二次遍历时,将相应的行和列设置为零。然而,存储零意味着使用一些额外的空间,这是问题所不允许的。

提示

通过一点技巧和一些工作,我们可以将空间复杂度设置为 O(1)。技巧在于使用矩阵的第一行和第一列来标记在矩阵的其余部分找到的零。例如,如果我们在单元格(i, j)处找到一个零,其中i≠0 且j≠0,则我们设置M[i][0] = 0 和M[0][j] = 0。完成了整个矩阵的这个操作后,我们可以循环遍历第一列(列 0)并传播在行上找到的每个零。之后,我们可以循环遍历第一行(行 0)并传播在列上找到的每个零。

但是第一行和第一列的潜在初始零怎么办?当然,我们也必须解决这个问题,所以我们首先标记第一行/列是否至少包含一个 0:

boolean firstRowHasZeros = false;
boolean firstColumnHasZeros = false;
// Search at least a zero on first row
for (int j = 0; j < m[0].length; j++) {
  if (m[0][j] == 0) {
    firstRowHasZeros = true;
    break;
  }
}
// Search at least a zero on first column
for (int i = 0; i < m.length; i++) {
  if (m[i][0] == 0) {
    firstColumnHasZeros = true;
    break;
  }
}

此外,我们应用了我们刚才说的。为此,我们循环遍历矩阵的其余部分,对于每个 0,我们在第一行和列上标记它:

// Search all zeros in the rest of the matrix
for (int i = 1; i < m.length; i++) {
  for (int j = 1; j < m[0].length; j++) {
    if (m[i][j] == 0) {
       m[i][0] = 0;
       m[0][j] = 0;
    }
  }
}

接下来,我们可以循环遍历第一列(列 0)并传播在行上找到的每个零。之后,我们可以循环遍历第一行(行 0)并传播在列上找到的每个零:

for (int i = 1; i < m.length; i++) {
  if (m[i][0] == 0) {
    setRowOfZero(m, i);
  }
}
for (int j = 1; j < m[0].length; j++) {
  if (m[0][j] == 0) {
    setColumnOfZero(m, j);
  }
}

最后,如果第一行包含至少一个 0,则我们将整行设置为 0。同样,如果第一列包含至少一个 0,则我们将整列设置为 0:

if (firstRowHasZeros) {
  setRowOfZero(m, 0);
}
if (firstColumnHasZeros) {
  setColumnOfZero(m, 0);
}

setRowOfZero()setColumnOfZero()都很简单:

private static void setRowOfZero(int[][] m, int r) {
  for (int j = 0; j < m[0].length; j++) {
    m[r][j] = 0;
  }
}
private static void setColumnOfZero(int[][] m, int c) {
  for (int i = 0; i < m.length; i++) {
    m[i][c] = 0;
  }
}

该应用程序称为MatrixWithZeros

编码挑战 11-使用一个数组实现三个堆栈

亚马逊谷歌Adobe微软Flipkart

push()pop()printStacks()

解决方案:提供所需实现的两种主要方法。我们将在这里讨论的方法是基于交错这三个堆栈的元素。查看以下图片:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.9-交错堆栈的节点

正如您所看到的,有一个单一的数组,保存了这三个堆栈的节点,分别标记为Stack 1Stack 2Stack 3。我们实现的关键在于每个推送到堆栈(数组)上的节点都有一个指向其前一个节点的后向链接。每个堆栈的底部都有一个链接到-1。例如,对于Stack 1,我们知道索引 0 处的值 2 有一个指向虚拟索引-1 的后向链接,索引 1 处的值 12 有一个指向索引 0 的后向链接,索引 7 处的值 1 有一个指向索引 1 的后向链接。

因此,堆栈节点保存了两个信息 – 值和后向链接:

public class StackNode {
  int value;
  int backLink;
  StackNode(int value, int backLink) {
    this.value = value;
    this.backLink = backLink;
  }
}

另一方面,数组管理着到下一个空闲槽的链接。最初,当数组为空时,我们只能创建空闲槽,因此链接的形式如下(注意initializeSlots()方法):

public class ThreeStack {
  private static final int STACK_CAPACITY = 15;
  // the array of stacks
  private final StackNode[] theArray;                   
  ThreeStack() {
    theArray = new StackNode[STACK_CAPACITY];
    initializeSlots();
  }
  ...   
  private void initializeSlots() {
    for (int i = 0; i < STACK_CAPACITY; i++) {
      theArray[i] = new StackNode(0, i + 1);
    }
  }
}

现在,当我们将一个节点推送到其中一个堆栈时,我们需要找到一个空闲槽并将其标记为非空闲。以下是相应的代码:

public class ThreeStack {
  private static final int STACK_CAPACITY = 15;
  private int size;
  // next free slot in array
  private int nextFreeSlot;
  // the array of stacks
  private final StackNode[] theArray;                      
  // maintain the parent for each node
  private final int[] backLinks = {-1, -1, -1};  
  ...
  public void push(int stackNumber, int value) 
                throws OverflowException {
    int stack = stackNumber - 1;
    int free = fetchIndexOfFreeSlot();
    int top = backLinks[stack];
    StackNode node = theArray[free];
    // link the free node to the current stack
    node.value = value;
    node.backLink = top;
    // set new top
    backLinks[stack] = free;
  }
  private int fetchIndexOfFreeSlot()  
                throws OverflowException {
    if (size >= STACK_CAPACITY) {
      throw new OverflowException("Stack Overflow");
    }
    // get next free slot in array
    int free = nextFreeSlot;
    // set next free slot in array and increase size
    nextFreeSlot = theArray[free].backLink;
    size++;
    return free;
  }
}

当我们从堆栈中弹出一个节点时,我们必须释放该槽。这样,这个槽可以被未来的推送重用。相关的代码如下:

public class ThreeStack {
  private static final int STACK_CAPACITY = 15;
  private int size;
  // next free slot in array
  private int nextFreeSlot;
  // the array of stacks
  private final StackNode[] theArray;                      
  // maintain the parent for each node
  private final int[] backLinks = {-1, -1, -1};  
  ...
  public StackNode pop(int stackNumber)
              throws UnderflowException {
    int stack = stackNumber - 1;
    int top = backLinks[stack];
    if (top == -1) {
      throw new UnderflowException("Stack Underflow");
    }
    StackNode node = theArray[top]; // get the top node
    backLinks[stack] = node.backLink;
    freeSlot(top);
    return node;
  }
  private void freeSlot(int index) {
    theArray[index].backLink = nextFreeSlot;
    nextFreeSlot = index;
    size--;
  }
}

完整的代码,包括使用printStacks(),被称为ThreeStacksInOneArray

解决这个问题的另一种方法是将堆栈数组分割成三个不同的区域:

  • 第一区域分配给第一个堆栈,并位于数组端点的左侧(当我们向这个堆栈推送时,它向右方向增长)。

  • 第二区域分配给第二个堆栈,并位于数组端点的右侧(当我们向这个堆栈推送时,它向左方向增长)。

  • 第三区域分配给第三个堆栈,并位于数组的中间(当我们向这个堆栈推送时,它可以向任何方向增长)。

以下图像将帮助您澄清这些观点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.10 – 将数组分割成三个区域

这种方法的主要挑战在于通过相应地移动中间堆栈来避免堆栈碰撞。或者,我们可以将数组分成三个固定区域,并允许各个堆栈在有限的空间中增长。例如,如果数组大小为s,那么第一个堆栈可以从 0(包括)到s/3(不包括),第二个堆栈可以从s/3(包括)到 2s/3(不包括),第三个堆栈可以从 2s/3(包括)到s(不包括)。这种实现在捆绑代码中作为ThreeStacksInOneArrayFixed可用。

或者,可以通过交替序列实现中间堆栈以进行后续推送。这种方式,我们也可以减少移位,但我们正在减少均匀性。然而,挑战自己,也实现这种方法。

编码挑战 12 – 对

AmazonAdobeFlipkart

问题:考虑一个整数数组(正数和负数),m。编写一小段代码,找到所有和为给定数字k的整数对。

解决方案:像往常一样,让我们考虑一个例子。假设我们有一个包含 15 个元素的数组,如下所示:-5, -2, 5, 4, 3, 7, 2, 1, -1, -2, 15, 6, 12, -4, 3。另外,如果k=10,那么我们有四对和为 10 的数:(-15 + 5), (-2 + 12), (3 + 7), 和 (4 + 6)。但是我们如何找到这些对呢?

解决这个问题有不同的方法。例如,我们有蛮力方法(通常,面试官不喜欢这种方法,所以只在万不得已时使用它 – 尽管蛮力方法可以很好地帮助我们理解问题的细节,但它不被接受为最终解决方案)。按照蛮力方法,我们从数组中取出每个元素,并尝试与其余元素中的每个元素配对。与几乎任何基于蛮力的解决方案一样,这个解决方案的时间复杂度也是不可接受的。

如果我们考虑对给定数组进行排序,我们可以找到更好的方法。我们可以通过 Java 内置的Arrays.sort()方法来实现这一点,其运行时间为 O(n log n)。有了排序后的数组,我们可以使用两个指针来扫描整个数组,基于以下步骤(这种技术称为双指针,在本章的几个问题中都会看到它的应用):

  1. 一个指针从索引 0 开始(左指针;我们将其表示为l),另一个指针从(m.length - 1)索引开始(右指针;我们将其表示为r)。

  2. 如果m[l] + m[r] = k,那么我们有一个解决方案,我们可以增加l位置并减少r位置。

  3. 如果m[l] + m[r]<k,那么我们增加l并保持r不变。

  4. 如果m[l] + m[r]>k,那么我们减少r并保持l不变。

  5. 我们重复步骤 2-4,直到l>= r

以下图片将帮助您实现这些步骤:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.11 - 找到所有和为给定数字的对

在我们看看它如何适用于k=10 时,请留意这张图片:

  • l= 0,r= 14 → sum = m[0] + m[14] = -5 + 15 = 10 → sum = kl++,r

  • l= 1,r= 13 → sum = m[1] + m[13] = -4 + 12 = 8 → sum < kl++

  • l= 2,r= 13 → sum = m[2] + m[13] = -2 + 12 = 10 → sum = kl++,r

  • l= 3,r= 12 → sum = m[3] + m[12] = -2 + 7 = 5 → sum < kl++

  • l= 4,r= 12 → sum = m[4] + m[12] = -1 + 7 = 6 → sum < kl++

  • l= 5,r= 12 → sum = m[5] + m[12] = 1 + 7 = 8 → sum < kl++

  • l= 6,r= 12 → sum = m[6] + m[12] = 2 + 7 = 9 → sum < kl++

  • l= 7,r= 12 → sum = m[7] + m[12] = 3 + 7 = 10 → sum = kl++,r

  • l= 8,r= 11 → sum = m[8] + m[11] = 3 + 6 = 9 → sum < kl++

  • l= 9,r= 11 → sum = m[9] + m[11] = 4 + 6 = 10 → sum = kl++,r

  • l= 10,r= 10 → 停止

如果我们将这个逻辑放入代码中,那么我们会得到以下方法:

public static List<String> pairs(int[] m, int k) {
  if (m == null || m.length < 2) {
    return Collections.emptyList();
  }
  List<String> result = new ArrayList<>();
  java.util.Arrays.sort(m);
  int l = 0;
  int r = m.length - 1;
  while (l < r) {
    int sum = m[l] + m[r];
    if (sum == k) {
      result.add("(" + m[l] + " + " + m[r] + ")");
      l++;
      r--;
    } else if (sum < k) {
      l++;
    } else if (sum > k) {
      r--;
    }
  }
  return result;
}

完整的应用程序称为FindPairsSumEqualK

编码挑战 13 - 合并排序数组

亚马逊谷歌Adobe微软Flipkart

问题:假设您有k个不同长度的排序数组。编写一个应用程序,将这些数组合并到 O(nk log n)中,其中n是最长数组的长度。

解决方案:假设给定的数组是以下五个数组,分别表示为abcde

a:{1, 2, 32, 46} b:{-4, 5, 15, 18, 20} c:{3} d:{6, 8} e:{-2, -1, 0}

预期结果如下:

{-4, -2, -1, 0, 1, 2, 3, 5, 6, 8, 15, 18, 20, 32, 46}

最简单的方法是将这些数组中的所有元素复制到单个数组中。这将花费 O(nk)的时间,其中n是最长数组的长度,k是数组的数量。接下来,我们通过 O(n log n)的时间复杂度算法(例如,通过归并排序)对这个数组进行排序。这将导致 O(nk log nk)。然而,问题要求我们编写一个可以在 O(nk log n)中执行的算法。

有几种解决方案可以在 O(nk log n)中执行,其中之一是基于二进制最小堆(这在第十三章**,树和图中有详细说明)。简而言之,二进制最小堆是一棵完全二叉树。二进制最小堆通常表示为一个数组(让我们将其表示为heap),其根位于heap[0]。更重要的是,对于heap[i],我们有以下内容:

  • heap[(i - 1) / 2]:返回父节点

  • heap[(2 * i) + 1]:返回左子节点

  • heap[(2 * i) + 2]:返回右子节点

现在,我们的算法遵循以下步骤:

  1. 创建大小为nk的结果数组。

  2. 创建大小为k的二进制最小堆,并将所有数组的第一个元素插入到此堆中。

  3. 重复以下步骤nk次:

从二进制最小堆中获取最小元素,并将其存储在结果数组中。

b. 用来自提取元素的数组的下一个元素替换二进制最小堆的根(如果数组没有更多元素,则用无限大替换根元素;例如,用Integer.MAX_VALUE)。

c. 替换根后,heapify树。

这段代码太长,无法在本书中列出,因此以下只是其实现的结尾(堆结构和merge()操作):

public class MinHeap {
  int data;
  int heapIndex;
  int currentIndex;
  public MinHeap(int data, int heapIndex,
        int currentIndex) {
    this.data = data;
    this.heapIndex = heapIndex;
    this.currentIndex = currentIndex;
  }
}

以下代码是merge()操作:

public static int[] merge(int[][] arrs, int k) {
  // compute the total length of the resulting array
  int len = 0;
  for (int i = 0; i < arrs.length; i++) {
    len += arrs[i].length;
  }
  // create the result array
  int[] result = new int[len];
  // create the min heap
  MinHeap[] heap = new MinHeap[k];
  // add in the heap first element from each array
  for (int i = 0; i < k; i++) {
    heap[i] = new MinHeap(arrs[i][0], i, 0);
  }
  // perform merging
  for (int i = 0; i < result.length; i++) {
    heapify(heap, 0, k);
    // add an element in the final result
    result[i] = heap[0].data;
    heap[0].currentIndex++;
    int[] subarray = arrs[heap[0].heapIndex];
    if (heap[0].currentIndex >= subarray.length) {
      heap[0].data = Integer.MAX_VALUE;
    } else {
      heap[0].data = subarray[heap[0].currentIndex];
    }
  }
  return result;
}

完整的应用程序称为MergeKSortedArr

编码挑战 14 - 中位数

亚马逊谷歌Adobe微软Flipkart

问题:考虑两个排序好的数组qp(它们的长度可以不同)。编写一个应用程序,在对数时间内计算这两个数组的中位数值。

解决方案:中位数值将数据样本(例如数组)的较高一半与较低一半分开。例如,下图分别显示了具有奇数元素数量的数组和具有偶数元素数量的数组的中位数值:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.12 - 奇数和偶数数组的中位数值

因此,对于一个包含n个元素的数组,我们有以下两个公式:

  • 如果n是奇数,则中位数值为(n+1)/2

  • 如果n是偶数,则中位数值为[(n/2+(n/2+1)]/2

计算单个数组的中位数是相当容易的。但是,我们如何计算两个长度不同的数组的中位数呢?我们有两个排序好的数组,我们必须从中找出一些东西。有经验的求职者应该能够直觉到应该考虑使用著名的二分搜索算法。通常,在实现二分搜索算法时,应该考虑到有序数组。

我们大致可以直觉到,找到两个排序数组的中位数值可以简化为找到必须被这个值遵守的适当条件。

由于中位数值将输入分成两个相等的部分,我们可以得出第一个条件是q数组的中位数值应该在中间索引处。如果我们将这个中间索引表示为qPointer,那么我们得到两个相等的部分:[0,qPointer]和[qPointer+1,q.length]。如果我们对p数组应用相同的逻辑,那么p数组的中位数值也应该在中间索引处。如果我们将这个中间索引表示为pPointer,那么我们得到两个相等的部分:[0,pPointer]和[pPointer+1,p.length]。让我们通过以下图表来可视化这一点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.13 - 将数组分成两个相等的部分

我们可以从这个图表中得出结论,中位数值应该遵守的第一个条件是qLeft + pLeft = qRight + pRight。换句话说,qPointer + pPointer = (q.length- qPointer) + (p.length - pPointer)。

然而,由于我们的数组长度不同(它们可以相等,但这只是我们解决方案应该覆盖的特殊情况),我们不能简单地将它们都减半。我们可以假设p >= q(如果它们没有给出这样的情况,那么我们只需交换它们以强制执行这个假设)。在这个假设的前提下,我们可以写出以下内容:

qPointer + pPointer = (q.length- qPointer) + (p.length - pPointer)

2 ** pPointer = q.length + p.length -* 2 ** qPointer →*

pPointer = (q.length + p.length)*/*2 - qPointer

到目前为止,pPointer可以落在中间,我们可以通过添加 1 来避免这种情况,这意味着我们有以下起始指针:

  • qPointer = ((q.length - 1) + 0)/2

  • pPointer = (q.length + p.length + 1)/2 - qPointer

如果 p>=q,那么最小值 (q.length + p.length + 1)/2 - qPointer 将始终导致 pPointer 成为正整数。这将消除数组越界异常,并且也遵守第一个条件。

然而,我们的第一个条件还不够,因为它不能保证左数组中的所有元素都小于右数组中的元素。换句话说,左部分的最大值必须小于右部分的最小值。左部分的最大值可以是 q[qPointer-1] 或 p[pPointer-1],而右部分的最小值可以是 q[qPointer] 或 p[pPointer]。因此,我们可以得出以下条件也应该被遵守:

  • q[qPointer-1] <= p[pPointer]

  • p[pPointer-1] <= q[qPointer]

在这些条件下,qp 的中值将如下所示:

  • p.length + q.length 是偶数:左部分的最大值和右部分的最小值的平均值

  • p.length + q.length 是奇数:左部分的最大值,max(q[qPointer-1], p[pPointer-1])。

让我们尝试用三个步骤和一个例子总结这个算法。我们以 q 的中间值作为 qPointer(即[(q.length - 1) + 0)/2]),以 (q.length + p.length + 1)/2 - qPointer 作为 pPointer。让我们按照以下步骤进行:

  1. 如果 q[qPointer-1] <= p[pPointer] 并且 p[pPointer-1] <= q[qPointer],那么我们找到了完美的 qPointer(完美的索引)。

  2. 如果 p[pPointer-1] >q[qPointer],那么我们知道 q[qPointer] 太小了,所以必须增加 qPointer 并减少 pPointer。由于数组是排序的,这个操作将导致 q[qPointer] 变大,p[pPointer] 变小。此外,我们可以得出结论,qPointer 只能在 q 的右部分(从 middle+1 到 q.length)中。回到 步骤 1

  3. 如果 q[qPointer-1] >p[pPointer],那么我们知道 q[qPointer-1] 太大了。我们必须减少 qPointer 以使 q[qPointer-1] <= p[pPointer]。此外,我们可以得出结论,qPointer 只能在 q 的左部分(从 0 到 middle-1)中。前往 步骤 2

现在,让我们假设 q={ 2, 6, 9, 10, 11, 65, 67},p={ 1, 5, 17, 18, 25, 28, 39, 77, 88},并应用上述步骤。

根据我们之前的陈述,我们知道 qPointer = (0 + 6) / 2 = 3,pPointer = (7 + 9 + 1) / 2 - 3 = 5。下面的图像说明了这一点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.14 - 计算中值(步骤 1)

我们的算法的第 1 步规定 q[qPointer-1] <= p[pPointer] 并且 p[pPointer-1] <= q[qPointer]。显然,9 < 28,但 25 > 10,所以我们应用 步骤 2,然后回到 步骤 1。我们增加 qPointer 并减少 pPointer,所以 qPointerMin 变为 qPointer + 1。新的 qPointer 将是 (4 + 6) / 2 = 5,新的 pPointer 将是 (7 + 9 + 1)/2 - 5 = 3。下面的图像将帮助您可视化这种情况:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.15 - 计算中值(步骤 2)

在这里,您可以看到新的 qPointer 和新的 pPointer 遵守了我们算法的 步骤 1,因为 q[qPointer-1],即 11,小于 p[pPointer],即 18;而 p[pPointer-1],即 17,小于 q[qPointer],即 65。有了这个,我们找到了完美的 qPointer,为 5。

最后,我们必须找到左侧的最大值和右侧的最小值,并根据两个数组的奇偶长度返回左侧的最大值或左侧的最大值和右侧的最小值的平均值。我们知道左侧的最大值是 max(q[qPointer-1], p[pPointer-1]),所以 max(11, 17) = 17。我们也知道右侧的最小值是 min(q[qPointer], p[pPointer]),所以 min(65, 18) = 18。由于长度之和为 7 + 9 = 16,我们计算出中位数的值是这两个值的平均值,所以 avg(17, 18) = 17.5。我们可以将其可视化如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.16 - 中位数(最终结果)

将这个算法转化为代码的结果如下:

public static float median(int[] q, int[] p) {
  int lenQ = q.length;
  int lenP = p.length;
  if (lenQ > lenP) {
    swap(q, p);
  }
  int qPointerMin = 0;
  int qPointerMax = q.length;
  int midLength = (q.length + p.length + 1) / 2;
  int qPointer;
  int pPointer;
  while (qPointerMin <= qPointerMax) {
    qPointer = (qPointerMin + qPointerMax) / 2;
    pPointer = midLength - qPointer;
    // perform binary search
    if (qPointer < q.length 
          && p[pPointer-1] > q[qPointer]) {
      // qPointer must be increased
      qPointerMin = qPointer + 1;
    } else if (qPointer > 0 
          && q[qPointer-1] > p[pPointer]) {
      // qPointer must be decreased
      qPointerMax = qPointer - 1;
    } else { // we found the poper qPointer
      int maxLeft = 0;
      if (qPointer == 0) { // first element on array 'q'?
        maxLeft = p[pPointer - 1];
      } else if (pPointer == 0) { // first element                                   // of array 'p'?
        maxLeft = q[qPointer - 1];
      } else { // we are somewhere in the middle -> find max
        maxLeft = Integer.max(q[qPointer-1], p[pPointer-1]);
      }
      // if the length of 'q' + 'p' arrays is odd, 
      // return max of left
      if ((q.length + p.length) % 2 == 1) {
        return maxLeft;
      }
      int minRight = 0;
      if (qPointer == q.length) { // last element on 'q'?
        minRight = p[pPointer];
      } else if (pPointer == p.length) { // last element                                          // on 'p'?
        minRight = q[qPointer];
      } else { // we are somewhere in the middle -> find min
        minRight = Integer.min(q[qPointer], p[pPointer]);
      }
      return (maxLeft + minRight) / 2.0f;
    }
  }
  return -1;
}

我们的解决方案在 O(log(max(q.length, p.length))时间内执行。完整的应用程序称为MedianOfSortedArrays

编码挑战 15-一个的子矩阵

亚马逊微软Flipkart

问题:假设你得到了一个只包含 0 和 1(二进制矩阵)的矩阵,m x n。编写一小段代码,返回只包含元素 1 的最大正方形子矩阵的大小。

解决方案:让我们假设给定的矩阵是以下图像中的矩阵(5x7 矩阵):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.17 - 给定的 5 x 7 二进制矩阵

正如你所看到的,只包含元素 1 的正方形子矩阵的大小为 3。蛮力方法,或者说是朴素方法,是找到所有包含所有 1 的正方形子矩阵,并确定哪一个具有最大的大小。然而,对于一个m x n矩阵,其中z=min(m, n),时间复杂度将为 O(z3mn)。你可以在本书附带的代码中找到蛮力实现。当然,在查看解决方案之前,先挑战自己。

现在,让我们试着找到一个更好的方法。让我们假设给定的矩阵是大小为n x n,并研究一个 4x4 样本矩阵的几种情况。在 4x4 矩阵中,我们可以看到 1s 的最大正方形子矩阵可以有 3x3 的大小,因此在大小为n x n的矩阵中,1s 的最大正方形子矩阵可以有大小为n-1x n-1。此外,以下图像显示了对m x n矩阵同样适用的两个基本情况:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.18 - 4 x 4 矩阵中 1s 的最大子矩阵

这些情况解释如下:

  • 如果给定的矩阵只包含一行,那么其中包含 1 的单元格将是最大正方形子矩阵的大小。因此,最大大小为 1。

  • 如果给定的矩阵只包含一列,那么其中包含 1 的单元格将是最大正方形子矩阵的大小。因此,最大大小为 1。

接下来,让我们假设subMatrix[i][j]表示以单元格(i,j)结尾的只包含 1 的最大正方形子矩阵的大小:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.19 - 整体递归关系

前面的图表允许我们在给定矩阵和辅助subMatrix(与给定矩阵大小相同的矩阵,应根据递归关系填充)之间建立递归关系:

  • 这并不容易直觉到,但我们可以看到,如果matrix[i][j] = 0,那么subMatrix[i][j] = 0

  • 如果matrix[i][j] = 1,那么subMatrix[i][j]

= 1 + min(subMatrix[i - 1][j], subMatrix[i][j - 1], subMatrix[i - 1][j - 1])

如果我们将这个算法应用到我们的 5 x 7 矩阵中,那么我们将得到以下结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.20 - 解决我们的 5 x 7 矩阵

将前述基本情况和递归关系结合起来,得到以下算法:

  1. 创建一个与给定矩阵大小相同的辅助矩阵(subMatrix)。

  2. 从给定矩阵中复制第一行和第一列到这个辅助subMatrix(这些是基本案例)。

  3. 对于给定矩阵的每个单元格(从(1, 1)开始),执行以下操作:

a. 填充符合前述递归关系的subMatrix

b. 跟踪subMatrix的最大元素,因为这个元素给出了包含所有 1 的子矩阵的最大大小。

以下实现澄清了任何剩余的细节:

public static int ofOneOptimized(int[][] matrix) {
  int maxSubMatrixSize = 1;
  int rows = matrix.length;
  int cols = matrix[0].length;                
  int[][] subMatrix = new int[rows][cols];
  // copy the first row
  for (int i = 0; i < cols; i++) {
    subMatrix[0][i] = matrix[0][i];
  }
  // copy the first column
  for (int i = 0; i < rows; i++) {
    subMatrix[i][0] = matrix[i][0];
  }
  // for rest of the matrix check if matrix[i][j]=1
  for (int i = 1; i < rows; i++) {
    for (int j = 1; j < cols; j++) {
      if (matrix[i][j] == 1) {
        subMatrix[i][j] = Math.min(subMatrix[i - 1][j - 1],
            Math.min(subMatrix[i][j - 1], 
             subMatrix[i - 1][j])) + 1;
        // compute the maximum of the current sub-matrix
        maxSubMatrixSize = Math.max(
          maxSubMatrixSize, subMatrix[i][j]);
      }
    }
  }        
  return maxSubMatrixSize;
}

由于我们迭代mn次来填充辅助矩阵,因此这种解决方案的总体复杂度为 O(mn)。完整的应用程序称为MaxMatrixOfOne*。

编码挑战 16 – 包含最多水的容器

GoogleAdobeMicrosoft

问题:假设给定了n个正整数p1,p2,…,pn,其中每个整数表示坐标点(i, pi)。接下来,画出n条垂直线,使得线i的两个端点分别位于(i, pi)和(*i, 0)。编写一小段代码,找到两条线,与 X 轴一起形成一个包含最多水的容器。

解决方案:假设给定的整数是 1, 4, 6, 2, 7, 3, 8, 5 和 3。根据问题陈述,我们可以勾画n条垂直线(线 1:{(0, 1), (0, 0)},线 2:{(1, 4), (1,0)},线 3:{(2, 6), (2, 0)},依此类推)。这可以在下图中看到:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.21 – n 条垂直线表示

首先,让我们看看如何解释这个问题。我们必须找到包含最多水的容器。这意味着在我们的 2D 表示中,我们必须找到具有最大面积的矩形。在 3D 表示中,这个容器将具有最大体积,因此它将包含最多的水。

用暴力方法思考解决方案是非常直接的。对于每条线,我们计算显示其余线的面积,同时跟踪找到的最大面积。这需要两个嵌套循环,如下所示:

public static int maxArea(int[] heights) {
  int maxArea = 0;
  for (int i = 0; i < heights.length; i++) {
    for (int j = i + 1; j < heights.length; j++) {
      // traverse each (i, j) pair
      maxArea = Math.max(maxArea, 
          Math.min(heights[i], heights[j]) * (j - i));
    }
  }
  return maxArea;
}

这段代码的问题在于它的运行时间是 O(n2)。更好的方法是采用一种称为双指针的技术。别担心 - 这是一种非常简单的技术,对你的工具箱非常有用。你永远不知道什么时候会用到它!

我们知道我们正在寻找最大面积。因为我们谈论的是一个矩形区域,这意味着最大面积必须尽可能多地容纳最大宽度最大高度之间的最佳报告。最大宽度是从 0 到n-1(在我们的例子中,从 0 到 8)。要找到最大高度,我们必须调整最大宽度,同时跟踪最大面积。为此,我们可以从最大宽度开始,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.22 – 最大宽度的区域

因此,如果我们用两个指针标记最大宽度的边界,我们可以说i=0 和j=8(或n-1)。在这种情况下,容纳水的容器的面积将为pi* 8 = 1 * 8 = 8。容器的高度不能超过pi = 1,因为水会流出。然而,我们可以增加ii=1,pi=4)以获得更高的容器,可能是更大的容器,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.23 – 增加 i 以获得更大的容器

一般来说,如果pi ≤ pj,则增加i;否则,减少j。通过不断增加/减少ij,我们可以获得最大面积。从左到右,从上到下,下面的图像显示了这个语句在接下来的六个步骤中的工作:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.24 – 在增加/减少ij时计算面积

步骤如下:

  1. 在左上角的图像中,我们减少了j,因为pi > pj*,p*1 > p8 (4 > 3)。

  2. 在顶部中间的图像中,我们增加了i,因为pi < pj*,p*1 < p7 (4 < 5)。

  3. 在右上角的图像中,我们减少了j,因为pi > pj*,p*2 > p7 (6 > 5)。

  4. 在左下角的图像中,我们增加了i,因为pi < pj*,p*2 < p6 (6 < 8)。

  5. 在底部中间的图像中,我们增加了i,因为pi < pj*,p*3 < p6 (2 < 8)。

  6. 在右下角的图像中,我们增加了i,因为pi < pj*,p*4 < p6 (7 < 8)。

完成!如果我们再增加i或减少j一次,那么i=j,面积为 0。此时,我们可以看到最大面积为 25(顶部中间的图像)。嗯,这种技术被称为双指针,可以用以下算法实现:

  1. 从最大面积为 0*,i*=0 和j=n-1 开始。

  2. i < j时,执行以下操作:

a. 计算当前ij的面积。

b. 根据需要更新最大面积。

c. 如果pi ≤ pj,则i++; 否则,j–

在代码方面,我们有以下内容:

public static int maxAreaOptimized(int[] heights) {
  int maxArea = 0;
  int i = 0; // left-hand side pointer            
  int j = heights.length - 1; // right-hand side pointer
  // area cannot be negative, 
  // therefore i should not be greater than j
  while (i < j) {
    // calculate area for each pair
    maxArea = Math.max(maxArea, Math.min(heights[i],
         heights[j]) * (j - i));
    if (heights[i] <= heights[j]) {
      i++; // left pointer is small than right pointer
    } else {
      j--; // right pointer is small than left pointer
    }
  }
  return maxArea;
}

这段代码的运行时间是 O(n)。完整的应用程序称为ContainerMostWater

编码挑战 17 – 在循环排序数组中搜索

亚马逊谷歌Adobe微软Flipkart

问题:考虑到你已经得到了一个没有重复的整数的循环排序数组m。编写一个程序,在 O(log n)的时间复杂度内搜索给定的x

解决方案:如果我们能在 O(n)的时间复杂度内解决这个问题,那么蛮力方法是最简单的解决方案。在数组中进行线性搜索将给出所搜索的x的索引。然而,我们需要提出一个 O(log n)的解决方案,因此我们需要从另一个角度来解决这个问题。

我们有足够的线索指向我们熟知的二分搜索算法,我们在第七章**,算法的大 O 分析第十四章**,排序和搜索中讨论过。我们有一个排序后的数组,我们需要找到一个特定的值,并且需要在 O(log n)的时间复杂度内完成。因此,有三个线索指向我们二分搜索算法。当然,最大的问题在于排序后的数组的循环性,因此我们不能应用普通的二分搜索算法。

让我们假设m = {11, 14, 23, 24, -1, 3, 5, 6, 8, 9, 10},x = 14,我们期望的输出是索引 1。以下图像介绍了几个符号,并作为解决手头问题的指导:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.25 – 循环排序数组和二分搜索算法

由于排序后的数组是循环的,我们有一个pivot。这是一个指向数组头部的索引。从pivot左边的元素已经被旋转。当数组没有旋转时,它将是{-1, 3, 5, 6, 8, 9, 10, 11, 14, 23, 24}。现在,让我们看一下基于二分搜索算法的解决方案步骤:

  1. 我们应用二分搜索算法,因此我们从计算数组的middle开始,即(left + right) / 2。

  2. 我们检查是否x = m[middle]。如果是,则返回middle。如果不是,则继续下一步。

  3. 接下来,我们检查数组的右半部分是否已排序。如果m[middle] <= m[right],则范围[middle, right]中的所有元素都已排序:

a. 如果x > m[middle]并且x <= m[right],那么我们忽略左半部分,设置left = middle + 1,并从步骤 1重复。

b. 如果x <= m[middle]或x > m[right],那么我们忽略右半部分,设置right = middle - 1,并从步骤 1重复。

  1. 如果数组的右半部分没有排序,那么左半部分必须是排序的:

a. 如果x >= m[left]并且x < m[middle],那么我们忽略右半部分,设置right = middle- 1,并从步骤 1重复。

b. 如果x < m[left]或x >= m[middle],那么我们忽略左半部分,设置left = middle + 1,并从步骤 1重复。

我们重复步骤 1-4,只要我们没有找到xleft <= right

让我们将前述算法应用到我们的情况中。

因此,middle是(left + right) / 2 = (0 + 10) / 2 = 5。由于m[5] ≠14(记住 14 是x),我们继续进行步骤 3。由于m[5]<m[10],我们得出右半部分是排序的结论。然而,我们注意到x>m[right](14 >10),所以我们应用步骤 3b。基本上,我们忽略右半部分,然后设置right = middle - 1 = 5 - 1 = 4。我们再次应用步骤 1

新的middle是(0 + 4) / 2 = 2。由于m[2]≠14,我们继续进行步骤 3。由于m[2] >m[4],我们得出左半部分是排序的结论。我们注意到x>m[left](14 >11)和x<m[middle](14<23),所以我们应用步骤 4a。我们忽略右半部分,然后设置right= middle - 1 = 2 - 1 = 1。我们再次应用步骤 1

新的middle是(0 + 1) / 2 = 0。由于m[0]≠14,我们继续进行步骤 3。由于m[0]<m[1],我们得出右半部分是排序的结论。我们注意到x > m[middle](14 > 11)和x = m[right](14 = 14),所以我们应用步骤 3a。我们忽略左半部分,然后设置left = middle + 1 = 0 + 1 = 1。我们再次应用步骤 1

新的middle是(1 + 1) / 2 = 1。由于m[1]=14,我们停止并返回 1 作为我们找到搜索值的数组索引。

让我们把这些放入代码中:

public static int find(int[] m, int x) {
  int left = 0;
  int right = m.length - 1;
  while (left <= right) {
    // half the search space
    int middle = (left + right) / 2;
    // we found the searched value
    if (m[middle] == x) {
      return middle;
    }
    // check if the right-half is sorted (m[middle ... right])
    if (m[middle] <= m[right]) {
      // check if n is in m[middle ... right]
      if (x > m[middle] && x <= m[right]) {
        left = middle + 1;  // search in the right-half
      } else {
        right = middle - 1;	// search in the left-half
      }
    } else { // the left-half is sorted (A[left ... middle])
      // check if n is in m[left ... middle]
      if (x >= m[left] && x < m[middle]) {
        right = middle - 1; // search in the left-half
      } else {
        left = middle + 1; // search in the right-half
      }
    }
  }
  return -1;
}

完整的应用程序称为SearchInCircularArray。类似的问题会要求你在一个循环排序的数组中找到最大值或最小值。虽然这两个应用程序都包含在捆绑代码中,分别为MaximumInCircularArrayMinimumInCircularArray,但建议你利用到目前为止学到的知识,挑战自己找到解决方案。

编码挑战 18-合并间隔

亚马逊谷歌Adobe微软Flipkart

问题:考虑到你已经得到了一个[start, end]类型的间隔数组。编写一小段代码,合并所有重叠的间隔。

解决方案:让我们假设给定的间隔是[12,15],[12,17],[2,4],[16,18],[4,7],[9,11]和[1,2]。在我们合并重叠的间隔之后,我们得到以下结果:[1, 7],[9, 11] [12, 18]。

我们可以从蛮力方法开始。很直观的是,我们取一个间隔(让我们将其表示为pi),并将其结束(pei)与其余间隔的开始进行比较。如果另一个间隔的开始(来自其余间隔)小于p的结束,那么我们可以合并这两个间隔。合并间隔的结束变为这两个间隔的结束的最大值。但是这种方法的时间复杂度为 O(n2),所以它不会给面试官留下深刻印象。

然而,蛮力方法可以为我们尝试更好的实现提供重要提示。在任何时刻,我们必须将p的结束与另一个间隔的开始进行比较。这很重要,因为它可以引导我们思考按照它们的开始时间对间隔进行排序的想法。这样,我们可以大大减少比较的次数。有了排序的间隔,我们可以在线性遍历中合并所有间隔。

让我们尝试使用一个图形表示我们的样本间隔,按照它们的开始时间按升序排序(psi<psi+1<psi+2)。此外,每个间隔始终是向前看的(pei>psi,pei+1>psi+1,pei+2>psi+2,依此类推)。这将帮助我们理解我们即将介绍的算法:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.26-对给定的间隔进行排序

根据前面的图像,我们可以看到如果p的起始值大于前一个p的结束值(psi>pei-1),那么下一个p的起始值也大于前一个p的结束值(psi+1>pei-1),所以不需要比较前一个p和下一个p。换句话说,如果pi 不与pi-1 重叠,则pi+1 也不能与pi-1 重叠,因为pi+1 的起始值必须大于或等于pi。

如果psi 小于pei-1,则我们应该将pei-1 更新为pei-1 和pei 之间的最大值,并移动到pei+1。这可以通过栈来完成,具体步骤如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.27 – 使用栈解决问题

这些是发生的步骤:

步骤 0:我们从一个空栈开始。

步骤 1:由于栈是空的,我们将第一个区间([1, 2])推入栈中。

步骤 2:接下来,我们关注第二个区间([2, 4])。[2, 4]的起始值等于栈顶部的区间[1, 2]的结束值,所以我们不将[2, 4]推入栈中。我们继续比较[1, 2]的结束值和[2, 4]的结束值。由于 2 小于 4,我们将区间[1, 2]更新为[1, 4]。所以,我们将[1, 2]与[2, 4]合并。

步骤 3:接下来,我们关注区间[4, 7]。[4, 7]的起始值等于栈顶部的区间[1, 4]的结束值,所以我们不将[4, 7]推入栈中。我们继续比较[1, 4]的结束值和[4, 7]的结束值。由于 4 小于 7,我们将区间[1, 4]更新为[1, 7]。所以,我们将[1, 4]与[4, 7]合并。

步骤 4:接下来,我们关注区间[9, 11]。[9, 11]的起始值大于栈顶部的区间[1, 7]的结束值,所以区间[1, 7]和[9, 11]不重叠。这意味着我们可以将区间[9, 11]推入栈中。

步骤 5:接下来,我们关注区间[12, 15]。[12, 15]的起始值大于栈顶部的区间[9, 11]的结束值,所以区间[9, 11]和[12, 15]不重叠。这意味着我们可以将区间[12, 15]推入栈中。

步骤 6:接下来,我们关注区间[12, 17]。[12, 17]的起始值等于栈顶部的区间[12, 15]的结束值,所以我们不将[12, 17]推入栈中。我们继续比较[12, 15]的结束值和[12, 17]的结束值。由于 15 小于 17,我们将区间[12, 15]更新为[12, 17]。所以,这里我们将[12, 15]与[12, 17]合并。

步骤 7:最后,我们关注区间[16, 18]。[16, 18]的起始值小于栈顶部的区间[12, 17]的结束值,所以区间[16, 18]和[12, 17]重叠。这时,我们需要使用[16, 18]的结束值和栈顶部区间的结束值之间的最大值来更新栈顶部的区间的结束值。由于 18 大于 17,栈顶部的区间变为[12, 17]。

现在,我们可以弹出栈的内容来查看合并后的区间,[[12, 18], [9, 11], [1, 7]],如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.28 – 合并后的区间

基于这些步骤,我们可以创建以下算法:

  1. 根据起始值对给定的区间进行升序排序。

  2. 将第一个区间推入栈中。

  3. 对于剩下的区间,执行以下操作:

a. 如果当前区间与栈顶部的区间不重叠,则将其推入栈中。

b. 如果当前区间与栈顶部的区间重叠,并且当前区间的结束值大于栈顶部的结束值,则使用当前区间的结束值更新栈顶部的结束值。

  1. 最后,栈中包含了合并后的区间。

在代码方面,该算法如下所示:

public static void mergeIntervals(Interval[] intervals) {
  // Step 1
  java.util.Arrays.sort(intervals,
          new Comparator<Interval>() {
    public int compare(Interval i1, Interval i2) {
      return i1.start - i2.start;
    }
  });
  Stack<Interval> stackOfIntervals = new Stack();
  for (Interval interval : intervals) {
    // Step 3a
    if (stackOfIntervals.empty() || interval.start
           > stackOfIntervals.peek().end) {
        stackOfIntervals.push(interval);
    }
    // Step 3b
    if (stackOfIntervals.peek().end < interval.end) {
      stackOfIntervals.peek().end = interval.end;
    }
  }
  // print the result
  while (!stackOfIntervals.empty()) {
    System.out.print(stackOfIntervals.pop() + " ");
  }
}

这段代码的运行时间是 O(n log n),辅助空间为 O(n)用于栈。虽然面试官应该对这种方法满意,但他/她可能会要求你进行优化。更确切地说,我们能否放弃栈并获得 O(1)的复杂度空间?

如果我们放弃栈,那么我们必须在原地执行合并操作。能够做到这一点的算法是不言自明的:

  1. 根据它们的开始时间,对给定的区间进行升序排序。

  2. 对于剩下的区间,做以下操作:

a. 如果当前区间不是第一个区间,并且与前一个区间重叠,则合并这两个区间。对所有先前的区间执行相同的操作。

b. 否则,将当前区间添加到输出数组中。

注意,这次区间按照它们的开始时间降序排序。这意味着我们可以通过比较前一个区间的开始和当前区间的结束来检查两个区间是否重叠。让我们看看这段代码:

public static void mergeIntervals(Interval intervals[]) {
  // Step 1
  java.util.Arrays.sort(intervals,
        new Comparator<Interval>() {
    public int compare(Interval i1, Interval i2) {
      return i2.start - i1.start;
    }
  });
  int index = 0;
  for (int i = 0; i < intervals.length; i++) {
    // Step 2a
    if (index != 0 && intervals[index - 1].start 
             <= intervals[i].end) {
      while (index != 0 && intervals[index - 1].start 
             <= intervals[i].end) {
        // merge the previous interval with 
        // the current interval  
        intervals[index - 1].end = Math.max(
          intervals[index - 1].end, intervals[i].end);
        intervals[index - 1].start = Math.min(
          intervals[index - 1].start, intervals[i].start);
        index--;
      }
    // Step 2b
    } else {
      intervals[index] = intervals[i];
    }
    index++;
  }
  // print the result        
  for (int i = 0; i < index; i++) {
    System.out.print(intervals[i] + " ");
  }
}

这段代码的运行时间是 O(n log n),辅助空间为 O(1)。完整的应用程序称为MergeIntervals

编程挑战 19 – 加油站环形旅游

亚马逊谷歌Adobe微软Flipkart

问题:考虑到你已经得到了沿着圆形路线的n个加油站。每个加油站包含两个数据:燃料量(fuel[])和从当前加油站到下一个加油站的距离(dist[])。接下来,你有一辆带有无限油箱的卡车。编写一小段代码,计算卡车应该从哪个加油站开始以完成一次完整的旅程。你从一个加油站开始旅程时,油箱是空的。用 1 升汽油,卡车可以行驶 1 单位的距离。

解决方案:考虑到你已经得到了以下数据:dist = {5, 4, 6, 3, 5, 7}, fuel = {3, 3, 5, 5, 6, 8}。

让我们使用以下图像更好地理解这个问题的背景,并支持我们找到解决方案:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.29 – 卡车环形旅游示例

从 0 到 5,我们有六个加油站。在图像的左侧,你可以看到给定圆形路线的草图和加油站的分布。第一个加油站有 3 升汽油,到下一个加油站的距离是 5 单位。第二个加油站有 3 升汽油,到下一个加油站的距离是 4 单位。第三个加油站有 5 升汽油,到下一个加油站的距离是 6 单位,依此类推。显然,如果我们希望从加油站X到加油站Y,一个重要的条件是XY之间的距离小于或等于卡车油箱中的燃料量。例如,如果卡车从加油站 0 开始旅程,那么它不能去加油站 1,因为这两个加油站之间的距离是 5 单位,而卡车的油箱只能装 3 升汽油。另一方面,如果卡车从加油站 3 开始旅程,那么它可以去加油站 4,因为卡车的油箱里会有 5 升汽油。实际上,如图像的右侧所示,这种情况的解决方案是从加油站 3 开始,油箱里有 5 升汽油 – 用纸和笔芯花点时间完成旅程。

蛮力(或者朴素)方法可以依赖于一个简单的陈述:我们从每个加油站开始,尝试完成整个旅程。这很容易实现,但其运行时间将为 O(n2)。挑战自己想出一个更好的实现。

为了更有效地解决这个问题,我们需要理解和使用以下事实:

  • 如果燃料总量≥距离总量,则旅程可以完成。

  • 如果加油站X不能在X → Y → Z的顺序中到达加油站Z,那么Y也不能到达。

第一个要点是常识,第二个要点需要一些额外的证明。以下是第二个要点背后的推理:

如果fuel[X] < dist[X],那么X甚至无法到达Y。因此,要从XZfuel[X]必须≥ dist[X]。

鉴于X无法到达Z,我们有fuel[X] + fuel[Y] < dist[X] + dist[Y],而fuel*[X] ≥ dist[X]。因此,fuel[Y] < dist[Y],Y也无法到达Z

基于这两点,我们可以得出以下实现:

public static int circularTour(int[] fuel, int[] dist) {
  int sumRemainingFuel = 0; // track current remaining fuel
  int totalFuel = 0;        // track total remaining fuel
  int start = 0;
  for (int i = 0; i < fuel.length; i++) {
    int remainingFuel = fuel[i] - dist[i];
    //if sum remaining fuel of (i-1) >= 0 then continue 
    if (sumRemainingFuel >= 0) {
      sumRemainingFuel += remainingFuel;
      //otherwise, reset start index to be current
    } else {
      sumRemainingFuel = remainingFuel;
      start = i;
    }
    totalFuel += remainingFuel;
  }
  if (totalFuel >= 0) {
    return start;
  } else {
    return -1;
  }
}

要理解这段代码,可以尝试使用纸和笔将给定的数据通过代码传递。此外,您可能希望尝试以下集合:

// start point 1
int[] dist = {2, 4, 1};
int[] fuel = {0, 4, 3};
// start point 1
int[] dist = {6, 5, 3, 5};
int[] fuel = {4, 6, 7, 4};
// no solution, return -1
int[] dist = {1, 3, 3, 4, 5};
int[] fuel = {1, 2, 3, 4, 5};
// start point 2
int[] dist = {4, 6, 6};
int[] fuel = {6, 3, 7};

这段代码的运行时间是 O(n)。完整的应用程序称为PetrolBunks

编程挑战 20 - 困住雨水

亚马逊谷歌Adobe微软Flipkart

问题:假设你已经得到了一组不同高度(非负整数)的酒吧。每个酒吧的宽度等于 1。编写一小段代码,计算可以在酒吧之间困住的水量。

解决方案:假设给定的一组酒吧是一个数组,如下所示:bars = { 1, 0, 0, 4, 0, 2, 0, 1, 6, 2, 3}。以下图片是这些酒吧高度的草图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.30 - 给定的一组酒吧

现在,雨水在这些酒吧之间的空隙中积水。因此,雨后我们将得到以下情况:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.31 - 雨后的给定酒吧

因此,在这里,我们最多可以获得 16 单位的水。这个问题的解决方案取决于我们如何看待水。例如,我们可以看看酒吧之间的水,或者看看每个酒吧顶部的水。第二种观点正是我们想要的。

查看以下图片,其中有一些关于如何隔离每个酒吧顶部的水的额外指导:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.32 - 每个酒吧顶部的水

因此,在酒吧 0 上方,我们没有水。在酒吧 1 上方,我们有 1 单位的水。在酒吧 2 上方,我们有 1 单位的水,依此类推。如果我们将这些值相加,那么我们得到 0 + 1 + 1 + 0 + 4 + 2 + 4 + 3 + 0 + 1 + 0 = 16,这就是我们拥有的水的精确数量。但是,要确定酒吧x顶部的水量,我们必须知道左右两侧最高酒吧之间的最小值。换句话说,对于每个酒吧,即 1、2、3…9(注意我们不使用酒吧 0 和 10,因为它们是边界),我们必须确定左右两侧最高酒吧,并计算它们之间的最小值。以下图片展示了我们的计算(中间的酒吧范围从 1 到 9):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.33 - 左右两侧最高的酒吧

因此,我们可以得出一个简单的解决方案,即遍历酒吧以找到左右两侧的最高酒吧。这两个酒吧的最小值可以被利用如下:

  • 如果最小值小于当前酒吧的高度,则当前酒吧无法在其顶部容纳水。

  • 如果最小值大于当前酒吧的高度,则当前酒吧可以容纳的水量等于最小值与其顶部的当前酒吧高度之间的差值。

因此,这个问题可以通过计算每个酒吧左右两侧的最高酒吧来解决。这些陈述的有效实现包括在 O(n)时间内预先计算每个酒吧左右两侧的最高酒吧。然后,我们需要使用结果来找到每个酒吧顶部的水量。以下代码应该澄清任何其他细节:

public static int trap(int[] bars) {
  int n = bars.length - 1;
  int water = 0;
  // store the maximum height of a bar to 
  // the left of the current bar
  int[] left = new int[n];
  left[0] = Integer.MIN_VALUE;
  // iterate the bars from left to right and 
  // compute each left[i]
  for (int i = 1; i < n; i++) {
    left[i] = Math.max(left[i - 1], bars[i - 1]);
  }
  // store the maximum height of a bar to the 
  // right of the current bar
  int right = Integer.MIN_VALUE;
  // iterate the bars from right to left 
  // and compute the trapped water
  for (int i = n - 1; i >= 1; i--) {
    right = Math.max(right, bars[i + 1]);
    // check if it is possible to store water 
    // in the current bar           
    if (Math.min(left[i], right) > bars[i]) {
      water += Math.min(left[i], right) - bars[i];
    }
  }
  return water;
}

这段代码的运行时间为 O(n),left[]数组的辅助空间为 O(n)。使用基于堆栈的实现也可以获得类似的大 O。那么如何编写一个具有 O(1)空间的实现呢?

好吧,我们可以使用两个变量来存储到目前为止的最大值(这种技术称为双指针),而不是维护一个大小为n的数组来存储所有左侧的最大高度。正如您可能记得的,您在之前的一些编程挑战中观察到了这一点。这两个指针是maxBarLeftmaxBarRight。实现如下:

public static int trap(int[] bars) {
  // take two pointers: left and right pointing 
  // to 0 and bars.length-1        
  int left = 0;
  int right = bars.length - 1;
  int water = 0;
  int maxBarLeft = bars[left];
  int maxBarRight = bars[right];
  while (left < right) {
    // move left pointer to the right
    if (bars[left] <= bars[right]) {
      left++;
      maxBarLeft = Math.max(maxBarLeft, bars[left]);
      water += (maxBarLeft - bars[left]);
    // move right pointer to the left
    } else {
      right--;
      maxBarRight = Math.max(maxBarRight, bars[right]);
      water += (maxBarRight - bars[right]);
    }
  }
  return water;
}

这段代码的运行时间为 O(n),空间复杂度为 O(1)。完整的应用程序称为TrapRainWater

编程挑战 21 - 购买和出售股票

亚马逊微软

问题:假设您已经得到了一个表示每天股票价格的正整数数组。因此,数组的第 i 个元素表示第 i 天的股票价格。通常情况下,您可能不会同时进行多次交易(买卖序列称为一次交易),并且必须在再次购买之前出售股票。编写一小段代码,在以下情况中返回最大利润(通常情况下,面试官会给您以下情况中的一个):

  • 您只允许买卖股票一次。

  • 您只允许买卖股票两次。

  • 您可以无限次地买卖股票。

  • 您只允许买卖股票* k次( k*已知)。

解决方案:假设给定的价格数组为prices={200, 500, 1000, 700, 30, 400, 900, 400, 550}。让我们分别解决上述情况。

只买卖一次股票

在这种情况下,我们必须通过只买卖一次股票来获得最大利润。这是非常简单和直观的。想法是在股票最便宜时买入,在最昂贵时卖出。让我们通过以下价格趋势图来确认这一说法:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.34 - 价格趋势图

根据上图,我们应该在第 5 天以 30 的价格买入股票,并在第 7 天以 900 的价格卖出。这样,利润将达到最大值(870)。为了确定最大利润,我们可以采用一个简单的算法,如下所示:

  1. 考虑第 1 天的最低价格,没有利润(最大利润为 0)。

  2. 迭代剩余的天数(2、3、4、…)并执行以下操作:

a. 对于每一天,将最大利润更新为 max(当前最大利润,(今天的价格 - 最低价格))。

b. 将最低价格更新为 min(当前最低价格,今天的价格)。

让我们将这个算法应用到我们的数据中。因此,我们将第 1 天的最低价格视为 200,最大利润为 0。下图显示了每天的计算:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.35 - 计算最大利润

第 1 天最低价格为 200;第 1 天的价格 - 最低价格 = 0;因此,到目前为止最大利润为 200。

第 2 天最低价格为 200(因为 500 > 200);第 2 天的价格 - 最低价格 = 300;因此,到目前为止最大利润为 300(因为 300 > 200)。

第 3 天最低价格为 200(因为 1000 > 200);第 3 天的价格 - 最低价格 = 800;因此,到目前为止最大利润为 800(因为 800 > 300)。

第 4 天最低价格为 200(因为 700 > 200);第 4 天的价格 - 最低价格 = 500;因此,到目前为止最大利润为 800(因为 800 > 500)。

第 5 天最低价格为 30(因为 200 > 30);第 5 天的价格 - 最低价格 = 0;因此,到目前为止最大利润为 800(因为 800 > 0)。

第 6 天最低价格是 30(因为 400 > 30);第 6 天的价格 - 最低价格 = 370;因此,到目前为止最大利润是 800(因为 800 > 370)。

第 7 天最低价格是 30(因为 900 > 30);第 7 天的价格 - 最低价格 = 870;因此,到目前为止最大利润是 870(因为 870 > 800)。

第 8 天最低价格是 30(因为 400 > 30);第 8 天的价格 - 最低价格 = 370;因此,到目前为止最大利润是 870(因为 870 > 370)。

第 9 天最低价格是 30(因为 550 > 30);第 9 天的价格 - 最低价格 = 520;因此,到目前为止最大利润是 870(因为 870 >520)。

最后,最大利润是 870。

让我们看看代码:

public static int maxProfitOneTransaction(int[] prices) {
  int min = prices[0];
  int result = 0;
  for (int i = 1; i < prices.length; i++) {
    result = Math.max(result, prices[i] - min);
    min = Math.min(min, prices[i]);
  }
  return result;
}

这段代码的运行时间是 O(n)。让我们来解决下一个情景。

只买卖股票两次

在这种情况下,我们必须通过只买卖股票两次来获得最大利润。想法是在股票最便宜时买入,最昂贵时卖出。我们这样做两次。让我们通过以下价格趋势图来识别这个陈述:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.36 - 价格趋势图

根据前面的图表,我们应该在第 1 天以 200 的价格买入股票,然后在第 3 天以 1000 的价格卖出。这笔交易带来了 800 的利润。接下来,我们应该在第 5 天以 30 的价格买入股票,然后在第 7 天以 900 的价格卖出。这笔交易带来了 870 的利润。因此,最大利润是 870+800=1670。

要确定最大利润,我们必须找到两笔最有利可图的交易。我们可以通过动态规划和分治技术来实现这一点。我们将算法成两部分。算法的第一部分包含以下步骤:

  1. 考虑第 1 天的最便宜的价格

  2. 迭代剩下的天数(2,3,4,…)并执行以下操作:

a. 更新最便宜的价格,作为 min(当前最便宜的价格,今天的价格*)。

b. 跟踪今天的最大利润,作为 max(前一天的最大利润,(今天的价格 - 最便宜的价格))。

在这个算法结束时,我们将得到一个数组(让我们称之为left[]),表示每天(包括当天)之前可以获得的最大利润。例如,直到第 3 天(包括第 3 天),最大利润是 800,因为你可以在第 1 天以 200 的价格买入,第 3 天以 1000 的价格卖出,或者直到第 7 天(包括第 7 天),最大利润是 870,因为你可以在第 5 天以 30 的价格买入,第 7 天以 900 的价格卖出,依此类推。

这个数组是通过步骤 2b获得的。我们可以将它表示为我们的数据如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.37 - 从第 1 天开始计算每天之前的最大利润

left[]数组在我们覆盖算法的第二部分之后非常有用。接下来,算法的第二部分如下:

  1. 考虑最后一天的最昂贵的价格

  2. 从(最后-1)到第一天(最后-1,最后-2,最后-3,…)迭代剩下的天数,并执行以下操作:

a. 更新最昂贵的价格,作为 max(当前最昂贵的价格,今天的价格*)。

b. 跟踪今天的最大利润,作为 max(下一天的最大利润,(最昂贵的价格 - 今天的价格*))。

在这个算法结束时,我们将得到一个数组(让我们称之为right[]),表示每天(包括当天)之后可以获得的最大利润。例如,第 3 天之后(包括第 3 天),最大利润是 870,因为你可以在第 5 天以 30 的价格买入,第 7 天以 900 的价格卖出,或者第 7 天之后最大利润是 150,因为你可以在第 8 天以 400 的价格买入,第 9 天以 550 的价格卖出,依此类推。这个数组是通过步骤 2b获得的。我们可以将它表示为我们的数据如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.38 - 从前一天开始计算每天的最大利润

到目前为止,我们已经完成了分割部分。现在,是征服部分的时间了。可以通过 max(left[day]+right[day])获得可以在两次交易中实现的最大利润。我们可以在下图中看到这一点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.39 - 计算第 1 和第 2 次交易的最终最大利润

现在,让我们来看代码:

public static int maxProfitTwoTransactions(int[] prices) {
  int[] left = new int[prices.length];
  int[] right = new int[prices.length];
  // Dynamic Programming from left to right
  left[0] = 0;
  int min = prices[0];
  for (int i = 1; i < prices.length; i++) {
    min = Math.min(min, prices[i]);
    left[i] = Math.max(left[i - 1], prices[i] - min);
  }
  // Dynamic Programming from right to left
  right[prices.length - 1] = 0;
  int max = prices[prices.length - 1];
  for (int i = prices.length - 2; i >= 0; i--) {
    max = Math.max(max, prices[i]);
    right[i] = Math.max(right[i + 1], max - prices[i]);
  }
  int result = 0;
  for (int i = 0; i < prices.length; i++) {
    result = Math.max(result, left[i] + right[i]);
  }
  return result;
}

这段代码的运行时间是 O(n)。现在,让我们来处理下一个情景。

买卖股票的次数不限

在这种情况下,我们必须通过买卖股票不限次数来获得最大利润。您可以通过以下价格趋势图来确定这一点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.40 - 价格趋势图

根据前面的图表,我们应该在第 1 天以 200 的价格买入股票,然后在第 2 天以 500 的价格卖出。这次交易带来了 300 的利润。接下来,我们应该在第 2 天以 500 的价格买入股票,然后在第 3 天以 1000 的价格卖出。这次交易带来了 500 的利润。当然,我们可以将这两次交易合并为一次,即在第 1 天以 200 的价格买入,然后在第 3 天以 1000 的价格卖出。同样的逻辑可以应用到第 9 天。最终的最大利润将是 1820。花点时间,确定从第 1 天到第 9 天的所有交易。

通过研究前面的价格趋势图,我们可以看到这个问题可以被视为尝试找到所有的升序序列。以下图突出显示了我们数据的升序序列:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.41 - 升序序列

根据以下算法,找到所有的升序序列是一个简单的任务:

  1. 最大利润视为 0(无利润)。

  2. 迭代所有的天数,从第 2 天开始,并执行以下操作:

a. 计算今日价格前一天价格之间的差异(例如,在第一次迭代中,计算(第 2 天的价格 - 第 1 天的价格),所以 500 - 200)。

b. 如果计算出的差异为正数,则将最大利润增加这个差异。

在这个算法结束时,我们将知道最终的最大利润。如果我们将这个算法应用到我们的数据中,那么我们将得到以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.42 - 计算最终最大利润

第 1 天最大利润为 0。

第 2 天最大利润为 0 + (500 - 200) = 0 + 300 = 300。

第 3 天最大利润为 300 + (1000 - 500) = 300 + 500 = 800。

第 4 天最大利润仍为 800,因为 700 - 1000 < 0。

第 5 天最大利润仍为 800,因为 30 - 700 < 0。

第 6 天最大利润为 800 + (400 - 30) = 800 + 370 = 1170。

第 7 天最大利润为 1170 + (900 - 400) = 1170 + 500 = 1670。

第 8 天最大利润仍为 1670,因为 400 - 900 < 0。

第 9 天最大利润为 1670 + (550 - 400) = 1670 + 150 = 1820。

最终的最大利润为 1820。

在代码方面,情况如下:

public static int maxProfitUnlimitedTransactions(
          int[] prices) {
  int result = 0;
  for (int i = 1; i < prices.length; i++) {
    int diff = prices[i] - prices[i - 1];
    if (diff > 0) {               
      result += diff;
    }
  }
  return result;
}

这段代码的运行时间是 O(n)。接下来,让我们来处理最后一个情景。

只买卖股票 k 次(给定 k)

这种情况是只买卖股票两次的一般化版本。主要是,通过解决这种情况,我们也解决了k=2 时的只买卖股票两次情况。

根据我们从之前情景中的经验,我们知道解决这个问题可以通过动态规划来完成。更确切地说,我们需要跟踪两个数组:

  • 第一个数组将跟踪在第q天进行最后一笔交易时p次交易的最大利润

  • 第二个数组将跟踪在第q天之前p次交易的最大利润

如果我们将第一个数组表示为temp,第二个数组表示为result,那么我们有以下两个关系:

  1. temp[p] = Math.max(result[p - 1] 
                + Math.max(diff, 0), temp[p] + diff);
    
result[p] = Math.max(temp[p], result[p]);

为了更好地理解,让我们将这些关系放入代码的上下文中:

public static int maxProfitKTransactions(
          int[] prices, int k) {
  int[] temp = new int[k + 1];
  int[] result = new int[k + 1];
  for (int q = 0; q < prices.length - 1; q++) {
    int diff = prices[q + 1] - prices[q];
    for (int p = k; p >= 1; p--) {
      temp[p] = Math.max(result[p - 1] 
              + Math.max(diff, 0), temp[p] + diff);
      result[p] = Math.max(temp[p], result[p]);
     }
  }
  return result[k];
}

这段代码的运行时间是 O(kn)。完整的应用程序称为BestTimeToBuySellStock

编码挑战 22-最长序列

亚马逊Adobe微软

问题:考虑到你已经得到了一个整数数组。编写一小段代码,找到最长的整数序列。注意,序列只包含连续不同的元素。给定数组中元素的顺序并不重要。

解决方案:假设给定数组是{4, 2, 9, 5, 12, 6, 8}。最长序列包含三个元素,由 4、5 和 6 组成。或者,如果给定数组是{2, 0, 6, 1, 4, 3, 8},那么最长序列包含五个元素,由 2、0、1、4 和 3 组成。再次注意,给定数组中元素的顺序并不重要。

蛮力或朴素方法包括对数组进行升序排序,并找到最长的连续整数序列。由于数组已排序,间隙会打破序列。然而,这样的实现将具有 O(n log n)的运行时间。

更好的方法是使用哈希技术。让我们使用以下图像来支持我们的解决方案:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10.43-序列集

首先,我们从给定数组{4, 2, 9, 5, 12, 6, 8}构建一个集合。如前面的图像所示,集合不保持插入顺序,但这对我们来说并不重要。接下来,我们遍历给定数组,并对于每个遍历的元素(我们将其表示为e),我们搜索e-1 的集合。例如,当我们遍历 4 时,我们搜索 3 的集合,当我们遍历 2 时,我们搜索 1,依此类推。如果e-1 不在集合中,那么我们可以说e代表连续整数新序列的开始(在这种情况下,我们有以 12、8、4 和 2 开头的序列);否则,它已经是现有序列的一部分。当我们有新序列的开始时,我们继续搜索连续元素的集合:e+1、e+2、e+3 等等。只要我们找到连续元素,我们就计数它们。如果找不到*e+i(1、2、3、…),那么当前序列就完成了,我们知道它的长度。最后,我们将这个长度与迄今为止找到的最长长度进行比较,并相应地进行下一步。

这段代码非常简单:

public static int findLongestConsecutive(int[] sequence) {
  // construct a set from the given sequence
  Set<Integer> sequenceSet = IntStream.of(sequence)
    .boxed()
    .collect(Collectors.toSet());
  int longestSequence = 1;
  for (int elem : sequence) {
    // if 'elem-1' is not in the set then     // start a new sequence
    if (!sequenceSet.contains(elem - 1)) {
      int sequenceLength = 1;
      // lookup in the set for elements 
      // 'elem + 1', 'elem + 2', 'elem + 3' ...
      while (sequenceSet.contains(elem + sequenceLength)) {
        sequenceLength++;
      }
      // update the longest consecutive subsequence
      longestSequence = Math.max(
        longestSequence, sequenceLength);
    }
  }
  return longestSequence;
}

这段代码的运行时间是 O(n),辅助空间是 O(n)。挑战自己并打印最长的序列。完整的应用程序称为LongestConsecutiveSequence

编码挑战 23-计分游戏

亚马逊谷歌微软

问题:考虑一个游戏,玩家可以在单次移动中得分 3、5 或 10 分。此外,考虑到你已经得到了一个总分n。编写一小段代码,返回达到这个分数的方法数。

解决方案:假设给定的分数是 33。有七种方法可以达到这个分数:

(10+10+10+3) = 33

(5+5+10+10+3) = 33

(5+5+5+5+10+3) = 33

(5+5+5+5+5+5+3) = 33

(3+3+3+3+3+3+3+3+3+3+3) = 33

(3+3+3+3+3+3+5+5+5) = 33

(3+3+3+3+3+3+5+10) = 33

我们可以借助动态规划来解决这个问题。我们创建一个大小等于n+1 的表(数组)。在这个表中,我们存储从 0 到n的所有分数的计数。对于移动 3、5 和 10,我们增加数组中的值。代码说明了一切:

public static int count(int n) {
  int[] table = new int[n + 1];
  table[0] = 1;
  for (int i = 3; i <= n; i++) {
    table[i] += table[i - 3];
  }
  for (int i = 5; i <= n; i++) {
    table[i] += table[i - 5];
  }
  for (int i = 10; i <= n; i++) {
    table[i] += table[i - 10];
  }
  return table[n];
}

这段代码的运行时间是 O(n),额外空间是 O(n)。完整的应用程序称为CountScore3510

编码挑战 24-检查重复项

亚马逊谷歌Adobe

如果这个数组包含重复项,则返回true

解决方案:假设给定的整数是arr={1, 4, 5, 4, 2, 3},所以 4 是重复的。蛮力方法(或者朴素方法)将依赖嵌套循环,如下面的简单代码所示:

public static boolean checkDuplicates(int[] arr) {
  for (int i = 0; i < arr.length; i++) {
    for (int j = i + 1; j < arr.length; j++) {
      if (arr[i] == arr[j]) {
        return true;
      }
    }
  }
  return false;
}

这段代码非常简单,但是它的时间复杂度是 O(n2),辅助空间复杂度是 O(1)。我们可以在检查重复项之前对数组进行排序。如果数组已经排序,那么我们可以比较相邻的元素。如果任何相邻的元素相等,我们可以说数组包含重复项:

public static boolean checkDuplicates(int[] arr) {
  java.util.Arrays.sort(arr);
  int prev = arr[0];
  for (int i = 1; i < arr.length; i++) {
    if (arr[i] == prev) {
      return true;
    }
    prev = arr[i];
  }
  return false;
}

这段代码的时间复杂度是 O(n log n)(因为我们对数组进行了排序),辅助空间复杂度是 O(1)。如果我们想要编写一个时间复杂度为 O(n)的实现,我们还必须考虑辅助空间复杂度为 O(n)。例如,我们可以依赖哈希(如果您不熟悉哈希的概念,请阅读第六章**,面向对象编程哈希表问题)。在 Java 中,我们可以通过内置的HashSet实现来使用哈希,因此无需从头开始编写哈希实现。但是HashSet有什么用呢?当我们遍历给定数组时,我们将数组中的每个元素添加到HashSet中。但是如果当前元素已经存在于HashSet中,这意味着我们找到了重复项,所以我们可以停止并返回:

public static boolean checkDuplicates(int[] arr) {
  Set<Integer> set = new HashSet<>();
  for (int i = 0; i < arr.length; i++) {
    if (set.contains(arr[i])) {
      return true;
    }

    set.add(arr[i]);
  }
  return false;
}

因此,这段代码的时间复杂度是 O(n),辅助空间复杂度是 O(n)。但是,如果我们记住HashSet不接受重复项,我们可以简化上述代码。换句话说,如果我们将给定数组的所有元素插入HashSet,并且这个数组包含重复项,那么HashSet的大小将与数组的大小不同。这个实现和一个基于 Java 8 的实现,具有 O(n)的运行时间和 O(n)的辅助空间,可以在本书附带的代码中找到。

如何实现具有 O(n)的运行时间和 O(1)的辅助空间?如果我们考虑给定数组的两个重要约束,这是可能的:

  • 给定的数组不包含负数元素。

  • 元素位于[0,n-1]的范围内,其中n=arr.length

在这两个约束的保护下,我们可以使用以下算法。

  1. 我们遍历给定的数组,对于每个arr[i],我们执行以下操作:

a. 如果arr[abs(arr[i])]大于 0,则将其变为负数。

b. 如果arr[abs(arr[i])]等于 0,则将其变为-(arr.length-1)。

c. 否则,我们返回true(有重复项)。

让我们考虑我们的数组arr={1, 4, 5, 4, 2, 3},并应用上述算法:

  • i=0,因为arr[abs(arr[0])] = arr[1] = 4 > 0 导致arr[1] = -arr[1] = -4。

  • i=1,因为arr[abs(arr[1])] = arr[4] = 2 > 0 导致arr[4] = -arr[4] = -2。

  • i=2,因为arr[abs(arr[5])] = arr[5] = 3 > 0 导致arr[5] = -arr[5] = -3。

  • i=3,因为arr[abs(arr[4])] = arr[4] = -2 < 0 返回true(我们找到了重复项)。

现在,让我们看看arr={1, 4, 5, 3, 0, 2, 0}:

  • i=0,因为arr[abs(arr[0])] = arr[1] = 4 > 0 导致arr[1] = -arr[1] = -4。

  • i=1,因为arr[abs(arr[1])] = arr[4] = 0 = 0 导致arr[4] = -(arr.length-1) = -6。

  • i=2,因为arr[abs(arr[2])] = arr[5] = 2 > 0 导致arr[5] = -arr[5] = -2。

  • i=3,因为arr[abs(arr[3])] = arr[3] = 3 > 0 导致arr[3] = -arr[3] = -3。

  • i=4,因为arr[abs(arr[4])] = arr[6] = 0 = 0 导致arr[6] = -(arr.length-1) = -6。

  • i=5,因为arr[abs(arr[5])] = arr[2] = 5 > 0 导致arr[2] = -arr[2] = -5。

  • i=6,因为arr[abs(arr[6])] = arr[6] = -6 < 0 返回true(我们找到了重复项)。

让我们把这个算法写成代码:

public static boolean checkDuplicates(int[] arr) {
  for (int i = 0; i < arr.length; i++) {
    if (arr[Math.abs(arr[i])] > 0) {
      arr[Math.abs(arr[i])] = -arr[Math.abs(arr[i])];
    } else if (arr[Math.abs(arr[i])] == 0) {
      arr[Math.abs(arr[i])] = -(arr.length-1);
    } else {
      return true;
    }
  }
  return false;
}

完整的应用程序称为DuplicatesInArray

对于接下来的五个编码挑战,您可以在本书附带的代码中找到解决方案。花点时间,挑战自己在查看附带代码之前想出一个解决方案。

编码挑战 25 - 最长不同子串

问题:假设你已经得到了一个字符串strstr的接受字符属于扩展 ASCII 表(256 个字符)。编写一小段代码,找到包含不同字符的str的最长子串。

解决方案:作为提示,使用滑动窗口技术。如果您对这种技术不熟悉,请考虑在继续之前阅读 Zengrui Wang 的滑动窗口技术medium.com/@zengruiwang/sliding-window-technique-360d840d5740)。完整的应用程序称为LongestDistinctSubstring。您可以访问以下链接检查代码:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter10/LongestDistinctSubstring

编码挑战 26-用排名替换元素

问题:假设你已经得到了一个没有重复元素的数组m。编写一小段代码,用数组的排名替换每个元素。数组中的最小元素排名为 1,第二小的排名为 2,依此类推。

TreeMap。完整的应用程序称为ReplaceElementWithRank。您可以访问以下链接检查代码:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter10/ReplaceElementWithRank

编码挑战 27-每个子数组中的不同元素

问题:假设你已经得到了一个数组m和一个整数n。编写一小段代码,计算大小为n的每个子数组中不同元素的数量。

HashMap用于存储当前窗口(大小为n)中元素的频率。完整的应用程序称为CountDistinctInSubarray。您可以访问以下链接检查代码:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter10/CountDistinctInSubarray

编码挑战 28-将数组旋转 k 次

问题:假设你已经得到了一个数组m和一个整数k。编写一小段代码,将数组向右旋转k次(例如,数组{1,2,3,4,5},旋转三次后结果为{3,4,5,1,2})。

解决方案:作为提示,依赖于取模(%)运算符。完整的应用程序称为RotateArrayKTimes。您可以访问以下链接检查代码:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter10/RotateArrayKTimes

编码挑战 29-已排序数组中的不同绝对值

问题:假设你已经得到了一个已排序的整数数组m。编写一小段代码,计算不同的绝对值(例如,-1 和 1 被视为一个值)。

解决方案:作为提示,使用滑动窗口技术。如果您对这种技术不熟悉,可以考虑在继续之前阅读 Zengrui Wang 的滑动窗口技术medium.com/@zengruiwang/sliding-window-technique-360d840d5740)。完整的应用程序称为CountDistinctAbsoluteSortedArray。您可以访问以下链接检查代码:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter10/CountDistinctAbsoluteSortedArray

摘要

本章的目标是帮助您掌握涉及字符串和/或数组的各种编码挑战。希望本章的编码挑战提供了各种技术和技能,这些技能将在许多属于这一类别的编码挑战中非常有用。不要忘记,您可以通过 Packt 出版的书籍Java 编码问题www.amazon.com/gp/product/1789801419/)进一步丰富您的技能。Java 编码问题包含 35 个以上的字符串和数组问题,这些问题在本书中没有涉及。

在下一章中,我们将讨论链表和映射。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值