☰
顺序表核心原理与Java实现:从数组到动态扩容
2026/10/9 14:16:45 网站建设 项目流程

1. 顺序表到底是什么:先建立整体认知

提到数据结构入门,顺序表往往是被翻得最多牌的那一张。很多同学在学完数组之后紧接着就撞见它,然后陷入一种“这不就是个数组吗”的困惑里。这个感觉没有错,顺序表本质上就是基于数组实现的一种线性表,但它比裸数组多了一层“管理思维”。我读大学时跟室友讨论过这个问题,他当时直接怼我:“数组就能存数据,为什么要多包一层?”后来等他自己写了一个通讯录管理系统,发现增删数据时全得手动搬数组元素,搬得头昏脑涨,才明白顺序表包装的价值在哪里。

顺序表的核心定义不复杂:用一段物理地址连续的存储单元,依次存放数据元素,逻辑上相邻的两个元素在物理位置上也相邻。这句话翻译成人话就是——你开了一排连续的柜子,每个柜子固定大小,一个挨一个,放东西的时候按顺序放,拿东西的时候也按顺序找。它的这种“连续存放”特性,决定了它拥有的两大天然优势:随机访问极快,按下标就能直接定位;cache友好,CPU加载内存时会预读连续区域,顺序表能吃到这波硬件红利。代价则是插入和删除需要搬动大量元素,而且扩容时往往要整体搬家。

这一章我们先把顺序表放进更大的坐标里看。数据结构里的线性表分两种物理存储方式:顺序存储和链式存储。顺序表代表前者,链表代表后者。如果你未来学Java的ArrayList、C++的vector、Python的list,本质上它们都是顺序表及其变体。搞清楚顺序表,等于把三大语言里最常用容器的底裤都看穿了,后面接触HashMap、TreeMap这些复杂结构时,至少不会因为“容器怎么存数据”这种基础概念而卡壳。

适合谁看这篇内容:正在学数据结构与算法的在校生、准备面试需要快速过一遍基础知识的求职者、以及工作多年但想回炉补底层原理的开发同行。我会尽量少讲空洞理论,多放能直接落在代码上的干货,并解释每一步背后的为什么。毕竟只背概念不写代码,等于只拿了驾照没摸过车,一上路全是状况。

2. 顺序表的核心设计与底层逻辑

2.1 为什么顺序表要选择数组作为载体

顺序表底层载体是数组,这是物理连续性的直接要求。数组在内存中开辟一段连续地址,下标从0开始,每个元素占用相同字节数,于是“第i个元素”的地址可以套公式直接算出来:loc(a_i) = loc(a_0) + i * sizeof(ElementType)。不要小看这个看似朴素的公式,它是顺序表随机访问O(1)复杂度的数学根基。

链表之所以做不到O(1)随机访问,是因为它的节点散落在内存各处,必须通过指针一路next过去。这里有个很直观的类比:顺序表像一排编号固定的电影院座位,你想找第10排5座,按号走过去就是;链表像是寻宝游戏,每张纸条只告诉你下一张纸条藏在哪里,你得一路找下去。

在实际工程里,CPU访问内存时并不是一个字节一个字节读,而是按缓存行(通常64字节)批量加载。顺序表因为数据挨得紧,你访问第0个元素时,后面几个元素大概率已经被预加载进高速缓存了,这在循环遍历场景下能跑出非常好的性能。用Java写代码时你可能感知不明显,但如果你用C语言写大规模数值计算,顺序表和链表在内存带宽利用率上的差距可能达到数倍。

2.2 容量管理:静态扩容背后的尺寸哲学

顺序表最让人头疼的问题是容量。教科书上会把它分成静态顺序表和动态顺序表:静态的表在声明时定死长度,塞满了就报错;动态的表在塞满时自动扩容,用的是“重新开辟一块更大的空间,把旧数据拷贝过去”的策略。实际开发中你几乎不会用静态方案,因为需求变动不居,定死长度等于把路走绝了。动态扩容也有讲究,问题核心是:每次扩容到底扩多少?

