《数据结构与算法》是《地铁上的面试题》专栏的第一章,重点介绍了技术面试中不可或缺的数据结构和算法知识。数据结构是组织和存储数据的方式,而算法是解决问题的步骤和规则。
这一章的内容涵盖了常见的数据结构和算法,包括数组和链表、栈和队列、树和图,以及排序和搜索算法等。通过深入学习和理解这些基础概念,读者将能够优化代码、提高算法效率,并解决面试中常见的编程问题。
每个主题都将提供清晰的解释、示例代码和常见面试题。读者将学习如何选择和实现合适的数据结构,如何运用常用算法来解决实际问题,以及如何分析和优化算法的时间和空间复杂度。
无论你是准备面试还是想夯实基础知识,这一章都将为你打下坚实的数据结构与算法基础。通过在地铁上的学习,你将能够在面试中展现出自信和高效的解决问题能力。让我们一起踏上数据结构与算法的旅程!
数据结构是计算机科学中研究数据组织、存储和管理方式的基本概念。它关注数据元素之间的关系以及操作这些数据元素的方法。数据结构可以分为两种基本类型:线性结构和非线性结构。线性结构中的数据元素之间存在一对一的关系,如数组和链表;而非线性结构中的数据元素之间存在一对多或多对多的关系,如树和图。数据结构的设计和选择对于解决实际问题和优化算法效率至关重要。常见的数据结构包括数组、链表、栈、队列、树、图等。每种数据结构都有自己的特点和适用场景,具体选择要考虑数据操作的效率、空间占用、插入和删除的复杂度等因素。
理解数据结构的基本概念有助于编写高效的程序、解决实际问题和应对技术面试。它为我们提供了组织和管理数据的工具,使得我们能够更好地利用计算机的处理能力。
数组和链表是常见的数据结构,用于组织和存储数据。它们具有不同的定义和特点。
数组是一种线性数据结构,由一组连续的内存空间组成,用于存储相同类型的数据元素。数组的定义包括元素类型和固定大小。元素可以通过索引访问,索引从0开始。数组的特点包括:
链表是一种动态数据结构,由一系列节点组成,每个节点包含数据和指向下一个节点的引用。链表的定义包括头节点和尾节点(在双向链表中还包括指向上一个节点的引用)。链表的特点包括:
根据应用场景和需求,选择数组或链表有不同的考量。数组适合随机访问和已知大小的情况,例如需要频繁访问元素或索引操作的场景。链表适用于频繁的插入和删除操作,以及动态大小的情况。
数组是一种基本的数据结构,它在计算机科学中被广泛使用。数组的基本原理是将一组相同类型的元素按照顺序存储在连续的内存空间中。每个元素在内存中占据固定的大小,并可以通过索引访问。
数组的特点是由于元素在内存中连续存储,使得数组具有以下优势和特点:
然而,数组也有一些限制和缺点:
TIP:数组作为一种基本的数据结构,具有快速访问、简单高效的存储和顺序访问的优点。然而,由于固定大小和低效的插入删除操作,需要在特定的应用场景中进行选择和权衡。
数组的插入、删除和查找操作是常见的数组操作,它们的时间复杂度和空间复杂度如下所示:
void insertElement(int arr[], int size, int index, int element) {
if (index >= size || index < 0) {
printf("无效的插入位置\n");
return;
}
for (int i = size - 1; i > index; i--) {
arr[i] = arr[i - 1];
}
arr[index] = element;
}
void deleteElement(int arr[], int size, int index) {
if (index >= size || index < 0) {
printf("无效的删除位置\n");
return;
}
for (int i = index; i < size - 1; i++) {
arr[i] = arr[i + 1];
}
arr[size - 1] = 0; // 或其他无效值
}
int searchElement(int arr[], int size, int element) {
for (int i = 0; i < size; i++) {
if (arr[i] == element) {
return i;
}
}
return -1; // 表示元素未找到
}
在以上示例中,假设数组 arr[] 的大小为 size。插入和删除操作涉及元素的移动,查找操作通过遍历数组进行比较。空间复杂度为数组的大小,即 O(n)。
数组在编程中有广泛的应用场景,以下是一些常见的应用场景和注意事项:
应用场景:
注意事项:
Tip:使用数组时需要根据具体情况权衡其优势和限制。若需要频繁的随机访问和已知大小的存储,数组是一个不错的选择。但在涉及频繁插入和删除、动态大小变化的情况下,可能需要考虑其他数据结构的使用。
链表是一种常见的数据结构,用于存储和组织数据。它的基本原理是通过节点之间的指针连接来表示数据的逻辑顺序。
链表由一系列节点组成,每个节点包含数据和指向下一个节点的引用(在双向链表中还包括指向上一个节点的引用)。链表的特点如下:
链表的灵活性和高效的插入、删除操作使其在许多场景中得到广泛应用,例如实现队列、栈、图等数据结构,以及在内存受限或需要频繁插入和删除的情况下。然而,链表的随机访问复杂度较高,不适合需要频繁随机访问元素的场景。选择链表还是其他数据结构应根据具体需求和性能考虑做出权衡。
单向链表、双向链表和循环链表是链表的三种常见形式,它们之间有以下区别:
单向链表(Singly Linked List):
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NSZC7Oke-1685087056611)(null)]](https://1000bd.com/contentImg/2024/04/11/6de73c6420bbddcb.png)
双向链表(Doubly Linked List):

循环链表(Circular Linked List):

区别总结:
Tip:根据具体的应用需求和操作特点,选择适合的链表类型。单向链表适用于仅需单向遍历的场景,双向链表适用于需要双向遍历或频繁的插入和删除操作的场景,循环链表适用于需要循环遍历的场景。
链表的插入、删除和查找操作是常见的链表操作,它们的时间复杂度和空间复杂度如下所示:
插入操作:
void insertNode(Node **head, int data) {
Node *newNode = (Node *)malloc(sizeof(Node));
newNode->data = data;
newNode->next = NULL;
if (*head == NULL) {
*head = newNode;
} else {
Node *current = *head;
while (current->next != NULL) {
current = current->next;
}
current->next = newNode;
}
}
删除操作:
void deleteNode(Node **head, int data) {
if (*head == NULL) {
return;
}
if ((*head)->data == data) {
Node *temp = *head;
*head = (*head)->next;
free(temp);
return;
}
Node *current = *head;
Node *prev = NULL;
while (current != NULL) {
if (current->data == data) {
prev->next = current->next;
free(current);
return;
}
prev = current;
current = current->next;
}
}
查找操作:
Node *searchNode(Node *head, int data) {
Node *current = head;
while (current != NULL) {
if (current->data == data) {
return current;
}
current = current->next;
}
return NULL;
}
链表的空间复杂度是 O(n),其中 n 是链表中节点的数量,因为需要存储每个节点的数据和指针。
链表在许多场景中都有广泛的应用,以下是一些常见的应用场景和注意事项:
应用场景:
注意事项:
使用链表时,需要根据具体的需求和操作特点进行选择。链表适用于动态数据结构、频繁的插入和删除操作,以及对内存占用要求较灵活的场景。但对于需要频繁随机访问和对内存占用有严格要求的场景,可能需要考虑其他数据结构的选择。
数组和链表是常见的数据结构,它们在内存分配和存储方式上有一些重要的差异。
malloc或new等函数来申请一块连续的内存空间。在数组和链表中,插入、删除和查找操作的效率有所差异。
Tip:数组适用于需要快速随机访问的场景,而链表适用于频繁的插入和删除操作。在插入和删除操作方面,链表具有较好的性能。在查找操作方面,数组由于具有随机访问的能力,通常比链表快。根据实际需求选择合适的数据结构可以提高操作效率。
随机访问和顺序访问是两种不同的访问方式,它们在不同场景下具有各自的优点和缺点。
随机访问的优点:
随机访问的缺点:
顺序访问的优点:
顺序访问的缺点:
Tip:随机访问适用于需要快速访问特定位置的场景,而顺序访问适用于按照顺序处理数据的场景。根据实际需求选择适当的访问方式可以提高数据访问的效率和性能。
在选择数据结构时,应根据具体场景和需求来判断哪种数据结构更适合。以下是一些常见的场景和相应的数据结构选择:
Tip:根据具体的场景和需求,选择合适的数据结构可以提高程序的效率和性能。理解各种数据结构的特点和适用场景是合理选择的关键。
给定一个单链表,将其逆转,即将链表的每个节点的指针方向反转。
示例:
输入: 1 -> 2 -> 3 -> 4 -> 5
输出: 5 -> 4 -> 3 -> 2 -> 1
解析:
逆转链表是一个常见的面试题,解决这个问题有多种方法。其中一种常用的方法是使用迭代法。
算法步骤:
struct ListNode {
int val;
struct ListNode *next;
};
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode *prev = NULL;
struct ListNode *curr = head;
struct ListNode *next;
while (curr != NULL) {
next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
return prev;
}
上面的代码中我们使用了迭代法。通过迭代法我们可以将给定的单链表逆转。算法的时间复杂度是O(n),其中n是链表的长度,因为需要遍历整个链表进行指针的修改。空间复杂度是O(1),因为只需要使用常数级别的额外空间。
给定一个单链表,删除倒数第N个节点,并返回删除后的链表。
示例:
输入: 1 -> 2 -> 3 -> 4 -> 5, N = 2
输出: 1 -> 2 -> 3 -> 5
解析:
删除链表倒数第N个节点是一个常见的面试题,解决这个问题可以使用双指针法。
算法步骤:
struct ListNode {
int val;
struct ListNode *next;
};
struct ListNode* removeNthFromEnd(struct ListNode* head, int n) {
struct ListNode *dummy = malloc(sizeof(struct ListNode));
dummy->next = head;
struct ListNode *fast = dummy;
struct ListNode *slow = dummy;
// 将fast指针向后移动N个节点
for (int i = 0; i < n; i++) {
fast = fast->next;
}
// 同时移动fast和slow指针
while (fast->next != NULL) {
fast = fast->next;
slow = slow->next;
}
// 删除倒数第N个节点
struct ListNode *temp = slow->next;
slow->next = slow->next->next;
free(temp);
return dummy->next;
}
这个问题我们使用了双指针法,可以删除链表倒数第N个节点。算法的时间复杂度是O(L),其中L是链表的长度,因为需要遍历整个链表找到倒数第N个节点。空间复杂度是O(1),因为只需要使用常数级别的额外空间。
给定两个有序数组nums1和nums2,将nums2合并到nums1中,并确保合并后的nums1仍然保持有序。
示例:
输入:
nums1 = [1, 2, 3, 0, 0, 0]
nums2 = [2, 5, 6]
输出:
nums1 = [1, 2, 2, 3, 5, 6]
解析:
合并两个有序数组是一个常见的面试题,可以使用双指针法解决。
算法步骤:
void merge(int* nums1, int m, int* nums2, int n) {
int p1 = m - 1;
int p2 = n - 1;
int p = m + n - 1;
while (p1 >= 0 && p2 >= 0) {
if (nums1[p1] > nums2[p2]) {
nums1[p] = nums1[p1];
p1--;
} else {
nums1[p] = nums2[p2];
p2--;
}
p--;
}
while (p2 >= 0) {
nums1[p] = nums2[p2];
p2--;
p--;
}
}
这里我们同样使用了双指针法,我们可以将两个有序数组合并到nums1中,并保持nums1的有序性。算法的时间复杂度是O(m+n),其中m为nums1的有效元素个数,n为nums2的有效元素个数。空间复杂度是O(1),因为只需要使用常数级别的额外空间。
请实现一个动态数组,支持以下操作:
解析:
动态数组是一种可以根据需要自动调整大小的数组结构。在许多编程语言中,可以使用动态内存分配来实现动态数组。
算法步骤:
7. 初始化数组时,分配一定大小的内存空间,并设置初始的元素个数和容量。
8. 添加元素时,检查数组容量是否足够,若不足则进行扩容,然后将新元素添加到数组的末尾。
9. 获取元素时,检查索引的有效性,若有效则返回对应位置的元素值。
10. 更新元素时,检查索引的有效性,若有效则更新对应位置的元素值。
11. 删除元素时,检查索引的有效性,若有效则将后面的元素向前移动一个位置,同时更新元素个数。
12. 若数组的元素个数小于容量的一半,并且大于某个最小容量阈值,可以考虑进行缩容操作。
#include
#include
typedef struct {
int* data; // 指向动态数组的指针
int size; // 数组的当前元素个数
int capacity; // 数组的容量
} DynamicArray;
// 初始化动态数组
void initDynamicArray(DynamicArray* arr, int capacity) {
arr->data = (int*)malloc(capacity * sizeof(int));
arr->size = 0;
arr->capacity = capacity;
}
// 添加元素到数组末尾
void append(DynamicArray* arr, int value) {
if (arr->size == arr->capacity) {
// 当数组容量不足时,进行扩容
arr->capacity *= 2;
arr->data = (int*)realloc(arr->data, arr->capacity * sizeof(int));
}
arr->data[arr->size] = value;
arr->size++;
}
// 获取指定位置的元素
int get(DynamicArray* arr, int index) {
if (index >= 0 && index < arr->size) {
return arr->data[index];
}
return -1; // 返回一个无效值表示索引越界
}
// 更新指定位置的元素
void update(DynamicArray* arr, int index, int value) {
if (index >= 0 && index < arr->size) {
arr->data[index] = value;
}
}
// 删除指定位置的元素
void removeElement(DynamicArray* arr, int index) {
if (index >= 0 && index < arr->size) {
for (int i = index; i < arr->size - 1; i++) {
arr->
data[i] = arr->data[i + 1];
}
arr->size--;
// 当数组元素个数小于容量的一半,并且大于某个最小容量阈值时,进行缩容
if (arr->size < arr->capacity / 2 && arr->capacity > 4) {
arr->capacity /= 2;
arr->data = (int*)realloc(arr->data, arr->capacity * sizeof(int));
}
}
}
// 释放动态数组内存
void freeDynamicArray(DynamicArray* arr) {
free(arr->data);
arr->data = NULL;
arr->size = 0;
arr->capacity = 0;
}
int main() {
DynamicArray arr;
initDynamicArray(&arr, 4);
append(&arr, 1);
append(&arr, 2);
append(&arr, 3);
printf("Element at index 1: %d\n", get(&arr, 1));
update(&arr, 2, 4);
removeElement(&arr, 0);
for (int i = 0; i < arr.size; i++) {
printf("%d ", arr.data[i]);
}
printf("\n");
freeDynamicArray(&arr);
return 0;
}
上述代码中,我们使用DynamicArray结构体来表示动态数组。通过调用initDynamicArray函数进行初始化,并使用append函数在数组末尾添加元素。使用get函数获取指定位置的元素值,使用update函数更新指定位置的元素值,使用removeElement函数删除指定位置的元素。在添加和删除元素时,如果数组的元素个数小于容量的一半,并且容量大于某个最小容量阈值时,可以进行缩容操作。最后,通过调用freeDynamicArray函数释放动态数组的内存。
Tip:在实际应用中,动态数组的实现可能需要考虑更多的细节,如插入元素、动态调整缩容的阈值等。上述示例代码提供了一个基本的动态数组实现框架,您可以根据实际需求进行扩展和修改。
假设有一个名为Person的结构体,包含两个字段:姓名(name)和年龄(age)。请设计一个数据结构,能够存储一组Person对象,并支持以下操作:
解析:
为了实现上述功能,可以结合数组和链表的特点进行设计。使用数组来存储Person对象的指针,方便根据索引进行访问。同时,使用链表来连接数组中的Person对象,方便根据姓名进行查找和根据年龄进行删除。
具体实现步骤:
下面是一个简化的C语言代码示例,演示了如何实现上述功能:
#include
#include
#include
typedef struct {
char name[20];
int age;
} Person;
typedef struct Node {
Person* person;
struct Node* next;
} Node;
typedef struct {
Person** array;
Node* head;
int capacity;
int count;
} DataStructure;
void init(DataStructure* ds, int capacity) {
ds->array = (Person**)malloc(capacity * sizeof(Person*));
ds->head = NULL;
ds->capacity = capacity;
ds->count = 0;
}
void addPerson(DataStructure* ds, Person* person) {
if (ds->count == ds->capacity) {
// 数组已满,进行扩容
ds->capacity *= 2;
ds->array = (Person**)realloc(ds->array, ds->capacity * sizeof(Person*));
}
// 将Person指针添加到数组中
ds->array[ds->count] = person;
ds->count++;
// 创建新的Node并插入链表头部
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->person = person;
newNode->next = ds->head;
ds-> head = newNode;
}
Person* findPerson(DataStructure* ds, const char* name) {
Node* current = ds->head;
while (current != NULL) {
if (strcmp(current->person->name, name) == 0) {
return current->person;
}
current = current->next;
}
return NULL;
}
void removePerson(DataStructure* ds, int age) {
Node* prev = NULL;
Node* current = ds->head;
while (current != NULL) {
if (current->person->age == age) {
// 从数组中释放Person对象的内存
for (int i = 0; i < ds->count; i++) {
if (ds->array[i] == current->person) {
free(ds->array[i]);
ds->array[i] = NULL;
break;
}
}
if (prev == NULL) {
// 删除头节点
ds->head = current->next;
} else {
// 删除中间或尾节点
prev->next = current->next;
}
free(current);
current = NULL;
ds->count--;
break;
}
prev = current;
current = current->next;
}
}
int getPersonCount(DataStructure* ds) {
return ds->count;
}
void freeDataStructure(DataStructure* ds) {
// 释放链表节点的内存
Node* current = ds->head;
while (current != NULL) {
Node* temp = current;
current = current->next;
free(temp);
}
// 释放数组中Person对象的内存
for (int i = 0; i < ds->count; i++) {
free(ds->array[i]);
}
// 释放数组和数据结构对象的内存
free(ds->array);
ds->array = NULL;
ds->head = NULL;
ds->capacity = 0;
ds->count = 0;
}
int main() {
DataStructure ds;
init(&ds, 5);
// 添加Person对象
Person* person1 = (Person*)malloc(sizeof(Person));
strcpy(person1->name, "Alice");
person1->age = 25;
addPerson(&ds, person1);
Person* person2 = (Person*)malloc(sizeof(Person));
strcpy(person2->name, "Bob");
person2->age = 30;
addPerson(&ds, person2);
// 查找Person对象
Person* result = findPerson(&ds, "Alice");
if (result != NULL) {
printf("Person found: %s, age: %d\n", result->name, result->age);
} else {
printf("Person not found\n");
}
// 删除Person对象
removePerson(&ds, 30);
// 获取Person对象数量
int count = getPersonCount(&ds);
printf("Person count: %d\n", count);
// 释放数据结构内存
freeDataStructure(&ds);
return 0;
}
上述代码中,我们定义了Person结构体来表示每个Person对象,定义了Node结构体作为链表的节点,定义了DataStructure结构体作为整个数据结构的信息。通过调用init函数进行初始化,使用addPerson函数向数据结构中添加Person对象,使用findPerson函数根据姓名查找Person对象,使用removePerson函数根据年龄删除Person对象,使用getPersonCount函数获取Person对象的数量。最后,通过调用freeDataStructure函数释放数据结构的内存。
这个综合应用题结合了数组和链表的特点,可以实现对一组Person对象的灵活管理。请注意,上述示例代码提供了一个基本的实现框架,您可以根据实际需求进行扩展和修改。
数组和链表是常见的数据结构,在实际应用中具有不同的优缺点,根据需求选择合适的数据结构对程序的性能和效率至关重要。
数组的优缺点:
| 优点 | 缺点 |
|---|---|
| 随机访问:可以通过索引直接访问元素,访问速度快 | 大小固定:在创建数组时需要指定大小,无法动态调整 |
| 内存连续:元素在内存中连续存储,利于缓存的命中 | 插入和删除效率低:插入和删除元素需要移动其他元素 |
链表的优缺点:
| 优点 | 缺点 |
|---|---|
| 动态大小:链表可以根据需要动态分配和释放内存 | 随机访问效率低:无法直接根据索引访问元素,需要遍历链表 |
| 插入和删除效率高:在链表中插入和删除元素只需要调整指针,不需要移动其他元素 | 额外的内存开销:链表每个节点需要额外的指针空间 |
根据需求选择合适的数据结构的原则: