Skip to content

数据结构与算法:数组的基本原理

轩辕十四
Published date:

在讲解数据结构时,数组总是一个大家早期接触的主题。但你可能会发现,不同编程语言中“数组”这一概念的使用方式和 API 有所区别。其实我们可以把「数组」分为两大类:

Table of contents

Open Table of contents

静态数组

静态数组在创建时就已经确定了元素的个数以及连续的内存空间。我们可以通过下标索引访问数组中存储的元素,这正是数组最原始的形式,也是其 “随机访问” 能力得以实现的根本原因。

动态数组

动态数组是在静态数组基础上,为了方便编程引入的一层封装。动态数组在内部依然使用静态数组存储数据,但会自动进行扩容和提供一些常用的增删查改 API,如 ⁠push、⁠insert、⁠remove 等。了解动态数组的底层原理,有助于我们深入理解后续实现其他数据结构(例如队列、栈、哈希表等)的核心思想。

本文将围绕静态数组的基本原理,并用 C 语言代码手把手实现简单版本的动态数组,展示其增删查改操作。

静态数组的基本使用

在 C 语言中,我们可以直接通过如下方式定义一个大小为 10 的静态数组:

#include <stdio.h>
#include <string.h>

int main() {
    // 定义一个大小为 10 的静态数组
    int arr[10];
    
    // 用 memset 函数把数组的值初始化为 0
    memset(arr, 0, sizeof(arr));

    // 使用索引赋值
    arr[0] = 1;
    arr[1] = 2;

    // 使用索引取值
    int a = arr[0];
    printf("a = %d\n", a);

    return 0;
}

在上面的代码中:

  1. 数组 ⁠arr 在内存中占用了连续的 ⁠10 * sizeof(int) 字节空间(通常一个 ⁠int 占 4 字节,总共 40 字节)。

  2. 数组名 ⁠arr 就代表这块内存的首地址,因此 ⁠arr[0] 就是起始地址处的数据,而 ⁠arr[1] 则是偏移 4 字节后存储的数据。

这种连续内存的特性保证了数组的“随机访问”,即给定任何下标,都可以在 O(1) 时间内通过首地址和偏移量直接计算目标元素的内存位置。

数组的增删改查

对于静态数组来说,查改操作比较简单,需要给定下标直接访问数据,时间复杂度均为 O(1)。而增删操作则需要考虑两种情况:

数组的增删改查

假设我们有一个大小为 10 的数组,前 4 个位置存储数据,现在直接在末尾追加一个元素:

#include <stdio.h>

