数据结构:算法复杂度与空间复杂度
c语言基础概念分享结束,往后会不定期更新c语言刷题以及算法题的文章,感谢大家的支持,自此,我们开始步入新的篇章--数据结构与算法设计。
代码见:登录 - Gitee.com
1.数据结构前言
1.1数据结构
数据结构是计算机存储,组织数据(增删查改)的方式,指相互之间存在一种或多种特定关系的数据元素的集合。没有一种单一的数据结构对所有用途都有用,所以,我们要学习各式各样的数据结构,例如:线性表,树,图,哈希等等。
1.2算法
算法就是定义良好的计算过程,取一个或一组的值为输入,并产生一个或一组值作为输出。简单来说,算法就是一系列的计算步骤,用来将输入数据转化为输出结果。
例如冒泡排序,通过算法将乱序的数组变为有序的数组。而一道算法题也会有多种思路。
1.3数据结构与算法的重要性
数据结构与算法设计是不分家的,而数据结构预算法也是工作面试或考试必考的一项内容,那么想要学好数据结构,就需要不断地代码练习以及不断地画图思考,日复一日才能有所收获。
2.衡量算法的好坏——复杂度
2.1明示算法效率带来的结果
以下面这道题的解答过程作为讲解:
案例:189. 轮转数组 - 力扣(LeetCode)
思路:题目要求将整数数组向右轮转K个位置,且K>=0,我以示例1为例画图。
由此完成代码,并点击运行,发现运行通过:
再试着提交代码,结果却出错了:
这样的结果表明,我们的思路或许正确,但算法不够优秀,在运行时浪费了大量的时间,那需要怎么改善?我们先了解复杂度的概念,并在最后用多种正确的方法完成代码。
2.2复杂度的概念
算法在编写为可执行程序后,运行需要耗费时间资源和空间内存资源。因此,衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储空间很小,所以对空间复杂度很在乎,但经历了如今计算机行业的快速发展,计算机的存储容量已经达到了很高的程度,所以我们如今已经不需要特别关注一个算法的空间复杂度。
2.3复杂度的重要性
好的算法,可以有较少的时间和空间的浪费,效率更高,而且在找工作和考试中,经常会有关于复杂度的题目,如:快排堆排归并时间复杂度等一类题目,所以对于复杂度的的学习要格外重视。
3.时间复杂度
定义:在计算机科学中,算法的时间复杂度是一个函数时T(N),它定量的描述了该算法的运行时间。而时间复杂度是衡量程序的时间效率,那为什么不去计算程序的运行时间呢?
1.因为程序运行时间和编译环境和运行机器的配置都有关系,比如同一个算法程序,用一个老编译器进行编译和新编译器编译,在同样机器下运行时间不同。
2.同一个算法程序,用一个老低配置机器和新高配置机器,运行时间也不同。
3.并且时间只能程序写好后测试,不能写程序前通过理论思想计算评估。
程序的执行时间 = 二进制指令运行时间(假设时间是一定的) * 执行次数
那么算法的时间复杂度是一个函数式T(N)到底是什么呢?这个T(N)函数式计算了程序的执行次数。通过c语言编译链接章节学习,我们知道算法程序被编译后生成二进制指令,程序运行,就是cpu执行这些编译好的指令。那么我们通过程序代码或者理论思想计算出程序的执行次数的函数式T(N),假设每句指令执行时间基本一样(实际中有差别,但是微乎其微),那么执行次数和运行时间就是等比正相关,这样也脱离了具体的编译运行环境。执行次数就可以代表程序时间效率的优劣。比如解决一个问题的算法a程序T(N)=N,算法b程序T(N)=N^2,那么算法a的效率一定优于算法b。
例如:
// 请计算⼀下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; }
}
在此函数中,Func1执行的基本操作次数:
所以,我们只需要计算程序能代表增长量级的大概执行次数,复杂的的表示通常使用大O的渐进表示法。
3.1大O的渐进表示法
大O符号:用于描述函数渐进行为的符号。
推导⼤O阶规则
1. 时间复杂度函数式T(N)中,只保留最⾼阶项,去掉那些低阶项,因为当N不断变⼤时,
低阶项对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
2. 如果最⾼阶项存在且不是1,则去除这个项⽬的常数系数,因为当N不断变⼤,这个系数
对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
3. T(N)中如果没有N相关的项⽬,只有常数项,⽤常数1取代所有加法常数。
通过此方法,可得Func1的时间复杂度为:O(N^2)。
3.2时间复杂度计算案例
3.2.1示例一
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);
}
3.2.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);
}
3.2.3示例3
// 计算Func4的时间复杂度?
void Func4(int N)
{ int count = 0; for (int k = 0; k < 100; ++ k) { ++count; } printf("%d\n", count);
}
此代码:T(N) = 100,根据推到规则第一条得:时间复杂度为:O(1)
3.2.4示例4
// 计算strchr的时间复杂度?
const char * strchr ( const char* str, char character)
{const char* p_begin = s;while (*p_begin != character){if (*p_begin == '\0')return NULL;p_begin++;
}return p_begin;
}
此代码是为查找字符。
总结:
通过上面我们会发现,有些算法的时间复杂度存在最好、平均和最坏情况。
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
大O的渐进表示法在实际中⼀般情况关注的是算法的上界,也就是最坏运行情况。
3.2.5示例5计算此冒泡排序的时间复杂度:
// 计算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; }
}
3.2.6示例6
void func5(int n)
{int cnt = 1;while (cnt < n){cnt *= 2;}
}
注意举例中和书籍中log2n 、logn、lgn 的表示
当n接近无穷大时,底数的大小对结果影响不大。因此,一般情况下不管底数是多少都可以省略不写,即可以表示为logn
不同书籍的表示方式不同,以上写法差别不大,建议使用logn
3.2.7示例7计算递归函数时间复杂度
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{if (0 == N)return 1;return Fac(N - 1) * N;
}
递归算法的时间复杂度 = 递归次数 * 单次递归的时间复杂度
调用一次Fac函数的时间复杂度为O(1),但需要n次递归调用Fac函数
因此,时间复杂度为:O(n)
4.空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中因为算法的需要额外临时开辟的空间。空间复杂度不是程序占用了多少bytes的空间,因为常规情况每个对象大小差异不会很大,所以空间复杂度算的是变量的个数。同样也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数,局部变量,一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时显式申请的额外空间来确定。
4.1空间复杂度计算实例
4.1.1示例1
// 计算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; }
}
函数栈帧在编译期间已经确定好了, 只需要关注函数在运行时额外申请的空间。
BubbleSort额外申请的空间有 exchange,end,i 有限个局部变量,使用了常数个额外空间,因此空间复杂度为O(1)
4.1.2示例2 计算递归空间复杂度
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{ if(N == 0) return 1; return Fac(N-1)*N;
}
Fac递归调用了N次,额外开辟了N个函数栈帧, 每个栈帧使用了常数个空间
因此空间复杂度为:O(N)
5.常见复杂度对比
6.复杂度算法题
6.1旋转数组
回到刚开始时的示例:189. 轮转数组 - 力扣(LeetCode)
6.1.1思路1
第一次代码时间复杂度为O(N^2),空间复杂度为O(1),超出时间限制,所以不通过。
void rotate(int* nums, int numsSize, int k) {while(k--){int end = nums[numsSize-1];for(int i = numsSize - 1;i > 0 ;i--){nums[i] = nums[i-1];}nums[0] = end;}
}
6.1.2思路2
将空间复杂度变为O(N),以空间换时间。
思路:创建一个新数组,且新数组的大小为numsSize,这样,就额外开辟了O(n)大小的辅助空间(其空间占用随原数组元素个数线性增长),然后将后K个数据依次放在新数组中,再将剩余数据依次放入新数组中。
这样,代码就通过了:
6.1.3思路3
上述代码通过空间换时间,那能不能将时间复杂度变为O(n),空间复杂度变为O(1),使代码更优呢?
我们通过三次逆置试试看:
先将前n - k个逆置:4,3,2,1,5,6,7
再将后k个逆置:4,3,2,1,7,6,5
最后将全部逆置:5,6,7,1,2,3,4
新的代码运行没有错误,接下来我提交后,提示错误:
通过最后输入的结果观察发现,输入的是-1,轮转两次。
观察原代码,numsSize为1,则前n - k个逆置中numsSize - 1 - k为-2,转到逆置函数中,left为0,right为-1,不发生交换;再看后k个逆置,left为-1,right为0,发生交换,nums[-1]发生溢出。
也就是说,我们现在要处理的是k大于数组长度这一情况,当k等于数组长度时,数组旋转之后和原数组完全相同,k大于数组长度时,其有效的旋转次数为k取余数组长度。则添加:k = k % numsSize
现在,我的代码正确了,并且满足了此思路的要求:时间复杂度为O(n),空间复杂度为O(1)
本章完。