聊到 JavaScript 刷题,数组、字符串和 ArrayList 的简单实现,大概是很多前端开发者在算法入门阶段最先遇到的一组基础题目。早些时候我帮一个学弟梳理面试题,发现一个很有意思的现象:数组的 API 大家都会背,字符串的方法也能列个七七八八,但一旦被问到底层实现——比如“ArrayList 是怎么扩容的”“为什么字符串拼接那么慢”——不少人就卡住了。这其实暴露了一个问题:刷题的时候只看到了语言给我们提供的便利,没有去理解数据结构本身的工作原理。这篇内容就是围绕这三个核心对象展开的,从刷题视角出发,先讲清楚数组和字符串在 JavaScript 里的真实特性,再手写一个简易的 ArrayList,把动态数组的容量管理、扩容策略、边界处理这些平时藏在底层的东西翻到台面上来。不管你是刚开始刷题的新手,还是想补数据结构的同学,这篇文章都能让你重新认识这些“老朋友”。
1. 刷题视角下的数组与字符串:它们到底在考什么
1.1 核心需求:不只是语法,而是数据结构思维
很多人在刷题初期会陷入一种误区:看到题目标签写着“数组”,就以为只要会用forEach、map、filter就够了;看到“字符串”,就想着字符串方法多背几个,总能套上。但刷题平台真正想考察的,从来不是某个方法的拼写,而是你对数据组织方式的理解。数组的连续内存、随机访问、动态扩容;字符串的不可变性、字符索引、子串操作;这背后隐藏的是一整套关于时间复杂度和空间复杂度的思考方式。
我举一个最常见的例子:unshift和shift。面试官指尖一动,出一道“用数组实现一个队列”的题,很多人的第一反应是push进队、shift出队,逻辑完全正确。可如果追问一句“这个队列的出队操作时间复杂度是多少”,能答出 O(n) 的人就少了一半。原因是 JavaScript 的数组是动态数组,shift会让所有剩余元素向前移动一位,数据量大时性能衰减得极快。这种从“能用”到“用得明白”的差距,才是刷题真正需要补齐的。
所以本文的第一个目的,是把数组和字符串从“语法糖堆”中拉出来,放到数据结构这个坐标系里重新审视。你会看到为什么数组适合随机访问、为什么在头部增删元素那么昂贵、为什么字符串拼接会引发性能灾难。对初学者来说,这是一个建立底层认知的好机会;对已经刷过一些题的同学来说,这篇文章能帮你把零散的经验串成体系。
1.2 JavaScript 的数组和 Java 的 ArrayList 到底有什么关系
这里有必要先厘清一个概念:JavaScript 的Array并不是传统意义上的“数组”。在很多经典教材里,“数组”指的是一块连续内存、固定长度、元素类型一致的结构,就像 C 语言里的int arr[10]。但 JavaScript 的Array本质上是一个动态数组,它内部是连续存储的数值索引集合,同时抛出了一大堆面向操作的方法,比如push、pop、slice、splice。换句话讲,JS 内置数组几乎就是一个“自带方法的 ArrayList”,平时我们用起来没什么感觉,是因为语言已经帮我们处理了扩容和迁移。
真正把两者区分开的,是它们在题目中扮演的角色。在 Java 刷题时,很多人会刻意使用ArrayList来替代原生数组,因为它的add、remove、get方法更友好,且可以动态增长。而在 JavaScript 中,我们直接拿Array当动态列表用,需要add就push,需要remove就splice,几乎没有心理负担。
但这带来一个问题:因为太方便,大家就忽略了动态数组底层发生了什么。ArrayList 的简单实现之所以值得手写一遍,不是让你在项目里替换原生数组,而是让你切身体会三个核心机制:
- 容量不足时如何申请新内存
- 数据如何从旧容器迁移到新容器
- 扩容因子(growth factor)如何影响整体性能
理解了这三件事,你再回来看 JS 的push和pop,就能明白为什么push在均摊意义下是 O(1),而在某些边界条件下会有一次 O(n) 的扩容开销。这种认识在解决大数据量的题目时非常关键,比如实现一个动态的哈希表底槽、手写 LRU 缓存、或者模拟一个大数组的分块存储。
2. JavaScript 数组的常用操作与性能隐患
2.1 常用方法分类与选用建议
数组的 API 少说也有三十多个,但在刷题场景里最常用的就那十几个。我习惯把它们按操作类型分成三组,这样记忆和理解会更清晰。
第一组是尾部操作:push和pop。这两个方法只影响数组末尾,都不会移动其他元素,所以时间复杂度是 O(1)。大量题目依赖这两个方法,比如括号匹配的栈模拟、深度优先搜索里的路径回溯、单调栈维护等等。
第二组是头部操作:unshift和shift。它们的时间复杂度是 O(n),因为插入或删除头部元素后,后面的所有元素都要整体平移。很多人在刷“用数组模拟队列”的题目时会掉进这个坑,频繁shift导致超时。替代方案是用两个栈模拟队列,或者干脆维护一个头指针headIndex,只在逻辑上“删除”头部元素,这就把时间复杂度降到了 O(1)。
第三组是中间操作和查询操作:splice、slice、indexOf、includes、find、filter等。splice用于增删改任意位置,本质上是先截取再拼接,最坏情况 O(n);slice是浅拷贝一个范围,不会改动原数组,适合用来做不可变操作;indexOf和includes是线性查找,O(n),如果频繁使用并且数据量大,就需要考虑换成哈希表结构。
我把刷题时最高频的几组操作整理成了一张表,方便对比:
| 操作 | 方法 | 时间复杂度 | 是否改变原数组 | 典型场景 |
|---|---|---|---|---|
| 尾部添加 | push | O(1) 均摊 | 是 | 栈、队列模拟 |
| 尾部删除 | pop | O(1) | 是 | 栈、回溯 |
| 头部添加 | unshift | O(n) | 是 | 尽量避免 |
| 头部删除 | shift | O(n) | 是 | 队列的朴素模拟 |
| 任意位置插入/删除 | splice | O(n) | 是 | 需要中间操作时 |
| 切片复制 | slice | O(n) | 否 | 获取子数组 |
| 查找元素 | indexOf / includes | O(n) | 否 | 线性搜索 |
| 批量生成新数组 | map / filter / reduce | O(n) | 否 | 数据处理流水线 |
这张表看起来简单,但很多性能问题都出在“选错方法”上。比如你想把数组反转,如果只是为了遍历,可以直接从尾部循环,而不是先reverse()再遍历。多一次 O(n) 操作,在数据量大时就是成倍的耗时。
2.2 “看似简单”的性能陷阱
有些数组操作的性能问题藏得很深,不仔细分析代码根本发现不了。我举两个刷题中很常见的例子。
第一个是“删除数组中的重复项”。很多人会怎么写?用一个for循环,每遇到一个重复项就调用一次splice。代码很短,逻辑全对,但一旦数组长度上了万,性能立刻裂开。因为每splice一次,剩余元素就要整体搬移一次,最坏情况下是 O(n^2)。正确的做法是用双指针或者filter保留不重复的元素,一次性过滤,把整体复杂度压回 O(n)。
第二个是“循环队列”或“滑动窗口”类题目中的头部淘汰。典型写法是queue.shift(),看着舒服,结果一提交发现超时。原因还是那个 O(n) 平移。常见优化思路是使用双端队列,但在 JavaScript 里没有原生双端队列,一种做法是维护一个指针start,不真正删除元素,用指针标记窗口的起始位置;另一种做法是使用数组模拟环形队列,用head和tail两个索引配合取模运算。这两种方案都能让头部“删除”变成 O(1)。
再补充一个容易被忽略的细节:数组的length属性并不一定代表实际存储元素的数量,在稀疏数组的情况下,length可能大于实际元素个数。刷题时我们很少构造稀疏数组,但如果你用new Array(n)来预分配空间,会得到一个全是空洞的数组。遍历时如果不对空洞做处理,map和forEach会直接跳过这些空位,而for循环会得到undefined。这会导致结果和预期不一致。比较稳妥的做法是用Array.from({ length: n })或Array(n).fill(0)来初始化,确保所有位置都有确定的值。
3. 字符串不可变性与刷题中的常见误区
3.1 不可变字符串改变你的操作方式
JavaScript 里的字符串是不可变的。这句听起来像常识的话,在实际刷题中会带来很实质的影响。不可变意味着你做的任何一个“修改”操作——比如拼接、替换、删除字符——实际上都不会改动原字符串,而是生成一个全新的字符串对象。
最典型的性能陷阱是循环拼接。看你有没有写过这样的代码:
let s = ''; for (let i = 0; i < 100000; i++) { s += 'a'; }这段代码在数据量小的时候运行无感,但在十万次甚至百万次循环里,会变得非常慢。为什么?因为每一次s += 'a'都不是“在尾部追加”,而是“创建一个新字符串,把旧内容全部复制过去,再追加一个字符”。单次 O(n),循环下来就是 O(n^2)。这就像抄一本 100 页的书,每加一页都要把全书重新抄一遍,成本高得离谱。
正确的做法是先用数组收集片段,最后用join一次性拼接:
const parts = []; for (let i = 0; i < 100000; i++) { parts.push('a'); } const s = parts.join('');join的内部实现会对所有片段做一次总长度计算,然后只分配一次空间,整体开销是 O(n)。刷题时遇到字符串逐字符构造的场景,比如实现字符串压缩、模拟文本编辑、构建编码结果,都应该默认使用“数组收集 + join 输出”这个模式,实测下来在长字符串场景里能快一个数量级。
字符串和数组的双向转换也是高频操作。用split('')把字符串拆成字符数组,处理完再用join('')拼回去,是刷题里的标准套路。比如反转字符串里的单词、判断回文串、对字符串内部字符排序,基本都是先转数组再操作。需要提醒的是,split('')会把代理对(代理对指码点超出基本多语言平面的字符,比如某些生僻字或 emoji 字符)按两个码元拆开,导致结果乱码。如果题目明确涉及这类字符,需要改用Array.from(s)或[...s],它们能正确按 Unicode 码点拆分。
3.2 字符串方法选择与边界处理
字符串的方法不比数组少,但刷题时真正高频的就是那十几个。我按用途分一下:
- 查看与搜索:
charAt、indexOf、lastIndexOf、includes、startsWith、endsWith。 - 截取与切片:
slice、substring、substr。 - 大小写转换:
toLowerCase、toUpperCase。 - 正则相关:
match、replace、search、split。 - 修剪与填充:
trim、padStart、padEnd、repeat。
这里面最容易出错的,是三个截取方法的区分。slice和substring在参数都是非负数时表现基本一致,都是取start到end(不含end)之间的字符;但substring会自动交换两个参数(如果start大于end),而slice支持负数索引(表示从末尾倒数)。substr的第二个参数是长度而不是结束位置,这个语义差异在刷题时非常坑,我建议能不用就不用,统一用slice最省心。
大小写相关的坑也不少。经典的回文串判断,很多人会直接s.toLowerCase().replace(/[^a-z0-9]/g, ''),然后反转比较。这本身没问题,但如果题目包含非英文字母的字符,比如带重音的法语字符或 Unicode 字符,toLowerCase()的行为会受当前语言环境的影响,导致大小写折叠结果和我们预期不一致。稳妥做法是明确限定题目给出的字符集,或者用toLocaleLowerCase('en-US')这类指定语言环境的方法。
还有一种常被忽略的边界情况:空字符串和单字符串。比如判断两个字符串是否互为排列,很多人会先排序再比较,但如果你没有处理空串,某些使用哈希计数的方法会在统计时产生undefined值比较,导致意外的结果。我通常在开头就写好if (s.length !== t.length) return false;这种基础守卫条件,避免后面做无意义的计算。
正则表达式在字符串题目里是双刃剑。replace配合正则确实能写出很简洁的代码,但正则的回溯在长字符串上可能非常慢,极端情况下会直接打崩判题时限。我见过不少同学在“字符串解码”“单词替换”这类题中尝试用正则通配,结果不仅代码难调试,性能也不理想。建议只把正则用在字符集清理和格式匹配上,真正需要逻辑判断的解析工作,老老实实写状态机或手写循环。
4. 手写一个简易 ArrayList:从零理解动态数组
4.1 为什么刷题时值得手写一遍 ArrayList
动态数组的简单实现,看起来是一个“玩具级”练习,但它包含的知识密度其实很高。手写一遍 ArrayList,你至少要回答下面几个问题:
- 底层用什么存数据?数组,但 JavaScript 的数组本身已经是动态的,那我还需要自建数组吗?答案是:为了模拟底层行为,我们用普通对象或定长数组来模拟固定容量存储。
- 容量满了怎么办?申请一块更大的连续存储空间,把旧数据逐个搬过去。
- 扩容扩多少?每次扩一倍,还是扩 1.5 倍,还是扩固定大小?选择不同会带来不同的均摊复杂度。
- 缩容吗?如果大量删除元素,容器是否主动释放空间?如果不缩容,内存浪费怎么控制。
- 越界访问如何处理?比如
get(-1)或get(100)应该报错还是返回undefined。
这些问题在语言内置的数组里都被隐藏了,但它们在算法题里会以各种变形出现。比如哈希表链地址法的底层存储、邻接表的动态添加边、模拟稀疏矩阵的列索引,这些场景都需要我们自己管理一个动态数组。提前把底层机制想清楚,再去看这些数据结构就顺滑很多。
另外,在很多公司的算法面试里,“手写一个支持动态扩容的数组/列表”是一道经典题目。它考察的正是你对内存管理、容量计算、均摊复杂度这些基础概念的掌握程度。所以无论从学习角度还是求职角度,用 JavaScript 实现一个简易 ArrayList 都很有价值。
4.2 完整实现代码与逐步拆解
下面这版实现是我比较常用的一个基础版,保留了动态数组最核心的行为,去掉了过于工程化的细节,方便读者对照理解。
class SimpleArrayList { constructor(initialCapacity = 8) { if (initialCapacity <= 0) { throw new Error('容量必须为正数'); } this.capacity = initialCapacity; this.data = new Array(initialCapacity); this.size = 0; } // 返回当前元素个数 length() { return this.size; } // 获取指定位置的元素 get(index) { this._checkIndex(index); return this.data[index]; } // 设置指定位置的元素 set(index, value) { this._checkIndex(index); this.data[index] = value; } // 尾部追加 add(value) { if (this.size === this.capacity) { this._resize(this.capacity * 2); } this.data[this.size] = value; this.size++; } // 指定位置插入 insert(index, value) { if (index < 0 || index > this.size) { throw new Error('索引越界'); } if (this.size === this.capacity) { this._resize(this.capacity * 2); } for (let i = this.size; i > index; i--) { this.data[i] = this.data[i - 1]; } this.data[index] = value; this.size++; } // 删除指定位置的元素并返回它 removeAt(index) { this._checkIndex(index); const removed = this.data[index]; for (let i = index; i < this.size - 1; i++) { this.data[i] = this.data[i + 1]; } this.size--; // 可选缩容策略 if (this.size <= this.capacity / 4 && this.capacity > 8) { this._resize(Math.max(8, Math.floor(this.capacity / 2))); } return removed; } // 内部:索引检查 _checkIndex(index) { if (index < 0 || index >= this.size) { throw new Error('索引越界'); } } // 内部:扩容/缩容函数 _resize(newCapacity) { const newData = new Array(newCapacity); for (let i = 0; i < this.size; i++) { newData[i] = this.data[i]; } this.data = newData; this.capacity = newCapacity; } // 转成普通数组,方便调试 toArray() { return this.data.slice(0, this.size); } }下面逐段拆解几个核心点。
构造函数部分,我默认初始容量是 8。这个数字不是随便定的,让初始容量太小会导致前期频繁扩容;太大则浪费内存。8 对我们手写练习足够,也符合很多语言标准库的默认选择。
add方法里最先判断的是size === capacity。这一步的意义是提前确认数组是否还有剩余空间。注意这里有个细节:size表示已存储的元素个数,capacity表示底层数组的物理容量。当两者相等时,代表当前数组已经“装满”,必须先扩容再写入。如果不判断直接data[size] = value,就会写入一个越界位置,后续访问会得到undefined。
_resize方法是整个实现的灵魂。newCapacity 传入时,我们创建一个新的定长数组,然后把旧数组里的size个元素逐个复制过去,最后替换this.data并更新this.capacity。为什么需要逐个复制?因为数组的连续存储特性决定了,新数组和旧数组的内存地址并不相同,必须通过搬移来保持数据连续性。这也是动态数组扩容时最主要的性能开销。
再说扩容因子的选择。我的实现里是capacity * 2,也就是每次扩容翻倍。把扩容因子设为 2 的好处,是均摊时间复杂度为 O(1)。简单解释一下:假设初始容量为 1,依次添加 n 个元素,扩容操作的总搬移次数大约为 1 + 2 + 4 + ... + n/2 + n,合计不到 2n,是 O(n) 量级。所以平均到每次add上,额外开销只有常数级。如果容量只增加固定值而不是翻倍,比如每次多 8 个空位,那么总搬移次数就是 1 + 2 + 3 + ... + n,变成 O(n^2),单次add的均摊成本就不是常数了。这个差别在千万级数据量上非常明显。
不过,扩容因子翻倍也并非百利而无一害。翻倍意味着底层数组可能比实际存储量多出一倍的空白位置,在内存敏感的场景下是浪费。有的标准实现会选择 1.5 倍扩容,在“搬移次数”和“空间浪费”之间取得平衡。我们手写练习时不必纠结哪个更好,二倍扩容更容易理解和推导,就先用它。
removeAt里的缩容策略是很多实现容易遗漏的部分。如果只扩容不缩容,一个存过 10 万元素后来被清空的列表,会一直占着 10 万容量的内存空间。我采用的策略是当size <= capacity / 4时触发缩容,一次性缩到原来的一半。这个阈值既有延迟又有余量,避免元素在边界反复增删时频繁触发扩容和缩容,降低了性能抖动。注意缩容时也不能缩到小于初始容量 8,这是防止列表被“缩没了”。
4.3 几种边界情况的处理细节
手写动态数组的过程中,最让人头疼的不是主线逻辑,而是各种边界情况。我在调试过程中总结出了几个高频翻车点。
第一个是索引检查的时机和范围。_checkIndex只用于get、set、removeAt这类必须访问已有元素的操作,允许的索引范围是0 <= index < size。但insert不一样,它允许在末尾追加,所以合法范围是0 <= index <= size。很多人在实现insert时习惯性地写index >= this.size就抛异常,结果发现无法在末尾插入,逻辑就乱了。
第二个是扩容判断的位置。insert里的扩容判断要和add一样,在搬移元素之前执行。如果先搬移再扩容,就会出现旧空间根本装不下新元素的情况,导致搬移时越界写入。这个顺序一旦搞错,bug 极难排查,因为只在容量刚好满了的时候暴露出来。
第三个是缩容导致的下界问题。我在缩容条件里加了this.capacity > 8这个守卫,避免容量越缩越小。如果不加,你可能会遇到一个极端情况:数组里只剩 1 个元素,缩容还是触发,结果底层容量被缩到 2、1,最后插入新元素时又被迫立即扩容,来回震荡。这个问题在读取密集型任务中尤其痛苦,性能损耗被无限放大。
第四个是调试时容易忽略的toArray实现。返回this.data.slice(0, this.size)而不是直接返回this.data,是为了避免把底层空位也暴露出来。如果直接返回整个底层数组,调用方可能误以为那些undefined是列表的一部分,还会得到与length()不一致的错误印象。
5. 实现过程中的踩坑记录与刷题建议
5.1 真实场景中的常见问题速查表
手写 ArrayList 也好,刷数组字符串题目也好,总会遇到一些让人头皮发麻的问题。我把踩过的坑整理成了一张速查表,每个问题后面都附了最常见的原因,以及对应的排查思路,供你对照参考。
| 现象 | 常见原因 | 排查与处理 |
|---|---|---|
访问data[index]返回 undefined | 索引越界,或数组存在空洞 | 先检查索引是否在[0, size)内;初始化时用fill或Array.from补全 |
add写入后旧的最后一项被覆盖 | 扩容判断写在了写入之后 | 确保size === capacity时先_resize再赋值 |
insert在末尾插入时报错 | 索引允许范围写成了index >= size | 将合法范围调整为index >= 0 && index <= size |
| 删除元素后数组长度不减少 | removeAt只搬移数据没有size-- | 在removeAt末尾补上this.size-- |
| 内存占用居高不下 | 只扩容不缩容 | 增加缩容策略,比如size <= capacity / 4时缩为一半 |
| 缩容后反复触发扩容 | 缩容下界没控制 | 缩容后的容量不能小于初始容量,如Math.max(8, ...) |
大量shfit操作导致判题超时 | 头部删除是 O(n) | 改用双栈模拟队列,或维护头指针代替真实 shift |
| 字符串循环拼接性能极差 | 字符串不可变,每次拼接复制全文 | 先收集到数组,最后一次性join('') |
split('')处理 emoji 或生僻字结果乱 | 代理对被拆成两个码元 | 改用Array.from(s)或展开运算符[...s] |
不指定语言环境的toLowerCase结果异常 | 方法受语言环境影响的边界 | 需要稳定结果时用toLocaleLowerCase('en-US') |
这张表里的内容大多不是某个题目的特定解,而是一类问题的通用解法。刷题时如果反复掉进同一个坑,我建议把对应的行抄在笔记本上,下次遇到类似提示就直接翻这里的结论,能节省大量排查时间。
5.2 日常刷题中如何高效使用这三种结构
基于我自己的刷题经验,给出一套比较顺手的选型建议。
先说数组。在 JavaScript 里,数组是你在刷题时使用频率最高的容器。当题目描述里出现“索引”“顺序”“子数组”“滑动窗口”这类关键词时,直接考虑数组。但注意优先使用push和pop,减少shift和unshift的使用次数。如果题目要求频繁从头部取元素,你要么换一种数据结构,要么在思路上调整成“从尾部取 + 反转顺序”,把 O(n) 的头部操作变成 O(1) 的尾部操作。
其次是字符串。字符串相关的题目,我的习惯是“能不动就不动,动就转数组”。对于字符级操作,比如翻转、排序、统计频次,先用Array.from(s)把字符串转成字符数组,操作完再join回来。对于子串匹配、字符统计这类需要快速访问的问题,直接用charCodeAt(i)配合编码差值来计数,效率比charAt更高,也更容易配合数组索引使用。比如判断字母异位词时,用s.charCodeAt(i) - 97作为频率数组的索引,既简洁又高效。
最后说手写 ArrayList 的定位。它不是一个需要你在每道题里都拿出来用的工具,而是帮助你理解动态数组内部机制的“解剖课”。当你需要优化一个动态扩容的容器、或者分析某个 API 的时间复杂度时,脑子里能自动浮现出我们刚才写过的_resize过程,这比背任何结论都管用。有时候我会故意在本地用SimpleArrayList替换原生数组跑同一套测试用例,看看两种实现的结果规模和耗时差异,这种对比能让你对语言内置结构产生更直观的感知。
在实际刷题过程中,我自己还有一个习惯:每做完一道数组题,都会在题解里补一句“如果用别的数据结构实现,复杂度会怎么变化”。这种多问一步的训练,比盲目刷题对能力提升大得多。比如把滑动窗口的数组实现换成链表实现,虽然逻辑可能差不多,但你能借此理解连续存储与链式存储在缓存友好性上的区别,这在真实项目里处理大数据时相当重要。
我个人在实际操作中的体会是,数组、字符串、ArrayList 这三者越早串起来学,后面遇到复杂数据结构就越不慌。很多看起来高级的算法,比如并查集、线段树、Trie 树,底层其实都在用数组做存储,只是加上了不同的索引规则和操作逻辑。把数组和动态数组吃透,相当于给后续的数据结构学习打了一个极结实的地基。最后再分享一个小技巧:写这些基础实现的代码不用追求一次成型,拿几个典型题目反复跑,边跑边观察哪里性能不对劲,这个过程比最终代码本身更有价值。