数据结构与算法
复杂度
复杂度是一个关于输入数据量 n 的函数(分为时间复杂度和空间复杂度)
假设你的代码复杂度是 f(n),那么就用个大写字母 O 和括号,把 f(n) 括起来就可以了,即 O(f(n))。例如,O(n) 表示的是,复杂度与计算实例的个数 n 线性相关;O(logn) 表示的是,复杂度与计算实例的个数 n 对数相关。
1、复杂度与具体的常系数无关 O(n)=O(2n)
2、多项式级的复杂度相加的时候,选择高者作为结果 3、O(1) 也是表示一个特殊复杂度,与输入数据量 n 无关(资源消耗与输入数据量无关)
对于同一个问题,采用不同的编码方法,对时间和空间的消耗是有可能不一样的
一个顺序结构的代码,时间复杂度是 O(1)。
二分查找,或者更通用地说是采用分而治之的二分策略,时间复杂度都是 O(logn)。
一个简单的 for 循环,时间复杂度是 O(n)。
两个顺序执行的 for 循环,时间复杂度是 O(n)+O(n)=O(2n),其实也是 O(n)。
两个嵌套的 for 循环,时间复杂度是 O(n²)。
时间复杂度与代码的结构设计高度相关;空间复杂度与代码中数据结构的选择高度相关
代码效率优化
要采用尽可能低的时间复杂度和空间复杂度,去完成一段代码的开发。
时间昂贵、空间廉价
降低时间复杂度的方法有递归、二分法、排序算法、动态规划等
降低空间复杂度的核心思路就是,能用低复杂度的数据结构能解决问题,就千万不要用高复杂度的数据结构。
第一步,暴力解法。在没有任何时间、空间约束下,完成代码任务的开发。
第二步,无效操作处理。将代码中的无效计算、无效存储剔除,降低时间或空间复杂度。
第三步,时空转换。设计合理数据结构,完成时间复杂度向空间复杂度的转移。
代码对数据的处理
查找: 看能否在数据结构中查找到这个元素,也就是判断元素是否出现过。(从索引或者数值查找)
新增: 针对没有出现过的情况,新增这个元素。
改动: 针对出现过的情况,需要对这个元素出现的次数加 1。
(数据结构中间的增和删,以及在数据结构最后的增和删。区别就在于原数据的位置是否发生改变,查找又可以细分为按照位置条件的查找和按照数据数值特征的查找)
首先,这段代码对数据进行了哪些操作?
其次,这些操作中,哪个操作最影响效率,对时间复杂度的损耗最大?
最后,哪种数据结构最能帮助你提高数据操作的使用效率?
链表
单向链表
- 通过上一个结点的指针找到下一个结点,反过来则是行不通的
循环链表
- 对于一个单向链表,让最后一个元素的指针指向第一个元素
双向链表
- 把结点的结构进行改造,除了有指向下一个结点的指针以外,再增加一个指向上一个结点的指针
双向循环链表
- 双向链表和循环链表进行融合
增加
- 把待插入结点的指针指向原指针的目标,把原来的指针指向待插入的结点
删除
- 把指向 b 的指针 (p.next),指向 b 的指针指向的结点(p.next.next)
查找
- 按照位置序号来查找,遍历
- 按照具体的数值来查找,遍历
一个奇数个元素的链表,查找出这个链表中间位置的结点的数值。
一个暴力的办法是,先通过一次遍历去计算链表的长度,这样我们就知道了链表中间位置是第几个。接着再通过一次遍历去查找这个位置的数值。 二是利用快慢指针进行处理。其中快指针每次循环向后跳转两次,而慢指针每次向后跳转一次。
判断是否有环 链表的快慢指针方法,如果链表存在环,快指针和慢指针一定会在环内相遇,即 fast == slow 的情况一定会发生,反之,则最终会完成循环,二者从未相遇。
翻转
- 构造三个指针 prev、curr 和 next,对当前结点、以及它之前和之后的结点进行缓存,再完成翻转动作
数据元素个数不确定,经常新增删除时推荐使用链表。
数据元素大小确定,删除插入并不多,使用数组
链表增删在O(1) 时间复杂度完成,查找在O(n) 时间复杂度完成
栈
栈是一种特殊的线性表。
栈与线性表的不同,体现在增和删的操作, 栈的数据结点必须后进先出,增删都在表尾进行 表尾叫栈顶,表头叫栈底
增
- 对于栈的新增操作,通常也叫作 push 或压栈
删
- 对于栈的删除操作,通常也叫作 pop 或出栈
查
- 遍历所有
顺序栈
- 栈的顺序存储可以借助数组来实现。一般来说,会把数组的首元素存在栈底,最后一个元素放在栈顶。
链式栈
- 用链表方式对栈的表示,通常把栈顶放在单链表头部,移动top指针(新增删除时间复杂度均为O(1),查找为O(n))
队列
队列也是一种特殊的线性表。
队列的特点是先进先出。增加在末端,删除在始端
顺序队列
- 依赖数组来实现,其中的数据在内存中也是顺序存储。存在假溢出现象。
链式队列
- 依赖链表来实现,其中的数据依赖每个结点的指针互联,在内存中并不是顺序存储。
循环队列
- 不存在假溢出现象,必须有个固定长度 增删元素时间复杂度都为O(1)
在可以确定队列长度最大值时,建议使用循环队列。无法确定队列长度时,应考虑使用链式队列。
增删
- 队列从队头(front)删除,从队尾(rear)插入元素,移动2个指针,增加时间复杂度O(1),删除时间复杂度O(n)
查
- 时间复杂度O(n)
数组
数组是数据结构中的最基本结构,理解为一种容器,它可以用来存放若干个相同类型的数据元素
存储数据是按顺序存储的,且存储数据的类型也是连续的,增删困难,查找容易
增
- 若插入数据在最后,则时间复杂度为 O(1);如果中间某处插入数据,则时间复杂度为 O(n)。
删
- 若删除数据在最后,则时间复杂度为 O(1); 对应位置的删除,扫描全数组,时间复杂度为 O(n)。
查
- 只需根据索引值进行一次查找,时间复杂度是 O(1)。 要在数组中查找一个数值满足指定条件的数据,则时间复杂度是 O(n)。
字符串
字符串(string) 是由 n 个字符组成的一个有序整体( n >= 0 )。
在字符串的基本操作中,通常以“串的整体”作为操作对象
空串 s=""
空格串 s=" " 子串 串中任意连续字符组成的叫做该串的子串 原串通常也叫做主串
存储结构分为顺序存储和链式存储
新增
- 在串中插入牵扯挪移操作,时间复杂度是O(n) 在串尾插入,时间复杂度是O(1)
删除
- 牵扯挪移操作,时间复杂度是O(n) 在串尾删除,时间复杂度是O(1)
查找
- 主串长度为n,模式串长度为m ,时间复杂度为 O(nm) 如果求最大公共子串,时间复杂度为O(nm²)
树
树是由结点和边组成的,不存在环的一种数据结构,分为 父节点、子节点、兄弟节点、根节点、 叶子节点、子树、层次
二叉树
- 在二叉树中,每个结点最多有两个分支,即每个结点最多有两个子结点,分别称作左子结点和右子结点
- 存储二叉树有两种办法,一种是基于指针的链式存储法,另一种是基于数组的顺序存储法
- 前序遍历,对树中的任意结点来说,先打印这个结点,然后前序遍历它的左子树,最后前序遍历它的右子树(先遍历父节点)
- 中序遍历,对树中的任意结点来说,先中序遍历它的左子树,然后打印这个结点,最后中序遍历它的右子树。(中间遍历父节点)
- 后序遍历,对树中的任意结点来说,先后序遍历它的左子树,然后后序遍历它的右子树,最后打印它本身。(最后遍历父节点)
- 二叉查找树进行中序遍历,就可以输出一个从小到大的有序数据队列, 遍历和插入时间复杂度O(logn),真正插入时间复杂度是O(1)
遍历和查询时间复杂度是O(n),增删时间复杂度是O(1)
哈希表
哈希表名字源于 Hash,也可以叫作散列表。哈希表是一种特殊的数据结构,它与数组、链表以及树等我们之前学过的数据结构相比,有很明显的区别。实现“地址 = f (关键字)”的映射关系
设计哈希函数
- 第一,直接定制法 第二,数字分析法 第三,平方取中法 第四,折叠法 第五,除留余数法
如何解决哈希冲突
- 第一,开放定址法 第二,链地址法
不论哈希表中有多少数据,查找、插入、删除只需要接近常量的时间,即 O(1)的时间级
递归
递归的基本思想就是把规模大的问题转化为规模小的相同的子问题来解决。
递归的实现包含了两个部分,一个是递归主体,另一个是终止条件。
写出递归代码的关键在于,写出递推公式和找出终止条件。
汉诺塔问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着 64 片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上,并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。
分治
分治法的核心思想就是分而治之
复杂度为 O(logn) 相比复杂度为 O(n) 的算法,在大数据集合中性能有着爆发式的提高。
一般原问题都需要具备以下几个特征:
难度在降低 问题可分 解可合并 相互独立
分治法在每轮递归上,都包含了分解问题、解决问题和合并结果这 3 个步骤。
1、二分查找的时间复杂度是 O(logn),这也是分治法普遍具备的特性。当你面对某个代码题,而且约束了时间复杂度是 O(logn) 或者是 O(nlogn) 时,可以想一下分治法是否可行。
2、二分查找的循环次数并不确定。一般是达到某个条件就跳出循环。因此,编码的时候,多数会采用 while 循环加 break 跳出的代码结构。 3、二分查找处理的原问题必须是有序的。因此,当你在一个有序数据环境中处理问题时,可以考虑分治法。相反,如果原问题中的数据并不是有序的,则使用分治法的可能性就会很低了。
排序
衡量排序算法优劣
- 时间复杂度 具体包括最好最坏以及平均时间复杂度
- 空间复杂度 如果为1时也叫原地排序
- 稳定性指相等的数据对象,在排序之后,顺序是否能保证不变
冒泡排序
- 从第一个数据开始,依次比较相邻元素的大小。如果前者大于后者,则进行交换操作,把大的元素往后交换。通过多轮迭代,直到没有交换操作为止。(最好时间复杂度O(n),最坏是O(nn),平均是O(nn)空间复杂度O(1))
插入排序
- 选取未排序的元素,插入到已排序区间的合适位置,直到未排序区间为空。(最好时间复杂度O(n),最坏是O(nn),平均是O(nn),空间复杂度O(1))
归并排序
- 归并排序采用二分的迭代方式复杂度是logn,需要在O(n) 的时间复杂度完成,所以最好最坏平均复杂度是O(nlogn),空间复杂度O(n)
快速排序
- 快速排序法最好情况下每次选取分区时都能选中中位数时间复杂度是O(nlogn), 最坏的情况每次分区都选中最大值或最小值,此时时间复杂度为O(nn) 平均时间复杂度O(n*logn),空间复杂度为O(1)
如果对数据规模比较小的数据进行排序,可以选择时间复杂度为 O(n*n) 的排序算法。但对数据规模比较大的数据进行排序,就需要选择时间复杂度为 O(nlogn) 的排序算法了。
动态规划
从数学的视角来看,动态规划是一种运筹学方法,是在多轮决策过程中的最优方法
下面的 k 表示多轮决策的第 k 轮
分阶段,将原问题划分成几个子问题。一个子问题就是多轮决策的一个阶段,它们可以是不满足独立性的。
找状态,选择合适的状态变量 Sk。它需要具备描述多轮决策过程的演变,更像是决策可能的结果。
做决策,确定决策变量 uk。每一轮的决策就是每一轮可能的决策动作,例如 D2 的可能的决策动作是 D2 -> E2 和 D2 -> E3。
状态转移方程。这个步骤是动态规划最重要的核心,即 sk+1= uk(sk) 。
定目标。写出代表多轮决策目标的指标函数 Vk,n。
寻找终止条件。
如下几个特征的问题,可以采用动态规划求解:最优子结构、无后效性、有重叠子问题
开发前的复杂度分析与技术选型
复杂度分析。估算问题中复杂度的上限和下限。
定位问题。根据问题类型,确定采用何种算法思维。
数据操作分析。根据增、删、查和数据顺序关系去选择合适的数据结构,利用空间换取时间。
编码实现。
XMind - Trial Version