207. 课程表 (Course Schedule)
标签: 拓扑排序, 广度优先搜索, 深度优先搜索
解法:
- 第一种解法是使用拓扑排序, 或者说从入度为零的结点开始的广度优先搜索. 我们使用一个队列来存储所有当前遍历到的入读为零的结点, 每次我们从队列中出队一个结点并将其加入当前拓扑序列的末尾, 然后遍历该结点的所有邻居, 由于该结点已经被遍历, 因此所有这些邻居的入度都应该减一, 我们将那些入度降为零的邻居入队, 然后重复前述过程直到队列为空为止, 此时的拓扑序列即为最终的合法的拓扑序列.
- 第二种解法是使用深度优先搜索. 第一次我们从任意一个结点出发, 使用深度优先搜索收集所有遍历到的结点, 如果路上遇到了当前正在被遍历的结点说明当前深度优先搜索路径中存在环, 于是可以直接返回
false
, 否则遍历返回的过程中我们将路径中的每个结点标记为已经被遍历过的. 此后的每一次我们均从任意没有被遍历到的结点出发, 如果路上遇到了已经被遍历过的结点则忽略, 如果遇到了当前正在被遍历的结点则仍然直接返回false
, 以此类推, 直到所有结点均被遍历过为止. 可以看到我们需要维护三个状态, 分别是初始状态 (NOT_VISITED
), 当前正在被遍历 (VISITING
), 以及已经被遍历过 (VISITED
). - 如果说广度优先搜索是按照 "从左到右" 的顺序 "一个一个" 复原出拓扑序列, 那么深度优先搜索就是按照 "从右到左" 的顺序 "一段一段" 复原出拓扑序列.
代码 (拓扑排序的解法):
|
|
代码 (深度优先搜索的解法):
|
|
208. 实现 Trie (前缀树) (Implement Trie (Prefix Tree))
标签: 字典树
解法:
- 本题实际上是在实现一个度为 26 的树.
- 对于插入操作 (
insert
), 我们可以从根结点开始遍历, 在遍历过程中一边遍历一边创建此前未出现的字母所对应的结点, 并在到达最后一个字母所对应的结点时将其标记为 "有单词以此结点结尾", 以便为之后的查找提供信息. - 对于查找操作 (
search
), 我们同样可以从根结点开始遍历, 如果中途发现当前字母所对应的结点还未创建则说明查找失败, 如果所有结点均已创建但最后一个字母所对应的结点并未被标记为 "有单词以此结点结尾" 则同样说明查找失败, 否则说明查找成功. - 对于查找前缀操作 (
startsWith
), 我们使用类似查找操作中的过程进行查找, 但区别是我们无需确保最后一个字母所对应的结点必须被标记为 "有单词以此结点结尾", 而是只要确定所有结点均已创建即可返回查找成功.
- 对于插入操作 (
代码:
|
|
215. 数组中的第K个最大元素 (Kth Largest Element in an Array)
标签: 快速排序, 堆排序
解法:
- 第一种解法是使用快速排序中的扫描算法, 每一趟扫描都能够将数组分为三个部分, 分别是小于当前主元的部分, 等于当前主元的部分, 以及大于当前主元的部分, 而第 k 大的元素必然出现在这三个部分中的其中一个部分中, 于是我们便能够通过不断循环并缩小搜索范围来确定第 k 大的数.
- 第二种解法是使用堆排序. 堆排序的具体算法可以参考《算法导论》. 假设我们要维护一个最小堆, 那么堆排序将涉及到如下基本操作:
- 向下调整 (
adjust_down
): 假设当前堆的左右两个子堆符合最小堆的性质, 而根结点却大于两个子堆的堆顶结点 (即最小结点), 我们便需要将根结点与两个子堆的堆顶结点中较小的那个进行互换 (此时两个子堆的堆顶结点中较小的那个结点的值实际上就是整个堆的最小值). 互换后的那个子堆将不再符合最小堆的性质, 因此我们需要继续对互换后的子堆进行向下调整. 重复上述过程直到到达堆底为止. - 建堆 (
build
): 建堆的过程实际上就是从堆的最后一个元素开始按相反的顺序进行遍历, 对于每个遍历到的元素, 我们对以该元素为根结点的堆执行一次向下调整操作, 直到遍历完第一个结点为止. 可以证明建堆过程总体的时间复杂度不会超过 $O(n)$, 其中 $n$ 为堆的总大小. - 出堆 (
pop
): 出堆实际上就是将堆的最后一个元素放置到堆顶以模仿弹出堆顶元素的操作, 然后对其进行向下调整操作. 之所以需要执行向下调整操作是因为堆的最后一个元素可能大于原来的堆顶元素, 而将其放置到堆顶有可能破坏堆的性质.
- 向下调整 (
代码 (快速排序的解法):
|
|
代码 (堆排序的解法):
|
|
221. 最大正方形 (Maximal Square)
标签: 动态规划
解法:
- 本题与 "85. 最大矩形 (Maximal Rectangle)" 不同. 本题中所要求的是正方形, 相比矩形而言要额外多了一层约束, 因此状态转移方程要相对简单许多. 对于当前遍历到的元素, 我们考虑 "以该元素为右下角的最大正方形", 易知该正方形的边长由 "以该元素的左边元素为右下角的最大正方形" 的边长和 "以该元素的上方元素为右下角的最大正方形" 的边长中的较小值决定, 如果这两个正方形的重叠的矩形区域的左上角元素的左上方元素同样为
1
, 那么 "以该元素为右下角的最大正方形" 的最大边长还能够再加一, 于是我们能够很容易地写出状态转移方程. 由于状态转移过程中只用到了左边元素和上方元素的信息, 因此我们可以使用滚动数组将空间复杂度优化至 $O(n)$.
代码:
|
|
226. 翻转二叉树 (Invert Binary Tree)
标签: 递归, 迭代
解法:
- 第一种解法是使用递归. 我们可以递归地先对两棵子树进行翻转, 然后再交换左右子树的根结点, 这等价于对整棵树进行翻转. 递归的解法实际上类似于深度优先搜索.
- 第二种解法是使用迭代, 类似于广度优先搜索.
代码 (递归的解法):
|
|
代码 (迭代的解法):
(略)
234. 回文链表 (Palindrome Linked List)
标签: 双指针, 迭代, 递归
解法:
- 第一种解法是使用迭代. 如果要判断一个链表是否为回文链表, 我们实际上只需要对链表进行二分, 并对右半部分进行反转, 然后逐个匹配左右两个子链表即可. 由于最后需要对链表进行复原, 这里我们选择对右半部分而不是左半部分进行反转.
- 第二种解法是使用递归. 在递归的解法中我们实际上是在使用函数的调用栈来正向遍历并存储整个链表的所有结点, 并在递归返回时自然地对链表进行逆向遍历, 在逆向遍历的过程中我们可以额外维护一个独立地进行正向遍历的指针, 在每层递归返回时, 逆向指针自然地向左移动, 而正向指针将被我们手动向右移动, 于是我们便能够通过比较正向指针和逆向指针所指向地一对结点的值是否相等来判断该链表是否为回文链表.
代码 (迭代的解法):
|
|
代码 (递归的解法):
|
|
236. 二叉树的最近公共祖先 (Lowest Common Ancestor of a Binary Tree)
标签: 递归
解法:
我们递归地对树进行遍历. 对于当前遍历到的树:
- 如果根结点为空, 那么返回空指针.
- 如若不然, 如果根结点恰为
p
或q
中的一个, 那么返回值为根结点. - 如若不然, 如果左子树为空, 那么我们递归地遍历右子树并返回其结果.
- 如若不然, 如果右子树为空, 那么我们递归地遍历左子树并返回其结果.
- 如若不然, 我们同时对左右子树进行遍历. 对于左右子树所返回的结果:
- 如果左子树和右子树均返回了非空结果, 说明
p
和q
分别位于左右子树中, 当前根结点就是二者的最近公共祖先, 于是我们返回根结点. - 如若不然, 如果左子树返回了空结果, 那么我们返回右子树的结果.
- 如若不然, 我们返回左子树的结果.
- 如果左子树和右子树均返回了非空结果, 说明
由上述过程可知, 如果遍历一棵树所得到的结果为空, 那么说明这棵树中不存在
p
和q
; 如果所得到的结果为非空, 那么说明这棵树中至少存在p
和q
中的其中一个. 由于题目保证p
和q
必然存在于顶层树中, 因此当顶层树的左子树返回空结果时说明右子树的根结点就是二者的最近公共祖先, 反之亦然. 算法的正确性得证.
代码:
|
|
238. 除自身以外数组的乘积 (Product of Array Except Self)
标签: 前缀和
解法:
- 对于当前元素而言, "除自身以外数组的乘积" 实际上就等于 "自身左边所有元素的乘积" 乘上 "自身右边所有元素的乘积". 由于这里涉及到两组前缀和 (或前缀乘积 (名称不重要, 重要的是思想)), 因此我们必须额外使用一个数组来记住任意一个前缀和, 然后在反向遍历的过程中实时计算出另一组前缀和并生成最终的答案数组.
代码:
|
|
239. 滑动窗口最大值 (Sliding Window Maximum)
标签: 滑动窗口, 单调队列
解法:
- 对于每个滑动窗口, 我们需要实时维护该窗口中的最大值. 观察到滑动窗口总是从左向右移动, 对于当前窗口中的最大值, 我们总是可以确定从当前窗口的最左端开始直到最大值所处的位置为止 这中间所有的元素 (除最大值元素本身以外) 均已不可能成为接下来任何滑动窗口的最大值, 因此我们可以放心地将其忽略. 同理, 从最大值到次大值也是如此, 以此类推, 最终滑动窗口中将只剩下一个由严格递减的元素构成的单调队列. 而每当滑动窗口向右移动时, 原窗口中的最右端的元素的下一个元素将加入单调队列, 而原窗口中的最左端的元素 (若未被忽略) 则将从单调队列中出队. 对于每个滑动窗口, 其对应的单调队列中最左端的值即为该窗口中的最大值, 我们只需要如实记录即可.
代码:
|
|
240. 搜索二维矩阵 II (Search a 2D Matrix II)
标签: 双指针
解法:
由于矩阵具有从左到右非递减, 从上到下非递减的性质, 我们考虑矩阵右上角的元素:
- 如果该元素大于目标值, 那么该元素下方的所有元素 (由于从上到下非递减的性质) 均将会大于目标值, 因此可将其忽略. 这等价于矩阵的最后一列被删去, 原矩阵化为列数减一的新子矩阵, 原问题转化为等价的子问题.
- 如果该元素小于目标值, 那么该元素左边的所有元素 (由于从左到右非递减的性质) 均将会小于目标值, 因此可将其忽略. 这等价于矩阵的第一行被删去, 原矩阵化为行数减一的新子矩阵, 原问题转化为等价的子问题.
由此可见如果我们重复上述步骤, 原问题将最终被转化为一个平凡的边界子问题.
代码:
|
|
253. 会议室 II
(本题为力扣 VIP 会员专属题目)
279. 完全平方数 (Perfect Squares)
标签: 动态规划
解法:
- 本题类似于背包问题, 但由于完全平方数的性质, 本题实际上可以采用更为简单粗暴的解法. 对于当前遍历到的数 $n$ 而言, 其第一个完全平方数零件可以是 $1^2, 2^2, \dots, \lfloor \sqrt{n} \rfloor^2$ 中的任意一个, 此后原问题转化为求 $n - i^2$ 的组合个数的子问题, 其中 $i = 1, 2, \dots, \lfloor \sqrt{n} \rfloor$. 注意到我们在此处是对 $n$ 进行遍历, 而不是对可能的完全平方数零件 $i^2$ 进行遍历, 而在一般的 0-1 背包问题中由于每个零件的大小关系不确定, 我们在主循环中是对各个零件进行遍历, 这里存在较大的区别.
代码:
|
|
283. 移动零 (Move Zeroes)
标签: 快速排序, 双指针
解法:
- 直接套用快速排序中的扫描算法即可, 其中
0
作为主元被扫到第二部分, 其余不为零的元素均视作 "小于" 零并被扫到第一部分, 第三部分保持为空.
代码:
|
|
287. 寻找重复数 (Find the Duplicate Number)
标签: 二分查找, 位运算, 快慢指针
解法:
- 首先题目给出了较为严格的约束: 整个数组中只有一个重复的整数.
- 其次题目提出了较为严格的要求: 必须不修改数组, 且只用常量级 $O(1)$ 的额外空间.
- 第一种解法是使用二分查找. 注意到对于任何 "小于重复的数字" 的数字而言, "整个数组中小于等于该数字的数字的个数" 必然小于等于 "该数字的值"; 反过来对于任何 "大于等于重复的数字" 的数字而言, "整个数组中小于等于该数字的数字的个数" 必然大于 "该数字的值", 这就在两者之间建立起了顺序关系, 我们便可以通过二分查找来找到这一分界线, 而分界线的值恰好就是我们要求的目标值.
- 第二种解法是使用位运算. 以最低有效位为例, 由于仅存在一个重复的数字, 如果该数字的最低有效位为一, 由于数组的长度为 $n + 1$, 整个数组中的所有数字的最低有效位之和必然大于 $1$ 到 $n$ 这 $n$ 个互不相同的数字的最低有效位之和; 反之整个数组中的所有数字的最低有效位之和必然小于等于 $1$ 到 $n$ 这 $n$ 个互不相同的数字的最低有效位之和. 根据这一点我们便能够一位一位地推出所重复的数字.
- 第三种解法是使用快慢指针. 注意到由于数组中的元素的值总是位于 $[1, n]$ 这个范围, 整个数组实际上形成了一个静态链表, 数组元素的值即为该元素所指向的下一个元素在数组中的索引. 由于每个结点总是指向非空结点, 无论我们从哪个结点出发, 我们最终都将会进入一个环中, 同时题目保证了入环口的存在性 (由数组长度大于 $n$ 推出, 否则数组中的元素有可能形成首尾相连的无重复元素的环) 和唯一性 (整个数组中只有一个重复的元素), 因此我们只需要从数组的最后一个元素出发 (为了确保从入环口外开始, 而非一开始就位于环的内部), 使用快慢指针确定入环口, 那么该入环口便是所重复的数字.
代码 (二分查找的解法):
(略)
代码 (位运算):
(略)
代码 (快慢指针):
|
|
297. 二叉树的序列化与反序列化 (Serialize and Deserialize Binary Tree)
标签: 前序遍历, 中序遍历, 层序遍历
解法:
- 第一种解法是使用前序遍历. 在序列化的时候, 我们首先序列化当前树的根结点, 然后递归地序列化左子树和右子树. 如果一棵树为空, 那么我们使用某个特殊的字符串 (例如
N
) 对其进行标记. 在反序列化时, 我们首先提取出根结点, 然后同样递归地反序列化左子树和右子树. 由于我们使用了特殊的字符串标记了空子树, 我们实际上等价地标记了左子树和右子树的范围, 因此递归过程最终将能够成功返回. - 第二种解法是使用中序遍历. 由于中序序列中根结点的位置从一开始就是不确定的, 因此我们必须使用额外的标记来确定根结点的位置, 例如使用类似于巴科斯范式 (BNF) 的记号
(<left-sub-tree>) <root-value> (right-sub-tree)
, 其中使用括号表示子树的范围, 同时我们仍然使用特殊的字符串来标记空子树, 这样便能够保证递归过程的正确性. - 第三种解法是使用层序遍历. 在层序遍历中每一层的结点个数由上一层的非空结点个数决定, 而第一层的结点个数总是确定的 (因为第一层仅包含根结点), 因此能够确保解析过程的正确性.
代码 (前序遍历的解法):
(略)
代码 (中序遍历的解法):
|
|
代码 (层序遍历的解法):
|
|
300. 最长递增子序列 (Longest Increasing Subsequence)
标签: 动态规划, 二分查找
解法:
考虑从左到右遍历数组. 对于每一个遍历到的位置, 我们维护一个 dp 数组表示 "从数组的最左端到当前位置为止这一范围内对应于每个可能的递增子序列长度的所有子序列的末尾元素的最小值". 例如对于序列
[2, 1, 3]
, 其对应的 dp 数组为[1, 3]
, 其中1
是长度为 1 的递增子序列的末尾元素的最小值, 而3
是长度为 2 的递增子序列的末尾元素的最小值, 该序列中不存在长度为 3 以上的递增子序列. 易证 dp 数组中的元素同样满足从左到右严格递增的顺序. 每次向右遍历一个元素时, 我们考虑添加所遍历到的当前元素:- 对于 dp 数组中大于等于当前元素的元素而言, 由于这些元素已经大于等于当前元素, 也就无法形成更长的递增序列, 因此可以忽略.
- 对于 dp 数组中小于当前元素的元素而言, 将当前元素添加进这些元素中的任何一个的末尾都将能够形成更长的递增序列, 但问题是如何添加以便维护 dp 数组的性质不变? 答案是添加至这些元素中最大的那个元素所代表的递增序列的末尾并尝试更新 dp 数组. 这是因为当前元素已经大于已经大于其他更小的元素, 将其添加进任何其他更小的元素的末尾所形成的新的递增序列都无法更新 dp 数组.
为了确保长度为 1 的递增序列也能够通过上述过程进行更新, 我们可以预先在 dp 数组中添加一个值为
INT_MIN
的数来模拟一个长度为 0 的假想数组.
代码:
|
|
301. 删除无效的括号 (Remove Invalid Parentheses)
标签: 回溯
解法:
- 本题直接使用一般的回溯即可解答, 但如果不仔细考虑剪枝的话十分容易便会超出时间限制. 本题的剪枝方法主要有以下三点:
- 所要删除的左右括号的数量是固定的.
- 右括号能够添加当且仅当目前仍然存在左括号能够用于匹配.
- 对于连续都是左括号或者连续都是右括号的区间, 我们需要确保不重复且不遗漏地遍历其中的组合, 其中重点是不重复.
- 对于第一点, 要删除的左括号的数量就是整个序列中未能匹配的左括号的数量, 而要删除的右括号的数量等于遍历过程中未能匹配的右括号的数量.
- 对于第三点, 如果我们按照枚举子集的方法来进行回溯, 那么很容易便会超出时间限制. 因此我们必须按照枚举组合的方法, 先通过遍历得到当前连续都是左括号 (或者连续都是右括号) 的区间的长度, 然后从零开始顺序枚举长度 (而非对应于各个长度的所有子集), 这样便能够使时间复杂度最优.
代码:
|
|
309. 买卖股票的最佳时机含冷冻期 (Best Time to Buy and Sell Stock with Cooldown)
标签: 动态规划
解法:
- 本题属于经典的动态规划的题目. 我们考虑维护三个状态, 分别是 "在从数组的最左端到当前位置为止之间的任意位置买入并在此后不做任何操作所能得到的最大收益" (这是因为买入和卖出之间可以间隔任意长的区间), "在当前位置卖出所能得到的最大收益", 以及 "在当前位置冻结所能得到的最大收益". 我们能够很容易地写出状态转移方程:
- "在从数组的最左端到当前位置为止之间的任意位置买入并在此后不做任何操作所能得到的最大收益" = "在从数组的最左端到当前位置的前一个位置为止之间的任意位置买入并在此后不做任何操作所能得到的最大收益" (即不在当前位置买入所能得到的最大收益) 与 "在当前位置买入所能得到的最大收益" 之间的较大值.
- "在当前位置卖出所能得到的最大收益" = "在从数组的最左端到当前位置的前一个位置为止之间的任意位置买入并在此后不做任何操作所能得到的最大收益" + "当前位置的价格".
- "在当前位置冻结所能得到的最大收益" = "在当前位置的前一个位置冻结所能得到的最大收益" 与 "在当前位置的前一个位置卖出所能得到的最大收益" 之间的较大值.
- 由于当前位置的状态仅由前一个位置的状态决定, 因此我们可以使用 $O(1)$ 长度的滚动数组来优化空间复杂度.
代码:
|
|
312. 戳气球 (Burst Balloons)
标签: 动态规划
解法:
- 我们考虑戳破范围
[i, j)
内的气球. 假设我们最后戳破的是第k
个气球, 其中i <= k < j
, 那么无论我们以何种顺序戳破范围内的气球, 这一戳破气球的序列总是能够被分为 "戳破[i, k)
范围内的气球", "戳破[k + 1, j)
范围内的气球", 以及 "戳破第k
个气球" 这三个部分, 由此可知 "戳破[i, j)
范围内的气球所能得到的最大收益" 实际上恰好等于 "戳破[i, k)
范围内的气球所能得到的最大收益" 加上 "戳破[k + 1, j)
范围内的气球所能得到的最大收益" 再加上 "戳破第 k 个气球所能得到的收益". 于是我们便能够通过动态规划求出所有范围的最大收益并得到最终的答案.
代码:
|
|
322. 零钱兑换 (Coin Change)
标签: 动态规划
解法:
- 本题类似于 "279. 完全平方数 (Perfect Squares)", 由于零件的个数和数量级远小于背包的容量, 因此我们应当在主循环中遍历背包容量而不是遍历零件集合.
代码:
|
|