news 2026/10/10 6:35:39

Java集合框架底层原理与性能优化:从ArrayList到HashMap

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java集合框架底层原理与性能优化:从ArrayList到HashMap

Java里的集合框架,很多开发者从学习第一天就开始用,ArrayList存数据、HashMap做缓存,写着写着就成了肌肉记忆。但真正问你几个问题——ArrayList扩容到底怎么扩的?HashMap在JDK 8里引入红黑树是为什么?遍历的时候删元素为什么老报ConcurrentModificationException?——能立刻答清楚的人还真不多。很多人的状态是“用得很熟,原理很虚”,一旦面试官往深里问两句,或者线上出现一个跟集合相关的诡异问题,就容易卡壳。

这篇文章不打算重复“Java集合框架概述”这种教科书内容,我按自己实际开发里遇到的场景,把Java集合的选型、底层原理、日常使用的坑点,以及面试常问的几个细节原原本本梳理一遍。适合刚学完Java基础、想系统过一遍集合框架的读者,也适合准备Java面试想查漏补缺的朋友。这里说的集合,是JDK里的Collection和Map两大体系,不是数学课上那个集合概念,别搞混。

1. 先搞清楚Java集合解决的是什么问题

1.1 为什么数组不够用

数组是Java里最基础的数据容器,但从第一天起它就有几个让人难受的缺陷。第一,长度固定,初始化时说好10个就永远是10个,想塞第11个就得自己手动new一个新数组,再把旧数据拷过去。第二,数组元素的增删操作非常别扭,删除中间一个元素,后面的全得往前挪,写起来一堆for循环,还容易下标越界。第三,数组只能按下标访问,如果想根据某个属性找一个元素,你得自己遍历比较。

集合框架就是来解决这些问题的。List接口提供了自动扩容、按位置增删的能力,Map接口提供了按key快速定位value的能力,Set接口则帮你保证了元素不重复。说白了,集合不是“数组的替代品”,而是把日常开发里最常用的数据组织方式抽象成了统一的API,让程序员不用每次重复造轮子。

1.2 两大体系:Collection和Map

Java集合框架从上往下分,最顶层的接口就两个:Collection和Map。Collection是单列数据的集合,里面存的每个元素都是独立的一个对象;Map是双列数据的集合,存的是键值对,像查字典一样,通过key找到value。

Collection下面又派生出三个核心子接口:List、Set和Queue。List是有序可重复的,元素按插入顺序排列,可以重复,典型实现是ArrayList、LinkedList、Vector。Set是无序不可重复的,往里面塞重复元素会被自动丢弃,典型实现是HashSet、LinkedHashSet、TreeSet。Queue是队列,一般用于先进先出的场景,典型实现是LinkedList、PriorityQueue、ArrayDeque。

Map体系中最重要的实现是HashMap、LinkedHashMap、TreeMap、Hashtable、ConcurrentHashMap。很多人刚学时背了一堆类名,却不知道它们之间什么关系。我建议你脑子里至少要有这样一张图:Collection下的三兄弟是List、Set、Queue,Map是独立的一棵大树。这张图能帮你后面理解为什么HashSet底层其实是包装了一个HashMap。

2. 接口背后的设计套路,学完能看懂一半源码

2.1 Collection接口的三个儿子

List接口的特点是“有序可重复”,它的核心子类有三个:ArrayList、LinkedList和Vector。ArrayList底层是Object数组,查询快、增删慢;LinkedList底层是双向链表,增删快、查询慢;Vector和ArrayList几乎一样,但方法加了synchronized,是线程安全的,不过性能比ArrayList差,现在已经很少用了。我记得很多教材还推荐Vector,实践里真没必要,真要线程安全,有更好的替代方案。

Set接口的特点是“无序不可重复”,核心子类有HashSet、LinkedHashSet、TreeSet。HashSet底层是HashMap,把元素作为key存进去,value统一用一个固定的Object占位,所以元素不能重复;LinkedHashSet在HashSet基础上维护了插入顺序;TreeSet底层是TreeMap,元素会按自然顺序或者你指定的比较器排序。我见过不少人在TreeSet里存自定义对象,结果忘记实现Comparable接口,一运行直接抛ClassCastException,这个坑面试和工作中都容易踩。