int main() {
    int arr[10] = {0};  // 数组初始化为 0
    int len = 4;        // 当前已存储 4 个元素

    // 对前 4 个位置赋值
    for (int i = 0; i < len; i++) {
        arr[i] = i;
    }

    // 在数组末尾追加一个元素 4
    if (len < 10) {
        arr[len] = 4;
        len++;
    }

    // 输出数组结果
    for (int i = 0; i < len; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    return 0;
}

在末尾追加数据时只需一次赋值操作,时间复杂度为 O(1)

在中间插入元素

如果要在数组的某个中间位置插入一个新元素,就需要先将后面的元素向后搬移,为新元素腾出空间。例如,在下标 2(第三个位置)插入新数据 666:

#include <stdio.h>

int main() {
    int arr[10] = {0};  // 初始化数组
    int len = 4;        // 当前有 4 个元素

    // 赋初始值
    for (int i = 0; i < len; i++) {
        arr[i] = i;
    }

    int pos = 2;      // 插入位置
    int newVal = 666; // 要插入的新元素

    // 检测是否有足够空间
    if (len < 10) {
        // 从最后一个元素开始到插入位置,倒序搬移,避免覆盖
        for (int i = len; i > pos; i--) {
            arr[i] = arr[i - 1];
        }
        // 将新元素放入指定位置
        arr[pos] = newVal;
        len++;
    }

    // 输出数组结果
    for (int i = 0; i < len; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    return 0;
}

这里需要将位置 pos 之后的所有元素依次后移一位,因此时间复杂度为 O(N)

删除元素

删除元素同样分两种情况:

删除末尾元素示例

#include <stdio.h>

int main() {
    int arr[10] = {0};
    int len = 5; // 假设数组中有 5 个元素

    // 赋初始值
    for (int i = 0; i < len; i++) {
        arr[i] = i;
    }

    // 删除末尾元素,简单将 len 减 1(此处用 -1 表示已删除,仅作示例)
    if (len > 0) {
        arr[len - 1] = -1;  // 标记为 -1
        len--;
    }

    // 输出数组结果
    for (int i = 0; i < len; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    return 0;
}

删除中间元素示例

例如删除下标 1(第二个元素):

#include <stdio.h>

int main() {
    int arr[10] = {0};
    int len = 5; // 假设数组中有 5 个元素

    // 赋初始值
    for (int i = 0; i < len; i++) {
        arr[i] = i;
    }
    
    int pos = 1; // 删除位置

    // 将 pos 后面的元素依次前移
    for (int i = pos; i < len - 1; i++) {
        arr[i] = arr[i + 1];
    }
    // 最后一个位置标记为 -1,代表已删除(仅作示例)
    arr[len - 1] = -1;
    len--;

    // 输出数组结果(仅输出数组中有效的部分)
    for (int i = 0; i < len; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    return 0;
}

数组的扩容思想

静态数组的最大问题在于:在创建数组时就必须确定内存大小,一旦数组满了,无法直接在连续内存后面追加更多数据。我们只能重新开辟一块更大的内存,将原有数据复制到新数组中,然后插入新元素。这就是“扩容”操作,其时间复杂度为 O(N)

以下示例展示如何扩容一个静态数组:

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 初始数组大小为 10
    int initCapacity = 10;
    int *arr = (int *)malloc(initCapacity * sizeof(int));
    int len = initCapacity;

    // 假设数组已满,赋值 0 ~ 9
    for (int i = 0; i < len; i++) {
        arr[i] = i;
    }

    // 现在需要扩容,创建一个大小为 20 的新数组
    int newCapacity = 20;
    int *newArr = (int *)malloc(newCapacity * sizeof(int));

    // 把原来数组的数据复制到新数组中
    for (int i = 0; i < len; i++) {
        newArr[i] = arr[i];
    }

    // 释放旧数组内存
    free(arr);

    // 在新的大数组中追加新元素,比如新元素为 10
    newArr[len] = 10;
    len++;

    // 输出新数组中的数据
    for (int i = 0; i < len; i++) {
        printf("%d ", newArr[i]);
    }
    printf("\n");

    free(newArr);
    return 0;
}

在实际开发中,动态数组的扩容不会在每次添加元素时都发生,而是采用均摊时间复杂度(摊还分析)来评估其操作效率。正因为这种策略,使得在尾部追加元素的均摊时间复杂度依然是 O(1)

动态数组的代码实现与关键点

动态数组是在静态数组基础上进行封装,以便更方便地进行增删查改操作。动态数组解决了静态数组大小固定的问题,最主要的改进包括:

下面我们通过一个完整的 C 语言示例来实现一个只存储整数的动态数组,该示例包含了自动扩容和缩容、插入、删除、查找以及修改等功能。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>

// 定义动态数组结构,用来存储整数数据
typedef struct {
    int *data;      // 指向存放数据的数组
    int size;       // 当前存储的元素个数
    int capacity;   // 底层数组的容量
} DynamicArray;

// 初始化动态数组,指定初始容量
DynamicArray* initDynamicArray(int initCapacity) {
    DynamicArray *arr = (DynamicArray*)malloc(sizeof(DynamicArray));
    if (initCapacity < 1) {
        initCapacity = 1;
    }
    arr->data = (int*)malloc(initCapacity * sizeof(int));
    arr->size = 0;
    arr->capacity = initCapacity;
    return arr;
}

// 释放动态数组内存
void freeDynamicArray(DynamicArray *arr) {
    if (arr) {
        free(arr->data);
        free(arr);
    }
}

// 根据新容量 newCap 重新分配内存
void resize(DynamicArray *arr, int newCap) {
    // 容量至少为 1
    if (newCap < 1) {
        newCap = 1;
    }
    int *newData = (int*)malloc(newCap * sizeof(int));
    // 搬移数据
    for (int i = 0; i < arr->size; i++) {
        newData[i] = arr->data[i];
    }
    // 释放旧数组内存
    free(arr->data);
    arr->data = newData;
    arr->capacity = newCap;
}

// 索引检查:用于返回已有元素的位置(查找、修改、删除)
void checkElementIndex(DynamicArray *arr, int index) {
    if (index < 0 || index >= arr->size) {
        fprintf(stderr, "IndexOutOfBoundsException: Index: %d, Size: %d\n", index, arr->size);
        exit(EXIT_FAILURE);
    }
}

// 索引检查:用于新增元素时,允许 index == size(插入至末尾)
void checkPositionIndex(DynamicArray *arr, int index) {
    if (index < 0 || index > arr->size) {
        fprintf(stderr, "IndexOutOfBoundsException: index: %d, Size: %d\n", index, arr->size);
        exit(EXIT_FAILURE);
    }
}

// 打印数组内部状态(调试函数)
void display(DynamicArray *arr) {
    printf("size = %d, capacity = %d\n", arr->size, arr->capacity);
    for (int i = 0; i < arr->size; i++) {
        printf("%d ", arr->data[i]);
    }
    printf("\n");
}

// --- 增 --- //

// 在数组末尾追加元素
void addLast(DynamicArray *arr, int val) {
    // 检查是否需要扩容
    if (arr->size == arr->capacity) {
        resize(arr, arr->capacity * 2);
    }
    arr->data[arr->size] = val;
    arr->size++;
}

// 在指定位置添加元素;允许 index == size(插入空隙)
void add(DynamicArray *arr, int index, int val) {
    checkPositionIndex(arr, index);
    if (arr->size == arr->capacity) {
        resize(arr, arr->capacity * 2);
    }
    // 将 index 后的所有元素向后搬移一位
    for (int i = arr->size - 1; i >= index; i--) {
        arr->data[i + 1] = arr->data[i];
    }
    arr->data[index] = val;
    arr->size++;
}

// 在数组首部添加元素,直接调用 add(index, val)
void addFirst(DynamicArray *arr, int val) {
    add(arr, 0, val);
}

// --- 删 --- //

// 删除数组最后一个元素,并返回该值
int removeLast(DynamicArray *arr) {
    if (arr->size == 0) {
        fprintf(stderr, "NoSuchElementException: Array is empty\n");
        exit(EXIT_FAILURE);
    }
    // 缩容判断
    if (arr->size == arr->capacity / 4 && arr->capacity > 1) {
        resize(arr, arr->capacity / 2);
    }
    int deletedVal = arr->data[arr->size - 1];
    // 清理数据,防止内存泄漏
    arr->data[arr->size - 1] = 0;
    arr->size--;
    return deletedVal;
}

// 删除指定位置的元素并返回该值
int removeAt(DynamicArray *arr, int index) {
    checkElementIndex(arr, index);
    // 缩容判断
    if (arr->size == arr->capacity / 4 && arr->capacity > 1) {
        resize(arr, arr->capacity / 2);
    }
    int deletedVal = arr->data[index];
    // 搬移数据,将 index+1 ... end 向前移动
    for (int i = index + 1; i < arr->size; i++) {
        arr->data[i - 1] = arr->data[i];
    }
    arr->data[arr->size - 1] = 0; // 清理数据
    arr->size--;
    return deletedVal;
}

// 删除数组首部元素
int removeFirst(DynamicArray *arr) {
    return removeAt(arr, 0);
}

// --- 查 --- //

// 获取指定索引处的元素
int get(DynamicArray *arr, int index) {
    checkElementIndex(arr, index);
    return arr->data[index];
}

// --- 改 --- //

// 修改指定索引处的元素,返回原值
int set(DynamicArray *arr, int index, int newVal) {
    checkElementIndex(arr, index);
    int oldVal = arr->data[index];
    arr->data[index] = newVal;
    return oldVal;
}

//
// 测试用例
//
int main() {
    // 初始容量设为 3
    DynamicArray *arr = initDynamicArray(3);

    // 添加 5 个元素:1 2 3 4 5
    for (int i = 1; i <= 5; i++) {
        addLast(arr, i);
    }
    // 此时数组元素为:[1, 2, 3, 4, 5]
    display(arr);

    // 删除下标 3 的元素(即第四个元素 4)
    int deleted = removeAt(arr, 3);
    printf("Removed value: %d\n", deleted);
    // 数组更新为:[1, 2, 3, 5]
    display(arr);

    // 在下标 1 的位置插入元素 9
    add(arr, 1, 9);
    // 数组更新为:[1, 9, 2, 3, 5]
    display(arr);

    // 在数组首部插入元素 100
    addFirst(arr, 100);
    // 更新为:[100, 1, 9, 2, 3, 5]
    display(arr);

    // 删除数组尾部元素
    int lastVal = removeLast(arr);
    printf("Removed last value: %d\n", lastVal);
    // 数组更新为:[100, 1, 9, 2, 3]
    display(arr);

    // 查、改示例
    int val = get(arr, 2);
    printf("Element at index 2: %d\n", val);
    set(arr, 2, 66);
    printf("After setting index 2 to 66:\n");
    display(arr);

    // 释放内存
    freeDynamicArray(arr);
    return 0;
}

代码说明

  1. 自动扩缩容
  1. 索引越界检查
  1. 防止内存泄漏
  1. 其他细节优化

总结

本文融合了静态数组与动态数组的相关内容,主要介绍了:

希望这篇博客能够帮助你全面理解数组的基本原理及动态数组的实现方法,并为你后续学习队列、栈、哈希表等更复杂的数据结构打下坚实基础!

Previous
算法:两数相加
Next
算法:两数之和