数据结构day1

本文详细介绍了静态数组和动态数组的基础概念,包括它们的定义、使用方法以及增删查改操作的时间复杂度。同时,对单链表和双链表的构造、基本操作和删除节点技巧进行了阐述,突出了它们在数据结构中的应用特点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1 数据结构基础

1.1 数组

分为两大类,一类是「静态数组」,一类是「动态数组」。

「静态数组」就是一块连续的内存空间,我们可以通过索引来访问这块内存空间中的元素,这才是数组的原始形态

而「动态数组」是编程语言为了方便我们使用,在静态数组的基础上帮我们添加了一些常用的 API,比如 push, insert, remove 等等方法,这些 API 可以让我们更方便地操作数组元素,不用自己去写代码实现这些操作。

静态数组

定义

静态数组在创建的时候就要确定数组的元素类型和元素数量。只有在 C++、Java、Golang 这类语言中才提供了创建静态数组的方式,类似 Python、JavaScript 这类语言并没有提供静态数组的定义方式。

静态数组的用法比较原始,实际软件开发中很少用到,写算法题也没必要用,我们一般直接用动态数组。

定义一个静态数组的方法如下:

// 定义一个大小为 10 的静态数组
int arr[10];

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

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

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

拿 C++ 来举例,int arr[10] 这段代码到底做了什么事情呢?主要有这么几件事:

1、在内存中开辟了一段连续的内存空间,大小是 10 * sizeof(int) 字节。一个 int 在计算机内存中占 4 字节,也就是总共 40 字节。

2、定义了一个名为 arr 的数组指针,指向这段内存空间的首地址。

那么 arr[1] = 2 这段代码又做了什么事情呢?主要有这么几件事:

1、计算 arr 的首地址加上 1 * sizeof(int) 字节(4 字节)的偏移量,找到了内存空间中的第二个元素的地址。

2、从这个地址开始的 4 个字节的内存空间中写入了整数 2

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

增删查改

数据结构的职责就是增删查改,再无其他。

给静态数组增加元素,这就有些复杂了,需要分情况讨论

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

比较简单,直接在对应的索引赋值就行了,这是大概的代码逻辑:

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

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

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

// 依此类推
// ...

数组末尾追加元素的时间复杂度是 O(1)。

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

这就要涉及「数据搬移」,给新元素腾出空位,然后再才能插入新元素。大概的代码逻辑是这样的:

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

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

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

在数组中间插入元素的时间复杂度是 O(N),因为涉及到数据搬移,给新元素腾地方

情况三,数组空间已满

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

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

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

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

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

数组的扩容操作会涉及到新数组的开辟和数据的复制,时间复杂度是 O(N)

删除元素的操作和增加元素的操作类似,也需要分情况讨论。

情况一,删除末尾元素

直接把末尾元素标记为一个特殊值代表已删除就行了,我们这里简单举例,就用 -1 作为特殊值代表已删除好了。后面带大家具体实现动态数组的时候,会有更完善的方法删除数组元素,这里只是为了说明删除数组尾部元素的本质就是进行一次随机访问,时间复杂度是 O(1)

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

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

情况二,删除中间元素

这也要涉及「数据搬移」,把被删元素后面的元素都往前移动一位,保持数组元素的连续性。

// 大小为 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;

总结

综上,静态数组的增删查改操作的时间复杂度是:

  • 增:
    • 在末尾追加元素:O(1)
    • 在中间(非末尾)插入元素:O(N)
  • 删:
    • 删除末尾元素:O(1)
    • 删除中间(非末尾)元素:O(N)
  • 查:给定指定索引,查询索引对应的元素的值,时间复杂度 O(1)
  • 改:给定指定索引,修改索引对应的元素的值,时间复杂度 O(1)

动态数组

动态数组是静态数组的强化版,也是我们在实际软件开发或者写算法题时最常用的数据结构之一。

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

// 创建动态数组
// 不用显式指定数组大小,它会根据实际存储的元素数量自动扩缩容
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.2 链表

链表是一种物理存储单元上非连续、非顺序的存储结构,数据结构的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比数组快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而数组只需要O(1)

使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。链表允许插入和移除表上任意位置上的节点,但是不允许随机存取。链表有很多种不同的类型:单向链表,双向链表以及循环链表。

单链表的基本操作

创建一条单链表:

struct ListNode 
{
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(NULL) {}
};