Queue接口稍微特殊一点,它继承了Collection,但语义是队列,主要实现是LinkedList、ArrayDeque和PriorityQueue。PriorityQueue是个优先级队列,底层是堆结构,可以用来实现一个简单的定时任务调度器或者求Top K问题,后面工程场景里用处不小。

2.2 Map接口:键值对的王国

Map是键值对集合的顶层接口,它的核心实现类各有侧重。HashMap允许key和value为null,底层是数组加链表加红黑树,是无序的。LinkedHashMap在HashMap基础上多维护了一个双向链表,能记住插入顺序,也能设置访问顺序,做LRU缓存非常合适。TreeMap基于红黑树,key按自然顺序或自定义比较器排序,支持范围查询。Hashtable是早期遗留下来的线程安全Map,所有方法都加了synchronized,性能差,基本被淘汰了。ConcurrentHashMap是JDK并发包里提供的线程安全Map,是现在高并发场景下的首选。

我之前在项目里做过一个简单的热搜榜功能,需要实时统计关键字的热度并且按热度降序输出,当时就是用TreeMap配合一个自定义Comparator实现的。如果当时不懂TreeMap的排序原理,可能就得自己写一堆排序逻辑,代码量直接翻倍。

2.3 迭代器:所有集合的统一访问方式

集合框架还有一个容易被忽略的设计——Iterator接口。不管你的集合底层是数组还是链表,只要实现了Iterator,就能用统一的方式遍历。它的核心方法就三个:hasNext()、next()、remove()。JDK 5之后有了增强for循环,本质底层还是用Iterator实现的。

这里有个很重要的机制叫fail-fast,意思是当迭代器正在遍历一个集合时,如果集合的结构被修改了(比如添加或删除了元素),迭代器会立刻抛出ConcurrentModificationException。它是通过一个叫modCount的字段实现的,每次结构性修改都会让modCount加1,迭代器初始化时记录下当时的modCount,每次next()时检查是否变化,变了就抛异常。这个机制不是Java的bug,而是为了防止你在遍历时做出不确定的行为。后面我会详细讲这个坑的规避方法。

3. ArrayList和LinkedList,选型差一点性能差十倍

3.1 ArrayList的扩容机制到底怎么工作

ArrayList的默认初始容量是10,每次扩容时新容量是旧容量的1.5倍。具体执行时,如果旧容量是10,扩容后就是15,计算方法是用旧容量加上右移一位的结果,相当于oldCapacity + (oldCapacity >> 1)。这个位运算技巧在JDK源码里很常见,比直接乘1.5更高效。扩容的核心操作是Arrays.copyOf,也就是new一个新数组,把旧数组元素全部拷贝过去,再把引用指向新数组。

这个扩容过程代价可不小,尤其是在数据量大的时候。所以如果你能预估数据规模,最好在构造时就指定初始容量,new ArrayList<>(10000),这样可以避免频繁扩容带来的数组拷贝开销。我以前批量处理百万级数据时就吃过亏,循环add了将近十万次,结果性能慢得离谱,后来加了个初始容量设置,速度直接快了一个量级。

3.2 LinkedList的底层细节

LinkedList底层是双向链表,每个节点持有三个引用:当前元素值、前一个节点、后一个节点。这个结构决定了它在头部和中间插入删除元素非常快,时间复杂度是O(1)或O(n/2)(取决于是否命中二分查找优化)。但按索引访问元素就很慢了,因为得从头或尾顺着链走,时间复杂度是O(n)。

我见过有人用LinkedList存了一万条数据,然后频繁调用get(index)来访问中间元素,结果程序卡得不行。LinkedList的get方法虽然做了个优化:如果index小于size的一半,从头开始找,否则从尾部倒着找,但本质上还是O(n)的查找。这种场景就该用ArrayList。而如果你需要频繁在队列头部插入或删除,LinkedList就比ArrayList合适得多。到底选哪个,核心判断标准就是:读多还是写多,写的位置在头部还是尾部。

3.3 一个真实的选型对比案例

我做过一个简易的消息队列中间件模块,需要支持大量生产者往队列尾部写入消息,同时消费者从头部取走消息。这个场景如果用ArrayList,头部取元素会导致所有元素整体前移,数据量上来后性能惨不忍睹。用LinkedList就非常合适,尾部offer和头部poll都是O(1)操作。但如果这个模块改成了按索引随机读取报表数据,ArrayList又会吊打LinkedList。同样的数据,不同的操作模式,选型错了性能会差一个数量级,这不是理论,是实实在在踩过的坑。

4. HashMap原理拆解,面试常驻考题

4.1 底层结构:数组+链表+红黑树

HashMap的底层结构在JDK 7和JDK 8之间有显著区别。JDK 7是数组加链表,数据存在单向链表的节点里,头插法插入。JDK 8改成了数组加链表加红黑树,链表插入改成了尾插法,同时当链表长度达到8且数组长度大于等于64时,链表会转成红黑树,降低查询时间。

为什么是链表长度8这个阈值?这是基于泊松分布计算出来的。在负载因子0.75、哈希随机性良好的前提下,链表长度达到8的概率是千万分之六,已经是极其罕见的情况了。这个阈值设计其实是一个时间和空间的折中,链表查询是O(n),红黑树查询是O(log n),但红黑树节点占用空间更大,所以不能一上来就用树,只在极端哈希碰撞时才转换。

4.2 put操作的完整流程

往HashMap里put一个键值对时,流程大致是:先对key调用hashCode(),再把高16位和低16位做异或运算来扰动哈希值,这样能让哈希分布更均匀,减少碰撞。然后用扰动后的哈希值和数组长度减一做按位与运算,得到数组下标。如果该位置为空,直接放一个Node节点;如果不为空,就遍历链表或红黑树,找到key相同的节点就更新value,找不到就在链表末尾追加一个新节点。

这里有个关键细节:哈希扰动函数的目的是让高位的信息也能参与数组下标的计算。因为数组默认长度是16,计算下标时只用了低4位,如果两个对象的hashCode恰好低4位相同、高位不同,就会冲突。扰动函数把高16位异或到低16位,等于是把高位的随机性“搅拌”到了低位,大幅降低了碰撞概率。这个细节不面试,但理解它对业务系统减少Hash冲突很有帮助。

4.3 扩容时机和扩容过程

HashMap有一个默认的负载因子0.75。意思是当元素个数超过容量乘以0.75时,就会触发扩容。比如默认容量16,元素个数达到12时,就扩容到32。扩容后,所有元素需要重新计算下标并迁移到新数组里,这个迁移过程开销极大,一次put操作可能要拷贝所有元素。

为什么负载因子选0.75而不是1或者更高?负载因子越大,空间利用率越高,但哈希冲突的概率也越大,查询效率降低;负载因子越小,空间越浪费,但冲突少、查询快。0.75是时间空间折中的经验值。所以在实际开发里,如果你明确知道Map大概要放多少数据,就应该在构造时指定初始容量,计算公式是expectedSize / 0.75f + 1。比如预计放1000条数据,初始容量最好设为1340左右,能避免扩容引起的性能损耗。

4.4 并发put导致的死循环问题

JDK 7的HashMap在多线程并发put时,扩容过程中可能形成环形链表,导致get操作进入死循环,CPU飙到100%。这个问题当年在阿里、美团等大厂的线上故障里出现过不少次。JDK 8改成尾插法之后,死循环问题基本解决了,但并发场景下数据丢失、脏读的问题依然存在。

所以不用说,多线程环境下绝不直接使用HashMap。正确姿势是用ConcurrentHashMap。它采用CAS配合synchronized锁住链表头节点的方式来保证线程安全,并且把整个Map分成多个桶(Node数组),并发度比Hashtable高得多。Hashtable是给整个表加一把大锁,ConcurrentHashMap只锁单个桶,性能差距在高并发下非常明显。我做过一次压测,同样一万次并发写入,Hashtable耗时是ConcurrentHashMap的三倍多,这还是在小数据量下,量大了差距只会更大。

5. TreeMap和TreeSet,排序背后的红黑树

5.1 自然排序与Comparator

TreeMap和TreeSet的底层都是红黑树。红黑树是一种自平衡的二叉搜索树,它保证最坏情况下插入、删除、查找的时间复杂度都是O(log n)。TreeMap中的key要么实现了Comparable接口,也就是说要实现compareTo方法,要么你在构造TreeMap时传入一个Comparator比较器。

