算法学习Day 1| LeetCode 704. 二分查找、LeetCode 27. 移除元素、LeetCode 977.有序数组的平方

算法学习Day 1| LeetCode 704. 二分查找、LeetCode 27. 移除元素、LeetCode 977.有序数组的平方
董等等Day1. 704. 二分查找、 27. 移除元素、 977.有序数组的平方
今天总体很简单。
704、基础题,注意elif return 拼写;注意左闭右开re= len(nums),熟悉一个即可
27、注意优化时间复杂度,快慢指针第一个版本比较舒服,熟悉
977、熟悉官方给出的双指针二和三,尤其是最后一个兄弟的题解,两头往中间计算比较,注意空列表为n个(注意不要打错[]*n),背熟最后一个兄弟的题解
704.二分查找
二分法:
判断target在不在数组中,在返回下标,无-1
易错点:
1、while循环时,L≤(<)R
2、num(middle)>target
right = middle - 1
循环不变量:
左闭右开
左闭右闭
左闭右闭区间:
1 | class Solution: |
左闭右开区间:
1 | class Solution: |
27.移除元素
1 | class Solution: |
1 | class Solution: |
核心逻辑:
- 快指针(
right
):遍历整个数组,检查每个元素是否为非val
值。 - 慢指针(
left
):记录下一个有效元素的位置,只有遇到非val
元素时才会移动。 - 覆盖操作:将非
val
元素直接覆盖到左指针位置,避免反复删除和移动元素。
复杂度对比:
方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
---|---|---|---|
原代码 | O(n²) | O(1) | 少量 val 元素 |
双指针法 | O(n) | O(1) | 任意数量 val 元素 |
为什么双指针法更高效?
(1) 单次遍历
- 双指针法仅遍历数组一次,而原代码每次
remove(val)
都要遍历一次。 - 示例:数组
[2, 2, 2, 2]
,val=2
- 原代码:每次
remove(2)
需遍历整个数组,共执行4次 → 总操作次数为4×4=16
。 - 双指针法:遍历一次,直接覆盖 → 操作次数为4。
- 原代码:每次
(2) 避免频繁内存操作
- 原代码的
remove()
需要频繁移动后续元素,导致内存操作开销大。 - 双指针法仅覆盖非
val
元素,无额外内存调整。
总结
- 原代码问题:理论复杂度应为O(n²),但LeetCode测试用例可能未覆盖最坏情况,导致误判为O(n)。
- 改进方法:双指针法通过单次遍历和覆盖操作,真正实现O(n)时间复杂度和O(1)空间复杂度。
- 建议:以双指针法为标准实现,适用于所有场景。
977. 有序数组的平方
让你设计时间复杂度为O(n)的算法
答案一:复杂度不对
时间复杂度:O(nlogn),其中 n 是数组 nums 的长度。
空间复杂度:O(n)。
1 | class Solution: |
官方答案:
方法一:直接排序
思路与算法
最简单的方法就是将数组 nums 中的数平方后直接排序。
1 | class Solution: |
复杂度分析
时间复杂度:O(nlogn),其中 n 是数组 nums 的长度。
空间复杂度:O(logn),除了存储答案的数组以外,我们需要 O(logn) 的栈空间进行排序。
方法二:双指针
思路与算法
方法一没有利用「数组 nums 已经按照升序排序」这个条件。显然,如果数组 nums 中的所有数都是非负数,那么将每个数平方后,数组仍然保持升序;如果数组 nums 中的所有数都是负数,那么将每个数平方后,数组会保持降序。
这样一来,如果我们能够找到数组 nums 中负数与非负数的分界线,那么就可以用类似「归并排序」的方法了。具体地,我们设 neg 为数组 nums 中负数与非负数的分界线,也就是说,**nums[0] 到 nums[neg] 均为负数,而 nums[neg+1] 到 nums[n−1] 均为非负数。**当我们将数组 nums 中的数平方后,那么 nums[0] 到 nums[neg] 单调递减,nums[neg+1] 到 nums[n−1] 单调递增。
由于我们得到了两个已经有序的子数组,因此就可以使用归并的方法进行排序了。具体地,使用两个指针分别指向位置 neg 和 neg+1,每次比较两个指针对应的数,选择较小的那个放入答案并移动指针。当某一指针移至边界时,将另一指针还未遍历到的数依次放入答案。
1 | #记住这个函数 |
复杂度分析
- 时间复杂度:O(n),其中 n 是数组 nums 的长度。
- 空间复杂度:O(1)。除了存储答案的数组以外,我们只需要维护常量空间。
方法三:双指针
思路与算法
同样地,我们可以使用两个指针分别指向位置 0 和 n−1,每次比较两个指针对应的数,选择较大的那个逆序放入答案并移动指针。这种方法无需处理某一指针移动至边界的情况,仔细思考其精髓。
1 | class Solution: |
复杂度分析
- 时间复杂度:O(n),其中 n 是数组 nums 的长度。
- 空间复杂度:O(1)。除了存储答案的数组以外,我们只需要维护常量空间。
方法三DS代码逐行注释与解析
1 | cclass Solution: |
算法逻辑详解
1. 核心思想
- 输入数组特性:原数组
nums
是非递减排列的,因此平方后的最大值必然出现在最左端(负数最大)或最右端(正数最大)。 - 双指针策略:
- i从头部开始,处理可能的大负数平方。
- j从尾部开始,处理可能的大正数平方。
- 从后往前填充结果数组,确保结果是非递减的。
2. 关键步骤
- 指针初始化:
i=0
,j=n-1
分别指向数组两端。pos=n-1
标记结果数组的填充位置(从末尾开始)。
- 循环条件:
i <= j
保证所有元素被处理且无遗漏。 - 平方比较与填充:
比较nums[i]
和nums[j]
的平方值,将较大的存入ans[pos]
,并移动对应指针。- 示例:
若原数组为[-4, -2, 0, 3, 5]
,平方后最大值依次为16(-4²)
和25(5²)
,优先选择25存入末尾。
- 示例:
复杂度分析
类型 | 说明 |
---|---|
时间复杂度 | O(n) |
空间复杂度 | O(n)(结果数组 ans 占用额外空间,与原数组等长) |
复杂度推导
- 时间复杂度:
- 单次遍历数组,比较和填充操作均为 O(1),总循环次数为 O(n)。
- 空间复杂度:
- 结果数组
ans
的长度为 n → O(n)。 - 其他变量(i, j, pos)占用常数空间 → O(1)。
- 结果数组
示例演示
假设输入 nums = [-4, -2, 0, 3, 5]
:
- 初始化:
i=0
,j=4
,pos=4
- 循环过程:
- 第1次:比较 -4²=16 和 5²=25 → 存入25,
j=3
,pos=3
- 第2次:比较 -4²=16 和 3²=9 → 存入16,
i=1
,pos=2
- 第3次:比较 -2²=4 和 3²=9 → 存入9,
j=2
,pos=1
- 第4次:比较 -2²=4 和 0²=0 → 存入4,
i=2
,pos=0
- 第5次:i=2, j=2 → 比较 0²=0 和 0²=0 → 存入0,
j=1
,pos=-1
- 第1次:比较 -4²=16 和 5²=25 → 存入25,
- 结果:
ans = [0, 4, 9, 16, 25]
算法优势
- 无需显式排序:利用原数组有序性,通过双指针合并逻辑避免排序(显式排序时间复杂度为 O(n log n))。
- 边界处理简单:
- 全负数数组:
i
逐步右移,存入所有负数平方的逆序。 - 全非负数数组:
j
逐步左移,直接存入原序平方。
- 全负数数组:
- 高效内存利用:仅需一个结果数组,无递归或复杂数据结构。
总结
该代码通过双指针从两端向中间扫描,利用原数组有序性,高效生成平方后的非递减数组。时间复杂度为 O(n),空间复杂度为 O(n),适用于大规模数据处理。
一个兄弟的不错题解
双指针:
由于数组 nums 已经按照非递减顺序排好序,所以数组中负数的平方值是递减的,正数的平方值是递增的。我们可以使用**双指针,分别指向数组的两端**,每次比较两个指针指向的元素的平方值,将较大的平方值放入结果数组的末尾。
1 | class Solution: |
时间复杂度 O(n),其中 n 是数组 nums 的长度。忽略答案数组的空间消耗,空间复杂度 O(1)。
另一个兄弟的不错题解
相向双指针
实际上,本题考察的并不是如何排序,而是如何使用双指针。
可以发现,排序的做法 **忽略** 了题目给的一个条件:原数组 nums 非递减排序。如何利用它?对于有序数组来说,如果要查找某个数,或者对数组进行变换,会联想到 **二分** 或者 **双指针**。
在本题中,对应两种思路:
- 使用二分去找正数和负数的分界线,然后对两边进行归并
- 使用双指针从两边向中间合并,有条理地进行归并
- 使用二分去找正数和负数的分界线,然后对两边进行归并
- 使用双指针从两边向中间合并,有条理地进行归并
这里采用后一种方法,毕竟前一种方法还需要找边界接着使用双指针,不如直接使用后一种,节省计算时间。
怎么想到双指针?双指针怎么使用?我们将数组中的数换成 绝对值 来理解,从数组 nums 的两个方向观察,从右往左是递减,从左往右也是递减。这意味着,数组的两头都是大数,中间的是小数。这种 单调性,可以让我们考虑用双指针。
希望得到的答案是从小到大排序的数组。那么,我们完全可以考虑从某个方向去填充这个最终答案。
思路:原数组的双指针从数组的两端开始 相向 移动,而答案的填充从右端开始向左 单向 移动。
做法:假设左指针为l,右指针为 r,填充的指针为 k。
如果左指针的元素绝对值 小于 右指针的元素,说明当前的最大元素为 ∣nums[r]∣,将它平方后放在 ans[k] 的位置
如果左指针的元素绝对值 小于 右指针的元素,说明当前的最大元素为 ∣nums[r]∣,将它平方后放在 ans[k] 的位置
这种思路为什么正确?上面已经指出,从绝对值的角度来看,数组的数表现为 *中间小两头大* 的分布。使用双指针遍历,选择它俩中的最大值,一定是 nums 中 **剩余所有数** 中的最大,平方后放入答案即可。当然,这也侧面解释了为什么答案的填充也是从右往左。
细节:具体实践时,比较绝对值可以换成比较平方值,一样的效果,后者能直接放入答案,更加方便。
将上面的推导转为代码,已附加注释:
1 | #熟悉这个for k in range(n - 1, -1, -1) |
- 时间复杂度: O(n),其中 n 为数组 nums 的长度,一次遍历
- 空间复杂度: O(1),同理不考虑存储答案的数组 ans 的空间开销