Bootstrap

LeetCode 热题 HOT 100【题型归类汇总,助力刷题】

在这里插入图片描述

介绍

  • 对于算法题,按题型类别刷题才会更有成效,因此我这里在网上搜索并参考了下 “🔥 LeetCode 热题 HOT 100” 的题型归类,并在其基础上做了一定的完善,希望能够记录自己的刷题历程,有所收获!具体参考如下文章:
  • 我这里只做了LeetCode 热题 HOT 100中的 e a s y \color{green}{easy} easy m i d d l e \color{orange}{middle} middle 的题, h a r d \color{red}{hard} hard 的题难度较大暂时都跳过了(题目上都有 删除线 标识),大部分面试也不会考察,后面有精力再做研究。
  • 题目后带有 ★ \color{red}{★} 标识的表示后续还要继续反复练习,题目可能不难,但有时可能会忽略其中的一些刷题细节而导致错误
  • 每一种类型的题目,并不绝对是按照题号递增的顺序来排列的(当然大部分都是按题号大小排好序的)。
    因为有些题目其实很相似,放在一起更好,便单独对他们做了调整,比如 [647. 回文子串] 和 [5. 最长回文子串]
  • 这里面的每一道题,都有相对应我自己日常整理的题解,具体可参考:我的博客-LeetCode专栏题解,在里面搜对应题号即可 ~
  • 大家在浏览时,可以通过下方⬇️按题型分类汇总后的【目录】来实现快速跳转,更方便、更快捷的刷题。
    同时这篇文章我也是我花费了很长的时间,对比多篇文章来总结和编写的,希望对大家有所帮助。
    在这里插入图片描述

文章目录

一、链表(共11题)

2. 两数相加 ★ \color{red}{★}

注意分别处理 【相同数位上的两数之和 val1 + val2,并加上上一轮新产生的进位值 carrysum = val1 + val2 + carry】 与 【这一轮新产生的进位值 carry = carry / 10】。

并且当两链表 l1 和 l2 都遍历完后,记得额外处理最后的一次进位。例如:99+9=108,这里需要单独处理百位最后的1。

19. 删除链表的倒数第 N 个结点 ★ \color{red}{★}
  • 注意 先创建虚拟头节点 dummy,且 dummy.Next = head。防止当链表头节点head为待删除节点时,删除该节点后链表头head为空的情况(边界情况)
    • 如果我们能得到倒数第n个节点的前驱节点而不是倒数第n个节点,那么删除操作会更加方便。因此我们可以考虑在初始时创建 快慢指针 fastslow,并将这两个指针指向哑节点 dummy,其余操作不变。这样一来,当 fast遍历到链表末尾时,slow的下一个节点就是我们需要删除的节点。
  • 快指针先走n步,然后快指针和慢指针再每次各走一步
  • 删除倒数第n个节点:slow.Next = slow.Next.Next注意不是 slow.Next = fast
  • 最后返回虚拟头节点的后继节点:dummy.Next

类似题目有:

21. 合并两个有序链表

两种方法:递归迭代

23. 合并K个升序链表

21. 合并两个有序链表 的基础上,使用

  • 递归法(推荐,时间复杂度更优),参考 LeetCode题解-分治法
    • 时间复杂度:O(nlog⁡k),其中 k 为 lists 的长度,n 为所有链表的节点数之和。每个节点参与链表合并的次数为 O(log⁡k) 次,一共有 n 个节点,所以总的时间复杂度为 O(nlog⁡k)。
    • 空间复杂度:O(log⁡k) 递归深度为 O(log⁡k),需要用到 O(log⁡k)的栈空间。
  • 迭代法(不推荐,时间复杂度较高):遍历链表数组,两两合并。
    • 时间复杂度 O(nk),时间复杂度高于递归法

有两个细节需要特别注意:

  • 在方法 mergeKLists()中,初始化链表时,采用如下写法:
    var res *ListNode
    // res := &ListNode{} // 错误写法,会初始化res为0值,导致结果集多一个0值
    for i := 0; i < len(lists); i++ {
        res = mergeTwoLists(res, lists[i])
    }

  • 而在方法 mergeTwoLists()中,初始化虚拟节点 head时,则为:
	// var head *ListNode // 错误写法 会空指针异常
	head := &ListNode{}
	cur := head
	...
 	return head.Next
141. 环形链表

判断快慢指针是否相遇(快指针两步,慢指针一步)

142. 环形链表 II

先判断快慢指针是否相遇(快指针两步,慢指针一步),若相遇则将快指针重置到头结点,然后快慢指针每次各走步,直至相遇

148. 排序链表 ★ \color{red}{★}

题目要求时间复杂度为:O(NlogN),故采用归并排序的思想(拆分→排序→合并)

  • 先通过快慢指针找到链表中点,并切割为前后两部分
  • 不断递归上述过程,直至最终将链表切割为多个长度为1的链表
  • 最后不断合并这多个长度为1的链表(此比较大小并合并的过程,与 21. 合并两个有序链表 一样)
160. 相交链表

用双指针pA 、pB分别遍历两个链表,pA对链表A遍历结束后就去遍历链表B,pB对链表B遍历结束后就遍历链表A。当 pA == pB 时,相遇节点即为交点,因为两个指针分别移动的步数是一样的。

206. 反转链表

注意go中要用该方式初始化 var pre, mid, end *ListNode = nil, head, nil,而不是 pre, mid, end := &ListNode{}, head, &ListNode{},否则会在反转后的尾节点添加值为0的 “空节点”,导致错误

类似题目:

234. 回文链表 ★ \color{red}{★}
  • 先通过快慢指针找链表中点,划分为前半部分和后半部分;注意寻找链表中点时的判断条件:for fast.Next != nil && fast.Next.Next != nil {...}
  • 再反转后半部分链表;
  • 最后将两部分链表的节点逐个比较
406. 根据身高重建队列 m i d d l e \color{orange}{middle} middle 题,暂时跳过)

二、二叉树(共14题,含2道 h a r d \color{red}{hard} hard 题)

做题心得:
  1. 处理递归,核心就是千万不要想子问题的过程,你脑子能处理几层?马上就绕迷糊了。要想子问题的结果,思路就清晰了
  2. 是的,只要代码的边界条件和非边界条件的逻辑写对了,其他的事情交给数学归纳法就好了。也就是说,写对了这两个逻辑,你的代码自动就是正确的了,没必要想递归是怎么一层一层走的。
  3. 跟树相关的题,一般有两种解法:递归&迭代:递归用dfs,而迭代用bfs(队列)
  4. Go 语言的深度优先遍历算法可以采用闭包函数实现,这样省去了许多参数的传递与全局变量的声明。

另外,关于递归,看到该题讨论区有一个评论,对于递归的理解很有帮助,特意截图留念。
在这里插入图片描述

94. 二叉树的中序遍历
  • 递归 or 迭代(利用栈的先进后出特性),必会

类似题目:

98. 验证二叉搜索树 ★★★ \color{red}{★★★} ★★★

利用二叉搜索树的中序遍历为升序序列这一性质,来递归验证。

方法一:官方题解 通过限制每个子树中的上下界(lower和upper)来判断,需额外引入常量:math.MinInt64, math.MaxInt64,不推荐,也没必要。

方法二:双指针比较法(pre和node),参考 B站视频题解,不需额外引入常量,而只需通过一个pre指针,在向上回溯的过程中,不断保存之前的节点用于比较。

  • 首先【不断向左子树递归】直至最后空节点:left := dfs(node.Left)
  • 然后再自底向上【回溯】的过程中,pre每次保存的都是之前上一层栈空间中的根节点,并不断将当前node节点和pre节点的值做比较:if pre != nil && node.Val <= pre.Val { return false }
    • 当 node = root 时,pre = root.Left,pre的值应永远小于node的值(满足二叉搜索树中,左子节点值 < 根节点值)
    • 当 node = root.Right时,pre = root,pre的值应永远小于node的值(满足二叉搜索树中,根节点值 < 右子节点值)
  • 保存当前节点node到pre中,用于下层递归中做比较
  • 然后不断向右子树递归:right := dfs(node.Right)
  • 最后返回:return left && right,判断当前节点的左右子树是否分别是二叉搜索树
101. 对称二叉树
  • 解法1 递归
    • if 左节点和右节点均为空,说明遍历完了,返回 true
    • 否则说明左右两个节点并非同时为空,那么判断:if 左节点和右节点其中一个为空(也就是一个为空,一个非空,那肯定不对称),或者左节点值不等于右节点值(不对称),返回 false
    • 最后继续递归下探:
      return recur(左节点的左子节点,右节点的右子节点) && recur(左节点的右子节点,右节点的左子节点)
  • 解法2 迭代
    层序遍历:通过队列来判断根节点的左子树和右子树的内侧和外侧是否相等,其中的条件判断和递归的逻辑是一样的。如动画所示:
    101. 对称二叉树(迭代法)
102. 二叉树的层序遍历