// 输入一个数组,转换为一条单链表
ListNode* createLinkedList(vector<int>& arr) 
{
    if (arr.empty()) 
    {
        return NULL;
    }
    ListNode* head = new ListNode(arr[0]);   //new 运算符用于动态分配内存以创建一个新的对象。
    ListNode* cur = head;
    for (int i = 1; i < arr.size(); i++) 
    {
        cur->next = new ListNode(arr[i]);
        cur = cur->next;
    }
    return head;
}
查/改

单链表的遍历/查找/修改

想访问单链表的每一个节点,并打印其值,可以这样写:

// 创建一条单链表
int arr[] = {1, 2, 3, 4, 5};
int n = sizeof(arr) / sizeof(arr[0]);
ListNode* head = createLinkedList(arr);

// 遍历单链表
for (ListNode* p = head; p != nullptr; p = p->next) {
    cout << p->val << endl;
}

在单链表头部插入新元素:

    // 创建一条单链表
    vector<int> arr = {1, 2, 3, 4, 5};
    ListNode* head = createLinkedList(arr);

    // 在单链表头部插入一个新节点 0 
    ListNode* newHead = new ListNode(0);
    newHead->next = head;
    head = newHead;

    // 现在链表变成了 0 -> 1 -> 2 -> 3 -> 4 -> 5

在单链表尾部插入新元素:

    // 创建一条单链表
    vector<int> arr = {1, 2, 3, 4, 5};
    ListNode* head = createLinkedList(arr);

    // 在单链表尾部插入一个新节点 6
    ListNode* p = head;
    // 先走到链表的最后一个节点
    while (p->next != nullptr) {
        p = p->next;
    }
    // 现在 p 就是链表的最后一个节点
    // 在 p 后面插入新节点
    p->next = new ListNode(6);

    // 现在链表变成了 1 -> 2 -> 3 -> 4 -> 5 -> 6

在单链表中间插入新元素:

    // 创建一条单链表
    vector<int> arr = {1, 2, 3, 4, 5};
    ListNode* head = createLinkedList(arr);

    // 在第 3 个节点后面插入一个新节点 66
    // 先要找到前驱节点,即第 3 个节点
    ListNode* p = head;
    for (int i = 0; i < 2; i++) {
        p = p->next;
    }
    // 此时 p 指向第 3 个节点
    // 组装新节点的后驱指针
    ListNode* newNode = new ListNode(66);
    newNode->next = p->next;

    // 插入新节点
    p->next = newNode;

    // 现在链表变成了 1 -> 2 -> 3 -> 66 -> 4 -> 5

删除中间节点

删除一个节点,首先要找到要被删除节点的前驱节点,然后把这个前驱节点的 next 指针指向被删除节点的下一个节点。这样就能把被删除节点从链表中摘除了。

    // 创建一条单链表
    vector<int> arr = {1, 2, 3, 4, 5};
    ListNode* head = createLinkedList(arr);

    // 删除第 4 个节点,要操作前驱节点
    ListNode* p = head;
    for (int i = 0; i < 2; i++) {
        p = p->next;
    }

    // 此时 p 指向第 3 个节点,即要删除节点的前驱节点
    // 把第 4 个节点从链表中摘除
    p->next = p->next->next;

    // 现在链表变成了 1 -> 2 -> 3 -> 5

删除尾部节点

    // 创建一条单链表
    vector<int> arr = {1, 2, 3, 4, 5};
    ListNode* head = createLinkedList(arr);

    // 删除尾节点
    ListNode* p = head;
    // 找到倒数第二个节点
    while (p->next->next != nullptr) {
        p = p->next;
    }

    // 此时 p 指向倒数第二个节点
    // 把尾节点从链表中摘除
    delete p->next;
    p->next = nullptr;

    // 现在链表变成了 1 -> 2 -> 3 -> 4

删除头部节点:

    // 创建一条单链表
    vector<int> arr = {1, 2, 3, 4, 5};
    ListNode* head = createLinkedList(arr);

    // 删除头结点
    head = head->next;

    // 现在链表变成了 2 -> 3 -> 4 -> 5

不过可能有读者疑惑,之前那个旧的头结点 1 的 next 指针依然指向着节点 2,这样会不会造成内存泄漏?

不会的,这个节点 1 指向其他的节点是没关系的,只要保证没有其他引用指向这个节点 1,它就能被垃圾回收器回收掉。

