【自用】LeetCode Hot100 刷题总结

注:题解和代码有些来自 @灵茶山艾府 大佬 和 力扣官方,本人只是记录自己的刷题过程

Day01 哈希

1.两数之和

创建一个哈希表,对于每一个 x,我们首先查询哈希表中是否存在 target - x,然后将 x 插入到哈希表中,即可保证不会让 x 和自己匹配。

49.字母异位词分组

由于互为字母异位词的两个字符串包含的字母相同,因此对两个字符串分别进行排序之后得到的字符串一定是相同的,故可以将排序之后的字符串作为哈希表的键。

emplace_back:

128.最长连续序列

本题是不能排序的,因为排序的时间复杂度是 O(nlogn),不符合题目 O(n) 的要求。

核心思路:对于 nums 中的元素 x,以 x 为起点,不断查找下一个数 x+1,x+2,⋯ 是否在 nums 中,并统计序列的长度。

如果 x−1 在哈希集合中,则不以 x 为起点。为什么?因为以 x−1 为起点计算出的序列长度,一定比以 x 为起点计算出的序列长度要长。

设 m 为 nums 中的不同元素个数(即哈希集合的大小)。各个连续序列(链)是互相独立的,如果我们发现其中一条链的长度至少为 m/2(长度乘 2 大于等于 m),由于不可能还有一条长度大于  m/2(否则这两条链的长度之和就超过 m 了),答案不会再增大,此时可以直接返回答案。


​Day02双指针

283.移动零

快慢指针,快指针指向要交换的元素,慢指针指向放元素的位置

15.三数之和

