Python数据结构与算法分析
第五章 搜索和排序
5.1 何谓搜索
搜索是指从元素集合中找到某个特定元素的算法过程。搜索过程通常返回 True 或 False,分别表示元素是否存在。有时,可以修改搜索过程,使其返回目标元素的位置。
5.1.1 顺序搜索
- 存储于列表等集合中的数据项彼此存在线性或顺序的关系,每个数据项的位置与其他数据项相关。
- 在 Python 列表中,数据项的位置就是它的下标(index),这些下标都是有序的整数。
- 为下标是有序的,所以能够顺序访问,由此可以进行顺序搜索。
顺序查找步骤
确定列表总是否存在需要查找的数据项
从列表中的第一个元素开始,沿着默认的顺序逐个查看,直到找到目标元素或者查完列表。如果查完列表后仍没有找到目标元素,则说明目标元素不在列表中
顺序搜索:无序表搜索
## 5.1 顺序搜索
### 5.1.1 顺序搜索:无序表搜索
def sequentialSearch(alist,item):
pos = 0 #下标初始化为0
found = False
while pos < len(alist) and not found:
if alist[pos] == item:
return True
else:
pos = pos+1 #下标位置前移
return found
testlist = [1,2,32,8,17,19,42,13,0]
print(sequentialSearch(testlist,3))
print(sequentialSearch(testlist,13))
分析顺序搜索算法
元素的排列是无序的,随机放置在列表的任何位置,目标元素位于每个位置的可能性都一样大。
要确定目标元素不在列表中,唯一的方法就是将它与列表中的每个元素都比较一次。如果列表中有 n 个元素,那么顺序搜索要经过 n 次比较后才能确定目标元素不在列表中。
- 最好情况:目标元素位于列表的第一个位置,即只需比较1次
- 最坏情况:目标元素位于最后一个位置,即需要比较 n次
- 普通情况:列表的中间位置处找到目标元素,即需要比较2/n次。
顺序搜索:有序表搜索
假设列表中的元素按升序排列。如果存在目标元素,那么它出现在 n 个位置中任意一个位置的可能性仍然一样大,因此比较次数与在无序列表中相同。不过,如果不存在目标元素,那么搜索效率就会提高。
- 如下图中查找目标元素50,当看到54时,可知道后面不存在50,可提前退出
def sequentialSearch(alist,item):
pos = 0
found = False
while pos < len(alist) and not found:
if alist[pos] == item:
return True
else:
if alist[pos] >item:
return False
else:
pos = pos+1
return found
testlist = [17,20,26,31,34,45,54,55,65,77,93]
print(sequentialSearch(testlist,50))
5.2 二分搜索
二分搜索从中间的元素着手,如果这个元素就是目标元素,那就立即停止搜索;如果不是,则可以利用列表有序的特性,排除一半的元素。如果目标元素比中间的元素大,就可以直接排除列表的左半部分和中间的元素。这是因为,如果列表包含目标元素,它必定位于右半部分
5.2.1 二分搜索:有序表搜索
### 5.2.1 二分搜索:有序表搜索
def binarySearch(alist,item):
first = 0 #初始下标值 头部=0
last = len(alist)-1 # 尾部= 列表长度-1
found = False
if 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
testlist = [17,20,26,31,34,45,54,55,65,77,93]
print(binarySearch(testlist,17))
5.2.2 二分搜索:递归版
### 5.2.2 二分搜索:递归版
def binarySearch(alist,item):
if len(alist) == 0:
return False #基本情况 空列表
else:
midPoint = len(alist)//2 #减小规模
if alist[midPoint] == item: #如果中间对应的值=目标值
found = True
else:#否则 调用自身
if item < alist[midPoint]:#目标值小于中间对应值,
return binarySearch(alist[:midPoint],item)#目标在列表左半部分 尾部前移
else:#目标值大于中间对应值
return binarySearch(alist[midPoint+1:],item)#目标在列表右半部分 头部前移
5.2.3 分析二分搜索算法
要检查完整个列表,二分搜索算法最多要比较多少次呢?
设列表共有 n 个元素,
第1次比较后剩下n/2个元素
第2次比较后剩下n/4个元素
接下来是n/8 ,然后n/16 ,依此类推。由此可得,i=logn 。
比较次数的最大值与列表的元素个数是对数关系。所以,二分搜索算法的时间复杂度是O(logn) 。
尽管二分搜索通常优于顺序搜索,但当 n 较小时,排序引起的额外开销可能并不划算。实际上应该始终考虑,为了提高搜索效率,额外排序是否值得。如果排序一次后能够搜索多次,那么排序的开销不值一提。然而,对于大型列表而言,只排序一次也会有昂贵的计算成本,因此从头进行顺序搜索可能是更好的选择。
5.3 散列 hashing
散列表是元素集合,其中的元素以一种便于查找的方式存储,使搜索次数降低到常数级别。散列表中的每个位置通常被称为槽,其中可以存储一个元素。槽用一个从 0 开始的整数标记,例如 0 号槽、1 号槽、2 号槽,等等。可以用列表来实现散列表,并将每个元素都初始化为 Python 中的特殊值 None。
散列函数将散列表中的元素与其所属位置对应起来。对散列表中的任一元素,散列函数返回一个介于 0 和 m – 1 之间的整数。
假设有一个由整数元素 54、26、93、17、77 和 31 构成的集合。首先来看第一个散列函数,它有时被称作“取余函数”,即用一个元素除以表的大小,并将得到的余数作为散列值h(item) = item%11
在 11 个槽中,有 6 个被占用了。占用率被称作载荷因子,记作
如果集合中的下一个元素是 44,它的散列值是 0(44%11==0),而 77 的散列值也是 0,这就有问题了。散列函数会将两个元素都放入同一个槽,这种情况被称作冲突,也叫“碰撞”。
5. 3. 1 散列函数:完美散列函数
给定一个元素集合,能将每个元素映射到不同的槽,这种散列函数称作完美散列函数。
- 构建方法
构建完美散列函数的一个方法是增大散列表,使之能容纳每一个元素,这样就能保证每个元素都有属于自己的槽。当元素很多时,就不可行了
- 冲突最少(近似完美)
- 计算难度低(额外开销小)
- 充分分散数据项(节约空间)
- 用途
5. 3. 2 散列函数:区块链技术
区块链是一种分布式数据库,通过网络连接的节点,每个节点都保存着整个数据库所有数据,任何地点存入的数据会完成同步
本质特征:“去中心化”
- 不存在任何控制中心,协调中心节点
- 所有节点都是平等,无法被控制
结构
-
由区块(block)组成,分为头(head)和体(body)
-
区块头记录了一些元数据和链接到前一个区块的信息
-生成时间,前一个区块(head+body)的散列值
-
区块体记录了实际数据
-
5. 3. 3 散列函数设计:折叠法
-
将数据项按照位数分为若干段
-
再将几段数字相加
-
最后对散列表大小求余,得到散列值
- 例如,对电话号码62767255,可以两位两位分为4段(62,76,72,55)
- 相加(62+76+72+55=265)
- 散列表包括11个槽,那么就是265%11=1
- 所以h(62767255)=1
-
隔数反转:,在加总前每隔一个数反转一次。
- (62,76,72,55)隔数反转(62,47,72,55)
- 累加(62+67+72+55=256)
- 求余(256%11=3)
- 所以h(62767255)=3
5. 3. 3 散列函数设计:平方取中法
首先将数据项做平方运算,然后取平方数的中间两位,再对散列表的大小求余
例如,对44进行散列
- 首先44*44=1936
- 取中 93
- 对11求余 93%11 =5
- h(44)=5
5. 3. 3 散列函数设计:非数项
为基于字符的元素(比如字符串)创建散列函数。可以将单词“cat”看作序数值序列,如下所示。
再将这些整数累加,对散列表大小求余
def hash(astring, tablesize):
sum = 0
for pos in range(len(astring)):
sum = sum + ord(astring[pos])
return sum%tablesize
针对异序词,这个散列函数总是得到相同的散列值。要弥补这一点,可以用字符位置作为权重因子
5.3.4 冲突解决方案
5.3.4.1开放定址
一种方法是在散列表中找到另一个空槽,用于放置引起冲突的元素。
- 从冲突的槽开始往后扫描,直到碰到一个空槽
- 如果到散列表尾部还未找到,则从首部接着扫描
线性探测
往后逐个槽寻找是开放定址技术中的‘线性探测’
我们把44、55、20 逐个插入到散列表中
h(44) = 0 ;#0槽以及被77占据,向后找到第一个空槽1#,保存
h(55) = 0 ;#0槽以及被占据,向后找空槽#2,保存
h(20) = 9 ;#9槽已经被31占据,向后找已经没有空槽,再从头找找到空槽#3,保存
线性探测改进
- 线性探测的缺点:会使散列表中的元素出现聚集,如果一个槽发生太多冲突,线性探测会填满其附近的槽,而这会影响到后续插入的元素。
- 改进:从逐个探测改为跳跃探测
'+3' 探测插入44、55、20
再散列
重新寻找空槽的过程被称为再散列
采用线性探测时,再散列函数是newhashvalue = rehash(oldhashvalue)
rehash(pos) = (pos + 1)%sizeoftable
采用'+3'跳跃探测:rehash(pos) = (pos + 3)%sizeoftable
通式:rehash(pos) = (pos + skip)%sizeoftable
平方探测
通过再散列函数递增散列值。如果第一个散列值是 h,后续的散列值就是h+1、h+4、h+9、h+16
5.3.4.2 链接法
- 让每个槽有一个指向元素集合(或链表)的引用。
5.3.5 抽象数据类型 "映射"
- 字典是最有用的 Python 集合之一
- 字典是存储键key–值对value的数据类型。键用来查找关联的值,这个概念常常被称作映射
- ADT Map是键和值关联起来的无序集合,具有唯一性,键和值是一一对应的关系
ADT Map定义操作如下:
- Map() 创建一个空的映射,它返回一个空的映射集合
- put(key, val) 往映射中加入一个新的键–值对。如果键已存在,就用 新值替换旧值
- get(key) 返回 key 对应的值。如果 key 不存在,则返回 None
- del 通过 del map[key] 这样的语句从映射中删除键–值对
- len() 返回映射中存储的键–值对的数目
- in 通过 key in map 这样的语句,在键存在时返回 True,否则返回 False
实现映射抽象数据类型
- 使用两个列表创建 HashTable 类
- 名为 slots 的列表用于存储键
- 名为 data 的列表用于存储值
- 在slots 列表查找到一个key的位置以后,在data列表对应相同位置的数据项即为关联数据
class HashTable:
def __init__(self): #初始化
self.size = 11
self.slots = [None]*self.size
self.data = [None]*self.size
def hashfunction(self,key):
return key % self.size #求余数 得 下标
def rehash(self,oldhash): #+1 线性探测
return (oldhash+1) % self.size
def put(self,key,data):
hashvalue = self.hashfunction(key) #求散列值
if self.slots[hashvalue] == None:# 对应空槽,无冲突,保存key和data
self.slots[hashvalue] = key
self.data[hashvalue] = data
else: #key已经存在,则替换
if self.slots[hashvalue] == key:
self.data[hashvalue] = data #替换
else: #key 存在,槽已经被占,发生散列冲突
nextslot = self.rehash(hashvalue) #在散列
while self.slots[nextslot] != None and self.slots[nextslot] != key: #向后逐个探测空槽
nextslot = self.rehash(nextslot)
if self.slots[nextslot] == None: # 对应空槽,无冲突,保存key和data
self.slots[nextslot] = key
self.data[nextslot] = data
else:#key已经存在,则替换
self.data[nextslot] = data
def get(self,key):
startslot = self.hashfunction(key) #找到key 所对应的槽值 标记为起点
data = None
stop = False
found = False
position = startslot
while self.slots[position] != None and not found and not stop: #当槽不为空
if self.slots[position] == key: #找到key
found = True
data = self.data[position] #取出data
else:#找不到key
position = self.rehash(position) #再散列 找空槽 key不存在
if position == startslot:#往后找不到,从前找,找了一圈回到起点 依旧没找到 停止找到
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"
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])
5.4 排序
5.4.1 冒泡排序
- 对无序表进行多次比较交换
- 每趟包括了多次两两相邻比较,将逆序的数据项互换位置
- 经过n-1趟比较交换,实现整表排序
- 第一趟比较交换,共有n-1对相邻数据进行比较
- 第二趟比较交换,最大项已经就位,需要排序的数据减少为n-1,共需要进行n-2次比较
- 直到第n-1趟完成后,最小项一定在列表首位,无需处理
def bubbleSort(alist):
for passnum in range(len(alist)):#获取列表长度
for i in range(passnum):
if alist[i] > alist[i+1]:#前数大于本身
alist[i],alist[i+1] = alist[i+1],alist[i] #Python执行语句 a, b = b, a,相当于同时执行两条赋值语句
# temp = alist[i]
# alist[i] = alist[i+1]
# alist[i+1] = temp
alist = [54,26,93,17,77,31,44,55,20]
bubbleSort(alist)
print(alist)
冒泡排序:算法分析
冒泡排序通常被认为是效率最低的排序算法,因为在确定最终的位置前必须交换元素。但是无需任何额外的存储空间开销。时间复杂度是O(n²)
性能改进
### 冒泡排序 :性能改进
def shortBubbleSort(alist):
exchange = True
passnum = len(alist)-1
while passnum > 0 and exchange:
exchange = False
for i in range(passnum):
if alist[i] > alist[i+1]:#前数大于本身
exchange = True
alist[i],alist[i+1] = alist[i+1],alist[i] #Python执行语句 a, b = b, a,相当于同时执行两条赋值语句
passnum = passnum - 1
alist = [54,26,93,17,77,31,44,55,20]
shortBubbleSort(alist)
print(alist)
5.4.2 选择排序
- 选择排序在冒泡排序的基础上做了改进,每次遍历列表时只做一次交换。
- 选择排序在每次遍历时寻找最大值,并在遍历完之后将它放到正确位置上
- 时间复杂度比冒泡排序稍优
- 对比次数不变 O(n²)
- 交换次数为O(n)
def selectionSort(alist):
for fillslot in range(len(alist)-1,0,-1): #逆序 从最后一项 到 第一项
positionMax = 0 #最大值对应的下标初始话为0
for location in range(1,fillslot+1):
if alist[location] > alist[positionMax]:
positionMax = location
temp = alist[fillslot]
alist[fillslot] = alist[positionMax]
alist[positionMax] = temp
5.4.3 插入排序
-
插入排序时间复杂度仍然为O(n²),但算法思路与冒泡,选择排序不同
-
插入排序旨在维持一个已经排好序的子列表,其位置始终位于列表的前部,然后逐步扩大这个子列表到全表
- 第一趟:子列表只包含以第一个数据项,将第二个数据项作为“新项”插入到子列表的适合位置,变成有2个元素的子列表
- 第二趟:将第三个数据项跟前两个数据项比较,并移动到比自己大的数据项,空出位置,加入子列表
- 经过n-1趟对比和插入,子列表扩展到全表,排序完成
- 插入排序主要用于比较寻找“新项”的插入位置
- 最差情况是每趟都需要与子列表比较数量级为O(n²)
- 最好情况,列表已经排好序,每趟只需要一次比较,总次数为O(n)
### 插入排序排序
def insertionSort(alist):
for index in range(1,len(alist)):
currentvalue = alist[index] #需要插入的新项
position = index # position为空项的下标
while position > 0 and alist [position-1] > currentvalue: #从空项前的一个位置不停的与插入项比较 前一项大于插入项
alist[position] = alist[position-1] #后项前移
position = position -1 #下标前移
alist[position] = currentvalue #进行全部比较后 放入插入项
5.4.4 希尔排序
- 希尔排序也称“递减增量排序”,使用增量i(有时称作步长)选取所有间隔为 i 的元素组成子列表。
- 子列表间隔一般都是N/2 开始,每趟倍增:n/4,n/8 直到1
最终再进行插入排序
def shellSort(alist):
sublistcount = len(alist)//2 #初始间隔
while sublistcount > 0:
for startposition in range(sublistcount):#间隔有多少 则有几个子列表 调用插入排序
gapInsertionSort(alist,startposition,sublistcount)
print("After increments of size",sublistcount,"This list is ",alist)
sublistcount = sublistcount//2 #缩小间隔
def gapInsertionSort(alist,start,gap):
for i in range(start+gap,len(alist),gap):
currenvalue = alist[i]
position = i
while position >0 and alist[position- gap] >currenvalue:
alist[position] = alist[position-gap]
position = position-gap
alist[position] = currenvalue
alist = [54,26,93,17,77,31,44,55,20]
shellSort(alist)
print(alist)
算法分析
希尔排序的时间复杂度大概介于O(n)和O(n²) 之间
5.4.5 归并排序
-
分治策略在排序中的应用
-
归并排序是递归算法,思路是将数据表特续分为两半,对两半分别进行归并排序
-
基本条件:数据表只有1个数据项
-
缩小规模:改变:将数据项分裂为两半,规模减少到原来的二分之一
-
调用自身:将两半分别调用自身排序,然后将分别排好序的两半进行归并
- 拆分
- 归并
-
### 归并排序
def mergeSort(alist):
if len(alist) > 1:#基本情况 列表只有1个数直接退出 包含多个找到中间id
mid = alist // 2
lefthalf = alist[:mid] #利用切片
righthalf = alist[mid:]
mergeSort(lefthalf) #调用自身对左边拆分
mergeSort(righthalf) #调用自身对右边拆分
i = j= k =0
while i< len(lefthalf) and j < len(righthalf): #i和j都大于左右列表的长度
if lefthalf[i] < righthalf[j]: #左边小于右边
alist[k] = lefthalf[i] #把左边的值归并到结果列表
i = i+1 #左边列表索引前移1
else:#左边大于右边
alist[k] = righthalf[j] #把右边的值归并到结果列表
j = j+1 #右边列表索引前移1 k = k+1 #结果列表索引+
while i<len(lefthalf): #如果左边还有剩余部分 在结果列表中归并
alist[k] = lefthalf[i]
i = i+1
k = k+1
while j<len(righthalf): #归并右半部分剩余部分
alist[k]=righthalf[j]
** **j= j+1
k= k+1
### 归并排序 解2
def merge_sort(lst):
if len(lst) <=1 :#基本情况
return lst
else:
middle =len(lst) // 2
left = merge_sort(lst[:middle]) #左半部排序
right = merge_sort(lst[middle:]) #右半部排序
merge = []
while left and right:
if left[0] <= right[0]:
merge.append(left.pop(0)) #左边小元素入结果对列 且删除做左队列首元素
else:
merge.append(right.pop(0)) #右边小元素入结果对列 且删除做右列首元素
merge.extend(right if right else left)
return merge
lst = [54,26,93,17,77,31,44,55,20]
print(merge_sort(lst))
算法分析
-
把归并排序分为两个过程来分析:分裂和归并
- 分裂:O(logn)
- 归并:O(n)
-
每次分裂的部分都进行一次O(n)的数据项归并,总的时间复杂度是O(nlogn)
5.4.6 快速排序
和归并排序一样,快速排序也采用分治策略,但不使用额外的存储空间。
-
思路:依据 “中值” 把数据项分为 少于中值的一半和大于中值的一半,再对每部分进行快速排序(递归)
- 找中值需要计算开销,随机找一个数来充当中值,比如第一个数
-
递归思路
- 基本情况:只有一个数据项
- 缩小规模:根据中值,将数据表分为两半,最好情况是相等规模的两半
- 调用自己:将两半分别调用自身进行排序(排序基本操作在分裂过程中)
#快速排序
def quickSort(alist):
quickSortHelper(alist,0,len(alist)-1)
def quickSortHelper(alist,first,last):
if first < last:#基本情况 表中至少还包含两个及以上元素进行分裂 只剩1 基本条件退出
splitpoint = partition(alist,first,last) #求分裂点 在alist中first 和 last 之间进行分裂
quickSortHelper(alist,first,splitpoint-1) #从first~splictpoint-1 前半部分调用快速排序
quickSortHelper(alist,splitpoint+1,last) #从splictpoint+1~last 后半部分调用快速排序
def partition(alist,first,last):#分裂
pivotvalue = alist[first] #选表中第一个值作为分裂中值
leftmark = first+1 #左标起始点 分裂中值的后一个值的下标
rightmark = last #右标 表中最后一个值的下标
done = False
while not done:
while leftmark <= rightmark and alist[leftmark] <= pivotvalue:#左标对应值小于分裂中值
leftmark = leftmark+1 #左标前进
while alist[rightmark] >= pivotvalue: #右标对应值大于于分裂中值
rightmark = rightmark-1 #右边后退
if rightmark < leftmark:#左右交错停止
done = True
else:
alist[leftmark],alist[rightmark] = alist[rightmark],alist[leftmark] #左右标对应值交换
alist[first],alist[rightmark] = alist[rightmark],alist[first] #最终右标对应值与分类中值交换
return rightmark
算法分析
-
快速排序过程分为:分裂和移动
- 分裂:把数据表分为相等两个部分 O(logn)
- 移动:对中值进行比对 O(n)
-
综合后:O(n logn)
本章小结
-
搜索
- 在无序表或者有序表上的顺序搜索,时间复杂度为O(n)
- 在有序表上进行二分搜索,最差复杂度为O(logn)
- 散列表可以实现常数级时间的搜索
- 完美散列函数作为数据一致性校验,应用广泛
- 区块链技术是一种去中心化的分布式数据库,通过‘工作量证明’机制来维持运行
-
排序
- 冒泡,选择和插入排序都是O(n²)
- 希尔排序在插入排序的基础上进行了改进,采用对递增子表排序的方法,其时间复杂度可以在O(n)和O(n²)之间
- 归并排序的时间复杂度是O(nlongn),但需要额外存储空间
- 快速排序最好时间复杂度为O(nlongn),但分裂点偏离列表中心的话,最坏情况会退化到O(n²)