数据结构和算法笔记

数据结构和算法

  • 基础理解
    • 复杂度
      • 时间复杂度
        • 总的时间复杂度等于量级最大的那段代码,如果有多个量级最大的,并行的那就加,嵌套的那就乘;
        • 复杂度量级
          • 常量阶
            • 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
              • 自己在纸上画一下就知道了
      • 阻塞队列
        • 就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。
      • 并发队列
        • 线程安全的队列
        • 最简单直接的实现方式是直接在enqueue()、dequeue()方法上加锁
      • 实际应用
        • 通过队列实现,排队机制。
          • 资源有限的场景,当没有空闲资源时,用队列实现阻塞的排队机制
    • 跳表(Skip list)
      • 定义
        • 链表加多级索引的结构,从二分查找想过来就很好理解。
      • 特点
        • 一种动态数据结构,支持快速地插入、删除、查找操作。
          • 跳表的实现非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
        • 数据要求:有序
        • 占用内存更多,比链表多一点。(实际中只是多一点点,数据量大才使用它,就那几个节点占的内存可以忽略不计。)
      • 查找
        • 时间复杂度
          • O(logn)
        • 空间复杂度
          • O(n)
            • 在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略
      • 插入和删除
        • 时间复杂度
          • O(logn)
            • O(nlog) + O(1)
    • 散列表(Hash list,哈希表)
      • 由来
        • 散列表来源于数组,它借助散列函数对数组进行扩展,利用的是数组支持按照下标随机访问元素的特性。
        • 简单的例子:数组[25]的下标对应A-Z;
      • 核心问题
        • 散列函数设计
          • 散列函数
            • 就是对数据进行处理,得到散列值的过程
            • 数据 -> 通过散列函数 -> 得到散列值
          • 不能太复杂
          • 散列函数生成的值要尽可能随机并且均匀分布
        • 散列冲突解决
          • 开放寻址法
          • 链表法
            • 时间复杂度
              • 插入
                • O(1)
              • 删除和查找
                • O(k):K:每个槽里链表的长度
        • 散列表(哈希)和字典不是一个东西,虽然都是键值对,但是哈希的键是映射到数组下标的,不占用额外空间的,本质就是个数组。
      • 几个概念
        • 高度
          • 从下到上走的最长路径(最远叶子节点到节点的最长路径)
        • 深度
          • 根节点到这个节点走的边数
        • 节点的层数
          • 节点的深度+1
        • 树的高度
          • 根节点的高度
          • 认为根节点是地面,从根节点往下走就是深度,高度是从叶子节点到改节点。
      • 二叉树
        • 满二叉树
          • 满满的二叉树
        • 完全二叉树
          • 除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列;
      • 存储方式
        • 链式存储
        • 基于数组的顺序存储
          • 存储
            • 下标1
              • 根节点
            • 2*i
              • 左子节点
            • 2*i+1
              • 右子节点
            • i/2
              • 父节点
          • 对于完全二叉树,只浪费了下标0一个空间,但是对于不是的,则浪费了很多空间
      • 二叉树的遍历
        • 前序遍历
          • 对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
          • 根左右
        • 中序遍历
          • 对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
          • 左根右
        • 后序遍历
          • 对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
          • 左右根
        • 层序遍历
          • 就是广度优先遍历在树里的应用
            • 借助队列实现树的广度优先遍历,同时加一层以队列长度进行的for循环来实现输出按层;
        • 注:
          • 对于二叉树来说,如果确定了中序遍历以后,给出任意一种其他的遍历序列,都可以得到唯一的一棵二叉树。在只给出前序遍历和后序遍历的时候,无法确定唯一二叉树。
          • 已知二叉树的前序遍历和中序遍历,如何得到它的后序遍历?
            • 几个特性
              • 对于前序遍历 ,第一个一定是根节点
              • 对于后序遍历,最后一个一定是根节点
              • 利用前序或者后序遍历确定根节点,在中序遍历中,根节点的两边分别是左子树,右子树
              • 递归的对左子树和右子树分别做上面的过程,就可以重建完整的二叉树
          • 不使用递归怎么实现二叉树的遍历
            • 使用stack模拟递归调用的过程,其实和层序遍历很像,就是借助栈实现深度优先遍历。
      • 二叉查找树
        • 二叉搜索树,二叉排序树
          • 二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是O(logn)
        • 定义
          • 1. 左子树上所有节点的值均小于它的根节点的值;
          • 2. 右子树上所有节点的值均大于它的根节点的值;
          • 3. 左右子树也分别为二叉搜索树。
          • 如果有两个值是一样的(是大于小于不含等于,不允许出现两个节点有相同的值)
            • 每个节点存储多个值相同的数据
              • 这样得加以区分,用数组?
            • 每个节点中存储一个数据,遇到和该节点值一样的,就放到该节点右子树的最后的左叶子节点
              • 这样需要改造原来的插入、删除、查找操作
              • 删除和查找是遍历所有的,然后一一操作,插入咋弄?
          • 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n),非常高效
        • 查找
          • 先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。
        • 插入
          • 新插入的数据一般都是在叶子节点上
          • 逻辑和查找一致,只不过插入的时候要先判断插入的位置是不是空
        • 删除
          • 第一种情况是,如果要删除的节点没有子节点,
            • 只需要直接将父节点中,指向要删除节点的指针置为null。
          • 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),
            • 只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
          • 第三种情况是,如果要删除的节点有两个子节点
            • 需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点。
        • 二叉树和散列表
          • 第一,存储数据是不是有序
            • 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。
          • 第二,性能稳定性
            • 散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。
          • 第三,O(logn) 和 O(1)的性能
            • 笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比logn小,所以实际的查找速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
          • 第四,复杂度
            • 散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
      • 平衡二叉查找树
        • 定义
          • 平衡二叉树
            • 二叉树中任意一个节点的左右子树的高度相差不能大于1
              • 完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。
          • 平衡二叉查找树
            • 平衡二叉树 + 二叉查找树
        • 由来
          • 发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。
          • 平衡
            • 平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。
            • “平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重
        • 红黑树
          • 不是严格定义的平衡二叉树,但是是最常用到的
          • 实现
            • 红黑树的平衡过程跟魔方复原非常神似,大致过程就是:遇到什么样的节点排布,我们就对应怎么去调整。
      • 递归树
        • 严格的说不是一个数据结构,是符合递归那个样子的调用结构。
    • 堆(heap)
      • 定义
        • 堆是一种特殊的树
        • 堆是一个完全二叉树
        • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
        • 大顶堆
        • 小顶堆
      • 数组实现
        • 如果下标从1开始
          • 节点
            • i
          • 左子节点
            • i*2
          • 右子节点
            • i*2+1
          • 父节点
            • i/2
        • 如果下标不是从1开始,而是从0开始,就是节点的计算公式有变而已
          • 节点
            • i
          • 左子节点
            • i*2+1
          • 右子节点
            • i*2+2
          • 父节点
            • (i-1)/2
      • 堆的实现
        • 插入一个元素
          • 往堆中插入一个元素后,需要继续满足堆的两个特性。
          • 堆化
            • 就是顺着节点所在的路径,向上或者向下,对比,不满足条件就交换,满足就结束。
            • 从下往上
              • 每次只和父节点比较就行
            • 从上往下
              • 每次需要和左右子节点比较
              • 堆化不是整个不是堆的东西变成堆,而是针对某一个元素使之融入到堆里面,也就是说没有对整个堆堆化的说法,而是对一个元素进行堆化;
          • 把需要插入的元素放到末尾,然后从下往上堆化
        • 删除堆顶元素
          • 把末尾的元素放到堆顶,然后从上往下堆化,
        • 插入和删除时间复杂度
          • O(logn)
        • 注:
          • 末尾:最低层最右边的那个节点,为啥是最右边的节点?这样刚好满足堆是一个完全二叉树。
      • 堆排序
        • 建堆
          • 假设刚开始堆里只有一个元素,就是数组的第一个元素,然后依次取后面的元素,从上往下堆化;
          • 假设数组就是一个堆,然后从后往前处理,依次从下往上堆化,因为只需要从n/2开始到前堆化(叶子节点不需要堆化,完全二叉树从n/2之后都是叶子节点),也就是从n/2开始,依次从上往下堆化,最后整个数组就堆化完毕,只堆化1~n/2个的节点,所以比第一种快;
            • 时间复杂度:O(n)
        • 堆排序
          • 就是依次把堆顶的元素放到(一定是最大或者最小)最后,然后对剩下的元素从上到下对堆化,周而复始直到堆里的元素就剩一个。
            • 如果建堆是大顶堆,那把栈顶放到数组最后,然后对剩下的n-1(大小减1)个元素接着堆化,周而复始,直到堆中就剩下下标为1的一个元素;
        • O(nlogn)
          • O(1)
        • 不稳定
        • 注:
          • 实际开发中,为什么快速排序要比堆排序性能好
            • 第一,对CPU缓存的影响
              • 堆排序数据访问的方式没有快速排序友好,对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。对CPU的缓存和预取不友好;
            • 第二、数据的交换次数
              • 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。
      • 堆的应用
        • 优先级队列
          • 优先级高的数据先出队
            • 实际上,堆就可以看作优先级队列,只是称谓不一样罢了
          • 合并有序小文件
          • 高性能定时器
        • 利用堆求Top K
          • 维护一个大小为K的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。
        • 利用堆求中位数
          • 维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
          • 数据排序
            • 如果有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)
  • 排序
    • 原地排序
      • 冒泡排序(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)
        • 不稳定
      • 为什么用插入不用冒泡
        • 因为每次数据交换插入有一次赋值,冒泡有三次;
    • 两个快的
      • 归并排序(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);
        • 归并是由下到上的,利用递归先处理子问题,然后合并; 快排是由上到下的,也是利用递归,不过是先分区(选点,遍历分区)然后再处理子问题;
    • 线性排序
      • 桶排序(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)
        • 稳定
    • 排序优化
      • 用到递归实现的排序的问题:
        • 警惕函数调用的堆栈溢出
          • 限制递归深度
          • 通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程
      • 数据量小的时候,不一定快排就比插入快
        • 时间复杂度只是一个估计值,体现的是一个趋势,数据量小的时候,省略的部分(低阶、系数、常数)的影响会大,不一定就是快排快;
        • 对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法;
  • 算法
    • 递归
      • 理解
        • 去的过程叫“递”,回来的过程叫“归”
      • 递归需要满足的三个条件
        • 一个问题的解可以分解为几个子问题的解
        • 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
        • 存在递归终止条件
      • 关键
        • 写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。
        • 只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
        • 如果一个问题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和谁异或结果就是谁,并且满足交换律;
      • 链表
        • 删除链表中的节点(无法访问头节点的情况下)
          • 要删除的节点的值赋值为后一个节点,然后要删除的节点指向下下个节点;
        • 反转链表
          • 画个图,在纸上理理,就几个变量的事;
        • 注:
          • 链表的题,复杂的就利用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,这样加了一个变量,写起来更简单,更容易理解;
          • 这题就是一个判断岛屿四面环水的条件
            • 只要深搜的时候,如果岛屿不会扩展到边界外(也就是岛屿不在边界上),那就是封闭的,否则就是不封闭的;
        • 岛屿的周长
          • 遍历每一个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。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值