首页 文章 精选 留言 我的

精选列表

搜索[面试],共4915篇文章
优秀的个人博客,低调大师

面试中你有遇到这些Spring Cloud常问题吗?知道如何完美解答吗?

![](https://upload-images.jianshu.io/upload_images/15462057-4563d327e947f117.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) ### **为什么需要学习Spring Cloud** 不论是商业应用还是用户应用,在业务初期都很简单,我们通常会把它实现为单体结构的应用。但是,随着业务逐渐发展,产品思想会变得越来越复杂,单体结构的应用也会越来越复杂。这就会给应用带来如下的几个问题: * **代码结构混乱:**业务复杂,导致代码量很大,管理会越来越困难。同时,这也会给业务的快速迭代带来巨大挑战; * **开发效率变低:**开发人员同时开发一套代码,很难避免代码冲突。开发过程会伴随着不断解决冲突的过程,这会严重的影响开发效率; * **排查解决问题成本高:**线上业务发现 bug,修复 bug 的过程可能很简单。但是,由于只有一套代码,需要重新编译、打包、上线,成本很高。 由于单体结构的应用随着系统复杂度的增高,会暴露出各种各样的问题。近些年来,微服务架构逐渐取代了单体架构,且这种趋势将会越来越流行。Spring Cloud是目前最常用的微服务开发框架,已经在企业级开发中大量的应用。 ### **什么是Spring Cloud** Spring Cloud是一系列框架的有序集合。它利用Spring Boot的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册、配置中心、智能路由、消息总线、负载均衡、断路器、数据监控等,都可以用Spring Boot的开发风格做到一键启动和部署。 Spring Cloud并没有重复制造轮子,它只是将各家公司开发的比较成熟、经得起实际考验的服务框架组合起来,通过Spring Boot风格进行再封装屏蔽掉了复杂的配置和实现原理,最终给开发者留出了一套简单易懂、易部署和易维护的分布式系统开发工具包。 ### **设计目标与优缺点** **设计目标** 协调各个微服务,简化分布式系统开发。 **优缺点** 微服务的框架那么多比如:dubbo、Kubernetes,为什么就要使用Spring Cloud的呢? 优点: * 产出于Spring大家族,Spring在企业级开发框架中无人能敌,来头很大,可以保证后续的更新、完善 * 组件丰富,功能齐全。Spring Cloud 为微服务架构提供了非常完整的支持。例如、配置管理、服务发现、断路器、微服务网关等; * Spring Cloud 社区活跃度很高,教程很丰富,遇到问题很容易找到解决方案 * 服务拆分粒度更细,耦合度比较低,有利于资源重复利用,有利于提高开发效率 * 可以更精准的制定优化服务方案,提高系统的可维护性 * 减轻团队的成本,可以并行开发,不用关注其他人怎么开发,先关注自己的开发 * 微服务可以是跨平台的,可以用任何一种语言开发 * 适于互联网时代,产品迭代周期更短 缺点: * 微服务过多,治理成本高,不利于维护系统 * 分布式系统开发的成本高(容错,分布式事务等)对团队挑战大 总的来说优点大过于缺点,目前看来Spring Cloud是一套非常完善的分布式框架,目前很多企业开始用微服务、Spring Cloud的优势是显而易见的。因此对于想研究微服务架构的同学来说,学习Spring Cloud是一个不错的选择。 ### **Spring Cloud发展前景** Spring Cloud对于中小型互联网公司来说是一种福音,因为这类公司往往没有实力或者没有足够的资金投入去开发自己的分布式系统基础设施,使用Spring Cloud一站式解决方案能在从容应对业务发展的同时大大减少开发成本。 同时,随着近几年微服务架构和Docker容器概念的火爆,也会让Spring Cloud在未来越来越“云”化的软件开发风格中立有一席之地,尤其是在五花八门的分布式解决方案中提供了标准化的、全站式的技术方案,意义可能会堪比当年Servlet规范的诞生,有效推进服务端软件系统技术水平的进步。 ### **整体架构** ![](https://upload-images.jianshu.io/upload_images/15462057-921c219c4b31b9e1?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) ### **主要项目** Spring Cloud的子项目,大致可分成两类,一类是对现有成熟框架"Spring Boot化"的封装和抽象,也是数量最多的项目;第二类是开发了一部分分布式系统的基础设施的实现,如Spring Cloud Stream扮演的就是kafka, ActiveMQ这样的角色。 **Spring Cloud Config** 集中配置管理工具,分布式系统中统一的外部配置管理,默认使用Git来存储配置,可以支持客户端配置的刷新及加密、解密操作。 **Spring Cloud Netflix** Netflix OSS 开源组件集成,包括Eureka、Hystrix、Ribbon、Feign、Zuul等核心组件。 * Eureka:服务治理组件,包括服务端的注册中心和客户端的服务发现机制; * Ribbon:负载均衡的服务调用组件,具有多种负载均衡调用策略; * Hystrix:服务容错组件,实现了断路器模式,为依赖服务的出错和延迟提供了容错能力; * Feign:基于Ribbon和Hystrix的声明式服务调用组件; * Zuul:API网关组件,对请求提供路由及过滤功能。 **Spring Cloud Bus** 用于传播集群状态变化的消息总线,使用轻量级消息代理链接分布式系统中的节点,可以用来动态刷新集群中的服务配置。 **Spring Cloud Consul** 基于Hashicorp Consul的服务治理组件。 **Spring Cloud Security** 安全工具包,对Zuul代理中的负载均衡OAuth2客户端及登录认证进行支持。 **Spring Cloud Sleuth** Spring Cloud应用程序的分布式请求链路跟踪,支持使用Zipkin、HTrace和基于日志(例如ELK)的跟踪。 **Spring Cloud Stream** 轻量级事件驱动微服务框架,可以使用简单的声明式模型来发送及接收消息,主要实现为Apache Kafka及RabbitMQ。 **Spring Cloud Task** 用于快速构建短暂、有限数据处理任务的微服务框架,用于向应用中添加功能性和非功能性的特性。 **Spring Cloud Zookeeper** 基于Apache Zookeeper的服务治理组件。 **Spring Cloud Gateway** API网关组件,对请求提供路由及过滤功能。 **Spring Cloud OpenFeign** 基于Ribbon和Hystrix的声明式服务调用组件,可以动态创建基于Spring MVC注解的接口实现用于服务调用,在Spring Cloud 2.0中已经取代Feign成为了一等公民。 ### **Spring Cloud的版本关系** Spring Cloud是一个由许多子项目组成的综合项目,各子项目有不同的发布节奏。为了管理Spring Cloud与各子项目的版本依赖关系,发布了一个清单,其中包括了某个Spring Cloud版本对应的子项目版本。 为了避免Spring Cloud版本号与子项目版本号混淆,Spring Cloud版本采用了名称而非版本号的命名,这些版本的名字采用了伦敦地铁站的名字,根据字母表的顺序来对应版本时间顺序,例如Angel是第一个版本,Brixton是第二个版本。 当Spring Cloud的发布内容积累到临界点或者一个重大BUG被解决后,会发布一个"service releases"版本,简称SRX版本,比如Greenwich.SR2就是Spring Cloud发布的Greenwich版本的第2个SRX版本。目前Spring Cloud的最新版本是Hoxton。 **Spring Cloud和SpringBoot版本对应关系** ![](https://upload-images.jianshu.io/upload_images/15462057-9676c7206b488b17?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) **Spring Cloud和各子项目版本对应关系** ![](https://upload-images.jianshu.io/upload_images/15462057-4a5275a56e73b175?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 注意:Hoxton版本是基于SpringBoot 2.2.x版本构建的,不适用于1.5.x版本。随着2019年8月SpringBoot 1.5.x版本停止维护,Edgware版本也将停止维护。 ### **SpringBoot和SpringCloud的区别?** SpringBoot专注于快速方便的开发单个个体微服务。 SpringCloud是关注全局的微服务协调整理治理框架,它将SpringBoot开发的一个个单体微服务整合并管理起来, 为各个微服务之间提供,配置管理、服务发现、断路器、路由、微代理、事件总线、全局锁、决策竞选、分布式会话等等集成服务 SpringBoot可以离开SpringCloud独立使用开发项目, 但是SpringCloud离不开SpringBoot ,属于依赖的关系 SpringBoot专注于快速、方便的开发单个微服务个体,SpringCloud关注全局的服务治理框架。 ### **使用 Spring Boot 开发分布式微服务时,我们面临以下问题** (1)与分布式系统相关的复杂性-这种开销包括网络问题,延迟开销,带宽问题,安全问题。 (2)服务发现-服务发现工具管理群集中的流程和服务如何查找和互相交谈。它涉及一个服务目录,在该目录中注册服务,然后能够查找并连接到该目录中的服务。 (3)冗余-分布式系统中的冗余问题。 (4)负载平衡 --负载平衡改善跨多个计算资源的工作负荷,诸如计算机,计算机集群,网络链路,中央处理单元,或磁盘驱动器的分布。 (5)性能-问题 由于各种运营开销导致的性能问题。 (6)部署复杂性-Devops 技能的要求。 ### **服务注册和发现是什么意思?Spring Cloud 如何实现?** 当我们开始一个项目时,我们通常在属性文件中进行所有的配置。随着越来越多的服务开发和部署,添加和修改这些属性变得更加复杂。有些服务可能会下降,而某些位置可能会发生变化。手动更改属性可能会产生问题。 Eureka 服务注册和发现可以在这种情况下提供帮助。由于所有服务都在 Eureka 服务器上注册并通过调用 Eureka 服务器完成查找,因此无需处理服务地点的任何更改和处理。 ### **Spring Cloud 和dubbo区别?** (1)服务调用方式 dubbo是RPC springcloud Rest Api (2)注册中心,dubbo 是zookeeper springcloud是eureka,也可以是zookeeper (3)服务网关,dubbo本身没有实现,只能通过其他第三方技术整合,springcloud有Zuul路由网关,作为路由服务器,进行消费者的请求分发,springcloud支持断路器,与git完美集成配置文件支持版本控制,事物总线实现配置文件的更新与服务自动装配等等一系列的微服务架构要素。 ### **负载平衡的意义什么?** 在计算中,负载平衡可以改善跨计算机,计算机集群,网络链接,中央处理单元或磁盘驱动器等多种计算资源的工作负载分布。负载平衡旨在优化资源使用,最大化吞吐量,最小化响应时间并避免任何单一资源的过载。使用多个组件进行负载平衡而不是单个组件可能会通过冗余来提高可靠性和可用性。负载平衡通常涉及专用软件或硬件,例如多层交换机或域名系统服务器进程。 ### **什么是 Hystrix?它如何实现容错?** Hystrix 是一个延迟和容错库,旨在隔离远程系统,服务和第三方库的访问点,当出现故障是不可避免的故障时,停止级联故障并在复杂的分布式系统中实现弹性。 通常对于使用微服务架构开发的系统,涉及到许多微服务。这些微服务彼此协作。 思考以下微服务 ![](https://upload-images.jianshu.io/upload_images/15462057-94c1dba162d0b7a6?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 假设如果上图中的微服务 9 失败了,那么使用传统方法我们将传播一个异常。但这仍然会导致整个系统崩溃。 随着微服务数量的增加,这个问题变得更加复杂。微服务的数量可以高达 1000.这是 hystrix 出现的地方 我们将使用 Hystrix 在这种情况下的 Fallback 方法功能。我们有两个服务 employee-consumer 使用由 employee-consumer 公开的服务。 简化图如下所示 ![](https://upload-images.jianshu.io/upload_images/15462057-b15bfc4e278dcda0?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 现在假设由于某种原因,employee-producer 公开的服务会抛出异常。我们在这种情况下使用 Hystrix 定义了一个回退方法。这种后备方法应该具有与公开服务相同的返回类型。如果暴露服务中出现异常,则回退方法将返回一些值。 ### **什么是 Hystrix 断路器?我们需要它吗?** 由于某些原因,employee-consumer 公开服务会引发异常。在这种情况下使用Hystrix 我们定义了一个回退方法。如果在公开服务中发生异常,则回退方法返回一些默认值。 ![](https://upload-images.jianshu.io/upload_images/15462057-2411e4e45493ece8?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 如果 firstPage method() 中的异常继续发生,则 Hystrix 电路将中断,并且员工使用者将一起跳过 firtsPage 方法,并直接调用回退方法。断路器的目的是给第一页方法或第一页方法可能调用的其他方法留出时间,并导致异常恢复。可能发生的情况是,在负载较小的情况下,导致异常的问题有更好的恢复机会 。 ![](https://upload-images.jianshu.io/upload_images/15462057-70fcf5724802b9b9?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) ### **什么是 Netflix Feign?它的优点是什么?** Feign 是受到 Retrofit,JAXRS-2.0 和 WebSocket 启发的 java 客户端联编程序。 Feign 的第一个目标是将约束分母的复杂性统一到 http apis,而不考虑其稳定性。 在 employee-consumer 的例子中,我们使用了 employee-producer 使用 REST模板公开的 REST 服务。 但是我们必须编写大量代码才能执行以下步骤 (1)使用功能区进行负载平衡。 (2)获取服务实例,然后获取基本 URL。 (3)利用 REST 模板来使用服务。前面的代码如下 ``` @Controller public class ConsumerControllerClient { @Autowired private LoadBalancerClient loadBalancer; public void getEmployee() throws RestClientException, IOException { ServiceInstance serviceInstance=loadBalancer.choose("employee-producer"); System.out.println(serviceInstance.getUri()); String baseUrl=serviceInstance.getUri().toString(); baseUrl=baseUrl+"/employee"; RestTemplate restTemplate = new RestTemplate(); ResponseEntity response=null; try{ response=restTemplate.exchange(baseUrl, HttpMethod.GET, getHeaders(),String.class); } catch (Exception ex) { System.out.println(ex); } System.out.println(response.getBody()); } ``` 之前的代码,有像 NullPointer 这样的例外的机会,并不是最优的。我们将看到如何使用 Netflix Feign 使呼叫变得更加轻松和清洁。如果 Netflix Ribbon 依赖关系也在类路径中,那么 Feign 默认也会负责负载平衡。 ### **什么是 Spring Cloud Bus?我们需要它吗?** 考虑以下情况:我们有多个应用程序使用 Spring Cloud Config 读取属性,而Spring Cloud Config 从 GIT 读取这些属性。 下面的例子中多个员工生产者模块从 Employee Config Module 获取 Eureka 注册的财产。 ![](https://upload-images.jianshu.io/upload_images/15462057-a234beeefd3d7745?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 如果假设 GIT 中的 Eureka 注册属性更改为指向另一台 Eureka 服务器,会发生什么情况。在这种情况下,我们将不得不重新启动服务以获取更新的属性。 还有另一种使用执行器端点/刷新的方式。但是我们将不得不为每个模块单独调用这个 url。例如,如果 Employee Producer1 部署在端口 8080 上,则调用 http:// localhost:8080 / refresh。同样对于 Employee Producer2 http://localhost:8081 / refresh 等等。这又很麻烦。这就是 Spring Cloud Bus 发挥作用的地方。 ![](https://upload-images.jianshu.io/upload_images/15462057-01306b85f1f304c0?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) Spring Cloud Bus 提供了跨多个实例刷新配置的功能。因此,在上面的示例中,如果我们刷新 Employee Producer1,则会自动刷新所有其他必需的模块。如果我们有多个微服务启动并运行,这特别有用。这是通过将所有微服务连接到单个消息代理来实现的。无论何时刷新实例,此事件都会订阅到侦听此代理的所有微服务,并且它们也会刷新。可以通过使用端点/总线/刷新来实现对任何单个实例的刷新。 ### **Spring Cloud断路器的作用** 当一个服务调用另一个服务由于网络原因或自身原因出现问题,调用者就会等待被调用者的响应 当更多的服务请求到这些资源导致更多的请求等待,发生连锁效应(雪崩效应) 断路器有完全打开状态:一段时间内 达到一定的次数无法调用 并且多次监测没有恢复的迹象 断路器完全打开 那么下次请求就不会请求到该服务 半开:短时间内 有恢复迹象 断路器会将部分请求发给该服务,正常调用时 断路器关闭 关闭:当服务一直处于正常状态 能正常调用 ### **什么是Spring Cloud Config?** 在分布式系统中,由于服务数量巨多,为了方便服务配置文件统一管理,实时更新,所以需要分布式配置中心组件。在Spring Cloud中,有分布式配置中心组件spring cloud config ,它支持配置服务放在配置服务的内存中(即本地),也支持放在远程Git仓库中。在spring cloud config 组件中,分两个角色,一是config server,二是config client。 使用: (1)添加pom依赖 (2)配置文件添加相关配置 (3)启动类添加注解@EnableConfigServer ### **什么是Spring Cloud Gateway?** Spring Cloud Gateway是Spring Cloud官方推出的第二代网关框架,取代Zuul网关。网关作为流量的,在微服务系统中有着非常作用,网关常见的功能有路由转发、权限校验、限流控制等作用。 使用了一个RouteLocatorBuilder的bean去创建路由,除了创建路由RouteLocatorBuilder可以让你添加各种predicates和filters,predicates断言的意思,顾名思义就是根据具体的请求的规则,由具体的route去处理,filters是各种过滤器,用来对请求做各种判断和修改。 ### **写在最后** 欢迎大家关注我的公众号【**风平浪静如码**】,海量Java相关文章,学习资料都会在里面更新,整理的资料也会放在里面。 觉得写的还不错的就点个赞,加个关注呗!点关注,不迷路,持续更新!!!

优秀的个人博客,低调大师

面试题:JVM在Java堆中对对象的创建、内存结构、访问方式

可柔可刚,点赞则柔,白嫖则刚!死鬼~~~看完记得给我来个三连哦! 一、对象创建过程 1、检查类是否已被加载 JVM遇到new指令时,首先会去检查这个指令参数能否在常量池中定位到这个类的符号引用,检查这个符号引用代表的类是否已被加载、解析、初始化,若没有,则进行类加载 2、为新对象分配内存 类加载检查后,JVM为新对象在堆内存中分配空间,内存大小在类加载完成后便可确定。内存分配方式有以下几种: 1)指针碰撞(Bump the Pointer):若堆内存规整的,已用的和空闲的各占一边,分配内存就是把指针作为分界点,指针往空闲的一边移动对象大小的空间。 2)空闲列表(Free List):若堆内存不规整,JVM必须维护一个记录可用内存块的列表,分配内存时,把列表中一块空间分配给对象,并更新表记录。 以上两种在并发情况下,存在线程安全问题,在给对象A分配内存时,指针还没来得及修改,对象B又同时使用原来的指针来分配内存。解决方案有两种: 1)给分配内存的动作同步处理:JVM使用CAS+失败重试,保证更新操作的原子性。 2)本地线程分配缓冲(TLAB Thread Local Allocation Buffer):给每个线程在堆内存中预先分配已小块内存,在需要分配内存的线程的TLAB上分配,TLAB用完并分配新的TLAB时,才同步锁定。JVM通过设置 -XX:+UseTLAB来开启。 3、将分配到的内存都初始化为零值(不含对象头) 保证了对象的实例字段在java代码中不赋初始值就可以直接使用。如果使用TLAB,这一步可提前到TLAB分配时进行。 4、对对象进行其他必要的设置 如设置对象头的内容 5、执行java代码中<init>方法进行初始化 以上4步完成后,对于JVM来说,新的对象已经产生了,但是对于java程序来说,对象才刚刚开始创建。 二、对象的内存结构 1、对象头 1.1 标识字段 Mark Work 用于存储对象自身的运行时数据,如HashCode,GC分代年龄,锁状态标志等 1.2 类型指针 Klass Pointer 对象指向它的类型元数据的指针,JVM通过这个指针确定该对象属于哪个类的实例 如果对象是一个数组,对象头中还要有一块用于记录数组长度的数据,因为数组长度是不确定的,无法通过元数据中的信息推断数组大小。 2、实例数据 对象实际存储的有效信息,即代码中定义的字段和父类继承下来的,存储顺序受到JVM分配策略参数(-XX:FieldAllocationStyle)和代码中字段定义顺序影响 3、对齐填充 不是必然存在,仅仅是起占位符作用;由于HotSpot虚拟机的自动内存管理系统要求任何对象大小都必须是8字节的整数倍,对象头被设计成正好是8字节的整数倍,因此实例数据部分没有对齐8字节的整数倍的话,就通过对齐填充来补全。 三、对象的访问方式 java程序是通过java栈中的reference数据来操作堆中的具体对象 1、句柄访问 java堆中划分一块内存作为句柄池,栈上的reference存的是对象的句柄地址,句柄池中包含对象实例数据和类型数据的地址信息。 优点:垃圾收集移动对象时,只改变句柄中实例数据指针,而reference本身不需要修改。 2、直接访问 直接指针访问,reference存的直接是对象的地址。不需要多一次间接访问的开销。 优点:速度快,节省一次指针定位的时间开销。 HotSpot虚拟机主要使用直接访问进行对象访问。