常见的扩容策略有两种:固定增量与倍增。固定增量比如每次加10个位置,好处是省空间,坏处是频繁插入时可能反复扩容,导致时间复杂度摊还下来偏高。倍增策略比如每次扩大为原来的1.5倍或2倍,空间换时间,插入操作的总代价被摊薄到可接受范围。Java的ArrayList默认扩容是1.5倍,Python list的扩容大约是1.125倍,两者都在性能和内存占用之间取了折中。

这里有一个值得思考的问题:为什么不是每次只扩容一个位置,这样最省内存?因为插入一个元素就要整体搬迁一次,假设连续插入n个元素,每次搬迁代价是O(n),整体复杂度就成了O(n^2)。如果按倍增策略,扩容次数只有O(logn),每次搬迁累计代价是O(n),摊还下来每次插入大约是“均摊O(1)”级别。这个均摊分析的直觉是:扩容是重体力活,但不能太频繁,一次多搬一点,让后续插入都享受“现有容量够用”的红利。

2.3 顺序表的时间复杂度全景图

数据结构课本上总喜欢给一张时间复杂度表,但很多人只背结论不理解意义。我把它结合实战再解释一遍:

操作平均时间复杂度为什么会这样
按下标访问O(1)直接通过首地址+偏移量计算,没有遍历
按值查找O(n)最坏情况下从头找到尾
尾部插入O(1)(不触发扩容时)直接将新元素写到末尾下标处
头部插入O(n)后面所有元素都得往后挪一个位置
任意位置插入O(n)平均需要搬动一半元素
删除末尾O(1)直接让长度减一
删除头部/任意位置O(n)删除后需要前移后续元素
扩容O(n)新开数组并拷贝全体旧数据

从这张表能看出一个规律:顺序表在“端部操作”上是优等生,在“中部操作”上是困难户。实际编码时,如果你频繁在头部插入元素,又不在乎顺序,完全可以倒过来存,把逻辑上的“头部”放在数组末尾,用反向遍历的思路来规避O(n)的搬移代价。这种通过调整操作方向来匹配存储结构特性的技巧,在优化接口性能时特别常见。

3. 动手实现自己的顺序表:完整Java代码逐行拆解

3.1 定义一个带泛型的动态顺序表类

我用Java来写一个接近工程实战的版本,但会刻意甩开ArrayList的源码,保证逻辑一目了然。先看整体骨架:

public class MyArrayList<E> { private static final int DEFAULT_CAPACITY = 10; private Object[] elements; private int size; public MyArrayList() { this(DEFAULT_CAPACITY); } public MyArrayList(int initialCapacity) { if (initialCapacity < 0) { throw new IllegalArgumentException("容量不能为负数: " + initialCapacity); } elements = new Object[initialCapacity]; size = 0; } }

几个设计点我展开说明。第一,为什么内部用Object[]而不是E[]?Java泛型存在类型擦除,直接new E[initialCapacity]在编译期会被擦除成new Object[],而且强制转换会产生未检查警告。用Object[]再在读取时做强转,是JDK源码的通行做法,ArrayList自己也是这么干的。第二,为什么默认容量定成10?太小会导致较早触发扩容,太大又浪费内存,10是一个平衡起步值,JDK也这么选。

容量校验为什么抛IllegalArgumentException而不是ArrayIndexOutOfBoundsException?因为语义不同。前者表达“调用者传入的参数不合理”,后者表达“索引越界访问数组”。一个负容量连数组都开不出来,这属于参数异常,不该等到往内部数组里塞数据时才暴露。

3.2 核心操作之添加:从尾部增加到指定位置插入

先写最简单的add(E value),也就是尾部追加:

public boolean add(E value) { ensureCapacity(size + 1); elements[size] = value; size++; return true; }

ensureCapacity(size + 1)的意思是:我需要至少能装下size+1个元素的容量。这个方法内部会检查当前数组是否已满,满则触发扩容。这里有个细节值得注意:扩容判断的时机应该在写入之前,因为如果写入后再发现数组越界,那元素已经无处安放,程序直接炸了。先扩容、再写入、最后size加一,三步顺序不能乱。

