算法复杂度
算法复杂度:把数据存储在数据结构中必须应用一部分算法的思想,所以数据结构与算法不分家,讲数据结构就是在讲算法,讲算法离不开数据结构,算法是借助复杂度去评估算法的好与坏。
初阶数据结构阶段学习顺序表、链表、栈、队列、二叉树5种数据结构以及常见排序算法。初阶数据结构中我们将继续使用C语言来实现基础的数据结构,在掌握数据结构的同时巩固了刚结束的C语法知识。图、哈希表、红黑树等数据结构将在C++中学习。
一、数据结构前言
1、数据结构(DS)
数据结构(Data Structure) 是计算机存储、组织数据方式,指相互之间存在一种或多种特定关系的数据元素的集合。没有一种单一的数据结构对所有用途都有用,所以我们要学各式各样的数据结构,如:线性表、树、图、哈希等。
数据结构类似一个盒子可以存储数据,但是也不能只存储数据,也可以往外拿数据。比如,你有一个存钱罐,你可以往里面放钱也可以往外拿钱。所以一个盒子即能存数据也能取数据就是所谓的数据结构。
存储数据、取数据统一叫作组织数据 。常见的组织数据的方式是增删查改 ,比如数组可以完成增删查改,那么数组就是一种最基本的数据结构。既然数组就可以组织数据,那么为什么要学习那么多数据结构?这是因为一个数组只能存储同类型的数据,若想存储不同类型的数据,首先想到的就是结构体,但是结构体只是表示有这一个结构,实际不是一个有效的容器来存储数据。所以我们需要针对不同的操作场景选择不同的高效的数据结构------没有一种单一的数据结构对所有用途都有用。
当考虑数组组织数据时它的效率如何?一道题有不同的解决方法,哪种算法更好就要考虑各自算法的效率如何。数据结构能存储数据并且组织数据,对数据进行存储和组织就离不开算法。
2、算法(Algorithm)
算法(Algorithm) :就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成符合条件的输出结果。
例如:冒泡排序,输入就是乱序的数组,输出就是有序的数组。将乱序的数组变为有序的数组需要编写代码,不同的人有不同的思路,可以将算法理解为思路/解决办法。哪一种算法更好?------通过算法效率分析。
二、算法效率
如何衡量一个算法的好坏呢?
案例:轮转数组
思路:循环k次将数组所有元素向后移动一位。
向右轮转1次:i从下标n - 1开始,先用tmp存储最后一个值,怕被覆盖,i - 1的值赋值给i,当i == 0跳出循环,tmp赋值给下标0对应的数。
再把向右轮转1次的过程重复k次即可。

代码自测运行是没有问题的,但是提交之后就有问题了,这就涉及到算法的好与坏了:

怎么衡量算法的好与坏呢?有没有一种方式在提出算法思想之后就知道算法的好与坏呢?------那就要探讨复杂度了。这个算法题暂且告一段落。
1、复杂度的概念
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
摩尔定律:内存是由很多的晶体管组成的,每隔大约两年,晶体管的数量大约会增1倍,也就是内存会不断地增加,同时导致其价格越来越低。所以这就导致我们对空间的关注度不是很高,但不是说空间很多且又很便宜我们就可以随意浪费。
三、时间复杂度
定义:在计算机科学中,算法的时间复杂度是一个函数式T(N),而不是具体的运行时间数字,它定量描述了该算法的运行时间。
函数式。例如,f(x) = ax + b,函数的最终结果由x决定,a、b是常数不会影响f(x)的实际结果。说明算法的时间复杂度函数式T(N)(T:Time)中有一个会影响算法的好与坏的算法输入变量N。比如刚刚的算法题,有3个输入,所以这3个输入都可以看作是"影响算法的好与坏的算法输入变量",变量k越大循环次数越多;数组及其数组个数变量,数组个数越多,循环次数也越多。说明"输入"都会影响程序的运行时间。
时间复杂度是衡量程序的时间效率,那么为什么不去计算程序的具体运行时间而是要通过函数式呢?
- 因为程序的运行时间与编译环境和运行机器的配置都有关系,比如同一个算法程序,用一个老编译器进行编译和新编译器编译,在同样机器下运行时间不同;同一个算法程序,用一个老低配置机器和新高配置机器,运行时间也不同。
- 并且时间只能在程序写好后测试,不能在写程序前通过理论思想计算评估。
比如计算程序的具体运行时间:
c
#include <stdio.h>
#include <time.h>// clock()的头文件
int main()
{
int nums[] = { 1, 2, 3, 4, 5, 6, 7 };
int numsSize = 7;
int k = 100000;
// 记录起始时间
int start = clock();// 单位ms
while (k--)
{
int tmp = nums[numsSize - 1];
for (int i = numsSize - 1; i > 0; --i)
{
nums[i] = nums[i - 1];
}
nums[0] = tmp;
}
// 记录结束时间
int end = clock();
printf("time:%d\n", end - start);
return 0;
}
发现多次的运行结果不一样,具体运行时间到底是0ms还是1ms?所以这不是具体的运行时间数字,也就是说没有办法得到准确的数字。所以算法的时间复杂度是一个函数式。

那么算法的时间复杂度是一个函数式T(N)到底是什么呢?这个T(N)函数式计算了程序的执行次数(一个程序,只要知道了有多少语句以及每个语句的执行次数就可以知道大概的时间复杂度了,就是根据执行次数评估运行时间)。
通过c语言编译链接章节学习,我们知道算法程序被编译后生成二进制指令,程序运行,就是cpu执行这些编译好的指令。
一个程序消耗的时间资源应该与输入的变量和程序本身两者都有关系,如果时间复杂度只研究变量对程序消耗时间资源的影响,那么用什么来表示程序本身对时间的影响?
程序本身里有条件判断语句、有break语句、有变量的定义、有循环语句等。变量的定义相较于循环语句,很明显循环语句的执行次数较多。程序会被编译成二进制指令,二进制指令的数量可以忽略不计,比如变量的定义是2条二进制指令,for循环语句是10条二进制指令,2条、10条指令在计算机里可以忽略不计,因为CPU在1s中可以执行上亿条指令。既然可以不去关注当前语句产生的二进制指令的数量,那就认为每条语句就是一条二进制指令,所以循环语句是一条指令,变量的定义也是一条指令:
c
int count = 0;// 一条指令
for (int i = 0; i < N; ++i)// 一条指令
{}
当前定义变量的时间是计算不出来的,for循环定义变量i的时间也是计算不出来的。每条语句的运行时间 * 执行次数 = 总的执行时间
,但是运行时间是不能确定的,因为跟编译环境和运行机器的配置都有关系,所以就认为每条语句的运行时间大差不差。那么就重点看执行次数
,变量的定义只执行1次,所以循环才是拉开差距的地方。所以计算时间复杂度绝大多数情况下看的是循环,但也不是只看循环,递归算法的时间复杂度那就看的是递归,递归其实也是一种循环。那用什么来表示程序本身?程序本身就是由一条一条语句组成的。
不同语句执行时被编译成个数不同的二进制指令,每一条语句被编译成几条二进制指令是不知道的。假如,下面的int count = 0;
语句被编译成两条二进制指令、for循环嵌套语句被编译成10条二进制指令、最下面for循环语句被编译成20条二进制指令。2条、10条、20条...,各语句指令的多少对于CPU执行这些指令时是没有区别的,因为CPU在1s中内可以执行上亿条指令,被编译成多少条指令对结果是没有影响的,所以不需要针对二进制指令计算,仅需针对一行一行的代码执行次数计算,比如,可以把int count = 0;
看成一条指令计算(只执行一次);for循环,循环N次,代码执行N次,可以看成N条指令计算。再就是CPU的执行速度很快,对变量的单次定义执行1次可以忽略不计,所以看的都是循环语句。
那么我们通过程序代码或者理论思想计算出程序的执行次数的函数式T(N),假设每句指令执行时间基本一样(实际中有差别,但是微乎其微),那么执行次数和运行时间就是等比正相关 ,这样也脱离了具体的编译运行环境。执行次数就可以代表程序时间效率的优劣。比如解决一个问题的算法a程序T(N) = N,算法b程序T(N) = N^2,那么算法a效率一定优于算法b。
(1)、案例
算法的时间复杂度是一个函数式T(N),试着写出下面示例的时间复杂度:
c
// 请计算⼀下Func1中++count语句总共执行了多少次?
void Func1(int N)
{
int count = 0;// 执行1次忽略不计
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;// 执行1次忽略不计
while (M--)
{
++count;
}
}
函数式受输入变量N决定。计算当前的时间复杂度函数式实际上看的是循环语句,变量的单次定义可以忽略不计 。函数式:T(n) = n^2 + 2n + 10(n影响最终的T(n))。时间复杂度衡量的是变量对算法效率结果的影响趋势(也可以说变量对最终时间复杂度的影响趋势:随着n的增加,时间复杂度会发生什么变化),也就是n越大,执行次数越多,时间复杂度越差;n越小,执行次数越少,时间复杂度越好。
给定一个N,n^2对结果的影响最大,因为CPU在1s中之内可以执行上亿次,所以2n + 10对结果的影响不大:

所以,T(n)可以写成T(n) = n^2
。那么可不可以说当前的时间复杂度就是T(n) = n^2
呢?------不是的。实际的时间复杂度是用大O的渐进表示法表示的:本题时间复杂度为O(n^2)
,这里的O就是大O的渐进表示法 。实际中我们计算时间复杂度时,计算的也不是程序的精确的执行次数,精确执行次数计算起来还是很麻烦的(不同的一句程序代码,编译出的指令条数都是不一样的),计算出精确的执行次数意义也不大,因为我们计算时间复杂度只是想比较算法程序的增长量级,也就是当N不断变大时T(N)的差别,上面我们已经看到了当N不断变大时常数和低阶项对结果的影响很小,所以我们只需要计算程序能代表增长量级的大概执行次数(函数式),复杂度的表示通常使用大O的渐进表示法。
理解一下"时间复杂度衡量的是变量对算法效率结果的影响趋势"中的趋势 :就拿时间复杂度为O(n^2)为例,趋势用坐标轴表示。x轴表示变量n,y轴表示最终的时间复杂度。时间复杂度的趋势:随着n的增加,n^2
的趋势会陡一些,时间复杂度也随之增加。

(2)、大O的渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。

(3)、时间复杂度计算示例
示例1
c
// 计算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);
}
函数式:T(n) = 2n + 10,根据推导大O阶规则1,只保留最高阶项,T(n) = 2n;根据推导大O阶规则2,T(n) = n。
所以Func2的时间复杂度是O(n)。
时间复杂度增长趋势:随着n的增加,n相较于n^2
的趋势会较平缓一些,时间复杂度也随之增加。

示例2
c
// 计算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);
}
函数式:T(n) = m + n。T(n)中的n是指代变量 ,并不意味着等号右边只有n是变量,这里有两个变量:m和n。比如说指代同学1为数学课代表,也指代同学2为数学课代表,但是同学1和同学2不是同一个人,只是用数学课代表指代这两位同学。这里的高阶项即是m也是n,没有低阶项,高阶项的系数也是1,所以时间复杂度为O(m + n)。但是这两变量中怎么能确定就没有低阶项呢?往下分析,针对m + n,有3种情况去讨论:
- m == n,时间复杂度:O(n)或者O(m)
- m >> n,时间复杂度:O(m)。(注意:这里不是m > n,若是m > n,时间复杂度就是O(m)或者O(n),同理<的时间复杂度也是O(m)或者O(n),>或<就没有比较的意义)
- m << n,时间复杂度:O(n)
其实默认情况下时间复杂度就是O(m + n),因为不知道哪个变量大。
示例3
c
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
函数式:T(n) = 100,根据推导大O阶规则第3条,T(n) = 1。所以时间复杂度是O(1)。
时间复杂度增长趋势:n怎么变化都始终不会影响到最终的时间效率。不管T(n)多大,始终是一条平滑的直线,没有任何趋势,所以可以用常数1取代所有加法常数。时间复杂度研究的不是具体的时间,而是研究变量对最终时间复杂度的影响趋势。所以这里时间复杂度为O(1)。