优秀的个人博客,低调大师

面试官问我斐波拉契数列,我从暴力递归讲到动态规划 ...

前言 在系统学习动态规划之前,一直搞不懂「动态规划」和「记忆化搜索」之间的区别。 总觉得动态规划只是单纯的难在于对“状态”的抽象定义和“状态转移方程”的推导,并无具体的规律可循。 本文将助你彻底搞懂动态规划。 演变过程 暴力递归 -> 记忆化搜索 -> 动态规划 其实动态规划也就是这样演练过来的。 可以说几乎所有的「动态规划」都可以通过「暴力递归」转换而来,前提是该问题是一个“无后效性”问题。 无后效性 所谓的“无后效性”是指:当某阶段的状态一旦确定,此后的决策过程和最终结果将不受此前的各种状态所影响。可简单理解为当编写好一个递归函数之后,当可变参数确定之后,结果是唯一确定的。 可能你还是对什么是“无后效性”问题感到难以理解。没关系,我们再举一个更具象的例子,这是LeetCode 62. Unique Paths:给定一个m x n的矩阵,从左上角作为起点,到达右下角共有多少条路径(机器人只能往右或者往下进行移动)。 这是一道经典的「动态规划」入门题目,也是一个经典的“无后效性”问题。 它的“无后效性”体现在:当给定了某个状态(一个具体的m x n的矩阵和某个起点,如 (1,2)),那么从这个点到达右下角的路径数量就是完全确定的。 而与如何到达这个“状态”无关,与机器人是经过点 (0,2) 到达的 (1,2),还是经过 (1,1) 到达的 (1,2) 无关。 这就是所谓的“无后效性”问题。 当我们尝试使用「动态规划」解决问题的时候,首先要关注该问题是否为一个“无后效性”问题。 1:暴力递归 经常我们面对一个问题,即使我们明确知道了它是一个“无后效性”问题,它可以通过「动态规划」来解决。我们还是觉得难以入手。 这时候我的建议是,先写一个「暴力递归」的版本。 还是以刚刚说到的LeetCode 62. Unique Paths举例: classSolution{ publicintuniquePaths(intm,intn){ returnrecursive(m,n,0,0); } privateintrecursive(intm,intn,inti,intj){ if(i==m-1||j==n-1)return1; returnrecursive(m,n,i+1,j)+recursive(m,n,i,j+1); } } 当我还不知道如何使用「动态规划」求解时,我会设计一个递归函数recursive()。 函数传入矩阵信息和机器人当前所在的位置,返回在这个矩阵里,从机器人所在的位置出发,到达右下角有多少条路径。 有了这个递归函数之后,那问题其实就是求解recursive(m, n, 0, 0):求解从 (0,0) 到右下角的路径数量。 接下来,实现这个函数: Base case: 由于题目明确了机器人只能往下或者往右两个方向走,所以可以定下来递归方法的 base case 是当已经处于矩阵的最后一行或者最后一列,即只一条路可以走。 其余情况:机器人既可以往右走也可以往下走,所以对于某一个位置来说,到达右下角的路径数量等于它右边位置到达右下角的路径数量 + 它下方位置到达右下角的路径数量。即recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1),这两个位置都可以通过递归函数进行求解。 其实到这里,我们已经求解了这个问题了。 但这种做法还有个严重的性能问题。 2:记忆化搜索 如果将我们上述的代码提交到 LeetCode,会得到 timeout 的结果。 可见「暴力递归」的解决方案“很慢”。 我们知道所有递归函数的本质都是“压栈”和“弹栈”。 既然这个过程很慢,我们可以通过将递归版本暴力解法的改为非递归的暴力解法,来解决 timeout 的问题吗? 答案是不行,因为导致 timeout 的原因不在于使用“递归”手段所带来的成本。 而在于在计算过程,我们进行了多次的重复计算。 我们尝试展开递归过程第几步来看看: 不难发现,在递归展开过程会遇到很多的重复计算。 随着我们整个递归过程的展开,重复计算的次数会呈倍数增长。 这才是「暴力递归」解决方案“慢”的原因。 既然是重复计算导致的 timeout,我们自然会想到将计算结果进行“缓存”的方案: classSolution{ privateint[][]cache; publicintuniquePaths(intm,intn){ cache=newint[m][n]; for(inti=0;i<m;i++){ int[]ints=newint[n]; Arrays.fill(ints,-1); cache[i]=ints; } returnrecursive(m,n,0,0); } privateintrecursive(intm,intn,inti,intj){ if(i==m-1||j==n-1)return1; if(cache[i][j]==-1){ if(cache[i+1][j]==-1){ cache[i+1][j]=recursive(m,n,i+1,j); } if(cache[i][j+1]==-1){ cache[i][j+1]=recursive(m,n,i,j+1); } cache[i][j]=cache[i+1][j]+cache[i][j+1]; } returncache[i][j]; } } 对「暴力递归」过程中的中间结果进行缓存,确保相同的情况只会被计算一次的做法,称为「记忆化搜索」。 做了这样的改进之后,提交 LeetCode 已经能 AC 并得到一个不错的评级了。 我们再细想一下就会发现,其实整个求解过程,对于每个情况(每个点)的访问次数并没有发生改变。 只是从「以前的每次访问都进行求解」改进为「只有第一次访问才真正求解」。 事实上,我们通过查看recursive()方法就可以发现: 当我们求解某一个点(i, j)的答案时,其实是依赖于(i, j + 1)和(i + 1, j)。 也就是每求解一个点的答案,都需要访问两个点的结果。 这种情况是由于我们采用的是“自顶向下”的解决思路所导致的。 我们无法直观确定哪个点的结果会在什么时候被访问,被访问多少次。 所以我们不得不使用一个与矩阵相同大小的数组,将所有中间结果“缓存”起来。 换句话说,「记忆化搜索」解决的是重复计算的问题,并没有解决结果访问时机和访问次数的不确定问题。 2.1:次优解版本的「记忆化搜索」 关于「记忆化搜索」最后再说一下。 网上有不少博客和资料在编写「记忆化搜索」解决方案时,会编写类似如下的代码: classSolution{ privateint[][]cache; publicintuniquePaths(intm,intn){ cache=newint[m][n]; for(inti=0;i<m;i++){ int[]ints=newint[n]; Arrays.fill(ints,-1); cache[i]=ints; } returnrecursive(m,n,0,0); } privateintrecursive(intm,intn,inti,intj){ if(i==m-1||j==n-1)return1; if(cache[i][j]==-1){ cache[i][j]=recursive(m,n,i+1,j)+recursive(m,n,i,j+1); } returncache[i][j]; } } 可以和我上面提供的解决方案作对比。主要区别在于if (cache[i][j] == -1)的判断里面。 在我提供解决方案中,会在计算cache[i][j]时,尝试从“缓存”中读取cache[i + 1][j]和cache[i][j + 1],确保每次调用recursive()都是必须的,不重复的。 网上大多数的解决方案只会在外层读取“缓存”,在真正计算cache[i][j]的时候并不采取先检查再调用的方式,直接调用recursive()计算子问题 。 虽然两者相比与直接的「暴力递归」都大大减少了计算次数(recursive()的访问次数),但后者的计算次数显然要比前者高上不少。 你可能会觉得反正都是“自顶向下”,两者应该没有区别吧? 为此我提供了以下实验代码来比较它们对recursive()的调用次数: classSolution{ publicstaticvoidmain(String[]args){ Solutionsolution=newSolution(); solution.uniquePaths(15,15); } privateint[][]cache; privatelongcount;//统计递归函数的调用次数 publicintuniquePaths(intm,intn){ cache=newint[m][n]; for(inti=0;i<m;i++){ int[]ints=newint[n]; Arrays.fill(ints,-1); cache[i]=ints; } //intresult=recursive(m,n,0,0);//count=80233199 //intresult=cacheRecursive(m,n,0,0);//count=393 intresult=fullCacheRecursive(m,n,0,0);//count=224 System.out.println(count); returnresult; } //完全缓存 privateintfullCacheRecursive(intm,intn,inti,intj){ count++; if(i==m-1||j==n-1)return1; if(cache[i][j]==-1){ if(cache[i+1][j]==-1){ cache[i+1][j]=fullCacheRecursive(m,n,i+1,j); } if(cache[i][j+1]==-1){ cache[i][j+1]=fullCacheRecursive(m,n,i,j+1); } cache[i][j]=cache[i+1][j]+cache[i][j+1]; } returncache[i][j]; } //只有外层缓存 privateintcacheRecursive(intm,intn,inti,intj){ count++; if(i==m-1||j==n-1)return1; if(cache[i][j]==-1){ cache[i][j]=cacheRecursive(m,n,i+1,j)+cacheRecursive(m,n,i,j+1); } returncache[i][j]; } //不使用缓存 privateintrecursive(intm,intn,inti,intj){ count++; if(i==m-1||j==n-1)return1; returnrecursive(m,n,i+1,j)+recursive(m,n,i,j+1); } } 因为我们使用 cache 数组的目的是减少recursive()函数的调用。 只有确保在每次调用recursive()之前先去 cache 数组检查,我们才可以将对recursive()函数的调用次数减到最少。 在数据为 15 的样本下,这是O(393n)和O(224n)的区别,但对于一些卡常数特别严重的 OJ,尤其重要。 所以我建议你在「记忆化搜索」的解决方案时,采取与我一样的策略: 确保在每次访问递归函数时先去“缓存”检查。尽管这有点“不美观”,但它能发挥「记忆化搜索」的最大作用。 3:从「自顶向下」到「自底向上」 你可能会想,为什么我们需要改进「记忆化搜索」,为什么需要明确中间结果的访问时机和访问次数? 因为一旦我们能明确中间结果的访问时机和访问次数,将为我们的算法带来巨大的提升空间。 前面说到,因为我们无法确定中间结果的访问时机和访问次数,所以我们不得不“缓存”全部中间结果。 但如果我们能明确中间结果的访问时机和访问次数,至少我们可以大大降低算法的空间复杂度。 这就涉及解决思路的转换:从「自顶向下」到「自底向上」 。 如何实现从「自顶向下」到「自底向上」的转变,还是通过具体的例子来理解。 这是LeetCode 509. Fibonacci Number,著名的“斐波那契数列”问题。 如果不了解什么是“斐波那契数列”,可以查看对应的维基百科。 由于斐波那契公式为: 天然适合使用递归: publicclassSolution{ privateint[]cache; publicintfib(intn){ cache=newint[n+1]; returnrecursive(n); } privateintrecursive(intn){ if(n<=1)returnn; if(n==2)return1; if(cache[n]==0){ if(cache[n-1]==0){ cache[n-1]=recursive(n-1); } if(cache[n-2]==0){ cache[n-2]=recursive(n-2); } cache[n]=cache[n-1]+cache[n-2]; } returncache[n]; } } 但这仍然会有我们之前所说的问题,这些问题都是因为直接递归是“自顶向下”所导致的。 这样的解法的时空复杂度为O(n):每个值计算一次,使用了长度为 n + 1 的数组。 通过观察斐波那契公式,我们可以发现要计算某个 n ,只需要知道 n - 1 的解和 n - 2 的解。 同时 n = 1 和 n = 2 的解又是已知的(base case)。 所以我们大可以从 n = 3 出发,逐步往后迭代得出 n 的解。 由于计算某个值的解,只依赖该值的前一位的解和前两位的解,所以我们只需要使用几个变量缓存最近的中间结果即可: classSolution{ publicintfib(intn){ if(n<=1)returnn; if(n==2)return1; intprev1=1,prev2=1; intcur=prev1+prev2; for(inti=3;i<=n;i++){ cur=prev1+prev2; prev2=prev1; prev1=cur; } returncur; } } 这样我们就把原本空间复杂度为O(N)的算法降低为O(1):只是用了几个有限的变量。 但不是所有的「动态规划」都像“斐波那契数列”那么简单就能实现从“自顶向下”到“自底向上”的转变。 当然也不是毫无规律可循,尤其是我们已经写出了「暴力递归」的解决方案。 让我们再次回到LeetCode 62. Unique Paths当中: classSolution{ publicintuniquePaths(intm,intn){ //由于我们的「暴力递归」函数,真正的可变参数就是i和j(变化范围分别是[0,m-1]和[0,n-1]) //所以建议一个二维的dp数组进行结果存储(相当于建一个表格) int[][]dp=newint[m][n]; //根据「暴力递归」函数中的basecase //我们可以直接得出dp中最后一行和最后一列的值(将表格的最后一行和最后一列填上) for(inti=0;i<n;i++)dp[m-1][i]=1 for(inti=0;i<m;i++)dp[i][n-1]=1; //根据「暴力递归」函数中对其他情况的处理逻辑(依赖关系)编写循环 //(根据表格的最后一行和最后一列的值,得出表格的其他格子的值) for(inti=m-2;i>=0;i--){ for(intj=n-2;j>=0;j--){ dp[i][j]=dp[i+1][j]+dp[i][j+1]; } } //最终我们要的是dp[0][0](表格中左上角的位置,也就起点的值) returndp[0][0]; //原「暴力递归」调用 //returnrecursive(m,n,0,0); } privateintrecursive(intm,intn,inti,intj){ //basecase if(i==m-1||j==n-1)return1; //其余情况 returnrecursive(m,n,i+1,j)+recursive(m,n,i,j+1); } } 不难发现,我们甚至可以直接根据「暴力递归」来写出「动态规划」,而不需要关心原问题是什么。 简单的「动态规划」其实就是一个“打表格”的过程: 先根据 base case 定下来表格中的一些位置的值,再根据已得出值的位置去推算其他格子的信息。 推算所用到的依赖关系,也就是我们「暴力递归」中的“其余情况”处理逻辑。 动态规划的本质 动态规划的本质其实仍然是枚举:枚举所有的方案,并从中找出最优解。 但和「暴力递归」不同的是,「动态规划」少了很多的重复计算。 因为所依赖的这些历史结果,都被存起来了,因此节省了大量重复计算。 从这一点来说,「动态规划」和「记忆化搜索」都是类似的。 要把历史结果存起来,必然要使用数据结构,在 dp 中我们通常使用一维数组或者二维数据来存储,假设是 dp[]。 那么对应解 dp 问题我们有以下过程 状态定义: 确定 dp[] 中元素的含义,也就是说需要明确 dp[i] 是代表什么内容 状态转移:确定 dp[] 元素之间的关系,dp[i] 这个格子是由哪些 dp 格子推算而来的。如斐波那契数列中就有 dp[i] = dp[i - 1] + dp[i - 2] 起始值:base case,dp[] 中的哪些格子是可以直接得出结果的。如斐波那契数列中就有 dp[0] = 0 和 dp[1] = 1 消除“后效性” 我们知道使用「动态规划」的前提是问题的“无后效性” 。 但是有些时候问题的“无后效性” 并不容易体现。 需要我们多引入一维来进行“消除”。 例如 LeetCode 上经典的「股票问题」,使用动态规划求解时往往需要多引入一维表示状态,有时候甚至需要再引入一维代表购买次数。 注意这里说的消除是带引号的,其实这样的做法更多的是作为一种“技巧”,它并没有真正改变问题“后效性” ,只是让问题看上去变得简单的。 由于本文篇幅已经很长了,这里就不再展开「股票问题」。 之后会使用专门章节来对「股票问题」进行讲解,以达到使用同一思路解决所有「股票问题」的目的,敬请期待。 总结 到这里我们已经可以回答「动态规划」和「记忆化搜索」的区别是什么了。 「记忆化搜索」本质是带“缓存”功能的「暴力递归」: 它只能解决重复计算的问题,而不能确定中间结果的访问时机和访问次数,本质是一种“自顶向下”的解决方式。 「动态规划」是一种“自底向上”的解决方案 : 能明确访问时机和访问次数,这为降低算法的空间复杂度带来巨大空间,我们可以根据依赖关系来决定保留哪些中间结果,而无须将全部中间结果进行“缓存”。

资源下载

更多资源
优质分享App

优质分享App

近一个月的开发和优化,本站点的第一个app全新上线。该app采用极致压缩,本体才4.36MB。系统里面做了大量数据访问、缓存优化。方便用户在手机上查看文章。后续会推出HarmonyOS的适配版本。

Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。