首先将数组排序,然后有一层for循环,i从下标0的地方开始,同时定一个下标left 定义在i+1的位置上,定义下标right 在数组结尾的位置上。依然还是在数组中找到 abc 使得a + b +c =0,我们这里相当于 a = nums[i],b = nums[left],c = nums[right]。移动left 和right: 如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下标就应该向左移动;如果 nums[i] + nums[left] + nums[right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些,直到left与right相遇为止。

去重:判断 nums[i] 与 nums[i + 1]是否相同,把 三元组中出现重复元素的情况直接pass掉了。 例如{-1, -1 ,2} 这组数据,当遍历到第一个-1 的时候,判断 下一个也是-1,那这组数据就pass了。要做的是 不能有重复的三元组,但三元组内的元素是可以重复的。所以是判断 nums[i] 与 nums[i-1] 是否相同。

11.盛最多水的容器

容器的面积取决于短板,移动长板永远不会得到更好的结果。因此在每一步,我们都应该移动短板向内收缩。

42.接雨水(单调栈/双指针)

单调栈

通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。就是用一个栈来记录我们遍历过的元素,因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以我们需要用一个容器(这里用单调栈)来记录我们遍历过的元素。单调栈里只需要存放元素的下标i就可以。栈头到栈底的顺序:如果求一个元素右边第一个更大元素,单调栈就是递增的,如果求一个元素右边第一个更小元素,单调栈就是递减的。三个条件:1.当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况。2.当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况。3.当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况。

说回本题:单调栈是按照行方向来计算雨水。从栈头(元素从栈头弹出)到栈底的顺序应该是从小到大的顺序。因为一旦发现添加的柱子高度大于栈头元素了,此时就出现凹槽了,栈头元素就是凹槽底部的柱子,栈头第二个元素就是凹槽左边的柱子,而添加的元素就是凹槽右边的柱子。遇到相同的元素,更新栈内下标,就是将栈里元素(旧下标)弹出,将新元素(新下标)加入栈中。因为我们要求宽度的时候 如果遇到相同高度的柱子,需要使用最右边的柱子来计算宽度。

双指针

对于任意一个位置 i,它能接多少雨水,取决于它左边最高的墙和它右边最高的墙中,较矮的那一个的高度。接水量 = min(左边最高墙, 右边最高墙) - 当前柱子的高度,如果算出来是负数(即当前柱子比两侧最高的都高),那它接不了水,最多为 0。

Day03滑动窗口

3.无重复字符的最长字串

维护一个动态的区间 [left, right](即窗口),使得这个窗口内的字符都是不重复的。right 指针(右边界): 负责向右扩张,试图把新的字符加入窗口。left 指针(左边界): 负责在窗口出现重复字符时,向右收缩,直到去掉重复字符,让窗口恢复“纯净”。

438.找到字符串中所有字母的异位词

在字符串 s 上维护一个长度为 p.length() 的固定大小窗口,不使用两个哈希表分别统计 p 和窗口内的字符,而是直接用一个大小为 26 的 cnt 数组。这个数组存储的是 “窗口内的字符需求余额”。

滑动窗口总结

1. 核心思想: “以空间换时间”

滑动窗口的本质,是在一个线性数据结构(如数组、字符串)上维护一个连续的区间(子数组/子串)

  • 右指针 (right):负责扩张窗口,引入新的元素。

  • 左指针 (left):负责收缩窗口,在条件被破坏时移除旧元素。

  • 威力:它把原本需要暴力枚举 O(N2)O(N2) 的子区间问题,优化到了 O(N)O(N) 的线性时间。因为每个元素最多被 right 访问一次,被 left 访问一次。

2. 适用场景(什么时候用滑动窗口?)

遇到以下特征,通常优先考虑滑动窗口:

  1. 求最值:求满足某个条件的最长最短连续子数组/子串。

  2. 特定条件:子数组/子串的元素必须连续(下标相邻)。

  3. 条件约束:需要在满足某个业务条件(如:“无重复字符”、“包含目标字符串的所有字符”、“和不超过 K”)的前提下进行查找。

3. 灵魂分类:固定窗口 VS 可变窗口

滑动窗口通常分为两种类型,模板略微不同:

A. 可变大小窗口(最常见,如“无重复字符的最长子串”)

窗口大小不是固定的。right 不断右移,一旦条件不满足,就移动 left 直到条件重新满足,然后比较长度。

B. 固定大小窗口(如“找到所有字母异位词”)

窗口长度固定为 p.length()right 和 left 同时移动(或者写两个循环,先填满窗口再滑动)。

4. 核心难点与解决方案

  1. 如何判断窗口内的状态?

    • 简单场景(求和):直接用变量 sum 记录。

    • 复杂场景(字符计数):使用 哈希表 (unordered_map) 或 固定大小数组 (int cnt[26])。特别是字母异位词那一题,差额数组(加进来减,移出去加)是非常精妙的优化。

  2. 为什么有时要用 while 而不是 if

    • 左指针收缩时,可能收缩一次还不够。比如“异位词”里多了 2 个 a,你需要把 left 移动两次直到把多余的 a 完全踢出去。所以必须用 while (条件)

  3. 边界条件

    • 小心数组越界。right < s.size() 是必须的。

    • right - left + 1 是算长度的黄金公式,注意区分下标差和实际长度(下标差是 right - left,长度是 right - left + 1)。

     5.单调性

              1.滑动窗口需要满足单调性,当右端点元素进入窗口时,窗口元素和是不能减少的。

Day04子串

560.和为k的子数组(前缀和)

前 i 个数的和,加上 a[i],就是前 i+1 个数的和,即s[i+1]=s[i]+a[i]。

通过前缀和,我们可以把子数组的元素和转化成两个前缀和的差,下标区间 [left,right] 的元素和等于前缀 [0,right] 的元素和减去另一个前缀 [0,left−1] 的元素和,即s[right+1]−s[left]。

设 i<j,如果 nums[i] 到 nums[j−1] 的元素和等于 k,用前缀和表示,就是s[j]−s[i]=k,问题转化为:s 中有多少对下标 (i,j) 满足 0≤i<j≤n 且 s[j]−s[i]=k。

把 s[j]−s[i]=k 移项,得s[i]=s[j]−k。枚举当前的前缀和 s[j],看看曾经有多少个前缀和等于 s[j]−k(配对)。每当我们在左边找到一个值等于 s[j]−k 的前缀和,就找到了一个和为 k 的子数组(因为 s[j]−(s[j]−k)=k)。

前缀和总结

1. 核心思想: “以空间换时间,化 O(N²) 为 O(N)”

  • 定义:前缀和 S[i] 表示原数组 nums 中从第 0 个元素加到第 i-1 个元素的总和(即 nums[0] + nums[1] + ... + nums[i-1])。

  • 威力:如果不使用前缀和,求任意子数组 nums[i...j] 的和需要遍历一次,耗时 O(N)O(N)。但是有了前缀和之后,子数组的和可以用数学公式瞬间计算出来

    子数组 nums[i...j] 的和 = 前缀和 S[j+1] - 前缀和 S[i]
    (记忆技巧:大范围的前缀和,减去小范围的前缀和)

2. 两种常见套路(由浅入深)

A. 基础款:直接构建前缀和数组(求某个区间和)

如果你只是需要多次快速查询某个连续子区间的和,直接构建一个前缀和数组即可。

  • 构建方法:新建数组 pre,长度比原数组大 1(nums.size() + 1)。

  • 适用场景:数组本身不改变,但你需要频繁计算不同区间的和。

B. 升级款:前缀和 + 哈希表

当你遇到 “求子数组的和等于 K 的个数” 或者 “求子数组的和能被 K 整除” 这类问题时,通常数组里还有负数。这时候滑动窗口失效了,就要用这个组合拳。

  • 核心逻辑

    1. 我们要找:pre[j] - pre[i] == k

    2. 变形为:pre[i] == pre[j] - k

    3. 哈希表的作用:我们不存放完整的前缀和数组,而是边遍历边把历史的前缀和扔进哈希表里。只要查一下 当前前缀和 - K 之前出现过几次,就能瞬间知道当前位置作为结尾,有多少个符合条件的子数组。

  • 要点:遍历时先查表再存表(防止 k=0 时把自己算进去)。

3. 注意事项与易错点

  1. 初始化 pre[0] = 0 极其重要

    • 为什么前缀和数组长度是 n+1 而不是 n

    • 因为 pre[0] = 0 代表“什么都没选”时的空数组和为 0

    • 如果子数组是从原数组的第一个元素开始算起的,公式就是 pre[j] - pre[0]。如果不把 0 加进去,你就会漏掉从下标 0 开始的子数组。

  2. 处理负数时的局限性

    • 前缀和可以处理含有负数的数组,这是它比滑动窗口强的地方。

    • 但是,前缀和查找依赖哈希表。如果哈希表需要存下标(比如“求等于 K 的最短子数组长度”),配合哈希表只能找到第一个符合条件的前缀和。如果要找所有符合的,需要配合 map<value, vector<index>> 或者直接构建完整数组来遍历。

  3. 哈希表操作的“坑”

    • 在 C++ 中,如果用 cnt[target] 直接取值,如果 target 不存在,C++ 会自动插入一个键值对。这会导致额外开销。所以必须先检查 contains() 或 find() 再取值。

239.滑动窗口最大值(单调队列)

  1. 单调递减:队列里存放的是数组元素的下标(关键点,不是具体的数值)。从队首 q.front() 到队尾 q.back()对应的元素数值 nums 是严格单调递减的

  2. 保存下标:因为数值可能会重复,且我们需要判断窗口是否划过去了,所以队列里存的是下标 i,通过 nums[q.front()] 来获取最大值。

  3. 结果:因为单调递减,队首元素 q.front() 永远代表当前滑动窗口内最大的那个元素的下标

  4. 右边入队 (维护单调性):如果队列尾部的元素 nums[q.back()] 小于等于 当前元素 nums[i],那么这个尾部的元素就永远没有出头之日了。因为只要 nums[i] 还在窗口里,它比尾部元素大,且下标更新(更靠后,活得更久)。所以,把尾部这些“没用”的较小元素全部踢出去,然后把当前元素的下标 i 塞进去。经过这一步,队列保证越靠近队首,元素越大。

  5. 左边出 (判断队首是否还在窗口内):检查队首元素的下标 q.front()。如果 q.front() < left,说明这个曾经的最大值,已经滑出了当前的窗口,它不能再代表当前窗口的最大值了。所以要把队首踢出去 (pop_front)。

  6. 记录答案: i 大于等于 k - 1 时(即 left >= 0),说明此时正好形成了一个完整的长度为 k 的窗口。这个时候,经过前面的“右边入”和“左边出”,当前窗口的最大值稳稳地坐在了队首。直接把它拿出来 nums[q.front()] 赋值给结果数组 ans[left] 即可。

  7. 滑动窗口的三个黄金公式

  • 当前窗口左端点left = i - k + 1 (i 是右端点)

  • 当前窗口长度len = i - left + 1

  • 总滑动次数/窗口个数n - k + 1 (n 是数组总长,k 是窗口固定大小)

     8. left >= 0 与窗口关系:

  • 当 left < 0 时,说明窗口右边界 i 还没跑够,窗口长度不满 k

  • 当 left == 0 时,说明窗口刚好达到了长度 k(包含了从 0 到 i 的所有元素)。

  • 当 left > 0 时,说明窗口已经完全形成,并且已经向右滑动了(其左边界已经离开了原点 0)。

76.最小覆盖子串

枚举 s 子串的右端点 right(子串最后一个字母的下标),如果子串涵盖 t,就不断右移左端点 left 直到不涵盖为止。在移动过程中更新最短子串的左右端点。

  • ans_left 和 ans_right 用于记录当前找到的最优解的起止下标。初始化为一个不可能的范围(左-1,右等于长度)。

  • right 扩张:不断把字符加进窗口。

  • while (is_covered...)只要满足覆盖条件,程序就进入内部的优化循环。它不会停止,而是拼命尝试把左指针 left 向右拉(收缩窗口)。

  • 记录答案:在每次收缩前,记录当前窗口的长度,如果比历史最短更短,就更新 ans_left 和 ans_right

  • 收缩操作cnt_s[s[left]]--; left++;。把左边字符踢出去,然后 left++。此时因为字符少了,下一次循环开头 is_covered 可能会返回 false,从而退出 while,重新开始外层的 right 扩张。

优化:

diff 数组:表示差额(窗口内的字符数量 - 目标 t 中的需求数量)。

kinds 和 ge_cnt:表示“种类达标数量”

diff(差额):初始化为负数(因为 t 里有需求,窗口里此时是空的,所以缺多少就是多少负数)。随着 right 加字符变成正数。kinds(需达标的种类数)t 字符串里有多少种不同的字符,kinds 就是多少。这是我们要达到的满分目标ge_cnt(已达标种类数):当前窗口里,有多少种字符的数量已经 >= 需求数量了当 ge_cnt == kinds 时,说明所有字符种类都达到了需求,窗口完全覆盖了 t。

初始化diff数组:

  • 细节diff 在初始化时是负数。比如 t = "AAB",初始 diff['A'] = -2diff['B'] = -1。负数代表“我还缺多少”。

  • 为什么 if (diff[c] == 0) 要加这个判断? 是为了去重。如果 t 是 "AA",第一次 diff['A']-- 变成 -1,第二次 diff['A']-- 变成 -2。如果不加判断,kinds 就会错误地变成 2(实际只有 1 种)。

右指针 right 扩张(加入字符):

为什么 if (diff[c] == 0) ge_cnt++

  • 因为初始是负数。每加一次字符,差值就往 0 靠近一点。

  • 当 diff[c] 变成 0 的那一刻,意味着窗口里这种字符的数量,终于 >= 需求的数量了(因为负数代表缺,0 代表刚好够了,正数代表多了)。

  • 所以,只要这一刻 diff[c] == 0,就说明“又搞定了一种字符”,达标种类数 ge_cnt 加一。

while 循环:处理达标后的收缩(寻找最短):

  • 为什么 if (diff[x] == 0) ge_cnt--

    • x 是即将被踢出窗口的字符。踢出前,如果它的差额正好是 0,说明它是刚刚好满足需求的(属于“达标”状态)。

    • 一旦把它踢出去,差额就会变成 -1,意味它又变成了“缺货”状态

    • 所以,踢出去的前一刻,如果状态是 0,说明这种字符又要不达标了。因此,ge_cnt--(已达标种类数减 1)。

    • 一旦 ge_cnt 小于 kindswhile 循环立刻终止,不再继续收缩。

Day05普通数组

53.最大子数组和

  • 如果现在的和 count 小于等于 0,这意味着前面这一段子数组(从某个地方到目前这个元素)不仅没带来收益,反而成了累赘。

  • 因为我们要找最大和,如果前面是负数,那么任何后续的元素加上这个负数,都会变小。

  • 所以,我们明智地选择“抛弃”前面的这一段,把 count 重置为 0,从下一个元素重新开始计算最大和。

56.合并区间

先按左边界排序,之后之需要看右边界是否重叠

  • 判断重叠条件 result.back()[1] >= intervals[i][0]

    • 因为在排序时,保证了 intervals[i][0] 一定大于等于 result.back()[0]

    • 所以,只要当前区间的起始点 intervals[i][0] ≤ 结果集最后一个区间的结束点 result.back()[1],说明它们发生了重叠(或者刚刚好首尾相连,也可以合并)。

  • 合并动作(贪心精髓)

    • result.back()[1] = max(result.back()[1], intervals[i][1]);

    • 重叠了怎么处理?因为当前区间的左边界肯定在基准区间左边界的右边(或重合),所以左边界不需要动

    • 我们只需要把右边界延长,取两者之间较大的那个作为新的右边界。

  • 不重叠时的动作

    • else result.push_back(intervals[i]);

    • 如果条件不满足,说明当前区间和前面的区间断开了,不能合并。直接把它作为一个新的独立区间追加进 result 数组即可。

  • result.back():back() 方法内部是通过指针直接跳转到数组末尾,它不会进行拷贝,只是返回最后一个元素的引用。时间消耗是常数级的。不能对空的 vector 调用 back()。

189.轮转数组

  • 把一个子数组反转两次,子数组不变。
  • k %= nums.size():
    • 原因:如果数组长度是 n = 5,轮转 k = 12 次,和轮转 12 % 5 = 2 次的结果是一模一样的。

    • 作用:防止 k 超出数组边界,导致后面迭代器 begin() + k 越界,引发程序崩溃。取模后,k 必定小于数组长度。数组长度为 n,轮转 n 次等于没有轮转。所以,任何大于 n 的轮转次数 k,都可以分解为:k = (几个完整的循环圈数 * n) + 多余的步数。在多出来的步数里,多余的步数就是 k 除以 n 的余数(即数学里的模运算 k % n)。

  • anges::reverse 与 std::reverse 的区别
    •  ranges::reverse(nums)。这是 C++20 引入的 Ranges 库 新写法。

    • 以前旧的写法是:std::reverse(nums.begin(), nums.end());

    • 新写法的优势:它更简洁,不用写两遍 begin() 和 end(),直接传容器进去即可。如果是在较老的 C++ 标准环境或刷题网站上,如果你遇到编译错误,把第一行改成 std::reverse(nums.begin(), nums.end()); 就可以完美运行。

Day06普通数组

238.除了自身以外数组的乘积

answer[i] 等于 nums 中除了 nums[i] 之外其余各元素的乘积。换句话说,如果知道了 i 左边所有数的乘积,以及 i 右边所有数的乘积,就可以算出 answer[i]。

定义 pre[i] 表示从 nums[0] 到 nums[i−1] 的乘积。定义 suf[i] 表示从 nums[i+1] 到 nums[n−1] 的乘积。

我们可以先计算出从 nums[0] 到 nums[i−2] 的乘积 pre[i−1],再乘上 nums[i−1],就得到了 pre[i],即pre[i]=pre[i−1]⋅nums[i−1],同理有suf[i]=suf[i+1]⋅nums[i+1]。

初始值:pre[0]=suf[n−1]=1。

算出 pre 数组和 suf 数组后,有answer[i]=pre[i]⋅suf[i]。

41.缺失的第一个正数

  • 例:想象有一间教室,座位从左到右编号为 1 到 n。有 n 个学生坐在教室的座位上,把 nums[i] 当作坐在第 i 个座位上的学生的学号。我们要做的事情,就是让学号在 1 到 n 中的学生,都坐到编号与自己学号相同的座位上(学号与座位编号匹配)。学号不在 [1,n] 中的学生可以忽略。学生们交换座位后,从左往右看,第一个学号与座位编号不匹配的学生,其座位编号就是答案。特别地,如果所有学生都坐在正确的座位上,那么答案是 n+1。
  • 为什么用 while 而不是 if

    • 因为可能发生连锁反应。你把 5 放到下标 4 后,原本在 4 位置的数字被交换到了当前 i。这个新换来的数字可能也是合法的正数,并且也没有归位。所以必须用 while 一直交换,直到当前 i 位置要么是非法数字,要么已经是它该呆的数字了。

  • 交换条件解析

    1. 1 <= nums[i]:数字必须大于等于 1(负数或 0 不需要处理)。

    2. nums[i] <= n:数字不能超过数组长度。如果 nums 长度是 5,出现了 6,你根本找不到下标为 5 的坑位放它,所以 6 也是不需要处理的非法数据。

    3. nums[nums[i] - 1] != nums[i]死循环保护。如果 nums[i] 本该去的位置 j 已经坐着同样的数字了,说明重复了,不需要再交换,避免死循环。

Day07矩阵

73.矩阵置零

用第一列的 matrix[i][0] :如果 i 行有 0,那么置 matrix[i][0]=0。用第一行的 matrix[0][j]:如果 j 列有 0,那么置 matrix[0][j]=0。然后,再次遍历 matrix。对于 matrix[i][j],如果 matrix[i][0]=0 或者 matrix[0][j]=0,说明 i 行有 0 或者 j 列有 0,把 matrix[i][j] 变成 0。

问题:如果第一行全为 1,第一列包含 0(或者反过来),我们会置 matrix[0][0]=0。修改后,根据定义,我们会误认为第一行和第一列都包含 0,最终会把第一行和第一列都变成 0。

解决:如果第一列全为 1,那么在遍历过程中,不会修改 matrix[0][0],所以遍历结束后 matrix[0][0] 仍然是 1。如果第一列包含 0,那么在遍历过程中,我们会把 matrix[0][0] 置为 0。可以直接用 matrix[0][0] 表示「第一列是否在一开始包含 0」。

54.螺旋矩阵

为什么左、下(第三步、第四步)需要加 if

因为第一步走完,top 会变大;第二步走完,right 会变小。 对于单行或单列的矩阵,这两步可能瞬间就把矩形“挖空”了

如果在矩形已经被挖空的情况下,还试图去走第三步和第四步(恢复逆方向读取),就会导致 “空刮”(重复读取) 甚至 “越界崩溃”

Day07矩阵

48.旋转图像

对角线反转再每一行反转。

转置:对角线反转

水平镜像:每一行反转

垂直镜像:反转每一列

顺时针90°:转置 + 水平镜像
逆时针90°:转置 + 垂直镜像
180°:水平镜像 + 垂直镜像

为什么内层循环从i + 1开始:为了只遍历对角线右上方的元素,把它和左下方对应位置的元素互换,避免“换两次又换回原样”的死循环。

240.搜索二维矩阵Ⅱ

矩阵的四个角是最特殊的,左上角的值最小,右下角的值最大,另两个角的值居中。对于有序数据,从右上角或者左下角这种中间值开始,可以帮助我们排除不含答案的行列。

从右上角开始,矩阵元素比目标值小,则这一行都比目标值小,跳到下一行。

右上角元素比目标值大,则这一列都比目标值大,往回收缩一列。

Day08技巧

136.只出现一次的数字

异或总结

异或的运算规则非常简单:相同为 0,不同为 1。

归零律(A ^ A = 0)任何数字和自己异或,结果都是 0

恒等律(A ^ 0 = A)任何数字和 0 异或,结果还是它自己

交换律和结合律:异或运算满足交换律和结合律(即 A ^ B ^ C = C ^ B ^ A),这意味着异或的顺序不影响最终结果

不借用第三个变量交换两数:

快速判断两个数是否相等:if ((a ^ b) == 0) 比 if (a == b) 有时在纯二进制层面执行得更底层、更快。

169.多数元素

题意:计算 nums 的绝对众数。绝对众数的出现次数,比其余所有元素的出现次数加起来还多。题目保证 nums 一定存在绝对众数。

Boyer-Moore 投票算法 (摩尔投票法):这个算法前提条件非常严格:题目必须保证“存在一个出现次数超过一半的元素”。

75.颜色分类

left(左指针):指向下一个应该放 0 的位置(排在最前面)。

right(右指针):指向下一个应该放 2 的位置(排在最后面)。

i(当前指针):负责从左向右扫描每个元素。

Day09技巧

31.下一个排列

所谓的“下一个排列”,就是找比当前数字组合稍微大一点点的那个数。

1.向右看,找洼地:从右往左,找到第一个 nums[i] < nums[i+1],这是要“微调”的起点。

2.向右找,换大牌:如果找到了,再从右往左,找第一个 nums[j] > nums[i],也就是nums[ i ]右边最小的大于nums[ i ] 的数,它们两个交换。

3.转头看,变最小必须把 i+1 到结尾的部分全部反转(因为这部分之前是降序,转成升序才是最小,从而保证这是“下一个”排列)。如果第一步没找到,就把整个数组反转(回到全升序)。

(扩展)求上一个排列

287.寻找重复数

核心在于:把数组的“值”当成“下一个节点的指针”

普通链表node -> next 指向下一个节点的内存地址。

本题的数组nums[i] 的值,指向下一个节点的下标

因为数组里一定有重复数字,所以当我们把下标 2 和下标 4 连起来时,它们都指向了同一个节点(下标 2)。这就必然形成了一个环!而环的入口,就是那个重复的数字

当快慢指针相遇后,让一个指针回到链表头(下标 0),另一个指针留在相遇点。
然后两个指针每次都走一步,它们再次相遇的地方,就是环的入口

Day10栈

20.有效的括号

不匹配情况:第一种情况,字符串里左方向的括号多余了 ,所以不匹配。 第二种情况,括号没有多余,但是 括号的类型没有匹配上。第三种情况,字符串里右方向的括号多余了,所以不匹配。

第一种情况:已经遍历完了字符串,但是栈不为空,说明有相应的左括号没有右括号来匹配,所以return false。

第二种情况:遍历字符串匹配的过程中,发现栈里没有要匹配的字符。所以return false。

第三种情况:遍历字符串匹配的过程中,栈已经为空了,没有匹配的字符了,说明右括号没有找到对应的左括号return false。

那么什么时候说明左括号和右括号全都匹配了呢,就是字符串遍历完之后,栈是空的,就说明全都匹配了。

在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了。

155.最小栈

给你一个数组 nums,如何计算每个前缀的最小值:定义 preMin[i] 表示 nums[0] 到 nums[i] 的最小值,则从左到右计算preMin[0]=nums[0],preMin[1]=min(nums[0],nums[1]),preMin[2]=min(nums[0],nums[1],nums[2])=min(preMin[1],nums[2]),一般有preMin[i]=min(preMin[i−1],nums[i])。

把 nums 视作栈,本题相当于在 nums 的末尾动态地添加/删除元素。栈中除了保存添加的元素,还保存前缀最小值。(栈中保存的是 pair)。

添加元素:设当前栈的大小是 n。添加元素 val 后,额外维护 preMin[n]=min(preMin[n−1],val),其中 preMin[n−1] 是添加 val 之前,栈顶保存的前缀最小值。删除元素:弹出栈顶即可。

getMin() 代表“在没放进当前元素之前,之前所有元素的最小值”。min(getMin(), val) 代表“放入当前元素后,当前栈的最小值”。把这两个值打包放进栈顶。这样栈顶的 second 永远保存着当前栈内的最小值。

394.字符串解码

把字符串看成一段一段的“指令”,遇到普通字母直接拼接到结果,遇到数字则找出对应的括号范围,递归解码括号内部,然后根据数字重复拼接。

如果开头是字母,直接取出来,去递归解剩下的。遇到数字 :找到包裹它的 [ 和匹配的 ]。提取括号中间的 ,递归解码。然后重复 ,拼到结果里。继续往后处理剩下的部分(如果有的话)。

栈解法

Day11栈

739.每日温度(单调栈)

如果求一个元素右边第一个更大元素,单调栈就是递增的,如果求一个元素右边第一个更小元素,单调栈就是递减的。

情况一:当前遍历的元素T[i]小于栈顶元素T[st.top()]的情况

情况二:当前遍历的元素T[i]等于栈顶元素T[st.top()]的情况

情况三:当前遍历的元素T[i]大于栈顶元素T[st.top()]的情况

84.柱状图中最大的矩形

本题是要找每个柱子左右两边第一个小于该柱子的柱子,所以从栈头(元素从栈头弹出)到栈底的顺序应该是从大到小的顺序。

栈顶和栈顶的下一个元素以及要入栈的三个元素组成了我们要求最大面积的高度和宽度。

在数组开头和结尾各加一个0:1.末尾加0,如果数组本身就是升序的,例如[2,4,6,8],那么入栈之后 都是单调递减,一直都没有走 情况三 计算结果的哪一步,所以最后输出的就是0了那么结尾加一个0,就会让栈里的所有元素,走到情况三的逻辑。2.开头加0,如果数组本身是降序的,例如 [8,6,4,2],在 8 入栈后,6 开始与8 进行比较,此时我们得到 mid(8),right(6),但是得不到 left,因为 将 8 弹出之后,栈里没有元素了,那么为了避免空栈取值,直接跳过了计算结果的逻辑。之后又将6 加入栈(此时8已经弹出了),然后 就是 4 与 栈口元素 6 进行比较,周而复始,那么计算的最后结果result就是0。

情况一:当前遍历的元素heights[i]大于栈顶元素heights[st.top()]的情况

情况二:当前遍历的元素heights[i]等于栈顶元素heights[st.top()]的情况

情况三:当前遍历的元素heights[i]小于栈顶元素heights[st.top()]的情况

Day12堆

215.数组中的第K个最大元素

第 k 大元素在升序数组中的下标是 n−k。1.在 nums 中随机选择一个基准元素 pivot。2.划分 nums。通过交换,把 ≤pivot 的元素放在 pivot 的左侧,把 ≥pivot 的元素放在 pivot 的右侧。如此划分可以让我们粗略地排序 nums。划分后,pivot 此刻的位置就等于 pivot 在升序数组中的位置。3.设 pivot 在 nums 中的下标为 i。如果 i=n−k,那么答案就是 pivot。如果 i>n−k,说明答案在 pivot 左侧,我们在其中寻找,回到第一步。如果 i<n−k,说明答案在 pivot 右侧,我们在其中寻找,回到第一步。

不随机选择基准元素 pivot:比如子数组是有序的,且我们每次都选子数组的第一个(或者最后一个)元素作为 pivot,那么按照算法,j 不变或者移动到最左边,划分是最不均匀的,算法会退化至 O(n^2)。

347.前K个高频元素

1.要统计元素出现频率:使用map来进行统计。

2.对频率排序:优先级队列是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。先级队列内部元素是自动依照元素的权值排列。缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序。堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。用priority_queue(优先级队列)实现,从小到大排就是小顶堆,从大到小排就是大顶堆。

3.找出前K个高频元素:用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。

priority_queue总结

295.数据流的中位数

left (大顶堆):存放较小的一半数据。堆顶是这一半的最大值。

right (小顶堆):存放较大的一半数据。堆顶是这一半的最小值。

left 里的所有元素 都 小于等于 right 里的所有元素。如果总共有偶数个元素,left.size() == right.size()。如果总共有奇数个元素,约定 left 比 right 多 1 个元素(即 left.size() = right.size() + 1)。

如果当前 left 的大小和 right 的大小相等:无论 num 是大是小,都可以先把 num 加到 right 中,然后把 right 的最小值从 right 中去掉,并添加到 left 中。

如果当前 left 比 right 多 1 个数:无论 num 是大是小,都可以先把 num 加到 left 中,然后把 left 的最大值从 left 中去掉,并添加到 right 中。

如果当前有奇数个元素,中位数是 left 的堆顶。如果当前有偶数个元素,中位数是 left 的堆顶和 right 的堆顶的平均值。

Day13二分

35.搜索插入位置

74.搜索二维矩阵

与240.搜索二维矩阵Ⅱ一样可以从矩阵右上角开始搜索,本题采用二分法。

由于矩阵的每一行是递增的,且每行的第一个数大于前一行的最后一个数,如果把矩阵每一行拼在一起,我们可以得到一个递增数组。

除以列数得行号(跳过几整行),对列数取余得列号(走进第几个格子)。

34.在排序数组中查找元素的第一个和最后一个位置

lower_bound 的核心思路是维护循环不变量:nums[left-1] < target 且 nums[right] >= target。当 mid 的值落在 >= 区间时,说明 mid 可能成为新的右边界,所以我们将它缩回来;当落在 < 区间时,说明 mid 根本没资格,所以直接把它排除掉。

只要找到一个满足条件的(>=),就往左缩(找更左的可能);只要发现一个不满足的(<),就立刻往右跳(越过它继续找)。

要想找到 ≤target 的最后一个数,无需单独再写一个二分。我们可以先找到这个数的右边相邻数字,也就是 >target 的第一个数。在所有数都是整数的前提下,>target 等价于 ≥target+1,这样就可以复用我们已经写好的二分函数了,即 lowerBound(nums, target + 1),算出这个数的下标后,将其减一,就得到 ≤target 的最后一个数的下标。

33.搜索旋转排序数组

根据旋转排序数组的定义,下标在 [0,i−1] 中的元素都比下标在 [i,n−1] 中的元素大(注意题目保证 nums 没有重复元素)。特别地,如果 i=0,那么 nums 是严格递增数组,没有发生旋转。

如果 target>nums[n−1],那么 target 只可能在子数组 [0,i−1] 中。由于子数组 [0,i−1] 是递增的,我们可以在 [0,i−1] 中二分查找 target。

如果 target≤nums[n−1],那么 target 只可能在子数组 [i,n−1] 中。由于子数组 [i,n−1] 是递增的,我们可以在 [i,n−1] 中二分查找 target。

先二分找旋转点,再在对应区间二分找目标值

Day14二分

153.寻找旋转排序数组中的最小值

设 x=nums[mid] 是现在二分取到的数。把 x 与最后一个数 nums[n−1] 比大小

1.如果 x>nums[n−1],那么可以推出以下结论:nums 一定被分成左右两个递增段;第一段的所有元素均大于第二段的所有元素;x 在第一段。最小值在第二段。所以 x 一定在最小值的左边

2.如果 x≤nums[n−1]:那么 x 一定在第二段。(或者 nums 就是递增数组,此时只有一段)。x 要么是最小值,要么在最小值右边。

4.寻找两个正序数组的中位数

Day15链表

160.相交链表

求出两个链表的长度,并求出两个链表长度的差值,让长链表的指针先走长度差步数,此时两个指针在同一起点。此时我们就可以比较curA和curB是否相同,如果不相同,同时向后移动curA和curB,如果遇到curA == curB,则找到交点。否则循环退出返回空指针。

206.反转链表

首先定义一个cur指针,指向头结点,再定义一个pre指针,初始化为null。然后就要开始反转了,首先要把 cur->next 节点用tmp指针保存一下,也就是保存一下这个节点。为什么要保存一下这个节点呢,因为接下来要改变 cur->next 的指向了,将cur->next 指向pre ,此时已经反转了第一个节点了。接下来,就是循环走代码逻辑了,继续移动pre和cur指针。最后,cur 指针已经指向了null,循环结束,链表也反转完毕了。 此时我们return pre指针就可以了,pre指针就指向了新的头结点。

双指针

递归

234.回文链表

首先,找链表的中间节点(如果链表有奇数个节点,找正中间的节点。如果链表有偶数个节点,找正中间右边的节点。):慢指针每次走一步,快指针每次走两步,快指针到结尾时慢指针正好走到链表中间。

然后,把中间节点到链表末尾反转头节点记作 head2​。这样我们就能从 head2​ 开始,依次访问原链表的最后一个节点、倒数第二个节点、倒数第三个节点……

最后,同时遍历 head 和 head2这两个链表,每次循环判断 head.val 是否等于 head 
2.val,若不相等,则返回 false。循环直到 head2链表遍历结束。如果循环中没有返回 false,说明链表是回文的,返回 true。

141.环形链表

使用快慢指针法,分别定义 fast 和 slow 指针,从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。

142.环形链表Ⅱ

假设从头结点到环形入口节点 的节点数为x。 环形入口节点到 fast指针与slow指针相遇节点 节点数为y。 从相遇节点 再到环形入口节点节点数为 z。 那么相遇时: slow指针走过的节点数为: x + y, fast指针走过的节点数:x + y + n (y + z),n为fast指针在环内走了n圈才遇到slow指针, (y+z)为 一圈内节点的个数A。因为fast指针是一步走两个节点,slow指针一步走一个节点, 所以 fast指针走过的节点数 = slow指针走过的节点数 * 2:(x + y) * 2 = x + y + n (y + z),两边消掉一个(x+y): x + y = n (y + z)。因为要找环形的入口,那么要求的是x,因为x表示 头结点到 环形入口节点的的距离。所以要求x ,将x单独放在左面:x = n (y + z) - y ,再从n(y+z)中提出一个 (y+z)来,整理公式之后为如下公式:x = (n - 1) (y + z) + z 注意这里n一定是大于等于1的,因为 fast指针至少要多走一圈才能相遇slow指针。当 n为1的时候,公式就化解为 x = z。

从头结点出发一个指针,从相遇节点 也出发一个指针,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是 环形入口的节点。也就是在相遇节点处,定义一个指针index1,在头结点处定一个指针index2。让index1和index2同时移动,每次移动一个节点, 那么他们相遇的地方就是 环形入口的节点。

Day16链表

21.合并两个有序链表

递归

迭代:

创建一个虚拟头节点,作为合并后的新链表头节点的前一个节点。这样可以避免单独处理头节点,也无需特判链表为空的情况,从而简化代码。比较 list 1和 list 2的节点值,如果 list 1的节点值小,则把 list 1加到新链表的末尾,然后把 list 1替换成它的下一个节点。如果 list 2的节点值小则同理。如果两个节点值一样,那么把谁加到新链表的末尾都是一样的,不妨规定把 list 2加到新链表末尾。重复上述过程,直到其中一个链表为空。循环结束后,其中一个链表可能还有剩余的节点,将剩余部分加到新链表的末尾。最后,返回新链表的头节点,即虚拟头节点的下一个节点。

2.两数相加

19.删除链表的倒数第N个节点

左端点在链表头部,右端点在正数第 n+1 个节点。向右移动尺子,当尺子右端点到达链表末尾时,左端点就在倒数第 n+1 个节点。

如果要遍历到最后一个节点,需要写 while node;如果要遍历到倒数第二个节点,需要写 while node.next

24.两两交换链表中的节点

先锁备份:解开之前,先把“节点 1”和“节点 3”这两根还没被操作的游离绳子单独按住(tmp 和 tmp1)。

先连前头:把前驱节点(cur)连到节点 2 上。(形成 cur -> 节点2

再连后头:把节点 2 连到节点 1 上。(形成 cur -> 节点2 -> 节点1

接上尾巴:把节点 1 连到提前存好的节点 3 上。(形成 cur -> 节点2 -> 节点1 -> 节点3

25.K个一组翻转链表

Day17链表

138.随机链表的复制

新链表中任何一个新节点的 random 指针,必须指向新链表内部的对应节点(而不是原链表的节点)。

前两轮循环(拼接和连 Random):链表处于 原->副本->原->副本 的交错状态。为了只处理原节点,必须跳过副本节点,所以步长是 两步

第三轮循环(拆分):因为每次拆线都把当前节点的线连回下一个原节点,链表变回了干净的原链结构,所以只需要 一步 走。

148.排序链表

归并排序(分治):找到链表的中间结点 head2​ 的前一个节点,并断开 head2​ 与其前一个节点的连接。这样我们就把原链表均分成了两段更短的链表。分治,递归调用 sortList,分别排序 head(只有前一半)和 head2​。排序后,我们得到了两个有序链表,那么合并两个有序链表,得到排序后的链表,返回链表头节点。

时间复杂度:O(nlogn),空间复杂度:O(logn)。

归并排序(迭代):

首先,归并长度为 1 的子链表。例如 [4,2,1,3],把第一个节点和第二个节点归并,第三个节点和第四个节点归并,得到 [2,4,1,3]。
然后,归并长度为 2 的子链表。例如 [2,4,1,3],把前两个节点和后两个节点归并,得到 [1,2,3,4]。
然后,归并长度为 4 的子链表。
依此类推,直到归并的长度大于等于链表长度为止,此时链表已经是有序的了。

具体算法:

1.遍历链表,获取链表长度 length。
2.初始化步长 step=1。
3.循环直到 step≥length。
4.每轮循环,从链表头节点开始。
5.分割出两段长为 step 的链表,合并,把合并后的链表插到新链表的末尾。重复该步骤,直到链表遍历完毕。
6.把 step 扩大一倍。回到第 4 步。

时间复杂度:O(nlogn),空间复杂度:O(1)。

23.合并K个升序链表

两两合并:把 lists[0] 和 lists[1] 合并,合并后的链表保存在 lists[0] 中;把 lists[2] 和 lists[3] 合并,合并后的链表保存在 lists[2] 中;依此类推。
四四合并:把 lists[0] 和 lists[2] 合并(相当于合并前四条链表),合并后的链表保存在 lists[0] 中;把 lists[4] 和 lists[6] 合并,合并后的链表保存在 lists[4] 中;依此类推。
八八合并:把 lists[0] 和 lists[4] 合并(相当于合并前八条链表),合并后的链表保存在 lists[0] 中;把 lists[8] 和 lists[12] 合并,合并后的链表保存在 lists[8] 中;依此类推。
依此类推,直到所有链表都合并到 lists[0] 中。最后返回 lists[0]。

146.LRU缓存

Day18二叉树

94.二叉树的中序遍历

递归

迭代

1.前序

2.中序

3.后序

102.二叉树的层序遍历

104.二叉树的最大深度

递归

迭代(层序遍历)

226.翻转二叉树

101.对称二叉树

本题遍历只能是“后序遍历”,因为我们要通过递归函数的返回值来判断两个子树的内侧节点和外侧节点是否相等。正是因为要遍历两棵树而且要比较内侧和外侧节点,所以准确的来说是一个树的遍历顺序是左右中,一个树的遍历顺序是右左中。

递归

迭代

543.二叉树的直径

每条路径都有个最高点。在递归回溯时(后序遍历),计算当前节点作为最高点时的左右累加长度,尝试更新全局最大值。然后上报左右中最长的一条给父节点。

Day19二叉树

108.将有序数组转换为二叉搜索树

要构造二叉树,用递归函数的返回值来构造中节点的左右孩子。

再来看参数,首先是传入数组,然后就是左下标left和右下标right,在构造二叉树的时候尽量不要重新定义左右区间数组,而是用下标来操作原数组。定义的是左闭右闭区间,在不断分割的过程中,也会坚持左闭右闭的区间。

定义的是左闭右闭的区间,所以当区间 left > right的时候,就是空节点了。

取数组中间元素的位置,int mid = left + ((right - left) / 2);取了中间位置,就开始以中间位置的元素构造节点,TreeNode* root = new TreeNode(nums[mid]);接着划分区间,root的左孩子接住下一层左区间的构造节点,右孩子接住下一层右区间构造的节点。最后返回root节点。

98.验证二叉搜索树

中序遍历转变为数组,检查数组是否是递增的

递归

在 BST 中,当前节点值必须大于前一个节点值。代码判断:如果 pre 不为空,且 pre 的值 大于等于 root 的值 (>=),则说明破坏了递增的规律,不是 BST,直接返回 false

199.二叉树的右视图

105.从前序与中序遍历序列构造二叉树

236.二叉树的最近公共祖先

lowestCommonAncestor 函数的返回值是什么意思?

答:返回值的准确含义是「最近公共祖先的候选项」。对于最外层的递归调用者来说,返回值是最近公共祖先的意思。但是,在递归过程中,返回值可能是最近公共祖先,也可能是空节点(表示子树内没找到任何有用信息)、节点 p 或者节点 q(可能成为最近公共祖先,或者用来辅助判断上面的某个节点是否为最近公共祖先)。

为什么发现当前节点是 p 或者 q 就不再往下递归了?万一下面有 q 或者 p 呢?

答:如果下面有 q 或者 p,那么当前节点就是最近公共祖先,直接返回当前节点。如果下面没有,那既然都没有要找的节点了,也不需要递归,直接返回当前节点。(注意题目保证 p 和 q 都在二叉树中)

Day20二叉树

230.二叉搜索树中第K小的元素

114.二叉树展开为链表

采用头插法构建链表,为此,要按照先序遍历的逆序,即按照右 - 左 - 根的顺序遍历二叉树。遍历的同时执行头插法,把当前节点插在链表头节点 head 的前面,然后更新 head 为当前节点。一开始 head 是空节点。

437.路径总和Ⅲ

DFS 遍历这棵树,遍历到节点 node 时,假设 node 是路径的终点,那么有多少个起点,满足起点到终点 node 的路径总和恰好等于 targetSum。

一边遍历二叉树,一边用哈希表 cnt 统计前缀和(从根节点开始的路径和)的出现次数。设从根到终点 node 的路径和为 s,那么起点的个数就是 cnt[s−targetSum],加入答案。

124.二叉树中的最大路径和

链:从下面的某个节点(不一定是叶子)到当前节点的路径。把这条链的节点值之和,作为 dfs 的返回值。如果节点值之和是负数,则返回 0(和 0 取最大值)。

直径:等价于由两条(或者一条)链拼成的路径。我们枚举每个 node,假设直径在这里「拐弯」,也就是计算由左右两条从下面的某个节点(不一定是叶子)到 node 的链的节点值之和,去更新答案的最大值。

注意:dfs 返回的是链的节点值之和,不是直径的节点值之和。

Day21回溯

46.全排列

叶子节点,就是收割结果的地方。当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。

for循环里不用startIndex了,因为排列问题,每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。

78.子集

子集问题是找树的所有节点。其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始。求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合。

剩余集合为空的时候,就是叶子节点。剩余集合为空时就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了。

17.电话号码的字母组合

39.组合总和

本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?如果是一个集合来求组合的话,就需要startIndex。如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex。

本题元素为可重复选取:递归时startIndex传i,不用传i+1, 表示可以重复选取当前的数

剪枝:如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。那么对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。

131.分割回文串

本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。

切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。那么在代码里什么是切割线呢?在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。

51.N皇后

在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素。

Day22回溯

22.括号生成

本质是「选或不选」的思想,你可以把填左括号视作「选」,填右括号视作「不选」。递归的过程中,要保证右括号的个数不能超过左括号的个数。如果现在右括号个数等于左括号个数,那么不能填右括号。如果现在右括号个数小于左括号个数,那么可以填右括号。由于左括号个数始终 ≥ 右括号个数,且至多填 n 个左括号,所以当我们填了 n 个右括号时,也一定填了 n 个左括号,此时填完所有 2n 个括号。

什么时候需要写恢复现场,什么时候不需要写?答:下面代码中,如果初始化 path 为空列表,就需要写恢复现场。本题由于所有括号长度都是固定的 2n,我们可以创建一个长为 2n 的 path 列表,在递归时直接写入字符(而不是插入字符),这样做无需写恢复现场。

代码如何保证 path[0] 一定是左括号,path[2n−1] 一定是右括号?答:一开始 left=right=0,填右括号的那个 if 条件不成立,所以 path[0] 只能填左括号。由于只有在 right<left 时才能填右括号,当 right=left 时 right 不能再变大,所以始终有 right≤left。当我们填了 2n−1 个括号时,唯一解是 left=n 且 right=n−1,此时填左括号的那个 if 条件不成立,所以 path[2n−1] 只能填右括号。

79.单词搜索

枚举 i=0,1,2,…,m−1 和 j=0,1,2,…,n−1,以 (i,j) 为起点开始搜索。同时,我们还需要知道当前匹配到了 word 的第几个字母,所以还需要一个参数 k。定义 dfs(i,j,k) 表示当前在 board[i][j] 这个格子,要匹配 word[k],返回在这个状态下最终能否匹配成功(搜索成功)。

分类讨论:如果 board[i][j]=word[k],匹配失败,返回 false。
否则,如果 k=len(word)−1,匹配成功,返回 true。
否则,枚举 (i,j) 周围的四个相邻格子 (x,y),如果 (x,y) 没有出界,则递归 dfs(x,y,k+1),如果其返回 true,则 dfs(i,j,k) 也返回 true。
如果递归周围的四个相邻格子都没有返回 true,则最后返回 false,表示没有搜到。

细节:递归过程中,为了避免重复访问同一个格子,可以用 vis 数组标记。更简单的做法是,直接修改 board[i][j],将其置为空(或者 0),返回 false 前再恢复成原来的值(恢复现场)。注意返回 true 的时候就不用恢复现场了,因为已经成功搜到 word 了。

优化一:一般地,如果 word 的某个字母的出现次数,比 board 中的这个字母的出现次数还要多,可以直接返回 false。

优化二:设 word 的第一个字母在 board 中出现了 x 次,word 的最后一个字母在 board 中出现了 y 次。如果 y<x,我们可以把 word 反转,相当于从 word 的最后一个字母开始搜索,这样更容易在一开始就满足 board[i][j] != word[k],不会往下递归,递归的总次数更少。

Day23贪心

121.买卖股票的最佳时机

因为股票就买卖一次,那么贪心的想法很自然就是取最左最小值,取最右最大值,那么得到的差值就是最大利润。

55.跳跃游戏

不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围。那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点。

45.跳跃游戏Ⅱ

要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最少步数!这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖。如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。

特殊情况需要考虑,当移动下标达到了当前覆盖的最远距离下标时:如果当前覆盖最远距离下标不是是集合终点,步数就加一,还需要继续走。如果当前覆盖最远距离下标就是是集合终点,步数不用加一,因为不能再往后走了。

763.划分字母区间

在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。

可以分为如下两步:1.统计每一个字符最后出现的位置2.从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点。

Day24动态规划

70.爬楼梯

空间优化

118.杨辉三角

resize(i + 1, 1)做了两件事:1.确定长度:第 i 行有 i + 1 个数,所以将当前行的大小调整为 i + 1。2.填充默认值:将新扩容出来的所有格子,默认填入数字 1

for(int j = 1; j < i; j++):要处理当前行除了开头(索引0)和结尾(索引i)之外的数字。所以,j 从 1 开始,到 i - 1 结束(即 j < i),刚好跳过了两端的 1

把杨辉三角的每一排左对齐,每一排的第一个数和最后一个数都是 1,其余数字,等于左上方的数,加上正上方的数。

01背包

二维dp数组

dp数组含义:dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

初始化:

遍历顺序:

一维dp数组

dp数组含义:dp[j]表示容量为j的背包,所背的物品价值可以最大为dp[j]。

递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

遍历顺序:倒序遍历是为了保证物品i只被放入一次

416.分割等和子集

如果背包所载重量为target, dp[target]就是装满 背包之后的总价值,因为 本题中每一个元素的数值既是重量,也是价值,所以,当 dp[target] == target 的时候,背包就装满了。

01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])。

完全背包

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。

dp数组:dp[i][j] 表示从下标为[0-i]的物品,每个物品可以取无限次,放进容量为j的背包,价值总和最大是多少。

递推公式:

初始化:

遍历顺序:先遍历物品和背包都可以。

322.零钱兑换

dp[j]:凑足总额为j所需钱币的最少个数为dp[j]。

凑足总额为j - coins[i]的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i]。所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j])。

