目录
1.认识数据结构
什么是数据结构
逻辑结构
物理结构
常见的数据结构
2.认识算法
什么是算法
如何衡量算法效率
时间复杂度
什么是时间复杂度
如何计算时间复杂度
大O渐进表示法
常见时间复杂度计算例子
空间复杂度
什么是空间复杂度
如何计算空间复杂度
常见空间复杂度计算例子
1.认识数据结构
对于数据结构的认识,讲解的都是一些概念,目前只需要有大概的了解即可,读者不用过度纠结,随着学习的深入,对于数据结构的理解自然会更上一层楼。
什么是数据结构
计算机在被发明之初,其目的就是用来处理数据的,而且要处理的数据通常不是一个一个的,而是多个,有多个数据要被处理,计算机就需要将这多个数据组织并存储起来,以便高效地使用。那么,计算机组织并存储数据的方式就显得尤为重要了。于是,数据结构这门学科便诞生了。所以,数据结构是计算机组织、存储数据的方式。
而计算机组织、存储数据的方式通常涉及到两个方面 —— 逻辑结构、物理结构。
逻辑结构
逻辑结构描述的是数据元素之间的逻辑关系,即数据元素之间的关联方式。比如:一对一、一对多、多对多。
常见的逻辑结构:
线性结构:数据元素之间是一对一的关系,如数组、链表、栈和队列等。
树形结构:数据元素之间存在一对多的关系,如二叉树、平衡树、B树等。
图结构:数据元素之间存在多对多的关系,如有向图、无向图等。
物理结构
物理结构描述数据元素在计算机中的存储方式,即数据在计算机内存中的表示和布局。物理结构也称为存储结构。
常见的物理结构:
顺序存储结构:数据元素在内存中是连续存储的,如数组。
链式存储结构:数据元素通过指针(或引用)来链接,如链表。
散列存储结构:通过散列函数将数据元素映射到特定的存储位置,如哈希表。
常见的数据结构
数组:固定大小的线性结构,支持随机访问。
链表:由节点组成,每个节点包含数据和指向下一个节点的指针。
栈:后进先出的线性结构,支持入栈和出栈操作。
队列:先进先出的线性结构,支持入队和出队操作。
树:具有层次关系的非线性结构,常用于表示分类和层次关系。
图:由节点和边组成,用于表示复杂的关系网络。
哈希表:基于散列函数实现的高效查找结构,支持快速插入、删除和查找操作。
2.认识算法
什么是算法
所谓算法,其实就是定义良好的计算过程,取一个或一组的值为输入,并产生出一个或一组值作为输出。
简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。
如何衡量算法效率
我们已经知道了算法其实就是一系列的计算步骤,也就是解决问题的方法,既然是方法,就有好的方法和坏的方法,那么如何衡量算法的好坏呢?
当算法被实现出来之后,其实就是程序,程序在运行的时候需要消耗时间资源和空间资源,因此,衡量一个算法的好坏是从时间和空间两个方面来衡量的,也就是时间复杂度和空间复杂度。
时间复杂度主要用来衡量一个算法的运行快慢。
空间复杂度主要用来衡量一个算法运行所需要的额外空间。
时间复杂度
什么是时间复杂度
一个算法所花费的时间与其中语句的执行次数成正比,但是算法中的语句往往比较多,所以,我们选择算法中的基本操作的执行次数,为算法的时间复杂度。
如何计算时间复杂度
找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
我们可以计算一下给func函数传递不同的值时,++count语句共执行了多少次?
void func(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;
}
}
func执行的基本操作次数:func(n) = n^2 + 2*n + 10
n = 10 func(10) = 130
n = 100 func(100) = 10210
n = 1000 func(1000) = 1002010
在实际计算时间复杂度时,我们并不一定要计算精确的执行次数,只需要计算大概的执行次数,也就是抓大头,取决定性结果的哪一项;这时,就需要使用大O渐进表示法了。
大O渐进表示法
大O符号:是用于描述函数渐进行为的数学符号。
推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。使用大O渐进表示法之后,上面的func函数的时间复杂度为O(n^2)。
通过上述过程我们发现大O渐进表示法其实就是去掉了对结果影响不大的项,简洁明了的表示出了执行次数。
常见时间复杂度计算例子
例一:时间复杂度为O(n)
void func2(int n)
{
int count = 0;
for (int k = 0; k < 2 * n ; ++k)
{
++count;
}
int m = 10;
while (m--)
{
++count;
}
}
该函数中的基本语句是 ++count,如果我们精确计算基本语句的执行次数,则执行次数为2n+10;使用大O渐进表示法表示出的时间复杂度为O(n)。
例二:时间复杂度为O(m+n)
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;
}
}
在该函数中,由于我们并不清楚m和n的具体情况,所以我们并不能确定m和n谁才是大头,所以,使用大O渐进表示法表示的时候,需要将二者相加。
如果:
- m 等于 n,时间复杂度为O(m) 或 O(n)。
- m 远大于 n,时间复杂度为O(m)。
- m 远小于 n,时间复杂度为O(n)。
例三:时间复杂度为O(1)
void func4()
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
}
基本语句++count 共执行100次,根据大O渐进表示法,用常数1取代运行时间中的所有加法常数,表示出来的时间复杂度为O(1)。
注意:O(1)不是一次,而是常数次。
例四:时间复杂度为O(N);
const char* strchr(const char* str, int character)
{
while(*str)
{
if(*str == character)
{
return str;
}
++str;
}
return NULL;
}
该函数的算法思想是从前往后依次遍历:
最好情况:如果我们查找的字符是靠前的字符,查找的时间复杂度为O(1)。
平均情况:如果我们查找的字符是中间的字符,查找的时间复杂度为O(N/2)。
最坏情况:如果我们查找的字符是靠后的字符,查找的时间复杂度为O(N)。
对于这种具有最好情况、平均情况、最坏情况的算法,在实际中,一般关注的是最坏情况,所以该算法的时间复杂度为O(N)。可以看出,时间复杂度的计算是一种保守的估计。
例五:时间复杂度为O(N^2)
void BubbleSort(int* arr, int n)
{
for (size_t end = n; end > 0; --end)
{
int flag = 0;
for (size_t i = 1; i < end; ++i)
{
if (arr[i-1] > arr[i])
{
Swap(&arr[i-1], &arr[i]);
flag = 1;
}
}
if (flag == 0)
break;
}
}
冒泡排序的思想是进行多趟两两比较, 当两个数的位置不符合预期就会进行交换,每趟都能将最后一个不正确的值放在正确的位置,比较的次数依次为:n-1、n-2 …… 3、2、1、0。
根据大O渐进表示法表示出来之后就是O(N^2)
例六:时间复杂度为O(logN)
int BinarySearch(int* arr, int n, int x)
{
int begin = 0;
int end = n-1;
while (begin <= end)
{
int mid = begin + ((end-begin)>>1);
if (arr[mid] < x)
begin = mid+1;
else if (arr[mid] > x)
end = mid-1;
else
return mid;
}
return -1;
}
二分查找的思想是在有序空间上查找,每次和中间值作比较,每次都能排除一半的值,查找的效率非常高。同样,二分查找也具有最好、平均、最坏情况,我们只考虑最坏情况。
当查找的区间缩放只剩一个值的时候,此时就是最坏情况。最坏情况下,除了多少次2,就查找了多少次,假设查找了x次,2^x = N,x = logN(以2为底)。因此,二分查找的时间复杂度是O(logN)。
注意:一般logN(以2为底)可以简写成logN,以其他的数字为底的都要显示的写出,不可省略底数。
例七:时间复杂度为O(N)
long long fac(size_t n)
{
if(0 == n)
return 1;
return fac(n-1)*n;
}
变形:时间复杂度为O(N^2)
long long fac(size_t n)
{
if(0 == n)
return 1;
for(int i = 0; i < n; ++i)
{
printf("%d ",i);
}
return fac(n-1)*n;
}
递归算法是自己调用自己,这意味着当前函数会被执行多次,所以,递归函数的时间复杂度是函数被调用的次数与单次函数的时间复杂度之积。
总结:递归算法时间复杂度是多次调用的次数累加。
例八:时间复杂度为O(2^N)
long long fib(size_t n)
{
if(n < 3)
return 1;
return fib(n-1) + fib(n-2);
}
与上面的递归不同,该递归函数为双路递归,我们可以简单地画画递归展开图
我们发现,下一层调用的次数是上一层的2倍,符合等比数列的性质,我们可以根据等比数列的求和公式求出大概的时间复杂度,使用大O渐进表示法表示之后,时间复杂度为O(2^N)。
空间复杂度
谈完时间复杂度,下面我们谈谈空间复杂度。
什么是空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时额外占用存储空间大小的度量。
我们可以这样理解,解决同一个问题,可能有不同的算法, 但是不同的算法都不能避免解决该问题本身所需要的存储空间。比如排序问题,数据元素本身就要占用一定的内存空间,这是使用任何算法都不能避免的,而不同的算法在解决该问题时,所需要的额外的、临时的空间就是我们要计算的空间复杂度。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
如何计算空间复杂度
空间复杂度的计算和时间复杂度是差不多的,我们并不需要计算精确的空间大小,只需要计算大概额外使用的变量的个数即可。同样使用大O渐进表示法来表示。
复习一下大O渐进表示法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。
常见空间复杂度计算例子
例一:空间复杂度为O(1)
void BubbleSort(int* arr, int n)
{
for (size_t end = n; end > 0; --end)
{
int flag = 0;
for (size_t i = 1; i < end; ++i)
{
if (arr[i-1] > arr[i])
{
Swap(&arr[i-1], &arr[i]);
flag = 1;
}
}
if (flag == 0)
break;
}
}
在冒泡排序算法中,数组arr的空间是固定的,使用任何算法都不能避免的。在算法运行过程中,使用了end、flag变量,额外临时使用的空间是常数个,所以空间复杂度是O(1)。
例二:空间复杂度为O(N)
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;
}
该算法在运行过程中动态开辟了n+1个空间,使用大O渐进表示法表示出来的空间复杂度为O(N)。
例三:空间复杂度为O(N)
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
该函数递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间,空间复杂度为O(N)。
递归函数的空间复杂度的计算主要关注函数被调用的次数和每次调用所开辟的额外空间即可。