相信许多正在为算法面试做着准备刷着题的程序员都会有类似的焦虑:我刷够题了吗?还要再来点吗?到底刷到什么程度才够呢?
刷题究竟应该怎么刷?
- 刷题绝不是死记硬背。
- LeetCode 上总共近 1700 道题,这看起来很恐怖,实际上很多问题本质上是很类似的,不过是做了一些小的变化。99%不敢说,至少90%的算法题,对应的解题模式不外乎那十多种常见的套路。
- 我们应该通过若干道题来总结掌握一个通用的解题模式,然后举一反三,去解决一批问题。
这边对常见的解题模式(套路),分析了 相关问题如何进行识别,给出了 具体的模板,同时每个模式都列出了 若干经典题和高频题,在实战中加深理解。
P.S. 本文为个人刷题心得总结,如有问题,欢迎交流探讨
Binary Search(二分查找,二分法)
问题特点
- 问题的输入为 某种意义上有序 的 数组【比如排序数组,旋转排序数组,或者对于某个条件满足OOXX排列的数组等等】
- 要求寻找 满足某个条件 的 特定位置 的项【比如最后一个小于 target 的数的位置,第一个大于等于 target 的数的位置】。
方法思路
二分法基本思路(基本原理)
二分法,也就是二分查找,用二分的方式去查找。
简单来讲,二分查找的 核心思路 就是:
- 给定一个“有序”数组
- 每次都取 中间项【
nums[mid]
】,进行判断
- 利用数组的有序性,在 O(1) 的时间内 将问题的规模缩小至一半(砍掉一半的项)【
left = mid
orright = mid
】 - 当 left 和 right 相遇,循环结束【
while left <= right: # 或者 left = right, left + 1 = right
】,查找完毕
换句话说,如果我们想用二分法的框架来解决一个问题,那么我们就需要 找到一个判断条件,我们可以通过这个判断条件,来确定目标项是位于左半段还是右半段
二分法的细节
- 中点的计算方式:最简单的方式是
mid = (left + right) / 2
,但这种方式可能会出现 整数越界,因此一般使用这种写法:mid = left + (right — left) / 2
【Python没有这种顾虑】
容易导致死循环的两个细节:
-
left 和 right 指针如何变化(要不要,能不能把 mid 给剔除掉?什么时候可以什么时候不行?)
- left = mid
- left = mid +1(把 mid 剔除掉)
-
循环结束条件(两指针相邻?重合?交叉?)
- while left <= right(两指针交叉)
- while left < right(两指针重合)
- while left + 1 < right(两指针相邻)
这边给出一个二分法的 通用模板。
所谓“通用”,就是指 在各种情形下都不会出问题(比如陷入死循环等),可以 放心大胆地,闭着眼睛去用,从而可以减少 思考的复杂度,让你可以把注意力集中在算法实现上。
通用代码模板
有三个关键点,
- 指针变化方式:left = mid
- 循环结束条件:while left + 1 < right(两指针相邻时退出循环)
这种写法的好处是 mid 无论如何都不会取到 left 和 right 上。因为 当 left 和 right 不相邻时,中间至少还隔着一个数,可以正常缩小问题规模,当 left 和 right 相邻时,就立刻退出循环 。 - 循环结束时 left 和 right 可能有两种情况,一种是 left 和 right 相邻,另一种是 left 和 right 重合(对应输入数组只有一项,直接跳过循环的情况),两种情况我们都可以当做 left 和 right 相邻去处理,即对
nums[left]
和nums[right]
分别进行判断(判断的先后顺序因问题而异)。
以 在排序数组中寻找target 为例
class Solution:
def search(self, nums: List[int], target: int) -> int:
# 检查(check)异常输入
if nums == []:
return -1
# 定义左指针和右指针
left = 0
right = len(nums) - 1
while left + 1 < right: # 当两指针相邻时退出循环
mid = (left + right) // 2 # 取中点进行判断
if nums[mid] == target: # 找到target
return mid # 直接return结果
elif nums[mid] < target: # target可能在右半段
left = mid # 砍掉左半段
else: # target < nums[mid],target可能在左半段
right = mid # 砍掉右半段
if nums[left] == target:
return left
elif nums[right] == target:
return right
else: # 目标不存在
return -1
这边简单列举了 几种查找情况下,nums[mid] == target
时应该如何分析和处理
- 查找第一个等于的数:等于时,左半段可能还有等于它的项,所以 砍掉右半段
- 查找第一个大于的数:等于时,大于它的只可能在右半段,所以 砍掉左半段
- 查找第一个大于等于的数:等于时,左半段可能还有等于它的项,所以 砍掉右半段
可以看到,第一个等于和第一个大于等于的处理方式是相同的,和第一个大于是相反的。
典型问题
① (简单) 目标最后位置 - Last Position of Target
class Solution:
"""
@param nums: An integer array sorted in ascrighting order
@param target: An integer
@return: An integer
"""
def lastPosition(self, nums, target):
if nums == []:
return -1
left = 0
right = len(nums) - 1
while left + 1 < right:
mid = (left + right) // 2
if nums[mid] <= target:
left = mid
else:
right = mid
# 因为是查找last position,所以先判断nums[right]
if nums[right] == target:
return right
elif nums[left] == target:
return left
else: # 目标不存在
return -1
② (中等) 在排序数组中查找元素的第一个和最后一个位置 - Find First and Last Position of Element in Sorted Array
# 用两次二分法,分别找到 first position 和 last position
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
if nums == []:
return [-1, -1]
# 查找目标的第一个位置
left ,right = 0, len(nums) - 1
while left + 1 < right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid
else:
right = mid
if nums[left] == target:
left_bound = left
elif nums[right] == target:
left_bound = right
else: # 目标不存在
return [-1, -1]
# 查找目标的最后一个位置
left ,right = left_bound, len(nums) - 1
while left + 1 < right:
mid = (left + right) // 2
if nums[mid] <= target:
left = mid
else:
right = mid
if nums[right] == target:
right_bound = right
elif nums[left] == target:
right_bound = left
return [left_bound, right_bound]
③ (中等) 统计比给定整数小的数的个数 - Count of Smaller Number
# 这个问题可以转换成:
# - 查找最后一个小于 target 的数字的位置,+ 1 即为答案
# - 反过来,也可以是查找第一个大于等于 target 的数字的位置。
class Solution:
"""
@param A: A list of integer
@return: The number of element in the array that
are smaller that the given integer
"""
def countOfSmallerNumber(self, A, queries):
A = sorted(A)
res = []
for query in queries:
res.appright(self.count_smaller(A, query))
return res
def count_smaller(self, A, query):
"""核心函数"""
left = 0
right = len(A) - 1
while left + 1 < right:
mid = (left + right) // 2
if A[mid] < query:
left = mid
else:
right = mid
if query <= A[left]:
return left
elif query <= A[right]:
return right
else:
return right + 1
④ (简单) x 的平方根 - Sqrt(x)
# 这个问题可以转换成:
# 查找 [0,x] 区间中,最后一个平方小于等于 x 的数
class Solution:
def mySqrt(self, x: int) -> int:
left = 0
right = x
while left + 1 < right:
mid = left + (right - left) // 2
if mid ** 2 <= x:
left = mid
else:
right = mid
if right ** 2 <= x:
return right
if left ** 2 <= x:
return left
return left - 1
④ (简单) 第一个错误的版本 - First Bad Version
# 寻找第一个 isBadVersion 为 True 的版本。
# 典型的给定一个 XXOO 的序列,寻找第一个 O 的位置的问题。
class Solution:
def firstBadVersion(self, n):
"""
:type n: int
:rtype: int
"""
left = 1
right = n
while left + 1 < right:
mid = left + (right - left) // 2
if isBadVersion(mid):
right = mid
else:
left = mid
if isBadVersion(left):
return left
elif isBadVersion(right):
return right
⑤ (简单) 山脉数组的峰顶索引 - Maximum Number in Mountain Sequence
根据 山脉数组的性质,nums[mid] 只可能有三种情况,如下图所示
# 查找nums[i],满足 nums[i-1] < nums[i] and nums[i] > nums[i+1]
class Solution:
def peakIndexInMountainArray(self, nums: List[int]) -> int:
left = 0
right = len(nums) - 1
while left + 1 < right:
mid = (left + right) // 2
if nums[mid] < nums[mid - 1]:
right = mid
elif nums[mid] < nums[mid + 1]: # nums[mid - 1] < nums[mid] < nums[nums + 1]
left = mid
else: # nums[mid - 1] < nums[mid] and nums[mid] > nums[nums + 1]
return mid
if nums[left] < nums[right]:
return right
else:
return left
⑥ (中等) 寻找旋转排序数组中的最小值 - Find Minimum in Rotated Sorted Array
旋转,也就是循环左移
思路一、暴力法
遍历数组,寻找最小值
思路二、二分法
对于旋转数组的题,数组中不存在重复元素这一条件非常重要,否则无法砍掉一半项(举个极端点的例子,111011111)
解决这个问题,需要 利用旋转数组 (Rotated Sorted Array) 的性质。
旋转数组大概是这样的形状
我们可以把最小点转换为 第一个小于等于 nums[-1] 的点
为什么是第一个小于等于 num[-1] 的点,而不能是第一个小于 nums[-1] 的点,或者是第一个小于 nums[0] 的点?
我们可以列举一下所有的特殊情况来验证:
- 如果数组循环左移了一个位置,此时最小点等于 nums[-1],不满足小于 nums[-1],此时最小点等效于第一个小于 nums[-1] 的点不成立
- 如果没有循环左移,此时最小点等于 nums[0],就不满足小于 nums[0] ,此时最小点等效于第一个小于 nums[0] 的点不成立
- 数组只有1个数的情况,此时最小点等于 nums[-1] = nums[0]
无论什么时候,最小点都是第一个小于等于 nums[-1] 的点。
这样一来其实就变成XXOO的形式了。
class Solution:
# @param nums: a rotated sorted array
# @return: the minimum number in the array
def findMin(self, nums):
if nums == []:
return -1
left = 0
right = len(nums) - 1
target = nums[-1]
while left + 1 < right:
mid = (left + right) // 2
if nums[mid] <= target:
right = mid
else:
left = mid
if nums[left] <= target:
return nums[left]
else:
return nums[right]
⑦ (中等) 搜索旋转排序数组 - Search in Rotated Sorted Array
思路一、一次二分
一次二分,先确定mid在前半段还是后半段,然后进一步判断 target 是否在剩余项的有序半边上
效率要更高一些
时间复杂度为 O(logn)
class Solution:
def search(self, nums: List[int], target: int) -> int:
if len(nums) == 0:
return -1
left, right = 0, len(nums) - 1
while left + 1 < right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
# nums[mid] != target
if nums[left] < nums[mid]: # mid落在前半段
if nums[left] <= target and target < nums[mid]: # target可能在有序的前半段
right = mid
else:
left = mid
else: # mid 落在后半段上
if nums[mid] < target <= nums[right]: # target可能在有序的后半段
left = mid
else:
right = mid
if nums[left] == target:
return left
if nums[right] == target:
return right
return -1
思路二、两次二分
两次二分,先用二分法找到分割点 (最小值点) ,再在有序一侧上用二分法查找目标值
容易思考和实现(思考复杂度比较低)
时间复杂度为 O(logn) + O(logn)
class Solution:
def search(self, nums: List[int], target: int) -> int:
if nums == []:
return -1
min_index = self.find_min_index(nums)
if nums[-1] < target: # 判断target可能在左半段还是右半段
return self.binary_search(nums, 0, min_index - 1, target)
return self.binary_search(nums, min_index, len(nums) - 1, target)
def find_min_index(self, nums):
left = 0
right = len(nums) - 1
target = nums[-1]
while left + 1 < right:
mid = (left + right) // 2
if nums[mid] <= target:
right = mid
else:
left = mid
if nums[left] <= target:
return left
return right
def binary_search(self, nums, left, right, target):
if left > right: # 输入为空数组
return -1
while left + 1 < right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid
else:
right = mid
if nums[left] == target:
return left
if nums[right] == target:
return right
return -1