Leetcode精选50题-Day04
016 最接近的三数之和
1. 题目描述
给定一个包括 n n n 个整数的数组 n u m s nums nums 和 一个目标值 t a r g e t target target。找出 n u m s nums nums 中的三个整数,使得它们的和与 t a r g e t target target 最接近。返回这三个数的和。假定每组输入只存在唯一答案。
2. 思路&代码
首先考虑枚举第一个元素 a a a,对于剩下的两个元素 b b b 和 c c c,我们希望它们的和最接近 t a r g e t − a target−a target−a。对于 b b b 和 c c c,如果它们在原数组中枚举的范围(既包括下标的范围,也包括元素值的范围)没有任何规律可言,那么我们还是只能使用两重循环来枚举所有的可能情况。因此,我们可以考虑对整个数组进行升序排序,这样一来:
假设数组的长度为 n n n,我们先枚举 a a a,它在数组中的位置为 i i i;
为了防止重复枚举,我们在位置 [ i + 1 , n ) [i+1,n) [i+1,n) 的范围内枚举 b b b 和 c c c。
当我们知道了 b b b 和 c c c 可以枚举的下标范围,并且知道这一范围对应的数组元素是有序(升序)的,那么我们是否可以对枚举的过程进行优化呢?
答案是可以的。借助双指针,我们就可以对枚举的过程进行优化。我们用 p b p_b pb 和 p c p_c pc 分别表示指向 b b b 和 c c c 的指针,初始时, p b p_b pb 指向位置 i + 1 i+1 i+1,即左边界; p c p_c pc 指向位置 n − 1 n−1 n−1,即右边界。在每一步枚举的过程中,我们用 a + b + c a+b+c a+b+c 来更新答案,并且:
如果
a
+
b
+
c
≥
t
a
r
g
e
t
a+b+c≥target
a+b+c≥target,那么就将
p
c
p_c
pc 向左移动一个位置;
如果
a
+
b
+
c
<
t
a
r
g
e
t
a+b+c<target
a+b+c<target,那么就将
p
b
p_b
pb 向右移动一个位置。
这是为什么呢?我们对 a + b + c ≥ t a r g e t a+b+c≥target a+b+c≥target 的情况进行一个详细的分析:
如果 a + b + c ≥ t a r g e t a+b+c≥target a+b+c≥target,并且我们知道 p b p_b pb 到 p c p_c pc 这个范围内的所有数是按照升序排序的,那么如果 p c p_c pc
不变而 p b p_b pb 向右移动,那么 a + b + c a+b+c a+b+c 的值就会不断地增加,显然就不会成为最接近
t a r g e t target target的值了。因此,我们可以知道在固定了 p c p_c pc 的情况下,此时的 p b p_b pb 就可以得到一个最接近
\textit{target}target 的值,那么我们以后就不用再考虑 p c p_c pc 了,就可以将 p c p_c pc 向左移动一个位置。
同样地,在 a + b + c < t a r g e t a+b+c<target a+b+c<target 时:
如果 a + b + c < t a r g e t a+b+c<target a+b+c<target,并且我们知道 p b p_b pb 到 p c p_c pc 这个范围内的所有数是按照升序排序的,那么如果 p b p_b pb
不变而 p c p_c pc 向左移动,那么 a + b + c a+b+c a+b+c 的值就会不断地减小,显然就不会成为最接近 t a r g e t target target
的值了。因此,我们可以知道在固定了 p b p_b pb 的情况下,此时的 p c p_c pc 就可以得到一个最接近 t a r g e t target target
的值,那么我们以后就不用再考虑 p b p_b pb了,就可以将 p b p_b pb 向右移动一个位置。
实际上, p b p_b pb 和 p c p_c pc 就表示了我们当前可以选择的数的范围,而每一次枚举的过程中,我们尝试边界上的两个元素,根据它们与 t a r g e t target target 的值的关系,选择「抛弃」左边界的元素还是右边界的元素,从而减少了枚举的范围。
小优化
本题也有一些可以减少运行时间(但不会减少时间复杂度)的小优化。当我们枚举到恰好等于 t a r g e t target target 的 a + b + c a+b+c a+b+c 时,可以直接返回 t a r g e t target target 作为答案,因为不会有再比这个更接近的值了。
class Solution:
def threeSumClosest(self, nums: List[int], target: int) -> int:
nums.sort()
n = len(nums)
best = 10**7
# 根据差值的绝对值来更新答案
def update(cur):
nonlocal best
if abs(cur - target) < abs(best - target):
best = cur
# 枚举 a
for i in range(n):
# 保证和上一次枚举的元素不相等
if i > 0 and nums[i] == nums[i - 1]:
continue
# 使用双指针枚举 b 和 c
j, k = i + 1, n - 1
while j < k:
s = nums[i] + nums[j] + nums[k]
# 如果和为 target 直接返回答案
if s == target:
return target
update(s)
if s > target:
# 如果和大于 target,移动 c 对应的指针
k0 = k - 1
# 移动到下一个不相等的元素
while j < k0 and nums[k0] == nums[k]:
k0 -= 1
k = k0
else:
# 如果和小于 target,移动 b 对应的指针
j0 = j + 1
# 移动到下一个不相等的元素
while j0 < k and nums[j0] == nums[j]:
j0 += 1
j = j0
return best
作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/3sum-closest/solution/zui-jie-jin-de-san-shu-zhi-he-by-leetcode-solution/
3. 复杂度分析
时间复杂度: O ( N 2 ) O(N^2) O(N2),其中 N N N 是数组 n u m s nums nums 的长度。我们首先需要 O ( N log N ) O(N \log N) O(NlogN) 的时间对数组进行排序,随后在枚举的过程中,使用一重循环 O ( N ) O(N) O(N) 枚举 a a a,双指针 O ( N ) O(N) O(N) 枚举 b b b 和 c c c,故一共是 O ( N 2 ) O(N^2) O(N2)。
空间复杂度: O ( log N ) O(\log N) O(logN)。排序需要使用 O ( log N ) O(\log N) O(logN)的空间。然而我们修改了输入的数组 n u m s nums nums,在实际情况下不一定允许,因此也可以看成使用了一个额外的数组存储了 n u m s nums nums 的副本并进行排序,此时空间复杂度为 O ( N ) O(N) O(N)。
020 有效的括号
1. 题目描述
给定一个只包括 '(',')','{','}','[',']'
的字符串,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 注意空字符串可被认为是有效字符串。
2. 思路&代码
判断括号的有效性可以使用「栈」这一数据结构来解决。
我们对给定的字符串 s s s 进行遍历,当我们遇到一个左括号时,我们会期望在后续的遍历中,有一个相同类型的右括号将其闭合。由于后遇到的左括号要先闭合,因此我们可以将这个左括号放入栈顶。
当我们遇到一个右括号时,我们需要将一个相同类型的左括号闭合。此时,我们可以取出栈顶的左括号并判断它们是否是相同类型的括号。如果不是相同的类型,或者栈中并没有左括号,那么字符串 s s s 无效,返回 F a l s e False False。为了快速判断括号的类型,我们可以使用哈希映射(HashMap)存储每一种括号。哈希映射的键为右括号,值为相同类型的左括号。
在遍历结束后,如果栈中没有左括号,说明我们将字符串 s s s 中的所有左括号闭合,返回 T r u e True True,否则返回 F a l s e False False。
注意到有效字符串的长度一定为偶数,因此如果字符串的长度为奇数,我们可以直接返回 F a l s e False False,省去后续的遍历判断过程。
class Solution:
def isValid(self, s: str) -> bool:
if len(s) % 2 == 1:
return False
pairs = {
")": "(",
"]": "[",
"}": "{",
}
stack = list()
for ch in s:
if ch in pairs:
if not stack or stack[-1] != pairs[ch]:
return False
stack.pop()
else:
stack.append(ch)
return not stack
3. 复杂度分析
时间复杂度:O(n)O(n),其中 n n n 是字符串 s s s 的长度。
空间复杂度: O ( n + ∣ Σ ∣ ) O(n + |\Sigma|) O(n+∣Σ∣),其中 Σ \Sigma Σ 表示字符集,本题中字符串只包含 6 6 6 种括号, ∣ Σ ∣ = 6 |\Sigma| = 6 ∣Σ∣=6。栈中的字符数量为 O ( n ) O(n) O(n),而哈希映射使用的空间为 O ( ∣ Σ ∣ ) O(|\Sigma|) O(∣Σ∣),相加即可得到总空间复杂度。
021 合并两个有序链表
1. 题目描述
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
2. 思路&代码
2.1 递归解法
思路
可以如下递归地定义两个链表里的
m
e
r
g
e
merge
merge 操作(忽略边界情况,比如空链表等):
{ l i s t 1 [ 0 ] + m e r g e ( l i s t 1 [ 1 : ] , l i s t 2 ) l i s t 1 [ 0 ] < l i s t 2 [ 0 ] l i s t 2 [ 0 ] + m e r g e ( l i s t 1 , l i s t 2 [ 1 : ] ) o t h e r w i s e \left\{ \begin{array}{ll} list1[0] + merge(list1[1:], list2) & list1[0] < list2[0] \\ list2[0] + merge(list1, list2[1:]) & otherwise \end{array} \right. {list1[0]+merge(list1[1:],list2)list2[0]+merge(list1,list2[1:])list1[0]<list2[0]otherwise
也就是说,两个链表头部值较小的一个节点与剩下元素的 m e r g e merge merge 操作结果合并。
算法
我们直接将以上递归过程建模,同时需要考虑边界情况。
如果 l 1 l1 l1 或者 l 2 l2 l2 一开始就是空链表 ,那么没有任何操作需要合并,所以我们只需要返回非空链表。否则,我们要判断 l 1 l1 l1 和 l 2 l2 l2 哪一个链表的头节点的值更小,然后递归地决定下一个添加到结果里的节点。如果两个链表有一个为空,递归结束。
class Solution:
def mergeTwoLists(self, l1, l2):
if l1 is None:
return l2
elif l2 is None:
return l1
elif l1.val < l2.val:
l1.next = self.mergeTwoLists(l1.next, l2)
return l1
else:
l2.next = self.mergeTwoLists(l1, l2.next)
return l2
作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/merge-two-sorted-lists/solution/he-bing-liang-ge-you-xu-lian-biao-by-leetcode-solu/
2.2 迭代
思路
我们可以用迭代的方法来实现上述算法。当 l 1 l1 l1 和 l 2 l2 l2 都不是空链表时,判断 l 1 l1 l1 和 l 2 l2 l2 哪一个链表的头节点的值更小,将较小值的节点添加到结果里,当一个节点被添加到结果里之后,将对应链表中的节点向后移一位。
算法
首先,我们设定一个哨兵节点 p r e h e a d prehead prehead ,这可以在最后让我们比较容易地返回合并后的链表。我们维护一个 p r e v prev prev 指针,我们需要做的是调整它的 n e x t next next 指针。然后,我们重复以下过程,直到 l 1 l1 l1 或者 l 2 l2 l2 指向了 n u l nul null :如果 l 1 l1 l1 当前节点的值小于等于 l 2 l2 l2 ,我们就把 l1 当前的节点接在 p r e v prev prev 节点的后面同时将 l 1 l1 l1 指针往后移一位。否则,我们对 l 2 l2 l2 做同样的操作。不管我们将哪一个元素接在了后面,我们都需要把 p r e v prev prev 向后移一位。
在循环终止的时候, l 1 l1 l1 和 l 2 l2 l2 至多有一个是非空的。由于输入的两个链表都是有序的,所以不管哪个链表是非空的,它包含的所有元素都比前面已经合并链表中的所有元素都要大。这意味着我们只需要简单地将非空链表接在合并链表的后面,并返回合并链表即可。
class Solution:
def mergeTwoLists(self, l1, l2):
prehead = ListNode(-1)
prev = prehead
while l1 and l2:
if l1.val <= l2.val:
prev.next = l1
l1 = l1.next
else:
prev.next = l2
l2 = l2.next
prev = prev.next
# 合并后 l1 和 l2 最多只有一个还未被合并完,我们直接将链表末尾指向未合并完的链表即可
prev.next = l1 if l1 is not None else l2
return prehead.next
作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/merge-two-sorted-lists/solution/he-bing-liang-ge-you-xu-lian-biao-by-leetcode-solu/
3. 复杂度分析
-
递归
时间复杂度: O ( n + m ) O(n + m) O(n+m),其中 n n n 和 m m m 分别为两个链表的长度。因为每次调用递归都会去掉 l 1 l1 l1 或者 l 2 l2 l2 的头节点(直到至少有一个链表为空),函数 m e r g e T w o L i s t mergeTwoList mergeTwoList 至多只会递归调用每个节点一次。因此,时间复杂度取决于合并后的链表长度,即 O ( n + m ) O(n+m) O(n+m)。
空间复杂度: O ( n + m ) O(n + m) O(n+m),其中 n n n 和 m m m 分别为两个链表的长度。递归调用 m e r g e T w o L i s t s mergeTwoLists mergeTwoLists 函数时需要消耗栈空间,栈空间的大小取决于递归调用的深度。结束递归调用时 m e r g e T w o L i s t s mergeTwoLists mergeTwoLists 函数最多调用 n + m n+m n+m 次,因此空间复杂度为 O ( n + m ) O(n+m) O(n+m)。 -
迭代
时间复杂度: O ( n + m ) O(n + m) O(n+m) ,其中 n n n 和 m m m 分别为两个链表的长度。因为每次循环迭代中, l 1 l1 l1 和 l 2 l2 l2 只有一个元素会被放进合并链表中, 因此 w h i l e while while 循环的次数不会超过两个链表的长度之和。所有其他操作的时间复杂度都是常数级别的,因此总的时间复杂度为 O ( n + m ) O(n + m) O(n+m) 。
空间复杂度: O ( 1 ) O(1) O(1)。我们只需要常数的空间存放若干变量。