算法:是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
定义中,提到的指令可以是计算机指令,也可以是我们平时的语言文学。
为了解决某个或某类问题,需要把指令表示成一定的操作序列,操作序列包括一组操作,每一个操作都完成特定的功能,这就是算法。
一、算法的特性
算法的五个基本特性:输入、输出、有穷性、确定性和可行性。
- 算法具有零个或多个输入(使用printf直接输出时,可以不进行输入),但算法至少有一个或多个输出;
- 有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一步骤在可接受的时间内完成;
- 确定性:算法的每一步骤都具有确定的含义,不会出现二义性;
- 可行性:算法的每一步都必须是可行的,也就是说,每一步都能沟通过执行有限次数完成。
二、算法设计的要求
正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性,能正确反映问题的需求,能够得到问题的正确答案。
可读性:算法设计的另一目的是为了便于阅读、理解和交流。
健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。
时间效率高和存储量低:时间效率指的是算法的执行时间。对于同一个问题,如果有多个算法能够解决,执行时间短的算法效率高,执行时间长的效率低。存储量需求指的是算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。设计算法应该尽量满足时间效率高和存储量低的需求。
三、算法效率的度量方法–事后统计方法
事后统计方法:这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
存在缺陷:
- 必须一句算法事先编制好程序,需要花费大量的时间和精力。
- 程序运行的时间依赖计算机硬件和软件等环境因素,有时会掩盖算法本身的优劣。比如在一台四核处理器的计算机,与当年286/386/486等机器相比,在处理算法的运算速度上,是不能相提并论的。
- 算法的测试数据设计困难,并且程序的运行时间往往还有测试数据的规模有很大关系。
此方法缺陷较大,不予采纳,但仍然拿出来介绍,是让大家对其有份认知。
四、算法效率的度量方法–事前分析估算方法
事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算。
现在我们直接引入算法分析方法。
(一)算法效率分析
算法效率分析分为两种:第一种是时间效率,第二种是空间效率。
时间效率又被称为时间复杂度,空间效率被称为空间复杂度。前者用于衡量一个算法的运行速度,后者用于衡量一个算法所需要的额外空间。
(二)通过代码比较了解时间复杂度
我们先不去看这段定义,大略阅读一下即可。
直接进入举例环节,让我们理解更加轻松。
//先从一个简单的代码开始了解。
// 一个加法代码 (从1~100相加)
//
//代码一:
#include <stdio.h>
int main()
{
int sum = 0, n = 100; //执行1次
for (int i = 1; i <= n; i++) //执行n+1次
sum += i;//为观察,for不用花括号 //执行n次
printf("%d",sum); //执行1次
return 0;
}
//代码二:
#include <stdio.h>
int main()
{
int sum = 0,n = 100; //执行1次
sum = (1 + n) * n / 2; //执行1次
printf("%d", sum); //执行1次
return 0;
}
那么代码一程序执行了1+n+1+n+1 = 2n+3次,代码二程序执行了3次。
两端代码的首尾是一致的,不同的是代码一种求和循环所执行的次数与代码二求和的次数差距大。
//代码三
#include <stdio.h>
int main()
{
int i, j, x = 0, sum = 0, n = 100; //执行1次
for (i = 0; i < n; i++)
{
for (j = 0; j < n; j++)
{
++x;
sum += x; //循环中执行n*n次
}
}
printf("%d", sum); //执行1次
return 0;
}
再来次循环发现代码三中的程序执行了 1+n*n+1 = n^1+2次。
在计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数。
这样用大写O()来体现算法时间度的记法,我们称之为大O记法(用于描述函数渐进行为的数学符号)。
(三)推导大O记法:
- 用常数1取代运行时间中的所有加法常数。
- 再修改后的运行次数函数中,只保留最高阶项。
- 如何最高阶项存在且不是1,则去除与这个项目相乘的常数,得到的结果就是大O阶。
因此:代码一的时间复杂度为O(N),代码二的时间复杂度为O(1),代码三的时间复杂度为O(N^2)。
对数阶
//代码四
#include <stdio.h>
int main()
{
int count = 1,n = 1000;
while (count < n)
{
count = count * 2;
//此段时间复杂度为O(1)的程序步骤序列
}
return 0;
}
//由于每次count乘以2之后,就距离n更近一步,也就是说,有多少个2相乘后大于n,则会退出循环。
有2^x = n 得到 x = log2n。所以这个循环的时间复杂度为O(logn)。
(四)时间复杂度
时间复杂度:在计算机科学中,算法的时间复杂度是一个函数,他定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说是不能算出来的,只有把程序放在机器上跑起来,才能知道,但如果每一次都上机测试就很麻烦。
一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
常见的时间复杂度:
执行次数函数 | 阶 | 非正式术语 |
---|---|---|
12 | O(1) | 常数阶 |
2n+3 | O(n) | 线性阶 |
3n2+2n+1 | O(n2) | 平方阶 |
5log2n+20 | O(logn) | 对数阶 |
2n+3nlog2n+19 | O(nlogn) | nlogn阶 |
6n3+2n2+3n+4 | O(n3) | 立方阶 |
2n | O(2n) | 指数阶 |
常用的时间复杂度所耗费的时间从小到大依次是:
O(1) < O(logn) <O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)
算法的时间复杂度存在最好、平均和最坏情况
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况: 1次找到
最坏情况: N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
练习一:
// 计算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);
}
练习二:
// 计算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);
}
练习三:
// 计算Func4的时间复杂度?
void Func4(int N) {
int count = 0;
for (int k = 0; k < 100; ++k) {
++count;
}
printf("%d\n", count);
}
练习四:计算strchr的时间复杂度?
// 计算strchr的时间复杂度?
const char* strchr(const char* str, int character);
练习五:计算BubbleSort的时间复杂度?
// 计算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;
}
}
练习六:计算BinarySearch的时间复杂度?
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x) {
assert(a);
int begin = 0;
int end = n - 1;
while (begin < end) {
int mid = begin + ((end - begin) >> 1);
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid;
else
return mid;
}
return -1;
}
练习七: 计算阶乘递归Factorial的时间复杂度?
// 计算阶乘递归Factorial的时间复杂度?
long long Factorial(size_t N) {
return N < 2 ? N : Factorial(N - 1) * N;
}
练习八:计算斐波那契递归Fibonacci的时间复杂度?
// 计算斐波那契递归Fibonacci的时间复杂度?
long long Fibonacci(size_t N) {
return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2);
}
(五)空间复杂度
空间复杂度:是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大0渐进表示法。
在练习前,我们先了解,空间复杂度和时间复杂度的计算规则是类似的,但时间是一去不复返的,所以只要涉及到它的部分,我们都要计算在内。
#include <stdio.h>
int Add1(int n, int sum)
{
sum = (n + 1) * n / 2;
printf("&Add1=%p\n", &Add1);
return sum;
}
int Add2(int n,int sum)
{
for (int i = 0; i <= n; i++)
{
sum += i;
}
printf("\n&Add2=%p\n", &Add1);
return sum;
}
int main()
{
int n = 100,sum = 0;
int sum1 = Add1(n,sum);
printf("sum1 = %d ",sum1);
int sum2 = Add2(n,sum);
printf("sum2 = %d ", sum2);
return 0;
}
而空间是可以重复利用的,好比,同样的两个在主函数外的函数在调用时:
我们可以看到函数的地址存在出现在同一位置的情况。这是因为函数调用申请空间,在函数调用结束后,归还使用空间。
就好比,我们住酒店时,在我们退房后,房间还会有下一位客人住宿。
因此在计算复杂度时须注意:时间是一去不复返的,所以应都计算完全;空间是可以重复利用的,所以计算时应注意。
练习一:计算BubbleSort的空间复杂度?
// 计算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;
}
}
练习二:计算Fibonacci的空间复杂度?
// 计算Fibonacci的空间复杂度?
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;
}
练习三:计算阶乘递归Factorial的空间复杂度?
// 计算阶乘递归Factorial的空间复杂度?
long long Factorial(size_t N) {
return N < 2 ? N : Factorial(N - 1) * N;
}
五、总结
通常,我们都使用“时间复杂度”来指运行时间的需求,使用“空间复杂度”指空间需求。当不用限定词地使用“复杂度”时,通常都是指时间复杂度。