如果你非要显式把节点 1 的 next 指针置为 null,这是个很好的习惯,在其他场景中可能可以避免指针错乱的潜在问题。

双链表的基本操作

创建一条双链表:

class DoublyListNode {
public:
    int val;
    DoublyListNode *next, *prev;
    DoublyListNode(int x) : val(x), next(NULL), prev(NULL) {}
};

DoublyListNode* createDoublyLinkedList(vector<int>& arr) {
    // 当数组为空或者长度为0, 返回null
    if (arr.empty()) {
        return NULL;
    }
    DoublyListNode* head = new DoublyListNode(arr[0]);
    DoublyListNode* cur = head;
    // for 循环迭代创建双链表
    for (int i = 1; i < arr.size(); i++) {
        DoublyListNode* newNode = new DoublyListNode(arr[i]);
        cur->next = newNode;
        newNode->prev = cur;
        cur = cur->next;
    }
    return head;
}
查/改

对于双链表的遍历和查找,我们可以从头节点或尾节点开始,根据需要向前或向后遍历:

// 创建一条双链表
    vector<int> arr = {1, 2, 3, 4, 5};
    DoublyListNode* head = createDoublyLinkedList(arr);

    // 从头遍历双链表
    for (DoublyListNode* p = head; p != nullptr; p = p->next) {
        cout << p->val << endl;
    }

    // 从尾遍历双链表(假设我们有尾节点的引用 tail)
    DoublyListNode* tail = head;
    while (tail->next != nullptr) {
        tail = tail->next;
    }
    for (DoublyListNode* p = tail; p != nullptr; p = p->prev) {
        cout << p->val << endl;
    }

在双链表头部插入新元素,需要调整新节点和原头节点的指针:

 // 创建一条双链表
    vector<int> arr = {1, 2, 3, 4, 5};
    DoublyListNode* head = createDoublyLinkedList(arr);

    // 在双链表头部插入新节点 0
    DoublyListNode* newHead = new DoublyListNode(0);
    newHead->next = head;
    head->prev = newHead;
    head = newHead;
    // 现在链表变成了 0 -> 1 -> 2 -> 3 -> 4 -> 5

在双链表尾部插入新元素:

    // 创建一条双链表
    vector<int> arr = {1, 2, 3, 4, 5};
    DoublyListNode* head = createDoublyLinkedList(arr);

    DoublyListNode* tail = head;
    // 先走到链表的最后一个节点
    while (tail->next != nullptr) {
        tail = tail->next;
    }

    // 在双链表尾部插入新节点 6
    DoublyListNode* newNode = new DoublyListNode(6);
    tail->next = newNode;
    newNode->prev = tail;
    // 更新尾节点引用
    tail = newNode;

    // 现在链表变成了 1 -> 2 -> 3 -> 4 -> 5 -> 6

在双链表中间插入新元素

 // 创建一条双链表
    vector<int> arr = {1, 2, 3, 4, 5};
    DoublyListNode* head = createDoublyLinkedList(arr);

    // 在第 3 个节点后面插入新节点 66
    // 找到第 3 个节点
    DoublyListNode* p = head;
    for (int i = 0; i < 2; i++) {
        p = p->next;
    }

    // 组装新节点
    DoublyListNode* newNode = new DoublyListNode(66);
    newNode->next = p->next;
    newNode->prev = p;

    // 插入新节点
    p->next->prev = newNode;
    p->next = newNode;

    // 现在链表变成了 1 -> 2 -> 3 -> 66 -> 4 -> 5

在双链表中删除节点时,需要调整前驱节点和后继节点的指针来摘除目标节点:

 // 创建一条双链表
    vector<int> arr = {1, 2, 3, 4, 5};
    DoublyListNode* head = createDoublyLinkedList(arr);

    // 删除第 4 个节点
    // 先找到第 3 个节点
    DoublyListNode* p = head;
    for (int i = 0; i < 2; i++) {
        p = p->next;
    }

    // 现在 p 指向第 3 个节点,我们它后面那个节点摘除出去
    DoublyListNode* toDelete = p->next;

    // 把 toDelete 从链表中摘除
    p->next = toDelete->next;
    toDelete->next->prev = p;

    // 把 toDelete 的前后指针都置为 nullptr 是个好习惯(可选)
    toDelete->next = nullptr;
    toDelete->prev = nullptr;
    delete toDelete;

    // 现在链表变成了 1 -> 2 -> 3 -> 5

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值