搜索

快速总结:

  • 不论列表是否有序,顺序搜索算法的时间复杂度都是 $O(n)$。
  • 对于有序列表来说,二分搜索算法在最坏情况下的时间复杂度是 $O(\log n)$。
  • 基于散列表的搜索算法可以达到常数阶。

Python 提供了运算符 in,通过它可以方便地检查元素是否在列表中。

>>> 15 in [3, 5, 2, 4, 1]
False
>>> 3 in [3, 5, 2, 4, 1]
True
>>>

顺序搜索

无序列表的顺序搜索

python_searching_fig_1.png

上图展示了无序列表顺序搜索的原理。从列表中的第一个元素开始,沿着默认的顺序逐个查看,直到找到目标元素或者查完列表。如果查完列表后仍没有找到目标元素,则说明目标元素不在列表中。

假设元素的排列是无序的,要确定目标元素不在列表中,唯一的方法就是将它与列表中的每个元素都比较一次,所以顺序搜索算法的时间复杂度是 $O(n)$。

def sequentialSearch(alist, item):
    pos = 0
    found = False

    while pos < len(alist) and not found:
        if alist[pos] == item:
            found = True
        else:
            pos = pos+1

    return found

这个函数接受列表与目标元素作为参数,并返回一个表示目标元素是否存在的布尔值。布尔型变量 found 的初始值为 False,如果找到目标元素,就将它的值改为 True

有序列表的顺序搜索

python_searching_fig_2.png

假设列表中的元素按升序排列,上图展示了无序列表顺序搜索的原理。如果存在目标元素,那么它出现在 n 个位置中任意一个位置的可能性仍然一样大,因此比较次数与在无序列表中相同,算法的时间复杂度仍是 $O(n)$。不过,如果不存在目标元素,那么搜索效率就会提高。

def orderedSequentialSearch(alist, item):
    pos = 0
    found = False
    stop = False
    while pos < len(alist) and not found and not stop:
        if alist[pos] == item:
            found = True
        else:
            if alist[pos] > item:
                stop = True
            else:
                pos = pos+1

    return found

有序列表的二分搜索

二分搜索不是从第一个元素开始搜索列表,而是从中间的元素着手。如果这个元素就是目标元素,那就立即停止搜索;如果不是,则可以利用列表有序的特性,排除一半的元素。

python_searching_fig_3.png

def binarySearch(alist, item):
    first = 0
    last = len(alist)-1
    found = False

    while first<=last and not found:
        midpoint = (first + last)//2
        if alist[midpoint] == item:
            found = True
        else:
            if item < alist[midpoint]:
                last = midpoint-1
            else:
                first = midpoint+1

    return found

二分搜索的递归版本:

def binarySearch(alist, item):
    if len(alist) == 0:
        return False
    else:
        midpoint = len(alist)//2
        if alist[midpoint]==item:
          return True
        else:
          if item<alist[midpoint]:
            return binarySearch(alist[:midpoint],item)
          else:
            return binarySearch(alist[midpoint+1:],item)

分析二分搜索算法:

比较次数 剩余元素的近似个数
$1$ $\frac{n}{2}$
$2$ $\frac{n}{4}$
$3$ $\frac{n}{8}$
$\vdots$ $\vdots$
$i$ $\frac{n}{2^i}$

拆分足够多次后,会得到只含一个元素的列表。这个元素要么就是目标元素,要么不是。无
论是哪种情况,计算工作都已完成。要走到这一步,需要比较 i 次,其中 $\frac{n}{2^i}=1$。由此可得,$i=\log n$。所以,二分搜索算法的时间复杂度是 $O(\log n)

散列

散列表是元素集合,其中的元素以一种便于查找的方式存储。散列表中的每个位置通常被称为槽,其中可以存储一个元素。槽用一个从 0 开始的整数标记,例如 0 号槽、 1 号槽、 2 号槽,等等。初始情形下,散列表中没有元素,每个槽都是空的。可以用列表来实现散列表,并将每个元素都初始化为 Python 中的特殊值 None