本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数。所以本题并不强调集合是组合还是排列。如果求组合数就是外层for循环遍历物品,内层for遍历背包如果求排列数就是外层for遍历背包,内层for循环遍历物品

279.完全平方数

完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品。

dp[j]:和为j的完全平方数的最少数量为dp[j]。

递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j])。

139.单词拆分

dp[i] : 字符串长度为i的话,dp[i]为true,表示可以拆分为一个或多个在字典中出现的单词。

如果确定dp[j] 是true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true。(j < i )。所以递推公式是 if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么 dp[i] = true。

本题其实我们求的是排列数,所以说,本题一定是 先遍历 背包,再遍历物品。

198.打家劫舍

dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]。

递推公式:

初始化:

300.最长递增子序列

dp[i]表示i之前包括i的以nums[i]结尾的最长递增子序列的长度。

Day25动态规划

152.乘积最大的子数组

因为负数的存在,我们要推导当前位置 i 的最大乘积,不能只看前一个位置的最大乘积,还必须看前一个位置的最小乘积(最负的数)。

所以,代码用了两个动态规划数组:

  • f_max[i]:记录以 nums[i] 为结尾的连续子数组的最大乘积。

  • f_min[i]:记录以 nums[i] 为结尾的连续子数组的最小乘积(可能是绝对值很大的负数)。

