正文
1. 数据结构概述
1.1 什么是数据结构
数据结构是计算机存储、组织数据的方式。它旨在高效地管理和操作数据,以便在解决各种实际问题时能够快速地进行数据的插入、删除、查找等操作。合理的数据结构选择可以极大地提高程序的运行效率。例如,我们要管理一个学校学生的信息,包括姓名、学号、成绩等,如果只是简单地将这些信息杂乱无章地存放,后续要查找某个学生或者统计成绩等操作就会变得非常困难,但通过合适的数据结构(如数组、链表等)来组织这些数据,就能轻松应对各类需求。
1.2 数据结构的分类
数据结构主要分为两大类:线性结构和非线性结构。
1.2.1 线性结构
线性结构中的数据元素之间存在着一对一的线性关系。常见的线性结构有数组、链表、栈和队列等。比如数组,它是一组连续的内存空间,用来存放相同类型的数据元素,通过下标就可以快速访问其中的元素;链表则是通过节点之间的指针链接来存储数据,每个节点包含数据域和指针域,指针域指向下一个节点,虽然访问元素可能没有数组那么直接通过下标就能实现,但在动态插入和删除元素方面有着独特的优势。
1.2.2 非线性结构
非线性结构中数据元素之间的关系不是简单的一对一关系,常见的如树结构和图结构。树结构像一棵倒挂的树,有根节点、分支节点和叶子节点等,例如二叉树在查找、排序等算法应用中非常广泛;图结构则更复杂,节点之间的关系可以是多对多的,常用来表示各种复杂的网络关系,比如交通网络、社交网络等都可以用图来建模。
2. 顺序存储与链式存储
2.1 顺序存储
2.1.1 概念
顺序存储是把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,通常借助数组来实现。比如有一个整型数组 int arr[5] = {1, 2, 3, 4, 5};
,数组中的元素在内存中是连续存放的,第一个元素 arr[0]
的地址确定后,后面元素的地址可以通过简单的偏移量计算得到,因为每个整型元素占用固定的字节数(在常见的 32 位系统中一般是 4 个字节)。
2.1.2 优点
- 随机访问效率高:由于元素的存储地址是连续的,只要知道第一个元素的起始地址和要访问元素的下标,就能直接通过公式
地址 = 起始地址 + 下标 * 元素字节大小
快速计算出目标元素的地址,进而访问该元素,时间复杂度为 O ( 1 ) O(1) O(1)。例如,对于上述的arr
数组,要访问arr[3]
,计算机可以立即定位到该元素的内存位置并获取其值。 - 节省存储空间:因为元素是紧凑排列的,不存在额外的指针等用于关联元素的空间开销(除了数组本身占用的内存空间外)。
2.1.3 缺点
- 插入和删除操作复杂且效率低:当要在顺序存储结构的中间插入或删除一个元素时,需要移动后面的元素来腾出空间或者填补空缺。例如,在
arr
数组中,如果要在第二个位置插入一个元素,那么从第二个元素开始后面的所有元素都要往后移动一位,时间复杂度在最坏情况下是 O ( n ) O(n) O(n),其中n
是数组中元素的个数。 - 需要预先分配固定大小的内存空间:在创建顺序存储结构(如数组)时,要事先确定其大小,如果后续需要存储更多的元素而初始分配的空间不够了,就可能需要重新分配更大的内存空间并进行数据的拷贝等操作,比较麻烦。
下面是一段用 C++ 实现顺序存储结构插入元素的示例代码(简单模拟插入操作的基本原理,实际应用中可能更复杂些):
#include <iostream>
using namespace std;
// 定义顺序存储结构的最大容量
const int MAX_SIZE = 100;
// 顺序存储结构类
class SeqList {
private:
int data[MAX_SIZE]; // 存储数据的数组
int length; // 当前元素个数
public:
SeqList() {
length = 0;
}
// 在指定位置插入元素
void insert(int pos, int element) {
if (pos < 0 || pos > length || length >= MAX_SIZE) {
cout << "插入位置不合法或者顺序表已满" << endl;
return;
}
for (int i = length; i > pos; i--) {
data[i] = data[i - 1];
}
data[pos] = element;
length++;
}
void display() {
for (int i = 0; i < length; i++) {
cout << data[i] << " ";
}
cout << endl;
}
};
int main() {
SeqList myList;
myList.insert(0, 10);
myList.insert(1, 20);
myList.insert(0, 5);
myList.display();
return 0;
}
在上述代码中,SeqList
类模拟了顺序存储结构(类似数组的功能),insert
方法实现了在指定位置插入元素的操作,通过循环移动元素来腾出空间插入新元素,display
方法用于输出顺序表中的元素。
2.2 链式存储
2.2.1 概念
链式存储结构是通过节点来存储数据的,每个节点除了包含数据域外,还包含一个(或多个,在复杂链表情况下)指针域,指针域用来指向与该节点有逻辑关系的其他节点。例如,在单链表中,每个节点有一个指针域指向下一个节点,节点之间通过这些指针连接形成一条链状结构,数据元素在物理内存中的存储位置可以是不连续的。
2.2.2 优点
- 动态分配内存空间灵活:不需要预先确定链表的长度,可以根据实际需要动态地创建节点并插入到链表中,方便存储数量不确定的数据集合。例如,要实现一个动态记录用户输入信息的程序,每次用户输入新的内容都可以创建一个新的节点插入到链表中,不用担心内存空间一开始分配不足的问题。
- 插入和删除操作相对简单高效:在链表中插入或删除一个节点,只需要修改相关节点的指针指向即可,不需要像顺序存储那样移动大量的元素。比如要在单链表中删除一个节点,只要让该节点前一个节点的指针指向它的后一个节点(如果是双向链表操作会更方便些,涉及到两个指针的修改),时间复杂度在最好情况下可以达到 O ( 1 ) O(1) O(1),当然平均和最坏情况需要根据具体链表的查找操作等来综合确定。
2.2.3 缺点
- 随机访问效率低:由于链表中的节点在内存中不是连续存储的,要访问链表中的某个节点,需要从链表头开始顺着指针依次遍历查找,不能像顺序存储那样通过简单的下标计算快速定位,平均时间复杂度为
O
(
n
)
O(n)
O(n),其中
n
是链表中节点的个数。 - 占用额外的指针空间:每个节点都需要额外的指针域来存储指针信息,相比于顺序存储结构会多占用一定的内存空间,特别是当数据元素本身占用空间较小时,指针空间开销占比相对就会更大。
以下是一个简单的 C++ 单链表实现的代码示例,包含了节点的定义以及链表的插入和遍历操作:
#include <iostream>
using namespace std;
// 定义链表节点结构体
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(NULL) {}
};
// 单链表类
class LinkedList {
private:
ListNode* head; // 链表头指针
public:
LinkedList() {
head = NULL;
}
// 在链表头部插入节点
void insertAtHead(int element) {
ListNode* newNode = new ListNode(element);
newNode->next = head;
head = newNode;
}
// 遍历链表并输出节点的值
void display() {
ListNode* current = head;
while (current!= NULL) {
cout << current->val << " ";
current = current->next;
}
cout << endl;
}
};
int main() {
LinkedList myList;
myList.insertAtHead(20);
myList.insertAtHead(10);
myList.display();
return 0;
}
在上述代码中,ListNode
结构体定义了链表的节点,包含数据域 val
和指针域 next
,LinkedList
类则对整个链表进行管理,insertAtHead
方法实现在链表头部插入新节点的操作,display
方法用于遍历链表并输出每个节点存储的数据值。
3. 时间复杂度分析
3.1 概念
时间复杂度是用来衡量算法运行时间随着输入规模增长而增长的量级。它关注的不是算法在具体某个输入下运行的实际时间(因为这个实际时间会受到很多因素影响,比如计算机硬件性能、编程语言实现细节等),而是从宏观上描述算法执行时间与输入规模之间的渐近关系,通常用大 O O O 表示法来表示。
例如,有一个简单的算法是求数组中所有元素的和,代码如下:
int sumArray(int arr[], int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}
return sum;
}
在这个算法中,主要的操作是循环遍历数组中的 n
个元素,循环执行的次数与数组的元素个数 n
成正比,不管数组中的元素具体是什么值,只要元素个数 n
确定了,循环执行的次数基本就确定了,所以这个算法的时间复杂度就是
O
(
n
)
O(n)
O(n),也就是随着输入规模(这里就是数组元素个数 n
)的线性增长,算法执行时间也线性增长。
3.2 常见的时间复杂度量级
3.2.1 常数阶 O ( 1 ) O(1) O(1)
常数阶时间复杂度表示算法的执行时间不随输入规模的变化而变化,无论输入的数据量多大,算法执行的基本操作次数都是固定的。例如,访问数组中指定下标的元素,如 int element = arr[5];
,不管数组 arr
有多大,通过下标访问元素这个操作只需要一步就能完成,其时间复杂度就是
O
(
1
)
O(1)
O(1)。
3.2.2 线性阶 O ( n ) O(n) O(n)
如上面求数组元素和的算法例子,算法的执行时间与输入规模 n
(数组元素个数)呈线性关系,随着 n
的增大,算法执行的基本操作次数也线性增加,这种就是线性阶时间复杂度。常见于需要遍历一次数据集合的操作,像链表的遍历查找、顺序表的顺序遍历等情况。
3.2.3 对数阶 O ( log n ) O(\log n) O(logn)
以二分查找算法为例,假设要在一个有序数组中查找某个元素,每次可以通过比较中间元素来确定要查找的元素在左半部分还是右半部分,然后继续在对应的那一半中重复这个操作,每次查找都能将搜索范围缩小一半,经过大约
log
2
n
\log_2 n
log2n 次比较就能找到目标元素(n
是数组元素个数),所以二分查找算法的时间复杂度就是
O
(
log
n
)
O(\log n)
O(logn)。它的增长速度比对数阶以下的复杂度增长都要慢,对于大规模数据的查找效率比较高。
3.2.4 平方阶 O ( n 2 ) O(n^2) O(n2)
常见于嵌套循环的情况,比如冒泡排序算法,它需要对数组进行多次遍历,每次遍历都要比较相邻的元素并可能交换它们的位置,外层循环控制遍历的轮数,内层循环用来进行每一轮的元素比较和交换操作,总的比较和交换操作次数大约是
n
(
n
−
1
)
/
2
n(n - 1) / 2
n(n−1)/2,随着 n
的增大,操作次数与 n
的平方成正比,所以时间复杂度是
O
(
n
2
)
O(n^2)
O(n2)。
3.2.5 指数阶 O ( 2 n ) O(2^n) O(2n)
像递归求解斐波那契数列的一种简单但效率不高的实现方式,它会有大量的重复计算,其时间复杂度就是指数阶的。随着输入规模 n
的增加,计算量会呈指数级增长,这种复杂度的算法在 n
稍微大一点时运行时间就会变得非常长,所以在实际应用中尽量要避免使用指数阶时间复杂度的算法,或者通过一些优化技巧(如记忆化等)来降低复杂度。
3.3 分析时间复杂度的方法
3.3.1 只关注最高次项
在分析算法的时间复杂度时,通常只关注执行时间随输入规模增长的最高次项,忽略低次项和常数系数。例如,一个算法的执行时间表达式可能是
3
n
2
+
5
n
+
2
3n^2 + 5n + 2
3n2+5n+2,但根据时间复杂度的渐近分析,我们只看最高次项
n
2
n^2
n2,其时间复杂度就是
O
(
n
2
)
O(n^2)
O(n2)。因为当 n
足够大时,低次项和常数项对整体运行时间的影响相对很小,可以忽略不计。
3.3.2 分析循环结构和递归调用
对于循环结构,要看循环执行的次数与输入规模的关系,像单层循环且循环次数与输入规模 n
成正比就是
O
(
n
)
O(n)
O(n),双层嵌套循环(内层循环和外层循环次数都与 n
有关)往往就是
O
(
n
2
)
O(n^2)
O(n2) 等。对于递归调用,要分析递归树的深度以及每层递归的操作次数等情况来确定时间复杂度,例如上面提到的斐波那契数列递归求解,通过分析其递归调用的情况就能得出是指数阶的时间复杂度。
4. 空间复杂度分析
4.1 概念
空间复杂度是用来衡量算法在运行过程中临时占用的额外存储空间与输入规模之间的关系,同样也是用大 O O O 表示法来表示。这里强调的是额外的存储空间,也就是除了输入数据本身所占用的空间之外,算法执行过程中因为变量定义、数据结构创建等需要额外开辟的内存空间。
例如,在上面提到的顺序存储结构中,如果只是存储 n
个元素的数组本身,它的空间复杂度就是
O
(
n
)
O(n)
O(n),因为数组占用的空间与元素个数成正比;但如果在某个算法中,除了这个数组外,还额外定义了几个固定大小的变量(比如几个整型变量来记录中间结果等),这些固定大小的变量不会随着输入规模 n
的变化而变化,在分析空间复杂度时可以忽略不计,还是认为该算法基于这个顺序存储结构的空间复杂度是
O
(
n
)
O(n)
O(n)。
4.2 常见的空间复杂度量级
4.2.1 常数阶 O ( 1 ) O(1) O(1)
如果算法在执行过程中,除了输入数据占用的空间外,额外开辟的存储空间是固定的,不随输入规模变化,那么其空间复杂度就是常数阶 O ( 1 ) O(1) O(1)。例如,只定义了几个简单的变量来进行一些临时计算,不管输入的数据量多大,这些变量占用的空间始终不变,像下面这个简单的交换两个变量值的函数:
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
在这个函数中,虽然定义了一个临时变量 temp
,但不管要交换的 a
和 b
所代表的数据规模有多大,始终只占用这一个额外的临时变量空间,所以空间复杂度就是
O
(
1
)
O(1)
O(1)。
4.2.2 线性阶 O ( n ) O(n) O(n)
当算法执行过程中额外开辟的存储空间与输入规模 n
呈线性关系时,空间复杂度就是线性阶。比如创建一个长度为 n
的数组来存储中间结果,或者创建一个包含 n
个节点的链表来处理数据等情况,随着输入规模 n
的增加,额外占用的存储空间也线性增加。
4.2.3 对数阶 O ( log n ) O(\log n) O(logn)
在一些算法中,例如使用了分治思想且递归调用时需要借助栈空间来保存每一层的状态信息,栈空间的使用量可能与递归的深度有关,而递归深度往往与输入规模 n
的对数相关,这种情况下空间复杂度就是
O
(
log
n
)
O(\log n)
O(logn)。像二叉树的递归遍历算法,递归栈的深度最多就是二叉树的高度,对于平衡二叉树来说,树的高度大约是
log
2
n
\log_2 n
log2n(n
是节点个数),所以其空间复杂度就是
O
(
log
n
)
O(\log n)
O(logn)。
4.2.4 平方阶 O ( n 2 ) O(n^2) O(n2)
如果算法在执行过程中需要创建二维的数据结构(比如二维数组),且二维数组的规模与输入规模 n
有关,例如创建一个 n×n
的二维数组来存储数据或者进行计算等操作,那么其空间复杂度就是
O
(
n
2
)
O(n^2)
O(n2)。或者存在嵌套的数据结构创建,且每层的数据结构规模都与输入规模 n
相关,也可能导致空间复杂度达到平方阶。
4.3 分析空间复杂度的方法
4.3.1 确定额外空间的使用情况
要仔细分析算法执行过程中除了输入数据本身所占用空间之外的其他空间使用情况。
-
变量方面:观察算法里定义的各种变量,包括临时变量、计数器、标志位变量等。例如,在一个简单的循环求数组元素总和的算法中,可能只定义了一个用于累加的变量
sum
,它的大小是固定的,不随输入数组规模(元素个数n
)变化,在这种情况下,这个变量所占用的空间在分析空间复杂度时可视为常数级别,通常可以忽略不计(除非整个算法额外开辟空间仅有此变量相关部分,那空间复杂度就是O(1)
)。但如果是定义了一个长度为n
的辅助数组来协助计算,比如在某些排序算法中,为了实现特定的排序逻辑会开辟和输入数组等长的临时数组用于数据交换等操作,那这部分空间就和输入规模n
呈线性关系,空间复杂度就可能达到O(n)
。 -
数据结构方面:考虑算法中创建的数据结构,像链表、栈、队列、树等。以链表为例,如果算法里根据输入规模动态创建包含
n
个节点的链表来存储或处理数据,那这个链表节点所占用的额外空间(除去输入数据本身如果需要存储在节点中的部分)就是和n
成正比的,空间复杂度为O(n)
。对于栈来说,在递归算法里常常会借助系统栈来保存函数调用的状态,如果递归深度和输入规模n
存在某种关联,比如二叉树的递归遍历,其递归栈的深度取决于树的高度,平衡二叉树高度大约为log₂n
,那么相应的空间复杂度就可能是O(log n)
;而对于不平衡的二叉树,最坏情况下树高可能达到n
,空间复杂度就会趋近于O(n)
。 -
递归调用情况:分析递归函数时,重点关注递归过程中每层调用所占用的栈空间以及递归的深度。每一次递归调用都会在系统栈中压入一些信息,比如函数的参数、局部变量、返回地址等,这些都会占用一定的空间。若递归函数每次调用自身时并没有创建大量额外的数据结构等,只是单纯依赖栈来保存状态,那空间复杂度往往就取决于递归的深度。比如经典的计算斐波那契数列的递归函数,其递归调用的次数呈指数级增长,会导致系统栈占用的空间随着输入规模
n
(求第n
个斐波那契数)快速膨胀,空间复杂度就是O(2ⁿ)
;而像采用了优化策略(如记忆化递归,通过额外的数据结构记录已经计算过的结果来避免重复计算)后,虽然时间复杂度能有所改善,但空间复杂度依然会受到记录中间结果的数据结构(比如可能用一个数组来记录已计算的斐波那契数)大小影响,若数组大小和n
相关,那空间复杂度就会是O(n)
等不同情况。
4.3.2 忽略常数因子和低阶项
如同分析时间复杂度一样,在空间复杂度分析中,也主要关注随着输入规模增长,额外空间占用量的增长趋势的主导部分,也就是只看最高阶的情况,忽略常数因子和低阶项。例如,一个算法在执行过程中,除了输入数据外,额外开辟的空间可能表示为 3n + 5
(假设 n
为输入规模),这里的常数 5
代表固定大小的几个变量占用的空间,当 n
不断增大时, 5
这点空间相对 3n
来说影响越来越小,而系数 3
也不影响整体空间随 n
增长的量级,所以我们只关注最高阶的 n
,该算法的空间复杂度就是 O(n)
。
再比如,有的算法额外空间的表达式可能是 2n² + 3n + 7
,同样道理,不管系数是多少以及低阶项 3n
和常数项 7
的情况,主要看最高阶 n²
,空间复杂度判定为 O(n²)
。这种忽略的做法是为了从宏观上把握算法在空间使用方面随着输入规模变化的大致情况,更便于比较不同算法在空间效率上的优劣。
4.3.3 考虑最坏、平均和最好情况(如果有差异)
在一些算法中,其空间复杂度会根据输入数据的不同情况有所变化,这时候就需要分别分析最坏情况、平均情况和最好情况的空间复杂度。
-
最坏情况空间复杂度:是指在所有可能的输入中,算法执行时占用额外空间最多的情况。例如在插入排序算法中,如果输入的数组是完全逆序的,那么每次插入操作都需要将已排序部分的元素往后移动,可能还需要额外的空间来暂存元素等(虽然原地排序可以尽量减少额外空间,但在分析时考虑较全面情况),此时算法占用的额外空间相对较多,其最坏情况空间复杂度就是
O(n)
(这里n
是数组元素个数)。 -
平均情况空间复杂度:考虑各种输入情况出现的概率,计算出算法平均占用额外空间的量级。对于很多排序算法,平均情况的分析会相对复杂些,需要基于一定的概率假设等。比如快速排序算法,平均情况下它的空间复杂度是
O(log n)
,因为其递归调用栈的深度平均来看和输入数组规模n
的对数相关,但在最坏情况(比如每次划分都极不平衡时),空间复杂度会退化为O(n)
。 -
最好情况空间复杂度:代表在最理想的输入情况下算法占用的额外空间情况。还是以插入排序为例,如果输入的数组本身就是有序的,那么在插入过程中几乎不需要额外的空间来移动元素等,其最好情况空间复杂度就是
O(1)
,仅仅可能有几个用于记录索引等的固定变量占用空间。
通过对这几种情况的分析,可以更全面地了解一个算法在空间使用方面的特性,在实际应用中根据具体的需求和数据特点来选择合适的算法,权衡时间和空间的效率。
结语
感谢您的阅读!期待您的一键三连!欢迎指正!