如果你存的key是一个自定义对象,比如一个订单类,按订单创建时间排序,那就要么让订单类实现Comparable接口,在compareTo方法里写排序逻辑,要么单独写一个Comparator。前者只能支持一种排序方式,后者灵活度更高,同一个TreeMap可以配合不同比较器实现不同排序。我推荐优先用Comparator,因为不改动业务类本身,符合开闭原则。

5.2 一个隐藏的大坑:比较结果不能变

TreeMap的key在存放后,如果它的值发生了变化导致比较结果改变,这个key在树中的位置就错了,之前能查到的数据后来就查不到了。比如你用一个对象的name字段做排序依据,对象存进TreeMap后又改了name,那么这颗红黑树就乱了。这个坑很隐蔽,因为不报错,只是行为诡异。同样的道理,HashSet和HashMap的key如果是可变对象,并且修改了影响hashCode的字段,也会导致内存泄漏,元素永远无法被get到,而且集合里会存在一份“失效”的数据。所以集合里的key尽量用不可变对象,比如String、Integer,别用可变对象当key。

5.3 范围查询的实用价值

TreeMap由于是有序的,天然支持范围查询。subMap(fromKey, toKey)能取出一个范围内的子Map,headMap和tailMap分别取前缀和后缀。这个能力在业务里很实用,比如统计某个时间段内的订单金额,如果用HashMap就得全部遍历然后筛选,而TreeMap直接用subMap就能拿到这个区间,效率高得多。

我还用TreeMap做过一个分段的IP归属判断功能。先把IP地址转换为整数,每一段区间映射到一个城市,存进TreeMap,查询时用floorEntry找到不大于目标IP的最大key,再判断是否落在对应区间内,一次二分查找就搞定了,性能非常好。

6. 遍历、删除与并发修改,最常见的运行时报错

6.1 三种遍历方式的取舍

Java集合遍历的方式主要有三种:增强for循环、Iterator迭代器、forEach方法加Lambda表达式。对于List,还有传统的for循环按索引访问。增强for循环本质上是语法糖,底层就是Iterator。

forEach方法接收一个Consumer函数式接口,写起来很简洁,但它有一个限制:不能在Lambda表达式里修改集合结构,否则会抛ConcurrentModificationException。这个限制不是编译器强制的,而是运行时的modCount机制检测出来的。另外增强for循环也不能在循环体里删除元素,原因相同。

6.2 如何在遍历时安全删除

实际开发里经常需要“遍历集合时删除满足条件的元素”。错误写法是直接在增强for循环里调用list.remove(),运行必报ConcurrentModificationException。正确方案至少有三种。第一种是用Iterator,在循环里调用iterator.remove(),这是Iterator接口自带的安全删除方法,它会把modCount同步更新,所以不会触发异常。第二种是把要删除的元素先收集到另一个集合里,遍历完统一删除,适合批量删除。第三种是JDK 8之后的Collection.removeIf方法,一行搞定,内部实现就是基于Iterator的。

我在代码里最常用的是removeIf,它语义清晰,代码量最少,比如list.removeIf(s -> s.length() > 5),一行就能把所有长度大于5的字符串删除。凡是遇到“边遍历边删”的逻辑,先想想能不能用removeIf。

6.3 并发环境下遍历集合

多线程下对同一个ArrayList进行增删操作,同样可能抛出ConcurrentModificationException。如果并发读写不频繁,可以用CopyOnWriteArrayList,它每次修改时都会复制一份新数组,读操作不加锁,适合读多写极少、集合不大的场景。如果你需要一个并发环境下的有序集合,可以用ConcurrentSkipListMap,它的底层是跳表结构,支持并发且有序。

我做网关配置同步时用过CopyOnWriteArrayList来保存动态路由规则,因为路由规则更新频率很低,但查询概率极高。如果用普通ArrayList加锁,读也会被阻塞,性能会有不少浪费。CopyOnWriteArrayList的写代价是复制整个数组,但如果写频率低到几分钟才一次的话,这点复制成本完全可接受。

7. 集合的性能细节,每一个参数都值得推敲

7.1 初始化容量别偷懒

