一、栈、队列
20. 有效的括号
我的题解:
class Solution:
def isValid(self, s: str) -> bool:
ls = {
'(':')',
'{':'}',
'[':']'
}
if s == '':
return True
else:
stack = []
check = True
index = 0
while index < len(s) and check:
symbol = s[index]
if symbol in ls.keys():
stack.append(symbol)
elif symbol in ls.values():
if not stack:
check = False
else:
if symbol != ls[stack.pop()]:
check = False
index += 1
if check and not stack:
return True
else:
return False
做这题最开始的时候卡在了stack是否为空的判断上,后来整理了一下,感觉流程应该是这样的:
我自己写的解法判断语句太多,实际上可以简化一下,以下是力扣用户“腐烂的橘子”的解法:
def isValid(s: str) -> bool:
dic = {
'(':')',
'{':'}',
'[':']'
}
stack = []
for i in s:
if stack and i in dic.values():
if i == dict[stack[-1]]:
stack.pop()
else:
return False
else:
stack.append(i)
return not stack
155. 最小栈
这题重点考察的不是如何实现栈,而是用常数时间检索到最小元素。一般来说如果只用一个栈的话,必须得遍历一遍才能检索到最小元素,时间复杂度为
O
(
n
)
O(n)
O(n),现在需要在常数时间内实现,最好的方法是设计两个栈,其中一个栈作为辅助,存储最小元素(以空间换时间)
class MinStack:
def __init__(self):
self.stack = []
self.minStack = []
def push(self, x: int) -> None:
self.stack.append(x)
if not self.minStack or x <= self.minStack[-1]:
self.minStack.append(x)
def pop(self) -> None:
if self.stack.pop() == self.minStack[-1]:
self.minStack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.minStack[-1]
225.用队列实现栈
分析:本题实际上有点奇怪,就是说在使用push
操作的时候默认放到末尾,但在使用pop
的时候会弹出队首的元素,但是只能用栈的方法去实现。
class MyStack:
def __init__(self):
self.s = []
def push(self, x: int) -> None:
self.s.append(x)
length = len(self.s)
while length > 1 :
self.s.append(self.s.pop(0))
length -= 1
def pop(self) -> int:
return self.s.pop(0)
def top(self) -> int:
return self.s[0]
def empty(self) -> bool:
return not self.s
232.用栈实现队列
栈的特点:先进后出;队列的特点:先进先出。所以我们在对栈使用pop
操作时,只会弹出栈顶元素。
思路是利用两个栈,将第一个栈push
得到的元素反向存储在第二个栈中,然后弹出第二个栈的栈顶元素,就可以相当于实现队列的弹出栈顶元素功能。
class MyQueue:
def __init__(self):
self.s1 = []
self.s2 = []
def push(self, x):
self.s1.append(x)
def pop(self):
if not self.s2:
while self.s1:
self.s2.append(self.s1.pop())
return self.s2.pop()
def peek(self):
if not self.s2:
while self.s1:
self.s2.append(self.s1.pop())
return self.s2[-1]
def empty(self):
return (not self.s1) & (not self.s2)
对这种解法做复杂度分析:
- 对于入队操作,复杂度为 O ( 1 ) O(1) O(1)
- 对于出队操作,摊还复杂度为
O
(
1
)
O(1)
O(1),空间复杂度为
O
(
n
)
O(n)
O(n)
所谓摊还复杂度,是指所有操作的平均性能,而且是指最坏情况下的操作一旦发生,则在未来较长一段时间内都不会发生。
496.下一个更大元素
这题需要用单调栈的方法进行维护。首先需要确定nums2中的每个元素的下一个更大元素的值。
首先刚开始的时候,栈中没有元素,直接从nums2中的元素入栈。然后对于num2中的后面元素,逐个与栈顶元素进行比较,如果大于栈顶元素,则将栈顶元素出栈,并且同时存储在字典中,如果小于栈顶元素,则依然入栈。
直到最后,num2中的元素已经循环完,则对栈中余下的元素,逐个弹出,并且在字典中赋值为-1.
def nextGreaterElement(nums1, nums2):
stack = []
ans = []
dic = {}
for i in nums2:
while stack and stack[-1] < i:
dic[stack.pop()] = i
stack.append(i)
while stack:
dic[stack.pop()] = -1
for i in nums1:
ans.append(dic[i])
return ans
对于在num1中的元素,将其作为键,直接查询在字典中的值就可以了。
这种单调栈的方法复杂度为
O
(
n
)
O(n)
O(n)
1021.删除最外层的括号
这题的题目描述说的挺绕的…还不如直接看示例
class Solution:
def removeOuterParentheses(self, S: str) -> str:
stack = []
ans = ''
for i in S:
if i == '(':
stack.append(i)
if len(stack) > 1:
ans += '('
else:
stack.pop()
if len(stack) != 0:
ans += ')'
return ans
那么这一题也是用到了栈的结构。主要利用栈存储左括号,当读取到的字符为右括号时则出栈,那么如何判断当前括号是不是有效的呢?
只需要判断当前栈中的元素个数即可,因为如果一对括号是有效的,那么一进一出,栈就是空的,否则如果同时进两个左括号,栈就不会空。
933. 最近的请求次数
首先这个题是个阅读理解题,题目的意思是指:根据当前输入的时间,确定往前3000毫秒内的请求数目。
class RecentCounter:
def __init__(self):
self.queue = []
def ping(self, t: int) -> int:
if not self.queue:
self.queue.append(t)
else:
while self.queue and self.queue[0] + 3000 < t:
self.queue.pop(0)
self.queue.append(t)
return len(self.queue)
搞懂题意后这个题目也就不难了,但是在写代码的时候我最开始还是犯了很多错误,比如第10行的while
语句,如果漏了判断条件self.queue
很有可能因为最近一次加入的时间间隔太远,导致此时队列已经被弹空,最后self.queue[0]
引用会超出范围,所以要加上这个。
然后如果要返回队列的长度直接用len
函数就行,刚开始我还自己加了个属性self.length
二、递归、动态规划
53.最大子序和
本题是一道比较经典的动态规划问题,作为动态规划,需要确定问题是否具有“重叠子结构”以及“最优子结构”的特点,这里可以参考
什么是动态规划(Dynamic Programming)?动态规划的意义是什么? - 苗华栋的回答 - 知乎
https://www.zhihu.com/question/23995189/answer/1160796300
这篇回答我感觉写的非常详尽。主要的想法还是要确定好递推关系,比如这里确定子问题为:前N个元素中以第N个元素结尾的最大子序和,就能把 f ( N ) f(N) f(N)和 f ( N + 1 ) f(N+1) f(N+1)联系起来。因为只需要判断新增进来的第N+1个元素是否能增大前N个元素的最大子序和就可以了。
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
for i in range(1,len(nums)):
nums[i] = max(nums[i-1] + nums[i], nums[i])
return max(nums)
但是容易错的一点就是这里是使用一次循环找出了所有以n个元素结尾的情况,因此最后需要返回的是这些情况中的最大值,而不是最后一个值。
300.最长上升子序列
这道题也是动态规划的经典问题。首先我们需要定义好子问题:在这里子问题记为
D
(
i
)
D(i)
D(i),它表示前i个数中的最长上升子序列,由于序列不一定要求是连续的,那么
D
(
i
)
D(i)
D(i)的状态转移方程应该是这样的:
对于所有小于i的j,如果
n
u
m
s
[
j
]
<
n
u
m
s
[
i
]
nums[j]<nums[i]
nums[j]<nums[i],那么
n
u
m
s
[
i
]
nums[i]
nums[i]自然就可以接到
D
(
j
)
D(j)
D(j)的后面,于是
D
(
i
)
=
D
(
j
)
+
1
D(i) = D(j)+1
D(i)=D(j)+1,那么
D
(
i
)
D(i)
D(i)的取值就应该是前面所有满足条件的
D
(
j
)
+
1
D(j)+1
D(j)+1中的最大值。
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
if not nums:
return 0
ans = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
ans[i] = max(ans[i], ans[j] + 1)
return max(ans)
121. 买卖股票的最佳时机
本题可以用动态规划解决,我们考虑以下思路:
假设前n天的最大利润为D[n](第n天卖掉),那么第n+1天的最大利润D[n+1]和D[n]有什么关系呢?首先我们假定前n天的最低价格为minprice_n,那么前n+1天的最低价格就是
min
(
m
i
n
p
r
i
c
e
n
,
p
[
n
+
1
]
)
\min (minprice_n, p[n+1])
min(minpricen,p[n+1]),所以最大收益就应该是
max
(
D
[
n
]
,
p
[
n
+
1
]
−
m
i
n
p
r
i
c
e
n
)
\max (D[n], p[n+1] - minprice_n)
max(D[n],p[n+1]−minpricen)。这就是状态转移方程,因此可以写出如下解法:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
if n == 0:
return 0
D = [0] * n
minprice = prices[0]
for i in range(1,n):
minprice = min(minprice, prices[i])
D[i] = max(D[i-1], prices[i] - minprice)
return max(D)
我们还可以对这个解法做一些改进,比如我们不需要一直维护数组D,从而通过写一个一个循环,找到该次循环中的最低价格,以及最大利润:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
minprice = float("inf") #表示正无穷,float("-inf")表示负无穷
maxprofit = 0
for price in prices:
minprice = min(minprice, price)
maxprofit = max(maxprofit, price - minprice)
return maxprofit
198.打家劫舍
我们利用这一题把动态规划再复习一遍(参考LeetCode题解,cr.nettee):
解决动态规划问题的四个步骤:
- 定义子问题
- 写出子问题的递推关系
- 确定DP数组的计算顺序
- 空间优化
- 定义子问题
所谓子问题,就是和原问题相似,但是规模更小的问题。比如如果我们要考虑全部数组的最大值,可以先考虑前k个数的最大值。子问题需要具备以下两个性质:
首先,原问题能够通过子问题表示;
其次,一个子问题的解能通过其他子问题的解求出,即所谓“最优子结构” - 写出递推关系
假设 f ( k ) f(k) f(k)表示从前k个房子中能偷到的最大金额。那么考虑一下:如果第k个房子被偷了,那么第k-1个房子肯定不会被偷,而前k-2个房子不受影响;如果第k个房子不被偷,那么前k-1个房子不受影响。真正的 f ( k ) f(k) f(k)应该是这两种情况中的最大值: f ( k ) = max { f ( k − 1 ) , f ( k − 2 ) + p r i c e [ k ] } f(k) = \max\{f(k-1), f(k-2) + price[k]\} f(k)=max{f(k−1),f(k−2)+price[k]}
当然还要注明边界条件:
当 k = 0 k = 0 k=0时,没有房子, f ( k ) = 0 f(k) = 0 f(k)=0
当 k = 1 k = 1 k=1时,只有一个房子,必须得偷, f ( k ) = p r i c e [ k ] f(k) = price[k] f(k)=price[k] - 确定DP数组的计算顺序
DP数组实际上就是子问题数组,因为该数组中的每个元素对应一个子问题,比如DP[k]
对应 f ( k ) f(k) f(k)的值。那么我们就可以写出解答问题的代码了:
def rob(self, nums):
if len(nums) == 0:
return 0
len_house = len(nums)
dp = [0] * (len_house + 1)
dp[1] = nums[0]
for i in range(2, len_house + 1):
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
return dp[-1]
- 空间优化
之所以做空间优化,是因为在有些情况下没必要维护整个dp数组,只需要用两个变量存储 f ( k ) , f ( k − 1 ) f(k),f(k-1) f(k),f(k−1)就可以了。
5. 最长回文子串
- 解法1:暴力匹配法
def longest(s):
size = len(s)
if size <= 1:
return s
# 答案的初始状态:至少当字符串长度为1时,是一个回文串
max_len = 1
ans = s[0]
for i in range(size-1):
for j in range(i+1,size):
# 函数valid判断是否为回文子串,并判断该子串的长度是否大于已有值
if j - i + 1 > max_len and valid(s,i,j):
max_len = j - i + 1
ans = s[i:j+1]
return ans
# 判断是否为回文子串
def valid(s, i, j):
if i == j:
return True
if i + 1 == j:
if s[i] == s[j]:
return True
else:
return False
if s[i] == s[j]:
return valid(s, i+1, j-1)
else:
return False
不过暴力法的结果就是超时了,因为时间复杂度为 O ( N 3 ) O(N^3) O(N3)
- 本题还可以用动态规划来解决,为什么这么说呢?
因为当一个字符串是回文串,且它的长度大于2的时候,那么去掉首尾字母之后,剩下的字符串肯定也是回文串。这样我们可以定义一个函数: P ( i , j ) = { t r u e , s [ i : j ] 是 回 文 串 f a l s e , o t h e r w i s e P(i,j) = \begin{cases} true, s[i:j]是回文串\\ false,otherwise\end{cases} P(i,j)={true,s[i:j]是回文串false,otherwise
这里的otherwise包含两个情况:
- s [ i : j ] s[i:j] s[i:j]本身就不是回文串
- i > j i>j i>j,此时 s [ i : j ] s[i:j] s[i:j]是不合法的引用
我们由此可以写出状态转移方程:
P
(
i
,
j
)
=
P
(
i
+
1
,
j
−
1
)
a
n
d
(
s
[
i
]
=
=
s
[
j
]
)
P(i,j) =P(i+1,j-1) and(s[i] == s[j])
P(i,j)=P(i+1,j−1)and(s[i]==s[j])
那么这个状态转移方程的边界条件是什么呢?
- 字符串长度为1时,显然是回文串
- 字符串长度为2时,只要两个字符相等,就是回文串
在之前的动态规划问题中,我们都用到了一个一维数组ans
来存储每一步的结果,在这个问题中我们得用二维数组ans[i][j]
来记录子串s[i:j]
是否为回文串
而且这里我们还需要注意一下填表顺序:根据状态转移方程,如果要确定
P
(
i
,
j
)
P(i,j)
P(i,j),就需要知道
P
(
i
+
1
,
j
−
1
)
P(i+1,j-1)
P(i+1,j−1),而这恰好是ans[i][j]
左下方单元格的取值情况。所以填表的时候不能按行填,必须按列填。
def longestStr(s):
size = len(s)
if size < 2:
return s
# 初始化二维列表的方法
dp = [[False for col in range(size)] for row in range(size)]
max_len = 1
start = 0
for i in range(1,size):
dp[i][i] = True #对角线元素肯定是回文串
# 这样循环可以保证是按列填充表格
for j in range(1,size):
for i in range(j):
if s[i] == s[j]:
if j - i <= 2: #长度只有2时,肯定是回文串
dp[i][j] = True
else:
dp[i][j] = dp[i+1][j-1]
else:
dp[i][j] = False
# 判断完了之后,我们需要取出是True的回文子串
if dp[i][j] and j - i + 1 > max_len:
max_len = j - i + 1
start = i
return s[start:start + max_len]
在动态规划中,算法复杂度为 O ( N 2 ) O(N^2) O(N2)
- 介绍第三种方法,中心扩散法,该方法是相对于暴力法而言的,因为采用暴力法,我们用了两个指针进行循环,并且用两个指针进行夹逼。中心扩散法中,我们用一个指针,对字符串进行遍历,并以该指针为中心,向两边扩散,看能扩散的最远地方是哪里。
这里我们还会遇到一个问题:向两边扩散的时候,不能确定中心到底是字符还是间隙,如果是间隙那扩散的字符串长度应该是偶数,如果是字符,扩散的字符串长度是奇数。所以我们可以同时考虑这两种情况,并对它们进行比较。
def longestStr2(s):
size = len(s)
if size < 2:
return s
max_len = 1
res = s[0]
for i in range(size):
left1, right1 = centerSpread(s, i, i) #第一种情况
left2, right2 = centerSpread(s, i, i+1) #第二种情况
if right1 - left1 + 1 > max_len:
max_len = right1 - left1
res = s[left1:right1 + 1]
if right2 - left2 + 1 > max_len:
max_len = right2 - left2
res = s[left2:right2 + 1]
return res
def centerSpread(s, left, right):
# 当left = right时,回文中心是一个字符
# 当left + 1 = right时,回文中心是间隙
while left >= 0 and right < len(s) and s[left] == s[right]:
left -= 1
right += 1
return left + 1, right - 1
该方法的时间复杂度为 O ( N 2 ) O(N^2) O(N2),因为遍历字符串需要 O ( N ) O(N) O(N),每次遍历中至多扩散到整个字符串。
22.括号生成
- 递归法:
假设我们定义一个函数generateParenthesis(n)
,这个函数能正确生成n对括号的组合,那么对于 n − 1 n-1 n−1对括号,需要搞清楚generateParenthesis(n-1)
与generateParenthesis(n)
之间的关系。
我们可以知道,如果已经有 n − 1 n-1 n−1对括号的正确排列,那么新加入一个括号时,由于正确的括号一定是以(
开头,所以只需要枚举)
可能的位置就可以了。可能的括号序列为 ( a ) b (a)b (a)b,其中 a , b a,b a,b是可能的括号组合, a , b a,b a,b中括号的对数为 n − 1 n-1 n−1对。这样我们就可以写出递归函数:
def generateParenthesis(n):
if n == 0:
return []
ans = []
for c in range(n): #第一部分括号组合可能包含的对数
for left in generateParenthesis(c): #遍历前c对括号的所有可能组合
for right in generateParenthesis(n - c - 1): #遍历后面n-c-1对括号的所有可能组合
ans.append("({}){}".format(left, right))
return ans
- 动态规划法
如果递归是自上而下,那么动态规划就是自下而上,分别计算 n = 0 , n = 1 , ⋯ n = 0, n = 1, \cdots n=0,n=1,⋯时的组合情况。
def generateParenthesis(n):
if n == 0:
return []
dp = [None for i in range(n+1)]
dp[0] = [""]
for i in range(1, n+1):
cur = []
for j in range(i):
left = dp[j]
right = dp[i-j-1]
for s1 in left:
for s2 in right:
cur.append("("+s1+")"+s2)
dp[i] = cur
return dp[n]
64. 最小路径和
这题如果要用深度优先搜索法是肯定会超时的,其本质仍然是一道动态规划题,比如如果以dp[m][n]
记录到达当前格子的路径数字之和,那么对于dp[i][j]
来说:
d
p
[
i
]
[
j
]
=
min
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
1
]
)
+
n
u
m
[
i
]
[
j
]
dp[i][j] = \min(dp[i-1][j], dp[i][j-1]) + num[i][j]
dp[i][j]=min(dp[i−1][j],dp[i][j−1])+num[i][j]
这样就可以写出最后的结果了:
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
row = len(grid)
col = len(grid[0])
ans = [[0 for _ in range(col)] for _ in range(row)]
ans[0][0] = grid[0][0]
for i in range(1,col):
ans[0][i] += ans[0][i-1] + grid[0][i]
for j in range(1, row):
ans[j][0] += ans[j-1][0] + grid[j][0]
for i in range(1,col):
for j in range(1, row):
ans[j][i] = min(ans[j-1][i], ans[j][i-1]) + grid[j][i]
return ans[-1][-1]
77.组合
本题首先考虑用递归的方法进行。比如对于一个数组[1,2,3,4,5]考虑 C 5 2 C_5^2 C52组合,假设拿出了第一个数[1],则只需再从[2,3,4,5]中拿出一个数进行组合即可,如果拿出了第二个数[2],则只需再从[3,4,5]中拿出一个数就可以了,这样依次进行,就能遍历整个数组。写出以下代码:
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
def C(nums, q):
if q == 1:
return [[i] for i in nums]
if q == len(nums):
return [[i for i in nums]]
d = C(nums[1:], q-1)
for i in range(0,len(d)):
d[i].append(nums[0])
d.extend(C(nums[1:], q))
return d
return C([i for i in range(1, n + 1)], k)
三、哈希表
1.两数之和
第一个比较简单易得的想法:用两个循环:
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
for i in range(len(nums)-1):
for j in range(i+1, len(nums)):
if nums[i] + nums[j] == target:
return [i,j]
这个方法的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2),考虑一下有没有其他方法能够提升效率。
这里用到哈希表(也就是Python中的字典),因为在哈希表中进行查找时间复杂度为
O
(
1
)
O(1)
O(1)
def twoSum(self, nums, target):
hmap = {}
for x in range(len(nums)):
if target - nums[x] in d:
return [d[target - nums[x]],x]
else:
d[nums[x]] = x
这种方法的时间复杂度为 O ( n ) O(n) O(n)
36. 有效的数独
这题思路其实很简单:对每个数字,判断它在行、列、九宫格(记作box)内是否重复出现。关键是怎么用代码实现,以及如何判断一个数字在哪个格子
def isValidSudoku(board):
check_row = [set() for i in range(9)] #为每一行创建一个空的集合
check_col = [set() for i in range(9)] #为每一列创建一个空的集合
check_box = [set() for i in range(9)] #为每一个box创建
for i in range(9):
for j in range(9):
item = board[i][j] #取出当前元素
box = (i // 3) * 3 + j // 3 #j//3可以表示在该行的第几个box,其实画个图算算等式就知道了
if item != '.':
if item not in check_row[i] and item not in check_col[j] and item not in check_box[box]:
check_row[i].add(item)
check_col[j].add(item)
check_box[box].add(item)
else:
return False
return True
这里面还涉及一个内容,就是使用set()
进行检查,因为python中set集合对应的哈希表,而哈希表的查找时间复杂度为
O
(
1
)
O(1)
O(1),因此可以提升查找速度。
四、链表
2. 两个数字之和
这题比较难,看了一些博主的解析才明白什么思路。
这里涉及到的需要处理的问题:
- 链表的操作(比如如何遍历链表)
- 两个链表长度不一致
- 进行相加时需要进位
首先由于我们需要返回一个链表,那么需要预留一个指针,指向链表的头结点,然后还要设置一个用于遍历的指针(也就是同时设置两个指针),因为第二个用于遍历的指针会沿着链表移动,导致在遍历之后无法再指向头部。
对于第二个问题,如果长度不一致,就在比较短的链表末尾添加0就可以了。
对于第三个问题,需要另外设置一个变量记录是否需要进一。
def addTwoNumers(self, l1, l2):
dummy = p = ListNode(None) # 设置指针,一个用于遍历,一个用于返回结果
s = 0 #设置每一步的求和暂存变量
while l1 or l2: # 当l1或l2没有遍历完的时候,继续进行循环
x = l1.val if l1 else 0 # 如果l1已经遍历完了,就添加0
y = l2.val if l2 else 0
total = x + y + s
s = total // 10 # 判断是否进位
p.next = ListNode(total % 10) #取余数
if l1:
l1 = l1.next
if l2:
l2 = l2.next
p = p.next
if s != 0:
p.next = ListNode(1)
return dummy.next
21. 合并两个有序列表
- 解法一:递归法
定义一个函数mergeTwoLists
,这个函数可以实现将两个链表进行合并。边界条件是其中至少一个链表为空,此时就把另一个链表剩下所有的内容都返回。而能够使得问题规模减小的步骤为:比较两个链表l1,l2
的同一位上的数字,如果l1.val < l2.val
,那么l1.val
应当排在合并链表的前面,所以函数为mergeTwoLists(l1.next, l2)
def mergeTwoLists(l1, l2):
if not l1:
return l2
if not l2:
return l1
if l1.val < l2.val:
l1.next = mergeTwoLists(l1.next, l2)
return l1
else:
l2.next = mergeTwoLists(l2.next, l1)
return l2
24.两两交换链表中的节点
这道题的典型思路就是递归,关于递归的思想,我们需要着重避免落入想要搞清楚每一层调用了什么返回了什么的误区,事实上递归更适合被看做是一个黑箱,我们假定它总能提供一个我们满意的结果,我们需要关注的仅仅是在某一层递归上,它处理了什么,它返回了什么,以及整个递归的终止条件是什么。
关于递归的思想有篇博文写的很好:三道题套路解决递归问题,这里就不重复叙述了,直接给出代码:
def swapList(head):
if head == None or head.next == None:
return head
temp = head.next
head.next = swapList(head.next.next)
temp.next = head
return head
61. 旋转链表
本题我自己的想法是:先把这个链表首尾相连得到一个环形链表,然后按照旋转次序滑动标志初始位置的指针,到达指定位置后,再把这个环形链表断开。
但是这么做的话实际上是复杂化了,很简单的,本题的实质就是将链表的倒数第
k
k
k个数作为头,把前面的部分与之前的尾部相连。
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def rotateRight(self, head: ListNode, k: int) -> ListNode:
if head == None or head.next == None or k == 0:
return head
temp, temp2, size = head, head, 1
temp3 = None
while temp.next != None:
size += 1
temp = temp.next
if k % size == 0:
return head
move = size - k % size
while move > 0:
if move == 1:
temp3 = temp2
temp2 = temp2.next
move -= 1
temp.next = head
temp3.next = None
return temp2
当然代码可能不够简洁,等待日后复习再做整理。
86. 分隔链表
我们考虑对输出的链表进行分割,我们知道可以找到一个点,在这个节点之前所有的值都小于x,在这个节点之后所有的值都大于x。所以如果我们能够创建这两个子链表,然后把它们连接起来就可以了。
那么我们可以设置3个指针,第一个指针head
指向原始链表,用于遍历,第二个和第三个指针用于创建满足条件的子链表。
def partition(head, x):
before = before_head = ListNode(0)
after = after_head = ListNode(0)
while head:
if head.val < x:
before.next = head
before = before.next
else:
after.next = head
after = after.next
head = head.next
after.next = None
before.next = after_head.next
return before_head.next
注意这里我们实际上是没有创建新的链表的,只是把原来链表的位置进行了移动,所以时间复杂度为 O ( n ) O(n) O(n),空间复杂度为 O ( 1 ) O(1) O(1)。
五、Sliding Window
3.无重复字符的最长子串
这题我们首先想到的就是暴力法求解:写两个循环,每个循环中判断字符是否相等,如果相等的时候就记下这两个字符序号之间的差值,暴力法的复杂度
O
(
n
2
)
O(n^2)
O(n2)。
但是这题还有更巧妙的方法:滑动窗口(sliding window)。何为滑动窗口呢?看下leetcode的官方题解就知道了:
把滑动窗口想象成一个队列,队列的左边每次向右一个,右边不断地向右移动,直到发现相同字符为止,返回此时队列的长度,然后判断所有长度中的最大值。此时只需要用到一次循环,时间复杂度为
O
(
n
)
O(n)
O(n)。
至于如何判断是否存在重复字符,用到了数学中的集合概念,数据结构中也叫哈希集合,集合中包含了不重复的元素。对应到Python中就是set
数据结构。
def lengthOfLongestSubstring(self, s):
occ = set() #哈希集合,用于记录当前窗口中的不重复元素
ans = 0
right = 0 # 右指针,用于滑动
for left in range(len(s)): # 左指针,确定滑动窗口的开始位置
# 当右指针指向的值不在哈希集合中时,右指针继续右移
while right < n and s[right] not in occ:
occ.add(s[right])
right += 1
if len(occ) > ans:
ans = len(occ)
occ.remove(s[left])
return ans
28. 实现strStr()
这题我用的方法就是逐个遍历字符串,一旦找个第一个字符相匹配的时候,就开始逐个匹配,这样的话时间复杂度应该为$O((N-L)L):
def strStr(haystack, needle) :
size1 = len(haystack)
size2 = len(needle)
if size1 < size2:
return -1
if size2 == 0:
return 0
check = False
for i in range(size1):
if i > size1 - size2:
return -1
if haystack[i] == needle[0]:
check = True
for j in range(size2):
if haystack[i + j] != needle[j]:
check = False
break
if check == True:
return i
if check == False:
return -1
当然还有看起来更简明易懂的方法:滑动窗口法:
我们设定一个滑动窗口,窗口的长度为需要比对的字符串的长度,每次移动窗口时只需要比较字符串是否相等就可以了
def strStr(haystack, needle):
size1, size2 = len(haystack), len(needle)
for start in (size1 - size2 + 1):
if haystack[start : start + size2] == needle:
return start
return -1
六、回溯算法
78. 子集(subset)
思路1(循环遍历):由于数组中不包含重复数字,所以对每个数字进行循环,每读到一个新数字,就加到之前创建的所有子集中。
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
ans = [[]]
for i in range(len(nums)):
for j in ans[:]:
ans.append(j + [nums[i]])
return ans
这里关于代码本身有需要说明的地方:
- 创建的列表
ans
中首先包含了一个空数组,因为空集总是数组的子集,之后都是往ans
中添加新的集合 - 在循环过程中,必须写成
ans[:]
,这样才能保证读到一个新数字时,原来ans
的答案是不变的,因为我们要不断向ans
中添加新元素。而切片是引用新的对象。 - 为什么不写
ans.append(j.append(nums[i]))
?因为append
方法是对原列表的修改,本身返回值为None,不能再作为前一个列表append方法的参数
留坑
17. 电话号码的字母组合
Leetcode官方题解
回溯算法通过穷举所有可能情况来找到解,并且如果一个候选解(某一条路径)最后不是可行解,就往前回溯,对结果进行一些调整。
定义一个回溯函数backtrack(combination, next_digits)
,将已经产生的组合combination
和接下来要输入的数字next_digits
作为参数,如果没有更多数字输入表明已经产生了答案,如果还有数字待输入,就把对应的所有字母加到combination
后面。
def letterCombination(digits):
phone = {'2': ['a', 'b', 'c'],
'3': ['d', 'e', 'f'],
'4': ['g', 'h', 'i'],
'5': ['j', 'k', 'l'],
'6': ['m', 'n', 'o'],
'7': ['p', 'q', 'r', 's'],
'8': ['t', 'u', 'v'],
'9': ['w', 'x', 'y', 'z']}
output = []
def backtrack(combination, next_digits):
if len(next_digits) == 0:
output.append(combination)
else:
for letter in phone[next_digits[0]]:
backtrack(combination + letter, next_digits[1:])
if digits:
backtrack("", digits)
return ouput
39.组合总和
本题也是要先画出决策树,这里的决策树应当使用减法,例如如果
t
a
r
g
e
t
=
7
,
c
a
n
d
i
d
a
t
e
s
=
[
2
,
3
,
6
,
7
]
target = 7, candidates=[2,3,6,7]
target=7,candidates=[2,3,6,7],那么第一步选择为
2
,
3
,
6
,
7
2,3,6,7
2,3,6,7中的一个,如果选择2,就要继续寻找
t
a
r
g
e
t
=
5
target = 5
target=5的组合。
这里借用LeetCode用户@liweiwei1419的配图,他解答得非常好:
这里的终止条件为,如果剩下的
t
a
r
g
e
t
<
0
target <0
target<0,终止循环,或者
t
a
r
g
e
t
=
=
0
target == 0
target==0,返回这条路径。简化之后的图如下:
但是这个算法还存在一些问题,就是可能存在重复现象,所以我们要对决策树进行剪枝,剪枝方法为每次按照递增的顺序选择减数,因此每次返回上一个节点后,开始新的搜索时,应当选择上一个节点对应的数值之后的数据。
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
size = len(candidates)
if size == 0:
return []
candidates.sort()
path = []
res = []
def backtrack(candidates, start, target):
## 到达底层
if target == 0:
res.append(path[:])
return
for index in range(start, size):
residual = target - candidates[index]
if residual < 0:
break
path.append(candidates[index])
backtrack(candidates, index, residual)
path.pop() ##保证能够返回到上一层节点
backtrack(candidates, 0, target)
return res
40. 组合总和②
这题跟39题其实差不多,关键在于要避免出现重复的组合,例如如果有数组[1,2,2,3]
,如果仍然用39题的算法,
t
a
r
g
e
t
=
6
target = 6
target=6时,会出现[1,2',3]
,[1,2'',3]
的情况,这个时候要做的重要剪枝工作就是确保在同一层中,对于相同的数字,只有第一个加入运算,然后下一层中的搜索是从上一层的candidates[]
后面一位开始。
class Solution:
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
size = len(candidates)
if size == 0:
return []
res = []
path = []
candidates.sort()
def backtrack(candidates, start, target):
if target == 0:
res.append(path[:])
return
for i in range(start, size):
resudial = target - candidates[i]
if resudial < 0:
break
if i > start and candidates[i] == candidates[i-1]:
continue
path.append(candidates[i])
backtrack(candidates, i + 1, resudial)
path.pop()
backtrack(candidates, 0, target)
return res
51.N皇后
本题是回溯法的经典实现。回溯法的含义是指按照某种条件向前搜索,当达到某一步发现无法实现目标时,就回退一步重新进行选择。
对于八皇后问题,可以参考这篇博客:回溯算法
当我们在某个格子填入皇后后,需要检验在该行、列以及对角线上是否存在其他皇后,然后逐步填入其他的皇后。
- 第一步:定义判断函数,即当前填入的皇后是否与其他位置的有冲突
def isValid(board, row, col): #board是当前的棋盘,row,col是现在填入的皇后位置
# 检查列上是否有冲突
for i in range(row):
if board[i][col] == 'Q':
return False
# 检查右对角线上是否有冲突
r_row, r_col = row, col
while r_row > 0 and r_col < len(board) - 1:
r_row -= 1
r_col += 1
if board[r_row][r_col] == 'Q':
return False
# 检查左对角线上是否有冲突
l_row, l_col = row, col
while l_row > 0 and l_col > 0:
l_row -= 1
l_col -= 1
if board[l_row][l_col] == 'Q':
return False
# 所有检查完毕,没有返回False,则当前是成立的
return True
- 第二步:定义回溯函数,即需要确定何时达到终点,以及何时条件不满足需要返回上一层
def backtrack(board, row):
## 如果到达底层,返回
if row == len(board):
# 二维变一维输出(题目要求)
tmp_list = []
for e_row in board: #将board中的每一行加到temp_list中
tmp = ''.join(e_row)
tmp_list.append(tmp)
res.append(tmp_list)
return
## 往下搜索算法,确定在这一层的哪一列填入皇后
for col in range(len(board)):
if not isValid(board, row, col):
continue
board[row][col] = 'Q'
backtrack(board, row + 1)
## 撤销选择,返回到上一层
board[row][col] = '.'
- 第三步:函数主体部分
def solveNQueens(n):
board = [['.'] * n for _ in range(n)] #初始化棋盘
res = []
backtrack(board,0)
return res
完整的解答:
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
board = [['.'] * n for _ in range(n)] #初始化棋盘
res = []
def isValid(board, row, col): #board是当前的棋盘,row,col是现在填入的皇后位置
# 检查列上是否有冲突
for i in range(row):
if board[i][col] == 'Q':
return False
# 检查右对角线上是否有冲突
r_row, r_col = row, col
while r_row > 0 and r_col < len(board) - 1:
r_row -= 1
r_col += 1
if board[r_row][r_col] == 'Q':
return False
# 检查左对角线上是否有冲突
l_row, l_col = row, col
while l_row > 0 and l_col > 0:
l_row -= 1
l_col -= 1
if board[l_row][l_col] == 'Q':
return False
# 所有检查完毕,没有返回False,则当前是成立的
return True
def backtrack(board, row):
## 如果到达底层,返回
if row == len(board):
# 二维变一维输出(题目要求)
tmp_list = []
for e_row in board: #将board中的每一行加到temp_list中
tmp = ''.join(e_row)
tmp_list.append(tmp)
res.append(tmp_list)
return
## 往下搜索算法,确定在这一层的哪一列填入皇后
for col in range(len(board)):
if not isValid(board, row, col):
continue
board[row][col] = 'Q'
backtrack(board, row + 1)
## 撤销选择
board[row][col] = '.'
backtrack(board,0)
return res
46.全排列
一般把8皇后和全排列问题作为回溯算法的经典例题。根据一个图来理解在执行深度优先搜索时的树形图应该是什么样子的,图来自leetcode用户@liweiwei1419:
注意我们有以下的一些操作:
- 在每个节点上进行选择,一直到最后没有选择时或达到终止条件时,返回整个的选择路径
- 同时还要向上撤销选择
然后看一下如何进行编码:
首先注意,在每个节点上(除了叶节点和根节点)我们要做的事情只有在剩下未选的数中依次选择一个数。因此这种重复性的工作可以用递归函数来解决。
然后需要定义一个能确定终止条件的变量,这个变量可以度量当前树到了第几层。
def permute(nums):
size = len(nums)
if size == 0:
return []
res = []
used = [False for _ in range(size)]
def backtrack(nums, depth, path):
if depth == size:
res.append(path[:])
return
for index in range(size):
if not used[index]:
used[index] = True
path.append(nums[index])
backtrack(nums, depth + 1, path)
used[index] = False
path.pop()
backtrack(nums, 0, [])
return res
注意在这里res.append(path[:])
,是因为path
变量只有一个,我们每次递归结束后需要向上重置状态,所以最后path
是为空的,如果写成res.append(path)
,就只会返回空列表,需要做一次切片拷贝。
47. 全排列②
这题相比较于前面一题多了一个限制,即给定的数字中可能存在重复数字,这个时候需要加上限制,去除可能存在的重复排列。
来看看这里的剪枝情况,图来自leetcode用户@liweiwei1419:
我们可以看到,在第三层的第二个节点上,因为1''
和前面已经选择过的1'
重复,所以不考虑这个分支下的情况,那么条件就是写成:
for ...:
...
if index > 0 and nums[index] == nums[index - 1]:
continue
...
但是如果这么写会出现什么问题?因为nums
数组只有一个,看第三层的第一个节点,这时我们选择了1'''
,根据刚才的判断条件,1''' == 1''
,那么这个情况会被跳过,导致我们不能得出正确答案。那么还该加什么判断条件呢?
我们可以看到,在上面那个误判的情况里,我们前面的1''
仍然在使用中,而在没有误判的情况中,1''
前的1'
是没有在使用中的,所以1'
会在后面被继续用到,这就导致了重复的发生,所以我们要加上条件:
if index > 0 and nums[index] == nums[index - 1] and not used[index -1]:
continue
这样代码实现就结束了,只需要在46题的代码里加上这个判断条件就行。
37. 解数独
又是一道典型的回溯算法题目。因为联想一下我们平时做数独的方法:先从第一个格子开始试,然后依次确定后面格子里的数字,如何合适就继续,不合适考虑更换。这种搜索方法即为回溯法。关于解数独问题这篇文章讲的很详细:回溯算法解数独
- 第一步,写出回溯算法代码框架。每一步的选择都是在1到9之间穷举,举完之后移动到下一格。
candidates = ['1','2','3','4','5','6','7','8','9']
def backtrack(board, row, col):
# 先对棋盘的每个位置进行穷举
for i in range(row,9):
for j in range(col,9):
for ch in candidates:
board[i][j] = ch
# 穷举完了,继续下一层
backtrack(board, i, j+1)
# 返回上一层
board[i][j] = '.'
- 第二步,补充框架,首先需要解决如果穷举到了当前行的最后一列该怎么办,然后需要解决如何判断当前的数字能否填入
candidates = ['1','2','3','4','5','6','7','8','9']
def backtrack(board, row, col):
if col == 9:
backtrack(board, row + 1, 0)
return
for i in range(row, 9):
for j in range(col, 9):
if board[i][j] != '.':
backtrack(board, row, col + 1)
return
for ch in candidates:
if not isValid(board, i, j, ch):
continue
board[i][j] = ch
backtrack(board, i, j + 1)
board[i][j] = '.'
def isValid(board, row, col, ch):
for i in range(col):
if board[row][i] == ch:
return False
for i in range(row):
if board[i][col] == ch:
return False
for i in range(9):
if board[(row // 3) * 3 + i // 3][(col // 3)*3 + i % 3] == ch:
return False
return True
- 第三步,确定终止情况,当填到最后一行的时候,如果找到了可行解,就可以返回了,并且在这里我们是只需返回一种情况就可以。
candidates = ['1','2','3','4','5','6','7','8','9']
def backtrack(board, row, col):
if col == 9:
return backtrack(board, row + 1, 0)
if row == 9:
return True
for i in range(row, 9):
for j in range(col, 9):
if board[i][j] != '.':
return backtrack(board, row, col + 1)
for ch in candidates:
if not isValid(board, i, j, ch):
continue
board[i][j] = ch
if backtrack(board, i, j + 1):
return True
board[i][j] = '.'
return False
return False
def isValid(board, row, col, ch):
for i in range(col):
if board[row][i] == ch:
return False
for i in range(row):
if board[i][col] == ch:
return False
for i in range(9):
if board[(row // 3) * 3 + i // 3][(col // 3)*3 + i % 3] == ch:
return False
return True
79. 单词搜索
回溯算法的核心:
本题中我们要从每个格子出发,看该格子能否实现目标,假设搜索函数为dfs()
,因此主函数exist()
应该为:
def exist(self, board, word):
m = len(board)
n = len(board[0])
marked = [ [False for _ in range n] for _ in range(m) ]
for i in range(m):
for j in range(n):
if self.dfs(board, word, 0, i, j, marked, m, n):
return True
return False
那么重点在于搜索函数dfs
,这里用到回溯算法,就是在每个点,沿每个方向进行搜索:
def dfs(self, board, word, index, start_x, start_y, marked, m, n):
## 先判断边界条件:
if index == len(word) - 1:
return board[start_x][start_y] == word[index]
if board[start_x][start_y] == word[index]:
marked[start_x][start_y] = True
# 这里directions是可供选择的方向
for direction in directions:
new_x = start_x + direction[0]
new_y = start_y + direction[1]
if 0 <= new_x < m and 0 <= new_y < n and not marked[new_x][new_y] and self.dfs(board, word, index + 1, new_x, new_y, marked, m, n):
return True
marked[start_x][start_y] = False
return False
因此总的函数就是这两个的结合。
七、数学
6.Z字形变换
这种题没啥好办法,就是得找找规律,然后总结出一套算法。
这里我们看,如果指定行数为N,对字符串逐个遍历,每个字符串依次放到相应的行上,放到末尾的时候再反方向放到行上。而且实际上我们不需要把图案严格弄成N字形,只要保证这个顺序就可以了。
以下是Leetcode博主Krahets的图解:
我们为每一行设定一个字符数组,最后输出的时候把字符数组组合就可以了。
def convert(s, N):
res = [""] * N
rownum = 0
for i in s:
if rownum == 0:
flag = 1
elif rownum == N-1:
flag = -1
res[rownum] += i
rownum += flag
ans = ""
for i in res:
ans += i
return ans
31. 下一个排列
这题题目意思有些难懂,实际上就是要找到比当前数字大的且相差最小的数字。
当一组数据完全升序时,它肯定是所有数字组合中最小的,只需要把最后两位数调换位置即可;而当一组数据完全降序时,它肯定是所有组合中最大的,此时无解。因此我们需要从后往前扫描,直到找到第一次数字下降,然后把这个位置的数字与后面的数字交换位置就可以了,那么交换位置后的数字肯定要比之前的要大,但是还需要再把后面的数字按照升序进行排列。
def nextmax(nums):
size = len(nums)
if size <= 1:
return nums
start = size - 1
while start > 0 and nums[start - 1] >= nums[start]:
start -= 1
if start == 0:
return nums.reverse()
end = size - 1
while end > start - 1 and nums[end] <= nums[start - 1]:
end -= 1
nums[start - 1], nums[end] = nums[end], nums[start - 1]
for i in range((size - start) // 2):
nums[start + i], nums[size - 1 - i] = nums[size - 1 - i], nums[start + i]
return nums
38. 外观序列
这题有点绕,首先要分析题目中的数量关系:
比如对于第四层1211
,要确定第五层的内容,需要先对第四层进行循环遍历,记录每一个第一次出现的数字,例如第一位是1
,记录下1
,且其出现次数为1,第二位是2
,记录下2
,出现次数为1
,第三第四位都是1
,且出现次数为2
,然后按照
出
现
次
数
+
数
字
出现次数 + 数字
出现次数+数字 的方法进行输出,这里第五层就输出为:1个1,1个2,2个1,所以是111221
def countAndSay(n):
prev = '1'
## 写循环,输出第n层的结果:
for i in range(n):
## 在每一层中先初始化
compare = prev[0] #要比较的对象先从prev的第一个数字开始
cnt = 1 #计数为1
ans = '' #该层的答案,是下一层的prev
for j in range(1, len(prev)): #从第二位开始
if prev[j] == compare: #如果相等,继续计数
cnt +=1
else:
ans += str(cnt) + compare #在答案里加上“几个几”
compare = prev[j] #要要比较的对象换成当前对象
cnt = 1 #重置计数器
ans += str(cnt) + compare #最后一次输出时不会发生字符不一致
prev = ans
return prev
55. 跳跃游戏
首先明确一点:如果某个数字能被跳到,那么它前面的所有位置都能被跳到。解释一下:假如最后一个位置n
能被跳到,那么它必然是由前面某个位置m
跳过来的,那么m
到n
之间的格子都可以被跳到,而m
也必然是由前面的某个位置l
跳过来的…,以此类推,前面所有的位置都可以被跳到。
因此,我们只需要计算给定数组中最远能达到的位置,如果最远位置比数组长度长,就表明一定可以跳到最后一个数字。
class Solution:
def canJump(self, nums: List[int]) -> bool:
max_i = 0 #当前能跳到的最大位置
for index, jump in enumerate(nums):
if max_i >= index and index + jump > max_i:
max_i = index + jump
return max_i >= len(nums) - 1
60. 第k个排列
这题,参考下力扣用户@蔚色旅晨的思路:依次对
n
n
n个数字的每一位进行定位,考虑这一位后面的数字的排列组合数是否能够达到当前的要求。
例如题目要求是从
4
!
4!
4!排列组合中找出第19个,那么首先固定第一个数字为1,后面3个数字的排列共有
3
!
=
6
3!=6
3!=6个,
6
<
19
6<19
6<19,所以1不满足要求,同理
2
,
3
2,3
2,3也不符合要求,因此第一个数字是4;之后依次考虑后面的数,但是此时要注意,4已经从数组中被选择,后面能考虑的数只有
1
,
2
,
3
1,2,3
1,2,3了,如此计算,直到数组
[
1
,
2
,
3
,
4
]
[1,2,3,4]
[1,2,3,4]为空。
class Solution:
def getPermutation(self, n: int, k: int) -> str:
def mul(n):
if n<=1:
return 1
else:
return n*mul(n-1)
l=list(range(1,n+1))
res=[]
while l:
a=mul(len(l)-1)
tmp=math.ceil(k/a)-1
value=l[tmp]
res.append(value)
l.remove(value)
k=k-tmp*a
res=list(map(str,res+l))
return ''.join(res)
69. x的平方根
求平方根需要用到的算法实际上是牛顿迭代法:假如我们想求
2
\sqrt{2}
2的值,那么实际上它是函数
f
(
x
)
=
x
2
−
2
f(x) = x^2 - 2
f(x)=x2−2的零点。
那么实际中应当如何做近似?例如刚开始我们就以2作为它的近似值,虽然差别有点大,但是注意到该函数的导数为
2
x
2x
2x,并且由于是凸函数,在某一点的导函数的零点总要更接近真实值,假设零点为
a
a
a,那么根据导数几何意义有
2
x
=
f
(
x
0
)
x
0
−
a
2x = \frac{f(x_0)}{x_0 - a}
2x=x0−af(x0),计算得到
a
=
x
0
−
f
(
x
0
)
2
x
a = x_0 - \frac{f(x_0)}{2x}
a=x0−2xf(x0),利用此式不断进行迭代,直到达到某一精度为止。
八、双指针
11. 盛水最多的容器
- 解法一:穷举法
def maxArea(height):
area = 0
size = len(height)
for i in range(size - 1):
for j in range(i + 1, size):
h = min(height[i], height[j])
w = j - i
if h * w > area:
area = h * w
return area
当然暴力法的结果就是时间超出限制,毕竟是 O ( N 2 ) O(N^2) O(N2)时间复杂度
- 解法二:双指针
这里引入一个船新的解法,即双指针法,关于双指针leetcode官方题解说明得很详细了:leetcode官方题解关于双指针
再来复习一遍:
开始的时候两个指针分别指向两端(即数组的两个边界),此时的面积记为 min ( x [ 0 ] , x [ n ] ) ∗ n \min(x[0],x[n])*n min(x[0],x[n])∗n,然后我们要考虑其他可能的情况,所以要移动指针。
这里我们只移动两个指针中指向比较小的数的指针而保持另一个指针不变,这是为什么呢?这样会不会造成遗漏情况呢?实际上不会,可以证明这一结论:
如果初始时两指针位置为 i , j i,j i,j,指向数字 x i , x j x_i,x_j xi,xj,不失一般性,假设 i < j , x i < x j i<j, x_i<x_j i<j,xi<xj,那么根据之前的结论,此时面积为 min ( x i , x j ) ∗ ( j − i ) \min(x_i,x_j) * (j-i) min(xi,xj)∗(j−i)我们只能移动 i i i,于是我们认为如果把 j j j向右移动,无论移动到哪,面积一定会减小:假设移动到 j ∗ j^* j∗,那么一定有 min ( x i , x j ∗ ) ≤ min ( x i , x j ) \min(x_i,x_{j^*} )\le \min(x_i,x_j) min(xi,xj∗)≤min(xi,xj)因为: i f x j ∗ < x j , min ( x i , x j ∗ ) < min ( x i , x j ) i f x j ∗ ≥ x j > x i , min ( x i , x j ∗ ) = min ( x i , x j ) if\ x_{j^*} < x_j,\min(x_i,x_{j^*})<\min(x_i,x_j)\\ if\ x_{j^*} \ge x_j >x_i,\min(x_i,x_{j^*})= \min(x_i,x_j) if xj∗<xj,min(xi,xj∗)<min(xi,xj)if xj∗≥xj>xi,min(xi,xj∗)=min(xi,xj)
所以面积是一定会减小的。
双指针算法的时间复杂度为 O ( N ) O(N) O(N)。
def maxArea1(height):
i, j = 0, len(height) - 1
area = 0
while i < j:
h = min(height[i], height[j])
w = j - i
if h * w > area:
area = h * w
if height[i] < height[j]:
i += 1
else:
j -= 1
return area
15. 三数之和
本题在开始之前应当先进行排序,以免输出重复结果。排序算法的时间复杂度为
O
(
N
l
o
g
N
)
O(N log N)
O(NlogN)
虽说是双指针,但是实际上应该是三指针,只不过最左边的指针是比较固定、非动态变化的。
首先固定最左边的指针left
,然后设置两个动态指针mid
right
在数组的两端,将双指针交替向中间移动,对于每个固定的left
,记录满足nums[left] + nums[mid] + nums[right] == 0
的组合,并且考虑以下情况:
- 若
nums[left] > 0
,则后面的数字都大于0,三数之和必然大于0,因此直接结束循环。 - 若
nums[left] == nums[left-1]
,则该当前指针left
可以直接跳过,因为在之前已经找过了所有可能的情况 - 当
mid == right
时当前循环结束,left
向左边移动一位,在此之前计算三数之和s
,如果:s < 0
,则mid+=1
,并且跳过所有重复的nums[mid]
s > 0
,则right-=1
,并且跳过所有重复的nums[right]
s = 0
,则记录组合left,mid,right
至res
,并且i += 1, j -= 1
,并跳过所有重复的情况。
def threeSum(nums):
nums.sort()
ans = []
for left in range(len(nums) - 2):
if nums[left] > 0:
break
if left > 0 and nums[left] == nums[left - 1]:
continue
## 设置双指针mid, right
mid = left + 1
right = len(nums) - 1
while mid < right:
s = nums[left] + nums[mid] + nums[right]
if s < 0:
mid += 1
while mid < right and nums[mid] == nums[mid - 1]:
mid += 1
elif s > 0:
right -= 1
while mid < right and nums[right] == nums[right + 1]:
right -= 1
else:
ans.append([nums[left],nums[mid],nums[right]])
mid += 1
right -= 1
while mid < right and nums[mid] == nums[mid-1]:
mid += 1
while mid < right and nums[right] == nums[right + 1]:
right -= 1
return ans
该方法时间复杂度为 O ( N 2 ) O(N^2) O(N2),优于暴力搜索法
26.删除排序数组中的重复项
这题题目的意思很明白:不需要新建数组,在原数组的基础上进行修改,把重复的数字往后挪,然后只返回含有唯一值的数组切片就可以了。
这题用双指针法可解,设置一个在左边的慢指针p
和一个在右边的快指针q
,q
从左向右遍历,如果q
和p
位置上的数字相等,那么就把q
向右移动,直到遇到第一个不相等的数字,将这个数字移动到p+1
的位置上,并且把p,q
向后移动一位。
27.移除元素
这题和26题其实很像,主要就是搞清楚处理的流程。
首先我们要求的是原地删除指定数据,那么就需要把后面的非指定元素移到该数据位置上。
假设第一个数据就是val,那么我们的快指针需要向右移动,直到找到第一个不为val的数,将这个数赋值给指定数,同时把慢指针向右移动。
def removeElement(nums, val):
i,j = 0,0
while j < len(nums):
if nums[j] != val:
nums[i] = nums[j]
i += 1
j += 1
return i
注意这题代码其实比较抽象,需要注意理解过程。
75. 颜色分类
首先,原地修改,
O
(
n
)
O(n)
O(n)复杂度,就很符合双指针查找的特点。
核心的想法:设置3个指针:p0,p2,curr
表示0部分的最右边界,2部分的最左边界以及当前考虑的元素,沿数组移动curr
指针,如果nums[curr] = 0
,那么就进行nums[curr]
和nums[p0]
的交换,如果nums[curr] = 2
],就进行nums[curr]
与nums[p2]
的交换。
交换过程中有几个细节需要注意:
- 如
nums[curr] == 2
,则需要交换nums[curr], nums[p2]
,然后p2
左移一个,但是curr
保持不变,因为可能之前nums[p2] = 1
,仍然需要进行判断 - 如
nums[curr] == 1
,这是我们不需要考虑的情况,curr
右移 - 如
nums[curr] == 0
,交换nums[curr], nums[p0]
,但是由于此时curr
左边都已经排好序了,所以curr
也可以右移一个
class Solution:
def sortColors(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
size = len(nums)
p0 = 0
curr = 0
p2 = size - 1
while curr <= p2:
if nums[curr] == 1:
curr += 1
elif nums[curr] == 0:
nums[curr], nums[p0] = nums[p0], nums[curr]
curr += 1
p0 += 1
elif nums[curr] == 2:
nums[curr], nums[p2] = nums[p2], nums[curr]
p2 -= 1
九、贪心算法
12. 整数转罗马数字
贪心算法核心就是每次从原始数据中减掉一个最大的。那么我们可以写出以下代码:
def intToRoman(num):
dic = {
1000 : 'M',
900 : 'CM',
500 : 'D',
400 : 'CD',
100 : 'C',
90 : 'XC',
50 : 'L',
40 : 'XL',
10 : 'X',
9 : 'IX',
5 : 'V',
4 : 'IV',
1 : 'I'
}
ans = ''
for val in dic.keys():
while num >= val:
ans += dic[val]
num -= val
return ans
在该代码中,我们从大的单位向小的单位遍历,在进入小单位之前,先确保大单位已经被消除。(很类似硬币找零)
十、二分查找
35.搜索插入位置
这题是道简单的二分查找题,注意几个边界就可以了:
- 如果是空数组,插到第一个位置上。
- 如果数组中所有元素都比target要小,那么要插到数组的最后一个,即需要增大数组长度。
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
size = len(nums)
if size == 0:
return 0
if nums[size - 1] < target:
return size
left = 0
right = size - 1
while left < right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
else:
right = mid
return left
33. 搜索旋转排序数组
这题什么意思呢,就是说如果是在有序数组中进行二分查找,那时间复杂度肯定是
O
(
l
o
g
n
)
O(\mathrm{log} n)
O(logn)的,现在这个数组相当于由一个有序数组进行切片分成了两部分,重新进行组合,要在这个数组中进行查找,使得时间复杂度同样为
O
(
l
o
g
n
)
O(\mathrm{log} n)
O(logn)。
那么解题思路是什么样:
仍然使用二分法,将数组一分为二,会拆成两个子数组,通常情况下,其中一个数组是有序的,另一个数组是部分有序的(中间截断),我们在有序数组内部使用二分法进行查找,在另一个数组内部再次调用该方法,判断中间值的大小。
def search(nums, target):
if not nums:
return -1
left = 0
right = len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
if nums[mid] < nums[right]: ##表明右边是有序数组
if nums[mid] < target <= nums[right]: ##并且判断target是否在右侧中
left = mid + 1
else:
right = mid
else: #否则就是左侧是有序数组
if nums[left] <= target < nums[mid]:
right = mid
else:
left = mid + 1
if nums[left] == target:
return left
else:
return -1
34.在排序数组中查找元素的第一个和最后一个位置
关于二分查找问题,有篇文章的细节讲的很清楚,这里贴一下:二分查找细节。参考这篇回答,我们可以搞清楚二分查找里while
语句控制的条件到底什么时候结束,以及每次循环时left, right
到底该如何赋值。其实这些细节都与我们所选择的查找区间有关。
比方说最基本的二分查找,在一个有序数组中查找一个数,代码应该为:
def binarySearch(nums, target):
left = 0
right = len(nums) - 1
#二分查找开始,注意结束条件应该是left == right + 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
if nums[mid] > target:
right = mid - 1
elif nums[mid] < target:
left = mid + 1
# 如果当查找结束的时候都还没有返回值,就说明不存在目标值
return -1
这里有几个细节:
left, right = 0, len(nums) -1
表明我们查找的始终是闭区间- 循环结束的条件是
left > right
,即left == right + 1
。试想如果left == right
,由于我们查找的是闭区间,所以就是查找区间[left,right]
,这个区间是能包含唯一一个数的,所以是符合查找条件的。 - 当
nums[mid] > target
时,下一个right = mid - 1
,这是因为nums[mid]
已经排除了不属于目标值,基于闭区间查找的原理,我们下一个区间里要剔除这个值。
那么我们把二分查找法泛化一下,假设现在需要查找有序数组某个可能重复数字的左边界,即第一个出现该数的下标:
def left_bound(nums, target):
left = 0
right = len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
elif nums[mid] == target:
right = mid - 1
## 检查出界情况
if left >= len(nums) or nums[left] != target:
return -1
return left
这里又有几个细节:
- 停止条件是
left == right + 1
,这是由闭区间搜索得到的 - 如果
nums[mid] == target
,那么要做的步骤是缩减右区间right = mid - 1
,从而逼近左边界值 - 如果目标值小于数组中的所有值或者目标值在第一个,那么最终
right = -1, left = 0
,于是只需判断nums[left]
是否为目标值,就可以判断是输出left
还是-1
了。 - 如果目标值大于数组中所有值,那么最终
left
会超过数组长度(越界),如果目标值就是数组中最后一个值,那么此时left
会停在最后一个,可以正常输出。
相似地,我们可以写出判断右边界的代码:
def right_bound(nums, target):
left = 0
right = len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
elif num[mid] > target:
right = mid - 1
elif nums[mid] == target:
left = mid + 1
if right < 0 or nums[right] != target:
return -1
return right
所以这道找到上边界和下边界的题就可以通过调用上述函数来解决了:
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
if not nums:
return [-1,-1]
return [self.left_bound(nums, target), self.right_bound(nums, target)]
def left_bound(self, nums, target):
left = 0
right = len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
elif nums[mid] == target:
right = mid - 1
## 检查出界情况
if left >= len(nums) or nums[left] != target:
return -1
return left
def right_bound(self, nums, target):
left = 0
right = len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
elif nums[mid] == target:
left = mid + 1
if right < 0 or nums[right] != target:
return -1
return right
这个方法很高效,击败了97.58%的用户:
74. 搜索二维数组
这题只不过是二分查找套了一个数组的壳子,实际上如果把这个矩阵拉直,那么就是一个已经排好序的数组,所以可以利用二分查找。主要难点是确定二分查找的中间值对应于矩阵中的哪个格子。
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
if not matrix:
return False
rowNum = len(matrix)
colNum = len(matrix[0])
left = 0
right = rowNum * colNum - 1
while left <= right:
mid = (left + right) // 2
#确定这个中间位置在矩阵的位置
row = mid // colNum
col = mid % colNum
if matrix[row][col] == target:
return True
if matrix[row][col] > target:
right = mid - 1
if matrix[row][col] < target:
left = mid + 1
return False
十一、字符串、数组
49.字母异位词分组
这题思路很简单:创建一个字典,key对应的是排序后的单词,value对应的是原始单词。
def groupAnagrams(strs):
dic = {}
for i in strs:
temp = "".join(sorted(i))
if temp not in dic.keys():
dic[temp] = [i]
else:
dict[temp].append(i)
return list(dic.values())
代码里有几个需要注意的地方:
sorted(str)
返回的是一个数组dic[temp] = [i]
,因为我们要创建的字典值是数组的集合return lis()
,因为要利用返回的dict.values()
创建一个列表。
54.螺旋矩阵
这里借鉴leetcode用户@YouLookDeliciousC的思路:
通过设置边界,每次按照上、右、下、左边界的顺序,在每个边界上进行读数,每次读完之后移动边界,而且恰好边界的序号表示了该边界包含的元素个数。
class Solution:
def spiralOrder(self, matrix: List[List[int]]) -> List[int]:
size = len(matrix)
if size == 0:
return []
ans = []
u = 0
d = size - 1
l = 0
r = len(matrix[0]) - 1
while True:
# 遍历上边界,上边界的终点是右边界
for i in range(l, r + 1):
ans.append(matrix[u][i])
u += 1
# 判断是否越界
if u > d:
break
# 遍历右边界,右边界的终点是下边界
for i in range(u, d + 1):
ans.append(matrix[i][r])
r -= 1
if r < l:
break
# 遍历下边界,下边界的终点是左边界
for i in range(r, l - 1, -1):
ans.append(matrix[d][i])
d -= 1
if d < u:
break
# 遍历左边界,左边界的终点是上边界
for i in range(d, u - 1, -1):
ans.append(matrix[i][l])
l += 1
if l > r:
break
return ans
56.合并区间
分析思路:
首先按照每个区间的左端点对区间进行排序,设置答案为数组ans
,然后依次遍历:
对于第一个区间,直接放进ans
对于后面的区间,如果该区间的左端点小于当前数组中最后一个元素的右端点,就对最后一个元素进行修改,修改方式是比较两个区间的右端点大小。
如果后面区间的左端点大于当前数组中最后一个元素的右端点,就把这个区间再放到 ans
的末尾。
class Solution:
def merge(self, intervals: List[List[int]]) -> List[List[int]]:
intervals.sort(key = lambda x:x[0])
ans = []
for interval in intervals:
if not ans or ans[-1][1] < interval[0]:
ans.append(interval)
else:
ans[-1][1] = max(ans[-1][1], interval[1])
return ans
59.螺旋矩阵②
这题思路和54题完全类似,变动就在于需要明确每一次填入的数字是多少,这里出现了一个思路上的错误,刚开始写成了for循环:
class Solution:
def generateMatrix(self, n: int) -> List[List[int]]:
ans = [[0 for _ in range(n)] for _ in range(n)]
u = 0
d = n - 1
l = 0
r = n - 1
for k in range(1, n + 1):
cnt = 1
for i in range(l,r+1):
ans[u][i] = cnt * k
cnt += 1
u += 1
if u > d:
break
for i in range(u, d+1):
ans[i][r] = cnt * k
cnt += 1
r -= 1
if r < l:
break
for i in range(r, l - 1, -1):
ans[d][i] = cnt * k
cnt += 1
d -= 1
if d > u:
break
for i in range(d, u - 1, -1):
ans[i][l] = cnt * k
cnt += 1
l += 1
if l > r - 1:
break
return ans
本来的设想是,如果填入
n
2
n^2
n2个数,那么应该可以分成
n
n
n组,每组都是以
n
n
n的倍数结尾,于是就写成了for
循环,但仔细看看这个程序,
k
k
k走完一个循环是什么时候?是整个上左下右都走遍的时候。而这显然跟我们预期的不一样,所以应该改成while
循环,直接控制最后输出的数。
class Solution:
def generateMatrix(self, n: int) -> [[int]]:
l, r, t, b = 0, n - 1, 0, n - 1
mat = [[0 for _ in range(n)] for _ in range(n)]
num, tar = 1, n * n
while num <= tar:
for i in range(l, r + 1): # left to right
mat[t][i] = num
num += 1
t += 1
for i in range(t, b + 1): # top to bottom
mat[i][r] = num
num += 1
r -= 1
for i in range(r, l - 1, -1): # right to left
mat[b][i] = num
num += 1
b -= 1
for i in range(b, t - 1, -1): # bottom to top
mat[i][l] = num
num += 1
l += 1
return mat
66.加一
这题其实不难,主要要先分清楚几种边界情况:
- 末尾不涉及到进位,这很简单,直接末位数字+1然后返回数组就行了
- 末位数字是9,这时把末位数字设为0,然后继续第一步
- 如果数组中所有数组都变成0,即对应999这种特殊情况,需要在数组头部加上1
class Solution:
def plusOne(self, digits: List[int]) -> List[int]:
size = len(digits)
for i in range(size - 1, -1, -1):
if digits[i] != 9:
digits[i] += 1
return digits
else:
digits[i] = 0
if digits[0] == 0:
digits.insert(0,1)
return digits