Java-代码面试完全指南-全-

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 是一种非常流行的语言,在各种领域和行业的 IT 工作岗位中都有很多需求。由于 Java 赋予全球数十亿设备动力,它已成为一种非常吸引人的学习技术。然而,学习 Java 是一回事,开始在 Java 领域发展职业是另一回事。本书专门为那些想要发展 Java 职业并希望在 Java 中心面试中脱颖而出的人而写。

通过本书,您将学会如何做到以下几点:

  • 以一种对抗时尚解决 220 多个最受欢迎的 Java 编码面试问题,这些问题在包括谷歌、亚马逊、微软、Adobe 和 Flipkart 在内的众多公司中都会遇到。

  • 收集解决各种 Java 编码问题的最佳技术。

  • 解决旨在培养强大和快速逻辑能力的耐人寻味的算法。

  • 重点介绍了可以决定成功与失败之间差异的常见非技术面试问题。

  • 全面了解雇主对 Java 开发人员的要求。

通过本书,您将建立起解决 Java 编码面试问题的坚实信息基础。从本书中获得的知识将使您对自己充满信心,从而获得您的以 Java 为中心的梦想工作。

本书适合的读者是谁

《Java 完整编码面试指南》是一个全面的资源,适用于那些正在寻找 Java 开发人员(或相关)工作并需要以对抗时尚的方式解决编码问题的人。它专门为初级和中级候选人而设计。

本书涵盖了什么

第一章从哪里开始以及如何为面试做准备,是一本全面指南,解决了从零到聘用的 Java 面试准备过程。更确切地说,我们想要强调可以确保未来职业道路顺利成功的主要检查点。

第二章大公司的面试是什么样子,讨论了在谷歌、亚马逊、微软、Facebook 和 Crossover 等主要大型科技公司进行面试的方式。

第三章常见非技术问题及如何回答,解决了非技术问题的主要方面。面试的这一部分通常由招聘经理甚至人力资源部门负责。

第四章如何处理失败,讨论了面试的一个微妙方面 - 处理失败。本章的主要目的是向您展示如何识别失败的原因以及如何在将来减轻它们。

第五章如何应对编码挑战,涵盖了通常被称为技术面试的技术测验和编码挑战主题。

第六章面向对象编程,解释了在 Java 面试中遇到的面向对象编程的最受欢迎的问题和问题,包括 SOLID 原则和编码挑战,如点唱机、停车场和哈希表。

第七章算法的大 O 分析,提供了分析算法效率和可伸缩性的最流行指标,即大 O 符号,在技术面试的背景下。

第八章递归和动态规划,涵盖了面试官最喜欢的话题之一 - 递归和动态规划。这两个主题彼此紧密合作,因此您必须能够同时涵盖两者。

第九章位操作,解释了您在技术面试中应该了解的位操作的最重要方面。这类问题在面试中经常遇到,而且并不容易。在本章中,您将遇到 25 个这样的编码挑战。

第十章数组和字符串,涵盖了涉及字符串和数组的 29 个热门问题。

第十一章链表和映射,教授您在面试中遇到的与映射和链表相关的 17 个最著名的编码挑战。

第十二章栈和队列,解释了涉及栈和队列的 11 个最受欢迎的面试编码挑战。主要是要学习如何从头开始提供栈/队列实现,以及如何通过 Java 内置实现解决编码挑战。

第十三章树和图,涵盖了面试中最棘手的话题之一——树和图。虽然与这两个话题相关的问题有很多,但实际面试中只有少数问题会遇到。因此,非常重要的是高度重视涉及树和图的最受欢迎的问题。

第十四章排序和搜索,涵盖了技术面试中遇到的最受欢迎的排序和搜索算法。我们将涵盖诸如归并排序、快速排序、基数排序、堆排序和桶排序等排序算法,以及二分搜索等搜索算法。通过本章结束时,您应该能够解决涉及排序和搜索算法的各种问题。

第十五章数学和谜题,讨论了面试中的一个有争议的话题:数学和谜题问题。许多公司认为这类问题不应该成为技术面试的一部分,而其他公司仍然认为这个话题对面试很重要。

第十六章并发,涵盖了一般面试中涉及 Java 并发(多线程)的最受欢迎的问题。

第十七章函数式编程,探讨了 Java 函数式编程的最受欢迎的问题。我们涵盖了关键概念、lambda 和流。

第十八章单元测试,讨论了您在申请开发人员或软件工程师等职位时可能遇到的单元测试面试问题。当然,如果您正在寻找测试人员(手动/自动化)职位,那么本章可能只是测试的另一个视角。因此,请不要期望在这里看到特定于手动/自动化测试人员职位的问题。

第十九章系统可扩展性,提供了在初中级面试中可能会被问到的最广泛的可扩展性面试问题,比如 Web 应用软件架构师、Java 架构师或软件工程师。

充分利用本书

您只需要 Java(最好是 Java 8+)和您喜欢的 IDE(NetBeans、IntelliJ IDEA、Eclipse 等)。

我还强烈建议读者参考 Packt 出版的Java 编码问题书,以进一步提高您的技能。

下载示例代码文件

您可以从www.packt.com的帐户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载

  4. 搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的书籍和视频目录,可在github.com/PacktPublishing/上找到。来看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:

static.packt-cdn.com/downloads/9781839212062_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

文本中的代码:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:'Triangle,Rectangle 和 Circle类实现Shape接口并重写draw()`方法以绘制相应的形状。"

代码块设置如下:

public static void main(String[] args) {
 Shape triangle = new Triangle();
 Shape rectangle = new Rectangle();
 Shape circle = new Circle();
 triangle.draw();
 rectangle.draw();
 circle.draw();
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

public static void main(String[] args) {
 Shape triangle = new Triangle();
Shape rectangle = new Rectangle();
 Shape circle = new Circle();
 triangle.draw();
 rectangle.draw();
 circle.draw();
}

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中出现。例如:"然而,这种方法对于第三种情况 339809(1010010111101100001)不起作用。"

提示或重要说明

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在本书中发现错误,我们将不胜感激地向我们报告。请访问www.packtpub.com/support/errata,选择您的书,点击勘误提交表单链接,然后输入详细信息。

copyright@packt.com,附有材料的链接。

如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。阅读并使用本书后,为什么不在购买书籍的网站上留下评论呢?潜在读者可以看到并使用您的公正意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问packt.com

第一部分:面试的非技术部分

本节的目标是涵盖面试的非技术部分。这包括面试惯用语和大公司的模式,如亚马逊、微软、谷歌等。您将熟悉主要的非技术面试问题及其含义(面试官如何解释答案)。

本节包括以下章节:

  • [第一章],从哪里开始以及如何准备面试

  • [第二章],大公司面试的模式

  • [第三章],常见的非技术问题及如何回答

  • [第四章],如何处理失败

  • [第五章],如何应对编程挑战

第一章:从哪里开始,如何为面试做准备

本章是一个全面的指南,涵盖了从最开始到被聘用的 Java 面试准备过程。更确切地说,我们想要强调可以确保未来职业道路顺利和成功的主要检查点。当然,在你阅读本书的时候,你可能会发现自己处于这些检查点中的任何一个:

  • 尽早开始面试准备

  • 获得正确的经验

  • 向世界展示你的工作

  • 准备你的简历

  • 参加面试

在本章结束时,你将清楚地了解如何根据你目前的状态实现前述检查点。因此,让我们从覆盖第一个检查点开始,看看新手面试路线图。

新手面试路线图

让我们从一个基本的真理开始,这是绝对必要的,但不足以成为成功的开发者:最优秀的 Java 开发者对他们的工作充满激情,而且,随着时间的推移,真正的激情会变成职业。长期来看,激情是无价的,它会让你脱颖而出,远远超过那些技术娴熟但缺乏激情的人。

既然你买了这本书,你想要在 Java 软件开发职业中投入一些时间和金钱。主要是,你想成为令人惊叹的 Java 生态系统的一部分!你已经感受到了专注于使用 Java 工作所带来的力量和能量,因此,即使你还没有积极地考虑过,你已经开始为 Java 面试做准备。

很可能,你是一名学生,或者刚刚获得了 IT、计算机科学学士学位,或者你只是发现自己对 Java 语言有天赋。然而,既然你在这里,你一定有很多关于如何在 Java 生态系统中找到梦想工作的问题和疑虑。

是时候制定成功的计划了!以下流程图代表了一个学生或 Java 新手的面试路线图,他们想成为 Java 生态系统的一部分:

图 1.1 - 新手面试路线图

图 1.1 - 新手面试路线图

在这一章中,我们将涵盖前面图表中的每一项。让我们从第一项开始,了解自己

了解自己

在寻找工作之前,了解自己是很重要的。这意味着你应该知道自己是什么样的开发者,想要什么样的工作。

这对于获得正确的经验、发展你的技能包和找到合适的雇主至关重要。很可能,你可以涵盖各种 Java 编程任务,但你是否觉得它们都同样吸引人?做一些自己不喜欢的事情短期内是可以的,但长期来看是行不通的。

理想情况下,长期来看,你必须专注于自己最喜欢做的事情!这样,你最大程度地提高了成为顶尖 Java 开发者的机会。但是,做自己最喜欢的事情应该考虑到 IT 市场提供的内容(无论是短期还是长期)。一些 Java 技术在工作机会中得到了广泛覆盖,而其他一些可能需要很长时间才能找到工作,或者必须做一些非常不愉快的权衡(例如,搬迁)。强烈建议定期参考并参与(每一票都很重要)由网站如 blogs.oracle.com、snyk.io、jaxenter.com、codeburst.io、jetbrains.com 和 dzone.com 进行的最相关的 Java 调查。有很多公司可供选择,从统计学上来看,最大程度地提高了找到适合你的公司的机会。这是问题的一半,另一半是为确保你想要的工作的公司也想要你而做好准备。

现在,让我们来看看 10 个问题,这些问题将帮助你确定你计划成为什么样的开发者。审视自己,尝试将你的个性和技能与以下问题和解释相重叠:

  1. 你对开发用户界面或在幕后执行的重要业务逻辑感兴趣?开发出色的用户界面是图形界面的一个极其重要的方面。毕竟,图形界面是最终用户看到和与之交互的内容。它需要创造力、创新、远见和心理学(例如,开发多设备界面是相当具有挑战性的)。它需要对 Java AWT、Swing、JavaFX、Vaadin 等有所了解。另一方面,在幕后执行并回应最终用户操作的业务逻辑是界面背后的引擎,但对于最终用户来说,它大部分时间是一个黑匣子。业务逻辑需要强大的编码技能和对算法、数据结构、框架(如 Spring Boot、Jakarta EE 和 Hibernate)、数据库等的扎实知识。大多数 Java 开发人员选择编写幕后的业务逻辑(用于桌面和网络应用程序)。

  2. 你觉得哪种应用程序最吸引人(桌面、移动、网络或其他)?每种类型的应用程序都有特定的挑战和专用的工具套件。如今,公司的目标是尽可能多地吸引消费者,因此现代应用程序应该适用于多平台设备。最重要的是,你应该能够在编码时知道该应用程序将在不同的设备上公开,并与其他系统进行交互。

  3. 你是否特别感兴趣测试、调试和/或代码审查?具有编写有价值的测试、发现错误和审查代码的强大技能是保证高质量最终产品的最重要的技能。在这三个领域中,我们应该专注于测试,因为几乎任何 Java 开发人员的工作描述都要求候选人具有编写单元测试和集成测试的强大技能(最常用的工具是 JUnit、TestNG、Mockito 和 Cucumber-JVM)。然而,试图找到一个专门的 Java 测试工作或 Java 代码审查员是相当具有挑战性的,通常在大公司(特别是在提供远程工作的公司,如 Upstack 或 Crossover)中遇到。大多数公司更喜欢成对代码审查,每个 Java 开发人员都应该编写有意义的测试,为他们编写的代码提供高覆盖率。因此,你必须能够做到两者:编写令人惊讶的代码,并为该代码编写测试。

  4. 你对与数据库交互的应用程序感兴趣,还是试图避免这样的应用程序?大多数 Java 应用程序使用数据库(关系数据库或 NoSQL 数据库)。广泛的 Java 开发人员工作将必然要求你对通过对象关系映射框架(如 Hibernate)、JPA 实现(如 Hibernate JPA 或 Eclipse Link)或 SQL 中心库(如 jOOQ)编码具有强大的知识。大多数 Java 应用程序与关系数据库交互,如 MySQL、PostgreSQL、Oracle 或 SQL Server。但在相当数量的应用程序中也会遇到 NoSQL 数据库,如 MongoDB、Redis 或 Cassandra。试图避免开发与数据库交互的应用程序可能会严重限制提供的工作范围。如果这是你的情况,那么你应该从今天开始重新考虑这一方面。

  5. 你是否偏爱代码优化和性能?关心代码性能是一项非常受赞赏的技能。这样的行为会让你被归类为一个注重细节的完美主义者。拥有优化代码并提高性能的解决方案将很快让你参与设计和架构功能需求的解决方案。但在面试时(编码挑战阶段),不要专注于代码优化和性能!只需专注于提供一个可行的解决方案,并尽可能地编写清晰的代码。

  6. 对你来说,更吸引人的是专注于编码的工作还是成为软件架构师?作为一名 Java 开发人员,你在职业生涯的开始阶段将专注于编码并在代码层面上做出实现设计决策。随着时间的推移,一些开发人员发现自己在设计大型应用程序方面有能力和兴趣。这意味着是时候从 Java 开发人员进化为 Java 架构师,甚至是 Java 首席架构师。虽然编码仍然是你工作的一部分,但作为架构师,你将在同一天戴上不同的帽子。你需要在会议、架构设计和编码之间分配时间。如果你觉得自己有设计和架构项目不同部分的能力,那么建议你考虑一些软件架构方面的培训。此外,在专注于编码的工作期间,挑战自己看看你能找到什么解决方案,并将其与应用程序当前架构师实施的解决方案进行比较。

  7. 你更倾向于小公司还是大公司?选择小公司还是大公司是一种权衡。理想情况下,大公司(品牌)会提供稳定性、职业发展路径和良好的薪酬计划。但你可能会感到受到官僚主义、缺乏沟通和部门之间的竞争以及冷漠僵化的环境的限制。在小公司,你有机会更加强烈地感到自己是成功的一部分,并且会感受到成为一个小社区(甚至是一个家庭)的温暖愉悦感。然而,小公司可能会很快失败,你可能会在一两年内被解雇,很可能没有任何补偿计划。

  8. 你是针对软件公司(从事各种项目)还是特定行业(例如石油工业、医药、汽车工业等)?软件公司管理来自各个领域的项目(例如,软件公司可能同时为好莱坞明星开发网站、开发金融应用程序和航空交通管制应用程序)。从开发者的角度来看,这意味着你需要多方面思考,能够快速适应并理解不同业务领域的要求,而不需要深入了解这些领域。另一方面,大型行业(例如石油工业)更倾向于创建自己的 IT 部门,开发和维护特定于该公司领域的应用程序。在这种情况下,你很可能也会接受一些关于该公司领域的培训。你将有机会成为开发特定领域应用程序的专家。

  9. 你更喜欢远程工作吗?在过去几年中,大量公司决定雇佣远程开发人员。此外,像 Upwork、Remote|OK、X-Team 和 Crossover 这样的新公司是 100%远程公司,只招聘远程职位。在世界的任何一个角落工作并拥有灵活的工作时间是非常吸引人的。这些公司为初级、中级和高级开发人员提供工作机会,其中一些公司(例如 Crossover)还提供远程管理职位。但是,你也必须意识到这种安排的一些其他方面:可能会通过网络摄像头进行监控(例如,每 10 分钟拍摄一次快照);你需要在完全远程的团队中工作,团队成员来自不同的时区(例如,参加夜间会议可能会有挑战);你需要熟悉 JIRA、GitHub、Zoom、Slack、Meetup 和内部市场平台等工具;你可能会面临大量摩擦(大量电子邮件)和缺乏沟通;你需要支付税款,最后但同样重要的是,你可能需要实现不切实际的指标以牺牲质量来保持你的职位。

  10. 管理是否吸引您?通常,达到管理职位是需要领导能力的目标。换句话说,您应该能够在技术和人类层面上做出重要决策。从这个角度来看,您需要避免那些提供扎实技术职业道路但不提供晋升到管理层的机会的公司。

重要提示

了解自己是生活中做出最佳决定所需的最困难的部分之一。有时,询问其他人的意见是消除您对自己的主观看法的最佳方式。大多数时候,询问您的老师、父母和朋友将帮助您更好地了解您的技能和最适合您的位置。独自做出重要决定是有风险的。

一旦了解自己,就是了解市场的时候了。

了解市场

知道自己想要什么很好,但还不够。作为下一步,您应该研究市场对您的需求。目标是获得您想要的和市场提供的完美结合。

重要提示

发展市场技能是在不久的将来找工作的重要方面。

首先,您必须检查过去几年中最受欢迎的 Java 技术以及未来趋势。随着时间的推移,保持相对稳定受欢迎度的技术是公司中最常用的技术。

花时间阅读来自重要网站的过去 2-3 年的几项调查,如 blogs.oracle.com,snyk.io,jaxenter.com,codeburst.io,jetbrains.com 和 dzone.com。主要可以在 Google 上搜索java technologies survey 2019或类似的关键词组合。同时,不要忽视财务部分,确保搜索java salaries survey 2019

您将找到许多调查,它们很好地总结了最受欢迎的技术,正如您在以下两个图中所看到的。第一个显示了应用服务器的受欢迎程度:

图 1.2 - 使用的应用服务器

图 1.2 - 使用的应用服务器

以下图显示了开发人员更喜欢使用的框架:

图 1.3 - 开发人员更喜欢使用的框架

图 1.3 - 开发人员更喜欢使用的框架

阅读时,列出并记录哪些 Java 技术最受欢迎,哪些技术目前不值得关注。这将是一个类似于以下的列表:

图 1.4 - 按受欢迎程度分割技术

图 1.4 - 按受欢迎程度分割技术

通过这种方式,您可以快速过滤市场上最需要的技术。学习热门技术最大化了您在不久的将来找工作的机会。

此外,通过以下方式了解市场对您添加到Popular列的技术的态度:

  • 社交网络:大量社交网络包含有关技术和 IT 行业趋势的帖子。一些主要参与者是 LinkedIn,Stack Overflow,Twitter,Reddit 和 Facebook。

  • 书店:图书出版商努力满足编程社区对最受欢迎技术的兴趣。他们通过对值得在他们的书中涵盖的主题进行严肃的研究活动来进行过滤。一本新书或某一主题或技术的大量书籍是编程社区对该主题的兴趣的良好指标。然而,要注意突然变得流行的技术。大多数时候,这样的技术不会立即被公司采用。可能需要多年才能被采用,或者它们可能永远留在阴影中。

  • 课程和培训:除了学院和大学外,大量网站致力于提供热门和热门话题的课程和培训。

一切都是关于获得正确的经验

你知道自己想要什么和市场提供了什么。这很酷!现在是时候获得正确的经验了!没有经验就没有简历,没有简历就没有面试,因此,这是一个重要且费力的步骤。接下来的小节将帮助你实现两个主要目标:

  • 积累大量的技术知识和技能。

  • 在 Java 生态系统中赢得信任和可见度。

注意 - 这两个目标不会一夜之间实现!这需要时间和毅力,但有一个明确且有保证的结果 - 你将成为顶尖的 Java 开发者。所以,让我们开始吧!

开始做些什么

对于学生或应届毕业生来说,很难决定从哪里开始获得经验并写简历。你知道你应该开始做些什么,但你无法决定那个什么应该是什么。嗯,那个什么应该是代码。在你有任何正式工作之前,参与学校项目、实习、编程、志愿工作和任何形式的实践经验。

是时候在网上大放异彩了

尽快上网展示你的能力是必不可少的(例如从学校开始)。公司和编程社区都希望看到你在网上的成长。但在你跳入之前,确保你遵循下面的两条黄金规则:

  • 非常重要的是要注意在网上展示你的工作时使用的身份。不要使用虚假的凭据、头像、昵称、电子邮件、密码等。你现在创建的账户(例如 GitHub、Stack Overflow、LinkedIn、YouTube、Twitter 等)很可能会在整个互联网上共享,并让你出名。始终使用你的全名(例如 Mark Janel,Joana Nimar),为你的个人资料使用一张相关的照片(如下图所示),并在账户(例如@markjanel,joananimar)和电子邮件地址(例如 mark.janel@gmail.com)中使用你的名字。虚假的名字、电子邮件和昵称很难与你和你的工作联系在一起:

图 1.5-使用相关照片

图 1.5-使用相关照片

  • 始终接受批评并保持礼貌。在网上展示你的工作会吸引批评。你收到的极少部分评论是毫无逻辑的恶意评论。在这种情况下,最好的做法是忽略这样的评论。但大多数评论都是积极和建设性的。始终用论据回应这样的评论,并始终保持礼貌。常识是最重要的技能!要开放并保持对其他观点的开放!

不要感到失望或沮丧。永远不要放弃!

为开源项目做贡献

为开源项目做贡献是衡量你的技能并迅速获得经验和向寻找候选人的公司展示自己的超音速途径。不要低估自己!小小的贡献也同样重要。甚至阅读和理解开源项目的代码也是获得编码经验和学习编码技巧的绝佳机会。

许多开源项目鼓励和支持开发者做出贡献。例如,查看以下截图中的 Hibernate ORM 开源项目:

图 1.6-为开源项目做贡献

图 1.6-为开源项目做贡献

你有机会在你以后的日常工作中使用的代码上留下自己的印记!而且它也被数百万的开发者使用。多酷啊!?

开始你自己的 GitHub 账户

除了为开源项目做贡献外,建议开设自己的 GitHub 账户。雇主会在见到你之前评估你的 GitHub 个人资料内容。不要忽视任何方面!花时间整理你的 GitHub 个人资料,让它展现你最好的代码。请记住,最糟糕的 GitHub 账户是空账户或者长期低活跃度的账户,如下图左侧所示:

图 1.7 - 四个月的 GitHub 贡献

图 1.7 - 四个月的 GitHub 贡献

表现出对清晰代码和有意义的README.md文件的偏好,并避免长期低活跃度,如前一张截图所示。

开始你自己的 Stack Overflow 账户

Stack Overflow 是评估你工作的下一个站点。你在 Stack Overflow 上的问题和答案将出现在谷歌搜索中,因此,你必须特别注意你发布的内容(问题和答案)。一般来说,你的问题可能会显示你的知识水平,因此不要发布简单的问题、在文档中有简单答案的问题、隐藏在琐碎编程挑战背后的问题等。另一方面,确保提供有价值的答案,不要重复别人的答案。提供能为你带来徽章的内容,而不是负面评价。将你的 GitHub 个人资料链接到你的答案,以提供完整的解决方案。

开设自己的 YouTube 频道

除了娱乐,YouTube 也是一个巨大的技术知识来源。在 YouTube 上,你可以发布完整的编程解决方案,向人们展示如何编程,如何成为更好的程序员。如果你做到以下几点,你可以迅速增加你的 YouTube 订阅者:

  • 不要制作过长的视频(保持在 10-20 分钟的课程)!

  • 确保你有一个好的网络摄像头和麦克风。一个好的网络摄像头至少有 1080p 的分辨率,一个好的麦克风是 Snowball ICE;录制时使用免费或低成本的工具,比如 Free2X Webcam Recorder(free2x.com/webcam-recorder)和 Loom(loom.com);Camtasia Studio 也很棒(techsmith.com/video-editor.html)。

  • 展现出优秀的英语能力(英语在 YouTube 上最常用)。

  • 介绍自己(但要快速)。

  • 热情(向人们展示你享受你的工作,但不要夸大其词)。

  • 务实(人们喜欢现场编码)。

  • 抓住机会证明你的演讲技巧(这将为你打开参加技术会议的大门)。

  • 推广你的工作(添加链接和提示,以获取更多视频、源代码等)。

  • 回应人们的反馈/问题(不要忽视人们对你视频的评论)。

  • 接受批评并保持礼貌。

将你的 GitHub 和 Stack Overflow 账户链接到你的 YouTube 视频,以获得更多曝光和粉丝。

开始你的技术博客

你在 GitHub、Stack Overflow 和 YouTube 上的出色工作可以很容易地在技术博客的故事中进行推广。写有关编程主题的文章,特别是你解决的编程问题,并写教程、技巧等。持续发布高质量内容将增加你的流量,并将你的博客索引到搜索引擎上。有一天,这些有价值的内容可以被利用来写一本惊人的书,或者开发一部优秀的 Udemy(udemy.com)或 PluralSight(learn.pluralsight.com)视频。

有很多博客平台,比如 Blogger(blogger.com)、WordPress(wordpress.org)和 Medium(medium.com)。选择你喜欢的平台并开始。

写文章并吸引大量流量和/或获得报酬

如果你想发布技术文章并赚钱,或者吸引大量流量到你的作品,那么个人博客在一段时间内可能不会很有用(1-2 年)。但你可以为那些每天注册大量流量的网站写技术文章。例如,DZone(dzone.com)是一个很棒的技术平台,你可以免费写作,或者加入不同的计划,按照你的工作获得报酬。通过简单地创建一个免费的 DZone 账户,你可以立即开始通过他们的在线编辑器发布技术文章。1-5 天内,他们将审查你的作品并在网上发布。几乎立即,成千上万的人会阅读你的文章。除了 DZone,其他很棒的技术平台也会支付你为他们写作(通常每篇文章 10-150 美元,取决于长度、主题、内部政策等)。其中一些平台包括 InformIT(informit.com)、InfoQ(infoq.com)、Mkyong(mkyong.com)、developer.com(developer.com)、Java Code Geeks(javacodegeeks.com)、GeeksForGeeks(geeksforgeeks.org)和 SitePoint(sitepoint.com)。

推广自己和自己的作品(作品集)

工作很重要,但展示你所做的事情并获得他人的反馈也很重要。

重要提示

管理你的在线资料非常重要。招聘人员使用在线资料来寻找理想的候选人,更好地了解你,并准备深入或定制的面试问题。

除了 GitHub、Stack Overflow 等,招聘人员还会在 Google 上搜索你的名字,并查看你的个人网站和社交网络资料。

个人网站

个人网站(或作品集)是展示你工作的网站。只需添加你制作/贡献的应用程序的截图,并简要描述你的工作。解释你在每个项目中的角色,并提供项目的链接。注意不要暴露私人和专有公司信息。你可以从互联网上快速获得灵感(例如,codeburst.io/10-awesome-web-developer-portfolios-d266b32e6154)

在建立个人网站时,你可以依赖免费或低成本的网站构建工具,如 Google Sites(sites.google.com)和 Wix(wix.com)。

社交网络资料

最重要的社交网络之一是 Twitter。在 Twitter 上,你可以在全世界最优秀的 Java 开发者面前推广你的工作。从第一天开始,搜索并关注最优秀的 Java 开发者,很快他们也会关注你!作为一个提示,开始关注你能找到的尽可能多的 Java Champions(全球最优秀的 Java 开发者的独家社区)。Twitter 上有一个庞大而有价值的 Java 开发者社区。尽快认识他们!

其他社交网络,如 Facebook 和 Instagram,也会被招聘人员扫描。注意你的帖子内容。显然,激进主义、种族主义、狂热主义、琐碎或性内容、政治内容、口号和煽动暴力、诽谤和冒犯性内容等都会让招聘人员退后一步。

CodersRank 很重要

CodersRank(codersrank.io/)是一个收集关于你工作的信息的平台(例如,它从 GitHub、Stack Overflow、Bitbucket、HakerRank 等收集信息),并试图将你与全球数百万其他开发者进行排名。在下面的截图中,你可以看到一个开发者的个人资料页面:

图 1.8 - CodersRank 个人资料摘要

图 1.8 - CodersRank 个人资料摘要

这也是招聘人员的另一个重要指标。

学习,编码,学习,编码...

一旦你成为开发者,你必须遵循“学习->编码”实践,以便跻身顶尖并保持在那里。永远不要停止学习,永远不要停止编码!作为一个经验法则,“学习->编码”实践可以通过“以例学习”或“教学是我的学习方式”等方法来应用,或者任何其他适合你的方法。

认证怎么样?

一旦你访问 education.oracle.com/certification,你会发现 Oracle 提供了一套 Java 认证。虽然获得认证(来自 Oracle 或其他方)没有错,但在职位描述中并不要求。获得这些认证需要大量的金钱和时间,而且大多数时候并不值得。你可以更明智地利用这段时间参与项目(副业项目,学校项目,开源项目等)。这是给雇主留下更好印象的方法。因此,证书的价值有限,获得它们需要大量资源。此外,证书是有限期的。想想在 2020 年,成为 Java 6 认证的价值有多大,或者在 2030 年成为 Java 12 认证的价值有多大!

但是如果你真的想考虑认证,那么以下是提供的顶级认证(有关更多信息,请在 Google 上搜索,因为链接可能会随时间而中断):

  • OCAJP(Oracle 认证助理,Java 程序员 1)和 OCPJP(Oracle 认证专业人员,Java 程序员 2)

  • Spring 专业认证

  • OCEWCD(Oracle 认证专家,Java EE 6 Web 组件开发人员)

  • Apache Spark Cert HDPCD(HDP 认证开发人员)

  • 专业 Scrum 大师

  • 项目管理(PMP)

  • AWS 解决方案架构师

  • Oracle 认证大师

在互联网上拥有经验和知名度(粉丝)对你的职业生涯是一个巨大的加分。但是你仍然需要一份有用的简历来申请 Java 工作。所以,现在是写简历的时候了。

写简历的时间

写一份令人印象深刻的简历并不容易。有很多平台承诺如果你让他们为你做,你的简历会很棒。也有很多简历模板,大多数都相当复杂和繁琐。另一方面,简历是个人的东西,最好自己做。记住以下几点就足以为招聘人员制作一份吸引人的简历。让我们看看这些要点以及如何处理它们。

简历筛选者在寻找什么

首先,简历筛选者想要找出你是否是一个优秀的编码人员,是否聪明。其次,他们想要找出你是否适合某个可用职位(他们会检查你的经验是否符合该职位所需的特定技术和工具)。

努力突出你是一个优秀的编码人员,聪明。这意味着尽可能在一个集中的形式中尽可能技术化。注意:太多的字会稀释你简历的本质,导致失去焦点。要技术化,清晰,简洁

简历应该有多长

要回答简历应该有多长,你必须回答另一个问题:你认为招聘人员花多长时间阅读一份简历?很可能是 10-20 秒。换句话说,招聘人员在字里行间阅读,试图快速确定他们感兴趣的内容。

一般来说,简历不应超过一页。如果你有 10 年以上的经验,那么可以用 2 页。

你可能认为在 1-2 页内概括你的丰富经验是不可能的,但这并不是真的。首先,优先考虑内容,其次,添加这些内容直到覆盖 1-2 页。跳过剩余的内容。不要担心招聘人员不会知道你做过的一切!他们会对你的简历亮点印象深刻,并乐于在面试中发现你的其余经验。

写一份适合一页的简历。

如果你有 10 年以上的经验,那么考虑两页。请记住,一些招聘人员可能会在没有阅读一行的情况下跳过长篇简历。他们想要立即找到最令人印象深刻的项目。添加不太重要的项目和/或太多的字会分散招聘人员的注意力,让他们浪费时间。

如何列出你的工作经历

如果你的就业历史很短(2-4 个角色),那么把所有的都加到简历中。如果你有很长的角色列表(4 个以上的角色),那就不要列出你的完整就业历史。只选择 4 个最令人印象深刻的角色(在重要公司的角色、领导角色、取得了巨大成就和/或做出了重大贡献的角色)。

对于每个角色,遵循成就->行动->效果模型。始终从成就开始!这将成为招聘人员的磁铁。一旦他们读到成就,你就吸引了他们继续阅读。

例如,假设你在公司Foo工作,通过调整参数,你成功将连接池的性能提高了 30%。现在应用程序可以额外容纳 15%的交易吞吐量。将这一成就以单一陈述方式添加到简历中:

通过调整其参数,将连接池的性能提高了 30%,从而提高了 15%的交易吞吐量。

通过成就->行动->效果陈述列出最相关的角色。始终尝试衡量你创造的收益。不要说,“通过压缩...,我减少了内存占用”,而是说,“通过压缩...,我减少了内存占用 5%”。

列出最相关的项目(前五个)

一些招聘人员更喜欢直接跳到你的简历的我的项目部分。他们遵循不要废话,只谈实质的原则。你不必列出所有的项目!列出前五个并只添加那些。不要从同一类别中添加所有五个。选择一个或两个独立项目,一个或两个开源贡献等。一个拥有高 GitHub 星级评分的独立项目才是真正会给招聘人员留下深刻印象的。

列出顶级项目及其相关细节。这是失去谦逊、尽力给人留下深刻印象的正确地方。

提名你的技术技能

技术技能部分是必须的。在这里,你必须列出你所了解的编程语言、软件和工具。它不必像命名那样冗长,但也不必是一个简短的部分。它必须与列出的项目相关并协调一致。以下列表明了编写技术技能部分时要遵循的主要标准:

  • 不要列出所有的 Java 变种:不要添加 Spring MVC、Spring Data、Spring Data REST、Spring Security 等列表。只说 Spring。或者,如果你是 Java EE 的人,不要列出 JPA、EJB、JSF、JAX-RX、JSON-B、JSON-P、JASPIC 等列表。只说 Java EE、Jakarta EE。或者,如果在职位描述中以这种方式列出它们,那么你可以在括号中添加它们。例如:“Spring(MVC、Data 包括 Data REST、Security)”或“Java EE(JPA、EJB、JSF、JAX-RX、JSON-B、JSON-B、JASPIC)”。

  • 不要添加软件版本:避免添加 Java 8、Spring Boot 2 或 Hibernate 5 等内容。如果这些细节是必要的,面试官会问你。

  • 不要列出实用技术:避免列出项目中常用的实用库。例如,不要添加 Apache Commons、Google Guava、Eclipse Collections 等。可能招聘人员没有听说过它们。或者,如果他们听说过,他们会带着讽刺的微笑。

  • 不要列出你只是轻微接触过的技术:列出你只是偶尔和/或肤浅地使用过的技术是相当冒险的。在面试中,你可能会被问到这些技术的问题,这会让你陷入困境。

  • 对于每种技术,添加你的经验:例如,写上Java(专家)、Spring Boot(高级)、Jakarta EE(熟练)、Hibernate(专家)

  • 不要用技术的使用年限来衡量你的经验:大多数时候,这并不相关。这个度量标准对招聘人员来说并没有太多意义。你的经验是通过你的项目展现出来的。

  • 避免常见技术:不要列出操作系统、Microsoft Office、Gmail、Slack 等。列出这些东西只会给招聘者带来干扰。

  • 仔细检查您的英语:如果简历有拼写错误,招聘者可能会将其丢弃。如果您不是以英语为母语的人,那么请找一个以英语为母语的人来校对您的简历。

  • 不要列出单一的编程语言:理想情况下,您应该列出两到三种编程语言(例如,Java(专家)、C++(中级)、Python(有经验)),但不要说您在所有这些语言中都是专家。没有人会相信你!另一方面,单一的编程语言可能被解释为您不愿意学习新技术。

  • 将技术分成类别:不要将技术列为一个长长的、逗号分隔的列表。例如,避免类似于Java、Ruby、C++、Java EE、Spring Boot、Hibernate、JMeter、JUnit、MySQL、PostgreSQL、AWS、Ocean 和 Vue.js这样的列表。将它们分成类别,并按经验排序,如下例所示:

a. 编程语言:Java(专家)、Ruby(中级)和 C++(初学者)

b. 框架:Java EE(专家)、Spring Boot(高级)

c. 对象关系映射ORM):Hibernate(专家)

d. 测试:JMeter(专家)、JUnit(高级)

e. 数据库:MySQL(专家)、PostgreSQL(中级)

f. :AWS(专家)、Ocean(初学者)

g. JavaScript 框架:Vue.js(中级)

LinkedIn 简历

很可能,您的 LinkedIn 个人资料将是招聘者的第一站。此外,大量的电子工作平台在您尝试申请工作时都要求您的 LinkedIn 账户。甚至有些情况下,这个账户是强制性的。

LinkedIn 是一个专门用于跟踪专业联系的社交网络。本质上,LinkedIn 是一个在线简历的增强版。在 LinkedIn 上,您可以创建工作提醒,同事、客户和朋友可以为您或您的工作背书,这可能非常有价值。

重要提示

注意保持您的 LinkedIn 简历与纸质简历同步。此外,如果您通过 LinkedIn 寻找工作,请注意,所有您的联系人都会收到关于您更新的通知。这些联系人包括您当前公司的人,而且很可能您不希望他们知道您在找新工作。解决方案是在更新之前禁用这些通知。

现在,我们可以讨论求职流程了。

求职流程

技术公司更喜欢多阶段面试。但是,在被邀请参加面试之前,您必须找到正在招聘的公司,申请他们的工作,然后最终与他们见面。

寻找正在招聘的公司

过去几年(2017 年以后)的调查估计,70%-85%的工作都是通过人际网络填补的(linkedin.com/pulse/new-survey-reveals-85-all-jobs-filled-via-networking-lou-adler/)。技术工作(尤其是 IT 领域)代表了利用人际网络的主要领域。

在几乎任何国家,都有几个电子工作平台。我们称它们为本地电子工作平台。通常,本地电子工作平台列出了在该国活跃的公司或全球招聘的公司的工作机会。

全球范围内,我们有全球性的电子工作平台。这些平台包括一些主要的参与者(所有这些网站都允许您上传简历或在线创建简历):

  • LinkedIn(linkedin.com):拥有超过 6.1 亿用户,覆盖全球 200 多个国家,这是全球最大的专业社交网络和社交招聘平台。

  • Indeed(indeed.com):这是一个领先的职位网站,收集了来自数千个网站的数百万个工作机会。

  • CareerBuilder(careerbuilder.com):这是另一个发布来自全球各地的大量工作机会的巨大平台。

  • Stack Overflow(stackoverflow.com/jobs):这是开发人员学习、分享编程知识和发展职业的最大、最值得信赖的在线社区。

  • FlexJobs(flexjobs.com)和Upwork(upwork.com):这些是专门为自由职业者提供高级、灵活的远程工作的平台。

提供寻找工作有用的服务的其他平台包括以下内容:

  • Dice(dice.com):这是每个阶段的技术专家的领先职业目的地。

  • Glassdoor(glassdoor.com):这是一个包括公司特定评级和评论的复杂平台。

除了这些平台,还有许多其他平台,你可以自己发现。

提交简历

一旦你找到想申请的公司,就是提交你的简历的时候了。

首先,看看公司的网站。这可以帮助你找到以下内容:

  • 看看是否可以直接通过公司网站申请(通过绕过就业机构,你可以加快流程,公司可以直接雇佣你,而不必向就业机构支付佣金)。

  • 你可以在公司数据库中注册,以便在合适的职位开放时联系你。

  • 你有机会了解公司的历史、愿景、项目、文化等等。

  • 你可以找到公司相关人员的联系方式(例如,你可以找到详细和支持的电话号码)。

第二,仔细检查你的简历和在线资料。很可能,如果你的简历给招聘人员留下了深刻印象,他们会在谷歌上搜索你的名字,并检查你的社交网络活动。从技术内容到社交媒体,一切都将在发送面试邀请之前进行扫描。

第三,不要向所有公司发送完全相同的简历!对于每家公司,都要对简历进行调整,使其尽可能与职位描述相关。

我得到了面试!现在怎么办?

如果你迄今为止都按照路线图进行,那么只是几天的事情,你就会收到一封电子邮件或电话邀请你参加面试。哦,等等...你是说你已经有了面试?太棒了!是时候准备好自己了!

电话筛选阶段

大多数 IT 公司更喜欢从电话筛选开始多步面试流程。电话筛选通常是通过 Skype、Zoom 或 Meetup(或类似平台)完成的,你需要分享你的网络摄像头。还需要麦克风和一副耳机。如果你选择远程职位,电话筛选非常受欢迎,但最近,它们被用于各种职位。

通常,公司使用两种方法:

  • 与人力资源或就业机构人员进行电话筛选:这是一个可选的、非技术性的 15-30 分钟面试,旨在详细说明提供条款,展示你的个性、关注点,你和他们的期望等等。这可能在技术电话筛选之前或之后进行。

  • 首先是技术电话筛选:有些公司会直接邀请你参加技术电话筛选。在这种情况下,你可以期待几个技术问题,也许是一场测验,以及一个或多个编码挑战环节(解决编码挑战是本书的主要重点)。如果你通过了技术电话筛选,那么很可能会有一个非技术的电话筛选。

参加面试

除非你选择远程职位,下一步将是面对面的面试。有些情况下,没有电话筛选,这是面试的第一步。在这种情况下,你可能会先接受人力资源部门的面试,然后是技术面试。但是,如果你经历了电话筛选,那么可能会联系你,也可能不会。这取决于公司如何评估电话筛选。如果他们决定不继续进行下一阶段的面试,那么你可能会收到一些反馈,涵盖了你电话筛选表现的优点和不足之处。不要忽视反馈,仔细阅读并客观地看待。这可能会帮助你避免重复同样的错误。说到错误…

避免常见的错误

注意以下可能导致面试失败的常见错误:

  • 忽视信息的力量: 有时面试失败后,我们会找朋友谈谈面试情况。这时,你的朋友可能会说:“我的朋友,我认识一个人两个月前在这家公司成功面试过!为什么你之前不告诉我?我肯定他可以给你一些建议!”显然,现在已经太迟了!避免这种情况,尽量获取尽可能多的信息。看看你或你的朋友在公司是否有联系,在社交媒体上问问等。这样可以获取非常有用的信息。

  • 回答缺乏清晰和连贯性: 你的回答应该是技术性的、清晰的、有意义的、表达力强的,并且始终与话题相关。认真回答问题。口吃、回答不完整、插话等都不受面试官欢迎。

  • 认为形象不重要: 不要忽视你的形象!着装要专业,去理发店,保持干净整洁!所有这些都是第一印象的一部分。如果你看起来邋遢,也许你的代码也是如此。如果你穿着得体,那么面试官会把你视为高人一等。然而,着装得体并不意味着你应该奢华。

  • 没有充分展示自己: 面试官必须看到你的价值。没有人比你更能向他们传达你的价值。告诉他们你曾经遇到的问题(在以前的公司,某个项目中等),并解释你是如何与团队或独立解决的。雇主希望找到既是优秀团队合作者又能独立工作的人。采用SAR(Situation|Action|Result)方法。首先描述情况,然后解释你采取的行动,最后描述结果。

  • 不练习编码挑战: 在某个时候,你将被安排至少一次编码挑战。大多数情况下,一般的编码技能是不够的!这些挑战是特定于面试的,你必须在面试前练习。一般来说,解决编码挑战(问题)遵循方法->分解->构建的解决模式。显然,你不能记住解决方案,因此你需要尽可能多地练习。在本书的后面,我们将讨论解决编码挑战的最佳方法。

面试结束后,就是等待回复的时候了。大多数公司会告诉你他们需要多少时间来提供最终答复,并通常会提供一个代表着录取、拒绝、下一轮面试或者申请状态的答复。祝你好运!

摘要

本章总结了在 Java 生态系统中获得工作应遵循的最佳实践。我们谈到了选择适当的工作和我们的资格,获取经验,制作简历等等。大部分建议都是针对学生或刚刚毕业的人。当然,不要把这些建议看作是一个详尽的清单或者应该完全应用的清单。这些实践将帮助你收获你认为有吸引力的果实,并允许你在过程中加入自己的触摸。

接下来,让我们看看大公司是如何进行面试的。

第二章:大公司的面试是什么样子的

大公司的面试过程通常比较长,技术问题和编码挑战的复杂性逐渐增加(这样的面试过程可能需要一个月甚至更长时间)。大多数公司在提供职位之前更倾向于进行一次或多次技术电话筛选、现场技术挑战和面对面面试。通常,其中一次面试将是非技术性的(被称为“午餐面试”)。

让我们来了解一下几家领先的 IT 公司是如何进行面试的。一般来说,所有这些公司都在寻找聪明、热情和优秀的程序员。

我们将讨论以下公司的面试是如何进行的:

  • 谷歌

  • 亚马逊

  • 微软

  • Facebook

  • Crossover

让我们开始吧!

谷歌的面试

谷歌的面试从技术电话筛选开始(技术问题和编码挑战)。这些技术电话筛选将涉及 4-5 个人。其中一个电话筛选将是非技术性的。在这个时候,你可以自由地问任何你想问的问题。

在这些面试阶段,你将根据你的分析能力、编码能力、经验和沟通技巧得分。

面试官将他们的反馈提交给招聘委员会HC)。HC 负责提供职位或拒绝你。如果 HC 认为你是合适的人选,那么他们会将提供提案转发给其他委员会。最终决定由执行管理委员会做出。

主要的技术重点是分析算法、脑力算法、系统设计和可扩展性。

很可能你需要等待几周才能得到回复。

建议在 YouTube 上搜索“在谷歌面试”并观看最相关的证言和路线图视频。还要搜索“谷歌最常问的面试问题”。

亚马逊的面试

亚马逊的面试从一个由亚马逊团队进行的技术电话筛选开始。如果一些面试官在电话筛选后仍然不满意,那么他们可能会要求进行另一轮以澄清问题。

如果你通过了技术电话筛选,那么你将被邀请参加几次面对面的面试。来自业务不同领域的面试官团队将分别进行面试并评估你的技术能力(包括编码)。其中一个人也被称为“提高标准者”。通常情况下,这个人经验最丰富,他的问题和编码挑战会更难。他们还会将你与其他候选人进行比较,并决定是否提供职位。

主要关注面向对象编程OOP)和可扩展性。

如果一周后没有收到任何反馈,那么你应该给亚马逊的联系人发送一封友好的跟进电子邮件。很可能他们会很快回复你的邮件,并解释你的面试当前状态。

建议在 YouTube 上搜索“在亚马逊面试”并观看最相关的证言和路线图视频。还要搜索“亚马逊最常问的面试问题”。

微软的面试

微软的面试从几轮技术电话筛选开始,或者他们可能要求你前往他们的工作分部之一。你将与不同团队进行 4-5 轮技术面试。

最终决定属于招聘经理。通常情况下,只有在你通过了所有技术面试阶段后,才会联系这位招聘经理。

主要关注算法和数据结构。

如果一周后没有收到任何反馈,那么你应该给微软的联系人发送一封友好的跟进电子邮件。有时,他们可能在一天内就做出决定,但也可能需要一周、一个月甚至更长时间。

建议在 YouTube 上搜索“在微软面试”并观看最相关的证言和路线图视频。还要搜索“微软最常问的面试问题”。

Facebook 的面试

Facebook 的面试从几轮技术和非技术电话筛选开始,涉及问题(技术和非技术)和编码挑战。通常,面试由一组软件工程师和招聘经理进行。

Facebook 使用三种类型的面试,涵盖以下领域:

  • 你适应 Facebook 文化的能力,以及一些技术技能 - 被称为行为绝地面试

  • 你的编码和算法技能(这些是我们稍后会涵盖的常见问题,从第六章开始,面向对象编程)- 被称为忍者面试

  • 你的设计和架构技能 - 被称为海盗面试

你可以期待这些类型的面试的组合。通常,一个绝地和两个忍者就足够了。对于需要更高经验的职位,还会有海盗面试。

如果你通过了这些技术电话筛选,那么你将收到一些家庭作业,包括技术问题和编码挑战。这一次,你必须提供优雅而干净的编码解决方案。

主要关注你在任何语言中快速构建东西的能力。你可以期待在 PHP、Java、C++、Python、厄朗等语言中编码。

面试团队将决定是否雇佣你。

建议在 YouTube 上搜索Facebook 面试,观看最相关的证词和路线图视频。还要搜索Facebook 最常问的面试问题

Crossover 的面试

Crossover 是一家远程公司。他们通过他们的平台远程招聘,并且有独家的现场面试流程。他们的现场面试遵循以下路线图:

图 2.1 - 跨界面试路线图

图 2.1 - 跨界面试路线图

所有步骤都很重要,这意味着你在每一步的回答必须通过他们的内部规则。如果一步没有通过他们的内部规则,那么可能会导致面试突然关闭。但是,最重要的步骤是第 3、5、6 和 7 步。第 3 步代表淘汰性的标准认知能力测试CCAT)。例如,你必须在 15 分钟内回答 50 个问题。你必须正确回答 25 个以上的问题才有机会进入下一步。如果你不熟悉 CCAT 测试,那么强烈建议练习(有专门的书籍和网站致力于 CCAT 测试)。没有认真的练习,要通过它将会相当具有挑战性。如果你不是母语为英语的人,那么你必须特别注意练习需要严肃英语技能的问题。

在第 5 步,你将接受一个技术问题的测验。有 30 个以上的问题,有 5 个答案变体(一个或多个答案是正确的)。在这一步不需要编码。

如果你达到第 6 步,那么你将收到需要在 3 小时内完成并提交(上传)到平台的技术家庭作业。这个家庭作业可以由一个或多个从提供的存根应用程序开始的 Java 应用程序组成。

在第 7 步,你最终会通过电话与一个人见面。这通常是技术和非技术问题的混合。

技术问题将涵盖各种 Java 主题(集合、并发、I/O、异常等)。

通常,你会在一周内通过电子邮件收到最终答复。根据职位不同,提供将以 1 个月的有薪实习班经验开始。请注意,在实习班结束后,你仍然可能被拒绝或需要重新申请。在实习班期间和之后,你必须通过每周衡量你的表现的指标来保持你的职位。你必须每周工作 40 小时,每 10 分钟进行一次网络摄像头截图。而且,你有责任安排支付自己的税款。薪水是固定的,并且在他们的网站上公开。

建议仔细阅读他们网站上的职位描述和推荐信。他们还有品牌大使,您可以联系他们了解公司文化、期望、面试流程等信息。

其他远程公司遵循三步面试流程。例如,Upstack 遵循以下模式:

  1. 初试面试:非技术电话筛选

  2. 技术面试:包含编程挑战的技术电话筛选

  3. 提供:发送给您一个聘用意向并签署协议

当然,这里没有列出许多其他大公司。但作为一个经验法则,这里概述的公司和他们的流程应该给您一些重要的见解,让您了解您应该从 IT 行业的大公司中期望什么。

总结

在本章中,我们概述了几家领先的 IT 公司如何进行面试。大多数 IT 公司都遵循本章介绍的相同做法,但有着不同的组合和特色。

接下来,让我们看看最常见的非技术问题是什么,以及如何回答它们。

第三章:常见的非技术问题及如何回答

在这一章中,我们将解决非技术面试问题的主要方面。面试的这部分通常由招聘经理或甚至是人力资源部门负责。为了准备这次面试,意味着熟悉以下问题:

  • 非技术问题的目的是什么?

  • 你的经验是什么?

  • 你最喜欢的编程语言是什么?

  • 你想做什么?

  • 你的职业目标是什么?

  • 你的工作风格是什么?

  • 你为什么想要换工作?

  • 你的薪资历史是什么?

  • 为什么我们应该雇佣你?

  • 你想要赚多少钱?

  • 你有问题要问我吗?

我们将在各自的具体部分讨论每个问题。让我们开始吧。

非技术问题的目的是什么?

非技术面试问题的目的是衡量你的经验、性格和个性与其他员工和团队的匹配程度,以及你是否能够与其他员工和团队融洽相处。成为现有团队的一员是必须的。这些问题也有助于在你和公司之间建立人际关系,并看看他们理想的候选人与你的教育、信仰、想法、期望、文化等是否有任何兼容性或化学反应。此外,非技术问题也涵盖了工作的实际和务实方面,如薪资、搬迁、医疗保险、工作时间安排、是否愿意加班等等。

有些公司会根据这个非技术面试拒绝候选人,即使他们最初打算提供工作机会。

有些公司在技术面试之前进行这个面试。这些公司试图从一开始就确定你的经验和目标是否使你成为该职位的合适候选人。这就像说人际部分比技术部分更重要。

其他公司会在技术面试之后进行这个面试。这些公司试图确定对你来说什么是最好的工作机会。这就像说技术部分比人际部分更重要。

非技术问题没有对或错的答案!在这些情况下,最好的答案是真诚的答案。作为一个经验法则,回答时要表达真实的感受;不要试图说面试官想听到的话。这就像一场谈判 - 会有取舍。不要忘记要有礼貌和尊重。

接下来,让我们看看最常见的非技术问题以及一些答案建议。不要学习/抄袭这些答案!试着想出你自己的答案,并专注于你想要突出的内容。在家里塑造和重复答案,并在面试官面前做好准备。不要依赖你的自发性;依赖真诚并平衡取舍。

你的经验是什么?

很可能,在正式介绍之后,你会被问及你的经验。如果你对这个问题没有准备好答案,那么你就麻烦了。让我们强调几个重要方面,帮助你准备一个合适的答案:

  • 不要把你的经验详细描述成无聊的时间线清单:选择最具代表性的项目和成就,并充满热情地谈论它们。充满热情地谈论你的工作(但不要显得绝望,也不要夸大),并将你的成就放在团队/项目的背景下。例如,避免说... 我独自完成了这个和那个! 最好说,...我通过做这个和那个来帮助我的团队。 不要说,...我是唯一一个能够做到那个的人。 更好的说法是...我被团队提名来完成这个棘手的任务。如果你是第一份工作,那么谈谈你的学校项目(把你的同事看作你的团队)和你的独立项目。如果你参加过编程比赛,那么谈谈你的成绩和经验。

  • 不要只强调积极的事情:经历可能是积极的也可能是消极的。谈谈发生了什么是对的,但也要谈谈发生了什么是错的。大多数时候,真正有价值的教训来自于消极的经历。这些经历迫使我们超越自己的极限去寻找解决方案。此外,这样的经历证明了对压力的抵抗力,坚韧不拔和专注力。当然,要平衡积极和消极的经历,并强调你从双方学到了什么。

  • 不要提供太短或太长的答案:调整你的回答,使其在 1-2 分钟内完成。

你最喜欢的编程语言是什么?

既然我们在谈论 Java 职位,显然你最喜欢的语言是 Java。但如果出现这样的问题,那么它的目的是要揭示你是 Java 迷还是一个开放思想的人。换句话说,面试官认为与固执的、沉迷于一种编程语言并希望在所有情况下都专门使用它的人一起工作是很困难的。成为一名 Java 开发人员并不意味着你应该考虑 Java 来解决你所有的任务,并忽略其他一切。因此,一个好的答案可能是,“显然,我是 Java 的忠实粉丝,但我也认为选择最适合工作的工具很重要。认为 Java 是所有问题的答案是荒谬的。”

你想做什么?

这是一个难题,你的答案可能有很多解释。要真诚地告诉面试官你想做什么。你已经阅读了工作描述,因此你知道你想要这份工作。向面试官解释你决定的主要原因。例如,你可以说,“我想成为一名优秀的 Java 后端开发人员,而你们的项目在这个领域非常具有挑战性。我想成为参与这些项目的团队的一部分。”或者,你可以说,“我想成为一家重要公司的一家重要初创公司的一部分,这对我来说是一个很好的机会。我听说正在组建一个新团队,我会非常兴奋成为其中的一部分。”不要忽略说一些关于在一个伟大团队工作的事情!很可能你不会独自工作,成为一个团队合作者是几乎在任何公司工作中的一个重要方面。

你的职业目标是什么?

通过这个问题(或它的姊妹问题,“你在五年内看到自己在哪里?”),面试官试图了解这个职位是否符合你的职业目标。他们试图了解你是否把这个职位看作你职业道路的一部分,或者你是否有其他原因(除了金钱)来做这件事。描述一个详细的职业道路很难,但你可以给出一个显示你承诺和动力去做好工作的答案。例如,你可以说,“我目前的目标是在具有挑战性的项目上担任 Java 后端开发人员,这将帮助我积累更多的经验。几年后,我希望自己参与设计复杂的 Java 应用程序。再往后的事情现在想太远了。”

你的工作风格是什么?

这种问题应该让你警惕。大多数时候,这个问题是特定于那些有不寻常工作风格的公司。例如,他们经常加班或周末工作。也许他们工作时间很长,或者他们有难以实现的指标或截止日期。或者,他们在这个职位上施加了很大的压力和责任。向面试官解释你的工作风格,并间接地强调你不同意的事情。例如,你可以指出你不愿意做夜班,说,“我喜欢在早上开始工作,处理最困难的任务,而在一天的后半部分,我会处理下一天的计划。”或者,你可以指出你不愿意周末工作,说,“我喜欢每周努力工作 40 小时,从周一到周五。我喜欢和朋友们度过周末。”

如果你被直接问及特定方面,那么提供一个明确的答案。例如,面试官可能会说:“你知道,如果你周末工作,你会得到双倍的报酬。你对此有什么看法?”好吧,三思而后行,根据你的感觉回答,但不要给解释留下空间。

你为什么想换工作?

当然,如果你是第一份工作,那么你不会被问到这样的问题(或者它的姊妹问题,“你为什么离开上一份工作?”)。但如果你之前有过工作(或者你计划改变你目前的工作),面试官会想知道你为什么做出这个决定。关键在于详细说明清晰而有力的论点,而不要说任何有关你之前的公司、老板、同事等不好或冒犯的话——遵循“如果你不能说出任何好话,就不要说任何话”的原则。

以下是一些可以帮助你回答这个问题的建议(注意这个问题如何与前一个问题交织在一起——如果这家公司的工作方式与你目前或之前的公司的风格相关,那么离开那份工作的原因很可能也适用于避免这份工作):

  • 不要把钱作为第一个论点:钱通常是换工作的一个很好的理由,但把它作为第一个论点是一条危险的路。面试官可能会认为你只关心钱。或者,他们可能会认为你现在的雇主没有给你加薪是因为你不够有价值。迟早,他们可能会认为,你会想要更多的钱,如果他们不能给你想要的加薪,你会寻找其他地方。

  • 引发你无法控制的因素:引发你无法控制的因素会让你处于安全区域。例如,你可以说:“我的团队被分配到一个需要搬迁的项目。”或者,你可以说:“我被调到了夜班,我无法适应这个时间表。”

  • 引发环境的重大变化:例如,你可以说:“我的公司大规模裁员,我不想冒这个风险。”或者,你可以说:“我在一家小公司工作了 5 年,现在我想把我的经验用在一家大公司。”

  • 引发你不喜欢的并且面试官知道的方面:你可以说:“我被聘为 Java 后端程序员,但我花了很多时间帮助前端的人。正如你在我的简历中看到的,我的经验根植于后端技术。”

你的薪资历史是什么?

显然,这个问题旨在确定新报价的基准。如果你对目前的薪水满意,那么你可以给出一个数字。否则,最好礼貌地说“我不想搞砸事情,我期待的补偿应该适合新职位及其要求。”

为什么我们应该雇佣你?

这是一个相关的并且稍微冒犯的问题。在大多数情况下,这是一个陷阱问题,旨在揭示你对批评的反应。如果这个问题出现在面试的开始,那么你应该把它看作是一个问题的误导性表述,“你的经验是什么?”

如果这个问题出现在面试的最后,那么很明显面试官非常清楚为什么公司应该雇佣你,因此,他不希望听到基于你的简历或经验的强有力的论点。在这种情况下,保持冷静和积极,并提到你为什么喜欢这家公司,为什么想在这家公司工作,以及你对它的了解。表现出你的兴趣(例如,表明你已经研究过这家公司并访问过他们的网站)应该会让面试官感到受宠若惊,然后他可以迅速转到下一个问题。

你想赚多少钱?

这个问题出现在面试的一开始(例如,在非技术电话面试)或者在结束时,当公司准备给你准备一个报价。如果出现在开始时,这意味着面试是否继续将取决于你的回答。如果你的期望超出了可能的报价,那么面试很可能会在这里结束。最好尽可能推迟明确的回答,比如说,我脑海中没有一个明确的数字。当然,钱很重要,但还有其他重要的事情。首先让我们看看我的价值是否符合你的期望,然后我们可以谈判。 或者,如果你必须给出一个答案,最好给出一个薪水范围。你应该知道这个职位的普遍薪水范围(因为你在面试前已经做了功课,在网上做了调查),因此,提供一个符合你期望并尊重你的研究的范围。

理想情况下,这个问题出现在面试过程的最后阶段。这清楚地表明公司想要你,并准备给你一个报价。

现在,你开始了谈判的艺术!

不要急着说数字!此时,你应该相当清楚自己在面试中的表现以及自己有多想要这份工作。首先问面试官报价范围,其他奖金有哪些,总薪酬包括哪些。你还需要考虑几种可能的情况:

  • 在一个非常愉快的情况下,报价会高于你的期望:接受它!

  • 更有可能的是,这个报价接近你的期望值:试着再挤一点。例如,如果你得到的范围在$60,000 - $65,000 之间,那么可以说一些类似的话,我心里想的差不多是这个 - 更确切地说,如果我们能达成$65,000 - $70,000,我会非常满意。 这可能会帮助你得到大约$63,000 - $68,000。

  • 得到含糊的答复:与其得到一个报价范围,你可能会得到一个含糊的答复,比如说,我们根据申请者定制薪水,因此,我需要知道你的期望。 在这种情况下,说出你心中的更高数字。很可能你不会得到这个报价,但这给了你谈判的空间。要简短直接;例如,说,我期望年薪 65,000 美元。 你可能会得到大约 60,000 美元的报价,或者一个让你失望的答复,比如说,抱歉,但我们心目中的数字要低得多。 这将导致下一节。

  • 得到令人失望的报价:在这种情况下,要迅速表达你的失望,比如说,我不得不说我对这个报价非常失望。 然后重申你的强大技能和经验。试着提出明确的支持所要求数字的论点,并强调你不想要什么离谱的东西。如果你不愿意接受这份工作的这些条件,那么在回答结束时加上一个最后通牒,比如说,如果这是你们的最终答复,我无法接受这样的报价。 如果公司对你印象深刻,他们可能需要更多时间,然后会给你另一个报价。如果你考虑接受这个报价,那么要求书面协议,在六个月后重新谈判。此外,试着从谈判中挤出其他好处,比如灵活的工作时间、奖金等等。

重要提示

作为一个经验法则,要记住以下几个方面:- 谈论薪水时不要害羞或尴尬(新手们经常会这样)。- 不要从不给你谈判空间的低数字开始。- 不要低估自己,卖自己短。- 不要浪费时间谈判不可谈判的事情。

你有问题要问我吗?

几乎任何面试都以这个问题结束。面试官想要澄清你可能有的任何疑虑。你可以问任何你想问的问题,但要注意不要问一些愚蠢的问题或需要长篇回答的问题。你可以询问面试官说过但不太清楚的事情的细节,或者你可以询问他们对你的个人看法。或者,你可以问一些类似“你是怎么来到这家公司的?对你来说最具挑战性的是什么?”的问题。如果你没有问题要问,那就不要问。简单地说一些像“嗯,我必须说你已经回答了我所有重要的问题。谢谢你的时间!”的话。

总结

在本章中,我们涵盖了面试中你可能面对的最常见的非技术问题。这些问题在面试前应该认真训练,因为它们是成功面试的重要组成部分。的确,对这些问题的出色回答单独并不能带给你一个 offer,没有必要的技术知识的充分展示,但它们可以影响你的薪水待遇、日常工作期望、工作风格和职业目标。因此,不要在这样的面试中毫无准备。

在下一章中,我们将看到如何面对当我们无法获得理想工作时的微妙情况。

第四章:如何处理失败

本章讨论了面试中一个微妙的方面——处理失败。本章的主要目的是向你展示如何识别失败的原因,并如何在将来减轻它们。

然而,在讨论如何处理失败之前,让我们快速解决接受或拒绝提议的正确方式。在面试结束时,或者在面试过程中的某个时候,你可能会发现自己处于接受或拒绝提议的位置。这不是简单地给出一个干脆的是或否的答案。

本章的议程包括以下内容:

  • 接受或拒绝一个提议

  • 考虑到失败是一个选择

  • 理解一个公司可能因为很多原因拒绝你

  • 客观地识别和消除不匹配

  • 不要对一个公司形成固执的迷恋

让我们开始第一个话题。

接受或拒绝一个提议

接受一个提议是相当简单的。你需要通知公司你接受了这个提议,并讨论细节,比如开始日期(特别是如果你需要在目前的工作场所工作一个通知期),文件工作,重新分配(如果有的话),等等。

拒绝一个提议是一个更微妙的情况。必须以一种让你能够与每个人保持良好关系的方式来做。公司在面试中投入了时间和资源,所以你必须礼貌地拒绝他们的提议。过一段时间后,你也可以考虑再次申请该公司。例如,你可以说类似于“我想感谢你的提议。我对你的公司印象深刻,我很喜欢面试的过程,但我决定现在不是适合我的选择。再次感谢你,也许有一天我们会再见面。”

有些情况下,你需要处理多个提议。当你接受一个提议时,你必须拒绝另一个。在 IT 行业,建立联系并随时保持联系非常重要。人们经常换工作和职位,在这个动态的环境中,不要浪费任何联系是很重要的。因此,不要忘记打电话给招聘经理(或联系人)告诉他们你的决定。你可以使用之前给出的同样的短语。如果你不能打电话,那就发一封电子邮件或亲自去办公室见他们。

失败是一个选择

在电影中,我们经常听到“失败不是一个选择”的说法。但那只是电影!面试总是以一个提议或拒绝结束,所以失败是一个选择。我们的任务是减轻失败。

处理失败并不容易,特别是当它们一个接一个地出现时。我们每个人对失败的反应都是不同的,也是人性的。从感到失望和顺从到紧张反应或说出后悔的话,这些都是正常的人类反应。然而,重要的是要控制这些反应并以专业的方式行事。这意味着应用一系列步骤来减轻将来的失败。首先,重要的是要理解为什么你被拒绝了。

一个公司可能因为很多原因拒绝你

嗯,也许问题正是从这个强大的词开始:拒绝。说或者想公司 X 拒绝了你是正确的吗?我会说这种表述是有毒的,听起来就像公司对你有什么私人恩怨一样。这种思维方式应该从一开始就被切断。相反,你应该试图找出问题出在哪里。

怎么样说或者想到你和公司之间的技能和/或期望不匹配?很可能,这更接近现实。面试中有两个参与方(你和面试官),双方都试图找到允许他们以主观的方式合作的匹配或兼容性。一旦你这样想,你就不会责怪自己,而会试图找出问题出在哪里。

面试后获得反馈

如果公司通知你没有被录用,那么现在是时候给他们打电话并要求他们的反馈了。你可以说类似于“谢谢你给我面试的机会。我正在努力提高我的面试技能,所以如果你能提供任何对我有用的反馈,那将是太棒了。”

获得适当的反馈非常重要。它代表了修复和消除不匹配的起点,因此你可以开始减轻失败。不匹配通常如下:

  • 表现: 候选人在面试过程中未达到或保持预期的表现。

  • 期望: 候选人不符合面试官的期望(例如,他们的薪资期望超出了公司的期望)。

  • 缺乏技能/经验: 候选人不符合工作的技能水平(例如,缺乏经验)。

  • 沟通: 候选人具有技术技能,但未能正确表达。

  • 面试官的偏见: 候选人的行为不适合该工作/公司。

现在让我们来看看如何识别和消除不匹配之处。

客观地识别和消除不匹配之处

虽然反馈代表了修复和消除不匹配的起点,但你必须意识到它可能相当主观。重要的是仔细阅读反馈,并在回忆面试的阶段时,以客观的方式将他们的反馈与你的记忆重叠。

一旦你确定了客观的不匹配之处,就是时候消除它们了。

不要对一家公司形成固执的迷恋

有些人很难被某家公司录用。即使经过两三次尝试,他们也不会停止。继续尝试是毅力还是固执?他们的梦想工作已经变成了固执,还是他们应该继续尝试?这些都是非常个人的问题,但作为一个经验法则,固执总是有害的,不会带来任何好处。如果你发现自己处于这种情况,或者你认识有人处于这种情况,那么现在是时候改变你的态度,认为也许以下是正确的思考方式。

不要失去对自己的信心 - 有时,他们不配拥有你!

这个标题听起来像是一句鼓励的空洞口号,旨在让你感觉更好。然而,这并不是真的!这种情况经常发生,在许多情境中都有。例如,一位刚开始职业生涯的歌手参加了一档著名的歌唱比赛,并没有赢得任何奖项;她甚至没有被认为是优秀的人之一。她没有再次参加比赛(就像章节标题中的情况),但几年后,她赢得了她的第一个格莱美奖。

现实生活中有很多这样的例子。这位歌手没有失去她的技能自信,她是对的!那个著名的歌唱比赛并不配拥有她。多年后,比赛的组织者邀请这位歌手再次演唱(这次作为嘉宾),并为之前发生的事情道歉。

所以,不要失去对自己的信心 - 有时,他们不配拥有你!

总结

本章简要概述了我们在求职过程中必须明智处理的一个重要方面 - 失败。它们是生活的一部分,我们必须知道如何以健康和专业的方式处理它们。不要过于情绪化,尝试以专业、冷静、现实和客观的方式对待每次失败。

在下一章中,我们将介绍技术面试的高潮:编程挑战。

第五章:如何应对编码挑战

本章涵盖了技术测验和编码挑战,这在技术面试中常见。

编码挑战是面试中最重要的部分。这部分可以由单个会话或多个会话组成。一些公司更喜欢将技术面试分为两部分:第一部分包括技术测验,而第二部分包括一个或多个编码挑战。在本章中,我们将详细讨论这两个主题:

  • 技术测验

  • 编码挑战

通过本章结束时,你应该能够规划自己的技术面试方法。你将知道如何处理面试中的关键时刻,面试官期望从你那里看到和听到什么,以及如何处理当你对答案/解决方案一无所知时的阻塞时刻。

技术测验

技术测验可以采用技术面试官问答的形式,也可以是现场测验。通常包含 20-40 个问题,耗时不到一小时。

当技术面试官进行这个过程时,你将需要提供自由回答,持续时间可能会有所不同(例如,30-45 分钟之间)。清晰、简洁、并且始终保持话题相关是很重要的。

通常,当技术面试官进行面试时,问题会被构建成需要你做出决定或选择的场景。例如,一个问题可能听起来像这样:我们需要一个能够以极快的速度搜索数百万条记录并具有相当数量的误报的高效算法。你会为我们推荐什么? 很可能,期望的答案是类似于,我会考虑 Bloom 过滤器家族的算法。如果你在以前的项目中遇到过类似的情况,那么你可以这样说:我们在一个关于流数据的项目中遇到了相同的情况,我们决定采用 Bloom 过滤器算法

另一类问题旨在简单检查你的技术知识。这些问题不是在场景或项目的背景下提出的;例如,你能告诉我 Java 中线程的生命周期状态是什么吗? 期望的答案是,在任何时刻,Java 线程可以处于以下状态之一: NEW, RUNNABLE, RUNNING, BLOCKED, SLEEP, WAITING/TIMED/WAITING, TERMINATED

通常,回答技术问题是一个三步方法,如下图所示。首先,你应该理解问题。如果有任何疑问,就要求澄清。其次,你必须知道面试官希望你在回答中识别出几个关键词或要点。这就像一个清单。这意味着你必须了解应该在答案中突出的关键内容。第三,你只需要用逻辑和有意义的方式包装关键词/要点:

图 5.1 - 处理技术测验的过程

图 5.1 - 处理技术测验的过程

你将在第六章**,面向对象编程中看到大量例子。

一般来说,你的答案应该是技术性的,简洁但全面,并且自信地表达出来。害羞的人常见的错误是提供一个听起来像问题的答案。他们的语气就像他们对每个词都在询问确认。当你的答案听起来像一个问题时,面试官可能会告诉你直接给出答案而不要问他。

重要提示

当你只能部分回答一个问题时,不要急于回答或者说你不知道。尝试向面试官询问更多细节和/或 20 秒的思考时间。有时,这会帮助你提供一个不完整但还不错的答案。例如,面试官可能会问你,“Java 中检查异常和未检查异常的主要区别是什么?”如果你不知道区别,那么你可以给出一个答案,比如,“检查异常是 Exception 的子类,而未检查异常是 RuntimeException 的子类”。你实际上没有回答问题,但这比说“我不知道”要好!或者,你可以提出一个问题,比如,“您是指我们被迫捕获的异常吗?”通过这样做,你可能会从面试官那里得到更多细节。注意不要问得像,“您是指我们被迫捕获的异常和我们不被迫捕获的异常吗?”你可能会得到一个简短的答复,比如“是”。这对你没有帮助!另一方面,如果你真的不知道答案/解决方案,那么最好说“我不知道”。这不一定会对你不利,而试图用太多的废话来迷惑面试官肯定会对你不利。

有些公司更喜欢进行现场的多项选择测验。在这种情况下,没有人的帮助,你必须在固定的时间内完成测验(例如,30 分钟)。重要的是要尽量回答尽可能多的问题。如果你不知道一个问题,那就继续下一个。时间在流逝!在最后(最后的 2-3 分钟),你可以回过头来尝试回答那些你放弃的问题。

然而,有些平台不允许你在问题之间来回跳转。在这种情况下,当你不知道问题的答案时,你被迫冒险猜测答案。花费大量时间回答一个问题最终会导致得分不佳。理想情况下,你应该尽量在每个问题上花相同的时间。例如,如果你有 20 个问题要在 30 分钟内回答,那么你可以为每个问题分配 30/20 = 1.5 分钟。

接近技术测验(无论是什么类型的测验)的最佳技巧之一是进行几次“模拟”面试。找个朋友,让他扮演面试官的角色。把问题放在一个碗里,让他随机挑选一个一个来回答。回答问题,表现得就像你真的在面对真正的面试官一样。

编码挑战

编码挑战是任何技术面试的高潮。这是你展示所有编码技能的时刻。是时候证明你能胜任这份工作了。有工作和整洁的代码可以帮助你留下良好的印象。一个良好的印象可能弥补你在面试的其他阶段留下的空白。

编码挑战是一把双刃剑,可能会从计划中将你剔除,另一方面可能会让你尽管有其他缺点,但还是得到一个工作机会。

然而,这些编码挑战所特有的问题因为各种原因而非常困难。这些将在下一节中介绍。

编码挑战所特有的问题意在困难

你是否曾经见过一个特定于编码挑战阶段的问题,觉得奇怪、愚蠢,或者可能毫无意义,与真正的问题毫无关联?如果是的话,那么你见到了一个特别好的编码挑战阶段的问题。

为了更好地了解如何为这些问题做准备,了解它们的特点和要求是很重要的。所以,让我们来看一下它们:

  • 它们不是现实世界的问题:通常,现实世界的问题需要大量时间来编码,因此它们不适合编码挑战。面试官会要求您解决可以在合理时间内解释和编码的问题,而这些问题通常不是现实世界的问题。

  • 它们可能相当愚蠢:看到相当愚蠢的问题并不罕见,看起来就像它们是为了使您的生活变得更加复杂而创造的。它们似乎对某事没有用或没有目标。这是正常的,因为它们大多数时候不是现实世界的问题。

  • 它们相当复杂:即使它们可以很快解决,它们也不容易!很可能,您将被要求编写一个方法或一个类,但这并不意味着它会很容易。通常,它们需要各种技巧,它们是令人费解的,和/或它们利用编程语言的不太知名的特性(例如,使用位操作)。

  • 解决方案并不明显:由于它们相当复杂,这些问题的解决方案并不明显。不要指望立即找到解决方案!几乎没有人能做到!这些问题是特别设计的,以查看您如何处理无法立即看到解决方案的情况。这就是为什么您可能需要几个小时来解决它(通常是 1 到 3 个小时之间)。

  • 禁止常见的解决路径:大多数时候,这样的问题有明确的条款,禁止使用常见的解决路径。例如,您可能会收到一个听起来像这样的问题:编写一个方法,它可以在给定位置之间提取字符串的子字符串,而不使用 String#substring()这样的内置方法。就像这个例子一样,有无数的例子。只需选择一个或多个内置的 Java 方法(例如,实用方法),可以在相对短的时间内实现,并加以阐述;例如,编写一个方法,它可以做 X 而不使用 Y 这样的内置解决方案。探索 API 源代码,参与开源项目,并练习这样的问题对于解决这样的问题非常有用。

  • 他们的目的是将您置于一组接受录用的候选人中:这些编码挑战的难度被校准,以使您成为一组独特百分比的候选人。一些公司只向不到 5%的候选人提供工作机会。如果大多数候选人可以轻松解决某个特定问题,那么它将被替换。

重要提示

编码挑战特有的问题旨在具有挑战性,并通常按难度递增的顺序提出。很可能,要通过这些编码挑战,您的经验和编码技能是不够的。因此,如果尽管您的知识,您无法立即看到解决方案,不要感到沮丧。许多这样的问题旨在测试您解决不寻常情况的能力和测试您的编码技能。它们可能具有荒谬的条款和/或模糊的解决方案,利用编程语言的不常见特性。它们可能包含愚蠢的要求和/或虚假案例。只专注于如何解决它们,并始终遵守规则。

通常,一次编码挑战会足够面试官。然而,也有一些情况下,您可能需要通过两次甚至三次这样的挑战。关键是尽可能多地练习。下一节将向您展示如何处理一般的编码挑战问题。

解决编码挑战问题

在讨论解决编码挑战问题的过程之前,让我们快速为编码挑战设置一个可能的环境。主要有两个坐标定义了这个环境:面试官在编码挑战期间的存在和纸笔对电脑的方法。

面试官在编码挑战过程中的存在

最常见的情况是,在编码挑战期间面试官会在场(通过电话或者面对面)。他们会评估你的最终结果(代码),但他们不仅仅是为了这个原因在场。仅仅衡量你的编码能力并不需要他们的存在,通常在编程比赛中会遇到。面试编码挑战不是一个编程比赛。面试官想要在整个过程中看到你,以分析你的行为和沟通能力。他们想要看到你是否有解决问题的计划,你是以有组织还是混乱的方式行动,你是否写了丑陋的代码,你是否愿意沟通你的行动,或者你是否内向。此外,他们想要协助和指导你。当然,你需要努力不需要指导或尽可能少的指导,但对指导的适当反应也是受欢迎的。然而,努力不需要指导并不意味着你不应该与面试官互动。

继续交流!

与面试官的互动是一个重要因素。以下列表解释了互动计划的几个方面:

  • 在编码之前解释你的解决方案:在开始编码之前,从面试官那里挤出一些有价值的信息是很重要的。向他们描述你想要如何解决问题,你要遵循什么步骤,以及你会使用什么。例如,你可以说,“我认为在这里使用 HashSet 是合适的选择,因为插入的顺序不重要,而且我们不需要重复的值”。你会得到一个赞成的手势或一些建议,这将帮助你获得预期的结果。

  • 在编码时解释你在做什么:在编码时,向面试官解释。例如,你可以说,首先,我会创建一个 ArrayList 的实例,或者,在这里,我将文件从本地文件夹加载到内存中

  • 提出适当的问题:只要你知道并尊重限制,你可以提出可以节省时间的问题。例如,问,“我记不得了 - 默认的 MySQL 端口是 3308 还是 3306?”然而,不要过分夸大这些问题!

  • 提及重要方面:如果你知道与问题相关的其他信息,那么与面试官分享。这是一个展示你的编程知识、思想和围绕问题的想法的好机会。

如果你遇到一个你已经知道的问题(也许你在练习中解决过这样的问题),那么不要马上说出来。这不会给面试官留下好印象,你可能会得到另一个编码挑战。最好遵循你对任何其他问题的处理过程。在我们讨论这个过程之前,让我们解决面试环境的另一个方面。

纸笔与电脑的方法

如果编码挑战是通过电话屏幕进行的,那么面试官会要求你分享屏幕并在你喜欢的集成开发环境IDE)中编码。这样,面试官可以看到你如何利用 IDE 的帮助(例如,他们可以看到你是否使用 IDE 生成 getter 和 setter,还是手动编写它们)。

重要提示

避免在每行代码后运行应用程序。相反,在每个逻辑代码块后运行应用程序。进行更正并再次运行。利用 IDE 调试工具。

如果你与面试官面对面,那么可能会被要求使用纸张或白板进行编码。这时,编码可以使用 Java 甚至伪代码。由于你的代码无法编译和执行,你必须手动测试它。通过拿一个例子并将其通过你的代码来展示你的代码是有效的是很重要的。

重要提示

在混乱的方法中避免过多的写入-删除代码循环。三思而后行!否则,你会让面试官头疼。

现在,让我们看一下旨在提供解决问题的方法论和逻辑方法的一般步骤。

处理编码挑战问题的过程

处理编码挑战问题的过程可以通过一系列顺序应用的步骤来完成。以下图表显示了这些步骤:

图 5.2 - 处理编码挑战问题的过程

图 5.2 - 处理编码挑战问题的过程

现在,让我们详细说明每个步骤。在应用这个解决问题的过程中,不要忘记交互组件。

理解问题

理解问题非常重要。不要基于假设或对问题的部分理解开始解决问题。至少要读两遍问题!不要依赖于一次阅读,因为在大多数情况下,这些问题包含隐藏和模糊的要求或细节,很容易被忽略。

不要犹豫向面试官询问关于问题的问题。有些情况下,故意忽略细节,以测试你发现潜在问题的能力。

重要提示

只有你理解了问题,你才有解决它的机会。

接下来,是时候建立一个例子了。如果你能建立一个例子,那么这清楚地表明你已经理解了问题。

建立一个例子

据说,“一幅图值千言”,但我们也可以用同样的方式来描述一个例子。

勾画问题并建立一个例子将澄清任何剩下的误解。这将给你一个通过逐步方法详细了解问题的机会。一旦你有一个可行的例子,你应该开始看到整体解决方案。这对于测试你的最终代码也是有用的。

重要提示

草图和例子对于巩固你对问题的理解是有用的。

现在,是时候考虑整体解决方案,并决定要使用的算法了。

决定要使用的算法并解释它们

在这一点上,你已经理解了问题,甚至建立了一个例子。现在,是时候形成一个整体解决方案,并将其分解为步骤和算法了。

这是一个耗时的过程。在这一点上,重要的是应用“表达你的想法”的方法。如果你什么都不说,面试官就不知道你是一无所知还是在头脑风暴。例如,你可以说,“我觉得我可以用一个列表来存储邮件,...嗯...不,这不行,因为列表接受重复项。”当你在说话时(即使看起来像是在自言自语),面试官可以判断你推理的正确性,看到你的知识水平,并给你一些建议。面试官可能会回答说,“是的,这是一个很好的观点”,“但是不要忘记你需要保持插入的顺序”。

大多数情况下,问题需要对数据(字符串、数字、位、对象等)进行某种形式的操作,比如排序、排序、过滤、反转、展平、搜索、计算等。有数据的地方,也有数据结构(数组、列表、集合、映射、树等)。关键是找到你需要的数据操作和数据结构之间的适当匹配。通常,适当的匹配意味着以下内容:

  • 你可以轻松地对数据结构应用某些操作。

  • 你可以获得良好的性能(大 O - 见[第七章](B15403_07_Final_JM_ePub.xhtml#_idTextAnchor135),算法的大 O 分析)。

  • 你可以在使用的数据结构之间保持和谐。这意味着你不需要复杂或繁重的算法,也不需要进行数据结构之间的转换或利用数据。

这些是拼图的大块。成功识别正确的匹配是工作的一半。另一半是将这些块组合在一起形成解决方案。换句话说,你需要在方程中引入逻辑。

在阅读问题或理解问题并在脑海中构思解决方案的大局之后,立即开始编码是非常诱人的。不要这样做!通常,这会导致一连串的失败,让你失去耐心。很快,你所有的想法都会被浓雾所包围,你会开始匆忙编码,甚至出现荒谬的错误。

重要提示

在开始编码之前,花时间深思熟虑解决方案。

现在,是时候开始编写你的解决方案,并用你的编码技能给面试官留下深刻印象了。

编写骨架

用一个骨架开始编写解决方案。更准确地说,定义你的类、方法和接口,但不实现(行为/动作)。你将在下一步中填充它们。这样,你向面试官展示你的编码阶段遵循了一条清晰的道路。不要过于匆忙地跳入代码中。此外,尊重编程的基本原则,如单一职责、开闭原则、里氏替换原则、接口隔离原则、依赖反转SOLID)和不要重复自己DRY)。面试官很可能会关注这些原则。

重要提示

编写解决方案的骨架有助于面试官更容易地跟随你,并更好地理解你的推理。

此时,你已经吸引了面试官的注意。现在,是时候让你的骨架活起来了。

编写解决方案

现在,是时候编写解决方案了。在这个过程中,向面试官解释你编写的主要代码行。注意并遵守著名的 Java 编码风格(例如,遵循 google.github.io/styleguide/javaguide.html 上的Google Java Style Guide)。

重要提示

遵循著名的 Java 编码风格并向面试官解释你的行动将对最终结果有很大帮助。

一旦你完成了解决方案的核心实现,就是增加代码的健壮性的时候了。因此,作为最后的一步,不要忽视异常处理和验证(例如,验证方法的参数)。同时,确保你满足了问题的所有要求,并且使用了正确的数据类型。最后,是时候祈祷你的代码能够通过测试了。

测试解决方案是这个过程的最后一步。

测试解决方案

在这个过程的第二步中,你建立了一个例子。现在,是时候向面试官展示你的代码通过了例子的测试。非常重要的是要证明你的代码至少对这个例子有效。它可能会出现一些小错误,但最终,重要的是它能够运行。

不要放松!你赢得了当前的战斗,但并没有赢得战争!通常,面试官还想看到你的代码对边界情况或特殊情况的处理。通常,这些特殊情况涉及虚拟值、边界值、不当输入、强制异常的操作等。如果你的代码不够健壮,无法通过这些尝试,那么面试官会认为你在生产应用中也会这样编码。另一方面,如果你的代码有效,那么面试官会完全印象深刻。

重要提示

有效的代码应该让面试官满意。至少,你会感到他们对你更友好和放松一些。

如果你给面试官留下了良好的印象,那么面试官可能会想要问你一些额外的问题。你应该期待被问及代码的性能和替代解决方案。当然,你可以在没有被问及的情况下提供这样的信息。面试官会很高兴看到你能够以多种方式解决问题,并且你理解每种解决方案和决策的利弊。

卡住会让你僵住

首先,卡住是正常的。不要惊慌!不要沮丧!不要放弃!

如果你卡住了,那么面试官可能也会卡住。主要问题是如何处理这种障碍,而不是障碍本身。你必须保持冷静,并尝试做以下事情:

  • 回到你的示例:有时,详细说明你的示例或查看另一个示例会有所帮助。有两个示例可以帮助你在脑海中塑造出一般情况,并理解问题的支柱。

  • 在示例中孤立问题:每个示例都有一系列步骤。确定你卡住的步骤,并将其作为一个单独的问题专注解决。有时,将问题从上下文中分离出来可以让你更好地理解并解决它。

  • 尝试不同的方法:有时,解决问题的方法是从不同的角度来解决问题。不同的视角可以给你一个新的视野。也许另一个数据结构,Java 的隐藏功能,蛮力方法等等可以帮助你。一个丑陋的解决方案总比没有解决方案好!

  • 模拟或推迟问题:长时间挣扎解决一个步骤可能会导致你无法及时完成问题的不愉快情况。有时,最好是模拟或推迟导致你困扰的步骤,并继续进行其他步骤。可能最后当你回到这一步时,你会对它有更清晰的认识,并知道如何编码。

  • 寻求指导:这应该是你的最后手段,但在危机中,你必须采取拼命的解决方案。你可以询问类似于“我对这个方面感到困惑,因为…”(并解释;尝试证明你的困惑)。“你能否给我一些关于我在这里错过了什么的提示?”

面试官意识到这一步(步骤)的困难,所以他们不会对你卡住感到惊讶。他们会欣赏你的毅力、分析能力和在寻找解决方案时的冷静,即使你找不到解决方案。面试官知道你在日常工作中会遇到类似的情况,而在这种情况下最重要的是保持冷静并寻找解决方案。

总结

在本章中,我们谈到了解决编程挑战问题的过程。除了我们之前列举的步骤 - 理解问题,构建示例,决定和解释算法,编写框架代码,编写和测试解决方案 - 还有一个步骤将成为接下来章节的目标:大量练习问题!在下一章中,我们将从编程的基本概念开始。

第二部分:概念

本节涵盖有关概念的问题。在这个领域提供优秀的知识是一个很好的指标,表明你具备所需的基本技能,这意味着你有一个坚实和健康的技术基础,可以在面试阶段回答问题。公司寻找这样的人作为可能的候选人,可以接受培训来解决非常具体和复杂的任务。

本节包括以下章节:

  • 第六章,面向对象编程

  • 第七章,算法的大 O 分析

  • 第八章,递归和动态规划

  • 第九章,位操作

第六章:面向对象编程

本章涵盖了在 Java 面试中遇到的与面向对象编程OOP)相关的最流行的问题和问题。

请记住,我的目标不是教你面向对象编程,或者更一般地说,这本书的目标不是教你 Java。我的目标是教你如何在面试的情境下回答问题和解决问题。在这样的情境下,面试官希望得到一个清晰而简洁的答案;你没有时间写论文和教程。你必须能够清晰而有力地表达你的想法。你的答案应该是有意义的,你必须说服面试官你真正理解你在说什么,而不只是背诵一些空洞的定义。大多数情况下,你应该能够用一两个关键段落表达一篇文章或一本书的一章。

通过本章结束时,你将知道如何回答 40 多个涵盖面向对象编程基本方面的问题和问题。作为基本方面,你必须详细了解它们。如果你不知道这些问题的正确和简洁的答案,那么在面试中成功的机会将受到严重影响。

因此,让我们总结我们的议程如下:

  • 面向对象编程概念

  • SOLID 原则

  • GOF 设计模式

  • 编码挑战

让我们从与面向对象编程概念相关的问题开始。

技术要求

你可以在 GitHub 上找到本章中的所有代码。请访问以下链接:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter06

理解面向对象编程概念

面向对象模型基于几个概念。这些概念对于计划设计和编写依赖于对象的应用程序的任何开发人员来说都必须熟悉。因此,让我们从以下列举它们开始:

  • 对象

  • 抽象

  • 封装

  • 继承

  • 多态

  • 协会

  • 聚合

  • 组合

通常,当这些概念被包含在问题中时,它们会以什么是...?为前缀。例如,什么是对象?,或者什么是多态?

重要说明

这些问题的正确答案是技术知识和现实世界的类比或例子的结合。避免冷冰冰的答案,没有超级技术细节和没有例子(例如,不要谈论对象的内部表示)。注意你说的话,因为面试官可能直接从你的答案中提取问题。如果你的答案中提到了一个概念,那么下一个问题可能会涉及到那个概念。换句话说,不要在你的答案中添加任何你不熟悉的方面。

因此,让我们在面试情境下回答与面向对象编程概念相关的问题。请注意,我们应用了第五章中学到的内容,如何应对编码挑战。更确切地说,我们遵循理解问题|确定关键词/关键点|给出答案的技巧。首先,为了熟悉这种技巧,我将提取关键点作为一个项目列表,并在答案中用斜体标出它们。

什么是对象?

你的答案中应该包含以下关键点:

  • 对象是面向对象编程的核心概念之一。

  • 对象是一个现实世界的实体。

  • 对象具有状态(字段)和行为(方法)。

  • 对象表示类的一个实例。

  • 对象占据内存中的一些空间。

  • 对象可以与其他对象通信。

现在,我们可以按照以下方式提出答案:

*对象是面向对象编程的核心概念之一。对象是现实世界的实体,比如汽车、桌子或猫。在其生命周期中,对象具有状态和行为。例如,猫的状态可以是颜色、名字和品种,而其行为可以是玩耍、吃饭、睡觉和喵喵叫。在 Java 中,对象通常是通过new关键字构建的类的实例,它的状态存储在字段中,并通过方法公开其行为。每个实例在内存中占据一些空间,并且可以与其他对象通信。例如,另一个对象男孩可以抚摸一只猫,然后它就会睡觉。

如果需要进一步的细节,那么你可能想谈论对象可以具有不同的访问修饰符和可见性范围,可以是可变的、不可变的或不可变的,并且可以通过垃圾收集器进行收集。

什么是类?

你应该在你的答案中封装的关键点是:

  • 类是面向对象编程的核心概念之一。

  • 类是创建对象的模板或蓝图。

  • 类不会占用内存。

  • 一个类可以被实例化多次。

  • 一个类只做一件事。

现在,我们可以这样提出一个答案:

*类是面向对象编程的核心概念之一。类是构建特定类型对象所需的一组指令。我们可以把类想象成一个模板、蓝图或配方,告诉我们如何创建该类的对象。创建该类的对象是一个称为实例化的过程,通常通过new关键字完成。我们可以实例化任意多个对象。类定义不会占用内存,而是保存在硬盘上的文件中。一个类应该遵循的最佳实践之一是单一职责原则(SRP)。在遵循这个原则的同时,一个类应该被设计和编写成只做一件事。

如果需要进一步的细节,那么你可能想谈论类可以具有不同的访问修饰符和可见性范围,支持不同类型的变量(局部、类和实例变量),并且可以声明为abstractfinalprivate,嵌套在另一个类中(内部类),等等。

什么是抽象?

你应该在你的答案中封装的关键点是:

  • 抽象是面向对象编程的核心概念之一。

  • 抽象是将对用户有意义的东西暴露给他们,隐藏其余的细节。

  • 抽象允许用户专注于应用程序的功能,而不是它是如何实现的。

  • 在 Java 中,通过抽象类和接口实现抽象。

现在,我们可以这样提出一个答案:

爱因斯坦声称一切都应该尽可能简单,但不要过于简单抽象是面向对象编程的主要概念之一,旨在尽可能简化用户的操作。换句话说,抽象只向用户展示对他们有意义的东西,隐藏其余的细节。在面向对象编程的术语中,我们说一个对象应该向其用户只公开一组高级操作,而这些操作的内部实现是隐藏的。因此,抽象允许用户专注于应用程序的功能,而不是它是如何实现的。这样,抽象减少了暴露事物的复杂性,增加了代码的可重用性,避免了代码重复,并保持了低耦合和高内聚。此外,它通过只暴露重要细节来维护应用程序的安全性和保密性。

让我们考虑一个现实生活的例子:一个人开车。这个人知道每个踏板的作用,以及方向盘的作用,但他不知道这些事情是车内部是如何完成的。他不知道赋予这些事情力量的内部机制。这就是抽象。在 Java 中,可以通过抽象类和接口实现抽象

如果需要更多细节,你可以分享屏幕或使用纸和笔编写你的例子。

所以,我们说一个人在开车。这个人可以通过相应的踏板加速或减速汽车。他还可以通过方向盘左转和右转。所有这些操作都被分组在一个名为Car的接口中:

public interface Car {
    public void speedUp();
    public void slowDown();
    public void turnRight();
    public void turnLeft();
    public String getCarType();
}

接下来,每种类型的汽车都应该实现Car接口,并重写这些方法来提供这些操作的实现。这个实现对用户(驾驶汽车的人)是隐藏的。例如,ElectricCar类如下所示(实际上,我们有复杂的业务逻辑代替了System.out.println):

public class ElectricCar implements Car {
    private final String carType;
    public ElectricCar(String carType) {
        this.carType = carType;
    }        
    @Override
    public void speedUp() {
        System.out.println("Speed up the electric car");
    }
    @Override
    public void slowDown() {
        System.out.println("Slow down the electric car");
    }
    @Override
    public void turnRight() {
        System.out.println("Turn right the electric car");
    }
    @Override
    public void turnLeft() {
        System.out.println("Turn left the electric car");
    }
    @Override
    public String getCarType() {
        return this.carType;
    }        
}

这个类的用户可以访问这些公共方法,而不需要了解具体的实现:

public class Main {
    public static void main(String[] args) {
        Car electricCar = new ElectricCar("BMW");
        System.out.println("Driving the electric car: " 
		  + electricCar.getCarType() + "\n");
        electricCar.speedUp();
        electricCar.turnLeft();
        electricCar.slowDown();
    }
}

输出列举如下:

Driving the electric car: BMW
Speed up the electric car
Turn left the electric car
Slow down the electric car

所以,这是一个通过接口进行抽象的例子。完整的应用程序名为Abstraction/AbstractionViaInterface。在本书附带的代码中,你可以找到通过抽象类实现相同场景的代码。完整的应用程序名为Abstraction/AbstractionViaAbstractClass

接下来,让我们谈谈封装。

什么是封装?

你应该在你的答案中封装的关键点如下:

  • 封装是面向对象编程的核心概念之一。

  • 封装是一种技术,通过它,对象状态被隐藏,同时提供了一组公共方法来访问这个状态。

  • 当每个对象将其状态私有化在一个类内部时,封装就实现了。

  • 封装被称为数据隐藏机制。

  • 封装有许多重要的优点,比如松散耦合、可重用、安全和易于测试的代码。

  • 在 Java 中,封装是通过访问修饰符(publicprivateprotected)实现的。

现在,我们可以这样呈现一个答案:

封装是面向对象编程的核心概念之一。主要来说,封装将代码和数据绑定在一个单元(类)中,并充当一个防御屏障,不允许外部代码直接访问这些数据。主要来说,它是隐藏对象状态,向外部提供一组公共方法来访问这个状态的技术。当每个对象将其状态私有化在一个类内部时,我们可以说封装已经实现。这就是为什么封装也被称为公共、私有和受保护。通常,当一个对象管理自己的状态时,其状态通过私有变量声明,并通过公共方法访问和/或修改。让我们举个例子:一个Cat类可以通过moodhungryenergy等字段来表示其状态。虽然Cat类外部的代码不能直接修改这些字段中的任何一个,但它可以调用play()feed()sleep()等公共方法来在内部修改Cat的状态。Cat类也可能有私有方法,外部无法访问,比如meow()。这就是封装。

如果需要更多细节,你可以分享屏幕或使用纸和笔编写你的例子。

所以,我们的例子中的Cat类可以按照下面的代码块进行编码。注意,这个类的状态是通过私有字段封装的,因此不能直接从类外部访问:

public class Cat {
    private int mood = 50;
    private int hungry = 50;
    private int energy = 50;
    public void sleep() {
        System.out.println("Sleep ...");
        energy++;
        hungry++;
    }
    public void play() {
        System.out.println("Play ...");
        mood++;
        energy--;
        meow();
    }
    public void feed() {
        System.out.println("Feed ...");
        hungry--;
        mood++;
        meow();
    }
    private void meow() {
        System.out.println("Meow!");
    }
    public int getMood() {
        return mood;
    }
    public int getHungry() {
        return hungry;
    }
    public int getEnergy() {
        return energy;
    }
}

修改状态的唯一方式是通过play()feed()sleep()这些公共方法,就像下面的例子一样:

public static void main(String[] args) {
    Cat cat = new Cat();
    cat.feed();
    cat.play();
    cat.feed();
    cat.sleep();
    System.out.println("Energy: " + cat.getEnergy());
    System.out.println("Mood: " + cat.getMood());
    System.out.println("Hungry: " + cat.getHungry());
}

输出将如下所示:

Feed ...Meow!Play ...Meow!Feed ...Meow!Sleep ...
Energy: 50
Mood: 53
Hungry: 49

完整的应用程序名为Encapsulation。现在,让我们来了解一下继承。

什么是继承?

你应该在你的答案中封装的关键点如下:

  • 继承是面向对象编程的核心概念之一。

  • 继承允许一个对象基于另一个对象。

  • 继承通过允许一个对象重用另一个对象的代码并添加自己的逻辑来实现代码的可重用性。

  • 继承被称为IS-A关系,也被称为父子关系。

  • 在 Java 中,继承是通过extends关键字实现的。

  • 继承的对象被称为超类,继承超类的对象被称为子类。

  • 在 Java 中,不能继承多个类。

现在,我们可以这样呈现一个答案:

“继承是面向对象编程的核心概念之一。它允许一个对象基于另一个对象”,当不同的对象非常相似并共享一些公共逻辑时,这是很有用的,但它们并不完全相同。“继承通过允许一个对象重用另一个对象的代码来实现代码的可重用性,同时它也添加了自己的逻辑”。因此,为了实现继承,我们重用公共逻辑并将独特的逻辑提取到另一个类中。“这被称为 IS-A 关系,也被称为父子关系”。就像说FooBuzz类型的东西一样。例如,猫是猫科动物,火车是车辆。IS-A 关系是用来定义类层次结构的工作单元。“在 Java 中,继承是通过extends关键字实现的,通过从父类派生子类”。子类可以重用其父类的字段和方法,并添加自己的字段和方法。“继承的对象被称为超类,或者父类,继承超类的对象被称为子类,或者子类。在 Java 中,继承不能是多重的;因此,子类或子类不能继承多于一个超类或父类的字段和方法。例如,Employee类(父类)可以定义软件公司任何员工的公共逻辑,而另一个类(子类),名为Programmer,可以扩展Employee以使用这个公共逻辑并添加特定于程序员的逻辑。其他类也可以扩展ProgrammerEmployee类。”

如果需要更多细节,你可以分享屏幕或使用纸和笔编写你的例子。

Employee类非常简单。它包装了员工的名字:

public class Employee {
    private String name;
    public Employee(String name) {
        this.name = name;
    }
    // getters and setters omitted for brevity
}

然后,Programmer类扩展了Employee。像任何员工一样,程序员有一个名字,但他们也被分配到一个团队中:

public class Programmer extends Employee {
    private String team;
    public Programmer(String name, String team) {
        super(name);
        this.team = team;
    }
    // getters and setters omitted for brevity
}

现在,让我们通过创建一个Programmer并调用从Employee类继承的getName()和从Programmer类继承的getTeam()来测试继承:

public static void main(String[] args) {
    Programmer p = new Programmer("Joana Nimar", "Toronto");
    String name = p.getName();
    String team = p.getTeam();
    System.out.println(name + " is assigned to the " 
          + team + " team");
}

输出将如下所示:

Joana Nimar is assigned to the Toronto team

完整的应用程序被命名为继承。接下来,让我们谈谈多态。

什么是多态?

你应该在你的答案中包含的关键点是:

  • 多态是面向对象编程的核心概念之一。

  • 多态在希腊语中意味着“多种形式”。

  • 多态允许对象在某些情况下表现得不同。

  • 多态可以通过方法重载(称为编译时多态)或通过方法重写来实现 IS-A 关系(称为运行时多态)。

现在,我们可以这样呈现一个答案:

多态是面向对象编程的核心概念之一。多态是由两个希腊单词组成的:poly,意思是morph,意思是形式。因此,多态意味着多种形式

更准确地说,在面向对象编程的上下文中,多态性允许对象在某些情况下表现不同,或者换句话说,允许以不同的方式(方法)完成某个动作。实现多态性的一种方式是通过方法重载。这被称为编译时多态性,因为编译器可以在编译时识别调用重载方法的形式(具有相同名称但不同参数的多个方法)。因此,根据调用的重载方法的形式,对象的行为会有所不同。例如,名为Triangle的类可以定义多个带有不同参数的draw()方法。

另一种实现多态性的方法是通过方法重写,当我们有一个 IS-A 关系时,这是常见的方法。这被称为运行时多态性,或动态方法分派。通常,我们从一个包含一堆方法的接口开始。接下来,每个类实现这个接口并重写这些方法以提供特定的行为。这次,多态性允许我们像使用其父类(接口)一样使用这些类中的任何一个,而不会混淆它们的类型。这是可能的,因为在运行时,Java 可以区分这些类并知道使用哪一个。例如,一个名为Shape的接口可以声明一个名为draw()的方法,而TriangleRectangleCircle类实现了Shape接口并重写了draw()方法来绘制相应的形状。

如果需要进一步的细节,那么你可以分享屏幕或使用纸和笔编写你的例子。

通过方法重载实现多态性(编译时)

Triangle类包含三个draw()方法,如下所示:

public class Triangle {
    public void draw() {
        System.out.println("Draw default triangle ...");
    }
    public void draw(String color) {
        System.out.println("Draw a triangle of color " 
            + color);
    }
    public void draw(int size, String color) {
        System.out.println("Draw a triangle of color " + color
           + " and scale it up with the new size of " + size);
    }
}

接下来,注意相应的draw()方法是如何被调用的:

public static void main(String[] args) {
    Triangle triangle = new Triangle();
    triangle.draw();
    triangle.draw("red");
    triangle.draw(10, "blue");
}

输出将如下所示:

Draw default triangle ...
Draw a triangle of color red
Draw a triangle of color blue and scale it up 
with the new size of 10

完整的应用程序名为多态性/编译时。接下来,让我们看一个实现运行时多态性的例子。

通过方法重写实现多态性(运行时)

这次,draw()方法是在一个接口中声明的,如下所示:

public interface Shape {
    public void draw();
}

TriangleRectangleCircle类实现了Shape接口并重写了draw()方法来绘制相应的形状:

public class Triangle implements Shape {
    @Override
    public void draw() {
        System.out.println("Draw a triangle ...");
    }
}
public class Rectangle implements Shape {
    @Override
    public void draw() {
        System.out.println("Draw a rectangle ...");
    }
}
public class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Draw a circle ...");
    }
}

接下来,我们创建一个三角形、一个矩形和一个圆。对于这些实例中的每一个,让我们调用draw()方法:

public static void main(String[] args) {
    Shape triangle = new Triangle();
    Shape rectangle = new Rectangle();
    Shape circle = new Circle();
    triangle.draw();
    rectangle.draw();
    circle.draw();
}

输出显示,在运行时,Java 调用了正确的draw()方法:

Draw a triangle ...
Draw a rectangle ...
Draw a circle ...

完整的应用程序名为多态性/运行时。接下来,让我们谈谈关联。

重要提示

有人认为多态性是面向对象编程中最重要的概念。此外,也有声音认为运行时多态性是唯一真正的多态性,而编译时多态性实际上并不是一种多态性形式。在面试中引发这样的辩论是不建议的。最好是充当调解人,提出事情的两面。我们很快将讨论如何处理这种情况。

什么是关联?

你的答案中应该包含的关键点是:

  • 关联是面向对象编程的核心概念之一。

  • 关联定义了两个相互独立的类之间的关系。

  • 关联没有所有者。

  • 关联可以是一对一、一对多、多对一和多对多。

现在,我们可以给出如下答案:

关联是面向对象编程的核心概念之一。关联的目标是定义两个类之间独立于彼此的关系,也被称为对象之间的多重性关系。没有关联的所有者。参与关联的对象可以互相使用(双向关联),或者只有一个使用另一个(单向关联),但它们有自己的生命周期。关联可以是单向/双向,一对一,一对多,多对一和多对多。例如,在PersonAddress对象之间,我们可能有一个双向多对多的关系。换句话说,一个人可以与多个地址相关联,而一个地址可以属于多个人。然而,人可以存在而没有地址,反之亦然。

如果需要进一步的细节,那么你可以分享屏幕或使用纸和笔编写你的例子。

PersonAddress类非常简单:

public class Person {
    private String name;
    public Person(String name) {
        this.name = name;
    }
    // getters and setters omitted for brevity
}
public class Address {
    private String city;
    private String zip;
    public Address(String city, String zip) {
        this.city = city;
        this.zip = zip;
    }
    // getters and setters omitted for brevity
}

PersonAddress之间的关联是在main()方法中完成的,如下面的代码块所示:

public static void main(String[] args) {
    Person p1 = new Person("Andrei");
    Person p2 = new Person("Marin");
    Address a1 = new Address("Banesti", "107050");
    Address a2 = new Address("Bucuresti", "229344");
    // Association between classes in the main method 
    System.out.println(p1.getName() + " lives at address "
            + a2.getCity() + ", " + a2.getZip()
            + " but it also has an address at "
            + a1.getCity() + ", " + a1.getZip());
    System.out.println(p2.getName() + " lives at address "
            + a1.getCity() + ", " + a1.getZip()
            + " but it also has an address at "
            + a2.getCity() + ", " + a2.getZip());
}

输出如下所示:

Andrei lives at address Bucuresti, 229344 but it also has an address at Banesti, 107050
Marin lives at address Banesti, 107050 but it also has an address at Bucuresti, 229344

完整的应用程序被命名为Association。接下来,让我们谈谈聚合。

什么是聚合?

你的答案中应该包含的关键点如下:

  • 聚合是面向对象编程的核心概念之一。

  • 聚合是单向关联的特殊情况。

  • 聚合代表一个 HAS-A 关系。

  • 两个聚合对象有各自的生命周期,但其中一个对象是 HAS-A 关系的所有者。

现在,我们可以这样呈现一个答案:

聚合是面向对象编程的核心概念之一。主要是,聚合是单向关联的特殊情况。当一个关联定义了两个类之间独立于彼此的关系时,聚合代表这两个类之间的 HAS-A 关系。换句话说,两个聚合对象有各自的生命周期,但其中一个对象是 HAS-A 关系的所有者。有自己的生命周期意味着结束一个对象不会影响另一个对象。例如,一个TennisPlayer有一个Racket。这是一个单向关联,因为一个Racket不能拥有一个TennisPlayer。即使TennisPlayer死亡,Racket也不会受到影响。

重要提示

请注意,当我们定义聚合的概念时,我们也对关联有了一个陈述。每当两个概念紧密相关且其中一个是另一个的特殊情况时,都要遵循这种方法。下一步,同样的做法被应用于将组合定义为聚合的特殊情况。面试官会注意到并赞赏你对事物的概览,并且你能够提供一个有意义的答案,没有忽视上下文。

如果需要进一步的细节,那么你可以分享屏幕或使用纸和笔编写你的例子。

我们从Rocket类开始。这是网球拍的简单表示:

public class Racket {
    private String type;
    private int size;
    private int weight;
    public Racket(String type, int size, int weight) {
        this.type = type;
        this.size = size;
        this.weight = weight;
    }
    // getters and setters omitted for brevity
}

一个TennisPlayer拥有一个Racket。因此,TennisPlayer类必须能够接收一个Racket,如下所示:

public class TennisPlayer {
    private String name;
    private Racket racket;
    public TennisPlayer(String name, Racket racket) {
        this.name = name;
        this.racket = racket;
    }
    // getters and setters omitted for brevity
}

接下来,我们创建一个Racket和一个使用这个RacketTennisPlayer

public static void main(String[] args) {
    Racket racket = new Racket("Babolat Pure Aero", 100, 300);
    TennisPlayer player = new TennisPlayer("Rafael Nadal", 
        racket);
    System.out.println("Player " + player.getName() 
        + " plays with " + player.getRacket().getType());
}

输出如下:

Player Rafael Nadal plays with Babolat Pure Aero

完整的应用程序被命名为Aggregation。接下来,让我们谈谈组合。

什么是组合?

你的答案中应该包含的关键点如下:

  • 组合是面向对象编程的核心概念之一。

  • 组合是聚合的一种更为严格的情况。

  • 组合代表一个包含一个不能独立存在的对象的 HAS-A 关系。

  • 组合支持对象的代码重用和可见性控制。

现在,我们可以这样呈现一个答案:

组合是面向对象编程的核心概念之一主要来说,组合是聚合的一种更严格的情况。聚合表示两个对象之间具有自己的生命周期的 HAS-A 关系,组合表示包含一个不能独立存在的对象的 HAS-A 关系。为了突出这种耦合,HAS-A 关系也可以被称为 PART-OF。例如,一个Car有一个Engine。换句话说,发动机是汽车的一部分。如果汽车被销毁,那么发动机也会被销毁。组合被认为比继承更好,因为它维护了对象的代码重用和可见性控制

如果需要进一步的细节,那么你可以分享屏幕或使用纸和笔编写你的例子。

Engine类非常简单:

public class Engine {
    private String type;
    private int horsepower;
    public Engine(String type, int horsepower) {
        this.type = type;
        this.horsepower = horsepower;
    }
    // getters and setters omitted for brevity
}

接下来,我们有Car类。查看这个类的构造函数。由于EngineCar的一部分,我们用Car创建它:

public class Car {
    private final String name;
    private final Engine engine;
    public Car(String name) {
        this.name = name;
        Engine engine = new Engine("petrol", 300);
        this.engine=engine;
    }
    public int getHorsepower() {
        return engine.getHorsepower();
    }
    public String getName() {
        return name;
   }    
}

接下来,我们可以从main()方法中测试组合如下:

public static void main(String[] args) {
    Car car = new Car("MyCar");
    System.out.println("Horsepower: " + car.getHorsepower());
}

输出如下:

Horsepower: 300

完整的应用程序被命名为组合

到目前为止,我们已经涵盖了关于面向对象编程概念的基本问题。请记住,这些问题几乎可以在涉及编码或架构应用程序的任何职位的 Java 技术面试中出现。特别是如果你有大约 2-4 年的经验,那么你被问到上述问题的机会很高,你必须知道答案,否则这将成为你的一个污点。

现在,让我们继续讨论 SOLID 原则。这是另一个基本领域,与面向对象编程概念并列的必须知道的主题。在这个领域缺乏知识将在最终决定你的面试时证明是有害的。

了解 SOLID 原则

在这一部分,我们将对与编写类的五个著名设计模式对应的问题进行回答 - SOLID 原则。简而言之,SOLID 是以下内容的首字母缩写:

  • S:单一责任原则

  • O:开闭原则

  • L:里氏替换原则

  • I:接口隔离原则

  • D:依赖反转原则

在面试中,与 SOLID 相关的最常见的问题是什么是...?类型的。例如,S 是什么?或者* D 是什么?*通常,与面向对象编程相关的问题是故意模糊的。这样,面试官测试你的知识水平,并希望看到你是否需要进一步的澄清。因此,让我们依次解决这些问题,并提供一个令面试官印象深刻的答案。

S 是什么?

你应该在你的答案中概括的关键点如下:

  • S 代表单一责任原则(SRP)。

  • S 代表一个类应该只有一个责任

  • S 告诉我们为了一个目标编写一个类。

  • S 维护了整个应用程序模块的高可维护性和可见性控制。

现在,我们可以给出以下答案:

首先,SOLID 是 Robert C. Martin(也被称为 Uncle Bob)阐述的前五个面向对象设计(OOD)原则的首字母缩写。S是 SOLID 的第一个原则,被称为单一责任原则SRP)。这个原则意味着一个类应该只有一个责任。这是一个非常重要的原则,应该在任何类型的项目中遵循,无论是任何类型的类(模型、服务、控制器、管理类等)。只要我们为一个目标编写一个类,我们就能在整个应用程序模块中保持高可维护性和可见性控制。换句话说,通过保持高可维护性,这个原则对业务有重大影响,通过提供应用程序模块的可见性控制,这个原则维护了封装性。

如果需要更多细节,那么你可以分享屏幕或使用纸和笔编写一个像这里呈现的例子一样的例子。

例如,你想计算一个矩形的面积。矩形的尺寸最初以米为单位给出,面积也以米为单位计算,但我们希望能够将计算出的面积转换为其他单位,比如英寸。让我们看一下违反 SRP 的方法。

违反 SRP

在单个类RectangleAreaCalculator中实现前面的问题可以这样做。但是这个类做了不止一件事:它违反了 SRP。请记住,通常当你用“和”这个词来表达一个类做了什么时,这是 SRP 被违反的迹象。例如,下面的类计算面积并将其转换为英寸:

public class RectangleAreaCalculator {
    private static final double INCH_TERM = 0.0254d;
    private final int width;
    private final int height;
    public RectangleAreaCalculator(int width, int height) {
        this.width = width;
        this.height = height;
    }
    public int area() {
        return width * height;
    }
    // this method breaks SRP
    public double metersToInches(int area) {
        return area / INCH_TERM;
    }    
}

由于这段代码违反了 SRP,我们必须修复它以遵循 SRP。

遵循 SRP

通过从RectangleAreaCalculator中删除“metersToInches()”方法来解决这种情况,如下所示:

public class RectangleAreaCalculator {
    private final int width;
    private final int height;
    public RectangleAreaCalculator(int width, int height) {
        this.width = width;
        this.height = height;
    }
    public int area() {
        return width * height;
    }       
}

现在,RectangleAreaCalculator只做一件事(计算矩形面积),从而遵守 SRP。

接下来,可以将“metersToInches()”提取到一个单独的类中。此外,我们还可以添加一个新的方法来将米转换为英尺:

public class AreaConverter {
    private static final double INCH_TERM = 0.0254d;
    private static final double FEET_TERM = 0.3048d;
    public double metersToInches(int area) {
        return area / INCH_TERM;
    }
    public double metersToFeet(int area) {
        return area / FEET_TERM;
    }
}

这个类也遵循了 SRP,因此我们的工作完成了。完整的应用程序被命名为“SingleResponsabilityPrinciple”。接下来,让我们谈谈第二个 SOLID 原则,即开闭原则。

O 是什么?

你应该在你的答案中包含的关键点是:

  • O 代表开闭原则(OCP)。

  • O 代表“软件组件应该对扩展开放,但对修改关闭”。

  • O 维持了这样一个事实,即我们的类不应该包含需要其他开发人员修改我们的类才能完成工作的约束条件-其他开发人员应该只能扩展我们的类来完成他们的工作。

  • O 以一种多才多艺、直观且无害的方式维持软件的可扩展性。

现在,我们可以这样回答:

首先,SOLID 是 Robert C. Martin 提出的前五个面向对象设计(OOD)原则的首字母缩写,也被称为 Uncle Bob(可选短语)。 O 是 SOLID 中的第二个原则,被称为开闭原则(OCP)。这个原则代表“软件组件应该对扩展开放,但对修改关闭”。这意味着我们的类应该被设计和编写成其他开发人员可以通过简单地扩展它们来改变这些类的行为。因此,“我们的类不应该包含需要其他开发人员修改我们的类才能完成工作的约束条件-其他开发人员应该只能扩展我们的类来完成工作”。

虽然我们“必须以一种多才多艺、直观且无害的方式维持软件的可扩展性”,但我们不必认为其他开发人员会想要改变我们的类的整个逻辑或核心逻辑。主要是,如果我们遵循这个原则,那么我们的代码将作为一个良好的框架,不会让我们修改它们的核心逻辑,但我们可以通过扩展一些类、传递初始化参数、重写方法、传递不同的选项等来修改它们的流程和/或行为。

如果需要更多细节,那么你可以分享屏幕或使用纸和笔编写一个像这里呈现的例子一样的例子。

现在,例如,你有不同的形状(例如矩形、圆)并且我们想要求它们的面积之和。首先,让我们看一下违反 OCP 的实现。

违反 OCP

每个形状都将实现Shape接口。因此,代码非常简单:

public interface Shape {    
}
public class Rectangle implements Shape {
    private final int width;
    private final int height;
    // constructor and getters omitted for brevity
}
public class Circle implements Shape {
    private final int radius;
    // constructor and getter omitted for brevity
}

在这一点上,我们可以很容易地使用这些类的构造函数来创建不同尺寸的矩形和圆。一旦我们有了几种形状,我们想要求它们的面积之和。为此,我们可以定义一个AreaCalculator类,如下所示:

public class AreaCalculator {
    private final List<Shape> shapes;
    public AreaCalculator(List<Shape> shapes) {
        this.shapes = shapes;
    }
    // adding more shapes requires us to modify this class
    // this code is not OCP compliant
    public double sum() {
        int sum = 0;
        for (Shape shape : shapes) {
            if (shape.getClass().equals(Circle.class)) {
                sum += Math.PI * Math.pow(((Circle) shape)
                    .getRadius(), 2);
            } else 
            if(shape.getClass().equals(Rectangle.class)) {
                sum += ((Rectangle) shape).getHeight() 
                    * ((Rectangle) shape).getWidth();
            }
        }
        return sum;
    }
}

由于每种形状都有自己的面积公式,我们需要一个if-else(或switch)结构来确定形状的类型。此外,如果我们想要添加一个新的形状(例如三角形),我们必须修改AreaCalculator类以添加一个新的if情况。这意味着前面的代码违反了 OCP。修复这段代码以遵守 OCP 会对所有类进行多处修改。因此,请注意,即使是简单的例子,修复不遵循 OCP 的代码可能会非常棘手。

遵循 OCP

主要思想是从AreaCalculator中提取每种形状的面积公式,并将其放入相应的Shape类中。因此,矩形将计算其面积,圆形也是如此,依此类推。为了强制每种形状必须计算其面积,我们将area()方法添加到Shape合同中:

public interface Shape { 
    public double area();
}

接下来,RectangleCircle实现Shape如下:

public class Rectangle implements Shape {
    private final int width;
    private final int height;
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
    public double area() {
        return width * height;
    }
}
public class Circle implements Shape {
    private final int radius;
    public Circle(int radius) {
        this.radius = radius;
    }
    @Override
    public double area() {
        return Math.PI * Math.pow(radius, 2);
    }
}

现在,AreaCalculator可以循环遍历形状列表,并通过调用适当的area()方法来计算面积。

public class AreaCalculator {
    private final List<Shape> shapes;
    public AreaCalculator(List<Shape> shapes) {
        this.shapes = shapes;
    }
    public double sum() {
        int sum = 0;
        for (Shape shape : shapes) {
            sum += shape.area();
        }
        return sum;
    }
}

代码符合 OCP。我们可以添加一个新的形状,而不需要修改AreaCalculator。因此,AreaCalculator对于修改是封闭的,当然,对于扩展是开放的。完整的应用程序被命名为开闭原则。接下来,让我们谈谈第三个 SOLID 原则,Liskov 替换原则。

什么是 L?

您应该在您的答案中封装以下关键点:

  • L 代表Liskov 替换原则 (LSP)

  • L 代表派生类型必须完全可替换其基类型

  • L 支持子类的对象必须与超类的对象以相同的方式行为。

  • L 对于运行时类型识别后跟随转换是有用的。

现在,我们可以如下呈现一个答案:

首先,SOLID 是前五个foo(p)的首字母缩写,其中p是类型T。然后,如果q是类型S,并且ST的子类型,那么foo(q)应该正常工作。

如果需要进一步的细节,那么您可以共享屏幕或使用纸张和笔来编写一个像这里呈现的例子一样的例子。

我们有一个接受三种类型会员的国际象棋俱乐部:高级会员、VIP 会员和免费会员。我们有一个名为Member的抽象类,它充当基类,以及三个子类-PremiumMemberVipMemberFreeMember。让我们看看这些会员类型是否可以替代基类。

违反 LSP

Member类是抽象的,它代表了我们国际象棋俱乐部所有成员的基类。

public abstract class Member {
    private final String name;
    public Member(String name) {
        this.name = name;
    }
    public abstract void joinTournament();
    public abstract void organizeTournament();
}

PremiumMember类可以加入国际象棋比赛,也可以组织这样的比赛。因此,它的实现非常简单。

public class PremiumMember extends Member {
    public PremiumMember(String name) {
        super(name);
    }
    @Override
    public void joinTournament() {
        System.out.println("Premium member joins tournament");         
    }
    @Override
    public void organizeTournament() {
        System.out.println("Premium member organize 
            tournament");        
     }
}

VipMember类与PremiumMember类大致相同,因此我们可以跳过它,专注于FreeMember类。FreeMember类可以参加比赛,但不能组织比赛。这是我们需要在organizeTournament()方法中解决的问题。我们可以抛出一个带有有意义消息的异常,或者我们可以显示一条消息,如下所示:

public class FreeMember extends Member {
    public FreeMember(String name) {
        super(name);
    }
    @Override
    public void joinTournament() {
        System.out.println("Classic member joins tournament 
            ...");

    }
    // this method breaks Liskov's Substitution Principle
    @Override
    public void organizeTournament() {
        System.out.println("A free member cannot organize 
            tournaments");

    }
}

但是抛出异常或显示消息并不意味着我们遵循 LSP。由于免费会员无法组织比赛,因此它不能替代基类,因此它违反了 LSP。请查看以下会员列表:

List<Member> members = List.of(
    new PremiumMember("Jack Hores"),
    new VipMember("Tom Johns"),
    new FreeMember("Martin Vilop")
); 

以下循环显示了我们的代码不符合 LSP,因为当FreeMember类必须替换Member类时,它无法完成其工作,因为FreeMember无法组织国际象棋比赛。

for (Member member : members) {
    member.organizeTournament();
}

这种情况是一个停滞不前的问题。我们无法继续实现我们的应用程序。我们必须重新设计我们的解决方案,以获得符合 LSP 的代码。所以让我们这样做!

遵循 LSP

重构过程从定义两个接口开始,这两个接口用于分离两个操作,即加入和组织国际象棋比赛:

public interface TournamentJoiner {
    public void joinTournament();
}
public interface TournamentOrganizer {
    public void organizeTournament();
}

接下来,抽象基类实现这两个接口如下:

public abstract class Member 
    implements TournamentJoiner, TournamentOrganizer {
    private final String name;
    public Member(String name) {
        this.name = name;
    }  
}

PremiumMemberVipMember保持不变。它们扩展了Member基类。然而,FreeMember类不能组织比赛,因此不会扩展Member基类。它只会实现TournamentJoiner接口:

public class FreeMember implements TournamentJoiner {
    private final String name;
    public FreeMember(String name) {
        this.name = name;
    }
    @Override
    public void joinTournament() {
        System.out.println("Free member joins tournament ...");
    }
}

现在,我们可以定义一个能够参加国际象棋比赛的成员列表如下:

List<TournamentJoiner> members = List.of(
    new PremiumMember("Jack Hores"),
    new PremiumMember("Tom Johns"),
    new FreeMember("Martin Vilop")
);

循环此列表,并用每种类型的成员替换TournamentJoiner接口,可以正常工作并遵守 LSP:

// this code respects LSP
for (TournamentJoiner member : members) {
    member.joinTournament();
}   

按照相同的逻辑,可以将能够组织国际象棋比赛的成员列表编写如下:

List<TournamentOrganizer> members = List.of(
    new PremiumMember("Jack Hores"),
    new VipMember("Tom Johns")
);

FreeMember没有实现TournamentOrganizer接口。因此,它不能添加到此列表中。循环此列表,并用每种类型的成员替换TournamentOrganizer接口可以正常工作并遵守 LSP:

// this code respects LSP
for (TournamentOrganizer member : members) {
    member.organizeTournament();
}

完成!现在我们有一个符合 LSP 的代码。完整的应用程序命名为LiskovSubstitutionPrinciple。接下来,让我们谈谈第四个 SOLID 原则,接口隔离原则。

什么是 I?

您应该在答案中封装的关键点如下:

  • I 代表接口隔离原则(ISP)。

  • I 代表客户端不应被强制实现他们不会使用的不必要的方法

  • 我将一个接口分割成两个或更多个接口,直到客户端不被强制实现他们不会使用的方法。

现在,我们可以如下呈现一个答案:

首先,SOLID 是前五个Connection接口的首字母缩写,它有三种方法:connect()socket()http()。客户端可能只想为通过 HTTP 的连接实现此接口。因此,他们不需要socket()方法。大多数情况下,客户端会将此方法留空,这是一个糟糕的设计。为了避免这种情况,只需将Connection接口拆分为两个接口;SocketConnection具有socket()方法,HttpConnection具有http()方法。这两个接口将扩展保留有共同方法connect()Connection接口。

如果需要进一步的细节,那么您可以共享屏幕或使用纸和笔编写一个像这里呈现的示例。由于我们已经描述了前面的例子,让我们跳到关于违反 ISP 的部分。

违反 ISP

Connection接口定义了三种方法如下:

public interface Connection {
    public void socket();
    public void http();
    public void connect();
}

WwwPingConnection是一个通过 HTTP 对不同网站进行 ping 的类;因此,它需要http()方法,但不需要socket()方法。请注意虚拟的socket()实现-由于WwwPingConnection实现了Connection,它被强制提供socket()方法的实现:

public class WwwPingConnection implements Connection {
    private final String www;
    public WwwPingConnection(String www) {
        this.www = www;
    }
    @Override
    public void http() {
        System.out.println("Setup an HTTP connection to " 
            + www);
    }
    @Override
    public void connect() {
        System.out.println("Connect to " + www);
    }
    // this method breaks Interface Segregation Principle
    @Override
    public void socket() {
    }
}

在不需要的方法中具有空实现或抛出有意义的异常,比如socket(),是一个非常丑陋的解决方案。检查以下代码:

WwwPingConnection www 
    = new WwwPingConnection 'www.yahoo.com');
www.socket(); // we can call this method!
www.connect();

我们期望从这段代码中获得什么?一个什么都不做的工作代码,或者由于没有 HTTP 端点而导致connect()方法引发的异常?或者,我们可以从socket()中抛出类型为Socket is not supported!的异常。那么,它为什么在这里?!因此,现在是时候重构代码以遵循 ISP 了。

遵循 ISP

为了遵守 ISP,我们需要分隔Connection接口。由于任何客户端都需要connect()方法,我们将其留在这个接口中:

public interface Connection {
    public void connect();
}

http()socket()方法分布在扩展Connection接口的两个单独的接口中,如下所示:

public interface HttpConnection extends Connection {
    public void http();
}
public interface SocketConnection extends Connection {
    public void socket();
}

这次,WwwPingConnection类只能实现HttpConnection接口并使用http()方法:

public class WwwPingConnection implements HttpConnection {
    private final String www;
    public WwwPingConnection(String www) {
        this.www = www;
    }
    @Override
    public void http() {
        System.out.println("Setup an HTTP connection to "
            + www);
    }
    @Override
    public void connect() {
        System.out.println("Connect to " + www);
    } 
}

完成!现在,代码遵循 ISP。完整的应用程序命名为InterfaceSegregationPrinciple。接下来,让我们谈谈最后一个 SOLID 原则,依赖倒置原则。

什么是 D?

您应该在答案中封装的关键点如下:

  • D代表依赖倒置原则(DIP)。

  • D代表依赖于抽象,而不是具体实现

  • D支持使用抽象层来绑定具体模块,而不是依赖于其他具体模块。

  • D维持了具体模块的解耦。

现在,我们可以提出一个答案如下:

首先,SOLID 是 Robert C. Martin 提出的前五个面向对象设计(OOD)原则的首字母缩写,也被称为 Uncle Bob(可选短语)。D是 SOLID 原则中的最后一个原则,被称为依赖倒置原则(DIP)。这个原则代表依赖于抽象,而不是具体实现。这意味着我们应该依赖于抽象层来绑定具体模块,而不是依赖于具体模块。为了实现这一点,所有具体模块应该只暴露抽象。这样,具体模块允许扩展功能或在另一个具体模块中插入,同时保持具体模块的解耦。通常,高级具体模块和低级具体模块之间存在高耦合。

如果需要更多细节,你可以分享屏幕或使用纸和笔编写一个例子。

数据库 JDBC URL,PostgreSQLJdbcUrl,可以是一个低级模块,而连接到数据库的类可能代表一个高级模块,比如ConnectToDatabase#connect()

打破 DIP

如果我们向connect()方法传递PostgreSQLJdbcUrl类型的参数,那么我们就违反了 DIP。让我们来看看PostgreSQLJdbcUrlConnectToDatabase的代码:

public class PostgreSQLJdbcUrl {
    private final String dbName;
    public PostgreSQLJdbcUrl(String dbName) {
        this.dbName = dbName;
    }
    public String get() {
        return "jdbc:// ... " + this.dbName;
    }
}
public class ConnectToDatabase {
    public void connect(PostgreSQLJdbcUrl postgresql) {
        System.out.println("Connecting to "
            + postgresql.get());
    }
}

如果我们创建另一种类型的 JDBC URL(例如MySQLJdbcUrl),那么我们就不能使用之前的connect(PostgreSQLJdbcUrl postgreSQL)方法。因此,我们必须放弃对具体的依赖,创建对抽象的依赖。

遵循 DIP

抽象可以由一个接口表示,每种类型的 JDBC URL 都应该实现该接口:

public interface JdbcUrl {
    public String get();
}

接下来,PostgreSQLJdbcUrl实现了JdbcUrl以返回特定于 PostgreSQL 数据库的 JDBC URL:

public class PostgreSQLJdbcUrl implements JdbcUrl {
    private final String dbName;
    public PostgreSQLJdbcUrl(String dbName) {
        this.dbName = dbName;
    }
    @Override
    public String get() {
        return "jdbc:// ... " + this.dbName;
    }
}

以完全相同的方式,我们可以编写MySQLJdbcUrlOracleJdbcUrl等。最后,ConnectToDatabase#connect()方法依赖于JdbcUrl抽象,因此它可以连接到实现了这个抽象的任何 JDBC URL:

public class ConnectToDatabase {
    public void connect(JdbcUrl jdbcUrl) {
        System.out.println("Connecting to " + jdbcUrl.get());
    }
}

完成!完整的应用程序命名为DependencyInversionPrinciple

到目前为止,我们已经涵盖了 OOP 的基本概念和流行的 SOLID 原则。如果你计划申请一个包括应用程序设计和架构的 Java 职位,那么建议你看看通用责任分配软件原则(GRASP)([en.wikipedia.org/wiki/GRASP_(object-oriented_design](https://en.wikipedia.org/wiki/GRASP_(object-oriented_design))。这在面试中并不是一个常见的话题,但你永远不知道!

接下来,我们将扫描一系列结合了这些概念的热门问题。现在你已经熟悉了理解问题 | 提名关键点 | 回答的技巧,我将只突出回答中的关键点,而不是事先提取它们作为一个列表。

与 OOP、SOLID 和 GOF 设计模式相关的热门问题

在这一部分,我们将解决一些更难的问题,这些问题需要对 OOP 概念、SOLID 设计原则和四人帮(GOF)设计模式有真正的理解。请注意,本书不涵盖 GOF 设计模式,但有很多专门讨论这个主题的优秀书籍和视频。我建议你尝试 Aseem Jain 的《用 Java 学习设计模式》(www.packtpub.com/application-development/learn-design-patterns-java-video)。

面向对象编程(Java)中的方法重写是什么?

方法重写是一种面向对象的编程技术,允许开发人员编写两个具有相同名称和签名但具有不同行为的方法(非静态,非私有和非最终)。在继承运行时多态的情况下,可以使用方法重写。

在继承的情况下,我们在超类中有一个方法(称为被重写方法),并且我们在子类中重写它(称为重写方法)。在运行时多态中,我们在一个接口中有一个方法,实现这个接口的类正在重写这个方法。

Java 在运行时决定应该调用的实际方法,取决于对象的类型。方法重写支持灵活和可扩展的代码,换句话说,它支持以最小的代码更改添加新功能。

如果需要更多细节,那么可以列出管理方法重写的主要规则:

  • 方法的名称和签名(包括相同的返回类型或子类型)在超类和子类中,或在接口和实现中是相同的。

  • 我们不能在同一个类中重写一个方法(但我们可以在同一个类中重载它)。

  • 我们不能重写privatestaticfinal方法。

  • 重写方法不能降低被重写方法的可访问性,但相反是可能的。

  • 重写方法不能抛出比被重写方法抛出的检查异常更高的检查异常。

  • 始终为重写方法使用@Override注解。

Java 中重写方法的示例可在本书附带的代码中找到,名称为 MethodOverriding。

在面向对象编程(Java)中,什么是方法重载?

方法重载是一种面向对象的编程技术,允许开发人员编写两个具有相同名称但不同签名和不同功能的方法(静态或非静态)。通过不同的签名,我们理解为不同数量的参数,不同类型的参数和/或参数列表的不同顺序。返回类型不是方法签名的一部分。因此,当两个方法具有相同的签名但不同的返回类型时,这不是方法重载的有效情况。因此,这是一种强大的技术,允许我们编写具有相同名称但具有不同输入的方法(静态或非静态)。编译器将重载的方法调用绑定到实际方法;因此,在运行时不进行绑定。方法重载的一个著名例子是System.out.println()println()方法有几种重载的版本。

因此,有四条主要规则来管理方法重载:

  • 通过更改方法签名来实现重载。

  • 返回类型不是方法签名的一部分。

  • 我们可以重载privatestaticfinal方法。

  • 我们可以在同一个类中重载一个方法(但不能在同一个类中重写它)。

如果需要更多细节,可以尝试编写一个示例。Java 中重载方法的示例可在本书附带的代码中找到,名称为 MethodOverloading。

重要提示

除了前面提到的两个问题,您可能需要回答一些其他相关的问题,包括什么规则管理方法的重载和重写(见上文)?方法重载和重写的主要区别是什么(见上文)?我们可以重写静态或私有方法吗(简短的答案是不可以,见上文)?我们可以重写 final 方法吗(简短的答案是不可以,见上文)?我们可以重载静态方法吗(简短的答案是可以,见上文)?我们可以改变重写方法的参数列表吗(简短的答案是不可以,见上文)?因此,建议提取和准备这些问题的答案。所有所需的信息都可以在前面的部分找到。此外,注意诸如只有通过 final 修饰符才能防止重写方法这样的问题。这种措辞旨在混淆候选人,因为答案需要概述所涉及的概念。这里的答案可以表述为这是不正确的,因为我们也可以通过将其标记为私有或静态来防止重写方法。这样的方法不能被重写

接下来,让我们检查几个与重写和重载方法相关的其他问题。

在 Java 中,协变方法重写是什么?

协变方法重写是 Java 5 引入的一个不太知名的特性。通过这个特性,重写方法可以返回其实际返回类型的子类型。这意味着重写方法的客户端不需要对返回类型进行显式类型转换。例如,Java 的clone()方法返回Object。这意味着,当我们重写这个方法返回一个克隆时,我们得到一个Object,必须显式转换为我们需要的Object的实际子类。然而,如果我们利用 Java 5 的协变方法重写特性,那么重写的clone()方法可以直接返回所需的子类,而不是Object

几乎总是,这样的问题需要一个示例作为答案的一部分,因此让我们考虑实现Cloneable接口的Rectangle类。clone()方法可以返回Rectangle而不是Object,如下所示:

public class Rectangle implements Cloneable {
    ...  
    @Override
    protected Rectangle clone() 
            throws CloneNotSupportedException {
        Rectangle clone = (Rectangle) super.clone();
        return clone;
    }
}

调用clone()方法不需要显式转换:

Rectangle r = new Rectangle(4, 3);
Rectangle clone = r.clone();

完整的应用程序名为CovariantMethodOverriding。注意一些关于协变方法重写的间接问题。例如,可以这样表述:我们可以在重写时修改方法的返回类型为子类吗? 对于这个问题的答案与Java 中的协变方法重写是什么?相同,在这里讨论过。

重要提示

了解针对 Java 的一些不太知名特性的问题的答案可能是面试中的一个重要加分项。这向面试官表明您具有深入的知识水平,并且您对 Java 的发展了如指掌。如果您需要通过大量示例和最少理论来快速了解所有 JDK 8 到 JDK 13 的功能,那么您一定会喜欢我出版的名为Java 编程问题的书,由 Packt 出版(packtpub.com/au/programming/java-coding-problems)。

在重写和重载方法方面,主要的限制是什么?

首先,让我们讨论重写方法。如果我们谈论未经检查的异常,那么我们必须说在重写方法中使用它们没有限制。这样的方法可以抛出未经检查的异常,因此任何RuntimeException。另一方面,在检查异常的情况下,重写方法只能抛出被重写方法的检查异常或该检查异常的子类。换句话说,重写方法不能抛出比被重写方法抛出的检查异常范围更广的检查异常。例如,如果被重写的方法抛出SQLException,那么重写方法可以抛出子类,如BatchUpdateException,但不能抛出超类,如Exception

其次,让我们讨论重载方法。这样的方法不会施加任何限制。这意味着我们可以根据需要修改throw子句。

重要提示

注意那些以主要是什么...?你能列举某些...吗?你能提名...吗?你能强调...吗?等方式措辞的问题。通常,当问题包含主要,某些,提名强调等词时,面试官期望得到一个清晰简洁的答案,应该听起来像一个项目列表。回答这类问题的最佳实践是直接进入回答并将每个项目列举为一个简洁而有意义的陈述。在给出预期答案之前,不要犯常见错误,即着手讲述所涉及的概念的故事或论文。面试官希望看到你的综合和整理能力,并在检查你的知识水平的同时提取本质。

如果需要更多细节,那么你可以编写一个示例,就像这本书中捆绑的代码一样。考虑检查OverridingExceptionOverloadingException应用程序。现在,让我们继续看一些更多的问题。

如何从子类重写的方法中调用超类重写的方法?

我们可以通过 Java 的 super 关键字从子类重写的方法中调用超类重写的方法。例如,考虑一个包含方法foo()的超类A,以及一个名为BA子类。如果我们在子类B中重写foo()方法,并且我们从重写方法B#foo()中调用super.foo(),那么我们调用被重写的方法A#foo()

我们能重写或重载 main()方法吗?

我们必须记住main()方法是静态的。这意味着我们可以对其进行重载。但是,我们不能对其进行重写,因为静态方法在编译时解析,而我们可以重写的方法在运行时根据对象类型解析。

我们能将非静态方法重写为静态方法吗?

不。我们不能将非静态方法重写为静态方法。此外,反之亦然也不可能。两者都会导致编译错误。

重要提示

像前面提到的最后两个问题一样直截了当的问题,值得一个简短而简洁的答案。面试官触发这样的闪光灯问题来衡量你分析情况并做出决定的能力。主要是,答案是简短的,但你需要一些时间来说。这类问题并不具有很高的分数,但如果你不知道答案,可能会产生重大的负面影响。如果你知道答案,面试官可能会在心里说好吧,这本来就是一个容易的问题!但是,如果你不知道答案,他可能会说他错过了一个简单的问题!她/他的基础知识有严重缺陷

接下来,让我们看一些与其他面向对象编程概念相关的更多问题。

我们能在 Java 接口中有一个非抽象方法吗?

直到 Java 8,我们不能在 Java 接口中有非抽象方法。接口中的所有方法都是隐式公共和抽象的。然而,从 Java 8 开始,我们可以向接口添加新类型的方法。从实际角度来看,从 Java 8 开始,我们可以直接在接口中添加具体实现的方法。这可以通过使用 default static 关键字来实现。 default 关键字是在 Java 8 中引入的,用于在接口中包含称为 static 方法的方法,接口中的 static 方法与默认方法非常相似,唯一的区别是我们不能在实现这些接口的类中重写 static 方法。由于static方法不绑定到对象,因此可以通过使用接口名称加上点和方法名称来调用它们。此外,static方法可以在其他defaultstatic方法中调用。

如果需要更多细节,那么您可以尝试编写一个示例。考虑到我们有一个用于塑造蒸汽车辆的接口(这是一种旧的汽车类型,与旧代码完全相同):

public interface Vehicle {
    public void speedUp();
    public void slowDown();    
}

显然,通过以下SteamCar类已经建造了不同种类的蒸汽车:

public class SteamCar implements Vehicle {
    private String name;
    // constructor and getter omitted for brevity
    @Override
    public void speedUp() {
        System.out.println("Speed up the steam car ...");
    }
    @Override
    public void slowDown() {
        System.out.println("Slow down the steam car ...");
    }
}

由于SteamCar类实现了Vehicle接口,它重写了speedUp()slowDown()方法。过了一段时间,汽油车被发明出来,人们开始关心马力和燃油消耗。因此,我们的代码必须发展以支持汽油车。为了计算消耗水平,我们可以通过添加computeConsumption()默认方法来发展Vehicle接口,如下所示:

public interface Vehicle {
    public void speedUp();
    public void slowDown();
    default double computeConsumption(int fuel, 
            int distance, int horsePower) {        
        // simulate the computation 
        return Math.random() * 10d;
    }        
}

发展Vehicle接口不会破坏SteamCar的兼容性。此外,电动汽车已经被发明。计算电动汽车的消耗与汽油汽车的情况不同,但公式依赖于相同的术语:燃料、距离和马力。这意味着ElectricCar将重写computeConsumption(),如下所示:

public class ElectricCar implements Vehicle {
    private String name;
    private int horsePower;
    // constructor and getters omitted for brevity
    @Override
    public void speedUp() {
        System.out.println("Speed up the electric car ...");
    }
    @Override
    public void slowDown() {
        System.out.println("Slow down the electric car ...");
    }
    @Override
    public double computeConsumption(int fuel, 
            int distance, int horsePower) {
        // simulate the computation
        return Math.random()*60d / Math.pow(Math.random(), 3);
    }     
}

因此,我们可以重写default方法,或者我们可以使用隐式实现。最后,我们必须为我们的接口添加描述,因为现在它服务于蒸汽、汽油和电动汽车。我们可以通过为Vehicle添加一个名为description()static方法来实现这一点,如下所示:

public interface Vehicle {
    public void speedUp();
    public void slowDown();
    default double computeConsumption(int fuel, 
        int distance, int horsePower) {        
        return Math.random() * 10d;
    }
    static void description() {
        System.out.println("This interface control
            steam, petrol and electric cars");
    }
}

这个static方法不绑定到任何类型的汽车,可以直接通过Vehicle.description()调用。完整的代码名为Java8DefaultStaticMethods

接下来,让我们继续其他问题。到目前为止,您应该对“理解问题”|“提名关键点”|“回答”技术非常熟悉,所以我将停止突出显示关键点。从现在开始,找到它们就是你的工作了。

接口和抽象类之间的主要区别是什么?

在 Java 8 接口和抽象类之间的差异中,我们可以提到抽象类可以有构造函数,而接口不支持构造函数。因此,抽象类可以有状态,而接口不能有状态。此外,接口仍然是完全抽象的第一公民,其主要目的是被实现,而抽象类是为了部分抽象。接口仍然被设计为针对完全抽象的事物,它们本身不做任何事情,但是指定了如何在实现中工作的合同。默认方法代表了一种方法,可以在不影响客户端代码和不改变状态的情况下向接口添加附加功能。它们不应该用于其他目的。换句话说,另一个差异在于,拥有没有抽象方法的抽象类是完全可以的,但是只有默认方法的接口是一种反模式。这意味着我们已经创建了接口作为实用类的替代品。这样,我们就打败了接口的主要目的,即被实现。

重要说明

当你不得不列举两个概念之间的许多差异或相似之处时,注意限制你的答案在问题确定的坐标内。例如,在前面的问题中,不要说接口支持多重继承而抽象类不支持这一点。这是接口和类之间的一般变化,而不是特别是 Java 8 接口和抽象类之间的变化。

抽象类和接口之间的主要区别是什么?

直到 Java 8,抽象类和接口的主要区别在于抽象类可以包含非抽象方法,而接口不能包含这样的方法。从 Java 8 开始,主要区别在于抽象类可以有构造函数和状态,而接口两者都不能有。

可以有一个没有抽象方法的抽象类吗?

是的,我们可以。通过向类添加abstract关键字,它变成了抽象类。它不能被实例化,但可以有构造函数和只有非抽象方法。

我们可以同时拥有一个既是抽象又是最终的类吗?

最终类不能被子类化或继承。抽象类意味着要被扩展才能使用。因此,最终和抽象是相反的概念。这意味着它们不能同时应用于同一个类。编译器会报错。

多态、重写和重载之间有什么区别?

在这个问题的背景下,重载技术被称为编译时多态,而重写技术被称为运行时多态。重载涉及使用静态(或早期)绑定,而重写使用动态(或晚期)绑定。

接下来的两个问题构成了这个问题的附加部分,但它们也可以作为独立的问题来表述。

什么是绑定操作?

绑定操作确定由于代码行中的引用而调用的方法(或变量)。换句话说,将方法调用与方法体关联的过程称为绑定操作。一些引用在编译时解析,而其他引用在运行时解析。在运行时解析的引用取决于对象的类型。在编译时解析的引用称为静态绑定操作,而在运行时解析的引用称为动态绑定操作。

静态绑定和动态绑定之间的主要区别是什么?

首先,静态绑定发生在编译时,而动态绑定发生在运行时。要考虑的第二件事是,私有、静态和最终成员(方法和变量)使用静态绑定,而虚方法根据对象类型在运行时进行绑定。换句话说,静态绑定是通过Type(Java 中的类)信息实现的,而动态绑定是通过Object实现的,这意味着依赖静态绑定的方法与对象无关,而是在Type(Java 中的类)上调用的,而依赖动态绑定的方法与Object相关。依赖静态绑定的方法的执行速度比依赖动态绑定的方法的执行速度稍快。静态和动态绑定也用于多态。静态绑定用于编译时多态(重载方法),而动态绑定用于运行时多态(重写方法)。静态绑定在编译时增加了性能开销,而动态绑定在运行时增加了性能开销,这意味着静态绑定更可取。

在 Java 中什么是方法隐藏?

方法隐藏是特定于静态方法的。更确切地说,如果我们在超类和子类中声明具有相同签名和名称的两个静态方法,那么它们将互相隐藏。从超类调用方法将调用超类的静态方法,从子类调用相同的方法将调用子类的静态方法。隐藏与覆盖不同,因为静态方法不能是多态的。

如果需要更多细节,你可以写一个例子。考虑Vehicle超类具有move()静态方法:

public class Vehicle {
    public static void move() {
        System.out.println("Moving a vehicle");
    }
}

现在,考虑Car子类具有相同的静态方法:

public class Car extends Vehicle {
    // this method hides Vehicle#move()
    public static void move() {
        System.out.println("Moving a car");
    }
}

现在,让我们从main()方法中调用这两个静态方法:

public static void main(String[] args) {
    Vehicle.move(); // call Vehicle#move()
    Car.move();     // call Car#move()
}

输出显示这两个静态方法互相隐藏:

Moving a vehicle
Moving a car

注意我们通过类名调用静态方法。在实例上调用静态方法是非常糟糕的做法,所以在面试中要避免这样做!

我们可以在 Java 中编写虚方法吗?

是的,我们可以!实际上,在 Java 中,所有非静态方法默认都是虚方法。我们可以通过使用private和/或final关键字标记来编写非虚方法。换句话说,可以继承以实现多态行为的方法是虚方法。或者,如果我们颠倒这个说法的逻辑,那些不能被继承(标记为private)和不能被覆盖(标记为final)的方法是非虚方法。

多态和抽象之间有什么区别?

抽象和多态代表两个相互依存的基本面向对象的概念。抽象允许开发人员设计可重用和可定制的通用解决方案,而多态允许开发人员推迟在运行时选择应该执行的代码。虽然抽象是通过接口和抽象类实现的,多态依赖于覆盖和重载技术。

你认为重载是实现多态的一种方法吗?

这是一个有争议的话题。有些人不认为重载是多态;因此,他们不接受编译时多态的概念。这些声音认为,唯一的覆盖方法才是真正的多态。这种说法背后的论点是,只有覆盖才允许代码根据运行时条件而表现出不同的行为。换句话说,表现多态行为是方法覆盖的特权。我认为只要我们理解重载和覆盖的前提条件,我们也就理解了这两种变体如何维持多态行为。

重要提示

处理有争议的话题的问题是微妙且难以正确处理的。因此,最好直接跳入答案,陈述这是一个有争议的话题。当然,面试官也对听到你的观点感兴趣,但他会很高兴看到你了解事情的两面。作为一个经验法则,尽量客观地回答问题,不要以激进的方式或者缺乏论据的方式处理问题的一面。有争议的事情毕竟还是有争议的,这不是揭开它们的神秘面纱的合适时间和地点。

好的,现在让我们继续一些基于 SOLID 原则和著名且不可或缺的四人帮(GOF)设计模式的问题。请注意,本书不涵盖 GOF 设计模式,但有很多专门讨论这个话题的优秀书籍和视频。我建议你尝试Aseem JainLearn Design Patterns with Javawww.packtpub.com/application-development/learn-design-patterns-java-video))。

哪个面向对象的概念服务于装饰者设计模式?

服务装饰者设计模式的面向对象编程概念是组合。通过这个面向对象编程概念,装饰者设计模式在不修改原始类的情况下提供新功能。

单例设计模式应该在什么时候使用?

单例设计模式似乎是在我们只需要一个类的应用级(全局)实例时的正确选择。然而,应该谨慎使用单例,因为它增加了类之间的耦合,并且在开发、测试和调试过程中可能成为瓶颈。正如著名的《Effective Java》所指出的,使用 Java 枚举是实现这种模式的最佳方式。在全局配置(例如日志记录器、java.lang.Runtime)、硬件访问、数据库连接等方面,依赖单例模式是一种常见情况。

重要提示

每当可以引用或提及著名参考资料时,请这样做。

策略和状态设计模式之间有什么区别?

状态设计模式旨在根据状态执行某些操作(在不更改类的情况下,在不同状态下展示某些行为)。另一方面,策略设计模式旨在用于在不修改使用它的代码的情况下在一系列算法之间进行切换(客户端通过组合和运行时委托可互换地使用算法)。此外,在状态中,我们有清晰的状态转换顺序(流程是通过将每个状态链接到另一个状态来创建的),而在策略中,客户端可以以任何顺序选择它想要的算法。例如,状态模式可以定义发送包裹给客户的状态

包裹从有序状态开始,然后继续到已交付状态,依此类推,直到通过每个状态并在客户端接收包裹时达到最终状态。另一方面,策略模式定义了完成每个状态的不同策略(例如,我们可能有不同的交付包裹策略)。

代理和装饰者模式之间有什么区别?

代理设计模式对于提供对某物的访问控制网关非常有用。通常,该模式创建代理对象,代替真实对象。对真实对象的每个请求都必须通过代理对象,代理对象决定如何何时将其转发给真实对象。装饰者设计模式从不创建对象,它只是在运行时用新功能装饰现有对象。虽然链接代理不是一个可取的做法,但以一定顺序链接装饰者可以以正确的方式利用这种模式。例如,代理模式可以表示互联网的代理服务器,而装饰者模式可以用于用不同的自定义设置装饰代理服务器。

外观和装饰者模式之间有什么区别?

装饰者设计模式旨在为对象添加新功能(换句话说,装饰对象),而外观设计模式根本不添加新功能。它只是外观现有功能(隐藏系统的复杂性),并通过向客户端暴露的“友好界面”在幕后调用它们。外观模式可以暴露一个简单的接口,调用各个组件来完成复杂的任务。例如,装饰者模式可以用来通过用发动机、变速箱等装饰底盘来建造汽车,而外观模式可以通过暴露一个简单的接口来隐藏建造汽车的复杂性,以便命令了解建造过程细节的工业机器人。

模板方法和策略模式之间的关键区别是什么?

模板方法和策略模式将特定领域的算法集合封装成对象,但它们的实现方式并不相同。关键区别在于策略模式旨在根据需求在运行时在不同策略(算法)之间做出决定,而模板方法模式旨在遵循算法的固定骨架(预定义的步骤序列)实现。一些步骤是固定的,而其他步骤可以根据不同的用途进行修改。例如,策略模式可以在不同的支付策略之间做出决定(例如信用卡或 PayPal),而模板方法可以描述使用特定策略进行支付的预定义步骤序列(例如,通过 PayPal 进行支付需要固定的步骤序列)。

生成器和工厂模式之间的关键区别是什么?

工厂模式在单个方法调用中创建对象。我们必须在此调用中传递所有必要的参数,工厂将返回对象(通常通过调用构造函数)。另一方面,生成器模式旨在通过一系列 setter 方法构建复杂对象,允许我们塑造任何组合的参数。在链的末尾,生成器方法公开了一个build()方法,表示参数列表已设置,现在是构建对象的时候了。换句话说,工厂充当构造函数的包装器,而生成器更加精细,充当您可能想要传递到构造函数的所有可能参数的包装器。通过生成器,我们避免了望远镜构造函数用于公开所有可能的参数组合。例如,回想一下Book对象。一本书由一些固定参数来描述,例如作者、标题、ISBN 和格式。在创建书籍时,您很可能不会在这些参数的数量上纠结,因此工厂模式将是适合创建书籍的选择。但是Server对象呢?嗯,服务器是一个具有大量可选参数的复杂对象,因此生成器模式在这里更加合适,甚至是工厂在内部依赖于生成器的这些模式的组合。

适配器和桥接模式之间的关键区别是什么?

适配器模式致力于提供现有代码(例如第三方代码)与新系统或接口之间的兼容性。另一方面,桥接模式是提前实现的,旨在将抽象与实现解耦,以避免大量的类。因此,适配器致力于在设计后提供事物之间的兼容性(可以想象为A 来自 After),而桥接是提前构建的,以使抽象和实现可以独立变化(可以想象为B 来自 Before)。适配器充当ReadJsonRequestReadXmlRequest,它们能够从多个设备读取,例如D1D2D3D1D2只产生 JSON 请求,而D3只产生 XML 请求。通过适配器,我们可以在 JSON 和 XML 之间进行转换,这意味着这两个类可以与所有三个设备进行通信。另一方面,通过桥接模式,我们可以避免最终产生许多类,例如ReadXMLRequestD1ReadXMLRequestD2ReadXMLRequestD3ReadJsonRequestD1ReadJsonRequestD2ReadJsonRequestD3

我们可以继续比较设计模式,直到完成所有可能的组合。最后几个问题涵盖了类型设计模式 1 与设计模式 2的最受欢迎的问题。强烈建议您挑战自己,尝试识别两种或更多给定设计模式之间的相似之处和不同之处。大多数情况下,这些问题使用来自同一类别的两种设计模式(例如,两种结构或两种创建模式),但它们也可以来自不同的类别。在这种情况下,这是面试官期望听到的第一句话。因此,在这种情况下,首先说出每个涉及的设计模式属于哪个类别。

请注意,我们跳过了所有简单问题,比如什么是接口?什么是抽象类?等等。通常,这类问题是要避免的,因为它们并不能说明您的理解水平,更多的是背诵一些定义。面试官可以问抽象类和接口的主要区别是什么?,他可以从您的回答中推断出您是否知道接口和抽象类是什么。始终要准备好举例。无法举例说明严重缺乏对事物本质的理解。

拥有 OOP 知识只是问题的一半。另一半是具有将这些知识转化为设计应用程序的愿景和灵活性。这就是我们将在接下来的 10 个示例中做的事情。请记住,我们专注于设计,而不是实现。

编码挑战

接下来,我们将解决关于面向对象编程的几个编码挑战。对于每个问题,我们将遵循第五章**中的图 5.2如何处理编码挑战。主要是,我们将首先向面试官提出一个问题,比如设计约束是什么?通常,围绕 OOD 的编码挑战是以一种一般的方式由面试官表达的。这是故意这样做的,以便让您询问有关设计约束的细节。

一旦我们清楚地了解了约束条件,我们可以尝试一个示例(可以是草图、逐步运行时可视化、项目列表等)。然后,我们找出算法/解决方案,最后,我们提供设计骨架。

示例 1:自动唱机

亚马逊谷歌

问题:设计自动唱机音乐机的主要类。

要问的问题:自动唱机播放什么-CD、MP3?我应该设计什么-自动唱机建造过程,它是如何工作的,还是其他什么?是免费的自动唱机还是需要钱?

面试官:免费的自动唱机只播放 CD 吗?设计它的主要功能,因此设计它是如何工作的。

解决方案:为了理解我们的设计应该涉及哪些类,我们可以尝试想象一台自动唱机并确定其主要部分和功能。沿着这里的线条画一个图表也有助于面试官了解您的思维方式。我建议您始终采取以书面形式将问题可视化的方法-草图是一个完美的开始:

图 6.1 – 自动唱机

图 6.1 – 自动唱机

因此,我们可以确定自动唱机的两个主要部分:CD 播放器(或特定的自动唱机播放机制)和用户命令的接口。CD 播放器能够管理播放列表并播放这些歌曲。我们可以将命令接口想象为一个由自动唱机实现的 Java 接口,如下面的代码所示。除了以下代码,您还可以使用这里的 UML 图:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/blob/master/Chapter06/Jukebox/JukeboxUML.png

public interface Selector {
    public void nextSongBtn();
    public void prevSongBtn();
    public void addSongToPlaylistBtn(Song song);
    public void removeSongFromPlaylistBtn(Song song);
    public void shuffleBtn();
}
public class Jukebox implements Selector {
    private final CDPlayer cdPlayer;
    public Jukebox(CDPlayer cdPlayer) {
        this.cdPlayer = cdPlayer;        
    }            
    @Override
    public void nextSongBtn() {...}
    // rest of Selector methods omitted for brevity
}

CDPlayer是点唱机的核心。通过Selector,我们控制CDPlayer的行为。CDPlayer必须能够访问可用的 CD 和播放列表:

public class CDPlayer {
    private CD cd;
    private final Set<CD> cds;
    private final Playlist playlist;
    public CDPlayer(Playlist playlist, Set<CD> cds) {
        this.playlist = playlist;
        this.cds = cds;
    }                
    protected void playNextSong() {...}
    protected void playPrevSong() {...}   
    protected void addCD(CD cd) {...}
    protected void removeCD(CD cd) {...}
    // getters omitted for brevity
}

接下来,Playlist管理一个Song列表:

public class Playlist {
    private Song song;
    private final List<Song> songs; // or Queue
    public Playlist(List<Song> songs) {
        this.songs = songs;
    }   
    public Playlist(Song song, List<Song> songs) {
        this.song = song;
        this.songs = songs;
    }        
    protected void addSong(Song song) {...}
    protected void removeSong(Song song) {...}
    protected void shuffle() {...}    
    protected Song getNextSong() {...};
    protected Song getPrevSong() {...};
    // setters and getters omitted for brevity
}

UserCDSong类暂时被跳过,但你可以在名为点唱机的完整应用程序中找到它们。这种问题可以以多种方式实现,所以也可以尝试你自己的设计。

示例 2:自动售货机

亚马逊谷歌Adobe

问题:设计支持典型自动售货机功能实现的主要类。

要问的问题:这是一个带有不同类型硬币和物品的自动售货机吗?它暴露了功能,比如检查物品价格、购买物品、退款和重置吗?

面试官:是的,确实!对于硬币,你可以考虑一分硬币、五分硬币、一角硬币和一美元硬币。

解决方案:为了理解我们的设计应该涉及哪些类,我们可以尝试勾画一个自动售货机。有各种各样的自动售货机类型。简单地勾画一个你知道的(比如下图中的那种):

图 6.2 – 自动售货机

图 6.2 – 自动售货机

首先,我们立即注意到物品和硬币是 Java 枚举的良好候选。我们有四种硬币和几种物品,所以我们可以编写两个 Java 枚举如下。除了以下代码,你还可以使用这里的 UML 图表:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/blob/master/Chapter06/VendingMachine/VendingMachineUML.png

public enum Coin {
    PENNY(1), NICKEL(5), DIME(10), QUARTER(25);
    ...
}
public enum Item {
    SKITTLES("Skittles", 15), TWIX("Twix", 35) ...
    ...
}

自动售货机需要一个内部库存来跟踪物品和硬币的状态。我们可以将其通用地塑造如下:

public final class Inventory<T> {
    private Map<T, Integer> inventory = new HashMap<>();
    protected int getQuantity(T item) {...}
    protected boolean hasItem(T item) {...}
    protected void clear() {...}    
    protected void add(T item) {...}
    protected void put(T item, int quantity) {...}
    protected void deduct(T item) {...}
}

接下来,我们可以关注客户用来与自动售货机交互的按钮。正如你在前面的例子中看到的,将这些按钮提取到一个接口中是常见做法,如下所示:

public interface Selector {
    public int checkPriceBtn(Item item);
    public void insertCoinBtn(Coin coin);
    public Map<Item, List<Coin>> buyBtn();
    public List<Coin> refundBtn();
    public void resetBtn();    
}

最后,自动售货机可以被塑造成实现Selector接口并提供一堆用于完成内部任务的私有方法:

public class VendingMachine implements Selector {
    private final Inventory<Coin> coinInventory
        = new Inventory<>();
    private final Inventory<Item> itemInventory
        = new Inventory<>();
  private int totalSales;
    private int currentBalance;
    private Item currentItem;
    public VendingMachine() {
        initMachine();
    }   
    private void initMachine() {
        System.out.println("Initializing the
            vending machine with coins and items ...");
    }
    // override Selector methods omitted for brevity
}

完整的应用程序名为自动售货机。通过遵循前面提到的两个例子,你可以尝试设计一个 ATM、洗衣机和类似的东西。

示例 3:一副卡牌

亚马逊谷歌Adobe微软

问题:设计一个通用卡牌组的主要类。

要问的问题:由于卡可以是几乎任何东西,你能定义通用吗?

面试官:一张卡由一个符号(花色)和一个点数来描述。例如,想象一副标准的 52 张卡牌组。

解决方案:为了理解我们的设计应该涉及哪些类,我们可以快速勾画一个标准 52 张卡牌组的卡牌和一副卡牌,如图 6.3 所示:

图 6.3 – 一副卡牌

图 6.3 – 一副卡牌

由于每张卡都有花色和点数,我们将需要一个封装这些字段的类。让我们称这个类为StandardCardStandardCard的花色包括黑桃,红心,方块梅花,因此这个花色是 Java 枚举的一个很好的候选。StandardCard的点数可以在 1 到 13 之间。

一张卡可以独立存在,也可以是一副卡牌的一部分。多张卡组成一副卡牌(例如,一副标准的 52 张卡牌组形成一副卡牌)。一副卡牌中的卡的数量通常是可能的花色和点数的笛卡尔积(例如,4 种花色 x 13 个点数 = 52 张卡)。因此,52 个StandardCard对象形成了StandardPack

最后,一副牌应该是一个能够执行一些与这个“标准包”相关的操作的类。例如,一副牌可以洗牌,可以发牌或发一张牌,等等。这意味着还需要一个Deck类。

到目前为止,我们已经确定了一个 Java 的enumStandardCardStandardPackDeck类。如果我们添加了所需的抽象层,以避免这些具体层之间的高耦合,那么我们就得到了以下的实现。除了以下代码,您还可以使用这里的 UML 图:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/blob/master/Chapter06/DeckOfCards/DeckOfCardsUML.png

  • 对于标准牌实现:
public enum StandardSuit {   
    SPADES, HEARTS, DIAMONDS, CLUBS;
}
public abstract class Card {
    private final Enum suit;
    private final int value;
    private boolean available = Boolean.TRUE;
    public Card(Enum suit, int value) {
        this.suit = suit;
        this.value = value;
    }
    // code omitted for brevity
}
public class StandardCard extends Card {
    private static final int MIN_VALUE = 1;
    private static final int MAX_VALUE = 13;
    public StandardCard(StandardSuit suit, int value) {
        super(suit, value);
    }
    // code omitted for brevity
}
  • 标准牌组实现提供以下代码:
public abstract class Pack<T extends Card> {
    private List<T> cards;
    protected abstract List<T> build();
  public int packSize() {
        return cards.size();
    }
    public List<T> getCards() {
        return new ArrayList<>(cards);
    }
    protected void setCards(List<T> cards) {
        this.cards = cards;
    }
}
public final class StandardPack extends Pack {
    public StandardPack() {
        super.setCards(build());
    }
    @Override
    protected List<StandardCard> build() {
        List<StandardCard> cards = new ArrayList<>();
        // code omitted for brevity        
        return cards;
    }
}
  • 牌组实现提供以下内容:
public class Deck<T extends Card> implements Iterable<T> {
    private final List<T> cards;
    public Deck(Pack pack) {
        this.cards = pack.getCards();
    }
    public void shuffle() {...}
    public List<T> dealHand(int numberOfCards) {...}
    public T dealCard() {...}
    public int remainingCards() {...}
    public void removeCards(List<T> cards) {...}
    @Override
    public Iterator<T> iterator() {...}
}

代码的演示可以快速写成如下:

// create a single classical card
Card sevenHeart = new StandardCard(StandardSuit.HEARTS, 7);       
// create a complete deck of standards cards      
Pack cp = new StandardPack();                   
Deck deck = new Deck(cp);
System.out.println("Remaining cards: " 
    + deck.remainingCards());

此外,您可以通过扩展CardPack类轻松添加更多类型的卡。完整的代码名为DeckOfCards

示例 4:停车场

亚马逊谷歌Adobe微软

问题:设计停车场的主要类。

需要询问的问题:这是单层停车场还是多层停车场?所有停车位是否相同?我们应该停放什么类型的车辆?这是免费停车吗?我们使用停车票吗?

面试官:这是一个同步自动多层免费停车场。所有停车位大小相同,但我们期望有汽车(需要 1 个停车位)、货车(需要 2 个停车位)和卡车(需要 5 个停车位)。其他类型的车辆应该可以在不修改代码的情况下添加。系统会释放一个停车票,以便以后用于取车。但是,如果司机只提供车辆信息(假设丢失了停车票),系统仍然应该能够工作并在停车场中找到车辆并将其取出。

解决方案:为了了解我们的设计应该涉及哪些类,我们可以快速勾画一个停车场,以识别主要的参与者和行为,如图 6.4 所示:

图 6.4 - 停车场

图 6.4 - 停车场

该图表显示了两个主要的参与者:停车场和自动停车系统。

首先,让我们专注于停车场。停车场的主要目的是停放车辆;因此,我们需要确定可接受的车辆(汽车、货车和卡车)。这看起来像是一个抽象类(Vehicle)和三个子类(CarVanTruck)的典型情况。但这并不是真的!司机提供有关他们的车辆的信息。他们并没有真正将车辆(对象)推入停车系统,因此我们的系统不需要为汽车、货车、卡车等专门的对象。从停车场的角度来看。它需要车辆牌照和停车所需的空闲车位。它不关心货车或卡车的特征。因此,我们可以将Vehicle塑造如下。除了以下代码,您还可以使用这里的 UML 图:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/blob/master/Chapter06/ParkingLot/ParkingLotUML.png

public enum VehicleType {
    CAR(1), VAN(2), TRUCK(5);
}
public class Vehicle {
    private final String licensePlate;
    private final int spotsNeeded;
    private final VehicleType type;
    public Vehicle(String licensePlate, 
            int spotsNeeded, VehicleType type) {
        this.licensePlate = licensePlate;
        this.spotsNeeded = spotsNeeded;
        this.type = type;
    }
    // getters omitted for brevity    
    // equals() and hashCode() omitted for brevity
}

接下来,我们需要设计停车场。主要是,停车场有几层(或级别),每层都有停车位。除其他外,停车场应该暴露出停车/取车的方法。这些方法将把停车/取车的任务委托给每一层(或特定的一层),直到成功或没有要扫描的层为止。

public class ParkingLot {
    private String name;
    private Map<String, ParkingFloor> floors;
    public ParkingLot(String name) {
        this.name = name;
    }
    public ParkingLot(String name, 
            Map<String, ParkingFloor> floors) {
        this.name = name;
        this.floors = floors;
    }    
    // delegate to the proper ParkingFloor
    public ParkingTicket parkVehicle(Vehicle vehicle) {...}
    // we have to find the vehicle by looping floors  
    public boolean unparkVehicle(Vehicle vehicle) {...} 
    // we have the ticket, so we have the needed information
    public boolean unparkVehicle
        ParkingTicket parkingTicket) {...} 
    public boolean isFull() {...}
    protected boolean isFull(VehicleType type) {...}
    // getters and setters omitted for brevity
}

停车楼控制某一楼层的停车/取车过程。它有自己的停车票注册表,并能够管理其停车位。主要上,每个停车楼都充当独立的停车场。这样,我们可以关闭一个完整的楼层,而其余楼层不受影响:

public class ParkingFloor{
    private final String name;
    private final int totalSpots;
    private final Map<String, ParkingSpot>
        parkingSpots = new LinkedHashMap<>();
    // here, I use a Set, but you may want to hold the parking 
    // tickets in a certain order to optimize search
    private final Set<ParkingTicket>
        parkingTickets = new HashSet<>();
private int totalFreeSpots;
    public ParkingFloor(String name, int totalSpots) {
        this.name = name;
        this.totalSpots = totalSpots;
        initialize(); // create the parking spots
    }
    protected ParkingTicket parkVehicle(Vehicle vehicle) {...}     
    //we have to find the vehicle by looping the parking spots  
    protected boolean unparkVehicle(Vehicle vehicle) {...} 
    // we have the ticket, so we have the needed information
    protected boolean unparkVehicle(
        ParkingTicket parkingTicket) {...} 
    protected boolean isFull(VehicleType type) {...}
    protected int countFreeSpots(
        VehicleType vehicleType) {...}
    // getters omitted for brevity
    private List<ParkingSpot> findSpotsToFitVehicle(
        Vehicle vehicle) {...}    
    private void assignVehicleToParkingSpots(
        List<ParkingSpot> spots, Vehicle vehicle) {...}    
    private ParkingTicket releaseParkingTicket(
        Vehicle vehicle) {...}    
    private ParkingTicket findParkingTicket(
        Vehicle vehicle) {...}    
    private void registerParkingTicket(
        ParkingTicket parkingTicket) {...}           
    private boolean unregisterParkingTicket(
        ParkingTicket parkingTicket) {...}                    
    private void initialize() {...}
}

最后,停车位是一个对象,它保存有关其名称(标签或编号)、可用性(是否空闲)和车辆(是否停放在该位置的车辆)的信息。它还具有分配/移除车辆到/从此位置的方法:

public class ParkingSpot {
    private boolean free = true;
    private Vehicle vehicle;
    private final String label;
    private final ParkingFloor parkingFloor;
    protected ParkingSpot(ParkingFloor parkingFloor, 
            String label) {
        this.parkingFloor = parkingFloor;
        this.label = label;
    }
    protected boolean assignVehicle(Vehicle vehicle) {...}
    protected boolean removeVehicle() {...}
    // getters omitted for brevity
}

此刻,我们已经拥有了停车场的所有主要类。接下来,我们将专注于自动停车系统。这可以被塑造为一个作为停车场调度员的单一类:

public class ParkingSystem implements Parking {

    private final String id;
    private final ParkingLot parkingLot;
    public ParkingSystem(String id, ParkingLot parkingLot) {
        this.id = id;
 this.parkingLot = parkingLot;
    }
    @Override
    public ParkingTicket parkVehicleBtn(
        String licensePlate, VehicleType type) {...}
    @Override
    public boolean unparkVehicleBtn(
        String licensePlate, VehicleType type) {...}
    @Override
    public boolean unparkVehicleBtn(
        ParkingTicket parkingTicket) {...}     
    // getters omitted for brevity
}

包含部分实现的完整应用程序被命名为ParkingLot

示例 5:在线阅读系统

问题:设计在线阅读系统的主要类。

需要询问的问题:需要哪些功能?可以同时阅读多少本书?

面试官:系统应该能够管理读者和书籍。您的代码应该能够添加/移除读者/书籍并显示读者/书籍。系统一次只能为一个读者和一本书提供服务。

解决方案:为了理解我们的设计应该涉及哪些类,我们可以考虑草绘一些东西,如图 6.5 所示:

图 6.5 – 一个在线阅读系统

图 6.5 – 一个在线阅读系统

为了管理读者和书籍,我们需要拥有这样的对象。这是一个小而简单的部分,在面试中从这样的部分开始对打破僵局和适应手头的问题非常有帮助。当我们在面试中设计对象时,没有必要提出一个对象的完整版本。例如,一个读者有姓名和电子邮件,一本书有作者、标题和 ISBN 就足够了。让我们在下面的代码中看到它们。除了下面的代码,您还可以使用这里的 UML 图:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/blob/master/Chapter06/OnlineReaderSystem/OnlineReaderSystemUML.png

public class Reader {
    private String name;
    private String email;
    // constructor omitted for brevity
    // getters, equals() and hashCode() omitted for brevity
}
public class Book {
    private final String author;
    private final String title;
    private final String isbn;
    // constructor omitted for brevity
    public String fetchPage(int pageNr) {...}
    // getters, equals() and hashCode() omitted for brevity
}

接下来,如果我们考虑到书籍通常由图书馆管理,那么我们可以将添加、查找和移除书籍等多个功能封装在一个类中,如下所示:

public class Library {
    private final Map<String, Book> books = new HashMap<>();
    protected void addBook(Book book) {
       books.putIfAbsent(book.getIsbn(), book);
    }
    protected boolean remove(Book book) {
       return books.remove(book.getIsbn(), book);
    }
    protected Book find(String isbn) {
       return books.get(isbn);
    }
}

读者可以由一个名为ReaderManager的类来管理。您可以在完整的应用程序中找到这个类。为了阅读一本书,我们需要一个显示器。Displayer应该显示读者和书籍的详细信息,并且应该能够浏览书籍的页面:

public class Displayer {
    private Book book;
    private Reader reader;
    private String page;
    private int pageNumber;
    protected void displayReader(Reader reader) {
        this.reader = reader;
        refreshReader();
    }
    protected void displayBook(Book book) {
        this.book = book;
        refreshBook();
    }
    protected void nextPage() {
        page = book.fetchPage(++pageNumber);
        refreshPage();
    }
    protected void previousPage() {
        page = book.fetchPage(--pageNumber);        
        refreshPage();
    }        
    private void refreshReader() {...}    
    private void refreshBook() {...}    
    private void refreshPage() {...}
}

最后,我们所要做的就是将LibraryReaderManagerDisplayer封装在OnlineReaderSystem类中。这个类在这里列出:

public class OnlineReaderSystem {
    private final Displayer displayer;
    private final Library library;
    private final ReaderManager readerManager;
    private Reader reader;
    private Book book;
    public OnlineReaderSystem() {
        displayer = new Displayer();
        library = new Library();
        readerManager = new ReaderManager();
    }
    public void displayReader(Reader reader) {
        this.reader = reader;
        displayer.displayReader(reader);
    }
    public void displayReader(String email) {
        this.reader = readerManager.find(email);
        if (this.reader != null) {
            displayer.displayReader(reader);
        }
    }
    public void displayBook(Book book) {
        this.book = book;
        displayer.displayBook(book);
    }
    public void displayBook(String isbn) {
        this.book = library.find(isbn);
        if (this.book != null) {
            displayer.displayBook(book);
        }
    }
    public void nextPage() {
        displayer.nextPage();
    }
    public void previousPage() {
        displayer.previousPage();
    }
    public void addBook(Book book) {
        library.addBook(book);
    }
    public boolean deleteBook(Book book) {
        if (!book.equals(this.book)) {
            return library.remove(book);
        }
        return false;
    }
    public void addReader(Reader reader) {
        readerManager.addReader(reader);
    }
    public boolean deleteReader(Reader reader) {
        if (!reader.equals(this.reader)) {
            return readerManager.remove(reader);
        }
        return false;
    }
    public Reader getReader() {
        return reader;
    }
    public Book getBook() {
        return book;
    }
}

完整的应用程序名为OnlineReaderSystem

示例 6:哈希表

亚马逊谷歌Adobe微软

问题:设计一个哈希表(这是面试中非常流行的问题)。

需要询问的问题:需要哪些功能?应该应用什么技术来解决索引冲突?键值对的数据类型是什么?

add()get()操作。为了解决索引冲突,我建议您使用链接技术。键值对应该是通用的。

哈希表的简要概述:哈希表是一种存储键值对的数据结构。通常,数组保存表中所有键值条目,该数组的大小设置为容纳预期数据量。每个键值的键通过哈希函数(或多个哈希函数)传递,输出哈希值或哈希。主要,哈希值表示哈希表中键值对的索引(例如,如果我们使用数组存储所有键值对,则哈希函数返回应该保存当前键值对的数组的索引)。通过哈希函数传递相同的键应该每次产生相同的索引 - 这对于通过其键查找值很有用。

当哈希函数为不同的键生成两个相同的索引时,我们面临索引冲突。解决索引冲突问题最常用的技术是线性探测(这种技术在表中线性搜索下一个空槽位 - 尝试在数组中找到一个不包含键值对的槽位(索引))和chaining(这种技术表示作为链表数组实现的哈希表 - 冲突存储在与链表节点相同的数组索引中)。下图是用于存储名称-电话对的哈希表。它具有chaining功能(检查马里乌斯-0838234条目,它被链接到卡琳娜-0727928,因为它们的键马里乌斯卡琳娜导致相同的数组索引126):

图 6.6 - 哈希表

图 6.6 - 哈希表

HashEntry)。正如您在前面的图中所看到的,键值对有三个主要部分:键、值和指向下一个键值对的链接(这样,我们实现chaining)。由于哈希表条目应该只能通过专用方法(如get()put())访问,因此我们将其封装如下:

public class HashTable<K, V> {
    private static final int SIZE = 10;
    private static class HashEntry<K, V> {
        K key;
        V value;
        HashEntry <K, V> next;
        HashEntry(K k, V v) {
            this.key = k;
            this.value = v;
            this.next = null;
        }        
    }
    ...

接下来,我们定义包含HashEntry的数组。为了测试目的,大小为10的元素足够了,并且可以轻松测试chaining(大小较小容易发生碰撞)。实际上,这样的数组要大得多:

private final HashEntry[] entries 
        = new HashEntry[SIZE];
    ...

接下来,我们添加get()put()方法。它们的代码非常直观:

    public void put(K key, V value) {
        int hash = getHash(key);
        final HashEntry hashEntry = new HashEntry(key, value);
        if (entries[hash] == null) {
            entries[hash] = hashEntry;
        } else { // collision => chaining
            HashEntry currentEntry = entries[hash];
            while (currentEntry.next != null) {
                currentEntry = currentEntry.next;
            }
            currentEntry.next = hashEntry;
        }
    }
    public V get(K key) {
        int hash = getHash(key);
        if (entries[hash] != null) {
            HashEntry currentEntry = entries[hash];
            // Loop the entry linked list for matching 
            // the given 'key'
            while (currentEntry != null) {                
                if (currentEntry.key.equals(key)) {
                    return (V) currentEntry.value;
                }
                currentEntry = currentEntry.next;
            }
        }
        return null;
    }

最后,我们添加一个虚拟哈希函数(实际上,我们使用诸如 Murmur 3 之类的哈希函数 - en.wikipedia.org/wiki/MurmurHash):

    private int getHash(K key) {        
        return Math.abs(key.hashCode() % SIZE);
    }    
}

完成!完整的应用程序名为HashTable

对于以下四个示例,我们跳过了书中的源代码。花点时间分析每个示例。能够理解现有设计是塑造设计技能的另一个工具。当然,您可以在查看书中代码之前尝试自己的方法,并最终比较结果。

示例 7:文件系统

问题:设计文件系统的主要类。

要问的问题:需要哪些功能?文件系统的组成部分是什么?

面试官:您的设计应支持目录和文件的添加、删除和重命名。我们谈论的是目录和文件的分层结构,就像大多数操作系统一样。

解决方案:完整的应用程序名为FileSystem。请访问以下链接以查看 UML:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/blob/master/Chapter06/FileSystem/FileSystemUML.png

示例 8:元组

亚马逊谷歌

问题:设计一个元组数据结构。

要问的问题:元组可以有 1 到n个元素。那么,您期望什么样的元组?元组中应存储什么数据类型?

面试官:我期望一个包含两个通用元素的元组。元组也被称为pair

解决方案:完整的应用程序名为Tuple

示例 9:带有电影票预订系统的电影院

亚马逊,谷歌,Adobe,微软

问题:设计一个带有电影票预订系统的电影院。

要问什么:电影院的主要结构是什么?它有多个影厅吗?我们有哪些类型的票?我们如何播放电影(只在一个房间,每天只播放一次)?

面试官:我期望一个有多个相同房间的电影院。一部电影可以同时在多个房间播放,同一部电影一天内可以在同一个房间播放多次。有三种类型的票,简单、白银和黄金,根据座位类型。电影可以以非常灵活的方式添加/移除(例如,我们可以在特定的开始时间从某些房间中移除一部电影,或者我们可以将一部电影添加到所有房间)。

解决方案:完整的应用程序名为 MovieTicketBooking。请访问以下链接查看 UML:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/blob/master/Chapter06/MovieTicketBooking/MovieTicketBookingUML.png

示例 10:循环字节缓冲区

亚马逊,谷歌,Adobe

问题:设计一个循环字节缓冲区。

要问什么:它应该是可调整大小的?

面试官:是的,它应该是可调整大小的。主要是,我希望你设计所有你认为必要的方法的签名。

解决方案:完整的应用程序名为 CircularByteBuffer。

目前为止一切顺利!我建议你也尝试为前面的 10 个问题设计你自己的解决方案。不要认为所提供的解决方案是唯一正确的。尽可能多地练习,通过改变问题的背景来挑战自己,也尝试其他问题。

本章的源代码包名称为 Chapter06。

总结

本章涵盖了关于面向对象编程基础知识的最受欢迎的问题和 10 个设计编码挑战,在面试中非常受欢迎。在第一部分,我们从面向对象的概念(对象、类、抽象、封装、继承、多态、关联、聚合和组合)开始,继续讲解了 SOLID 原则,并以结合了面向对象编程概念、SOLID 原则和设计模式知识的问题为结束。在第二部分,我们解决了 10 个精心设计的设计编码挑战,包括设计点唱机、自动售货机和著名的哈希表。

练习这些问题和问题将使你有能力解决面试中遇到的任何面向对象编程问题。

在下一章中,我们将解决大 O 符号和时间的问题。

第七章:算法的大 O 分析

本章将涵盖分析算法效率和可扩展性的最流行的度量标准——大 O 符号——在技术面试的背景下。

有很多文章专门讨论这个话题。其中一些是纯数学的(学术性的),而其他一些则试图以更友好的方式解释它。纯数学的方法很难理解,在面试中也不太有用,所以我们将采用更友好的方法,这将更加熟悉于面试官和开发人员。

即使如此,这并不是一项容易的任务,因为除了是衡量算法效率和可扩展性的最流行的度量标准外,大 O 符号通常也是你从未有动力学习的东西,尽管你知道它会出现在每一次面试中。从初级到高级的战士,大 O 符号可能是每个人最大的软肋。然而,让我们努力将这个软肋变成我们面试的一个强项。

我们将快速介绍大 O 符号,并强调最重要的事情。接下来,我们将深入研究精心设计的示例,涵盖各种问题,因此在本章结束时,你将能够确定并表达几乎任何给定代码的大 O。我们的议程包括以下内容:

  • 类比

  • 大 O 复杂度时间

  • 最佳情况、最坏情况和预期情况

  • 大 O 示例

所以,让我们开始我们的大 O 之旅!

类比

想象一种情景,你在互联网上找到了自己喜欢的电影之一。你可以订购或下载它。由于你想尽快看到它,最好的方法是什么?如果你订购,那么需要一天才能送到。如果你下载,那么需要半天的时间。所以,下载更快。这就是要走的路!

但等等!就在你准备下载时,你发现了指环王大师收藏,价格很优惠,所以你也考虑下载它。只是这一次,下载需要 2 天的时间。然而,如果你下订单,仍然只需要一天。所以,下订单更快!

现在,我们可以得出结论,无论我们订购多少物品,运输时间都保持不变。我们称之为 O(1)。这是一个恒定的运行时间。

此外,我们得出结论,下载时间与文件大小成正比。我们称之为 O(n)。这是一种渐近运行时间。

从日常观察中,我们还可以得出结论,网上订购比网上下载更具扩展性。

这正是大 O 时间的含义:渐近运行时间测量或渐近函数。

作为一种渐近测量,我们谈论的是大 O 复杂度时间(这也可以是复杂度空间)。

大 O 复杂度时间

以下图表显示,在某个时间点,O(n)超过了 O(1)。因此,在 O(n)超过 O(1)之前,我们可以说 O(n)的性能优于 O(1):

图 7.1 – 渐近运行时间(大 O 时间)

图 7.1 – 渐近运行时间(大 O 时间)

除了 O(1)——常数时间——和 O(n)——线性时间运行时间——我们还有许多其他运行时间,比如 O(log n)、O(n log n)——对数时间——O(n2)——二次时间,O(2n)——指数时间,以及 O(n!)——阶乘时间。这些是最常见的运行时间,但还有许多其他存在。

以下图表代表了大 O 复杂度图表:

图 7.2 – 大 O 复杂度图表

图 7.2 – 大 O 复杂度图表

正如你所看到的,并非所有的 O 时间表现都相同。O(n!)、O(2n)和 O(n2)被认为是可怕的,我们应该努力编写超出这个范围的算法。O(n log n)比 O(n!)更好,但仍然糟糕。O(n)被认为是公平的,而 O(log n)和 O(1)被认为是好的

有时,我们需要多个变量来表示运行时性能。例如,修剪足球场草坪的时间可以表示为 O(wl),其中 w 是足球场的宽度,l 是足球场的长度。或者,如果你必须修剪 p 个足球场,那么你可以表示为 O(wlp)。

然而,这并不全是关于时间。我们也关心空间。例如,构建一个包含n个元素的数组需要 O(n)的空间。构建一个n x n元素的矩阵需要 O(n2)的空间。

最佳情况、最坏情况和预期情况

如果我们简化事情,我们可以考虑算法的效率,以最佳情况最坏情况预期情况来衡量。最佳情况是当我们的算法的输入满足一些特殊条件,使其表现最佳。最坏情况是在另一个极端,输入处于不利的状态,使我们的算法表现最差。然而,通常这些惊人或可怕的情况不会发生。因此,我们引入了预期性能。

大多数情况下,我们关心最坏和预期情况,对于大多数算法来说,它们通常是相同的。最佳情况是理想的性能,因此它仍然是理想的。主要是,对于几乎任何算法,我们都可以找到一个特殊的输入,导致 O(1)的最佳情况性能。

有关 Big O 的更多细节,我强烈建议您阅读 Big O 速查表(www.bigocheatsheet.com/)。

现在,让我们来解决一堆例子。

大 O 的例子

我们将尝试确定不同代码片段的 Big O,就像你在面试中看到的那样,我们将经历需要学习的几个相关课程。换句话说,让我们采用以例子学习的方法。

前六个例子将突出 Big O 的基本规则,列举如下:

  • 去掉常数

  • 去掉非主导项

  • 不同的输入意味着不同的变量

  • 不同的步骤被求和或相乘

让我们开始尝试一些例子。

例 1 - O(1)

考虑以下三个代码片段并计算它们的 Big O:

// snippet 1
return 23;

由于这段代码返回一个常数,Big O 是 O(1)。无论代码的其余部分做什么,这行代码都会以恒定的速度执行:

// snippet 2 - 'cars' is an array 
int thirdCar = cars[3];

通过索引访问数组是以 O(1)完成的。无论数组中有多少元素,从特定索引获取元素都是一个恒定的操作:

// snippet 3 - 'cars' is a 'java.util.Queue'
Car car = cars.peek();

Queue#peek()方法检索但不删除队列的头(第一个元素)。不管头部后面有多少元素,通过peek()方法检索头部的时间都是 O(1)。

因此,前面代码块中的所有三个代码片段都具有 O(1)的复杂度时间。同样,从队列中插入和删除,从栈中推送和弹出,插入链表中的节点,以及从数组中检索节点的左/右子节点也是 O(1)时间的情况。

例 2 - O(n),线性时间算法

考虑以下代码片段并计算 Big O:

// snippet 1 - 'a' is an array
for (int i = 0; i < a.length; i++) {
    System.out.println(a[i]);
}

为了确定这段代码的 Big O 值,我们必须回答以下问题:这个 for 循环迭代了多少次?答案是a.length次。我们无法准确地说这意味着多少时间,但我们可以说随着给定数组的大小(表示输入),时间会线性增长。因此,这段代码将具有 O(a.length)时间,被称为线性时间。它表示为 O(n)。

例 3 - O(n),去掉常数

考虑以下代码片段并计算 Big O:

// snippet 1 - 'a' is an array
for (int i = 0; i < a.length; i++) {
    System.out.println("Current element:");
    System.out.println(a[i]);
    System.out.println("Current element + 1:");
    System.out.println(a[i] + 1);
}

即使我们在循环中添加了更多的指令,我们仍然会像例 2中一样具有相同的运行时间。运行时间仍然会随着其输入a.length的大小呈线性增长。就像例 2中我们在循环中有一行代码,而在这里我们在循环中有四行代码,你可能期望大 O 是 O(n + 4)或类似的。然而,这种推理不准确或准确 - 它是错误的!这里的大 O 仍然是 O(n)。

重要说明

请记住,大 O 不取决于代码行数。它取决于运行时间的增长率,这不会被常数时间操作修改。

为了加强这种情况,让我们考虑以下两个代码片段,它们计算给定数组a的最小值和最大值:

7.3 - 代码比较

7.3 - 代码比较

那么,这两个代码片段中哪一个运行得更快?

第一个代码片段使用了一个循环,但有两个if语句,而第二个代码片段使用了两个循环,但每个循环中有一个if语句。

这样的思考方式会让人发疯!计算语句可以继续深入。例如,我们可以继续在编译器级别计算语句(操作),或者我们可能想要考虑编译器优化。嗯,这不是大 O 的意义所在!

重要说明

大 O 不是关于计算代码语句的。它的目标是表达输入大小的运行时间增长,并表达运行时间的规模。简而言之,大 O 只是描述运行时间的增长率。

此外,不要陷入这样的陷阱,认为因为第一个片段有一个循环,所以大 O 是 O(n),而在第二个片段的情况下,因为它有两个循环,所以大 O 是 O(2n)。只需去掉 2n中的 2,因为 2 是一个常数!

重要说明

按照经验法则,当你表达大 O 时,去掉运行时间中的常数。

因此,前面两个代码片段都有一个大 O 值为 O(n)。

例 4 - 去掉非主导项

考虑以下代码片段并计算大 O(a是一个数组):

7.4 - 在 O(n)中执行的代码片段

7.4 - 在 O(n)中执行的代码片段

第一个for循环的执行时间是 O(n),而第二个for循环的执行时间是 O(n2)。所以,我们可能会认为这个问题的答案是 O(n) + O(n2) = O(n + n2)。但这是不正确的!增长率由n2 给出,而n是一个非主导项。如果数组的大小增加,那么n2 对增长率的影响要比n大得多,所以n不相关。考虑一些更多的例子:

  • O(2n + 2n) -> 去掉常数和非主导项 -> O(2n)。

  • O(n + log n) -> 去掉非主导项 -> O(n)。

  • O(3n2 + n + 2n) -> 去掉常数和非主导项 -> O(n2)。

重要说明

按照经验法则,当你表达大 O 时,去掉非主导项。

接下来,让我们专注于两个常让候选人感到困惑的例子。

例 5 - 不同的输入意味着不同的变量

考虑以下两个代码片段(ab是数组)。应该使用多少变量来表达大 O?

7.5 - 代码片段 1 和 2

7.5 - 代码片段 1 和 2

在第一个片段中,我们有两个for循环,循环同一个数组a(我们对两个循环都有相同的输入),所以大 O 可以表示为 O(n),其中n指的是a。在第二个代码片段中,我们也有两个for循环,但它们循环不同的数组(我们有两个输入,ab)。这次,大 O 不是 O(n)!n指的是a还是b?假设n指的是a。如果我们增加b的大小,那么 O(n)就不能反映运行时间的增长率。因此,大 O 是这两个运行时间的总和(a的运行时间加上b的运行时间)。这意味着大 O 必须指代这两个运行时间。为此,我们可以使用两个变量分别指代ab。因此,大 O 表示为 O(a + b)。这次,如果我们增加a和/或b的大小,那么 O(a + b)就能捕捉到运行时间的增长率。

重要提示

作为一个经验法则,不同的输入意味着不同的变量。

接下来,让我们看看在添加和乘以算法步骤时会发生什么。

示例 6 - 不同步骤是求和还是乘积

考虑以下两个代码片段(ab是数组)。如何为这两个片段中的每一个表达大 O?

7.6 - 代码片段 a 和 b

7.6 - 代码片段 a 和 b

我们已经从前面的例子中知道,在第一个片段的情况下,大 O 是 O(a + b)。我们对运行时间求和,因为它们的工作不像第二个片段那样交织在一起。所以,在第二个片段中,我们不能对运行时间求和,因为对于每个a[i]的情况,代码都会循环b数组,所以大 O 是 O(a * b)。

在决定对运行时间求和还是乘积之前三思。这是面试中常见的错误。而且,很常见的是没有注意到有多个输入(这里有两个),并错误地使用单个变量表示大 O。那是错误的!一定要注意有多少个输入。对于每个影响运行时间增长率的输入,你应该有一个单独的变量(参见示例 5)。

重要提示

作为一个经验法则,不同的步骤可以求和或相乘。根据以下两个陈述,运行时间应该求和或相乘:

如果你描述你的算法为它 foos,当它完成时,它就 buzzes,那么就对运行时间求和。

如果你描述你的算法为每次它 foos,它就 buzzes,那么就乘以运行时间。

现在,让我们讨论log n的运行时间。

示例 7 - 对数 n 运行时间

写一个大 O 为 O(log n)的伪代码片段。

为了理解 O(log n)的运行时间,让我们从二分搜索算法开始。二分搜索算法的详细信息和实现在第十四章排序和搜索中可用。这个算法描述了在数组a中查找元素x的步骤。考虑一个有 16 个元素的有序数组a,如下所示:

图 7.7 - 16 个元素的有序数组

图 7.7 - 16 个元素的有序数组

首先,我们将x与数组p的中点进行比较。如果它们相等,那么我们将返回相应的数组索引作为最终结果。如果x > p,那么我们在数组的右侧搜索。如果x < p,那么我们在数组的左侧搜索。以下是用于查找数字 17 的二分搜索算法的图形表示:

图 7.8 - 二分搜索算法

图 7.8 - 二分搜索算法

注意,我们从 16 个元素开始,最后剩下 1 个。第一步之后,我们剩下 16/2 = 8 个元素。第二步,我们剩下 8/2 = 4 个元素。第三步,我们剩下 4/2 = 2 个元素。然后,在最后一步,我们找到了搜索的数字 17。如果我们将这个算法转换成伪代码,那么我们得到如下内容:

search 17 in {1, 4, 5, 7, 10, 16, 17, 18, 20, 
              23, 24, 25, 26, 30, 31, 33}
    compare 17 to 18 -> 17 < 18
    search 17 in {1, 4, 5, 7, 10, 16, 17, 18}
        compare 17 to 7 -> 17 > 7
        search 17 in {7, 10, 16, 17}
            compare 17 to 16 -> 17 > 16
            search 17 in {16, 17}
                compare 17 to 17
                return

现在,让我们为这个伪代码表示大 O。我们可以观察到,该算法由数组的连续半衰期组成,直到只剩下一个元素。因此,总运行时间取决于我们需要多少步才能在数组中找到某个数字。

在我们的例子中,我们有四步(我们将数组减半了 4 次),可以表示为以下形式:

图 7.9 - 一般情况表达

或者,如果我们压缩它,我们得到:

再进一步,我们可以将其表示为一般情况(n是数组的大小,k是达到解决方案所需的步数):

但是,2k = n 正是对数的意思 - 表示必须提高一个固定数字(底数)的幂以产生给定数字的数量。因此,我们可以写成如下形式:

在我们的情况下,2k = n意味着 24 = 16,即 log216 = 4。

因此,二分搜索算法的大 O 是 O(log n)。然而,对数的底在哪里?简短的答案是,对数的底不需要用于表示大 O,因为不同底数的对数只相差一个常数因子。

重要说明

作为一个经验法则,当你必须为一个在每一步/迭代中将其输入减半的算法表示大 O 时,它很有可能是 O(log n)的情况。

接下来,让我们谈谈递归运行时间的大 O 评估。

示例 8 - 递归运行时间

以下是代码片段的大 O 是多少?

int fibonacci(int k) {
    if (k <= 1) {
        return k;
    }
    return fibonacci(k - 2) + fibonacci(k - 1);
}

在我们的第一印象中,我们可能将大 O 表示为 O(n2)。很可能,我们会得出这个结果,因为我们被returnfibonacci()方法的两次调用所误导。然而,让我们给k赋值并快速勾画运行时。例如,如果我们调用fibonacci(7)并将递归调用表示为一棵树,那么我们会得到以下图表:

图 7.9 - 调用树

图 7.9 - 调用树

我们几乎立即注意到这棵树的深度等于 7,因此一般树的深度等于k。此外,除了终端级别外,每个节点都有两个子节点,因此几乎每个级别的调用数量都是上面一个级别的两倍。这意味着我们可以将大 O 表示为 O(分支深度)。在我们的情况下,这是 O(2k),表示为 O(2n)。

在面试中,只说 O(2n)应该是可以接受的答案。如果我们想更准确,那么我们应该考虑终端级别,特别是最后一级(或调用堆栈的底部),有时可能只包含一个调用。这意味着我们并不总是有两个分支。更准确的答案应该是 O(1.6n)。提到实际值小于 2 应该足够让任何面试官满意。

如果我们想以空间复杂度的术语来表示大 O,那么我们得到 O(n)。不要被运行时复杂度为 O(2n)所迷惑。在任何时刻,我们不能有超过k个数字。如果我们看看前面的树,我们只能看到从 1 到 7 的数字。

示例 9 - 二叉树的中序遍历

考虑一个给定的完美二叉搜索树。如果你需要快速回顾二叉树,那么请考虑第十三章**, 树和图概要部分。以下是代码片段的大 O 是多少?

void printInOrder(Node node) {
    if (node != null) {
       printInOrder(node.left);
       System.out.print(" " + node.element);
       printInOrder(node.right);
    }
}

完美的二叉搜索树是一棵二叉搜索树,其内部节点恰好有两个子节点,所有叶节点都在同一级别或深度上。在下面的图表中,我们有一棵典型的完美二叉搜索树(再次,可视化运行时输入非常有用):

图 7.10 - 高度平衡的二叉搜索树

图 7.10 - 高度平衡的二叉搜索树

我们从经验中知道(更确切地说,从前面的例子中知道),当我们面对一个具有分支的递归问题时,我们可能会有一个 O(分支深度)的情况。在我们的情况下,我们有两个分支(每个节点有两个子节点),因此我们有 O(2 深度)。指数时间看起来很奇怪,但让我们看看节点数量和深度之间的关系。在前面的图表中,我们有 15 个节点,深度为 4。如果我们有 7 个节点,那么深度将是 3,如果我们有 31 个节点,那么深度将是 5。现在,如果我们不知道理论上完美二叉树的深度是对数的,那么也许我们可以观察以下内容:

  • 对于 15 个节点,我们有 4 层深度;因此,我们有 24 = 16,相当于 log216 = 4。

  • 对于 7 个节点,我们有 3 层深度;因此,我们有 23 = 8,相当于 log28 = 3。

  • 对于 31 个节点,我们有 5 层深度;因此,我们有 25 = 32,相当于 log232 = 5。

根据前面的观察,我们可以得出结论,我们可以将大 O 表示为 O(2log n),因为深度大约是log n。因此,我们可以写成以下形式:

图 7.11 – 大 O 表达式

图 7.11 – 大 O 表达式

因此,在这种情况下,大 O 是 O(n)。如果我们意识到这段代码实际上是二叉树的中序遍历,我们也可以得出相同的结论,在这种遍历中(正如前序遍历和后序遍历一样),每个节点只被访问一次。此外,对于每个遍历的节点,有一个恒定的工作量,因此大 O 是 O(n)。

示例 10 – n 可能变化

以下代码片段的大 O 是多少?

void printFibonacci(int k) {
    for (int i = 0; i < k; i++) {
        System.out.println(i + ": " + fibonacci(i));
    }
}
int fibonacci(int k) {
    if (k <= 1) {
        return k;
    }
    return fibonacci(k - 2) + fibonacci(k - 1);
}

示例 8中,我们已经知道fibonacci()方法的大 O 值为 O(2n)。printFibonacci()调用fibonacci()n次,因此很容易将总的大 O 值表示为 O(n)*O(2n) = O(n2n)。然而,这是真的吗,还是我们匆忙给出了一个表面上容易的答案?

嗯,这里的诀窍是n是变化的。例如,让我们来可视化运行时间:

我们不能说我们执行相同的代码n次,因此这是 O(2n)。

示例 11 – 记忆化

以下代码片段的大 O 是多少?

void printFibonacci(int k) {
    int[] cache = new int[k];
    for (int i = 0; i < k; i++) {
        System.out.println(i + ": " + fibonacci(i, cache));
    }
}
int fibonacci(int k, int[] cache) {
    if (k <= 1) {
        return k;
    } else if (cache[k] > 0) {
        return cache[k];
    }
    cache[k] = fibonacci(k - 2, cache) 
        + fibonacci(k - 1, cache);
    return cache[k];
}

这段代码通过递归计算斐波那契数。然而,这段代码使用了一种称为记忆化的技术。主要思想是缓存返回值并使用它来减少递归调用。我们已经从示例 8中知道fibonacci()方法的大 O 是 O(2n)。由于记忆化应该减少递归调用(它引入了一种优化),我们可以猜测这段代码的大 O 应该比 O(2n)好。然而,这只是一种直觉,所以让我们来可视化k = 7 的运行时间:

Calling fibonacci(0):
Result of fibonacci(0) is 0
Calling fibonacci(1):
Result of fibonacci(1) is 1
Calling fibonacci(2):
 fibonacci(0)
 fibonacci(1)
 fibonacci(2) is computed and cached at cache[2]
Result of fibonacci(2) is 1
Calling fibonacci(3):
 fibonacci(1)
 fibonacci(2) is fetched from cache[2] as: 1
 fibonacci(3) is computed and cached at cache[3]
Result of fibonacci(3) is 2
Calling fibonacci(4):
 fibonacci(2) is fetched from cache[2] as: 1
 fibonacci(3) is fetched from cache[3] as: 2
 fibonacci(4) is computed and cached at cache[4]
Result of fibonacci(4) is 3
Calling fibonacci(5):
 fibonacci(3) is fetched from cache[3] as: 2
 fibonacci(4) is fetched from cache[4] as: 3
 fibonacci(5) is computed and cached at cache[5]
Result of fibonacci(5) is 5
Calling fibonacci(6):
 fibonacci(4) is fetched from cache[4] as: 3
 fibonacci(5) is fetched from cache[5] as: 5
 fibonacci(6) is computed and cached at cache[6]
Result of fibonacci(6) is 8

每个fibonacci(k)方法都是从缓存的fibonacci(k-1)fibonacci(k-2)方法计算出来的。从缓存中获取计算出的值并对它们求和是一个恒定的工作量。由于我们这样做了k次,这意味着大 O 可以表示为 O(n)。

除了记忆化,我们还可以使用另一种方法,称为表格法。更多细节请参阅第八章递归和动态规划

示例 12 – 循环矩阵的一半

以下两个代码片段(a是一个数组)的大 O 是多少?

7.12 – 大 O 的代码片段

7.12 – 大 O 的代码片段

这些代码片段几乎是相同的,只是在第一个片段中,j0开始,而在第二个片段中,它从i+1开始。

我们可以很容易地给出数组大小的值,并可视化这两个代码片段的运行时间。例如,让我们假设数组大小为 5。左侧矩阵是第一个代码片段的运行时间,而右侧矩阵对应于第二个代码片段的运行时间:

图 7.13 – 可视化运行时间

图 7.13 – 可视化运行时间

与第一段代码对应的矩阵显示了一个nn大小,而与第二段代码对应的矩阵大致显示了一个nn/2 大小。因此,我们可以写成以下形式:

  • 代码段 1 的运行时间是:

  • 代码段 2 的运行时间是: 因为我们消除了常数。

因此,这两段代码的时间复杂度都是 O(n2)。

或者,你可以这样想:

  • 对于第一个代码段,内部循环不做任何工作,外部循环运行了n次,所以nn = n*2,结果是 O(n2)。

  • 对于第二段代码,内部循环大致做了n/2 的工作,并且外部循环运行了n次,所以nn*/2 = n2/2 = n2 * 1/2,去除常数后得到 O(n2)。

示例 13 - 识别 O(1)循环

以下代码段的大 O 是多少(a是一个数组)?

for (int i = 0; i < a.length; i++) {
    for (int j = 0; j < a.length; j++) {
        for (int q = 0; q < 1_000_000; q++) {
            System.out.println(a[i] + a[j]);
        }
    }
}

如果我们忽略第三个循环(q循环),那么我们已经知道大 O 是 O(n2)。那么第三个循环如何影响总的大 O 值呢?第三个循环独立于数组大小,迭代从 0 到 100 万,因此这个循环的大 O 是 O(1),是一个常数。由于第三个循环不依赖于输入大小的变化,我们可以写成以下形式:

for (int i = 0; i < a.length; i++) {
    for (int j = 0; j < a.length; j++) {
        // O(1)  
    }
}

现在,很明显这个例子的大 O 是 O(n2)。

示例 14 - 循环数组的一半

以下代码段的大 O 是多少(a是一个数组)?

for (int i = 0; i < a.length / 2; i++) {
    System.out.println(a[i]);
}

这里可能会引起混淆,因为这段代码只循环了数组的一半。不要犯将大 O 表达为 O(n/2)的常见错误。记住常数应该被去除,所以大 O 是 O(n)。只迭代数组的一半不会影响大 O 时间。

示例 15 - 减少大 O 表达式

以下哪个可以表示为 O(n)?

  • O(n + p)

  • O(n + log n)

答案是 O(n + log n)可以简化为 O(n),因为log n是一个非主导项,可以被去除。另一方面,O(n + p)不能简化为 O(n),因为我们不知道p的情况。在我们确定p是什么以及np之间的关系之前,我们必须保留它们两个。

示例 16 - 具有 O(log n)的循环

以下代码段的大 O 是多少(a是一个数组)?

for (int i = 0; i < a.length; i++) {
    for (int j = a.length; j > 0; j /= 2) {
        System.out.println(a[i] + ", " + j);
    }
}

让我们只关注外部循环。根据之前示例的经验,我们可以迅速将大 O 表达为 O(n)。

内部循环呢?我们可以注意到j从数组长度开始,并且在每次迭代时减半。记住示例 7中的重要说明:当你必须为每一步减半的算法表达大 O 时,很可能是 O(log n)的情况

重要说明

每当你认为这很可能是 O(log n)的情况时,建议使用除数的幂的测试数字。如果输入被除以 2(被减半),那么使用除数的幂的数字(例如,23 = 8,24 = 16,25 = 32 等)。如果输入被除以 3,那么使用除数的幂的数字(例如,32 = 9,33 = 27 等)。这样,很容易计算除法的次数。

因此,让我们给a.length赋值并可视化运行时间。假设a.length是 16。这意味着j将取 12、8、4、2 和 1 的值。我们已经将j除以 2 四次,所以有以下结果:

图 7.14 - 具有 O(log n)的循环

图 7.14 - 具有 O(log n)的循环

因此,内部循环的大 O 是 O(log n)。为了计算总的大 O,我们考虑外部循环执行了n次,在这个循环内,另一个循环执行了log n次。因此,总的大 O 结果是 O(n)* O (log n) = O(n log n)。

作为提示,许多排序算法(例如,归并排序和堆排序)具有 O(n log n)的运行时间。 此外,许多 O(n log n)算法是递归的。 一般来说,属于分而治之D&C)类别的算法是 O(n log n)。 希望记住这些提示在面试中会非常有用。

示例 17 – 字符串比较

以下代码片段的大 O 是多少?(注意a是一个数组,请仔细阅读注释):

String[] sortArrayOfString(String[] a) {
    for (int i = 0; i < a.length; i++) {
        // sort each string via O(n log n) algorithm           
    }
    // sort the array itself via O(n log n) algorithm               
    return a;
}

sortArrayOfString()接收一个String数组并执行两个主要操作。 它对该数组中的每个字符串和数组本身进行排序。 这两种排序都是通过运行时表达为 O(n log n)的算法完成的。

现在,让我们专注于for循环,并看看候选人通常给出的错误答案。 我们已经知道对单个字符串进行排序会给我们带来 O(n log n)。 对每个字符串进行这样的操作意味着 O(n) * (n log n) = O(nn log n) = O(n2 log n)。 接下来,我们对数组本身进行排序,这也表示为 O(n log n)。 将所有结果放在一起,总的大 O 值是 O(n2 log n) + O(n log n) = O(n2 log n + n log n),这是 O(n2 log n),因为n* log n是一个非主导项。 但是,这是正确的吗? 简短的答案是否定的! 但为什么不是呢? 我们犯了两个主要错误:我们使用n来表示两个东西(数组的大小和字符串的长度),并且我们假设比较String需要一个常数时间,就像对于固定宽度的整数一样。

让我们详细说明第一个问题。 因此,对单个字符串进行排序会给我们带来 O(n log n),其中n代表该字符串的长度。 我们对a.length个字符串进行排序,所以n现在代表数组的大小。 这就是混淆的原因,因为当我们说for循环是 O(n2 log n)时,我们指的是哪个n? 由于我们正在处理两个变量,我们需要以不同的方式表示它们。 例如,我们可以考虑以下内容:

  • s:最长String的长度。

  • pString数组的大小。

用这些术语来说,对单个字符串进行排序是 O(s log s),这样做p次的结果是 O(p)O(s log s) = O(ps log s)。

现在,让我们解决第二个问题。 用我们的新术语来说,对数组进行排序是 O(p log p) – 我刚刚用p替换了n。 但是,String的比较是否需要像固定宽度整数一样的常数时间呢? 答案是否定的! String排序改变了 O(p log p),因为String比较本身具有可变成本。 String的长度是变化的,因此比较时间也是变化的。 因此,在我们的情况下,每个String比较都需要 O(s),由于我们有 O(p log p)次比较,结果是对字符串数组进行排序是 O(s) * O(p log p) = O(s*p log p)。

最后,我们必须将 O(ps log s)加到 O(sp log p)上= O(s*p(log s + log p))。 完成!

示例 18 – 阶乘的大 O

以下代码片段的大 O 是多少?

long factorial(int num) {
    if (num >= 1) {
        return num * factorial(num - 1);
    } else {
        return 1;
    }
}

很明显,这段代码是计算阶乘的递归实现。 不要犯认为大 O 是 O(n!)的常见错误。 这是不正确的! 要仔细分析代码,不要提前假设。

递归过程遍历序列n–1,n–2,... 1 次;因此,这是 O(n)。

示例 19 – 谨慎使用 n 符号

以下两个代码片段的大 O 是多少?

7.15 – 代码片段

7.15 – 代码片段

左侧的第一个代码片段对y次进行了常量工作。 x输入不会影响运行时间的增长速度,因此大 O 可以表示为 O(y)。 请注意,我们不说 O(n),因为n也可能与x混淆。

第二个代码片段(在右侧)递归遍历y-1、y-2、...、0。每个y输入只遍历一次,因此大 O 可以表示为 O(y)。再次强调,x输入不会影响运行时间的增长率。此外,我们避免说 O(n),因为有多个输入,O(n)会引起混淆。

例 20 - 总和和计数

以下代码片段的大 O 是多少(xy为正数)?

int div(int x, int y) {
    int count = 0;
    int sum = y;
    while (sum <= x) {
       sum += y;
       count++;
    }
    return count;
}

让我们给xy赋值,并观察count变量,该变量计算迭代次数。考虑x=10 和y=2。对于这种情况,count将为 5(10/2 = 5)。按照相同的逻辑,我们有x=14,y=4,count=3(14/4 = 3.5),或x=22,y=3,或count=7(22/3 = 7.3)。我们可以注意到,在最坏的情况下,countx/y,因此大 O 可以表示为 O(x/y)。

示例 21 - 大 O 中的迭代计数

以下代码片段尝试猜测一个数字的平方根。大 O 是多少?

int sqrt(int n) {
    for (int guess = 1; guess * guess <= n; guess++) {
        if (guess * guess == n) {
            return guess;
        }
    }
    return -1;
}

让我们假设数字(n)是一个完全平方根,例如 144,我们已经知道 sqrt(144)= 12。由于guess变量从 1 开始,并在guess*guess <= n时停止,步长为 1,因此很容易计算出guess将取值 1、2、3、...,12。当guess为 12 时,我们有 12*12 = 144,循环停止。因此,我们有 12 次迭代,这恰好是 sqrt(144)。

我们对非完全平方根采用相同的逻辑。假设n是 15。这次,guess将取 1、2 和 3 的值。当guess=4 时,我们有 4*4 > 15,循环停止。返回值为-1。因此,我们有 3 次迭代。

总之,我们有 sqrt(n)次迭代,因此大 O 可以表示为 O(sqrt(n))。

示例 22 - 数字

以下代码片段总结了整数的数字。大 O 是多少?

int sumDigits(int n) {
    int result = 0;
    while (n > 0) {
        result += n % 10;
        n /= 10;
    }
    return result;
}

在每次迭代中,n被 10 除。这样,代码会将数字的右侧孤立出来(例如,56643/10 = 5664.3)。为了遍历所有数字,while循环需要的迭代次数等于数字的位数(例如,对于 56,643,它需要 5 次迭代来孤立 3、4、6、6 和 5)。

然而,一个有 5 位数字的数字可以达到 105 = 100,000,这意味着 99,999 次迭代。一般来说,这意味着一个具有d位数的数字(n)可以达到 10d。因此,我们可以说以下内容:

图 7.16 - 数字关系

图 7.16 - 数字关系

示例 23 - 排序

以下代码片段的大 O 是多少?

boolean matching(int[] x, int[] y) {
    mergesort(y);
    for (int i : x) {
        if (binarySearch(y, i) >= 0) {
            return true;
        }
    }
    return false;
}

示例 16中,我们说很多排序算法(包括归并排序)的运行时间为 O(n log n)。这意味着mergesort(y)的运行时间为 O(y log y)。

E**xample 7中,我们说二分搜索算法的运行时间为 O(log n)。这意味着binarySearch(y, i)的运行时间为 O(log y)。在最坏的情况下,for循环将遍历整个x数组,因此二分搜索算法将被执行x.length次。for循环的运行时间为 O(x log y)。

因此,总的大 O 值可以表示为 O(y log y)+ O(x log y)= O(y log y + x log y)。

完成!这是这里介绍的最后一个示例。接下来,让我们尝试提取几个关键提示,这些提示可以帮助你在面试中确定和表达大 O。

面试中要寻找的关键提示

在面试中,时间和压力是可以影响集中力的严重因素。具有识别模板、识别特定情况、猜测正确答案等能力会给你带来重大优势。正如我们在第五章中所述,如何应对编码挑战,在图 5.2中,构建示例(或用例)是应对编码挑战的第二步。即使面试官提供了代码,构建示例对于确定大 O 仍然非常有用。

您可能已经注意到,在我们涵盖的几乎每个非平凡示例中,我们更喜欢为一个或多个具体案例可视化运行时间。这样,您可以真正理解代码的细节,识别输入,确定代码的静态(常数)和动态(变量)部分,并对代码的工作方式有一个总体了解。

以下是一些关键提示的非尽事项清单,可以帮助您在面试中:

  • xyw,并进行一些计算,比如x-yy*w)。在某些情况下,为了制造混淆,它还会添加重复的语句(例如,计算是在for(int i=0; i<10; i++)中完成的)。因此,从一开始就确定算法的输入是否影响其运行时间非常重要。

  • for(int i=0; i<a.length; i++),其中a是一个数组)。通常,这些结构的运行时间为 O(n)。在某些情况下,为了制造混淆,重复的结构会添加一个验证break语句的条件。请记住,大 O 是关于最坏情况的,因此您应该评估运行时间时要牢记验证break语句的条件可能永远不会发生,而大 O 仍然是 O(n)。

  • 如果在每次迭代中,算法将输入数据减半,则总体大 O 值可能涉及 O(log n):正如您在示例 7中看到的,二分查找算法是 O(log n)的著名案例。通常,您可以通过尝试可视化运行时间来识别类似的情况。

  • 具有分支的递归问题是 O(分支深度)可能是总体大 O 值的一个很好的信号:O(2^深度)遇到的最常见情况是在操纵二叉树的代码片段中。还要注意如何确定深度。正如您在示例 9中看到的,深度可以影响最终结果。在那种情况下,O(2log n)被降低为 O(n)。

  • 使用记忆化或表格法的递归算法是具有 O(n)作为其总体大 O 值的良好候选:通常,递归算法暴露出指数运行时间(例如,O(2^n)),但是优化,如记忆化表格法可以将运行时间降低到 O(n)。

  • 排序算法通常在总体大 O 值中引入 O(n log n):请记住,许多排序算法(例如,堆排序,归并排序等)的运行时间为 O(n log n)。

希望这些提示能帮助您,因为我们已经涵盖了一些经过充分验证的例子。

总结

在本章中,我们涵盖了面试中最主要的话题之一,即大 O。有时,您将不得不确定给定代码的大 O,而其他时候,您将不得不确定自己的代码的大 O。换句话说,在面试中几乎没有绕过大 O 的机会。无论您如何努力训练,大 O 始终是一个难题,即使是最优秀的开发人员也可能遇到困难。幸运的是,这里涵盖的情况是面试中最受欢迎的,并且它们代表了许多派生问题的完美模板。

在下一章中,我们将解决面试中其他受欢迎的话题:递归和动态规划。

第八章:递归和动态规划

本章涵盖了面试官最喜欢的主题之一:递归和动态规划。两者密切相关,因此您必须能够同时掌握两者。通常,面试官希望看到纯递归解决方案。但是,他们可能要求您提供一些优化提示,甚至编写代码的优化版本。换句话说,您的面试官希望看到动态规划的工作。

在本章中,我们将涵盖以下主题:

  • 简而言之,递归

  • 简而言之,动态规划

  • 编码挑战

本章结束时,您将能够实现各种递归算法。您将拥有大量递归模式和方法,可以在几分钟内识别和实现递归算法。让我们从我们议程的第一个主题开始:递归。

技术要求

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

简而言之,递归

直接/间接调用自身的方法称为递归。这种方法称为递归方法。著名的斐波那契数问题可以按照以下方式进行递归实现:

int fibonacci(int k) {
    // base case
    if (k <= 1) {
        return k;
    }
    // recursive call
    return fibonacci(k - 2) + fibonacci(k - 1);
}

这段代码中有两个重要部分:

  • 基本情况:在没有后续递归调用的情况下返回一个值。对于特殊的输入,函数可以在没有递归的情况下进行评估。

  • fibonacci()方法调用自身,我们有一个递归方法。

识别递归问题

在尝试通过递归算法解决问题之前,我们必须将其识别为适合这种算法的良好候选。面试中使用的大多数递归问题都很有名,因此我们可以通过名称来识别它们。例如,斐波那契数、对列表中的数字求和、最大公约数、阶乘、递归二分查找、字符串反转等问题都是众所周知的递归问题。

但是,所有这些问题有什么共同之处呢?一旦我们知道了这个问题的答案,我们将能够识别其他递归问题。答案非常简单:所有这些问题都可以建立在子问题的基础上。换句话说,我们可以说我们可以用方法返回的其他值来表示方法返回的值。

重要提示

当问题可以建立在子问题的基础上时,它是适合递归解决的良好候选。通常,这类问题包括诸如列出前/后 n 个...,计算第 n 个...或所有...,计算所有解...,生成所有情况...等词语。为了计算第 n 个...,我们必须计算f(n-1)f(n-2)等,以便将问题分解为子问题。换句话说,计算f(n)需要计算f(n-1)f(n-2)等。练习是识别和解决递归问题的关键词。解决大量递归问题将帮助您像眨眼一样轻松地识别它们。

接下来,我们将重点介绍动态规划的主要方面,并学习如何通过动态规划优化纯递归。

简而言之,动态规划

当我们谈论优化递归时,我们谈论动态规划。这意味着可以使用纯递归算法或动态规划来解决递归问题。

现在,让我们将动态规划应用于斐波那契数,从简单的递归算法开始:

int fibonacci(int k) {
    if (k <= 1) {
        return k;
    }
    return fibonacci(k - 2) + fibonacci(k - 1);
}

斐波那契数的纯递归算法的运行时间为 O(2n),空间复杂度为 O(n) - 您可以在第七章**,算法的大 O 分析中找到解释。如果我们设置k=7,并将调用堆栈表示为调用树,则我们将获得以下图表:

图 8.1 - 调用树(纯递归)

图 8.1 - 调用树(普通递归)

如果我们检查第七章**,算法的大 O 分析中的大 O 图表,我们会注意到 O(2n)远非高效。指数运行时间适合大 O 图表的可怕区域。我们能做得更好吗?是的,通过备忘录方法。

备忘录(或自顶向下的动态规划)

当一个递归算法对相同的输入进行重复调用时,这表明它执行了重复的工作。换句话说,递归问题可能存在重叠子问题,因此解决方案的路径涉及多次解决相同的子问题。例如,如果我们重新绘制斐波那契数的调用树,并突出显示重叠的问题,那么我们会得到以下图表:

图 8.2 - 调用树(重复工作)

图 8.2 - 调用树(重复工作)

很明显,超过一半的调用是重复的调用。

备忘录是一种用于消除方法中重复工作的技术。它保证一个方法只对相同的输入调用一次。为了实现这一点,备忘录缓存了给定输入的结果。这意味着,当方法应该被调用来计算已经计算过的输入时,备忘录将通过从缓存中返回结果来避免这次调用。

以下代码使用备忘录来优化斐波那契数的普通递归算法(缓存由cache数组表示):

int fibonacci(int k) {
    return fibonacci(k, new int[k + 1]);
}
int fibonacci(int k, int[] cache) {
    if (k <= 1) {
        return k;
    } else if (cache[k] > 0) {
        return cache[k];
    }
    cache[k] = fibonacci(k - 2, cache) 
        + fibonacci(k - 1, cache);
    return cache[k];
}

如果我们重新绘制前面代码的调用树,那么我们会得到以下图表:

图 8.3 - 调用树(备忘录)

图 8.3 - 调用树(备忘录)

在这里,很明显备忘录大大减少了递归调用的次数。这次,fibonacci()方法利用了缓存的结果。运行时间从 O(2n)降低到 O(n),因此从指数降低到多项式。

重要说明

备忘录也被称为自顶向下方法。自顶向下方法并不直观,因为我们立即开始开发最终解决方案,解释我们如何从较小的解决方案中开发它。这就像说以下内容:

我写了一本书。怎么写的?我写了它的章节。怎么写的?我写了每个章节的部分。怎么写的?我写了每个部分的段落

空间复杂度仍然是 O(n)。我们能改进吗?是的,通过Tabulation方法。

Tabulation(或自底向上的动态规划)

Tabulation,或自底向上方法,比自顶向下更直观。基本上,递归算法(通常)从末尾开始向后工作,而自底向上算法从一开始就开始。自底向上方法避免了递归并改进了空间复杂度。

重要说明

Tabulation通常被称为自底向上方法。自底向上是一种避免递归并且相当自然的方法。就像说以下内容:

我写了每个部分的段落。然后呢?然后我写了每个章节的部分。然后呢?然后我写了所有的章节。然后呢?然后我写了一本书。

自底向上减少了递归构建调用栈时所施加的内存成本,这意味着自底向上消除了发生堆栈溢出错误的脆弱性。如果调用栈变得太大并且空间不足,就可能发生这种情况。

例如,当我们通过递归方法计算fibonacci(k)时,我们从k开始,然后继续到k-1,k-2,依此类推直到 0。通过自底向上方法,我们从 0 开始,然后继续到 1,2 等,直到k。如下代码所示,这是一种迭代方法:

int fibonacci(int k) {
    if (k <= 1) {
        return k;
    }
    int first = 1;
    int second = 0;
    int result = 0;
    for (int i = 1; i < k; i++) {
        result = first + second;
        second = first;
        first = result;
    }
    return result;
}

该算法的运行时间仍然是 O(n),但空间复杂度已从 O(n)降低到 O(1)。因此,总结斐波那契数算法,我们有以下内容:

  • 普通递归算法的运行时间为 O(2n),空间复杂度为 O(n)。

  • 备忘录递归算法的运行时间为 O(n),空间复杂度为 O(n)。

  • 制表法算法的运行时间为 O(n),空间复杂度为 O(1)。

现在,是时候练习一些编码挑战了。

编码挑战

在接下来的 15 个编码挑战中,我们将利用递归和动态规划。这些问题经过精心设计,旨在帮助您理解和解决这一类别中的各种问题。在本编码挑战会话结束时,您应该能够在面试环境中识别和解决递归问题。

编码挑战 1 - 机器人网格(I)

Adobe,Microsoft

问题:我们有一个m x n网格。一个机器人被放置在这个网格的左上角。机器人只能在任何时候向右或向下移动,但不允许移动到某些单元格。机器人的目标是找到从网格的左上角到右下角的路径。

解决方案:首先,我们需要设置m x n网格的一些约定。假设右下角的坐标为(0, 0),而左上角的坐标为(m, n),其中m是网格的行,n是网格的列。因此,机器人从(m, n)开始,必须找到一条到(0, 0)的路径。如果我们尝试为一个 6x6 网格绘制一个示例,那么我们可以得到如下的东西:

图 8.4 - 确定移动模式

图 8.4 - 确定移动模式

在这里,我们可以看到机器人可以从一个单元格(m, n)到相邻的单元格,可以是(m-1, n)或(m, n-1)。例如,如果机器人放置在(5, 5),那么它可以到达(4, 5)或(5, 4)。此外,从(4, 5),它可以到达(3, 5)或(4, 4),而从(5, 4),它可以到达(5, 3)或(4, 4)。

所以,我们有一个可以分解成子问题的问题。我们必须找到单元格的最终路径(问题),如果我们能找到到相邻单元格的路径(子问题),我们就可以做到这一点。这听起来像是一个递归算法。在递归中,我们从上到下解决问题,所以我们从(m, n)开始,然后回到原点(0, 0),如前面的图表所示。这意味着从单元格(m, n),我们尝试进入(m, n-1)或(m-1, n)。

将这个问题转化为代码可以这样做(maze[][]矩阵是一个boolean矩阵,对于我们不允许进入的单元格具有true的值 - 例如,maze[3][1] = true表示我们不允许进入单元格(3,1)):

public static boolean computePath(int m, int n, 
      boolean[][] maze, Set<Point> path) {
    // we fell off the grid so we return
    if (m < 0 || n < 0) {
        return false;
    }
    // we cannot step at this cell
    if (maze[m][n]) {
        return false;
    }
    // we reached the target 
    // (this is the bottom-right corner)    
    if (((m == 0) && (n == 0))                  
       // or, try to go to the right     
       || computePath(m, n - 1, maze, path)    
       // or, try to go to down
       || computePath(m - 1, n, maze, path)) { 
        // we add the cell to the path
        path.add(new Point(m, n));
        return true;
    }
    return false;
}

返回的路径存储为LinkedHashSet<Point>。每条路径包含m+n步,每一步我们只能做两个有效的选择;因此,运行时间为 O(2m+n)。但是,如果我们缓存了失败的单元格(返回false),我们可以将这个运行时间减少到 O(mn)。这样,备忘录方法可以避免机器人多次尝试进入一个失败的单元格。完整的应用程序称为RobotGridMaze。它还包含了备忘录代码。

使用机器人的另一个流行问题如下。假设我们有一个m x n网格。一个机器人被放置在这个网格的左上角。机器人只能在任何时候向右或向下移动。机器人的目标是找到从网格的左上角到右下角的所有唯一路径。

普通递归解决方案和自底向上方法都包含在RobotGridAllPaths应用程序中。

编码挑战 2 - 汉诺塔

问题:这是一个经典问题,可能随时在面试中出现。汉诺塔是一个有三根杆(ABC)和n个磁盘的问题。最初,所有的磁盘都按升序放置在一个杆上(最大的磁盘在底部(磁盘n),一个较小的磁盘放在它上面(n-1),依此类推(n-2,n-3,...)直到最小的磁盘在顶部(磁盘 1)。目标是将所有的磁盘从这根杆移动到另一根杆,同时遵守以下规则:

  • 一次只能移动一个磁盘。

  • 一次移动意味着将顶部的磁盘从一个杆滑动到另一个杆。

  • 一个磁盘不能放在比它更小的磁盘上。

解决方案:尝试解决这样的问题意味着我们需要可视化一些情况。让我们假设我们想要将磁盘从杆A移动到杆C。现在,让我们在杆A上放置n个磁盘:

对于n=1:有一个单独的磁盘,我们需要将一个磁盘从杆A移动到C

对于n=2:我们知道如何移动一个单独的磁盘。为了移动两个磁盘,我们需要完成以下步骤:

  1. 将磁盘 1 从A移动到B(杆B作为磁盘 1 的中间杆)。

  2. 将磁盘 2 从A移动到C(磁盘 2 直接移动到最终位置)。

  3. 将磁盘 1 从B移动到C(磁盘 1 可以移动到杆C上的磁盘 2 上)。

对于n=3:让我们从以下图表中获得一些帮助:

图 8.5 - 汉诺塔(三个磁盘)

图 8.5 - 汉诺塔(三个磁盘)

由于n=2,我们知道如何将顶部两个磁盘从A(起点)移动到C(目标)。换句话说,我们知道如何将顶部两个磁盘从一个杆移动到另一个杆。让我们将它们从A移动到B,如下所示:

  1. 将磁盘 1 从A移动到C(这次我们使用C作为中间杆)。

  2. 将磁盘 2 从A移动到B

  3. 将磁盘 1 从C移动到B

好的,这是我们以前做过的事情。接下来,我们可以将磁盘 2 和 3 移动到C,如下所示:

  1. 将磁盘 3 从A移动到C

  2. 将磁盘 1 从B移动到A(我们使用A作为中间杆)。

  3. 将磁盘 2 从B移动到C

  4. 最后,将磁盘 3 从A移动到C

继续这种逻辑,我们可以直观地得出我们可以移动四个磁盘,因为我们知道如何移动三个,我们可以移动五个磁盘,因为我们知道如何移动四个,依此类推。以杆A为起点,杆B为中间杆,杆C为目标杆,我们可以得出我们可以通过以下步骤移动n个磁盘:

  • 将顶部的n - 1 个磁盘从起点移动到中间杆,使用目标杆作为中间杆。

  • 将顶部的n - 1 个磁盘从中间杆移动到目标杆,使用起点作为中间杆。

在这一点上,很明显我们有一个可以分解为子问题的问题。基于前面两个项目符号,我们可以编写代码如下:

public static void moveDisks(int n, char origin, 
    char target, char intermediate) {
    if (n <= 0) {
        return;
    }
    if (n == 1) {
        System.out.println("Move disk 1 from rod " 
          + origin + " to rod " + target);
        return;
    }
    // move top n - 1 disks from origin to intermediate, 
    // using target as a intermediate
    moveDisks(n - 1, origin, intermediate, target);
    System.out.println("Move disk " + n + " from rod " 
            + origin + " to rod " + target);
    // move top n - 1 disks from intermediate to target, 
    // using origin as an intermediate
    moveDisks(n - 1, intermediate, target, origin);
}

完整的应用程序称为HanoiTowers

编码挑战 3 - Josephus

亚马逊,谷歌,Adobe,微软,Flipkart

问题:考虑一个排成圆圈的n个人(1,2,3,...,n)。每隔k个人将在圆圈中被杀,直到只剩下一个幸存者。编写一个算法,找到这个幸存者的k位置。这就是所谓的 Josephus 问题。

解决方案:记住我们之前有一个注释,当一个问题包含计算第 n 个之类的表达时,它可能是递归解决的一个很好的候选。在这里,我们有找到第 k 个位置,这是一个可以分解为子问题并通过递归解决的问题。

让我们考虑n=15 和k=3。所以,有 15 个人,每三个人中的一个将在圆圈中被淘汰,直到只剩下一个人。让我们通过以下图表来可视化这一点(这对于找出杀人的模式非常有用):

图 8.6 - n=15 和 k=3 的 Josephus

图 8.6 - n=15 和 k=3 的 Josephus

所以,我们需要进行五轮,直到找到幸存者,如下所示:

  • 第 1 轮:第一个淘汰的是位置 3;接下来是 6,9,12 和 15。

  • 第 2 轮:第一个淘汰的是位置 4(1 和 2 被跳过,因为位置 15 是第 1 轮最后被淘汰的);接下来,淘汰 8 和 13。

  • 第 3 轮:第一个淘汰的是位置 2(14 和 1 被跳过,因为位置 13 是第 2 轮最后被淘汰的);接下来,淘汰 10 和 1。

  • 第 4 轮:第一个淘汰的位置是 11,接着是位置 7。

  • 第 5 轮:淘汰 14,5 是幸存者。

尝试识别模式或递归调用可以基于以下观察来完成。在第一个人(k个)被淘汰后,剩下n-1 个人。这意味着我们调用josephus(n – 1, k)来得到第n-1 个人的位置。然而,请注意,josephus(n – 1, k)返回的位置将考虑从k%n + 1 开始的位置。换句话说,我们必须调整josephus(n – 1, k)返回的位置以获得(josephus(n - 1, k) + k - 1) % n + 1。递归方法如下所示:

public static int josephus(int n, int k) {
    if (n == 1) {
        return 1;
    } else {
        return (josephus(n - 1, k) + k - 1) % n + 1;
    }
}

如果您觉得这种方法非常棘手,那么您可以尝试基于队列的迭代方法。首先,用n个人填充队列。接下来,循环队列,并且对于每个人,检索并删除此队列的头部(poll())。如果检索到的人不是第k个,则将此人重新插入队列(add())。如果这是第k个人,则中断循环,并重复此过程,直到队列的大小为 1。这个代码如下:

public static void printJosephus(int n, int k) {
    Queue<Integer> circle = new ArrayDeque<>();
    for (int i = 1; i <= n; i++) {
        circle.add(i);
    }
    while (circle.size() != 1) {
        for (int i = 1; i <= k; i++) {
            int eliminated = circle.poll();
            if (i == k) {
               System.out.println("Eliminated: " 
                   + eliminated);
               break;
            }
            circle.add(eliminated);
        }
    }
    System.out.println("Using queue! Survivor: " 
        + circle.peek());
}

完整的应用程序称为Josephus

编码挑战 4-彩色斑点

亚马逊,谷歌,Adobe,微软,Flipkart

问题:考虑一个r x c网格,其中r代表行,c代表列。每个单元格都有一个用数字k表示的颜色(例如,对于三种颜色,k=3)。我们将单元格的连接集(或颜色斑点)定义为我们可以通过对行或列的连续位移从相应单元格到达的总单元格数,从而保持颜色。目标是确定最大连接集的颜色和单元格数。换句话说,我们需要确定最大的颜色斑点。

解决方案:让我们考虑一个 5x5 的网格和三种颜色,其中r=c=5,k=3。接下来,让我们按照以下图示来表示网格:

图 8.7-最大颜色斑点(a)-初始网格,(b)-解决网格)

图 8.7-最大颜色斑点(a)-初始网格,(b)-解决网格

让我们专注于图像(a)。在这里,我们可以看到从一个单元格移动到另一个单元格最多可以有四个方向(上,下,左,右)。这意味着,从一个单元格(r,c)到另一个单元格(r-1,c),(r+1,c),(rc-1),和(rc+1)。如果我们冒着从网格上掉下来的风险,或者目标单元格的颜色与当前单元格不同,我们就不能进行移动。因此,通过迭代每个单元格((0,0),(0,1),...(r,c)),我们可以通过访问每个允许的单元格并计数来确定该单元格的连接集的大小(颜色斑点的大小)。在图像(a)中,我们有四个颜色为 1 的斑点,它们的大小分别为 1、1、1 和 2。我们还有六个颜色为 2 的斑点,它们的大小分别为 1、1、2、1、1 和 1。最后,我们有三个颜色为 3 的斑点,它们的大小分别为 11、1 和 1。

从这里,我们可以得出最大的颜色斑点大小为 11,颜色为 3。主要的是,我们可以认为第一个单元格的颜色斑点是最大的,每当我们找到一个比这个更大的颜色斑点时,我们就用我们找到的那个来替换这个。

现在,让我们专注于图像(b)。为什么我们有负值?因为当我们访问一个单元格时,我们将其颜色值切换为-颜色。这是一个方便的约定,用于避免多次计算单元格的相同连接集。这就像是说我们标记了这个单元格已被访问。按照约定,我们不能移动到具有颜色的负值的单元格,因此我们不会计算相同颜色斑点的大小两次。

现在,将这些观察结果组合成一个递归方法,得到以下代码:

public class BiggestColorSpot {
    private int currentColorSpot;
    void determineBiggestColorSpot(int cols, 
          int rows, int a[][]) {
      ...
    }  
    private void computeColorSpot(int i, int j, 
            int cols, int rows, int a[][], int color) {
        a[i][j] = -a[i][j];
        currentColorSpot++;
        if (i > 1 && a[i - 1][j] == color) {
            computeColorSpot(i - 1, j, cols, 
                rows, a, color);
        }
        if ((i + 1) < rows && a[i + 1][j] == color) {
           computeColorSpot(i + 1, j, cols, rows, a, color);
        }
        if (j > 1 && a[i][j - 1] == color) {
            computeColorSpot(i, j - 1, cols, 
                rows, a, color);
        }
        if ((j + 1) < cols && a[i][j + 1] == color) {
            computeColorSpot(i, j + 1, cols, 
                rows, a, color);
        }
    }
}

在给定单元格开始时,前面的递归方法computeColorSpot()可以计算颜色斑点的大小,而以下方法确定了最大的颜色斑点:

void determineBiggestColorSpot(int cols, 
      int rows, int a[][]) {
    int biggestColorSpot = 0;
    int color = 0;
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            if (a[i][j] > 0) {
               currentColorSpot = 0;
               computeColorSpot(i, j, cols, 
                 rows, a, a[i][j]);
               if (currentColorSpot > biggestColorSpot) {
                   biggestColorSpot = currentColorSpot;
                   color = a[i][j] * (-1);
               }
            }
        }
    }
    System.out.println("\nColor: " + color 
        + " Biggest spot: " + biggestColorSpot);
}

完整的应用程序称为BiggestColorSpot

编码挑战 5 - 硬币

GoogleAdobeMicrosoft

问题:考虑 n 美分的金额。计算您可以使用任意数量的 25 美分,10 美分,5 美分和 1 美分来更改此金额的方式。

解决方案:假设我们必须更改 50 美分。从一开始,我们就可以看到更改 50 美分是一个可以通过子问题解决的问题。例如,我们可以使用 0、1 或 2 个 25 美分来更改 50 美分。或者我们可以使用 0、1、2、3、4 或 5 个 10 美分来做到这一点。我们还可以使用 0、1、2、3、4、5、6、7、8、9 或 10 个 5 美分。最后,我们可以使用 0、1、2、3、...、50 个 1 美分。假设我们有 1 个 25 美分,1 个 10 美分,2 个 5 美分和 5 个 1 美分。我们可以使用我们的 25 美分来说以下内容:

calculateChange(50) = 1 个 25 美分 + ...

但这就像说以下内容:

calculateChange(25) = 0 个 25 美分 + ...

我们没有更多的 25 美分;因此,我们添加一个 10 美分:

calculateChange(25) = 0 个 25 美分 + 1 个 10 美分 + ...

这可以简化如下:

calculateChange(15) = 0 个 25 美分 + 0 个 10 美分 + ...

我们没有更多的 10 美分。我们添加了 5 美分:

calculateChange(15) = 0 个 25 美分 + 0 个 10 美分 + 2 个 5 美分 + ...

这可以简化为以下内容:

calculateChange(5) = 0 个 25 美分 + 0 个 10 美分 + 0 个 5 美分 + ...

最后,由于我们没有更多的 5 美分,我们添加了 1 美分:

calculateChange(5) = 0 个 25 美分 + 0 个 10 美分 + 0 个 5 美分 + 5 个 1 美分

这可以简化为以下内容:

calculateChange(0) = 0 个 25 美分 + 0 个 10 美分 + 0 个 5 美分 + 0 个 1 美分

如果我们试图表示所有可能的减少,我们得到以下图表:

图 8.8 - 将 n 美分换成 25 美分,10 美分,5 美分和 1 美分

图 8.8 - 将 n 美分换成 25 美分,10 美分,5 美分和 1 美分

通过递归实现这种可简化的算法,如下代码所示。请注意,我们使用Memoization来避免多次更改相同的金额:

public static int calculateChangeMemoization(int n) {
    int[] coins = {25, 10, 5, 1};
    int[][] cache = new int[n + 1][coins.length];
    return calculateChangeMemoization(n, coins, 0, cache);
}
private static int calculateChangeMemoization(int amount, 
      int[] coins, int position, int[][] cache) {
    if (cache[amount][position] > 0) {
        return cache[amount][position];
    }
    if (position >= coins.length - 1) {
        return 1;
    }
    int coin = coins[position];
    int count = 0;
    for (int i = 0; i * coin <= amount; i++) {
        int remaining = amount - i * coin;
        count += calculateChangeMemoization(remaining, 
            coins, position + 1, cache);
    }
    cache[amount][position] = count;
    return count;
}

完整的应用程序称为Coins。它还包含了纯递归方法(不包括Memoization)。

编码挑战 6 - 五座塔

问题:考虑一个 5x5 的网格,网格上分布着五座防御塔。为了为网格提供最佳防御,我们必须在网格的每一行上建造一座塔。找出建造这些塔的所有解决方案,以便它们没有共享相同的列和对角线。

解决方案:我们知道,在每一行上,我们必须建造一座塔,并且在网格上建造它们的顺序并不重要。让我们草拟一个解决方案和一个失败,如下所示:

图 8.9(a) - 失败和解决方案

图 8.9(a) - 失败和解决方案

让我们专注于解决方案,并从第一行开始:第 0 行。我们可以在任何列上的这一行上建造一座塔;因此,我们可以说以下内容:

图 8.9(b):构建塔的逻辑的第一部分

图 8.9(b):构建塔的逻辑的第一部分

如果我们继续使用相同的逻辑,那么我们可以说以下内容:

图 8.9(c):构建塔的逻辑的第二部分

图 8.9(c):构建塔的逻辑的第二部分

因此,我们从第一行开始,在(0,0)处建立第一个塔。我们转到第二行,并尝试建立第二个塔,以便不与第一个塔共享列或对角线。我们转到第三行,并尝试建立第三个塔,以便不与前两个塔共享列或对角线。我们对第四和第五个塔采用相同的逻辑。这是我们的解决方案。现在,我们重复此逻辑-我们在(0,1)处建立第一个塔,并继续建立,直到找到第二个解决方案。接下来,我们在(0,2)、(0,3)和最后在(0,4)处建立第一个塔,同时重复这个过程。我们可以将这个递归算法写成如下:

protected static final int GRID_SIZE = 5; // (5x5)
public static void buildTowers(int row, Integer[] columns, 
        Set<Integer[]> solutions) {
    if (row == GRID_SIZE) {
        solutions.add(columns.clone());
    } else {
        for (int col = 0; col < GRID_SIZE; col++) {
            if (canBuild(columns, row, col)) {
                // build this tower
                columns[row] = col;
                // go to the next row
                buildTowers(row + 1, columns, solutions);
            }
        }
    }
}
private static boolean canBuild(Integer[] columns, 
    int nextRow, int nextColumn) {
    for (int currentRow=0; currentRow<nextRow; 
            currentRow++) {
        int currentColumn = columns[currentRow];
        // cannot build on the same column
        if (currentColumn == nextColumn) {
            return false;
        }
        int columnsDistance
            = Math.abs(currentColumn - nextColumn);
        int rowsDistance = nextRow - currentRow;
        // cannot build on the same diagonal
        if (columnsDistance == rowsDistance) {
            return false;
        }
    }
    return true;
}

完整的应用程序称为FiveTowers

编码挑战 7-魔术索引

Adobe,Microsoft

问题:考虑一个允许重复的n个元素的排序数组。如果array[k] = k,则索引k是魔术索引。编写一个递归算法,找到第一个魔术索引。

解决方案:首先,让我们快速绘制包含 18 个元素的两个排序数组,如下图所示。图像顶部的数组不包含重复项,而图像底部的数组包含重复项。这样,我们可以观察到这些重复项的影响:

图 8.10-18 个元素的排序数组

图 8.10-18 个元素的排序数组

如果我们将不包含重复项的数组减半,那么我们可以得出结论,魔术索引必须在右侧,因为array[8] < 8。这是正确的,因为魔术索引是 11,所以array[11] = 11。

如果我们将包含重复项的数组减半,我们无法得出与之前相同的结论。魔术索引可以在两侧。在这里,我们有array[5] = 5 和array[12] = 12。我们必须找到第一个魔术索引,所以我们应该首先搜索左侧。

但是我们如何找到它呢?最明显的方法是循环数组并检查array[i] = i。虽然这对于任何有序数组都有效,但它不会给面试官留下深刻印象,因为它不是递归的,所以我们需要另一种方法。

第七章**,算法的大 O 分析中,您看到了通过二分搜索算法在排序数组中搜索的示例。由于在每一步中,我们都将前一个数组减半并创建一个子问题,因此可以通过递归实现此算法。由于数组的索引是有序的,我们可以调整二分搜索算法。我们面临的主要问题是重复元素使搜索变得复杂。当我们将数组减半时,我们无法说魔术索引在左侧还是右侧,因此我们必须在两个方向搜索,如下面的代码所示(首先,我们搜索左侧):

public static int find(int[] arr) {
    return find(arr, 0, arr.length - 1);
}
private static int find(int[] arr, 
        int startIndex, int endIndex) {
    if (startIndex > endIndex) {
        return -1; // return an invalid index
    }
    // halved the indexes
    int middleIndex = (startIndex + endIndex) / 2;
    // value (element) of middle index
    int value = arr[middleIndex];
    // check if this is a magic index
    if (value == middleIndex) {                                     
        return middleIndex;
    }
    // search from middle of the array to the left       
    int leftIndex = find(arr, startIndex, 
            Math.min(middleIndex - 1, value));
    if (leftIndex >= 0) {
        return leftIndex;
    }
    // search from middle of the array to the right               
    return find(arr,  Math.max(middleIndex + 1, 
          value), endIndex);
    }
}

完整的应用程序称为MagicIndex

编码挑战 8-下落的球

问题:考虑一个m x n的网格,其中每个(m, n)单元格的高程由 1 到 5 之间的数字表示(5 是最高的高程)。一个球放在网格的一个单元格中。只要该单元格的高程小于球单元格,球就可以掉落到另一个单元格。球可以向四个方向掉落:北、西、东和南。显示初始网格,以及球在所有可能路径上掉落后的网格。用 0 标记路径。

解决方案:始终注意问题的要求。注意我们必须显示解决的网格,而不是列出路径或计数。显示网格的最简单方法是使用两个循环,如下面的代码所示:

for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
        System.out.format("%2s", elevations[i][j]);
    }
    System.out.println();
}

现在,让我们勾画一个 5x5 的网格,并查看一个输入及其输出。下图显示了初始网格的 3D 模型形式,以及可能的路径和解决的网格:

图 8.11-下落的球

图 8.11-下落的球

我认为我们有足够的经验来直觉地认为这个问题可以通过递归来解决。主要是,我们将球移动到所有可接受的方向,并用 0 标记每个访问的单元格。当我们将球放在(i, j)单元格中时,我们可以朝着(i-1, j),(i+1, j),(i, j-1)和(i, j+1*)的方向前进,只要这些单元格的高度较小。在代码方面,我们有以下内容:

public static void computePath(
      int prevElevation, int i, int j, 
      int rows, int cols, int[][] elevations) {
    // ensure the ball is still on the grid
    if (i >= 0 && i <= (rows-1) && j >= 0 && j <= (cols-1)) {
        int currentElevation = elevations[i][j];
        // check if the ball can fall
        if (prevElevation >= currentElevation
                && currentElevation > 0) {
            // store the current elevation                       
            prevElevation = currentElevation;
            // mark this cell as visited
            elevations[i][j] = 0;
            // try to move the ball 
            computePath(prevElevation,i,j-1,
              rows,cols,elevations);
            computePath(prevElevation,i-1,   
              j,rows,cols,elevations);              
            computePath(prevElevation,i,j+1,
              rows,cols,elevations);              
            computePath(prevElevation,i+1,j,
              rows,cols,elevations);
        }
    }
}

完整的应用程序称为TheFallingBall

编程挑战 9 - 最高彩色塔

AdobeMicrosoftFlipkart

问题:考虑不同宽度(w1...n)、高度(h1...n)和颜色(c1...n)的n个盒子。找到符合以下条件的最高的盒子塔:

  • 你不能旋转盒子。

  • 你不能连续放置两个相同颜色的盒子。

  • 每个盒子在宽度和高度上都严格大于它上面的盒子。

解决方案:让我们试着将这个可视化,如下所示:

图 8.12(a) - 最高的彩色塔

图 8.12(a) - 最高的彩色塔

我们有七个不同尺寸和颜色的盒子。我们可以想象最高的塔将包含所有这些盒子,b1...b7。但是我们有一些约束条件,不允许我们简单地堆叠这些盒子。我们可以选择一个盒子作为基础盒子,并将另一个允许的盒子放在其顶部,如下所示:

![图 8.12(b) 选择盒子建造最高塔的逻辑](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/cpl-code-itw-gd-java/img/Figure_8.12(b)_ 选择盒子建造最高塔的逻辑.jpg)

图 8.12(b) 选择盒子建造最高塔的逻辑

因此,我们找到了一个模式。我们选择一个盒子作为基础,然后尝试看看剩下的盒子中哪个可以作为第二层放在顶部。我们对第三层也是同样的操作。当我们完成时(不能再添加盒子或没有剩余的盒子时),我们存储最高塔的大小。接下来,我们用另一个基础盒子重复这种情况。

由于每个盒子在宽度和高度上都必须大于上面的盒子,我们可以按宽度或高度按降序对盒子进行排序(选择哪一个并不重要)。这样,对于k < n的盒子的任何塔,我们可以通过搜索bk+1...n区间来找到下一个有效的盒子。

此外,我们可以通过记忆化来避免为相同的基础盒子重新计算最佳解决方案:

// Memoization
public static int buildViaMemoization(List<Box> boxes) {
    // sorting boxes by width (you can do it by height as well)
    Collections.sort(boxes, new Comparator<Box>() {
        @Override
        public int compare(Box b1, Box b2) {
            return Integer.compare(b2.getWidth(), 
                b1.getWidth());
        }
    });
    // place each box as the base (bottom box) and
    // try to arrange the rest of the boxes
    int highest = 0;
    int[] cache = new int[boxes.size()];
    for (int i = 0; i < boxes.size(); i++) {
        int height = buildMemoization(boxes, i, cache);
        highest = Math.max(highest, height);
    }
    return highest;
}
// Memoization
private static int buildMemoization(List<Box> boxes, 
      int base, int[] cache) {
    if (base < boxes.size() && cache[base] > 0) {
        return cache[base];
    }
    Box current = boxes.get(base);
    int highest = 0;
    // since the boxes are sorted we don’t 
    // look in [0, base + 1)
    for (int i = base + 1; i < boxes.size(); i++) {
        if (boxes.get(i).canBeNext(current)) {
            int height = buildMemoization(boxes, i, cache);
            highest = Math.max(height, highest);
        }
    }
    highest = highest + current.getHeight();
    cache[base] = highest;
    return highest;
}

完整的应用程序称为HighestColoredTower。代码还包含了这个问题的纯递归方法(没有记忆化)。

编程挑战 10 - 字符串排列

AmazonGoogleAdobeMicrosoftFlipkart

问题:编写一个算法,计算字符串的所有排列,并满足以下两个条件:

  • 给定的字符串可以包含重复项。

  • 返回的排列列表不应包含重复项。

解决方案:就像在任何递归问题中一样,关键在于识别不同子问题之间的关系和模式。我们立刻就能直观地感觉到,对具有重复字符的字符串进行排列应该比对具有唯一字符的字符串进行排列更复杂。这意味着我们必须先理解具有唯一字符的字符串的排列。

对字符串的字符进行排列的最自然的方式可以遵循一个简单的模式:字符串的每个字符将成为字符串的第一个字符(交换它们的位置),然后使用递归调用对所有剩余的字母进行排列。让我们深入研究一般情况。对于包含单个字符的字符串,我们有一个排列:

P(c1) = c1

如果我们添加另一个字符,那么我们可以按如下方式表示排列:

P(c1c2) = c1c2 和 c2c1

如果我们添加另一个字符,那么我们必须使用c1c2 来表示排列。每个c1c2c3 的排列代表了c1c2 的顺序,如下所示:

c1c2 -> c1c2c3,c1c3c2,c3c1c2

c2c1 -> c2c1c3,c2c3c1,c3c2c1

让我们用 ABC 替换c1c2c3。接下来,我们将 P(ABC)表示为图表:

图 8.13 – 对 ABC 进行排列

图 8.13 – 对 ABC 进行排列

如果我们添加另一个字符,那么我们必须使用c1c2c3c4 来表示排列。c1c2c3c4 的每个排列代表c1c2c3 的排序,如下所示:

c1c2c3 -> c1c2c3c4,c1c2c4c3,c1c4c2c3,c4c1c2c3

c1c3c2 -> c1c3c2c4,c1c3c4c2,c1c4c3c2,c4c1c3c2

c3c1c2 -> c3c1c2c4,c3c1c4c2,c3c4c1c2,c4c3c1c2

c2c1c3 -> c2c1c3c4,c2c1c4c3,c2c4c1c3,c4c2c1c3

c2c3c1 -> c2c3c1c4,c2c3c4c1,c2c4c3c1,c4c2c3c1

c3c2c1 -> c3c2c1c4,c3c2c4c1,c3c4c2c1,c4c3c2c1

我们可以一直这样继续下去,但我认为可以很清楚地知道可以用什么模式来生成P(c1, c2, ..., cn)。

因此,现在是时候进一步推进我们的逻辑了。现在,是时候问以下问题了:如果我们知道如何计算k-1 个字符的字符串的所有排列(c1c2...ck-1),那么我们如何使用这些信息来计算k个字符的字符串的所有排列(c1c2...ck-1ck)?例如,如果我们知道如何计算c1c2c3 字符串的所有排列,那么我们如何使用c1c2c3 的排列来表示c1c2c3c4 字符串的所有排列?答案是从c1c2...ck 字符串中取出每个字符,并将c1c2...ck-1 排列附加到它,如下所示:

P(c1c2c3c4) = [c1 + P(c2c3c4)] + [c2 + P(c1c3c4)] + [c3 + P(c1c2c4)] + [c4 + P(c1c2c3)]

[c1 + P(c2c3c4)] -> c1c2c3c4,c1c2c4c3,c1c3c2c4,c1c3c4c2,c1c4c2c3,c1c4c3c2

[c2 + P(c1c3c4)] -> c2c1c3c4,c2c1c4c3,c2c3c1c4,c2c3c4c1,c2c4c1c3,c2c4c3c1

[c3 + P(c1c2c4)] -> c3c1c2c4,c3c1c4c2,c3c2c1c4,c3c2c4c1,c3c4c1c2,c3c4c2c1

[c4 + P(c1c2c3)] -> c4c1c2c3,c4c1c3c2,c4c2c1c3,c4c2c3c1,c4c3c1c2,c4c3c2c1

我们可以继续添加另一个字符并重复此逻辑,以便我们有一个可以用代码表示的递归模式,如下所示:

public static Set<String> permute(String str) {        
    return permute("", str);
}
private static Set<String> permute(String prefix, String str) {
    Set<String> permutations = new HashSet<>();
    int n = str.length();
    if (n == 0) {
        permutations.add(prefix);
    } else {
        for (int i = 0; i < n; i++) {
            permutations.addAll(permute(prefix + str.charAt(i),
            str.substring(i + 1, n) + str.substring(0, i)));
        }
    }
    return permutations;
}

这段代码将正常工作。因为我们使用的是Set(而不是List),我们遵守了返回的排列列表不应包含重复项的要求。但是,我们确实生成了重复项。例如,如果给定的字符串是aaa,那么我们生成了六个相同的排列,即使只有一个。唯一的区别是它们没有被添加到结果中,因为Set不接受重复项。这远非高效。

我们可以通过多种方式避免生成重复项。一种方法是通过计算字符串的字符数并将其存储在映射中。例如,对于给定的字符串abcabcaa,键值映射可以是a=4,b=2,c=2。我们可以通过一个简单的辅助方法来实现这一点,如下所示:

private static Map<Character, Integer> charactersMap(
                 String str) {
    Map<Character, Integer> characters = new HashMap<>();
    BiFunction<Character, Integer, Integer> count = (k, v)
          -> ((v == null) ? 1 : ++v);
    for (char c : str.toCharArray()) {
        characters.compute(c, count);
    }
    return characters;
}

接下来,我们选择其中一个字符作为第一个字符,并找到其余字符的所有排列。我们可以表示如下:

P(a=4,b=2,c=2) = [a + P(a=3,b=2,c=2)] + [b + P(a=4,b=1,c=1)] + [c + P(a=4,b=2,c=1)]

P(a=3,b=2,c=2) = [a + P(a=2,b=2,c=2)] + [b + P(a=3,b=1,c=1)] + [c + P(a=3,b=2,c=1)]

P(a=4,b=1,c=1) = [a + P(a=3,b=1,c=1)] + [b + P(a=4,b=0,c=1)] + [c + P(a=4,b=1,c=0)]

P(a=4,b=2,c=1) = [a + P(a=3,b=2,c=1)] + [b + P(a=4,b=1,c=1)] + [c + P(a=4,b=2,c=0)]

P(a=2,b=2,c=2) = [a + P(a=1,b=2,c=2)] + [b + P(a=2,b=1,c=2)] + [c + P(a=2,b=2,c=1)]

P(* a = * 3 ,b = * 1 ,c = * 1) = ...

我们可以继续写,直到没有剩余字符。现在,将这些放入代码行应该相当简单:

public static List<String> permute(String str) {      
    return permute("", str.length(), charactersMap(str));
}
private static List<String> permute(String prefix, 
        int strlength, Map<Character, Integer> characters) {
    List<String> permutations = new ArrayList<>();
    if (strlength == 0) {
        permutations.add(prefix);
    } else {
        // fetch next char and generate remaining permutations
        for (Character c : characters.keySet()) {
            int count = characters.get(c);
            if (count > 0) {
                characters.put(c, count - 1);
                permutations.addAll(permute(prefix + c, 
                    strlength - 1, characters));
                characters.put(c, count);
            }
        }
    }
    return permutations;
}

完整的应用程序称为排列

编码挑战 11 - 骑士之旅

亚马逊谷歌

问题:考虑一个棋盘(8x8 网格)。在这个棋盘上放一个骑士,并打印出它所有独特的移动。

解决方案:正如您已经看到的,解决这类问题的最佳方法是拿出一张纸和一支笔,勾画出情景。一幅图胜过千言万语:

图 8.14 - 骑士之旅

图 8.14 - 骑士之旅

正如我们所看到的,骑士可以从一个(rc)单元格移动到最多八个其他有效单元格;也就是说,(r+2,c+1),(r+1,c+2),(r-1,c+2),(r-2,c+1),(r-2,c-1),(r-1,c-2),(r+1,c-2),和(r+2,c-1)。因此,为了获得从 1 到 64 的路径(如前图右侧所示),我们可以从给定位置开始,并递归地尝试访问每个有效的移动。如果当前路径不代表一个解决方案,或者我们已经尝试了所有八个单元格,那么我们就会回溯。

为了尽可能高效,我们考虑以下几个方面:

  • 我们从棋盘的一个角开始:这样,骑士最初只能朝两个方向走,而不是八个。

  • 我们按照固定顺序检查有效单元格:保持循环路径将帮助我们比随机选择更快地找到新的移动。从(rc)的逆时针循环路径是(r+2,c+1),(r+1,c+2),(r-1,c+2),(r-2,c+1),(r-2,c-1),(r-1,c-2),(r+1,c-2),和(r+2,c-1)。

  • 我们使用两个数组计算循环路径:我们可以从(rc)移动到(r + ROW[i],c + COL[i])其中i*在[0, 7]:

COL[] = {1,2,2,1,-1,-2,-2,-1,1};

ROW[] = {2,1,-1,-2,-2,-1,1,2,2};

  • 我们通过在一个r x c矩阵中存储访问的单元格来避免路径中的循环和重复工作(例如,多次访问相同的单元格)。

通过将所有内容粘合在代码方面,我们得到以下递归方法:

public class KnightTour {
    private final int n;
    // constructor omitted for brevity
    // all 8 possible movements for a knight
    public static final int COL[] 
        = {1,2,2,1,-1,-2,-2,-1,1};
    public static final int ROW[] 
        = {2,1,-1,-2,-2,-1,1,2,2};
    public void knightTour(int r, int c, 
            int cell, int visited[][]) {

        // mark current cell as visited
        visited[r][c] = cell;
        // we have a solution
        if (cell >= n * n) {
            print(visited);
            // backtrack before returning
            visited[r][c] = 0;
            return;
        }
        // check for all possible movements (8) 
        // and recur for each valid movement
        for (int i = 0; i < (ROW.length - 1); i++) {
            int newR = r + ROW[i];
            int newC = c + COL[i];
            // check if the new position is valid un-visited
            if (isValid(newR, newC) 
                  && visited[newR][newC] == 0) { 
                knightTour(newR, newC, cell + 1, visited);
            }
        }

        // backtrack from current cell
        // and remove it from current path
        visited[r][c] = 0;
    }
    // check if (r, c) is valid chess board coordinates    
    private boolean isValid(int r, int c) {        
        return !(r < 0 || c < 0 || r >= n || c >= n);
    }
    // print the solution as a board
    private void print(int[][] visited) {
    ...   
    }
}

完整的应用程序称为KnightTour

编码挑战 12 - 大括号

亚马逊谷歌Adobe微软Flipkart

问题:打印出n对大括号的所有有效组合。当大括号正确打开和关闭时,才是有效组合。对于n=3,有效组合如下:

{{{}}},{{}{}},{{}}{},{}{{}},{}{}{}

解决方案n=1 的有效组合是{}。

对于n=2,我们立即看到组合为{}{}。然而,另一个组合包括在前一个组合中添加一对大括号;也就是说,{{}}。

再进一步,对于n=3,我们有平凡的组合{}{}{}。按照相同的逻辑,我们可以为n=2 的组合添加一对大括号,因此我们得到{{{}}}, {{}}{}, {}{{}}, {{}{}}。

实际上,这是我们在删除或忽略重复后得到的结果。让我们根据n=2 来勾画n=3 的情况,如下所示:

图 8.15 - 大括号重复对

图 8.15 - 大括号重复对

因此,如果我们在每个现有的大括号中添加一对大括号,并且我们添加平凡的情况({}{}...{}),那么我们就会得到一个可以通过递归实现的模式。然而,我们必须处理大量重复对,因此我们需要额外的检查来避免最终结果中出现重复。

因此,让我们考虑另一种方法,从一个简单的观察开始。对于任何给定的 n,一个组合将有 2n 个花括号(不是一对!)。例如,对于 n=3,我们有六个花括号(三个左花括号({{{)和三个右花括号(}}}})以不同的有效组合排列。这意味着我们可以尝试通过从零花括号开始并向其添加左/右花括号来构建解决方案,只要我们有一个有效的表达式。当然,我们要跟踪添加的花括号的数量,以便不超过最大数量 2n。我们必须遵循的规则如下:

  • 我们以递归方式添加所有左花括号。

  • 我们以递归方式添加右花括号,只要右花括号的数量不超过左花括号的数量。

换句话说,这种方法的关键是跟踪允许的左花括号和右花括号的数量。只要我们有左花括号,我们就插入一个左花括号并再次调用该方法(递归)。如果剩下的右花括号比左花括号多,那么我们就插入一个右花括号并调用该方法(递归)。所以,让我们开始编码:

public static List<String> embrace(int nr) {
    List<String> results = new ArrayList<>();
    embrace(nr, nr, new char[nr * 2], 0, results);
    return results;
}
private static void embrace(int leftHand, int rightHand, 
      char[] str, int index, List<String> results) {
    if (rightHand < leftHand || leftHand < 0) {
        return;
    }
    if (leftHand == 0 && rightHand == 0) {
        // result found, so store it
        results.add(String.valueOf(str));
    } else {
        // add left brace
        str[index] = '{';
        embrace(leftHand - 1, rightHand, str, index + 1, 
            results);
        // add right brace
        str[index] = '}';
        embrace(leftHand, rightHand - 1, str, index + 1, 
            results);
    }
}

完整的应用程序称为Braces

编码挑战 13 - 楼梯

亚马逊Adobe微软

问题:一个人走上楼梯。他们可以一次跳一步、两步或三步。计算他们可以到达楼梯顶部的可能方式的数量。

解决方案:首先,让我们设定一步、两步或三步跳的含义。考虑到一步跳意味着一步一步地上楼梯(我们每步都着陆)。跳两步意味着跳过一步并着陆在下一步。最后,跳三步意味着跳过两步并着陆在第三步。

例如,如果我们考虑一个有三个台阶的楼梯,那么我们可以以四种方式从第 0 步(或者,没有步骤)到第 3 步:一步一步(我们每步都着陆),我们跳过第 1 步并着陆在第 2 步上并走在第 3 步上,我们走在第 1 步上并跳过第 2 步,从而着陆在第 3 步上,或者我们直接跳到第 3 步,如下图所示:

图 8.16 - 楼梯(如何到达第 3 步)

图 8.16 - 楼梯(如何到达第 3 步)

通过进一步推理,我们可以问自己如何到达第 n 步。主要是,如果我们按照以下步骤,就可以到达第 n 步:

  • n-1 步和跳 1 步

  • n-2 步和跳 2 步

  • n-3 步和跳 3 步

然而,只要我们遵循前面的要点,就可以到达这些步骤中的任何一个 - n-1、n-2 或 n-3。例如,如果我们在 n-2 上并跳 1 步,我们在 n-3 上并跳 2 步,或者我们在 n-4 上并跳 3 步,我们就可以到达 n-1 步。

因此,要达到第 n 步,我们有三条可能的路径。要达到第 n-1 步,我们也有三条可能的路径。因此,要达到这两个步骤,我们必须有 3+3=6 条路径。不要说 3*3=9 条路径!这是错误的!

现在,我们可以得出结论,以递归方式添加所有路径应该给我们预期的答案。此外,我们还可以利用我们的经验来添加记忆化。这样,我们就避免了多次使用相同输入调用该方法(就像斐波那契数的情况一样):

public static int countViaMemoization(int n) {
    int[] cache = new int[n + 1];  
    return count(n, cache);
}
private static int count(int n, int[] cache) {
    if (n == 0) {
        return 1;
    } else if (n < 0) {
        return 0;
    } else if (cache[n] > 0) {
        return cache[n];
    }
    cache[n] = count(n - 1, cache) 
        + count(n - 2, cache) + count(n - 3, cache);
    return cache[n];
}

完整的应用程序称为Staircase。它还包含了纯递归方法(没有记忆化)。

编码挑战 14 - 子集和

亚马逊Adobe微软Flipkart

问题:考虑一个给定的正整数集合(arr)和一个值s。编写一小段代码,找出数组中是否存在一个子集,其总和等于给定的s

解决方案:让我们考虑数组arr = {3, 2, 7, 4, 5, 1, 6, 7, 9}。如果s=7,那么一个子集可以包含元素 2、4 和 1,如下图所示:

图 8.17 - 和为 7 的子集

图 8.17 - 和为 7 的子集

包含元素 2、4 和 1 的子集只是可能子集中的一个。所有可能的子集包括(3, 4)、(2, 4, 1)、(2, 5)、(7)、(1, 6)和(7)。

递归方法

让我们尝试通过递归找到一个解决方案。如果我们添加子集arr[0]=3,那么我们必须找到和为s = s-arr[0] = 7-3 = 4 的子集。找到和为s=4 的子集是一个可以基于相同逻辑解决的子问题,这意味着我们可以将arr[1]=2 添加到子集中,下一个子问题将包括找到和为s = s-arr[1] = 4-2 = 2 的子集。

或者,我们可以这样思考:从sum=0 开始。我们将arr[0]=3 加到这个sum上,得到sum=sum+arr[0] = 3。接下来,我们检查sum = s(例如,如果 3 = 7)。如果是,我们找到了一个子集。如果不是,我们将下一个元素arr[1]=2 加到sum上,得到sum = sum+arr[1] = 3+2 =5。我们递归地继续重复这个过程,直到没有更多的元素可以添加。在这一点上,我们递归地从sum中移除元素,并在每次移除时检查sum = s。换句话说,我们构建了每个可能的子集,并检查它的sum是否等于s。当我们有这个相等时,我们打印当前的子集。

到目前为止,很明显,如果我们递归地解决每一个子问题,那么它会引导我们得到结果。对于arr中的每个元素,我们必须做出一个决定。主要的是,我们有两个选择:将当前元素包含在子集中或者不包含它。基于这些陈述,我们可以创建以下算法:

  1. 将子集定义为与给定arr长度相同的数组。这个数组只取值 1 和 0。

  2. 通过在arr中递归地添加每个元素到子集中,将该特定索引处的值设置为 1。检查解决方案(当前和=给定和)。

  3. 通过在特定索引处将子集中的每个元素递归地移除,将该值设置为 0。检查解决方案(当前和=给定和)。

让我们看看代码:

/* Recursive approach */
public static void findSumRecursive(int[] arr, int index,
      int currentSum, int givenSum, int[] subset) {
    if (currentSum == givenSum) {
        System.out.print("\nSubset found: ");
        for (int i = 0; i < subset.length; i++) {
            if (subset[i] == 1) {
                System.out.print(arr[i] + " ");
            }
        }
    } else if (index != arr.length) {
        subset[index] = 1;
        currentSum += arr[index];
        findSumRecursive(arr, index + 1, 
                currentSum, givenSum, subset);
        currentSum -= arr[index];
        subset[index] = 0;
        findSumRecursive(arr, index + 1, 
                currentSum, givenSum, subset);
    }
}

这段代码的时间复杂度是 O(n2n),因此远非高效。现在,让我们尝试通过动态规划的迭代方法。这样,我们就避免了重复解决同一个问题。

动态规划方法

通过动态规划,我们可以在 O(sn)的时间内解决这个问题。更确切地说,我们可以依赖自底向上的方法和一个维度为(n+1) x (s+1)的boolean二维矩阵,其中n是集合arr*的大小。

要理解这个实现,你必须理解这个矩阵是如何填充和读取的。如果我们考虑给定的arr是{5, 1, 6, 10, 7, 11, 2},s=9,那么这个boolean矩阵从一个初始状态开始,如下图所示:

图 8.18 – 初始矩阵

图 8.18 – 初始矩阵

因此,我们有s+1 = 9+1 = 10 列和n+1 = 7+1 = 8 行。你可以看到,我们已经填满了第 0 行和第 0 列。这些是基本情况,可以解释如下:

  • 初始化矩阵的第一行(row 0)(matrix[0][])为 0(或false,F),除了matrix[0][0],它初始化为 1(或true,T)。换句话说,如果给定的和不是 0,那么就没有子集可以满足这个和。然而,如果给定的和是 0,那么就有一个只包含 0 的子集。因此,包含 0 的子集可以形成一个和为 0 的单一子集。

  • 将矩阵的第一列(column 0)(matrix[][0])初始化为 1(或true,T),因为对于任何集合,都可以有一个和为 0 的子集。

接下来,我们取每一行(5, 1, 6, ...)并尝试用 F 或 T 填充它。让我们考虑包含元素 5 的第二行。现在,对于每一列,让我们回答以下问题:我们能用 5 形成和为列号的子集吗?让我们看一下输出:

图 8.19 – 填充第二行

图 8.19 – 填充第二行

  • 我们能用 5 形成和为 1 的子集吗?不能,所以是 false(F)。

  • 我们能用 5 形成和为 2 的子集吗?不能,所以是 false(F)。

...

  • 我们能用 5 形成和为 5 的子集吗?能,所以是 true(T)。

  • 我们能够用 5 组成和为 6 的子集吗?不行,所以为假(F)。

...

  • 我们能够用 5 组成和为 9 的子集吗?不行,所以为假(F)。

我们可以尝试将这个问题应用到剩下的每一行,但是我们前进得越多,问题就会变得越困难。此外,我们没有算法无法在代码中实现这个问题。幸运的是,我们可以使用一个可以应用于每个(row, column)单元格的算法。这个算法包含以下步骤:

  1. 当当前行(i)的元素大于当前列(j)的值时,我们只需复制前一个值(i-1, j),填入当前的(i, j)单元格中。

  2. 如果当前行(i)的元素小于或等于当前列(j)的值,则我们查看(i-1, j)单元格,并执行以下操作:

a. 如果单元格(i-1, j)是 T,则我们也在(i, j)单元格中填入 T。

b. 如果单元格(i-1, j)是 F,则我们在(i, j)单元格中填入(i-1, j-element_at_this_row)的值。

如果我们将这个算法应用于第二行(包含元素 5),那么我们将得到以下图表中显示的相同结果:

图 8.20 – 将算法应用于第二行

图 8.20 – 将算法应用于第二行

根据步骤 1,对于 5 < 1,5 < 2,5 < 3 和 5 < 4,我们复制前一个单元格的值。当我们到达单元格(1, 5)时,我们有 5=5,所以我们需要应用步骤 2。更确切地说,我们应用步骤 2b。单元格(1-1, 5-5)是单元格(0, 0),其值为 T。因此,单元格(1, 5)被填入 T。相同的逻辑适用于其余的单元格。例如,单元格(1, 6)被填入 F,因为 F 是(0, 5)的值;单元格(1, 7)被填入 F,因为 F 是(0, 6)的值,依此类推。

如果我们将这个算法应用于所有行,那么我们将得到以下填充的矩阵:

图 8.21 – 完整矩阵

图 8.21 – 完整矩阵

请注意,我们突出显示了最后一个单元格(7, 9)。如果右下角的单元格的值为 T,则表示至少存在一个满足给定和的子集。如果为 F,则表示没有这样的子集。

因此,在这种情况下,存在一个子集,其和等于 9。我们能够识别它吗?是的,我们可以,通过以下算法:

  1. 从右下角的单元格开始,即 T(假设这个单元格是(i, j))。

a. 如果这个单元格上面的单元格(i-1, j)是 F,则写下这一行的元素(这个元素是子集的一部分),并前往单元格(i-1, j-element_at_this_row)。

b. 当上方的单元格(i-1, j)是 T 时,我们向上移动到单元格(i-1, j)。

c. 重复步骤 1a,直到整个子集都被写下。

让我们画出我们这种情况下的子集路径:

图 8.22 – 子集解决路径

图 8.22 – 子集解决路径

因此,我们从右下角的单元格开始,即(7, 9),其值为 T。因为这个单元格是 T,我们可以尝试找到和为 9 的子集。接下来,我们应用步骤 1a,所以我们写下第 7 行的元素(即 2),并前往单元格(7-1, 9-2)=(6, 7)。到目前为止,子集为{2}。

接下来,我们应用步骤 1b,所以我们来到单元格(3, 7)。单元格上方的单元格(3, 7)的值为 F,所以我们应用步骤 1a。首先,我们写下第 3 行的元素,即 6。然后,我们前往单元格(3-1, 7-6)=(2, 1)。到目前为止,子集为{2, 6}。

上方的单元格(2, 1)的值为 F,所以我们应用步骤 1a。首先,我们写下第 2 行的元素,即 1。然后,我们前往单元格(2-1, 1-1)=(1, 0)。在(1,0)单元格上方,我们只有 T,所以我们停止。当前和最终的子集为{2, 6, 1}。显然,2+6+1=9。

以下代码将澄清任何其他细节(这段代码可以告诉我们给定的和至少有一个对应的子集):

/* Dynamic Programming (Bottom-Up) */
public static boolean findSumDP(int[] arr, int givenSum) {
    boolean[][] matrix 
          = new boolean[arr.length + 1][givenSum + 1];
    // prepare the first row
    for (int i = 1; i <= givenSum; i++) {
        matrix[0][i] = false;
    }
    // prepare the first column
    for (int i = 0; i <= arr.length; i++) {
        matrix[i][0] = true;
    }
    for (int i = 1; i <= arr.length; i++) {
        for (int j = 1; j <= givenSum; j++) {
            // first, copy the data from the above row
            matrix[i][j] = matrix[i - 1][j];
            // if matrix[i][j] = false compute 
            // if the value should be F or T
            if (matrix[i][j] == false && j >= arr[i – 1]) {
                matrix[i][j] = matrix[i][j] 
                  || matrix[i - 1][j - arr[i - 1]];
            }
        }
    }
    printSubsetMatrix(arr, givenSum, matrix);
    printOneSubset(matrix, arr, arr.length, givenSum);
    return matrix[arr.length][givenSum];
}

printSubsetMatrix()printOneSubset()方法可以在名为SubsetSum的完整代码中找到。

编码挑战 15 – 单词拆分(这是一个著名的谷歌问题)

亚马逊谷歌Adobe微软Flipkart

如果给定的字符串(str)可以分割成一个以空格分隔的字典单词序列,则返回true

解决方案:这个问题在谷歌和亚马逊中很常见,在撰写本文时,它被许多中大型公司采用。如果我们在谷歌中输入一个毫无意义的字符串,那么谷歌会尝试将其分解为单词,并问我们是否这实际上是我们想要输入的。例如,如果我们输入"thisisafamousproblem",那么谷歌会问我们是否想要输入"this is a famous problem"。

基于纯递归的解决方案

因此,如果我们假设给定的字符串是str="thisisafamousproblem",给定的字典是{"this" "is" "a" "famous" "problem"},那么我们可以得到结果;即"this is a famous problem"。

那么,我们如何做到这一点呢?我们如何检查给定的字符串是否可以分割成一个以空格分隔的字典单词序列?

让我们从一个观察开始。如果我们从给定字符串的第一个字符开始,那么我们会注意到"t"不是给定字典中的一个单词。我们可以继续将第二个字符附加到"t",这样我们得到"th"。由于"th"不是给定字典中的一个单词,我们可以附加第三个字符"i"。显然,"thi"不是字典中的一个单词,所以我们附加第四个字符"s"。这一次,我们找到了一个单词,因为"this"是字典中的一个单词。这个单词成为结果的一部分。

进一步推理,如果我们找到了"this",那么最初的问题就被减小为一个更小的问题,即找到剩下的单词。因此,通过添加每个字符,问题就会减小为一个更小的问题,但本质上仍然是相同的。这听起来像是递归实现的理想案例。

如果我们详细说明递归算法,那么我们必须执行以下步骤:

  1. 从第一个字符(索引0)开始迭代给定字符串str

  2. 从给定字符串(通过子字符串,我们理解为从索引到 1 的子字符串,从索引到 2 的子字符串,...从索引str.length的子字符串)中取出每个子字符串。换句话说,只要当前子字符串不是给定字典中的一个单词,我们就继续从给定字符串str中添加一个字符。

  3. 如果当前子字符串是给定字典中的一个单词,那么我们更新索引,使其成为这个子字符串的长度,并依靠递归来检查从索引str.length的剩余字符串。

  4. 如果索引达到字符串的长度,我们返回true;否则,我们返回false

这段代码如下:

private static boolean breakItPlainRecursive(
      Set<String> dictionary, String str, int index) {
    if (index == str.length()) {
        return true;
    }
    boolean canBreak = false;
    for (int i = index; i < str.length(); i++) {
        canBreak = canBreak
          || dictionary.contains(str.substring(index, i + 1))
          && breakItPlainRecursive(dictionary, str, i + 1);
    }
    return canBreak;
}

这段代码的运行时间并不奇怪是指数级的。现在,是时候部署动态规划了。

自底向上的解决方案

我们可以避免递归,而是部署动态规划。更确切地说,我们可以使用这里显示的自底向上解决方案:

public static boolean breakItBottomUp(
           Set<String> dictionary, String str) {
  boolean[] table = new boolean[str.length() + 1];
  table[0] = true;
  for (int i = 0; i < str.length(); i++) {
    for (int j = i + 1; table[i] && j <= str.length(); j++) {
      if (dictionary.contains(str.substring(i, j))) {
        table[j] = true;
      }
    }
  }
  return table[str.length()];
}

这段代码仍然以指数时间 O(n2)运行。

基于 Trie 的解决方案

解决这个问题的最有效方法依赖于动态规划和 Trie 数据结构,因为它提供了最佳的时间复杂度。您可以在书籍Java 编程问题中找到 Trie 数据结构的详细实现:(www.amazon.com/gp/product/B07Y9BPV4W/)。

让我们考虑将给定字符串分解为表示其单词的一组组件的问题。如果pstr的前缀,qstr的后缀(剩余的字符),那么pq就是strpq的连接就是str)。如果我们可以通过递归将pq分解为单词,那么我们可以通过合并两组单词来分解pq=str

现在,让我们在给定单词字典的 Trie 的上下文中继续这种逻辑。我们可以假设p是字典中的一个单词,我们必须找到一种构造它的方法。这正是 Trie 派上用场的地方。因为p被认为是字典中的一个单词,pstr的前缀,我们可以说p必须通过由str的前几个字母组成的路径在 Trie 中找到。为了通过动态规划实现这一点,我们使用一个数组,让我们将其表示为table。每当我们找到一个合适的q时,我们通过在table数组中设置一个解决方案来表示它,解决方案在|p|+1 处,其中|p|是前缀p的长度。这意味着我们可以通过检查最后一个条目来确定整个字符串是否可以被分解。让我们看看这段代码:

public class Trie {
    // characters 'a'-'z'
    private static final int CHAR_SIZE = 26;
    private final Node head;
    public Trie() {
        this.head = new Node();
    }
    // Trie node
    private static class Node {
        private boolean leaf;
        private final Node[] next;
        private Node() {
            this.leaf = false;
            this.next = new Node[CHAR_SIZE];
        }
    };
    // insert a string in Trie
    public void insertTrie(String str) {
        Node node = head;
        for (int i = 0; i < str.length(); i++) {
            if (node.next[str.charAt(i) - 'a'] == null) {
                node.next[str.charAt(i) - 'a'] = new Node();
            }
            node = node.next[str.charAt(i) - 'a'];
        }
        node.leaf = true;
    }
    // Method to determine if the given string can be 
    // segmented into a space-separated sequence of one or 
    // more dictionary words
    public boolean breakIt(String str) {
        // table[i] is true if the first i
        // characters of str can be segmented
        boolean[] table = new boolean[str.length() + 1];
        table[0] = true;
        for (int i = 0; i < str.length(); i++) {
            if (table[i]) {
                Node node = head;
                for (int j = i; j < str.length(); j++) {
                    if (node == null) {
                        break;
                    }
                    node = node.next[str.charAt(j) - 'a'];
                    // [0, i]: use our known decomposition
                    // [i+1, j]: use this String in the Trie
                    if (node != null && node.leaf) {
                        table[j + 1] = true;
                    }
                }
            }
        }
        // table[n] would be true if 
        // all characters of str can be segmented
        return table[str.length()];
    }
}

显然,因为我们有两个嵌套循环,所以这个解决方案的运行时间是 O(n2)。实际上,内部循环在节点为null时会中断。在最坏的情况下,这发生在k步之后,其中k是 Trie 中最深路径。因此,对于包含大小为z的最长单词的字典,我们有k=z+1。这意味着内部循环的时间复杂度是 O(z),总时间复杂度是 O(nz)。额外空间是 O(Trie 的空间+str.length)。

完整的应用程序称为WordBreak。该应用程序还包含一个打印可以为给定字符串生成的所有字符串的方法。例如,如果给定的字符串是"thisisafamousproblem",字典是{"this", "th", "is", "a", "famous", "f", "a", "m", "o", "u", "s", "problem"},那么输出将包含四个序列:

  • 这是一个著名的问题

  • 这是一个著名的问题

  • 这是一个著名的问题

  • 这是一个著名的问题

完成!现在是时候总结本章了。

总结

在本章中,我们涵盖了面试中最流行的话题之一:递归和动态规划。掌握这个话题需要大量的练习。幸运的是,本章提供了一套全面的问题,涵盖了最常见的递归模式。从排列到基于网格的问题,从经典问题如汉诺塔到棘手的问题如生成花括号,本章涵盖了广泛的递归案例。

不要忘记解决递归问题的关键在于绘制有意义的草图并练习多种情况。这样,您可以识别模式和递归调用。

在下一章中,我们将讨论需要位操作的问题。

第九章:位操作

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

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

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

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

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

  • 理解位操作

  • 编码挑战

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

技术要求

本章中的所有代码都可以在 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 位整数)

图 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 - 按位操作符

图 9.2 - 按位操作符

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

图 9.3 - 按位操作提示

图 9.3 - 按位操作提示

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

位移操作符

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

有符号左移[<<]

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

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

图 9.4 - 有符号左移

图 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 - 签名右移

图 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 - 无符号右移

图 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 - 位掩码

图 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 二进制表示

图 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 二进制表示

图 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 二进制表示

图 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 二进制表示

图 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-求和二进制数

图 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 = 2 * and & xorxor = 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-相乘二进制数

图 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 在代码中相乘二进制

图 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

图 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-减去二进制数

图 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 - 二进制数的除法

图 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 之间的位

图 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)

图 9.19 - 位掩码(a)

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

图 9.20 - 位掩码(b)

图 9.20 - 位掩码(b)

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

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

图 19.21 二进制表示

图 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 最长 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 - 非尾随零

图 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)

图 9.25 - 输出(1)

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

图 9.26 - 输出(2)

图 9.26 - 输出(2)

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

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

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

图 9.27 输出(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 转换

图 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.29 - 最大化表达式(1)

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

图 9.30 最大化表达式(2)

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

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

图 9.31 最大化表达式(3)

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

现在,如果我们说当a = b时,a * b是最大的,那么让我们表示a =(q AND s)和b =(p AND s)。因此,当(q AND s)=(p AND s)时,(q AND sp* 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.32 - 将 q 和 p 向右移动 1 个位置

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

图 9.33 - 两种情况

图 9.33 - 两种情况

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

图 9.34 答案

图 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)

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

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

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

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

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

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

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

图 9.37 最终结果

图 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– 左旋转位

图 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 减法

图 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 – 找到给定数组中的唯一元素

图 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 的幂

图 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)

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

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

图 9.44 浮点值

图 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 字符串

图 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 – 独特字符位掩码

图 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 字符(代理对)

图 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 分成两部分并重新排列

图 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 关系

图 10.5 - 矩阵转置关系

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

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

图 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 - 逐环旋转矩阵

图 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-包含零的矩阵

图 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-交错堆栈的节点

图 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 – 将数组分割成三个区域

图 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 - 找到所有和为给定数字的对

图 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 - 奇数和偶数数组的中位数值

图 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 - 将数组分成两个相等的部分

图 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)

图 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)

图 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 - 中位数(最终结果)

图 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 - 给定的 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 - 4x4 矩阵中 1s 的最大子矩阵

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

这些情况解释如下:

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

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

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

图 10.19 - 整体递归关系

图 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 - 解决我们的 5x7 矩阵

图 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.19 – n 条垂直线表示

图 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 – 最大宽度的区域

图 10.22 – 最大宽度的区域

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

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

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

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

图 10.24 – 在增加/减少 i 和 j 时计算面积

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

步骤如下:

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

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

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

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

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

  6. 在右下角的图像中,我们增加了i,因为pi < pj,p4 < 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 – 循环排序数组和二分搜索算法

图 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-对给定的间隔进行排序

图 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 – 使用栈解决问题

图 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 – 合并后的区间

图 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 – 卡车环形旅游示例

图 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.30 - 给定的一组酒吧

现在,雨水在这些酒吧之间的空隙中积水。因此,雨后我们将得到以下情况:

图 10.31 - 雨后的给定酒吧

图 10.31 - 雨后的给定酒吧

因此,在这里,我们最多可以获得 16 单位的水。这个问题的解决方案取决于我们如何看待水。例如,我们可以看看酒吧之间的水,或者看看每个酒吧顶部的水。第二种观点正是我们想要的。

查看以下图片,其中有一些关于如何隔离每个酒吧顶部的水的额外指导:

图 10.32 - 每个酒吧顶部的水

图 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 - 左右两侧最高的酒吧

图 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 - 价格趋势图

图 10.34 - 价格趋势图

根据上图,我们应该在第 5 天以 30 的价格买入股票,并在第 7 天以 900 的价格卖出。这样,利润将达到最大值(870)。为了确定最大利润,我们可以采用一个简单的算法,如下所示:

  1. 考虑第 1 天的最低价格,没有利润(最大利润为 0)。

  2. 迭代剩余的天数(2、3、4、...)并执行以下操作:

a. 对于每一天,将最大利润更新为 max(当前最大利润,(今天的价格 - 最低价格))。

b. 将最低价格更新为 min(当前最低价格,今天的价格)。

让我们将这个算法应用到我们的数据中。因此,我们将第 1 天的最低价格视为 200,最大利润为 0。下图显示了每天的计算:

图 10.35 - 计算最大利润

图 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 - 价格趋势图

图 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 天开始计算每天之前的最大利润

图 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 - 从前一天开始计算每天的最大利润

图 10.38 - 从前一天开始计算每天的最大利润

到目前为止,我们已经完成了分割部分。现在,是征服部分的时间了。可以通过 max(left[day]+right[day])获得可以在两次交易中实现的最大利润。我们可以在下图中看到这一点:

图 10.39 - 计算第 1 和第 2 次交易的最终最大利润

图 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 - 价格趋势图

图 10.40 - 价格趋势图

根据前面的图表,我们应该在第 1 天以 200 的价格买入股票,然后在第 2 天以 500 的价格卖出。这次交易带来了 300 的利润。接下来,我们应该在第 2 天以 500 的价格买入股票,然后在第 3 天以 1000 的价格卖出。这次交易带来了 500 的利润。当然,我们可以将这两次交易合并为一次,即在第 1 天以 200 的价格买入,然后在第 3 天以 1000 的价格卖出。同样的逻辑可以应用到第 9 天。最终的最大利润将是 1820。花点时间,确定从第 1 天到第 9 天的所有交易。

通过研究前面的价格趋势图,我们可以看到这个问题可以被视为尝试找到所有的升序序列。以下图突出显示了我们数据的升序序列:

图 10.41 - 升序序列

图 10.41 - 升序序列

根据以下算法,找到所有的升序序列是一个简单的任务:

  1. 最大利润视为 0(无利润)。

  2. 迭代所有的天数,从第 2 天开始,并执行以下操作:

a. 计算今日价格前一天价格之间的差异(例如,在第一次迭代中,计算(第 2 天的价格 - 第 1 天的价格),所以 500 - 200)。

b. 如果计算出的差异为正数,则将最大利润增加这个差异。

在这个算法结束时,我们将知道最终的最大利润。如果我们将这个算法应用到我们的数据中,那么我们将得到以下输出:

图 10.42 - 计算最终最大利润

图 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-序列集

图 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 个以上的字符串和数组问题,这些问题在本书中没有涉及。

在下一章中,我们将讨论链表和映射。

第十一章:链表和映射

本章涵盖了在编码面试中遇到的涉及映射和链表的最受欢迎的编码挑战。由于在技术面试中更喜欢使用单向链表,本章中的大多数问题将利用它们。但是,您可以挑战自己,尝试在双向链表的情况下解决每个问题。通常,对于双向链表来说,问题变得更容易解决,因为双向链表为每个节点维护两个指针,并允许我们在列表内前后导航。

通过本章结束时,您将了解涉及链表和映射的所有热门问题,并且将具有足够的知识和理解各种技术,以帮助您解决此类问题。我们的议程非常简单;我们将涵盖以下主题:

  • 链表简介

  • 映射简介

  • 编码挑战

技术要求

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

但在进行编码挑战之前,让我们先了解一下链表和映射。

链表简介

链表是表示节点序列的线性数据结构。第一个节点通常被称为头部,而最后一个节点通常被称为尾部。当每个节点指向下一个节点时,我们有一个单向链表,如下图所示:

11.1:单向链表

图 11.1 – 单向链表

当每个节点指向下一个节点和前一个节点时,我们有一个双向链表,如下图所示:

11.2:双向链表

图 11.2 – 双向链表

让我们考虑一个单向链表。如果尾部指向头部,那么我们有一个循环单向链表。或者,让我们考虑一个双向链表。如果尾部指向头部,头部指向尾部,那么我们有一个循环双向链表

在单向链表中,一个节点保存数据(例如,整数或对象)和指向下一个节点的指针。以下代码表示单向链表的节点:

private final class Node {
  private int data;
  private Node next;
}

双向链表还需要指向前一个节点的指针:

private final class Node {
  private int data;
  private Node next;
  private Node prev;
}

与数组不同,链表不提供访问第 n 个元素的常数时间。我们必须迭代 n-1 个元素才能获得第 n 个元素。我们可以在常数时间内从链表(单向和双向)的开头插入,删除和更新节点。如果我们的实现管理双向链表的尾部(称为双头双向链表),那么我们也可以在常数时间内从链表的末尾插入,删除和更新节点;否则,我们需要迭代链表直到最后一个节点。如果我们的实现管理单向链表的尾部(称为双头单向链表),那么我们可以在常数时间内在链表的末尾插入节点;否则,我们需要迭代链表直到最后一个节点。

本书的代码包包括以下应用程序(每个应用程序都公开insertFirst()insertLast()insertAt()delete()deleteByIndex()print()方法):

  • SinglyLinkedList:双头单向链表的实现

  • SinglyLinkedListOneHead:单头单向链表的实现

  • DoublyLinkedList:双头双向链表的实现

  • DoublyLinkedListOneHead:单头双向链表的实现

强烈建议您自己彻底分析这些应用程序。每个应用程序都有大量注释,以帮助您理解每个步骤。以下编码挑战依赖于这些链表实现。

简而言之,地图

想象一下,您正在字典中查找一个单词。这个单词本身是唯一的,可以被视为。这个单词的意思可以被视为。因此,这个单词及其意思形成了一个键值对。同样,在计算中,键值对容纳了一段数据,可以通过键来查找值。换句话说,我们知道键,我们可以用它来找到值。

地图是一个抽象数据类型ADT),通过数组管理键值对(称为条目)。地图的特征包括以下内容:

  • 键是唯一的(即,不允许重复键)。

  • 我们可以查看键的列表,值的列表,或两者。

  • 处理地图的最常见方法是get()put()remove()

现在我们已经简要概述了链表和地图的概念,让我们开始我们的编码挑战。

编码挑战

在接下来的 17 个编码挑战中,我们将涵盖涉及地图和链表的许多问题。由于链表是技术面试中更受欢迎的话题,我们将为它们分配更多的问题。然而,为了掌握地图数据结构的概念,特别是内置的 Java 地图实现,我强烈建议您购买 Packt Publishing 出版的书籍Java 编码问题www.packtpub.com/programming/java-coding-problems)。除了是本书的绝佳伴侣外,Java 编码问题还包含以下地图问题(请注意,这不是完整的列表):

  • 创建不可修改/不可变集合

  • 映射默认值

  • 计算Map中值的存在/不存在

  • Map中删除

  • 替换Map中的条目

  • 比较两个地图

  • Map进行排序

  • 复制HashMap

  • 合并两个地图

  • 删除与谓词匹配的集合的所有元素

现在我们对链表和地图有了基本的了解,让我们来看看与地图和链表相关的面试中最常见的问题。

编码挑战 1 - Map put,get 和 remove

put(K k, V v),一个名为get(K k)的方法,和一个名为remove(K k)的方法。

解决方案:正如您所知,地图是一个键值对数据结构。每个键值对都是地图的一个条目。因此,我们无法实现地图的功能,直到我们实现一个条目。由于一个条目包含两个信息,我们需要定义一个类来以通用的方式包装键和值。

代码非常简单:

private final class MyEntry<K, V> {
  private final K key;
  private V value;
  public MyEntry(K key, V value) {
    this.key = key;
    this.value = value;
  }
  // getters and setters omitted for brevity
}

现在我们有了一个条目,我们可以声明一个地图。地图通过具有默认大小的条目数组来管理,这个默认大小称为地图容量。具有 16 个元素的初始容量的地图声明如下:

private static final int DEFAULT_CAPACITY = 16;
private MyEntry<K, V>[] entries 
        = new MyEntry[DEFAULT_CAPACITY];

接下来,我们可以专注于使用这个数组作为客户端的地图。只有在条目的键在地图中是唯一的情况下,才能将条目放入地图中。如果给定的键存在,则只需更新其值。除此之外,只要我们没有超出地图的容量,就可以添加一个条目。在这种情况下的典型方法是将地图的大小加倍。基于这些语句的代码如下:

private int size;
public void put(K key, V value) {
  boolean success = true;
  for (int i = 0; i < size; i++) {
    if (entries[i].getKey().equals(key)) {
      entries[i].setValue(value);
      success = false;
    }
  }
  if (success) {
    checkCapacity();
    entries[size++] = new MyEntry<>(key, value);
  }
}

以下辅助方法用于将地图的容量加倍。由于 Java 数组无法调整大小,我们需要通过创建初始数组的副本,但大小加倍来解决这个问题:

private void checkCapacity() {
  if (size == entries.length) {
    int newSize = entries.length * 2;
    entries = Arrays.copyOf(entries, newSize);
  }
}

使用键来获取值。如果找不到给定的键,则返回null。获取值不会从地图中删除条目。让我们看一下代码:

public V get(K key) {
  for (int i = 0; i < size; i++) {
    if (entries[i] != null) {
      if (entries[i].getKey().equals(key)) {
        return entries[i].getValue();
      }
    }
  }
  return null;
}

最后,我们需要使用键来删除一个条目。从数组中删除一个元素涉及将剩余的元素向前移动一个位置。元素移动后,倒数第二个和最后一个元素相等。通过将数组的最后一个元素置空,可以避免内存泄漏。忘记这一步是一个常见的错误:

public void remove(K key) {
  for (int i = 0; i < size; i++) {
    if (entries[i].getKey().equals(key)) {
      entries[i] = null;
      size--;
      condenseArray(i);
    }
  }
}
private void condenseArray(int start) {
  int i;
  for (i = start; i < size; i++) {
    entries[i] = entries[i + 1];
  }
  entries[i] = null; // don't forget this line
}

地图的生产实现比这里展示的要复杂得多(例如,地图使用桶)。然而,很可能在面试中你不需要了解比这个实现更多的内容。尽管如此,向面试官提到这一点是个好主意。这样,你可以向他们展示你理解问题的复杂性,并且你意识到了这一点。

完成!完整的应用程序名为Map

编码挑战 2 - 映射键集和值

keySet())和一个返回值集合的方法(values())。

Set。以下代码不言自明:

public Set<K> keySet() {
  Set<K> set = new HashSet<>();
  for (int i = 0; i < size; i++) {
    set.add(entries[i].getKey());
  }
  return set;
}

为了返回一个值的集合,我们循环遍历映射并将值逐个添加到List中。我们使用List,因为值可能包含重复项:

public Collection<V> values() {
  List<V> list = new ArrayList<>();
  for (int i = 0; i < size; i++) {
    list.add(entries[i].getValue());
  }
  return list;
}

完成!这很简单;生产中实现的地图比这里展示的要复杂得多。例如,值被缓存而不是每次都被提取。向面试官提到这一点,让他/她看到你知道生产地图是如何工作的。花点时间检查 Java 内置的MapHashMap源代码。

完整的应用程序名为Map

编码挑战 3 - 螺母和螺栓

谷歌Adobe

问题:给定n个螺母和n个螺栓,考虑它们之间的一一对应关系。编写一小段代码,找出螺母和螺栓之间的所有匹配项,使迭代次数最少。

解决方案:让我们假设螺母和螺栓分别由以下两个数组表示:

char[] nuts = {'$', '%', '&', 'x', '@'};
char[] bolts = {'%', '@', 'x', '$', '&'};

最直观的解决方案依赖于蛮力方法。我们可以选择一个螺母,并迭代螺栓以找到它的配偶。例如,如果我们选择nuts[0],我们可以用bolts[3]找到它的配偶。此外,我们可以取nuts[1],并用bolts[0]找到它的配偶。这个算法非常简单,可以通过两个for语句来实现,并且具有 O(n2)的时间复杂度。

或者,我们可以考虑对螺母和螺栓进行排序。这样,螺母和螺栓之间的匹配将自动对齐。这也可以工作,但不会包括最少的迭代次数。

为了获得最少的迭代次数,我们可以使用哈希映射。在这个哈希映射中,首先,我们将每个螺母作为一个键,将其在给定螺母数组中的位置作为一个值。接下来,我们迭代螺栓,并检查哈希映射是否包含每个螺栓作为一个键。如果哈希映射包含当前螺栓的键,那么我们找到了一个匹配(一对);否则,这个螺栓没有匹配。让我们看一下代码:

public static void match(char[] nuts, char[] bolts) {
  // in this map, each nut is a key and 
  // its position is as value
  Map<Character, Integer> map = new HashMap<>();
  for (int i = 0; i < nuts.length; i++) {
    map.put(nuts[i], i);
  }
  //for each bolt, search a nut
  for (int i = 0; i < bolts.length; i++) {
    char bolt = bolts[i];
    if (map.containsKey(bolt)) {
      nuts[i] = bolts[i];
    } else {
      System.out.println("Bolt " + bolt + " has no nut");
    }
  }
  System.out.println("Matches between nuts and bolts: ");
  System.out.println("Nuts: " + Arrays.toString(nuts));
  System.out.println("Bolts: " +Arrays.toString(bolts));
}

这段代码的运行时间是 O(n)。完整的代码名为NutsAndBolts

编码挑战 4 - 删除重复项

亚马逊谷歌Adobe微软

问题:考虑一个未排序的整数单向链表。编写一小段代码来删除重复项。

Set<Integer>。然而,在将当前节点的数据添加到Set之前,我们检查数据是否与Set的当前内容相匹配。如果Set已经包含该数据,我们就从链表中删除节点;否则,我们只是将其数据添加到Set中。从单向链表中删除节点可以通过将前一个节点链接到当前节点的下一个节点来完成。

以下图示说明了这个陈述:

11.3: 从单向链表中删除节点

图 11.3 - 从单向链表中删除节点

由于单链表只保存指向下一个节点的指针,我们无法知道当前节点之前的节点。技巧是跟踪两个连续的节点,从当前节点作为链表头部和前一个节点作为null开始。当当前节点前进到下一个节点时,前一个节点前进到当前节点。让我们看一下将这些语句组合在一起的代码:

// 'size' is the linked list size
public void removeDuplicates() {
  Set<Integer> dataSet = new HashSet<>();
  Node currentNode = head;
  Node prevNode = null;
  while (currentNode != null) {
    if (dataSet.contains(currentNode.data)) {
      prevNode.next = currentNode.next;
      if (currentNode == tail) {
        tail = prevNode;
      }
      size--;
    } else {
      dataSet.add(currentNode.data);
      prevNode = currentNode;
    }
    currentNode = currentNode.next;
  }
}

这个解决方案的时间和空间复杂度为 O(n),其中n是链表中的节点数。我们可以尝试另一种方法,将空间复杂度降低到 O(1)。首先,让我们将以下图表作为下一步的指南:

11.4:从单链表中移除节点

图 11.4 - 从单链表中移除节点

这种方法使用两个指针:

  1. 当前节点从链表的头部开始遍历链表,直到到达尾部(例如,在前面的图表中,当前节点是第二个节点)。

  2. 奔跑者节点,从与当前节点相同的位置开始,即链表的头部。

此外,奔跑者节点遍历链表,并检查每个节点的数据是否等于当前节点的数据。当奔跑者节点遍历链表时,当前节点的位置保持不变。

如果奔跑者节点检测到重复,那么它会将其从链表中移除。当奔跑者节点到达链表的尾部时,当前节点前进到下一个节点,奔跑者节点再次从当前节点开始遍历链表。因此,这是一个 O(n2)时间复杂度的算法,但空间复杂度为 O(1)。让我们看一下代码:

public void removeDuplicates() {
  Node currentNode = head;
  while (currentNode != null) {
    Node runnerNode = currentNode;
    while (runnerNode.next != null) {
      if (runnerNode.next.data == currentNode.data) {
        if (runnerNode.next == tail) {
          tail = runnerNode;
        }
        runnerNode.next = runnerNode.next.next;
        size--;
      } else {
        runnerNode = runnerNode.next;
      }
    }
    currentNode = currentNode.next;
  }
}

完整的代码名为LinkedListRemoveDuplicates

编码挑战 5 - 重新排列链表

AdobeFlipkartAmazon

问题:考虑一个未排序的整数单链表和一个给定的整数n。编写一小段代码,围绕n重新排列节点。换句话说,最后,链表将包含所有小于n的值,后面跟着所有大于n的节点。节点的顺序可以改变,n本身可以位于大于n的值之间的任何位置。

解决方案:假设给定的链表是 1→5→4→3→2→7→null,n=3。所以,3 是我们的枢轴。其余的节点应该围绕这个枢轴重新排列,符合问题的要求。解决这个问题的一个方法是逐个遍历链表节点,并将小于枢轴的每个节点放在头部,而大于枢轴的每个节点放在尾部。以下图表帮助我们可视化这个解决方案:

11.5:链表重新排列

图 11.5 - 链表重新排列

因此,值为 5、4 和 3 的节点被移动到尾部,而值为 2 的节点被移动到头部。最后,所有小于 3 的值都在虚线的左侧,而所有大于 3 的值都在虚线的右侧。我们可以将此算法编写成以下代码:

public void rearrange(int n) {
  Node currentNode = head;
  head = currentNode;
  tail = currentNode;
  while (currentNode != null) {
    Node nextNode = currentNode.next;
    if (currentNode.data < n) {
      // insert node at the head
      currentNode.next = head;
      head = currentNode;
    } else {
      // insert node at the tail
      tail.next = currentNode;
      tail = currentNode;
    }
    currentNode = nextNode;
  }
  tail.next = null;
}

完整的应用程序名为LinkedListRearranging

编码挑战 6 - 倒数第 n 个节点

AdobeFlipkartAmazonGoogleMicrosoft

问题:考虑一个整数单链表和一个给定的整数n。编写一小段代码,返回倒数第 n 个节点的值。

解决方案:我们有一堆节点,我们必须找到满足给定约束的第n个节点。根据我们从第八章的经验,递归和动态规划,我们可以直觉地认为这个问题有一个涉及递归的解决方案。但我们也可以通过迭代解决它。由于迭代解决方案更有趣,我将在这里介绍它,而递归解决方案在捆绑代码中可用。

让我们使用以下图表来呈现算法(按照从上到下的顺序遵循图表):

11.6: The nth to last node

图 11.6 - 最后第 n 个节点

因此,我们有一个链表,2 → 1 → 5 → 9 → 8 → 3 → 7 → null,并且我们想要找到第五个到最后一个节点值,即 5(您可以在前面的图表顶部看到)。迭代解决方案使用两个指针;让我们将它们表示为runner1runner2。最初,它们都指向链表的头部。在步骤 1(前面图表的中间),我们将runner1从头移动到第 5 个到头(或n到头)节点。这在for循环中从 0 到 5(或n)中很容易实现。在步骤 2(前面图表的底部),我们同时移动runner1runner2,直到runner1null。当runner1null时,runner2将指向距离头部第五个到最后一个节点(或n到最后一个)节点。在代码行中,我们可以这样做:

public int nthToLastIterative(int n) {
  // both runners are set to the start
  Node firstRunner = head;
  Node secondRunner = head;
  // runner1 goes in the nth position
  for (int i = 0; i < n; i++) {
    if (firstRunner == null) {
      throw new IllegalArgumentException(
             "The given n index is out of bounds");
    }
    firstRunner = firstRunner.next;
  }
  // runner2 run as long as runner1 is not null
  // basically, when runner1 cannot run further (is null), 
  // runner2 will be placed on the nth to last node
  while (firstRunner != null) {
    firstRunner = firstRunner.next;
    secondRunner = secondRunner.next;
  }
  return secondRunner.data;
}

完整的应用程序名为LinkedListNthToLastNode

编码挑战 7 - 循环开始检测

AdobeFlipkartAmazonGoogleMicrosoft

问题:考虑一个包含循环的整数单链表。换句话说,链表的尾部指向之前的一个节点,定义了一个循环或循环。编写一小段代码来检测循环的第一个节点(即循环开始的节点)。

tail.next. 如果我们不管理尾部,那么我们可以搜索具有两个指向它的节点的节点。这也很容易实现。如果我们知道链表的大小,那么我们可以从 0 到大小进行迭代,最后一个node.next指向标记循环开始的节点。

快跑者/慢跑者方法

然而,让我们尝试另一种需要更多想象力的算法。这种方法称为快跑者/慢跑者方法。它很重要,因为它可以用于涉及链表的某些问题。

主要的快跑者/慢跑者方法涉及使用两个指针,它们从链表的头部开始,并同时遍历列表,直到满足某些条件。一个指针被命名为慢跑者SR),因为它逐个节点地遍历列表。另一个指针被命名为快跑者FR),因为它在每次移动时跳过下一个节点来遍历列表。以下图表是四个移动的示例:

11.7: Fast Runner/Slow Runner example

图 11.7 - 快跑者/慢跑者示例

因此,在第一步移动时,FRSR指向head。在第二步移动时,SR指向值为 1 的head.next节点,而FR指向值为 4 的head.next.next节点。移动继续遵循这种模式。当FR到达链表的尾部时,SR指向中间节点。

正如您将在下一个编码挑战中看到的,快跑者/慢跑者方法可以用于检测链表是否是回文。但是,现在让我们恢复我们的问题。那么,我们可以使用这种方法来检测链表是否有循环,并找到此循环的起始节点吗?这个问题引发了另一个问题。如果我们将快跑者/慢跑者方法应用于具有循环的链表,FRSR指针会相撞或相遇吗?答案是肯定的,它们会相撞。

解释一下,假设在开始循环之前,我们有q个先行节点(这些节点在循环外)。对于SR遍历的每个q个节点,FR已经遍历了 2q个节点(这是显而易见的,因为FR在每次移动时都会跳过一个节点)。因此,当SR进入循环(到达循环起始节点)时,FR*已经遍历了 2q个节点。换句话说,FR在循环部分的 2**q-q节点处;因此,它在循环部分的q个节点处。让我们通过以下测试案例来形象化这一点:

11.8: 带有循环的链表

图 11.8 - 带有循环的链表

因此,当SR进入循环(到达第四个节点)时,FR也到达了循环的第四个节点。当然,我们需要考虑到q(先行非循环节点的数量)可能比循环长度要大得多;因此,我们应该将 2**q-q表示为Q=modulo(q, LOOP_SIZE)*。

例如,考虑Q = modulo(3, 8) =3,其中我们有三个非循环节点(q=3),循环大小为八(LOOP_SIZE=8)。在这种情况下,我们也可以应用 2**q-q,因为 23-3=3。因此,我们可以得出SR距离列表开头三个节点,FR距离循环开头三个节点。然而,如果链表前面有 25 个节点,后面有 7 个节点的循环,那么Q = modulo (25, 7) = 4 个节点,而 2*25-25=25,这是错误的。

除此之外,FRSR在循环内移动。由于它们在一个圆圈内移动,这意味着当FR远离SR时,它也在向SR靠近,反之亦然。下图将循环隔离出来,并展示了它们如何继续移动FRSR直到它们相撞:

11.9: FR and SR collision

图 11.9 - FR 和 SR 碰撞

花时间追踪SRFR直到它们到达相遇点。我们知道FRFR落后LOOP_SIZE - Q个节点,SRFR落后Q个节点。在我们的测试案例中,FRSR落后 8-3=5 个节点,SRFR落后 3 个节点。继续移动SRFR,我们可以看到FR以每次移动 1 步的速度追上了。

那么,它们在哪里相遇呢?如果FR以每次移动 1 步的速度追上,FRSR落后LOOP_SIZE - Q个节点,那么它们将在离循环头部Q步的地方相遇。在我们的测试案例中,它们将在距离循环头部 3 步的地方相遇,节点值为 8。

如果相遇点距离循环头部的节点数为Q,我们可以继续回想相遇点距离循环头部的节点数也为q,因为Q=modulo(q, LOOP_SIZE)。这意味着我们可以制定以下四步算法:

  1. 从链表的头部开始FRSR

  2. SR以 1 个节点的速度移动,FR以 2 个节点的速度移动。

  3. 当它们相撞(在相遇点),将SR移动到链表的头部,保持FR在原地。

  4. SRFR以 1 个节点的速度移动,直到它们相撞(这是代表循环头部的节点)。

让我们把这写成代码:

public void findLoopStartNode() {
  Node slowRunner = head;
  Node fastRunner = head;
  // fastRunner meets slowRunner
  while (fastRunner != null && fastRunner.next != null) {
    slowRunner = slowRunner.next;
    fastRunner = fastRunner.next.next;
    if (slowRunner == fastRunner) { // they met
      System.out.println("\nThe meet point is at 
        the node with value: " + slowRunner);
      break;
    }
  }
  // if no meeting point was found then there is no loop
  if (fastRunner == null || fastRunner.next == null) {
    return;
  }
  // the slowRunner moves to the head of the linked list
  // the fastRunner remains at the meeting point
  // they move simultaneously node-by-node and 
  // they should meet at the loop start
  slowRunner = head;
  while (slowRunner != fastRunner) {
    slowRunner = slowRunner.next;
    fastRunner = fastRunner.next;
  }
  // both pointers points to the start of the loop
  System.out.println("\nLoop start detected at 
      the node with value: " + fastRunner);
}

作为一个快速的提示,不要期望FR能够跳过SR,所以它们不会相遇。这种情况是不可能的。想象一下,FR已经跳过了SR,它在节点a,那么SR必须在节点a-1。这意味着,在上一步中,FR在节点a-2,SR在节点(a-1)-1=a-2;因此,它们已经相撞了。

完整的应用程序名为LinkedListLoopDetection。在这段代码中,你会找到一个名为generateLoop()的方法。调用这个方法可以生成带有循环的随机链表。

编码挑战 8 - 回文

Adobe,Flipkart,Amazon,Google,Microsoft

如果链表是回文的,则返回true。解决方案应该涉及快速运行者/慢速运行者方法(这种方法在先前的编码挑战中有详细介绍)。

解决方案:只是一个快速提醒,回文(无论是字符串、数字还是链表)在翻转时看起来没有变化。这意味着处理(读取)回文可以从两个方向进行,得到的结果是相同的(例如,数字 12321 是一个回文,而数字 12322 不是)。

我们可以通过思考,当FR到达链表的末尾时,SR正好在链表的中间,来直观地得出使用快慢指针方法的解决方案。

如果链表的前半部分是后半部分的倒序,那么链表就是一个回文。因此,如果我们在栈中存储FR到达链表末尾之前SR遍历的所有节点,那么结果栈将包含链表前半部分的倒序。让我们通过以下图表来可视化这一点:

11.10:使用快慢指针方法的链表回文

图 11.10 - 使用快慢指针方法的链表回文

因此,当FR到达链表的末尾,SR到达第四个节点(链表的中间)时,栈包含值 2、1 和 4。接下来,我们可以继续以 1 个节点的速度移动SR,直到链表的末尾。在每次移动时,我们从栈中弹出一个值,并将其与当前节点的值进行比较。如果我们发现不匹配,那么链表就不是回文。在代码中,我们有以下内容:

public boolean isPalindrome() {
  Node fastRunner = head;
  Node slowRunner = head;
  Stack<Integer> firstHalf = new Stack<>();
  // the first half of the linked list is added into the stack
  while (fastRunner != null && fastRunner.next != null) {
    firstHalf.push(slowRunner.data);
    slowRunner = slowRunner.next;
    fastRunner = fastRunner.next.next;
  }
  // for odd number of elements we to skip the middle node
  if (fastRunner != null) {
    slowRunner = slowRunner.next;
  }
  // pop from the stack and compare with the node by node of 
  // the second half of the linked list
  while (slowRunner != null) {
    int top = firstHalf.pop();
    // a mismatch means that the list is not a palindrome
    if (top != slowRunner.data) {
      return false;
    }
    slowRunner = slowRunner.next;
  }
  return true;
}

完整的应用程序名为LinkedListPalindrome

编码挑战 9 - 两个链表相加

AdobeFlipkartMicrosoft

问题:考虑两个正整数和两个单链表。第一个整数按位存储在第一个链表中(第一个数字是第一个链表的头)。第二个整数按位存储在第二个链表中(第一个数字是第二个链表的头)。编写一小段代码,将这两个数字相加,并将和作为一个链表返回,每个节点一个数字。

解决方案:让我们从一个测试案例的可视化开始:

11.11:将两个数字作为链表相加

图 11.11 - 将两个数字作为链表相加

如果我们逐步计算前面图表的总和,我们得到以下结果:

我们添加 7 + 7 = 14,所以我们写下 4 并携带 1:

结果链表是 4 →?

我们添加 3 + 9 + 1 = 13,所以我们写下 3 并携带 1:

结果链表是 4 → 3 →?

我们添加 8 + 8 + 1 = 17,所以我们写下 7 并携带 1:

结果链表是 4 → 3 → 7 →?

我们添加 9 + 4 + 1 = 14,所以我们写下 4 并携带 1

结果链表是 4 → 3 → 7 → 4 →?

我们添加 4 + 1 = 5,所以我们写下 5 并携带无:

结果链表是 4 → 3 → 7 → 4 → 5 →?

我们添加 1 + 0 = 1,所以我们写下 1 并携带无:

结果链表是 4 → 3 → 7 → 4 → 5 → 1 →?

我们添加 2 + 0 = 2,所以我们写下 2 并携带无:

结果链表是 4 → 3 → 7 → 4 → 5 → 1 → 2

如果我们将结果链表写成一个数字,我们得到 4374512;因此,我们需要将其反转为 2154734。虽然反转结果链表的方法(可以被视为一个编码挑战)可以在捆绑代码中找到,但以下方法以递归的方式应用了前面的步骤(如果你不擅长递归问题,请不要忘记阅读第八章递归和动态规划)。基本上,以下递归通过逐个节点添加数据,将任何多余的数据传递到下一个节点:

private Node sum(Node node1, Node node2, int carry) {
  if (node1 == null && node2 == null && carry == 0) {
    return null;
  }
  Node resultNode = new Node();
  int value = carry;
  if (node1 != null) {
    value += node1.data;
  }
  if (node2 != null) {
    value += node2.data;
  }
  resultNode.data = value % 10;
  if (node1 != null || node2 != null) {
    Node more = sum(node1 == null
        ? null : node1.next, node2 == null
        ? null : node2.next, value >= 10 ? 1 : 0);
    resultNode.next = more;
  }
  return resultNode;
}

完整的应用程序名为LinkedListSum

编码挑战 10 - 链表交集

AdobeFlipkartGoogleMicrosoft

问题:考虑两个单链表。编写一小段代码,检查这两个列表是否相交。交集是基于引用的,而不是基于值的,但是你应该返回交集节点的值。因此,通过引用检查交集并返回值。

解决方案:如果你不确定两个链表的交集是什么意思,那么我们建议你勾画一个测试用例,并与面试官讨论细节。下面的图表展示了这样一个情况:

11.12: 两个列表的交集

图 11.12 – 两个列表的交集

在这个图表中,我们有两个相交的列表,它们在值为 8 的节点处相交。因为我们谈论的是引用交集,这意味着值为 9 和值为 4 的节点指向值为 8 的节点的内存地址。

主要问题是列表的大小不同。如果它们的大小相等,我们可以从头到尾遍历它们,逐个节点,直到它们相撞(直到node_list_1.next= node_list_2.next)。如果我们能跳过值为 2 和 1 的节点,我们的列表将是相同大小的(参考下一个图表;因为第一个列表比第二个列表长,我们应该从标记为虚拟头的节点开始迭代):

11.13: Removing the first two nodes of the top list

图 11.13 – 移除顶部列表的前两个节点

记住这个陈述,我们可以推导出以下算法:

  1. 确定列表的大小。

  2. 如果第一个列表(我们将其表示为l1)比第二个列表(我们将其表示为l2)长,那么将第一个列表的指针移动到(l1-l2)。

  3. 如果第一个列表比第二个列表短,那么将第二个列表的指针移动到(l2-l1)。

  4. 逐个移动两个指针,直到达到末尾或者它们相撞为止。

将这些步骤转化为代码是直接的:

public int intersection() {
  // this is the head of first list
  Node currentNode1 = {head_of_first_list};
  // this is the head of the second list
  Node currentNode2 = {head_of_second_list};
  // compute the size of both linked lists
  // linkedListSize() is just a helper method
  int s1 = linkedListSize(currentNode1);
  int s2 = linkedListSize(currentNode2);
  // the first linked list is longer than the second one
  if (s1 > s2) {
    for (int i = 0; i < (s1 - s2); i++) {
      currentNode1 = currentNode1.next;
    }
  } else {
    // the second linked list is longer than the first one
    for (int i = 0; i < (s2 - s1); i++) {
      currentNode2 = currentNode2.next;
    }
  }
  // iterate both lists until the end or the intersection node
  while (currentNode1 != null && currentNode2 != null) {
    // we compare references not values!
    if (currentNode1 == currentNode2) {
      return currentNode1.data;
    }
    currentNode1 = currentNode1.next;
    currentNode2 = currentNode2.next;
  }
  return -1;
}

完整的应用程序名为LinkedListsIntersection。在代码中,你会看到一个名为generateTwoLinkedListWithInterection()的辅助方法。这用于生成具有交集点的随机列表。

编码挑战 11 – 交换相邻节点

亚马逊谷歌

问题:考虑一个单链表。编写一小段代码,交换相邻的节点,使得一个列表,比如 1 → 2 → 3 → 4 → null,变成 2 → 1 → 4 → 3 → null。考虑交换相邻的节点,而不是它们的值!

解决方案:我们可以将交换两个相邻节点n1n2的问题简化为找到解决方案。交换两个值(例如,两个整数v1v2)的一个众所周知的技巧依赖于一个辅助变量,并且可以写成如下形式:

aux = v1; v1 = v2; v2 = aux;

然而,我们不能对节点应用这种简单的方法,因为我们必须处理它们的链接。仅仅写下面这样是不够的:

aux = n1; n1 = n2; n2 = aux;

如果我们依赖这种简单的方法来交换n1n2,那么我们将得到类似于以下图表的东西(注意,在交换n1n2之后,我们有n1.next = n3n2.next = n1,这是完全错误的):

11.14: Plain swapping with broken links (1)

图 11.14 – 交换破损链接(1)

但是我们可以修复链接,对吧?嗯,我们可以明确地设置n1.next指向n2,并设置n2.next指向n3

n1.next = n2

n2.next = n3

现在应该没问题了!我们可以交换两个相邻的节点。然而,当我们交换一对节点时,我们也会破坏两对相邻节点之间的链接。下面的图表说明了这个问题(我们交换并修复了n1-n2对和n3-n4对的链接):

11.15: Plain swapping with broken links (2)

图 11.15 – 交换破损链接(2)

注意,在交换这两对之后,n2.next指向了* n4,这是错误的。因此,我们必须修复这个链接。为此,我们可以存储n2,在交换n3-n4之后,我们可以通过设置n2.next=n3*来修复链接。现在,一切看起来都很好,我们可以将其放入代码中:

public void swap() {
  if (head == null || head.next == null) {
    return;
  }
  Node currentNode = head;
  Node prevPair = null;
  // consider two nodes at a time and swap their links
  while (currentNode != null && currentNode.next != null) {
    Node node1 = currentNode;           // first node
    Node node2 = currentNode.next;      // second node                    
    Node node3 = currentNode.next.next; // third node            
    // swap node1 node2
    Node auxNode = node1;
    node1 = node2;
    node2 = auxNode;
    // repair the links broken by swapping
    node1.next = node2;
    node2.next = node3;
    // if we are at the first swap we set the head
    if (prevPair == null) {
      head = node1;
    } else {
      // we link the previous pair to this pair
      prevPair.next = node1;
    }
    // there are no more nodes, therefore set the tail
    if (currentNode.next == null) {
      tail = currentNode;
    }
    // prepare the prevNode of the current pair
    prevPair = node2;
    // advance to the next pair
    currentNode = node3;
  }
}

完整的应用程序名为LinkedListPairwiseSwap。考虑挑战自己交换n个节点的序列。

编码挑战 12 - 合并两个排序的链表

亚马逊谷歌Adobe微软Flipkart

问题:考虑两个排序的单链表。编写一小段代码,将这两个列表合并而不使用额外空间。

解决方案:所以,我们有两个排序的列表,list1:4 → 7 → 8 → 10 → null 和list2:5 → 9 → 11 → null,我们希望得到结果,4 → 5 → 7 → 8 → 9 → 10 → 11 → null。此外,我们希望在不分配新节点的情况下获得这个结果。

由于我们不能分配新节点,我们必须选择其中一个列表成为最终结果或合并的链表。换句话说,我们可以从list1开始作为合并的链表,并在list1的适当位置添加list2的节点。在处理每次比较后,我们将指针(list1)移动到合并列表的最后一个节点。

例如,我们首先比较这两个列表的头部。如果list1的头部小于list2的头部,我们选择list1的头部作为合并列表的头部。否则,如果list1的头部大于list2的头部,我们交换头部。以下图表说明了这一步骤:

图 11.16 - 合并两个排序的链表(步骤 1)

图 11.16 - 合并两个排序的链表(步骤 1)

由于list1的头部小于list2的头部(4 < 5),它成为了合并列表的头部。我们说list1将指向合并列表的最后一个节点;因此,下一个要比较的节点应该是list1.next(值为 7 的节点)和list2(值为 5 的节点)。以下图表显示了这个比较的结果:

图 11.17 - 合并两个排序的链表(步骤 2)

图 11.17 - 合并两个排序的链表(步骤 2)

因为list1跟随合并后的列表(最终结果),我们必须将list1.next移动到值为 5 的节点,但我们不能直接这样做。如果我们说list1.next=list2,那么我们就会失去list1的其余部分。因此,我们必须执行一次交换,如下所示:

Node auxNode = list1.next; // auxNode = node with value 7
list1.next = list2;        // list1.next = node with value 5
list2 = auxNode;           // list2 = node with value 7

接下来,我们将list1移动到list1.next,也就是值为 9 的节点。我们将list.nextlist2进行比较;因此,我们将 9 与 7 进行比较。以下图表显示了这个比较的结果:

图 11.18 - 合并两个排序的链表(步骤 3)

图 11.18 - 合并两个排序的链表(步骤 3)

因为list1跟随合并后的列表(最终结果),我们必须将list1.next移动到值为 7 的节点(因为 7 < 9),我们使用之前讨论过的交换来完成。接下来,我们将list1移动到list1.next,也就是值为 8 的节点。我们将list.nextlist2进行比较;因此,我们将 8 与 9 进行比较。以下图表显示了这个比较的结果:

图 11.19 - 合并两个排序的链表(步骤 4)

图 11.19 - 合并两个排序的链表(步骤 4)

由于 8 < 9,不需要交换。我们将list1.next移动到下一个节点(值为 10 的节点)并将 10 与 9 进行比较。下一个图表显示了这个比较的结果:

图 11.20 - 合并两个排序的链表(步骤 5)

图 11.20 - 合并两个排序的链表(步骤 5)

作为list1跟随合并后的列表(最终结果),我们必须将list1.next移动到值为 9 的节点(因为 9 < 10),我们使用之前讨论过的交换来完成。接下来,我们将list1移动到list1.next,这是值为 11 的节点。我们将list.nextlist2进行比较;因此,我们将 11 与 10 进行比较。下一个图表显示了这个比较的结果:

11.21:合并两个排序的链表(第 6 步)

图 11.21 - 合并两个排序的链表(第 6 步)

因为list1跟随合并后的列表(最终结果),我们必须将list1.next移动到值为 10 的节点(因为 10 < 11),我们使用之前讨论过的交换来完成。接下来,我们将list1移动到list1.next,这是null;因此,我们从list2中复制剩余部分。下一个图表显示了这个比较的结果:

11.22:合并两个排序的链表(最后一步)

图 11.22 - 合并两个排序的链表(最后一步)

此时,合并后的链表已经完成。现在是时候揭示代码了(这个方法被添加到了著名的SinglyLinkedList中):

public void merge(SinglyLinkedList sll) {
  // these are the two lists
  Node list1 = head;      // the merged linked list 
  Node list2 = sll.head;  // from this list we add nodes at 
                          // appropriate place in list1
  // compare heads and swap them if it is necessary
  if (list1.data < list2.data) {
    head = list1;
  } else {
    head = list2;
    list2 = list1;
    list1 = head;
  }
  // compare the nodes from list1 with the nodes from list2
  while (list1.next != null) {
    if (list1.next.data > list2.data) {
      Node auxNode = list1.next;
      list1.next = list2;
      list2 = auxNode;
    }
    // advance to the last node in the merged linked list              
    list1 = list1.next;
  }
  // add the remaining list2
  if (list1.next == null) {
    list1.next = list2;
  }
}

完整的应用程序名为LinkedListMergeTwoSorted。类似的问题可能要求您通过递归合并两个排序的链表。虽然您可以找到名为LinkedListMergeTwoSortedRecursion的应用程序,但我建议您挑战自己尝试一种实现。此外,基于这种递归实现,挑战自己合并n个链表。完整的应用程序名为LinkedListMergeNSortedRecursion

编码挑战 13 - 去除多余路径

问题:考虑一个存储矩阵中路径的单链表。节点的数据类型为(行,列)或简写为(r,c)。路径只能是水平(按)或垂直(按)。完整路径由所有水平和垂直路径的终点给出;因此,中间点(或中间的点)是多余的。编写一小段代码,删除多余的路径。

解决方案:让我们考虑一个包含以下路径的链表:(0, 0) → (0, 1) → (0, 2) → (1, 2) → (2, 2) → (3, 2) → (3, 3) → (3, 4) → null。多余的路径包括以下节点:(0, 1),(1, 2),(2, 2)和(3, 3)。因此,在移除多余路径后,我们应该保留一个包含四个节点的列表:(0, 0) → (0, 2) → (3, 2) → (3, 4) → null。下一个图表表示了多余的路径:

11.23:多余的路径

图 11.23 - 多余的路径

去除多余路径后,我们得到以下图表:

11.24:去除冗余后的剩余路径

图 11.24 - 去除冗余后的剩余路径

前面的图表应该提供了这个问题的解决方案。请注意,定义垂直路径的节点具有相同的列,因为我们只在行上下移动,而定义水平路径的节点具有相同的行,因为我们只在列左右移动。这意味着,如果我们考虑具有相同列或行的值的三个连续节点,那么我们可以移除中间节点。对相邻三元组重复此过程将移除所有多余节点。代码应该非常简单易懂:

public void removeRedundantPath() {
  Node currentNode = head;
  while (currentNode.next != null 
          && currentNode.next.next != null) {
    Node middleNode = currentNode.next.next;
    // check for a vertical triplet (triplet with same column)
    if (currentNode.c == currentNode.next.c
            && currentNode.c == middleNode.c) {
      // delete the middle node
      currentNode.next = middleNode;
    } // check for a horizontal triplet 
    else if (currentNode.r == currentNode.next.r
            && currentNode.r == middleNode.r) {
      // delete the middle node
      currentNode.next = middleNode;
    } else {
      currentNode = currentNode.next;
    }
  }
}

完整的应用程序名为LinkedListRemoveRedundantPath

编码挑战 14 - 将最后一个节点移到最前面

问题:考虑一个单链表。编写一小段代码,通过两种方法将最后一个节点移到最前面。因此,链表的最后一个节点变为头节点。

解决方案:这是一个听起来简单并且确实简单的问题。第一种方法将遵循以下步骤:

  1. 将指针移动到倒数第二个节点(我们将其表示为currentNode)。

  2. 存储currentNode.next(我们将其表示为nextNode - 这是最后一个节点)。

  3. currentNode.next设置为null(因此,最后一个节点变为尾部)。

  4. 将新的头部设置为存储的节点(因此,头部变为nextNode)。

在代码行中,我们有以下内容:

public void moveLastToFront() {      
  Node currentNode = head;
  // step 1
  while (currentNode.next.next != null) {
    currentNode = currentNode.next;
  }
  // step 2
  Node nextNode = currentNode.next;
  // step 3
  currentNode.next = null;
  // step 4
  nextNode.next = head;
  head = nextNode;
}

第二种方法可以通过以下步骤执行:

  1. 将指针移动到倒数第二个节点(我们将其表示为currentNode)。

  2. 将链表转换为循环列表(将currentNode.next.next链接到头部)。

  3. 将新的头部设置为currentNode.next

  4. 通过将currentNode.next设置为null来打破循环性。

在代码行中,我们有以下内容:

public void moveLastToFront() {
  Node currentNode = head;
  // step 1
  while (currentNode.next.next != null) {
    currentNode = currentNode.next;
  }
  // step 2
  currentNode.next.next = head;
  // step 3
  head = currentNode.next;
  // step 4
 currentNode.next = null;
}

完整的应用程序名为LinkedListMoveLastToFront

编码挑战 15 - 以 k 组反转单链表

AmazonGoogleAdobeMicrosoft

问题:考虑一个单链表和一个整数k。编写一小段代码,以k组反转链表的节点。

解决方案:假设给定的链表是 7 → 4 → 3 → 1 → 8 → 2 → 9 → 0 → null,k=3。结果应为 3 → 4 → 7 → 2 → 8 → 1 → 0 → 9 → null。

让我们考虑给定的k等于链表的大小。在这种情况下,我们将问题简化为反转给定的链表。例如,如果给定的列表是 7 → 4 → 3 → null,k=3,则结果应为 3 → 4 → 7 → null。那么,我们如何获得这个结果呢?

为了反转节点,我们需要当前节点(current)、当前节点旁边的节点(next)和当前节点之前的节点(previous),并且我们应用以下代表节点重新排列的算法:

  1. 从 0 开始计数。

  2. 作为当前节点(最初是头节点)不是null,并且我们还没有达到给定的k,发生以下情况:

a. next节点(最初为null)变为current节点旁边的节点(最初是头节点)。

b. current节点(最初是头节点)旁边的节点变为previous节点(最初为null)。

c. previous节点变为current节点(最初是头节点)。

d. current节点变为next节点(步骤 2a的节点)。

e. 增加计数器。

因此,如果我们应用此算法,我们可以反转整个列表。但是我们需要按组反转它;因此,我们必须解决我们所做的k个子问题。如果这对你来说听起来像递归,那么你是对的。在前述算法的末尾,设置为步骤 2anext)的节点指向计数器所指向的节点。我们可以说我们已经反转了前k个节点。接下来,我们通过递归从next节点开始继续下一组k节点。以下图表说明了这个想法:

11.25:以 k 组(k=3)反转列表

图 11.25 - 以 k 组(k=3)反转列表

以下代码实现了这个想法:

public void reverseInKGroups(int k) {
  if (head != null) {
    head = reverseInKGroups(head, k);
  }
}
private Node reverseInKGroups(Node head, int k) {
  Node current = head;
  Node next = null;
  Node prev = null;
  int counter = 0;
  // reverse first 'k' nodes of linked list
  while (current != null && counter < k) {
    next = current.next;                        
    current.next = prev;            
    prev = current;
    current = next;
    counter++;
  }
  // 'next' points to (k+1)th node            
  if (next != null) {
    head.next = reverseInKGroups(next, k);
  }
  // 'prev' is now the head of the input list 
  return prev;
}

这段代码运行时间为 O(n),其中n是给定列表中的节点数。完整的应用程序名为ReverseLinkedListInGroups

编码挑战 16 - 反转双向链表

MicrosoftFlipkart

问题:考虑一个双向链表。编写一小段代码来反转它的节点。

解决方案:反转双向链表可以利用双向链表维护到前一个节点的链接的事实。这意味着我们可以简单地交换每个节点的前指针和后指针,如下面的代码所示:

public void reverse() {
  Node currentNode = head;
  Node prevNode = null;
  while (currentNode != null) {
    // swap next and prev pointers of the current node
    Node prev = currentNode.prev;
    currentNode.prev = currentNode.next;
    currentNode.next = prev;
    // update the previous node before moving to the next node
    prevNode = currentNode;
    // move to the next node in the doubly linked list            
    currentNode = currentNode.prev;
  }
  // update the head to point to the last node
  if (prevNode != null) {
    head = prevNode;
  }
}

完整的应用程序名为DoublyLinkedListReverse。要对单链表和双链表进行排序,请参考第十四章排序和搜索

编码挑战 17 - LRU 缓存

AmazonGoogleAdobeMicrosoftFlipkart

问题:编写一小段代码来实现固定大小的 LRU 缓存。LRU 缓存代表最近最少使用的缓存。这意味着,当缓存已满时,添加新条目将指示缓存自动驱逐最近最少使用的条目。

解决方案:任何缓存实现必须提供一种快速有效的检索数据的方式。这意味着我们的实现必须遵守以下约束:

  • 固定大小:缓存必须使用有限的内存。因此,它需要一些限制(例如,固定大小)。

  • 快速访问数据:插入和搜索操作应该快速;最好是 O(1)复杂度时间。

  • 快速驱逐数据:当缓存已满(达到其分配的限制)时,缓存应该提供一个有效的算法来驱逐条目。

在最后一个要点的背景下,从 LRU 缓存中驱逐意味着驱逐最近最少使用的数据。为了实现这一点,我们必须跟踪最近使用的条目和长时间未使用的条目。此外,我们必须确保插入和搜索操作的 O(1)复杂度时间。在 Java 中没有内置的数据结构可以直接给我们提供这样的缓存。

但是我们可以从HashMap数据结构开始。在 Java 中,HashMap允许我们在 O(1)时间内按键插入和搜索(查找)数据。因此,使用HashMap解决了问题的一半。另一半,即跟踪最近使用的条目和长时间未使用的条目,无法通过HashMap完成。

然而,如果我们想象一个提供快速插入、更新和删除的数据结构,那么我们必须考虑双向链表。基本上,如果我们知道双向链表中节点的地址,那么插入、更新和删除可以在 O(1)时间内完成。

这意味着我们可以提供一个实现,它依赖于HashMap和双向链表之间的共生关系。基本上,对于 LRU 缓存中的每个条目(键值对),我们可以在HashMap中存储条目的键和关联链表节点的地址,而这个节点将存储条目的值。以下图表是对这一陈述的可视化表示:

11.26:使用 HashMap 和双向链表的 LRU 缓存

图 11.26 - 使用 HashMap 和双向链表的 LRU 缓存

但是双向链表如何帮助我们跟踪最近使用的条目呢?秘密在于以下几点:

  • 在缓存中插入新条目将导致将相应的节点添加到双向链表的头部(因此,双向链表的头部保存了最近使用的值)。

  • 当访问一个条目时,我们将其对应的节点移动到双向链表的头部。

  • 当我们需要驱逐一个条目时,我们驱逐双向链表的尾部(因此,双向链表的尾部保存了最近最少使用的值)。

基于这些陈述,我们可以提供以下直接的实现:

public final class LRUCache {
  private final class Node {
    private int key;
    private int value;
    private Node next;
    private Node prev;
  }
  private final Map<Integer, Node> hashmap;
  private Node head;
  private Node tail;
  // 5 is the maximum size of the cache
  private static final int LRU_SIZE = 5;
  public LRUCache() {
    hashmap = new HashMap<>();
  }
  public int getEntry(int key) {
    Node node = hashmap.get(key);
    // if the key already exist then update its usage in cache
    if (node != null) {
      removeNode(node);
      addNode(node);
      return node.value;
    }
    // by convention, data not found is marked as -1
    return -1;
  }
  public void putEntry(int key, int value) {
    Node node = hashmap.get(key);
    // if the key already exist then update 
    // the value and move it to top of the cache                 
    if (node != null) { 
      node.value = value;
      removeNode(node);
      addNode(node);
    } else {
      // this is new key
      Node newNode = new Node();
      newNode.prev = null;
      newNode.next = null;
      newNode.value = value;
      newNode.key = key;
      // if we reached the maximum size of the cache then 
      // we have to remove the  Least Recently Used
      if (hashmap.size() >= LRU_SIZE) { 
        hashmap.remove(tail.key);
        removeNode(tail);
        addNode(newNode);
      } else {
        addNode(newNode);
      }
      hashmap.put(key, newNode);
    }
  }
  // helper method to add a node to the top of the cache
  private void addNode(Node node) {
    node.next = head;
    node.prev = null;
    if (head != null) {
      head.prev = node;
    }
    head = node;
    if (tail == null) {
      tail = head;
    }
  }
  // helper method to remove a node from the cache
  private void removeNode(Node node) {
    if (node.prev != null) {
      node.prev.next = node.next;
    } else {
      head = node.next;
    }
    if (node.next != null) {
      node.next.prev = node.prev;
    } else {
      tail = node.prev;
    }
  }   
}

完整的应用程序名为LRUCache

好了,这是本章的最后一个编码挑战。是时候总结本章了!

摘要

本章引起了您对涉及链表和映射的最常见问题的注意。在这些问题中,首选涉及单向链表的问题;因此,本章主要关注了这一类编码挑战。

在下一章中,我们将解决与堆栈和队列相关的编码挑战。

第十二章:栈和队列

本章涵盖了涉及栈和队列的最受欢迎的面试编码挑战。主要是,您将学习如何从头开始提供栈/队列实现,以及如何通过 Java 的内置实现来解决编码挑战,例如Stack类和Queue接口实现,特别是ArrayDeque。通常,此类别的编码挑战将要求您构建栈/队列,或者要求您使用 Java 的内置实现解决特定问题。根据问题的不同,它可能明确禁止您调用某些内置方法,这将导致您找到一个简单的解决方案。

通过本章结束时,您将深入了解栈和队列,能够利用它们的功能,并且能够识别和编写依赖于栈和队列的解决方案。

在本章中,您将学习以下主题:

  • 概述栈

  • 概述队列

  • 编码挑战

让我们首先简要介绍栈的数据结构。

技术要求

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

概述栈

栈是一种使用后进先出LIFO)原则的线性数据结构。想象一堆需要清洗的盘子。您从顶部取出第一个盘子(最后添加的盘子),然后清洗它。然后,您从顶部取出下一个盘子,依此类推。这正是现实生活中的栈(例如,一堆盘子,一堆书,一堆 CD 等)。

因此,从技术上讲,在栈中,元素只能从一端添加(称为push操作)和移除(称为pop操作)(称为top)。

在栈中执行的最常见操作如下:

  • push(E e): 将元素添加到栈的顶部

  • E pop(): 移除栈顶的元素

  • E peek(): 返回(但不移除)栈顶的元素

  • boolean isEmpty(): 如果栈为空则返回true

  • int size(): 返回栈的大小

  • boolean isFull(): 如果栈已满则返回true

与数组不同,栈不以常数时间提供对第 n 个元素的访问。但是,它确实提供了添加和移除元素的常数时间。栈可以基于数组甚至基于链表实现。这里使用的实现是基于数组的,并命名为MyStack。该实现的存根如下所示:

public final class MyStack<E> {
  private static final int DEFAULT_CAPACITY = 10;
  private int top;
  private E[] stack;
  MyStack() {
    stack = (E[]) Array.newInstance(
             Object[].class.getComponentType(), 
             DEFAULT_CAPACITY);
    top = 0; // the initial size is 0
  }
  public void push(E e) {}
  public E pop() {}
  public E peek() {}
  public int size() {}
  public boolean isEmpty() {}
  public boolean isFull() {}
  private void ensureCapacity() {}
}

将元素推入栈意味着将该元素添加到基础数组的末尾。在推入元素之前,我们必须确保栈不是满的。如果满了,我们可以通过消息/异常来表示这一点,或者我们可以增加其容量,如下所示:

// add an element 'e' in the stack
public void push(E e) {
  // if the stack is full, we double its capacity
  if (isFull()) {
    ensureCapacity();
  }
  // adding the element at the top of the stack
  stack[top++] = e;
}
// used internally for doubling the stack capacity
private void ensureCapacity() {
  int newSize = stack.length * 2;
  stack = Arrays.copyOf(stack, newSize);
}

如您所见,每当我们达到栈的容量时,我们都会将其大小加倍。从栈中弹出一个元素意味着我们返回最后添加到基础数组中的元素。通过将最后一个索引置空来从基础数组中移除该元素,如下所示:

// pop top element from the stack
public E pop() {
  // if the stack is empty then just throw an exception
  if (isEmpty()) {
    throw new EmptyStackException();
  }
  // extract the top element from the stack                
  E e = stack[--top];
  // avoid memory leaks
  stack[top] = null;
  return e;
}

从栈中查看元素意味着返回最后添加到基础数组中的元素,但不从该数组中移除它:

// return but not remove the top element in the stack
public E peek() {
  // if the stack is empty then just throw an exception
  if (isEmpty()) {
    throw new EmptyStackException();
  }
  return stack[top - 1];
}

由于此实现可能代表您在面试中可能遇到的编码挑战,建议您花时间分析其代码。完整的应用程序称为MyStack

概述队列

队列是一种使用先进先出FIFO)原则的线性数据结构。想象排队购物的人。您还可以想象成蚂蚁在队列中行走。

因此,从技术上讲,元素的移除顺序与它们添加的顺序相同。在队列中,添加到一端的元素称为后端(这个操作称为入队操作),从另一端移除的元素称为前端(这个操作称为出队或轮询操作)。

队列中的常见操作如下:

  • enqueue(E e): 将元素添加到队列的末尾

  • E dequeue(): 删除并返回队列前面的元素

  • E peek(): 返回(但不删除)队列前面的元素

  • boolean isEmpty(): 如果队列为空则返回true

  • int size(): 返回队列的大小

  • boolean isFull():如果队列已满则返回true

与数组不同,队列不提供以常量时间访问第 n 个元素的功能。但是,它确实提供了添加和删除元素的常量时间。队列可以基于数组实现,甚至可以基于链表或堆栈(堆栈是基于数组或链表构建的)实现。这里使用的实现是基于数组的,并且命名为MyQueue。这个实现的存根在这里列出:

public final class MyQueue<E> {
  private static final int DEFAULT_CAPACITY = 10;
  private int front;
  private int rear;
  private int count;
  private int capacity;
  private E[] queue;
  MyQueue() {
    queue = (E[]) Array.newInstance(
                Object[].class.getComponentType(), 
                DEFAULT_CAPACITY);
  count = 0; // the initial size is 0
  front = 0;
  rear = -1;
  capacity = DEFAULT_CAPACITY;
  }
  public void enqueue(E e) {}
  public E dequeue() {}
  public E peek() {}
  public int size() {}
  public boolean isEmpty() {}
  public boolean isFull() {}
  private void ensureCapacity() {}
} 

将元素加入队列意味着将该元素添加到底层数组的末尾。在将元素加入队列之前,我们必须确保队列不是满的。如果满了,我们可以通过消息/异常来表示,或者我们可以增加其容量,如下所示:

// add an element 'e' in the queue
public void enqueue(E e) {
  // if the queue is full, we double its capacity
  if (isFull()) {
    ensureCapacity();
  }
  // adding the element in the rear of the queue
  rear = (rear + 1) % capacity;
  queue[rear] = e;
  // update the size of the queue
  count++;
}
// used internally for doubling the queue capacity
private void ensureCapacity() {       
  int newSize = queue.length * 2;
  queue = Arrays.copyOf(queue, newSize);
  // setting the new capacity
  capacity = newSize;
}

从队列中出列一个元素意味着从底层数组的开头返回下一个元素。该元素从数组中删除:

// remove and return the front element from the queue
public E dequeue() {
  // if the queue is empty we just throw an exception
  if (isEmpty()) {
    throw new EmptyStackException();
  }
  // extract the element from the front
  E e = queue[front];
  queue[front] = null;
  // set the new front
  front = (front + 1) % capacity;
  // decrease the size of the queue
  count--;
  return e;
}

从队列中窥视一个元素意味着从底层数组的开头返回下一个元素,而不将其从数组中删除:

// return but not remove the front element in the queue
public E peek() {
  // if the queue is empty we just throw an exception
  if (isEmpty()) {
    throw new EmptyStackException();
  }
  return queue[front];
}

由于这个实现可以代表你在面试中可能遇到的编码挑战,建议你花时间来分析它的代码。完整的应用程序称为MyQueue

编码挑战

在接下来的 11 个编码挑战中,我们将涵盖在过去几年中出现在面试中的涉及栈和队列的最流行问题,这些问题涉及到各种雇佣 Java 开发人员的公司。其中最常见的问题之一,使用一个数组实现三个栈,在第十章**,数组和字符串中有所涉及。

以下编码挑战的解决方案依赖于 Java 内置的StackArrayDequeAPI。所以,让我们开始吧!

编码挑战 1 - 反转字符串

问题:假设你有一个字符串。使用堆栈将其反转。

解决方案:使用堆栈反转字符串可以按以下方式完成:

  1. 从左到右循环字符串,并将每个字符推入堆栈。

  2. 循环堆栈并逐个弹出字符。每个弹出的字符都放回字符串中。

基于这两个步骤的代码如下:

public static String reverse(String str) {
  Stack<Character> stack = new Stack();
  // push characters of the string into the stack
  char[] chars = str.toCharArray();
  for (char c : chars) {
    stack.push(c);
  }
  // pop all characters from the stack and
  // put them back to the input string
  for (int i = 0; i < str.length(); i++) {
    chars[i] = stack.pop();
  }
  // return the string
  return new String(chars);
}

完整的应用程序称为StackReverseString

编码挑战 2 - 大括号堆栈

亚马逊谷歌Adobe微软Flipkart

包含大括号的字符串。编写一小段代码,如果有匹配的大括号对,则返回true。如果我们可以找到适当顺序的闭合大括号来匹配开放的大括号,那么我们可以说有一个匹配的对。例如,包含匹配对的字符串如下:{{{}}}{}{{}}。

false。其次,如果它们的数量相等,则它们必须按适当的顺序;否则,我们返回false。按适当的顺序,我们理解最后打开的大括号是第一个关闭的,倒数第二个是第二个关闭的,依此类推。如果我们依赖于堆栈,那么我们可以详细说明以下算法:

  1. 对于给定字符串的每个字符,做出以下决定之一:

a. 如果字符是一个开放的大括号,{,那么将其放入堆栈。

b. 如果字符是闭合大括号,},则执行以下操作:

i. 检查堆栈顶部,如果是{,则弹出并将其移动到下一个字符。

ii. 如果不是{,则返回false

  1. 如果堆栈为空,则返回true(我们找到了所有配对);否则返回false(堆栈包含不匹配的大括号)。

将这些步骤转化为代码,结果如下:

public static boolean bracesMatching(String bracesStr) {
  Stack<Character> stackBraces = new Stack<>();
  int len = bracesStr.length();
  for (int i = 0; i < len; i++) {
    switch (bracesStr.charAt(i)) {
      case '{':
        stackBraces.push(bracesStr.charAt(i));
        break;
      case '}':
        if (stackBraces.isEmpty()) { // we found a mismatch
          return false;
        }
        // for every match we pop the corresponding '{'
        stackBraces.pop(); 
        break;
      default:
        return false;
    }
  }
  return stackBraces.empty();
}

完整的应用程序称为StackBraces。通过实现类似的问题,但是对于多种类型的括号(例如,在相同的给定字符串中允许(){}[]),来挑战自己。

编程挑战 3 - 堆叠盘

亚马逊谷歌Adobe微软Flipkart

push()pop()方法将像单个堆栈一样工作。另外,编写一个popAt(int stackIndex)方法,它会从堆栈中弹出一个值,如stackIndex所示。

解决方案:我们知道如何处理单个堆栈,但是如何将多个堆栈链接在一起呢?嗯,既然我们需要链接,那么链表怎么样?如果链表中每个节点都包含一个堆栈,那么节点的下一个指针将指向下一个堆栈。以下图表可视化了这个解决方案:

图 12.1 - 堆栈的链表

图 12.1 - 堆栈的链表

每当当前堆栈容量超过时,我们就创建一个新节点并将其附加到链表中。Java 的内置链表(LinkedList)通过getLast()方法使我们可以访问最后一个节点。换句话说,通过LinkedList#getLast(),我们可以轻松操作当前堆栈(例如,我们可以推送或弹出一个元素)。通过LinkedList#add()方法很容易添加一个新的堆栈。基于这些语句,我们可以实现push()方法,如下所示:

private static final int STACK_SIZE = 3;
private final LinkedList<Stack<Integer>> stacks 
  = new LinkedList<>();
public void push(int value) {
  // if there is no stack or the last stack is full
  if (stacks.isEmpty() || stacks.getLast().size()
      >= STACK_SIZE) {
    // create a new stack and push the value into it
    Stack<Integer> stack = new Stack<>();
    stack.push(value);
    // add the new stack into the list of stacks
    stacks.add(stack);
  } else {
    // add the value in the last stack
    stacks.getLast().push(value);
  }
}

如果我们想要弹出一个元素,那么我们必须从最后一个堆栈中这样做,所以LinkedList#getLast()在这里非常方便。这里的特殊情况是当我们从最后一个堆栈中弹出最后一个元素时。当这种情况发生时,我们必须删除最后一个堆栈,在这种情况下,倒数第二个(如果有的话)将成为最后一个。以下代码说明了这一点:

public Integer pop() {
  // find the last stack
  Stack<Integer> lastStack = stacks.getLast();
  // pop the value from the last stack
  int value = lastStack.pop();
  // if last stack is empty, remove it from the list of stacks
  removeStackIfEmpty();
  return value;
}
private void removeStackIfEmpty() {
  if (stacks.getLast().isEmpty()) {
      stacks.removeLast();
  }
}

最后,让我们专注于实现popAt(int stackIndex)方法。我们可以通过简单调用stacks.get(stackIndex).pop()stackIndex堆栈中弹出。一旦我们弹出一个元素,我们必须移动剩余的元素。下一个堆栈的底部元素将成为由stackIndex指向的堆栈的顶部元素,依此类推。如果最后一个堆栈包含单个元素,则移动其他元素将消除最后一个堆栈,并且其前面的堆栈将成为最后一个堆栈。让我们通过代码来看一下:

public Integer popAt(int stackIndex) {
  // get the value from the correspondind stack
  int value = stacks.get(stackIndex).pop();
  // pop an element -> must shift the remaining elements        
  shift(stackIndex);
  // if last stack is empty, remove it from the list of stacks
  removeStackIfEmpty();
  return value;
}
private void shift(int index) {
  for (int i = index; i<stacks.size() - 1; ++i) {
    Stack<Integer> currentStack = stacks.get(i);
    Stack<Integer> nextStack = stacks.get(i + 1);
    currentStack.push(nextStack.remove(0));
  }
}

完整的应用程序称为StackOfPlates

编程挑战 4 - 股票跨度

亚马逊谷歌Adobe微软Flipkart

问题:假设你已经获得了一个单一股票连续多天的价格数组。股票跨度由前几天(今天)的股票价格小于或等于当前天(今天)的股票价格的天数表示。例如,考虑股票价格覆盖 10 天的情况;即{55, 34, 22, 23, 27, 88, 70, 42, 51, 100}。结果的股票跨度是{1, 1, 1, 2, 3, 6, 1, 1, 2, 10}。注意,对于第一天,股票跨度始终为 1。编写一小段代码,计算给定价格列表的股票跨度。

解决方案:我们可以从给定的示例开始,尝试将其可视化,如下所示:

图 12.2 - 10 天的股票跨度

图 12.2 - 10 天的股票跨度

从前面的图表中,我们可以观察到以下内容:

  • 对于第一天,跨度始终为 1。

  • 对于第 2 天,价格为 34。由于 34 小于前一天(55)的价格,第 2 天的股票跨度也是 1。

  • 对于第 3 天,价格为 22。由于 22 小于前一天(34)的价格,第 3 天的股票跨度也是 1。第 7 天和第 8 天也属于同样的情况。

  • 对于第 4 天,价格是 23。由于 23 大于前一天的价格(22),但小于第 2 天的价格,所以股票跨度为 2。第 9 天与第 4 天类似。

  • 对于第 5 天,价格是 27。由于这个价格大于第 3 天和第 4 天的价格,但小于第 2 天的价格,所以股票跨度为 3。

  • 对于第 6 天,价格是 88。这是迄今为止最高的价格,所以股票跨度是 6。

  • 对于第 10 天,价格是 100。这是迄今为止最高的价格,所以股票跨度是 10。

注意,我们计算当前天的股票跨度,是当前天的索引与对应于最后一个最大股价的那一天的索引之间的差。在追踪这种情况之后,我们可能会有这样的第一个想法:对于每一天,扫描它之前的所有天,直到股价大于当前天。换句话说,我们使用了蛮力方法。正如我在本书中早些时候提到的,蛮力方法应该在面试中作为最后的手段使用,因为它的性能较差,面试官不会感到印象深刻。在这种情况下,蛮力方法的时间复杂度为 O(n2)。

然而,让我们换个角度来思考。对于每一天,我们想找到一个之前的一天,它的价格比当前天的价格高。换句话说,我们要找到最后一个价格比当前天的价格高的那一天。

在这里,我们应该选择一个后进先出的数据结构,它允许我们按降序推入价格并弹出最后推入的价格。一旦我们做到这一点,我们可以遍历每一天,并将栈顶的价格与当前天的价格进行比较。直到栈顶的价格小于当前天的价格,我们可以从栈中弹出。但是如果栈顶的价格大于当前天的价格,那么我们计算当前天的股票跨度,就是当前天和栈顶价格对应的那一天之间的天数差。如果我们按降序将价格推入栈中,这将起作用 - 最大的价格在栈顶。然而,由于我们可以将股票跨度计算为当前天的索引与对应于最后一个最大股价的那一天的索引之间的差(我们用i表示),我们可以简单地将i索引存储在栈中;stackPrices[i](我们将价格数组表示为stackPrices)将返回第i天的股票价格。

这可以通过以下算法实现:

  1. 第一天的股票跨度为 1,索引为 0 - 我们将这个索引推入栈中(我们将其表示为dayStack;因此,dayStack.push(0))。

  2. 我们循环剩余的天数(第 2 天的索引为 1,第 3 天的索引为 2,依此类推)并执行以下操作:

a. 当stockPrices[i] > stockPrices[dayStack.peek()]并且!dayStack.empty()时,我们从栈中弹出(dayStack.pop())。

  1. 如果dayStack.empty(),那么i+1的股票跨度。

  2. 如果stockPrices[i] <= stockPrices[dayStack.peek()],那么股票跨度就是i - dayStack.peek()

  3. 将当前天的索引i推入栈中(dayStack)。

让我们看看这个算法如何适用于我们的测试案例:

  1. 第一天的股票跨度为 1,索引为 0 - 我们将这个索引推入栈中,dayStack.push(0)

  2. 对于第 2 天,stockPrices[1]=34stockPrices[0]=55。由于 34 < 55,第 2 天的股票跨度为i - dayStack.peek() = 1 - 0 = 1。我们将 1 推入栈中,dayStack.push(1)

  3. 对于第三天,stockPrices[2]=22,stockPrices[1]=34。由于 22 < 34,第 3 天的股票跨度为 2 - 1 = 1。我们将 1 推入栈中,dayStack.push(2)

  4. 对于第 4 天,stockPrices[3]=23,stockPrices[2]=22。由于 23 > 22 并且栈不为空,我们弹出栈顶,所以我们弹出值 2。由于 23 < 34(stockPrices[1]),第 4 天的股票跨度为 3 - 1 = 2。我们将 3 推入栈中,dayStack.push(3)

  5. 对于第五天,stockPrices[4]=27 和 stockPrices[3]=23。由于 27 > 23 并且栈不为空,我们弹出栈顶,所以我们弹出值 3。接下来,27 < 34(记住我们在上一步弹出了值 2,所以下一个栈顶的值为 1),第 5 天的股票跨度为 4 - 1 = 3。我们在栈中推入 4,dayStack.push(4)

  6. 对于第六天,stockPrices[5]=88 和 stockPrices[4]=27。由于 88 > 27 并且栈不为空,我们弹出栈顶,所以我们弹出值 4。接下来,88 > 34 并且栈不为空,所以我们弹出值 1。接下来,88 > 55 并且栈不为空,所以我们弹出值 0。接下来,栈为空,第 6 天的股票跨度为 5 + 1 = 6。

好了,我想你已经明白了,现在,挑战自己,继续到第 10 天。目前,我们有足够的信息将这个算法转化为代码:

public static int[] stockSpan(int[] stockPrices) {
  Stack<Integer> dayStack = new Stack();
  int[] spanResult = new int[stockPrices.length];
  spanResult[0] = 1; // first day has span 1
  dayStack.push(0);
  for (int i = 1; i < stockPrices.length; i++) {
    // pop until we find a price on stack which is 
    // greater than the current day's price or there 
    // are no more days left
    while (!dayStack.empty() 
      && stockPrices[i] > stockPrices[dayStack.peek()]) {
      dayStack.pop();
    }
    // if there is no price greater than the current 
    // day's price then the stock span is the numbers of days
    if (dayStack.empty()) {
        spanResult[i] = i + 1;
    } else {
      // if there is a price greater than the current 
      // day's price then the stock span is the 
      // difference between the current day and that day
        spanResult[i] = i - dayStack.peek();
    }
    // push current day onto top of stack
     dayStack.push(i);
  }
  return spanResult;
}

完整的应用程序称为 StockSpan

编码挑战 5 – 栈最小值

亚马逊谷歌Adobe微软Flipkart

push()pop()min() 方法应在 O(1) 时间内运行。

push()pop() 在 O(1) 时间内运行。

符合问题约束的解决方案需要一个额外的栈来跟踪最小值。主要是,当推送的值小于当前最小值时,我们将这个值添加到辅助栈(我们将其表示为 stackOfMin)和原始栈中。如果从原始栈中弹出的值是 stackOfMin 的栈顶,则我们也从 stackOfMin 中弹出它。在代码方面,我们有以下内容:

public class MyStack extends Stack<Integer> {
  Stack<Integer> stackOfMin;
  public MyStack() {
    stackOfMin = new Stack<>();
  }
  public Integer push(int value) {
    if (value <= min()) {
       stackOfMin.push(value);
    }
    return super.push(value);
  }
  @Override
  public Integer pop() {
    int value = super.pop();
    if (value == min()) {
       stackOfMin.pop();
    }
    return value;
  }
  public int min() {
   if (stackOfMin.isEmpty()) {
      return Integer.MAX_VALUE;
    } else {
      return stackOfMin.peek();
    }
  }
}

完成!我们的解决方案以 O(1) 复杂度时间运行。完整的应用程序称为 MinStackConstantTime。与此相关的一个问题要求您在常数时间和空间内实现相同的功能。这个问题的解决方案施加了几个限制,如下:

  • pop() 方法返回 void,以避免返回不正确的值。

  • 给定值乘以 2 不应超出 int 数据类型的范围。

简而言之,这些限制是由解决方案本身造成的。我们不能使用额外的空间;因此,我们将使用初始值栈来存储最小值。此外,我们需要将给定值乘以 2,因此我们应确保不超出 int 范围。为什么我们需要将给定值乘以 2?

让我们来解释一下这个问题!假设我们需要将一个值推入一个具有特定最小值的栈中。如果这个值大于或等于当前最小值,那么我们可以简单地将它推入栈中。但是如果它小于最小值,那么我们推入 2**值-最小值*,这应该小于值本身。然后,我们将当前最小值更新为值。

当我们弹出一个值时,我们必须考虑两个方面。如果弹出的值大于或等于最小值,那么这是之前推送的真实值。否则,弹出的值不是推送的值。真正推送的值存储在最小值中。在我们弹出栈顶(最小值)之后,我们必须恢复先前的最小值。先前的最小值可以通过 2最小值 - 栈顶* 获得。换句话说,由于当前栈顶是 2值 - 先前的最小值,而值是当前最小值,先前的最小值是 2**当前最小值 - 栈顶。以下代码说明了这个算法:

public class MyStack {
  private int min;
  private final Stack<Integer> stack = new Stack<>();
  public void push(int value) {
    // we don't allow values that overflow int/2 range
    int r = Math.addExact(value, value);
    if (stack.empty()) {
      stack.push(value);
      min = value;
    } else if (value > min) {
      stack.push(value);
    } else {
      stack.push(r - min);
      min = value;
    }
  }
  // pop() doesn't return the value since this may be a wrong   
  // value (a value that was not pushed by the client)!
  public void pop() {
    if (stack.empty()) {
      throw new EmptyStackException();
    }
    int top = stack.peek();
    if (top < min) {
      min = 2 * min - top;
    }
    stack.pop();
  }
  public int min() {
    return min;
  }
}

完整的应用程序称为 MinStackConstantTimeAndSpace

编码挑战 6 – 通过栈实现队列

谷歌Adobe微软Flipkart

问题:通过两个栈设计一个队列。

解决方案:为了找到这个问题的合适解决方案,我们必须从队列和栈之间的主要区别开始。我们知道队列按照先进先出的原则工作,而栈按照后进先出的原则工作。接下来,我们必须考虑主要的操作(推入、弹出和查看)并确定它们之间的区别。

它们都以相同的方式推送新元素。当我们将一个元素推入队列时,我们是从一端(队列的后端)推入的。当我们将一个元素推入栈时,我们是从栈的新顶部推入的,这可以被视为与队列的后端相同。

当我们从栈中弹出或查看一个值时,我们是从顶部这样做的。然而,当我们在队列上执行相同的操作时,我们是从前面这样做的。这意味着,当弹出或查看一个元素时,一个反转的栈将充当队列。以下图表说明了这一点:

图 12.3 - 通过两个栈实现队列

图 12.3 - 通过两个栈实现队列

因此,每个新元素都被推入enqueue stack作为新的顶部。当我们需要弹出或查看一个值时,我们使用dequeue栈,这是enqueue stack的反转版本。请注意,我们不必在每次弹出/查看操作时都反转enqueue stack。我们可以让元素停留在dequeue stack中,直到我们绝对必须反转元素。换句话说,对于每个弹出/查看操作,我们可以检查dequeue stack是否为空。只要dequeue stack不为空,我们就不需要反转enqueue stack,因为我们至少有一个元素可以弹出/查看。

让我们用代码来看一下:

public class MyQueueViaStack<E> {
  private final Stack<E> stackEnqueue;
  private final Stack<E> stackDequeue;
  public MyQueueViaStack() {
    stackEnqueue = new Stack<>();
    stackDequeue = new Stack<>();
  }
  public void enqueue(E e) {
    stackEnqueue.push(e);
  }
  public E dequeue() {
    reverseStackEnqueue();
    return stackDequeue.pop();
  }
  public E peek() {
    reverseStackEnqueue();
    return stackDequeue.peek();
  }
  public int size() {
    return stackEnqueue.size() + stackDequeue.size();
  }
  private void reverseStackEnqueue() {
    if (stackDequeue.isEmpty()) {
      while (!stackEnqueue.isEmpty()) {
        stackDequeue.push(stackEnqueue.pop());
      }
    }
  }
}

完整的应用程序称为QueueViaStack

编码挑战 7 - 通过队列实现栈

GoogleAdobeMicrosoft

问题:设计一个通过两个队列实现的栈。

解决方案:为了找到这个问题的合适解决方案,我们必须从栈和队列之间的主要区别开始。我们知道栈是后进先出,而队列是先进先出。接下来,我们必须考虑主要操作(推入、弹出和查看)并确定它们之间的区别。

它们都以相同的方式推送新元素。当我们将一个元素推入栈时,我们是从栈的新顶部推入的。当我们将一个元素推入队列时,我们是从一端(队列的后端)推入的。队列的后端就像栈的顶部。

当我们从队列中弹出或查看一个值时,我们是从前面这样做的。然而,当我们在栈上执行相同的操作时,我们是从顶部这样做的。这意味着,当我们从充当栈的队列中弹出或查看一个元素时,我们需要轮询除最后一个元素之外的所有元素。最后一个元素就是我们弹出/查看的元素。以下图表说明了这一点:

图 12.4 - 通过两个队列实现栈

图 12.4 - 通过两个队列实现栈

正如前面的图表左侧所显示的,将一个元素推入栈和队列是一个简单的操作。前面图表的右侧显示了当我们想要从充当栈的队列中弹出/查看一个元素时会出现问题。主要是,在弹出/查看元素之前,我们必须将队列(在前面的图表中标记为queue1)中的元素(rear-1 和front之间)移动到另一个队列(在前面的图表中标记为queue2)。在前面的图表中,右侧,我们从queue1中轮询元素 2、5、3 和 1,并将它们添加到queue2中。接下来,我们从queue1中弹出/查看最后一个元素。如果我们弹出元素 6,那么queue1就会保持为空。如果我们查看元素 6,那么queue1就会保留这个元素。

现在,剩下的元素都在queue2中,所以为了执行另一个操作(推入、查看或弹出),我们有两个选项:

  • queue2中剩余的元素移回queue1,恢复queue1

  • 使用queue2就像它是queue1一样,这意味着交替使用queue1queue2

在第二个选项中,我们避免了将queue2中的元素移回queue1的开销,目的是在queue1上执行下一个操作。虽然你可以挑战自己来实现第一个选项,但让我们更多地关注第二个选项。

如果我们考虑到我们应该使用的下一个操作的队列是不空的,那么可以交替使用queue1queue2。由于我们在这两个队列之间移动元素,其中一个始终为空。因此,当我们查看一个元素时,会出现问题,因为查看操作不会移除元素,因此其中一个队列仍然保留该元素。由于没有一个队列是空的,我们不知道下一个操作应该使用哪个队列。解决方案非常简单:我们弹出最后一个元素,即使是对于查看操作,我们也将其存储为实例变量。随后的查看操作将返回此实例变量。推送操作将在推送给定值之前将此实例变量推回队列,并将此实例变量设置为null。弹出操作将检查此实例变量是否为null。如果不是null,那么这就是要弹出的元素。

让我们看看代码:

public class MyStackViaQueue<E> {
  private final Queue<E> queue1;
  private final Queue<E> queue2;
  private E peek;
  private int size;
  public MyStackViaQueue() {
    queue1 = new ArrayDeque<>();
    queue2 = new ArrayDeque<>();
  }
  public void push(E e) {
    if (!queue1.isEmpty()) {
      if (peek != null) {
        queue1.add(peek);
      }
      queue1.add(e);
    } else {
      if (peek != null) {
        queue2.add(peek);
      }
      queue2.add(e);
    }
    size++;
    peek = null;
  }
  public E pop() {
    if (size() == 0) {
      throw new EmptyStackException();
    }
    if (peek != null) {
      E e = peek;
      peek = null;
      size--;
      return e;
    }
    E e;
    if (!queue1.isEmpty()) {
      e = switchQueue(queue1, queue2);
    } else {
      e = switchQueue(queue2, queue1);
    }
    size--;
    return e;
  }
  public E peek() {
    if (size() == 0) {
      throw new EmptyStackException();
    }
    if (peek == null) {
      if (!queue1.isEmpty()) {
        peek = switchQueue(queue1, queue2);
      } else {
        peek = switchQueue(queue2, queue1);
      }
    }
    return peek;
  }
  public int size() {
    return size;
  }
  private E switchQueue(Queue from, Queue to) {
    while (from.size() > 1) {
      to.add(from.poll());
    }
    return (E) from.poll();
  }
}

完整的应用程序称为StackViaQueue

编码挑战 8 - 最大直方图面积

亚马逊谷歌Adobe微软Flipkart

问题:假设你已经得到了下图中显示的直方图:

图 12.5 - 直方图,类间隔等于 1

图 12.5 - 直方图,类间隔等于 1

我们将直方图定义为一个矩形条的图表,其中面积与某个变量的频率成比例。条的宽度称为直方图类间隔。例如,前面图像中的直方图的类间隔等于 1。有六个宽度均为 1,高度分别为 4、2、8、6、5 和 3 的条。

假设你已经得到了这些高度作为整数数组(这是问题的输入)。编写一小段代码,使用栈来计算直方图中最大的矩形区域。为了更好地理解这一点,下图突出显示了几个(不是全部)可以形成的矩形:

图 12.6 - 直方图的矩形

图 12.6 - 直方图的矩形

在前面的图像中,最大的矩形区域(即最大的矩形)是中间的一个,3 x 5 = 15。

解决方案:这个问题比起初看起来要困难得多。首先,我们需要分析给定的图像并制定几个声明。例如,非常重要的是要注意,只有当某个条的高度小于或等于该区域的高度时,该条才能成为矩形区域的一部分。此外,对于每个条,我们可以说,所有左侧高于当前条的条都可以与当前条形成一个矩形区域。同样,所有右侧高于当前条的条都可以与当前条形成一个矩形区域。

这意味着每个矩形区域由边界限定,而(右 - 左) ** current_bar*给出了这个区域的值。我们应该计算所有可能的区域,并选择最高的区域作为我们实现的输出。以下图像突出显示了 3 x 5 矩形的左右边界:

图 12.7 - 左右边界

图 12.7 - 左右边界

请记住,我们必须使用栈来解决这个问题。现在我们有了一些可以引导我们解决问题的声明,是时候把栈引入讨论了。主要是,我们可以使用栈来计算左右边界。

我们从第一个条开始,将其索引(索引 0)推入栈中。我们继续处理剩下的条,并执行以下操作:

  1. 重复步骤 1a1b1c,直到当前条小于栈顶部并且栈不为空:

a.我们弹出栈顶部。

b.我们计算左边界。

c. 我们计算可以在计算的左边界条和当前条之间形成的矩形区域的宽度。

d. 我们计算面积为计算的宽度乘以我们在步骤 1a中弹出的条的高度。

e. 如果这个区域比以前的大,那么我们存储这个区域。

  1. 将当前条的索引推入栈中。

  2. 重复从步骤 1直到每个条都被处理。

让我们看看代码方面的情况:

public static int maxAreaUsingStack(int[] histogram) {
  Stack<Integer> stack = new Stack<>();
  int maxArea = 0;
  for (int bar = 0; bar <= histogram.length; bar++) {
    int barHeight;
    if (bar == histogram.length) {
      barHeight = 0; // take into account last bar
    } else {
      barHeight = histogram[bar];
    }
    while (!stack.empty() 
          && barHeight < histogram[stack.peek()]) {
      // we found a bar smaller than the one from the stack                
      int top = stack.pop();
      // find left boundary
      int left = stack.isEmpty() ? -1 : stack.peek();
      // find the width of the rectangular area 
      int areaRectWidth = bar - left - 1;
      // compute area of the current rectangle
      int area = areaRectWidth * histogram[top];
      maxArea = Integer.max(area, maxArea);
    }
    // add current bar (index) into the stack
    stack.push(bar);
  }        
  return maxArea;
}

这段代码的时间复杂度是 O(n)。此外,额外的空间复杂度是 O(n)。完整的应用程序称为StackHistogramArea

编码挑战 9 - 最小数字

问题:考虑到你已经得到一个表示n位数的字符串。编写一小段代码,删除给定的k位数后打印出最小可能的数字。

解决方案:让我们假设给定的数字是n=4514327 和k=4。在这种情况下,删除四位数字后的最小数字是 127。如果n=2222222,那么最小数字是 222。

解决方案可以通过Stack和以下算法轻松实现:

  1. 从左到右迭代给定的数字,逐位数字。

a. 只要给定的k大于 0,栈不为空,并且栈中的顶部元素大于当前遍历的数字:

i. 从栈中弹出顶部元素。

ii. 将k减 1。

b. 将当前数字推入栈中。

  1. 当给定的k大于 0 时,执行以下操作(处理特殊情况,如 222222):

a. 从栈中弹出元素。

b. 将k减 1。

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

public static void smallestAfterRemove(String nr, int k) {
  int i = 0;
  Stack<Character> stack = new Stack<>();
  while (i < nr.length()) {
    // if the current digit is less than the previous 
    // digit then discard the previous one
    while (k > 0 && !stack.isEmpty()
          && stack.peek() > nr.charAt(i)) {
      stack.pop();
      k--;
    }
    stack.push(nr.charAt(i));
    i++;
  }
  // cover corner cases such as '2222'
  while (k > 0) {
    stack.pop();
    k--;
  }
  System.out.println("The number is (as a printed stack; "
      + "ignore leading 0s (if any)): " + stack);
  }
}

完整的应用程序称为SmallestNumber

编码挑战 10 - 岛屿

亚马逊Adobe

问题:考虑到你已经得到一个包含只有 0 和 1 的mxn矩阵。按照惯例,1 表示陆地,0 表示水。编写一小段代码来计算岛屿的数量。岛屿被定义为由 0 包围的 1 组成的区域。

解决方案:让我们想象一个测试案例。以下是一个包含 6 个岛屿的 10x10 矩阵,分别标记为 1、2、3、4、5 和 6:

图 12.8 - 10x10 矩阵中的岛屿

图 12.8 - 10x10 矩阵中的岛屿

为了找到岛屿,我们必须遍历矩阵。换句话说,我们必须遍历矩阵的每个单元格。由于一个单元格由行(我们将其表示为r)和列(我们将其表示为c)来表示,我们观察到,从一个单元格(r, c),我们可以朝八个方向移动:(r-1, c-1), (r-1, c), (r-1, c+1), (r, c-1), (r, c+1), (r+1, c-1), (r+1, c), 和 (r+1, c+1)。这意味着从当前单元格(r, c),我们可以移动到(r+ROW[k], c+COL[k]),只要ROWCOL是下面的数组,且 0 ≤ k ≤ 7:

// top, right, bottom, left and 4 diagonal moves
private static final int[] ROW = {-1, -1, -1, 0, 1, 0, 1, 1};
private static final int[] COL = {-1, 1, 0, -1, -1, 1, 0, 1};

只要我们做到以下几点,移动到一个单元格就是有效的:

  • 不要从网格上掉下来。

  • 踩在代表陆地的单元格上(一个 1 的单元格)。

  • 没有在该单元格之前。

为了确保我们不多次访问同一个单元格,我们使用一个布尔矩阵表示为flagged[][]。最初,这个矩阵只包含false的值,每次我们访问一个单元格(r, c)时,我们将相应的flagged[r][c]翻转为true

以下是代码形式中的前三个要点:

private static booleanisValid(int[][] matrix, 
      int r, int c, boolean[][] flagged) {
  return (r >= 0) && (r < flagged.length)
    && (c >= 0) && (c < flagged[0].length)
    && (matrix[r][c] == 1 && !flagged[r][c]);
}

到目前为止,我们知道如何决定从当前单元格移动到另一个单元格(从八个可能的移动中)。此外,我们必须定义一个算法来确定移动模式。我们知道从一个单元格(r, c),我们可以在相邻单元格中的八个方向移动。因此,最方便的算法是尝试从当前单元格移动到所有有效的邻居,如下所示:

  1. 从一个空队列开始。

  2. 移动到一个有效的单元格(r, c),将其入队,并标记为已访问 - 起始点应该是单元格(0, 0)。

  3. 出队当前单元并解决其周围的八个相邻单元 - 解决单元意味着如果有效则将其入队并标记为已访问。

  4. 重复步骤 3直到队列为空。当队列为空时,这意味着我们找到了一个岛屿。

  5. 重复从步骤 2直到没有更多有效单元格。

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

private static class Cell {
  int r, c;
  public Cell(int r, int c) {
    this.r = r;
    this.c = c;
  }
}
// there are 8 possible movements from a cell    
private static final int POSSIBLE_MOVEMENTS = 8;
// top, right, bottom, left and 4 diagonal moves
private static final int[] ROW = {-1, -1, -1, 0, 1, 0, 1, 1};
private static final int[] COL = {-1, 1, 0, -1, -1, 1, 0, 1};
public static int islands(int[][] matrix) {
  int m = matrix.length;
  int n = matrix[0].length;
  // stores if a cell is flagged or not
  boolean[][] flagged = new boolean[m][n];
  int island = 0;
  for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
      if (matrix[i][j] == 1 && !flagged[i][j]) {
        resolve(matrix, flagged, i, j);
        island++;
      }
    }
  }
  return island;
}
private static void resolve(int[][] matrix, 
        boolean[][] flagged, int i, int j) {
  Queue<Cell> queue = new ArrayDeque<>();
  queue.add(new Cell(i, j));
  // flag source node
  flagged[i][j] = true;
  while (!queue.isEmpty()) {
    int r = queue.peek().r;
    int c = queue.peek().c;
    queue.poll();
    // check for all 8 possible movements from current 
    // cell and enqueue each valid movement
    for (int k = 0; k < POSSIBLE_MOVEMENTS; k++) {
      // skip this cell if the location is invalid
      if (isValid(matrix, r + ROW[k], c + COL[k], flagged)) {
        flagged[r + ROW[k]][c + COL[k]] = true;
        queue.add(new Cell(r + ROW[k], c + COL[k]));
      }
    }
  }
}

完整的应用程序称为QueueIslands

编码挑战 11-最短路径

亚马逊谷歌Adobe

问题:假设给定一个只包含 0 和 1 的矩阵m x n。按照惯例,1 表示安全土地,而 0 表示不安全的土地。更准确地说,0 表示不应该被激活的传感器。此外,所有八个相邻的单元格都可以激活传感器。编写一小段代码,计算从第一列的任何单元格到最后一列的任何单元格的最短路径。您只能一次移动一步;向左、向右、向上或向下。结果路径(如果存在)应只包含值为 1 的单元格。

解决方案:让我们想象一个测试案例。以下是一个 10 x 10 的矩阵。

在下图的左侧,您可以看到给定的矩阵。请注意,值为 0 表示不应该被激活的传感器。在右侧,您可以看到应用程序使用的矩阵和可能的解决方案。这个矩阵是通过扩展传感器的覆盖区域从给定的矩阵中获得的。请记住,传感器的八个相邻单元格也可以激活传感器。解决方案从第一列(单元格(4,0))开始,以最后一列(单元格(9,9))结束,并包含 15 个步骤(从 0 到 14)。您可以在下图中看到这些步骤:

图 12.9 - 给定矩阵(左侧)和解析矩阵(右侧)

图 12.9 - 给定矩阵(左侧)和解析矩阵(右侧)

从坐标(r,c)的安全单元格,我们可以朝四个安全方向移动:(r-1,c),(r,c-1),(r+1,c)和(r,c+1)。如果我们将可能的移动视为方向(边)并将单元格视为顶点,则可以在图的上下文中可视化这个问题。边是可能的移动,而顶点是我们可以到达的可能单元格。每次移动都保持从当前单元格到起始单元格的距离(起始单元格是第一列的单元格)。对于每次移动,距离增加 1。因此,在图的上下文中,问题可以简化为在图中找到最短路径。因此,我们可以使用广度优先搜索(BFS)方法来解决这个问题。在第十三章**,树和图中,您已经了解了 BFS 算法的描述,并且另一个问题也是以与此处解决的问题相同的方式解决的- 国际象棋骑士问题。

现在,根据前面问题提供的经验,我们可以详细说明这个算法:

  1. 从一个空队列开始。

  2. 将第一列的所有安全单元格入队,并将它们的距离设置为 0(这里,0 表示每个单元格到自身的距离)。此外,这些单元格被标记为已访问或标记。

  3. 只要队列不为空,执行以下操作:

a. 弹出表示队列顶部的单元格。

b. 如果弹出的单元格是目的地单元格(即在最后一列),则简单地返回其距离(从目的地单元格到第一列源单元格的距离)。

c. 如果弹出的单元格不是目的地,则对该单元格的四个相邻单元格中的每一个,将每个有效单元格(安全且未访问)入队到队列中,并标记为已访问。

d. 如果我们处理了队列中的所有单元格但没有到达目的地,则没有解决方案。返回-1。

由于我们依赖 BFS 算法,我们知道所有最短路径为 1 的单元格首先被访问。接下来,被访问的单元格是具有最短路径为 1+1=2 等的相邻单元格。因此,具有最短路径的单元格等于其父级的最短路径+1。这意味着当我们第一次遍历目标单元格时,它给出了我们的最终结果。这就是最短路径。让我们看看代码中最相关的部分:

private static int findShortestPath(int[][] board) {
  // stores if cell is visited or not
  boolean[][] visited = new boolean[M][N];
  Queue<Cell> queue = new ArrayDeque<>();
  // process every cell of first column
  for (int r1 = 0; r1 < M; r1++) {
    // if the cell is safe, mark it as visited and
    // enqueue it by assigning it distance as 0 from itself
    if (board[r1][0] == 1) {
      queue.add(new Cell(r1, 0, 0));
      visited[r1][0] = true;
    }
  }
  while (!queue.isEmpty()) {
    // pop the front node from queue and process it
    int rIdx = queue.peek().r;
    int cIdx = queue.peek().c;
    int dist = queue.peek().distance;
    queue.poll();
    // if destination is found then return minimum distance
    if (cIdx == N - 1) {
      return (dist + 1);
    }
    // check for all 4 possible movements from 
    // current cell and enqueue each valid movement
    for (int k = 0; k < 4; k++) {
      if (isValid(rIdx + ROW_4[k], cIdx + COL_4[k])
            && isSafe(board, visited, rIdx + ROW_4[k], 
                cIdx + COL_4[k])) {
        // mark it as visited and push it into 
        // queue with (+1) distance
        visited[rIdx + ROW_4[k]][cIdx + COL_4[k]] = true;
        queue.add(new Cell(rIdx + ROW_4[k], 
          cIdx + COL_4[k], dist + 1));
      }
    }
  }
  return -1;
}

完整的应用程序称为ShortestSafeRoute

中缀、后缀和前缀表达式

前缀、后缀和中缀表达式在当今并不是一个非常常见的面试话题,但它可以被认为是任何开发人员至少应该涵盖一次的一个话题。以下是一个快速概述:

  • 前缀表达式:这是一种表示法(代数表达式),用于编写算术表达式,其中操作数在其运算符之后列出。

  • 后缀表达式:这是一种表示法(代数表达式),用于编写算术表达式,其中操作数在其运算符之前列出。

  • 中缀表达式:这是一种表示法(代数表达式),通常用于算术公式或语句中,其中运算符写在其操作数之间。

如果我们有三个运算符 a、b 和 c,我们可以写出下图中显示的表达式:

图 12.10 - 中缀、后缀和前缀

图 12.10 - 中缀、后缀和前缀

最常见的问题涉及评估前缀和后缀表达式以及在前缀、中缀和后缀表达式之间进行转换。所有这些问题都有依赖于堆栈(或二叉树)的解决方案,并且在任何专门致力于基本算法的严肃书籍中都有涵盖。花些时间,收集一些关于这个主题的资源,以便熟悉它。由于这个主题在专门的书籍中得到了广泛的涵盖,并且在面试中并不常见,我们将不在这里进行涵盖。

摘要

本章涵盖了任何准备进行 Java 开发人员技术面试的候选人必须了解的堆栈和队列问题。堆栈和队列出现在许多实际应用中,因此掌握它们是面试官将测试您的顶级技能之一。

在下一章《树、Trie 和图形》中,您将看到堆栈和队列经常用于解决涉及树和图形的问题,这意味着它们也值得您的关注。

第十三章:树和图

本章涵盖了面试中经常被问到的最棘手的主题之一:树和图。虽然与这两个主题相关的问题有很多,但实际上只有少数问题会在面试中遇到。因此,非常重要的是要优先考虑与树和图相关的最受欢迎的问题。

在本章中,我们将首先简要概述树和图。随后,我们将解决在像亚马逊,微软,Adobe 和其他公司的 IT 巨头的面试中遇到的最受欢迎和具有挑战性的问题。通过本章结束时,你将知道如何以高效和全面的方式回答面试问题并解决关于树和图的编码挑战。

本章涵盖以下主题:

  • 树的概述

  • 图的概述

  • 编码挑战

所以,让我们开始吧!

技术要求

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

树的概述

树是一种非线性数据结构,以节点的层次结构组织数据,不能包含循环。树有一个特定的术语,可能会有些许不同,但通常采用以下概念:

  • 根节点是最顶层的节点。

  • 边缘是两个节点之间的链接或连接。

  • 父节点是具有到子节点的边缘的节点。

  • 子节点是具有父节点的节点。

  • 叶子是没有子节点的节点。

  • 高度是到叶子的最长路径的长度。

  • 深度是到其根的路径的长度。

下图举例说明了这些术语在树上的使用:

图 13.1 – 树术语

图 13.1 – 树术语

通常情况下,任何树都可以有一个根。树的节点可以遵循一定的顺序(或不遵循),可以存储任何类型的数据,并且可以链接到它们的父节点。

树编码挑战充斥着模糊的细节和/或不正确的假设。非常重要的是要在面试中澄清每一个细节,以消除歧义。其中最重要的一个方面涉及到树的类型。让我们来看看最常见的树类型。

一般树

粗略地说,我们可以将树分类为二叉树和其他允许的树。二叉树是一种每个节点最多有两个子节点的树。在下面的图表中,左侧的图像是非二叉树,而右侧的图像是二叉树:

图 13.2 – 非二叉树与二叉树

图 13.2 – 非二叉树与二叉树

在代码方面,二叉树可以被塑造如下(这个实现稍后会在编码挑战部分中使用,所以请记住这一点):

private class Node {
  private Node left;
  private Node right;
  private final T element;
  public Node(T element) {
    this.element = element;
    this.left = null;
    this.right = null;
  }
  public Node(Node left, Node right, T element) {
    this.element = element;
    this.left = left;
    this.right = right;
  }
  // operations
}

正如你所看到的,每个Node都保留对其他两个Node元素的引用,以及一个通用数据(元素)。左节点和右节点代表当前节点的子节点。在面试中遇到的大多数树编码挑战都使用二叉树,因此它们值得特别关注。二叉树可以被分类如下。

了解二叉树遍历

在参加技术面试之前,你必须知道如何遍历二叉树。通常情况下,遍历二叉树本身不会成为问题,但你必须熟悉广度优先搜索BFS)和深度优先搜索DFS)算法,以及它们的三种变体:前序中序后序。下图表示了每种遍历类型的结果:

图 13.3 – 二叉树遍历

图 13.3 – 二叉树遍历

让我们简要概述一下 BFS 和 DFS 算法。

树的广度优先搜索(BFS)

树的 BFS 也被称为层次遍历。其主要思想是维护一个节点队列,以确保遍历顺序。最初,队列只包含根节点。算法步骤如下:

  1. 从队列中弹出第一个节点作为当前节点。

  2. 访问当前节点。

  3. 如果当前节点有左节点,则将该左节点入队。

  4. 如果当前节点有右节点,则将该右节点入队。

  5. 重复从步骤 1开始,直到队列为空。

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

private void printLevelOrder(Node node) {
  Queue<Node> queue = new ArrayDeque<>();
  queue.add(node);
  while (!queue.isEmpty()) {
    // Step 1
    Node current = queue.poll();
    // Step 2
    System.out.print(" " + current.element);
    // Step 3
    if (current.left != null) {
      queue.add(current.left);
    }
    // Step 4
    if (current.right != null) {
      queue.add(current.right);
    }
  }
}

接下来,让我们专注于 DFS。

深度优先搜索(DFS)用于树

树的 DFS 有三种变体:先序遍历中序遍历后序遍历

先序遍历在访问其子节点之前访问当前节点,如下所示(根节点 | 左子树 | 右子树)

private void printPreOrder(Node node) {
  if (node != null) {
    System.out.print(" " + node.element);
    printPreOrder(node.left);
    printPreOrder(node.right);
  }
}

中序遍历先访问左分支,然后访问当前节点,最后访问右分支,如下所示(左子树 | 根节点 | 右子树)

private void printInOrder(Node node) {
  if (node != null) {
    printInOrder(node.left);
    System.out.print(" " + node.element);
    printInOrder(node.right);
  }
}

后序遍历在访问其子节点之后访问当前节点,如下所示(左子树 | 右子树 | 根节点)

private void printPostOrder(Node node) {
  if (node != null) {
    printPostOrder(node.left);
    printPostOrder(node.right);
    System.out.print(" " + node.element);
  }
}

完整的应用程序称为BinaryTreeTraversal。除了前面的示例之外,完整的代码还包含了返回ListIterator的 BFS 和 DFS 实现。

二叉搜索树

二叉搜索树(BST)是一种遵循排序规则的二叉树。通常,在 BST 中,左子节点(根的左侧所有元素)小于或等于根元素,右子节点(根的右侧所有元素)大于根元素。然而,这个顺序不仅适用于根元素。它适用于每个节点n,因此在 BST 中,n左子节点n < n右子节点。在下图中,左侧的图像是二叉树,右侧的图像是 BST:

图 13.4 – 二叉树与二叉搜索树

图 13.4 – 二叉树与二叉搜索树

通常,BST 不接受重复项,但当它接受时,它们可以在一侧(例如,仅在左侧)或两侧都存在。重复项也可以存储在单独的哈希映射中,或者直接通过计数器存储在树的结构中。请注意并与面试官澄清这些细节。在亚马逊、Flipkart 和微软的面试中,处理 BST 中的重复项是一个常见问题,因此它将在编码挑战部分中进行讨论。

在本书附带的代码中,您可以找到一个名为BinarySearchTreeTraversal的应用程序,其中包含以下一组方法:insert(T element)contains(T element)delete(T element)min()max()root()size()height()。此外,它包含了用于打印节点和将节点返回为ListIterator的 BFS 和 DFS 的实现。请花时间仔细研究代码。

平衡和不平衡的二叉树

当二叉树保证插入和查找操作的 O(log n)时间时,我们可以说我们有一个平衡的二叉树,但这并不一定是尽可能平衡的。当树中任何节点的左子树和右子树的高度差不超过 1 时,树就是高度平衡的。在下图中,左侧的树是不平衡的二叉树,中间的树是平衡的二叉树,但不是高度平衡的,右侧的树是高度平衡的树:

图 13.5 – 不平衡二叉树与平衡二叉树与高度平衡二叉树

图 13.5 – 不平衡二叉树与平衡二叉树与高度平衡二叉树

平衡树有两种类型:红黑树和 AVL 树。

红黑树

红黑树是一种自平衡的二叉搜索树,其中每个节点都受以下规则的影响:

  • 每个节点要么是红色,要么是黑色

  • 根节点始终是黑色

  • 每个叶子(NULL)都是黑色

  • 红色节点的两个子节点都是黑色

  • 从节点到 NULL 节点的每条路径具有相同数量的黑色节点

以下图表示了红黑树:

图 13.6 - 红黑树示例

图 13.6 - 红黑树示例

红黑树永远不会变得非常不平衡。如果所有节点都是黑色,那么树就变成了完全平衡树。当其最长路径上的节点交替为黑色和红色节点时,红黑树的高度最大。黑红树的高度始终小于或等于 2log2(n+1),因此其高度始终在 O(log n)的数量级内。

由于其复杂性和实施时间,涉及红黑树的问题在面试中并不常见。然而,当它们出现时,问题可能会要求您实现插入、删除或查找操作。在本书附带的代码中,您可以找到一个展示这些操作的红黑树实现。花些时间研究代码,熟悉红黑树的概念。该应用程序名为RedBlackTreeImpl

您可能想要查看的更多实现可以在github.com/williamfiset/data-structures/blob/master/com/williamfiset/datastructures/balancedtree/RedBlackTree.javaalgs4.cs.princeton.edu/33balanced/RedBlackBST.java.html找到。有关图形可视化,请考虑www.cs.usfca.edu/~galles/visualization/RedBlack.html

如果您需要深入研究这个主题,我强烈建议您阅读一本专门讲述数据结构的书,因为这是一个非常广泛的主题。

AVL 树

AVL树(以其发明者Adelson-Velsky 和Landis 命名)是一种自平衡的 BST,遵守以下规则:

  • 子树的高度最多相差 1。

  • 节点(n)的平衡因子(BN)为-1、0 或 1,并定义为高度(h)差:BN=h(right_subtree(n)) - h(left_subtree(n))或BN=h(left_subtree(n)) - h(right_subtree(n)。

以下图表示了 AVL 树:

图 13.7 - AVL 树示例

图 13.7 - AVL 树示例

AVL 树允许所有操作(插入、删除、查找最小值、查找最大值等)在 O(log n)的时间内执行,其中n是节点数。

由于其复杂性和实施时间,涉及 AVL 树的问题在面试中并不常见。然而,当它们出现时,问题可能会要求您实现插入、删除或查找操作。在本书附带的代码中,您可以找到一个展示这些操作的 AVL 树实现。花些时间研究代码,熟悉 AVL 树的概念。该应用程序名为AVLTreeImpl

您可能想要查看的更多实现可以在github.com/williamfiset/data-structures/blob/master/com/williamfiset/datastructures/balancedtree/AVLTreeRecursiveOptimized.javaalgs4.cs.princeton.edu/code/edu/princeton/cs/algs4/AVLTreeST.java.html找到。有关图形可视化,请考虑www.cs.usfca.edu/~galles/visualization/AVLtree.html

如果您需要深入研究这个主题,我强烈建议您阅读一本专门讲述数据结构的书,因为这是一个非常广泛的主题。

完全二叉树

完全二叉树是指每一层(最后一层可能除外)都是完全填充的二叉树。此外,所有节点尽可能靠左。在下图中,左侧显示了一个非完全二叉树,而右侧显示了一个完全二叉树:

图 13.8 – 非完全二叉树与完全二叉树

图 13.8 – 非完全二叉树与完全二叉树

完全二叉树必须从左到右填充,因此上图中左侧显示的树不是完全二叉树。具有n个节点的完全二叉树始终具有 O(log n)的高度。

满二叉树

满二叉树是指每个节点都有两个子节点或没有子节点的二叉树。换句话说,一个节点不能只有一个子节点。在下图中,左侧显示了一个非满二叉树,而右侧显示了一个满二叉树:

图 13.9 – 非满二叉树与满二叉树

图 13.9 – 非满二叉树与满二叉树

在上图中,左侧的树不是满树,因为节点 68 只有一个子节点。

完美二叉树

完美二叉树既是完全的又是满的。下图显示了这样一棵树:

图 13.10 – 完美二叉树

图 13.10 – 完美二叉树

因此,在完美二叉树中,所有叶节点都在同一级别。这意味着最后一级包含最大数量的节点。这种树在面试中相当罕见。

重要提示

Is this a balanced tree? Is it a full binary tree?, Is it a BST?. In other words, don't base your solution on assumptions that may not be true for the given binary tree.

现在,让我们更详细地讨论二叉堆。

二叉堆

简而言之,二叉堆是一棵具有堆属性的完全二叉树。当元素按升序排列时(堆属性表示每个节点的元素大于或等于其父节点的元素),我们有一个最小二叉堆(最小元素是根元素),而当它们按降序排列时(堆属性表示每个节点的元素小于或等于其父节点的元素),我们有一个最大二叉堆(最大元素是根元素)。

下图显示了一个完全二叉树(左侧),一个最小二叉堆(中间),和一个最大二叉堆(右侧):

图 13.11 – 完全二叉树和最小和最大堆

图 13.11 – 完全二叉树和最小和最大堆

二叉堆不是排序的。它是部分有序的。在任何给定级别上,节点之间没有关系。

二叉堆通常表示为一个数组(我们将其表示为heap),其根节点位于heap[0]。更重要的是,对于heap[i],我们有以下情况:

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

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

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

当通过数组实现最大二叉堆时,它看起来如下:

public class MaxHeap<T extends Comparable<T>> {
  private static final int DEFAULT_CAPACITY = 5;
  private int capacity;
  private int size;
  private T[] heap;
  public MaxHeap() {
    capacity = DEFAULT_CAPACITY;
    this.heap = (T[]) Array.newInstance(
      Comparable[].class.getComponentType(),DEFAULT_CAPACITY);
  }
  // operations
}

与堆一起使用的常见操作是add()poll()peek()。添加或轮询元素后,我们必须修复堆,以使其符合堆属性。这一步通常被称为堆化堆。

向堆中添加元素是一个 O(log n)的时间操作。新元素添加到堆树的末尾。如果新元素小于其父元素,则我们不需要做任何操作。否则,我们必须向上遍历堆以修复违反的堆属性。这个操作被称为堆化上堆化上背后的算法有两个步骤:

  1. 从堆的末尾开始作为当前节点。

  2. 当前节点有父节点且父节点小于当前节点时,交换这些节点。

从堆中轮询元素也是一个 O(log n)的时间操作。在我们轮询了堆的根元素之后,我们必须修复堆,使其遵守堆属性。这个操作被称为heapify-downheapify-down背后的算法有三个步骤:

  1. 从堆的根开始作为当前节点。

  2. 确定当前节点的子节点中最大的节点。

  3. 如果当前节点小于其最大的子节点,则交换这两个节点,并从步骤 2重复;否则,没有其他事情可做,所以停止。

最后,peeking 是一个 O(1)的操作,返回堆的根元素。

在本书附带的代码中,您可以找到一个名为MaxHeap的应用程序,它公开了以下一组方法:add(T element)peek()poll()

重要提示

树的一个特殊情况被称为 Trie。Trie 也被称为数字树前缀树,是一种用于存储字符串的有序树结构。它的名称来自于 Trie 是一种检索数据结构。它的性能比二叉树好。Trie 在我的书《Java 编程问题》中有详细介绍(www.packtpub.com/programming/java-coding-problems),以及其他数据结构,如元组、不相交集、二进制索引树(Fenwick 树)和 Bloom 过滤器。

接下来,让我们简要概述一下图。

图简介

图是用于表示可以通过边连接的节点集合的数据结构。例如,图可以用于表示社交媒体平台上成员的网络,因此它是表示现实生活连接的良好数据结构。树(如前一节中详细介绍的)是图的一种特殊类型。换句话说,树是没有循环的图。在图的术语中,没有循环的图被称为无环图

图的特定术语涉及两个主要术语:

  • 顶点表示信息(例如成员、狗或值)

  • 是两个顶点之间的连接或关系

连接可以是单向的(如二叉树的情况)或双向的。当连接是双向的(比如双向街道)时,图被称为无向图,它有无向边。当连接是单向的(比如单向街道)时,图被称为有向图,它有有向边

图的边可以携带称为权重的信息(例如,道路的长度)。在这种情况下,图被称为加权图。当图有一个指向相同顶点的单个边时,它被称为自环图。下图提供了每种图类型的表示:

图 13.12 - 图类型

图 13.12 - 图类型

与二叉树不同,通过节点链接表示图形是不实际的。在计算机中,图通常通过邻接矩阵或邻接表表示。让我们来解决前者;也就是邻接矩阵。

邻接矩阵

邻接矩阵由一个大小为n x n的布尔二维数组(或只包含 0 和 1 的整数二维数组)表示,其中n是顶点的数量。如果我们将这个二维数组表示为一个矩阵,那么matrix[i][j]为 true(或 1),如果从顶点i到顶点j有一条边;否则为 false(或 0)。下图显示了一个无向图的邻接矩阵的示例:

图 13.13 - 无向图的邻接矩阵

图 13.13 - 无向图的邻接矩阵

为了节省空间,也可以使用位矩阵。

在加权图的情况下,邻接矩阵可以存储边的权重,而 0 可以用于表示边的不存在。

根据邻接矩阵实现图可以如下进行(我们只需要顶点列表,因为边被传递给每个必须遍历图的方法,作为邻接矩阵的一部分):

public class Graph<T> {
  // the vertices list
  private final List<T> elements;
  public Graph() {
    this.elements = new ArrayList<>();
  }
  // operations
}

我们可以使用另一种方法来在计算机中表示图,那就是邻接表。

邻接表

邻接表是一个列表数组,其大小等于图中顶点的数量。每个顶点都存储在这个数组中,并且它存储了一个相邻顶点的列表。换句话说,数组中索引i处的列表包含了存储在数组索引i处的顶点的相邻顶点。下图显示了一个无向图的邻接表示例:

图 13.14 - 无向图的邻接表

图 13.14 - 无向图的邻接表

根据邻接表实现图可以如下进行(这里,我们使用Map来实现邻接表):

public class Graph<T> {
  // the adjacency list is represented as a map
  private final Map<T, List<T>> adjacencyList;
  public Graph() {
    this.adjacencyList = new HashMap<>();
  }
  // operations
}

接下来,让我们简要介绍一下图的遍历。

图的遍历

遍历图的两种最常见方法是深度优先搜索DFS)和广度优先搜索BFS)。让我们简要介绍一下每种方法。BFS主要用于图。

在图的情况下,我们必须考虑到图可能有循环。普通的 BFS 实现(就像你在二叉树的情况下看到的那样)不考虑循环,所以在遍历 BFS 队列时存在无限循环的风险。通过额外的集合来消除这种风险,这个集合保存了已访问的节点。该算法的步骤如下:

  1. 将起始节点(当前节点)标记为已访问(将其添加到已访问节点的集合中)并将其添加到 BFS 队列中。

  2. 从队列中弹出当前节点。

  3. 访问当前节点。

  4. 获取当前节点的相邻节点。

  5. 循环相邻节点。对于每个非空且未访问的节点,执行以下操作:

a. 将其标记为已访问(将其添加到已访问节点的集合中)。

b. 将其添加到队列中。

  1. 重复从步骤 2直到队列为空。

图的深度优先搜索(DFS)

在图的情况下,我们可以通过递归或迭代实现 DFS 算法。

通过递归实现图的 DFS

通过递归实现图的 DFS 算法的步骤如下:

  1. 从当前节点(给定节点)开始,并将当前节点标记为已访问(将其添加到已访问节点的集合中)。

  2. 访问当前节点。

  3. 通过递归遍历未访问的相邻顶点。

图的深度优先搜索 - 迭代实现

DFS 算法的迭代实现依赖于Stack。步骤如下:

  1. 从当前节点(给定节点)开始,并将当前节点推入Stack

  2. Stack不为空时,执行以下操作:

a. 从Stack中弹出当前节点。

b. 访问当前节点。

c. 将当前节点标记为已访问(将其添加到已访问节点的集合中)。

d. 将未访问的相邻顶点推入Stack

在本书附带的代码中,你可以找到基于邻接矩阵的图实现,名为GraphAdjacencyMatrixTraversal。你还可以找到一个基于邻接表的实现,名为GraphAdjacencyListTraversal。这两个应用程序都包含了 BFS 和 DFS 的实现。

编程挑战

现在我们已经简要了解了树和图,是时候挑战自己,解决关于这些主题的面试中遇到的 25 个最受欢迎的编程问题了。

和往常一样,我们有一系列通常由世界顶级公司遇到的问题,包括亚马逊、Adobe 和谷歌等 IT 巨头。所以,让我们开始吧!

编码挑战 1 - 两个节点之间的路径

如果两个给定节点之间存在路径(路由),则返回true

解决方案:让我们考虑下图所示的有向图:

图 13.15 - 从 D 到 E 和从 E 到 D 的路径

图 13.15 - 从 D 到 E 和从 E 到 D 的路径

如果我们考虑节点DE,我们可以看到从DE有三条路径,而从ED没有路径。因此,如果我们从D开始遍历图(通过 BFS 或 DFS),那么在某个时候,我们必须经过节点E,否则DE之间将没有路径。因此,解决这个问题的解决方案包括从给定节点中的一个开始,并遍历图直到到达第二个给定节点,或者直到没有更多有效的移动。例如,我们可以通过 BFS 来做到这一点:

public boolean isPath(T from, T to) {
  Queue<T> queue = new ArrayDeque<>();
  Set<T> visited = new HashSet<>();
  // we start from the 'from' node
  visited.add(from);
  queue.add(from);
  while (!queue.isEmpty()) {
    T element = queue.poll();
    List<T> adjacents = adjacencyList.get(element);
    if (adjacents != null) {
      for (T t : adjacents) {
        if (t != null && !visited.contains(t)) {
          visited.add(t);
          queue.add(t);
          // we reached the destination (the 'to' node)
          if (t.equals(to)) {
            return true;
          }
        }
      }
    }
  }
  return false;
}

完整的应用程序称为DirectedGraphPath

编码挑战 2 - 排序数组到最小 BST

亚马逊谷歌

问题:假设你得到了一个有序(升序)的整数数组。编写一小段代码,从这个数组创建最小的 BST。我们将最小的 BST 定义为高度最小的 BST。

解决方案:将给定的数组视为{-2, 3, 4, 6, 7, 8, 12, 23, 90}。可以从该数组创建的最小 BST 如下所示:

图 13.16 - 排序数组到最小 BST

图 13.16 - 排序数组到最小 BST

为了获得最小高度的 BST,我们必须努力在左右子树中分配相等数量的节点。考虑到这一点,注意到我们可以选择排序数组的中间值作为根。中间值左侧的数组元素小于中间值,因此它们可以形成左子树。中间值右侧的数组元素大于中间值,因此它们可以形成右子树。

因此,我们可以选择 7 作为树的根。接下来,-2、3、4 和 6 应该形成左子树,而 8、12、23 和 90 应该形成右子树。然而,我们知道我们不能简单地将这些元素添加到左子树或右子树,因为我们必须遵守 BST 属性:在 BST 中,对于每个节点nn的左子节点≤n<n的右子节点。

然而,我们可以简单地遵循相同的技术。如果我们将-2、3、4 和 6 视为一个数组,那么它的中间值是 3,如果我们将 8、12、24 和 90 视为一个数组,那么它的中间值是 12。因此,3 是包含-2 的左子子树的根,右子子树是包含 4 和 6 的子树。同样,12 是包含 8 的左子子树的根,右子子树是包含 24 和 90 的子树。

嗯,我认为我们有足够的经验来直觉地应用相同的技术,直到我们处理完所有的子数组。此外,很直观地,这个解决方案可以通过递归来实现(如果你不认为递归是你的顶级技能之一,请查看第八章**,递归和动态规划)。因此,我们可以将我们的算法总结为四个步骤:

  1. 将数组的中间元素插入树中。

  2. 将左子数组的元素插入左子树。

  3. 将右子数组的元素插入右子树。

  4. 触发递归调用。

以下实现将这些步骤转化为代码:

public void minimalBst(T m[]) {       
  root = minimalBst(m, 0, m.length - 1);
}
private Node minimalBst(T m[], int start, int end) {
  if (end < start) {
    return null;
  }
  int middle = (start + end) / 2;
  Node node = new Node(m[middle]);
  nodeCount++;
  node.left = minimalBst(m, start, middle - 1);
  node.right = minimalBst(m, middle + 1, end);
  return node;
}

完整的应用程序称为SortedArrayToMinBinarySearchTree

编码挑战 3 - 每层列表

问题:假设你得到了一个二叉树。编写一小段代码,为树的每一层创建一个元素列表(例如,如果树的深度为d,那么你将有d个列表)。

解决方案:让我们考虑下面图中显示的二叉树:

图 13.17 - 每层列表

图 13.17 - 每层列表

因此,我们有一个深度为 3 的二叉树。在深度 0 上,我们有根 40。在深度 1 上,我们有 47 和 45。在深度 2 上,我们有 11、13、44 和 88。最后,在深度 3 上,我们有 3 和 1。

这样想是很直观的:如果我们逐级遍历二叉树,那么我们可以为每个级别创建一个元素列表。换句话说,我们可以调整 BFS 算法(也称为层次遍历),以便捕获每个遍历级别的元素。更确切地说,我们从遍历根节点开始(并创建一个包含此元素的列表),继续遍历第 1 级(并创建一个包含此级别的元素的列表),依此类推。

当我们到达第i级时,我们将已经完全访问了前一级,i-1 上的所有节点。这意味着要获得第i级的元素,我们必须遍历前一级,i-1 上的所有节点的子节点。以下解决方案需要 O(n)时间运行:

public List<List<T>> fetchAllLevels() {
  // each list holds a level
  List<List<T>> allLevels = new ArrayList<>();
  // first level (containing only the root)
  Queue<Node> currentLevelOfNodes = new ArrayDeque<>();
  List<T> currentLevelOfElements = new ArrayList<>();
  currentLevelOfNodes.add(root);
  currentLevelOfElements.add(root.element);
  while (!currentLevelOfNodes.isEmpty()) {
    // store the current level as the previous level
    Queue<Node> previousLevelOfNodes = currentLevelOfNodes;
    // add level to the final list
    allLevels.add(currentLevelOfElements);
    // go to the next level as the current level
    currentLevelOfNodes = new ArrayDeque<>();
    currentLevelOfElements = new ArrayList<>();
    // traverse all nodes on current level
    for (Node parent : previousLevelOfNodes) {
      if (parent.left != null) {
        currentLevelOfNodes.add(parent.left);                    
        currentLevelOfElements.add(parent.left.element);
      }
      if (parent.right != null) {
        currentLevelOfNodes.add(parent.right);                      
        currentLevelOfElements.add(parent.right.element);
      }
    }
  }
  return allLevels;
}

完整的应用程序称为ListPerBinaryTreeLevel.

编码挑战 4 – 子树

Adobe微软Flipkart

如果qp的子树,则返回true

解决方案:考虑以下图表:

图 13.18 – 二叉树的另一个二叉树的子树

图 13.18 – 一个二叉树的子树

正如我们所看到的,中间的二叉树qp1二叉树(左侧)的子树,但不是p2二叉树(右侧)的子树。

此外,该图表揭示了两种情况:

  • 如果p的根与q的根匹配(p.root.element == q.root.element),那么问题就变成了检查q的右子树是否与p的右子树相同,或者q的左子树是否与p的左子树相同。

  • 如果p的根节点与q的根节点不匹配(p.root.element != q.root.element),那么问题就变成了检查p的左子树是否与q相同,或者p的右子树是否与q相同。

为了实现第一个方法,我们需要两种方法。为了更好地理解为什么我们需要两种方法,请查看以下图表:

图 13.19 – 根和叶匹配,但中间节点不匹配

图 13.19 – 根和叶匹配,但中间节点不匹配

如果pq的根匹配,但左/右子树的一些节点不匹配,那么我们必须回到pq的起点,检查q是否是p的子树。第一个方法应该检查根相同的情况下树是否相同。第二个方法应该处理我们发现树不相同但从某个节点开始的情况。注意这一点,因为许多候选人没有考虑到这一点。

因此,在代码方面,我们有以下内容(对于n个节点,这需要 O(n)时间运行):

public boolean isSubtree(BinaryTree q) {
  return isSubtree(root, q.root);
}
private boolean isSubtree(Node p, Node q) {
  if (p == null) {
    return false;
  }
  // if the roots don't match
  if (!match(p, q)) {
    return (isSubtree(p.left, q) || isSubtree(p.right, q));
  }
  return true;
}
private boolean match(Node p, Node q) {
  if (p == null && q == null) {
    return true;
  }
  if (p == null || q == null) {
    return false;
  }
  return (p.element == q.element
      && match(p.left, q.left)
      && match(p.right, q.right));
}

该应用程序称为BinaryTreeSubtree**.

编码挑战 5 – 着陆预订系统

亚马逊Adobe微软

问题:考虑一个只有一条跑道的机场。这个机场接收来自不同飞机的着陆请求。着陆请求包含着陆时间(例如,9:56)和完成程序所需的分钟数(例如,5 分钟)。我们将其表示为 9:56(5)。编写一段代码,使用 BST 设计这个预订系统。由于只有一条跑道,代码应拒绝任何与现有请求重叠的着陆请求。请求的顺序决定了预订的顺序。

解决方案:让我们考虑一下我们着陆时间线的时间截图(着陆请求的顺序是 10:10(3),10:14(3),9:55(2),10:18(1),9:58(5),9:47(2),9:41(2),10:22(1),9:50(6)和 10:04(4)。这可以在以下图表中看到:

图 13.20 – 时间线截图

图 13.20 – 时间线截图

因此,我们已经做了几次预订,如下:在 9:41,一架飞机将着陆,需要 2 分钟完成程序;在 9:47 和 9:55,还有两架飞机需要 2 分钟完成着陆;在 9:58,我们有一架飞机需要 5 分钟完成着陆;等等。此外,我们还有两个新的着陆请求,图中标记为R1R2

请注意,我们无法批准R1着陆请求。着陆时间是 9:50,需要 6 分钟完成,所以在 9:56 结束。然而,在 9:56 时,我们已经有了来自 9:55 的飞机。由于我们只有一个跑道,我们拒绝了这个着陆请求。我们认为这种情况是重叠的。

另一方面,我们批准R2着陆请求。请求时间是 10:04,需要 4 分钟完成,所以在 10:08 结束。在 10:08 时,跑道上没有其他飞机,因为下一次着陆是在 10:10。

请注意,我们必须使用 BST 来解决这个问题,但使用数组(排序或未排序)或链表(排序或未排序)也是一种有效的方法。使用未排序的数组(或链表)将需要 O(1)时间来插入着陆请求,并且需要 O(n)时间来检查潜在的重叠。如果我们使用排序的数组(或链表)和二分搜索算法,那么我们可以在 O(log n)时间内检查潜在的重叠。但是,要插入着陆请求,我们将需要 O(n),因为我们必须将插入位置右侧的所有元素移动。

使用 BST 如何?首先,让我们将前面的时间线截图表示为 BST。请查看以下图表(着陆请求的顺序是 10:10(3),10:14(3),9:55(2),10:18(1),9:58(5),9:47(2),9:41(2),10:22(1),9:50(6)和 10:04(4)):

图 13.21-时间线截图作为 BST

图 13.21-时间线截图作为 BST

这一次,对于每个着陆请求,我们只需要扫描树的一半。这是使用 BST 的结果(左侧的所有节点都小于右侧的所有节点,因此着陆请求时间只能在左侧或右侧子树中)。例如,10:04 的着陆请求小于根(10:10),因此它进入左子树。如果在任何给定的着陆请求中,我们遇到重叠,那么我们只需返回而不将相应的节点插入树中。我们可以在 O(h)时间内找到潜在的重叠,其中h是 BST 的高度,并且我们可以在 O(1)时间内插入它。

重叠由以下简单的计算给出(我们使用 Java 8 日期时间 API,但您也可以将其简化为简单的整数-如果您不熟悉 Java 8 日期时间 API,那么我强烈建议您购买我的书Java 编码问题,由 Packt 出版(www.packtpub.com/programming/java-coding-problems)。这本书有一章关于这个主题的惊人章节,对于任何候选人来说都是必读

long t1 = Duration.between(current.element.
  plusMinutes(current.time), element).toMinutes();
long t2 = Duration.between(current.element,   
  element.plusMinutes(time)).toMinutes();
if (t1 <= 0 && t2 >= 0) {
    // overlapping found
}

因此,在t1中,我们计算当前节点的(着陆时间+完成所需时间)与当前请求的着陆时间之间的时间。在t2中,我们计算当前节点的着陆时间与(当前请求的着陆时间+完成所需时间)之间的时间。如果t1小于或等于t2,那么我们已经找到了一个重叠,因此我们拒绝当前的着陆请求。让我们看看完整的代码:

public class BinarySearchTree<Temporal> {
  private Node root = null;
  private class Node {
    private Node left;
    private Node right;
    private final LocalTime element;
    private final int time;
    public Node(LocalTime element, int time) {
      this.time = time;
      this.element = element;
      this.left = null;
      this.right = null;
    }
    public Node(Node left, Node right, 
            LocalTime element, int time) {
      this.time = time;
      this.element = element;
      this.left = left;
      this.right = right;
    }
  }
  public void insert(LocalTime element, int time) {
    if (element == null) {
      throw new IllegalArgumentException("...");
    }
    root = insert(root, element, time);
  }
  private Node insert(Node current, 
          LocalTime element, int time) {
    if (current == null) {
      return new Node(element, time);
    }
    long t1 = Duration.between(current.element.
        plusMinutes(current.time), element).toMinutes();
    long t2 = Duration.between(current.element, 
        element.plusMinutes(time)).toMinutes();
    if (t1 <= 0 && t2 >= 0) {
      System.out.println("Cannot reserve the runway at "
        + element + " for " + time + " minutes !");
      return current;
    }
    if (element.compareTo(current.element) < 0) {
      current.left = insert(current.left, element, time);
    } else {
      current.right = insert(current.right, element, time);
    }
    return current;
  }
  public void printInOrder() {
    printInOrder(root);
  }
  private void printInOrder(Node node) {
    if (node != null) {
      printInOrder(node.left);
      System.out.print(" " + node.element
        + "(" + node.time + ")");
      printInOrder(node.right);
    }
  }
}

请注意,我们可以通过使用 BST 的中序遍历轻松打印时间线。完整的应用程序称为BinaryTreeLandingReservation

编码挑战 6-平衡二叉树

亚马逊微软

如果二叉树是平衡的,则为true

解决方案:因此,为了拥有平衡的二叉树,对于每个节点,两个子树的高度不能相差超过一。遵循这个声明,右侧的图像代表一个平衡的二叉树,而左侧的图像代表一个不平衡的二叉树:

图 13.22 – 不平衡和平衡二叉树

图 13.22 – 不平衡和平衡二叉树

左侧的二叉树不平衡,因为根节点 40 和 30 的左子树的高度和右子树的高度之差大于一(例如,left-height(40) = 4,而right-height(40) = 2)。

右侧的二叉树是平衡的,因为对于每个节点,左子树和右子树的高度差不大于一。

根据这个例子,我们可以直观地得出一个简单的解决方案,即递归算法。我们可以遍历每个节点并计算左右子树的高度。如果这些高度之间的差大于一,那么我们返回false。在代码方面,这非常简单:

public boolean isBalanced() {
  return isBalanced(root);
}
private boolean isBalanced(Node root) {
  if (root == null) {
    return true;
  }
  if (Math.abs(height(root.left) - height(root.right)) > 1) {
    return false;
  } else {
    return isBalanced(root.left) && isBalanced(root.right);
  }
}
private int height(Node root) {
  if (root == null) {
    return 0;
  }
  return Math.max(height(root.left), height(root.right)) + 1;
}

这种方法的执行时间为 O(n log n),因为在每个节点上,我们通过整个子树应用递归。因此,问题在于height()调用的次数。目前,height()方法只计算高度。但它可以改进为检查树是否平衡。我们只需要通过错误代码来表示不平衡的子树。另一方面,对于平衡树,我们返回相应的高度。我们可以使用Integer.MIN_VALUE代替错误代码,如下所示:

public boolean isBalanced() {
  return checkHeight(root) != Integer.MIN_VALUE;
}
private int checkHeight(Node root) {
  if (root == null) {
    return 0;
  }
  int leftHeight = checkHeight(root.left);
  if (leftHeight == Integer.MIN_VALUE) {
    return Integer.MIN_VALUE; // error 
  }
  int rightHeight = checkHeight(root.right);
  if (rightHeight == Integer.MIN_VALUE) {
    return Integer.MIN_VALUE; // error 
  }
  if (Math.abs(leftHeight - rightHeight) > 1) {
    return Integer.MIN_VALUE; // pass error back
  } else {
    return Math.max(leftHeight, rightHeight) + 1;
  }
}

这段代码运行时间为 O(n),空间为 O(h),其中h是树的高度。该应用程序称为BinaryTreeBalanced

编码挑战 7 – 二叉树是 BST

亚马逊谷歌Adobe微软Flipkart

true如果这棵树是二叉搜索树BST)。

解决方案:从一开始,我们注意到问题明确提到给定的二叉树可能包含重复项。为什么这很重要?因为如果二叉树不允许重复项,那么我们可以依赖简单的中序遍历和数组。如果我们将每个遍历的元素添加到数组中,那么结果数组只有在二叉树是 BST 时才会排序。让我们通过以下图表澄清这一方面:

图 13.23 – 有效和无效的 BSTs

图 13.23 – 有效和无效的 BSTs

我们知道 BST 属性表示 BST 的每个节点n左子代 n ≤ n < 右子代 n。这意味着前面图表中显示的前两个二叉树是有效的 BST,而最后一个不是有效的 BST。现在,将中间和最后一个二叉树的元素添加到数组中将得到一个数组[40, 40]。这意味着我们无法根据此数组验证或使 BST 无效,因为我们无法区分树。因此,总之,如果给定的二叉树不接受重复项,您应该依赖这个简单的算法。

现在,是时候更进一步了。让我们检查下面二叉树中所示的n ≤ n < n 的左子代语句:

图 13.24 – 无效的 BST

图 13.24 – 无效的 BST

看看这个!对于每个节点n,我们可以写成n.left ≤ n < n.right,但很明显 55 放错了地方。所以,让我们强调当前节点的所有左节点应小于或等于当前节点,当前节点必须小于所有右节点。

换句话说,仅仅验证当前节点的左右节点是不够的。我们必须将每个节点与一系列节点的范围进行验证。更确切地说,左子树或右子树的所有节点应该在最小接受元素和最大接受元素(min, max)所限定的范围内进行验证。让我们考虑以下树:

图 13.25 - 验证 BST

图 13.25 - 验证 BST

我们从根节点(40)开始,并考虑(min=null, max=null),所以 40 满足条件,因为没有最小或最大限制。接下来,我们转向左子树(让我们将这个子树称为 40-left-sub-tree)。40-left-sub-tree 中的所有节点应该在(null, 40)范围内。接下来,我们再次向左转,遇到 35-left-sub-tree,它应该在(null, 35)范围内。基本上,我们继续向左走,直到没有节点为止。在这一点上,我们开始向右走,所以 35-right-sub-tree 应该在(35, 40)范围内,40-right-sub-tree 应该在(40, null)范围内,依此类推。所以,当我们向左走时,最大值会更新。当我们向右走时,最小值会更新。如果出了问题,我们就停下来并返回false。让我们基于这个算法来看看代码:

public boolean isBinarySearchTree() {
  return isBinarySearchTree(root, null, null);
}
private boolean isBinarySearchTree(Node node, 
        T minElement, T maxElement) {
  if (node == null) {
    return true;
  }
  if ((minElement != null && 
    node.element.compareTo(minElement) <= 0)
       || (maxElement != null && node.element.
              compareTo(maxElement) > 0)) {
    return false;
  }
  if (!isBinarySearchTree(node.left, minElement, node.element)
          || !isBinarySearchTree(node.right, 
                node.element, maxElement)) {
    return false;
  }
  return true;
}

完整的应用程序称为BinaryTreeIsBST

编码挑战 8 - 后继节点

谷歌微软

问题:考虑到你已经得到了一个二叉搜索树BST)和这个树中的一个节点。编写一小段代码,打印出中序遍历上给定节点的后继节点。

解决方案:因此,让我们回顾一下二叉树的中序遍历。这种深度优先搜索DFS)的遍历方式先遍历左子树,然后是当前节点,然后是右子树。现在,让我们假设我们任意选择了 BST 中的一个节点(让我们将其称为n),并且我们想在中序遍历的上下文中找到它的后继节点(让我们将其称为s)。

让我们将以下图表视为给定的 BST。我们可以用它来区分可能的情况:

图 13.26 - 具有起始和后继节点的 BST 示例

图 13.26 - 具有起始和后继节点的 BST 示例

如前面的图表所示,我们将两个主要情况标记为(a)和(b)。在情况(a)中,节点n有右子树。在情况(b)中,节点n不包含右子树。

情况(a)在左侧 BST 中得到了例证,如果节点n有右子树,那么后继节点s就是这个右子树的最左节点。例如,对于n=50,后继节点是 54。

情况(b)有两个子情况:一个简单情况和一个棘手情况。简单情况在前面图表中显示的中间 BST 中得到了例证。当节点n不包含右子树且n是其父节点的左子节点时,后继节点就是这个父节点。例如,对于n=40,后继节点是 50。这是情况(b)的简单子情况。

(b)的棘手子情况在前面图表中显示的右侧 BST 中得到了例证。当节点n不包含右子树且n是其父节点的右子节点时,我们必须向上遍历,直到n成为其父节点的左子节点。一旦我们做到了这一点,我们返回这个父节点。例如,如果n=59,则后继节点是 60。

此外,我们必须考虑如果n是遍历中的最后一个节点,那么我们返回根节点的父节点,这个父节点可能为空。

如果我们将这些情况组合起来形成一些伪代码,那么我们得到以下内容:

Node inOrderSuccessor(Node n) {
  if (n has a right sub-tree) {
    return the leftmost child of right sub-tree
  } 
  while (n is a right child of n.parent) {
    n = n.parent; // traverse upwards 
  }
  return n.parent; // parent has not been traversed
}

现在,我们可以将这个伪代码转换成代码,如下所示:

public void inOrderSuccessor() {
  // choose the node
  Node node = ...;
  System.out.println("\n\nIn-Order:");
  System.out.print("Start node: " + node.element);
  node = inOrderSuccessor(node);
  System.out.print(" Successor node: " + node.element);
}
private Node inOrderSuccessor(Node node) {
  if (node == null) {
    return null;
  }
  // case (a)
  if (node.right != null) {
    return findLeftmostNode(node.right);
  }
  // case (b)
  while (node.parent != null && node.parent.right == node) {
    node = node.parent;
  }
  return node.parent;
}

完整的应用程序称为BinarySearchTreeSuccessor。这个应用程序也包含了同样的问题,但是通过先序遍历和后序遍历来解决。在检查先序遍历和后序遍历上下文的解决方案之前,你应该挑战自己,识别可能的情况,并勾画伪代码及其实现。

编码挑战 9 – 拓扑排序

亚马逊谷歌Adobe微软Flipkart

问题:假设你已经得到了一个有向无环图DAG);即,一个没有循环的有向图。编写一小段代码,返回顶点的线性排序,使得对于每条有向边XY,顶点X在排序中出现在Y之前。换句话说,对于每条边,源节点在目标节点之前。这也被称为拓扑排序,它只适用于 DAGs。

解决方案:让我们通过以下有向无环图(DAG)来深入研究这个问题:

图 13.27 – 有向无环图(DAG)

图 13.27 – 有向无环图(DAG)

让我们从顶点 D 开始进行拓扑排序。在顶点 D 之前,没有其他顶点(没有边),所以我们可以将 D 添加到结果中,(D)。从 D,我们可以到达 B 或 A。让我们去顶点 A。我们不能将 A 添加到结果中,因为我们没有处理边 BA 的顶点 B,所以让我们去顶点 B。在 B 之前,我们只有 D,已经添加到结果中,所以我们可以将 B 添加到结果中,(D, B)。从 B,我们可以到达 A、E、C 和 F。我们不能到达 C,因为我们没有处理 AC,我们也不能到达 F,因为我们没有处理 CF。然而,我们可以到达 A,因为 DA 和 BA 已经被处理,我们也可以到达 E,因为在 E 之前只有 B,它在结果中。注意,拓扑排序可能会提供不同的结果。让我们去 E。因此,E 被添加到结果中(D, B, E)。接下来,我们可以将 A 添加到结果中,这使我们可以添加 C,这使我们可以添加 F。因此,结果现在是(D, B, E, A, C, F)。从 F,我们可以到达 G。由于 EG 已经被处理,我们可以将 G 添加到结果中。最后,从 G,我们到达 H,得到的拓扑排序结果为(D, B, E, A, C, F, G, H)。

这种遍历只是一种任意的遍历,我们无法将其编写成代码。然而,我们知道图可以通过 BFS 和 DFS 算法进行遍历。如果我们尝试在 DFS 的上下文中思考,那么我们从节点 D 开始,遍历 B、A、C、F、G、H 和 E。在执行 DFS 遍历时,我们不能简单地将顶点添加到结果中,因为我们违反了问题的要求(对于每条有向边XY,顶点X在排序中出现在Y之前)。然而,我们可以使用一个Stack,在遍历完所有邻居节点后将一个顶点推入这个栈中。这意味着 H 是第一个被推入栈中的顶点,然后是 G、F、C、A、E、B 和 D。现在,从栈中弹出直到为空将给我们拓扑排序的结果,即 D、B、E、A、C、F、G 和 H。

因此,拓扑排序只是基于Stack的 DFS 变种,可以实现如下:

public Stack<T> topologicalSort(T startElement) {
  Set<T> visited = new HashSet<>();
  Stack<T> stack = new Stack<>();
  topologicalSort(startElement, visited, stack);
  return stack;
}
private void topologicalSort(T currentElement, 
      Set<T> visited, Stack<T> stack) {
  visited.add(currentElement);
  List<T> adjacents = adjacencyList.get(currentElement);
  if (adjacents != null) {
    for (T t : adjacents) {
      if (t != null && !visited.contains(t)) {
        topologicalSort(t, visited, stack);
        visited.add(t);
      }
    }
  }
  stack.push(currentElement);
}

完整的应用程序称为GraphTopologicalSort

编码挑战 10 – 共同祖先

亚马逊谷歌微软Flipkart

问题:假设你已经得到了一棵二叉树。编写一小段代码,找到两个给定节点的第一个共同祖先。你不能在数据结构中存储额外的节点。

解决方案:分析这种问题的最佳方法是拿一些纸和笔,画一个二叉树并标注一些样本。注意,问题没有说这是一个二叉搜索树。实际上,它可以是任何有效的二叉树。

在下图中,我们有三种可能的情况:

图 13.28 – 寻找第一个共同祖先

图 13.28 – 寻找第一个共同祖先

在这里,我们可以看到给定的节点可以位于不同的子树(左子树和右子树)或者位于同一个子树(中间子树)。因此,我们可以从根节点开始遍历树,使用commonAncestor(Node root, Node n1, Node n2)类型的方法,并返回如下(n1n2是给定的两个节点):

  • 如果根的子树包括n1(但不包括n2),则返回n1

  • 如果根的子树包括n2(但不包括n1),则返回n2

  • 如果根的子树中既没有n1也没有n2,则返回null

  • 否则,返回n1n2的公共祖先。

commonAncestor(n.left, n1, n2)commonAncestor(n.right, n1, n2)返回非空值时,这意味着n1n2在不同的子树中,而n是它们的公共祖先。让我们看看代码:

public T commonAncestor(T e1, T e2) {
  Node n1 = findNode(e1, root);
  Node n2 = findNode(e2, root);
  if (n1 == null || n2 == null) {
    throw new IllegalArgumentException("Both nodes 
             must be present in the tree");
  }
  return commonAncestor(root, n1, n2).element;
}
private Node commonAncestor(Node root, Node n1, Node n2) {
  if (root == null) {
    return null;
  }
  if (root == n1 && root == n2) {
    return root;
  }
  Node left = commonAncestor(root.left, n1, n2);
  if (left != null && left != n1 && left != n2) {
    return left;
  }
  Node right = commonAncestor(root.right, n1, n2);
  if (right != null && right != n1 && right != n2) {
    return right;
  }
  // n1 and n2 are not in the same sub-tree
  if (left != null && right != null) {
    return root;
  } else if (root == n1 || root == n2) {
    return root;
  } else {
    return left == null ? right : left;
  }
}

完整的应用程序称为BinaryTreeCommonAncestor

编程挑战 11 - 国际象棋骑士

亚马逊微软Flipkart

问题:假设你已经得到了一个国际象棋棋盘和一个骑士。最初,骑士放在一个单元格(起始单元格)中。编写一小段代码,计算将骑士从起始单元格移动到给定目标单元格所需的最小移动次数。

解决方案:让我们考虑一个例子。国际象棋棋盘的大小为 8x8,骑士从单元格(1, 8)开始。目标单元格是(8, 1)。正如下图所示,骑士需要至少移动 6 次才能从单元格(1, 8)到单元格(8, 1):

图 13.29 - 将骑士从单元格(1, 8)移动到单元格(8, 1)

图 13.29 - 将骑士从单元格(1, 8)移动到单元格(8, 1)

正如这张图片所显示的,一个骑士可以从一个(r,c)单元格移动到另外八个有效的单元格,如下:(r+2,c+1),(r+1,c+2),(r-1,c+2),(r-2,c+1),(r-2,c-1),(r-1,c-2),(r+1,c-2),和(r+2,c-1)。因此,有八种可能的移动。如果我们将这些可能的移动看作方向(边)和单元格看作顶点,那么我们可以在图的上下文中可视化这个问题。边是可能的移动,而顶点是骑士的可能单元格。每个移动都保存从当前单元格到起始单元格的距离。对于每次移动,距离增加 1。因此,在图的上下文中,这个问题可以简化为在图中找到最短路径。因此,我们可以使用 BFS 来解决这个问题。

该算法的步骤如下:

  1. 创建一个空队列。

  2. 将起始单元格入队,使其与自身的距离为 0。

  3. 只要队列不为空,执行以下操作:

a. 从队列中弹出下一个未访问的单元格。

b. 如果弹出的单元格是目标单元格,则返回它的距离。

c. 如果弹出的单元格不是目标单元格,则将此单元格标记为已访问,并通过增加距离 1 来将八个可能的移动入队列。

由于我们依赖 BFS 算法,我们知道所有最短路径为 1 的单元格首先被访问。接下来,被访问的单元格是最短路径为 1+1=2 的相邻单元格,依此类推;因此,任何最短路径等于其父节点的最短路径 + 1 的单元格。这意味着当我们第一次遍历目标单元格时,它给出了我们的最终结果。这就是最短路径。让我们看看代码:

private int countknightMoves(Node startCell, 
            Node targetCell, int n) {
  // store the visited cells
  Set<Node> visited = new HashSet<>();
  // create a queue and enqueue the start cell
  Queue<Node> queue = new ArrayDeque<>();
  queue.add(startCell);
  while (!queue.isEmpty()) {
    Node cell = queue.poll();
    int r = cell.r;
    int c = cell.c;
    int distance = cell.distance;
    // if destination is reached, return the distance
    if (r == targetCell.r && c == targetCell.c) {
      return distance;
    }
    // the cell was not visited
    if (!visited.contains(cell)) {
      // mark current cell as visited
      visited.add(cell);
      // enqueue each valid movement into the queue 
      for (int i = 0; i < 8; ++i) {
        // get the new valid position of knight from current
        // position on chessboard and enqueue it in the queue 
        // with +1 distance
        int rt = r + ROW[i];
        int ct = c + COL[i];
        if (valid(rt, ct, n)) {
          queue.add(new Node(rt, ct, distance + 1));
        }
      }
    }
  }
  // if path is not possible
  return Integer.MAX_VALUE;
}
// Check if (r, c) is valid    
private static boolean valid(int r, int c, int n) {
  if (r < 0 || c < 0 || r >= n || c >= n) {
    return false;
  }
  return true;
}

该应用程序称为ChessKnight

编程挑战 12 - 打印二叉树的角

亚马逊谷歌

问题:假设你已经得到了一棵二叉树。编写一小段代码,打印出每个级别的树的角。

解决方案:让我们考虑以下树:

图 13.30 - 打印二叉树的角

图 13.30 - 打印二叉树的角

因此,主要思想是打印每个级别的最左边和最右边的节点。这意味着层序遍历(BFS)可能很有用,因为我们可以遍历每个级别。我们所要做的就是识别每个级别上的第一个和最后一个节点。为了做到这一点,我们需要通过添加一个条件来调整经典的层序遍历,该条件旨在确定当前节点是否代表一个角落。代码本身说明了这一点:

public void printCorners() {
  if (root == null) {
    return;
  }
  Queue<Node> queue = new ArrayDeque<>();
  queue.add(root);
  int level = 0;
  while (!queue.isEmpty()) {
    // get the size of the current level
    int size = queue.size();
    int position = size;
    System.out.print("Level: " + level + ": ");
    level++;
    // process all nodes present in current level
    while (position > 0) {
      Node node = queue.poll();
      position--;
      // if corner node found, print it
      if (position == (size - 1) || position == 0) {
        System.out.print(node.element + " ");
      }
      // enqueue left and right child of current node
      if (node.left != null) {
        queue.add(node.left);
      }
      if (node.right != null) {
        queue.add(node.right);
      }
    }
    // level done            
    System.out.println();
  }
}

该应用程序称为BinaryTreePrintCorners.

编程挑战 13 - 最大路径和

亚马逊谷歌Adobe微软Flipkart

问题:考虑到你已经得到了一个非空的二叉树。编写一小段代码来计算最大路径和。路径被认为是从任何节点开始并在树中的任何节点结束的任何节点序列,以及父子连接。路径必须包含至少一个节点,可能经过树的根,也可能不经过树的根。

解决方案:下图显示了最大路径和的三个例子:

图 13.31 - 最大路径和的三个例子

图 13.31 - 最大路径和的三个例子

解决这个问题需要我们确定当前节点可以成为最大路径的一部分的方式数量。通过检查前面的例子,我们可以得出四种情况,如下图所示(花点时间看更多例子,直到得出相同的结论):

图 13.32 - 当前节点可以成为最大路径的一部分的方式数量

图 13.32 - 当前节点可以成为最大路径的一部分的方式数量

因此,作为最大路径的一部分的节点被放入以下四种情况之一:

  1. 节点是最大路径中唯一的节点

  2. 节点是最大路径的一部分,紧邻其左子节点

  3. 节点是最大路径的一部分,紧邻其右子节点

  4. 节点是最大路径的一部分,紧邻其左右子节点

这四个步骤使我们得出一个明确的结论:我们必须遍历树的所有节点。一个很好的选择是 DFS 算法,但更确切地说是后序遍历树遍历,它将遍历顺序规定为左子树 | 右子树 | 。当我们遍历树时,我们将树的其余部分的最大值传递给父节点。下图显示了这个算法:

图 13.33 - 后序遍历并将树中的最大值传递给父节点

图 13.33 - 后序遍历并将树中的最大值传递给父节点

因此,如果我们按照这个算法逐步应用到前面的图中,我们得到以下结果(记住这是后序遍历):

  • 41 没有子节点,所以 41 被添加到 max(0, 0),41+max(0, 0)=41。

  • 3 只有左子节点-5,所以 3 被添加到 max(-5, 0),3+max(-5, 0)=3。

  • -2 被添加到 max(41, 3)子树,所以-2+max(41, 3)=39。

  • -7 没有子节点,所以-7 被添加到 max(0, 0),-7+max(0, 0)=-7。

  • 70 没有子节点,所以 70 被添加到 max(0, 0),70+max(0, 0)=70。

  • -1 被添加到 max(-7, 70)子树,所以-1+70=69。

  • 50 被添加到左(39)和右(69)子树的最大值,所以 39+69+50=158(这是最大路径和)。

以下代码显示了这个算法的实现:

public int maxPathSum() {
  maxPathSum(root);
  return max;
}
private int maxPathSum(Node root) {
  if (root == null) {
    return 0;
  }
  // maximum of the left child and 0
  int left = Math.max(0, maxPathSum(root.left));
  // maximum of the right child and 0
  int right = Math.max(0, maxPathSum(root.right));
  // maximum at the current node (all four cases 1,2,3 and 4)
  max = Math.max(max, left + right + root.element);
  //return the maximum from left, right along with current               
  return Math.max(left, right) + root.element;
}

该应用程序称为BinaryTreeMaxPathSum.

编程挑战 14 - 对角线遍历

亚马逊Adobe微软

问题:考虑到你已经得到了一个非空的二叉树。编写一小段代码,打印每个负对角线(\)上的所有节点。负对角线具有负斜率。

解决方案:如果你对二叉树的负对角线概念不熟悉,请确保与面试官澄清这一方面。他们可能会为你提供一个例子,类似于下图所示的例子:

图 13.34 - 二叉树的负对角线

图 13.34-二叉树的负对角线

在上图中,我们有三条对角线。第一条对角线包含节点 50、12 和 70。第二条对角线包含节点 45、3、14 和 65。最后,第三条对角线包含节点 41 和 11。

基于递归的解决方案

解决这个问题的一个解决方案是使用递归和哈希(如果您不熟悉哈希的概念,请阅读第六章**,面向对象编程哈希表问题)。在 Java 中,我们可以通过内置的HashMap实现使用哈希,因此无需从头开始编写哈希实现。但是这个HashMap有什么用呢?我们应该在这个地图的条目(键值对)中存储什么?

我们可以将二叉树中的每条对角线与地图中的一个键关联起来。由于每条对角线(键)包含多个节点,因此将值表示为List非常方便。当我们遍历二叉树时,我们需要将当前节点添加到适当的List中,因此在适当的对角线下。例如,在这里,我们可以执行前序遍历。每次我们进入左子树时,我们将对角线增加 1,每次我们进入右子树时,我们保持当前对角线。这样,我们得到类似以下的东西:

图 13.35-前序遍历并将对角线增加 1 以处理左子节点

图 13.35-前序遍历并将对角线增加 1 以处理左子节点

以下解决方案的时间复杂度为 O(n log n),辅助空间为 O(n),其中n是树中的节点数:

// print the diagonal elements of given binary tree
public void printDiagonalRecursive() {
  // map of diagonals
  Map<Integer, List<T>> map = new HashMap<>();
  // Pre-Order traversal of the tree and fill up the map
  printDiagonal(root, 0, map);
  // print the current diagonal
  for (int i = 0; i < map.size(); i++) {
    System.out.println(map.get(i));
  }
}
// recursive Pre-Order traversal of the tree 
// and put the diagonal elements in the map
private void printDiagonal(Node node, 
        int diagonal, Map<Integer, List<T>> map) {
  if (node == null) {
    return;
  }
  // insert the current node in the diagonal
  if (!map.containsKey(diagonal)) {
    map.put(diagonal, new ArrayList<>());
  }
  map.get(diagonal).add(node.element);
  // increase the diagonal by 1 and go to the left sub-tree
  printDiagonal(node.left, diagonal + 1, map);
  // maintain the current diagonal and go 
  // to the right sub-tree
  printDiagonal(node.right, diagonal, map);
}

现在,让我们看看这个问题的另一个解决方案。

基于迭代的解决方案

解决这个问题也可以通过迭代完成。这次,我们可以使用层次遍历,并使用Queue将对角线的节点入队。这个解决方案的主要伪代码可以写成如下形式:

(first diagonal)
Enqueue the root and all its right children 
While the queue is not empty
	Dequeue (let's denote it as A)
	Print A
    (next diagonal)
	If A has a left child then enqueue it 
    (let's denote it as B)
		Continue to enqueue all the right children of B

将这个伪代码转换成代码后,我们得到以下结果:

public void printDiagonalIterative() {
  Queue<Node> queue = new ArrayDeque<>();
  // mark the end of a diagonal via dummy null value
  Node dummy = new Node(null);
  // enqueue all the nodes of the first diagonal
  while (root != null) {
    queue.add(root);
    root = root.right;
  }
  // enqueue the dummy node at the end of each diagonal
  queue.add(dummy);
  // loop while there are more nodes than the dummy
  while (queue.size() != 1) {
    Node front = queue.poll();
    if (front != dummy) {
      // print current node
      System.out.print(front.element + " ");
      // enqueue the nodes of the next diagonal 
      Node node = front.left;
      while (node != null) {
        queue.add(node);
        node = node.right;
      }
    } else {
      // at the end of the current diagonal enqueue the dummy                 
      queue.add(dummy);
      System.out.println();
    }
  }
}

上述代码的运行时间为 O(n),辅助空间为 O(n),其中n是树中的节点数。完整的应用程序称为BinaryTreePrintDiagonal

编码挑战 15-处理 BST 中的重复项

亚马逊微软Flipkart

问题:假设你有一个允许重复的 BST。编写一个支持插入和删除操作的实现,同时处理重复项。

解决方案:我们知道 BST 的属性声称对于每个节点n,我们知道n的左子节点≤n<n的右子节点。通常,涉及 BST 的问题不允许重复项,因此不能插入重复项。但是,如果允许重复项,那么我们的约定将是将重复项插入左子树。

然而,面试官可能希望看到一个允许我们将计数与每个节点关联的实现,如下图所示:

图 13.36-处理 BST 中的重复项

图 13.36-处理 BST 中的重复项

为了提供这个实现,我们需要修改经典 BST 的结构,以便支持计数:

private class Node {
  private T element;
  private int count;
  private Node left;
  private Node right;
  private Node(Node left, Node right, T element) {
    this.element = element;
    this.left = left;
    this.right = right;
    this.count = 1;
  }
}

每次创建一个新节点(树中不存在的节点)时,计数器将等于 1。

当我们插入一个节点时,我们需要区分新节点和重复节点。如果我们插入一个重复节点,那么我们只需要将该节点的计数增加一,而不创建新节点。插入操作的相关部分如下:

private Node insert(Node current, T element) {
  if (current == null) {
    return new Node(null, null, element);
  }
  // START: Handle inserting duplicates
  if (element.compareTo(current.element) == 0) {
    current.count++;
    return current;
  }
  // END: Handle inserting duplicates
...
}

删除节点遵循类似的逻辑。如果我们删除一个重复节点,那么我们只需将其计数减一。如果计数已经等于 1,那么我们只需删除节点。相关代码如下:

private Node delete(Node node, T element) {
  if (node == null) {
    return null;
  }
  if (element.compareTo(node.element) < 0) {
    node.left = delete(node.left, element);
  } else if (element.compareTo(node.element) > 0) {
    node.right = delete(node.right, element);
  }
  if (element.compareTo(node.element) == 0) {
    // START: Handle deleting duplicates
    if (node.count > 1) {
      node.count--;
      return node;
    }
    // END: Handle deleting duplicates
    ...
}

完整的应用程序称为BinarySearchTreeDuplicates. 这个问题的另一个解决方案是使用哈希表来计算节点的数量。这样,您就不需要修改树的结构。挑战自己,完成这个实现。

编码挑战 16 - 二叉树同构

亚马逊谷歌微软

问题:假设你已经得到了两棵二叉树。编写一小段代码,判断这两棵二叉树是否同构。

解决方案:如果你对同构一词不熟悉,那么你必须向面试官澄清。这个术语在数学上有很明确的定义,但面试官可能不会给出数学上的解释/演示,而且你知道,数学家有自己的语言,几乎不可能流利和易于理解的英语。此外,在数学中,同构的概念指的是任何两个结构,不仅仅是二叉树。因此,面试官可能会给你一个解释,如下(让我们将树表示为T1T2):

定义 1如果 T1 可以通过多次交换子节点而改变为 T2,那么 T1 和 T2 是同构的,T1 和 T2 根本不必是相同的物理形状。

定义 2如果你可以将 T1 翻译成 T2,将 T2 翻译成 T1 而不丢失信息,那么 T1 和 T2 是同构的。

定义 3想想两个字符串,AAB 和 XXY。如果 A 被转换成 X,B 被转换成 Y,那么 AAB 就变成了 XXY,所以这两个字符串是同构的。因此,如果 T2 在结构上是 T1 的镜像,那么两个二叉树是同构的。

无论面试官给出什么定义,我相当肯定他们都会试图给你一个例子。下图显示了一堆同构二叉树的例子:

图 13.37 - 同构二叉树示例

图 13.37 - 同构二叉树示例

根据前面的定义和示例,我们可以制定以下算法来确定两个二叉树是否同构:

  1. 如果T1T2null,那么它们是同构的,所以返回true.

  2. 如果T1T2null,那么它们不是同构的,所以返回false.

  3. 如果T1.data不等于T2.data,那么它们不是同构的,所以返回false.

  4. 遍历T1的左子树和T2的左子树。

  5. 遍历T1的右子树和T2的右子树:

a. 如果T1T2的结构相同,那么返回true.

b. 如果T1T2的结构不相同,那么我们检查一个树(或子树)是否镜像另一个树(子树),

  1. 遍历T1的左子树和T2的右子树。

  2. 遍历T1的右子树和T2的左子树:

a. 如果结构是镜像的,那么返回true;否则返回false.

将这个算法编写成代码,结果如下:

private boolean isIsomorphic(Node treeOne, Node treeTwo) {
  // step 1
  if (treeOne == null && treeTwo == null) {
    return true;
  }
  // step 2
  if ((treeOne == null || treeTwo == null)) {
    return false;
  }
  // step 3
  if (!treeOne.element.equals(treeTwo.element)) {
    return false;
  }
  // steps 4, 5, 6 and 7
  return (isIsomorphic(treeOne.left, treeTwo.right)
    && isIsomorphic(treeOne.right, treeTwo.left)
    || isIsomorphic(treeOne.left, treeTwo.left)
    && isIsomorphic(treeOne.right, treeTwo.right));
}
.

完整的应用程序称为TwoBinaryTreesAreIsomorphic.

编码挑战 17 - 二叉树右视图

亚马逊谷歌Adobe微软Flipkart

问题:假设你已经得到了一棵二叉树。编写一小段代码,打印出这棵树的右视图。打印右视图意味着打印出你从右侧看这棵二叉树时能看到的所有节点。

解决方案:如果你不确定二叉树的右视图是什么,那么请向面试官澄清。例如,下图突出显示了代表二叉树右视图的节点:

图 13.38 - 二叉树的右视图

图 13.38 - 二叉树的右视图

因此,如果您被放在这棵树的右侧,您只会看到节点 40、45、44、9 和 2。如果我们考虑层次遍历(BFS),我们得到以下输出:

  • 40,47,45,11,3,44,7,5,92

突出显示的节点是表示右视图的节点。但是,这些节点中的每一个都代表树中每个级别的最右节点。这意味着我们可以调整 BFS 算法并打印每个级别的最后一个节点。

这是一个 O(n)复杂度的时间算法,辅助空间为 O(n)(由队列表示),其中n是树中的节点数:

private void printRightViewIterative(Node root) {
  if (root == null) {
    return;
  }
  // enqueue root node
  Queue<Node> queue = new ArrayDeque<>();
  queue.add(root);
  Node currentNode;
  while (!queue.isEmpty()) {
    // number of nodes in the current level is the queue size
    int size = queue.size();
    int i = 0;
    // traverse each node of the current level and enqueue its
    // non-empty left and right child
    while (i < size) {
      i++;
      currentNode = queue.poll();
      // if this is last node of current level just print it
      if (i == size) {
        System.out.print(currentNode.element + " ");
      }
      if (currentNode.left != null) {
        queue.add(currentNode.left);
      }
      if (currentNode.right != null) {
        queue.add(currentNode.right);
      }
    }
  }
}

在这里,我们也可以实现递归解决方案。

这是一个 O(n)复杂度的时间算法,辅助空间为 O(n)(由映射表示),其中n是树中的节点数。您可以在本书附带的代码中找到递归方法,该代码在BinaryTreeRightView应用程序中。挑战自己,实现二叉树的左视图。

编码挑战 18 - 第 k 个最大元素

GoogleFlipkart

问题:假设您已经得到了一个 BST。编写一小段代码,打印出第k个最大元素,而不改变 BST。

解决方案:让我们考虑以下 BST:

图 13.39 - BST 中的第 k 个最大元素

图 13.39 - BST 中的第 k 个最大元素

对于k=1,我们可以看到 56 是第一个最大的元素。对于k=2,我们可以看到 55 是第二大的元素,依此类推。

暴力解法非常简单,将在 O(n)时间内运行,其中n是树中的节点数。我们所要做的就是提取一个数组,并将其放在树的中序遍历(左子树 | 右子树 | 根)中:45, 47, 50, 52, 54, 55, 56。完成后,我们可以找到array[n-k]作为kth 元素。例如,对于k=3,第三个元素是array[7-3] = array[4]=54。如果您愿意,可以挑战自己并提供此实现。

然而,还可以基于逆中序遍历(右子树 | 左子树 | 根)编写另一种在 O(k+h)复杂度时间内运行的方法,其中h是 BST 的高度,该方法可以按降序给出元素:56, 55, 54, 52, 50, 47, 45。

代码说明自己(c变量计算访问的节点数):

public void kthLargest(int k) {
  kthLargest(root, k);
}
private int c;
private void kthLargest(Node root, int k) {
  if (root == null || c >= k) {
    return;
  }
  kthLargest(root.right, k);
  c++;
  // we found the kth largest value
  if (c == k) {
    System.out.println(root.element);
  }
  kthLargest(root.left, k);
}

完整的应用程序称为BinarySearchTreeKthLargestElement

编码挑战 19 - 镜像二叉树

AmazonGoogleAdobeMicrosoft

问题:假设您已经得到了一棵二叉树。编写一小段代码,构造这棵树的镜像。

解决方案:镜像树如下所示(右侧树是左侧树的镜像版本):

图 13.40 - 给定树和镜像树

图 13.40 - 给定树和镜像树

因此,镜像树就像给定树的水平翻转。要创建树的镜像,我们必须决定是否将镜像树作为新树返回,还是在原地镜像给定树。

在新树中镜像给定树

将镜像作为新树返回可以通过遵循以下步骤的递归算法完成:

图 13.41 - 递归算法

图 13.41 - 递归算法

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

private Node mirrorTreeInTree(Node root) {
  if (root == null) {
    return null;
  }
  Node node = new Node(root.element);
  node.left = mirrorTreeInTree(root.right);
  node.right = mirrorTreeInTree(root.left);
  return node;
}

现在,让我们尝试在原地镜像给定树。

在原地镜像给定树

在原地镜像给定树也可以通过递归来完成。这次,算法遵循以下步骤:

  1. 镜像给定树的左子树。

  2. 镜像给定树的右子树。

  3. 交换左右子树(交换它们的指针)。

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

private void mirrorTreeInPlace(Node node) {
  if (node == null) {
    return;
  }
  Node auxNode;
  mirrorTreeInPlace(node.left);
  mirrorTreeInPlace(node.right);
  auxNode = node.left;
  node.left = node.right;
  node.right = auxNode;
}

完整的应用程序称为MirrorBinaryTree

编码挑战 20 - 二叉树的螺旋级别顺序遍历

AmazonGoogleMicrosoft

问题:假设你有一个二叉树。编写一小段代码,打印这个二叉树的螺旋级遍历。更确切地说,应该从左到右打印所有在第 1 级的节点,然后从右到左打印所有在第 2 级的节点,然后从左到右打印所有在第 3 级的节点,依此类推。因此,奇数级应从左到右打印,偶数级应从右到左打印。

解决方案:螺旋级遍历可以用两种方式来表达,如下所示:

  • 奇数级应从左到右打印,偶数级应从右到左打印。

  • 奇数级应从右到左打印,偶数级应从左到右打印。

以下图表示这些陈述:

图 13.42 - 螺旋顺序遍历

图 13.42 - 螺旋顺序遍历

因此,在左侧,我们得到 50、12、45、12、3、65、70、24 和 41。另一方面,在右侧,我们得到 50、45、12、70、65、3、12、41 和 24。

递归方法

让我们尝试从前面图表的左侧实现螺旋顺序遍历。请注意,奇数级应从左到右打印,而偶数级应以相反的顺序打印。基本上,我们需要通过翻转偶数级的方向来调整众所周知的层次遍历。这意味着我们可以使用一个布尔变量来交替打印顺序。因此,如果布尔变量为true(或 1),那么我们从左到右打印当前级别;否则,我们从右到左打印。在每次迭代(级别)中,我们翻转布尔值。

通过递归应用可以这样做:

public void spiralOrderTraversalRecursive() {
  if (root == null) {
    return;
  }
  int level = 1;
  boolean flip = false;
  // as long as printLevel() returns true there 
  // are more levels to print
  while (printLevel(root, level++, flip = !flip)) {
    // there is nothing to do
  };
}
// print all nodes of a given level 
private boolean printLevel(Node root, 
      int level, boolean flip) {
  if (root == null) {
    return false;
  }
  if (level == 1) {
    System.out.print(root.element + " ");
    return true;
  }
  if (flip) {
    // process left child before right child
    boolean left = printLevel(root.left, level - 1, flip);
    boolean right = printLevel(root.right, level - 1, flip);
    return left || right;
  } else {
    // process right child before left child
    boolean right = printLevel(root.right, level - 1, flip);
    boolean left = printLevel(root.left, level - 1, flip);
    return right || left;
  }
}

这段代码运行时间为 O(n2),效率相当低。我们能更有效地做到吗?是的 - 我们可以用额外空间 O(n)的迭代方法在 O(n)的时间内完成。

迭代方法

让我们尝试从给定图表的右侧实现螺旋顺序遍历。这次我们将通过迭代方法来实现。主要是,我们可以使用两个栈(Stack)或双端队列(Deque)。让我们学习如何通过两个栈来实现这一点。

使用两个栈的主要思想非常简单:我们使用一个栈来打印从左到右的节点,另一个栈来打印从右到左的节点。在每次迭代(或级别)中,一个栈中有相应级别的节点。在我们打印一个栈中的节点时,我们将下一级别的节点推入另一个栈中。

以下代码将这些陈述转化为代码形式:

private void printSpiralTwoStacks(Node node) {
  if (node == null) {
    return;
  }
  // create two stacks to store alternate levels         
  Stack<Node> rl = new Stack<>(); // right to left         
  Stack<Node> lr = new Stack<>(); // left to right 
  // Push first level to first stack 'rl' 
  rl.push(node);
  // print while any of the stacks has nodes 
  while (!rl.empty() || !lr.empty()) {
    // print nodes of the current level from 'rl' 
    // and push nodes of next level to 'lr'
    while (!rl.empty()) {
      Node temp = rl.peek();
      rl.pop();
      System.out.print(temp.element + " ");
      if (temp.right != null) {
        lr.push(temp.right);
      }
      if (temp.left != null) {
        lr.push(temp.left);
      }
    }
    // print nodes of the current level from 'lr' 
    // and push nodes of next level to 'rl'
    while (!lr.empty()) {
      Node temp = lr.peek();
      lr.pop();
      System.out.print(temp.element + " ");
      if (temp.left != null) {
        rl.push(temp.left);
      }
      if (temp.right != null) {
        rl.push(temp.right);
      }
    }
  }
}

完整的应用程序称为BinaryTreeSpiralTraversal。在这个应用程序中,您还可以找到基于Deque的实现。

编码挑战 21 - 距离叶节点 k 的节点

亚马逊谷歌微软Flipkart

问题:假设你有一个整数二叉树和一个整数k。编写一小段代码,打印所有距离叶节点k的节点。

解决方案:我们可以直觉地认为距离叶子k的距离意味着叶子上方k级。但为了澄清任何疑问,让我们遵循经典方法,尝试可视化一个例子。以下图表表示二叉树;突出显示的节点(40、47 和 11)表示距离叶节点k=2 的节点:

图 13.43 - 距离叶节点 k=2 的节点

图 13.43 - 距离叶节点 k=2 的节点

从前面的图表中,我们可以得出以下观察结果:

  • 节点 40 距离叶子 44 有 2 个距离。

  • 节点 47 距离叶子 9 和叶子 5 有 2 个距离。

  • 节点 11 距离叶子 2 有 2 个距离。

如果我们观察每个级别,那么我们可以看到以下内容:

  • 距离叶节点 1 个距离的节点是 3、11、7 和 45。

  • 距离叶节点 2 个距离的节点是 11、47 和 40。

  • 距离叶节点 3 个距离的节点是 40 和 47。

  • 距离叶节点 4 的节点是 40。

因此,根节点是距离叶节点最远的节点,k不应该大于层级数;也就是说,1. 如果我们从根开始并沿着树向下直到找到一个叶子,那么结果路径应该包含一个距离该叶子有k距离的节点。

例如,一个可能的路径是 40(根),47,11,7 和 2(叶子)。如果k=2,那么节点 11 距离叶子有 2 的距离。另一个可能的路径是 40(根),47,11 和 5(叶子)。如果k=2,那么节点 47 距离叶子有 2 的距离。另一条路径是 40(根),47,3 和 9(叶子)。如果k=2,那么节点 47 距离叶子有 2 的距离。我们已经找到了这个节点;因此,我们现在必须注意并删除重复项。

到目前为止列出的路径表明,存在树的前序遍历(根|左子树|右子树)。在遍历过程中,我们必须跟踪当前路径。换句话说,构建的路径由前序遍历中当前节点的祖先组成。当我们找到一个叶节点时,我们必须打印距离这个叶节点k的祖先。

为了消除重复,我们可以使用一个Set(让我们将其表示为nodesAtDist),如下面的代码所示:

private void leafDistance(Node node, 
    List<Node> pathToLeaf, Set<Node> nodesAtDist, int dist) {
  if (node == null) {
    return;
  }
  // for each leaf node, store the node at distance 'dist'
  if (isLeaf(node) && pathToLeaf.size() >= dist) {
    nodesAtDist.add(pathToLeaf.get(pathToLeaf.size() - dist));
    return;
  }
  // add the current node into the current path        
  pathToLeaf.add(node);
  // go  to left and right subtree via recursion
  leafDistance(node.left, pathToLeaf, nodesAtDist, dist);
  leafDistance(node.right, pathToLeaf, nodesAtDist, dist);
  // remove the current node from the current path       
  pathToLeaf.remove(node);
}
private boolean isLeaf(Node node) {
  return (node.left == null && node.right == null);
}

前面的代码的运行时间复杂度为 O(n),辅助空间为 O(n),其中n是树中的节点数。完整的应用程序称为BinaryTreeDistanceFromLeaf

编码挑战 22 - 给定总和的一对

亚马逊谷歌Adobe微软Flipkart

如果有一对节点的总和为这个数,则返回true

解决方案:让我们考虑下面图表中显示的 BST 和总和=74:

图 13.44 - 总和为 74 的一对包含节点 6 和 68

图 13.44 - 总和为 74 的一对包含节点 6 和 68

因此,对于总和=74,我们可以找到一对(6,68)。如果总和=89,那么一对是(43,46)。如果总和=99,那么一对是(50,49)。组成一对的节点可以来自同一子树或不同的子树,也可以包括根和叶节点。

这个问题的一个解决方案依赖于哈希和递归。主要是,我们使用中序遍历(HashSet)遍历树。此外,在将当前节点插入集合之前,我们检查(给定的总和 - 当前节点的元素)是否存在于集合中。如果是的话,那么我们找到了一对,所以我们停止这个过程并返回true。否则,我们将当前节点插入集合并继续这个过程,直到找到一对,或者遍历完成。

这个代码如下所示:

public boolean findPairSum(int sum) {
  return findPairSum(root, sum, new HashSet());
}
private static boolean findPairSum(Node node, 
        int sum, Set<Integer> set) {
  // base case
  if (node == null) {
    return false;
  }
  // find the pair in the left subtree 
  if (findPairSum(node.left, sum, set)) {
    return true;
  }
  // if pair is formed with current node then print the pair      
  if (set.contains(sum - node.element)) {
    System.out.print("Pair (" + (sum - node.element) + ", "
      + node.element + ") = " + sum);
    return true;
  } else {
    set.add(node.element);
  }
  // find the pair in the right subtree 
  return findPairSum(node.right, sum, set);
}

这段代码的运行时间复杂度为 O(n),辅助空间为 O(n)。完整的应用程序称为BinarySearchTreeSum

另一个你可能想考虑并挑战自己的解决方案是,BST 在使用中序遍历时,以排序顺序输出节点。这意味着如果我们扫描 BST 并将输出存储在数组中,那么问题与在数组中找到给定总和的一对完全相同。但是这个解决方案需要对所有节点进行两次遍历,并且需要 O(n)的辅助空间。

另一种方法从 BST 属性开始:n 的左子节点≤n<n 的右子节点。换句话说,树中的最小节点是最左边的节点(在我们的例子中是 6),树中的最大节点是最右边的节点(在我们的例子中是 71)。现在,考虑树的两次遍历:

  • 前序中序遍历(最左边的节点是第一个访问的节点)

  • 逆序中序遍历(最右边的节点是第一个访问的节点)

现在,让我们评估(最小+最大)表达式:

  • 如果(最小+最大)<总和,那么去下一个最小(前序中序遍历返回的下一个节点)。

  • 如果(最小值 + 最大值) > 总和,那么转到下一个最大值(反向中序遍历返回的下一个节点)。

  • 如果(最小值 + 最大值) = 总和,那么返回true

主要问题在于我们需要管理这两个遍历。一种方法可以依赖于两个堆栈。在一个堆栈中,我们存储前向中序遍历的输出,而在另一个堆栈中,我们存储反向中序遍历的输出。当我们到达最小(最左边)和最大(最右边)节点时,我们必须弹出堆栈的顶部并对给定的总和执行相等性检查。

这个相等性检查通过了前面三个检查(由前面的三个项目符号给出),并且解释如下:

  • 如果(最小值 + 最大值) < 总和,那么我们通过前向中序遍历转到弹出节点的右子树。这是我们如何找到下一个最大的元素。

  • 如果(最小值 + 最大值) > 总和,那么我们通过反向中序遍历转到弹出节点的左子树。这是我们如何找到下一个最小的元素。

  • 如果(最小值 + 最大值) = 总和,那么我们找到了一个验证给定总和的一对。

只要前向中序遍历和反向中序遍历不相遇,算法就会应用。让我们看看这段代码:

public boolean findPairSumTwoStacks(int sum) {
  return findPairSumTwoStacks(root, sum);
}
private static boolean findPairSumTwoStacks(
              Node node, int sum) {
  Stack<Node> fio = new Stack<>(); // fio - Forward In-Order
  Stack<Node> rio = new Stack<>(); // rio - Reverse In-Order
  Node minNode = node;
  Node maxNode = node;
  while (!fio.isEmpty() || !rio.isEmpty()
           || minNode != null || maxNode != null) {
    if (minNode != null || maxNode != null) {
      if (minNode != null) {
        fio.push(minNode);
        minNode = minNode.left;
      }
      if (maxNode != null) {
        rio.push(maxNode);
        maxNode = maxNode.right;
      }
    } else {
      int elem1 = fio.peek().element;
      int elem2 = rio.peek().element;
      if (fio.peek() == rio.peek()) {
        break;
      }
      if ((elem1 + elem2) == sum) {
        System.out.print("\nPair (" + elem1 + ", " 
             + elem2 + ") = " + sum);
        return true;
      }
      if ((elem1 + elem2) < sum) {
        minNode = fio.pop();
        minNode = minNode.right;
      } else {
        maxNode = rio.pop();
        maxNode = maxNode.left;
      }
    }
  }
  return false;
}

这段代码的运行时间是 O(n),辅助空间是 O(n)。完整的应用程序称为BinarySearchTreeSum

编码挑战 23 - 二叉树中的垂直求和

亚马逊谷歌Flipkart

问题:假设你已经得到了一个二叉树。编写一小段代码,计算这个二叉树的垂直求和。

解决方案:为了清晰地理解这个问题,非常重要的是你画一个有意义的图表。最好使用一个有方格的笔记本(数学笔记本)。这很有用,因为你必须以 45 度角画出节点之间的边缘;否则,可能看不到节点的垂直轴线。通常,当我们画一个二叉树时,我们不关心节点之间的角度,但在这种情况下,这是理解问题并找到解决方案的一个重要方面。

以下图表是二叉树的草图。它显示了一些有用的地标,将引导我们找到解决方案:

图 13.45 - 二叉树中的垂直求和

图 13.45 - 二叉树中的垂直求和

如果我们从左边扫描树到右边,我们可以识别出七个垂直轴,它们的总和分别为 5、7、16、35、54、44 和 6。在图表的顶部,我们添加了每个节点距离根节点的水平距离。如果我们将根节点视为距离 0,那么我们可以通过减少或增加 1 来轻松地从根的左侧或右侧唯一地识别每个垂直轴,分别为-3、-2、-1、0(根)、1、2、3。

每个轴都是通过它距离根的距离唯一标识的,并且每个轴都包含我们必须求和的节点。如果我们将轴的唯一距离视为一个键,将该轴上节点的总和视为一个值,那么我们可以直观地认为这个问题可以通过哈希(如果你不熟悉哈希的概念,请参阅第六章**,面向对象编程哈希表问题)。在 Java 中,我们可以通过内置的HashMap实现使用哈希,因此无需从头开始编写哈希实现。

但是我们如何填充这个映射呢?很明显,我们必须在遍历树的同时填充映射。我们可以从根开始,将键添加到映射为 0(0 对应包含根的轴),值为根(21)。接下来,我们可以使用递归通过减小距离从根到左轴。我们也可以使用递归通过增加距离从根到右轴。在每个节点,我们更新映射中对应于标识当前轴的键的值。因此,如果我们递归地遵循路径root|left sub-tree|right sub-tree,那么我们使用二叉树的前序遍历。

最后,我们的映射应该包含以下键值对:(-3, 5),(-2, 7),(-1, 16),(0, 35),(1, 54),(2, 44)和(3, 6)。

将此算法编码为以下结果(map包含垂直和):

private void verticalSum(Node root, 
        Map<Integer, Integer> map, int dist) {
  if (root == null) {
    return;
  }
  if (!map.containsKey(dist)) {
    map.put(dist, 0);
  }

  map.put(dist, map.get(dist) + root.element);        
  // or in functional-style
  /*
  BiFunction <Integer, Integer, Integer> distFunction
    = (distOld, distNew) -> distOld + distNew;
  map.merge(dist, root.element, distFunction);
  */
  // decrease horizontal distance by 1 and go to left
  verticalSum(root.left, map, dist - 1);
  // increase horizontal distance by 1 and go to right
  verticalSum(root.right, map, dist + 1);
}

前面的代码在 O(n log n)时间内运行,辅助空间为 O(n),其中n是树的总节点数。将映射添加到具有 O(log n)复杂度的时间,因为我们对树的每个节点进行一次添加,这意味着我们得到 O(n log n)。对于面试来说,这里提出的解决方案应该足够了。但是,你可以挑战自己,通过使用额外的双向链表将时间复杂度降低到 O(n)。主要是,你需要将每个垂直和存储在链表的一个节点中。首先,将与包含根的轴对应的垂直和添加到链表中。然后,链表的node.nextnode.prev应该存储根轴左侧和右侧轴的垂直和。最后,依靠递归在遍历树时更新链表。

完整的应用程序称为* BinaryTreeVerticalSum。*

编码挑战 23 - 将最大堆转换为最小堆

亚马逊谷歌Adobe微软Flipkart

问题:考虑到你已经得到了一个表示最小二叉堆的数组。编写一小段代码,将给定的最小二叉堆在线性时间内转换为最大二叉堆,而且不需要额外的空间。

解决方案:这个问题的解决方案受到了Heap Sort算法的启发(该算法在第十四章**,排序和搜索中介绍)。

最初,这个问题可能听起来很复杂,但经过几分钟的思考,你可能会得出结论,问题可以简化为从未排序的数组构建最大二叉堆。因此,给定的数组是或不是最小二叉堆并不重要。我们可以通过以下两个步骤从任何数组(排序或未排序)构建所需的最大二叉堆:

  1. 从给定数组的最右下方节点(最后一个内部节点)开始。

  2. 通过自底向上的技术Heapify所有节点。

代码说明自己:

public static void convertToMinHeap(int[] maxHeap) {
  // build heap from last node to all 
  // the way up to the root node
  int p = (maxHeap.length - 2) / 2;
  while (p >= 0) {
    heapifyMin(maxHeap, p--, maxHeap.length);
  }
}
// heapify the node at index p and its two direct children    
private static void heapifyMin(int[] maxHeap,
      int p, int size) {
  // get left and right child of node at index p
  int left = leftChild(p);
  int right = rightChild(p);
  int smallest = p;
  // compare maxHeap[p] with its left and 
  // right child and find the smallest value
  if ((left < size) && (maxHeap[left] < maxHeap[p])) {
    smallest = left;
  }
  if ((right < size) 
      && (maxHeap[right] < maxHeap[smallest]))  {
    smallest = right;
  }
  // swap 'smallest' with 'p' and heapify
  if (smallest != p) {
    swap(maxHeap, p, smallest);
    heapifyMin(maxHeap, smallest, size);
  }
}
/* Helper methods */
private static int leftChild(int parentIndex) {
  return (2 * parentIndex + 1);
}
private static int rightChild(int parentIndex) {
  return (2 * parentIndex + 2);
}
// utility function to swap two indices in the array
private static void swap(int heap[], int i, int j) {
  int aux = heap[i];
  heap[i] = heap[j];
  heap[j] = aux;
}

这段代码的运行时间是 O(n),不需要额外的空间。完整的应用程序称为MaxHeapToMinHeap。它还包含将最小二叉堆转换为最大二叉堆。

编码挑战 24 - 查找二叉树是否对称

亚马逊谷歌Adobe微软Flipkart

如果这个二叉树是对称的(镜像的或不是;左子树和右子树是彼此的镜像),则返回true

解决方案:首先,让我们看一下包含对称和不对称二叉树的图表。标有(a)、(b)和(d)的二叉树是不对称的,而标有(c)、(e)和(f)的二叉树是对称的。请注意,如果二叉树的结构和数据都是对称的,那么二叉树是对称的:

图 13.46 - 对称和不对称的二叉树示例

图 13.46 - 对称和不对称的二叉树示例

我们可以将这个问题看作是镜像root.left并检查它是否与root.right相同。如果它们相同,那么二叉树是对称的。然而,我们也可以通过三个条件来表达两个二叉树的对称性,如下所示(理解这些条件最简单的方法是将它们分别应用到前面图表中显示的示例中):

  1. 根节点的元素相同。

  2. 左树的左子树和右树的右子树必须是镜像。

  3. 左树的右子树和右树的左子树必须是镜像。

我认为我们有足够的经验来认识到这些条件可以通过递归来实现,如下所示:

private boolean isSymmetricRecursive(
      Node leftNode, Node rightNode) {
  boolean result = false;
  // empty trees are symmetric
  if (leftNode == null && rightNode == null) {
    result = true;
  }
  // conditions 1, 2, and 3 from above
  if (leftNode != null && rightNode != null) {
    result = (leftNode.element.equals(rightNode.element))
      && isSymmetricRecursive(leftNode.left, rightNode.right)
      && isSymmetricRecursive(leftNode.right, rightNode.left);
  }
  return result;
}

这段代码的时间复杂度是 O(n),额外空间是 O(h),其中h是树的高度。那么迭代实现呢?我们可以通过队列提供迭代实现。以下代码是对这种方法的最好解释:

public boolean isSymmetricIterative() {        
  boolean result = false;
  Queue<Node> queue = new LinkedList<>();
  queue.offer(root.left);
  queue.offer(root.right);
  while (!queue.isEmpty()) {
    Node left = queue.poll();
    Node right = queue.poll();
    if (left == null && right == null) {
      result = true;
    } else if (left == null || right == null 
                || left.element != right.element) {
      result = false;
      break;
    } else {
      queue.offer(left.left);
      queue.offer(right.right);
      queue.offer(left.right);
      queue.offer(right.left);
    }
  }
  return result;
}

这段代码的时间复杂度是 O(n),额外空间是 O(h),其中h是树的高度。完整的应用程序称为IsSymmetricBinaryTree

编码挑战 25 - 以最小成本连接n根绳子

亚马逊,谷歌,Adobe,微软,Flipkart

问题:假设你有一个包含n根绳子长度的数组,我们需要将所有这些绳子连接成一根绳子。考虑到连接两根绳子的成本等于它们长度的总和。编写一小段代码,以最小成本将所有绳子连接成一根绳子。

解决方案:假设我们有四根长度分别为 1、3、4 和 6 的绳子。让我们首先连接最短的两根绳子。这意味着我们需要连接长度为 1 和 3 的绳子,成本为 1+3=4。按照相同的逻辑,接下来的两根绳子是长度为 4(我们刚刚得到的)和 4。成本是 4+4=8,所以总成本是 4+8=12。我们还剩下两根长度分别为 8 和 6 的绳子。连接它们的成本是 8+6=14。因此,总成本和最终成本是 12+14=26。

现在,让我们尝试另一种策略。让我们首先连接最长的两根绳子。这意味着我们需要连接长度为 4 和 6 的绳子,成本为 4+6=10。按照相同的逻辑,接下来的两根绳子是 10(我们刚刚得到的)和长度为 3。成本是 10+3=13,所以总成本是 10+13=23。我们还剩下两根绳子,长度分别为 13 和 1。连接它们的成本是 13+1=14。因此,总成本和最终成本是 23+14=37。

由于 37>26,很明显第一种方法比第二种方法更好。但是,有什么陷阱吗?嗯,如果你还没有注意到,首先连接的绳子的长度在其余的连接中出现。例如,当我们连接绳子 1 和 3 时,我们写 1+3=4。所以,4 是到目前为止的总成本。接下来,我们加上 4+4=8,所以新的总成本是之前的总成本+8,即 4+8,但 4 是从 1+3 得到的,所以 1+3 再次出现。最后,我们连接 8+6=14。新的总成本是之前的成本+14,即 12+14,但 12 是从 4+8 得到的,4 是从 1+3 得到的,所以 1+3 再次出现。

分析上述陈述会让我们得出结论,如果重复添加的绳子是最小的,那么我们可以获得连接所有绳子的最小成本,然后是第二小的,依此类推。换句话说,我们可以将此算法视为如下所示:

  1. 按长度降序对绳子进行排序。

  2. 连接前两根绳子并更新部分最小成本。

  3. 用结果替换前两根绳子。

  4. 步骤 1开始重复,直到只剩下一根绳子(连接所有绳子的结果)。

实现了这个算法后,我们应该得到最终的最小成本。如果我们尝试通过快速排序或归并排序等排序算法来实现这个算法,那么结果将在 O(n2 log n)的时间内执行。正如你从第七章**,算法的大 O 分析中所知道的那样,这些排序算法的执行时间为 O(n log n),但我们必须每次连接两根绳子时对数组进行排序。

我们能做得更好吗?是的,我们可以!在任何时候,我们只需要最小长度的两根绳子;我们不关心数组的其余部分。换句话说,我们需要一个数据结构,它能够有效地让我们访问最小的元素。因此,答案是最小二进制堆。向最小二进制堆添加和移除是一个 O(log n)复杂度时间的操作。这个算法可以表达如下:

  1. 从绳长数组创建最小二进制堆(O(log n))。

  2. 从最小二进制堆的根部取出元素,这将给我们最小的绳子(O(log n))。

  3. 再次从根部取出元素,这将给我们第二小的绳子(O(log n))。

  4. 连接两根绳子(将它们的长度相加)并将结果放回最小二进制堆中。

  5. 步骤 2重复,直到只剩下一根绳子(连接所有绳子的结果)。

因此,以 O(n log n)复杂度时间执行的算法如下:

public int minimumCost(int[] ropeLength) {
  if (ropeLength == null) {
    return -1;
  }
  // add the lengths of the ropes to the heap
  for (int i = 0; i < ropeLength.length; i++) {           
    add(ropeLength[i]);
  }
  int totalLength = 0;
  while (size() > 1) {         
    int l1 = poll();
    int l2 = poll();
    totalLength += (l1 + l2);
    add(l1 + l2);
  }
  return totalLength;
}

完整的应用程序称为HeapConnectRopes

高级主题

从一开始,你应该知道以下主题在技术面试中很少遇到。首先,让我将这些主题列举为一个非穷尽的列表:

  • AVL 树(本书附带的代码中提供了简要描述和实现)

  • 红黑树(本书附带的代码中提供了简要描述和实现)

  • Dijkstra 算法

  • Rabin-Karp 子字符串搜索

  • Bellman-Ford 算法

  • Floyd-Warshall 算法

  • 区间树

  • 最小生成树

  • B-树

  • 二分图

  • 图着色

  • P、NP 和 NP 完全

  • 组合和概率

  • 正则表达式

  • A*

如果你已经掌握了本书涵盖的所有问题,那么我强烈建议你继续学习上述主题。如果你不这样做,那么请将所有问题视为比这些主题更重要。

这里概述的大部分主题可能在面试中被问到,也可能不会。它们代表了复杂的算法,你要么知道,要么不知道——面试官无法真正洞察你的逻辑和思维能力,仅仅因为你能够重现一个著名的算法。面试官想要看到你能够利用你的知识。这些算法并不能展示你解决之前未见过的问题的能力。显然,你无法直觉地理解这些复杂的算法,因此你的印记几乎微不足道。如果你不知道这些算法,不要担心!它们既不会让你看起来更聪明,也不会让你看起来更愚蠢!此外,由于它们很复杂,需要大量时间来实现,在面试中时间是有限的。

然而,多学习也没有坏处!这是一个规则,所以如果你有时间,那么也看看这些高级主题。

总结

这是本书中最艰难的章节之一,也是任何技术面试的必读。树和图是如此广泛、美妙和具有挑战性的主题,以至于整整一本书都专门献给了它们。然而,当你要准备面试时,你没有时间去研究大量的书籍并深入研究每个主题。这正是本章的魔力所在:这一章(就像整本书一样)完全专注于你必须实现你的目标:通过技术面试。

换句话说,本章包含了在技术面试中可能遇到的最流行的树和图问题,以及有意义的图表、全面的解释和清晰干净的代码。

在下一章中,我们将解决与排序和搜索相关的问题。

第十四章:排序和搜索

本章涵盖了技术面试中遇到的最流行的排序和搜索算法。我们将涵盖诸如归并排序、快速排序、基数排序、堆排序和桶排序等排序算法,以及二分搜索等搜索算法。

通过本章结束时,您应该能够解决涉及排序和搜索算法的各种问题。我们将涵盖以下主题:

  • 排序算法

  • 搜索算法

  • 编码挑战

让我们开始吧!

技术要求

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

排序算法

从准备面试的人的角度考虑排序算法,可以发现两个主要类别:一个类别包含许多相对简单的排序算法,不会在面试中出现,例如冒泡排序、插入排序、计数排序等,另一个类别包含堆排序、归并排序、快速排序、桶排序和基数排序。这些代表了技术面试中出现的前五个排序算法。

如果您对简单的排序算法不熟悉,那么我强烈建议您购买我的书《Java 编程问题》(www.packtpub.com/programming/java-coding-problems),由 Packt 出版。在《Java 编程问题》的第五章**,数组、集合和数据结构中,您可以找到对冒泡排序、插入排序、计数排序等的详细介绍。

此外,名为SortArraysIn14Ways的应用程序包含了您应该了解的 14 种不同排序算法的实现。完整列表如下:

  • 冒泡排序

  • 带有Comparator的冒泡排序

  • 优化的冒泡排序

  • 优化的带有Comparator的冒泡排序

  • 煎饼排序

  • 交换排序

  • 选择排序

  • 希尔排序

  • 插入排序

  • 带有Comparator的插入排序

  • 计数排序

  • 归并排序

  • 堆排序

  • 带有Comparator的堆排序

  • 桶排序

  • 鸡尾酒排序

  • 循环排序

  • 快速排序

  • 带有Comparator的快速排序

  • 基数排序

在接下来的章节中,我们将简要概述面试中遇到的主要算法:堆排序、归并排序、快速排序、桶排序和基数排序。如果您已经熟悉这些算法,请考虑直接跳转到搜索算法部分,甚至是编码挑战部分。

堆排序

如果您对堆的概念不熟悉,请考虑阅读第十三章**,树和图中的二叉堆部分。

堆排序是一种依赖于二叉堆(完全二叉树)的算法。时间复杂度分别为:最佳情况 O(n log n),平均情况 O(n log n),最坏情况 O(n log n)。空间复杂度为 O(1)。

通过最大堆(父节点始终大于或等于子节点)对元素进行升序排序,通过最小堆(父节点始终小于或等于子节点)对元素进行降序排序。

堆排序算法有几个主要步骤,如下:

  1. 将给定数组转换为最大二叉堆。

  2. 接下来,根节点与堆的最后一个元素交换,并且堆的大小减 1(这就像删除堆的根元素)。因此,较大的元素(堆的根)移到最后的位置。换句话说,堆的根元素一个接一个地按排序顺序出来。

  3. 最后一步是堆化剩余的堆(以自顶向下的递归过程重建最大堆)。

  4. 在堆大小大于 1 时重复步骤 2

下面的图表代表了应用堆排序算法的一个测试案例:

图 14.1 - 堆排序

图 14.1 - 堆排序

举例来说,让我们假设前面图表中的数组;即 4, 5, 2, 7, 1:

  1. 所以,在第一步,我们构建最大堆:7, 5, 2, 4, 1(我们用 5 和 7 交换,用 4 和 7 交换,用 4 和 5 交换)。

  2. 接下来,将根(7)与最后一个元素(1)交换并删除 7。结果:1, 5, 2, 4, 7

  3. 此外,我们再次构建最大堆:5, 4, 2, 1(我们用 1 和 5 交换,用 1 和 4 交换)。

  4. 我们将根(5)与最后一个元素(1)交换并删除 5。结果:1, 4, 2, 5, 7

  5. 接下来,我们再次构建最大堆:4, 1, 2(我们用 1 和 4 交换)。

  6. 我们将根(4)与最后一个元素(2)交换并删除 4。结果:2, 1, 4, 5, 7

  7. 这已经是一个最大堆了,所以我们只需将根(2)与最后一个元素(1)交换并移除 2:1, 2, 4, 5, 7

  8. 完成!堆中只剩下一个元素(1)。所以,最终结果是1, 2, 4, 5, 7

在代码方面,上面的例子可以概括如下:

public static void sort(int[] arr) {
  int n = arr.length;
  buildHeap(arr, n);
  while (n > 1) {
    swap(arr, 0, n - 1);
    n--;
    heapify(arr, n, 0);
  }
}
private static void buildHeap(int[] arr, int n) {
  for (int i = arr.length / 2; i >= 0; i--) {
    heapify(arr, n, i);
  }
}
private static void heapify(int[] arr, int n, int i) {
  int left = i * 2 + 1;
  int right = i * 2 + 2;
  int greater;
  if (left < n && arr[left] > arr[i]) {
    greater = left;
  } else {
    greater = i;
  }
  if (right < n && arr[right] > arr[greater]) {
    greater = right;
  }
  if (greater != i) {
    swap(arr, i, greater);
    heapify(arr, n, greater);
  }
}
private static void swap(int[] arr, int x, int y) {
  int temp = arr[x];
  arr[x] = arr[y];
  arr[y] = temp;
}

堆排序不是一个稳定的算法。稳定的算法保证了重复元素的顺序。完整的应用程序称为HeapSort。这个应用程序还包含了基于Comparator的实现 - 这对于对对象进行排序很有用。

归并排序

现在,让我们讨论一下归并排序算法。时间复杂度情况如下:最佳情况 O(n log n),平均情况 O(n log n),最坏情况 O(n log n)。空间复杂度可能会有所不同,取决于所选择的数据结构(可能是 O(n))。

归并排序算法是一种基于著名的分而治之策略的递归算法。考虑到你已经得到了一个未排序的数组,应用归并排序算法需要你不断地将数组分成两半,直到得到空的子数组或者只包含一个元素的子数组(这就是分而治之)。如果一个子数组是空的或者只包含一个元素,那么它根据定义是已排序的 - 这就是递归的基本情况

如果我们还没有达到基本情况,我们再次将这些子数组分割,并尝试对它们进行排序。所以,如果数组包含多于一个元素,我们将其分割,并在这两个子数组上递归调用排序操作。下面的图表显示了对数组 52, 28, 91, 19, 76, 33, 43, 57, 20 的分割过程:

图 14.2 - 在归并排序算法中分割给定的数组

图 14.2 - 在归并排序算法中分割给定的数组

一旦分割完成,我们调用这个算法的基本操作:merge操作(也称为combine操作)。合并是将两个较小的排序子数组合并成一个新的排序子数组的操作。这样做直到整个给定的数组排序完成。下面的图表显示了我们数组的合并操作:

图 14.3 - 归并排序的合并操作

图 14.3 - 归并排序的合并操作

下面的代码实现了归并排序算法。流程从sort()方法开始。在这里,我们首先询问基本情况的问题。如果数组的大小大于 1,那么我们调用leftHalf()rightHalf()方法,这将把给定的数组分成两个子数组。sort()中的其余代码负责调用merge()方法,对两个未排序的子数组进行排序:

public static void sort(int[] arr) {
  if (arr.length > 1) {
    int[] left = leftHalf(arr);
    int[] right = rightHalf(arr);
    sort(left);
    sort(right);
    merge(arr, left, right);
  }
}
private static int[] leftHalf(int[]arr) {
  int size = arr.length / 2;
  int[] left = new int[size];
  System.arraycopy(arr, 0, left, 0, size);
  return left;
}
private static int[] rightHalf(int[] arr) {
  int size1 = arr.length / 2;
  int size2 = arr.length - size1;
  int[] right = new int[size2];
  for (int i = 0; i < size2; i++) {
    right[i] = arr[i + size1];
  }
  return right;
}

接下来,合并操作将元素逐个放回原始数组,重复从排序好的子数组中取出最小的元素:

private static void merge(int[] result, 
      int[] left, int[] right) {
  int t1 = 0;
  int t2 = 0;
  for (int i = 0; i < result.length; i++) {
    if (t2 >= right.length
        || (t1 < left.length && left[t1] <= right[t2])) {
      result[i] = left[t1];
      t1++;
    } else {
      result[i] = right[t2];
      t2++;
    }
  }
}

注意left[t1] <= right[t2]语句保证了算法的稳定性。稳定的算法保证了重复元素的顺序。

完整的应用程序称为MergeSort

快速排序

快速排序是另一种基于著名的分而治之策略的递归排序算法。时间复杂度情况如下:最佳情况 O(n log n),平均情况 O(n log n),最坏情况 O(n2)。空间复杂度为 O(log n)或 O(n)。

快速排序算法首次选择很重要。我们必须从给定数组中选择一个元素作为枢轴。接下来,我们对给定数组进行分区,使得所有小于枢轴的元素都排在所有大于它的元素之前。分区操作通过一系列交换进行。这是分而治之中的步骤。

接下来,使用相应的枢轴递归地将左侧和右侧子数组再次进行分区。这是分而治之中的征服步骤。

最坏情况(O(n2))发生在给定数组的所有元素都小于所选的枢轴或大于所选的枢轴时。可以以至少四种方式选择枢轴元素,如下所示:

  • 选择第一个元素作为枢轴。

  • 选择最后一个元素作为枢轴。

  • 选择中位数作为枢轴。

  • 选择随机元素作为枢轴。

考虑数组 4, 2, 5, 1, 6, 7, 3。在这里,我们将把枢轴设置为最后一个元素。下面的图表描述了快速排序的工作原理:

图 14.4 - 快速排序

图 14.4 - 快速排序

步骤 1:我们选择最后一个元素作为枢轴,所以 3 是枢轴。分区开始时,找到两个位置标记 - 让我们称它们为im。最初,两者都指向给定数组的第一个元素。接下来,我们将位置i上的元素与枢轴进行比较,因此我们将 4 与 3 进行比较。由于 4 > 3,所以没有什么可做,i变为 1(i++),而m保持为 0。

步骤 2:我们将位置i上的元素与枢轴进行比较,因此我们将 2 与 3 进行比较。由于 2 < 3,我们交换位置m上的元素与位置i上的元素,所以我们交换 4 与 2。mi都增加了 1,所以m变为 1,i变为 2。

步骤 3:我们将位置i上的元素与枢轴进行比较,因此我们将 5 与 3 进行比较。由于 5 > 3,所以没有什么可做的,所以i变为 3(i++),而m保持为 1。

步骤 4:我们将位置i上的元素与枢轴进行比较,因此我们将 1 与 3 进行比较。由于 1 < 3,我们交换位置m上的元素与位置i上的元素,所以我们交换 1 与 4。mi都增加了 1,所以m变为 2,i变为 4。

步骤 5 和 6:我们继续比较位置i上的元素与枢轴。由于 6 > 3 和 7 > 3,在这两个步骤中没有什么可做的。完成这些步骤后,i=7。

步骤 7i的下一个元素是枢轴本身,因此没有更多的比较要执行。我们只需交换位置m上的元素与枢轴,所以我们交换 5 与 3。这将枢轴带到其最终位置。其左侧的所有元素都小于它,而右侧的所有元素都大于它。最后,我们返回m

此外,算法对由 0(left)和m-1 界定的数组以及由m+1 和数组末尾(right)界定的数组重复。只要left<right为真,算法就会重复。当此条件评估为假时,数组就已排序。

快速排序算法的伪代码如下:

sort(array, left, right)
    if left < right
        m = partition(array, left, right)
        sort(array, left, m-1)
        sort(array, m+1, right)
    end
end
partition(array, left, right)
    pivot = array[right]
    m = left
    for i = m to right-1
        if array[i] <= pivot
            swap array[i] with array[m]
            m=m+1
        end 
    end
    swap array[m] with array[right]
    return m
end

要对整个数组进行排序,我们调用sort(array, 0, array.length-1)。让我们看看它的实现:

public static void sort(int[] arr, int left, int right) {
  if (left < right) {
    int m = partition(arr, left, right);         
    sort(arr, left, m - 1);
    sort(arr, m + 1, right);
  }
}
private static int partition(int[] arr, int left, int right) {
  int pivot = arr[right];
  int m = left;
  for (int i = m; i < right; i++) {
    if (arr[i] <= pivot) {                
      swap(arr, i, m++);                
    }
  }
  swap(arr, right, m);
  return m;
}

快速排序可以交换非相邻元素;因此,它不是稳定的。完整的应用程序称为QuickSort。该应用程序还包含基于Comparator的实现 - 这对于对对象进行排序很有用。

桶排序

桶排序(或者称为箱排序)是面试中遇到的另一种排序技术。它在计算机科学中常用,在元素均匀分布在一个范围内时非常有用。时间复杂度情况如下:最好和平均情况为 O(n+k),其中 O(k)是创建桶的时间(对于链表或哈希表来说是 O(1)),而 O(n)是将给定数组的元素放入桶中所需的时间(对于链表或哈希表来说也是 O(1))。最坏情况为 O(n2)。空间复杂度为 O(n+k)。

其高潮在于将给定数组的元素分成称为的组。接下来,使用不同的适当排序算法或使用递归通过桶排序算法单独对每个桶进行排序。

可以通过几种方式来创建桶。一种方法依赖于定义一些桶,并将给定数组中的特定范围的元素填充到每个桶中(这称为scatter)。接下来,对每个桶进行排序(通过桶排序或其他排序算法)。最后,从每个桶中收集元素以获得排序后的数组(这称为gathering)。这也被称为scatter-sort-gather技术,并在下图中进行了示例。在这里,我们在数组 4, 2, 11, 7, 18, 3, 14, 7, 4, 16 上使用桶排序:

图 14.5 - 通过 scatter-sort-gather 方法进行桶排序

图 14.5 - 通过 scatter-sort-gather 方法进行桶排序

因此,正如前面的图表所显示的,我们为间隔中的元素定义了四个桶,即 0-5, 5-10, 10-15 和 15-20。给定数组的每个元素都适合一个桶。在将给定数组的所有元素分配到桶中后,我们对每个桶进行排序。第一个桶包含元素 2, 3, 4 和 4。第二个桶包含元素 7, 7 等。最后,我们从桶中收集元素(从左到右),并获得排序后的数组;即 2, 3, 4, 4, 7, 7, 11, 14, 16, 18。

因此,对于这个,我们可以编写以下伪代码:

sort(array)
  create N buckets each of which can hold a range of elements
  for all the buckets
    initialize each bucket with 0 values
  for all the buckets
    put elements into buckets matching the range
  for all the buckets 
    sort elements in each bucket
    gather elements from each bucket
end 

可以通过列表实现此伪代码,如下所示(在此代码中调用的hash()方法在本书附带的代码中可用):

/* Scatter-Sort-Gather approach */
public static void sort(int[] arr) {
  // get the hash codes 
  int[] hashes = hash(arr);
  // create and initialize buckets
  List<Integer>[] buckets = new List[hashes[1]];
  for (int i = 0; i < hashes[1]; i++) {
    buckets[i] = new ArrayList();
  }
  // scatter elements into buckets
  for (int e : arr) {
    buckets[hash(e, hashes)].add(e);
  }
  // sort each bucket
  for (List<Integer> bucket : buckets) {
    Collections.sort(bucket);
  }
  // gather elements from the buckets
  int p = 0;
  for (List<Integer> bucket : buckets) {
    for (int j : bucket) {
      arr[p++] = j;
    }
  }
}

创建桶的另一种方法是将单个元素放入一个桶,如下图所示(这次不涉及排序):

图 14.6 - 通过 scatter-gather 方法进行桶排序

图 14.6 - 通过 scatter-gather 方法进行桶排序

在这种scatter-gather方法中,我们在每个桶中存储元素的出现次数,而不是元素本身,而桶的位置(索引)代表元素的值。例如,在桶号 2 中,我们存储元素 2 的出现次数,在数组 4, 2, 8, 7, 8, 2, 2, 7, 4, 9 中出现三次。由于给定数组中不存在元素 1, 3, 5 和 6,它们的桶为空(其中有 0)。收集操作从左到右收集元素并获得排序后的数组。

因此,对于这个,我们可以编写以下伪代码:

sort(array)
  create N buckets each of which can track a  
        counter of a single element
  for all the buckets
    initialize each bucket with 0 values
  for all the buckets
    put elements into buckets matching a single 
        element per bucket
  for all the buckets 
    gather elements from each bucket
end 

可以通过以下方式实现此伪代码:

/* Scatter-Gather approach */
public static void sort(int[] arr) {
  // get the maximum value of the given array
  int max = arr[0];
  for (int i = 1; i < arr.length; i++) {
    if (arr[i] > max) {
      max = arr[i];
    }
  }
  // create max buckets
  int[] bucket = new int[max + 1];
  // the bucket[] is automatically initialized with 0s, 
  // therefore this step is redundant
  for (int i = 0; i < bucket.length; i++) {
    bucket[i] = 0;
  }
  // scatter elements in buckets
  for (int i = 0; i < arr.length; i++) {
    bucket[arr[i]]++;
  }
  // gather elements from the buckets
  int p = 0;
  for (int i = 0; i < bucket.length; i++) {
    for (int j = 0; j < bucket[i]; j++) {
      arr[p++] = i;
    }
  }
}

桶排序不是一个稳定的算法。稳定的算法保证了重复元素的顺序。完整的应用程序称为BucketSort

基数排序

基数排序是一种非常适用于整数的排序算法。在基数排序中,我们通过将数字的各个数字按其在数字中的位置进行分组来对元素进行排序。接下来,我们通过对每个重要位置上的数字进行排序来对元素进行排序。通常,这是通过计数排序来完成的(计数排序算法在 Packt 出版的书籍Java Coding Problems中有详细介绍,但您也可以在名为SortArraysIn14Ways的应用程序中找到其实现)。主要的,可以通过任何稳定的排序算法来对数字进行排序。

理解基数排序算法的简单方法是通过一个例子。让我们考虑数组 323, 2, 3, 123, 45, 6, 788。下图展示了按顺序对这个数组进行排序的步骤,依次对个位数、十位数和百位数进行排序:

图 14.7 – 基数排序

图 14.7 – 基数排序

所以,首先,我们根据个位数对元素进行排序。其次,我们根据十位数对元素进行排序。第三,我们根据百位数对元素进行排序。当然,根据数组中的最大数,这个过程会继续到千位、万位,直到没有更多的数字为止。

以下代码是基数排序算法的实现:

public static void sort(int[] arr, int radix) {
  int min = arr[0];
  int max = arr[0];
  for (int i = 1; i < arr.length; i++) {
    if (arr[i] < min) {
      min = arr[i];
    } else if (arr[i] > max) {
      max = arr[i];
    }
  }
  int exp = 1;
  while ((max - min) / exp >= 1) {
    countSortByDigit(arr, radix, exp, min);
    exp *= radix;
  }
}
private static void countSortByDigit(
    int[] arr, int radix, int exp, int min) {
  int[] buckets = new int[radix];
  for (int i = 0; i < radix; i++) {
    buckets[i] = 0;
  }
  int bucket;
  for (int i = 0; i < arr.length; i++) {
    bucket = (int) (((arr[i] - min) / exp) % radix);
    buckets[bucket]++;
  }
  for (int i = 1; i < radix; i++) {
    buckets[i] += buckets[i - 1];
  }
  int[] out = new int[arr.length];
  for (int i = arr.length - 1; i >= 0; i--) {
    bucket = (int) (((arr[i] - min) / exp) % radix);
    out[--buckets[bucket]] = arr[i];
  }
  System.arraycopy(out, 0, arr, 0, arr.length);
}

基数排序的时间复杂度取决于用于对数字进行排序的算法(请记住,这可以是任何稳定的排序算法)。由于我们使用计数排序算法,时间复杂度为 O(d(n+b)),其中n是元素的数量,d是数字的数量,b是基数(在我们的情况下,基数是 10)。空间复杂度为 O(n+b)。

完整的应用程序称为RadixSort。到目前为止,我们已经涵盖了技术面试中出现的前五种排序算法。现在,让我们快速概述搜索算法。

搜索算法

在面试中经常出现的主要搜索算法是二分搜索算法,它可能作为一个独立的问题或其他问题的一部分。最佳情况时间复杂度为 O(1),而平均和最坏情况为 O(log n)。二分搜索的最坏情况辅助空间复杂度为 O(1)(迭代实现)和 O(log n)(递归实现)。

二分搜索算法依赖于“分而治之”的策略。主要是通过将给定的数组分成两个子数组来开始。此外,它会丢弃其中一个子数组,并迭代或递归地对另一个子数组进行操作。换句话说,在每一步中,该算法将搜索空间减半(最初是整个给定数组)。

因此,这些算法描述了在数组a中查找元素x的步骤。考虑一个包含 16 个元素的排序数组a,如下图所示:

图 14.8 – 包含 16 个元素的有序数组

图 14.8 – 包含 16 个元素的有序数组

首先,我们将x与数组的中点p进行比较。如果它们相等,我们返回。如果x > p,那么我们在数组的右侧搜索并丢弃左侧(搜索空间是数组的右侧)。如果x < p,那么我们在数组的左侧搜索并丢弃右侧(搜索空间是数组的左侧)。以下是用于查找数字 17 的二分搜索算法的图形表示:

图 14.9 – 二分搜索算法

图 14.9 – 二分搜索算法

注意我们从 16 个元素开始,最后只剩下 1 个。第一步之后,我们剩下 16/2 = 8 个元素。第二步之后,我们剩下 8/2 = 4 个元素。第三步之后,我们剩下 4/2 = 2 个元素。最后一步,我们找到了搜索的数字 17。如果我们将这个算法转换成伪代码,那么我们将得到类似以下的内容:

search 17 in {1, 4, 5, 7, 10, 16, 17, 18, 20,  
              23, 24, 25, 26, 30, 31, 33}
    compare 17 to 18 -> 17 < 18
    search 17 in {1, 4, 5, 7, 10, 16, 17, 18}
        compare 17 to 7 -> 17 > 7
        search 17 in {7, 10, 16, 17}
            compare 17 to 16 -> 17 > 16
            search 17 in {16, 17}
                compare 17 to 17
                return

迭代实现如下所示:

public static int runIterative(int[] arr, int p) {
  // the search space is the whole array
  int left = 0;
  int right = arr.length - 1;
  // while the search space has at least one element
  while (left <= right) {
    // half the search space
    int mid = (left + right) / 2;
    // if domain overflow can happen then use:
    // int mid = left + (right - left) / 2;
    // int mid = right - (right - left) / 2;
    // we found the searched element 
    if (p == arr[mid]) {
      return mid;
    } // discard all elements in the right of the 
      // search space including 'mid'
    else if (p < arr[mid]) {
      right = mid - 1;
    } // discard all elements in the left of the 
      // search space including 'mid'
    else {
      left = mid + 1;
    }
  }
  // by convention, -1 means element not found into the array
  return -1;
}

完整的应用程序称为BinarySearch。它还包含了二分查找算法的递归实现。在第十章**,数组和字符串中,你可以找到利用二分查找算法的不同编码挑战。

编码挑战

到目前为止,我们已经涵盖了在技术面试中遇到的最流行的排序和搜索算法。建议你练习这些算法,因为它们可能作为独立的问题出现,需要伪代码或实现。

说到这里,让我们来解决与排序和搜索算法相关的 18 个问题。

编码挑战 1 – 合并两个排序好的数组

亚马逊谷歌Adobe微软Flipkart

问题:假设你已经得到了两个排序好的数组pqp数组足够大,可以容纳q放在其末尾。编写一段代码片段,将pq按排序顺序合并。

解决方案:重要的是要强调p在末尾有足够的空间容纳q。这表明解决方案不应涉及任何辅助空间。解决方案应该通过按顺序将q中的元素插入到p中,输出合并pq的结果。

主要是,我们应该比较pq中的元素,并按顺序将它们插入到p中,直到我们处理完pq中的所有元素。让我们看一个有意义的图表,揭示了这个动作(p包含元素-1, 3, 8, 0, 0,而q包含元素 2, 4):

图 14.10 – 合并两个排序好的数组

图 14.10 – 合并两个排序好的数组

让我们逐步看这个测试案例(让我们用p的最后一个元素的索引表示为pIdx,用q的最后一个元素的索引表示为qIdx)。在前面的图中,pIdx=2(对应元素 8),qIdx=1(对应元素 4)。

步骤 1:我们比较p的最后一个元素(索引pIdx处的元素)和q的最后一个元素(索引qIdx处的元素),所以我们比较 8 和 4。由于 8 > 4,我们将 8 复制到p的末尾。由于两个数组都是排序好的,8 是这些数组中的最大值,所以它必须放在p的最后位置(索引)。它将占据p中的一个空槽(记住p足够大,可以容纳q在其末尾)。我们将pIdx减 1。

步骤 2:我们比较p的最后一个元素(索引pIdx处的元素)和q的最后一个元素(索引qIdx处的元素),所以我们比较 3 和 4。由于 3 < 4,我们将 4 复制到p的末尾。我们将qIdx减 1。

步骤 3:我们比较p的最后一个元素(索引pIdx处的元素)和q的最后一个元素(索引qIdx处的元素),所以我们比较 3 和 2。由于 3 > 2,我们将 3 复制到p的末尾。我们将pIdx减 1。

步骤 4:我们比较p的最后一个元素(索引pIdx处的元素)和q的最后一个元素(索引qIdx处的元素),所以我们比较-1 和 2。由于-1 < 2,我们将 2 复制到p的末尾。我们将qIdx减 1。没有更多的元素可以比较,p已经排序。

看看这个!在每次比较之后,我们将元素插入到p的末尾。这样,我们就不需要移动任何元素。然而,如果我们选择将元素插入到p的开头,那么我们必须将元素向后移动,为每个插入的元素腾出空间。这是不高效的!

现在,是时候看看这个算法的实现了:

public static void merge(int[] p, int[] q) {
  int pLast = p.length - q.length;
  int qLast = q.length;
  if (pLast < 0) {
    throw new IllegalArgumentException("p cannot fit q");
  }
  int pIdx = pLast - 1;
  int qIdx = qLast - 1;
  int mIdx = pLast + qLast - 1;
  // merge p and q
  // start from the last element in p and q
  while (qIdx >= 0) {
    if (pIdx >= 0 && p[pIdx] > q[qIdx]) {
      p[mIdx] = p[pIdx];
      pIdx--;
    } else {
      p[mIdx] = q[qIdx];
      qIdx--;
    }
    mIdx--;
  }
}

完整的应用程序称为MergeTwoSortedArrays。如果你想检查/记住如何合并k个排序数组,那么请回顾第十章**,数组和字符串在 O(nk log k)时间内合并 k 个排序数组编码挑战。

编码挑战 2 - 将变位词分组在一起

AdobeFlipkart

问题:考虑到你已经得到了一个包含来自'a'到'z'的字符的单词数组,代表了几个混合的变位词(例如,“calipers”,“caret”,“slat”,“cater”,“thickset”,“spiracle”,“trace”,“last”,“salt”,“bowel”,“crate”,“loop”,“polo”,“thickest”,“below”,“thickets”,“pool”,“elbow”,“replicas”)。编写一小段代码,以便打印这个数组,以便所有的变位词都被分组在一起(例如,“calipers”,“spiracle”,“replicas”,“caret”,“cater”,“trace”,“crate”,“slat”,“last”,“salt”,“bowel”,“below”,“elbow”,“thickset”,“thickest”,“thickets”,“loop”,“polo”,“pool”)。

解决方案:首先,这里有一个关于变位词的快速提醒。如果两个或更多字符串(单词)包含相同的字符但顺序不同,则被认为是变位词。

根据这个问题提供的示例,让我们定义以下混合变位词数组:

String[] words = {
  "calipers", "caret", "slat", "cater", "thickset",   
  "spiracle", "trace", "last", "salt", "bowel", "crate", 
  "loop", "polo", "thickest", "below", "thickets", 
  "pool", "elbow", "replicas"
};

由于变位词包含完全相同的字符,这意味着如果我们对它们进行排序,它们将是相同的(例如,对“slat”,“salt”和“last”进行排序得到“alst”)。因此,我们可以说两个字符串(单词)通过比较它们的排序版本来判断它们是否是变位词。换句话说,我们只需要一个排序算法。这样做的最方便的方法是依赖于 Java 的内置排序算法,对于基本类型是双轴快速排序,对于对象是 TimSort。

内置解决方案称为sort(),在java.util.Arrays类中有很多不同的版本(15+种)。其中两种版本具有以下签名:

  • void sort(Object[] a)

  • <T> void sort(T[] a, Comparator<? super T> c)

如果我们将一个字符串(单词)转换为char[],然后对其字符进行排序并通过以下辅助方法返回新的字符串:

// helper method for sorting the chars of a word
private static String sortWordChars(String word) {
  char[] wordToChar = word.toCharArray();
  Arrays.sort(wordToChar);
  return String.valueOf(wordToChar);
}

接下来,我们只需要一个Comparator,指示彼此是变位词的两个字符串是等价的:

public class Anagrams implements Comparator<String> {
  @Override
  public int compare(String sl, String s2) {
    return sortStringChars(sl).compareTo(sortStringChars(s2));
  }
}

最后,我们通过这个compareTo()方法对给定的字符串(单词)数组进行排序:

Arrays.sort(words, new Anagrams());

然而,问题实际上并没有要求我们对给定的变位词数组进行排序;问题要求我们打印分组在一起的变位词。为此,我们可以依赖哈希(如果你不熟悉哈希的概念,请阅读第六章**,面向对象编程哈希表问题)。在 Java 中,我们可以通过内置的HashMap实现使用哈希,因此无需从头开始编写哈希实现。但是HashMap有什么用呢?这个映射的条目(键值对)应该存储什么?

每组变位词都会收敛到相同的排序版本(例如,包含字符串(单词)“slat”,“salt”和“last”的变位词组具有唯一和共同的排序版本“alst”)。由于唯一,排序版本是成为我们映射中键的一个很好的候选者。接下来,值表示变位词的列表。因此,算法非常简单;它包含以下步骤:

  1. 循环遍历给定的单词数组。

  2. 对每个单词的字符进行排序。

  3. 填充映射(添加或更新映射)。

  4. 打印结果。

在代码行中:

/* Group anagrams via hashing (O(nm log m) */
public void printAnagrams(String words[]) {
  Map<String, List<String>> result = new HashMap<>();
  for (int i = 0; i < words.length; i++) {
    // sort the chars of each string
    String word = words[i];
    String sortedWord = sortWordChars(word);
    if (result.containsKey(sortedWord)) {
      result.get(sortedWord).add(word);
    } else {
      // start a new group of anagrams
      List<String> anagrams = new ArrayList<>();
      anagrams.add(word);
      result.put(sortedWord, anagrams);
    }
  }
  // print the result
  System.out.println(result.values());
}

如果n是字符串(单词)的数量,每个字符串(单词)最多有m个字符,则前面两种方法的时间复杂度是 O(nm log m)。

我们能做得更好吗?嗯,要做得更好,我们必须确定前两种方法的问题。问题在于我们对每个字符串(单词)进行排序,这将花费额外的时间。然而,我们可以使用额外的char[]来计算字符串(单词)中每个字符的出现次数(频率)。构建了这个char[]之后,我们将其转换为String,以获得我们在HashMap中搜索的键。由于 Java 处理char类型与(无符号)short相同,我们可以使用char进行计算。让我们看看代码(wordToChar数组跟踪给定数组中每个字符串(单词)的字符频率,从az):

/* Group anagrams via hashing (O(nm)) */
public void printAnagramsOptimized(String[] words) {
  Map<String, List<String>> result = new HashMap<>();
  for (int i = 0; i < words.length; i++) {
    String word = words[i];
    char[] wordToChar = new char[RANGE_a_z];
    // count up the number of occurrences (frequency) 
    // of each letter in 'word'
    for (int j = 0; j < word.length(); j++) {
      wordToChar[word.charAt(j) - 'a']++;
    }
    String computedWord = String.valueOf(wordToChar);
    if (result.containsKey(computedWord)) {
      result.get(computedWord).add(word);
    } else {
      List<String> anagrams = new ArrayList<>();
      anagrams.add(word);
      result.put(computedWord, anagrams);
    }
  }
  System.out.println(result.values());
}

如果n是字符串(单词)的数量,每个字符串(单词)包含最多m个字符,则前两种方法的时间复杂度为 O(nm)。如果你需要支持更多的字符,而不仅仅是从az,那么使用int[]数组和codePointAt() - 更多细节请参考第十章**,数组和字符串,在提取代理对的代码点编码挑战中。完整的应用程序称为GroupSortAnagrams

编码挑战 3 - 未知大小的列表

size()或类似的方法)仅包含正数。该列表的代码如下:

public class SizelessList {
  private final int[] arr;
  public SizelessList(int[] arr) {
    this.arr = arr.clone();
  }
  public int peekAt(int index) {
    if (index >= arr.length) {
      return -1;
    }
    return arr[index];
  }
}

然而,正如你所看到的,有一种方法叫做peekAt(),它以 O(1)返回给定索引处的元素。如果给定的索引超出了列表的范围,那么peekAt()返回-1。编写一小段代码,返回元素p出现的索引。

list.size()/2)来找到中间点。给定的数据结构(列表)不会显示其大小。

因此,问题被简化为找到这个列表的大小。我们知道如果给定的索引超出了列表的范围,peekAt()会返回-1,所以我们可以循环列表并计算迭代次数,直到peekAt()返回-1。当peekAt()返回-1 时,我们应该知道列表的大小,所以我们可以应用二分搜索算法。我们可以尝试以指数方式而不是逐个元素地循环列表(线性算法)。因此,我们可以在 O(log n)的时间内完成,其中n是列表的大小。我们之所以能够这样做,是因为给定的列表是排序的!

以下代码应该阐明这种方法和其余细节:

public static int search(SizelessList sl, int element) {
  int index = 1;
  while (sl.peekAt(index) != -1
        && sl.peekAt(index) < element) {
    index *= 2;
  }
  return binarySearch(sl, element, index / 2, index);
}
private static int binarySearch(SizelessList sl, 
      int element, int left, int right) {
  int mid;
  while (left <= right) {
    mid = (left + right) / 2;
    int middle = sl.peekAt(mid);
    if (middle > element || middle == -1) {
      right = mid - 1;
    } else if (middle < element) {
      left = mid + 1;
    } else {
      return mid;
    }
  }
  return -1;
}

完整的应用程序称为UnknownSizeList

编码挑战 4 - 对链表进行归并排序

亚马逊,谷歌,Adobe,微软,Flipkart

问题:假设你已经得到了一个单链表。编写一小段代码,使用归并排序算法对这个链表进行排序。

解决方案:解决这个问题需要对我们在本书中已经涵盖的几个主题有所了解。首先,你必须熟悉链表。这个主题在第十一章**,链表和映射中有所涵盖。其次,你需要阅读本章的归并排序部分。

根据归并排序算法,我们必须不断将链表一分为二,直到获得空子列表或包含单个元素的子列表(这是分而治之的方法)。如果子列表为空或包含一个元素,它就是按定义排序的 - 这被称为基本情况递归。以下图表展示了对初始链表 2 → 1 → 4 → 9 → 8 → 3 → 7 → null 进行此过程:

图 14.11 - 在链表上使用分而治之

图 14.11 - 在链表上使用分而治之

通过快速运行者/慢速运行者方法可以将给定的链表分成这样。这种方法在第十一章**,链表和映射中的快速运行者/慢速运行者方法部分有详细介绍。主要是,当快速运行者FR)到达给定链表的末尾时,慢速运行者SR)指向此列表的中间位置,因此我们可以将列表分成两部分。此代码如下所示:

// Divide the given linked list in two equal sub-lists.
// If the length of the given linked list is odd, 
// the extra node will go in the first sub-list
private Node[] divide(Node sourceNode) {
  // length is less than 2
  if (sourceNode == null || sourceNode.next == null) {
    return new Node[]{sourceNode, null};
  }
  Node fastRunner = sourceNode.next;
  Node slowRunner = sourceNode;
  // advance 'firstRunner' two nodes, 
  // and advance 'secondRunner' one node
  while (fastRunner != null) {
    fastRunner = fastRunner.next;
    if (fastRunner != null) {
      slowRunner = slowRunner.next;
      fastRunner = fastRunner.next;
    }
  }
  // 'secondRunner' is just before the middle point 
  // in the list, so split it in two at that point
  Node[] headsOfSublists = new Node[]{
          sourceNode, slowRunner.next};
  slowRunner.next = null;
  return headsOfSublists;
}

代码的其余部分是经典的归并排序实现。sort()方法负责递归地对子列表进行排序。接下来,merge()方法通过反复从排序后的子列表中取出最小的元素,将元素逐个放回原始链表中:

// sort the given linked list via the Merge Sort algorithm
public void sort() {
  head = sort(head);
}
private Node sort(Node head) {
  if (head == null || head.next == null) {
    return head;
  }
  // split head into two sublists
  Node[] headsOfSublists = divide(head);
  Node head1 = headsOfSublists[0];  
  Node head2 = headsOfSublists[1];
  // recursively sort the sublists
  head1 = sort(head1);
  head2 = sort(head2);
  // merge the two sorted lists together
  return merge(head1, head2);
}
// takes two lists sorted in increasing order, and merge 
// their nodes together (which is returned)
private Node merge(Node head1, Node head2) {
  if (head1 == null) {
    return head2;
  } else if (head2 == null) {
    return head1;
  }
  Node merged;
  // pick either 'head1' or 'head2'
  if (head1.data <= head2.data) {
    merged = head1;
    merged.next = merge(head1.next, head2);
  } else {
    merged = head2;
    merged.next = merge(head1, head2.next);
  }
  return merged;
}

完整的应用程序称为MergeSortSinglyLinkedList。对双向链表进行排序非常类似。您可以在名为MergeSortDoublyLinkedList的应用程序中找到这样的实现。

编码挑战 5-字符串与空字符串交错

亚马逊,谷歌,Adobe,微软,Flipkart

问题:假设您已经获得了一个包含空字符串的排序字符串数组。编写一小段代码,返回给定非空字符串的索引。

解决方案:当我们必须在排序的数据结构中进行搜索(例如,在排序的数组中),我们知道二分搜索算法是正确的选择。那么,在这种情况下我们可以使用二分搜索吗?我们有给定数组的大小,因此可以将搜索空间减半并找到中点。如果我们将数组的索引 0 表示为left,将array.length-1 表示为right,那么我们可以写mid =left + right)/2。因此,mid是给定数组的中点。

但是如果中间索引落在一个空字符串上怎么办?在这种情况下,我们不知道是应该去右边还是左边。换句话说,应该丢弃哪一半,哪一半应该用于继续搜索?答案可以在下图中找到(给定的字符串是"cat","","","","","","","rear",""):

图 14.12-在空字符串情况下计算中点

图 14.12-在空字符串情况下计算中点

因此,当中点(mid)落在一个空字符串上时,我们必须通过将其移动到最近的非空字符串来更正其索引。如前图的步骤 2所示,我们选择leftMidmid-1,rightMidmid+1。我们不断远离mid,直到leftMidrightMid索引指向一个非空字符串(在前图中,rightMid步骤 34之后找到字符串"rear")。当发生这种情况时,我们更新mid位置并继续经典的二分搜索(步骤 4)。

在代码方面,这非常简单:

public static int search(String[] stringsArr, String str) {
  return search(stringsArr, str, 0, stringsArr.length - 1);
}
private static int search(String[] stringsArr, 
      String str, int left, int right) {
  if (left > right) {
    return -1;
  }
  int mid = (left + right) / 2;
  // since mid is empty we try to find the 
  // closest non-empty string to mid
  if (stringsArr[mid].isEmpty()) {
    int leftMid = mid - 1;
    int rightMid = mid + 1;
    while (true) {
      if (leftMid < left && rightMid > right) {
        return -1;
      } else if (rightMid <= right 
            && !stringsArr[rightMid].isEmpty()) {
        mid = rightMid;
        break;
      } else if (leftMid >= left 
            && !stringsArr[leftMid].isEmpty()) {
        mid = leftMid;
        break;
      }
      rightMid++;
      leftMid--;
    }
  }
  if (str.equals(stringsArr[mid])) {
    // the searched string was found
    return mid;
  } else if (stringsArr[mid].compareTo(str) < 0) {
    // search to the right
    return search(stringsArr, str, mid + 1, right);
  } else {
    // search to the left
    return search(stringsArr, str, left, mid - 1);
  }
}

这种方法的最坏时间复杂度为 O(n)。请注意,如果搜索的字符串是空字符串,则返回-1,因此我们将此情况视为错误。这是正确的,因为问题说需要找到的给定字符串是非空的。如果问题没有提供关于这一方面的任何细节,那么您必须与面试官讨论这一点。这样,您向面试官表明您注意细节和边缘情况。完整的应用程序称为InterspersedEmptyStrings

编码挑战 6-使用另一个队列对队列进行排序

亚马逊,谷歌,Adobe,微软,Flipkart

问题:假设您已经获得了一个整数队列。编写一小段代码,使用另一个队列(额外队列)对该队列进行排序。

解决方案:解决此问题的解决方案必须包括一个额外的队列,因此我们必须考虑如何在对给定队列进行排序时使用这个额外的队列。有不同的方法,但是在面试中的一个方便的方法可以总结如下:

  1. 只要给定队列中的元素按升序排列(从队列的前端开始),我们就将它们出列并排队到额外队列中。

  2. 如果一个元素违反了前面的陈述,那么我们将其出列并重新排队到给定队列中,而不触及额外队列。

  3. 在所有元素通过步骤 12进行处理之后,我们将所有元素从额外队列中出列并重新排队到给定队列中。

  4. 只要额外队列的大小不等于给定队列的初始大小,我们就从步骤 1开始重复,因为队列还没有排序。

让我们假设给定队列包含以下元素:rear → 3 → 9 → 1 → 8 → 5 → 2 → front。下图表示给定队列和额外队列(最初为空):

图 14.13 - 给定队列和额外队列

图 14.13 - 给定队列和额外队列

应用我们算法的步骤 1意味着从给定队列中出列 2、5 和 8,并将它们排队到额外队列中,如下图所示:

图 14.14 - 在额外队列中排队 2、5 和 8

图 14.14 - 在额外队列中排队 2、5 和 8

由于给定队列中的下一个元素比添加到额外队列的最后一个元素小,我们应用我们算法的步骤 2,所以我们出列 1 并将其排队到给定队列中,如下图所示:

图 14.15 - 从给定队列中出列并排队 1

图 14.15 - 从给定队列中出列并排队 1

此外,我们再次应用步骤 1,因为 9(给定队列的前端)比添加到额外队列的最后一个元素(8)大。所以,9 进入额外队列,如下图所示:

图 14.16 - 在额外队列中排队 9

图 14.16 - 在额外队列中排队 9

接下来,3 小于 9,所以我们必须将其出列并重新排队到给定队列中,如下图所示:

图 14.17 - 从给定队列中出列并排队 3

图 14.17 - 从给定队列中出列并排队 3

此时,我们已经处理(访问)了给定队列中的所有元素,所以我们应用我们算法的步骤 3。我们将所有元素从额外队列中出列并排队到给定队列中,如下图所示:

图 14.18 - 从额外队列中出列并加入给定队列

图 14.18 - 从额外队列中出列并加入给定队列

现在,我们重复整个过程,直到给定队列按升序排序。让我们看看代码:

public static void sort(Queue<Integer> queue) {
  if (queue == null || queue.size() < 2) {
    return;
  }
  // this is the extra queue
  Queue<Integer> extraQueue = new ArrayDeque();
  int count = 0;            // count the processed elements
  boolean sorted = false;   // flag when sorting is done
  int queueSize = queue.size();   // size of the given queue
  int lastElement = queue.peek(); // we start from the front  
                                  // of the given queue
  while (!sorted) {
    // Step 1
    if (lastElement <= queue.peek()) {
      lastElement = queue.poll();
      extraQueue.add(lastElement);
    } else { // Step 2
      queue.add(queue.poll());
    }
    // still have elements to process
    count++;
    if (count != queueSize) {
      continue;
    }
    // Step 4
    if (extraQueue.size() == queueSize) {
      sorted = true;
    }
    // Step 3            
    while (extraQueue.size() > 0) {
      queue.add(extraQueue.poll());
      lastElement = queue.peek();
    }
    count = 0;
  }
}

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

编码挑战 7 - 在不使用额外空间的情况下对队列进行排序

亚马逊谷歌Adobe微软Flipkart

问题:假设你有一个整数队列。编写一小段代码,对这个队列进行排序,而不使用额外的空间。

解决方案:在前面的问题中,我们必须解决相同的问题,但是使用额外的队列。这一次,我们不能使用额外的队列,所以我们必须在原地对队列进行排序。

我们可以将排序看作是一个持续的过程,从给定队列中找到最小元素,将其从当前位置提取出来,并将其添加到队列的末尾。扩展这个想法可能会得到以下算法:

  1. 将当前最小值视为Integer.MAX_VALUE

  2. 从队列的未排序部分(最初,未排序部分是整个队列)中出列一个元素。

  3. 将这个元素与当前最小值进行比较。

  4. 如果这个元素比当前最小值小,那么执行以下操作:

a. 如果当前最小值是Integer.MAX_VALUE,那么这个元素就成为当前最小值,我们不会将其重新加入队列。

b. 如果当前最小值不是Integer.MAX_VALUE,那么我们将当前最小值重新加入队列,并且这个元素成为当前最小值。

  1. 如果这个元素大于当前最小值,则将其重新加入队列。

  2. 重复从步骤 2直到整个未排序部分被遍历。

  3. 在这一步中,当前最小值是整个未排序部分的最小值,因此我们将其重新加入队列。

  4. 设置未排序部分的新边界,并从步骤 1重复,直到未排序部分的大小为 0(每次执行此步骤时,未排序部分的大小减 1)。

下图是该算法对队列的快照;即,rear → 3 → 9 → 1 → 8 → 5 → 2 → front:

图 14.19 – 不使用额外空间对队列进行排序

图 14.19 – 不使用额外空间对队列进行排序

注意每个未排序部分(最初是整个队列)的最小值是如何重新加入队列并成为队列的排序部分的成员的。让我们看看代码:

public static void sort(Queue<Integer> queue) {
  // traverse the unsorted part of the queue
  for (int i = 1; i <= queue.size(); i++) {
    moveMinToRear(queue, queue.size() - i);
  }
}
// find (in the unsorted part) the minimum
// element and move this element to the rear of the queue
private static void moveMinToRear(Queue<Integer> queue, 
          int sortIndex) {
  int minElement = Integer.MAX_VALUE;
  boolean flag = false;
  int queueSize = queue.size();
  for (int i = 0; i < queueSize; i++) {
    int currentElement = queue.peek();
    // dequeue
    queue.poll();
    // avoid traversing the sorted part of the queue            
    if (currentElement <= minElement && i <= sortIndex) {
      // if we found earlier a minimum then 
      // we put it back into the queue since
      // we just found a new minimum
      if (flag) {
        queue.add(minElement);
      }
      flag = true;
      minElement = currentElement;
    } else {
      // enqueue the current element which is not the minimum
      queue.add(currentElement);
    }
  }
  // enqueue the minimum element
  queue.add(minElement);
}

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

编程挑战 8 – 使用另一个栈帮助对栈进行排序

亚马逊谷歌Adobe微软Flipkart

问题:考虑到你已经得到了一个未排序的栈。编写一小段代码,对栈进行升序或降序排序。你只能使用一个额外的临时栈。

解决方案:如果我们可以使用两个额外的栈,那么我们可以实现一个算法,该算法重复搜索给定栈中的最小值,并将其推入最终或结果栈。第二个额外的栈将用作在搜索给定栈时的缓冲区。然而,问题要求我们只能使用一个额外的临时栈。

由于这个限制,我们被迫从给定的栈(我们将其表示为s1)中弹出并按顺序推入另一个栈(我们将其表示为s2)。为了实现这一点,我们使用一个临时的或辅助变量(我们将其表示为t),如下图所示(给定的栈为 top → 1 → 4 → 5 → 3 → 1 → 2):

图 14.20 – 对栈进行排序

图 14.20 – 对栈进行排序

解决方案由两个主要步骤组成:

  1. s1不为空时,执行以下操作:

a. 从s1中弹出一个值并将其存储在t中(前一个图中显示了值 3 的动作 1)。

b. 从s2中弹出并将其推入s1,只要从s2中弹出的值大于t或者s2不为空(前一个图中的动作 2)。

c. 将t推入s2(前一个图中的动作 3)。

  1. 一旦步骤 1完成,s1为空,s2已排序。最大值在底部,因此结果栈为 top → 5 → 4 → 3 → 2 → 1 → 1。第二步是将s2复制到s1。这样,s1s2的相反顺序排序,因此最小值在s1的顶部(top → 1 → 1 → 2 → 3 → 4 → 5)。

让我们看看代码:

public static void sort(Stack<Integer> stack) {
  Stack<Integer> auxStack = new Stack<>();
  // Step 1 (a, b and c)
  while (!stack.isEmpty()) {
    int t = stack.pop();
    while (!auxStack.isEmpty() && auxStack.peek() > t) {
      stack.push(auxStack.pop());
    }
    auxStack.push(t);
  }
  // Step 2
  while (!auxStack.isEmpty()) {
    stack.push(auxStack.pop());
  }
}

完整的代码称为SortStack

编程挑战 9 – 原地对栈进行排序

亚马逊谷歌Adobe微软Flipkart

forwhile等等。

解决方案:在前面的问题中,我们必须解决相同的问题,但是使用一个显式的额外栈。这一次,我们不能使用显式的额外栈,因此我们必须原地对栈进行排序。

假设给定的栈为 top → 4 → 5 → 3 → 8 → 2 →1。解决方案从栈中弹出值开始,直到栈为空。然后,我们将递归调用栈中的值按排序位置插入回给定的栈。

让我们尝试将这种方法应用到我们的栈上。下图显示了从栈中弹出值直到栈为空的过程。在左侧,我们有初始状态。在右侧,我们有结果:

图 14.21 – 原地对栈进行排序(1)

图 14.21 – 原地对栈进行排序(1)

接下来,只要要推入的当前元素小于当前堆栈的顶部元素或堆栈为空,我们就将其推回到堆栈中。因此,我们将推入 1、2 和 8。我们不推入 3(下一个要推入的元素),因为 3 小于 8(您可以在以下图表中看到这个语句作为动作 1)。在这一点上,我们需要为 3 腾出空间,所以我们必须弹出堆栈的顶部,8(您可以在以下图表中看到这个语句作为动作 2)。最后,我们推入 3,然后推入 8 到堆栈中(您可以在以下图表中看到这个语句作为动作 3):

图 14.22 – 原地对堆栈进行排序(2)

图 14.22 – 原地对堆栈进行排序(2)

到目前为止,一切都很顺利!接下来,我们必须重复前面图表中呈现的流程。因此,从递归调用堆栈中推入给定堆栈的下一个元素是 5。但是 5 小于 8,所以我们不能推入它(您可以在以下图表中看到这个语句作为动作 1)。在这一点上,我们需要为 5 腾出空间,所以我们必须弹出堆栈的顶部,即 8(您可以在以下图表中看到这个语句作为动作 2)。最后,我们推入 5,然后推入 8 到堆栈中(您可以在以下图表中看到这个语句作为动作 3):

图 14.23 – 原地对堆栈进行排序(3)

图 14.23 – 原地对堆栈进行排序(3)

最后,应该从递归调用堆栈中推入给定堆栈的最后一个元素是 4。然而,4 小于 8,所以我们不能推入它(您可以在以下图表中看到这个语句作为动作 1)。在这一点上,我们需要为 4 腾出空间,所以我们必须弹出堆栈的顶部,即 8(您可以在以下图表中看到这个语句作为动作 2)。然而,我们仍然不能将 4 推入堆栈,因为 4 小于 5(弹出 8 后的新顶部元素)。我们必须也弹出 5(您可以在以下图表中看到这个语句作为动作 3)。现在,我们可以推入 4。接下来,我们推入 5 和 8。您可以在以下图表中看到这一点作为动作 4

图 14.24 – 原地对堆栈进行排序(4)

图 14.24 – 原地对堆栈进行排序(4)

完成!给定的堆栈已经排序。让我们看看代码:

public static void sort(Stack<Integer> stack) {
  // stack is empty (base case)
  if (stack.isEmpty()) {
    return;
  }
  // remove the top element
  int top = stack.pop();
  // apply recursion for the remaining elements in the stack
  sort(stack);
  // insert the popped element back in the sorted stack
  sortedInsert(stack, top);
}
private static void sortedInsert(
 Stack<Integer> stack, int element) {
  // the stack is empty or the element 
  // is greater than all elements in the stack (base case)
  if (stack.isEmpty() || element > stack.peek()) {
    stack.push(element);
    return;
  }
  // the element is smaller than the top element, 
  // so remove the top element       
  int top = stack.pop();
  // apply recursion for the remaining elements in the stack
  sortedInsert(stack, element);
  // insert the popped element back in the stack
  stack.push(top);
}

这段代码的运行时间是 O(n2),辅助空间是 O(n)用于递归调用堆栈(n是给定堆栈中的元素数)。完整的应用程序称为SortStackInPlace

编码挑战 10 – 在完全排序的矩阵中搜索

亚马逊微软Flipkart

true如果给定的整数在这个矩阵中。

解决方案:暴力方法非常低效。如果我们尝试迭代矩阵并将每个(行,列)整数与搜索的整数进行比较,那么这将导致时间复杂度为 O(mn),其中m是矩阵中的行数,n是列数。

另一个解决方案将依赖于二分搜索算法。我们有足够的经验来为排序数组实现这个算法,但是我们能为排序矩阵实现吗?是的,我们可以,这要归功于这个排序矩阵是完全排序。更确切地说,由于每行的第一个整数大于前一行的最后一个整数,我们可以将这个矩阵看作长度为行数 x 列数的数组。以下图表澄清了这个说法:

图 14.25 – 完全排序的矩阵作为数组

图 14.25 – 完全排序的矩阵作为数组

因此,如果我们将给定的矩阵视为数组,那么我们可以将应用二分搜索到排序数组的问题减少。没有必要将矩阵物理转换为数组。我们只需要根据以下语句相应地表达二分搜索:

  • 数组的最左边整数位于索引 0(让我们将其表示为left)。

  • 数组的最右边整数位于索引(行数 x 列数)- 1(让我们将其表示为right)。

  • 数组的中间点在(left + right) / 2 处。

  • 索引的中间点处的整数为matrix[mid / cols][mid % cols],其中cols是矩阵中的列数。

有了这些陈述,我们可以编写以下实现:

public static boolean search(int[][] matrix, int element) {
  int rows = matrix.length;    // number of rows
  int cols = matrix[0].length; // number of columns
  // search space is an array as [0, (rows * cols) - 1]
  int left = 0;
  int right = (rows * cols) - 1;
  // start binary search
  while (left <= right) {
    int mid = (left + right) / 2;
    int midElement = matrix[mid / cols][mid % cols];
    if (element == midElement) {
      return true;
    } else if (element < midElement) {
      right = mid - 1;
    } else {
      left = mid + 1;
    }
  }
  return false;
}

前面的代码在 O(log mn)时间内执行,其中m是给定矩阵中的行数,n是列数。该应用程序称为SearchInFullSortedMatrix

编码挑战 11 - 在排序矩阵中搜索

亚马逊微软Flipkart

true如果给定整数在此矩阵中。

解决方案:请注意,这个问题不像前一个编码挑战,因为每行的第一个整数不必大于前一行的最后一个整数。如果我们应用二分搜索算法(就像我们对前一个编码挑战所做的那样),那么我们必须对每一行应用它。由于二分搜索的时间复杂度为 O(log n),我们必须对每一行应用它,这意味着这种方法将在 O(m log n)时间内执行,其中m是给定矩阵中的行数,n是列数。

为了找到解决方案,让我们考虑以下图表(一个 4 x 6 的矩阵):

图 14.26 - 在排序矩阵中搜索

图 14.26 - 在排序矩阵中搜索

假设我们搜索元素 80,可以在(2, 3)处找到。让我们试着推断这个位置。这个推断的高潮围绕着矩阵有序的行和列。让我们分析列的开始:如果一列的开始大于 80(例如,列 4),那么我们知道 80 不能在该列中,因为该列的开始是该列中的最小元素。此外,80 不能在该列右侧的任何列中找到,因为每列的开始元素必须从左到右递增。此外,我们可以将相同的逻辑应用于行。如果一行的开始大于 80,那么我们知道 80 不能在该行或随后(向下)的行中。

现在,如果我们看列和行的末尾,我们可以得出一些类似的结论(镜像结论)。如果一列的末尾小于 80(例如,列 2),那么我们知道 80 不能在该列中,因为该列的末尾是该列中的最大元素。此外,80 不能在该列左侧的任何列中找到,因为每列的开始元素必须从右到左递减。此外,我们可以将相同的逻辑应用于行。如果一行的末尾小于 80,那么我们知道 80 不能在该行或随后(向上)的行中。

如果我们将这些结论综合起来,我们可以推断出以下结论:

  • 如果一列的开始大于p,那么p必须在该列的左边。

  • 如果一行的开始大于p,那么p必须在该行的上方。

  • 如果一列的末尾小于p,那么p必须在该列的右边。

  • 如果一行的末尾小于p,那么p必须在该行下方。

这已经开始看起来像一个算法。不过,我们还有一件事要决定。我们从哪里开始?从哪一行和哪一列开始?幸运的是,我们有几个选择。例如,我们可以从最大列(0,最后一列)开始,并向同一行的左边开始,或者从最大行(最后一行,0)开始,并向同一列的上方开始。

假设我们选择从最大列(0,最后一列)开始,并向左查找元素p。这意味着我们的流程将如下(让我们表示i=0 和j=cols-1):

  1. 如果matrix[i][j] > p,那么在同一行向左移动。这一列的元素肯定大于matrix[i][j],因此,通过推论,大于p。因此,我们丢弃当前列,将j减 1,并重复。

  2. 如果matrix[i][j] < p,则在同一列向下移动。这一行的元素肯定小于matrix[i][j],因此,通过推论,也小于p。因此,我们丢弃当前行,将i增加 1,并重复。

  3. 如果p等于matrix[i][j],返回true

如果我们将这个算法应用于在我们的 4 x 6 矩阵中查找元素 80,那么从(0, 5)到(2, 3)的路径将如下所示:

图 14.27 - 解决方案的路径

图 14.27 - 解决方案的路径

如果我们将这个算法编写成代码,那么我们会得到以下结果:

public static boolean search(int[][] matrix, int element) {
  int row = 0;
  int col = matrix[0].length - 1;
  while (row < matrix.length && col >= 0) {
    if (matrix[row][col] == element) {
      return true;
    } else if (matrix[row][col] > element) {
      col--;
    } else {
      row++;
    }
  }
  return false;
}

这个算法的时间复杂度是 O(m+n),其中m是行数,n是列数。完整的应用程序称为SearchInSortedMatrix。它还包含了这个算法的递归实现。

编码挑战 12 - 第一个 1 的位置

亚马逊谷歌Adobe

问题:假设你得到了一个只包含 0 和 1 值的数组。至少有一个 0 和一个 1。所有的 0 都在前面,然后是 1。编写一小段代码,返回这个数组中第一个 1 的索引。

解决方案:考虑数组arr=[0, 0, 0, 1, 1, 1, 1]。搜索到的索引是 3,因为arr[3]是 1,这是第一个 1。

由于 0 在前面,然后是 1,所以数组是排序的。

注意

由于这是面试中非常常见的话题,我再说一遍:当我们在一个排序的数组中查找东西时,我们必须考虑二分搜索算法。

在这种情况下,二分搜索算法可以很容易地实现。在二分搜索中计算的中间点可以落在 0 或 1 上。由于数组是排序的,如果中间点落在 0 上,那么我们可以确定 1 的第一个值必须在中间点的右侧,所以我们丢弃中间点的左侧。另一方面,如果中间点落在 1 上,那么我们知道 1 的第一个值必须在中间点的左侧,所以我们丢弃中间点的右侧。以下代码阐明了这一点:

public static int firstOneIndex(int[] arr) {
  if (arr == null) {
    return -1;
  }
  int left = 0;
  int right = arr.length - 1;
  while (left <= right) {
    int middle = 1 + (right - left) / 2;
    if (arr[middle] == 0) {
      left = middle + 1;
    } else {
      right = middle - 1;
    }
    if (arr[left] == 1) {
      return left;
    }
  }
  return -1;
}

完整的应用程序称为PositionOfFirstOne

编码挑战 13 - 两个元素之间的最大差值

问题:假设你得到了一个整数数组arr。编写一小段代码,返回当较大的整数出现在较小的整数之后时,两个元素之间的最大差值。

解决方案:让我们考虑几个例子。

如果给定的数组是 1, 34, 21, 7, 4, 8, 10,那么最大差值是 33(计算为 34(索引 1)- 1(索引 0))。如果给定的数组是 17, 9, 2, 26, 32, 27, 3,那么最大差值是 30(计算为 32(索引 4)- 2(索引 2))。

如果是按升序排序的数组,比如 3, 7, 9, 11,那么最大差值是 11 - 3 = 8,所以这是最大元素和最小元素之间的差值。如果是按降序排序的数组,比如 11, 9, 7, 6,那么最大差值是 6 - 7 = -1,所以最大差值是最接近 0 的差值。

根据这些例子,我们可以考虑几种解决方案。例如,我们可以先计算数组的最小值和最大值。接下来,如果最大值的索引大于最小值的索引,则最大差值是数组的最大值和最小值之间的差值。否则,我们需要计算数组的下一个最小值和最大值,并重复这个过程。这可能导致 O(n2)的时间复杂度。

另一种方法可以通过对数组进行排序来开始。之后,最大差值将是最大元素和最小元素之间的差值(最后一个元素和第一个元素之间的差值)。这可以通过 O(n log n)的运行时间内的排序算法来实现。

如何在 O(n)时间内完成?我们尝试另一种方法,而不是对数组进行排序或计算其最大值或最小值。请注意,如果我们认为p是数组中的第一个元素,我们可以计算每个连续元素与p之间的差异。在这样做的同时,我们跟踪最大差异并相应地更新它。例如,如果数组是 3, 5, 2, 1, 7, 4,p=3,那么最大差异是 7-p=7-3=4。然而,如果我们仔细观察,真正的最大差异是 7-1=6,而 1 小于p。这导致我们得出结论,当遍历p之后的连续元素时,如果当前遍历的元素小于p,那么p应该变成该元素。在p的后继元素之间计算后续差异,直到完全遍历数组或找到另一个小于p的元素。在这种情况下,我们重复这个过程。

让我们看看代码:

public static int maxDiff(int arr[]) {
  int len = arr.length;
  int maxDiff = arr[1] - arr[0];
  int marker = arr[0];
  for (int i = 1; i < len; i++) {
    if (arr[i] - marker > maxDiff) { 
      maxDiff = arr[i] - marker;
    }
    if (arr[i] < marker) {
      marker = arr[i];
    }
  }
  return maxDiff;
}

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

编码挑战 14 - 流排名

问题:假设你得到了一系列整数流(例如连续的整数值流)。定期地,我们想要检查给定整数p的排名。通过排名,我们理解小于或等于p的值的数量。实现支持此操作的数据结构和算法。

解决方案:让我们考虑以下流:40, 30, 45, 15, 33, 42, 56, 5, 17, 41, 67。45 的排名是 8,5 的排名是 0,17 的排名是 2,依此类推。

蛮力方法可能适用于排序数组。每次生成一个新整数时,我们将其添加到这个数组中。虽然这对于返回给定整数的排名非常方便,但这种方法有一个重要的缺点:每次插入一个元素,我们都必须将大于新整数的元素移动,以为其腾出空间。这是为了在数组按升序排序时维护数组。

一个更好的选择是二叉搜索树BST)。BST 维护相对顺序,并插入新整数将相应地更新树。让我们将整数从我们的流添加到二叉搜索树中,如下所示:

图 14.28 - 流排名的 BST

图 14.28 - 流排名的 BST

假设我们想要找到排名 43。首先,我们将 43 与根节点进行比较,并得出结论 43 必须在根节点 40 的右子树中。然而,根节点的左子树有 5 个节点(显然,它们都小于根节点),因此 43 的排名至少为 6(根节点的左子树的 5 个节点,加上根节点)。接下来,我们将 43 与 45 进行比较,并得出结论 43 必须在 45 的左边,因此排名保持为 5。最后,我们将 43 与 42 进行比较,并得出结论 43 必须在 42 的右子树中。排名必须增加 1,因此 43 的排名为 7。

那么,我们如何用算法概括这个例子呢?在这里,我们注意到,对于每个节点,我们已经知道了其左子树的排名。这不需要每次需要排名时都计算,因为这将非常低效。每次生成新元素并将其插入树中时,我们可以跟踪和更新左子树的排名。在前面的图中,每个节点都有其子树排名在节点上方突出显示。当需要节点的排名时,我们已经知道了其左子树的排名。接下来,我们必须考虑以下递归步骤,通过int getRank(Node node, int element)应用:

  1. 如果element等于node.element,则返回node.leftTreeSize

  2. 如果elementnode的左边,则返回getRank(node.left, element)

  3. 如果elementnode的右边,则返回node.leftTreeSize + 1 + getRank(node.right, element)

如果找不到给定的整数,则返回-1。相关代码如下:

public class Stream {
  private Node root = null;
  private class Node {
    private final int element;
    private int leftTreeSize;
    private Node left;
    private Node right;
    private Node(int element) {
      this.element = element;
      this.left = null;
      this.right = null;
    }     
  }
  /* add a new node into the tree */
  public void generate(int element) {
    if (root == null) {
      root = new Node(element);
    } else {
      insert(root, element);
    }
  }
  private void insert(Node node, int element) {
    if (element <= node.element) {
      if (node.left != null) {
        insert(node.left, element);
      } else {
        node.left = new Node(element);
      }
      node.leftTreeSize++;
    } else {
      if (node.right != null) {
        insert(node.right, element);
      } else {
        node.right = new Node(element);
      }
    }
  }
  /* return rank of 'element' */
  public int getRank(int element) {
    return getRank(root, element);
  }
  private int getRank(Node node, int element) {
    if (element == node.element) {
      return node.leftTreeSize;
    } else if (element < node.element) {
      if (node.left == null) {
        return -1;
      } else {
        return getRank(node.left, element);
      }
    } else {
      int rightTreeRank = node.right == null 
        ? -1 : getRank(node.right, element);
      if (rightTreeRank == -1) {
        return -1;
      } else {
        return node.leftTreeSize + 1 + rightTreeRank;
      }
    }
  }
}

前面的代码将在平衡树上以 O(log n)的时间运行,在不平衡树上以 O(n)的时间运行,其中n是树中的节点数。完整的应用程序称为RankInStream

编码挑战 15 - 山峰和山谷

亚马逊谷歌Adobe微软Flipkart

问题:假设你得到了一个表示地形高程的正整数数组。如果数组中的整数大于或等于其邻居(相邻整数),则称该整数为山峰。另一方面,如果数组中的整数小于或等于其邻居(相邻整数),则称该整数为山谷。例如,对于数组 4, 5, 8, 3, 2, 1, 7, 8, 5, 9,我们可以看到 8(两者)和 9 是山峰,而 4, 1 和 5(除了最后一个)是山谷。编写一小段代码,将给定的数组排序为交替的山峰和山谷序列。

解决方案:乍一看,一个方便的解决方案是从升序排序数组开始。一旦数组按l1 ≤ l2 ≤ l3 ≤ l4 ≤ l5 ...排序,我们可以将每个三元组看作large(l1)≤larger(l2)≤largest(l3)。如果我们交换l2l3,那么l1l3l2,所以l3变成了山峰。对于下一个三元组,l2l4l5,我们交换l4l5以获得l2l5l4,所以l5是一个山峰。对于下一个三元组,l4l6l7,我们交换l6l7以获得l4l7l6,所以l7是一个山峰。如果我们继续这些交换,那么我们会得到类似这样的结果:l1l3l2l5l4l7l6 .... 但这样有效吗?由于我们必须对数组进行排序,我们可以说这种解决方案的时间复杂度是 O(n log n)。我们能做得比这更好吗?是的,我们可以!假设我们将我们的数组表示如下:

图 14.29 - 给定的地形高程数组

图 14.29 - 给定的地形高程数组

现在,我们可以清楚地看到给定数组的山峰和山谷。如果我们关注第一个三元组(4, 5, 8)并尝试获得一个山峰,那么我们必须将中间值(5)与其邻居(相邻整数)的最大值交换。因此,通过将 5 与 max(4, 8)交换,我们得到(4, 8, 5)。因此,8 是一个山峰,可以表示如下:

图 14.30 - 用 5 交换 8

图 14.30 - 用 5 交换 8

接下来,让我们关注下一个三元组(5, 3, 2)。我们可以通过将 3 与 max(5, 2)交换来获得一个山峰,因此通过将 3 与 5 交换。结果是(3, 5, 2),如下所示:

图 14.31 - 用 3 交换 5

图 14.31 - 用 5 交换 3

现在,5 是一个山峰,3 是一个山谷。我们应该继续处理三元组(2, 1, 7)并交换 1 与 7 以获得山峰(2, 7, 1)。下一个三元组将是(1, 8, 5),并且 8 是一个山峰(没有东西可以交换)。最后,我们得到最终结果,如下图所示:

图 14.32 - 最终结果

图 14.32 - 最终结果

面试官希望你注意细节并提到它们。例如,当我们将中间值与左值交换时,我们是否可以破坏已经处理过的地形?我们能破坏山谷或山峰吗?答案是否定的,我们不能破坏任何东西。这是因为当我们将中间值与左值交换时,我们已经知道中间值小于左值,左值是一个山谷。因此,我们只是通过在那个位置添加一个更小的值来创建一个更深的山谷。

基于这些陈述,实现是相当简单的。以下代码将澄清任何剩下的细节:

public static void sort(int[] arr) {
  for (int i = 1; i < arr.length; i += 2) {
    int maxFoundIndex = maxElementIndex(arr, i - 1, i, i + 1);
    if (i != maxFoundIndex) {
      swap(arr, i, maxFoundIndex);
    }            
  }
}
private static int maxElementIndex(int[] arr, 
 int left, int middle, int right) {
  int arrLength = arr.length;
  int leftElement = left >= 0 && left < arrLength
    ? arr[left] : Integer.MIN_VALUE;
  int middleElement = middle >= 0 && middle < arrLength
    ? arr[middle] : Integer.MIN_VALUE;
  int rightElement = right >= 0 && right < arrLength
    ? arr[right] : Integer.MIN_VALUE;
  int maxElement = Math.max(leftElement,
    Math.max(middleElement, rightElement));
  if (leftElement == maxElement) {
    return left;
  } else if (middleElement == maxElement) {
    return middle;
  } else {
    return right;
  }
}

这段代码的时间复杂度为 O(n)。完整的应用程序称为PeaksAndValleys

编码挑战 16 - 最近的左边较小数

亚马逊谷歌Adobe微软Flipkart

问题:考虑到您已经得到了一个整数数组arr,编写一小段代码,找到并打印每个元素的最近较小数,使得较小的元素在左侧。

解决方案:让我们考虑给定的数组;即 4, 1, 8, 3, 8, 2, 6, 7, 4, 9。预期结果是 ,1,1,3,1,2,6,2,4。从左到右,我们有以下内容:

  • arr[0]=4,它的左边没有元素,所以我们打印 _。

  • arr[1]=1,它的左边没有比它更小的元素,所以我们打印 _。

  • arr[2]=8,它左边最近的较小元素是 1,所以我们打印 1。

  • arr[3]=3,它左边最近的较小元素是 1,所以我们打印 1。

  • arr[4]=8,它左边最近的较小元素是 3,所以我们打印 3。

  • arr[5]=2,它左边最近的较小元素是 1,所以我们打印 1。

  • arr[6]=6,它左边最近的较小元素是 2,所以我们打印 2。

  • arr[7]=7,它左边最近的较小元素是 6,所以我们打印 6。

  • arr[8]=4,它左边最近的较小元素是 2,所以我们打印 2。

  • arr[9]=9,它左边最近的较小元素是 4,所以我们打印 4。

一个简单但低效的解决方案依赖于两个循环。外循环可以从第二个元素(索引 1)开始,直到数组的长度(arr.length-1),而内循环遍历外循环选择的元素左侧的所有元素。一旦找到一个较小的元素,它就会停止这个过程。这样的算法很容易实现,但运行时间复杂度为 O(n2)。

然而,我们可以通过Stack将时间复杂度降低到 O(n)。主要是,我们可以从 0 到arr.length-1 遍历给定的数组,并依赖于Stack来跟踪到目前为止已经遍历的子序列元素,这些元素小于已经遍历的任何后续元素。虽然这个说法可能听起来很复杂,但让我们通过查看该算法的步骤来澄清一下:

  1. 创建一个新的空栈。

  2. 对于arr的每个元素(i = 0 到arr.length-1),我们执行以下操作:

a. 当栈不为空且顶部元素大于或等于arr[i]时,我们从栈中弹出。

b. 如果栈为空,则arr[i]的左边没有元素。我们可以打印一个表示没有找到元素的符号(例如,-1 或 _)。

c. 如果栈不为空,则arr[i]的最近较小值是栈的顶部元素。我们可以查看并打印这个元素。

d. 将arr[i]推入栈中。

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

public static void leftSmaller(int arr[]) {
  Stack<Integer> stack = new Stack<>();
  // While the top element of the stack is greater than 
  // equal to arr[i] remove it from the stack        
  for (int i = 0; i < arr.length; i++) {
    while (!stack.empty() && stack.peek() >= arr[i]) {
      stack.pop();
    }
    // if stack is empty there is no left smaller element
    if (stack.empty()) {
      System.out.print("_, ");
    } else {
      // the top of the stack is the left smaller element
      System.out.print(stack.peek() + ", ");
    }
    // push arr[i] into the stack
    stack.push(arr[i]);
  }
}

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

编码挑战 17 - 单词搜索

亚马逊谷歌

如果给定的单词在板上存在,则返回true。同一个字母单元格不能被多次使用。

解决方案:让我们考虑一下我们有以下的板:

图 14.33 - 板样本

图 14.33 - 板样本

请记住,这不是我们第一次需要解决需要在网格中找到某条路径的问题。在第八章**,递归和动态规划中,我们有机器人网格问题,包括彩色斑点五座塔下落的球骑士之旅。最后,在第十二章**,栈和队列中,我们有岛屿。最后,在第十三章**,树和图中,我们有国际象棋骑士

根据您从这些问题中积累的经验,挑战自己在没有进一步指示的情况下为这个问题编写一个实现。完整的应用程序称为WordSearch。如果k是给定单词的长度,而板的大小为m x n,那么此应用程序的运行时间为 O(m * n * 4k)。

编码挑战 18 - 根据另一个数组对数组进行排序

亚马逊谷歌微软

问题:假设你已经得到了两个数组。编写一小段代码,根据第二个数组定义的顺序重新排列第一个数组的元素。

解决方案:假设我们已经得到了以下两个数组:

int[] firstArr = {4, 1, 8, 1, 3, 8, 6, 7, 4, 9, 8, 2, 5, 3};
int[] secondArr = {7, 4, 8, 11, 2};

预期结果是{7, 4, 4, 8, 8, 8, 2, 1, 1, 3, 3, 5, 6, 9}。

这个问题的解决方案依赖于哈希。更确切地说,我们可以采用以下算法:

  1. 计算并存储映射中来自第一个数组的每个元素的频率。

  2. 对于第二个数组的每个元素,检查当前元素是否存在于映射中。

然后,执行以下操作:

a. 如果是这样,那么在第一个数组中设置n次(n是第二个数组中当前元素在第一个数组中的频率)。

b. 从映射中删除当前元素,这样最终映射中将只包含在第一个数组中存在但不在第二个数组中的元素。

  1. 将映射中的元素追加到第一个数组的末尾(这些元素已经排序,因为我们使用了TreeSet)。

让我们看看代码:

public static void custom(int[] firstArr, int[] secondArr) {
  // store the frequency of each element of first array
  // using a TreeMap stores the data sorted
  Map<Integer, Integer> frequencyMap = new TreeMap<>();
  for (int i = 0; i < firstArr.length; i++) {
    frequencyMap.putIfAbsent(firstArr[i], 0);
    frequencyMap.put(firstArr[i],   
          frequencyMap.get(firstArr[i]) + 1);
  }
  // overwrite elements of first array
  int index = 0;
  for (int i = 0; i < secondArr.length; i++) {
    // if the current element is present in the 'frequencyMap'
    // then set it n times (n is the frequency of 
    // that element in the first array)
    int n = frequencyMap.getOrDefault(secondArr[i], 0);
    while (n-- > 0) {
      firstArr[index++] = secondArr[i];
    }
    // remove the element from map
    frequencyMap.remove(secondArr[i]);
  }
  // copy the remaining elements (the elements that are
  // present in the first array but not present 
  // in the second array)        
  for (Map.Entry<Integer, Integer> entry :
        frequencyMap.entrySet()) {
    int count = entry.getValue();
    while (count-- > 0) {
      firstArr[index++] = entry.getKey();
    }
  }
}

这段代码的运行时间是 O(m log m + n),其中m是第一个数组中的元素数量,n是第二个数组中的元素数量。完整的应用程序称为SortArrayBasedOnAnotherArray

好了,这是本章的最后一个问题。现在,是时候总结我们的工作了!

总结

这是一个全面涵盖了排序和搜索算法的章节。您看到了归并排序、快速排序、基数排序、堆排序、桶排序和二分搜索的实现。此外,在本书附带的代码中,还有一个名为SortArraysIn14Ways的应用程序,其中包含了 14 种排序算法的实现。

在下一章中,我们将涵盖一系列被归类为数学和谜题问题的问题。

第十五章:数学和谜题

本章涵盖了一个在面试中经常遇到的有争议的话题:数学和谜题问题。许多公司认为这类问题不应该成为技术面试的一部分,而其他公司仍然认为这个话题是相关的。

这个话题包括的问题是令人费解的,可能需要相当高的数学和逻辑知识。如果你计划申请在学术领域工作的公司(数学、物理、化学等),你应该期待这样的问题。然而,亚马逊和谷歌等大公司也愿意依赖这类问题。

在本章中,我们将涵盖以下主题:

  • 提示和建议

  • 编码挑战

在本章结束时,你应该熟悉这类问题,并能够探索更多类似的问题。

技术要求

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

提示和建议

当你遇到一个脑筋急转弯的问题时,最重要的是不要惊慌。多次阅读问题,并以系统化的方式写下你的结论是必不可少的。必须清楚地确定它应该遵守的输入、输出和约束条件。

尝试举几个例子(输入数据样本),画一些草图,并在分析问题时与面试官保持交流。面试官并不希望你立即得出解决方案,但他们期望听到你在尝试解决问题时的思考过程。这样,面试官可以追踪你的想法逻辑,并了解你解决问题的方式。

此外,非常重要的是写下你在解决问题时注意到的任何规则或模式。每写下一个陈述,你离解决方案更近一步。通常,如果你从解决方案的角度来看(你知道解决方案),这些问题并不是非常困难;它们只是需要高度的观察和更高的注意力。

让我们来试一个简单的例子。两个父亲和两个儿子坐下来吃鸡蛋。他们一共吃了三个鸡蛋;每个人都有一个鸡蛋。这怎么可能?

如果这是你第一次遇到这样的问题,你可能会认为这是不合逻辑或不可能解决的。认为文本中有错误(可能是四个鸡蛋,而不是三个)并一遍又一遍地阅读是正常的。这些是对脑筋急转弯问题最常见的反应。一旦你看到解决方案,它看起来就很简单了。

现在,让我们假设自己是面试官面前的候选人。以下段落采用了大声思考的方法。

如果每个人都有一个鸡蛋,而有三个鸡蛋,那么显然有一个人没有鸡蛋。所以,你可能会认为答案是三个人吃了一个鸡蛋(每个人都吃了一个鸡蛋),而第四个人什么都没吃。但问题说两个父亲和两个儿子坐下来吃鸡蛋,所以他们四个人都吃了鸡蛋。

试想一下:每个人都有一个鸡蛋,他们(四个人)一共吃了三个鸡蛋,所以问题并没有说每个人了一个鸡蛋;他们只是一个鸡蛋。也许其中一个人把自己的鸡蛋分享给了另一个人。嗯,这似乎不太合乎逻辑!

可能只有三个人吗?如果其中一个父亲也是一个祖父,这意味着另一个父亲同时也是一个儿子和父亲。这样,通过三个人,我们有两个父亲和两个儿子。他们吃了三个鸡蛋,每个人都有一个鸡蛋。问题解决了!

正如你所看到的,解决方案是通过一系列推理的结果,逐个排除错误的解决方案而得到的。试图通过逻辑推理排除错误的解决方案来解决问题是解决这类问题的方法之一。其他问题只是关于计算。大多数时候,没有复杂的计算或大量的计算,但它们需要数学知识和/或推理。

很难断言有一些技巧和提示可以帮助你在几秒钟内解决数学和逻辑谜题问题。最好的方法是尽可能多地练习。有了这个,让我们继续进行编码挑战部分。

编码挑战

在接下来的 15 个编码挑战中,我们将专注于数学和逻辑谜题类别中最受欢迎的问题。让我们开始吧!

编码挑战 1 - FizzBuzz

AdobeMicrosoft

问题:考虑到你已经得到了一个正整数n。编写一个问题,打印从 1 到n的数字。对于 5 的倍数,打印fizz,对于 7 的倍数,打印buzz,对于 5 和 7 的倍数,打印fizzbuzz。在每个字符串或数字后打印一个新行。

解决方案:这是一个简单的问题,依赖于你对除法和 Java 取模(%)运算符的了解。当我们除两个数,被除数和除数,我们得到一个商和一个余数。在 Java 中,我们可以通过取模(%)运算符获得除法的余数。换句话说,如果X是被除数,Y是除数,那么XY(在 Java 中写作X % Y)返回X除以Y的余数。例如,11(被除数) / 2(除数) = 5(商) 1(余数),所以 11 % 2 = 1。

换句话说,如果余数为 0,则被除数是除数的倍数;否则,它不是。因此,五的倍数必须满足X % 5 = 0,而七的倍数必须满足X % 7 = 0。基于这些关系,我们可以将这个问题的解决方案写成如下形式:

public static void print(int n) {
  for (int i = 1; i <= n; i++) {
    if (((i % 5) == 0) && ((i % 7) == 0)) { // multiple of 5&7            
      System.out.println("fizzbuzz");
    } else if ((i % 5) == 0) { // multiple of 5            
      System.out.println("fizz");
    } else if ((i % 7) == 0) { // multiple of 7            
      System.out.println("buzz");
    } else {
      System.out.println(i); // not a multiple of 5 or 7
    }
  }
}

完整的应用程序称为FizzBuzz

编码挑战 2 - 罗马数字

AmazonGoogleAdobeMicrosoftFlipkart

问题:考虑到你已经得到了一个正整数n。编写一小段代码,将这个数字转换成它的罗马数字表示。例如,如果n=34,那么罗马数字就是 XXXIV。你已经得到了以下包含罗马数字符号的常量:

图 15.1 - 罗马数字

图 15.1 - 罗马数字

解决方案:这个问题依赖于罗马数字是常识。如果你从来没有听说过罗马数字,那么最好向面试官提到这一点。他们可能会同意给你另一个编码挑战来代替这个。但如果你知道罗马数字是什么,那太好了 - 让我们看看如何编写一个解决这个问题的应用程序。

这个问题的算法可以从几个例子中推导出来。让我们看几个用例:

  • n = 73 = 50+10+10+1+1+1 = L+X+X+I+I+I = LXXIII

  • n = 558 = 500+50+5+1+1+1 = D+L+V+I+I+I = DLVIII

  • n = 145 = 100+(50-10)+5 = C+(L-X)+V = C+XL+V = CXLV

  • n = 34 = 10+10+10+(5-1) = X+X+X+(V-I) = X+X+X+IV = XXXIV

  • n = 49 = (50-10)+(10-1) = (L-X)+(X-I) = XL+IX = XLIX

大致上,我们拿到给定的数字,然后尝试找到对应于个位、十位、百位或千位的罗马符号。这个算法可以表达如下:

  1. 从千位开始并打印相应的罗马数字。例如,如果千位上的数字是 4,则打印 4000 的罗马数字等价物,即 MMMM。

  2. 继续通过使用百位数字分割数字并打印相应的罗马数字。

  3. 继续通过使用十位数字分割数字并打印相应的罗马数字。

  4. 继续通过使用个位数对数字进行除法,并打印相应的罗马数字。

在代码方面,这个算法的工作原理如下:

private static final String HUNDREDTHS[]
 = {"", "C", "CC", "CCC", "CD", "D", 
    "DC", "DCC", "DCCC", "CM"};
private static final String TENS[]
 = {"", "X", "XX", "XXX", 
    "XL", "L", "LX", "LXX", "LXXX", "XC"};
private static final String ONES[]
 = {"", "I", "II", "III", "IV", "V", 
    "VI", "VII", "VIII", "IX"};
public static String convert(int n) {
  String roman = "";
  // Step 1
  while (n >= 1000) {
    roman = roman + 'M';
    n -= 1000;
  }
  // Step 2
  roman = roman + HUNDREDTHS[n / 100];
  n = n % 100;
  // Step 3
  roman = roman + TENS[n / 10];
  n = n % 10;
  // Step 4
  roman = roman + ONES[n];
  return roman;
}

完整的应用程序称为RomanNumbers。另一种方法依赖于连续的减法而不是除法。RomanNumbers应用程序也包含了这种实现。

编码挑战 3 - 访问和切换 100 扇门

AdobeMicrosoftFlipkart

问题:假设你有 100 扇门,它们最初都是关闭的。你必须访问这些门 100 次,每次都从第一扇门开始。对于每个访问的门,你都要切换它(如果它关闭,则打开它,反之亦然)。在第一次访问时,你访问所有 100 扇门。在第二次访问时,你访问每第二扇门(#2、#4、#6……)。在第三次访问时,你访问每第三扇门(#3、#6、#9……)。你按照这个模式一直到只访问第 100 扇门。编写一小段代码,揭示 100 次访问后门的状态(关闭或打开)。

解决方案:通过遍历几步可以直观地解决这个问题。在初始状态下,所有 100 扇门都是关闭的(在下图中,每个 0 都是关闭的门,每个 1 都是打开的门):

图 15.2 - 所有门都关闭(初始状态)

图 15.2 - 所有门都关闭(初始状态)

现在,让我们看看在以下每个步骤中我们能观察和得出什么结论:

在第一次访问时,我们打开每扇门(我们访问每扇门,#1、#2、#3、#4、…,#100):

图 15.3 - 所有门都打开(步骤 1)

图 15.3 - 所有门都打开(步骤 1)

在第二次访问时,我们只访问偶数门(#2、#4、#6、#8、#10、#12……),所以偶数门关闭,奇数门打开:

图 15.4 - 偶数门关闭,奇数门打开(步骤 2)

图 15.4 - 偶数门关闭,奇数门打开(步骤 2)

在第三次访问时,我们只访问门#3、#6、#9、#12……这次,我们关闭了第一次访问时打开的门#3,打开了第二次访问时关闭的门#6,依此类推:

图 15.5 - 应用第三次访问后的结果(步骤 3)

图 15.5 - 应用第三次访问后的结果(步骤 3)

在第四次访问时,我们只访问门#4、#8、#12……如果我们继续这样做,那么在第 100 次访问时,我们将得到以下结果:

图 15.6 - 所有打开的门都是完全平方数(最后一次访问)

图 15.6 - 所有打开的门都是完全平方数(最后一次访问)

因此,在最后一次访问(第 100 次访问)时,所有打开的门都是完全平方数,而其余的门都是关闭的。显然,即使我们观察到这一点,在面试中我们也没有足够的时间来遍历 100 次访问。但也许我们甚至不需要做所有 100 次访问来观察这个结果。让我们假设我们只做 15 步,然后我们试图看看某扇门发生了什么。例如,以下图像显示了门#12 在 15 步中的状态:

图 15.7 - 第 15 步后的门#12

图 15.7 - 第 15 步后的门#12

检查前面图像中突出显示的步骤。门#12 的状态在步骤 1、2、3、4、612发生了变化。所有这些步骤都是 12 的约数。此外,步骤 1打开门,步骤 2关闭门,步骤 3打开门,步骤 4关闭门,步骤 6打开门,步骤 12关闭门。从这个观察开始,我们可以得出结论,对于每一对约数,门最终都会回到初始状态,即关闭状态。换句话说,每个具有偶数个约数的门最终都会关闭。

让我们看看这是否对于一个完全平方数来说是正确的,比如 9。选择完全平方数的原因在于完全平方数总是有奇数个正因子。例如,9 的因子是 1、3 和 9。这意味着门#9 保持打开状态。

根据这两段文字,我们可以得出结论,经过 100 次访问后,保持打开状态的门是那些完全平方数(#1,#4,#9,#16,...,#100),而其余的门保持关闭状态。

一旦你理解了前面的过程,编写一个确认最终结果的应用程序就非常简单了:

private static final int DOORS = 100;
public static int[] visitToggle() {
  // 0 - closed door
  // 1 - opened door     
  int[] doors = new int[DOORS];
  for (int i = 0; i <= (DOORS - 1); i++) {
    doors[i] = 0;
  }
  for (int i = 0; i <= (DOORS - 1); i++) {
    for (int j = 0; j <= (DOORS - 1); j++) {
      if ((j + 1) % (i + 1) == 0) {
        if (doors[j] == 0) {
          doors[j] = 1;
        } else {
          doors[j] = 0;
        }
      }
    }            
  }
  return doors;
}

完整的应用程序称为VisitToggle100Doors

编码挑战 4 - 8 支队伍

亚马逊谷歌Adobe

问题:考虑有一个比赛,有 8 支队伍。每支队伍与其他队伍比赛两次。从所有这些队伍中,只有 4 支队伍进入半决赛。一支队伍要赢得多少场比赛才能进入半决赛?

解决方案:让我们将队伍标记为 T1、T2、T3、T4、T5、T6、T7 和 T8。如果 T1 与 T2...T8 比赛,他们将进行 7 场比赛。由于每个队伍必须与其他队伍比赛两次,所以我们有 8*7=56 场比赛。如果每场比赛中一支队伍可以赢得一分,那么我们有 56 分分配给 8 支队伍。

让我们考虑最坏的情况。T0 输掉了所有比赛。这意味着 T0 得到 0 分。另一方面,T1 对 T0 赢得了 2 分,并输掉了所有其他比赛,T2 对 T0 和 T1 赢得了 4 分,并输掉了所有其他比赛,T3 对 T0、T1 和 T2 赢得了 6 分,并输掉了所有其他比赛,依此类推。T4 赢得了 8 分,T5 赢得了 10 分,T6 赢得了 12 分,T7 赢得了 14 分。因此,一支赢得所有比赛的队伍赢得了 14 分。最后四支队伍(进入半决赛的队伍)赢得了 8+10+12+14=44 分。因此,一支队伍可以确保他们进入半决赛,如果他们获得至少 44/4=11 分。

编码挑战 5 - 找到具有质因数 3、5 和 7 的第 k 个数字

Adobe微软

问题:设计一个算法,找到唯一的质因数是 3、5 和 7 的第 k 个数字。

解决方案:拥有一组数字,其唯一的质因数是 3、5 和 7,意味着一组看起来如下:1、3、5、7、9、15、21、25 等等。或者,更具启发性地,可以写成:1、13、15、17、33、35、37、55、333、57、335、7*7 等等。

通过这种具有启发性的表示,我们可以看到我们可以最初将值 1 插入列表中,而其余的元素必须计算出来。理解确定其余元素的算法最简单的方法是看实现本身,所以让我们来看看:

public static int kth(int k) {
  int count3 = 0;
  int count5 = 0;
  int count7 = 0;
  List<Integer> list = new ArrayList<>();
  list.add(1);
  while (list.size() <= k + 1) {
    int m = min(min(list.get(count3) * 3, 
      list.get(count5) * 5), list.get(count7) * 7);
    list.add(m);
    if (m == list.get(count3) * 3) {
      count3++;
    }
    if (m == list.get(count5) * 5) {
      count5++;
    }
    if (m == list.get(count7) * 7) {
      count7++;
    }
  }
  return list.get(k - 1);
}

我们也可以通过三个队列来实现。该算法的步骤如下:

  1. 初始化一个整数minElem=1。

  2. 初始化三个队列;即queue3queue5queue7

  3. 从 1 到给定的k-1 进行循环:

a. 将minElem**3、minElem5 和*minElem7 分别插入queue3queue5queue7

b. 更新minElem为 min(queue3.peek, queue5.peek, queue7.peek)。

c. 如果minElemqueue3.peek,则执行queue3.poll。

d. 如果minElemqueue5.peek,则执行queue5.poll。

e. 如果minElemqueue7.peek,则执行queue7.poll。

  1. 返回minElem

完整的应用程序称为KthNumber357。它包含了本节中提出的两种解决方案。

编码挑战 6 - 计算解码数字序列

亚马逊微软Flipkart

问题:假设A是 1,B是 2,C是 3,... Z是 26。对于任何给定的数字序列,编写一小段代码来计算可能的解码数量(例如,1234 可以解码为 1 2 3 4,12 3 4 和 1 23 4,也就是 ABCD、LCD 和 AWD)。如果给定的数字序列包含从 0 到 9 的数字,则它是有效的。不允许前导 0,不允许额外的尾随 0,也不允许连续出现两个或更多个 0。

解决方案:这个问题可以通过递归或动态规划来解决。这两种技术都在第八章**,递归和动态规划中讨论过。因此,让我们看看一个 n 位数字序列的递归算法:

  1. 将解码的总数初始化为 0。

  2. 从给定数字序列的末尾开始。

  3. 如果最后一位不是 0,则对(n-1)位数字应用递归,并使用结果更新解码的总数。

  4. 如果最后两位数字表示的数字小于 27(因此是有效字符),则对(n-2)位数字应用递归,并使用结果更新解码的总数。

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

public static int decoding(char[] digits, int n) {
  // base cases 
  if (n == 0 || n == 1) {
    return 1;
  }
  // if the digits[] starts with 0 (for example, '0212')
  if (digits == null || digits[0] == '0') {
    return 0;
  }
  int count = 0;
  // If the last digit is not 0 then last 
  // digit must add to the number of words 
  if (digits[n - 1] > '0') {
    count = decoding(digits, n - 1);
  }
  // If the last two digits represents a number smaller 
  // than or equal to 26 then consider last two digits 
  // and call decoding()
  if (digits[n - 2] == '1'
      || (digits[n - 2] == '2' && digits[n - 1] < '7')) {
    count += decoding(digits, n - 2);
  }
  return count;
}

这段代码运行时间是指数级的。但是我们可以应用动态规划,通过类似的非递归算法将运行时间降低到 O(n),具体如下:

public static int decoding(char digits[]) {
  // if the digits[] starts with 0 (for example, '0212')
  if (digits == null || digits[0] == '0') {
    return 0;
  }
  int n = digits.length;
  // store results of sub-problems 
  int count[] = new int[n + 1];
  count[0] = 1;
  count[1] = 1;
  for (int i = 2; i <= n; i++) {
    count[i] = 0;
    // If the last digit is not 0 then last digit must 
    // add to the number of words 
    if (digits[i - 1] > '0') {
      count[i] = count[i - 1];
    }
    // If the second last digit is smaller than 2 and 
    // the last digit is smaller than 7, then last 
    // two digits represent a valid character 
    if (digits[i - 2] == '1' || (digits[i - 2] == '2' 
          && digits[i - 1] < '7')) {
      count[i] += count[i - 2];
    }
  }
  return count[n];
}

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

编程挑战 7 – ABCD

问题:找到一种类型的数字 ABCD,使得乘以 4 后得到 DCBA。

解决方案:这类问题通常相当难。在这种情况下,我们必须使用一些数学来解决它。

让我们从一些简单的不等式开始:

  • 1 <= A <= 9(A 不能为零,因为 ABCD 是一个四位数)

  • 0 <= B <= 9

  • 0 <= C <= 9

  • 4 <= D <= 9(D 必须至少为 4*A,所以至少应为 4)

接下来,我们可以假设我们的数字 ABCD 被写成 1000A + 100B + 10C + D。根据问题描述,我们可以将 ABCD 乘以 4 得到 DCBA,可以写成 1000D + 100C + 10B + A。

符合 4 的整除性,BA 是一个可以被 4 整除的两位数。现在,较大的 ABCD 是 2499,因为大于 2499 的数乘以 4 将得到一个五位数。

接下来,A 可以是 1 和 2。然而,如果 BA 是一个可以被 4 整除的两位数,那么 A 必须是偶数,所以必须是 2。

继续这种逻辑,这意味着 D 要么是 8,要么是 9。然而,由于 D 乘以 4 会以 2 结尾,所以 D 必须是 8。

此外,4000A + 400B + 40C + 4D = 1000D + 100C + 10B + A。由于 A=2 和 D=8,这可以写成 2C-13B=1。B 和 C 只能是 [1, 7] 范围内的个位整数,但由于 BA 是一个可以被 4 整除的两位数,B 必须是奇数。由于最大可能的数字是 2499,这意味着 B 可以是 1 或 3。

因此,结果是 2178,因为 21784=8712,所以 ABCD4=DCBA。

我们也可以使用蛮力方法来找到这个数字。以下代码说明了这一点:

public static void find() {
  for (int i = 1000; i < 2499; i++) {
    int p = i;
    int q = i * 4;
    String m = String.valueOf(p);
    String n = new StringBuilder(String.valueOf(q))
      .reverse().toString();
    p = Integer.parseInt(m);
    q = Integer.parseInt(n);
    if (p == q) {
      System.out.println("\n\nFound: " + p + " : " + (q * 4));
      break;
    }
  }
}

完整的应用程序称为 Abcd

编程挑战 8 – 重叠的矩形

亚马逊谷歌微软

true 如果这些矩形重叠(也称为相交)。

解决方案:这个问题听起来有点模糊。重要的是要与面试官讨论并就两个重要方面达成一致:

这两个矩形是平行的,并且与水平面成 0 度角(它们与坐标轴平行),或者它们可以在一个角度下旋转吗?

大多数情况下,给定的矩形是平行的,并且与坐标轴平行。如果涉及旋转,那么解决方案需要一些几何知识,这在面试中并不那么明显。面试官很可能是想测试你的逻辑,而不是你的几何知识,但是挑战自己,为非平行矩形实现问题。

矩形的坐标是在笛卡尔平面上给出的吗? 答案应该是肯定的,因为这是数学中常用的坐标系。这意味着一个矩形从左到右,从下到上增加大小。

因此,让我们将矩形表示为r1r2。它们每个都是通过左上角和右下角的坐标给出的。r1的左上角的坐标为r1lt.xr1lt.y,而右下角的坐标为r2rb.xr2rb.y,如下图所示:

图 15.8-矩形坐标

图 15.8-矩形坐标

我们可以说,如果两个矩形接触(至少有一个公共点),它们就是重叠的。换句话说,在下图中显示的五对矩形中,有重叠:

图 15.9-重叠的矩形

图 15.9-重叠的矩形

从前面的图表中,我们可以得出两个不重叠的矩形可能处于以下四种情况之一:

  • r1完全在r2的右边。

  • r1完全在r2的左边。

  • r1完全在r2的上方。

  • r1完全在r2下方。

以下图表显示了这四种情况:

图 15.10-不重叠的矩形

图 15.10-不重叠的矩形

我们可以用坐标表示前面的四个项目,如下所示:

  • r1完全在r2的右边→r1lt.x>r2rb.x

  • r1完全在r2的左边→r2lt.x>r1rb.x

  • r1完全在r2上方→r1rb.y>r2lt.y

  • r1完全在r2下方→r2rb.y>r1lt.y

因此,如果我们将这些条件分组到代码中,我们得到以下结果:

public static boolean overlap(Point r1lt, Point r1rb, 
        Point r2lt, Point r2rb) {
  // r1 is totally to the right of r2 or vice versa
  if (r1lt.x > r2rb.x || r2lt.x > r1rb.x) {
    return false;
  }
  // r1 is totally above r2 or vice versa
  if (r1rb.y > r2lt.y || r2rb.y > r1lt.y) {
    return false;
  }
  return true;
}

这段代码运行时间为 O(1)。或者,我们可以将这两个条件合并为一个条件,如下所示:

public static boolean overlap(Point r1lt, Point r1rb, 
        Point r2lt, Point r2rb) {
  return (r1lt.x <= r2rb.x && r1rb.x >= r2lt.x
           && r1lt.y >= r2rb.y && r1rb.y <= r2lt.y);
}

完整的应用程序称为RectangleOverlap。请注意,面试官可能以不同的方式定义重叠。根据这个问题,你应该能够相应地调整代码。

编码挑战 9-乘以大数

亚马逊微软

整数或长整数域。编写一个计算ab*的代码片段。

解决方案:让我们假设a=4145775 和b=771467。然后,ab=3198328601925。解决这个问题依赖于数学。以下图像描述了可以在纸上应用并编码的ab解决方案:

图 15.11-两个大数相乘

图 15.11-两个大数相乘

主要是,我们依赖于乘法可以写成一系列加法的事实。因此,我们可以将 771467 写成 7+60+400+1000+70000+700000,然后我们将这些数字中的每一个与 4145775 相乘。最后,我们将结果相加以获得最终结果 3198328601925。进一步推理,我们可以取第一个数字的最后一位(5)并将其乘以第二个数字的所有数字(7,6,4,1,7,7)。然后,我们取第一个数字的第二位(7)并将其乘以第二个数字的所有数字(7,6,4,1,7,7)。然后,我们取第一个数字的第三位(7)并将其乘以第二个数字的所有数字(7,6,4,1,7,7)。我们继续这个过程,直到我们将第一个数字的所有数字乘以第二个数字的所有数字。在添加结果时,我们声明tth 乘法移位。

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

public static String multiply(String a, String b) {
  int lenA = a.length();
  int lenB = b.length();
  if (lenA == 0 || lenB == 0) {
    return "0";
  }
  // the result of multiplication is stored in reverse order 
  int c[] = new int[lenA + lenB];
  // indexes to find positions in result
  int idx1 = 0;
  int idx2 = 0;
  // loop 'a' right to left
  for (int i = lenA - 1; i >= 0; i--) {
    int carry = 0;
    int n1 = a.charAt(i) - '0';
    // used to shift position to left after every 
    // multiplication of a digit in 'b' 
    idx2 = 0;
    // loop 'b' from right to left
    for (int j = lenB - 1; j >= 0; j--) {
      // current digit of second number 
      int n2 = b.charAt(j) - '0';
      // multiply with current digit of first number 
      int sum = n1 * n2 + c[idx1 + idx2] + carry;
      // carry of the next iteration
      carry = sum / 10;
      c[idx1 + idx2] = sum % 10;
      idx2++;
    }
    // store carry 
    if (carry > 0) {
      c[idx1 + idx2] += carry;
    }
    // shift position to left after every 
    // multiplication of a digit in 'a' 
    idx1++;
  }
  // ignore '0's from the right 
  int i = c.length - 1;
  while (i >= 0 && c[i] == 0) {
    i--;
  }
  // If all were '0's - means either both or 
  // one of 'a' or 'b' were '0' 
  if (i == -1) {
    return "0";
  }
  String result = "";
  while (i >= 0) {
    result += (c[i--]);
  }
  return result;
}

完整的应用程序称为MultiplyLargeNumbers

编码挑战 10-具有相同数字的下一个最大数字

亚马逊谷歌微软

问题:考虑到你已经得到了一个正整数。编写一个返回具有相同数字的下一个最大数字的代码片段。

解决方案:通过几个示例可以观察到这个问题的解决方案。让我们考虑以下示例:

  • 示例 1:6→不可能

  • 示例 2:1234→1243

  • 示例 3:1232→1322

  • 示例 4:321→不可能

  • 示例 5:621873→623178

从前面的例子中,我们可以直觉到解决方案可以通过重新排列给定数字的数字来获得。因此,如果我们可以找到交换数字的规则集,使我们得到要搜索的数字,那么我们可以尝试实现。

让我们尝试几个观察:

  • 从示例 1 和 4 可以看出,如果给定数字的数字是降序的,那么不可能找到更大的数字。每次交换都会导致更小的数字。

  • 从示例 2 可以看出,如果给定数字的数字是按升序排列的,那么具有相同数字的下一个更大数字可以通过交换最后两个数字来获得。

  • 从示例 3 和 5 可以看出,我们需要找到所有更大数字中的最小数字。为此,我们必须从最右边处理数字。以下算法阐明了这一说法。

基于这三点观察,我们可以详细说明以下算法,该算法已在数字 621873 上进行了示例:

  1. 我们从最右边的数字开始逐个遍历数字。我们一直遍历,直到找到一个比先前遍历的数字小的数字。例如,如果给定的数字是 621873,那么我们遍历到 621873 中的数字 1。数字 1 是第一个比先前遍历的数字 8 小的数字。

  2. 接下来,我们关注我们在步骤 1 中找到的数字右侧的数字。我们想在这些数字中找到最小的数字(我们将其表示为t)。由于这些数字按降序排列,最小的数字在最后位置。例如,3 是 1 右侧数字中最小的数字,621873

  3. 我们交换这两个数字(1 和 3),我们得到 623871

  4. 最后,我们将所有数字按升序排列到t的右侧。但是由于我们知道t右侧的所有数字都是按降序排列的,除了最后一个数字,我们可以应用线性反转。这意味着结果是 623178。这就是要搜索的数字。

这个算法可以很容易地实现,如下所示:

public static void findNextGreater(int arr[]) {
  int min = -1;
  int len = arr.length;
  int prevDigit = arr[arr.length - 1];
  int currentDigit;
  // Step 1: Start from the rightmost digit and find the 
  // first digit that is smaller than the digit next to it. 
  for (int i = len - 2; i >= 0; i--) {
    currentDigit = arr[i];
    if (currentDigit < prevDigit) {
      min = i;
      break;
    }
  }
  // If 'min' is -1 then there is no such digit. 
  // This means that the digits are in descending order. 
  // There is no greater number with same set of digits 
  // as the given one.
  if (min == -1) {
    System.out.println("There is no greater number with "
     + "same set of digits as the given one.");
  } else {
    // Steps 2 and 3: Swap 'min' with 'len-1'
    swap(arr, min, len - 1);
    // Step 4: Sort in ascending order all the digits 
    // to the right side of the swapped 'len-1'
    reverse(arr, min + 1, len - 1);
    // print the result
    System.out.print("The next greater number is: ");
    for (int i : arr) {
      System.out.print(i);
    }
  }
}
private static void reverse(int[] arr, int start, int end) {
  while (start < end) {
    swap(arr, start, end);
    start++;
    end--;
  }
}
private static void swap(int[] arr, int i, int j) {
  int aux = arr[i];
  arr[i] = arr[j];
  arr[j] = aux;
}

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

编码挑战 11 - 数字可被其数字整除

亚马逊谷歌Adobe微软

如果给定数字可以被其数字整除,则返回true

true,因为 412 可以被 2、1 和 4 整除。另一方面,如果n=143,那么输出应该是false,因为 143 不能被 3 和 4 整除。

如果你认为这个问题很简单,那么你是完全正确的。这些问题被用作热身问题,并且有助于快速筛选出很多候选人。大多数情况下,你应该在规定的时间内解决它(例如,2-3 分钟)。

重要说明

建议对待这些简单的问题与对待任何其他问题一样认真。一个小错误可能会让你提前退出比赛。

因此,对于这个问题,算法包括以下步骤:

  1. 获取给定数字的所有数字。

  2. 对于每个数字,检查给定数字 %数字是否为 0(这意味着可被整除)。

  3. 如果其中任何一个不为零,则返回false

  4. 如果对于所有数字,给定数字%数字都是 0,则返回true

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

public static boolean isDivisible(int n) {
  int t = n;
  while (n > 0) {
    int k = n % 10;
    if (k != 0 && t % k != 0) {
      return false;
    }
    n /= 10;
  }
  return true;
}

完整的应用程序称为NumberDivisibleDigits

编码挑战 12 - 打破巧克力

亚马逊谷歌Adobe微软Flipkart

问题:考虑到你已经得到了尺寸为宽度 x 高度的矩形巧克力条和一些瓷砖。通常情况下,巧克力由许多小瓷砖组成,因此宽度高度给出了我们的瓷砖数量(例如,巧克力尺寸为 4 x 3,包含 12 块瓷砖)。编写一小段代码,计算我们需要对给定的巧克力施加多少次断裂(切割)才能获得具有完全所需数量的瓷砖的一块。您可以通过单个垂直或水平断裂(切割)将给定的巧克力切成两个矩形块。

解决方案:让我们考虑以下图像中显示的巧克力(一个 3 x 6 的巧克力条,有 18 块瓷砖):

图 15.12 – 一个 3 x 6 巧克力条

图 15.12 – 一个 3 x 6 巧克力条

前面的图像显示了七种情况,可以带我们找到解决方案,如下:

  • 情况 1、2 和 3:如果给定瓷砖的数量大于 3 x 6 或者我们无法将瓷砖与巧克力的宽度高度排列在一起,则无法获得解决方案。对于无解,我们返回-1。

  • 情况 4:如果给定瓷砖的数量等于 3 x 6 = 18,则这就是解决方案,所以我们不需要切割。我们将返回 0。

  • 情况 5:如果给定瓷砖的数量可以与巧克力条的宽度排列在一起,则只需要一次切割。我们将返回 1。

  • 情况 6:如果给定瓷砖的数量可以与巧克力条的高度排列在一起,则只需要一次切割。我们将返回 1。

  • 情况 7:在所有其他情况下,我们需要 2 次切割。我们将返回 2。

让我们看看代码:

public static int breakit(int width, int height, int nTiles) {
  if (width <= 0 || height <= 0 || nTiles <= 0) {
    return -1;
  }
  // case 1
  if (width * height < nTiles) {
    return -1;
  }
  // case 4
  if (width * height == nTiles) {
    return 0;
  } 
  // cases 5 and 6
  if ((nTiles % width == 0 && (nTiles / width) < height)
     || (nTiles % height == 0 && (nTiles / height) < width)) {
    return 1;
  }
  // case 7
  for (int i = 1; i <= Math.sqrt(nTiles); i++) {
    if (nTiles % i == 0) {
      int a = i;
      int b = nTiles / i;
      if ((a <= width && b <= height)
          || (a <= height && b <= width)) {
        return 2;
      }
    }
  }
  // cases 2 and 3
  return -1;
}

完整的应用程序称为BreakChocolate

编码挑战 13 – 时钟角度

谷歌微软

问题:考虑到你已经以h:m格式给出时间。编写一小段代码,计算模拟时钟上时针和分针之间的较短角度。

解决方案:从一开始,我们必须考虑几个公式,这些公式将帮助我们得出解决方案。

首先,时钟被分成 12 个相等的小时(或 12 个相等的部分),因为它是一个完整的圆,所以有 360o。因此,1 小时有 360o/12 = 30o。因此,在 1:00 时,时针与分针形成 300 的角度。在 2:00 时,时针与分针形成 60o 的角度,依此类推。以下图像阐明了这一方面:

图 15.13 – 12 小时的 360 度分割

图 15.13 – 12 小时的 360 度分割

进一步推理,1 小时有 60 分钟和 30o,因此 1 分钟有 30/60 = 0.5o。因此,如果我们只参考时针,那么在 1:10 时,我们有 30o + 100.5o = 30o + 5o = 35o。或者,在 4:17 时,我们有 430o + 17*0.5o = 120o + 8.5o = 128.5o。

到目前为止,我们知道可以计算给定h:m时间的时针角度为h300 + *m0.5o。对于计算分针的角度,我们可以认为,在 1 小时内,分针需要完成 360o 的旋转,因此 360o/ 60 分钟 = 每分钟 6o。因此,在h:24 时,分针形成 144o 的角度。在h*:35 时,分针形成 210o 的角度,依此类推。

因此,时针和分针之间的角度是 abs((h30o + *m0.5o) - m**6o)。如果返回的result大于 180o,则我们必须返回(360o - result),因为问题要求我们计算时针和分针之间的较短角度。

现在,让我们尝试计算以下图像中显示的时钟所需的角度:

图 15.14 – 三个时钟

图 15.14 – 三个时钟

时钟 1,10:10

  • 时针:1030o + 100.5o = 300o + 5o = 305o

  • 分针:10 * 6o = 60o

  • 结果:abs(305o - 60o) = abs(245o) = 245o > 180o,因此返回 360o - 245o = 115o

时钟 2,9:40

  • 时针:930o + 400.5o = 270o + 20o = 290o

  • 分针:40 * 6o = 240o

  • 结果:abs(290o - 240o) = abs(50o) = 50o

时钟 3,4:40

  • 时针:430o + 400.5o = 120o + 20o = 140o

  • 分钟:40 * 6o = 240o

  • 结果:abs(140o - 240o) = abs(-100o) = 100o

根据这些陈述,我们可以编写以下代码:

public static float findAngle(int hour, int min) {
  float angle = (float) Math.abs(((30f * hour) 
    + (0.5f * min)) - (6f * min));
  return angle > 180f ? (360f - angle) : angle;
}

完整的应用程序称为HourMinuteAngle

编码挑战 14-勾股定理三元组

谷歌Adobe微软

问题:勾股定理三元组是一组三个正整数{a,b,c},使得a2 = b2 + c2。假设你得到了一个正整数数组arr。编写一小段代码,打印出这个数组的所有勾股定理三元组。

解决方案:可以通过三个循环实现蛮力方法,尝试给定数组中的所有可能三元组。但这将在 O(n3)的复杂度时间内工作。显然,蛮力方法(通常称为naive方法)不会给面试官留下深刻印象,所以我们必须做得比这更好。

实际上,我们可以在 O(n2)的时间内解决这个问题。让我们看看算法的步骤:

  1. 对输入数组中的每个元素进行平方(O(n))。这意味着我们可以将a2 = b2 + c2 写成a = b + c

  2. 按升序对给定数组进行排序(O(n log n))。

  3. 如果a = b + c,那么a始终是abc之间的最大值。因此,我们固定a,使其成为这个排序数组的最后一个元素。

  4. 固定b,使其成为这个排序数组的第一个元素。

  5. 固定c,使其成为元素a之前的元素。

  6. 到目前为止,b<ac<a。要找到勾股定理三元组,执行一个循环,从 1 增加bn,从n减少c到 1。当bc相遇时,循环停止:

a. 如果b + c < a,则增加b的索引。

b. 如果b + c > a,则减少c的索引。

c. 如果b + c等于a,则打印找到的三元组。增加b的索引并减少c的索引。

  1. 步骤 3开始重复下一个a

假设arr={3, 6, 8, 5, 10, 4, 12, 14}。经过前两步,arr={9, 16, 25, 36, 64, 100, 144, 196}。经过步骤 345,我们有a=196,b=9,c=144,如下所示:

图 15.15-设置 a、b 和 c

图 15.15-设置 a、b 和 c

由于 9+144 < 196,b的索引增加 1,符合步骤 6a。对于 16+144,25+144 和 36+144,同样的步骤适用。由于 64+144 > 196,c的索引减少 1,符合步骤 6b

由于 64 +100 < 196,b的索引增加 1,符合步骤 6a。循环在这里停止,因为bc相遇,如下所示:

图 15.16-循环结束时的 b 和 c

图 15.16-循环结束时的 b 和 c

接下来,根据步骤 7,我们设置a=144,b=9,c=100。对每个a重复此过程。当a变为 100 时,我们找到了第一个勾股定理三元组;即a=100,b=36,c=64,如下所示:

图 15.17-勾股定理三元组

图 15.17-勾股定理三元组

让我们把这个算法写成代码:

public static void triplet(int arr[]) {
  int len = arr.length;
  // Step1
  for (int i = 0; i < len; i++) {
    arr[i] = arr[i] * arr[i];
  }
  // Step 2
  Arrays.sort(arr);
  // Steps 3, 4, and 5
  for (int i = len - 1; i >= 2; i--) {  
    int b = 0;
    int c = i - 1;
    // Step 6
    while (b < c) {
      // Step 6c
      if (arr[b] + arr[c] == arr[i]) {
        System.out.println("Triplet: " + Math.sqrt(arr[b]) 
          + ", " + Math.sqrt(arr[c]) + ", " 
              + Math.sqrt(arr[i]));
        b++;
        c--;
      }
      // Steps 6a and 6b
      if (arr[b] + arr[c] < arr[i]) {
        b++;
      } else {
        c--;
      }
    }
  }
}

完整的应用程序称为PythagoreanTriplets

编码挑战 15-调度一个电梯

亚马逊谷歌Adobe微软Flipkart

问题:假设你得到了一个表示n个人目的地楼层的数组。电梯的容量为给定的k。最初,电梯和所有人都在 0 楼(底楼)。电梯从当前楼层到达任何连续楼层(向上或向下)需要 1 个时间单位。编写一小段代码,安排电梯,以便我们获得将所有人到达目的地楼层所需的最小总时间,然后返回到地面楼层。

解决方案:让我们考虑给定的目的地数组为floors = {4, 2, 1, 2, 4},k=3。所以,我们有五个人:一个人去一楼,两个人去二楼,两个人去四楼。电梯一次可以搭载三个人。那么,我们如何安排电梯以最短的时间将这五个人送到他们的楼层呢?

解决方案包括按降序将人们送到各自的楼层。让我们根据以下图片来处理这个场景:

图 15.18 - 调度电梯示例

图 15.18 - 调度电梯示例

让我们遍历这个场景的步骤:

  1. 这是初始状态。电梯在地面层,有五个人准备搭乘。让我们假设最小时间为 0(所以,0 个时间单位)。

  2. 在电梯中,我们带上了要去四楼的人和要去二楼的一个人。记住我们一次最多可以带三个人。到目前为止,最小时间为 0。

  3. 电梯上升并停在二楼。一个人下去。因为每层代表一个时间单位,我们有一个最小时间为 2。

  4. 电梯上升并停在四楼。剩下的两个人下去。最小时间变为 4。

  5. 在这一步,电梯是空的。它必须下到地面层去接更多的人。因为它下降了四层,最小时间变为 8。

  6. 我们接上剩下的两个人。最小时间保持为 8。

  7. 电梯上升并停在一楼。一个人下去。最小时间变为 9。

  8. 电梯上升并停在二楼。一个人下去。最小时间变为 10。

  9. 在这一步,电梯是空的。它必须下到地面层。因为它下降了两层,最小时间变为 12。

因此,总最小时间为 12。基于这个场景,我们可以详细说明以下算法:

  1. 按目的地降序对给定的数组进行排序。

  2. 创建k人的组。每组所需的时间为 2 * floors[group]。

因此,对我们的测试数据进行排序将得到floors = {4, 4, 2, 2, 1}。我们有两组。一组包含三个人(4, 4, 2),而另一组包含两个人(2, 1)。总最小时间为(2 * floors[0]) + (2 * floors[3]) = (2 * 4) + (2 * 2) = 8 + 4 = 12。

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

public static int time(int k, int floors[]) {
  int aux;
  for (int i = 0; i < floors.length - 1; i++) {
    for (int j = i + 1; j < floors.length; j++) {
      if (floors[i] < floors[j]) {
        aux = floors[i];
        floors[i] = floors[j];
        floors[j] = aux;
      }
    }
  }
  // iterate the groups and update 
  // the time needed for each group 
  int time = 0;
  for (int i = 0; i < floors.length; i += k) {
    time += (2 * floors[i]);
  }
  return time;
}

当然,你可能最终选择了一个更好的排序算法。完整的应用程序称为ScheduleOneElevator。这是本章的最后一个编码挑战。

调度多部电梯

但是如何安排多部电梯和任意数量的楼层呢?嗯,在面试中,你可能不需要为多部电梯实现解决方案,但你可能会被问到如何设计一个解决方案。

调度多部电梯和算法的问题是著名且困难的。对于这个问题并没有最佳算法。换句话说,创建一个可以应用于现实世界电梯调度的算法是非常困难的,而且显然已经被专利保护。

电梯算法(https://en.wikipedia.org/wiki/Elevator_algorithm)是一个很好的起点。在考虑如何为多部电梯设计解决方案之前,你必须列出你想要考虑的所有假设或约束条件的清单。每个可用的解决方案/算法都有一个关于楼层数、电梯数量、每部电梯的容量、平均人数、高峰时间、电梯速度、装载和卸载时间等的假设或约束条件的清单。主要有三种解决方案,如下:

  • 区域:每部电梯分配到一个区域(它服务一部分楼层)。

  • 最近的电梯:每个人被分配到最近的电梯(这是基于电梯的位置、呼叫的方向和电梯当前的方向)。

  • 考虑容量的最近电梯:这类似于最近电梯选项,但它考虑了每部电梯的负载。

部门

例如,一个有八层楼和三部电梯的建筑可以这样服务:

  • 电梯 1 服务 1 楼、2 楼和 3 楼。

  • 电梯 2 服务 1 楼、4 楼和 5 楼。

  • 电梯 3 服务 1 楼、6 楼、7 楼和 8 楼。

每部电梯都服务一楼,因为一楼的到达率最高。

最近的电梯

为每部电梯分配一个分数。这个分数代表了新人到来时电梯的适用性评分:

  • 朝呼叫方向,相同方向FS = (N + 2) - d

  • 朝呼叫方向,相反方向FS = (N+1) - d

  • 远离呼叫FS = 1

其中,N = #楼层 - 1,d = 电梯和呼叫之间的距离。

考虑容量的最近电梯

这与最近电梯的情况完全相同,但它考虑了电梯的多余容量:

  • 朝呼叫方向,相同方向FS = (N + 2) - d + C

  • 朝呼叫方向,相反方向FS = (N + 1) - d + C

  • 远离呼叫FS = 1 + C

这里,N是#楼层 - 1,d是电梯和呼叫之间的距离,C是多余容量。

我强烈建议你搜索和学习这个问题的不同实现,并尝试学习你认为最适合你的那个。我建议你从这里开始:

现在,让我们总结一下这一章。

总结

在本章中,我们涵盖了最受欢迎的数学和谜题类问题。虽然许多公司避免这类问题,但仍然有像谷歌和亚马逊这样的主要参与者在面试中依赖这类问题。

练习这些问题对我们的大脑是一个很好的锻炼。除了数学知识,这些问题还能够支持基于推理和直觉的分析思维,这意味着它们对任何程序员都是很好的支持。

在下一章中,我们将讨论面试中的一个热门话题:并发(多线程)。

第四部分:奖励 - 并发和函数式编程

公司非常关注并发和函数式编程等主题。本章涵盖了围绕这两个主题的最流行的问题。这四章是奖励章节;其方法与迄今为止阅读的章节不同。由于这些主题的性质,我们将简要涉及它们,并详细阐述在相应主题的面试中提出的问题。您可以在本章的技术要求部分找到在 GitHub 存储库中使用的代码链接。

本节包括以下章节:

  • 第十六章,并发

  • 第十七章,函数式编程风格

  • 第十八章,单元测试

  • 第十九章,系统可扩展性

第十六章:并发

开发单线程的 Java 应用程序很少可行。因此,大多数项目将是多线程的(即它们将在多线程环境中运行)。这意味着,迟早,您将不得不解决某些多线程问题。换句话说,您将不得不动手编写直接或通过专用 API 操纵 Java 线程的代码。

本章涵盖了关于 Java 并发(多线程)的最常见问题,这些问题在关于 Java 语言的一般面试中经常出现。和往常一样,我们将从简要介绍开始,介绍 Java 并发的主要方面。因此,我们的议程很简单,涵盖以下主题:

  • Java 并发(多线程)简介

  • 问题和编码挑战

让我们从我们的主题 Java 并发的基本知识开始。使用以下简介部分提取一些关于并发的基本问题的答案,比如什么是并发?什么是 Java 线程?什么是多线程?等。

技术要求

本章中使用的代码可以在 GitHub 上找到:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter16

Java 并发(多线程)简介

我们的计算机可以同时运行多个程序应用程序(例如,我们可以同时在媒体播放器上听音乐并浏览互联网)。进程是程序或应用程序的执行实例(例如,通过在计算机上双击 NetBeans 图标,您启动将运行 NetBeans 程序的进程)。此外,线程轻量级子进程,表示进程的最小可执行工作单元。Java 线程的开销相对较低,并且它与其他线程共享公共内存空间。一个进程可以有多个线程,其中一个是主线程

重要说明

进程和线程之间的主要区别在于线程共享公共内存空间,而进程不共享。通过共享内存,线程减少了大量开销。

并发是应用程序处理其工作的多个任务的能力。程序或应用程序可以一次处理一个任务(顺序处理)或同时处理多个任务(并发处理)。

不要将并发与并行混淆。并行是应用程序处理每个单独任务的能力。应用程序可以串行处理每个任务,也可以将任务分割成可以并行处理的子任务。

重要说明

并发是关于处理(而不是执行)多个事情,而并行是关于执行多个事情。

通过多线程实现并发。多线程是一种技术,使程序或应用程序能够同时处理多个任务,并同步这些任务。这意味着多线程允许通过在同一时间执行两个或更多任务来最大程度地利用 CPU。我们在这里说在同一时间是因为这些任务看起来像是同时运行;然而,实质上,它们不能这样做。它们利用操作系统的 CPU 上下文切换时间片功能。换句话说,CPU 时间被所有运行的任务共享,并且每个任务被安排在一定时间内运行。因此,多线程是多任务处理的关键。

重要说明

在单核 CPU 上,我们可以实现并发但不是并行。

总之,线程可以产生多任务的错觉;然而,在任何给定的时间点,CPU 只执行一个线程。CPU 在线程之间快速切换控制,从而产生任务并行执行(或推进)的错觉。实际上,它们是并发执行的。然而,随着硬件技术的进步,现在普遍拥有多核机器和计算机。这意味着应用程序可以利用这些架构,并且每个线程都有一个专用的 CPU 在运行。

以下图表通过四个线程(T1T2T3T4)澄清了并发和并行之间的混淆:

16.1-并发与并行

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

16.1-并发与并行

因此,一个应用程序可以是以下之一:

  • 并发但不是并行:它同时执行多个任务,但没有两个任务同时执行。

  • 并行但不是并发:它在多核 CPU 中同时执行一个任务的多个子任务。

  • 既不是并行也不是并发:它一次执行所有任务(顺序执行)。

  • 并行和并发:它在多核 CPU 中同时并发执行多个任务。

被分配执行任务的一组同质工作线程称为线程池。完成任务的工作线程将返回到池中。通常,线程池绑定到任务队列,并且可以调整到它们持有的线程的大小。通常情况下,为了获得最佳性能,线程池的大小应等于 CPU 核心的数量。

在多线程环境中同步是通过锁定实现的。锁定用于在多线程环境中协调和限制对资源的访问。

如果多个线程可以访问相同的资源而不会导致错误或不可预测的行为/结果,那么我们处于线程安全的上下文。可以通过各种同步技术(例如 Java synchronized关键字)实现线程安全

接下来,让我们解决一些关于 Java 并发的问题和编码挑战。

问题和编码挑战

在本节中,我们将涵盖 20 个关于并发的问题和编码挑战,这在面试中非常流行。

您应该知道,Java 并发是一个广泛而复杂的主题,任何 Java 开发人员都需要详细了解。对 Java 并发的基本见解应该足以通过一般的 Java 语言面试,但对于特定的面试来说还不够(例如,如果您申请一个将涉及开发并发 API 的工作,那么您必须深入研究这个主题并学习高级概念-很可能,面试将以并发为中心)。

编码挑战 1-线程生命周期状态

线程

Thread.State枚举。Java 线程的可能状态可以在以下图表中看到:

16.2-Java 线程状态

16.2-Java 线程状态

Java Thread的不同生命周期状态如下:

  • NEW Thread#start()方法被调用)。

  • RUNNABLE Thread#start()方法,线程从NEWRUNNABLE。在RUNNABLE状态下,线程可以运行或准备运行。等待JVM(Java 虚拟机)线程调度程序分配必要的资源和时间来运行的线程是准备运行的,但尚未运行。一旦 CPU 可用,线程调度程序将运行线程。

  • BLOCKED BLOCKED状态。例如,如果一个线程t1试图进入另一个线程t2已经访问的同步代码块(例如,标记为synchronized的代码块),那么t1将被保持在BLOCKED状态,直到它可以获取所需的锁。

  • WAITING WAITING状态。

  • TIMED WAITING TIMED_WAITING状态。

  • TERMINATED TERMINATE状态。

除了描述 Java 线程的可能状态之外,面试官可能会要求您为每个状态编写一个示例。这就是为什么我强烈建议您花时间分析名为ThreadLifecycleState的应用程序(为简洁起见,书中未列出代码)。该应用程序的结构非常直观,主要注释解释了每种情景/状态。

编码挑战 2 - 死锁

问题:向我们解释一下死锁,我们会雇佣你!

解决方案:雇佣我,我会向您解释。

在这里,我们刚刚描述了一个死锁。

死锁可以这样解释:线程T1持有锁P,并尝试获取锁Q。与此同时,有一个线程T2持有锁Q,并尝试获取锁P。这种死锁被称为循环等待致命拥抱

Java 不提供死锁检测和/或解决机制(例如数据库有)。这意味着死锁对应用程序来说可能非常尴尬。死锁可能部分或完全阻塞应用程序。这导致显著的性能惩罚,意外的行为/结果等。通常,死锁很难找到和调试,并且会迫使您重新启动应用程序。

避免竞争死锁的最佳方法是避免使用嵌套锁或不必要的锁。嵌套锁很容易导致死锁。

模拟死锁的常见问题是哲学家就餐问题。您可以在Java 编码问题书中找到对这个问题的详细解释和实现(www.packtpub.com/programming/java-coding-problems)。Java 编码问题包含两章专门讨论 Java 并发,并旨在使用特定问题深入探讨这个主题。

在本书的代码包中,您可以找到一个名为Deadlock的死锁示例。

编码挑战 3 - 竞争条件

问题:解释一下竞争条件是什么。

解决方案:首先,我们必须提到可以由多个线程执行(即并发执行)并公开共享资源(例如共享数据)的代码片段/块被称为关键部分

竞争条件发生在线程在没有线程同步的情况下通过这样的关键部分。线程在关键部分中竞争尝试读取/写入共享资源。根据线程完成这场竞赛的顺序,应用程序的输出会发生变化(应用程序的两次运行可能会产生不同的输出)。这导致应用程序的行为不一致。

避免竞争条件的最佳方法是通过使用锁、同步块、原子/易失性变量、同步器和/或消息传递来正确同步关键部分。

编码挑战 4 - 可重入锁

问题:解释什么是可重入锁概念。

解决方案:一般来说,可重入锁指的是一个进程可以多次获取锁而不会使自身陷入死锁的过程。如果锁不是可重入的,那么进程仍然可以获取它。但是,当进程尝试再次获取锁时,它将被阻塞(死锁)。可重入锁可以被另一个线程获取,或者被同一个线程递归地获取。

可重入锁可以用于不包含可能破坏它的更新的代码片段。如果代码包含可以更新的共享状态,那么再次获取锁将会破坏共享状态,因为在执行代码时调用了该代码。

在 Java 中,可重入锁是通过ReentrantLock类实现的。可重入锁的工作方式是:当线程第一次进入锁时,保持计数设置为 1。在解锁之前,线程可以重新进入锁,导致每次进入时保持计数增加一。每个解锁请求将保持计数减少一,当保持计数为零时,锁定的资源被打开。

编码挑战 5 - Executor 和 ExecutorService

ExecutorExecutorService

java.util.concurrent包中,有许多专用于执行任务的接口。最简单的一个被命名为Executor。这个接口公开了一个名为execute (Runnable command)的方法。

一个更复杂和全面的接口,提供了许多额外的方法,是ExecutorService。这是Executor的增强版本。Java 带有一个完整的ExecutorService实现,名为ThreadPoolExecutor

在本书的代码包中,您可以找到在名为ExecutorAndExecutorService的应用程序中使用ExecutorThreadPoolExecutor的简单示例。

编码挑战 6 - Runnable 与 Callable 的比较

Callable接口和Runnable接口?

Runnable接口是一个包含一个名为run()的方法的函数接口。run()方法不接受任何参数,返回void。此外,它不能抛出已检查的异常(只能抛出RuntimeException)。这些陈述使Runnable适用于我们不寻找线程执行结果的情况。run()签名如下:

void run()

另一方面,Callable接口是一个包含一个名为call()的方法的函数接口。call()方法返回一个通用值,并且可以抛出已检查的异常。通常,Callable用于ExecutorService实例。它用于启动异步任务,然后调用返回的Future实例来获取其值。Future接口定义了用于获取Callable对象生成的结果和管理其状态的方法。call()签名如下:

V call() throws Exception

请注意,这两个接口都代表一个任务,该任务旨在由单独的线程并发执行。

在本书的代码包中,您可以找到在名为RunnableAndCallable的应用程序中使用RunnableCallable的简单示例。

编码挑战 7 - 饥饿

问题:解释什么是线程饥饿

解决方案:一个永远(或很少)得不到 CPU 时间或访问共享资源的线程是经历饥饿的线程。由于它无法定期访问共享资源,这个线程无法推进其工作。这是因为其他线程(所谓的贪婪线程)在这个线程之前获得访问,并使资源长时间不可用。

避免线程饥饿的最佳方法是使用公平锁,比如 Java 的ReentrantLock公平锁授予等待时间最长的线程访问权限。通过 Java 的Semaphore可以实现多个线程同时运行而避免饥饿。公平Semaphore使用 FIFO 来保证在争用情况下授予许可。

编码挑战 8 - 活锁

问题:解释什么是线程活锁

解决方案:当两个线程不断采取行动以响应另一个线程时,就会发生活锁。这些线程不会在自己的工作中取得任何进展。请注意,这些线程没有被阻塞;它们都忙于相互响应而无法恢复工作。

这是一个活锁的例子:想象两个人试图在走廊上互相让对方通过。马克向右移动让奥利弗通过,奥利弗向左移动让马克通过。现在他们互相阻塞。马克看到自己挡住了奥利弗,向左移动,奥利弗看到自己挡住了马克,向右移动。他们永远无法互相通过并一直阻塞对方。

我们可以通过 ReentrantLock 避免活锁。这样,我们可以确定哪个线程等待的时间最长,并为其分配一个锁。如果一个线程无法获取锁,它应该释放先前获取的锁,然后稍后再试。

编码挑战 9 – Start() 与 run()

Java Thread 中的 start() 方法和 run() 方法。

start()run() 的区别在于 start() 方法创建一个新的线程,而 run() 方法不会。start() 方法创建一个新的线程,并调用在这个新线程中写的 run() 方法内的代码块。run() 方法在同一个线程上执行该代码(即调用线程),而不创建新线程。

另一个区别是在线程对象上两次调用 start() 将抛出 IllegalStateException。另一方面,两次调用 run() 方法不会导致异常。

通常,新手会忽略这些区别,并且,由于 start() 方法最终调用 run() 方法,他们认为没有理由调用 start() 方法。因此,他们直接调用 run() 方法。

编码挑战 10 – 线程与可运行

Thread 或实现 Runnable

通过 java.lang.Thread 或实现 java.lang.Runnable。首选的方法是实现 Runnable

大多数情况下,我们实现一个线程只是为了让它运行一些东西,而不是覆盖 Thread 的行为。只要我们想要给一个线程运行一些东西,我们肯定应该坚持实现 Runnable。事实上,使用 CallableFutureTask 更好。

此外,通过实现 Runnable,你仍然可以扩展另一个类。通过扩展 Thread,你不能扩展另一个类,因为 Java 不支持多重继承。

最后,通过实现 Runnable,我们将任务定义与任务执行分离。

编码挑战 11 – CountDownLatch 与 CyclicBarrier

CountDownLatchCyclicBarrier

CountDownLatchCyclicBarrier 是 Java 同步器 中的五个之一,另外还有 ExchangerSemaphorePhaser

CountDownLatchCyclicBarrier 之间的主要区别在于 CountDownLatch 实例在倒计时达到零后无法重用。另一方面,CyclicBarrier 实例是可重用的。CyclicBarrier 实例是循环的,因为它可以被重置和重用。要做到这一点,在所有等待在屏障处的线程被释放后调用 reset() 方法;否则,将抛出 BrokenBarrierException

编码挑战 12 – wait() 与 sleep()

wait() 方法和 sleep() 方法。

wait() 方法和 sleep() 方法的区别在于 wait() 必须从同步上下文(例如,从 synchronized 方法)中调用,而 sleep() 方法不需要同步上下文。从非同步上下文调用 wait() 将抛出 IllegalMonitorStateException

此外,重要的是要提到 wait()Object 上工作,而 sleep() 在当前线程上工作。实质上,wait() 是在 java.lang.Object 中定义的非static方法,而 sleep() 是在 java.lang.Thread 中定义的static方法。

此外,wait() 方法释放锁,而 sleep() 方法不释放锁。sleep() 方法只是暂停当前线程一段时间。它们都会抛出 IntrupptedException 并且可以被中断。

最后,应该在决定何时释放锁的循环中调用wait()方法。另一方面,不建议在循环中调用sleep()方法。

编码挑战 13 - ConcurrentHashMap 与 Hashtable

ConcurrentHashMapHashtable快吗?

ConcurrentHashMapHashtable更快,因为它具有特殊的内部设计。ConcurrentHashMap在内部将映射分成段(或桶),并且在更新操作期间仅锁定特定段。另一方面,Hashtable在更新操作期间锁定整个映射。因此,Hashtable对整个数据使用单个锁,而ConcurrentHashMap对不同段(桶)使用多个锁。

此外,使用get()ConcurrentHashMap中读取是无锁的(无锁),而所有Hashtable操作都是简单的synchronized

编码挑战 14 - ThreadLocal

ThreadLocal

ThreadLocal用作分别存储和检索每个线程的值的手段。单个ThreadLocal实例可以存储和检索多个线程的值。如果线程A存储x值,线程B在同一个ThreadLocal实例中存储y值,那么后来线程A检索x值,线程B检索y值。Java ThreadLocal通常用于以下两种情况:

  1. 为每个线程提供实例(线程安全和内存效率)

  2. 为每个线程提供上下文

编码挑战 15 - submit()与 execute()

ExecutorService#submit()Executor#execute()方法。

用于执行的Runnable任务,它们并不相同。主要区别可以通过简单检查它们的签名来观察。注意,submit()返回一个结果(即代表任务的Future对象),而execute()返回void。返回的Future对象可以用于在以后(过早地)以编程方式取消运行的线程。此外,通过使用Future#get()方法,我们可以等待任务完成。如果我们提交一个Callable,那么Future#get()方法将返回调用Callable#call()方法的结果。

编码挑战 16 - interrupted()和 isInterrupted()

interrupted()isInterrupted()方法。

Thread.interrupt()方法中断当前线程并将此标志设置为true

interrupted()isInterrupted()方法之间的主要区别在于interrupted()方法会清除中断状态,而isInterrupted()不会。

如果线程被中断,则Thread.interrupted()将返回true。但是,除了测试当前线程是否被中断外,Thread.interrupted()还会清除线程的中断状态(即将其设置为false)。

static isInterrupted()方法不会更改中断状态标志。

作为一个经验法则,在捕获InterruptedException后,不要忘记通过调用Thread.currentThread().interrupt()来恢复中断。这样,我们的代码的调用者将意识到中断。

编码挑战 17 - 取消线程

问题:如何停止或取消线程?

volatile(也称为轻量级同步机制)。作为volatile标志,它不会被线程缓存,并且对它的操作不会在内存中重新排序;因此,线程无法看到旧值。读取volatile字段的任何线程都将看到最近写入的值。这正是我们需要的,以便将取消操作通知给所有对此操作感兴趣的运行中的线程。以下图表说明了这一点:

16.3 - Volatile 标志读/写

16.3 - Volatile 标志读/写

请注意,volatile变量不适合读-修改-写场景。对于这种场景,我们将依赖原子变量(例如AtomicBooleanAtomicIntegerAtomicReference)。

在本书的代码包中,您可以找到一个取消线程的示例。该应用程序名为CancelThread

编码挑战 18 - 在线程之间共享数据

问题:如何在两个线程之间共享数据?

BlockingQueueLinkedBlockingQueueConcurrentLinkedDeque。依赖于这些数据结构在线程之间共享数据非常方便,因为您不必担心线程安全和线程间通信。

编码挑战 19 - ReadWriteLock

ReadWriteLock是在 Java 中的。

ReadWriteLock用于在并发环境中维护读写操作的效率和线程安全性。它通过锁分段的概念实现这一目标。换句话说,ReadWriteLock为读和写使用单独的锁。更确切地说,ReadWriteLock保持一对锁:一个用于只读操作,一个用于写操作。只要没有写线程,多个读线程可以同时持有读锁(共享悲观锁)。一个写线程可以一次写入(独占/悲观锁)。因此,ReadWriteLock可以显著提高应用程序的性能。

除了ReadWriteLock,Java 还提供了ReentrantReadWriteLockStampedLockReentrantReadWriteLock类将可重入锁概念(参见编码挑战 4)添加到ReadWriteLock中。另一方面,StampedLockReentrantReadWriteLock表现更好,并支持乐观读取。但它不是可重入的;因此,它容易发生死锁。

编码挑战 20 - 生产者-消费者

问题:为著名的生产者-消费者问题提供一个实现。

注意

这是任何 Java 多线程面试中的一个常见问题!

解决方案:生产者-消费者问题是一个可以表示为以下形式的设计模式:

16.4 - 生产者-消费者设计模式

16.4 - 生产者-消费者设计模式

在这种模式中,生产者线程和消费者线程通常通过一个队列进行通信(生产者将数据入队,消费者将数据出队),并遵循特定于建模业务的一组规则。这个队列被称为数据缓冲区。当然,根据流程设计,其他数据结构也可以扮演数据缓冲区的角色。

现在,让我们假设以下情景(一组规则):

  • 如果数据缓冲区为空,那么生产者会生产一个产品(将其添加到数据缓冲区)。

  • 如果数据缓冲区不为空,那么消费者会消费一个产品(从数据缓冲区中移除它)。

  • 只要数据缓冲区不为空,生产者就会等待。

  • 只要数据缓冲区为空,消费者就会等待。

接下来,让我们通过两种常见的方法解决这种情况。我们将从基于wait()notify()方法的解决方案开始。

通过wait()notify()实现生产者-消费者

一些面试官可能会要求您实现wait()notify()方法。换句话说,他们不允许您使用内置的线程安全队列,如BlockingQueue

例如,让我们考虑数据缓冲区(queue)由LinkedList表示,即非线程安全的数据结构。为了确保生产者和消费者以线程安全的方式访问这个共享的LinkedList,我们依赖于Synchronized关键字。

生产者

如果队列不为空,那么生产者会等待,直到消费者完成。为此,生产者依赖于wait()方法,如下所示:

synchronized (queue) {     
  while (!queue.isEmpty()) {
    logger.info("Queue is not empty ...");
    queue.wait();
  }
}

另一方面,如果队列为空,那么生产者会将一个产品入队,并通过notify()通知消费者线程,如下所示:

synchronized (queue) {
  String product = "product-" + rnd.nextInt(1000);
  // simulate the production time
  Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS)); 
  queue.add(product);
  logger.info(() -> "Produced: " + product);
  queue.notify();
}

在将产品添加到队列后,消费者应该准备好消费它。

消费者

如果队列为空,那么消费者会等待,直到生产者完成。为此,生产者依赖于wait()方法,如下所示:

synchronized (queue) {
  while (queue.isEmpty()) {
    logger.info("Queue is empty ...");
    queue.wait();
  }
}

另一方面,如果队列不为空,则消费者将出列一个产品并通过notify()通知生产者线程,如下所示:

synchronized (queue) {
  String product = queue.remove(0);
  if (product != null) {
    // simulate consuming time
    Thread.sleep(rnd.nextInt(MAX_CONS_TIME_MS));                                
    logger.info(() -> "Consumed: " + product);
    queue.notify();
  }
}

完整的代码在捆绑代码ProducerConsumerWaitNotify中可用。

通过内置的阻塞队列进行生产者-消费者

如果您可以使用内置的阻塞队列,那么您可以选择BlockingQueue甚至TransferQueue。它们两者都是线程安全的。在下面的代码中,我们使用了TransferQueue,更确切地说是LinkedTransferQueue

生产者

生产者等待消费者通过hasWaitingConsumer()可用:

while (queue.hasWaitingConsumer()) {
  String product = "product-" + rnd.nextInt(1000);
  // simulate the production time
  Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS)); 
  queue.add(product);
  logger.info(() -> "Produced: " + product);
}

在将产品添加到队列后,消费者应准备好消费它。

消费者

消费者使用poll()方法并设置超时来提取产品:

// MAX_PROD_TIME_MS * 2, just give enough time to the producer
String product = queue.poll(
  MAX_PROD_TIME_MS * 2, TimeUnit.MILLISECONDS);
if (product != null) {
  // simulate consuming time
  Thread.sleep(rnd.nextInt(MAX_CONS_TIME_MS));                         
  logger.info(() -> "Consumed: " + product);
}

完整的代码在捆绑代码ProducerConsumerQueue中可用

总结

在本章中,我们涵盖了在 Java 多线程面试中经常出现的最受欢迎的问题。然而,Java 并发是一个广泛的主题,深入研究它非常重要。我强烈建议您阅读 Brian Goetz 的Java 并发实践。这对于任何 Java 开发人员来说都是必读之书。

在下一章中,我们将涵盖一个热门话题:Java 函数式编程。

第十七章:函数式编程

你可能知道,Java 不像 Haskell 那样是一种纯函数式编程语言,但从版本 8 开始,Java 添加了一些函数式支持。添加这种支持的努力取得了成功,并且函数式代码被开发人员和公司广泛采用。函数式编程支持更易理解、易维护和易测试的代码。然而,以函数式风格编写 Java 代码需要严肃的了解 lambda、流 API、Optional、函数接口等知识。所有这些函数式编程主题也可以是面试的主题,在本章中,我们将涵盖一些必须了解的热门问题,以通过常规的 Java 面试。我们的议程包括以下主题:

  • Java 函数式编程概述

  • 问题和编码挑战

让我们开始吧!

Java 函数式编程概述

像往常一样,本节旨在突出和复习我们主题的主要概念,并为回答技术面试中可能出现的基本问题提供全面的资源。

函数式编程的关键概念

因此,函数式编程的关键概念包括以下内容:

  • 函数作为一等对象

  • 纯函数

  • 高阶函数

让我们简要地介绍一下这些概念。

函数作为一等对象

说函数是一等对象意味着我们可以创建一个函数的实例,并将变量引用该函数实例。这就像引用StringList或任何其他对象。此外,函数可以作为参数传递给其他函数。然而,Java 方法不是一等对象。我们能做的最好的事情就是依赖于 Java lambda 表达式。

纯函数

函数是一个执行没有副作用的函数,返回值仅取决于其输入参数。以下 Java 方法是一个纯函数:

public class Calculator {
  public int sum(int x, int y) {
    return x + y;
  }
}

如果一个方法使用成员变量或改变成员变量的状态,那么它就不是一个函数。

高阶函数

高阶函数将一个或多个函数作为参数和/或返回另一个函数作为结果。Java 通过 lambda 表达式模拟高阶函数。换句话说,在 Java 中,高阶函数是一个以一个(或多个)lambda 表达式作为参数和/或返回另一个 lambda 表达式的方法。

例如,Collections.sort()方法接受一个Comparator作为参数,这是一个高阶函数:

Collections.sort(list, (String x, String y) -> {
  return x.compareTo(y);
});

Collections.sort()的第一个参数是一个List,第二个参数是一个 lambda 表达式。这个 lambda 表达式参数是使Collections.sort()成为一个高阶函数的原因。

纯函数式编程规则

现在,让我们简要讨论纯函数式编程规则。纯函数式编程也有一套规则要遵循。这些规则如下:

  • 没有状态

  • 没有副作用

  • 不可变变量

  • 偏爱递归而不是循环

让我们简要地介绍一下这些规则。

没有状态

通过无状态,我们并不是指函数式编程消除了状态。通常,无状态意味着函数没有外部状态。换句话说,函数可能使用包含临时状态的局部变量,但不能引用其所属类/对象的任何成员变量。

无副作用

通过“无副作用”,我们应该理解一个函数不能改变(突变)函数之外的任何状态(在其功能范围之外)。函数之外的状态包括以下内容:

  • 包含该函数的类/对象中的成员变量

  • 作为参数传递给函数的成员变量

  • 或外部系统中的状态(例如数据库或文件)。

不可变变量

函数式编程鼓励并支持不可变变量的使用。依赖不可变变量有助于我们更轻松、更直观地避免副作用

更喜欢递归而不是循环

由于递归依赖于重复的函数调用来模拟循环,代码变得更加函数式。这意味着不鼓励使用以下迭代方法来计算阶乘:

static long factorial(long n) {
  long result = 1;
  for (; n > 0; n--) {
    result *= n;
  }
  return result;
}

函数式编程鼓励以下递归方法:

static long factorial(long n) {
  return n == 1 ? 1 : n * factorial(n - 1);
}

我们使用尾递归来改善性能损耗,因为在前面的例子中,每个函数调用都保存为递归堆栈中的一个帧。当存在许多递归调用时,尾递归是首选。在尾递归中,函数执行递归调用作为最后要做的事情,因此编译器不需要将函数调用保存为递归堆栈中的帧。大多数编译器将优化尾递归,从而避免性能损耗:

static long factorialTail(long n) {
  return factorial(1, n);
}
static long factorial(long acc, long v) {
  return v == 1 ? acc : factorial(acc * v, v - 1);
}

另外,循环可以通过受 Java Stream API 的启发来实现:

static long factorial(long n) {
  return LongStream.rangeClosed(1, n)
     .reduce(1, (n1, n2) -> n1 * n2);
}

现在,是时候练习一些问题和编码挑战了。

问题和编码挑战

在本节中,我们将涵盖 21 个在面试中非常流行的问题和编码挑战。让我们开始吧!

编码挑战 1- Lambda 部分

问题:描述 Java 中 lambda 表达式的部分。此外,什么是 lambda 表达式的特征?

解决方案:如下图所示,lambda 有三个主要部分:

图 17.1- Lambda 部分

图 17.1- Lambda 部分

lambda 表达式的部分如下:

  • 在箭头的左侧,是 lambda 的参数,这些参数在 lambda 主体中被使用。在这个例子中,这些是FilenameFilter.accept(File folder, String fileName)方法的参数。

  • 在箭头的右侧,是 lambda 的主体。在这个例子中,lambda 的主体检查文件(fileName)所在的文件夹(folder)是否可读,并且这个文件的名称是否以.pdf字符串结尾。

  • 箭头位于参数列表和 lambda 主体之间,起到分隔作用。

接下来,让我们谈谈 lambda 表达式的特征。因此,如果我们写出前面图表中 lambda 的匿名类版本,那么它将如下所示:

FilenameFilter filter = new FilenameFilter() {
  @Override
  public boolean accept(File folder, String fileName) {
    return folder.canRead() && fileName.endsWith(".pdf");
  }
};

现在,如果我们比较匿名版本和 lambda 表达式,我们会注意到 lambda 表达式是一个简洁的匿名函数,可以作为参数传递给方法或保存在变量中。

下图中显示的四个词表征了 lambda 表达式:

图 17.2- Lambda 特征

图 17.2- Lambda 特征

作为一个经验法则,请记住,lambda 支持行为参数化设计模式(行为作为函数的参数传递),并且只能在功能接口的上下文中使用。

编码挑战 2-功能接口

问题:什么是功能接口?

解决方案:在 Java 中,功能接口是一个只包含一个抽象方法的接口。换句话说,功能接口只包含一个未实现的方法。因此,功能接口将函数作为接口进行封装,并且该函数由接口上的单个抽象方法表示。

除了这个抽象方法之外,功能接口还可以有默认和/或静态方法。通常,功能接口会用@FunctionalInterface进行注解。这只是一个信息性的注解类型,用于标记功能接口。

这是一个功能接口的例子:

@FunctionalInterface
public interface Callable<V> {
  V call() throws Exception;
}

根据经验法则,如果一个接口有更多没有实现的方法(即抽象方法),那么它就不再是一个函数式接口。这意味着这样的接口不能被 Java lambda 表达式实现。

编码挑战 3 - 集合与流

问题:集合和流之间的主要区别是什么?

解决方案:集合和流是非常不同的。一些不同之处如下:

  • ListSetMap),流旨在对该数据应用操作(例如过滤映射匹配)。换句话说,流对存储在集合上的数据表示的视图/源应用复杂的操作。此外,对流进行的任何修改/更改都不会反映在原始集合中。

  • 数据修改:虽然我们可以向集合中添加/删除元素,但我们不能向流中添加/删除元素。实际上,流消耗视图/源,对其执行操作,并在不修改视图/源的情况下返回结果。

  • 迭代:流消耗视图/源时,它会自动在内部执行该视图/源的迭代。迭代取决于选择应用于视图/源的操作。另一方面,集合必须在外部进行迭代。

  • 遍历:集合可以被多次遍历,而流只能被遍历一次。因此,默认情况下,Java 流不能被重用。尝试两次遍历流将导致错误读取Stream has already been operated on or closed

  • 构造:集合是急切构造的(所有元素从一开始就存在)。另一方面,流是懒惰构造的(所谓的中间操作直到调用终端操作才被评估)。

编码挑战 4 - map()函数

map()函数是做什么的,为什么要使用它?

map()函数是一个名为映射的中间操作,通过Stream API 可用。它用于通过简单应用给定函数将一种类型的对象转换为另一种类型。因此,map()遍历给定流,并通过应用给定函数将每个元素转换为它的新版本,并在新的Stream中累积结果。给定的Stream不会被修改。例如,通过Stream#map()List<String>转换为List<Integer>可以如下进行:

List<String> strList = Arrays.asList("1", "2", "3");
List<Integer> intList = strList.stream()
  .map(Integer::parseInt)
  .collect(Collectors.toList());

挑战自己多练习一些例子。尝试应用map()将一个数组转换为另一个数组。

编码挑战 5 - flatMap()函数

flatMap()函数是做什么的,为什么要使用它?

flatMap()函数是一个名为展平的中间操作,通过Stream API 可用。这个函数是map()的扩展,意味着除了将给定对象转换为另一种类型的对象之外,它还可以展平它。例如,有一个List<List<Object>>,我们可以通过Stream#flatMap()将其转换为List<Object>,如下所示:

List<List<Object>> list = ...
List<Object> flatList = list.stream()
  .flatMap(List::stream)
  .collect(Collectors.toList());

下一个编码挑战与此相关,所以也要考虑这一点。

编码挑战 6 - map()与 flatMap()

map()flatMap()函数?

flatMap()函数还能够将给定对象展平。换句话说,flatMap()也可以展平一个Stream对象。

为什么这很重要?嗯,map()知道如何将一系列元素包装在Stream中,对吧?这意味着map()可以生成诸如Stream<String[]>Stream<List<String>>Stream<Set<String>>甚至Stream<Stream<R>>等流。但问题是,这些类型的流不能被流操作成功地操作(即,如我们所期望的那样)sum()distinct()filter()

例如,让我们考虑以下List

List<List<String>> melonLists = Arrays.asList(
  Arrays.asList("Gac", "Cantaloupe"),
  Arrays.asList("Hemi", "Gac", "Apollo"),
  Arrays.asList("Gac", "Hemi", "Cantaloupe"));

我们试图从这个列表中获取甜瓜的不同名称。如果将数组包装成流可以通过Arrays.stream()来完成,对于集合,我们有Collection.stream()。因此,第一次尝试可能如下所示:

melonLists.stream()
  .map(Collection::stream) // Stream<Stream<String>>
  .distinct();

但这不起作用,因为map()将返回Stream<Stream<String>>。解决方案由flatMap()提供,如下所示:

List<String> distinctNames = melonLists.stream()
  .flatMap(Collection::stream) // Stream<String>
  .distinct()
  .collect(Collectors.toList());

输出如下:GacCantaloupeHemiApollo

此外,如果您在理解这些函数式编程方法时遇到困难,我强烈建议您阅读我的另一本书,Java 编码问题,可从 Packt 获得(www.packtpub.com/programming/java-coding-problems)。该书包含两个关于 Java 函数式编程的全面章节,提供了详细的解释、图表和应用,对于深入研究这个主题非常有用。

编码挑战 7-过滤器()函数

filter()函数是做什么的,为什么要使用它?

filter()函数是通过Stream API 提供的一种名为filtering的中间操作。它用于过滤满足某种条件的Stream元素。条件是通过java.util.function.Predicate函数指定的。这个谓词函数只是一个以Object作为参数并返回boolean的函数。

假设我们有以下整数List

List<Integer> ints
  = Arrays.asList(1, 2, -4, 0, 2, 0, -1, 14, 0, -1);

可以通过以下方式对此列表进行流处理并提取非零元素:

List<Integer> result = ints.stream()
  .filter(i -> i != 0)
  .collect(Collectors.toList());

结果列表将包含以下元素:12-42-114-1

请注意,对于几个常见操作,Java Stream API 已经提供了即用即得的中间操作。例如,无需使用filter()和为以下操作定义Predicate

  • distinct(): 从流中删除重复项

  • skip(n): 跳过前n个元素

  • limit(s): 将流截断为不超过s长度

  • sorted(): 根据自然顺序对流进行排序

  • sorted(Comparator<? super T> comparator): 根据给定的Comparator对流进行排序

所有这些函数都内置在Stream API 中。

编码挑战 8-中间操作与终端操作

问题:中间操作和终端操作之间的主要区别是什么?

Stream,而终端操作产生除Stream之外的结果(例如,集合或标量值)。换句话说,中间操作允许我们在名为管道的查询类型中链接/调用多个操作。

中间操作直到调用终端操作才会执行。这意味着中间操作是懒惰的。主要是在实际需要某个给定处理的结果时执行它们。终端操作触发Stream的遍历并执行管道。

在中间操作中,我们有map()flatMap()filter()limit()skip()。在终端操作中,我们有sum()min()max()count()collect()

编码挑战 9-peek()函数

peek()函数是做什么的,为什么要使用它?

peek()函数是通过Stream API 提供的一种名为peeking的中间操作。它允许我们查看Stream管道。主要是,peek()应该对当前元素执行某个非干扰的操作,并将元素转发到管道中的下一个操作。通常,这个操作包括在控制台上打印有意义的消息。换句话说,peek()是调试与流和 lambda 表达式处理相关问题的一个很好的选择。例如,想象一下,我们有以下地址列表:

addresses.stream()
  .peek(p -> System.out.println("\tstream(): " + p))
  .filter(s -> s.startsWith("c"))
  .sorted()
  .peek(p -> System.out.println("\tsorted(): " + p))
  .collect(Collectors.toList());

重要的是要提到,即使peek()可以用于改变状态(修改流的数据源),它代表看,但不要触摸。通过peek()改变状态可能在并行流管道中成为真正的问题,因为修改操作可能在上游操作提供的任何时间和任何线程中被调用。因此,如果操作修改了共享状态,它负责提供所需的同步。

作为一个经验法则,在使用peek()来改变状态之前要三思。此外,要注意这种做法在开发人员中是有争议的,并且可以被归类为不良做法甚至反模式的范畴。

编码挑战 10 - 懒惰流

问题:说一个流是懒惰的是什么意思?

解决方案:说一个流是懒惰的意思是,流定义了一系列中间操作的管道,只有当管道遇到终端操作时才会执行。这个问题与本章的编码挑战 8有关。

编码挑战 11 - 函数式接口与常规接口

问题:函数式接口和常规接口之间的主要区别是什么?

解决方案:函数式接口和常规接口之间的主要区别在于,常规接口可以包含任意数量的抽象方法,而函数式接口只能有一个抽象方法。

您可以查阅本书的编码挑战 2以深入了解。

编码挑战 12 - 供应商与消费者

SupplierConsumer

SupplierConsumer是两个内置的函数式接口。Supplier充当工厂方法或new关键字。换句话说,Supplier定义了一个名为get()的方法,不带参数并返回类型为T的对象。因此,Supplier对于提供某个值很有用。

另一方面,Consumer定义了一个名为void accept(T t)的方法。这个方法接受一个参数并返回voidConsumer接口消耗给定的值并对其应用一些操作。与其他函数式接口不同,Consumer可能会引起副作用。例如,Consumer可以用作设置方法。

编码挑战 13 - 谓词

Predicate

Predicate是一个内置的函数式接口,它包含一个抽象方法,其签名为boolean test(T object)

@FunctionalInterface
public interface Predicate<T> {
  boolean test(T t);
  // default and static methods omitted for brevity
}

test()方法测试条件,如果满足条件则返回true,否则返回falsePredicate的常见用法是与Stream<T> filter(Predicate<? super T> predicate)方法一起过滤流中不需要的元素。

编码挑战 14 - findFirst()与 findAny()

findFirst()findAny()

findFirst()方法从流中返回第一个元素,特别适用于获取序列中的第一个元素。只要流有定义的顺序,它就会返回流中的第一个元素。如果没有遇到顺序,那么findFirst()会返回流中的任何元素。

另一方面,findAny()方法从流中返回任何元素。换句话说,它从流中返回一个任意(非确定性)的元素。findAny()方法忽略了遇到的顺序,在非并行操作中,它很可能返回第一个元素,但不能保证这一点。为了最大化性能,在并行操作中无法可靠地确定结果。

请注意,根据流的来源和中间操作,流可能有或可能没有定义的遇到顺序。

编码挑战 15 - 将数组转换为流

问题:如何将数组转换为流?

解决方案:将对象数组转换为流可以通过至少三种方式来完成,如下所示:

  1. 第一种是通过Arrays#stream()
public static <T> Stream<T> toStream(T[] arr) {
  return Arrays.stream(arr);
}
  1. 其次,我们可以使用Stream#of()
public static <T> Stream<T> toStream(T[] arr) {        
  return Stream.of(arr);
}
  1. 最后一种技术是通过List#stream()
public static <T> Stream<T> toStream(T[] arr) {        
  return Arrays.asList(arr).stream();
}

将原始数组(例如整数)转换为流可以通过至少两种方式完成,如下:

  1. 首先,通过Arrays#stream()
public static IntStream toStream(int[] arr) {       
  return Arrays.stream(arr);
}
  1. 其次,通过使用IntStream#of()
public static IntStream toStream(int[] arr) {
  return IntStream.of(arr);
}

当然,对于长整型,您可以使用LongStream,对于双精度浮点数,您可以使用DoubleStream

编码挑战 16-并行流

问题:什么是并行流?

解决方案:并行流是一种可以使用多个线程并行执行的流。例如,您可能需要过滤包含 1000 万个整数的流,以找到小于某个值的整数。您可以使用并行流来代替使用单个线程顺序遍历流。这意味着多个线程将同时在流的不同部分搜索这些整数,然后将结果合并。

编码挑战 17-方法引用

问题:什么是方法引用?

::,然后在其后提供方法的名称。我们有以下引用:

  • 对静态方法的方法引用:Class::staticMethod(例如,Math::max等同于Math.max(x, y)

  • 对构造函数的方法引用:Class::new(例如,AtomicInteger::new等同于new AtomicInteger(x)

  • 对实例方法的方法引用:object::instanceMethodSystem.out::println等同于System.out.println(foo)

  • 对类类型的实例方法的方法引用:Class::instanceMethodString::length等同于str.length()

编码挑战 18-默认方法

问题:什么是默认方法?

解决方案:默认方法主要是在 Java 8 中添加的,以提供对接口的支持,使其可以超越抽象合同(即仅包含抽象方法)。这个功能对于编写库并希望以兼容的方式发展 API 的人非常有用。通过默认方法,接口可以在不破坏现有实现的情况下进行丰富。

默认方法直接在接口中实现,并且通过default关键字识别。例如,以下接口定义了一个名为area()的抽象方法和一个名为perimeter()的默认方法:

public interface Polygon {
  public double area();
  default double perimeter(double... segments) {
    return Arrays.stream(segments)
      .sum();
  }
}

由于Polygon有一个抽象方法,它也是一个函数接口。因此,它可以用@FunctionalInterface注解。

编码挑战 19-迭代器与 Spliterator

IteratorSpliterator

Iterator是为CollectionAPI 创建的,而Spliterator是为StreamAPI 创建的。

通过分析它们的名称,我们注意到Spliterator = Splittable Iterator。因此,Spliterator可以分割给定的源并且也可以迭代它。分割是用于并行处理的。换句话说,Iterator可以顺序迭代Collection中的元素,而Spliterator可以并行或顺序地迭代流的元素。

Iterator只能通过hasNext()/next()遍历集合的元素,因为它没有大小。另一方面,Spliterator可以通过estimateSize()近似地提供集合的大小,也可以通过getExactSizeIfKnown()准确地提供集合的大小。

Spliterator可以使用多个标志来内部禁用不必要的操作(例如,CONCURRENTDISTINCTIMMUTABLE)。Iterator没有这样的标志。

最后,您可以按以下方式围绕Iterator创建一个Spliterator

Spliterators.spliteratorUnknownSize(
  your_Iterator, your_Properties);

在书籍Java 编码问题www.amazon.com/gp/product/B07Y9BPV4W/)中,您可以找到有关此主题的更多详细信息,包括编写自定义Spliterator的完整指南。

编码挑战 20-Optional

Optional类?

Optional类是在 Java 8 中引入的,主要目的是减轻/避免NullPointerException。Java 语言架构师 Brian Goetz 的定义如下:

Optional 旨在为库方法的返回类型提供有限的机制,在需要清晰表示没有结果的情况下,使用 null 很可能会导致错误。

简而言之,您可以将Optional视为一个单值容器,它可以包含一个值或者为空。例如,一个空的Optional看起来像这样:

Optional<User> userOptional = Optional.empty();

一个非空的Optional看起来像这样:

User user = new User();
Optional<User> userOptional = Optional.of(user);

在《Java 编程问题》(www.amazon.com/gp/product/B07Y9BPV4W/)中,您可以找到一个完整的章节专门讨论了使用Optional的最佳实践。这是任何 Java 开发人员必读的章节。

编码挑战 21 - String::valueOf

String::valueOf的意思是什么?

String::valueOf是对String类的valueOf静态方法的方法引用。考虑阅读《编码挑战 17》以获取更多关于这个的信息。

总结

在本章中,我们涵盖了关于 Java 中函数式编程的几个热门话题。虽然这个主题非常广泛,有很多专门的书籍,但在这里涵盖的问题应该足以通过涵盖 Java 8 语言主要特性的常规 Java 面试。

在下一章中,我们将讨论与扩展相关的问题。

第十八章:单元测试

作为开发人员(或软件工程师),您必须在测试领域也具备技能。例如,开发人员负责编写其代码的单元测试(例如,使用 JUnit 或 TestNG)。很可能,不包含单元测试的拉取请求也不会被接受。

在本章中,我们将涵盖单元测试面试问题,如果您申请开发人员或软件工程师等职位,可能会遇到这些问题。当然,如果您正在寻找测试人员(手动/自动化)职位,那么本章可能只代表测试的另一个视角,因此不要期望在这里看到特定于手动/自动化测试人员职位的问题。在本章中,我们将涵盖以下主题:

  • 单元测试简介

  • 问题和编码问题

让我们开始吧!

技术要求

本章中使用的代码可以在 GitHub 上找到:github.com/PacktPublishing/The-Complete-Coding-Interview-Guide-in-Java/tree/master/Chapter18

单元测试简介

测试应用程序的过程包含几个测试层。其中之一是单元测试层。

主要的,一个应用程序是由称为单元的小功能部分构建的(例如,一个常规的 Java 方法可以被认为是一个单元)。测试这些单元在特定输入/条件/约束下的功能和正确性称为单元测试。

这些单元测试是由开发人员使用源代码和测试计划编写的。理想情况下,每个开发人员都应该能够编写测试/验证其代码的单元测试。单元测试应该是有意义的,并提供被接受的代码覆盖率。

如果单元测试失败,那么开发人员负责修复问题并再次执行单元测试。以下图表描述了这一陈述:

图 18.1 – 单元测试流程

图 18.1 – 单元测试流程

单元测试使用单元测试用例单元测试用例是一对输入数据和预期输出,用于塑造对某个功能的测试。

如果您参加的面试要求了解单元测试,如果被问及功能测试和/或集成测试的问题,不要感到惊讶。因此,最好准备好这些问题的答案。

功能测试是基于给定的输入和产生的输出(行为)来测试功能要求,需要将其与预期输出(行为)进行比较。每个功能测试都使用功能规范来验证表示该功能要求实现的组件(或一组组件)的正确性。这在下图中有解释:

图 18.2 – 功能测试

图 18.2 – 功能测试

集成测试的目标是在软件组件被迭代增量地集成时发现缺陷。换句话说,已经进行单元测试的模块被集成(分组或聚合)并按照集成计划进行测试。这在下图中有所描述:

图 18.3 – 集成测试

图 18.3 – 集成测试

关于单元测试和集成测试的问题经常被问及面试候选人,问题是突出这两者之间的主要区别。以下表格将帮助您准备回答这个问题:

图 18.4 – 单元测试和集成测试的比较

图 18.4 – 单元测试和集成测试的比较

一个好的测试人员能够在不做任何关于输入的假设或约束的情况下对测试对象进行压力测试和滥用。这也适用于单元测试。现在我们已经涉及了单元测试,让我们来看看一些关于单元测试的编码挑战和问题。

问题和编码挑战

在这一部分,我们将涵盖与单元测试相关的 15 个问题和编码挑战,这在面试中非常受欢迎。让我们开始吧!

编码挑战 1 - AAA

问题:单元测试中的 AAA 是什么?

解决方案:AAA 首字母缩写代表[A]rrange,[A]ct,[A]ssert,它代表一种构造测试的方法,以维持清晰的代码和可读性。今天,AAA 是一种几乎成为行业标准的测试模式。以下代码片段说明了这一点:

@Test
public void givenStreamWhenSumThenEquals6() {
  // Arrange
  Stream<Integer> theStream = Stream.of(1, 2, 3);
  // Act
  int sum = theStream.mapToInt(i -> i).sum();
  // Assert
  assertEquals(6, sum);
}

安排部分:在这一部分,我们准备或设置测试。例如,在前面的代码中,我们准备了一个整数流,其中的元素是 1、2 和 3。

行动部分:在这一部分,我们执行必要的操作以获得测试的结果。例如,在前面的代码中,我们对流的元素求和,并将结果存储在一个整数变量中。

断言部分:在这一部分,我们检查单元测试的结果是否与预期结果相匹配。这是通过断言来完成的。例如,在前面的代码中,我们检查元素的总和是否等于 6。

你可以在名为junit5/ArrangeActAssert的应用程序中找到这段代码。

编码挑战 2 - FIRST

问题:单元测试中的FIRST是什么?

解决方案:好的测试人员使用 FIRST 来避免在单元测试中遇到的许多问题。FIRST 首字母缩写代表[F]ast,[I]solated,[R]epeatable,[S]elf-validating,[T]imely。让我们看看它们各自的含义:

快速:建议编写运行快速的单元测试。快速是一个依赖于你有多少单元测试、你多频繁运行它们以及你愿意等待它们运行多长时间的任意概念。例如,如果每个单元测试的平均完成时间为 200 毫秒,你运行 5000 个单元测试,那么你将等待约 17 分钟。通常,单元测试很慢,因为它们访问外部资源(例如数据库和文件)。

隔离:理想情况下,你应该能够随时以任何顺序运行任何测试。如果你的单元测试是隔离的,并且专注于小代码片段,这是可能的。良好的单元测试不依赖于其他单元测试,但这并不总是可实现的。尽量避免依赖链,因为当出现问题时它们是有害的,你将不得不进行调试。

可重复:单元测试应该是可重复的。这意味着单元测试的断言每次运行时都应该产生相同的结果。换句话说,单元测试不应该依赖于可能给断言引入可变结果的任何东西。

自我验证:单元测试应该是自我验证的。这意味着你不应该手动验证测试的结果。这是耗时的,并且会显示断言没有完成它们的工作。努力编写断言,使它们按预期工作。

及时:重要的是不要推迟编写单元测试。你推迟得越久,面对的缺陷就会越多。你会发现自己找不到时间回来编写单元测试。想想如果我们不断推迟倒垃圾会发生什么。我们推迟得越久,拿出来就会越困难,我们的健康也会受到风险。我有没有提到气味?所以,及时地编写单元测试。这是一个好习惯!

编码挑战 3 - 测试夹具

问题:什么是测试夹具?

解决方案:通过测试夹具,我们指的是任何存在于测试之外并用于设置应用程序的测试数据,以便它处于固定状态。应用程序的固定状态允许对其进行测试,并且处于一个恒定和已知的环境中。

编码挑战 4-异常测试

问题:在 JUnit 中测试异常的常见方法有哪些?

try/catch习语,@Testexpected元素,以及通过ExpectedException规则。

try/catch习语在 JUnit 3.x 中盛行,并且可以如下使用:

@Test
public void givenStreamWhenGetThenException() {
  Stream<Integer> theStream = Stream.of();
  try {
    theStream.findAny().get();
    fail("Expected a NoSuchElementException to be thrown");
  } catch (NoSuchElementException ex) {
    assertThat(ex.getMessage(), is("No value present"));
  }
}

由于fail()抛出AssertionError,它不能用来测试这种错误类型。

从 JUnit 4 开始,我们可以使用@Test注解的expected元素。该元素的值是预期异常的类型(Throwable的子类)。查看以下示例,该示例使用了expected

@Test(expected = NoSuchElementException.class)
public void givenStreamWhenGetThenException() {
  Stream<Integer> theStream = Stream.of();
  theStream.findAny().get();
}

只要您不想测试异常消息的值,这种方法就可以。此外,请注意,如果任何代码行抛出NoSuchElementException,则测试将通过。您可能期望此异常是由特定代码行引起的,而实际上可能是由其他代码引起的。

另一种方法依赖于ExpectedException规则。从 JUnit 4.13 开始,此方法已被弃用。让我们看看代码:

@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
public void givenStreamWhenGetThenException() 
    throws NoSuchElementException {
  Stream<Integer> theStream = Stream.of();
  thrown.expect(NoSuchElementException.class);
  thrown.expectMessage("No value present");
  theStream.findAny().get();
}

通过这种方法,您可以测试异常消息的值。这些示例已被分组到一个名为junit4/TestingExceptions的应用程序中。

从 JUnit5 开始,我们可以使用两种方法来测试异常。它们都依赖于assertThrows()方法。此方法允许我们断言给定的函数调用(作为 lambda 表达式甚至作为方法引用传递)导致抛出预期类型的异常。以下示例不言自明:

@Test
public void givenStreamWhenGetThenException() {
  assertThrows(NoSuchElementException.class, () -> {
    Stream<Integer> theStream = Stream.of();
    theStream.findAny().get();
  });
}

这个例子只验证了异常的类型。但是,由于异常已被抛出,我们可以断言抛出异常的更多细节。例如,我们可以断言异常消息的值如下:

@Test
public void givenStreamWhenGetThenException() {
  Throwable ex = assertThrows(
    NoSuchElementException.class, () -> {
      Stream<Integer> theStream = Stream.of();
      theStream.findAny().get();
    });
  assertEquals(ex.getMessage(), "No value present");
}

只需使用ex对象来断言您认为从Throwable中有用的任何内容。每当您不需要断言有关异常的详细信息时,请依靠assertThrows(),而不捕获返回。这两个示例已被分组到一个名为junit5/TestingExceptions的应用程序中。

编码挑战 5-开发人员还是测试人员

问题:谁应该使用 JUnit-开发人员还是测试人员?

解决方案:通常,JUnit 由开发人员用于编写 Java 中的单元测试。编写单元测试是测试应用程序代码的编码过程。JUnit 不是一个测试过程。但是,许多测试人员愿意学习并使用 JUnit 进行单元测试。

编码挑战 6-JUnit 扩展

问题:您知道/使用哪些有用的 JUnit 扩展?

解决方案:最常用的 JUnit 扩展是 JWebUnit(用于 Web 应用程序的基于 Java 的测试框架)、XMLUnit(用于测试 XML 的单个 JUnit 扩展类)、Cactus(用于测试服务器端 Java 代码的简单测试框架)和 MockObject(模拟框架)。您需要对这些扩展中的每一个都说几句话。

编码挑战 7-@Before和@After注释

您知道/使用哪些@Before*/@After*注释?

@Before@BeforeClass@After@AfterClass

在每个测试之前执行方法时,我们使用@Before注解对其进行注释。这对于在运行测试之前执行常见的代码片段非常有用(例如,我们可能需要在每个测试之前执行一些重新初始化)。在每个测试之后清理舞台时,我们使用@After注解对方法进行注释。

当仅在所有测试之前执行一次方法时,我们使用@BeforeClass注解对其进行注释。该方法必须是static的。这对于全局和昂贵的设置非常有用,例如打开到数据库的连接。在所有测试完成后清理舞台时,我们使用@AfterClass注解对一个static方法进行注释;例如,关闭数据库连接。

您可以在名为junit4/BeforeAfterAnnotations的简单示例中找到一个简单的示例。

从 JUnit5 开始,我们有@BeforeEach作为@Before的等效项,@BeforeAll作为@BeforeClass的等效项。实际上,@Before@BeforeClass被重命名为更具指示性的名称,以避免混淆。

您可以在名称为junit5/BeforeAfterAnnotations的简单示例中找到这个。

编码挑战 8 - 模拟和存根

问题:模拟和存根是什么?

解决方案:模拟是一种用于创建模拟真实对象的对象的技术。这些对象可以预先编程(或预设或预配置)期望,并且我们可以检查它们是否已被调用。在最广泛使用的模拟框架中,我们有 Mockito 和 EasyMock。

存根类似于模拟,只是我们无法检查它们是否已被调用。存根预先配置为使用特定输入产生特定输出。

编码挑战 9 - 测试套件

问题:什么是测试套件?

解决方案:测试套件是将多个测试聚合在多个测试类和包中,以便它们一起运行的概念。

在 JUnit4 中,我们可以通过org.junit.runners.Suite运行器和@SuiteClasses(...)注解来定义测试套件。例如,以下代码片段是一个聚合了三个测试(TestConnect.classTestHeartbeat.classTestDisconnect.class)的测试套件:

@RunWith(Suite.class)
@Suite.SuiteClasses({
  TestConnect.class,
  TestHeartbeat.class,
  TestDisconnect.class
})
public class TestSuite {
    // this class was intentionally left empty
}

完整的代码称为junit4/TestSuite

在 JUnit5 中,我们可以通过@SelectPackages@SelectClasses注解来定义测试套件。

@SelectPackages注解对于从不同包中聚合测试非常有用。我们只需要指定包的名称,如下例所示:

@RunWith(JUnitPlatform.class)
@SuiteDisplayName("TEST LOGIN AND CONNECTION")
@SelectPackages({
  "coding.challenge.connection.test",
  "coding.challenge.login.test"
})
public class TestLoginSuite {
  // this class was intentionally left empty
}

@SelectClasses注解对于通过类名聚合测试非常有用:

@RunWith(JUnitPlatform.class)
@SuiteDisplayName("TEST CONNECTION")
@SelectClasses({
  TestConnect.class, 
  TestHeartbeat.class, 
  TestDisconnect.class
})
public class TestConnectionSuite {
  // this class was intentionally left empty
}

完整的代码称为junit5/TestSuite

此外,可以通过以下注解来过滤测试包、测试类和测试方法:

  • 过滤包:@IncludePackages@ExcludePackages

  • 过滤测试类:@IncludeClassNamePatterns@ExcludeClassNamePatterns

  • 过滤测试方法:@IncludeTags@ExcludeTags

编码挑战 10 - 忽略测试方法

问题:如何忽略测试?

@Ignore注解。在 JUnit5 中,我们可以通过@Disable注解做同样的事情。

忽略测试方法在我们预先编写了一些测试并且希望在运行当前测试时不运行这些特定测试时是有用的。

编码挑战 11 - 假设

问题:什么是假设?

解决方案:假设用于执行测试,如果满足指定条件,则使用假设。它们通常用于处理测试执行所需的外部条件,但这些条件不在我们的控制范围之内,或者与被测试的内容不直接相关。

在 JUnit4 中,假设是可以在org.junit.Assume包中找到的static方法。在这些假设中,我们有assumeThat()assumeTrue()assumeFalse()。以下代码片段举例说明了assumeThat()的用法:

@Test
public void givenFolderWhenGetAbsolutePathThenSuccess() {
  assumeThat(File.separatorChar, is('/'));
  assertThat(new File(".").getAbsolutePath(),
    is("C:/SBPBP/GitHub/Chapter18/junit4"));
}

如果assumeThat()不满足给定条件,则测试将被跳过。完整的应用程序称为junit4/Assumptions

在 JUnit5 中,假设是可以在org.junit.jupiter.api.Assumptions包中找到的static方法。在这些假设中,我们有assumeThat()assumeTrue()assumeFalse()。所有三种都有不同的用法。以下代码片段举例说明了assumeThat()的用法:

@Test
public void givenFolderWhenGetAbsolutePathThenSuccess() {
  assumingThat(File.separatorChar == '/',
   () -> {
     assertThat(new File(".").getAbsolutePath(), 
       is("C:/SBPBP/GitHub/Chapter18/junit5"));
   });
   // run these assertions always, just like normal test
   assertTrue(true);
}

请注意,测试方法(assertThat())只有在满足假设时才会执行。lambda 之后的所有内容都将被执行,而不管假设的有效性如何。完整的应用程序称为junit5/Assumptions

编码挑战 12 - @Rule

@Rule

解决方案:JUnit 通过所谓的规则提供了高度的灵活性。规则允许我们创建和隔离对象(代码),并在多个测试类中重用这些代码。主要是通过可重用的规则增强测试。JUnit 提供了内置规则和可以用来编写自定义规则的 API。

编码挑战 13 - 方法测试返回类型

在 JUnit 测试方法中使用void

void转换为其他内容,但 JUnit 不会将其识别为测试方法,因此在测试执行期间将被忽略。

编码挑战 14 - 动态测试

问题:我们能在 JUnit 中编写动态测试(在运行时生成的测试)吗?

@Test是在编译时完全定义的静态测试。JUnit5 引入了动态测试 - 动态测试是在运行时生成的。

动态测试是通过一个工厂方法生成的,这个方法使用@TestFactory注解进行注释。这样的方法可以返回DynamicTest实例的IteratorIterableCollectionStream。工厂方法没有被@Test注解,并且不是privatestatic。此外,动态测试不能利用生命周期回调(例如,@BeforeEach@AfterEach会被忽略)。

让我们看一个简单的例子:

1: @TestFactory
2: Stream<DynamicTest> dynamicTestsExample() {
3:
4:   List<Integer> items = Arrays.asList(1, 2, 3, 4, 5);
5:
6:   List<DynamicTest> dynamicTests = new ArrayList<>();
7:
8:   for (int item : items) {
9:     DynamicTest dynamicTest = dynamicTest(
10:        "pow(" + item + ", 2):", () -> {
11:        assertEquals(item * item, Math.pow(item, 2));
12:    });
13:    dynamicTests.add(dynamicTest);
14:  }
15:
16:  return dynamicTests.stream();
17: }

现在,让我们指出主要的代码行:

@TestFactory注解来指示 JUnit5 这是一个动态测试的工厂方法。

Stream<DynamicTest>

4:我们测试的输入是一个整数列表。对于每个整数,我们生成一个动态测试。

List<DynamicTest>。在这个列表中,我们添加每个生成的测试。

8-12:我们为每个整数生成一个测试。每个测试都有一个名称和包含必要断言的 lambda 表达式。

13:我们将生成的测试存储在适当的列表中。

测试的Stream

运行这个测试工厂将产生五个测试。完整的例子被称为junit5/TestFactory

编码挑战 15 - 嵌套测试

问题:我们能在 JUnit5 中编写嵌套测试吗?

@Nested注解。实际上,我们创建了一个嵌套测试类层次结构。这个层次结构可能包含设置、拆卸和测试方法。然而,我们必须遵守一些规则,如下:

  • 嵌套测试类使用@Nested注解进行注释。

  • 嵌套测试类是非static的内部类。

  • 嵌套测试类可以包含一个@BeforeEach方法,一个@AfterEach方法和测试方法。

  • 内部类中不允许使用static成员,这意味着嵌套测试中不能使用@BeforeAll@AfterAll方法。

  • 类层次结构的深度是无限的。

嵌套测试的一些示例代码可以在这里看到:

@RunWith(JUnitPlatform.class)
public class NestedTest {
  private static final Logger log 
    = Logger.getLogger(NestedTest.class.getName());
  @DisplayName("Test 1 - not nested")
  @Test
  void test1() {
    log.info("Execute test1() ...");
  }
  @Nested
  @DisplayName("Running tests nested in class A")
  class A {
    @BeforeEach
    void beforeEach() {
      System.out.println("Before each test 
        method of the A class");
    }
    @AfterEach
    void afterEach() {
      System.out.println("After each test 
        method of the A class");
    }
    @Test
    @DisplayName("Test2 - nested in class A")
    void test2() {
      log.info("Execute test2() ...");
    }
  }
}

完整的例子被称为junit5/NestedTests

总结

在本章中,我们涵盖了关于通过 JUnit4 和 JUnit5 进行单元测试的几个热门问题和编码挑战。不要忽视这个话题是很重要的。很可能,在 Java 开发人员或软件工程师职位的面试的最后部分,你会得到一些与测试相关的问题。此外,这些问题将与单元测试和 JUnit 相关。

在下一章中,我们将讨论与扩展和扩展相关的面试问题。

第十九章:系统可伸缩性

可伸缩性无疑是 Web 应用程序成功的最关键需求之一。应用程序的可伸缩能力取决于整个系统架构,而在构建项目时考虑可伸缩性是最佳选择。当业务的成功可能需要应用程序因大量流量而需要高度可伸缩时,您以后会非常感激。

因此,随着网络的发展,设计和构建可伸缩的应用程序也变得更加重要。在本章中,我们将涵盖您在初级/中级面试中可能会被问到的所有可伸缩性问题,比如 Web 应用程序软件架构师、Java 架构师或软件工程师等职位。如果您正在寻找的职位不涉及与软件架构和设计相关的任务,那么可伸缩性很可能不会成为面试话题。

本章的议程包括以下内容:

  • 简而言之,可伸缩性

  • 问题和编码挑战

让我们开始吧!

简而言之,可伸缩性

面试官最可预测但也最重要的问题是:什么是可伸缩性?可伸缩性是指一个过程(系统、网络、应用程序)应对工作负载增加的能力和能力(通过工作负载,我们理解任何推动系统极限的东西,如流量、存储容量、最大交易数量等),当添加资源(通常是硬件)时。可伸缩性可以表示系统性能提升与资源使用增加之间的比率。此外,可伸缩性还意味着能够在不影响/修改主节点结构的情况下添加额外的资源。

如果增加更多资源导致性能略微提高,甚至更糟的是,增加资源对性能没有影响,那么您面临所谓的可伸缩性差

您如何实现可伸缩性?在涉及可伸缩性问题的面试中,您很可能也会被问到这个问题。给出一个一般、全面且不会花费太多时间的答案是最佳选择。应该触及的主要点包括以下内容:

  • 利用 12 要素https://12factor.net/):这种方法与编程语言无关,对于交付灵活和可伸缩的应用程序非常有帮助。

  • 明智地实现持久性:从为应用程序选择合适的数据库和开发最优化的模式,到掌握扩展持久层的技术(例如,集群、副本、分片等),这是值得您全部关注的关键方面之一。

  • 不要低估查询:数据库查询是获取短事务的关键因素。调整连接池和查询以实现可伸缩性。例如,注意跨节点连接,这可能会迅速降低性能。

  • 选择托管和工具:扩展不仅仅是代码!基础设施也非常重要。今天,许多云服务提供商(例如亚马逊)提供自动扩展和专用工具(Docker、Kubernetes 等)。

  • 考虑负载均衡和反向代理:有一天,您必须从单服务器切换到多服务器架构。在云基础设施下运行(例如亚马逊),只需进行几项配置即可轻松提供这些设施(对于大多数云服务提供商,负载均衡和反向代理是即插即用的一部分)。否则,您必须为这一重大变化做好准备。

  • 缓存:在扩展应用程序时,考虑新的缓存策略、拓扑和工具。

  • 减轻后端负担:尽可能将尽可能多的计算从后端移到前端。这样,您可以减轻后端的工作负担。

  • 测试和监控:测试和监控代码将帮助您尽快发现问题。

还有许多其他方面需要讨论,但在这一点上,面试官应该准备将面试推进到下一步。

问题和编码挑战

在本节中,我们涵盖了 13 个问题和编码挑战,这些问题和挑战在初中级可扩展性面试中是必须了解的。让我们开始吧!

编码挑战 1 - 扩展类型

问题:扩展和扩展意味着什么?

解决方案:扩展(或纵向扩展)是通过向现有系统添加更多资源来实现更好的性能并成功应对更大的工作负载。通过资源,我们可以理解更多的存储、更多的内存、更多的网络、更多的线程、更多的连接、更强大的主机、更多的缓存等。添加新资源后,应用程序应能够遵守服务级别协议。今天,在云中扩展是非常高效和快速的。像 AWS、Azure、Oracle、Heroku、Google Cloud 等云可以根据阈值计划自动分配更多的资源,仅需几分钟。当流量减少时,AWS 可以禁用这些额外的资源。这样,您只需支付您使用的部分。

扩展(或横向扩展)通常与分布式架构相关。有两种基本形式的扩展:

  • 在预打包的基础设施/节点块中增加更多的基础设施容量(例如,超融合)。

  • 使用独立的分布式服务来收集有关客户的信息。

通常,扩展是通过添加更多与当前使用的相同类型或任何兼容类型的服务器或 CPU 来完成的。扩展使服务提供商能够为客户提供“按需增长”的基础设施和服务。扩展速度相当快,因为不需要导入或重建任何东西。然而,扩展速度受服务器通信速度的限制。

像 AWS 这样的云可以根据阈值计划自动分配更多的基础设施,仅需几分钟。当流量较低时,AWS 可以禁用这些额外的基础设施。这样,您只需支付您使用的部分。

通常,扩展提供比扩展更好的性能。

编码挑战 2 - 高可用性

问题:什么是高可用性?

解决方案:高可用性和低延迟对于许多企业来说至关重要。

通常以一年中的正常运行时间的百分比来表示,当应用程序在没有中断的情况下对用户可用时,就实现了高可用性(在一年内 99.9%的时间内)。

通过集群实现高可用性是常见的。

编码挑战 3 - 低延迟

问题:什么是低延迟?

解决方案:低延迟是与计算机网络相关的术语,它被优化为以最小的延迟或延迟处理极高数量的数据。这样的网络被设计和构建用于处理试图实现几乎实时数据处理能力的操作。

编码挑战 4 - 集群

问题:什么是集群,为什么我们需要集群?

解决方案:集群是一组可以单独运行应用程序的机器。我们可以有应用程序服务器集群、数据库服务器集群等。

拥有集群显著降低了我们的服务在集群中的一台机器失败时变得不可用的机会。换句话说,集群的主要目的是实现 100%的可用性或服务的零停机时间(高可用性 - 见编码挑战 2)。当然,所有集群机器同时失败的可能性仍然很小,但通常通过将机器放置在不同的位置或由它们自己的资源支持来减轻这种可能性。

编码挑战 5 - 延迟、带宽和吞吐量

问题:什么是延迟、带宽和吞吐量?

解决方案:在面试中解释这些概念的最佳方法是使用下图中的管道进行简单类比:

图 19.1 – 延迟与带宽与吞吐量

图 19.1 – 延迟与带宽与吞吐量

延迟是通过管道传输所需的时间,而不是管道长度。但是,它作为管道长度的函数来衡量。

带宽是管道有多宽。

吞吐量是通过管道流动的水量。

编码挑战 6 – 负载均衡

问题:什么是负载均衡?

解决方案:负载均衡是一种用于在多台机器或集群之间分配工作负载的技术。在负载均衡使用的算法中,有循环轮询、粘性会话(或会话亲和性)和 IP 地址亲和性。常见且简单的算法是循环轮询,它按循环顺序分配工作负载,确保所有可用的机器获得相等数量的请求,没有一台机器过载或负载不足。

例如,下图标记了典型主从架构中负载均衡器的位置:

图 19.2 – 主从架构中的负载均衡器

图 19.2 – 主从架构中的负载均衡器

通过在机器之间分配工作,负载均衡力求实现最大吞吐量和响应时间。

编码挑战 7 – 粘性会话

问题:什么是粘性会话(或会话亲和性)?

解决方案:粘性会话(或会话亲和性)是负载均衡器中遇到的一个概念。通常,用户信息存储在会话中,并且会话在集群中的所有机器上都有副本。但是会话复制(参见编码挑战 11)可以通过从同一台机器为特定用户会话请求提供服务来避免。

因此,会话与机器关联。这发生在会话创建时。对于此会话的所有传入请求始终重定向到关联的机器。用户数据仅在该机器上。

在 Java 中,粘性会话通常通过jsessionid cookie 来实现。在第一次请求时,cookie 被发送到客户端。对于每个后续请求,客户端请求也包含 cookie。这样,cookie 标识了会话。

粘性会话方法的主要缺点在于,如果机器失败,则用户信息丢失,该会话无法恢复。如果客户端浏览器不支持 cookie 或禁用 cookie,则无法通过 cookie 实现粘性会话。

编码挑战 8 – 分片

问题:什么是分片?

解决方案:分片是一种将单个逻辑数据库系统分布在一组机器上的架构技术。下图描述了这种说法:

图 19.3 – 分片

图 19.3 – 分片

如前面的图所示,分片是关于数据库方案的水平分区。主要是将数据库表(例如teams)的行分别存储(例如,西数据中心保存奇数行,而东数据中心保存偶数行),而不是将表分割为列(将表分割为列称为规范化和垂直分区)。

每个分区称为分片。从前面的图中可以看出,每个分片可以独立地位于物理位置或单独的数据库服务器上。

分片的目标是使数据库系统具有高度可伸缩性。每个分片中的行数较少,减少了索引大小,并提高了读取/搜索操作的性能。

分片的缺点如下:

  • 应用程序必须知道数据的位置。

  • 向系统添加/删除节点需要重新平衡系统。

  • 跨节点连接查询会带来性能惩罚。

编码挑战 9 – 无共享架构

问题:什么是无共享架构?

解决方案:无共享架构(标记为SN)是一种分布式计算技术,它认为每个节点都是独立的,并包含其需要具有自治权的一切。此外,系统中不需要任何单一的争用点。SN 架构的主要方面包括以下内容:

  • 节点独立工作。

  • 节点之间没有共享资源(内存、文件等)。

  • 如果一个节点失败,那么它只影响其用户(其他节点继续工作)。

具有线性和理论上无限的可扩展性,SN 架构非常受欢迎。谷歌是依赖 SN 的主要参与者之一。

编码挑战 10 - 故障转移

问题:什么是故障转移?

解决方案:故障转移是一种通过在集群中的另一台机器上切换来实现高可用性的技术。通常,故障转移是通过负载均衡器自动应用的,通过心跳检查机制。主要是通过负载均衡器检查机器的可用性,确保它们响应。如果某台机器的心跳失败(机器没有响应),那么负载均衡器就不会向其发送任何请求,并将请求重定向到集群中的另一台机器。

编码挑战 11 - 会话复制

问题:什么是会话复制?

解决方案:会话复制通常出现在应用服务器集群中,其主要目标是实现会话故障转移。

会话复制是每次用户更改其当前会话时应用的。主要是,用户会话会自动复制到集群中的其他机器。这样,如果一台机器失败,负载均衡器会将传入的请求发送到集群中的另一台机器。由于集群中的每台机器都有用户会话的副本,负载均衡器可以选择其中任何一台机器。

虽然会话复制可以维持会话故障转移,但在内存和网络带宽方面可能会有额外的成本。

编码挑战 12 - CAP 定理

问题:CAP 定理是什么?

解决方案:CAP 定理由 Eric Brewer 发布,专门针对分布式计算。根据这个定理,分布式计算系统只能同时提供以下三个中的两个:

  • 一致性:并发更新对所有节点都是可用的。

  • 可用性:每个请求都会收到成功或失败的响应。

  • 分区容忍性:系统在部分故障的情况下仍然可以运行。

以下图描述了 CAP 定理:

图 19.4 - CAP 定理

图 19.4 - CAP 定理

谷歌、Facebook 和亚马逊等公司使用 CAP 定理来决定其应用架构。

编码挑战 13 - 社交网络

问题:您将如何为像 Facebook 这样的社交网络设计数据结构?描述一种算法来显示两个人之间的最短路径(例如,Tom → Alice → Mary → Kely)。

解决方案:通常,社交网络是使用图来设计的。结果是一个庞大的图,如下图所示(此图是通过 Google 图像通过社交网络图关键字收集的):

图 19.5 - 社交网络图

图 19.5 - 社交网络图

因此,找到两个人之间的路径意味着在这样的图中找到一条路径。在这种情况下,问题就变成了如何在这样一个庞大的图中高效地找到两个节点之间的路径。

我们可以从一个人开始,遍历图来找到另一个人。遍历图可以使用BFS广度优先搜索)或DFS深度优先搜索)来完成。有关这些算法的更多细节,请查看第十三章树和图

DFS 将非常低效!两个人可能只相隔一度,但 DFS 可能在找到这种相对即时的连接之前遍历数百万个节点(人)。

因此,胜利者是 BFS。更确切地说,我们可以采用双向 BFS。就像两列火车从相反的方向开来,在某个时刻相交一样,我们使用一个从人A(源)开始的 BFS,和一个从人B(目的地)开始的 BFS。当搜索相撞时,我们找到了AB之间的路径。

为什么不使用单向 BFS?因为从AB会遍历p+pp人。主要是,单向 BFS 将遍历Ap个朋友,然后是每个朋友的p个朋友。这意味着对于长度为q的路径,单向 BFS 将在 O(pq)的运行时间内执行。另一方面,双向 BFS 遍历 2p个节点:每个Ap个朋友和每个Bp个朋友。这意味着对于长度为q*的路径,双向 BFS 执行 O(pq/2+ pq/2) = O(pq/2)。显然,O(pq/2)比 O(pq)更好。

让我们考虑一个路径,比如 Ana -> Bob -> Carla -> Dan -> Elvira,每个人都有 100 个朋友。单向 BFS 将遍历 1 亿(1004)个节点。双向 BFS 只会遍历 2 万个节点(2 x 1002)。

找到连接AB的有效方法只是其中一个问题。另一个问题是由于人数众多,当数据量如此之大以至于无法存储在一台机器上时。这意味着我们的图将使用多台机器(例如,一个集群)。如果我们将用户列表表示为 ID 列表,那么我们可以使用分片并在每台机器上存储 ID 范围。这样,我们通过首先进入包含该人 ID 的机器来沿着路径前进到下一个人。

为了减少在机器之间的大量随机跳跃,这将降低性能,我们可以通过考虑国家、城市、州等来分布用户到机器上。同一个国家的用户更有可能成为朋友。

需要回答的更多问题包括缓存使用、何时停止没有结果的搜索、如果机器出现故障该怎么办等等。

很明显,解决前述问题等问题并不是一件容易的事。这需要解决很多问题和问题,因此阅读和尽可能多地实践是必须的。

实践是成功的关键

这个简短章节的主题值得一本整书。但是,挑战自己解决以下前 10 个问题将增强您对可扩展性的见解,并增加成为软件工程师的机会。

设计 bitly、TinyURL 和 goo.gl(用于缩短 URL 的服务)

需要解决的问题:

  • 如何为每个给定的 URL 分配一个唯一的标识符(ID)?

  • 每秒有数千个 URL,如何在规模上生成唯一的标识符(ID)?

  • 如何处理重定向?

  • 如何处理自定义短 URL?

  • 如何处理过期的 URL(删除它们)?

  • 如何跟踪统计数据(例如,点击统计)?

设计 Netflix、Twitch 和 YouTube(全球视频流服务)

需要解决的问题:

  • 如何存储和分发数据以适应大量同时用户(用户可以观看和分享数据)?

  • 如何跟踪统计数据(例如,总浏览次数、投票等)?

  • 如何允许用户在视频上添加评论(最好是实时的)?

设计 WhatsApp 和 Facebook Messenger(全球聊天服务)

需要解决的问题:

  • 如何设计用户之间的一对一对话/会议?

  • 如何设计群聊/会议?

  • 如何处理离线用户(未连接到互联网)?

  • 何时发送推送通知?

  • 如何支持端到端加密?

设计 Reddit、HackerNews、Quora 和 Voat(留言板服务和社交网络)

需要解决的问题:

  • 如何跟踪每个答案的统计数据(总浏览次数、投票等)?

  • 如何允许用户关注其他用户或主题?

  • 如何设计包含用户热门问题的时间线(类似于新闻源生成)?

设计谷歌云盘、谷歌相册和 Dropbox(全球文件存储和共享服务)

需要解决的问题:

  • 如何设计用户功能,如上传、搜索、查看和共享文件/照片?

  • 如何跟踪文件共享的权限?

  • 如何允许一组用户编辑同一文档?

设计 Twitter、Facebook 和 Instagram(一个非常大的社交媒体服务)

需要解决的问题:

  • 如何高效存储和搜索帖子/推文?

  • 如何实现新闻源生成?

  • 如何解决社交图(参见编码挑战 13)?

设计 Lyft、Uber 和 RideAustin(共乘服务)

需要解决的问题:

  • 如何将乘车请求与附近的司机匹配?

  • 如何为不断移动的乘客和司机存储数百万个位置(地理坐标)?

  • 如何更新驾驶员/乘客位置(每秒更新一次)?

设计类型提前和网络爬虫(与搜索引擎相关的服务)

需要解决的问题:

  • 如何刷新数据?

  • 如何存储先前的搜索查询?

  • 如何检测已输入字符串的最佳匹配?

  • 当用户输入速度过快时,如何解决?

  • 如何找到新页面(网页)?

  • 如何为动态变化的网页分配优先级?

  • 如何确保爬虫不会永远卡在同一个域上?

设计 API 速率限制器(例如 GitHub 或 Firebase)

需要解决的问题:

  • 如何限制在时间窗口内的请求数量(例如,每秒 30 个请求)?

  • 如何实现在服务器集群中工作的速率限制?

  • 如何解决限流(软限流和硬限流)?

设计附近的地方/朋友和 Yelp(一个临近服务器)

需要解决的问题:

  • 如何搜索附近的朋友或地点?

  • 如何对地点进行排名?

  • 如何根据人口密度存储位置数据?

回答这些挑战并不是一件容易的事,需要丰富的经验。然而,如果你是一名初级/中级程序员,并且已经阅读了关于可扩展性的介绍性章节,那么你应该能够决定你的职业道路是否应该朝这个方向发展。然而,请记住,设计大规模分布式系统是软件工程面试中一个非常苛刻的领域。

总结

这是本书的最后一章。我们刚刚涵盖了一系列与可扩展性主题相关的问题。

恭喜你走到了这一步!现在,在本书的最后,记得尽可能多地练习,对自己的判断有信心,永不放弃!我真诚地希望你的下一个 Java 职位能给你带来梦想的工作,而这本书能为你的成功做出贡献。

posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报