数据结构学习的心理准备:
数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。
理解:
在我们实际编程应用中,会涉及到不同类型信息的存储、处理,这个时候就需要用不同存储和组织方式的数据来存储和组织这些不同的信息。同时为了满足我们处理数据的需要,数据和数据之间也会存在某些关系,比如说我可以根据当前的这个数据找到与它相关联的上一个或下一个数据,甚至可以找到存储这个数据的整个集合。
算法(Agorithm) :就是定义良好的计算过程,它取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。
理解:
算法就是解决计算问题的方法或步骤。但是算法实际上可以扩展到,解决某个问题的方法或步骤,这些问题不一定是计算问题,也有可能其它类型的问题。(虽然这些问题最终也要转换成可计算的形式)
校园招聘的笔试中:
Online Judge, 20 - 30选择题,3 - 4编程题
校园招聘的面试中 : (字节面试很变态,很严格,可以去尝试)
提问—>回答
数据结构和算法不管是在编程能力提升方面,还是实际找工作的时候遇到的笔试和面试,亦或者是程序员今后一生的职业生涯,都是至关重要的。它是编程人员的基本能力和素养的体现!
①多写代码,多练习,多思考总结(刻意练习!)
②注意画图和思考,单靠脑海想,不一定想的很全。
教材:严蔚敏的《数据结构》、《大话数据结构》
书籍:
《剑指offer》
《程序员代码面试指南》
多做题、多练习
算法效率分析分为两种 : 第一种是时间效率,第二种是空间效率。时间效率被称为时间复杂度,而空间效率被称作空间复杂度。时间复杂度主要衡量的是一个算法的运行速度、而空间复杂度主要衡量一个算法所需要的额外空间,在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展(摩尔定律),计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度(除了嵌入式编程)。
关注并掌握:时间复杂度计算
了解:空间复杂度计算
时间复杂度的定义 : 在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗 ? 是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
时间复杂度不是计算程序运行的时间,运行的时间是不确定的(跟硬件、环境相关)
时间复杂度计算的是程序运行大概的次数,这样可以脱离运行环境进行比较。
先看下面这段代码:
//请计算一下Func1基本操作执行了多少次 ? 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; } printf("%d \n", count); }
Func1的执行次数要将两个for语句的执行次数和一个while语句的执行次数相加:
第一个for语句执行次数为:N* N,这个for循环有两层嵌套,第一层执行N次,第二层也是执行N,所以总共执行N* N = N ^ 2次。
第二个for语句执行次数为:2 *N while语句执行次数为10次
所以Func1执行的基本操作次数为:N ^ 2 + 2 * N + 10
我们给N赋不同量级的值:
N= 10, F(N) = 130
N = 100, F(N) = 10210
N = 1000, F(N) = 1002010
实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数
那么这里我们使用大O的渐进表示法。
大O符号(Big O notation) :是用于描述函数渐进行为的数学符号,类似于微积分中的取极限,n->无穷大。
推导大O阶方法 :
1、用常数1取代运行时间中的所有加法常数
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
使用大O的渐进表示法以后,Func1的时间复杂度为 : O(N ^ 2)
N = 10, F(N) = 100
N = 100, F(N) = 10000
N = 1000, F(N) = 1000000
通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。
另外有些算法的时间复杂度存在最好、平均和最坏情况 :
最坏情况:任意输入规模的最大运行次数(上界)
平均情况 : 任意输入规模的期望运行次数
最好情况 : 任意输入规模的最小运行次数(下界)
例如 : 在一个长度为N数组中搜索一个数据x
最好情况 : 1次找到
最坏情况 : N次找到
平均情况:N / 2找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
计算时间复杂度的具体操作 / 步骤
①找出所有会涉及时间复杂度(执行次数)计算的函数 / 模块
②写出每个函数 / 模块对应的时间复杂度
③大O渐进法表示,取其中最高阶项
2.3常见时间复杂度计算举例
实例一:
//计算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); }
按照我们刚刚说的计算时间复杂度的具体步骤来计算:
①Fun2中涉及到时间复杂度计算的函数 / 模块有两个地方,一个时起始出现的for循环语句模块,另一个是while循环语句模块
②for模块的时间复杂度为2 * N,while模块的时间复杂度是10,总体相加就是2 * N + 10
③用大O渐进表示,取其中最高项:2* N,再忽略其常数项2,得到时间复杂度为O(N)
实例二:
// 计算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); }
①涉及时间复杂度计算的有两个模块,一个是第一个for循环,另一个是第二个for循环
②第一个for循环的时间复杂度是M,第二个for循环的时间复杂度是N
③因为M和N都是未知数,所以整体而言的时间复杂度是O(M + N)
实例三:
//计算Func4的时间复杂度 ? void Func4(int N) { int count = 0; for (int k = 0; k < 100; ++k) { ++count; } printf("%d \n", count); }
①涉及时间复杂度计算的仅有一个,即for循环语句
②for循环语句的时间复杂度为100,常数
③用大O渐进表示法,常数的表示为O(1)
实例四:
//计算strchr的时间复杂度? const char* strchr(const char* str, int character);
说明:strchr是一个库函数,表示在str所指向的字符串中搜索第一次出现字符character(无符号字符)的位置。
这里需要注意的是,函数的调用过程也需要被计算到时间复杂度当中。不要误以为调用一个函数是一条语句,执行这个函数,就是执行这一条语句,执行操作次数就为1。这种想法是错误的,实际上我们需要去考虑函数的具体实现过程以及函数内部的实际执行次数!
①str函数
②str函数执行次数为N(假设有N个字符)
③O(N)
实例五:
//计算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;
}
}
注意:我们在学习时间复杂度的时候,很容易用这样一种误解,看到for循环有两层,就不加思索的认为其时间复杂度为N* N = N ^ 2,实际上我们遇到的情况不一定都是N ^ 2,只有当两层for循环均执行N次的时候才为N ^ 2。
另外,真正的高手在计算时间复杂度的时候,尤其是复杂的情况时,不是根据代码去计算,而是去思考其具体的实现过程来计算的。((图解,脑海中模拟实现的具体过程)
①两层for循环
②执行次数为(n - 1) + (n - 2) + (n - 3) + …… + 1 = n / 2(n - 1) = n ^ 2 / 2 - n / 2 (等差数列求和)
③用大O渐进表示法,为O(N ^ 2)
实例六:
//计算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; }
这个是二分查找时间复杂度的计算,要想正确的计算其时间复杂度
首先我们必须了解二分查找的具体过程:
如果正向思考不好理解,我们也可以试着反向思考:
假设我们有2^k个有序数集,用二分查找法查找某一个数字,最差的情况是查找多少次?因为没查找一次,查找的范围减半,第一次查找完成后还剩 2^k / 2
= 2 ^ (k - 1)…, 最差需要查找k次。
而 k = log22 ^ k—>将2 ^ k用N替代,得到 k = log2N所以时间复杂度为:O(log2N)
实例七:
// 计算阶乘递归Factorial的时间复杂度 ? long long Factorial(size_t N) { return N < 2 ? N : Factorial(N - 1) * N; }
这个地方涉及到递归函数时间复杂度的计算,通常递归函数 / 算法计算时间复杂度的方法为:
递归次数 * 每次递归函数的次数
根据这个方法可以计算处上面实例的时间复杂度为N * 1,即O(N)
变式:如果我们将上述的 Factorial(N - 1) * N 这个位置的N改成一个需要求N次循环才能计算出来的数值,如
while (n) { n--; }
那么其时间复杂度为 N* N = N ^ 2, O(N ^ 2)
这里只是举例,不用关注其是否真实存在或者意义。
实例八:
//计算斐波那契递归Fibonacci的时间复杂度? long long Fibonacci(size_t N) { return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2); }
像这种比较复杂的递归函数求复杂度,光靠小脑袋瓜去想,半天都想不出来,不如画图去思考吧!
时间复杂度为:O(2 ^ n)
一些常识内容:
2 ^ 10 = 1024 约等于 1000
2 ^ 20 = 1024 * 1024 约等于 1000 * 1000
= 1000000(1百万)
2 ^ 30 = 1024 * 1024 * 1024 约等于 1000 * 1000 * 1000 = 1000 000 000 (10亿) 了解这些常识可以方便我们快速推算一个程序大概的计算量级
实例答案及分析 :
1.实例1基本操作执行了2N + 10次,通过推导大O阶方法知道,时间复杂度为O(N)
2.实例2基本操作执行了M + N次,有两个未知数M和N,时间复杂度为O(N + M)
3.实例3基本操作执行了10次,通过推导大O阶方法,时间复杂度为O(1)
4.实例4基本操作执行最好1次,最坏N次,时间复杂度一般看最坏,时间复杂度为O(N)
5.实例5基本操作执行最好N次,最坏执行了(N * (N + 1) / 2次,通过推导大O阶方法 + 时间复杂度一般看最坏,时间复杂度为O(N ^ 2)
6.实例6基本操作执行最好1次,最坏O(logN)次,时间复杂度为O(logN) ps: logN在算法分析中表示是底数为2,对数为N。有些地方会写成lgN。(建议通过折纸查找的方式讲解logN是怎么计算出来的)
7.实例7通过计算分析发现基本操作递归了N次,时间复杂度为O(N)。
8.实例8通过计算分析发现基本操作递归了2 ^ N次,时间复杂度为O(2 ^ N)。(建议画图递归栈帧的二叉树讲解)
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
①找出所有会涉及空间间复杂度(创建变量)计算的函数 / 模块
②写出每个函数 / 模块对应的空间复杂度
③大O渐进法表示,取其中最高阶项
实例一:
//计算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函数内部有变量的创建,无其他函数,无函数调用
②函数形参变量2个,创建变量end / exchang / I,3个,总共5个
③是一个常数5,用大O渐进表示法,为:O(1)
实例二:
// 计算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; }
①仅Fibonacci函数内部需要计算
②形参1个,创建变量 malloc出来n + 1个,变量i1个,总共n + 3个
③O(N)
实例三:
// 计算阶乘递归Factorial的时间复杂度 ? long long Factorial(size_t N) { return N < 2 ? N : Factorial(N - 1) * N; }
①Factorial内部计算
②递归n次,每次开辟常数个空间
③所以空间复杂度为O(N)
实例答案及分析:
1.实例1使用了常数个额外空间,所以空间复杂度为O(1)
2.实例2动态开辟了N个空间,空间复杂度为O(N)
3.实例3递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间。空间复杂度为O(N)
彩蛋:
//计算斐波那契递归Fibonacci的空间复杂度? long long Fibonacci(size_t N) { return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2); }
答案:O(n)
做题方法:当我们想不到解题的思路的时候,不要立马就去看答案,而要先将这个题放到小脑袋瓜里,先想一段时间(比如吃饭的时候,休息的时候,走路的时候),试着去找出可以解题的方法。如果实在想不到解题思路,或者没有好的思路,可以去看一下别人的代码和思路,去理解去吸收。理解之后,后面就不要再去看别人的代码,而是自己去动手将代码实现,这样才能提高!
在思考的过程中,一定要勤画图。
课后练习编程题:
面试题 17.04.消失的数字
思路:拿0去跟数组中的数及[0, N]之间的所有数进行异或,结果:就是要找到数
理解:相同的两个数异或的结果是0,而消失的数字X在原数组中没有,在[0, N]中出现一次,那么最终的异或结果就是那个消失的数字X
剑指 Offer 56 - I.数组中数字出现的次数
(找出单身狗:将二个只出现一次的到数字,变成一个只出现一次的数字,使用异或的方式,所有数进行异或,就能找出单身狗)
思路:所有数异或 结果 = 两次出现一次的数异或的值
假设出现一次的两个数为 :
00000000 …… 00000011
00000000 …… 00001011
OJ两种类型
1、IO型 头文件 + 代码 + main函数测试(测试用例,类似于你用scanf拿到的数据一样 结果:输出打印) (牛客网居多)
2、接口型 测试用例:参数 结果:返回值 + 输出型参数(指针,传址的方式可以将函数调用过程中的值带回来)(leetcode)
不需要加头文件,后台测试的时候是封装的。
检查自己代码的能力:不是去对照别人的代码(错误做法),而是需要去分析调试、查错!