代码随想录算法训练营第一天| 数组理论、704. 二分查找、27. 移除元素
数组理论
数组array:存放在连续内存空间上的相同类型数据的集合。数据在数组中配有下标索引,我们可以通过定位下标索引的方式获取对应的数据。
通过此图我们可以看出:
- 数组下标索引从0开始
- “连续内存空间”体现在对应的内存地址是连续的
由于数组内存地址是来连续的,因此在对数组进行插入或者删除的操作时,会导致其相邻的数据对应的下标和内存地址也发生变化。
变化通过覆盖实现,即将原来的数值用新的数值进行替换或覆盖。删除中,新数值为相邻的后续数据,将其往前移动一位;插入中,将数据往后移动一位。
704. 二分查找
思路
将目标值与数组的中间元素进行比较,从而确定目标值可能在的区间,然后将搜索范围缩小一半,重复这个过程,直到找到目标值或确定目标值不存在。
时间复杂度
二分查找的时间复杂度是O(log n)。每次查找都将搜索范围缩小一半,因此时间复杂度是对数级别的。在每次迭代中,查找范围都会减半,直到找到目标值或确定目标值不存在。
区间规则
实现二分查找的代码需要注意区间规则的设置。可视为左闭右闭 (left<=right) 和左闭右开 (left<right) 两种情况。
代码
左闭右闭
class Solution:
def search(self, nums: List[int], target: int) -> int:
left,right=0,len(nums)-1
# 由于left<=right,要确保right是可以取得到的数字
# 因此right初始值要设为List的最后一位元素的索引值
while(left<=right): # [left,right]左闭右闭
middle=left+(right-left)//2
#等同于(left+right)/2,目的是防止内存溢出
if nums[middle]<target:
left=middle+1
# left为闭区间,此时确定middle对应的数字在target左侧
# 设置left=middle+1确保新的左区间是从target可能的取值开始
elif nums[middle]>target:
right=middle-1
# right为闭区间,此时确定middle对应的数字在target右侧
# 设置right=middle-1确保新的右区间是从target可能的取值开始
else: return middle # middle对应的数字就是target
return -1 # 数组中不存在target
左闭右开
class Solution:
def search(self, nums: List[int], target: int) -> int:
left,right=0,len(nums)
# 由于left<right,要确保right是第一个取不到的数字
# 因此right初始值要设为List的最后一位元素的索引值+1
while(left<right): # [left,right)左闭右开
middle=left+(right-left)//2
#等同于(left+right)/2,目的是防止内存溢出
if nums[middle]<target:
left=middle+1
# left为闭区间,此时确定middle对应的数字在target左侧
# 设置left=middle+1确保新的左区间是从target可能的取值开始
elif nums[middle]>target:
right=middle
# right变为开区间,此时middle对应的数字在target右侧
# 新的右区间要确保包含了所有target可能的取值
# 且是第一个取不到的数字,即right=middle
else: return middle # middle对应的数字就是target
return -1 # 数组中不存在target
拓展题目
两道拓展题目已经通过博客记录了,附上Leetcode 34和Leetcode 35的博客链接
Leetcode 34 博客记录链接
Leetcode 35 博客记录链接
27. 移除元素
Python Remove
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
while val in nums:
nums.remove(val)
return len(nums)
Python中的remove可以使这道题的解答不那么复杂。但本质上python中的remove是针对list的库函数,而list不是数组,数组存储的是相同类型的数据, list可以存储不同类型的数据,list是更高级的数据结构了。
如果库函数可以直接解决一道题,那么最好练习算法时不要用库函数;当其只是其中一小步的时候,可以用库函数简化过程。
本质上Q27需要将其视为数组来解决,而数组在内存中是连续的地址空间,数组是不能删除单一元素的,只能覆盖,删除只能是全部删除(程序运行结束,回收内存站空间)。因此,remove虽然也能解决这个问题,但并不是从数组的角度,即通过覆盖来实现。
暴力解法
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
i=0
l=len(nums)
while i<l:
# 不用for是因为当移除目标元素后,i应当-1
# 但for不会使用-1后的i,还会用原来的i
# 因此用while循环i+=1
if nums[i]==val:
for x in range(i+1,l):
# 当i+1=l时,range返回一个空集,不会进入for循环
# i+1=l表明最后一个元素=val,不用覆盖,直接nums长度-1
nums[x-1]=nums[x]
#后续的元素往前移动一格
i-=1
l-=1
i+=1
return l # 返回最后的数组长度
这个算法的时间复杂度为O(n),其中n是输入列表nums的长度。算法中的主要操作是遍历列表中的元素,其时间复杂度为O(n)。在最坏的情况下,需要遍历整个列表。
双指针法
通过设置两个指针,一个快指针在原有数组探路,寻找新数组的元素(即不等于val的值),一个慢指针指向更新新数组下标的位置。
实际看作慢指针指向的是一个空的虚拟数组,快指针传递数值过来,从头开始覆盖虚拟数组上的位置。
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
slow,fast=0,0 # 初始化快慢指针
l=len(nums)
while fast<l: # 不加等于是因为,fast = l 时,nums[fast] 会越界
# slow 用来收集不等于 val 的值,如果 fast 对应值不等于 val,则把它与 slow 替换
if nums[fast]!=val:
nums[slow]=nums[fast]
slow+=1 #收集到不等于val的值时,slow+=1等待下一个不等于val的值
fast+=1 # fast+=1往前探索
return slow # 返回最终slow停的地方就是新数组的长度
这个算法的时间复杂度为O(n),其中n是输入列表nums的长度。算法使用了双指针的方法,通过一次遍历将不等于val的元素移到数组的前面,并返回最终的元素个数。
在最坏的情况下,需要遍历整个列表,即执行n次循环。每次循环中,只有在nums[fast]不等于val时才会执行元素的替换操作,并将slow指针后移。因此,每个元素最多被访问和替换一次。
因此,总体的时间复杂度为O(n)
双指针法优化
由于题目允许:元素的顺序可以改变。那么,相比于把后续的元素都往前移动一格,我们可以直接用末尾的元素覆盖掉当前元素,避免移动全部数据的同时,还能减少之后检索的数组长度。这个优化在序列中val 元素的数量较少时非常有效。
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
left=0
right=len(nums)
# 设置左右两个区间
while left<right: # 当left=right时,nums[left]会越界
if nums[left]==val:
# 检索到val就用末尾元素覆盖掉他
nums[left]=nums[right-1] #末尾元素索引为right-1
right-=1 # 缩小区间
else:left+=1
# 没检索到val就直接跳过,不对其操作
# 没有else会导致:当首尾元素都是val情况时,末位元素覆盖掉当前元素后,由于left+1,直接跳到后续元素,导致更新后的当前元素没有被去除
return left # left为最后新数组的长度
双指针法总结
双指针法相比于暴力解法,优点在于慢指针对应的虚空数组,形象理解起来可以看作往里面填数就可以了,解法清楚又简单。而暴力解法的覆盖,虽然很清楚,但不好脑补,容易搞晕。