前言:
什么是数据结构?
什么是算法?
数据结构和算法的重要性
校招面试的重点考察项目,同时在你正式步入工作之后对你的编程思维有很大的帮助。
如何学好数据结构和算法
没有捷径,死磕代码同时注意画图和思考,这必须成为贯穿你整个学习过程的信条。
数据结构和算法书籍及资料推荐
在学习完数据结构初阶课程之后,先刷《剑指offer》上的练习题,如果你的目标是进入大厂,那么还要进行leetcode OJ上做大量的题(难度较高),总而言之,想要学好基础知识和大量的联系缺一不可。
算法的时间复杂度和空间复杂度
算法效率
我们如何衡量一个代码的好坏?以几行简单的代码实现一个复杂的功能,写得少能代表这个代码好,效率高吗?当然不能,这个时候,我们就要知道算法复杂度这一概念。
算法的复杂度
时间复杂度
// 请计算一下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);
}
Func1 执行的基本操作次数 :N^2+2*N+10
- N = 10 F(N) = 130
- N = 100 F(N) = 10210
- N = 1000 F(N) = 1002010
实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要 大概执行次数,那么这 里我们使用大 O 的渐进表示法。
大O的渐进表示法
// 计算Func2的时间复杂度?
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
//总次数=2*N+10 时间复杂度:O(N)
2、
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k)
{
++count;
}
for (int k = 0; k < N ; ++ k)
{
++count;
}
printf("%d\n", count);
}
//总次数=M+N 时间复杂度:O(M+N)【当M>>N时为O(M),当N>>M时为O(N)】
3、
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
//总次数:100 时间复杂度:O(1)
例二:
// 计算strchr的时间复杂度?
const char * strchr ( const char * str, int character );
例二分析:分析代码可知strchr函数功能为在一个字符串中查找给定字符的第一个匹配之处,N个字符构成的字符串最坏情况下要查找N次才能找到给定字符,因此时间复杂度为O(N).
//计算BubbleSort的时间复杂度?
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;
}
}
例三分析:解读代码可知这是对一个数列进行冒泡排序的函数,N个数进行冒泡排序,最坏情况下一共需要走N-1趟,每一趟需要进行N-1次,总次数是以N-1为首项,1为尾项,项数是N-1的等差数列求前n项和,根据公式Sn=n(a1+an)/2可得(N^2)/2-N/2,根据复杂度计算的原则可得,O(N^2)
图例:
思考:这个代码中有没有优化部分?答案是有的,当然无论是否优化,它的最坏情况都是没有变的,但当给定的数列本身就是按照从小到大的顺序排列的,即如果在进行第一次冒泡排序的情况下没有任何数发生交换,我们就可以结束排序,这就是例子中加一个exchange 判断的意义,此时代码的最好情况由O(N^2)变成O(N)
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号
while (begin <= end)
{
int mid = begin + ((end-begin)>>1);
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid-1;
else
return mid;
}
return -1;
}
例四分析:解读代码可知这是对一个有序数列进行二分查找的函数,N个数进行二分查找,最坏情况下是不断地折半最终中间只剩1个数,此时能不能找到就可以确定,进行一次二分查找,元素个数就少一半,即除以2,设查找次数为x,则N/(2^x)=1——>x=log2N,即时间复杂度为O(log2N),由于键盘不方便敲出底数2,在一些地方也会简写成O(logN)、O(lgN),我们自己写的时候不这样写,但出现要知道代表的意思,那么有没有可能是其他底数呢?在编程中我们2分用的最多,一般不会出现其它,即使有也要把底数写出来,此时就不存在简写
图例:
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(1 == N)
return 1;
return Fac(N-1)*N;
}
例五分析:解读代码可知这是利用递归求N的阶乘的函数,实际上这里基本操作的次数就是递归的次数,递归次数是N-1,时间复杂度为:O(N)
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
例六分析:解读代码可知这是一个用递归求N的斐波那契数的函数,同样这里基本操作的次数也是递归的次数,我们来画图看看N的斐波那契数究竟要递归多少次
图例:
由图可知求第N个斐波那契数,要递归的次数之和是一个以2^0为首项,2^(N-2)为尾项,公比为2,项数为N-1的等比数列的前n项和,根据等比数列的前n项和公式(或错位相减法)可求得,总次数为2^(N-1)-1,时间复杂度为O(2^N).【注意,算到最后几行,其实后面的会提前到F(2)因此会缺失一些值,但因为N足够大的时候,这些值对最后结果的影响微乎其微,因此就不考虑把他们减掉】
思考:这样求斐波那契数是 最优方式吗?答案是否定的,通过观察上图,我们能看出,利用递归的方式求斐波那契数会进行大量的重复计算,且当N特别大时,计算速度会越来越慢,甚至会导致程序崩溃,原因是指数函数的增长速度极快,因此根据斐波那契数的特点我们可以用迭代的方式来优化,具体见以下代码:
long long Fib(size_t N) { if (N < 3) return 1; long long f1 = 1, f2 = 1, f3; for (size_t i = 3; i <= N; ++i) { f3 = f2 + f1; f1 = f2; f2 = f3; } return f3; }
优化后的时间复杂度变为O(N)
空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时(额外)占用存储空间大小的量度 。 空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。 注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
例一:
//计算BubbleSort的空间复杂度?
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;
}
}
例一分析:在这个冒泡排序的函数中,只创建了end、i、exchange三个变量,属于常数阶,因此空间复杂度为:O(1)。这里要注意,不是循环多少次就创建多少次变量,以end为例,只创建一次,但赋值会随着每次循环发生改变,再如exchange,虽然每次循环都会重新创建一次变量,但由于上一次结束之后,原来的空间就被还给操作系统,因此再一次进入循环后会向操作系统同一位置再次申请这块空间的使用权,这里明确【时间是不断积累的,但空间是可以反复利用的,申请空间相当于租房,释放空间相当于退房】
// 计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{
if(n==0)
return NULL;
long long * fibArray = (long long *)malloc((n+1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n ; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray [i - 2];
}
return fibArray;
}
例二分析:动态开辟了N个空间,空间复杂度为 O(N)。
例三:
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
例三分析:递归调用了N次,开辟了N+1个栈帧,每个栈帧使用了常数个空间。空间复杂度为O(N)
// 计算斐波那契递归Fib的空间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
例四分析:我们前面再分析其时间复杂度的时候画过图,又有提过空间是可以重复利用的,那么计算额外开辟的栈帧其实只要计算调用次数最深的那一层即可,这一层调用完后返回这块空间就会释放,后续的调用用的还是这些空间,由图分析可知,调用最深的的那一层调用了N+1次,因此空间复杂度为O(N)。
例五:
#include <stdio.h>
#include <stdlib.h>
void f1(int n)
{
int a = 0;
int* p = (int*)malloc(4 * n);
if(p == NULL)
{
perror("malloc fail");
return;
}
free(p);
}
int main()
{
f1(10);
f1(10);
return 0;
}
例五分析:两次调用其实用的是同一块栈帧空间,第一次调用,创建2个变量和堆上的n个变量,调用完成后都会释放相应的空间,第二次调用,用的还是这块空间,,即N+2,因此空间复杂度为:O(N)。为证明上述过程,我们把两次调用创建的变量的地址打印出来如下图:
由此可见,两次调用利用的是同一块栈帧空间。
常见复杂度对比
![](https://i-blog.csdnimg.cn/blog_migrate/547b818a21c9fd4c0361bfa6b3a5459e.png)
*一般情况下当时间复杂度达到平方阶算法效率就比较低了,空间复杂度一般是常数阶和线性阶,很少达到。
复杂度难题练习-----轮转数组:请给出尽可能多的解决方案,并且分析时间复杂度和空间复杂度。
具体解决方案可以参考此链接:旋转数组 - 轮转数组 - 力扣(LeetCode)https://leetcode.cn/problems/rotate-array/solution/xuan-zhuan-shu-zu-by-leetcode-solution-nipk/