BFS层序遍历使用 queue 队列(先进先出)

  • 初始化队列,并将非空根节点 root入队
  • 判断队列大小是否非零,非零则进入外层for循环 for len(queue) > 0 {
    • 由于需要按层返回二维数组结果集,因此要提前缓存当前这一层的节点数 length := len(queue),并创建用于保存这一层结果的临时数组 subRes

      进入内循环 for i := 0; i < length; i++ {

      • 获取队头节点 root = queue[0],将其 root.Val值保存到临时数组 subRes中,再将该节点出队(它的使命已完成)
      • root的非空左子节点 root.Left和非空右子节点 root.Right入队
    • 将保存当前这一层结果集的临时数组 subRes追加到二维数组 res

  • 返回保存最终结果集的二维数组 res
104. 二叉树的最大深度 ★ \color{red}{★}
  • 递归法(dfs)
  • 迭代法(bfs):利用队列(先进先出)。内层for循环保留上一层节点数,避免内层循环因为对queue进行append操作,导致队列元素个数发生变化
105. 从前序与中序遍历序列构造二叉树 ★★★ \color{red}{★★★} ★★★

推荐掌握递归法迭代法比较难理解,不过都需要作图理解和推敲:左右子树分别在 前序/中序 遍历中的左右边界。具体代码可参考:我的题解

递归法
先通过遍历inorder数组,找到根节点(值为preorder[0])位于中序遍历中的下标位置 i。然后,根据中序遍历中根节点的下标位置 i,分别构建root的左右子树 …

  • 1 分别确定"左子树"在前序和中序遍历中的左右边界
    • 1.1 确定前序遍历中左子树的左右边界:
      • root = preorder[0]是根节点,所以前序遍历中左子树的左边界是1;
      • 然后根据根节点在中序遍历中的下标 i,可知【中序遍历中左子树的范围是0~ i】,由此可确定中序遍历中左子树的长度是 i其实 i 的值也等于 len(inorder[:i]),但为了便于理解及简化代码量,就使用 i 来作为左子树的长度),又因为前序遍历中左子树的左边界为1,所以可得前序遍历中左子树的右边界为:i+1(或 len(inorder[:i])+1
    • 确定中序遍历中左子树的左右边界:由上面的分析中的【中序遍历中左子树的范围是0~ i】可得:inorder[:i]

最后可得:root.Left = buildTree(preorder[1:i+1], inorder[:i])

  • 2 分别确定"右子树"在前序和中序遍历中的左右边界
    • 2.1 确定前序遍历中右子树的左右边界:
      • 1.1 可知当前左子树的长度是 i(其实 i 的值也等于 len(inorder[:i]),但为了便于理解及简化代码量,就使用 i 来作为左子树的长度),且根节点也占一个位置,因此可得前序遍历中右子树的左边界为:i+1(或 len(inorder[:i])+1),右子树右边界一直到preorder末尾
    • 2.2 确定中序遍历中右子树的左右边界:
      由于之前已经找出根节点位于中序遍历中的下标位置是 i,所以 i+1就是中序遍历中右子树的左边界,右边界一直到inorder末尾

最后可得:root.Right = buildTree(preorder[i+1:], inorder[i+1:])

迭代法
preorder第一个元素为root,在inorder里面找到root,在它之前的为左子树(长l1),之后为右子树(长l2)。preorder[1]到preorder[l1]为左子树,之后为右子树,分别递归。

主要难点在于需要分别确定前序遍历和中序遍历中的左右子树的左右边界对应关系。。。
在这里插入图片描述

114. 二叉树展开为链表 ★★★ \color{red}{★★★} ★★★
  • 方法1:先前序遍历 获得各节点被访问到的顺序,然后更新每个节点的左右子节点的信息,将二叉树展开为单链表。
  • 方法2:没理解这个递归逻辑,继续研究在这里插入图片描述
124. 二叉树中的最大路径和 h a r d \color{red}{hard} hard 题,暂时跳过)
226. 翻转二叉树
236. 二叉树的最近公共祖先 ★ \color{red}{★}

求最小公共祖先,需要从底向上遍历。那么二叉树 只能通过后序遍历(即:回溯)实现从底向上的遍历方式。附上一个LeetCode评论区大佬图解,方便理解:
在这里插入图片描述

297. 二叉树的序列化与反序列化 h a r d \color{red}{hard} hard 题,暂时跳过)
538. 把二叉搜索树转换为累加树 ★ \color{red}{★}

反中序遍历右中左)的方式不断累加并更新每个节点值即可

543. 二叉树的直径 ★ \color{red}{★}

【前序遍历】思想:任意一条路径均可以被看作由某个节点为起点,从其左儿子和右儿子向下遍历的路径拼接得到。

617. 合并二叉树

可用两种方式操作树:原地修改 or 新建树,可参考:我的题解

新版 hot100 题目扩充(以下题目列表不断更新ing~):

LeetCode 108. 将有序数组转换为二叉搜索树
  • 二叉搜索树BST 的【中序遍历】是升序的,因此本题等同于根据中序遍历的序列恢复二叉搜索树
  • 虽然我们可以以升序序列中的任一个元素作为根节点
  • 但是因为本题要求【高度平衡】,因此我们需要选择升序序列的【中间元素】作为根节点奥~

三、DFS/BFS(共6题,含3道 h a r d \color{red}{hard} hard 题)

部分二叉树相关题目也包含DFS/BFS

79. 单词搜索 ★ \color{red}{★}

遍历二维数组,将每个坐标点分别作为起点进行dfs上下左右遍历。在遍历过程中走过的格子标记为空格 ' ',但要记得,在最后回溯时将之前被标记过的格子恢复为原字符,避免当前递归结果影响到其他的递归过程。

// i, j:当前遍历的起点坐标
// k: 当前目标字符在 word 中的索引 k,初始为0
// m,n:二维数组的长和宽
m, n := len(board), len(board[0])
var dfs func(i, j, k int) bool

该题与 剑指 Offer 12. 矩阵中的路径 相同

85. 最大矩形 h a r d \color{red}{hard} hard 题,暂时跳过)
200. 岛屿数量

遍历二维数组,以 grid[i][j] == 1的格子为起点,开始向其 上下左右 dfs遍历,并在遍历过程中将遍历过为1的格子标记为 ‘0’,避免重复遍历。

注意 79题和200题很相似,都需遍历二维数组的网格:

  • 但是上面第79题中,每个网格中的字符是可以复用的,用于组成新的单词。所以需要将被标记过的网格进行回溯处理,恢复为标记前的值,避免当前递归结果影响到其他递归过程。
  • 而该题则略有不同,这里每个网格在遍历完后,标记为 ‘0’(水),由于仅统计连接的陆地数,后续不会再复用,因此无需进行回溯的恢复处理。
207. 课程表 h a r d \color{red}{hard} hard 题,暂时跳过)
301. 删除无效的括号 h a r d \color{red}{hard} hard 题,暂时跳过)
437. 路径总和 III ★ \color{red}{★}

dfs递归,将每个节点都当做起始的根节点对待

  • 该题可以通过在子函数中增加额外入参 【sum累加和】来做加法,通过比较【当前累加和】是否和【目标和】相等: if sum + node.Val == targetSum
  • 也可以通过对已有入参 targetSum做减法,来比较【当前节点值】是否和【不断消减的目标和】相等: if node.Val == target

注意:处理中间层逻辑时,即使找到了一条目标路径,也不立即 return,继续找。因为 Node.val 有可能为负数,后续有可能再减为目标和 targetSum

var res int

func pathSum(root *TreeNode, targetSum int) int {
    res = 0 // 务必初始化。多个测试用例时,避免当前结果被后续测试用例的结果覆盖
    if root == nil {
        return 0
    }
    
    return dfs(root, targetSum, 0) + pathSum(root.Left, targetSum) + pathSum(root.Right, targetSum)
}

func dfs(node *TreeNode, targetSum, sum int) int {
    if node == nil {
        return 0
    }

    sum += node.Val
    if sum == targetSum {
        res++
    }

    dfs(node.Left, targetSum, sum)
    dfs(node.Right, targetSum, sum)

    return res
}
// 更多不同种的递归形式,可跳转 CSDN-我的题解 部分(最开始介绍部分有链接)

四、递归/回溯(共6题,含1道 h a r d \color{red}{hard} hard 题)

17. 电话号码的字母组合

使用map保存按键数字和对应字母的映射关系,然后利用递归去追加各种字母…

22. 括号生成 ★ \color{red}{★}
  • 方式1 递归,尝试对括号数做减法。left:【剩余】左括号数;right:【剩余】右括号数
  • 方式2 递归,尝试对括号数做加法。left:【已使用】左括号数;right:【已使用】右括号数

可参考:我的题解

39. 组合总和 ★ \color{red}{★}
// 从下标i=0开始不断尝试添加新元素,通过dfs回溯搜索最终找到满足要求的方案。
func combinationSum(candidates []int, target int) [][]int {
    res := make([][]int, 0) // 一定要加这一行,初始化res,否则不同用例在跑的时候 res会被覆盖导致 res值为上次的值
    
    var dfs func(arr []int, sum, index int)
    dfs = func(arr []int, sum, index int) {
        if sum > target {
            return
        }
        
        if sum == target {
            tmpArr := make([]int, len(arr))
            copy(tmpArr, arr)
            res = append(res, tmpArr)
            return
        }

        // 当sum < target时:
        // 避免每次遍历candidates时从i=0开始,导致结果重复
        // 比如2,2,3和2,3,2:后者2,3,2在选取3之后又再次选择了之前的2,导致结果重复
        for i := index; i < len(candidates); i++ {
            // arr = append(arr, candidates[i])// 追加
            // dfs(arr, sum + candidates[i], i)
            // arr = arr[:len(arr)-1]          // 回溯

            dfs(append(arr, candidates[i]), sum + candidates[i], i)
        }
    }
    
    dfs([]int{}, 0, 0)
    return res
}
46. 全排列 ★ \color{red}{★}

具体步骤如下:

  • 将每个元素分别都固定一次到首位置上
  • k+1(即视频中的p)为起点,len(nums-1)(即视频中的q)为终点的新子数组,再次进行下一层新子数组的递归
    在这里插入图片描述
// 1、固定首位置k(0 <= k < len(nums)),首位置会在每层递归中不断后移(每次自增1)
// 2、固定首位置k后,每次将首位置k之后的子数组进行重排列
// 即遍历首位置k之后的子数组nums[k+1:],将其中的每个元素与首位置下标为k的元素进行swap
// 3、然后进入下一层递归,继续固定首位置k+1(dfs(k+1))
// 4、固定首位置k+1后,每次将首位置之后的子数组进行重排列
// 以此类推 重复上述过程,从而得到不同的全排列...

// 例如:
// 先固定 k=0 的下标值,然后试着重排列k之后的子数组 nums[k+1:],即nums[1:]
// 紧接着,基于上层的递归结果,开启下一层dfs递归:dfs(k+1)
// 再固定 k=1 的下标值,然后试着重排列k之后的子数组  nums[k+1:],即nums[2:]

// 参考b站视频:https://www.bilibili.com/video/BV1dx411S7WR?spm_id_from=333.337.search-card.all.click&vd_source=2c268e25ffa1022b703ae0349e3659e4
func permute(nums []int) [][]int {
	res := make([][]int, 0) 
    n := len(nums)

    var dfs func(k int)
    dfs = func(k int) {
        if k == n {
            tmpNums := make([]int, n)   // 注意:使用copy()方法时,要提前分配好数组长度len,否则copy结果为空
            copy(tmpNums, nums)
            res = append(res, tmpNums)
            return
        }

        for i := k; i < n; i++ {
            nums[k], nums[i] = nums[i], nums[k] // 将每个元素分别都固定一次到首位置上
            dfs(k+1)  // 以k+1为起点,n为终点的新子数组,再次进行下一层新子数组的递归
            nums[k], nums[i] = nums[i], nums[k] // 还原回溯,避免上一层递归结果影响到下一层递归
        }
    }

	dfs(0)
	return res
}