对于当前位置 nums[i](记为 x),以它结尾的连续子数组只有 3 种可能性

  1. 延续上一个最大乘积f_max[i - 1] * x

    • 如果之前的最大乘积是正的,并且 x 也是正的,那么乘积会变得更大。

  2. 延续上一个最小乘积(负负得正)f_min[i - 1] * x

    • 如果之前的最小乘积是负数(绝对值很大),而 x 也是负数,那么 负数 * 负数 = 正数,可能会得到一个非常大且符合要求的正数结果。

  3. 重新开始(单独成一段)x

    • 如果前面的数组乘积都不如 x 自己大,或者前面是0导致乘积归零了,那么我们可以抛弃前面的元素,直接以当前元素 x 作为新子数组的开头

正是因为这三者都有可能,所以我们必须把这三个结果都算出来,然后取最大值赋给 f_max[i],取最小值赋给 f_min[i]

32.最长有效括号

从左到右遍历字符串 s,对于右括号,找左侧最近的未配对的左括号,然后把这一对括号标记为「已配对」。

为方便找到左侧最近的未配对的左括号,我们可以用一个栈保存遍历过的左括号的下标:

遇到左括号,把其下标入栈。
遇到右括号,且栈非空,那么弹出栈顶。这样遍历到下一个右括号时,栈顶总是最近的未配对的左括号的下标。
最后,最长连续已配对标记的长度,就是最长有效括号的长度。

