查找算法

🧩线性查找

原理和实现

线性查找 Linear Search:是一种最基础的查找方法,其从数据结构的一端开始,依次访问每个元素,直到另一端后停止。
线性查找实质上就是遍历数据结构 + 判断条件。

比如,我们想要在数组 nums 中查找目标元素 target 的对应索引,那么可以在数组中进行线性查找。

linear_search

python实现

""" 线性查找(数组) """
def linear_search(nums, target):
    # 遍历数组
    for i in range(len(nums)):
        if nums[i] == target:  # 找到目标元素,返回其索引
            return i
    return -1                  # 未找到目标元素,返回 -1

再比如,我们想要在给定一个目标结点值 target ,返回此结点对象,也可以在链表中进行线性查找。

""" 线性查找(链表) """
def linear_search1(head, target):
    # 遍历链表
    while head:
        if head.val == target: # 找到目标结点,返回之
            return head
        head = head.next
    return None                # 未找到目标结点,返回 None

复杂度

时间复杂度 O(n) : 其中 n 为数组或链表长度。

空间复杂度 O(1) : 无需使用额外空间。

优缺点

  • 线性查找的通用性极佳。 由于线性查找是依次访问元素的,即没有跳跃访问元素,因此数组或链表皆适用。

  • 线性查找的时间复杂度太高。 在数据量 n 很大时,查找效率很低。

🧩二分查找

05f082b5b2d33ef52c9df4756748eee9

原理和实现

二分查找,又叫折半查找,英文名: Binary Search

前提条件要查找的数必须在一个有序数组里。

二分查找仅适用于数组 ,而在链表中使用效率很低,因为其在循环中需要跳跃式(非连续地)访问元素。

在这个前提下,取中间位置数作为比较对象:

  • 若要查找的值和中间数相等,则查找成功。
  • 若小于中间数,则在中间位置的左半区继续查找。
  • 若大于中间数,则在中间位置的右半区继续查找。

不断重复上述过程,直到查找成功或者查找区域变为 0,查找失败。

二分查找在我们的生活中随处可见,比如写好一个 100 以内的正整数,猜我写的是哪一个,我只回答是大了还是小了。

由于是 100 以内的正整数,按照二分查找规则,第 1 次先猜 50,如果我说大了,那第 2 次就猜 25,如果我说小了,那第 2 次就猜 75,以此往复。

假设查找的数字为1:

查找数字
1 50 25 12 6 3 2 1

只花了 7 次就能找到,是不是很快,而且这还是在最坏情况下,也就是 100 个数的二分查找最多 7 次就能知道结果。

1b2db5cd444bb9d2d0b913b91bdded61

根据二分查找的思想,1000 个数的二分查找最多只需要查找 10 次,10000 以内的数最多只需要查找 14 次,有谁不相信可以自己动手试试,如果你不怕累的话。

二分查找涉及的很多的边界条件,逻辑比较简单,但就是写不好。例如到底是 while(left < right) 还是 while(left <= right),到底是right = middle呢,还是要right = middle - 1呢?

大家写二分法经常写乱,主要是因为对区间的定义没有想清楚,区间的定义就是不变量。要在二分查找的过程中,保持不变量,就是在while寻找中每一次边界的处理都要坚持根据区间的定义来操作,这就是循环不变量规则。

写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right)。

python实现:

左闭右闭区间写法:

from typing import List
class Solution:
    def search(self, nums, target):
        left, right = 0, len(nums) - 1

        while left <= right:          # 根据区间定义,带等号是为了满足区间要求,[a,a]这样的区间也是成立的
            mid = (left + right) // 2
           #mid=left+(right-left)//2
            if nums[mid] < target:
                left = mid + 1
            elif nums[mid] > target:
                right = mid - 1       # 既然nums[mid] > target,那么target一定不等于nums[mid],
                                      #即下一个区间一定不用包含nums[mid],所以是mid-1
            else:
                return mid
        return -1                     # 不在数组内返回-1

a=Solution()
print(a.search([-1, 0, 3, 5, 9, 12], 9))

左闭右开区间写法:

from typing import List
class Solution:
    def search(self, nums, target):
        left, right = 0, len(nums)    #右开区间,右边界取不到,因此不用-1

        while left < right:          
            mid = (left + right) // 2
            if nums[mid] < target:
                left = mid + 1
            elif nums[mid] > target:
                right = mid            # 右边界取不到  
            else:
                return mid
        return -1

a=Solution()
print(a.search([-1, 0, 3, 5, 9, 12], 9))

两种表示对比

对比下来,两种表示的代码写法有以下不同点:

表示方法 初始化指针 缩小区间 循环终止条件
双闭区间 [0,n−1] i=0 , j=n−1 i=m+1 , j=m−1 i>j
左闭右开 [0,n) i=0 , j=n i=m+1 , j=m i=j

观察发现,在 “双闭区间” 表示中,由于对左右两边界的定义是相同的,因此缩小区间的 i , j 处理方法也是对称的,这样更不容易出错。综上所述,建议你采用 “双闭区间” 的写法。

注意点

