目录
从本篇博客开始,我们从C语言阶段的学习转入数据结构的学习,数据结构是一门十分重要的学科,无论将来从事前端开发工作还是后端开发还是其他有关的岗位面试的时候都要考察数据结构,
今天我们就从最简单的数据结构的基础概念入手,介绍一些常在数据结构中出现的术语。
1.怎么评估一个算法的效率
2.什么是时间复杂度
3.时间复杂度的大O表示法
4.二分查找的一些小细节
一.怎么评估算法的效率
我们知道计算机的天职就是计算,那么算法就是在有限的步骤中解决实际问题的计算方法,我们学过的冒泡排序,二分查找都是算法,那么怎么来评估一个算法的好坏呢?答案是通过算法的效率来评估。我们可以通过一个实际的案例来进行分析:
案例:计算1-100的自然数的和
方法1:从1加到100依次计算
方法2:使用等差数列求和公式:(100*(100+1))/2
很明显,方法2只用了1次 就解决了这个问题,我们显然可以看出这个算法效率更高!这是我们通过肉眼观察得出来的结论。那么有没有一个指标来衡量这个算法的效率呢?有!这就是我们接下来要介绍的----->时间复杂度!
2.什么是时间复杂度
说实话,我觉得时间复杂度这个名字起的不太好(会让人误解),时间复杂度的概念是算法中根据问题实际规模而进行运算次数的一个估算!也就是说时间复杂度真正计算的是计算的步骤次数的约数,而并不是程序运行时间的估算!这一点很重要!
3.时间复杂度的大O表示法
时间复杂度的计算方式非常简单:计算出实际运算次数的数学表达式就可以得到时间复杂度
比如这样的一段代码:
void fun(int n)
{
int count = 0;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; ++j)
{
count++;
}
}
for (int i = 0; i < 2*n; i++)
{
count++;
}
int M = 10;
while (M--)
{
++count;
}
}
我们来分析以下这样一段代码的实际的运算次数:首先这个外层循环每进行1次,内部的count就会进行n次的自增,就是说这个嵌套循环执行完了以后,最终count自增了n*n次,那么接下来这个独立的for循环又进行了2n次的递增运算,最后定义了一个值为10的变量M,最后在这个while循环中又执行了10次,所以最终整个函数的执行次数的数学函数的表达式为:
F(n)=n*n+2*n+10
得到了这个数学表达式,我们就可以知道这个算法的时间复杂度,我们知道这个函数表达式的结果是随着n的变化而变化的,我们不妨取n=10,100,1000代入这个函数表达式来看具体的执行结果:
n=10,F(10)=130;
n=100,F(100)=10210;
n=1000,F(1000)=1002010;
我们能够直观的发现,随着n的增大,n*n的数据增幅量远远大于2n和10,也就是说当n趋于无限大的时候,对运算次数影响最大的就是n*n,而时间复杂度衡量的就是对算法执行次数影响最大的项,我们通常使用大O的渐进表示法来表示时间复杂度,以这个算法为例,这个算法的时间复杂度就是O(n^2),括号里填充的就是对整个算法执行次数影响最大的项的量级!
对于这个大O的渐进表达法的规则:
1.用常数1来表示运行时间执行的次数的加法常数
2.在修改后的运行次数函数,只保留最高阶项
3.如果最高阶项的次数不是1,那么忽略掉这个常数,最终的得到的就是大O阶
从这里我们就可以看出,大O的渐进表示法只关注和问题规模影响算法执行次数最大的那个项,去掉了对实际结果影响不大的项,是一种简洁明了的表达方式!
不过在一些特定的场景里,同样一个算法的时间复杂度也有可能不同,这个算法存在最好、平均、最坏这三种情况:
最坏情况:任意输入规模时算法执行次数最多---->执行次数的上界
平均情况:任意输入规模时预期算法执行次数---->期望的次数
最好情况:任意输入规模的最小运行次数---->执行次数的下界
案例:在一个长度为N的数组里,寻找一个值为x的元素
最好情况:1次找到
最坏情况:N次找到(甚至可能找不到)
平均情况:找了N/2次
在真实的开发环境中,我们研究的时间复杂度都是最坏的情况,换言之,我们对时间复杂度的估计都是一种悲观的情况,即我们用最糟糕的情况下来分析算法的执行次数!
我们接下来计算几个算法的复杂度:
//案例1
void func1(int N)
{
int count = 0;
for (int i = 0; i < 2 * N; ++i)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
通过对算法的分析,我们可以得知算法执行次数函数F(N)=2*N+10;
根据大O渐进表示法的规则可以得知,这个算法的时间复杂度时O(N)
//案例2
void func2(int M, int N)
{
int count = 0;
for (int i = 0; i < M; ++i)
{
++count;
}
for (int j = 0; j < N; ++j)
{
++count;
}
}
通过分析我们可以得知,执行次数为M+N,但是由于我们没法判断M和N两者的量级大小,所以这两个变量我们都要保留,即这个算法的时间复杂度为O(M+N)
//案例3
void func3(int N)
{
int count = 0;
for (int i = 0; i < 100; ++i)
{
count++;
}
}
观察可知,无论N的值是多少,这个算法总是执行100运算,根据大O的渐进表示法,我们可以知道这个算法的时间复杂度是O(1)---->常数次执行都是O(1)
//案例4---->冒泡排序
void Swap(int* pa, int* pb)
{
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
void BubbleSort(int* a, int sz)
{
assert(a);
for (int i = 0; i < sz; i++)
{
int exchange = 0;
for (int j = 0; j < sz - 1 - i; ++j)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
exchange = 1;
}
}
if (0 == exchange)
{
break;
}
}
}
冒泡排序呢就是一种特殊的算法,最好的情况就是数据已经有序,这时候时间复杂度是O(N),而当数据是降序但我们需要排升序的时候,这时候算法的执行次数是n*(n-1)/2,而时间复杂的估计是按照最坏的情况估计,所以冒泡排序的算法的时间复杂度是O(N^2)
//案例5---->计算阶乘的时间复杂度
long long Fac(int N)
{
if (0==N)
return 1;
return Fac(N - 1)*N;
}
要想准确计算递归函数的时间复杂度,那么就要理解函数递归调用的本质----->每一次调用都会创建新的栈帧!那么我们就可以知道函数递归的计算执行次数怎么计算了!
递归函数的调用次数=(函数每次调用运行的次数)*(函数的递归次数)
对于阶乘函数。函数每次调用执行的运算2次运算,函数递归N次,所以这个阶乘算法的时间复杂度是O(N)
//案例6---->Fibonacci数列的时间复杂度
long long Fib(int N)
{
if (N < 3)
{
return 1;
}
return Fib(N - 1) + Fib(N - 2);
}
同样这也是一个递归函数,那么我们就需要画出这个递归函数的递归调用展开图(N=4为例)
这个展开图的结构是我们后面要学习的一种比较复杂的二叉树结构,我们可以看到这个展开图如果补齐的话。下一层的个数都是上一层的两倍,那么总的调用次数就会接近于一个公比为2的等差数列的前n项和,那么根据等差数列求和公式,我们可以得到这样的一个写法的时间复杂度是O(2^n)
//二分查找
int BinarySearch(int* a, int sz, int k)
{
int begin = 0, end = sz - 1;
while (begin <=end)
{
int mid = ((end - begin) >> 1) + begin;
if (a[mid] < k)
{
begin = mid + 1;
}
else if (a[mid] > k)
{
end = mid - 1;
}
else return mid;
}
return -1;
}
那么我们来分析以下这个算法的时间复杂度,首先最优的情况就是只找了1次就找到了最后的结果,这时候的时间复杂度是O(1),但是我们时间复杂度要考虑的就是最坏的情况!而最坏的情况就是整个数组里找不到对应的元素。
设查找的次数为x,根据最坏情况的分析可得 2^x=N----->x=log2 N,因为计算机中表示对数不够方便,所以我们简记为logN。也就是二分查找的时间复杂度是O(logN),但是二分查找的前提是数据需要有序!!!
4.二分查找的一些小细节
曾经有一家不错的公司对它的员工进行了一个小测试,测试的内容是写二分查找算法,结果10个人里有9个人的二分查找写的不是很正确!一些小细节处理不当就会导致二分查找出错!下面我来给大家讲一讲二分查找算法中你没有注意到的小细节
二分查找的两种模式:1.左闭右开
int BinarySearch(int* a, int n, int k)
{
int begin = 0, end = n;
while (begin < end)
{
int mid = ((end - begin) >> 1) + begin;
if (a[mid] < k)
{
begin = mid + 1;
}
else if (a[mid] > k)
{
end = mid;
}
else return mid;
}
return -1;
}
int main()
{
int a[] = { 0,1,2,3,4,5,6,7,8,9,10};
for (int i = 0; i < sizeof(a)/sizeof(int); ++i)
{
printf("%d\n", BinarySearch(a, sizeof(a)/sizeof(int), i));
}
return 0;
}
程序的运行结果如下:
可以看到程序运行出了正确的结果
版本二:左闭右闭
同样程序也运行出了正确的结果!可以看到我们在这两份二分查找中始终保持左闭右开或者是左闭右闭,那么假设我们并没有始终遵循这一个原则会发生什么呢?
int BinarySearch(int* a, int n, int k)
{
int begin = 0, end = n;
while (begin <end)
{
int mid = ((end - begin) >> 1) + begin;
if (a[mid] < k)
{
begin = mid;
}
else if (a[mid] > k)
{
end = mid ;
}
else return mid;
}
return -1;
}
int main()
{
int a[] = { 0,1,2,3,4,5,6,7,8,9,11};
for (int i = 0; i < sizeof(a)/sizeof(int); ++i)
{
printf("%d\n", BinarySearch(a, sizeof(a)/sizeof(int), i));
}
return 0;
}
程序的运行结果如下:
我们发现程序卡在这里,说明程序出现了死循环!!!!为什么会出现死循环呢?我们来分析以下这个问题
我们不妨分析以下程序执行到元素11的时候begin和end的情况
开始: begin=0 ,end=10,mid=5 a[5]<11 begin=mid=5
第二轮:begin=5 end=10,mid=7 a[7]<11 begin=mid=7
第三轮:begin=7 end=10,mid=8 a[8]<11 begin=mid=8
第三轮:begin=8 end=10,mid=9 a[9]=9<11 begin=mid=9
第四轮:begin=9 end=10 mid=9 a[9]<11 begin=mid=9---->死循环
究其根本原因就是程序在最后寻找元素的时候出现了重复,导致mid无法准确更新从而导致死循环
再来看代码二:
int BinarySearch(int* a, int n, int k)
{
int begin = 0, end = n;
while (begin <end)
{
int mid = ((end - begin) >> 1) + begin;
if (a[mid] < k)
{
begin = mid+1;
}
else if (a[mid] > k)
{
end = mid-1;
}
else return mid;
}
return -1;
}
int main()
{
int a[] = { 0,1,2,3,4,5,6,7,8,9,11};
for (int i = 0; i < sizeof(a)/sizeof(int); ++i)
{
printf("%d\n", BinarySearch(a, sizeof(a)/sizeof(int), i));
}
return 0;
}
程序的运行结果如下:
我们发现有很多元素都没有查找到,这个原因就是原来是左闭右开的区间,右边指向的元素应该是已经查找过的不应该重复查找的元素,而这段代码错把没有查找过的元素当作开区间处理,这就恰恰导致了本应该被查找的元素没被查找!
以查找1为例
[0,1,2,3,4,5,6,7,8,9,11]
第一轮:begin=0,end=10 k=1 mid=5 a[5]>1 end=4
第二轮:begin=0,end=4 k=1 mid=2 a[2]>1 end=1
第三轮:begin=0,end=1 k=1 mid=0 a[0]<1 end=0
第四轮:begin==end 退出循环 返回-1
所以说在写二分查找的时候要记住一个原则:如果左闭右开,那么在循环里更新begin和end也要左闭右开,反之如果是左闭右闭,那么begin和end也要保持左闭右闭否则就会出现意想不到的错误!