示例4:在字符串里面查找一个字符
c
// 计算strchr的时间复杂度?
const char* strchr(const char* str, char character)
{
const char* p_begin = str;
while (*p_begin != character)
{
if (*p_begin == '\0')
return NULL;
p_begin++;
}
return p_begin;
}
时间复杂度跟当前字符串的长度有关系。假设给了一个字符串"abcd......uithjl",长度为n,时间复杂度取决于字符串的长度n。当查找 'a' 字符时,发现就查找一次就行;当查找b字符时,发现就查找两次;假设整个字符串中就最后一个有效字符是l,那么要查找n次。本题的时间复杂度取决于输入变量:字符串的长度以及查找的字符。若查找的字符在字符串的前面的位置,那么时间复杂度基本是常数,也就是O(1);若查找的字符在字符串的后面的位置,不管是n、n - 1、n - 2,时间复杂度都是O(n);若查找的字符在字符串的中间以及附近的位置,只需要遍历n/2次左右,根据推导大O阶规则,时间复杂度为O(n)。
因此,这个时间复杂度分为:
最好情况(类比在字符串前面查找到指定字符):O(1)
最坏情况(类比在字符串后面查找到指定字符):O(n)
平均情况(类比在字符串中间查找到指定字符):O(n)
发现这个时间复杂度没办法立马给出来,它是要根据情况去定的,所以像这样,时间复杂度要分为3种情况:最好情况、最坏情况、平均情况。
但是一般算法的好与坏我们看的一定是最坏的情况。 所以本题时间复杂度是O(n)。

示例5
c
// 计算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;
}
}
冒泡排序有循环的嵌套,循环嵌套的时间复杂度一定是O(n^2)吗?------不一定。是需要具体分析的。
外层循环定义变量end,有n个数据进行冒泡排序。
第一次循环,end = n,内层循环i < n,数据要比较n - 1次。
第二次循环,end = n - 1,内层循环i < n - 1,数据要比较n - 2次。
...
第n次循环,数据要比较1次。
所以本题的时间复杂度计算的就是n次循环中总的执行次数,总的执行次数T(n) = 1+2+3+...+(n-2)+(n-1)。等差数列求和。

根据推导大O阶规则,T(n) = n^2。 时间复杂度是O(n^2)。
示例6
c
void func5(int n)
{
int cnt = 1;
while (cnt < n)
{
cnt *= 2;
}
}

有时会把底数省略了,写成logn,这种写法在数学中一定是错误的,但是在计算机中是可以这样写的。原因1:没办法用键盘敲出底数。原因2:

所以本题时间复杂度是O(logn)。
示例7
c
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
这里没有循环欸,不要忘了,递归也可以通过循环的方式去写,只是算法写法不一样。所以递归也是一种循环。
计算递归的时间复杂度:
当前函数栈帧中有Fac(N),调用Fac(N),传递变量N。若N不是0,则进行递归调用,调用Fac(N - 1);若N - 1不是0,进行递归,调用Fac(N - 2)...。一直递归到参数为0为止,再向上回溯。
递归算法的时间复杂度,先说结论:递归算法的时间复杂度 = 单次递归的时间复杂度 * 递归次数
不断递归的过程就是循环的过程,每一次递归调用就是一次循环。每递归一次就调用一次函数,创建函数栈帧。单次递归函数时,函数体内没有循环,所以不管是Fac(N-1)、Fac(N-2)、... 、Fac(1)、Fac(0),单次递归的时间复杂度都是O(1)。
那么递归次数
怎么计算呢?注意是递归次数,除了Fac(N)不是递归,是运行后直接调用的,剩下的都是递归调用。Fac(N-1)、Fac(N-2)、... 、Fac(1)、Fac(0)一共是N次递归。所以时间复杂度是O(n)。

