数据结构<C++>——数组

一、静态数组

cpp 复制代码
静态数组在创建的时候就要确定数组的元素类型,元素数量。只有在C++、Java、Golong这类语言中财提供了创建静态数组的方式,类似Python、JS并没有提供。
静态数组的用法比较原始,实际开发中很少用到,算法题没必要用,我们一般直接用动态数组。

定义静态数组:

cpp 复制代码
// 定义一个大小为 10 的静态数组
int arr[10];

// 用 memset 函数把数组的值初始化为 0
memset(arr, 0, sizeof(arr));

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

// 使用索引取值
int a = arr[0];

int arr[10]主要执行步骤:

  1. 在内存中开辟了一段连续的内存空间,大小是10*sizeof(int)字节。
  2. 定义了一个名为arr的数组指针,指向这段内存空间的首地址。

arr[1]=2执行步骤:

  1. 计算arr的首地址加上1*sizeof(int)字节的偏移量,找到内存空间中的第二个元素的首地址。
  2. 从这个地址开始的4个字节的内存空间中写入了整数2.
  • 1、为什么数组的索引从 0 开始?就是方便取地址。arr[0] 就是 arr 的首地址,从这个地址往后的 4 个字节存储着第一个元素的值;arr[1] 就是 arr 的首地址加上 1 * 4 字节,也就是第二个元素的首地址,这个地址往后的 4 个字节存储着第二个元素的值。arr[2], arr[3] 以此类推。
  • 2、因为数组的名字 arr 就指向整块内存的首地址,所以数组名 arr 就是一个指针。你直接取这个地址的值,就是第一个元素的值。也就是说,*arr 的值就是 arr[0],即第一个元素的值。
  • 3、如果不用 memset 这种函数初始化数组的值,那么数组内的值是不确定的。因为 int arr[10] 这个语句只是请操作系统在内存中开辟了一块连续的内存空间,你也不知道这块空间是谁使用过的二手内存,你也不知道里面存了什么奇奇怪怪的东西。所以一般我们会用 memset 函数把这块内存空间的值初始化一下再使用。

所以,我们获得了数组的超能力「随机访问」:只要给定任何一个数组索引,我可以在 O(1) 的时间内直接获取到对应元素的值。

综上,数组的随机访问的时间复杂度是O(1)。

二、静态数组的增删改查

1.增

(1)情况一,数组末尾追加(append)元素

O(1)

cpp 复制代码
// 大小为 10 的数组已经装了 4 个元素
int arr[10];
for (int i = 0; i < 4; i++) {
    arr[i] = i;
}

// 现在想在数组末尾追加一个元素 4
arr[4] = 4;

// 再在数组末尾追加一个元素 5
arr[5] = 5;

// 依此类推
// ...

(2)情况二,数组中间插入(insert)元素

O(n)

cpp 复制代码
// 大小为 10 的数组已经装了 4 个元素
int arr[10];
for (int i = 0; i < 4; i++) {
    arr[i] = i;
}

// 在索引 2 置插入元素 666
// 需要把索引 2 以及之后的元素都往后移动一位
// 注意要倒着遍历数组中已有元素避免覆盖,不懂的话请看下方可视化面板
for (int i = 4; i > 2; i--) {
    arr[i] = arr[i - 1];
}

// 现在第 3 个位置空出来了,可以插入新元素
arr[2] = 666;

(3)情况三,数组空间已满

O(n)

那怎么办呢?只能重新申请一块更大的内存空间,把原来的数据复制过去,再插入新的元素,这就是数组的「扩容」操作。

比方说,我重新创建一个更大的数组 int arr[20],然后把原来的 10 个元素复制过去,这样就有空余位置插入新的元素了。

大概的逻辑是这样的:

cpp 复制代码
// 大小为 10 的数组已经装满了
int arr[10];
for (int i = 0; i < 10; i++) {
    arr[i] = i;
}

// 现在想在数组末尾追加一个元素 10
// 需要先扩容数组
int newArr[20];
// 把原来的 10 个元素复制过去
for (int i = 0; i < 10; i++) {
    newArr[i] = arr[i];
}

// 释放旧数组的内存空间
// ...

// 在新的大数组中追加新元素
newArr[10] = 10;

2.删

(1)情况一,删除末尾元素

O(1)

cpp 复制代码
// 大小为 10 的数组已经装了 5 个元素
int arr[10];
for (int i = 0; i < 5; i++) {
    arr[i] = i;
}

// 删除末尾元素,暂时用 -1 代表元素已删除
arr[4] = -1;

(2)情况二,删除中间元素

O(n)

cpp 复制代码
// 大小为 10 的数组已经装了 5 个元素
int arr[10];
for (int i = 0; i < 5; i++) {
    arr[i] = i;
}

// 删除 arr[1]
// 需要把 arr[1] 之后的元素都往前移动一位
// 注意要正着遍历数组中已有元素避免覆盖,不懂的话请看下方可视化面板
for (int i = 1; i < 4; i++) {
    arr[i] = arr[i + 1];
}