再看指定位置插入:

public void add(int index, E value) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("插入位置不合法: " + index + ", size=" + size); } ensureCapacity(size + 1); for (int i = size; i > index; i--) { elements[i] = elements[i - 1]; } elements[index] = value; size++; }

这里的关键是循环方向。我要把[index, size-1]范围内的元素整体右移一位,必须从数组末尾开始往前搬,而不能从头往后搬。如果从前往后搬,前一个元素会被后一个元素覆盖,数据就丢了。以index为1、size为3举例:原始数据是[A,B,C],插入X后希望得到[A,X,B,C]。正确的搬法是先把C从2位搬到3位,再把B从1位搬到2位,最后在1位写入X。如果顺序反过来,先把B搬到2位,此时B覆盖了C,接着再想搬C时已经搬无可搬。

index > size这个边界条件也要注意。允许插入到size位置表示在尾部追加,这在逻辑上合法;但不允许插入到size+1,因为那会造成空洞,中间隔着一个没被初始化的位置。

3.3 核心操作之删除:前移覆盖与缩容时机

删除指定位置元素的代码:

public E remove(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("删除位置不合法: " + index + ", size=" + size); } E oldValue = (E) elements[index]; int numMoved = size - index - 1; if (numMoved > 0) { System.arraycopy(elements, index + 1, elements, index, numMoved); } elements[--size] = null; return oldValue; }

删除逻辑本质是做“整体前移覆盖”:把index后面的所有元素往前挪一格,然后让size减一,最后将末尾位置置空。System.arraycopy是native方法,底层按内存块拷贝,比手写for循环快很多。你可能注意到这一点我直接用了JDK的数组拷贝工具,而不是像插入时那样写for循环,原因是工程代码里能用系统级优化就用系统级优化,没必要重复造轮子。但你要理解:arraycopy的语义是把源数组的某段复制到目标数组的某段,即使源和目标重叠,它也能按正确方向处理,Java内部会判断方向避免覆盖错误。

删除末尾元素时numMoved=0,表示不需要搬数据,直接size减一即可,这就是O(1)尾部删除的来源。末尾置空这步容易被忽略,但你别小看它。如果顺序表里存的是对象引用,size减了但引用还躺在数组里,Java的垃圾回收器可能因为仍有强引用而不能回收它,造成内存泄漏。在ArrayList源码中,删除后也会把位置清成null,这不是洁癖,是内存管理的自觉。

缩容的讨论同样绕不过去。很多教材会建议删除后如果元素个数远小于容量,就主动缩容。现实是:缩容太频繁会导致大量复制操作,反而拖低性能。较常见的做法是不缩容或设置一个缓冲区间,比如只有在size降低到容量的四分之一时才缩为一半。这一点我建议你保守操作,不要一删就缩,扩容的开销远比缩容的“省内存”严重。

3.4 核心操作之查找:按下标和按值

按下标获取元素是所有操作里最简单的:

public E get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("索引不合法: " + index + ", size=" + size); } return (E) elements[index]; }

它的时间复杂度是O(1),因为数组原生支持按下标访问。这里要注意的是返回前必须做强转:内部存的是Object,泛型擦除后编译器不知道确切类型,需要你明确告诉它“这个Object其实就是E”。

按值查找有两种返回方式——返回布尔值,或者返回下标。我用返回下标的版本:

public int indexOf(Object target) { if (target == null) { for (int i = 0; i < size; i++) { if (elements[i] == null) { return i; } } } else { for (int i = 0; i < size; i++) { if (target.equals(elements[i])) { return i; } } } return -1; }

为什么要把“查找null”单独处理?因为equals方法必须作用在非空对象上,如果你用elements[i].equals(target),而elements[i]恰好是null,会抛空指针。反过来用target.equals(elements[i]),又要求传入的target不为null。所以最稳妥的办法是分两个分支,对null单独走==判断,JDK的ArrayList也是这么写的。别觉得这种细节无聊,生产环境的空指针异常十有八九就是在这种不起眼的角落爆出来的。

