数据结构和算法
- 基础理解
- 复杂度
- 时间复杂度
- 总的时间复杂度等于量级最大的那段代码,如果有多个量级最大的,并行的那就加,嵌套的那就乘;
- 复杂度量级
- 常量阶
- O(1)
- 对数阶
- O(log n)
- 线性阶
- O(n)
- 线性对数阶
- O(n log n)
- 平方阶,立方阶,K次方阶
- O(n^2)
- 指数阶
- O(2^n)
- 阶乘阶
- O(n!)
- 注:
- 非多项式量级,NP(Non-Deterministic Polynomial,非确定多项式)
- 常量阶
- 分类
- 最好情况时间复杂度
- 最坏情况时间复杂度
- 平均情况时间复杂度,加权平均时间复杂度,期望时间复杂度
- 均摊时间复杂度
- 特殊的平均情况时间复杂度,目前知道有这个就好;
- 注
- 复杂度分析时,如果某个例子很抽象,可以从意义上考虑。比如把平衡二叉树上的所有节点的值相加,想想意义,就是O(N)了。
- 空间复杂度
- 渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。说白了就是指除了原本的数据存储空间外,算法运行还需要额外的存储空间。
- 时间复杂度
- 数据结构和算法
- 数据结构
- 一组数据的存储结构
- 算法
- 操作数据的方法
- 算法是建立在数据结构之上的
- 数据结构
- 常用的数据结构和算法
- 数据结构
- 数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树
- 算法
- 递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配
- 数据结构
- 数据结构和算法的重点
- 定义和来历
- 自身的特点
- 适用的场景
- 适合解决的问题
- 复杂度
- 数据结构
- 数组(array)
- 定义
- 线性表,连续的内存空间,存储一组具有相同类型的数据
- 特性
- 优点
- 随机访问
- 缺点
- 低效的插入和删除
- 优点
- 适用的场景以及能解决的问题
- 查找修改多,插入和删除不多
- 数组下标
- 偏移
- 数组越界
- C/C++里是不会对数组越界进行检查的,越界之后的情况未定义;
- 定义
- 链表(Linked list)
- 定义
- 结点,指针,存储不连续
- 特性
- 优点
- 高效的插入和删除
- 缺点
- 不能随机访问,查找,修改某个值困难
- 优点
- 单链表
- 循环链表
- 从链尾到链头比较方便
- 当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。
- 双向链表
- 支持双向遍历,从后往前找方便
- 数组和链表的比较
- 存储方式
- 数组简单易用,存储在连续的内存空间,有利于CPU的预取,提升CPU的执行效率,链表在存储不连续,对CPU的预取成功率会下降;
- 扩容
- 数组大小固定,扩容麻烦,即便有容器支持扩容,内部也是有扩容的操作,耗时;数组声明过大,也容易出现内存不足的情况;链表的大小没有限制,天然支持扩容;
- 链表的内存占用更多,并且如果进行频繁的删除插入操作,会导致频繁的内存申请和释放,容易造成内存碎片;
- 存储方式
- 编写链表代码
- 理解指针或引用的含义
- 警惕指针丢失和内存泄漏
- 利用哨兵简化实现难度
- 带头链表
- 重点留意边界条件处理
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个结点时,代码是否能正常工作?
- 如果链表只包含两个结点时,代码是否能正常工作?
- 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
- 多写多练,没有捷径
- 单链表反转
- 链表中环的检测
- 两个有序的链表合并
- 删除链表倒数第n个结点
- 求链表的中间结点
- 定义
- 栈(stack)
- 理解
- 栈是一种“操作受限”的线性表
- 后入先出,先入后出
- 适用的场景以及能解决的问题
- 当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,这时我们就应该首选“栈”这种数据结构
- 理解
- 队列(queue)
- 理解
- 队列是一种“操作受限”的线性表
- 后入后出,先入先出
- 边界判断
- 队空
- tail == head
- 队满
- tail == n
- 队真满
- tail == n && head == 0
- 队空
- 循环队列
- 没有BUG
- 确定好队空和队满的判定条件
- 队空
- tail == head
- 队满
- (tail + 1) %n == head
- 自己在纸上画一下就知道了
- (tail + 1) %n == head
- 没有BUG
- 阻塞队列
- 就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。
- 并发队列
- 线程安全的队列
- 最简单直接的实现方式是直接在enqueue()、dequeue()方法上加锁
- 实际应用
- 通过队列实现,排队机制。
- 资源有限的场景,当没有空闲资源时,用队列实现阻塞的排队机制
- 通过队列实现,排队机制。
- 理解
- 跳表(Skip list)
- 定义
- 链表加多级索引的结构,从二分查找想过来就很好理解。
- 特点
- 一种动态数据结构,支持快速地插入、删除、查找操作。
- 跳表的实现非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
- 数据要求:有序
- 占用内存更多,比链表多一点。(实际中只是多一点点,数据量大才使用它,就那几个节点占的内存可以忽略不计。)
- 一种动态数据结构,支持快速地插入、删除、查找操作。
- 查找
- 时间复杂度
- O(logn)
- 空间复杂度
- O(n)
- 在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略
- O(n)
- 时间复杂度
- 插入和删除
- 时间复杂度
- O(logn)
- O(nlog) + O(1)
- O(logn)
- 时间复杂度
- 定义
- 散列表(Hash list,哈希表)
- 由来
- 散列表来源于数组,它借助散列函数对数组进行扩展,利用的是数组支持按照下标随机访问元素的特性。
- 简单的例子:数组[25]的下标对应A-Z;
- 核心问题
- 散列函数设计
- 散列函数
- 就是对数据进行处理,得到散列值的过程
- 数据 -> 通过散列函数 -> 得到散列值
- 不能太复杂
- 散列函数生成的值要尽可能随机并且均匀分布
- 散列函数
- 散列冲突解决
- 开放寻址法
- 链表法
- 时间复杂度
- 插入
- O(1)
- 删除和查找
- O(k):K:每个槽里链表的长度
- 插入
- 时间复杂度
- 散列函数设计
- 注
- 散列表(哈希)和字典不是一个东西,虽然都是键值对,但是哈希的键是映射到数组下标的,不占用额外空间的,本质就是个数组。
- 由来
- 树
- 几个概念
- 高度
- 从下到上走的最长路径(最远叶子节点到节点的最长路径)
- 深度
- 根节点到这个节点走的边数
- 节点的层数
- 节点的深度+1
- 树的高度
- 根节点的高度
- 注
- 认为根节点是地面,从根节点往下走就是深度,高度是从叶子节点到改节点。
- 高度
- 二叉树
- 满二叉树
- 满满的二叉树
- 完全二叉树
- 除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列;
- 满二叉树
- 存储方式
- 链式存储
- 基于数组的顺序存储
- 存储
- 下标1
- 根节点
- 2*i
- 左子节点
- 2*i+1
- 右子节点
- i/2
- 父节点
- 下标1
- 对于完全二叉树,只浪费了下标0一个空间,但是对于不是的,则浪费了很多空间
- 存储
- 二叉树的遍历
- 前序遍历
- 对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
- 根左右
- 中序遍历
- 对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
- 左根右
- 后序遍历
- 对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
- 左右根
- 层序遍历
- 就是广度优先遍历在树里的应用
- 借助队列实现树的广度优先遍历,同时加一层以队列长度进行的for循环来实现输出按层;
- 就是广度优先遍历在树里的应用
- 注:
- 对于二叉树来说,如果确定了中序遍历以后,给出任意一种其他的遍历序列,都可以得到唯一的一棵二叉树。在只给出前序遍历和后序遍历的时候,无法确定唯一二叉树。
- 已知二叉树的前序遍历和中序遍历,如何得到它的后序遍历?
- 几个特性
- 对于前序遍历 ,第一个一定是根节点
- 对于后序遍历,最后一个一定是根节点
- 利用前序或者后序遍历确定根节点,在中序遍历中,根节点的两边分别是左子树,右子树
- 递归的对左子树和右子树分别做上面的过程,就可以重建完整的二叉树
- 几个特性
- 不使用递归怎么实现二叉树的遍历
- 使用stack模拟递归调用的过程,其实和层序遍历很像,就是借助栈实现深度优先遍历。
- 前序遍历
- 二叉查找树
- 二叉搜索树,二叉排序树
- 二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是O(logn)
- 定义
- 1. 左子树上所有节点的值均小于它的根节点的值;
- 2. 右子树上所有节点的值均大于它的根节点的值;
- 3. 左右子树也分别为二叉搜索树。
- 如果有两个值是一样的(是大于小于不含等于,不允许出现两个节点有相同的值)
- 每个节点存储多个值相同的数据
- 这样得加以区分,用数组?
- 每个节点中存储一个数据,遇到和该节点值一样的,就放到该节点右子树的最后的左叶子节点
- 这样需要改造原来的插入、删除、查找操作
- 删除和查找是遍历所有的,然后一一操作,插入咋弄?
- 每个节点存储多个值相同的数据
- 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n),非常高效
- 查找
- 先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。
- 插入
- 新插入的数据一般都是在叶子节点上
- 逻辑和查找一致,只不过插入的时候要先判断插入的位置是不是空
- 删除
- 第一种情况是,如果要删除的节点没有子节点,
- 只需要直接将父节点中,指向要删除节点的指针置为null。
- 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),
- 只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
- 第三种情况是,如果要删除的节点有两个子节点
- 需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点。
- 第一种情况是,如果要删除的节点没有子节点,
- 二叉树和散列表
- 第一,存储数据是不是有序
- 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。
- 第二,性能稳定性
- 散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。
- 第三,O(logn) 和 O(1)的性能
- 笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比logn小,所以实际的查找速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
- 第四,复杂度
- 散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
- 第一,存储数据是不是有序
- 二叉搜索树,二叉排序树
- 平衡二叉查找树
- 定义
- 平衡二叉树
- 二叉树中任意一个节点的左右子树的高度相差不能大于1
- 完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。
- 二叉树中任意一个节点的左右子树的高度相差不能大于1
- 平衡二叉查找树
- 平衡二叉树 + 二叉查找树
- 平衡二叉树
- 由来
- 发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。
- 平衡
- 平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。
- “平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重
- 红黑树
- 不是严格定义的平衡二叉树,但是是最常用到的
- 实现
- 红黑树的平衡过程跟魔方复原非常神似,大致过程就是:遇到什么样的节点排布,我们就对应怎么去调整。
- 定义
- 递归树
- 严格的说不是一个数据结构,是符合递归那个样子的调用结构。
- 几个概念
- 堆(heap)
- 定义
- 堆是一种特殊的树
- 堆是一个完全二叉树
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
- 大顶堆
- 小顶堆
- 数组实现
- 如果下标从1开始
- 节点
- i
- 左子节点
- i*2
- 右子节点
- i*2+1
- 父节点
- i/2
- 节点
- 如果下标不是从1开始,而是从0开始,就是节点的计算公式有变而已
- 节点
- i
- 左子节点
- i*2+1
- 右子节点
- i*2+2
- 父节点
- (i-1)/2
- 节点
- 如果下标从1开始
- 堆的实现
- 插入一个元素
- 往堆中插入一个元素后,需要继续满足堆的两个特性。
- 堆化
- 就是顺着节点所在的路径,向上或者向下,对比,不满足条件就交换,满足就结束。
- 从下往上
- 每次只和父节点比较就行
- 从上往下
- 每次需要和左右子节点比较
- 注
- 堆化不是整个不是堆的东西变成堆,而是针对某一个元素使之融入到堆里面,也就是说没有对整个堆堆化的说法,而是对一个元素进行堆化;
- 把需要插入的元素放到末尾,然后从下往上堆化
- 删除堆顶元素
- 把末尾的元素放到堆顶,然后从上往下堆化,
- 插入和删除时间复杂度
- O(logn)
- 注:
- 末尾:最低层最右边的那个节点,为啥是最右边的节点?这样刚好满足堆是一个完全二叉树。
- 插入一个元素
- 堆排序
- 建堆
- 假设刚开始堆里只有一个元素,就是数组的第一个元素,然后依次取后面的元素,从上往下堆化;
- 假设数组就是一个堆,然后从后往前处理,依次从下往上堆化,因为只需要从n/2开始到前堆化(叶子节点不需要堆化,完全二叉树从n/2之后都是叶子节点),也就是从n/2开始,依次从上往下堆化,最后整个数组就堆化完毕,只堆化1~n/2个的节点,所以比第一种快;
- 时间复杂度:O(n)
- 堆排序
- 就是依次把堆顶的元素放到(一定是最大或者最小)最后,然后对剩下的元素从上到下对堆化,周而复始直到堆里的元素就剩一个。
- 如果建堆是大顶堆,那把栈顶放到数组最后,然后对剩下的n-1(大小减1)个元素接着堆化,周而复始,直到堆中就剩下下标为1的一个元素;
- 就是依次把堆顶的元素放到(一定是最大或者最小)最后,然后对剩下的元素从上到下对堆化,周而复始直到堆里的元素就剩一个。
- O(nlogn)
- O(1)
- 不稳定
- 注:
- 实际开发中,为什么快速排序要比堆排序性能好
- 第一,对CPU缓存的影响
- 堆排序数据访问的方式没有快速排序友好,对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。对CPU的缓存和预取不友好;
- 第二、数据的交换次数
- 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。
- 第一,对CPU缓存的影响
- 实际开发中,为什么快速排序要比堆排序性能好
- 建堆
- 堆的应用
- 优先级队列
- 优先级高的数据先出队
- 实际上,堆就可以看作优先级队列,只是称谓不一样罢了
- 合并有序小文件
- 高性能定时器
- 优先级高的数据先出队
- 利用堆求Top K
- 维护一个大小为K的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。
- 利用堆求中位数
- 维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
- 数据排序
- 如果有n个数据,n是偶数,那前n/2个数据存储在大顶堆中,后n/2存在小顶堆中,如果n是奇数,前n/2 + 1存在大顶堆中,后n/2存在小顶堆中;
- 进来一个数据,比较,如果比小于等于大顶堆,就放到大顶堆中,否则放到小顶堆
- 为了维护之前的条件,可以从一个堆中不停地将堆顶元素移动到另一个堆(判断是不是满足约定),通过这样的调整,来让两个堆中的数据满足上面的约定。
- 进来一个数据,比较,如果比小于等于大顶堆,就放到大顶堆中,否则放到小顶堆
- 如果有n个数据,n是偶数,那前n/2个数据存储在大顶堆中,后n/2存在小顶堆中,如果n是奇数,前n/2 + 1存在大顶堆中,后n/2存在小顶堆中;
- 注:后n/2的数据为啥一定要存成小堆顶:
- 是为了动态的平衡,后n/2的数据都是大于大顶堆的,如果后面的数据多了,应该把那个数据存到大顶堆呢?就是后面的数据最小的那个,也就是小顶堆的堆顶;
- 优先级队列
- 定义
- 图(graph)
- 定义
- 图是由一组节点(或顶点)和一组边组成的数据结构。节点表示图中的对象,边表示节点之间的关系。
- 相关概念
- 顶点
- 边
- 度
- 入度
- 出度
- 入度
- 度
- 边
- 顶点
- 分类
- 无向图
- 有向图
- 带权图
- 稀疏图
- 存储方式
- 邻接矩阵(数组)
- 优点
- 查询效率高,而且方便矩阵运算
- 存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。
- 计算方便
- 查询效率高,而且方便矩阵运算
- 缺点
- 浪费内存
- 优点
- 邻接表
- 邻接表的存储方式比较节省存储空间,但链表不方便查找,所以查询效率没有邻接矩阵存储方式高。针对这个问题,邻接表还有改进升级版,即将链表换成更加高效的动态数据结构,比如平衡二叉查找树、跳表、散列表等。
- 邻接矩阵(数组)
- 广度优先算法(BFS:Breadth-First-Search)
- 通俗的理解就是,地毯式层层推进,从起始顶点开始,依次往外遍历。广度优先搜索需要借助队列来实现,遍历得到的路径就是,起始顶点到终止顶点的最短路径。
- 时间复杂度O(E),空间复杂度O(V)
- E:边数;V:顶点的数
- 深度优先算法(DFS:Depth-First-Search)
- 一条道走到黑,如果走到底或者走错了,就退回来,接着下一条
- 深度优先搜索用的是回溯思想,借助递归实现。换种说法,深度优先搜索是借助栈来实现的。
- 时间复杂度O(E),空间复杂度O(V)
- 定义
- 数组(array)
- 排序
- 原地排序
- 冒泡排序(bubble sort)
- 每次比较两个相邻的值,进行交换,直到不发生数据交换;
- 遍历的过程中,是后面的先排好序,因为每次一通交换会把最大的交换到最后面。所以i:[0, n)。j:[0, n - i - 1)。
- O(n^2)
- O(1)
- 稳定
- 每次比较两个相邻的值,进行交换,直到不发生数据交换;
- 插入排序(insert sort)
- 整个区间分为有序区和非有序区,取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,直到非有序区空;
- 有序区的插入,可以从后往前遍历,遇到条件就交换,直到不交换了,也就插入到了合适的位置。所以i:[1, n)j:[i - 1, 0]
- O(n^2)
- O(1)
- 稳定
- 整个区间分为有序区和非有序区,取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,直到非有序区空;
- 选择排序(select sort)
- 一种加了限制的插入排序
- 整个区间分为有序区和非有序区,取非有序区最小的值放到有序区的末尾,直到非有序区空;
- 每次都是从后面选一个最小的放到前面,所以i[0, n),j[i, n)
- O(n^2)
- O(1)
- 不稳定
- 为什么用插入不用冒泡
- 因为每次数据交换插入有一次赋值,冒泡有三次;
- 冒泡排序(bubble sort)
- 两个快的
- 归并排序(merge sort)
- 对于一个数组,把数组从中间分成前后两部分,然后对两部分分别排序,最后再合并到一起,周而复始;
- 分治是一种解决问题的处理思想,递归是一种编程技巧
- 算法实现
- 递归 + 合并函数
- 合并函数
- 建立一个临时数组,遍历需要合并两个数组,谁小把谁放入临时数组,最后把剩下的那个数组放入临时数组,然后再把临时数组放回原数组;(除了临时数组,其余传下标原地就行)
- 合并函数
- 递归 + 合并函数
- O(nlogn):最好,最坏,平均都是
- O(n)
- 稳定
- 快速排序(quick sort)
- 排序数组中下标从p到r之间的一组数据,选择p到r之间的任意一个数据作为pivot(分区点,一般选数组的最后一个元素),遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间,直到区间缩小到1,不能在分;
- 算法实现
- 原地分区函数 + 递归
- 原地分区函数
- 选择最后一个元素为分区点,i = j = left,j++遍历第一个到分区点前的元素,i时时记录比分区点大的值。也就是说只要aar[j] < 分区点,aar[i] aar[j]交换位置,就是把大的放后面,小的放前面,i++。这样最后i的位置就是最后一个大于分区点的值,也就是处理完应该是分区点的位置,再和和本来的分区点交换位置。
- 本质上就是遍历的时候把大的用i记着,然后遇到小的和大的交换,这样最后就是前面都是小的,后面都是大的;
- 原地分区函数
- 原地分区函数 + 递归
- O(nlogn):最好是,平均是,最坏是O(n^2),只有极端情况下是,概率很小,可以通过合理地选择pivot来避免这种情况?
- 三数取中
- 第一个,最后一个,中间的,最后比较取值是中间的。
- 随机法
- 三数取中
- O(1)
- 不稳定
- 归并和快排的区别
- 归并的合并函数不能原地实现,空间复杂度是O(n);快排的分区函数可以在原地实现,空间复杂度是O(1);
- 归并是由下到上的,利用递归先处理子问题,然后合并; 快排是由上到下的,也是利用递归,不过是先分区(选点,遍历分区)然后再处理子问题;
- 归并排序(merge sort)
- 线性排序
- 桶排序(bucket sort)
- 特点
- 首先,要排序的数据需要很容易就能划分成m个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
- 其次,数据在各个桶之间的分布是比较均匀的。
- 适用场景
- 桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
- 可以一个桶一个桶排序,最后连到一起;
- 桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
- 核心思想
- 要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
- O(n)
- O(n)
- 稳定
- 特点
- 计数排序(Counting sort)
- 计数排序其实是桶排序的一种特殊情况
- 要排序的n个数据,所处的范围并不大的时候,比如最大值是k,我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
- 适用场景
- 计数排序只能用在数据范围不大的场景中,如果数据范围k比要排序的数据n大很多,就不适合用计数排序
- 计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数(小数乘一个数,负数加一个数)
- 核心思想
- 数据规模不大,例如最大值为K,那就划分K个桶,遍历一遍,放到桶里,桶内都是一样的,不需要排序,然后依次扫描每个桶,输出到一个数组;
- O(n)
- O(n)
- 稳定
- 计数排序其实是桶排序的一种特殊情况
- 基数排序(Radix sort)
- 适用场景
- 需要可以分割出独立的“位”来比较;
- 位之间有递进的关系,比较两个数,只需要比较高位,高位相同的再比较低位。
- 每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到O(n);
- 核心思想
- 使用稳定排序算法,从后到前对每一位进行排序;
- O(n)
- O(n)
- 稳定
- 适用场景
- 桶排序(bucket sort)
- 排序优化
- 用到递归实现的排序的问题:
- 警惕函数调用的堆栈溢出
- 限制递归深度
- 通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程
- 警惕函数调用的堆栈溢出
- 数据量小的时候,不一定快排就比插入快
- 时间复杂度只是一个估计值,体现的是一个趋势,数据量小的时候,省略的部分(低阶、系数、常数)的影响会大,不一定就是快排快;
- 对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法;
- 用到递归实现的排序的问题:
- 原地排序
- 算法
- 递归
- 理解
- 去的过程叫“递”,回来的过程叫“归”
- 递归需要满足的三个条件
- 一个问题的解可以分解为几个子问题的解
- 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
- 存在递归终止条件
- 关键
- 写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。
- 只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
- 如果一个问题A可以分解为若干子问题B、C、D,你可以假设子问题B、C、D已经解决,在此基础上思考如何解决问题A。
- 递归代码要警惕堆栈溢出
- 递归代码要警惕重复计算
- 优点
- 代码简洁
- 缺点
- 堆栈溢出、重复计算、函数调用耗时多、空间复杂度高
- 注:
- 递归的方式隐含地使用了系统的 栈,我们不需要自己维护一个数据结构。
- 理解
- 分治算法
- 定义
- 分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
- 适用场景
- 一个是用来指导编码,降低问题求解的时间复杂度
- 一个是解决海量数据处理问题
- 注:
- 分治算法是一种处理问题的思想,递归是一种编程技巧。
- 定义
- 二分查找(binary search)
- 二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0。
- o(logn)
- 即便n非常非常大,对应的logn也很小,如果我们在42亿个数据中用二分查找一个数据,最多需要比较32次,有可能比O(1)还高效;
- 适用场景
- 二分查找依赖的是顺序表结构,简单点说就是数组。
- 二分查找针对的是有序数据
- 只能用在插入、删除操作不频繁,一次排序多次查找的场景中
- 数据量太小不适合二分查找
- 有一个例外,如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找。比如,数组中存储的都是长度超过300的字符串;
- 数据量太大也不适合二分查找
- 主要是依赖数组,需要连续的存储空间;
- 4种常见的二分查找变形问题
- 查找第一个值等于给定值的元素
- 符合相等的情况下,继续判断是不是第一个,下面三种一样;
- 查找最后一个值等于给定值的元素
- 查找第一个大于等于给定值的元素
- 查找最后一个小于等于给定值的元素
- 查找第一个值等于给定值的元素
- 注:
- while循环结束的条件一定是<=,不然可能会漏,出问题;
- 二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。比如今天讲的这几种变体问题,用其他数据结构,比如散列表、二叉树,就比较难实现;
- 哈希算法
- 定义
- 将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。
- 要求
- 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);
- 对输入数据非常敏感,哪怕原始数据只修改了一个Bit,最后得到的哈希值也大不相同;
- 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
- 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。
- 应用
- 安全加密
- MD5(MD5 Message-Digest Algorithm,MD5消息摘要算法)
- SHA(Secure Hash Algorithm,安全散列算法)
- 唯一标识
- 数据校验
- 下载或者上传前后的数据校验
- 散列函数
- 哈希算法解决分布式问题
- 负载均衡
- 可以通过哈希算法,对客户端IP地址或者会话ID计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。
- 数据分片
- 处理数据太大,分片,然后多台机器分布执行
- 分片
- 我们每次从图库中读取一个图片,计算唯一标识,然后与机器个数n求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。
- 分布式存储
- 一致性哈希算法
- 负载均衡
- 安全加密
- 定义
- 递归
- 贪心回溯动态规划
- 贪心算法
- 使用步骤
- 第一步,当我们看到这类问题的时候,首先要联想到贪心算法:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。
- 第二步,我们尝试看下这个问题是否可以用贪心算法解决:每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据。
- 第三步,我们举几个例子看下贪心算法产生的结果是否是最优的。
- 注:
- 贪心的认为把第一个东东全部填进去就能行,如果不行就把第二个东东全部填进去;前提是这样第一个完事,上第二个这种顺序依次是可以的;
- 使用步骤
- 回溯算法
- 枚举的往下走,能行就接着走,不能行就回溯,退回来接着走;就是深搜多了个回退的动作。
- 适用场景
- 大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。
- 实例
- 深度优先搜索
- 八皇后
- 0-1背包问题
- 图的着色
- 旅行商问题
- 数独
- 全排列
- 正则表达式匹配
- 动态规划
- 动态规划问题的一般形式就是求最值
- 求解动态规划的核心问题是穷举(穷举所有可能性,并存下来,用空间换时间)。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值
- 分析步骤
- 定义子问题
- 如
- 从所有房子能偷到的最大金额
- 从k个房子能偷到的最大金额
- 找出一个具有最大和的连续子数组
- 以k元素结尾的最大和是多少(这个结尾就已经包含和连续的关系)
- 从所有房子能偷到的最大金额
- 如
- 状态转移方程
- 就是当前这个状态和其他的状态有啥关系(和计算机网路的阻塞处理机制贼想)
- 如
- 第k个房子要是偷会咋样,不偷会咋样
- 定义子问题
- 框架
- dp意义的设计和实现
- # 初始化 base case dp[0][0][...] = base # 进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...)
- 注:
- 贪心算法实际上是动态规划算法的一种特殊情况。
- 贪心算法
- 刷题心得
- 获取输入
- 获取输入直到空行结束
- string line; while (getline(cin, line) && !line.empty())
- 获取输入直到空行结束
- 解题
- 亲力亲为
- 构造一个大而好的测例,自己慢慢动手解决一下,然后想想自己是怎么解决的?
- 测例
- 测例不要举一个特殊的,尽量普适。有时想不出来,也可以换几个例子,看是不是能有想法。
- 亲力亲为
- 思考
- 切换主体
- 有时不要盯着题目的描述死看,换一个和题目描述不一样的主体思考一下;
- 例子:交换零,交换零使得所有的0在最后面,同时保证其他元素的相对顺序不变,换个角度,交换非零值,从头到尾把非零值依次移到最前面,这样移动时不会改变非零值的相对顺序,移动完之后,零自然就在数组最后面;
- 例子:数组的两数之和等于目标值,拆一下,可以变成:和减去一个值==剩下的值;
- find一个值
- 如果在数组中find一个值,时间复杂度时O(N),可以想一下是不是能使用哈希,哈希的find时间复杂度是O(1);
- 双指针
- 当我们需要枚举数组中的两个元素时,如果我们发现随着第一个元素的递增,第二个元素是递减的,那么就可以使用双指针的方法;
- 链表的题
- 笨一点,在纸上画画,就OK了;
- 切换主体
- Leetcode初级算法
- 数组
- 买股票的最佳时机II
- 贪心:既然每天都可以买,也可以卖,没有次数限制,那不就是只要后一天比前一天高,就能赚。遍历数组,累加增长就OK;
- 动态规划:dp[0/1][i] 表示第i天有股票和没股票的最大利润,最后最大利润就是dp[0][n - 1]
- 只出现一次的数字
- 因为除了一个元素只出现一次,其余都是出现两次,所有直接遍历求异或,最后的结果就是只出现一次的那个元素;
- 异或,相同是0,0和谁异或结果就是谁,并且满足交换律;
- 买股票的最佳时机II
- 链表
- 删除链表中的节点(无法访问头节点的情况下)
- 要删除的节点的值赋值为后一个节点,然后要删除的节点指向下下个节点;
- 反转链表
- 画个图,在纸上理理,就几个变量的事;
- 注:
- 链表的题,复杂的就利用vector存下来再处理;
- 删除链表中的节点(无法访问头节点的情况下)
- 树
- 验证二叉搜索树
- 别忘了二叉搜索树的性质,中序遍历就是递增数组;
- 多想想递归,想想子问题是啥样的,然后实现子问题就行,别一个劲的一层一层递归的想;
- 验证二叉搜索树
- 动态规划
- 难点就是dp的设计,设计完后就是仔细想想当前都有哪些情况,和边界条件,就完事;
- 其他
- 罗马数字转整数
- IV:遍历的时候,判断I后面是不是V,是就加4;
- 质数
- 遍历从2到根号n就行,根号n可以用 j * j < n表示;
- 罗马数字转整数
- 数组
- Leetcode中级算法
- 数组和字符串
- 三数之和
- 排序之后,第二层循环和第三层循环使用双指针,一个增,一个减,合并为一个循环;注意去重(第一层循环,和第二层循环保证这一次的值和上一次的值不相等)
- 字母异位词分组
- 这个题是把现有的词,按照异位词分组;词只要排序之后一样,那就是异位词,可以用排序后的词做哈希的键,然后遍历分组就行;
- 无重复字符的最长子串
- 滑动窗口:类似一个窗口往后滑动,左指针动,完事右指针动;
- 递增的三元子序列
- 这个题的核心思想:找到一个值,左边有比他小的,右边有比他大的,则true;
- 双向遍历:维护两个数组rMin[i] rMax[i],分别记录以i点左边的最小值,和最大值,然后遍历数组,如果存在i大于i的最小,小于i的最大,则true;
- 贪心算法:遍历数组,维护两个数(first second second比first大),如果第三个数大于第二个数则true,如果第三个数大于第一个数小于第二个数,则第二个数等于第三个数,如果第三个数小于第一个数,则更新第一个数;(这个方法有一个隐藏,顺序遍历数组的时候,默认下标识增的;)
- 三数之和
- 树和图
- 从前序与中序遍历序列构造二叉树
- 就是树的遍历的特性,前序遍历的第一个一定是根,中序遍历里根两边的一定是左子树和右子树;
- 从前序与中序遍历序列构造二叉树
- 回溯算法
- 括号生成
- 设计一个递归函数,利用回溯实现括号列表每次添加“(”或者“)”。注意终止条件(生成的长度够并且是符合成对的);
- 全排列
- 扫描数组,判断,没选的就选,然后回溯,然后接着选,已经选了的就不选了(也就不回溯,不选其实就是过,啥也没干,也就不回溯);注意终止条件(选的长度够了)
- 注:可以一个unordered_set来做记录,判断选没选。(哈希无序集合的插入,删除,查找都是O(1))
- 子集
- 用回溯的思想实现一句话,遍历数组的每个元素,每个元素有两种选择,选和不选;
- 注
- 回溯如果有个记录结果的临时数组,这个数组是怎么清零的?
- 回溯本身的过程就有清零的操作,一条到走到黑,走错就回来,回来回来的,会回到出发的地方。
- 回溯如果有个记录结果的临时数组,这个数组是怎么清零的?
- 括号生成
- 排序和搜索
- 合并区间
- 排序之后,区间是不是需要合并就清楚了;
- 合并区间
- 搜索二维矩阵
- 对每一个一维数组进行二分查找;
- 动态规划
- 跳跃游戏
- 累加跳跃,定义sum是整个数组能跳跃的最大距离,sum = max(每个点能到那,sum),如果跳跃的长度大于等于数组的长度,那就可以跳到最后;
- 零钱兑换
- dp[i]记录从1到目标金额的每个金额使用的最少零钱数量(零钱+1, 零钱+1)
- 金额从1到目标值遍历,然后遍历每一种零钱,如果零钱比目标值小于(或者等于)(还可以找零钱),那dp[i] = 不用这个零钱(dp[i]本来的值)和 用这个零钱(dp[i - 零钱] + 1) 里小的;
- 找不零的怎么半?
- dp的初值设为目标值+1,找不零,就没有最小值,最后dp[目标值]就还是目标值+1。
- 最长递增子序列
- dp[i]记录以i结尾的最长递增子序列的长度,dp[i] = max(dp[i], dp[i前面所有比i小的] + 1)
- 跳跃游戏
- 数组和字符串
- 岛屿问题
- 岛屿的深搜问题
- 遍历整个二维数组,然后如果是岛屿,进行深搜(四个方向),根据题目要求设定条件什么样的情况下,继续往下走,同时注意避免重复搜索;
- 岛屿的最大面积
- 遍历整个二维数组,如果是陆地,对岛进行深搜并且计数(如果不在范围内return,不是岛retrun,已经遍历过retrun,每遍历一个count++,同时对遍历过的点重新赋值,避免重复遍历。)遍历完整个二维数组,记录最大的面积;
- 岛屿的数量
- 同上;
- 统计封闭岛屿的数量(岛四周完全被水包裹)
- 遍历整个二维数组,如果是陆地,对岛进行深搜,在深搜内判断岛屿是不是四面环水,如果岛屿不会扩展到边界外,那就是封闭的,否则就是不封闭的;(如果到边界之外,return false,否则return true,但是因为是深搜是递归实现的,所以不能直接else return true那样没遍历到的陆地就不会遍历到,所有实际的,如果遍历到的节点不是陆地或者已经遍历过了,return true。最后return所有四个方向遍历的ret且之后的结果,四个方向都是true为true,否则就是false)
- 也可以设个标志,在深搜的时候,如果到边界之外就置标志为false,这样加了一个变量,写起来更简单,更容易理解;
- 这题就是一个判断岛屿四面环水的条件
- 只要深搜的时候,如果岛屿不会扩展到边界外(也就是岛屿不在边界上),那就是封闭的,否则就是不封闭的;
- 遍历整个二维数组,如果是陆地,对岛进行深搜,在深搜内判断岛屿是不是四面环水,如果岛屿不会扩展到边界外,那就是封闭的,否则就是不封闭的;(如果到边界之外,return false,否则return true,但是因为是深搜是递归实现的,所以不能直接else return true那样没遍历到的陆地就不会遍历到,所有实际的,如果遍历到的节点不是陆地或者已经遍历过了,return true。最后return所有四个方向遍历的ret且之后的结果,四个方向都是true为true,否则就是false)
- 岛屿的周长
- 遍历每一个1,对每一个1都走一次四个方向,如果超出边界,或者走到了水里,就贡献一条边;
- 统计子岛屿的数量
- 两个grid,一个grid的岛屿完全包含在另一个grid的岛屿里,就叫子岛屿;
- 对grid2深搜,看grid2岛屿的每一个陆地的点grid1是不是也有;
- 最大人工岛
- 暴力 + 岛屿的最大面积(这个会超时,标记的那套没理解,以后再看)
- 太平洋大西洋水流
- 从边界进行深搜,分别记录流向太平洋和流向大西洋的格子,然后取并集;深搜的时候条件是没出边界,并且下一个比现在的更高,就继续,注意一个条件边界本来就默认能流向相邻的海洋;
- 岛屿的广搜问题
- 代码框架
- while queue 非空: node = queue.pop() n = queue.size() 循环n次: for node 的所有相邻节点m: if m 未访问过: queue.push(m)
- 首先把所有的陆地放到队列里,然后利用队列的大小控制层数(循环n次),从队列里取出这一层的节点,扫描添加下一层到队列里,根据题目要求设定条件,注意避免重复搜索;
- 距离海洋最远的陆地
- 首先把所有的陆地放到队列里,然后利用队列的大小控制层数(循环n次),从队列里取出这一层的节点,广搜海洋格子,直到广搜完所有的海洋,就是最远距离,注意避免重复遍历;
- 最短的桥(两个岛屿之间的最短距离)
- 先把一个岛屿的全部陆地格子放到队列里,然后利用队列的大小控制层数(循环n次),进行广搜,直到第一次遇见陆地,就是最近距离,注意避免重复遍历;
- 怎么把第一个岛屿放到队列里:
- 两个队列,先放第一个岛屿的一个点进第一个队列,然后利用第一个队列广搜这个岛屿,把每个点存到第二个队列里。
- 代码框架
- 注:
- 图的遍历和二叉树本质是一样的,二叉树就俩分支,遍历左和右就行,图有四个分支,就遍历四个分支;
- 很多问题,深搜和广搜都可以解决,只是看那个实现方便,速度快,使用内存小罢了(广搜需要一个队列辅助实现),所有深搜的问题都可以用广搜解决;
- 图的深搜和广搜区别
- 深搜就是一个一个搜,四个方向走,但是在一个方向上走到底才会走另一个;
- 广搜是一次把这一层都遍历完,然后再遍历下一层,如果不用队列控制层,就是四个方向同时走,也是广搜;
- 岛屿的深搜问题
- 数独
- 有效数独(检查给的数独是不是满足数独规则的)
- 暴力解
- 扫描每一个点,分别检查该点所在的每行,每列,每个33单元是不是满足条件。
- 扫描整个二维数组三次,每次分别检查每行,每列,每33单元格是不是满足要求。(哈希数组,1-9都只能出现一次)
- 遍历一次
- 构建3个哈希数组(因为数字只有1-9,所以直接用数组就行),rows[9][9],cols[9][9],subs[3][3][9],分别记录每行,每列,每33单元内的1-9出现了几次,出现2次就false。
- 暴力解
- 解数独
- 暴力回溯
- 遍历数独,遇到空的就1-9都填一遍,检查在当前row,col,num下是否合法,合法就填,然后着递归,然后回溯。
- 结束条件,三个return的位置
- 填数合法之后的递归,要检查是不是不需要递归了,检查递归函数的返回值是不是true,是就return;(实际是return true)
- 没解的标志:从1~9都填了一遍,没有一个合法的。(也就是说,每个数都试了一遍,循环里面没有return true.)
- 填满的标志:遍历完整个数独了,进行了所有的操作了,没有return false,那就return true。
- 暴力回溯
- 有效数独(检查给的数独是不是满足数独规则的)
- 获取输入
1766

被折叠的 条评论
为什么被折叠?



