我们只看常用的三种算法:顺序查找(sequential search)、二分查找(binary_search)、哈希表查找(hashing)。并简单分析三种算法的效率。
1. 顺序查找:从头向尾逐次查找,直到找到为止。我们使用Python实现顺序查找功能。
import time
def sequential_search(a_list,item):
pos = 0 # 当前指针位置
found = False
# 如果指针未历遍列表,且当前位置以前未找到该元素,则
while pos < len(a_list) and not found:
if a_list[pos] == item: # 如果当前位置是需要查找的元素
found = True
else:
pos = pos + 1 # 否则指针指向下一个元素
return found
test_list = [1,2,32,8,17,19,42,13,0]
print(sequential_search(test_list,3))
print(sequential_search(test_list,13))
上述是通用的顺序查找算法,针对于一些特殊的列表,如顺序列表(order list),顺序查找算法做出一些微小的调整:如果当前位置的元素大于要查找的元素,则停止,不必再向后检索,因为后面的元素肯定都大于要查找的元素。下面我们来看一个例子。import time
def ordered_sequential_search(a_list,item):
pos = 0 # 当前指针位置
found = False
stop = False
while pos < len(a_list) and not found and not stop:
if a_list[pos] == item:
found = True
else:
if a_list[pos] > item:
stop = True # 如果当前位置的元素大于要查找的元素,则停止
else:
pos = pos + 1 # 指针指向下一个元素
return found
test_list = [0,2,3,8,13,17,19,32,42]
print(ordered_sequential_search(test_list,1))
print(ordered_sequential_search(test_list,32))
2. 二分查找:在一个顺序列表中,从中位数开始查找,并比较所查找的元素与中位数的大小,将列表二分为含查找元素的区间C1,以及不含查找元素的区间C2。然后再对C1进行二分,直到找到为止。
例如有一个列表[0,2,3,8,13,17,19,32,42],假设需要查找到32,若使用顺序查找法,要先历遍0,2,3,8,13,17,19共7个元素。若使用二分法,则首先找到列表中点(0+9)//2=4(实际上是第5个元素13)。
此时中点为13,小于需要查找的32,所以32应该在区间[17,19,32,42]中。
再次查找中点(0+3)//2 = 1 (实际上是第2个元素)。此时中点为19,小于需要查找的32,所以32应该在区间[32,42]中。
再次查找中点(0+1)//2 = 0 (实际上是第1个元素)。此时中点为32,我们找到了需要的元素。
下面是实现二分查找的Python代码:
import time
def binary_search(a_list,item):
starttime = time.clock()
first = 0 # 当前指针位置
last = len(a_list)-1
found = False
while first <= last and not found:
midpoint = (first + last) // 2
if a_list[midpoint] == item:
found = True
else:
if item < a_list[midpoint]: # 如果查找元素小于中点
last = midpoint - 1 # 那么中点作为新的上界
else:
first = midpoint + 1 # 否则作为新的下界
endtime = time.clock()
return found,(endtime-starttime)
test_list = [0,2,3,8,13,17,19,32,42]
print(binary_search(test_list,1))
print(binary_search(test_list,32))
3. 哈希表查找
无论顺序法还是二分法,都无法事先知道我们需要查找的元素的准确位置,因此查找速度较慢。那么我们是否可以通过一些计算事先找到元素的位置,直接到该位置获取元素的值呢?试想一下,如果在列表[1,2,3,4]中,我们事先知道3在第三位,那么直接到第三个位置取值即可,无须从1,2找起,也无需进行二分。
Python里一种最常用的数据结构字典(dictionary)就使用了这种算法。字典内有键(key)和值(data),如dict= {‘1’:’li’, ‘2’:’chen’},当我们需要找学号为2的同学时,程序直接到储存’chen’的位置获取值。
下面我们来看哈希表查找更加具体的原理:
首先我们有一个空的哈希表
现在有一组值item[54,26,93,17,77,31]想要放入哈希表中
我们令h(item) = item % 11,如54%11=10,则放至编号为10的位置,则我们有了一个放入了数值的哈希表
现在我们有了放入数值的哈希表,如果我们需要找学号为54的同学,则只需计算54%11=10,即可直接到编号为10的狭槽(slot)获取值。无须从77开始找起。
哈希表具有一个严重的缺陷:哈希值冲突。例如44%11=0,77%11=0,两个数值被添加到相同狭槽。影响我们查找的结果。
为了解决哈希值冲突的问题,有一个方法便是寻找一个完美的哈希值计算公式,以及足够大的哈希表,保证每一个可能的放入哈希表的值(item),都有一个特别的slot。
实际上这种做法十分地浪费存储空间。比如一个学号可能长达9位,有一亿种可能,需要准备一亿个slot。
我们的目标是找到一个哈希计算公式,能够最小化哈希值冲突,容易计算,且使item均匀分布在哈希表之中。
1)我们使用的方法是线性探测(linear probing)。我们想要将44加入哈希表中
经过计算44%11=0,发现slot [0] 已经有值,那么我们只需要找到下一个空的slot。即将44放入slot [1] 内。
我们再往里面添加55%11=0,此时slot [0] 里面已经有值,向后位移三位到slot [3],已经有值,向后位移三位到slot [6],已经有值,向后位移三位到slot [9],已经有值,向后位移三位到slot [1],空值,则放进去。
我们继续往里面添加20%11=9,此时slot [9] 里面已经有值,向后位移三位到slot [1],已经有值,向后位移三位到slot [4],已经有值,向后位移三位到slot [7],空值,则放进去。
2)线性探测的一种变形——二次探测(quadratic probing)
直观上很容易理解,如果我们要查找55,则根据55%44=0找到slot [0] 这一列,然后再按照77-44-55的顺序找到55。
3)我们使用python创建一个HashTable类, 实现哈希表查找。这个HashTable类,实际上是简单的字典数据结构。
import time
class HashTable:
def __init__(self):
self.size = 11
self.slots = [None] * self.size # 创建一个空的哈希表(第一行)
self.data = [None] * self.size # 创建一个空的哈希表(第二行)
def put(self,key,data):
hash_value = self.hash_function(key,len(self.slots)) # 获得应该放入的slot的编号
if self.slots[hash_value] == None: # 如果该slot为空
self.slots[hash_value] = key # 则放入该slot
self.data[hash_value] = data
else:
if self.slots[hash_value] == key: # 如果该slot已有相同的key
self.data[hash_value] =data # 则替换其value
else:
next_slot = self.rehash(hash_value,len(self.slots)) # 如果该slot非空,则移至下一个slot
while self.slots[next_slot] != None and self.slots[next_slot] != key:
next_slot = self.rehash(next_slot,len(self.slots)) # 知道找到空slot或有相同key的slot
if self.slots[next_slot] == None:
self.slots[next_slot] = key
self.data[next_slot] = data
else:
self.data[next_slot] = data # 替换
def hash_function(self,key,size):
return key % size # 返回slot的编号,如55%11=0
def rehash(self,old_hash,size):
return (old_hash + 1) % size # 返回下一个slot的编号 (0+1)%11=1
def get(self,key):
start_slot = self.hash_function(key,len(self.slots)) # 55%11=0
data = None
stop = False
found = False
position = start_slot
# 直到找到或历遍哈希表为止
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)) # 移到下一个slot
if position == start_slot: # 如果已经历遍哈希表,则停止
stop = True
return data
def __getitem__(self,key):
return self.get(key)
def __setitem__(self,key,data):
self.put(key,data)
h = HashTable()
h[54] = "cat" # 自动调用类中定义的方法__setitem__
h[26] = "dog"
h[93] = "lion"
h[17] = "tiger"
h[77] = "bird"
h[31] = "cow"
h[44] = "goat"
h[55] = "pig"
h[20] = "chicken"
print(h.slots)
print(h.data)
print(h[20])
4. 三种方法的分析对比
我们使用大O表示法来体现算法时间复杂度:
O(1)表示该算法的执行时间(或执行时占用空间)总是为一个常量,不论输入的数据集是大是小。
O(N)表示一个算法的性能会随着输入数据的大小变化而线性变化。
……
1)顺序查找:
当列表的元素越多,顺序查找的次数n越大(线性),算法复杂度为O(N)。
当列表的元素越多,顺序查找的次数n越大(线性),算法复杂度为O(N)。
在一个经过排序的列表中查找时
此时算法复杂度仍然为O(N)。
2)二分查找
两分法就是将列表中的元素均分成两部分,直到剩下1个元素为止。如果列表中有n个元素,令计算次数为i,则n/2^(i)=1。例如列表中有2个元素,则2/2^(1)=1,计算1次,有4个元素,则2/2^(2)=1,计算2次。解得i=log2(n)。此时算法复杂度为O(log n)。
3)哈希表查找
如果仅仅是简单的哈希表,算法复杂度仅仅为O(1),因为我们可以经过1次计算(或者常数次)即可找到。哈希表的优点在查找快捷,在数据通信领域常常得到运用。缺点在于对储存空间的消耗。
参考资料:Problem Solving with Algorithms and Data Structures