循环条件:闭区间时为left <= right,左闭右开区间时为left < right
low和high的取值
mid的取值: mid = (left + right) // 2 防止溢出,可以写成: mid=left+(right-left)//2

复杂度

二分查找的时间复杂度是 O(logn)

一个数组长度为 n,以为二分查找每次查完,要查找的区间都会变成原来的一半,最坏情况下,一直到查找空间为 0 才停止。所以它的查找区间会第 1 次是 n,第 2 次是 n/2,第 3 次是 (n/2)/2,假设循环 x 次,那公式变成:

image-20221207141145912

图片

因此:图片

优缺点

二分查找效率很高,体现在:

  • 二分查找时间复杂度低。 对数阶在数据量很大时具有巨大优势,例如,当数据大小 (n = 2^{20}) 时,线性查找需要 (2^{20} = 1048576) 轮循环,而二分查找仅需要 (\log_2 2^{20} = 20) 轮循环。
  • 二分查找不需要额外空间。 相对于借助额外数据结构来实现查找的算法来说,其更加节约空间使用。

但并不意味着所有情况下都应使用二分查找,这是因为:

  • 二分查找仅适用于有序数据。 如果输入数据是无序的,为了使用二分查找而专门执行数据排序,那么是得不偿失的,因为排序算法的时间复杂度一般为 (O(n \log n)) ,比线性查找和二分查找都更差。再例如,对于频繁插入元素的场景,为了保持数组的有序性,需要将元素插入到特定位置,时间复杂度为 (O(n)) ,也是非常昂贵的。
  • 二分查找仅适用于数组。 由于在二分查找中,访问索引是 ”非连续“ 的,因此链表或者基于链表实现的数据结构都无法使用。
  • 在小数据量下,线性查找的性能更好。 在线性查找中,每轮只需要 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,在数据量 (n) 较小时,线性查找反而比二分查找更快。

🧩哈希查找

在数据量很大时,「线性查找」太慢;而「二分查找」要求数据必须是有序的,并且只能在数组中应用。那么是否有方法可以同时避免上述缺点呢?答案是肯定的,此方法被称为「哈希查找」。

原理和实现

哈希查找 Hash Searching:借助一个哈希表来存储需要的「键值对 Key Value Pair」,我们可以在 (O(1)) 时间下实现 “键 (\rightarrow) 值” 映射查找,体现着 “以空间换时间” 的算法思想。

如果我们想要给定数组中的一个目标元素 target ,获取该元素的索引,那么可以借助一个哈希表实现查找。

hash_search_index
""" 哈希查找(数组) """
def hashing_search(mapp, target):
    # 哈希表的 key: 目标元素,value: 索引
    # 若哈希表中无此 key ,返回 -1
    return mapp.get(target, -1)

再比如,如果我们想要给定一个目标结点值 target ,获取对应的链表结点对象,那么也可以使用哈希查找实现。

hash_search_listnode
"""  哈希查找(链表) """
def hashing_search1(mapp, target):
    # 哈希表的 key: 目标元素,value: 结点对象
    # 若哈希表中无此 key ,返回 -1
    return mapp.get(target, -1)

复杂度

时间复杂度: O(1) ,哈希表的查找操作使用 O(1) 时间。

空间复杂度: O(n) ,其中 n 为数组或链表长度。

优缺点

在哈希表中,查找、插入、删除操作的平均时间复杂度都为 O(1) ,这意味着无论是高频增删还是高频查找场景,哈希查找的性能表现都非常好。当然,一切的前提是保证哈希表未退化。

即使如此,哈希查找仍存在一些问题,在实际应用中,需要根据情况灵活选择方法。

  • 辅助哈希表 需要使用 O(n) 的额外空间,意味着需要预留更多的计算机内存;
  • 建立和维护哈希表需要时间,因此哈希查找 不适合高频增删、低频查找的使用场景
  • 当哈希冲突严重时,哈希表会退化为链表,时间复杂度劣化至 O(n)
  • 当数据量很小时,线性查找比哈希查找更快。这是因为计算哈希映射函数可能比遍历一个小型数组更慢;

查找算法小结

  • 线性查找是一种最基础的查找方法,通过遍历数据结构 + 判断条件实现查找。
  • 二分查找利用数据的有序性,通过循环不断缩小一半搜索区间来实现查找,其要求输入数据是有序的,并且仅适用于数组或基于数组实现的数据结构。
  • 哈希查找借助哈希表来实现常数阶时间复杂度的查找操作,体现以空间换时间的算法思想。

Table. 三种查找方法对比

线性查找 二分查找 哈希查找
适用数据结构 数组、链表 数组 数组、链表
输入数据要求 有序
平均时间复杂度 查找 / 插入 / 删除 (O(n)) / (O(1)) / (O(n)) (O(\log n)) / (O(n)) / (O(n)) (O(1)) / (O(1)) / (O(1))
最差时间复杂度 查找 / 插入 / 删除 (O(n)) / (O(1)) / (O(n)) (O(\log n)) / (O(n)) / (O(n)) (O(n)) / (O(n)) / (O(n))
空间复杂度 (O(1)) (O(1)) (O(n))
posted @ 2022-12-07 14:41  happyfeliz  阅读(85)  评论(0)    收藏  举报