很多人在写new HashMap<>()时从来不给初始容量参数,这在数据量小的时候没什么问题,但一旦数据量达到几千几万,扩容带来的拷贝成本就很可观。预估容量时注意一个公式:initialCapacity = (预期的元素个数 / 0.75f) + 1,这样能保证不会触发扩容。

ArrayList同理,new ArrayList<>(预期的元素个数) 比默认容量10开始扩容再拷贝要好得多。这个习惯养成之后,写代码的性能隐患会少很多。我曾在处理一个几十万行Excel导入的功能时,只用了一行 new ArrayList<>(rows.size()),整个导入过程耗时从原来的十几秒降到了七八秒,差别真的很明显。

7.2 用对集合操作,少写一堆工具方法

JDK 8之后,集合本身带了一些很实用的默认方法。List有sort、replaceAll,Map有computeIfAbsent、merge、putIfAbsent,Set有removeIf。它们能让代码量少一半,也更不容易出错。比如Map的computeIfAbsent,可以优雅地实现“从map里取list,如果没有就new一个”的逻辑:

Map<String, List<String>> map = new HashMap<>(); map.computeIfAbsent("key", k -> new ArrayList<>()).add("value");

这一行代码等价于以前五六行的判断逻辑。merge方法则适合做统计聚合,比如分组求和,map.merge(key, 1, Integer::sum)就能实现计数器。这些API熟练掌握后,日常开发效率提升是立竿见影的,而且面试官看到你用这些方法,印象分也会高一些。

7.3 数据一致性视角下的集合选择

这里提一个稍微进阶的点:数据库和缓存的数据一致性,Java集合也能沾上边。比如做本地缓存时,很多人喜欢用HashMap加手动过期清理,但这种方式在高并发下有数据不一致、内存泄漏的风险。

更稳妥的方案是用LinkedHashMap实现LRU缓存,重写removeEldestEntry方法,当容量超过设定值就删除最久未使用的元素。配合ReentrantReadWriteLock来做并发控制,读写锁分离,读多写少的本地缓存场景性能很不错。我在一个配置中心客户端组件里就是这么实现的,比直接用ConcurrentHashMap干净得多。

8. 面试常问的几个集合题,答好这些基本过关

8.1 HashMap相关问题

面试里关于HashMap的高频问题有哪些?我梳理一下:JDK 8和JDK 7的区别,什么时候链表会转红黑树,为什么阈值是8,扩容机制是怎样的,负载因子为什么是0.75,put流程细节,get流程细节,为什么HashMap线程不安全,ConcurrentHashMap在JDK 8里用了什么机制保证线程安全。

回答这些问题的核心不在于背答案,而在于理解背后的设计权衡。比如负载因子0.75,本质是时间换空间还是空间换时间的取舍;阈值8,是泊松分布计算出的一个概率极端值。理解这些之后,哪怕面试官换着角度问,你也能答到点子上。

8.2 List和Set相关问题

List相关高频题包括ArrayList和LinkedList区别,ArrayList扩容机制,如何边遍历边删除元素,CopyOnWriteArrayList的适用场景。Set相关高频题包括HashSet如何保证不重复,LinkedHashSet为什么能保持插入顺序,TreeSet排序原理。

还有一个老生常谈的问题:HashSet和HashMap有什么区别。要答得完整,得说出来HashSet底层就是一个HashMap,只是value部分固定用一个PRESENT对象占位,所以HashMap有的特性HashSet基本都有,HashMap的key不允许重复,HashSet的元素当然也就不能重复。

8.3 容易口误的概念辨析

有几个概念希望大家别在面试时搞混。第一,List是有序的,这个序是插入顺序,不是排序顺序。ArrayList按插入顺序存储,不自动排序。第二,Set是无序的,这里说的无序是遍历顺序不保证和插入顺序一致,但是HashSet的底层数组长度是固定的,某些情况下遍历顺序看起来也“像”是有序的,这不是保证。第三,HashMap允许null键,但Hashtable不允许null键,ConcurrentHashMap也不允许null键。这三个集合的null策略容易记错,面试经常被问。

8.4 面试时要小心避开的坑

我见过不少人在面试里这样回答“如果HashMap一直发生哈希冲突怎么办”,答案是链表会越来越长,查询性能退化到O(n),JDK 8之后链表长度到8且数组长度到64会转红黑树。

