Table of Contents
数组
二分法
题目中有"有序数组",就可以考虑采用「二分法」
一般二分法可以将线性复杂度O(n)降低为O(logn)
二分查找法中边界条件
- 左闭右闭 [left,right]
left, right = 0, len(nums)-1
while left <= right:
mid = (left+right) // 2
# 根据具体题目写条件
if ...: # 当最终结果在左区间 [left, mid-1]
right = mid - 1
else: # 当最终结果在右区间 [mid+1,right]
left = mid + 1
return ... # 根据具体题目的要求返回
- 左闭右开 [left,right)
left, right = 0, len(nums)
while left < right:
mid = (left+right) // 2
# 根据具体题目写条件
if ...: # 当最终结果在左区间 [left, mid)
right = mid
else: # 当最终结果在右区间 [mid+1,right)
left = mid + 1
return ... # 根据具体题目的要求返回
1. 搜索插入位置
- 左闭右闭 [left,right]
def search1(nums):
left, right = 0, len(nums)-1 # 定义[left,right]闭区间
while left <= right: # 当left == right的时候,[left, right]成立
mid = (left+right) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
left = mid + 1 # 此时target在右区间,即在区间[mid+1,right]中
else:
right = mid - 1 # 此时target在左区间,即在区间[left,mid-1]中
return right + 1
- 左闭右开 [left,right)
def search2(nums):
left, right = 0, len(nums) # 定义在[left,right)左闭右开区间内
while left < right: # 当left == right的时候,[left,right)不成立
mid = (left+right) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
left = mid + 1 # 此时target在右区间,即在区间[mid+1,right)中
else:
right = mid # 此时target在左区间,即在区间[left,mid)中
return right
2. 在排序数组中查找元素的第一个和最后一个位置
思路
- 第一个位置:数组中第一个>=target的下标
- 最后一个位置:数组中第一个>=target+1的下标
因此,该问题转化为在一个有序数组中,寻找第一个>=某个target的元素下标问题。「在有序数组中找某个值=>二分法」
# 在nums中寻找第一个>=target的元素下标
def searchGeq(nums,target):
left, right = 0, len(nums)-1
while left <= right:
mid = (left+right) // 2
if nums[mid] >= target: # 由于是查找第一个满足条件的,因此当nums[mid] == target的时候,需要进一步往左判断,也就是>=target落在[left,mid-1]
right = mid - 1
else:
left = mid + 1
return left # 于是查找第一个满足条件的,因此关心的是最左端的取值,也就是left.
# 寻找第一个和最后一个位置
def searchRange(nums,target):
left = searchGeq(nums,target)
if left >= len(nums) or nums[left] != target: # 当最左端的下标不存在或得到的不是target,说明target不存在于nums
return [-1,-1]
# 当最左端下标存在,说明最右端下标一定存在
right = searchGeq(nums,target+1)
return [left,right-1]
3. 寻找两个有序数组中的第K个数
思路
假设这两个有序数组分别为nums1, nums2.它们的长度分别为m,n.
由于为有序数组,因此想到「二分法」。但由于这边有两个有序数组。首先对两个数组分别切半处理。则有以下几种情况:
-
nums1[m//2] < nums2[n//2] # 此时nums1[0:m//2]一定在nums2[n//2]的左侧
- m//2 + n//2 > k # 第K小数在num1,nums2的前半部分
- 此时可以抛弃nums2后半部分,因为它们必定比K大
- m//2 + n//2 <= k # 第K小数在num1,nums2的后半部分
- 此时可以抛弃nums1前半部分,因为它们必定是前K-1小的数。也就是已经找到前m//2个比K小的数
- m//2 + n//2 > k # 第K小数在num1,nums2的前半部分
-
nums1[m//2] >= nums2[n//2] # 与上述情况类似,只是相当于讲nums1和nums2交换了。
# time: log(m+n)
def FindKthElm(a, b, k):
if len(a) == 0:
return b[k-1]
if len(b) == 0:
return a[k-1]
a_mid = len(a) // 2
b_mid = len(b) // 2
half_len = a_mid + b_mid + 2 # a,b数组前半部分(包括Mid)的大小
if a[a_mid] < b[b_mid]:
if half_len > k:
# K(K指的是第k大的数)这个数在合并数组内
# 因为b[b_mid]必定是合并数组中最大的那个,那么b[b_mid]一定比K的数大
# 所以b[b_mid~end]的数就不用搜索了,因为它们必定比K大
return FindKthElm(a[:], b[:b_mid], k)
else:
# 此时在合并的数组中a[:a_mid+1]元素一定在b[b_mid]的左侧,
# 所以前K个元素中一定包含A[:a_mid+1](可以使用反证法来证明这点)
# 但是无法判断A[a_mid+1:]与B[:]之间的关系,需要对他们进行判断
return FindKthElm(a[a_mid+1:], b[:], k-(a_mid+1))
else:
if half_len > k:
# 同上
return FindKthElm(a[:a_mid], b[:], k)
else:
return FindKthElm(a[:], b[b_mid+1:], k-(b_mid+1))
3.1 寻找两个正序数组的中位数
该题是在「寻找两个有序数组中的第K个数」基础上,外加了一个考虑点,也就是根据两数组总长的奇偶性,求解中位数的方法有些不同。可根据上面这个问题改编得到,因此时间复杂度还是O(log(m+n)):
- 当两数组的总长为偶数的时候
- 寻找第K小和第K+1小的数,然后再求平均。其中K=totol_length // 2
- 当两数组的总长为奇数的时候
- 寻找第K小的数,其中K= totol_length // 2+1
输入:matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]
输出:[1,2,3,4,8,12,11,10,9,5,6,7]
“向左,向下,向右,向上”循环,直到所有元素都被遍历到,因此时间复杂度为O(mn)。
- 当到达最左端未被遍历边界,进行“向下”遍历
- 当到达最下端未被遍历边界,进行“向右”遍历
- 当到达最右端未被遍历边界,进行“向上”遍历
- 当到达最上端未被遍历边界,进行“向左”遍历
为了记录是否被遍历,需要额外的一个 m × n m\times n m×n列表来记录。因此此时的空间复杂度为O(mn)
def printSpiralOrder(matrix):
# 空值判断,当为一维空矩阵或为二维空矩阵的时候,都返回空列表。
if not matrix or not matrix[0]:
return []
rows, cols = len(matrix), len(matrix[0])
visited = [[False] * cols for _ in range(rows)] # 用于记录是否被遍历
res = [] # 记录所有遍历结果
directions = [[0,1],[1,0],[0,-1],[-1,0]] # 定义“向左,向下,向右,向上”在坐标上的表现形式,如向左:行不变,列+1.
row, col = 0, 0 # 定义起始坐标点位置
directionIndex = 0 # 定义起始遍历方向
for i in range(rows*cols):
res.append(matrix[row][col])
visited[row][col] = True
# 得到下一个遍历元素,用于预判,是否能到达
nextRow, nextCol = row + directions[directionIndex][0], col + directions[directionIndex][1]
# 如果遇到边界:行到边界,或列到边界,或已被遍历,则进行根据下一种遍历方向遍历
if not (0<=nextRow<rows and 0<=nextCol<cols and not visited[nextRow][nextCol]):
directionIndex = (directionIndex + 1) % 4
row += directions[directionIndex][0]
col += directions[directionIndex][1]
return res
双指针法
双指针法一般也被称为快慢指针,通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。因此时间复杂度可以从暴力法为O( n 2 n^2 n2)降为O(n)。
1. 原地移除元素
1.1 移除元素
思路
- 若没有遇到需要移除的元素
- 快慢指针同步
- 当遇到需要移除的元素
- 快指针发挥作用,比慢指针多走一步。
接着最终需要输出移除元素后的数组长度,由于当遇到的不是需要移除的元素的时候快慢指针是同步的,因此慢指针遍历过的都是需要保留的元素,因此最终得到的数组长度就是「慢指针所在位置」
def moveEle(nums, target):
slow = fast = 0
while fast < len(nums):
if nums[fast] != target: # 没有遇到需要移除的元素,保持同步
nums[slow] = nums[fast]
slow += 1
fast += 1
else:
fast += 1
return slow
1.2 比较含退格的字符串
思路
- 可将该题目转换为,移除’#'以及其前面的元素。
- 与「移除元素」所不同的是,遇到’#'后需要继续删除前面一个元素,也就是慢指针得退回去一步。
def delete(s):
slow = fast = 0
while fast < len(s):
if s[fast] != '#':
s[slow] = s[fast]
slow += 1
fast += 1
else:
slow -= 1 if slow > 0 else slow # ‘#'前面的元素也得删除
fast += 1
return s[:slow]
def compare(s, t):
s = list(s)
t = list(t)
return delete(s) == delete(t)
1.3 移动零
思路
该题目与「移除元素」所不同的是,需要保留该元素0。因此在做「移动」的时候,我们选择做「交换」。
def moveZero(nums):
slow = fast = 0
while fast < len(nums):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
fast += 1
else:
fast += 1
return nums
1.4 删除有序数组中的重复项
思路
该题目与「移除元素」所不同的是,需要保留第一个元素,因此我们可以将快慢指针从第二个元素开始遍历,然后按照「移除元素」方法,其中条件为是否与前一个元素相同。
def moveDuplicate(nums):
if len(nums) == 0:
return 0
fast = slow = 1
while fast < len(nums):
if nums[fast] != nums[fast-1]:
nums[slow] = nums[fast]
slow += 1
fast += 1
else:
fast += 1
return slow
2. 三数之和
思路
我们可以在「两数之和」基础上,先指定一个三数中最小的,然后在该数后半部分数组中,利用「两数之和」的方法寻找另两个数。
- 先将数组从小到大排序,时间复杂度为O(nlogn)
- 第一层for循环,遍历最多n-3次
- 令当前元素nums[i]为三数中最小的
- 如果nums[i] > target,因为连最小的数都大于target,如果在加比target大的数,只会比target更大。
- return []
- 判断当前元素nums[i]是否与nums[i-1]相同
- 如果相同,则跳过。因为题目要求不能有重复组合。
- 为什么是和它前面的比,而不是和后面一个比?因为当前面一个已经被取为三数之一,则nums[i:]其实已经被遍历过,符合的都已经被记录过了,如果不跳过,则会重新遍历nums[i+1:],一方面会有重合的,另一方面得到的结果会重复。
- 如果相同,则跳过。因为题目要求不能有重复组合。
- 如果nums[i] > target,因为连最小的数都大于target,如果在加比target大的数,只会比target更大。
- 令当前元素nums[i]为三数中最小的
- 第二层for循环,由于使用的是双指针,最多需要遍历n-i-1次
- 在nums[i+1:]中寻找另外两个和为target-nums[i]
因此时间复杂度为 max \max max{O(nlogn), O ( n 2 ) O(n^2) O(n2)}= O ( n 2 ) O(n^2) O(n2)
def twoSum(nums,target):
nums.sort()
slow, fast = 0, len(nums)-1
res = []
while slow < fast:
cur_s = nums[slow] + nums[fast]
if cur_s == target:
res.append([nums[slow],nums[fast]])
## 防止重复
while slow < fast and nums[slow] == nums[slow+1]:
slow += 1
while slow < fast and nums[fast] == nums[fast-1]:
fast -= 1
slow += 1
fast -= 1
elif cur_s > target:
fast -= 1
else:
slow += 1
return res
def threeSum(nums,target):
nums.sort()
ans = []
for i in range(len(nums)):
if nums[i] > target:
break
if i > 0 and nums[i] == nums[i-1]:
continue
ans += [[nums[i]] + res for res in twoSum(nums[i+1:],target-nums[i])]
return ans
3. 回文子串
思路
根据回文串的特征可以知道,回文串是中心对称的,因此我们可以利用双指针,从中心出发,分别向两边扩散。
- 如果发现不对称(也就是两个指针对应的字符不一致)
- 判定它不是回文串
- 如果对称
- 判定它为回文串
但中心分为两种:
- 一个中心点(也就是该回文串的长度是奇数的)
- 两个指针初始值相同
- 两个中心点(也就是该回文串的长度是偶数的)
- 两个指针初始值不同,而是+1的关系
# 从中心点向两边扩散
def extend(s, center1, center2):
res = []
while center1 >= 0 and center2 < len(s) and s[center1] == s[center2]: # 当两指针对应的字符相同的时候,说明此时两指针中间部分属于回文串,另外继续同时向两边扩散
res.append(s[center1:center2+1])
center1 -= 1
center2 += 1
# 寻找所有的回文子串
def findpalSubstring(s):
res = []
for i in range(len(s)):
res.append(extend(s,i,i))
res.append(extend(s,i,i+1))
return res
4. 最长回文子串
思路
与「回文子串」的不同在于,需要检验每个子串的长度。
class solution:
self.max_len = 0
self.left = self.right = 0
def extend(self, s, c1, c2):
while c1 >= 0 and c2 <= len(s) and s[c1] == s[c2]:
if c2 - c1 + 1 > max_len:
self.left = c1
self.right = c2
self.max_len = c2 - c1 + 1
c1 -= 1
c2 += 1
def getMaxLen(self, s):
for i in range(len(s)):
self.extend(s,i,i)
self.extend(s,i,i+1)
return s[self.left:self.right+1]
滑动窗口
时间复杂度可以从暴力法为O( n 2 n^2 n2)降为O(n)。
思考
- 定义什么是「合法窗口」
- 考虑什么情况下需要移动窗口,即收缩窗口。也就是不符合合法窗口的条件
- 弄清楚返回值是什么,窗口大小?窗口内元素?最大窗口?最小窗口?
1. 长度最小的子数组
给定一个含有 n 个正整数的数组和一个正整数 target 。找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, …, numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
思路
与「三数之和」所不同的是,在不知道长度的情况下,需要对任意可能的子数组进行遍历判断。
该题中的合法窗口是:窗口内的所有数之和>=target
-
右指针总是指向合法窗口的最右端元素
-
左指针总是指向合法窗口的最左端元素
-
需要移动窗口的情况
- 当窗口内的数的总和 < target,此时扩大窗口(右边界向右移动)
- 将最右端的加入总和
- 右指针+1
- 当窗口内的数的总和 >= target,此时减小窗口(左边界向右移动)
- 将最左端的从总和中减去
- 左指针+1
- 当窗口内的数的总和 < target,此时扩大窗口(右边界向右移动)
def minSubstring(nums, target):
if not nums:
return 0
ans = 0
min_len = len(nums) + 1
left = right = 0
while right < len(nums):
ans += nums[right]
while ans >= target:
min_len = min(min_len, right - left + 1)
ans -= nums[left]
left += 1
right += 1
return 0 if min_len == len(nums) + 1 else min_len
2. 水果成篮
思路
问题等价于,找到最长的子序列,最多含有两种“类型”。由于需要寻找不同长度的子序列,因此考虑使用滑动窗口。与「长度最小的子数组」的区别是right指针指的是合法窗口后一个元素。
该题中的合法窗口是:窗口内元素类别不大于2
- 右指针总是指向合法窗口后一个元素
- 左指针总是指向合法窗口的最左端元素
- 需要移动窗口的情况有
- 当哈希表的大小为3的时候,需要减少窗口(左边界向右移动)
- 当哈希表大小不超过3的时候,可以增加(不变)窗口(右边界向右移动)
另外:
- 由于需要记录每种类型的count,因此考虑用哈希列表dict来存储
def getTotal(fruits):
hashMap = {}
left = right = 0
max_len = 0
while right < len(fruits):
hashMap[fruit[right]] = hashMap.get(fruit[right],0) + 1
while left < len(fruits) and len(hashMap) == 3:
max_len = max(max_len, right - left)
hashMap[fruit[left]] -= 1
if hashMap[fruit[left]] == 0:
del hashMap[fruit[left]] # 如果当前水果数量为0,将该种类水果剔除
left += 1
right += 1
max_len = max(max_len, right - left)
return max_len
3. 摘水果
与「水果成篮」的区别是,该题目有左右两个方向可以走,因此需要进行滑动窗口的情况为:
本题中的合法窗口可以被定义为:从startPos到窗口内各位置的最短路径总和不大于k.
移动窗口的情况为:
- 从startPos出发,到left后再到right(或到right后再到left),总共走过的步数 > k:此时需要减小窗口
- 通过数轴直观的看到,判断是先到left还是先到right。为了尽可能减少步数,由于从left->right和right->left是一致的,因此主要考虑min(startPos->left, startPos->right)
def maxTotalFruits(fruits, startPos, k):
total = 0
ans = 0
left = right = 0
while right < len(fruits):
total += fruits[right][1]
while left <= right and min(abs(startPos-fruits[right][0]),abs(startPos-fruits[left][0]))+fruits[right][0]-fruits[left][0] > k:
total -= fruits[left][1]
left += 1
ans = max(ans,total)
right += 1
return ans
4. 最小覆盖子串
思路
该题与「水果成篮」类似,需要统计字符数量,并且这边有两个字符串,因此需要引入两个哈希表,来存储每个字符串中字符出现次数。
另外本题中合法窗口可以定义为:窗口内包含目标字符串,且窗口最左端的字符在目标字符串中
移动窗口的情况为:
- 窗口最左端的字符是多余的(也就是窗口最左端的字符不在目标字符串内,或窗口最左端的字符出现频次多余该字符在目标字符串中的频次)
def minWin(s,t):
t_map = {}
for i in t:
t_map[i] = t_map.get(i,0)+1
s_map = {}
left = right = 0
cnt = 0 # 用于记录匹配字符数
min_str = '' # 用于记录合法窗口内的内容
while right < len(s):
s_map[s[right]] = s_map.get(s[right],0) + 1
if s_map[s[right]] <= t_map.get(s[right],0): # 若匹配目标字符串的字符,则将其纳入匹配字符数
cnt += 1
# 收缩窗口,提出多余的
while left <= right and s_map[s[left]] > t_map.get(s[left],0):
s_map[s[left]] -= 1
left += 1
# 如果得到合法窗口,则更新合法窗口内容
if cnt == len(t):
if not min_str or right - left + 1 < len(min_str):
min_str = s[left:right+1]
right += 1
return min_str
链表是否有环
当慢指针追上快指针了,说明有环。
def hasCycle(self, head: ListNode) -> bool:
slow = head
fast = head
while True:
if fast is None or fast.next is None: # 此时说明有尽头,因此没有环
return False
slow = slow.next
fast = fast.next.next
if slow == fast:
return True