46. 全排列78. 子集区别在于:

  • [46. 全排列] 这道题在dfs时,不需要创建 subRes = []int{},以及尝试在空数组的基础上对其不断追加nums[i]。因为在这道题的最终结果集数组 res = [][]int{}中,其中每个一维数组的长度大小都是固定的,为n = len(nums),我们只需不断交换一维数组中每个数字的相对顺序即可,而不需要通过一个个数字追加来生成。但是交换完一维数组中每个数字的相对顺序后,记得回溯来还原nums,避免影响其他递归分支的结果。
  • 而 [78. 子集] 这道题在dfs时,则需要创建 subRes = []int{},并尝试在空数组的基础上对其不断追加nums[i]。因为需要从空数组的基础上,尝试追加各种nums数组中的元素,来生成不同长度的subRes子集。但也要注意,在这里题目要求:解集不能 包含重复的子集,比如 [1,2] 和 [2, 1] 就属于重复子集,只是相对顺序不同罢了,不满足题意。
  • 另外 两道题的dfs递归终止条件中,追加结果集时的逻辑也不同:
    • 如果说返回结果集为所给nums的不同全排列,则为 len(nums);
    • 如果说返回结果集为所给nums的不同子集,则为 len(subRes)。
78. 子集 ★ \color{red}{★}

单看每个元素,都有两种选择:【选入子集】,或【不选入子集】。
考察当前枚举的数,基于选它而继续,是一个递归分支;基于不选它而继续,又是一个分支。
比如[1,2,3],先看1,选1或不选1;再看2,选2或不选2,以此类推…
在这里插入图片描述

func subsets(nums []int) [][]int {
    res := make([][]int, 0)
    var dfs func(i int, subRes []int)
    dfs = func(i int, subRes []int) {
        // 如果说返回结果集为所给nums的不同全排列,则为len(nums)
        // 如果说返回结果集为所给nums的不同子集,则为len(subRes)
        if i == len(nums) { // 当遍历完nums长度后,return
            tmpRes := make([]int, len(subRes))
            copy(tmpRes, subRes)
            res = append(res, tmpRes)
            return 
        }

        // 两种情况:状态树: 追加当前第i个元素或者不追加
        // // 方式1:先不追加,然后追加:
        dfs(i + 1, subRes)  // 追加:
        subRes = append(subRes, nums[i])
        dfs(i + 1, subRes)

        // 方式2:先追加,然后回溯取消追加
        // list = append(list, nums[i])
        // dfs(i + 1, list)
        // list = list[:len(list) - 1]
        // dfs(i + 1, list)
    }
    dfs(0, []int{}) // 从第0个元素开始,触发递归;且赋值空数组

    return res
}
399. 除法求值 h a r d \color{red}{hard} hard 题,暂时跳过)

五、Hash表/map(共3题)

1. 两数之和
49. 字母异位词分组

创建一个map,key为string类型,val为string类型数组。

对给定字符串数组strs中的每个字符串排序,将其排序结果统一作为map的key。并将原本未排序的字符串追加到map的val数组中。目的是通过排序后的key来将字符串数组strs中的字符串str做归类。

128. 最长连续序列
  • 首先,将 nums 数组初始化到map中,以便于在O(1)时间内查找元素
  • 其次,遍历 map(map是存有nums数组元素去重后的结果集。这里不能遍历nums,因为当nums中有大量重复元素时,遍历nums会导致超时),判断当前 num 是否能够作为某个连续序列的首元素(也就是 num-1 不存在)
    • 如果当前 num 能作为某个连续序列的首元素:
      那就继续找以当前 num 为首的后续连续序列,并累加当前连续序列的长度 length,并更新最长连续序列的长度 maxLength
    • 如果当前 num 不能作为某个连续序列的首元素:
      直接跳过,继续找 …
  • 返回最长连续序列的长度 maxLength

六、位运算(共3题)

136. 只出现一次的数字

对数组所有元素进行异或^的值就是最终结果。
注:两个值不同,则异或结果为1;两个值相同,异或结果为0。

338. 比特位计数
461. 汉明距离

在x与y 异或 后的二进制结果中,不断右移并判断对应二进制位为1的个数。


七、数组(共5题)

15. 三数之和 待研究
31. 下一个排列 ★★★ \color{red}{★★★} ★★★

LeetCode题解,评论区一个大佬的解释很通俗易懂,我摘抄了过来。先看下举例,再结合代码可能更容易理解解题思路

一直觉得排列的题目很有趣,终于想通了根据当前排列计算出下一个排列的方法,在这里记录一下。 例如 2, 6, 3, 5, 4, 1 这个排列, 我们想要找到下一个刚好比他大的排列,于是可以从后往前看 我们先看后两位 4, 1 能否组成更大的排列,答案是不可以,同理 5, 4, 1也不可以 直到3, 5, 4, 1这个排列,因为 3 < 5, 我们可以通过重新排列这一段数字,来得到下一个排列 因为我们需要使得新的排列尽量小,所以我们从后往前找第一个比3更大的数字,发现是4 然后,我们调换3和4的位置,得到4, 5, 3, 1这个数列 因为我们需要使得新生成的数列尽量小,于是我们可以对5, 3, 1进行排序,可以发现在这个算法中,我们得到的末尾数字一定是倒序排列的,于是我们只需要把它反转即可 最终,我们得到了4, 1, 3, 5这个数列 完整的数列则是2, 6, 4, 1, 3, 5

首先,这里应该针对 nums数组可能出现的情况分开讨论:

  • 第一种 从左至右不包含递增序列的数组(也就是纯递减数组),例如:
    [3, 2, 1]的下一个排列是 [1, 2, 3],因为 [3, 2, 1] 不存在一个字典序更大的排列。
  • 第二种 从左至右包含递增序列的数组,细分的话有三种情况,但我们只关心 下一个更大的排列,这里逻辑上实则可以合并无需细分处理,分别举几个示例:
    • 从左至右一直递增的数组,[1,2,3]的下一个排列是 [1,3,2]
    • 先递增后递减的数组,[2,3,1]的下一个排列是 [3,1,2]
    • 先递减后递增的数组,[3,1,2]的下一个排列是 [3,2,1]

这里需要对以上两种可能出现的数组分情况处理:

  • 第一步:从后向前 循环遍历数组,找到第一个满足条件 nums[i] < nums[j]的下标 ij
    注:当然对于第一种从左至右不包含递增序列的数组(也就是纯递减数组),是找不到题目要求的 下一个更大的排列 的。因为类似 [3,2,1] 这种本身就是字典序最大的排列,是不存在一个字典序更大的排列的。
    因此这里只能在第二种 从左至右包含递增序列的数组 中找到满足条件的下标 ij了。
    • 继续,若在上述第二种情况中找到了满足条件 nums[i] < nums[j]的下标 ij(也表明 此时 nums[i]的值是要小于其右边的任意一个数的),那么再次从数组尾元素开始,从后向前找到比当前 nums[i]大的倒数第一个数 nums[k]。交换 nums[i]nums[k]的值。
  • 第二步:经过上面 第一步 【找到第一个满足条件 nums[i] < nums[j]的下标 ij】后,此时的 nums[j:len(nums)]后一段子数组其实是降序的。
    因为在第一步中,跳出第一个for循环之前,一直都是满足条件 nums[i] > nums[j]的,也就是前一个数大于后一个数,为降序。
  • 第三步:将上面降序的 后一段子数组 进行反转使其升序,即可得到 下一个排列 ~

具体Golang版代码示例如下:

func nextPermutation(nums []int) {
	if len(nums) <= 1 {
		return
	}

	i, j, k := len(nums)-2, len(nums)-1, len(nums)-1

	// 第一步:从后向前 循环遍历数组,试图找到第一个满足条件 nums[i] < nums[j]的下标 i,j
    // 如果是[3 2 1]这种纯递减数组,i会一直减到-1,就进不去下面的if判断逻辑
    // 注意:在跳出该for循环前 nums[i] >= nums[j],就已经能保证nums[j:len(nums)]后半段子数组为【降序】
	for i >= 0 && nums[i] >= nums[j] {
		i--
		j--
	}

    // i >= 0 保证不是纯降序排列,避免越界
    // 再次从数组尾元素开始,从后向前找到比当前 nums[i]大的倒数第一个数 nums[k],并交换值
	if i >= 0 {
		for nums[k] <= nums[i] && k > j {
			k--
		}

		nums[i], nums[k] = nums[k], nums[i]
	}

	// 将 “后半段【降序】的子数组” 反转使其升序,即可得到 下一个排列 ~
	for a, b := j, len(nums)-1; a < b; a, b = a+1, b-1 {
		nums[a], nums[b] = nums[b], nums[a]
	}
}
169. 多数元素

常规的 hash表(空间复杂度为O(n))和 排序算法(时间复杂度为O(nlogn)),都不满足题目的进阶要求:时间复杂度为 O(n)、空间复杂度为 O(1) ,这里不做展开,具体可以参考LeetCode官方题解。

这里学到一种新的算法 摩尔投票法,具体思路如下图,也可以看 我的题解
在这里插入图片描述

238. 除自身以外数组的乘积 ★ \color{red}{★}

思路:除自身以外数组的乘积 = 自身的左侧数组 * 自身的右侧数组

题目进阶要求:在 O(1) 的额外空间复杂度内完成这个题目,因此复用待返回的res数组即可

  • 初始化 左侧/右侧 数组:res[0] = 1
  • 处理左侧数组乘积:i从 [ 1, len(nums) ) 递增,res[i] = res[i-1] * nums[i-1]
  • 处理右侧数组乘积:i从 [ len(nums)-2, 0 ] 递减,用一个变量 right 来跟踪右边元素的乘积,并初始化为1。然后内部循环中不断更新 right 和 res 的值:right *= nums[i+1]res[i] *= right

此外,注意边界条件的处理即可。

448. 找到所有数组中消失的数字 ★ \color{red}{★}

题目进阶要求:在不使用额外空间且时间复杂度为 O(n) 的情况下解决这个问题。

因此这里不考虑使用 hash表 来统计次数的方法,而是使用数组元素标记法,具体如下:
本质上是把原数组上对应index的值取负来标识某个值出现过,最后再次遍历标识过的数组找出其中非负的元素,其对应 【下标+1】 即为消失的数字。


八、二分查找(共5题,含1道 h a r d \color{red}{hard} hard 题)

使用场景:题目要找的是一个 整数 target,并且这个整数有明确的范围,该情况下可使用「二分查找」。

