在计算机科学中,数据结构(Data Structure)是计算机中存储、组织数据的方式。算法(Algorithms)用来设计一种使用计算机来解决问题的方法。
算法效率的度量
时间复杂度
如何计算:
- 找到一个基本操作(最深层循环)
- 分析该基本操作的执行次数 与问题规模 的关系
- 的数量级 就是算法时间复杂度
常用技巧:
- 加法规则:
- 乘法规则:
三种复杂度:
- 最坏时间复杂度:考虑输入数据最坏的情况
- 平均时间复杂度:考虑所有输入数据都等概率出现的情况
- 最好时间复杂度:考虑输入数据"最好"的情况
空间复杂度
如何计算:
- 找到所占空间大小与问题规模相关的变量
- 分析所占空间 与问题规模 的关系
- 的数量级 就是算法空间复杂度
特别注意含有递归的程序:找到递归调用深度 与问题规模 的关系
链表(Linked List)
单链表的插入
头插一个节点
head
初始指向NULL
1 | struct Node{ |
任意位置插入节点
分配给我们的程序或应用程序的内存通常分为:
- Heap
- Stack
- Static/Global
- Code(Text)
应用程序内存的一部分用于存储所有需要被执行的指令,另一部分用来存储全局变量,贯穿整个程序或者说应用程序的生命周期。
存储器的一部分称为栈,用于存储所有有关函数调用执行的信息,存储所有局部变量。并且这三个部分的大小是固定的,它们的大小在编译的时候就决定了。最后一部分我们称为堆或空闲存储,是不固定的,我们可以请求内存运行时从堆中读取数据,这就是使用malloc或new运算符所做的事情。
1 | struct Node{ |
总结
- 按位序插入(带头节点):平均时间复杂度
单链表的删除
删除链表中的某个节点一定需要知道这个点的前继节点,所以需要一直有指针指向前继节点
还有一种删除是伪删除,是指复制一个和要删除节点值一样的节点,然后删除,这样就不必知道其真正的前继节点了
反转链表-单向链表
[206] Reverse Linked List
依次遍历链表节点,每遍历一个节点即逆置一个节点:
- 原始链表:
head -> 1 -> 2 -> 3 -> 4 -> 5
- 新链表:
new_head -> NULL
循环5次后:
- 原始链表:
head -> NULL
- 新链表:
new_head -> 5 -> 4 -> 3 -> 2 -> 1
1 | class Solution: |
[92] Reverse Linked List II
将链表从位置m
到n
逆序:
1 | class Solution: |
栈(Stack)
栈是一种LIFO(Last In First Out)
的数据结构,常用方法有添加元素,取栈顶元素,弹出栈顶元素,判断栈是否为空
1 | stack = [] |
len(stack) != 0
- 判断stack
是否为空stack[-1]
- 取栈顶元素,不移除pop()
- 移除栈顶元素并返回该元素append(item)
- 向栈顶添加元素
队列(Queue)
队列是一个FIFO(先进先出)
的数据结构,并发中使用较多,可以安全地将对象从一个任务传给另一个任务
- 通过
pop()
在list的尾部弹出元素实现Stack的LIFO
- 如果是
pop(0)
则弹出头部的元素实现Queue的FIFO
1 | l = [] |
排序(Sort)
插入排序(Insertion Sort)
通过构建有序序列,对于未排序序列,在已排序序列中从后向前扫描(对于单向链表则只能从前往后遍历),找到相应位置并插入。实现上通常使用in-place排序(需用到O(1)的额外空间)
- 从第一个元素开始,该元素可认为已排序
- 取下一个元素,对已排序数组从后往前扫描
- 若从排序数组中取出的元素大于新元素,则移至下一位置
- 重复步骤3,直至找到已排序元素小于或等于新元素的位置
- 插入新元素至该位置
- 重复2~5
1 | // 插入排序 |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
插入排序 | 稳定 |
- 稳定性:由于只需要找到不大于当前数的位置而并不需要交换,因此,直接插入排序是稳定的排序方法
- 适用场景:插入排序由于 的复杂度,在数组较大的时候不适用。但是,在数据比较少的时候,是一个不错的选择,一般做为快速排序的扩充
希尔排序(shell sort)
希尔排序是插入排序的改良版,又叫缩小增量排序,在希尔排序出现之前,计算机界普遍存在"排序算法不可能突破 "的观点。希尔排序是第一个突破 的排序算法,它是简单插入排序的改进版,与插入排序不同的是,会优先比较距离较远的元素。希尔排序的提出,主要基于以下两点:
- 插入排序算法在数组基本有序的情况下,可以近似达到 复杂度,效率极高
- 但插入排序每次只能将数据移动一位,在数组较大且基本无序的情况下性能会迅速恶化
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列 ,其中
- 按增量序列个数 ,对序列进行 趟排序
- 每趟排序,根据对应的增量 ,将待排序列分割成若干长度为 的子序列,分别对各子表进行直接插入排序
- 仅增量因子为1时,整个序列作为一个表来处理,表长度即为整个序列的长度
简单地来说其包含四层循环:最外面一层是增量,每循环一次增量减一,直到最后进行增量为1的排序。次外层是对每组数据进行排序。还有两层就是插入排序的操作
1 | void shellSort(int arr[], int n) |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
希尔排序 | 不稳定 |
- 稳定性:Shell排序是一个多次插入的过程。在一次插入中我们能确保不移动相同元素的顺序,但在多次的插入中,相同元素完全有可能在不同的插入轮次被移动,最后稳定性被破坏
- 适用场景:Shell排序虽然快,但是毕竟是插入排序,其数量级并没有后起之秀–快速排序 快。在大量数据面前,Shell排序不是一个好的算法。但是,中小型规模的数据完全可以使用它
选择排序(Selection Sort)
核心:不断地选择剩余元素中的最小者。
- 找到数组中最小元素并将其和数组第一个元素交换位置
- 在剩下的元素中找到最小元素并将其与数组第二个元素交换,直至整个数组排序
1 | // 选择排序 |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
选择排序 | 不稳定 |
- 稳定性:用数组实现的选择排序是不稳定的,用链表实现的选择排序是稳定的。不过,一般提到排序算法时,大家往往会默认是数组实现,所以选择排序是不稳定的
- 适用场景:选择排序实现也比较简单,并且由于在各种情况下复杂度波动小,因此一般是优于冒泡排序的。在所有的完全交换排序中,选择排序也是比较不错的一种算法。但是,由于固有的O(n2)复杂度,选择排序在海量数据面前显得力不从心。因此,它适用于简单数据排序
冒泡排序(Bubble Sort)
冒泡排序:持续比较相邻元素,大的挪到后面,因此大的会逐步往后挪,故称之为冒泡
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数
- 针对所有的元素重复以上的步骤,除了最后一个
- 重复步骤1~3,直到排序完成
1 | // 冒泡排序 |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | 稳定 |
- 稳定性:在相邻元素相等时,它们并不会交换位置,所以,冒泡排序是稳定排序
- 适用场景:冒泡排序思路简单,代码也简单,特别适合小数据的排序。但是,由于算法复杂度较高,在数据量大的时候不适合使用
- 代码优化:由于在数据基本有序的情况下,性能最好。要使算法在最佳情况下有 复杂度,需要做一些改进,增加一个
swap
的标志,当前一轮没有进行交换时,说明数组已经有序,没有必要再进行下一轮的循环了,直接退出
堆排序(Heap Sort)
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。可以利用数组的特点快速定位指定索引的元素。堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束
下面这个链接我觉得是非常通俗易懂的
堆定义和存储
二叉堆是一种完全二叉树,对于完全二叉树来说,只有树 的最后一层的节点不是满的,其他层都是满的,完全二叉树采用顺序存储,利用数组进行实现
- Node:
- Parent =
- Left child =
- Right child =
建堆
构造最大堆有两种方式:从前往后构造,即从前往后遍历数组元素,每次将新元素插入堆的末尾,不断与父节点比较,大于父节点就交换;第二种是从后往前构建,(从最后一个非叶节点开始)每次将当前节点和左右孩子中较大的那个比较,如果当前节点小于左右孩子中较大的那个,则交换
第一个非叶子节点的下标为`arr.length/2
在堆中定义以下几种操作:其中步骤1是给步骤2和3用的
- 最大堆调整(Max-Heapify):将堆的末端子节点 作调整(从最后一颗子树开始,往前进行筛选),使得子节点永远小于父节点
- 创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆;建堆时可以自顶向下,也可以采取自底向上
- 堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
排序
每次将堆顶和堆中最后一个元素交换,然后堆的大小减1,对堆顶调用heapify
保持堆结构,重复这个过程
可以发现,如果使用最小堆进行排序的话,数组中的元素最后会是逆序的,还要格外的reverse,所以要从小到大排序还是要用最大堆
1 | // heapify 堆调整为大堆顶,即将最大值放在根结点 |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
堆排序 | 不稳定 |
- 稳定性:堆排序存在大量的筛选和移动过程,属于不稳定的排序算法
- 适用场景:堆排序在建立堆和调整堆的过程中会产生比较大的开销,在元素少的时候并不适用。但是,在元素比较多的情况下,还是不错的一个选择。尤其是在解决诸如"前 大的数"一类问题时,几乎是首选算法
快速排序(Quick Sort)
快速排序使用分治法(Divide and conquer)策略来把一个序列(list)分为两个子序列(sub-lists)
- 从数列中挑出一个元素,称为基准(pivot)(有不同选择方法)
- 小于基准的元素放在左半区,大于基准的元素放在右半区。这个称为分区(partition)操作
- 对左半区进行1,2;对右半区进行1,2,递归(recursive)到半区内只有一个元素时返回
一次划分会将一个元素pivot
放置到它最终的位置上
1 | int partition(int arr[], int low, int high) |
这里我选择最后一个元素作为pivot
, 从第一个, 从pivot前一个元素开始遍历: 遇到比pivot
大的元素停下来, 遇到比pivot
小的元素就停下来,若 时,交换i与j的元素;然后继续遍历,直到当 不再满足的时候,可以发现i前面的元素已经全部小于pivot,i后面的元素已经全部大于pivot,然后交换i元素与pivot,此时pivot
放置到它最终的位置上
时间复杂度(平均):
- 每一层划分时间复杂度为
- 平均划分层数为
最坏时间复杂度:,即这个时候原来数组本来就是从小到大顺序,我们选基准元素是最后一个的情况
- 每一层划分时间复杂度为
- 平均划分层数为
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
快速排序 | 不稳定 |
- 稳定性:快速排序并不是稳定的。这是因为我们无法保证相等的数据按顺序被扫描到和按顺序存放
- 适用场景:快速排序在大多数情况下都是适用的,尤其在数据量大的时候性能优越性更加明显。但是在必要的时候,需要考虑下优化以提高其在最坏情况下的性能
归并排序(Merge Sort)
归并排序(Merge Sort),是创建在归并操作上的一种有效的排序算法,算法采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行
归并排序有2种:迭代版和递归版
迭代版本:
- 把长度为 的输入序列分成两个长度为 的子序列
- 对这两个子序列分别采用归并排序
- 将两个排序好的子序列合并成一个最终的排序序列
1 | // 合并 |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
归并排序 | 稳定 |
- 稳定性:因为我们在遇到相等的数据的时候必然是按顺序"抄写"到辅助数组上的,所以,归并排序同样是稳定算法
- 适用场景:归并排序在数据量比较大的时候也有较为出色的表现(效率上),但是,其空间复杂度 使得在数据量特别大的时候(例如,1千万数据)几乎不可接受。而且,考虑到有的机器内存本身就比较小,因此,采用归并排序一定要注意
计数排序(Counting Sort)
计数排序,是一种牺牲内存空间来换取低时间复杂度的排序算法,同时它也是一种不基于比较的算法,即对待排序数组按元素进行计数。使用前提是需要先知道待排序数组的元素范围,将这些一定范围的元素置于新数组中,新数组的大小为待排序数组中最大元素与最小元素的差值
计数排序是桶排序的一种特殊情况,可以把计数排序当成每个桶里只有一个元素的情况
算法描述:
- 定新数组大小:找出待排序的数组中最大和最小的元素
- 统计次数:统计数组中每个值为 的元素出现的次数,存入新数组 的第 项
- 对统计次数逐个累加:对所有的计数累加(从 中的第一个元素开始,每一项和前一项相加)
- 反向填充目标数组:将每个元素 放在新数组的第 项,每放一个元素就将 减去1
1 | def countingSort(array): |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
计数排序 | 稳定 |
整个过程需要遍历两次数组,一次是遍历长度为 的数组,另一次是从计数器(假设有 个计数器)中遍历,因此时间复杂度为 。而在计数排序的过程中用到了长度为 的额外数组,故空间复杂度为 。
- 稳定性:相同的两个元素,经过排序后位置顺序没有变化,稳定
- 适用场景:排序目标要能够映射到整数域,其最大值最小值应当容易辨别。例如高中生考试的总分数,显然用0-750就可以了。计数排序需要占用大量空间,它比较适用于数据比较集中的情况
桶排序(Bucket Sort)
桶排序又叫箱排序,是计数排序的升级版,它的工作原理是将数组分到有限数量的桶子里,然后对每个桶子再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序),最后将各个桶中的数据有序的合并起来
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
桶排序 | 稳定 |
- 稳定性:可以看出,在分桶和从桶依次输出的过程是稳定的。但是,由于我们在对每个桶进行排序时使用了其他算法,所以,桶排序的稳定性依赖于这一步。如果我们使用了快排,显然,算法是不稳定的
- 适用场景:桶排序可用于最大最小值相差较大的数据情况,但桶排序要求数据的分布必须均匀,否则可能导致数据都集中到一个桶中。比如[104,150,123,132,20000], 这种数据会导致前4个数都集中到同一个桶中。导致桶排序失效
基数排序(Radix Sort)
基数排序(Radix Sort)是桶排序的扩展,它的基本思想是:将整数按位数切割成不同的数字,然后按每个位数分别比较
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点)
1 | // A utility function to get maximum value in arr[] |
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
基数排序 | 稳定 |
- 稳定性:通过上面的排序过程,我们可以看到,每一轮映射和收集操作,都保持从左到右的顺序进行,如果出现相同的元素,则保持他们在原始数组中的顺序。可见,基数排序是一种稳定的排序
- 适用场景:基数排序要求较高,元素必须是整数,整数时长度10W以上,最大值100W以下效率较好,但是基数排序比其他排序好在可以适用字符串,或者其他需要根据多个条件进行排序的场景,例如日期,先排序日,再排序月,最后排序年 ,其它排序算法可是做不了的
树(Tree)
树的基本概念
树是一种非线性表结构,树的重要性质:
- 如果树有 个顶点,那么其就有 条边,这说明了树的顶点数和边数是同阶的。
- 任何一个节点到根节点存在唯一路径,路径的长度为节点所处的深度
- 节点的高度:节点到叶子节点的最长路径(边数)
- 节点的深度:根节点到这个节点所经历的边的个数
- 节点的层数:节点的深度 + 1
- 树的高度:根节点的高度
二叉树(Binary Tree)
二叉树的第 层至多有 个节点;深度为 的二叉树至多有 个节点
一棵深度为 , 且有 个节点称之为满二叉树
对于编号为 的节点,若存在,其双亲的编号为 ,左孩子为 ,右孩子为
深度为 ,有 个节点的二叉树,当且仅当其每一个节点都与深度为 的满二叉树中序号为 1 至 的节点对应时,称之为完全二叉树
根节点的高度 = max(左子树高度,右子树高度) + 1
二叉树的遍历
- 前序遍历(Pre-order):根-左-右
- 中序遍历(In-order):左-根-右
- 后序遍历(Post-order):左-右-根
1 | class TreeNode: |
除此之外,二叉树还有层序遍历:
二叉查找树(Binary Search Tree)
二叉查找树(BST)是一棵二叉树,其中每个节点都含有一个可进行比较的键及相应的值,且每个节点的键都大于左子树中的任意节点的键,而小于右子树中的任意节点的键
进行中序遍历,可以得到一个递增的有序数列
二叉排序树相关操作的代码实现:查找,插入,删除
这里二叉排序树的删除:
- 如果要删除的节点是叶节点,即
No Child
,则直接删除,不会破坏二叉排序树的性质 - 如果要删除的节点只有一个子节点,即
One child
,则让删除的节点的子树直接替代它的位置即可 - 如果要删除的节点有2个子节点,即
2 children
,则第一种方案:可以从当前删除节点的右子树中,寻找最小值的节点来替代(也就是右子树按照中序遍历第一个被访问的元素);第二种方案:可以从当前删除节点的左子树中,寻找最大值的节点来替代
关于查找效率分析:
最好情况: 个节点的二叉树最小高度为 。平均查找长度
最坏情况:每个节点只有一个分支,树高 节点数 。平均查找长度
平衡二叉树(AVL)
平衡二叉树(AVL):树上任一节点的左子树和右子树的高度之差不超过1
[110] Balanced Binary Tree
调整不平衡
- LL:在A的左孩子的左子树插入导致A不平衡,将A的左孩子右上旋
- RR:在A的右孩子的右子树插入导致A不平衡,将A的右孩子左上旋
- LR:在A的左孩子的右子树插入导致A不平衡,将A的左孩子的右孩子先左上旋再右上旋
- RL:在A的右孩子的左子树插入导致A不平衡,将A的右孩子的左孩子先右上旋再左上旋
图(Graph)
图的表示通常使用邻接矩阵和邻接表,前者易实现但是对于稀疏矩阵会浪费较多空间,后者使用链表的方式存储信息但是对于图搜索时间复杂度较高
邻接矩阵
设顶点个数为 , 那么邻接矩阵可以使用 的二维数组来表示。 表示顶点 和顶点 的关系,对于无向图可以使用0/1
表示是否有连接,对于带权图则需要使用INF
来区分。有重边时保存边数或者权值最大/小的边即可
1 |
|
1 |
|
邻接表法
图的基本操作
图的广度优先遍历
图的深度优先遍历
最小生成树
最短路径问题
Leetcode 刷题
链表
栈,队列,堆
贪心
递归,回溯,分治
二叉树与图
二分查找与二叉排序树
哈希表与字符串
搜索
动态规划
复杂数据结构
统计素数的个数
统计 N 以内的素数个数(素数:只能被1和自身整除的自然数,0和1除外)
暴力算法:
1 | func isPrime(x int) bool { |
埃筛法: