10.和为 K 的子数组
这一题条件反射想用滑动窗口了, 用滑动窗口能通过33/93
1、什么时候扩大窗口?当窗口内的元素和小于 k 时,就扩大窗口。
2、什么时候缩小窗口?当窗口内的元素和大于 k 时,就缩小窗口。
3、什么时候找到答案?当窗口内的元素和等于 k 时,就找到了答案。
看起来很合理,结合这个思路和滑动窗口代码模板,你应该五分钟之内就能写出解法。
但是我想说,这道题不能用滑动窗口,因为你忽略了一个隐含前提:
当窗口内的元素和小于 k 时,你为什么想扩大窗口?因为你默认扩大窗口能让窗口内的元素和变大。
同理,当窗口内的元素和大于 k 时,你为什么想缩小窗口?因为你默认缩小窗口能让窗口内的元素和变小。但其实缩小窗口不是一定窗口中元素和变小。滑动窗口需要滑动能符合想要的条件。
正数数组中,滑动窗口非常高效,能够保证窗口的扩展和收缩合理。
包含负数时,滑动窗口并非总是有效,可能需要根据具体情况调整算法
当数组中包含负数时,滑动窗口可以依然使用,但在某些情况下可能失效,或需要额外处理。
- 和:负数会打破滑动窗口递增或递减的规律。例如,当右边界扩展时,和可能因为负数反而减小;类似地,缩小左边界时,和可能不减反增。这种情况会使简单的滑动窗口策略不再适用。
处理负数的滑动窗口问题时,通常有几种解决策略:
- 更复杂的窗口调整策略:需要根据具体问题设置动态调整窗口的逻辑,而不是单纯依赖右边扩展、左边收缩。
- 借助其他算法:当问题中包含负数时,滑动窗口可能不再是最佳选择,通常可以结合前缀和、动态规划等其他算法来解决。也就是元素有负的话就想想别的办法吧。
简单介绍前缀和数组
前缀和数组的长度为 n + 1
是因为它包括了从原始数组的起始位置到每个位置的和,以及一个额外的位置用于简化计算。这里的 n
是原始数组的长度。让我们详细解释一下这个设计。
为什么前缀和长度为 n + 1
- 额外的初始位置:
- 前缀和数组的第一个位置
prefix_sum[0]
通常被设置为0
。这表示空数组的和。 - 这额外的位置
prefix_sum[0]
的存在,使得计算任何从数组开头到某个位置的子数组和变得更简单。
- 前缀和数组的第一个位置
- 简化子数组和的计算:
- 使用前缀和数组,你可以快速计算从数组的任意起始位置到某个位置的子数组和。
- 如果没有这个额外的位置
prefix_sum[0]
,你将无法在常数时间内计算从数组开头的和,而需要额外的处理。
前缀和(Prefix Sum)是一种高效计算子数组或子字符串和的技术。它通过在数组的开头预计算前缀和来减少查询子数组和时的时间复杂度。这个方法非常适用于多个查询操作或需要快速计算子数组和的场景。
什么是前缀和
前缀和 是一个数组的前缀和数组,其中每个元素表示从数组开始到当前位置的所有元素之和。前缀和数组 prefix_sum
可以通过以下公式构建:
prefix_sum[i] = nums[0] + nums[1] + ... + nums[i]
如何计算前缀和
给定一个数组 nums
,你可以通过以下步骤计算前缀和数组 prefix_sum
:
-
初始化:
- 创建一个长度为
n+1
的前缀和数组,其中n
是原数组nums
的长度。 - 设定
prefix_sum[0]
为0
,表示空数组的和。
- 创建一个长度为
-
构建前缀和数组:
- 遍历原数组
nums
,逐步计算前缀和,并填入prefix_sum
中。
- 遍历原数组
def compute_prefix_sum(nums):
n = len(nums)
prefix_sum = [0] * (n + 1) # 前缀和数组,长度为 n+1
for i in range(n):
prefix_sum[i + 1] = prefix_sum[i] + nums[i]
return prefix_sum
前缀和的优势
-
快速查询:
- 一旦构建了前缀和数组,可以在常数时间内计算任意子数组的和。子数组
nums[i:j+1]
的和可以通过prefix_sum[j+1] - prefix_sum[i]
快速计算。 - 这个过程只需要 O(1) 时间复杂度。
- 一旦构建了前缀和数组,可以在常数时间内计算任意子数组的和。子数组
-
时间复杂度低:
- 构建前缀和数组的时间复杂度是 O(n),其中
n
是原数组的长度。 - 由于查询子数组和的时间复杂度是 O(1),因此在需要进行多次查询的情况下,前缀和非常高效。
- 构建前缀和数组的时间复杂度是 O(n),其中
前缀和都定义s[0] = 0,这样就不需要特别判断left = 0 的情况了,所以前缀和数组长度为n + 1
虽然前缀和数组的构建确实需要 O(n) 的时间复杂度,但在实际应用中,这种方法的优势主要体现在以下几个方面:
优势与特点
- 快速查询:
- 一旦前缀和数组构建完成,可以在 O(1) 的时间复杂度内计算任何子数组的和。这对于需要频繁查询子数组和的场景非常高效。
- 适合多次查询:
- 对于需要处理多个子数组和查询的情况,前缀和方法非常高效。构建前缀和数组的 O(n) 复杂度只需进行一次,之后的查询操作是常数时间复杂度 O(1),从而大幅度提高了整体效率。
- 简化问题:
- 前缀和方法将问题转化为一个简单的查找操作。特别是在需要处理不同长度的子数组和时,前缀和可以简化计算过程,不需要动态调整窗口或复杂的操作。
前缀和方法的主要优势在于它的查询效率。在构建前缀和数组后,能够在常数时间内进行子数组和的查询,这对于需要多次查询或处理大数据集时是非常有利的。虽然构建前缀和需要 O(n) 的时间复杂度,但这通常是一次性操作,后续的查询操作效率很高,因此在处理复杂问题时具有明显的优势。
class Solution:#这一题范围很nums[i]范围可能为负数,所以不适用于滑动窗口,只能通过一部分
def subarraySum(self, nums: List[int], k: int) -> int:
n = len(nums)
preSum = [0] * (n + 1)
preSum[0] = 0
#前缀和到该前缀和出现次数的映射,哈希集合,方便快速查找所需要的前缀和
count = {0: 1}
res = 0
for i in range(1, n + 1):
preSum[i] = preSum[i - 1] + nums[i - 1]
#如果之前存在值为need的前缀和,说明存在以num[i - 1]结尾的子数组的和为k
#也就是如果之前有need,之前某个j,preSum[j] = preSum[i] - k 由前缀和做差,可知存在子数组[j + 1, i]满足和为k
need = preSum[i] - k
if need in count:
res += count[need]
#将当前前缀和存入哈希表
if preSum[i] not in count:
count[preSum[i]] = 1
else:
count[preSum[i]] = count[preSum[i]] + 1
return res
精妙之处:
使用哈希表快速查找所需的前缀和
- 为了在 O(1) 时间内找到符合条件的前缀和,代码利用了一个哈希表
count
,将已经计算出的前缀和存储在哈希表中,并记录每个前缀和出现的次数。 - 具体而言,
count
的键是前缀和,值是该前缀和出现的次数。在遍历过程中,每当计算出新的前缀和时,就检查是否存在满足条件的前缀和need = preSum[i] - k
。如果存在,说明有一个或多个子数组的和等于k
,对应的子数组个数就是哈希表中need
的值,即count[need]
。
前缀和的动态更新与哈希表的记录
- 每次遍历时,当前的前缀和
preSum[i]
计算出来后,立即更新哈希表中该前缀和出现的次数。如果该前缀和之前没有出现过,则在哈希表中初始化为 1;如果之前已经出现过,则次数加 1。 - 通过这种动态更新和查找,可以确保每个前缀和只需要计算和存储一次,大大减少了重复计算。
解决负数和子数组的问题
题目中数组 nums
可能包含负数,这使得常规的滑动窗口方法不适用(滑动窗口一般用于处理数组全为正数的情况)。然而,前缀和的方式能够处理负数的情况,因为它通过前缀和之间的差计算子数组和,不受元素正负性的影响。这也是这道题中前缀和方法的一个精妙之处。
11.滑动窗口最大值
写题目从暴力法入手,暴力解法:这是最直接的解法,往往基于对问题的直观理解。它通常不考虑优化,只关注“如何实现”。暴力解法的好处是能够快速验证思路的正确性,也为后续优化提供了基础。
1.暴力解法,能通过37/51
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
res = []
# 从 0 到 n - k,逐个计算滑动窗口内的最大值,窗口开端从i = 0 , 移动到n - k。一开始就有一个窗口,共有n - k + 1个窗口
for i in range(n - k + 1):
# 找到 nums[i:i+k] 中的最大值
window_max = max(nums[i:i + k])#这样切片大小是k
res.append(window_max)
return res
2.单调队列解法
双端队列(Deque, Double-ended Queue)是一种可以在两端进行插入和删除操作的数据结构。它允许我们以 O(1) 的时间复杂度在队列的头部和尾部进行插入和删除操作。这一特性使它在滑动窗口最大值问题中非常有用。
双端队列的性质:
- 支持两端插入和删除:
- 可以在队列的头部和尾部插入和删除元素。
- 头部操作:在队列头部移除旧的元素。
- 尾部操作:在队列尾部插入新的元素。
- 时间复杂度为 O(1):
- 双端队列的插入和删除操作都可以在常数时间内完成,这使得它在某些需要频繁更新的场景中非常高效。
双端队列解法如何降低时间复杂度:
在滑动窗口最大值问题中,暴力解法的时间复杂度是 O(n * k),因为每次滑动窗口都要重新遍历窗口内的 k 个元素寻找最大值。双端队列的解法通过维护一个候选最大值的双端队列,避免了每次都从头遍历窗口,时间复杂度被优化到了 O(n)。
双端队列解法的核心思想是:在窗口滑动过程中,我们只需要保留对当前窗口有可能成为最大值的元素索引。其他元素可以通过双端队列的特性,快速地被移除或更新。
双端队列解法的步骤:
-
维护一个双端队列,队列中的每个元素是数组元素的索引。
-
保持队列单调递减
:
- 队列的首元素始终是当前窗口中的最大值的索引。
- 如果新的元素比队列中的尾部元素大,说明尾部元素不可能再成为最大值,直接将尾部元素移除,直到队列中没有比新元素大的元素。
-
删除超出窗口范围的元素
:
- 如果队列中的首元素索引已经不在当前滑动窗口内,就将其移除。
-
将新元素索引加入队列,并更新结果。
-
每次滑动窗口时,将队列首元素(即当前窗口的最大值)加入结果数组。
deque
的特点:
- 双端插入和删除:可以在队列的两端执行
append
和pop
操作,分别对应队尾和队头的操作。 - 时间复杂度:在两端的插入和删除操作都是 O(1) 的时间复杂度,非常高效。
deque
的用法与单调队列
单调队列利用了 deque
的双端特性,确保在添加新元素时可以保持单调性(递增或递减)。例如:
- 如果要保持单调递减队列:
- 在队尾添加新元素时,移除所有比新元素小的元素,从而确保队列中的元素从头到尾是递减的。
- 在窗口滑动时,检查队头的元素是否过期(超出滑动窗口范围),如果是,就从队头移除它。
单调栈与单调队列对比:
1. 数据结构和使用场景不同
- 单调栈(Monotonic Stack):是一种栈结构,通常用于处理与下一个更大/更小的元素相关的问题,比如“寻找每个元素右边第一个比它大的元素”这种类型的问题。单调栈的典型场景是遍历数组时通过栈的后进先出(LIFO)特性,逐步剔除栈顶不满足单调性的元素。
- 单调队列(Monotonic Queue):是一种队列结构,通常用于处理与滑动窗口相关的问题。单调队列可以在窗口移动时高效地维护窗口中的最值,通常用于处理具有连续范围问题的场景。
2. 操作方式不同
- 单调栈:采用的是栈的“后进先出”(LIFO)操作。遇到元素时,不断将比当前元素小的栈顶元素弹出,直到栈顶元素符合单调性,适合问题如“找下一个更大元素”。
- 单调队列:采用的是队列的“先进先出”(FIFO)操作。每当新元素进入时,队列中所有小于该元素的元素会被移除,确保队列的单调性。并且,在滑动窗口中,队列头部元素代表窗口中的最大或最小值。
这一题中存储的都是数组元素的索引
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
if not nums or k == 0:
return []
deq = deque()
res = []
for i in range(len(nums)):
# 移除队列中不在当前滑动窗口范围内的元素,只有两种可能会移除,要么不在窗口范围,要么不是最大值索引
if deq and deq[0] < i - k + 1:
deq.popleft()
# 移除队列中所有比当前元素小的元素,比nums[i]小的元素索引全部删除了,留下数值一定比num[i]大,并且位置在后进来的i前面
#所以deq[0]保留了最大值的索引
#最大值出去的办法,要么后面来了个更大的,要么超出窗口左边界
while deq and nums[deq[-1]] < nums[i]:
deq.pop()
# 将当前元素的索引加入队列
deq.append(i)
# 当滑动窗口覆盖了 k 个元素后,记录结果
#当滑动到满足窗口大小开始往里面添加元素
if i >= k - 1: #窗口右边界为i, 左边界为i - k + 1
res.append(nums[deq[0]]) # 队列的首元素是当前窗口的最大值
return res
在 Python 中,if deq
是对双端队列 deq
的一个布尔判断,它用于检查队列是否为空。
collections.deque
的 pop
和 append
方法的默认行为是针对双端队列的右侧(尾部),如果不指定 left
,则操作默认在右侧进行。
这道题,弄清楚窗口的左右边界对解题帮助很大
单调队列为什么保存索引的原因:
在单调队列的实现中,我们通常保存的是索引而不是直接保存元素值,这是因为在滑动窗口问题中,我们需要根据窗口的左右边界动态调整队列,同时也需要高效地知道窗口范围内的最大值是否过期。保存索引有以下几个关键原因: 这是这一题目的关键。
1. 索引可以帮助判断窗口范围
- 滑动窗口问题中的窗口是不断移动的。每当窗口右边界移动时,窗口的左边界也要相应调整。通过保存元素的索引,我们可以判断当前队列中的最大值是否已经不再属于当前窗口。
- 例如,在队列中,如果队列头部的索引超出了窗口的左边界,就可以通过索引来判断并移除它。
- 如果只保存值,无法判断哪些元素已经超出窗口边界。
例子:
假设窗口大小为 3,数组为 [1, 3, -1, -3, 5, 3, 6, 7]
。在某个时刻,队列可能保存的是 nums[1] = 3
。当窗口继续滑动后,nums[1]
可能已经超出窗口,但如果只保存值,无法确定这一点;而如果保存索引 1
,就可以很容易判断 1
是否已经超出当前窗口。
2. 可以保持队列的动态性
- 滑动窗口的问题中,最大值不一定是最后加入的元素。通过保存索引,既可以确保队列中元素保持单调性(如递减),又可以确保我们随时可以删除已经不属于窗口的元素。
- 如果只保存值,无法进行对比,因为不知道每个值的位置。
例子:
假设我们有一个数组 [3, 2, 5, 4]
,窗口大小为 3。我们希望知道在滑动窗口中的最大值。如果只保存值,窗口滑动后就不知道哪个值是在当前窗口中的,因为无法判断哪个值超出了窗口范围。
3. 灵活处理重复元素
- 当数组中有重复元素时,保存索引可以确保我们在滑动窗口中正确处理这些重复值。
- 例如,数组
[4, 4, 4]
,滑动窗口不断移动时,保存索引可以确保我们能正确判断窗口的起始位置,知道哪些4
属于窗口,哪些4
已经超出了窗口。
4. 高效的时间复杂度
- 保存索引可以让我们在滑动窗口每次移动时通过
popleft
和pop
操作高效地维护窗口中的最大值和最小值,而不需要重新遍历整个窗口。 - 如果只保存值,每次窗口移动时,可能需要遍历窗口中的所有元素才能找到新的最大值,从而使时间复杂度从 O(n) 上升到 O(n*k)(其中
k
是窗口大小)。
十二. 最小覆盖子串
Counter
是 Python collections
模块中的一个类,用于统计可迭代对象中元素的出现次数。它返回的是一个字典,其中键是元素,值是该元素出现的次数
简单例子,
from collections import Counter
# 使用 Counter 来统计一个字符串中各个字符的出现次数
text = "hello world"
counter = Counter(text)
print(counter)
Counter({'l': 3, 'o': 2, 'h': 1, 'e': 1, ' ': 1, 'w': 1, 'r': 1, 'd': 1})
Counter
中不存在的元素默认值为 0。这意味着你可以直接访问一个不存在的键,它会返回 0,而不会引发 KeyError
。
利用counter快速通俗字符串中各个字幕出现的次数。
滑动窗口题目,采用左闭右开区间,这样[left, right),有两个好处,在left,right = 0 时候,内部没有元素,方便处理。另一个好处是,长度的统计直接用right - left 不用加 1处理
python中可哈希对象必须是不可变对象,
不可变对象
与可变对象相对,不可变对象是指那些一旦创建后,内容就不能再修改的对象。常见的不可变对象包括:
- 整数(int)
- 浮点数(float)
- 字符串(str)
- 元组(tuple)
- frozenset(不可变集合)
对于不可变对象,任何试图修改它的操作都会生成一个新对象,而不是修改原对象本身!!! 也就是整数变量的赋值,如果对它修改其实是另起炉灶的赋值,在这些题目存在引用修改的思考问题。
常见的可变对象:列表、字典、集合、 对这些内容的修改是在原地址上直接修改。 而这些可变对象的修改是在原地址上进行修改,可变的意思是:原地址存储的变量可变,接受修改,吐故纳新
可变对象适合那些需要频繁修改的数据结构,例如列表、字典等。在这种情况下,原地修改可以提高效率。
不可变对象适合那些需要共享或不希望被修改的数据结构,如字符串、元组、整数等。在多线程环境下,它们提供了更高的安全性和稳定性。也就是不可变对象一旦创建尽量不做修改。
滑动窗口模板,数字放进操作符后,right立马 + 1 ,并且扩张窗口和收缩窗口是对称的两个操作
while right < len(s):
c = s[right]
right += 1
#进行扩展的一系列操作
while left < right and 收缩的条件:
d = s[left]
left += 1
class Solution:
def minWindow(self, s: str, t: str) -> str:
need = Counter(t)
window = Counter()#注意Counter大写。
valid = 0
left, right = 0, 0
start, length = 0, float('inf')
#窗口开始滑动
while right < len(s):
c = s[right]
right += 1 #right一直进行 + 1,方便写代码,也是左闭右开区间的好处
#进行扩大之后的数据操作
if c in need:
window[c] += 1
if window[c] == need[c]:
valid += 1
#收缩条件:找到了子串
#更新条件,子串是最小的
while left < right and valid == len(need):
if right - left < length:
start = left
length = right - left
d = s[left]
left += 1
if d in need:
if window[d] == need[d]:
valid -= 1
window[d] -= 1
if length == float('inf'):
return ''
else:
return s[start: start + length]
扩大窗口:窗口的右边界不断右移,向右扩大,直到满足某个条件,比如窗口包含了所有需要的字符。 扩大的终点是达到右边界
收缩窗口:一旦条件满足(如找到一个符合条件的子串),开始收缩窗口,也就是移动左边界。窗口在收缩过程中保持条件有效,同时尝试缩小子串的长度以寻找更优解(如更短的符合条件的子串)。
十三. 最大子数组和
为什么需要两个状态转移方程?
- 局部最优和全局最优:
current_sum
是一个局部最优的解,它表示到达当前位置时,结尾的子数组最大和。局部最优不一定是全局最优,所以我们需要max_sum
来追踪全局最优的解。 - 动态规划的思想:动态规划中的关键在于“递推关系”,
current_sum
表示当前步的最优解,它依赖于前一步的状态。而max_sum
表示到目前为止的全局最优解,确保我们遍历结束后能返回真正的最大子数组和。
动态规划中是否每个问题都有局部最优和全局最优?
- 绝大多数动态规划问题都有局部最优和全局最优的概念。这是因为动态规划的核心思想就是将问题分解成子问题,并通过组合这些子问题的解来得到整体问题的解。
- 局部最优解的概念帮助我们通过递推的方式构建出全局最优解。我们利用每一步的最优解来逐步构建整个问题的解。
动态规划的关键特性:
- 最优子结构:问题的最优解包含其子问题的最优解。也就是说,问题可以被分解成若干个子问题,并且这些子问题的解可以用来构造原问题的解。
- 重叠子问题:同样的子问题会被多次求解,动态规划通过记忆化或表格化来避免重复计算,从而提高效率。
总结:
- 局部最优:解决当前子问题的最优解。
- 全局最优:所有子问题的最优解中最优的那个。
动态规划通过结合局部最优解来逐步构建全局最优解。这种方法在许多优化问题中非常有效,特别是当问题具有最优子结构和重叠子问题的特性时。
局部最优与全局最优
- 局部最优:指的是在某个子问题或某个阶段的最优解。它只考虑在当前状态下最优的选择,而不考虑全局状态。局部最优通常是通过一些递推关系或者状态转移方程计算得出的。
- 全局最优:指的是在整个问题的所有可能解中最优的解。它是整个问题的最终解,通常需要综合所有局部最优解来得到。
如何理解这两个概念:
- 局部最优:
- 在解决动态规划问题时,通常我们会逐步构建解决方案。
- 例如,在求解最大子数组和时,我们在每一步决定是否将当前元素加入到现有的子数组中,从而得到当前的局部最优解(即以当前元素为结尾的最大子数组和)。
- 这种局部最优解并不保证整体最优,但通过记录和维护这些局部最优解,我们可以逐步找到整体最优解。
- 全局最优:
- 通过将所有局部最优解结合起来,我们可以得到全局最优解。
- 例如,在最大子数组和的问题中,最终的全局最优解就是在遍历过程中得到的最大局部最优解。
- 在动态规划中,我们通过不断更新全局最优解来确保最终结果是最优的。
动态规划,把求原问题的最优解优化为求子问题的最优解,通过动态规划,我们只需在一次遍历中找到最大子数组的和,避免了使用暴力解法可能带来的 O(n²) 或更高的时间复杂度。
Kadane’s 算法 Kadane’s 算法是一种用于解决最大子数组和问题的动态规划算法。最大子数组和问题是指在给定的整数数组中找到一个具有最大和的连续子数组。Kadane’s 算法的核心思想是遍历数组,同时维护两个变量:当前元素作为子数组结尾的最大子数组和(current_max
),以及到目前为止找到的最大子数组和(global_max
)。
def maxSubArray(nums):
# 初始状态:最大和和当前和都设为第一个元素
current_sum = max_sum = nums[0]
# 从第二个元素开始遍历
for i in range(1, len(nums)):
# 决定是否将当前元素加入之前的子数组,还是重新开始
current_sum = max(nums[i], current_sum + nums[i])
# 更新全局最大值
max_sum = max(max_sum, current_sum)
return max_sum
大部分状态转移方程还是有些规律可循的,跑不出那几个套路。像子数组、子序列这类问题,你就可以尝试定义 dp[i]
是以 nums[i]
为结尾的最大子数组和/最长递增子序列,因为这样定义更容易将 dp[i+1]
和 dp[i]
建立起联系,利用数学归纳法写出状态转移方程。
按照我们常规的动态规划思路,一般是这样定义 dp
数组:
nums[0..i]
中的「最大的子数组和」为 dp[i]
。 但是如果这样定义,无法由dp[i] 得到dp[i + 1]因为并不保证最大子数组是相连的。
动态规划套路写法,本质与kadane算法一致。
用前缀和思路解题,以 nums[i]
为结尾的最大子数组之和是多少?其实就是 preSum[i+1] - min(preSum[0..i])
。
# 前缀和技巧解题
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
n = len(nums)
preSum = [0] * (n + 1)
preSum[0] = 0
# 构造 nums 的前缀和数组
for i in range(1, n + 1):
preSum[i] = preSum[i - 1] + nums[i - 1]
res = float('-inf')
minVal = float('inf')
for i in range(n):
# 维护 minVal 是 preSum[0..i] 的最小值
minVal = min(minVal, preSum[i])#记录前i个前缀和的最小值
# 以 nums[i] 结尾的最大子数组和就是 preSum[i+1] - min(preSum[0..i])
res = max(res, preSum[i + 1] - minVal) #此解法要求对前缀和理解非常透彻
return res
十四.合并区间
sort()
和 sorted()
的 key
参数提供了一种灵活的方式来定义排序规则。你可以使用 lambda
函数、内置函数或自定义函数来提取排序键,从而控制排序的行为。关键字函数使得排序操作更加灵活和强大,适用于各种复杂的排序需求。
sort()
方法和 sorted()
函数
list.sort()
:就地排序方法,直接修改原列表。返回None
。 是列表的方法。sorted()
:返回一个新排序的列表,不修改原列表。返回排序后的列表。 sorted是函数,返回排序好的列表
sort()方法常与,lambda函数一起使用
关键字函数(key
参数)
关键字函数是传递给 sort()
或 sorted()
的一个函数,用于指定排序的关键字。排序时,Python 会调用这个函数来生成排序键,并基于这些键来排序列表中的元素。
一次性使用的函数
lambda
函数特别适合那些只需要一次使用的小函数。它让你在不定义正式函数的情况下快速定义简单的功能。
# 在排序中定义排序的关键字函数
data = [(1, 'apple'), (2, 'banana'), (3, 'cherry')]
sorted_data = sorted(data, key=lambda x: x[1])
练习中出现的经典错误
while i > 0 and nums[i] == nums[i - 1]:
continue
会进入无限循环。
continue
只是跳过当前迭代,不会改变 i
的值。因此,如果 nums[i]
和 nums[i - 1]
相等,while
循环会不断重复,不会终止,从而导致无限循环。
应该这样用
while i > 0 and nums[i] == nums[i - 1]:
i += 1 # 移动 `i` 以跳过重复元素
排序方法常见的关键函数使用方法
在 Python 中,sort
方法和 sorted
函数都允许你通过 key
参数指定一个键函数来对列表进行排序。这个键函数决定了排序的依据,它接受一个元素作为输入,并返回一个用于排序的值。你可以根据实际需要定义不同的键函数来实现各种排序策略。
常见的键函数示例
-
按元组的第一个元素排序:
intervals.sort(key=lambda x: x[0])
- 用法:根据元组的第一个元素进行排序。例如,对于
[(2, 5), (1, 3), (4, 6)]
,排序后会得到[(1, 3), (2, 5), (4, 6)]
。
- 用法:根据元组的第一个元素进行排序。例如,对于
-
按元组的第二个元素排序:
intervals.sort(key=lambda x: x[1])
- 用法:根据元组的第二个元素进行排序。例如,对于
[(2, 5), (1, 3), (4, 6)]
,排序后会得到[(1, 3), (2, 5), (4, 6)]
。
- 用法:根据元组的第二个元素进行排序。例如,对于
-
按字典的值排序:
data = {'a': 3, 'b': 1, 'c': 2} sorted_keys = sorted(data, key=lambda k: data[k])
- 用法:根据字典的值对字典的键进行排序。例如,
sorted_keys
会得到['b', 'c', 'a']
。
- 用法:根据字典的值对字典的键进行排序。例如,
-
按字符串的长度排序:
words = ['apple', 'banana', 'kiwi', 'pineapple'] words.sort(key=len)
- 用法:根据字符串的长度进行排序。例如,
words
会得到['kiwi', 'apple', 'banana', 'pineapple']
。
- 用法:根据字符串的长度进行排序。例如,
-
按字符串的字母顺序排序:
strings = ['banana', 'apple', 'cherry'] strings.sort(key=str.lower)
- 用法:根据字符串的字母顺序进行排序,忽略大小写。例如,
strings
会得到['apple', 'banana', 'cherry']
。
- 用法:根据字符串的字母顺序进行排序,忽略大小写。例如,
-
按复合条件排序:
data = [(2, 'apple'), (1, 'banana'), (3, 'cherry')] data.sort(key=lambda x: (x[1], x[0]))
- 用法:首先根据元组的第二个元素排序,然后根据第一个元素排序。例如,
data
会得到[(1, 'banana'), (2, 'apple'), (3, 'cherry')]
。
- 用法:首先根据元组的第二个元素排序,然后根据第一个元素排序。例如,
-
按多个键排序:
data = [(1, 'a', 3), (1, 'b', 2), (2, 'a', 1)] data.sort(key=lambda x: (x[0], x[1], x[2]))
- 用法:按多个键进行排序。例如,
data
会得到[(1, 'a', 3), (1, 'b', 2), (2, 'a', 1)]
。
- 用法:按多个键进行排序。例如,
-
按自定义条件排序:
items = ['abc', 'aBc', 'ABc'] items.sort(key=lambda x: x.lower())
- 用法:按自定义条件进行排序。在这个例子中,
items
会得到['abc', 'ABc', 'aBc']
,因为key
函数将所有字符串转换为小写形式进行比较。
- 用法:按自定义条件进行排序。在这个例子中,
题解
class Solution:
def merge(self, intervals: List[List[int]]) -> List[List[int]]:
intervals.sort(key = lambda x : x[0])
res = []
res.append(intervals[0])
for current in intervals[1 : ]:
last = res[-1] #每次看重叠与否都是与最后一个元素作比较,用到了python中列表的元素是可变对象,对索引的更改会在原位置发生变化
if current[0] <= last[1]:
# 合并区间,修改可变对象 last_merged 的结束点
last[1] = max(current[1], current[0])
else:
res.append(current)
return res
思路:
排序:
- 首先根据区间的起始点对区间进行排序。这是因为合并区间的关键是区间的重叠情况,而排序可以帮助我们更容易地找到重叠区间。
合并:
- 遍历排序后的区间,逐个检查区间是否重叠。
- 如果当前区间与合并结果中的最后一个区间重叠(即当前区间的起始点小于或等于最后一个区间的结束点),则更新最后一个区间的结束点。
- 如果不重叠,则将当前区间添加到合并结果中。
15.轮转数组
先介绍原地反转这一算法技巧。
原地反转是一种高效的算法技巧,主要用于在不使用额外空间的情况下对数组或其他数据结构进行修改。其核心特性包括以下几点:
原地反转的特性 原地反转是一种高效的算法技巧,在处理需要反转的数据结构时,能够显著减少空间复杂度。
- 空间复杂度为 O(1):
- 原地反转方法通过直接修改数组或数据结构中的元素来完成任务,不需要额外的存储空间。这使得其空间复杂度为 O(1),即常数级空间复杂度。
- 使用双指针技术:
- 原地反转通常使用两个指针(或索引),分别指向数组的起始和结束位置。通过交换这两个位置的元素,并逐渐向中间移动这两个指针,直到它们相遇或交叉,从而完成反转操作。
- 不需要额外的数据结构:
- 这种方法避免了使用额外的数组或数据结构进行操作,从而提高了空间利用率。在处理大数据时,特别有用。
- 时间复杂度为 O(n):
- 原地反转的时间复杂度是 O(n),其中 n 是数组的长度。每个元素只被访问和修改一次,因此时间复杂度是线性的。
元组解包
在 Python 中,nums[start], nums[end] = nums[end], nums[start]
是合法且常见的操作,利用了元组解包的特性,能够安全地在一行代码中完成多个变量的赋值。这种方式在数组和其他数据结构的处理过程中非常方便。
Python 的元组解包功能使得可以在一行代码中同时对多个变量进行赋值。它的工作原理如下:
- 创建一个临时元组:
nums[end], nums[start]
组成一个临时元组(nums[end], nums[start])
。
- 将临时元组解包:
- 临时元组被解包并赋值给
nums[start]
和nums[end]
,分别将nums[end]
的值赋给nums[start]
,将nums[start]
的值赋给nums[end]
。
- 临时元组被解包并赋值给
整体反转:将整个数组反转后,目标是将数组的相对顺序翻转。这样,原来数组的末尾元素会移到数组的前面,原来前面的元素会移到数组的末尾。
部分反转:为了恢复这些部分到正确的顺序,我们需要分别反转数组的两个部分:前 k
个元素和从 k
到末尾的元素。这样就能将这些部分恢复到轮转后的正确顺序。这一步就相当于把由原来的后面k个变成了前面k个
恢复顺序:由于前 k
个元素和从 k
到末尾的元素分别反转,使得它们在反转整个数组后的正确位置恢复到轮转后的状态。
轮转k其实就是把后面K个元素放到前面k个位置上去,但是仍然保持原来的顺序,用到两次反转恢复原样这个技巧
class Solution:
def reverse(self, nums, i, j):
while i < j:
nums[i], nums[j] = nums[j], nums[i]
i += 1
j -= 1
def rotate(self, nums: List[int], k: int) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
n = len(nums)
k = k % n
self.reverse(nums,0, len(nums)- 1)
self.reverse(nums, 0, k- 1)
self.reverse(nums,k, len(nums) - 1)
使用函数嵌套函数的优势:
内部函数可以访问其所在外部函数的局部变量。
内部函数的作用域仅限于其所在的外部函数内,这减少了与其他函数或变量的命名冲突的可能性。这样可以确保内部函数不会在全局命名空间中引发名称冲突。
通过将相关的代码逻辑封装到内部函数中,可以使外部函数的代码更简洁。rotate
函数的主要逻辑清晰地展现在外部函数中,而 reverse
函数作为一个辅助工具则隐藏在内部。