3.5 核心操作之扩容:1.5倍策略的正确打开方式

扩容逻辑是顺序表动态特性的引擎:

private void ensureCapacity(int minCapacity) { if (minCapacity > elements.length) { int newCapacity = elements.length + (elements.length >> 1); if (newCapacity < minCapacity) { newCapacity = minCapacity; } elements = Arrays.copyOf(elements, newCapacity); } }

elements.length >> 1是右移一位,相当于除以2,所以新容量为旧容量的1.5倍。为什么Java的ArrayList用1.5倍而不是2倍?如果扩容倍数太大,比如2倍,虽然扩容次数少了,但浪费的内存空间也显著增加。如果扩容倍数太小,比如1.1倍,内存利用率高了,可扩容次数太多,拷贝开销变大。1.5倍在两者之间取了一个不错的平衡点,既减少了扩容频率,又控制了空间浪费。

Arrays.copyOf会创建新数组并把旧数组内容整体复制过去,这一步结束后,旧数组失去引用,等待垃圾回收。如果旧数组里存了特别大的对象,gc压力不小,这也是为什么做大数据量批量插入时,最好在构造阶段直接指定预估容量,让数组一步到位,避免反复扩容带来的复制风暴。

3.6 完整代码汇总与测试用例

把上述方法整合进一个完整的类,再写几个测试点验证行为:

