引言
随着时代的飞速进步与科技的日新月异,我们所依赖的应用程序日益复杂,处理的数据量也急剧增长。为了在这浩瀚的数据海洋中迅速定位所需信息,提升数据管理效率,人们不懈探索,最终孕育出了一门至关重要的学科——数据结构与算法。
什么是数据结构
数据结构(Data Structure)是计算机科学中用于组织和存储数据的方式,使得数据能够高效地被访问和修改。
下表是常见的数据结构:
名称 | 定义 |
数组(Array) | 一组连续存储的相同类型的数据元素,通过索引快速访问 |
链表(Linked List) | 由一系列节点组成,每个节点包含数据部分和指向列表中下一个节点的指针(或链接),支持高效的插入和删除操作 |
栈(Stack) | 遵循后进先出(LIFO)原则的有序集合,主要操作有压栈(push)和弹栈(pop) |
队列(Queue) | 遵循先进先出(FIFO)原则的有序集合,主要操作有入队(enqueue)和出队(dequeue) |
树(Tree) | 由节点和边组成的层次结构,每个节点可以有零个或多个子节点,常见的有二叉树、平衡二叉树、B树、B+树等 |
堆(Heap) | 堆是一种特殊的树形数据结构,一般讨论的堆都是二叉堆。 |
图(Graph) | 由顶点(或节点)和连接这些顶点的边(或弧)组成的集合 |
什么是算法
算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。
算法一般分为:排序、递归、迭代、分治、动态规划、贪心、回溯以及搜索。
算法与数学密切相关,就如数学题一样,每道数学题都有不同的解法,算法也是同理。
复杂度分析
我们在进行算法分析时,通常会要完成如下两个目标:
1.找出问题的解决方法。
2.找到问题的最优解。
为了找出最优解,我们通常会要从如下两个维度考虑:
1.时间效率:算法运行的快慢
2.空间效率:算法所占空间的大小
即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
时间复杂度和空间复杂度
1.时间复杂度
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
我们来看看这段代码:
// 请计算一下Func1中++count语句总共执行了多少次?
void Func1(int n)
{
int count = 0;
for (int i = 0; i < n; ++i)
{
for (int j = 0; j < n; ++j)
{
++count;
}
}
for (int k = 0; k < 2 * n; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
计算时间如下:
T(n)=n^2 +2*n+10
但是实际上统计每一项所需时间是不现实的,并且由于是理论分析,当n—>∞时,其余项皆可忽略,T(n)的数量级由最高阶决定。所以我们计算时间复杂度时,可以简化为两个步骤:
1.忽略除最高阶以外的所有项。
2.忽略所有系数。
而上述代码时间可以记为O(n^2),这种方法被称为大O渐进表示法。如果计算机结果全是常数,则记为O(1)。
2.空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
我们来看看如下代码:
计算冒泡排序的空间复杂度
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
代码使用了常数个额外空间,所以空间复杂度为 O(1)。
常见复杂度分类
算法的复杂度有几个量级,表示如下:
O(1)<O(log N)<O(N)<O(Nlog N)<O(N^2)<O(2^N)<O(N!)
1.常数阶O(1)
常数阶是一种非常快速的算法。
下面是是一个时间复杂度和空间复杂度都为O(1)的代码:
int main()
{
int a = 0;
int b = 1;
int c = a + b;
printf("c = %d\n", c);
return 9;
}
2.对数阶O(logN)
对数阶是一种比较快的算法,它一般每次减少一半的数据。对数阶时间复杂度的一个典型应用是二分查找算法。在二分查找中,每次都将搜索区间减半,因此查找次数与输入规模n的对数成正比。
二分查找代码如下:
int bin_search(int arr[], int left, int right, int key)
{
while (left <= right) //当left == right时,区间[left, right]仍然有效
{
int mid = left + (right - left) / 2; //等同于 (left + right) / 2,防止溢出
if (arr[mid] < key) //target在右区间,所以[middle + 1, right]
{
left = mid + 1;
}
else if (arr[mid] > key) //target在左区间,所以[left, middle - 1]
{
right = mid - 1;
}
else //既不在左边,也不在右边,那就是找到答案了
{
return mid;
}
}
return left;
}
通常来说,空间复杂度为O(logN)的算法,一般为分治算法。
比如用递归实现二分查找。
代码如下:
int bin_search(int* arr, int left, int right, int key)
{
if (left > right)
{
return -1;
}
int mid = left + (right-left) / 2;
if (key == arr[mid])
{
return mid;
}
else if (key < arr[mid])
{
return bin_search(arr, left, mid - 1, key);
}
else
{
return bin_search(arr, mid + 1, right, key);
}
}
每一次执行递归都会对应开辟一个空间,也被称为栈帧。
3.线性阶O(N)
线性阶算法,时间复杂度与空间复杂度随着数量均匀变化。
遍历数组或者链表是常见的线性阶算法,以下为时间复杂度为O(N)的算法:
int main()
{
int n = 0;
int count = 0;
scanf("%d", &n);
for (int i = 0; i < n; i++)
{
count += i;
}
return 0;
}
下面是实现空间复杂度为O(N)的算法:
int main()
{
int n = 0;
int count = 0;
scanf("%d", &n);
int* p = (int*)malloc(sizeof(int) * n);
//开辟大小为n的空间
if (p == NULL)
{
perror("malloc fail:");
return -1;
}
free(p);
p = NULL;
return 0;
}
4.线性对数阶O(NlogN)
无论是时间复杂度还是空间复杂度,线性对数阶一般出现在嵌套循环中,即一层的复杂度为O(N),另一层为O(logN)。
例如循环使用二分查找打印:
int bin_search(int arr[], int left, int right, int key)
{
while (left <= right) //当left == right时,区间[left, right]仍然有效
{
int mid = left + (right - left) / 2; //等同于 (left + right) / 2,防止溢出
if (arr[mid] < key) //target在右区间,所以[middle + 1, right]
{
left = mid + 1;
}
else if (arr[mid] > key) //target在左区间,所以[left, middle - 1]
{
right = mid - 1;
}
else //既不在左边,也不在右边,那就是找到答案了
{
return mid;
}
}
return left;
}
void func(int nums[], int left, int right, int target)
{
for (int i = 0; i < size; i++)
{
binary_search(nums, size, target);
}
}
空间复杂度为O(NlogN)的算法并不常见,这里就不列举了。
5.平方阶O(N^2)
平方阶与线性对数阶相似,常见于嵌套循环中,每层循环的复杂度为O(N)
时间复杂度为O(N2),最常见的就是冒泡排序
代码如下:
void bubble_sort(int arr[], int sz)
{
for (int i = 0; i < sz - 1; i++)
{
int flag = 0;
for (int j = 0; j < sz - i - 1; j++)
{
if (arr[j] > arr[j + 1])
{
flag = 1;
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
if (flag == 0)
{
break;
}
}
}
计算过程如下:
T(N)=(sz-1) + (sz-2) + ... + 2 + 1=(sz-1) * sz / 2=O(n^2)
动态开辟可以使空间复杂度为O(n^2)
int main()
{
int n = 0;
int count = 0;
scanf("%d", &n);
int* p = (int*)malloc(sizeof(int) * n * n);
//开辟大小为n的空间
if (p == NULL)
{
perror("malloc fail");
return -1;
}
free(p);
p=NULL;
return 0;
}
6.指数阶O(2^N)
指数阶的算法效率低效,并不常用。
常见的时间复杂度为O(2^N)的算法就是递归实现斐波拉契数列:
int Fib1(int n)
{
if (n == 1 || n == 2)
{
return 1;
}
else
{
return Fib1(n - 1) + Fib1(n - 2);
}
}
计算过程:
T(n)=2^0+2^1+2^2+......+2^(n-1)=2^n-1=O(2^n)
注意:斐波拉契的空间复杂度为O(N),因为在递归至最深处后往回归的过程中,后续空间都在销毁的空间上建立的,这样能大大提高空间的利用率。
空间复杂度为O(2^N)的算法一般与树有关,比如建立满二叉树:
TreeNode* buildTree(int n)
{
if (n == 0)
return NULL;
TreeNode* root = newTreeNode(0);
root->left = buildTree(n - 1);
root->right = buildTree(n - 1);
return root;
}
7.阶乘阶O(N!)
阶乘阶O(N!)的算法是非常低效的,几乎不会采用该类型的算法。
下面是时间复杂度为阶乘阶O(N!)的代码:
int func(int n)
{
if (n == 0)
return 1;
int count = 0;
for (int i = 0; i < n; i++)
{
count += func(n - 1);
}
return count;
}
计算过程:
T(n)=n⋅T(n−1)T(n)=n⋅T(n−1)
T(n−1)=(n−1)⋅T(n−2)T(n−1)=(n−1)⋅T(n−2)
T(n−2)=(n−2)⋅T(n−3)T(n−2)=(n−2)⋅T(n−3)
...........
T(1)=1⋅T(0)T(1)=1⋅T(0)
因此
T(n)=n⋅(n−1)⋅(n−2)⋯⋅1⋅T(0)T(n)=n⋅(n−1)⋅(n−2)⋯⋅1⋅T(0)
T(n)=n!T(n)=O(n!)
空间复杂度为阶乘阶O(N!)的算法更为罕见,这里就不举例子了。
结束语
本篇博客记录了有关数据结构的一些基础知识。
感谢各位大佬们的阅读!!!
求点赞收藏加关注!!!
十分感谢!!!