四、空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中因为算法的需要额外临时开辟的空间。
空间复杂度不是程序占用了多少bytes的空间,因为常规情况每个对象大小差异不会很大,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
注意:
函数运行时所需要的栈空间(栈空间:函数栈帧空间,函数在调用运行时会去申请的空间。存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时,看函数体里是否有显式申请的额外空间来确定。
空间复杂度计算示例
示例1:冒泡排序的空间复杂度
c
// 计算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等有限个局部变量,使用了常数个额外空间,空间复杂度对应函数表达式F(1),根据推导大O阶规则。因此空间复杂度为 O(1)。
这里有无符号整型变量end、整型变量exchange。计算占用空间大小是通过字节个数,但是函数体内所有变量所占的字节总数并不是空间复杂度。因为对于内存来说,字节单位太小了,所以这里仍然粗略地去估计。就让变量end、exchange是一个一个单位,具体一个单位是多少字节是不需要去关注的。既然变量是一个一个单位,同样的在空间复杂度这里,变量所占空间也可以忽略。
示例2:阶乘递归的空间复杂度
c
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
结论:Fac递归调用了N次,额外开辟了N个函数栈帧,每个栈帧使用了常数个空间,因此空间复杂度为 O(N)。
同理,计算递归的空间复杂度 = 单次递归的空间复杂度 * 递归次数。单次递归就是调用当前Fac这个函数,里面没有显示申请额外的空间,所以使用了常数个空间,所以单次递归的空间复杂度是O(1)。
研究复杂度研究的是增长趋势。N越大,递归次数越多。例如,N为10,递归10次;N为100,递归100次;N为1000,递归1000次。曲线增长趋势:

五、常见复杂度对比
从左到右是复杂度的变化,除了logn,随着输入变量的增加,复杂度的增长趋势逐渐变陡:

增长趋势越陡,复杂度越差,算法越差:

六、复杂度算法题
轮转数组
思路1:循环k次将数组的每一个元素都向后挪动一位
时间复杂度是O(n^2),代码不通过。"超出时间限制":时间复杂度不够好。
空间复杂度是O(1)。
c
void rotate(int* nums, int numsSize, int k) {
while (k--)// 注意是后置++
{
int tmp = nums[numsSize - 1];
for (int i = numsSize - 1; i > 0; --i)
{
nums[i] = nums[i - 1];
}
nums[0] = tmp;
}
}
时间复杂度是O(n^2),用n指代输入条件:k是多少是不知道的,i是根据numsSize确定的,而numsSize也是一个输入变量,具体是多少也是不知道的。
若k = 7,轮转后的结果还是原数组,所以解决k太大的问题:k %= numsSize
。7%7 = 0,这样k就回到下标为0的位置了。

"提交"后还是会有"超出时间限制"的问题。若numsSize是一个亿,k是一个亿减去1,那这里的循环次数还是很多,也就是k接近numsSize时,时间复杂度还是O(n^2)。

思路2:创建一个与原数组nums的空间一样大的数组tmp,将数据挪动到新数组数据轮转后对应的位置,再一一覆盖回原数组
优化:将时间复杂度降到O(n)。
创建一个新数组回额外开一块空间,所以空间复杂度是O(n)。
c
void rotate(int* nums, int numsSize, int k) {
// 创建一个新数组
int tmp[numsSize];
// 遍历原数组,将数据挪动到新数组对应的位置
for (int i = 0; i < numsSize; ++i)
{
tmp[(k + i) % numsSize] = nums[i];
}
// 新数组数据一一覆盖回原数组
for (int i = 0; i < numsSize; ++i)
{
nums[i] = tmp[i];
}
}
思路2与思路1对比,时间复杂度降下来了。为了能突破时间限制会额外开辟空间使代码通过,这本质是一种空间换时间。
思路3:最优解,三次逆置。时间复杂度是O(n),空间复杂度是O(1)
看"最后执行的输入"这里,k比数据个数大,当数组中只有一个数据,k = 2。第一次逆置,right为-2、left为0,进入不了逆置循环代码;第二次逆置,left为-1、right为0,进入逆置循环nums[left]此时是-1位置的数据,取不到的所以报错。

特殊处理:若k > numsSize,k %= numsSize。
完整代码:
c
void reverse(int* nums, int left, int right)
{
while (left < right)
{
int tmp = nums[left];
nums[left] = nums[right];
nums[right] = tmp;
++left;
--right;
}
}
void rotate(int* nums, int numsSize, int k) {
k %= numsSize;
// 第一次逆置,前numsSize - k个数据逆置
reverse(nums, 0, numsSize - k - 1);// 注意传递的是下标
// 第二次逆置,后k个数据逆置
reverse(nums, numsSize - k, numsSize - 1);
// 第三次逆置,整体逆置
reverse(nums, 0, numsSize - 1);
}