// 最后一个元素置为 -1 代表已删除
arr[4] = -1;

3.改

给定指定的索引值,修改索引对应的元素的值,时间复杂度O(1).

4.查

给定指定的索引值,查询索引对应的元素的值,时间复杂度O(1).

三、动态数组

动态数组底层还是静态数组,只是自动帮我们进行数组空间的扩缩容,并把增删查改操作进行了封装,让我们使用起来更方便而已。

基本使用方法:

cpp 复制代码
// 创建动态数组
// 不用显式指定数组大小,它会根据实际存储的元素数量自动扩缩容
vector<int> arr;

for (int i = 0; i < 10; i++) {
    // 在末尾追加元素,时间复杂度 O(1)
    arr.push_back(i);
}

// 在中间插入元素,时间复杂度 O(N)
// 在索引 2 的位置插入元素 666
arr.insert(arr.begin() + 2, 666);

// 在头部插入元素,时间复杂度 O(N)
arr.insert(arr.begin(), -1);

// 删除末尾元素,时间复杂度 O(1)
arr.pop_back();

// 删除中间元素,时间复杂度 O(N)
// 删除索引 2 的元素
arr.erase(arr.begin() + 2);

// 根据索引查询元素,时间复杂度 O(1)
int a = arr[0];

// 根据索引修改元素,时间复杂度 O(1)
arr[0] = 100;

// 根据元素值查找索引,时间复杂度 O(N)
int index = find(arr.begin(), arr.end(), 666) - arr.begin();

1.自动扩缩容

在实际使用动态数组时,缩容也是重要的优化手段。比方说一个动态数组开辟了能够存储 1000 个元素的连续内存空间,但是实际只存了 10个元素,那就有 990 个空间是空闲的。为了避免资源浪费,我们其实可以适当缩小存储空间,这就是缩容。

我们这里就实现一个简单的扩缩容的策略:

  • 当数组元素个数达到底层静态数组的容量上限时,扩容为原来的 2 倍;
  • 当数组元素个数缩减到底层静态数组的容量的 1/4 时,缩容为原来的
    1/2。

2.索引越界的检查

下面的代码实现中,有两个检查越界的方法,分别是 checkElementIndex 和 checkPositionIndex,你可以看到它俩的区别仅仅在于 index < size 和 index <= size。

为什么 checkPositionIndex 可以允许 index == size 呢,因为这个 checkPositionIndex 是专门用来处理在数组中插入元素的情况。

比方说有这样一个 nums 数组,对于每个元素来说,合法的索引一定是 index < size:

cpp 复制代码
nums = [5, 6, 7, 8]
index   0  1  2  3

但如果是要在数组中插入新元素,那么新元素可能的插入位置并不是元素的索引,而是索引之间的空隙:

cpp 复制代码
nums = [ | 5 | 6 | 7 | 8 | ]
index    0   1   2   3   4

这些空隙都是合法的插入位置,所以说 index == size 也是合法的。这就是 checkPositionIndex 和 checkElementIndex 的区别

3.删除元素谨防内存泄漏

单从算法的角度,其实并不需要关心被删掉的元素应该如何处理,但是具体到代码实现,我们需要注意可能出现的内存泄漏。

在我给出的代码实现中,删除元素时,我都会把被删除的元素置为 null,以 Java 为例:

cpp 复制代码
// 删
public E removeLast() {
    E deletedVal = data[size - 1];
    // 删除最后一个元素
    // 必须给最后一个元素置为 null,否则会内存泄漏
    data[size - 1] = null;
    size--;

    return deletedVal;
}

Java 的垃圾回收机制是基于

图算法 的可达性分析,如果一个对象再也无法被访问到,那么这个对象占用的内存才会被释放;否则,垃圾回收器会认为这个对象还在使用中,就不会释放这个对象占用的内存。

如果你不执行 data[size - 1] = null 这行代码,那么 data[size - 1] 这个引用就会一直存在,你可以通过 data[size - 1] 访问这个对象,所以这个对象被认为是可达的,它的内存就一直不会被释放,进而造成内存泄漏。

其他带垃圾回收功能的语言应该也是类似的,你可以具体了解一下你使用的编程语言的垃圾回收机制,这是写出无 bug 代码的基本要求。

四、动态数组代码实现

cpp 复制代码
#include <iostream>
#include <stdexcept>
#include <vector>

template<typename E>
class MyArrayList {
private:
    // 真正存储数据的底层数组
    E* data;
    // 记录当前元素个数
    int size;

    // 最大元素容量
    int cap;

    // 默认初始容量
    static const int INIT_CAP = 1;

public:
    MyArrayList() {
        this->data = new E[INIT_CAP];
        this->size = 0;
        this->cap = INIT_CAP;
    }

    MyArrayList(int initCapacity) {
        this->data = new E[initCapacity];
        this->size = 0;
        this->cap = initCapacity;
    }

