数据结构概述
基本定义
数组是一种基础的线性数据结构,它由相同类型的元素组成,并存储在一段连续的内存空间中。每个元素可以通过其索引(下标)进行访问,索引通常从0开始。
核心特性
- 连续内存:数组元素在内存中占据连续的空间,这使得数组支持高效的随机访问
- 固定大小:数组在创建时需要指定大小,一旦创建,大小通常不可改变(静态数组)
- 类型统一:数组中的所有元素必须具有相同的数据类型
- 索引访问:通过索引可以在O(1)时间内直接访问任意位置的元素
应用场景
- 需要频繁随机访问元素的场景
- 数据元素数量固定且已知的场景
- 需要利用连续内存特性进行缓存优化的场景
- 实现其他复杂数据结构的基础(如实现栈、队列、矩阵等)
数组结构示意图
Array
+int[] data
+int size
+int length
+T get(int index)
+void set(int index, T value)
+int indexOf(T value)
数组内存布局
内存空间
索引0
元素A
索引1
元素B
索引2
元素C
索引3
元素D
索引4
元素E
基地址
0x1000
数组插入操作流程
是
否
是
否
开始插入操作
检查数组是否已满
抛出溢出异常
插入位置是否在尾部
直接添加到末尾
从后向前移动元素
腾出插入位置
结束
原理与核心概念
底层原理
数组的核心原理建立在连续内存分配的基础上。当创建一个数组时,操作系统会分配一块连续的内存空间,这块空间的大小等于数组容量乘以单个元素的大小。由于元素在内存中连续排列,我们可以通过基地址和元素大小的简单计算来定位任意元素:
地址计算公式 :元素地址 = 基地址 + 索引 × 元素大小
这种直接寻址方式使得数组具有极快的随机访问能力,无论访问数组中的哪个元素,所需时间都是相同的。
核心操作
| 操作 | 时间复杂度 | 空间复杂度 | 描述 |
|---|---|---|---|
| 随机访问 | O(1) | O(1) | 通过索引直接访问元素 |
| 查找元素 | O(n) | O(1) | 线性查找或二分查找 |
| 插入元素 | O(n) | O(1) | 需要移动后续元素 |
| 删除元素 | O(n) | O(1) | 需要移动后续元素 |
| 修改元素 | O(1) | O(1) | 直接通过索引修改 |
关键细节
动态数组的扩容机制
静态数组的大小是固定的,而动态数组(如Python的list、Java的ArrayList)能够在需要时自动扩容。典型的扩容策略是:
- 当数组空间不足时,创建一个容量更大的新数组(通常是原容量的1.5倍或2倍)
- 将原数组的所有元素复制到新数组中
- 释放原数组的内存
虽然扩容操作的时间复杂度是O(n),但由于扩容不是频繁发生(摊还分析后),动态数组的插入操作的均摊时间复杂度仍为O(1)。
数组下标越界问题
访问数组时使用的索引必须在合法范围内(0到size-1)。越界访问可能导致:
- 程序抛出IndexOutOfBoundsException异常
- 访问到其他变量的内存空间,造成数据损坏
- 潜在的安全漏洞(数组越界攻击)
代码实现
Java 实现
java
public class Array<E> {
private E[] data;
private int size;
@SuppressWarnings("unchecked")
public Array(int capacity) {
this.data = (E[]) new Object[capacity];
this.size = 0;
}
public Array() {
this(10);
}
public int getSize() {
return size;
}
public int getCapacity() {
return data.length;
}
public boolean isEmpty() {
return size == 0;
}
public void addLast(E e) {
add(size, e);
}
public void addFirst(E e) {
add(0, e);
}
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Require index >= 0 and index <= size.");
}
if (size == data.length) {
resize(2 * data.length);
}
for (int i = size - 1; i >= index; i--) {
data[i + 1] = data[i];
}
data[index] = e;
size++;
}
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Get failed. Index is illegal.");
}
return data[index];
}
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Set failed. Index is illegal.");
}
data[index] = e;
}
public boolean contains(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) {
return true;
}
}
return false;
}
public int find(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) {
return i;
}
}
return -1;
}
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Remove failed. Index is illegal.");
}
E ret = data[index];
for (int i = index + 1; i < size; i++) {
data[i - 1] = data[i];
}
size--;
data[size] = null;
if (size == data.length / 4) {
resize(data.length / 2);
}
return ret;
}
public E removeFirst() {
return remove(0);
}
public E removeLast() {
return remove(size - 1);
}
public void removeElement(E e) {
int index = find(e);
if (index != -1) {
remove(index);
}
}
@SuppressWarnings("unchecked")
private void resize(int newCapacity) {
E[] newData = (E[]) new Object[newCapacity];
for (int i = 0; i < size; i++) {
newData[i] = data[i];
}
data = newData;
}
@Override
public String toString() {
StringBuilder res = new StringBuilder();
res.append(String.format("Array: size = %d, capacity = %d\n", size, data.length));
res.append("[");
for (int i = 0; i < size; i++) {
res.append(data[i]);
if (i != size - 1) {
res.append(", ");
}
}
res.append("]");
return res.toString();
}
}
复杂度分析
时间复杂度
| 操作 | 平均情况 | 最坏情况 | 最佳情况 |
|---|---|---|---|
| 随机访问 | O(1) | O(1) | O(1) |
| 查找(无序) | O(n) | O(n) | O(1) |
| 查找(有序) | O(log n) | O(log n) | O(1) |
| 尾部插入 | O(1) | O(n) | O(1) |
| 中间插入 | O(n) | O(n) | O(n) |
| 尾部删除 | O(1) | O(1) | O(1) |
| 中间删除 | O(n) | O(n) | O(n) |
空间复杂度
- 最坏情况: O(n)
- 平均情况: O(n)
- 最好情况: O(1)(固定大小数组)
优化策略
- 空间预分配:在已知大致元素数量时,提前分配足够的空间,避免频繁扩容
- 缩容策略:当元素数量远小于容量时,适当缩减数组大小以节省内存
- 缓存友好:利用数组的连续内存特性,提高CPU缓存命中率
- 批量操作:对于需要插入或删除多个元素的操作,尽量合并进行,减少移动次数
应用实例
实例一:移动零 点击查看详情信息
问题描述 :
给定一个数组 nums,将数组中的所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
解决思路 :
使用双指针技术,一个指针用于遍历数组,另一个指针指向非零元素应该放置的位置。
代码实现:
java
class Solution {
public void moveZeroes(int[] nums) {
if (nums == null || nums.length == 0) return;
int left = 0;
for (int right = 0; right < nums.length; right++) {
if (nums[right] != 0) {
nums[left] = nums[right];
left++;
}
}
while (left < nums.length) {
nums[left++] = 0;
}
}
}
总结与思考
核心知识点
- 数组的连续内存特性决定了其O(1)的随机访问能力
- 数组的插入和删除操作需要移动元素,时间复杂度为O(n)
- 动态数组通过扩容机制实现了灵活性,但需要考虑均摊复杂度
- 双指针技巧是数组相关问题的重要解题工具
优缺点
优点:
- 支持O(1)时间的随机访问,查询效率高
- 内存连续,对CPU缓存友好,访问速度快
- 实现简单直观,是其他数据结构的基础
缺点:
- 插入和删除操作需要移动元素,效率较低
- 静态数组大小固定,无法动态调整
- 需要连续的内存空间,可能导致内存碎片问题
与其他数据结构的对比
| 特性 | 数组 | 链表 | 哈希表 |
|---|---|---|---|
| 随机访问 | O(1) | O(n) | O(1)平均 |
| 插入/删除(头部) | O(n) | O(1) | O(1) |
| 插入/删除(尾部) | O(1) | O(1) | O(1) |
| 内存连续性 | 是 | 否 | 否 |
| 内存开销 | 低 | 高 | 中 |
*注:动态数组的尾部插入均摊复杂度为O(1)
学习心得
数组作为最基础的数据结构,是学习其他数据结构的起点。理解数组的原理对于掌握时间复杂度分析、内存管理等内容至关重要。在实际开发中,虽然很少直接使用原生数组,但动态数组(List、ArrayList)的使用非常广泛,它们的底层原理与数组一致。
学习数组时,建议重点关注:
- 理解连续内存分配带来的优势和问题
- 掌握双指针技巧在各种问题中的应用
- 理解动态数组的扩容机制和均摊分析