4. 寻找两个正序数组的中位数 h a r d 题 \color{red}{hard题} hard
  • 方法1 先将两个数组有序合并至新数组中,然后根据奇偶情况返回中位数。较简单,但时间复杂度不满足题目要求的 O(log (m+n))。
    时间复杂度 O(m+n),空间复杂度 O(m+n)
  • 方法2 先找到中位数可能的下标值 left 和 right,然后遍历数组得到nums[left], nums[right],再根据奇偶情况返回中位数。较复杂,对于奇偶情况的判断逻辑容易出错,且时间复杂度不满足题目要求的 O(log (m+n))。
    时间复杂度 O(m+n),空间复杂度:O(1)
  • 方法3 二分法 待研究 \color{red}{待研究} 待研究
    时间复杂度 O(log (m+n)),空间复杂度:O(1)
33. 搜索旋转排序数组 ★ \color{red}{★}

此题运用二分法的特殊变种情况来区分:

  • if nums[mid] == target
  • if nums[left] <= nums[mid],mid的左侧是单调递增区间。注意,当nums中元素较少时,可能left和mid的下标值相同,因此这里的 nums[left] 也可能等于 nums[mid]。
    • if nums[left] <= target && target < nums[mid]
      • right = mid - 1
    • else
      • left = mid + 1
  • else /*if nums[left] > nums[mid]*/,mid的左侧不是单调递增区间,说明右侧是单调递增区间
    • if nums[mid] < target && target <= nums[right]
      • left = mid + 1
    • else
      • right = mid - 1
34. 在排序数组中查找元素的第一个和最后一个位置 ★★ \color{red}{★★} ★★

如果在nums中找到了一个target,则继续在此基础上有两种处理方式:

  • 方法1:通过左右滑动指针,来找到符合题意的区间
  • 方法2:二分法 + 左右滑动指针
  • 方法3(推荐):继续用二分法分别查找左右边界,而不是用左右滑动指针来逐个遍历
    • 注意:
      • 通过新增变量leftOrRight,leftOrRight为true找左边界,leftOrRight为false找右边界
      • searchRange要分别调用两次 binarySearch() 方法,获取第一个和最后一个位置return []int{binarySearch(nums, target, true), binarySearch(nums, target, false)}
      • binarySearch方法是保存上一轮循环中target == nums[mid]时,对应的mid值,其初始值设为 -1,而不能为0,因为可能会与下标0冲突
// 方法1 双指针:不推荐
// 时间复杂度:O(n)
func searchRange(nums []int, target int) []int {
    // if len(nums) == 0 {
    //     return []int{-1, -1}
    // }

    left, right := 0, 0

    // 注意:要避免left和right越界
    for ; left < len(nums) && nums[left] != target; left++ {
    }

    // 如果在nums中没找到target,则直接返回-1
    if left >= len(nums) {
        return []int{-1, -1}
    }

    // 注意:要避免left和right越界
    for right = left; right < len(nums) && nums[right] == target; right++ {
    }

    right-- // 因为最后right多加了一次,所以减回去

    return []int{left, right}
}

// 方法2 二分法 + 左右滑动指针:不推荐
// 思路:如果在nums中找到了一个target,则继续在此基础上,通过左右滑动指针,来找到符合题意的区间
// 平均时间复杂度:O(logn),最坏情况下如果数组中的所有元素都一样,那最坏时间复杂度为O(n) 
func searchRange(nums []int, target int) []int {
   left, right := 0, len(nums) - 1
   for left <= right {
       mid := left + (right - left) / 2
       if nums[mid] == target {
            // 找到target位置后,分别 向左/向右 寻找左右边界
            pre, end := mid, mid
            for pre >= 0 && nums[pre] == target {
                pre--
            }

            for end < len(nums) && nums[end] == target {
                end++
            }

            return []int{pre + 1, end - 1} // l在循环中多减了一次,要加回来;r在循环中多加了一次,要减回去
       } else if nums[mid] < target {
           left = mid + 1
       } else {
           right = mid - 1
       }
   }

   return []int{-1, -1}
}

// 方法3 二分法:推荐
// 思路:如果在nums中找到了一个target,则继续在此基础上用二分法分别查找左右边界,而不是用左右滑动指针来逐个遍历
func searchRange(nums []int, target int) []int {
    return []int{ binarySearch(nums, target, true), binarySearch(nums, target, false) }
}

// leftOrRight为true找左边界,leftOrRight为false找右边界
func binarySearch(nums []int, target int, leftOrRight bool) int {
    left, right, res := 0, len(nums) - 1, -1

    for left <= right {
        mid := left + (right - left) / 2
        if target == nums[mid] {
            res = mid // 保存上一轮循环中target == nums[mid]时,对应的mid值
            // leftOrRight = true,继续寻找target的左边界,所以要缩小当前的右边界,right需要往左移
            if leftOrRight {
                right = mid - 1

            // leftOrRight = false,继续寻找target的右边界,所以要增大当前的左边界,left需要往右移
            } else {
                left = mid + 1
            }
        } else if target > nums[mid] {
            left = mid + 1
        } else { // target < nums[mid]
            right = mid - 1
        }
    }

    return res
}
240. 搜索二维矩阵 II

题目给出 m x n 矩阵从左到右升序排列,从上到下升序排列。

因此以矩阵右上角的元素 nums[i][j)]为起点,若target小于当前值,则j--,若target大于当前值则i++
时间复杂度O(m+n):i 最多能被增加 m 次,j 最多能被减少 n 次,总搜索次数为 m+n

另外此题的更优解是 二分法查找

287. 寻找重复数 ★★ \color{red}{★★} ★★

在这里插入图片描述
此题的进阶要求在新版本LeetCode中有做更新,之前是要求O(1)空间复杂度,因此不能使用hash表计数。因此采用 二分法。

二分法查找:
每一次找中间值,考虑到1-n之间的数一定都会出现,如果非重复情况下每个数只会出现一次,故我们判断小于等于中间值元素个数如果超过了中间值则说明重复的数在左半边,否则去右半边找。(注意:这里的左半边和右半边,是指非重复情况下的有序数组的左右两侧)

具体可参考:我的题解

新版 hot100 题目扩充(以下题目列表不断更新ing~):

LeetCode 35. 搜索插入位置
  • 与普通的二分查找类似,只不过对于target不存在的情况,需要返回left

九、双指针/三指针(共3题)

双指针问题和上述的【八、二分查找】问题类似,都是通过各种方式去操作左右两侧的数组下标 left 和 right …

11. 盛最多水的容器

设置双指针 i, j 分别位于容器壁两端。那么为了获取到更大的面积,i 和 j 中较短的一边向中间靠拢(才有可能获取到更大的面积),直至 i 和 j 相遇。

75. 颜色分类 ★★ \color{red}{★★} ★★

思路:三指针法,left左侧永远都是0,right右侧永远都是2,左右侧都确定好了,那么中间的就自然全是1了(此问题是 荷兰国旗 问题:0 - 红,1 - 白,2 - 蓝 排序)。

具体步骤如下:

  • 定义三指针 left, right, i := 0, len(nums) - 1, 0,遍历数组 for i <= right
    注意循环条件:原本是 i <= len(nums),但 right 指针右侧已经都是2了,没必要继续寻找。因此以越过右指针为终止条件,减少查找次数

  • 判断 nums[i]的值:

    • 若是 0,则移动到表头:swap(nums[i], nums[left]),left++,i++
      注意:nums[left]已经在 i 向右遍历的过程中早就验证过了,所以i要加加右移
    • 若是 1,则继续:i++
    • 若是 2,则移动到表尾:swap(nums[i], nums[right]),right--
      注意:这里不用 i++,因为 nums[right])交换到 nums[i]上的数还没有验证(有可能是0或2),所以 i 不用右移。
283. 移动零 ★ \color{red}{★}

定义 双指针 left, right := 0, 0left左侧的数都是非零值,而right则不断的右移以跟踪0值。
如果nums[right] != 0,则left和right对应值交换,且left和right都右移;反之则仅仅right右移。


十、栈/单调栈(共6题,含2道 h a r d \color{red}{hard} hard 题)

20. 有效的括号

创建辅助栈,利用 先进后出的特性来对每组括号进行匹配

  • 左括号:入栈
  • 右括号:与栈顶元素比较,判断是否匹配
  • 最后判断栈是否为空,非空不匹配则false,反之true
42. 接雨水 h a r d \color{red}{hard} hard 题,暂时跳过)
84. 柱状图中最大的矩形 h a r d \color{red}{hard} hard 题,暂时跳过)
155. 最小栈

空间换时间的思想,分别构建 数据栈 stack 和 最小栈 minStack(辅助),使得能在常数时间内检索到最小元素的栈。

主要注意push操作即可。

394. 字符串解码 ★★ \color{red}{★★} ★★

外层的解码需要等待内层解码的结果。先扫描的字符还用不上,但不能忘了它们。
我们准备由内到外,层层解决[ ],需要保持对字符的记忆,于是用栈。

需要两个栈来记录外层字符串的状态:倍数栈 numStack、字符串栈 strStack
创建几个变量:result不断追加每一层新得到的子串,str保存每一层的子串,num保存每一层的倍数

遍历字符串s,每次得到的字符c存在以下几种情况:

  • c >= ‘0’ && c <= ‘9’:处理当前层倍数
    获取每一层子串要重复的倍数:例如 12[ab],那么就需要获取到12(1*10+2)这个完整的数字
  • c == ‘[’:分别保存外层 倍数子串中,准备进入下一层
    将上一层的 resultnum分别入栈保存,并清空对应记录,避免干扰下一层 resultnum的统计
  • c == ‘]’:内外层拼接
    处理完当前层,获取栈顶数据,并将外层及当前层字符串相拼接
  • c >= ‘a’ && c <= ‘z’:处理当前层子串
    不断追加每层的 result结果:例如ab[…],那么就要拼接获取到这一层ab这个完整的字符串
739. 每日温度 ★ \color{red}{★}

思路:参考LeetCode视频题解 - 单调栈

  • 单调递减栈:注意题目要求的是升温的天数,而不是升温后的温度,因此栈中应存储下标,而非温度
    • 若当前遍历到的温度 < 栈顶温度,则将当前温度对应的天数下标入栈
    • 若当前遍历到的温度 > 栈顶天数所对应的温度,则计算天数差值:后者减去前者

