LeetCode刷题总结文档

前言

本文的刷题顺序依照代码随想录进行,因此题目板块的划分也和代码随想录一致。每个版块我会按照以下内容进行组织:

  • 该类型题目的特征
  • 时间复杂度
  • 值得一讲的相关题目知识

    文章目录

      • 前言
      • 正文
        • 数组
          • 二分查找
          • 移除元素 & 有序数组的平方 & 长度最小的子数组
          • 螺旋矩阵
          • 总结
        • 链表
          • 设计链表
          • k个一组翻转链表
          • 环形链表 & 删除倒数第k个链表节点
          • 总结
        • 哈希表
          • 字符匹配
          • 数组k个元素之和等于特定值
          • 总结
        • 字符串
          • 反转字符串
          • 字符串匹配(比哈希表中的字符匹配更为复杂)
          • 总结
        • 栈和队列
          • 栈的相关应用
          • 队列的相关应用
        • 二叉树
          • 深度优先遍历
          • 广度优先遍历(层序遍历)
          • 二叉树解题技巧
          • 总结
        • 回溯算法
          • 组合问题
          • 排列问题
          • 有向图的所有可行路径
          • 棋盘问题
          • 总结
        • 贪心算法
          • 对有左右比较关系的数组使用贪心算法
          • 对进行包含关系的数组使用贪心算法
          • 巧用状态转移,简化贪婪判断
        • 动态规划
          • 类型题总结1------背包问题
          • 类型题总结2------股票买卖的最佳时机
          • 类型题总结3------子序列与子数组相关问题

正文

数组

二分查找

  • 特征:有序数组中找特定组合快速查询方法
  • 时间复杂度: O ( l o g n ) O(logn) O(logn)
  • 值得一讲的相关题目知识:
    • 对于求平方根的题目:#69 x的平方根和#367 有效的完全平方数
      • 如果是截取式返回,便可以将其转化成一个查找问题,采用二分法快速定位到应截取的整数平方根;也可以采用牛顿迭代法, x n + 1 = x n − f ( x n ) / f ′ ( x n ) x_{n+1}=x_n-f(x_n)/f'(x_n) xn+1=xnf(xn)/f(xn),但需要注意的是,面对截取式返回而采用牛顿迭代法,必须保证f(x)的单调性,同时初始取值必须大于截取的整数平方根,否则可能出现1.99999被截取为1的情形(正确值应该是2)

移除元素 & 有序数组的平方 & 长度最小的子数组

  • 特征
    • 要求只遍历一遍数组就解决问题,不要重复遍历
    • 要求原地操作,最好不使用新的空间来存储数组
  • 时间复杂度: O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
  • 值得一讲的相关题目知识:
    • 该类题目在只遍历一遍数组的限制下,核心要义就是使用双指针,通过指针的不同作用,来完成原地操作更新值的需求。
      • 比如#26 删除有序数组中的重复项和#283 移动零使用的快慢指针,快指针用来遍历数组,慢指针则用来指向更新位置
      • 或者#977 有序数组的平方中双指针分别指向首和尾,从首尾两个方向依序从大到小插入至新数组
      • 又如#209 长度最小的子数组,采用滑动窗口思想,双指针分别代表窗口的起始和终止位置,窗口内的累加和始终保持小于目标值的状态,每遇到一次大于等于情况发生,记录一次最小长度
      • 还有#76 最小覆盖子串,也是采用滑动窗口思想,窗口内始终保持小于覆盖目标字符串状态

螺旋矩阵

  • 特征
    • 要求按照→↓←↑的顺序遍历二维数组(因为该顺序像螺旋一样,故得名)
  • 时间复杂度: O ( n 2 ) O(n^2) O(n2),空间复杂度: O ( 1 ) O(1) O(1)
  • 我的解题思路:在这里,我对边界的设置与代码随想录的想法不同,左右视为列遍历,由column控制for循环上限;上下视为行遍历,由row控制for循环上限。每执行完一次列/行遍历,row/column减一上限。直至row或column其中一个为0。该方案的好处是完全不需要关心矩阵最后一行/列如何特殊处理,都是由一个统一原则控制,代码如下

总结

数组部分的困难题目主要体现在字符串的各种操作上,如#76 最小覆盖子串这种。这种类型虽然难,但我总结出了两条通用的技巧:

  • 大多采用滑动窗口法,通过滑动窗口来保证一次遍历完成任务
  • 记住ASCII码上限为128,可通过 i n t [ 128 ] int[128] int[128]来表示所有ASCII的映射,比利用 M a p Map Map进行相关操作更加简洁

