痛并快乐着。
复杂度分析
大O复杂度表示法(时间复杂度)
T(n) = O(f(n))
T(n)表示代码执行的时间;n 表示数据规模的大小;f(n) 表示每行代码执行的次数总和
1 | // 例1 |
假设每行代码的运行时间为x,例1中的第二三行代码,分别需要x个时间,第4、5行代码,各执行了n次,执行时间分别需要 n x。所以例1中代码执行时间需要(2n+2) x 。可以看出,所有代码的执行时间 T(n) 与每行代码的执行次数成正比。
所以例2中代码所需要的执行时间T(n) = 2n² + 2n + 3.
即: 所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势
时间复杂度分析
- 只关注循环执行次数最多的一段代码。
如例1中的代码,2、3行代码的执行时间都是常量级,与n的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是第 4、5 行代码,所以这块代码要重点分析。这两行代码被执行了 n 次,所以总的时间复杂度就是 O(n)。 - 加法法则: 总复杂度等于量级最大的那段代码的复杂度
- 乘法法则: 嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
- O(m+n)、O(m*n)
如一个函数中有两个数据规模的量,在事先又无法评估谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。乘法一样。
最好、最坏情况时间复杂度
最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。
最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。
平均情况时间复杂度
比如要查找的变量 x 在数组中的位置,有 n+1 种情况:在数组的 0~n-1 位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值
(1 + 2 + 3 + … + n + n) / (n+1)
均摊时间复杂度
在代码执行的所有复杂度情况中绝大部分是低级别的复杂度,个别情况是高级别复杂度且发生具有时序关系时,可以将个别高级别复杂度均摊到低级别复杂度上。基本上均摊结果就等于低级别复杂度
均摊复杂度在以下两个条件满足时使用
- 代码在绝大多数情况下是低级别复杂度,只有极少数情况是高级别复杂度;
- 低级别和高级别复杂度出现具有时序规律。均摊结果一般都等于低级别复杂度。
空间复杂度
表示算法的存储空间与数据规模之间的增长关系。
数据结构类型
线性表
线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。
数组,链表、队列、栈等都是线性表结构。
数组
数组,用一组连续的内存空间,来存储一组具有相同类型的数据。
- 连续的内存空间和相同类型的数据
- 支持随机访问
- 低效的“插入”和“删除”
如果某个数组中,需要在arr[x]的位置插入一个新的val,就需要将原来的x向后挪出一位,最坏的时间复杂度的情况为O(n)
链表
与数组相反,它并不需要一块一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。其中,我们把内存块称为链表的”结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。
单链表
1 | --> data --> data -- > data --> null |
单链表有两个结点比较特殊,头结点(第一个结点)和尾结点(最后一个结点指向null)
- 插入删除非常便捷
- 但随即访问没有数组高效,无法像数组一样通过下标获取,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。
循环链表
是一种特殊的单链表。
单链表的尾结点指向null(空地址),而循环链表的尾结点指针是指向链表的头结点.
双向链表。
单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
- 双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。
####链表与数组的对比
- 数组大小固定,一经声明就要占用整块连续内存空间。链表本身没有大小的限制,天然地支持动态扩容。
指针
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
插入结点时一定要注意: 如果希望在a结点和b结点插入c结点,要先将c的next结点指向b,再将a的next结点指向c,这样才不会丢失指针,导致内存泄漏。同理,删除链表结点时,也一定要记得手动释放内存空间。
如何优雅的写出链表代码
- 理解指针或引用的含义
- 警惕指针丢失和内存泄漏(单链表)
- 利用“哨兵”简化实现难度
链表中的“哨兵”节点是解决边界问题的,不参与业务逻辑。如果我们引入“哨兵”节点,则不管链表是否为空,head指针都会指向这个“哨兵”节点。我们把这种有“哨兵”节点的链表称为带头链表,相反,没有“哨兵”节点的链表就称为不带头链表。 - 重点留意边界条件处理
- 举例画图,辅助思考
栈
先进后出,后进先出。
- 只允许在端插入和删除数据
- 存储运行方法的形参、局部变量、返回值。由系统自动分配和回收。
- 可以由数组实现
队列
先进先出
- 最基本的操作有两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。
- 可以由数组实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。
散列表
递归
指在运行的过程中调用自己。
构成递归需要满足的条件
- 一个问题的解可以分解为几个子问题的解。
- 子问题须与原始问题为同样的事,且更为简单。
- 存在递归终止条件。
如何编写递归
写出递推公式,找到终止条件。
如斐波那契数列。 1, 1, 2, 3, 5, 8, 13, 21, …
如何求 斐波那契数列第n项的值?
- 找出递推公式。 按数列不难发现,从第三项起,第三项的值,等于前两项值的和。
及f(n) = f(n - 1) + f(n - 2) - 终止条件
当只有一个数时,值为1,所以 f(1) = 1。
校验递归终止条件是否足够时,可以将n = 2,n = 3 带入地推公式里面实验一下。
当n = 2 时,f(2) = f(1) + f(0)。如果递归终止条件只有一个 f(1)=1,那 f(2) 就无法求解了。
所以除了f(1) = 1之外,还要有 f(0) = 1,用来表示0个数时的值,不过显然长度为0的数列是不符合正常逻辑的,所以,我们可以把f(2) = 2,当做一个 终止条件。
当n = 3时 …
把得到的递推公式和终止条件结合在一起:
1 | f(1) = 1 |
有了公式,将其转换成代码就容易多啦。
1 | // 时间复杂度 2^n 2的n次方 |
避免 OOM(内存溢出)
- 以通过在代码中限制递归调用的最大深度的方式来解决这个问题。及递归调用次数超过一定的深度(如1000以后),我们就报错不再执行。
警惕重复计算
如果将上述代码的递归过程分解一下的话,不难发现有重复计算的问题。
如当 n为5时,f(5) = f(4) + f(3), 当n 为4时, f(4) = f(3) + f(2),这里的f(3)就重复计算了。
还是斐波那契数列求n项的值,为了避免重复计算,可以考虑将前两位数作为参数避免重复计算。
1 | function fib(n) { |
排序
冒泡排序
核心原理
冒泡排序只会操作两个相邻的数据。每次冒泡排序都会对两个相邻的元素进行比较,看是否满足大小关系,如果不满足就让他俩互换,一次冒泡会至少让一个元素移动到他应该在的位置,重复n次就完成了n个数据的排序工作。
时间复杂度
最好时间复杂度 O(n)
最坏时间复杂度 O(n²)
平均时间复杂度 O(n²)
代码示例
1 | function bubbling(arr) { |
插入排序
核心原理
首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,通常是数据的第一个元素。插入算法的核心思想就是取未排序区间中的数据,在已排序区间找到合适的位置将其插入,并保证已排序区间一直有序。重复这个过程,直到未排序区间数据为空,结束排序。
时间复杂度
最好时间复杂度 O(n)
最坏时间复杂度 O(n²)
平均时间复杂度 O(n²)
代码示例
1 | function insertSort(arr) { |
选择排序
核心原理
选择排序的思路与插入排序的方法有些类似,也分为已排序区和未排序区。但是选择排序每次会在未排序区选择一个 最小值插入到已排序区末尾。
时间复杂度
最好时间复杂度 O(n)
最坏时间复杂度 O(n²)
平均时间复杂度 O(n²)
代码示例
1 | function selectSort(arr) { |
归并排序
排序原理
- 尽可能的将一组元素拆分成两个元素相等的子组(若无法相等向下取整保留前多后少),并对每一个子组继续拆分,直到拆分后的每个子组中的元素个数为1为止。
- 将相邻的两个子组归并成一个有序的大组。
- 重复上述步骤直到归并成一个数组为止。
时间复杂度
最好时间复杂度 O(nlogn)
最坏时间复杂度 O(nlogn)
平均时间复杂度 O(nlogn)
代码示例
1 | function mergeSort(arr) { |
快速排序
核心思想
- 在数集中选择一个元素作为基准。
- 所有小于基准的元素,都移到基准的左边,大于基准的元素,都挪到右边。
- 对基准左右两边的数集,重复1,2 的步骤,直到所有的子集都只剩一个元素为止
时间复杂度
最好时间复杂度 O(nlogn)
最坏时间复杂度 O(nlogn)
平均时间复杂度 O(nlogn)
代码示例
1 | function quickSort(arr) { |
桶排序
核心思想:
将要排序的数据,分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序后,再把桶里每个数据按照顺序依次取出,组成的序列就是有序的了。
计数排序
核心思想
当要排序的n个数据,所处的范围 并不大的时候,如最大值为k,则把数据划分成k个桶,这样桶里的数据都是相同的,相较于桶排序,省去了桶内排序的时间。
基数排序
####核心思想
需要分割出独立的“位”来比较,而且位之间有递进 关系,如果a数据的高位比b数据的大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
二分查找
核心思想
每次都取数据中的中间数来比较大小,从而不断地缩小查找范围,直至找到数据为止。
时间复杂度
O(logn)
使用限制
- 需要数据为数组类型;
- 需要数据为有序数据;
- 数据量太小、太大,均不适合使用二分查找。
代码示例
1 | // 非递归 |
散列表 Hash Table
散列表用的是数组下标支持随机访问的特性,所以散列表其实是数组的一种扩展,由数组演化而来。
假如有50名学生参加学校运动会,为了方便记录成绩,会在胸前贴上编号,通过编号可以快速的查找到某个学生的成绩。
参赛学生的编号我们 叫做key,把参赛编号转化为数组下标的映射方法就叫作散列函数,而散列函数计算得到的值就叫作散列值。
1 | // 找出两个数组中重复的项 |
树
释义
- 节点:树上的每一个元素我们称之为节点;
- 父子关系: 用来连接相邻节点之间的关系称之为父子关系;
- 高度:节点到叶节点的最长路径(边数) 由下往上的度量。图中二图的高度为3(从零开始)
- 深度:根节点到这个节点所经历的边的个数 由上往下度量,从0开始。
- 层数:节点的深度 + 1。及层数的计算是由1开始。
- 树的高度 = 根节点的高度
二叉树 Binary Tree
每个节点最多有两个叉,也就是最多两个子节点,分别为左节点和右节点。不过二叉树也并不要求每个节点都有左右节点,有的只有左节点,有的只有右节点。
- 满二叉树: 除了根节点外,每个节点都有左右两个子节点。
- 完全二叉树: 最后一层的叶子节点都靠左排列,并且除了最后一层,其它层的节点数都打到了最大。