    // 增
    void addLast(E e) {
        // 看 data 数组容量够不够
        if (size == cap) {
            resize(2 * cap);
        }
        // 在尾部插入元素
        data[size] = e;
        size++;
    }

    void add(int index, E e) {
        // 检查索引越界
        checkPositionIndex(index);

        // 看 data 数组容量够不够
        if (size == cap) {
            resize(2 * cap);
        }

        // 搬移数据 data[index..] -> data[index+1..]
        // 给新元素腾出位置
        for (int i = size - 1; i >= index; i--) {
            data[i + 1] = data[i];
        }

        // 插入新元素
        data[index] = e;

        size++;
    }

    void addFirst(E e) {
        add(0, e);
    }

    // 删
    E removeLast() {
        if (size == 0) {
            throw std::out_of_range("NoSuchElementException");
        }
        // 可以缩容,节约空间
        if (size == cap / 4) {
            resize(cap / 2);
        }

        E deletedVal = data[size - 1];
        // 删除最后一个元素
        // 必须给最后一个元素置为 null,否则会内存泄漏
        data[size - 1] = E();
        size--;

        return deletedVal;
    }

    E remove(int index) {
        // 检查索引越界
        checkElementIndex(index);

        // 可以缩容,节约空间
        if (size == cap / 4) {
            resize(cap / 2);
        }

        E deletedVal = data[index];

        // 搬移数据 data[index+1..] -> data[index..]
        for (int i = index + 1; i < size; i++) {
            data[i - 1] = data[i];
        }

        data[size - 1] = E();
        size--;

        return deletedVal;
    }

    E removeFirst() {
        return remove(0);
    }

    // 查
    E get(int index) {
        // 检查索引越界
        checkElementIndex(index);

        return data[index];
    }

    // 改
    E set(int index, E element) {
        // 检查索引越界
        checkElementIndex(index);
        // 修改数据
        E oldVal = data[index];
        data[index] = element;
        return oldVal;
    }

    // 工具方法
    int getSize() {
        return size;
    }

    bool isEmpty() {
        return size == 0;
    }

    // 将 data 的容量改为 newCap
    void resize(int newCap) {
        E* temp = new E[newCap];

        for (int i = 0; i < size; i++) {
            temp[i] = data[i];
        }

        // 释放原数组内存
        delete[] data;

        data = temp;
        cap = newCap;
    }

    bool isElementIndex(int index) {
        return index >= 0 && index < size;
    }

    bool isPositionIndex(int index) {
        return index >= 0 && index <= size;
    }

    // 检查 index 索引位置是否可以存在元素
    void checkElementIndex(int index) {
        if (!isElementIndex(index)) {
            throw std::out_of_range("Index out of bounds");
        }
    }

    // 检查 index 索引位置是否可以添加元素
    void checkPositionIndex(int index) {
        if (!isPositionIndex(index)) {
            throw std::out_of_range("Index out of bounds");
        }
    }

    void display() {
        std::cout << "size = " << size << " cap = " << cap << std::endl;
        for (int i = 0; i < size; i++) {
            std::cout << data[i] << " ";
        }
        std::cout << std::endl;
    }

    ~MyArrayList() {
        delete[] data;
    }
};


int main() {
    // 初始容量设置为 3
    MyArrayList<int> arr(3);

    // 添加 5 个元素
    for (int i = 1; i <= 5; i++) {
        arr.addLast(i);
    }

    arr.remove(3);
    arr.add(1, 9);
    arr.addFirst(100);
    int val = arr.removeLast();

    // 100 1 9 2 3
    for (int i = 0; i < arr.getSize(); i++) {
        std::cout << arr.get(i) << std::endl;
    }

    return 0;
}
相关推荐
郝学胜-神的一滴3 小时前
使用现代C++构建高效日志系统的分步指南
服务器·开发语言·c++·程序人生·个人开发
典则3 小时前
STM32FreeRtos入门(五)——同步互斥与通信
java·jvm·stm32
你不是我我3 小时前
【Java 开发日记】我们来讲一讲阻塞队列及其应用
java·开发语言
互联网中的一颗神经元3 小时前
小白python入门 - 9. Python 列表2 ——从基础操作到高级应用
java·开发语言·python
大厂码农老A3 小时前
我带的外包兄弟放弃大厂转正,薪资翻倍入职字节
java·后端·面试
摇滚侠3 小时前
Spring Boot3零基础教程,生命周期监听,自定义监听器,笔记59
java·开发语言·spring boot·笔记
凯子坚持 c4 小时前
Llama-2-7b在昇腾NPU上的六大核心场景性能基准报告
java·开发语言·llama
百锦再4 小时前
国产数据库替代MongoDB的技术实践:金仓数据库赋能浙江省人民医院信息化建设新展望
java·开发语言·数据库·mongodb·架构·eclipse·maven
武子康4 小时前
Java-160 MongoDB副本集部署实战 单机三实例/多机同法 10 分钟起集群 + 选举/读写/回滚全流程
java·数据库·sql·mongodb·性能优化·系统架构·nosql