具体步骤如下:

  • 创建单调栈(递减)
  • 外部for循环,遍历 temperatures数组
    • ① 内部for循环:
      若栈非空len(stack) > 0,且【当前第 i天的温度 temperatures[i]> 栈顶第 stack[len(stack)-1]天所对应的温度 temperatures[stack[len(stack)-1]】,直接求出二者的下标差(天数差值)就可得到 下一个更高温度出现在几天后,将结果保存到结果数组res中:res[stack[len(stack)-1]] = i - stack[len(stack)-1],并将栈顶元素出栈。
      循环处理该过程,继续看新的栈顶元素,直到不满足上述条件时而退出内部for循环。因为当前温度 temperature[i]可能大于之前早已入栈的多个温度,需逐个处理…
    • ② 若由于不满足上述条件而退出内部for循环,则表示 当前栈为空 或 当前温度 temperatures[i]<= 栈顶第 stack[len(stack)-1]天所对应温度 temperatures[stack[len(stack)-1]],那么直接将当天的下标 i入栈。

注意:①②两种情况并不是互斥关系,无需 if-else 处理,直接写逻辑即可。

这样就可以一直保持单调递减栈,且每个元素和后面第一个大于它的元素的距离天数也可以算出来。


十一、排序(共4题)

56. 合并区间 ★ \color{red}{★}
  • 先对二维数组下的所有一维数组的首元素(左边界)进行升序排序,使每个一维子数组的左边界为升序排列:
    sort.Slice(intervals, func(i, j int) bool {
      return intervals[i][0] < intervals[j][0]
    })
    避免后面合并时出现无序的 intervals数组,例如:[[1,4],[0,4]],导致合并结果出错为:[1,4]而非[0,4]
  • 遍历题目给定的二维数组 for i := 1; i < len(intervals); i++ {,然后合并区间(经过上面排序后,此时每个一维子数组的左边界都已是升序排列。在此前提下,我们只需再比较每个一维子数组的右边界并判断是否能合并即可):
    • 可合并:若前一个区间右边界 >= 后一个区间左边界,则可将前一个区间 intervals[i-1]合并到当前区间 intervals[i]中。另外,还要注意比较并选择前后区间中较大的右边界作为合并后 intervals[i]的右边界。
      当前新合并后的区间将继续作为下一轮遍历中的 前一个区间 。
    • 无法合并:若前一个区间右边界 < 后一个区间左边界,则无法合并,直接追加【前一个区间】到结果集 res
  • 最后,要追加 intervals的最后一个元素到结果集 res中:因为在之前每次的循环中,append的是前一个区间,导致 intervals的最后一个区间一直没来得及添加到结果集数组 res
215. 数组中的第K个最大元素 ★★★ \color{red}{★★★} ★★★

利用快排堆排(小顶堆时间复杂度更优),具体参考 我的题解

347. 前 K 个高频元素 ★ \color{red}{★}

方法1 内置排序法

  • 利用map统计每个元素出现频次:key = num, val = cnt
  • 将map中的key加入新数组 res 中,也就是对nums去重后的数组集
  • 根据map中【出现频次cnt】对【去重后的res数组】排序,返回前k个
 sort.Slice(res, func(i, j int) bool {
        return m[res[i]] > m[res[j]]
    })

方法2 小顶堆(推荐):

前面对元素出现频次的统计和上面方法1一样。
接下来是利用小顶堆,因为要统计最大前k个高频元素,只有小顶堆每次将频次最小的元素弹出,最后小顶堆里积累的才是前k个高频元素。

那为什么不能用大顶堆呢?
你想啊,如果定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把当前最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。

方法3 桶排序
参考 LeetCode题解-桶排序算法

func topKFrequent1(nums []int, k int) (ans []int) {
	// 确定每个数的频率
	freq := make(map[int]int)
	for _, v := range nums {
		freq[v]++
	}
	// fmt.Println(freq)

	// 将不同频率的元素放入不同桶
	buckts := make([][]int, len(nums)+1) // 创建桶
	for k, v := range freq {
		buckts[v] = append(buckts[v], k)
	}
	// fmt.Println(buckts, buckts[3], buckts[2], buckts[1])

	// 从最大桶开始逐步取出高频元素
	for i := len(buckts)-1; k > 0; i-- {
		for _, v := range buckts[i] {
			ans = append(ans, v)
			k--
		}
	}

	return
}
581. 最短无序连续子数组 m i d d l e \color{orange}{middle} middle 题,暂时跳过)

十二、前缀和(共1题)

560. 和为 K 的子数组

注意:nums中可能存在负数,sum累加和可能更大也可能更小,因此,即使累加和等于k,也不能提前break退出

  • 方法1 双层循环暴力求解
    • 思路:内层循环中,分别以不同元素作为起点,分别计算sum
    • 注意:即使当前累加值与目标值相等(sum == k),也不能提前break退出循环,因为测试用例可能给出负数。
      比如:nums = [1,-1,0],k = 0,有三种符合的子数组:[1, -1]、[0]、[1,-1,0],注意最后一种 1 + (-1) + 0 = 0 也是满足题目要求的子数组,因此不能提前break结束循环。
    • 时间复杂度:O(n^2),不推荐
    • 空间复杂度:O(1)
  • 方法2 前缀和,参考:大佬题解
    利用空间换时间的思想,思路整体类似于 LeetCode 1. 两数之和
    • 具体步骤如下:
      • 遍历 nums 数组,对每个nums[i]求其前缀和(作为map的key),并累加该前缀和的出现次数(作为map的val),以键值对存入 map。注意 初始边界值:开始时前缀和为0出现过1次,所以将 0:1 存入map。
      • 边存边检查 map,如果 map 中存在 key 为「当前前缀和 - k」,说明这个之前出现的前缀和,满足通项式:「当前i所对应的前缀和 - 之前出现的前缀和 == k」。最后,将「当前前缀和 - k」出现的次数,累加到最后的结果中即可。
      • 通俗的说,已知当前 i 所对应的前缀和为 prefixSum[i],我们想找出 prefixSum[i] 减去之前的某些连续子数组和之后的差值等于 k 的这么一些连续子数组。
        那么应满足条件:当前 i 所对应的前缀和 prefixSum[i]- 之前出现的前缀和 x= k,那么这个 x= prefixSum[i]- k。接下来我们要判断这个 x 在之前是否出现过,因此将求得的每一项前缀和,以及对应的出现次数,以键值对形式存入 map中,以便后续判断。
    • 时间复杂度:O(n)
    • 空间复杂度:O(n),map所占空间为O(n)
      在这里插入图片描述
// 方法2 前缀和 
// 参考:https://leetcode.cn/problems/subarray-sum-equals-k/solution/dai-ni-da-tong-qian-zhui-he-cong-zui-ben-fang-fa-y/
// 思路:类似于【1、两数之和】
// 1、遍历 nums 数组,求每一项前缀和,统计对应出现次数,以键值对存入 map
// 2、边存边检查 map,如果 map 中存在 key 为「当前前缀和 - k」
// 说明这个之前出现的前缀和,满足「当前前缀和 - 该前缀和 == k」
// 将它出现的次数,累加给 count
func subarraySum(nums []int, k int) int {
    // 初始值:一开始,0个元素和为0,出现次数为1
    m := map[int]int{0:1}
    sum, res := 0, 0

    for i := 0; i < len(nums); i++ {
        sum += nums[i]

        // 如果满足条件:
        // 【之前某前缀和 = 当前前缀和sum - k】
        if cnt, ok := m[sum - k]; ok {
            res += cnt
        }
        m[sum] += 1
    }

    return res
}

十三、字典树/前缀树(共1题)

208. 实现 Trie (前缀树)) ★★★ \color{red}{★★★} ★★★

前缀树:

  • 功能优点:用于高效地存储和检索字符串数据集中的键
  • 应用场景:搜索引擎、自动补全、拼写检查、Golang Gin web框架的路由匹配模块。

前缀树节点的结构体类型:

type Trie struct {
    child  [26]*Trie // 26叉树,注意是指针类型
    isEnd bool       // 标记当前节点是否是一个完整单词的末尾位置
}

每个方法中,都有一个对应的 this指针,通过移动这个 this指针,可以实现对 新字符串的插入 与 旧字符串的查找。

先遍历给定字符串的每个字符,并计算其对应的下标:chIndex := word[i] - 'a',通过判断 该字符在当前数组中是否为空 if this.child[chIndex] == nil,来决定 insert 和 search 等后续操作。insert 时若为空,则新建 &Trie结构,否则直接移动指针 this = this.child[chIndex]即可。

另外 注意:
Search(word string)StartsWith(prefix string)方法虽然类似,但是也有区别。

  • Search()方法是用于查找完整 word 单词的,所以最后需要额外判断下 this.isEnd是否为 true。
  • StartsWith()只要匹配到 prefix 前缀即可,不需要是完整的单词,所以不需要额外判断 this.isEnd是否为 true。

十四、LRU缓存(共1题)

146. LRU 缓存 ★★★ \color{red}{★★★} ★★★
// LRUCache缓存结构体类型
type LRUCache struct {
	cap        int
	head, tail *Node
	m          map[int]*Node
}

先解释下结构体 LRUCache 的各个字段:

  • 创建 headtail(这两个虚拟节点不存真实数据)原因:
    仅用于标记双向链表的首尾位置,使得在【加入新节点】和【删除旧节点】可通过 headtail更方便操作
  • 使用map原因:
    • Get()Put()需要根据入参key,从map中查找和更新对应节点信息,且以 O(1) 平均时间复杂度运行
    • map的另一个作用是避免加入重复的key到链表中去,可以对key去重

另外 补充一点:将一个活跃节点移动到链表头部可以拆分为两步:moveToHead()=deleteNodeFromList()+addNodeToHead()


十五、动态规划(共23题,含6道 h a r d \color{red}{hard} hard 题)

10. 正则表达式匹配 h a r d \color{red}{hard} hard 题,暂时跳过)
32. 最长有效括号 h a r d \color{red}{hard} hard 题,暂时跳过)
53. 最大子数组和
  • 动态规划 方式1:
    判断 dp[i] = dp[i-1] + nums[i]中,dp[i-1]是起到 正向作用还是负向作用
    也就是判断 nums[i]是否要加上之前的前缀和 dp[i-1],有两种情况:

    • 当 dp[i-1] > 0 对最大子数组和起正向作用时,则可以加上之前的dp[i-1];
    • 反之 当 dp[i-1] < 0 对最大子数组和起负向作用时,则不加
  • 动态规划 方式2(方式1的优化版):
    其实上述方式并不是最优的,因为每次遍历我只需要判断之前的 dp[i-1]的正负即可,所以可以用一个变量 pre来代替和复用,并不需要额外构造dp数组。这样空间复杂度可降至O(1)。

62. 不同路径

思路:

  • dp方程递推关系:到达当前坐标点的路径数 = 到达当前坐标点上面一格的路径数 + 到达当前坐标点左边一格的路径数
  • dp数组相对于mxn的网格,额外多初始化了最上面一行,与最左边一列。起始点是(1,1),终点是(m,n)。这样初始化可以跟题目坐标系对应,多余的dp数组位置也能处理掉循环的边界条件,不用增加额外的判断代码。(当然不额外多初始化最上面一行和最左边一列也可以,但是需要增加额外的判断代码。详情见我的题解)

