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