Day26多维动态规划

62.不同路径

机器人从(0 , 0) 位置出发,到(m - 1, n - 1)终点。

dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。

想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]。那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来。

初始化,首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。

1143.最长公共子序列

dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]。

主要就是两大情况: text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同

如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1;如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的。即:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])。

72.编辑距离

dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。

Day27多维动态规划

64.最小路径和

记忆化递归

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 memo 数组中。
如果一个状态不是第一次遇到(memo 中保存的结果不等于 memo 的初始值),那么可以直接返回 memo 中保存的结果。
注意:memo 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 0,并且要记忆化的 dfs(i,j) 也等于 0,那就没法判断 0 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 −1。

dp

grid[i][j] 的含义重新定义:它不再仅仅代表原本格子里的数字,而是代表“从左上角 (0,0) 走到 (i,j) 的最小路径和”。

5.最长回文子串

Day28图论

200.岛屿数量

一旦我们发现 (i,j) 是 1,就从 (i,j) 开始,DFS 这个岛。

每一步可以往左右上下四个方向走,也就是

(i,j−1),(i,j+1),(i−1,j),(i+1,j)
这四个格子。

如果到达一块未发现的陆地格子,就插上旗子🚩,把 grid[i][j] 改成 2。

如果 (i,j) 出界,或者 (i,j) 是水,或者 (i,j) 已发现(已插上旗子🚩),就不再继续往下递归。

怎么避免重复访问?本题的做法是把访问过的格子都插上旗子🚩。例如从 (0,1) 往左走,发现 (0,0) 是插过旗子的格子,就不继续走了。

994.腐烂的橘子

为了判断是否有永远不会腐烂的橘子,我们可以统计初始新鲜橘子的个数 fresh。在 BFS 中,每有一个新鲜橘子被腐烂,就把 fresh 减一,这样最后如果发现 fresh>0,就意味着有橘子永远不会腐烂,返回 −1。

207.课程表

这道题的核心本质是:在一个有向图中,判断是否存在“环”(循环依赖)。
如果课程之间有循环依赖(比如 A 依赖 B,B 依赖 C,C 又依赖 A),那就永远无法修完所有课程。

对于每个节点 x,都定义三种颜色值(状态值):

0:节点 x 尚未被访问到。
1:节点 x 正在访问中,dfs(x) 尚未结束。
2:节点 x 已经完全访问完毕。注意这还说明从 x 出发无法找到环。所以当我们遇到状态值为 2 的节点 x 时,无需递归 x。

208.实现Trie(前缀树)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值