• 算法效率的计算


    一、如何衡量一个算法的好坏?

    如果一个算法运行速度快且消耗内存少,那么该算法一定是一个效率高的好算法。那么如何计算一个算法的速度和消耗的内存呢?通常我们使用时间复杂度和空间复杂度来衡量一个算法的好坏,并且使用大O表示法来表示。

    二、时间复杂度

    1. 时间复杂度的计算方法

    一、确定基本操作
    首先,明确算法中的基本操作。基本操作通常是指算法中执行次数最多或者对时间影响最大的操作。例如,在一个循环中,循环体的执行次数通常是基本操作;在排序算法中,比较操作和交换操作可能是基本操作。

    二、分析基本操作的执行次数与问题规模的关系

    1. 用问题规模的变量来表示基本操作的执行次数。例如,如果算法是对一个长度为 n 的数组进行操作,那么问题规模通常用 n 表示。
    2. 分析算法的结构,确定基本操作的执行次数与问题规模之间的关系。这可能需要对算法进行逐步分析,考虑不同的情况和分支。

    三、用大 O 记号表示时间复杂度

    1. 忽略低阶项和系数。在表示时间复杂度时,只关注最高阶项,因为当问题规模足够大时,低阶项和系数对时间的影响相对较小。
    2. 根据基本操作的执行次数与问题规模的关系,常见的时间复杂度级别有常数阶 O (1)、对数阶 O (log n)、线性阶 O (n)、线性对数阶 O (n log n)、平方阶 O (n²) 等。

    用作者的话来说,就是有循环就计算整个算法中循环语句的执行次数,没有循环就计算整个算法所执行的所有语句数。然后用问题的规模的变量把这个计算结果表示出来,最后使用大O表示法去掉最高项的系数,只保留最高项的阶数,结果就是该算法的时间复杂的大O表示法。如果该算法中有函数调用或者递归也是同样的计算方法。

    2. 时间复杂度习题

    例题1

    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;
    	}

    首先,有循环就计算循环语句的执行次数,n2 + 2n + 10
    其次,从该函数的参数判断该算法的规模为 n
    接着,使用规模变量表示前面的执行次数,n2 + 2n + 10
    最后,使用大O表示法,去掉最高阶系数,只保留最高阶,O(n2)

    例题2
    下面是一个计算前 n 项正整数之和的算法:

    // 计算前 n 项和
    long long sum_first_n(int n)
    {
    	int i;
    	long long sum = 0;
    	for (i = 1; i <= n; ++i)
    		sum += i;
    	// 返回
    	return sum;
    }
    

    首先,有循环,那么就计算循环语句的执行次数,n。
    然后,该算法是计算前 n 项正整数的和,规模变量为 n。
    接着用规模变量表示前面计算的执行次数,n。
    最后使用大O表示法,去掉最高阶系数,只保留最高阶,O(n)。

    例题3

    // 计算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
    规模变量表示:M+N
    如果题目说明 M 远大于 N,则 O(M),如果 N 远大于 M,则O(N),如果没有说明则 O(M+N)。

    例题4

    // 计算Func4的时间复杂度?
    void Func4(int N)
    {
    	 int count = 0;
    	 for (int k = 0; k < 100; ++ k)
    	 {
    		 ++count;
    	 }
    	 printf("%d\n", count);
    }
    

    循环语句执行次数:100
    问题规模变量表示:100
    结果为常数,那就是常数阶O(1),不管这个常数多大,只要是一个常数那都是O(1)

    例题5

    // 计算strchr的时间复杂度?
    const char * strchr ( const char * str, int character );
    

    库函数 strchr() 的功能是在一个字符串中查找指定字符,如果找到了就返回该字符的地址,如果没找到就返回空指针。

    那么通过计算,该算法的时间复杂度并不唯一,如果带查找的字符刚好在第一个,那么只需要查找一次,时间复杂度也就是O(1);如果带查找的字符刚好在最后一个,那么需要查找 n 次,时间复杂度也就是O(n)。

    在这种存在最好和最坏的情况的算法中,通常是取最坏的情况。那么该算法的时间复杂度就是 O(n)。

    例题6
    下面是一个优化过后的冒泡排序:

    // 计算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;
    	 }
    }
    

    该冒泡排序算法也存在最好和最坏两种情况:
    (1)当待排序数组完全有序,那么第一轮比较之后就会跳出循环。那么循环中 if 条件语句执行的次数为 n-1,其时间复杂度也就是 O(n)
    (2)当待排序数组完全逆序,那么冒泡排序就会一直执行到结束。那么循环中 if 条件语句的执行次数为 (n-1)+(n-2)+…+1,其时间复杂度为 O(n2)

    例题7
    下面是一个二分查找的算法:

    // 计算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;
    }
    

    二分查找是在 n 个有序的数中查找一个数,而且每次查找都可以去掉一半的数,也就是剩下 n/2 个数。最坏的情况就是查找到最后一个数,也就是 n/2/2/2…/2 = 1,即 2k = n,k = log2n,这个 k 也就是要查找的次数,且每次查找所执行的语句也是常数,所以该算法的时间复杂度就是 O(log2n),也就是上面说的对数阶。

    例题8
    下面是计算正整数 n 的阶乘的递归算法:

    // 计算阶乘递归Fac的时间复杂度?
    long long Fac(size_t N)
    {
     if(0 == N)
     return 1;
     
     return Fac(N-1)*N;
    }
    

    通过计算,该函数加上递归一共需要被调用 N+1 次,每次调用该函数执行常数条语句,所以该算法的时间复杂度为 O(n)

    例题9
    下面是第 n 项斐波那契数列的递归算法:

    // 计算斐波那契递归Fib的时间复杂度?
    long long Fib(size_t N)
    {
    	 if(N < 3)
    	 	return 1;
    	 
    	 return Fib(N-1) + Fib(N-2);
    }
    

    首先,递归调用一次执行常数条语句,那么一共递归调用多少次?
    在这里插入图片描述
    如上图所示,我们把缺失的项设置为 k,那么递归调用的次数为:20 + 21 + 22 +…+ 2n-1 - k = 2n - 1 - k,相比于 2n 来说 1+k 显得微不足道,所以该求斐波那契第 n 项的递归算法的时间复杂度为 O(2n)

    三、空间复杂度

    1. 空间复杂度的计算方法

    有了前面时间复杂度的计算方法,那么空间复杂度的计算就要简单很多了。
    (1)统计创建变量的个数(不管是什么类型)
    (2)用问题的规模变量表示变量的总个数
    (3)大O表示法,去掉最高阶的系数,只保留最高阶

    2. 空间复杂度习题

    习题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;
    	 }
    }
    

    由于在执行算法的过程中只开辟了常数个变量(end、i 和 exchange),所以该算法的空间复杂度为 O(1)

    习题2
    下面是阶乘的递归算法:

    // 计算阶乘递归Fac的空间复杂度?
    long long Fac(size_t N)
    {
    	 if(N == 0)
    	 	return 1;
    	 
    	 return Fac(N-1)*N;
    }
    

    虽然在递归的过程中没有开辟新的变量,但是每次递归都需要开辟栈帧空间,一共递归 n+1 次,开辟了 n+1 个栈帧空间,所以该算法的空间复杂度为 O(n)

    习题3
    下面是第 n 项斐波那契数列的递归算法:

    // 计算斐波那契递归Fib的时间复杂度?
    long long Fib(size_t N)
    {
    	 if(N < 3)
    	 	return 1;
    	 
    	 return Fib(N-1) + Fib(N-2);
    }
    

    前面通过画图的方式的出,该递归需要调用 2n 层级的次数,那么是否需要开辟 2n 层级的函数栈桢?

    答案肯定不是,仔细阅读代码,你会发现,每次递归调用函数 Fib 时,它都会先调用左边的 Fib(N-1),然后再在里面调用他自己的 Fib(N-1),直到最后一次递归时它的 N-1 < 3 时才会返回上一层递归,然后调用该层递归的 Fib(N-2)。如下图:
    在这里插入图片描述
    所以,该算法运行时函数栈帧消耗的最大层数为 n,所以该算法的空间复杂度为 O(n)

    四、常见复杂度对比

    在这里插入图片描述
    在这里插入图片描述
    上述图片均来自比特的课件哈,作者比较懒也不会画。

    五、复杂度oj题

    1. 消失的数字

    题目描述: 数组nums包含从 0 到 n 的所有整数,但其中缺了一个。请编写代码找出那个缺失的整数。你有办法在 O(n) 的时间复杂度内完成吗?

    注意:本题相对书上原题稍作改动

    示例 1:
    输入:[3,0,1]
    输出:2

    示例 2:
    输入:[9,6,4,2,3,5,7,0,1]
    输出:8

    题目OJ链接: 链接: link

    题目解析:
    (1)题目要求时间复杂度需要 <= O(n)
    (2)三种算法:

    a. 计算 0 - n 的和然后减去数组的每个元素,结果就是缺失的数

    // 计算 0 - n 的和,然后减去数组的每个元素
    int find_missing_num(int *arr, int sz)
    {
    	// 计算 0-n 之和
    	int sum = (sz + 1) * sz / 2;
    	// 减去数组的每个元素
    	int i;
    	for (i = 0; i < sz; ++i)
    	{
    		sum -= arr[i];
    	}
    	// 返回
    	return sum;
    }
    

    时间复杂度: O(n)
    空间复杂度: O(1)

    b. 用 0 - n 与数组的每个元素异或,结果就是缺失的数

    int find_missing_num(int* arr, int sz)
    {
    	int result = sz;
    	int i;
    	for (i = 0; i < sz; ++i)
    	{
    		result ^= i ^ arr[i];
    	}
    	// 返回
    	return result;
    }
    

    由于循环是 0 - n-1,所以 result 的初值为 sz。

    时间复杂度: O(n)
    空间复杂度: O(1)

    c. 使用 calloc() 函数动态开辟一个大小为 n+1 的 int 数组,该数组用来统计 0 - n 的出现次数,遍历原数组,记录出现次数,然后遍历次数数组,出现次数为 0 的就是缺失的数。

    int find_missing_num(int* arr, int sz)
    {
    	// 动态开辟次数数组
    	int* times = (int*)calloc((sz + 1), sizeof(int));
    	// 遍历原数组统计次数
    	int i;
    	for (i = 0; i < sz; ++i)
    		++times[arr[i]];
    	// 遍历次数数组找缺失数
    	for (i = 0; i <= sz; ++i)
    	{
    		if (0 == times[i])
    			break;
    	}
    	// 释放
    	free(times);
    	times = NULL;
    	// 返回
    	return i;
    }
    

    时间复杂度: O(n)
    空间复杂度: O(n)

    2. 轮转数组

    题目描述: 给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

    示例 1:
    输入: nums = [1,2,3,4,5,6,7], k = 3
    输出: [5,6,7,1,2,3,4]
    解释:
    向右轮转 1 步: [7,1,2,3,4,5,6]
    向右轮转 2 步: [6,7,1,2,3,4,5]
    向右轮转 3 步: [5,6,7,1,2,3,4]

    示例 2:
    输入:nums = [-1,-100,3,99], k = 2
    输出:[3,99,-1,-100]
    解释:
    向右轮转 1 步: [99,-1,-100,3]
    向右轮转 2 步: [3,99,-1,-100]

    提示:
    1 <= nums.length <= 105
    -231 <= nums[i] <= 231 - 1
    0 <= k <= 105

    题目OJ链接: 链接: link

    题目解析:
    (1)像这种旋转数组或者字符串的问题,不管是右旋转还是坐旋转,当旋转次数为其长度的整数倍时,就会复原。也就是旋转的周期是其长度,如:1,2,3,4,右旋转或者左旋转 4n 次后复原。所以,实际的旋转次数需要对其长度取余。

    (2)三种解法:

    a. 暴力求解法
    就是直接一步一步旋转,没有任何技巧可言

    // 暴力求解法
    void rotate_arr_i(int* arr, int sz, int k)
    {
    	// 实际旋转次数
    	int n = k % sz;
    	// 旋转
    	while (n--)
    	{
    		// 存储尾元素
    		int tmp = arr[sz - 1];
    		// 前面元素往后移
    		int i;
    		for (i = sz - 1; i > 0; ++i)
    			arr[i] = arr[i - 1];
    		// 尾元素放开头
    		arr[0] = tmp;
    	}
    }
    

    时间复杂度: O(n2)
    空间复杂度: O(1)

    b. 三步逆序法(三种里面最优)
    先计算实际旋转次数 k,然后把后 k 项逆序,再把前 sz-k 项逆序,最后整个数组逆序,就是想要的结果。

    // 三步逆序法
    
    // 逆序
    void reverse(int* left, int* right)
    {
    	while (left < right)
    	{
    		// 交换
    		int tmp = *left;
    		*left = *right;
    		*right = tmp;
    		// 下一组
    		++left;
    		--right;
    	}
    }
    
    // 三步
    void rotate_arr_i(int* arr, int sz, int k)
    {
    	// 实际旋转次数
    	k %= sz;
    	// 三步逆序
    	reverse(arr + sz - k, arr + sz - 1);
    	reverse(arr, arr + sz - k - 1);
    	reverse(arr, arr + sz - 1);
    }
    

    时间复杂度: O(n)
    空间复杂度: O(1)

    c. 额外开辟数组法
    这是一个空间换时间的方法,额外开辟一个大小相同的动态数组,然后算出实际旋转次数 k,把后 k 项放在新数组前面,再把前 sz-k 项放在新数组后面,最后把新数组拷贝到原数组。

    // 额外开辟数组法
    void rotate_arr_i(int* arr, int sz, int k)
    {
    	// 实际旋转次数
    	k %= sz;
    	// 开辟额外数组
    	int* tmp = (int*)malloc(sizeof(int) * sz);
    	// 把原数组旋转后的顺序拷贝到新数组
    	int i;
    	for (i = 0; i < k; ++i)
    		tmp[i] = arr[sz - k + i];
    
    	for (i = 0; i < sz - k; ++i)
    		tmp[k - 1 + i] = arr[i];
    	// 把新数组拷贝到原数组
    	for (i = 0; i < sz; ++i)
    		arr[i] = tmp[i];
    
    	// 释放额外数组
    	free(tmp);
    }
    

    时间复杂度: O(n)
    空间复杂度: O(n)

  • 相关阅读:
    ROM修改进阶教程------如何去除安卓机型系统的开机向导 几种操作步骤解析
    网络库OKHTTP(3)拦截器扩展,一个好用的网络请求监控工具Chuck
    数字孪生创新计划
    qt调用python脚本中的函数
    clion(学习记录)
    tensorrt安装使用教程
    antd-table同一行内容不同列数据进行大小对比
    更改主机名的方法(永久)
    单关系查询到自然链接,再到joinon
    Iphone文件传到电脑用什么软件,看这里
  • 原文地址:https://blog.csdn.net/weixin_70742989/article/details/143371928