具体步骤如下:

  • 创建长宽分别为 m+1,n+1 的dp数组用于记录不同的路径数。注意初始化时以下任意方式都ok:
    • dp[0][1] = 1,从上到下,dp[0][1] → dp[1][1]
    • 或者 dp[1][0] = 1,从左到右,dp[1][0] → dp[1][1]
  • 其中,dp[1][1]对应原有矩阵的 [0][0] 位置,也就是机器人的起始位置
    dp[1][1]起始位置出发,机器人每次只能向下或者向右移动一步,可得到递推方程:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
64. 最小路径和

注意,这道题与上一道题 62. 不同路径 的区别在于:

  • [62. 不同路径] 题不需要针对 第0行 与 第0列 单独处理,所有不同路径数对应的所有情况可以统一使用 到达当前坐标点的路径数 = 到达当前坐标点上面一格的路径数 + 到达当前坐标点左边一格的路径数 来处理。
  • 而这道题因为是关系到每个坐标格子中的数值累加和,所以需要针对 第0行 与 第0列 单独处理:原点时不处理,最上边一行时只能从左到右累加和,最左边一行只能从上到下累加和。

搜索的做法仅仅在数据规模比较小的时候才考虑使用,因为复杂度较高,所以采用dp。由于每个元素对应的最小路径和与其相邻元素(当前点的上面或左面)对应的最小路径和有关,因此可以使用动态规划求解。

普通dp空间复杂度为O(n),而在原有grid数组上进行原地修改,空间复杂度更优,为 O(1),具体步骤如下:

  • i == 0 && j == 0,即位于 原点(0,0) 时:dp[i][j] = grid[i][j]
  • i == 0时,即当前点位于 最上边一行 时:只能从原点向右移动,从"左"边过来:dp[i][j] = dp[i][j-1] + grid[i][j]
  • j == 0时,即当前点位于 最左边一列 时:只能从原点向下移动,从"上"边过来:dp[i][j] = dp[i - 1][j] + grid[i][j]
  • 其余情况,当前点既不在最上面一行,也不在最左边一列,此时选取其上或其左位置点中的较小值:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
  • 最终返回 dp[m-1][n-1]

此题也可以采用递归处理,可以参考 我的题解,不过相比dp更复杂和难以理解。

70. 爬楼梯

三种方法,劣 → 优:

  • 方法1 递归法:时间复杂度较高,不推荐
  • 方法2 动态规划:时间复杂度O(n),空间复杂度:O(n)
  • 方法3 动态规划 优化版:在动态规划的基础上进行优化,空间复杂度:O(1)
    由于当前结果只跟 n-1 和 n-2 项有关,故只需缓存这两个变量即可,不需要使用方法2的dp数组缓存整个遍历结果
72. 编辑距离 h a r d \color{red}{hard} hard 题,暂时跳过)
96. 不同的二叉搜索树

思路:

  • 动态规划,双层循环,i 为当前树的节点总数(从0到n),j 为左子树节点数(j < i),因此可得右子树节点数为:i - 1 - j(根节点也占一个节点)。
  • 左子树本身可能有m种构建方式,右子树有n种构建方式,所以取 i 作为根的二叉搜索树的种类为 m*n
  • 对于整个序列 1→n 都可能作为根节点,根有n种可能。所以整个序列构建二叉搜索树,所有种类等于分别取 1→n 为根的二叉搜索树种类和。
  • 因此可得递推关系式为:dp[i] += dp[j]*[i-1-j]

具体步骤如下:

  • 创建dp数组,初始化:
    dp[0] = 1(空树), dp[1] = 1(单节点树)
  • 双层循环
    • i表示当前想要表示的节点总数,从 1 递增至 n
    • j表示当前一共有 i个节点时,其左子树可能的节点数
      • 递推方程:dp[i] += dp[j]*[i-1-j],前一项 dp[j]左子树可能出现的种数,后一项 dp[i-j-1]右子树可能出现的种数,注意 根节点也占 1 个数量。
        举例:dp[3] = dp[0]*dp[2] + dp[1]*dp[1] + dp[2]*dp[0]
  • 返回 dp[n]即可
121. 买卖股票的最佳时机

此题可以看做一种动态规划,只不过对空间复杂度进行了优化。

考虑每次如何获取最大收益?第 i 天的最大收益只需要知道前 i 天的最低点就可以算出来了。而第 i 天以前(包括第i天)的最低点和 i - 1 天的最低点有关,至此我们的动态方程就出来了:dp[i] = min(d[i-1], prices[i])

其中 dp[0] = prices[0],然后动态计算之后的就可以了。 得到了前 i 天的最低点以后,只需要维护一个 max 用来保存最大收益就可以了。 这个时候是空间复杂度 O(n) 的动态规划,代码如下:

// 动态规划 版本1(优化前,空间复杂度:O(n))
func maxProfit(prices []int) int {
    n, maxProfitVal := len(prices), 0

    // dp[i]表示截止到第 i 天,股票价格的最低点是多少 
    // dp[i] = min(dp[i-1], nums[i])
    dp := make([]int, n)
    dp[0] = prices[0]

    for i := 1; i < n; i++ {
        dp[i] = min(dp[i - 1], prices[i])
        maxProfitVal = max(maxProfitVal, prices[i] - dp[i])
    }

    return maxProfitVal
}

接着考虑优化空间,仔细观察动态规划的辅助数组,其每一次只用到了 dp[i - 1] 这一个空间,因此可以把数组改成单个变量 dp 来存储截止到第 i 天的价格最低点。优化后的代码如下:

// 动态规划 版本2(版本1基础上优化后)
// 使用变量 minPrice 保存当前遍历过的股价最低价格,替代之前的dp数组,空间复杂度:O(1)
// 思路:只需记录数组中已遍历过的最小值,当一个数减去最小值,则差值最大!
func maxProfit(prices []int) int {
    minPrice, maxProfitVal := prices[0], 0 // 股价最低价格,最大利润

    for i := 1; i < len(prices); i++ {
        maxProfitVal = max(maxProfitVal, prices[i] - minPrice)
        minPrice = min(minPrice, prices[i])
    }

    return maxProfitVal
}
139. 单词拆分 ★ \color{red}{★}

思路:

  • 借助 map 来实现在 O(1) 时间复杂度内判断当前子串是否在字典 wordDict 中。
    通过 ji下标来标记当前遍历到的子串的左右边界,得到当前的子串 subStr = s[j:i]
  • 判断该子串是否在字典 wordDict 中出现,并且在这之前以 j结尾的子串 dp[j]是否也为 true。同时满足这两个条件,则表明当前遍历到的子串 subStr = s[j:i]是可以正常进行单词拆分的,记为 true。

具体示例代码如下:

func wordBreak(s string, wordDict []string) bool {
    // 初始化单词map,用于判断当前遍历到的子串是否在wordDict中出现过
    wordMap := make(map[string]bool)
    for _, word := range wordDict {
        wordMap[word] = true
    }

    n := len(s)
    dp := make([]bool, n + 1)
    dp[0] = true // 边界条件,dp[0]=true 表示空串且合法

    // i表示子串的结束位置的后一个位置
    // 因为s[j:i]是左闭右开,不包含右边界的,所以i <= n,j <= i
    for i := 1; i <= n; i++ {
        // j表示子串的开始位置
        for j := 0; j <= i/*j < i*/; j++ {
            subStr := s[j:i]
            if wordMap[subStr] && dp[j] {
                dp[i] = true
                // break
            }
        }
    }

    return dp[n]
}
152. 乘积最大子数组 m i d d l e \color{orange}{middle} middle 题,暂时跳过)
198. 打家劫舍

思路:递推方程为:dp[i] = max(偷上上个房屋 + 偷当前房屋, 偷上个房屋 + 不偷当前房屋)

  • 动态规划 方法1:优化前 空间复杂度 O(n)
    • 初始化:dp[0], dp[1] = nums[0], max(nums[0], nums[1])
    • 递推方程:i从2开始,dp[i] = max(dp[i - 2] + nums[i], dp[i - 1])
    • 返回值:dp[len(nums) - 1]
  • 动态规划 方法2:优化后 空间复杂度 O(1)
    上述方法用数组存储结果,考虑到每间房屋的最高总金额只和【该房屋的前两间房屋】的最高总金额相关。因此,在每个时刻只用通过变量保存前两间房屋的最高总金额即可,无需额外保存更早的记录。
    • 初始化:prepre, pre := nums[0], max(nums[0], nums[1])
    • 递推方程:i从2开始,prepre, pre = pre, max(prepre + nums[i], pre)
    • 返回值:pre
337. 打家劫舍 III m i d d l e \color{orange}{middle} middle 题,暂时跳过)
221. 最大正方形

思路:

  • 若某个格子值为 ‘1’,则在以此格为右下角的正方形中,其最大边长为:上方正方形、左方正方形、左上方正方形 中最小的那条边长,最后再加上右下角格子本身的边长 1。
  • 因此可得递推公式:dp[i][j] = 以坐标点(i,j) 为【右下角】的正方形最大边长 = min(左, 上, 左上) + 1
  • 可参考下图:
    在这里插入图片描述

注意:
最大正方形的边长 并不一定是以 matrix二维数组 右下角的网格参与构成的,而是在遍历过程中不断比较并随时记录到 maxLen 变量中的。因此,最后返回的结果不是 dp[m][n] * dp[m][n],而是 maxLen * maxLen

具体步骤如下:

  • 遍历二维数组
    • 先判断当前matrix[i][j]值是否为’1’
      • 若当前格子值为字符 ‘1’,构造递推方程:
        首先,d[i][j]代表的是 以坐标点 (i,j) 为右下角的正方形最大边长。
        由于 以坐标点(i,j) 为【右下角】的正方形最大边长 = min(左, 上, 左上) + 1,故可得递推方程:dp[i][j] = min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1]) + 1。这里的 +1:表示右下角的格子本身的边长,也要算上,也参与到组成的最大正方形中。
    • 若当前格子值为字符 ‘0’,跳过不作处理
279. 完全平方数 ★ \color{red}{★}

思路:
找到 n 之前最大的一个完全平方数 j(j×j<=n),记为一个个数;那么 还剩 n-j×j 需要继续拼凑。也就是说只要将 n-j×j 的解 dp[n-j×j] 加上上面 j×j 所占的那个1,就是n的解,这就是最短的。
递推方程:dp[i] = min(dp[i], dp[i-j*j] + 1)