public class MyArrayList<E> { private static final int DEFAULT_CAPACITY = 10; private Object[] elements; private int size; public MyArrayList() { this(DEFAULT_CAPACITY); } public MyArrayList(int initialCapacity) { if (initialCapacity < 0) { throw new IllegalArgumentException("容量不能为负数: " + initialCapacity); } elements = new Object[initialCapacity]; } public boolean add(E value) { ensureCapacity(size + 1); elements[size++] = value; return true; } public void add(int index, E value) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("插入位置不合法: " + index); } ensureCapacity(size + 1); for (int i = size; i > index; i--) { elements[i] = elements[i - 1]; } elements[index] = value; size++; } public E remove(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("删除位置不合法: " + index); } E oldValue = (E) elements[index]; int numMoved = size - index - 1; if (numMoved > 0) { System.arraycopy(elements, index + 1, elements, index, numMoved); } elements[--size] = null; return oldValue; } public E get(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("索引不合法: " + index); } return (E) elements[index]; } public int indexOf(Object target) { if (target == null) { for (int i = 0; i < size; i++) { if (elements[i] == null) return i; } } else { for (int i = 0; i < size; i++) { if (target.equals(elements[i])) return i; } } return -1; } public int size() { return size; } private void ensureCapacity(int minCapacity) { if (minCapacity > elements.length) { int newCapacity = elements.length + (elements.length >> 1); if (newCapacity < minCapacity) { newCapacity = minCapacity; } elements = Arrays.copyOf(elements, newCapacity); } } public static void main(String[] args) { MyArrayList<String> list = new MyArrayList<>(2); list.add("A"); list.add("B"); System.out.println(list.size()); // 2 list.add("C"); // 触发扩容 list.add(1, "X"); System.out.println(list.get(1)); // X System.out.println(list.remove(0)); // A System.out.println(list.size()); // 3 System.out.println(list.indexOf("C")); // 2 } }

这段代码跑起来后,你会看到size变化和元素顺序都符合预期。有一件测试时容易忽略的事:打印容器内部状态时最好重写toString方法,否则你看到的是一串类的全限定名和哈希码,一点都不直观。很多初学者卡在这,以为容器坏了,其实只是没重写toString。

4. 顺序表实操中的高频问题与排查心得

4.1 初学最容易踩的四个坑

第一个坑是混淆“容量”和“大小”。容量指内部数组能装多少个元素,大小指当前已存储的元素个数。很多新人用elements.length去控制遍历边界,结果遍历到null位置还继续取,轻则多循环几次,重则下标越界。记住一条规则:对外暴露的遍历和查找永远以size为准,elements.length只是容量上限。

第二个坑是插入删除时方向搞反。我在前面特意强调过后移必须从尾部开始,前移必须从头部开始。写反的后果是数据被前一个位置覆盖,现场惨不忍睹。建议初学阶段在纸上先把搬移轨迹画一遍,再落到代码里,磨刀不误砍柴工。

第三个坑是忽略扩容带来的引用失效。假设你写了段代码,持有了数组里某个位置的引用,然后插入一个元素触发扩容,引用指向的旧数组被gc,原来那个位置上的对象已经不在新数组里了。这个问题的隐匿性很强,因为代码在越界前不会崩溃,只有数据错乱后才暴露。避免方案是不要长期持有容器内部位置的引用,需要数据就取出来暂存,不要惦记“位置”。

第四个坑是没有处理null的查找。前面我写了indexOf的null分支,许多初学者只写了非空分支。如果存入null又查找null,程序直接抛空指针。测试时往往只存正常字符串,问题不会被发现,可一旦线上出现了null数据,排查起来很费神。

4.2 扩容时机与性能陷阱实测

很多人觉得扩容不是问题:“反正它是自动的,我无感。”但如果你频繁在循环里往顺序表尾部塞数据,性能会有肉眼可见的抖动。我拿一个真实案例说明:一段循环往ArrayList里插入1千万条数据,不指定初始容量,耗时约300毫秒;同样的循环在构造时指定容量为1千万,耗时约50毫秒。差值来自扩容导致的多次数组复制与垃圾回收压力。

这背后的机制不复杂:每次扩容不仅复制旧元素,还让旧数组成为gc候选。如果扩容发生十几次,就多产生十几个待回收的大数组对象,STW时间肉眼可见地增长。用Java做大数据量处理时,我的习惯是在创建集合时就评估数据规模,给出一个偏大的容量,宁可浪费一点空间,也不要让扩容打断性能曲线。

4.3 当顺序表不够用时:什么时候该换链表

顺序表并非万能,工程中有些场景它明显不适合。比如任务队列里频繁在头部插入新任务,同时也不断从尾部取出任务,这属于“两端操作”场景,顺序表每次头部插入都要搬动全体元素,复杂度直线上升。反过来,如果操作集中在“按下标随机访问”和“尾部增删”,顺序表近乎完美。

还有一个隐蔽的场景因素:大对象存储。如果顺序表里每个元素都是重量级对象(比如大型字符串、图片二进制流),数组扩容时复制的是引用不是对象本身,所以这个因素影响不大。真正影响大的是元素数量级——当元素数量达到千万级别时,连续内存的分配压力会变大,数组扩容时可能找不到一大块连续内存。这时候JVM堆内存碎片化严重, LinkedList之类基于节点散布的结构反而更灵活。总结一句:以随机访问为主用顺序表,以任意位置增删为主且数据量大时,链表更有优势。

4.4 一份排查速查表

现象可能原因解决思路
插入后数据丢失后移元素时循环方向错了,前向覆盖确认插入从末尾向指定位置搬移
删除后末尾仍能看到旧对象只减size没有置null将elements[size] = null
程序间歇性卡顿多次扩容复制数组、gc压力预估容量,构造时传入合适初值
查找null时报空指针对null项调用了equals单独处理null分支
删除后元素整体错位前移范围计算错误确认numMoved = size - index - 1
容量明明够却数组越界用length当size用遍历时改用size判断边界

我的排查习惯是:先看边界条件,再看循环方向,最后才怀疑算法本身。因为边界写错和方向写反占了我平时遇到的顺序表问题八成以上。盯住size这个关键词不放松,问题往往能快速定位。

5. 顺序表在API设计与工程选型中的进阶思考

5.1 从“会写”到“会设计”:接口设计的细节

手写顺序表时,接口设计决定了使用者的体验。好的接口应该只暴露最少且必要的操作,保持语义清晰。add(E)表示尾部追加,add(int, E)表示指定位置插入,remove(int)按位置删除,get(int)按下标访问,indexOf(Object)按值查找。这几个操作覆盖了日常开发的全部需求。

接口里还有两个容易忽略的方法:isEmpty()和clear()。isEmpty直接判断size == 0;clear的实现有两种,一种是粗暴地把size置零,另一种是把数组内所有元素置null再置零。前者效率高但有内存对象残留风险,后者更安全但多了遍历开销。工程上我建议实现后一种,尤其是容器生命周期较长时。

还有iterator遍历器的实现,这个内容撑起另一篇长文,但对顺序表初识阶段来说,你只需要知道:为遍历而暴露内部数组引用是不妥的,外部可能修改内部状态。想开放遍历,要么返回的是副本,要么提供迭代器接口。理解这一点,你就在“会用”和“会设计”之间迈出了一大步。

5.2 顺序表与Java集合框架的对照

Java里最著名的顺序表实现是ArrayList,它的核心代码和我上面的手写版有大量重合之处,主要区别在于它实现了List、RandomAccess、Cloneable、Serializable等接口,引入了modCount机制来支持快速失败(fail-fast)迭代器,并且使用了System.arraycopy做批量搬移。这些扩展属于工程加固,底层存储和算法核心并无本质不同。

ArrayList的modCount字段值得一提。每当结构性修改发生时(add、remove、clear等),modCount就加一。迭代器每次检查这个值,如果发现被外部修改过,立即抛出ConcurrentModificationException。这是防止“遍历同时被修改”导致数据错乱的有效手段。初学者碰到这个异常时往往很困惑——明明没有多线程,为什么也报错?其实是因为你在foreach里直接调用了add或remove。解法是使用Iterator的remove方法,它会在移除元素后同步更新迭代器的期望modCount。

对比ArrayList和LinkedList的适用边界,很多人喜欢背“ArrayList查询快、增删慢,LinkedList增删快、查询慢”,但这句话在工程上不严谨。LinkedList的增删快只有在首尾操作时成立,在中间位置插入,它一样要线性查找索引位置,复杂度同样是O(n)。而ArrayList在尾部增删是O(1)。真实项目中,90%以上的集合场景都在做遍历和尾部追加,所以ArrayList几乎总是默认选择。

5.3 从顺序表延伸出去:数据结构的“第一块拼图”

学完顺序表后,自然的下一步是链表、栈和队列。有趣的是,栈和队列其实都可以用顺序表来实现——用数组加一个top指针,就是一个栈;用数组加头尾两个指针,再配合环形取模逻辑,就是循环队列。这些实现不仅能加深你对顺序表的理解,还能帮你明白“数据结构是一种思想,不是死板的模板”。

如果你最终还是用Java语言工作,建议去读一遍ArrayList的源码,会发现自己动手实现的代码与JDK官方代码之间那些细节差异:比如JDK如何用优雅的方式处理grow时机,如何处理subList视图与父列表的联动修改。学到这些,才是把顺序表“吃透”的标志。

5.4 一个扩展练习:实现遍历器并验证快速失败

纸上谈兵不如动手,我建议做一个扩展练习来巩固理解:给上面的MyArrayList增加一个内部迭代器类,实现hasNext()和next()方法,再引入modCount计数,在迭代器创建时记住expectedModCount,每次方法调用都检查两者是否一致,不一致就抛ConcurrentModificationException。

练习过程中你会发现自己对“对象内部状态”的理解加深了很多。迭代器本质上是容器内部状态的一个快照与游标,两个对象(容器和迭代器)共享同一份数据,但通过modCount实现了修改同步安全。这套机制在Java集合里到处都在用,你现在搞懂,以后看HashMap、TreeMap的源码都会顺利不少。

我个人在带新人时还会让他们做一个额外的任务:把顺序表改成存储学生成绩,支持统计平均分、最高分、按分数段筛选。实现这些功能的过程中,遍历、按值查找、条件统计自然就熟练了。看到新人从对着IDE发呆到流畅地写出这些功能,我就知道顺序表这一关他算是过了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询