leetcode之数组(二分法,双指针法,滑动窗口)

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小的数
  • 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:],一方面会有重合的,另一方面得到的结果会重复。
  • 第二层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
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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值