注意:
此题和【322. 零钱兑换】类似,在初始化时都需要将dp数组所有元素初始化为 n+1,这样递推方程 dp[i] 在取较小值 min 时,才能获取到和为 n 的完全平方数的最少数量 。

300. 最长递增子序列 ★ \color{red}{★}

具体步骤如下:

  • 初始化:子序列一定都是至少包含 nums[i]本身,占1个长度,所以dp数组的所有元素都初始化为1
  • 双层循环:外循环控制每轮遍历中的结尾位置为 i,内循环则是不断寻找 nums[j:i]区间内最大的递增子序列长度(j从0开始)。
    注意:在内循环(j=0 → j=i)的过程中,dp[i]的值是不断变化的,我们要取的就是这一过程中dp[i]的最大值。
    • if nums[j] < nums[i],则说明当前子序列是递增子序列
      • 可得 递推方程:dp[i] = max(dp[i], dp[j] + 1),其中 dp[i]表示:以 nums[i]结尾的最长递增子序列的长度,且 nums[i]本身也占一个长度,因此 +1。
  • 返回值:注意最后不能返回 dp[n-1],因为最长递增子序列不一定就包含最末尾的元素,而是需要在循环中不断比较并保存到最终返回的结果中。

类似题目有:
1143. 最长公共子序列

309. 最佳买卖股票时机含冷冻期 ★★★ \color{red}{★★★} ★★★

这道题的更多思路可以参考 我的题解,状态比较多,理解起来很绕,感觉有hard难度了,比较容易出错,具体代码及注释如下:

// 参考 LeetCode【评论区】题解:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-cooldown/solutions/181734/fei-zhuang-tai-ji-de-dpjiang-jie-chao-ji-tong-su-y/
// 题目要求:多次买卖一支股票
func maxProfit(prices []int) int {
    days := len(prices)
    dp := make([][]int, days)
    for i := 0; i < len(prices); i++ {
        dp[i] = make([]int, 4) // ABCD四种股票买卖中间状态
    }

    // A.【当天不持有股票】并且也没有卖出操作:原本就不持有股票,可能前些天之前就早卖了没了
    dp[0][0] = 0
   
    // B.【当天不持有股票】因为<今天有卖出操作>:原本是有股票的,但是今天卖掉了就没了,所以不持有
    dp[0][1] = 0 // 第0天,可以理解为今天买入股票,然后当天又卖出了,所以当前收益为0
   
    // C.【当天持有股票】今天才最新买入的股票:前提是前一天没有卖出操作,不处于冷冻期
    dp[0][2] = -prices[0]
   
    // D.【当天持有股票】并不是今天买入的股票:可能是从前一天“继承”过来的股票,一直没操作而已
    dp[0][3] = -prices[0] // 第0天特殊情况,初始化

    for i := 1; i < days; i++ {
        // a.【当天不持有股票】并且也没有卖出操作
        // dp[i-1][0]:原本就一直没有股票
        // dp[i-1][1]:原本有但昨天刚卖了
        dp[i][0] = max(dp[i-1][0], dp[i-1][1])

        // b.【当天不持有股票】因为<今天有卖出操作>
        // dp[i-1][2]:昨天买入的股票,今天卖出
        // dp[i-1][3]:昨天之前早就买入的股票,今天卖出
        // 最后再加上今天买股票的钱 prices[i]
        dp[i][1] = max(dp[i-1][2], dp[i-1][3]) + prices[i]

        // c.【当天持有股票】今天才最新买入的股票
        // 题目要求:卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
        // 所以这里的前提:前一天不能卖出股票(不满足dp[i-1][1]),且之前不持有股票(不满足dp[i-1][2]和dp[i-1][3])
        // 那么今天才能买入,最后再减去今天买股票的钱 prices[i]
        dp[i][2] = dp[i-1][0] - prices[i]

        // d.【当天持有股票】并不是今天买入的股票
        // dp[i-1][2]:前一天买入的
        // dp[i-1][3]:前一天虽未买入但前一天之前就早早持有股票了
        dp[i][3] = max(dp[i-1][2], dp[i-1][3])
    } 

    // 因为买卖到最后,一定是不持有的(即使亏了,卖也比不卖强),所以应该是0和1两种状态,取较大值
    return max(dp[days-1][0], dp[days-1][1])
}

func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}
312. 戳气球 h a r d \color{red}{hard} hard 题,暂时跳过)
322. 零钱兑换 ★ \color{red}{★}

思路:

  • 类似于LeetCode70:爬楼梯,每次爬1,2,5阶楼梯层数
  • 双层循环,外循环表示待拼凑的 amount 数(从1到amount),内层循环表示可选的硬币种类(两层循环倒过来也ok,反正是求最少的硬币个数,并不是硬币的 无序组合 或 有序排列)。

注意:

  • 注意dp数组初始化时的长度大小:dp := make([]int, amount+1),表示从最初的0元逐步递推到amount元。
  • 由于递推方程是求最小值min,所以初始dp数组时应该将其中所有元素初始化为大于amount的任意值,比如:amount + 1,这样才能获取到凑成当前amount的最小硬币数。
  • 关键点:有一种情况,如果面额为 i-coins[j]的硬币不存在(无法正好拼凑),那么 dp[i-coins[j]]的值就还是一开始初始化dp数组时大于amount的任意值,例如:dp[i-coins[j]] = amount+1。然后再通过min函数取较小值时,就可以排除掉 i-coins[j]这种硬币面额不存在的情况。且最后也可以通过 if dp[amount] == amount+1,来判断是否有任何一种硬币组合能组成总金额amount。
// 类似于LeetCode70:爬楼梯,每次爬1,2,5阶楼梯层数
func coinChange(coins []int, amount int) int {
    if amount == 0 {
        return 0
    }

    // 假设最小硬币面额为1,那么最坏情况下,达到amount金额则至少需要amount个面额为1的硬币,且最开始还有个dp[0],故dp长度最大为amount+1
    max := amount + 1
    dp := make([]int, max) 
    dp[0] = 0
    for i := 1; i < max; i++ {
        // 只要初始化为大于 amount 的任意数都可以
        // 主要用于下面的min函数取最小值,且在最后return时可借此判断dp[amount]的值是否有被修改过
        dp[i] = max
    }

    // i代表当前要凑的金额数,从0逐渐递增至amount
    for i := 1; i <= amount; i++ { 
        // 分别选取coins数组中不同面额的硬币,找出其中最少的硬币个数
        // 例如示例1:选取硬币面额为1、2、5时,都对应不同的最少硬币个数,选取这其中硬币个数最少的一组结果
        for j := 0; j < len(coins); j++ {
            // 刚开始i代表的金额较小时,可能会导致 i-coins[j] 的值为负数,越界报错
            if i >= coins[j] {
                // 关键点:如果面额为 i-coins[j] 的硬币不存在(无法正好拼凑)
                // 那么 dp[i-coins[j]] 的值就还是一开始初始化dp数组时的"较大值",即:dp[i-coins[j]] = amount+1
                // 然后在通过min函数取较小值时,就可以排除掉 i-coins[j] 这种硬币面额不存在的情况
                dp[i] = min(dp[i], dp[i-coins[j]] + 1) // +1:代表coins[j]自身所占的一个硬币数
            }
        }
    }

    // 若等于初始值amount + 1,则代表该记录未被修改过
    if dp[amount] == max {
        return -1
    }

    // 最终不能正好拼凑为amount的dp[i]的值都为max

    return dp[amount]
}
416. 分割等和子集 m i d d l e \color{orange}{middle} middle 题,暂时跳过)
494. 目标和 ★ \color{red}{★}

这道题用回溯属于easy 难度,用dp得有 hard 难度。
虽然这道题用动态规划来解,时间复杂度和空间复杂度上会更优,但是我没太能理解,所以暂时用的回溯法来做。

回溯法 关键代码:

func dfs(nums []int, target, sum, i int) {
	// 因为要用到nums数组中的每个元素来构造表达式,因此i最终会等于nums数组的长度
	// 注意是与len(nums)比较,而不是len(nums) - 1
	if i == len(nums) {
        if sum == target {
            res++
        }
        return
    }

	dfs(nums, target, sum + nums[k], k+1) // 加一个数
	dfs(nums, target, sum - nums[k], k+1) // 减一个数
}
647. 回文子串 ★★ \color{red}{★★} ★★

参考 LeetCode大佬题解

中心拓展法(个人认为该方法相对于动态规划来讲,更加清晰明了,便于理解)
因为回文串是中心对称的,我们可以先枚举子串的中心,然后从中心处向两边探测,直到发现两端字符不相等或者到达字符串边缘。

  • s长度为 奇数,中心是单个字符,以 s[i]为中心向两边扩展
  • s长度为 偶数,中心是两个字符,以 s[i]s[i+1]为中心向两边扩展
    在这里插入图片描述
    当然该题也有 动态规划 的方法,但是个人认为以下代码更清晰明了。且因为该题比较具有技巧性,具体代码示例如下:
func countSubstrings(s string) int {
    cnt, length := 0, len(s) 
    for i := 0; i < length; i++ {
        // 回文串s长度为"奇数":
        l, r := i, i // s长度为奇数,中心是单个字符,以s[i]为中心向两边扩展
        for l >= 0 && r < length && s[l] == s[r] {
            l--
            r++
            cnt++
        }
        // 回文串s长度为"偶数":
        l, r = i, i + 1 // s长度为偶数,中心是两个字符,以s[i]、s[i+1]为中心向两边扩展
        for l >= 0 && r < length && s[l] == s[r] {
            l-- 
            r++
            cnt++
        }
    }
    return cnt
}
5. 最长回文子串

中心拓展法(个人认为该方法相对于动态规划来讲,更加清晰明了,便于理解)

注:此题可以在上面的 [647. 回文子串] 题的基础上,加上对最长回文子串的逻辑判断 即可,具体示例代码如下:

func longestPalindrome(s string) string {
    sLength, res := len(s), ""
	
    for i := 0; i < sLength; i++ {
    	// s长度为奇数,中心是单个字符,以s[i]为中心向两边扩展
        l, r := i, i 
        for l >= 0 && r < sLength && s[l] == s[r] {
        	// 通过比较找出更长的最长回文子串
            tmpLength := r - l + 1
            if tmpLength > len(res) {
                res = s[l:r+1]
            }

            l--
            r++
        }

		// s长度为偶数,中心是两个字符,以s[i]、s[i+1]为中心向两边扩展
        l, r = i, i + 1 
        for l >= 0 && r < sLength && s[l] == s[r] {
        	// 通过比较找出更长的最长回文子串
            tmpLength := r - l + 1
            if tmpLength > len(res) {
                res = s[l:r+1]
            }

            l-- 
            r++
        }
    }
    return res
}

