Java集合和时空复杂度,是数据结构DAY1最该先建立的两个概念。很多人学数据结构喜欢上来就啃红黑树、跳表、B+树的底层实现,结果三天后心态崩溃。我带的经验是反过来:把集合框架和时空复杂度作为第一天的主线,先搞清现成工具怎么选、怎么用、快在哪、慢在哪,再带着问题去读源码,效率完全是两个量级。
集合是什么?它是Java里最常用的数据结构容器,ArrayList、HashMap这些名字几乎每天出现在代码里;时空复杂度则是衡量代码快慢、占用内存多少的尺子。今天这篇JAVA数据结构DAY1笔记,就把这两件事串起来讲清楚:集合怎么分类、怎么选型,大O记号怎么读,常用集合操作的时间、空间成本到底是多少。
适合谁看?刚接触数据结构的Java新手、准备面试但基础不牢的求职者,以及平时写业务代码、很少关注性能的开发者。不需要前置知识,打开任意一款你顺手的IDE,跟着第五节的例子敲一遍,效果比只看不练强十倍。
1. 集合框架——先学会用,再研究底层
1.1 为什么第一课主线是集合而不是算法
很多人把“数据结构”等同于“算法”,一上来就刷题。但落到实处,大多数业务代码里90%的数据操作,是在给数据找一个合适的“容器”:要按顺序存、要按值快速查、要保证不重复、要记录键值对应关系。Java的集合框架(Collections Framework)就是这些容器的标准答案,它把接口、实现类、算法三者打包好,直接用就行。
更重要的是,集合框架是学习时空复杂度的最佳载体。ArrayList查询快、插入慢,LinkedList反过来,HashSet去重快但打乱顺序,TreeSet有序但插入变慢——这些“快慢差异”背后,正是时间复杂度的具体体现。把集合用熟了,复杂度概念就不再是数学公式,而是你摸得着的体验。这门课真正难的地方不是记住某个类的名字,而是在具体场景里判断“该用哪一个”,这种判断力只能靠先建立整体地图、再逐个击破的路径来培养。
1.2 集合框架的地图:接口、实现类、选择口诀
先给一张地图。集合框架最顶层的接口是Collection,往下分三支:List(列表,有序可重复)、Set(集合,无序不可重复)、Queue(队列,先进先出)。Map不走Collection分支,它单独成一个体系,存键值对。记住一句话:Collection存单列元素,Map存双列映射。
常用实现类可以浓缩成一张表:
| 接口 | 常用实现类 | 核心特点 |
|---|---|---|
| List | ArrayList | 动态数组,按下标访问O(1),尾部插入快 |
| List | LinkedList | 双向链表,头尾增删快,按下标访问慢 |
| Set | HashSet | 基于哈希表,查找/插入O(1)均摊,无序 |
| Set | LinkedHashSet | 在哈希基础上维护插入顺序 |
| Set | TreeSet | 基于红黑树,元素自动排序,操作O(log n) |
| Map | HashMap | 哈希表,键值存取O(1)均摊,无序 |
| Map | LinkedHashMap | 哈希+链表,保留插入顺序或访问顺序 |
| Map | TreeMap | 红黑树,键自动排序,操作O(log n) |
选择口诀我总结为:要下标快取用ArrayList;头尾频繁增删用LinkedList;去重用HashSet;要排序用TreeSet/TreeMap;要键值对且无序用HashMap;要保留插入顺序的键值对用LinkedHashMap。先背口诀,特例后面踩坑了再回来修正。注意这张表只是“默认答案”,真实的选型还要结合读多还是写多、数据量多大、要不要并发等维度,后面几天我们会逐个展开。
2. 三大接口家族:List、Set、Map 的取舍逻辑
2.1 List:有序可重复,数组思维的延续
List保存一组有序、可重复的元素,允许下标访问。最常见的是ArrayList和LinkedList。
ArrayList底层就是一个Object数组,当元素个数超过容量时自动扩容,新容量大约是原来的1.5倍(源码里的计算是oldCapacity加上oldCapacity右移一位)。因为底层是连续数组,按下标get是O(1),直接根据下标算出内存地址就行;但中间插入或删除时,后面的元素要整体前移或后移,是O(n)。尾部add则基本是O(1),只有触发扩容时才多一些拷贝开销,所以叫“均摊O(1)”。
LinkedList底层是节点链,每个节点持有前驱、后继的引用。头尾add/remove是O(1),只需要改指针;但按下标get(n)必须从头部或尾部逐节点走,是O(n)。中间插入看起来也是O(1),前提是你已经拿到了那个位置的迭代器;如果只按下标插入,定位本身还是O(n)。
实际开发里两者差异非常直观:一万个元素的列表,按下标遍历,ArrayList毫秒级,LinkedList可能慢几十倍。因为链表跳跃访问时缓存命中率很差,真实世界的常数开销比理论上更大。所以别迷信“链表插入快”这句话,它只对头尾操作成立。
2.2 Set:唯一性的背后是哈希与排序
Set的核心语义是“不重复”。HashSet用哈希表实现,add时先算hashCode定位桶,再判断是否存在相同元素,均摊O(1);正因为无序,它不保证迭代顺序。LinkedHashSet在哈希基础上额外用链表记录插入顺序,适合既要唯一性又要“按插入顺序遍历”的场景,代价是多一点内存。
TreeSet用红黑树实现,元素按自然顺序(或自定义比较器)排列。add、contains都是O(log n),虽然比HashSet慢,但好处是遍历时天然有序,还能取头尾、查范围子集。面试里经常考的两个点要提前心里有数:一是HashSet必须配合正确的equals和hashCode,否则“去重”会失效;二是TreeSet对元素要求要么实现Comparable、要么构造时传入Comparator,否则add会抛ClassCastException。
这里有个很典型的对比场景:如果需求是“把用户ID去重,并按ID从小到大输出”,TreeSet一行搞定;但同样的量级,如果你只要求去重、不在乎顺序,HashSet会快很多。顺序是有代价的,这个代价就是O(1)和O(log n)的差距。
2.3 Map:键值对的三种形态与哈希的本质
Map是键值对容器,业务里在线用户状态、统计频次、配置缓存全是它。HashMap是默认选择,底层是“数组+链表”(JDK8后链表过长会转红黑树),通过key的hashCode定位桶,均摊O(1)。负载因子默认0.75,意思是元素个数达到容量×0.75时扩容翻倍。这个0.75是时间与空间的折中:太小浪费内存,太大碰撞变多、性能下降。
LinkedHashMap在HashMap基础上加了双向链表,可以保持插入顺序,还支持“访问后调到末尾”的LRU行为,做简单缓存很顺手。TreeMap则按键排序,操作是O(log n),适合需要有序遍历或范围查询的键值场景。
强调一个本质:HashMap的O(1)建立在“哈希均匀”的前提上。如果key的hashCode写得稀烂,所有元素挤到同一个桶,JDK8之前最坏会退化到O(n),JDK8之后桶内转红黑树能压到O(log n),但已经是另一种量级了。所以自定义类做key时,一定要重写hashCode和equals,别偷懒。
3. 时间复杂度——用数学眼光衡量程序快慢
3.1 大O记号到底在说什么
大O描述的是“当输入规模n足够大时,操作次数随n增长的趋势”,它不关心精确的执行时间,只关心增长量级。因此有两个默认规则:忽略常数项,3n和100n都算O(n);忽略低阶项,n+1000、2n+n都算O(n)。大O还默认讨论最坏情况,因为最坏情况才是性能的兜底。
判断方法很机械:数代码里关键操作的重复次数。一次算术或一次比较,算常数O(1);循环执行n次,是O(n);两层嵌套循环,每层都到n,是O(n²);每一轮都把问题规模减半,是O(log n);外层n次、内层减半,是O(n log n)。
一个容易混淆的点是“实际耗时”和“复杂度”的关系。复杂度告诉你的是增长趋势,不是秒表读数。比如在n很小的时候,O(n²)的冒泡排序可能比O(n log n)的归并排序更快,因为常数小、实现简单;但当n上到一万、十万,趋势主导一切,差值会呈指数级拉开。所以分析复杂度时想的应该是“n增大100倍会怎样”,而不是“现在跑几毫秒”。
3.2 五个常见复杂度级别的代码实例
代码说话。O(1):数组按下标取元素,无论数组多大,都只是一次内存寻址。
int[] arr = {10, 20, 30}; System.out.println(arr[1]); // 固定一步,和数组长度无关O(n):普通循环,n个元素就做n次操作。
int sum = 0; for (int i = 0; i < n; i++) { sum += arr[i]; }O(log n):二分查找。每轮区间减半,n到1大约需要log2(n)轮。
int low = 0, high = n - 1; while (low <= high) { int mid = low + (high - low) / 2; if (arr[mid] == target) return mid; if (arr[mid] < target) low = mid + 1; else high = mid - 1; }O(n log n):经典的归并排序、快速排序平均情况就是这种结构,外层对每个元素做一次处理,每步又伴随log n级别的分治。
O(n²):两层循环分别到n,典型是冒泡排序的双重比较。
for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { // 比较或者交换 } }初学最容易忽略的是常数差异:O(n)和O(2n)本质都是O(n),但真实运行还是差两倍。大O是分类学,不是计时器,它帮你看趋势,具体优化还要回到代码本身。
3.3 常用集合操作的复杂度对照表
把常见集合的关键操作汇总成一张表,面试和日常选型都够用了:
| 数据结构 | 按下标访问 | 按值查找 | 插入 | 删除(按值) |
|---|---|---|---|---|
| ArrayList | O(1) | O(n) | 尾部均摊O(1),中间O(n) | O(n) |
| LinkedList | O(n) | O(n) | 头尾O(1),中间O(n) | O(n) |
| HashSet | - | O(1)均摊 | O(1)均摊 | O(1)均摊 |
| TreeSet | - | O(log n) | O(log n) | O(log n) |
| HashMap | - | O(1)均摊 | O(1)均摊 | O(1)均摊 |
| TreeMap | - | O(log n) | O(log n) | O(log n) |
有个细节值得单独说:常说的“HashMap O(1)”是均摊意义下的,因为哈希碰撞不可避免,扩容时也要重新散列;极端数据下可能有退化。而ArrayList“尾部插入O(1)”同样是均摊,因为扩容偶尔发生。理解“均摊”这个词,比背下复杂度数值更重要。
4. 空间复杂度——容易被忽略的隐形账单
4.1 空间复杂度的两种口径和计算方式
空间复杂度衡量的是“程序运行时额外占用的内存规模随n的变化”,同样用大O表示。计算时通常看三块:基本变量(常数)、数组或容器(跟n成正比)、递归调用栈(跟递归深度成正比)。实际题目里更常说的是“额外空间”——即除了输入本身,你新开辟了多少内存。
举两个反转数组的例子。原地反转只用一个temp变量,额外空间O(1):
for (int i = 0; i < arr.length / 2; i++) { int temp = arr[i]; arr[i] = arr[arr.length - 1 - i]; arr[arr.length - 1 - i] = temp; }新建数组反转,额外空间O(n):
int[] copy = new int[n]; for (int i = 0; i < n; i++) { copy[i] = arr[n - 1 - i]; }两者都解决同一问题,但内存账单差一个量级。递归也容易踩空间坑:深度为n的递归,每次调用都要压栈,额外空间O(n),n特别大时可能栈溢出。所以看到递归先想想深度,别光盯着时间复杂度。
4.2 集合对象背后真实的内存开销
空间复杂度不只存在于算法题,日常Java代码的内存膨胀,很大一部分来自集合对象本身。ArrayList虽然逻辑上是一排元素,底层数组容量经常大于元素个数,扩容后可能留有空位;LinkedList每个节点是一个Node对象,除了数据还要存前驱、后继两个引用,一个int的代价远不止4字节;HashMap每个键值对一个节点,加上table数组可能是容量的若干倍。
包装类型也在偷偷吃内存。int是4字节,Integer对象带上对象头可能16字节起步;用HashMap<Integer, Integer>时,每个键和值各自封箱成对象,内存翻好几倍。数据量大时,这个问题就是实打实的OOM风险。实用建议:能估计规模就给集合指定初始容量,能少封箱就少用包装类型,能复用对象就复用,别让GC替你还债。
一条经验是:宁可初始容量给大一点,也不要频繁扩容。扩容不只是数组拷贝,还涉及哈希类容器重新散列所有现有元素,这是O(n)级别的操作。比如你预计要存一万个键值对,直接new HashMap<>(10000 / 0.75f + 1)这种写法,比默认容量一路resize到够用,要省下不少时间。
5. 实操练习与踩坑记录
5.1 三组适合第一天的入门练习
第一组:统计一段文本中单词出现次数,体会HashMap的getOrDefault。
String text = "apple banana apple orange banana apple"; String[] words = text.split(" "); Map<String, Integer> counter = new HashMap<>(); for (String word : words) { counter.put(word, counter.getOrDefault(word, 0) + 1); } System.out.println(counter); // {apple=3, banana=2, orange=1}第二组:用TreeSet对数组去重并排序,体会Set语义和自动排序。
int[] data = {5, 3, 8, 5, 1, 9, 3, 8}; Set<Integer> set = new TreeSet<>(); for (int num : data) set.add(num); System.out.println(set); // [1, 3, 5, 8, 9]第三组:用LinkedList当队列实现先进先出,体会Queue接口的offer/poll。
Queue<String> queue = new LinkedList<>(); queue.offer("A"); queue.offer("B"); queue.offer("C"); while (!queue.isEmpty()) { System.out.println("出队: " + queue.poll()); } // A B C三组代码分别覆盖Map、Set、Queue三条线,复杂度不超过O(n log n),第一天的目标就是把API用顺、把复杂度感受建立起来。写完再回去看每个操作的复杂度,会有“原来如此”的感觉。
5.2 我踩过的几个典型坑
第一个坑:边遍历边删除集合元素,报ConcurrentModificationException。foreach本质是迭代器遍历,期间直接调用list.remove会破坏迭代状态。正确做法是iterator.remove(),或者Java8后用removeIf。
第二个坑:自定义对象放进HashSet,没重写equals和hashCode,去重完全失效。HashSet判断重复先比hashCode,命中再比equals,两个都不实现,等于在比对象引用,内容相同的对象也会被当成不同元素。这个坑在面试里出现的频率极高。
第三个坑:用==比较Integer。Java对-128到127的Integer有缓存,这段范围内==比较为true,超出范围同样的写法返回false,非常隐蔽。对象比较一律用equals。
第四个坑:循环里反复调用ArrayList的remove(i)。每次remove都要移动后续元素,单次O(n),循环n次就是O(n²),数据量上万就肉眼可见地卡。要么倒序删除,要么用removeIf,要么先标记后统一删。
5.3 常见问题速查表
把上面的知识点压成一张查表,写代码遇到问题时打开看一眼:
| 现象 | 可能原因 | 方案 |
|---|---|---|
| 遍历中删除抛ConcurrentModificationException | 迭代器状态被破坏 | iterator.remove() / removeIf |
| HashSet没有真正去重 | 未重写equals/hashCode | 自定义类重写这两个方法 |
| Integer用==比较结果不符合预期 | 缓存范围外的比较 | 用equals |
| HashMap查询突然变慢 | hashCode分布差,碰撞过多 | 重写更均匀的hashCode |
| 频繁扩容导致性能波动、内存大 | 未设置初始容量 | 按预估容量创建集合 |
| 列表大量中间删除非常慢 | remove(i)循环导致O(n²) | removeIf、倒序删、改用LinkedList |
最后说点个人感受。我见过很多同学把TreeMap的源码背得滚瓜烂熟,但真让他写一个统计词频的程序,第一反应还是数组加一堆if,最后又乱又慢。这不是智力问题,是对“容器”缺少直觉。第一天的集合和时空复杂度,核心就是建立这种直觉:看到需求,想到容器;看到容器,想到它的时间空间账单。
按这个思路学下去,后面无论啃红黑树、B+树还是并发容器,你都会带着明确的问题去读源码,而不是为了背而背。数据结构这门课,真正的门槛从来不是记忆,而是“选择”。