另外,数组结构在 J a v a Java Java中作为以下类的底层实现:

  • A r r a y L i s t ArrayList ArrayList:所以该类的删除操作时间复杂度为 O ( n ) O(n) O(n),这是在编程中经常被忽视的一点

LeetCode刷题总结文档_第1张图片

链表

设计链表

  • 特征:
    • 全面的链表基础题,要求完成链表的增删查功能
  • 时间复杂度:查询为 O ( n ) O(n) O(n),增删操作为 O ( 1 ) O(1) O(1)(这是链表结构的特点,查询慢,增删快
  • 值得一讲的相关题目知识:
    • #707 设计链表这个题有一个需要注意的点,Java中实例化对象是存储存在堆里的,而这部分内存将由JVM的垃圾回收机制在特定的时刻回收。因此,这里为了做到删除链表节点,必须把对该节点的所有强引用置为null

k个一组翻转链表

  • 特征:
    • 要求一次扫描完成k个一组的翻转,同时要保证在最后剩余节点总数小于k时,不翻转
    • 要求原地操作,且必须移动节点完成翻转而不是交换值
  • 时间复杂度: O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
  • 值得一讲的相关题目知识:
    • 这是一类题,简单的如#206 反转链表(此时k为无限大);中等难度的如#24 两两交换链表中的节点(此时k为2);困难的如#25 K 个一组翻转链表(此时k为任意值)
    • 要解决这类题,抓住两个指导思想:
      • k个一组形成子串,子串应该独立翻转,即翻转时应该认为每一子串都是断开
      • 一组子串的翻转完成后,应该完成两件事:一是串联上一子串的尾节点;二是记录当前字串的尾节点

环形链表 & 删除倒数第k个链表节点

  • 特征:
    • 要求一次扫描,完成对应条件节点的检索
    • 要求原地操作
  • 时间复杂度: O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
  • 值得一讲的相关题目知识:
    • 这里我们仅扩展#142 环形链表II,因为它包含了两类双指针应用:
      • 快慢指针:快指针遍历速度是慢指针的两倍,从而保证快指针更快到达环内,且在环形内时,每轮移动靠近慢指针的速度为1(不会错过慢指针)
      • 间隔指针:由于要求一次扫描,而链表又是难以回溯和直接定位的,所以通过间隔指针来同步扫描链表,从而同时定位相隔k个距离的节点;这种思想在#19 删除链表的倒数第 N 个结点中也有所体现

总结

链表一节,核心有两个:

  • 应用虚拟头节点,解决头节点的pre节点不存在的孤立现象
  • 应用双指针法(我们介绍了两类双指针,分别是快慢指针以及间隔指针),解决一次扫描与原地操作限制

另外,链表结构在 J a v a Java Java中作为以下类的底层实现:

  • L i n k e d L i s t LinkedList LinkedList:所以该类的查询操作时间复杂度为 O ( n ) O(n) O(n),应尽量避免使用 L i n k e d L i s t LinkedList LinkedList结构来设计查询过多的业务

LeetCode刷题总结文档_第2张图片

哈希表

h a s h T a b l e I D = h a s h C o d e ( k e y ) % t a b l e S i z e hashTable ID = hashCode(key) \% tableSize hashTableID=hashCode(key)%tableSize
综上,哈希表就是一个保证 O ( 1 ) O(1) O(1)查找时间复杂度的工具,也因此被大量用于查找特定元素存在与否的场景

字符匹配

  • 特征:
    • 给定两个字符串,对字符串进行规定的匹配操作
  • 时间复杂度: O ( n ) O(n) O(n),空间复杂度: O ( n ) O(n) O(n)
  • 值得一讲的相关题目知识:
    • 这里涉及到的题目有#242 有效的字母异位词,#383 赎金信。这类题目的关键在于认识到字符匹配可以先用哈希表收集字符串各个字符的情况,然后再通过哈希表快速查找某个字符是否出现。这样做可以将 O ( n 2 ) O(n^2) O(n2)的时间复杂度缩小至 O ( n ) O(n) O(n)。此外,对于字符匹配,使用数组结构做哈希表要远比Map运行速度更快。

数组k个元素之和等于特定值

  • 特点:
    • 要求返回所有可能的元素组合,而不是位置组合
    • 要求元素组合去重
  • 时间复杂度: O ( n k − 1 ) O(n^{k-1}) O(nk1),空间复杂度: O ( 1 ) O(1) O(1)
  • 值得一讲的相关题目知识:
    • 例如#15 三数之和,#18 四数之和,解题思路抓住两个关键点:
      • (1)如何去重:元素按 i 1 < . . . . < i k i_1<....i1<....<ik的顺序,依次加入for循环,保证元素组合 ( i 1 , . . . , i k ) (i_1,...,i_k) (i1,...,ik)是从小到大递增的,此其一;限制 i 1... k ( t ) ! = i 1... k ( t − 1 ) i_{1...k}(t)!=i_{1...k}(t-1) i1...k(t)!=i1...k(t1),保证同位置元素不重复,此其二。
      • (2)如何精简查询过程:先排序,保证数组从小到大;固定前k-2个元素,第k-1和k个元素满足互斥性,即为了保证和为特定值,k-1增大必定导致k减小。因此前者从前往后遍历,后者从后往前遍历,相交时则证明k-1继续向后将无法找到满足和为特定值的组合,退出对k-1的遍历。双指针的使用将两层for循环缩小至一层

总结

哈希表一节,核心有两个:

  • 掌握哈希特征:用于根据关键词快速查询,查询时间复杂度为 O ( 1 ) O(1) O(1)
  • 掌握三类哈希结构数组型——解决字符统计问题;Set型——解决去重统计问题;Map型——解决需要明确的Key-Value结构问题

由于Map和Set都需要额外维护哈希表,因此不管是修改还是查询速度,两者都是要慢于数组型的

如上所述,哈希结构在 J a v a Java Java中作为以下类的底层实现:

  • H a s h S e t HashSet HashSet:作用为去重统计和 O ( 1 ) O(1) O(1)查询
  • H a s h M a p HashMap HashMap:作用为Key-Value映射和 O ( 1 ) O(1) O(1)查询

字符串

反转字符串

  • 特征:
    • 给定一个字符串,要求按规定进行整体或局部反转
  • 时间复杂度: O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
  • 值得一讲的相关题目知识:
    • 仅包含整体反转或仅包含局部反转
      • 整体反转如#344 反转字符串:利用双指针,对反转对应位置进行值交换,直到前指针大于等于后指针
      • 局部反转如#541 反转字符串II:与整体反转原理一致,但需要多次进行
    • 既包含整体反转,又包含局部反转,从而达到反转语序而局部不变的目的
      • 如#151 反转字符串中的单词以及#剑指offer58 左旋转字符串:反转原理一致,但需要通过局部的再反转来达到局部顺序归位的目的

字符串匹配(比哈希表中的字符匹配更为复杂)

  • 特征:
    • 给定一个匹配串(n)和模式串(m),返回模式串在匹配串中第一次出现的位置
  • 时间复杂度: O ( m + n ) O(m+n) O(m+n),空间复杂度: O ( m ) O(m) O(m)
  • 原理性分析(KMP算法简述)
    • 真前后缀:以头字符为开头,某一字符为末尾,范围内所有不包含末尾字符的子串为前缀,所有不包含头字符的子串为后缀
    • KMP核心思想:进行扫描时,模式串当前待匹配字符的最大前缀字符串已完成匹配,若当前待匹配字符与匹配串不匹配,取最大前缀字符串的最大相等前后缀长度,即可将模式串的对应前缀与匹配串的对应后缀再次对应起来,从而找到新的匹配出发点(而不需要重新查找)
    • KMP关键点:如何求解每个字符 i i i的最大相等前后缀长度是问题关键,设该长度以函数 π \pi π表示,字符串以 s s s表示,在进行以下推导后,我们可以得出使得 π ( i ) = j + 1 \pi(i)=j+1 π(i)=j+1 j j j的表达式:
      • 已知 π ( i ) = π ( i − 1 ) + 1 \pi(i)=\pi(i-1)+1 π(i)=π(i1)+1的前提是 s [ i ] = s [ π ( i − 1 ) ] s[i]=s[\pi(i-1)] s[i]=s[π(i1)](这部分证明不难,可参见Leetcode解析),令 j = π ( i − 1 ) j=\pi(i-1) j=π(i1),当 s [ i ] = s [ π ( i − 1 ) ] s[i]=s[\pi(i-1)] s[i]=s[π(i1)],就有 π ( i ) = j + 1 \pi(i)=j+1 π(i)=j+1
      • 如果 s [ i ] ! = s [ π ( i − 1 ) ] s[i]!=s[\pi(i-1)] s[i]!=s[π(i1)],证明 π ( i ) < π ( i − 1 ) \pi(i)<\pi(i-1) π(i)<π(i1),而 π ( i − 1 ) \pi(i-1) π(i1)的定义给出了等式 s [ 0 : π ( i − 1 ) − 1 ] = s [ i − π ( i − 1 ) : i − 1 ] s[0:\pi(i-1)-1]=s[i-\pi(i-1):i-1] s[0:π(i1)1]=s[iπ(i1):i1],其中必存在后缀子串满足 s [ j : π ( i − 1 ) − 1 ] = s [ i − π ( i − 1 ) + j : i − 1 ] s[j:\pi(i-1)-1]=s[i-\pi(i-1)+j:i-1] s[j:π(i1)1]=s[iπ(i1)+j:i1]
      • 此时,如果能找到一个 j j j,使得 s [ 0 : j − 1 ] = s [ j : π ( i − 1 ) − 1 ] s[0:j-1]=s[j:\pi(i-1)-1] s[0:j1]=s[j:π(i1)1],且 s [ i ] = s [ j ] s[i]=s[j] s[i]=s[j]时,由于 s [ 0 : j − 1 ] = s [ i − π ( i − 1 ) + j : i − 1 ] s[0:j-1]=s[i-\pi(i-1)+j:i-1] s[0:j1]=s[iπ(i1)+j:i1],我们仍可以得出 π ( i ) = j + 1 \pi(i)=j+1 π(i)=j+1。当 j = π ( π ( i − 1 ) − 1 ) j=\pi(\pi(i-1)-1) j=π(π(i1)1)时满足 s [ 0 : j − 1 ] = s [ j : π ( i − 1 ) − 1 ] s[0:j-1]=s[j:\pi(i-1)-1] s[0:j1]=s[j:π(i1)1],如果此时的 j j j仍不能使得 s [ i ] = s [ j ] s[i]=s[j] s[i]=s[j],则按照递推公式 j = π ( . . . π ( . . . ) − 1 ) j=\pi(...\pi(...)-1) j=π(...π(...)1)继续找下去
      • 综上,我们只需要记住一个关键点 j j j的递推公式为 π ( . . . π ( . . . ) − 1 ) \pi(...\pi(...)-1) π(...π(...)1),且初始值设置为 π ( i − 1 ) \pi(i-1) π(i1),当 s [ i ] = s [ j ] s[i]=s[j] s[i]=s[j]时,才有 π ( i ) = j + 1 \pi(i)=j+1 π(i)=j+1
    • KMP实现:根据关键点,我们可以梳理出实现KMP中的next数组(即 π \pi π函数)的方法:设立双指针, i = 1 , j = π ( 0 ) = 0 i=1,j=\pi(0)=0 i=1,j=π(0)=0,当 s [ i ] = s [ j ] s[i]=s[j] s[i]=s[j]时, π ( i ) = j + 1 \pi(i)=j+1 π(i)=j+1 i i i j j j同时向后推,否则令 j = π ( j − 1 ) j=\pi(j-1) j=π(j1),直至满足 s [ i ] = s [ j ] s[i]=s[j] s[i]=s[j]。若 j = 0 j=0 j=0时仍不满足, π ( i ) = 0 \pi(i)=0 π(i)=0 i i i向后推;有了next数组后,只需要一次扫描匹配串,当出现不匹配时,通过next数组回退模式串,匹配串不移动,进行重新匹配工作。
  • 值得一讲的相关题目知识:#459 重复的子字符串一题中,在使用KMP进行字符串匹配前,先通过一个叠加操作,复制原本的字符串并拼接在字符串后,然后掐头去尾,如果在中间重新找到原字符串,则说明该字符串是具备旋转不变性的,即可得到递推公式 s [ j ] = s [ j + i ] = s [ j + 2 i ] = . . . s[j]=s[j+i]=s[j+2i]=... s[j]=s[j+i]=s[j+2i]=...,即原字符串是可重复的;另一方面,如果原字符串是可重复的,复制一份拼接后就一定可以在中间重新找到原字符串。因此,当且仅当原字符串可重复,我们才能在中间重新找到原字符串(充分必要)

总结

字符串相关的题目,由于多数对辅助空间有限制,所以能用到双指针的机会很多;而对于字符串匹配的任务,使用KMP算法可以严格限制时间复杂度为 O ( m + n ) O(m+n) O(m+n),其原理性梳理如上,只要掌握所总结的关键点,相信可以顺利写出next数组的生成代码

这里再总结一些字符串题目的小技巧:

  • Java中,直接处理字符串比较难,一般将其转为StringBuilder或char[]来处理
// 构造
StringBuilder sb = new StringBuilder(str)
// 修改
sb.setCharAt(char c, int index);
// 删除
sb.delete(int start, int end) // 左闭右开
// 更多详见https://www.runoob.com/java/java-stringbuffer.html
//构造
char[] ac = String.toCharArray(str);

栈和队列

Java中,一般使用LinkedList或ArrayDeque来实现栈和队列,两者的实例化对象相同,仅通过调用方法的不同来区分是栈还是队列

需要注意的是,LinkedList与ArrayDeque的底层结构分别是链表和数组,这意味着LinkedList可以添加空指针null作为表值,因为这不会影响LinkedList对链表长度的判断(只有当表指针为null时,才表明链表结束)。但ArrayDeque不能添加null作为值,否则会抛出空指针异常,这是因为变长数组判断长度的标准就是结尾的null标识。因此在用统一迭代法书写二叉树遍历代码时,只能使用LinkedList作为栈,从而使用null来标识中间节点

以ArrayDeque为例,其队列API为(add, remove, element都会在特定情况抛出异常,不利于直接在程序中做判断,因此使用队列时多用offer, poll, get三个方法,由于deque是双端队列,所以该三个方法又分别有两个变形,总计6个方法)
LeetCode刷题总结文档_第3张图片
栈的API为
LeetCode刷题总结文档_第4张图片

栈的相关应用

  • 特征:
    • 进行的操作多与邻居相关(大部分应用场景为相邻匹配)
    • 通过栈结构完成的相邻匹配任务,一般流程为在出现待匹配元素时,查看栈顶元素是否可匹配,匹配后弹出栈顶元素
  • 相邻匹配相关题目总结:
    • 括号匹配:#20 有效的括号,#22 括号生成。
    • 重复字符匹配:#1047 删除字符串中的所有相邻重复项
    • 后缀表达式计算:#150 逆波兰表达式求值,后缀的运算符号方便计算机从前向后扫描的计算

队列的相关应用

先插一句题外话,如果对优先队列底层结构——不够了解的,可以参看这篇讲堆排序的博客,在实现堆的时候可以参考这篇博客:上移和下移堆节点的最优写法

  • 特殊队列:
    • 优先队列:按照优先级出队列,而不是先后顺序
    • 单调队列:与优先队列的堆结构不同,单调队列保持顺序性
  • 相关题目知识:
    • #239 滑动窗口最大值:单调队列或优先队列都可以辅助求解,重点是维护已扫描区域最大值的查找更新
    • #347 前 K 个高频元素:只进行部分排序,所以可以选择构造堆(也可以选择快速排序,通过信标索引与部分排序分界点的关系,来执行单边递归

二叉树

二叉树可经由数组或链表实现,考虑到二叉树的可扩展性以及图形表达,一般使用链表作为二叉树的底层结构。对于以数组实现的完全二叉树(如堆结构),可考虑如下父子节点关系:

父节点标号为k,其左子节点为2k+1,右子节点为2k+2

深度优先遍历

  • 特征
    • 优先向下探索,直到叶子结点为止
  • 分类
    • 前序遍历:左右
    • 中序遍历:左
    • 后序遍历:左右
  • 实现
    • 递归法:传入参数与返回值(传入当前节点),递归终止条件(当前节点为null),单次递归逻辑(按访问顺序,决定中间节点的读取时机)
    • 迭代法:按节点访问顺序入栈,为标识中间节点,在压入中间节点后紧接着压入null空指针

广度优先遍历(层序遍历)

  • 特征
    • 优先进行层遍历,一层访问完才进入下一层
  • 实现
    • 递归法:传入参数与返回值(传入当前节点以及当前深度,以深度进行递归时结果的层级区分),递归终止条件(当前节点为null),单次递归逻辑(每次进入深度+1,节点放入对应层级,递归时先左后右,保证从左到右)
    • 迭代法:使用队列结构,在遍历队列前,队列为当前层的所有节点,在遍历队列后,队列为下一层所有节点

二叉树解题技巧

  • 递归分治:二叉树的题使用递归分治,左右子树分别递归执行同一任务,再由根节点汇总,从而自顶向下的解决问题,并在递归返回过程中自底向上的重构内容
    • 适用题目:左右子树各自收集内容并由根节点做汇总的相关题目,如
      • 二叉树的最大深度与最小深度:左右子树各自计算最大深度,由根节点最终挑选最大的一个
      • 判断平衡二叉树:左右子树各自判断是否为平衡二叉树,再由根节点汇总判断
      • 从中序和后序遍历构造二叉树,最大二叉树:左右子树各自构造树结构并返回头节点,再由根节点关联各自头节点
  • 左右分治:即按照左子树和右子树所呈现的不同性质,来分别进行访问任务
    • 适用题目:左子树与右子树拥有不同性质的相关题目,如
      • 二叉树的最近公共祖先:p和q的最近公共祖先表现出如下性质:若某一节点为最近公共祖先,p和q一定位于该节点及其子树上,且p和q一定不同时位于该节点的左右任一子树。因此,就可利用p和q分居左右子树的性质,来使左右子树分别进行p和q的查找与返回,从而确定最近的公共祖先
      • 二叉搜索树的相关题目:二叉搜索树的性质为——其左子树均小于当前节点右子树均大于当前节点,因此是一个天然进行左右差分的类型
        • 二叉搜索树中的搜索:按搜索值与节点的大小关系,决定对左右子树的访问。与之原理完全相同的还有二叉搜索树的插入
        • 删除二叉搜索树节点:删除操作将待删除节点分为四种情况,分别对应其左右子树的存在与否,其中左右子树均存在时的处理方案就运用到左右分治的思想,将左子树完整插入到右子树的最左侧最深处节点,再返回右子树根节点
        • 修剪二叉搜索树:小于左边界时,以节点右子树进行修剪重塑,返回重塑后的根节点以替换删除节点;大于右边界时同理

总结

二叉树的题目无外乎以下三种:

  • 构造二叉树:一般使用前序遍历,先建立根节点,再利用递归分治思想关联左子树与右子树头节点
  • 求二叉树的属性:一般使用后序遍历,先获得子树的返回值,再根据返回值不断递归统计
  • 修改二叉树的结构:一般以修改二叉搜索树为主,所以主要用中序遍历,从而利用起二叉搜索树的有序性质

该部分的思维导图如下:

LeetCode刷题总结文档_第5张图片

回溯算法

组合问题

  1. 组合问题与单调性息息相关,对一个整数数组进行组合时,如整数数组满足单调性,则组合问题的两个方面均有所简化:
  • 剪枝:由于数组的单调性,当某次遍历达到临界值后,依序进行的后续遍历则一定大于临界值,从而可直接剪除
  • 避免重复组合:由于数组的单调性,顺序挑选元素也可保证挑选元素的大小顺序,从而由小到大的生成不重复组合
  1. 在以下情形下,整数数组的组合问题需要要具备单调性
  • 包含同层去重的问题,如子集II,组合总和II。如果整数数组不能具备单调性,则需要使用额外的辅助集,如递增子序列使用set记录同层中已访问过的元素,来避免同层重复;如应用于子集II和组合总和II中,还额外需要进行层间的去重
  • K数之和的类似问题,如组合总和,组合总和III,具备单调性后可方便剪枝
  1. 子集的组合与组合总和以及切割问题是不同的
  • 总和的组合需要等于某个深度时,路径收集器才能加入结果集,与之类似的,切割问题需要完全切割数组,才能将路径收集器加入结果集,因此两者都是在叶子节点时将收集的结果加入集合
  • 子集的组合则不限制是否完全切割,也不限制到达某个深度,因此其构造树的每个节点均可以加入集合

排列问题

与组合问题不同的是,排列问题没有层间+1的约束,而仅仅要求提出每层所选的元素,因此当提出数组中间的元素时,排列的难点就来了——如何来表示传输到下一层的子数组:

  • 第一种方法时间复杂度较高,但胜在思路简单:使用一个used数组来标识路径所遍历的节点位置,每一层在选取元素时,只能选取未被标示的元素
  • 第二种方法时间复杂度较第一种更低:每次将选取的元素数组的首位元素做交换,然后传入下层时首位地址+1。在回溯阶段,再将交换的元素还原(值得注意的是,本方法在去重阶段将无法使用排序法,因为交换元素后单调性被打破,因此只能使用哈希去重)

有向图的所有可行路径

该类题对有向图进行深度优先搜索,因此多采用递归形式求解。又由于需要找到所有可行路径,所以在递归中还带有回溯操作

  • 如重新安排行程,即通过深度优先搜索,找到一条可达的最小排序路径

棋盘问题

棋盘问题在性能接受范围内,均可采用枚举的方法解决,即对棋盘格按左右,上下顺序依次递归与回溯,从而遍历所有可能解并找到符合条件的最终解

  • 如N皇后:同层通过for循环控制一个N皇后的落点,层间则通过递归来组织所有N皇后的遍历,每一个落点均需考察其先驱落点所产生的的禁区
  • 如解数独:每个棋盘格通过for循环控制所有可选元素,在获取到该格的某个可选元素后,按按从左至右,从上至下的顺序依次组织递归,遍历所有棋盘格

总结

回溯算法最难排查的错误一般都出现在回溯的顺序上,因此一定要遵循下面的原则进行编程:

  • 回溯与遍历的顺序一定是一一相反对应:当一次遍历涉及到多组元素顺序的交换时,一定要拆分成一步步来,这样在回溯中才可以清晰地判定是否与遍历的顺序成一一相反对应。
    LeetCode刷题总结文档_第6张图片

贪心算法

对有左右比较关系的数组使用贪心算法

这类题有一个很巧妙的辅助分析手段,即从左至右绘制数组值的波动图,然后对波峰/波谷使用贪心算法分析,例如:

  • 分发糖果中,我们首先绘制了从左至右孩子的评分变化曲线,然后显而易见的发现波谷分得的糖果总为1波峰分得的糖果为左右的高位+1,而波峰与波谷之间的糖果分配则遵循单调性原则,因此该题就可以很好地利用贪心算法求解

对进行包含关系的数组使用贪心算法

这类题的巧思一种是覆盖面积法,主要针对顺序固定的包含关系,即在从左至右的扫描过程中,不断优化当前子区间可能的最大覆盖范围,当超出覆盖范围后,表示当前子区间的任务完成

  • 划分字母区间中,我们首先收集了每个字母的最远下标,然后在依次扫描的过程中,根据当前字母的最远下标不断更新子字符串的最大覆盖范围,从而使得同一字母仅落在一个子串内,且子串被分割的尽可能多
  • 跳跃游戏中,我们也是在依次扫描过程中更新当前子区间的最远覆盖范围,若某次扫描后将末尾覆盖进来,则跳跃可以到达末尾

如果顺序可更改,那么首先进行排序,排序后再使用覆盖面积法则是一个很好的想法,例如下面一类重叠区间问题(其本身就是一个覆盖面积的问题):

  • 在合并区间中,我们首先对二维数组表示的区间集,根据其左边界进行升序排列(有关二维数组的排序写法可参看我的另一篇博客),由此,在合并过程中,我们就仅需和最后记录的子区间作比较即可决定当前区间的去留

巧用状态转移,简化贪婪判断

这类题的特点是针对不同的状态,需要采用不同的转移方程(转移方程就涉及到贪婪策略)。因此一个有效的解题思路就是:

  • 首先定义完备且符合题意的状态组,符合题意是指状态组不仅要包含所有的可能性,还要可解决问题
  • 然后根据状态组的可能组合,定义不同状态间的状态转移方程
  • 最后确定状态初始值以及状态转移顺序

例如在监控二叉树中,我们定义的是三状态,如果仅定义是否被覆盖两个状态,就无法解决问题

动态规划

类型题总结1------背包问题

  • 动态规划的关键一:设计递推变量与递推公式
    • 递推变量的结束位一定与结果强关联
    • 递推函数一定可以从初始推导至结束
    • 举例说明——目标和问题:由于递推变量的结束位一定与结果强关联,考虑设计递推变量为表达式数目;此为总的思路,然后就是细化该思路,找到某一个符合递推思想的表达式数目,本题便是从正数集的和为i的表达式数目入手解决问题
  • 动态规划的关键二:如何区分传统的动规(如跳跃游戏)和背包问题
    • 背包问题的一大特色是元素任取,因此其隐含的物品维所给出的定义(在0-i之中任取)就非常重要了,它将任取的行为转变成了传统动规中按顺序取得行为,从而使得动规可解
  • 动态规划的关键三:01背包和完全背包的区别
    • 对背包容量遍历顺序的不同:01背包从后向前遍历背包容量,保证位于后面的容量更新价值时使用的是未包含当前物品的容量价值;完全背包从前向后,保证后面的容量更新价值时,前面已包含当前物品
    • 由上可知,01背包在使用一维数组存储时,背包与物品的遍历顺序无法交换,必须先物品,再背包,避免更新价值时使用了已覆盖的价值
    • 完全背包则分为两种情况:
      • 如果递推函数使用的是max类型,求取的是最大装包重量之类的,那么先背包与先物品都是一致的,因为此时是否对任取物品范围做限制并不会影响最终结果;
      • 如果递推函数使用的是sum类型,求取的是组合数目之类的,那么只能先物品,因为在一个背包容量下进行所有物品的遍历,会导致后续sum时出现重复的组合统计,先物品可以保证组合去重
      • 如果递推函数使用的是sum类型,求取的是排列数目,只能后物品,因为只需要最后一位的选择不同,其都将属于不同的组合
      • sum排列类型我总结为其要求的是sum[最后一位是物品i]+sum[最后一位不是物品i],因为只对最后一位有要求,所以使用先背包后物品是符合的;而sum组合类型要求的是sum[组合内包含i]+sum[组合内不包含i],对整个组合提出要求,所以使用先物品后背包加以限制
      • 换言之,先背包还是先物品的本质是是否对特殊的组合(即元素相同但顺序不同)有需求,如果有需求,则应选择先背包,让前缀背包包含其所有的可能;如果没有需求,则应该选择先物品,给出限制

类型题总结2------股票买卖的最佳时机

该类型题的一大特色是递推变量不再是单维问题,而是有多个维度,针对多个维度分别设计了递推函数,从而解决问题。与之有异曲同工之妙的有贪婪算法中的二叉树上的最少监控头。我对股票买卖型的题有如下总结经验:

设置2k+1个状态
1. dp[i][0]:第一次持有前i时的总现金, d p [ i ] [ 0 ] = d p [ i − 1 ] [ 0 ] dp[i][0] = dp[i-1][0] dp[i][0]=dp[i1][0]
2. dp[i][2
j-1]:第j次持有,且当前下标为i时的总现金, d p [ i ] [ 2 ∗ j − 1 ] = m a x ( d p [ i − 1 ] [ 2 ∗ j − 2 ] − p r i c e s [ i ] , d p [ i − 1 ] [ 2 ∗ j − 1 ] ) dp[i][2*j-1] = max(dp[i-1][2*j-2]-prices[i], dp[i-1][2*j-1]) dp[i][2j1]=max(dp[i1][2j2]prices[i],dp[i1][2j1])
3. dp[i][2*j]:第j次卖出后,第j+1次持有前且当前下标为i时的总现金, d p [ i ] [ 2 ∗ j ] = m a x ( d p [ i − 1 ] [ 2 ∗ j − 1 ] + p r i c e s [ i ] , d p [ i − 1 ] [ 2 ∗ j ] ) dp[i][2*j] = max(dp[i-1][2*j-1]+prices[i], dp[i-1][2*j]) dp[i][2j]=max(dp[i1][2j1]+prices[i],dp[i1][2j])
初始化方案1: d p [ 0 ] [ 0 ] = 0 , d p [ 0 ] [ 1 ] = − p r i c e s [ 0 ] , d p [ 0 ] [ 2   t o   e n d ] = I n t e g e r . M I N V A L U E dp[0][0]=0, dp[0][1]=-prices[0], dp[0][2\ to\ end]=Integer.MIN_VALUE dp[0][0]=0,dp[0][1]=prices[0],dp[0][2 to end]=Integer.MINVALUE(隐含假设为只能当天买入,第二天卖出)
初始化方案2: d p [ 0 ] [ 0 ] = 0 , d p [ 0 ] [ 2 ∗ j − 1 ] = − p r i c e s [ 0 ] , d p [ 0 ] [ 2 ∗ j ] = 0 dp[0][0]=0, dp[0][2*j-1]=-prices[0], dp[0][2*j]=0 dp[0][0]=0,dp[0][2j1]=prices[0],dp[0][2j]=0(隐含假设为可以当天买入当天卖出无数次)
重点:初始化方案1由于多了对前缀值是否为不可取值的判断,因此其可能出现最后一天不在最后一笔交易时取到最大,所得结果必须通过遍历手段再拿到最大值;初始化方案2由于当天可买卖无数次,因此一定在最后一笔交易时取到最大
时间复杂度O(n * k)

类型题总结3------子序列与子数组相关问题

首先应明确子序列和子数组的区别:子数组是连续的子序列

子序列和子数组的相关题目是动态规划方法的天然产物,因为其最终结果正是来自于不断地递推前缀子序列/子数组

求解子序列与子数组类型的题,其巧思主要集中在递推变量的设计上,我将其总结为两大类:

  • 考虑递推变量的设计内容:
    • 如果求解过程对前缀子序列的结尾与当前子序列的结尾没有特殊限制(如要求连续或者递增),那么此时的递推变量设计可直接反映结果,例如设置为0~i的范围内的最大子序列值
    • 如果求解过程中前缀子序列的结尾与当前子序列的结尾有特殊限制,那么此时递推变量的设计还应考虑到前缀子序列结尾的可追溯性,例如设置为0~i的范围内以nums[i]结尾的最大子序列值
  • 考虑 递推变量的设计维度:
    • 如果待求解的数组有多个,或者需要多个辅助信标,在无法精简的情况下,递推变量应考虑设计多维以切实反映所有可能

由子序列问题扩展来的编辑距离类型的题,其递推变量的设计规则符合上述总结的1-1

如果加上回文限制,递推变量在符合点1.1的前提下,还需要考虑应回文对首尾信标的同时需求,从而将递推变量的维度扩展为2

你可能感兴趣的:(Java实践,leetcode,算法,职场和发展)