十六、滑动窗口(共4题,含2道 h a r d \color{red}{hard} hard 题)

3. 无重复字符的最长子串 ★★★ \color{red}{★★★} ★★★

此题和 剑指 Offer 48. 最长不含重复字符的子字符串 相同,思路可参考 LeetCode视频题解

思路:

  • 定义双指针 start,end := 0, 0 来维护滑动窗口大小,双层循环:
  • 外层循环负责固定每次字符的结束位置end,并记录待比较字符 cmpStr := s[end],
  • 而内层循环负责判断 cmpStr = s[end] 是否在区间 s[start:end) 中出现过(这里循环判断是否出现重复字符,可以通过map来记录字符最后出现的下标位置:m[cmpStr] = end,利用空间换时间的思想,使得时间复杂度提升为O(n))
    • 若出现过则将start移动到出现位置的后一个位置(去重),并更新length为 end - start;
      反之不处理
  • 最后在内循环外面更新maxLength和end的边界值

具体步骤如下:

  • 创建左右两个指针,startendend下标不断向右移动。
  • 内循环 每次判断当前遍历到的新的 s[end]是否在之前已出现过(即是否重复出现)。
    若出现过,则将 start下标指向之前出现位置的下一个位置,并更新当前不重复子串长度 length = end - start (这里本来应该是 length = end -start + 1,但被合并到了内循环外面的 length++中)。
    这一过程其实是为了跳过之前的重复字符(比如 abbbc,最终会跳过中间的 bbb)。
  • 更新右指针下标 end++maxLength = max(maxLength, length)子串的最大长度,且 length++使得不重复子串长度加1。

具体代码如下:

// 方法1-滑动窗口 利用map优化前:
// 时间复杂度:O(n^2),空间复杂度:O(1)
/*
思路:
定义双指针left,right,来维护滑动窗口左右边界,双层循环:
外循环负责固定每次字符的结束位置right,并记录待比较字符cmpStr := s[right],
而内循环则判断 cmpStr = s[right] 是否在区间 s[left:right) 中出现过(这里循环判断是否出现重复字符,可以用map来记录字符最后出现的下标位置:m[cmpStr] = right,利用空间换时间的思想,使得时间复杂度提升为O(n))
若出现过则将left移动到出现位置的后一个位置(去重),并更新length为 right - left;
反之不处理
最后在内循环外面更新maxLength和right的边界值
*/
func lengthOfLongestSubstring(s string) int {
    // left:无重复字符的子串左边界;right:右边界
    // length:当前子串长度;maxLength:最长子串长度
    left, right, length, maxLength := 0, 0, 0, 0
    for right < len(s) {

        // 过滤重复字符:将cmpChar与s[left:right)的字符逐个比较。比如abbbc,最终会跳过中间的bbb
        for i := left; i < right; i++ {
            if s[i] == s[right] {
                left = i + 1        // 若出现重复字符,left跳过重复字符,指向重复字符的下一位置
                length = right - left // 计算length时外循环中会对length++,所以这里暂时无需+1操作
                break                // s[left:right)中已经存在相同字符,break退出进行下一轮外循环
            }
        }
        length++ 
        right++ // 扩展右边界
        maxLength = max(maxLength, length)
    }

    return maxLength
}

/******************************************************************/

// 方法2-滑动窗口 利用map优化后(判断tmpStr在s[start,end)中之前是否已经重复出现过):
// 时间复杂度:O(n),空间复杂度:O(n)
// 1、创建map,key:每个字符,val:s中每个字符最后出现的下标
// map用于判断某个字符是否在滑动窗口中出现过
// 2.1、如果某字符在滑动窗口中出现过,则更新滑动窗口左边界,并记录子串长度,扩大右边界
// 2.2、反之,扩大右边界,这两步扩大右边界的操作可合并
// 3、过程中不断更新最长子串长度res
func lengthOfLongestSubstring(s string) int {
    // left:无重复字符的子串左边界;right:右边界
    // length:当前子串长度;maxLength:最长子串长度
    left, right, length, maxLength, sLength := 0, 0, 0, 0, len(s)
    lastIndexMap := make(map[byte]int) // 存储已遍历字符最后出现的下标位置

    // 滑动窗口的左右边界控制,右边界要一直右移
    for right < sLength {
        cmpChar := s[right]

        if lastIndex, ok := lastIndexMap[cmpChar]; ok && lastIndex >= left {
            // 之前出现过该字符,更新left,length
            left = lastIndex + 1
            length = right - left
        }/* else {
            // 之前未出现过该字符,右移滑动窗口右边界
            right++ // 该步骤统一合并到下面的right++中
        }*/
        lastIndexMap[cmpChar] = right
       
        length++
        right++ // 右移右边界
        maxLength = max(maxLength, length)
    }

    return maxLength
}
76. 最小覆盖子串 h a r d \color{red}{hard} hard 题,暂时跳过)
239. 滑动窗口最大值 h a r d \color{red}{hard} hard 题,暂时跳过)
438. 找到字符串中所有字母异位词 ★ \color{red}{★}

思路:
将长度为 len(p)且不断右移的滑动窗口sCntArr与pCntArr比较

  • 先构建两个[26]int数组 sCntArr 和 pCntArr,分别统计"在s中滑动的滑动窗口"和"被比较字符串p"中字母出现的次数
  • 正式比较两数组前,先要根据字符串p来填充1次sCntArr和pCntArr,并判断是否为异位词
  • 后续只需更新滑动窗口并比较,不断使其右移即可(左出右进,保证滑动窗口长度为 len(p)

具体步骤如下:

  • 首先,获取 sLen = len(s)pLen = len(p),如果 sLen < pLen,直接返回空结果集。
  • 然后,创建两个长度为 26 的 int 类型数组 sCntArrpCntArr,用作比较【长度为pLen,在字符串s中不断向右滑动的滑动窗口中对应子串】与【给定字符串p】是否是字母异位词。
    初始化这两个数组,并进行 首次比较 ,若相同则向结果集中追加当前滑动窗口的起始位置 0
	// 初始化这两个数组
	// 分别统计【在s中滑动的滑动窗口】和【被比较字符串p】中字母出现的次数
	for i := 0; i < len(p); i++ {
        pCntArr[p[i] - 'a'] += 1
        sCntArr[s[i] - 'a'] += 1
    }

    // 首次比较
    if sCntArr == pCntArr {
    	// 字符串s[:pLen]为p的字母异位词
        res = append(res, 0)
    }
  • 后续比较 中,滑动窗口每次从左边滑出一个扫描过的旧字符从右边滑进一个未扫描过的新字符。然后比较两个数组 sCntArrpCntArr是否相同,若相同则向结果集中追加当前滑动窗口的起始位置 i+1
	for i := 0; i < sLen - pLen; i++ {
		// 左边滑出一个扫描过的旧字符
		sCntArr[s[i] - 'a'] -= 1 
		// 右边滑进一个未扫描过的新字符
	    sCntArr[s[i+pLen] - 'a'] += 1

		if sCntArr == pCntArr {
            res = append(res, i+1)
        }
    }
  • 最后返回结果集

十七、贪心(共1题)

55. 跳跃游戏
  • 贪心法-1 【从左向右】,参考 LeetCode题解
    • 定义一个 maxJump = 0,表示从头开始能跳到的最大覆盖范围,每次都只向右跳一次
    • for 循环:for i := 0; i <= maxJump; i++ {
      • 更新 maxJump 范围 maxJump = max(maxJump, i + nums[i]),在 “上一次索引能覆盖的范围” 和 “当前索引能覆盖的范围” 中取一个较大值,作为当前能跳跃到的的最远距离
      • 每次循环都判断:当 目前能跳跃的最大范围 maxJump>= 数组最后一个索引 len(nums) - 1时,返回 true,否则返回 false。
  • 贪心法 2:【从右向左】,参考 B站视频题解 最后面讲解的贪心法
    • 起初定义一个 maxJump = len(nums) - 1,表示最终要跳到数组最后一个下标位置
    • 倒数第二个下标位置开始判断 能否到达最后一个下标位置:若满足【当前下标位置i + 当前可跳跃最大长度nums[i] >= 这一轮循环中要到达的目标位置 maxJump】,则表示从当前下标位置可以到达它右侧的目标位置 maxJump。
    • 其实就是判断 倒数第二个位置 是否能到达 倒数第一个位置 。若能到达,则继续判断 倒数第三个位置 是否能到达 倒数第二个位置,以此类推 … 最终到给定数组nums的首位置(下标为0)

十八、数学(共1题)

48. 旋转图像 ★ \color{red}{★}

思路:此题比较有技巧性,分为以下两步:

  • 主对角线(左上至右下)翻转
  • 以中心轴水平翻转每一行
func rotate(matrix [][]int)  {
	// 因为题目说是n*n的矩阵,所以这里实际上m=n
    m, n := len(matrix), len(matrix[0])

    // 对角线翻转(左上至右下)
    for i := 0; i < m; i++ {
        for j := 0; j < i; j++ { // 注意:这里j < i
            matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
        }
    }

    // 水平翻转
    for i := 0; i < m; i++ {
        for j := 0; j < n/2; j++ { // 注意:这里j < n/2
            matrix[i][j], matrix[i][n-j-1] = matrix[i][n-j-1], matrix[i][j]
        }
    }
}

补充一道类似技巧性的非 HOT100🔥 的高频题

189. 轮转数组
假设数组长度为:n = len(nums)
先整体翻转:0 ~ n-1
再翻转前 k 个:0 ~ k-1
最后翻转后 n - k 个: k ~ n


十九、其它(共2题,含1道 力扣 V I P 专属 \color{red}{力扣VIP专属} 力扣VIP专属 题)

253. 会议室 II 力扣 V I P 专属 \color{red}{力扣VIP专属} 力扣VIP专属 题,暂时跳过)
621. 任务调度器 m i d d l e \color{orange}{middle} middle 题,暂时跳过)

以下 m i d d l e \color{orange}{middle} middle 题目我暂时跳过了,后面有精力再研究:

406. 根据身高重建队列
152. 乘积最大子数组
416. 分割等和子集
337. 打家劫舍 III
581. 最短无序连续子数组
253. 会议室 II
621. 任务调度器

常见高频题:

88. 合并两个有序数组,倒序双指针,类似于 21. 合并两个有序链表
面试题 01.06. 字符串压缩,类似于 443. 压缩字符串

;