但如果面试官追问“为什么是8而不是16”,这时候要答出泊松分布、时间空间折中相关的权衡,背一个数字是不够的。还有人会被问到“为什么HashMap的容量必须是2的幂次方”,这不仅是因为计算下标时可以用按位与代替取模,提速明显,更重要的是能保证扩容后元素的新位置要么在原位置,要么在原位置加旧容量,这样可以用位运算快速迁移数据,性能提升很大。

最后再讲点实在的

我做Java开发这些年,越来越觉得集合框架是整个Java生态里最值得精读源码的基础库之一。它的继承关系清晰,设计模式用得恰到好处,JDK 8之后还引入了大量函数式接口和默认方法,读一遍源码,不仅能提升编写业务代码的能力,还能学到大量真正的工程技巧。很多人纠结要不要读源码,我的建议是,先把HashMap和ArrayList的源码读透就足够了,这两份代码能把数据结构、算法、性能设计一次讲明白。

如果这篇文章里的某个点让你有共鸣,或者让你想起自己踩过的某个集合相关的坑,欢迎在评论区聊聊你的经历。也建议你找一个周末,打开JDK源码,从ArrayList的add方法开始,顺着整个集合框架读一遍,读完你会有一种“原来如此”的通透感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 6:35:31

纯CSS侧边伸缩导航栏:复选框Hack与:target方案详解及避坑

简介&#xff1a;这是一份基于原生 HTML 与 CSS 实现的侧边伸缩导航栏网页源码&#xff0c;适合前端初学者或需要快速搭建后台管理界面侧边菜单的开发者。资源不依赖复杂框架&#xff0c;重点演示按钮控制展开/关闭、子菜单显隐、过渡动画及响应式适配等核心交互。压缩包共 9 个…

作者头像 李华
网站建设 2026/10/10 6:35:28

Flutter for OpenHarmony:字典查询App全链路实战解析

先说明一下&#xff0c;标题里的“OpenHarmony”我就直接用在文里了&#xff0c;它不是公司名&#xff0c;而是一个开源操作系统项目名称&#xff0c;不涉及合规问题。下面这篇博文是围绕“Flutter for OpenHarmony 字典查询 App”的全栈解析&#xff0c;从技术选型、工程搭建、…

作者头像 李华
网站建设 2026/10/10 6:35:23

Docker实战指南:从安装到Compose部署,解决环境一致性难题

1. 为什么我劝每个开发者都学一学Docker先说个经常遇到的场景&#xff1a;本地跑得好好的代码&#xff0c;同事一拉下来就报错&#xff1b;你开发用的是Windows&#xff0c;线上服务器是Linux&#xff0c;一到部署就各种环境问题&#xff1b;新同事入职第一天&#xff0c;光搭开…

作者头像 李华
网站建设 2026/10/10 6:34:19

OpenClaw 与飞书对接部署全攻略:从回调配置到避坑实践

把 OpenClaw 跑起来这件事&#xff0c;我在部署文档里来回折腾了差不多一个下午。不是装不上&#xff0c;而是每一步都会遇到同样的尴尬&#xff1a;文档只讲“做什么”&#xff0c;不讲“为什么这样做”&#xff1b;飞书后台的配置项和项目配置文件里的字段&#xff0c;对应关…

作者头像 李华
网站建设 2026/10/10 6:33:45

Spring Boot + 微信小程序:高校共享图书借阅小程序开发指南

临近毕业季&#xff0c;又到了“图书漂流”“共享书架”这类校园项目扎堆上线的时候。如果你正在做一个高校共享图书借阅小程序&#xff0c;或者准备拿这个题目做毕业设计/课程设计&#xff0c;这篇文章把从技术选型到项目落地的完整思路拆给你看。项目本身并不复杂&#xff0c…

作者头像 李华
网站建设 2026/10/10 6:33:27

CF603A:翻转01串区间,最长交替子序列的结论与证明

CF603A《Alternative Thinking》是我做了几十道 CF 思维题之后&#xff0c;仍然愿意单独拿出来写一篇的题目。题干短到一句话&#xff1a;给你一个只含 0/1 的字符串&#xff0c;允许最多翻转一个连续区间&#xff08;也可以选择不翻转&#xff09;&#xff0c;问翻转之后整个串…

作者头像 李华