python_searching_fig_4.png

上图展示了大小 m11 的散列表。也就是说,表中有 m 个槽,编号从 010

散列函数将散列表中的元素与其所属位置对应起来。对散列表中的任一元素,散列函数返回一个介于 0m – 1 之间的整数。下表给出了使用余数作为散列值的结果。

元素 散列值
54 10
26 4
93 5
17 6
77 0
31 9

计算出散列值后,就可以将每个元素插入到相应的位置,如下图所示。

python_searching_fig_5.png

注意,在 11 个槽中,有 6 个被占用了。占用率被称作载荷因子,记作$\lambda$定义如下:

$$
\lambda = \frac {元素个数}{散列表大小}
$$

在本例中,$\lambda = \frac {6}{11}$。

搜索目标元素时,仅需使用散列函数计算出该元素的槽编号,并查看对应的槽中是否有值。因为计算散列值并找到相应位置所需的时间是固定的,所以搜索操作的时间复杂度是 $O(1)$

散列函数

散列函数会将两个元素都放入同一个槽,这种情况被称作冲突,也叫“碰撞”。

给定一个元素集合,能将每个元素映射到不同的槽,这种散列函数称作完美散列函数。如果
元素已知,并且集合不变,那么构建完美散列函数是可能的。

我们的目标是创建一个散列函数:冲突数最少,计算方便,元素均匀分布于散列表中。

下面代码给出了 hash 函数的定义,传入一个字符串和散列表的大小,该函数会返回散列值,其取值范围是 0tablesize-1

python_searching_fig_6.png

def hash(astring, tablesize):
    sum = 0
    for pos in range(len(astring)):
        sum = sum + ord(astring[pos])

    return sum%tablesize

但是,针对异序词,这个散列函数总是得到相同的散列值。要弥补这一点,可以用字符位置作为权重因子

python_searching_fig_7.png

处理冲突

当两个元素被分到同一个槽中时,必须通过一种系统化方法在散列表中安置第二个元素,这个过程被称为处理冲突

方法一:在散列表中找到另一个空槽

线性探测:简单的做法是从起初的散列值开始,顺序遍历散列表,直到找到一个空槽。注意,为了遍历散列表,可能需要往回检查第一个槽。这个过程被称为开放定址法,它尝试在散列表中逐个寻找下一个空槽或地址。

线性探测有个缺点,那就是会使散列表中的元素出现聚集现象。也就是说,如果一个槽发生太多冲突,线性探测会填满其附近的槽,而这会影响到后续插入的元素。

“加 3”探测:要避免元素聚集,一种方法是扩展线性探测,不再依次顺序查找空槽,而是跳过一些槽,这样做能使引起冲突的元素分布得更均匀。

再散列:泛指在发生冲突后寻找另一个槽的过程。

  • 线性探测:再散列函数是 newhashvalue = rehash(oldhashvalue),且 rehash(pos) = (pos + 1)%sizeoftable
  • “加 3”探测:再散列函数可以定义为 rehash(pos) = (pos + 3)%sizeoftable
  • 也就是说,可以将再散列函数定义为 rehash(pos) = (pos + skip)%sizeoftable

    注意: “跨步”(skip)的大小要能保证表中所有的槽最终都被访问到,否则就会浪费槽资源。要保证这一点,常常建议散列表的大小为素数

平方探测:线性探测的一个变体,它不采用固定的跨步大小,而是通过再散列函数递增散列值。如果第一个散列值是 h,后续的散列值就是 h+1h+4h+9h+16,等等。

方法二:让每个槽有一个指向元素集合(或链表)的引用

链接法:允许散列表中的同一个位置上存在多个元素。发生冲突时,元素仍然被插入其散列值对应的槽中。不过,随着同一个位置上的元素越来越多,搜索变得越来越困难。

python_searching_fig_8.png

搜索目标元素时,我们用散列函数算出它对应的槽编号。由于每个槽都有一个元素集合,因此需要再搜索一次,才能得知目标元素是否存在。链接法的优点是,平均算来,每个槽的元素不多,因此搜索可能更高效。

实现映射抽象数据类型

字典是最有用的 Python 集合之一。字典是存储键–值对的数据类型。键用来查找关联的值,这个概念常常被称作映射

映射抽象数据类型:将键和值关联起来的无序集合,其中的键是不重复的,键和值之间是一一对应的关系。映射支持以下操作:

  • Map() 创建一个空的映射,它返回一个空的映射集合。
  • put(key, val) 往映射中加入一个新的键–值对。如果键已经存在,就用新值替换旧值。
  • get(key) 返回 key 对应的值。如果 key 不存在,则返回 None
  • del 通过 del map[key] 这样的语句从映射中删除键–值对。
  • len() 返回映射中存储的键–值对的数目。
  • in 通过 key in map 这样的语句,在键存在时返回 True,否则返回 False

下面代码使用两个列表创建 HashTable 类,以此实现映射抽象数据类型。

class HashTable:
    def __init__(self):
        self.size = 11
        self.slots = [None] * self.size
        self.data = [None] * self.size

名为 slots 的列表用于存储键,名为 data 的列表用于存储值。两个列表中的键与值一一对应。在本例中,散列表的初始大小是 11。散列表选为素数,可以尽可能地提高冲突处理算法的效率。

put 函数:

def put(self,key,data):
    hashvalue = self.hashfunction(key,len(self.slots))

    if self.slots[hashvalue] == None:
        self.slots[hashvalue] = key
        self.data[hashvalue] = data
    else:
        if self.slots[hashvalue] == key:
            self.data[hashvalue] = data  #replace
       else:
            nextslot = self.rehash(hashvalue,len(self.slots))
            while self.slots[nextslot] != None and \
                      self.slots[nextslot] != key:
                nextslot = self.rehash(nextslot,len(self.slots))

            if self.slots[nextslot] == None:
                self.slots[nextslot]=key
                self.data[nextslot]=data
            else:
                self.data[nextslot] = data #replace

def hashfunction(self,key,size):
     return key%size

def rehash(self,oldhash,size):
    return (oldhash+1)%size

hashfunction 实现了简单的取余函数。处理冲突时,采用“加 1”再散列函数的线性探测法put 函数假设,除非键已经在 self.slots 中,否则总是可以分配一个空槽。该函数计算初始的散列值,如果对应的槽中已有元素,就循环运行 rehash 函数,直到遇见一个空槽。如果槽中已有这个键,就用新值替换旧值。

get 函数:

def get(self,key):
    startslot = self.hashfunction(key,len(self.slots))

    data = None
    stop = False
    found = False
    position = startslot
    while self.slots[position] != None and  \
                       not found and not stop:
       if self.slots[position] == key:
           found = True
           data = self.data[position]
       else:
           position=self.rehash(position,len(self.slots))
           # 确保搜索最终一定能结束,因为不会回到初始槽
           # 如果遇到初始槽,就说明已经检查完所有可能的槽,并且元素必定不存在。
           if position == startslot: 
               stop = True
    return data

def __getitem__(self,key):
    return self.get(key)

def __setitem__(self,key,data):
    self.put(key,data)

get 函数先计算初始散列值,如果值不在初始散列值对应的槽中,就使用 rehash 确定下一个位置。

HashTable 类的最后两个方法提供了额外的字典功能。我们重载 __getitem____setitem__,以通过 [] 进行访问。这意味着创建 HashTable 类之后,就可以使用熟悉的索引运算符了。

分析散列搜索算法

采用线性探测策略的开放定址法,搜索成功的平均比较次数如下:

$$
\frac{1}{2}\left(1+\frac{1}{1-\lambda}\right)
$$

搜索失败的平均比较次数如下:

$$
\frac{1}{2}\left(1+\left(\frac{1}{1-\lambda}\right)^2\right)
$$

采用链接法,则搜索成功的平均比较次数如下:

$$
1 + \frac {\lambda}{2}
$$

搜索失败时,平均比较次数就是 $\lambda$。