数据结构和算法笔记
时间复杂度和空间复杂度
时间复杂度的公式是: T(n) = O( f(n) ),其中f(n) 表示每行代码执行次数之和,而 O 表示正比例关系,这个公式的全称是:算法的渐进时间复杂度
常见的时间复杂度量级有:
常数阶O(1)
:无论代码执行了多少行,只要是没有循环等复杂结构,那这个代码的时间复杂度就都是O(1)
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
对数阶O(logN)
int i = 1;
while(i<n)
{
i = i * 2;
}
在while循环里面,每次都将 i 乘以 2,乘完之后,i 距离 n 就越来越近了。我们试着求解一下,假设循环x次之后,i 就大于 n 了,此时这个循环就退出了,也就是说 2 的 x 次方等于 n,那么 x = log2^n,也就是说当循环 log2^n 次以后,这个代码就结束了。因此这个代码的时间复杂度为:O(logn)
线性阶O(n)
:for循环里面的代码会执行n遍,因此它消耗的时间是随着n的变化而变化的,因此这类代码都可以用O(n)来表示它的时间复杂度。
for(i=1; i<=n; ++i)
{
j = i;
j++;
}
线性对数阶O(nlogN)
:线性对数阶O(nlogN) 其实非常容易理解,将时间复杂度为O(logn)的代码循环N遍的话,那么它的时间复杂度就是 n * O(logN),也就是了O(nlogN)。
for(m=1; m<n; m++)
{
i = 1;
while(i<n)
{
i = i * 2;
}
}
平方阶O(n²)
:如果把 O(n) 的代码再嵌套循环一遍,嵌套了2层n循环,它的时间复杂度就是 O(n*n),即 O(n²),它的时间复杂度就是 O(n²) 了。
for(x=1; i<=n; x++)
{
for(i=1; i<=n; i++)
{
j = i;
j++;
}
}
立方阶O(n³)
K次方阶O(n^k
)指数阶(2^n)
空间复杂度
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,我们用 S(n) 来定义。算法空间复杂度的计算公式记作:S(n)= O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。空间复杂度比较常用的有:O(1)、O(n)、O(n²)
-
空间复杂度O(1)
如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)
int i = 1; int j = 2; ++i; j++; int m = i + j;
代码中的 i、j、m 所分配的空间都不随着处理数据量变化,因此它的空间复杂度 S(n) = O(1)
-
空间复杂度O(n)
int[] m = new int[n]
for(i=1; i<=n; ++i)
{
j = i;
j++;
}
这段代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-6行,虽然有循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)
空间复杂度O(n²)
1 常用数据结构
数组、字符串:
数组一般用来存储相同类型的数据,可通过数组名和下标进行数据的访问和更新。数组中元素的存储是按照先后顺序进行的,同时在内存中也是按照这个顺序进行连续存放。数组相邻元素之间的内存地址的间隔一般就是数组数据类型的大小。
链表:
链表相较于数组,除了数据域,还增加了指针域用于构建链式的存储数据。链表中每一个节点都包含此节点的数据和指向下一节点地址的指针。由于是通过指针进行下一个数据元素的查找和访问,使得链表的自由度更高。
优点:灵活地分配内存空间、能在O(1)时间内删除或者添加元素
缺点:链表不能进行下标查询,查询元素需要O(n)时间
单链表:链表中的每个元素实际上是一个单独的对象,而所有对象都通过每个元素中的引用字段链接在一起。
双链表:与单链表不同的是,双链表的每个结点中都含有两个引用字段
链表解题技巧:
利用快慢指针(有时候需要用到三个指针)
构建一个虚假的链表头:例如 两个排序链表,进行整合排序。 将链表的奇偶数按原定顺序分离,生成前半部分为奇数,后半部分为偶数的链表
**训练技巧:**纸上画出节点之间的相互关系、画出修改的方法
栈
栈是一种比较简单的数据结构,常用一句话描述其特性,后进先出。栈本身是一个线性表,但是在这个表中只有一个口子允许数据的进出。
栈的常用操作包括入栈push和出栈pop,对应于数据的压入和压出。还有访问栈顶数据、判断栈是否为空和判断栈的大小等。由于栈后进先出的特性,常可以作为数据操作的临时容器,对数据的顺序进行调控,与其它数据结构相结合可获得许多灵活的处理。
特点:后进先出(LIFO)
算法基本思想:可以用一个单链表来实现
只关心上一次的操作
处理完上一次的操作后,能在O(1)时间内查找到更前一次的操作
例题:有效的括号、739 每日温度
队列
队列是栈的兄弟结构,与栈的后进先出相对应,队列是一种先进先出的数据结构。顾名思义,队列的数据存储是如同排队一般,先存入的数据先被压出。常与栈一同配合,可发挥最大的实力。
特点:先进先出(FIFO)
常用的场景:广度优先搜索
双端队列
基本实现:可以利用一个双链表
队列的头尾两端能在O(1)的时间内进行数据的查看、添加和删除
常用的场景:实现一个长度动态变化的窗口或者连续区间
例题:239 滑动窗口最大值
树
树作为一种树状的数据结构,其数据节点之间的关系也如大树一样,将有限个节点根据不同层次关系进行排列,从而形成数据与数据之间的父子关系。常见的数的表示形式更接近“倒挂的树”,因为它将根朝上,叶朝下。
树的数据存储在结点中,每个结点有零个或者多个子结点。没有父结点的结点在最顶端,成为根节点;没有非根结点有且只有一个父节点;每个非根节点又可以分为多个不相交的子树。
完全二叉树:除了最后一层结点,其它层的结点数都达到了最大值;同时最后一层的结点都是按照从左到右依次排布。
满二叉树:除了最后一层,其它层的结点都有两个子结点。
平衡二叉树
平衡二叉树又被称为AVL树,它是一棵二叉排序树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
平衡二叉树的产生是为了解决二叉排序树在插入时发生线性排列的现象。由于二叉排序树本身为有序,当插入一个有序程度十分高的序列时,生成的二叉排序树会持续在某个方向的字数上插入数据,导致最终的二叉排序树会退化为链表,从而使得二叉树的查询和插入效率恶化。
二叉排序树:是一棵空树,或者:若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;它的左、右子树也分别为二叉排序树。
树的高度:结点层次的最大值
平衡因子:左子树高度 - 右子树高度
树的考题:遍历:
前序遍历(preorder traversal):先访问根节点、再访问左子树、最后访问右子树
中序遍历(inorder traversal):先访问左子树、再访问根节点、最后访问右子树:
用的最多的:二叉搜索树
后序遍历(postorder traversal):先访问左子树、再访问右子树、最后访问根节点
题 230
2 高级数据结构
优先队列
与普通队列的区别:保证每次取出的元素是队列中优先级最高的、优先级别可以自定义
最常用的场景:从杂乱无章的数据中按照一定的顺序(或者优先级)筛选数据
本质:二叉堆的结构,堆 binary heap
利用一个数组结构来实现完全二叉树
特性:数组里的第一个元素array[0]
拥有最高的优先级
给定一个下标i,那么对于元素array[i]
而言
父节点 对应的元素下标是 (i -1)/2
左侧子节点 对应的元素下标是 2*i +1
右侧子节点 对应的元素下标是 2*i +2
数组中每个元素的优先级都必须要高于它两侧子节点
其基本操作为以下两个:向上筛选(sift up/bubble up)、向下筛选(sift down/bubble down)
另一个最重要的时间复杂度:优先队列的初始化
例题:前k个
图
图相较于上文的几个结构可能接触的不多,但是在实际的应用场景中却经常出现。比方说交通中的线路图,常见的思维导图都可以看作是图的具体表现形式。
图结构一般包括顶点和边,顶点通常用圆圈来表示,边就是这些圆圈之间的连线。边还可以根据顶点之间的关系设置不同的权重,默认权重相同皆为1。此外根据边的方向性,还可将图分为有向图和无向图。
最基本知识点:阶、度
树、森林、环
有向图、无向图、完全有向图、完全无向图
连通图、连通分量
邻接矩阵
目前常用的图存储方式为邻接矩阵,通过所有顶点的二维矩阵来存储两个顶点之间是否相连,或者存储两顶点间的边权重
无向图的邻接矩阵是一个对称矩阵,是因为边不具有方向性,若能从此顶点能够到达彼顶点,那么彼顶点自然也能够达到此顶点。此外,由于顶点本身与本身相连没有意义,所以在邻接矩阵中对角线上皆为0。
有向图由于边具有方向性,因此彼此顶点之间并不能相互达到,所以其邻接矩阵的对称性不再。
用邻接矩阵可以直接从二维关系中获得任意两个顶点的关系,可直接判断是否相连。但是在对矩阵进行存储时,却需要完整的一个二维数组。若图中顶点数过多,会导致二维数组的大小剧增,从而占用大量的内存空间。
而根据实际情况可以分析得,图中的顶点并不是任意两个顶点间都会相连,不是都需要对其边上权重进行存储。那么存储的邻接矩阵实际上会存在大量的0。虽然可以通过稀疏表示等方式对稀疏性高的矩阵进行关键信息的存储,但是却增加了图存储的复杂性。
因此,为了解决上述问题,一种可以只存储相连顶点关系的邻接表应运而生。
邻接表
在邻接表中,图的每一个顶点都是一个链表的头节点,其后连接着该顶点能够直接达到的相邻顶点。相较于无向图,有向图的情况更为复杂,因此这里采用有向图进行实例分析。
在邻接表中,每一个顶点都对应着一条链表,链表中存储的是顶点能够达到的相邻顶点。存储的顺序可以按照顶点的编号顺序进行。比如上图中对于顶点B来说,其通过有向边可以到达顶点A和顶点E,那么其对应的邻接表中的顺序即B->A->E,其它顶点亦如此。
通过邻接表可以获得从某个顶点出发能够到达的顶点,从而省去了对不相连顶点的存储空间。然而,这还不够。对于有向图而言,图中有效信息除了从顶点“指出去”的信息,还包括从别的顶点“指进来”的信息。这里的“指出去”和“指进来”可以用出度和入度来表示。
逆邻接表
逆邻接表与邻接表结构类似,只不过图的顶点链接着能够到达该顶点的相邻顶点。也就是说,邻接表时顺着图中的箭头寻找相邻顶点,而逆邻接表时逆着图中的箭头寻找相邻顶点。
邻接表和逆邻接表的共同使用下,就能够把一个完整的有向图结构进行表示。可以发现,邻接表和逆邻接表实际上有一部分数据时重合的,因此可以将两个表合二为一,从而得到了所谓的十字链表。
十字链表
十字链表似乎很简单,只需要通过相同的顶点分别链向以该顶点为终点和起点的相邻顶点即可。
但这并不是最优的表示方式。虽然这样的方式共用了中间的顶点存储空间,但是邻接表和逆邻接表的链表节点中重复出现的顶点并没有得到重复利用,反而是进行了再次存储。因此,上图的表示方式还可以进行进一步优化。
十字链表优化后,可通过扩展的顶点结构和边结构来进行正逆邻接表的存储:(下面的弧头可看作是边的箭头那端,弧尾可看作是边的圆点那端)
data:用于存储该顶点中的数据;
firstin指针:用于连接以当前顶点为弧头的其他顶点构成的链表,即从别的顶点指进来的顶点;
firstout指针:用于连接以当前顶点为弧尾的其他顶点构成的链表,即从该顶点指出去的顶点;
边结构通过存储两个顶点来确定一条边,同时通过分别代表这两个顶点的指针来与相邻顶点进行链接:
tailvex:用于存储作为弧尾的顶点的编号;
headvex:用于存储作为弧头的顶点的编号;
headlink 指针:用于链接下一个存储作为弧头的顶点的节点;
taillink 指针:用于链接下一个存储作为弧尾的顶点的节点;
必须熟练掌握的知识点
图的存储和表达方式:邻接矩阵、邻接链表
图的遍历:深度优先、广度优先
环的检测:有向图、无向图
拓扑排序
联合-查找算法(union-find)
最短路径:Dijkstra、Bellman-Ford
例题:785
前缀树
也称作字典树
这种数据结构被广泛的运用在字典查找当中
字典查找:例如给定一系列构成字典的字符串,要求在字典当中找出所有以ABC
开头的字符串
方法一:暴力搜索法 —时间复杂度:O(M*N)
方法二:前缀树 —时间复杂度:O(M)
经典应用:搜索框输入搜索文字,罗列搜索
重要性质:
每个节点至少包含两个基本属性:
children:数组或者集合,罗列出每个分支当中包含的所有字符
isEnd:布尔值,表示该节点是否为某字符串的结尾
根节点是空的;除了根节点,其他所有节点都可能是单词的结尾,叶子节点一定都是单词的结尾
最基本的操作:创建 —方法:遍历一遍输入的字符串,对每个字符串的字符进行遍历,从前缀树的根节点开始,将每个字符加入到节点的children字符集当中,如果字符集已经包含了这个字符,跳出;如果当前字符是字符串的最后一个,把当前节点和 isEnd 标记为真
搜索 — 方法: 从前缀树的根节点出发,逐个匹配输入的前缀字符,如果遇到了,继续往下一层搜索,如果没遇到,立即返回
线段树
一种按照二叉树的形式存储数据的结构,每个节点保存的都是数组里某一段的总和
例题:315
树状数组
也被称为 binary indexed tree
1.更新数组元素的数值 2. 求数组前k个元素的总和(或者平均值)
重要的基本特征:利用数组来表示多叉树的结构,和优先队列有些类似
优先队列是用数组来表示完全二叉树,而树状数组是多叉树
树状数组的第一个元素是空节点
如果节点 tree[y] 是 tree[x] 的 父节点,那么需要 满足 y=x-(x &(-x))
例题:308题
3 排序
排序算法:基本的排序算法、常考的排序算法、其他排序算法
基本的排序算法:冒泡排序、插入排序
常考的排序算法:归并排序、快速排序、拓扑排序
其他排序算法:堆排序、桶排序
**冒泡排序:**每一轮,从杂乱无章的数组头部开始,每两个元素比较大小并进行交换;直到这一轮当中最大或者最小的元素被放置在数组的尾部;然后,不断地重复这个过程,直到所有元素都排好位置。
**归并排序:**核心思想是分治,把一个复杂问题拆分成若干个子问题来求解
**算法思想:**把数组从中间划分成两个子数组:一直递归地把子数组划分成更小的子数组,直到子数组里面只有一个元素;依次按照递归的返回顺序,不断地合并排好序的子数组,直到最后把整个数组的顺序排好
快速排序:分治的思想 把原始的数组筛选成较小和较大的两个子数组,然后递归地排序两个子数组;在分成较小和较大的两个子数组过程中,如何选定一个基准值尤为关键。
拓扑排序:应用场合:将图论里的顶点按照相连的性质进行排序
**前提:**必须是有向图,图里没有环
4 递归与回溯
递归的基本性质:函数调用本身—把大规模的问题不断地变小,再进行推导 的过程
回溯:利用递归的性质 — 从问题的起始点出发,不断尝试 ,返回一步甚至多步再做选择, 直到抵达终点的过程
递归算法是一种调用自身函数的算法
算法思想:将问题规模变小,再利用从小规模问题中得出的结果。再结合当前值或情况得出最终结果------>自顶向下: 把要实现的递归函数,看成已经实现好的,直接利用解决一些子问题。 需要思考的:如何根据子问题的解以及当前面对的情况得出答案。
写法结构总结:1. 判断当前情况是否非法,如果非法就立即返回,也称为完整性检查(sanity check)
2. 判断是否满足结束递归的条件
3. 将问题的规模缩小,递归调用
4. 利用在小规模问题中的答案,结合当前的 数据进行整合,得出最终的答案
递归算法解决时间复杂度分析:迭代法:
公式法:计算递归函数复杂度最方便的工具:只需要牢记三种可能会出现的情况以及处理他们的公式
当递归函数的时间执行函数满足如下的关系式时,可以利用公式法:
回溯算法:
试探算法,一步步向前试探,对每一步探测的情况进行评估,再决定是否继续,可以避免走弯路
回溯算法必须保证:每次都有多种尝试的可能
问题套路: 1. 首先判断当前情况是否非法,如果非法就立即返回
2. 看看当前情况是否已经满足条件?如果是,就将当前结果保存起来并返回
3. 在当前情况下,遍历所有可能出现的情况,并进行递归
4. 递归完毕后,立即回溯,回溯的方法就是取消前一步进行的尝试
回溯其实是用递归实现的 ,在分析回溯的时间复杂度时,其实就是在对递归函数进行分析
5 深度与广度优先搜索
深度优先搜索算法(DFS)
DFS解决的是连通性的问题,判断是否有一条路径能从起点连接到终点。很多情况下,连通的路径有很多条,只需要找出一条就可,DFS只关心路径存在与否,不在乎其长短。
算法思想:从起点出发,选择一个可选方向不断向前,直到无法继续为止,然后尝试另外一种方向, 直到最后走到终点。借助栈的思想
DFS的非递归实现: 1. 创建一个stack,用来将要被访问的点压入以及弹出
2. 将起始点压入stack,并标记它被访问过
3. 只要stack不为空,就不断的循环,处理每个点
4. 从堆栈取出当前要处理的点
5. 判断是否抵达了目的地B,是则返回true
6. 如果不是目的地,就从四个方向上尝试
7. 将各个方向上的点压入堆栈,并标记为访问过
8. 尝试了所有可能还没找到B,则返回false
DFS复杂度分析:
广度优先搜索算法(BFS)
一般用来解决最短路径的问题,搜索是从起始点出发,一层一层的进行,每层当中的点距离起始点的步数都是相同的
双端BFS:同时从起始点和终点开始进行的 广度优先的搜索,可以大大提高搜索效率
借助队列的数据结构:FIFO
BFS 的实现: 1. 创建一个队列,将起始点加入队列中
2. 只要队列不为空,就一直循环下去
3. 在队列中取出当前要处理的点
4. 在四个方向上进行BFS搜索
5. 判断一下该方向上的点是否已经访问过了
6. 被访问过了,则记录步数,并加入队列中
7. 找到目的地之后立即返回
6 动态规划
基本属性:
最优子结构:状态转移方程 f(n)、重叠子问题
难点:如何定义 f(n),如何推导出 状态转移方程
题目分类:
线性规划:各个子问题的规模以线性的方式分布、子问题的最佳状态或结果可以存储在一维线性的数据结构中,例如:一维数组,哈希表等。通常我们会dp[i]
表示第i个位置的结果,或者从0开始到第i个位置为止的最佳状态或结果·
7 二分搜索与贪婪
二分搜索算法
是一种在有序数组中查找某一特定元素的搜索算法
运用前提:数组必须是排好序的,输入并不一定是数组,也可能是给定一个区间的起始和终止的位置
采取自平衡的二叉查找树
贪婪算法
是一种在每一步选中都采取在当前状态下最好或最优的选择。