排序和算法
- 视频链接:https://www.bilibili.com/video/BV1VC4y1x7uv?
- 章节:P45-P58
查找
顺序查找
-
定义
如果数据项保存在如列表这样的集合中,我们称这些数据项具有线性或者顺序关系。
在python
list
中,这些数据项的存储位置称为下标(index
),这些下标都是有序号的整数。通过下标,就可以按照顺序来访问和查找数据项,这种技术称为"顺序查找" -
操作步骤
- 先从列表的第1个数据项开始
- 按照下标增长的顺序,逐个比对数据项
- 如果到最后一个都未发现要查找的项,那么查找失败。
- 代码实现-无序表查找
def seq_search(item, alist):
for i in range(len(alist)):
if alist[i] == item:
return i
else:
return False
-
算法分析-无序表查找
1、要对查找算法进行分析,首先要确定其中的基本计算步骤。
2、在查找算法中,基本计算步骤就是进行数据项的比对,当前数据项等于还是不等于要查找的数据项,比对的次数决定了算法复杂度。
3、数据项是否在列表中,比对次数是不一样的,如果数据项不在列表中,比对次数为n
,如果数据项在列表中,最好的情况,比对次数为1
,最坏情况是n
。
4、数据项在列表中出现的位置是随机的,所以比对次数平均为n/2
,顺序查找的算法复杂度O(n)
。 -
有序表-顺序查找
在有序表进行顺序查找时,对比过程与无序表相同。
假设数据项不存在时,出现前一个数据项小于目标数据项,后一个数据项大于目标数据项的场景,查找就可以提前结束。
def ordered_seq_search(item, alist):
for i in range(len(alist)):
if alist[i] == item:
return i
if alist[i] > item:
return False
else:
return False
算术复杂度:有序表查找次数也是n/2,算术复杂度也是O(n)。
二分查找
在有序表中从中间开始对比:
1、如果中间数据项正好对上则查找结束。
2、中间项大于目标项,目标项只可能出现在前半部分;反之只可能出现在后半部分中。
无论如何,我们的比对范围缩小为原来的一半:n/2
def bin_search(item, alist):
if len(alist) == 0:
return False
begin = 0
end = len(alist) - 1
# 首尾索引不超出范围
while begin <= end:
mid = (begin + end) // 2
if alist[mid] == item:
return mid
elif alist[mid] > item:
end = mid - 1
elif alist[mid] < item:
begin = mid + 1
else:
return False
- 分而治之
二分查找算法实际上体现了解决问题的典型策略-分而治之:
将问题分为若干更小规模的部分,通过解决每一个小规模部分问题,并将结果汇总得到原问题的解。
- 递归实现2分查找
def bin_search(item, alist):
if len(alist) == 0:
return False
bin_index = len(alist) // 2
if alist[bin_index] == item:
return bin_index
elif alist[bin_index] > item:
return bin_search(item, alist[:bin_index]) # 切片超出范围即为空列表
elif alist[bin_index] < item:
return bin_search(item, alist[bin_index + 1:]) # 切片超出范围即为空列表
注意:这里使用了列表切片,切片操作也有时间复杂度为O(k)
,k
为切片长度,这样会使整个算法的时间复杂度稍有增加。
递归切片算法可以增加程序可读性。
如果传入起始和结束的索引值来,就可以去除切片的时间开销了。
- 算法分析
无论如何,我们的比对范围缩小为原来的一半:n/2
,当比对次数足够多以后,比对范围内就会仅剩余1个数据项。
n/2i=1
n/2^i=1
n/2i=1
i=log2(n) i=log2(n) i=log2(n)
所以二分法的算法复杂度为O(log n)
- 对比顺序查找
虽然二分查找在时间复杂度上优于顺序查找,但也要考虑到对数据项进行排序的开销(最低开销为n log2 (n))
根据数据项的变化和使用场景进行选择:
- 如果一次排序后可以进行多次查找,那么排序的开销就可以摊薄
- 如果数据集经常变动,查找次数相对较少,直接用无序表加上顺序查找可能更加高效
所以在算法选择的问题上,光看时间复杂度的优劣是不够的,还需要考虑到实际应用的情况。
排序
冒泡排序
-
原理
- 对无序表进行多趟比较;
- 每趟里面包含多次两两相邻比较,将逆序数据互换位置,到达本趟最大项就位;
- 经过
n-1
趟比较,实现整表排序;
-
步骤
- 第一趟从1、2至n-1、n的比较,总共
n-1
次比较 - 第二趟从1、2至n-2、n-1的比较,总共
n-2
次比较 - 到第
n-1
趟完成后,最小值就在首位,无需操作
第一趟操作视图:
- 第一趟从1、2至n-1、n的比较,总共
- 代码实现
def bubble_sort(list1):
# i 代表找出第几个最大值,0至n-2共n-1个,最小值在首位
for i in range(len(list1) - 1):
# 每次具体比较的索引值,第一躺j最大为n-2,索引j+1最大n-1,覆盖列表最后一个数。
for j in range(len(list1) - 1 - i):
# 左边数据较大时,交换位置
if list1[j] > list1[j + 1]:
list1[j], list1[j + 1] = list1[j + 1], list1[j]
return list1
-
算法分析
- 对比次数
无序表的初始值数据排列不对冒泡排序有任何影响。
比对次数为总趟数∗平均比较次数=(n−1)∗(n/2)=0.5n2−0.5n 比对次数为总趟数*平均比较次数=(n-1)*(n/2)=0.5n^2 - 0.5^n 比对次数为总趟数∗平均比较次数=(n−1)∗(n/2)=0.5n2−0.5n
对比的时间复杂度为O(n^2)
。- 交换次数
交换次数的时间复杂度也是
O(n^2)
,包含交换的3次赋值(可以忽略不计)。
最好的情况是有序的,交换次数为0;最差的情况是相反的,交换次数为n-1
;
平均就是(n-1)/2
。- 意义
冒泡排序通常作为时间效率较差的排序算法,来作为其他算法的对比基准。
缺点:其效率差在多次比对和交换中有大量的操作是无效的。
优点:无需任何额外的存储空间的开销,且只对比相邻位置的数值,泛用性广,还能使用在链表这种结构上。
-
冒泡排序改进
如果某趟比对没有发生任何交换,说明列表已经排好序,可以提前结束算法。
def short_bubble_sort(list1):
for i in range(len(list1) - 1):
flag = 0
for j in range(len(list1) - 1 - i):
if list1[j] > list1[j + 1]:
list1[j], list1[j + 1] = list1[j + 1], list1[j]
flag += 1
if flag == 0:
break
return list1
选择排序
对冒泡排序的改进,保留了其基本的多趟对比的思路,每趟也是使当前最大项就位。
选择排序对交换进行了削减,每趟的比对过程中记录最大项的所在位置,最后再跟本趟最后一项交换。
选择排序的时间复杂度比冒泡排序稍优:比对次数变,还是O(n^2),交换次数则减少为O(n)
def selection_sort(list1):
# 查找第几个最大值,共n个数,查找n-1次
for i in range(1, len(list1)):
# 默认列表起始位为最大值
max_i = 0
# 从索引1-索引n-i(首次n-1)与当前最大做比较
for j in range(1, len(list1) - i + 1):
# 记录当前最大值索引值
if list1[max_i] < list1[j]:
max_i = j
# 将当前最大值与对应位置进行交换,首次查找替换索引-1
list1[-i], list1[max_i] = list1[max_i], list1[-i]
return list1
插入排序
1、插入排序是时间复杂度为O(n^2)
,但是算法思路与冒泡排序,选择排序不同。
2、插入排序维持一个有序子列表,其位置始终在列表的前部,然后逐步扩大这个子列表直全表(类似整理扑克)
- 操作步骤
- 第1趟,子列表仅包含第1个数据项,将第2个数据项作为新项插入到子列表的合适位置中,这样已排序的子列表就包含了第二个数据项。
- 第2趟,再继续将第3个数据项跟前2个数据项比对,并移动比自身大的数据项,空出位置来,以便加入到子列表中。
- 经过n-1趟比对和插入,有序子列表扩展到全表。
-
代码实现
# 插入方式使用类似冒泡的对比交换操作 def insert_sort1(alist): # 需要插入数据项索引值,从第二个数据项开始 for i in range(1, len(alist)): current_index = i # 有序子列表索引(i-1 -> 0) for j in range(i - 1, -1, -1): # 当前项小于对比项时,交换位置的值及更新当前项的索引 if alist[current_index] < alist[j]: alist[current_index], alist[j] = alist[j], alist[current_index] current_index = j else: # 如果当前项大于对比项时,停止继续与前面的数据项比较 break return alist
-
算法分析
1、插入排序的对比,主要是寻找新项的插入位置。
2、最差情况是每次都和子列表里的所有项对比交换,与冒泡排序相同,为O(n^2)
。
3、最好情况,列表已经排好序的时候,每趟 仅需1次比对,总次数是O(n)
。
谢尔(shell)排序
-
谢尔排序以插入排序作为基础,对无序表进行“间隔”划分子列表,每个子列表都执行插入排序。
-
逐步减少间隔值,子列表数量也降低,排序后,无序表整体越来越有序,从而减少整体排序的比对次数。
-
最后的间隔变成1,就是标准的插入排序,由于前面几趟处理,列表已接近有序,这次仅需要几次移动就可以完成。
-
操作步骤
- 设定间隔为列表长度
n/2
索引值,为一个子列表,子列表数量与间隔相同,对子列表进行排序; n/4.n/8...
降低子列表的间隔,直至为1
;
- 设定间隔为列表长度
- 代码实现
def shell_sort(alist):
# 起始间隔分母为2
n = 2
# 子列表间隔为1时,就是总表的插入排序
while len(alist) // n >= 1:
# 计算间隔
inter_val = len(alist) // n
# 子列表的起始索引,也是子列表的个数
for i in range(inter_val):
# 对每个子列表做插入排序
insert_sort2(alist, i, inter_val)
# 减少间隔
n = n * 2
return alist
def insert_sort2(alist, begin, inter_val):
"""
插入排序
:param alist:
:param begin: 子列表起始索引
:param inter_val: 对比间隔
:return:
"""
# 需要插入数据项索引值,开始索引值为begin+inter_val,每次间隔inter_val
for i in range(begin + inter_val, len(alist), inter_val):
# 记录当前新项值
current_value = alist[i]
# 记录插入的位置索引
position = i
# 保证position - inter_val 对比项索引在范围内
while position >= inter_val:
# 对比项为当前位置的前一个数据项
# 当前项小于对比项
if current_value < alist[position - inter_val]:
# 将当前项原有位置赋值与对比项
alist[position] = alist[position - inter_val]
# 向前移动
position = position - inter_val
else:
break
# 对比完成后,将当前新项赋值给目标索引中
alist[position] = current_value
return alist
- 算法分析
- 表面上看,谢尔排序以插入排序为基础,并不会比插入排序好。
- 由于每趟都使得列表更加接近有序,这过程会减少很多原先需要的“无效”比对。(对谢尔排序进行详尽分析后,时间复杂度在
O(n)-O(n^2)
之间)。 - 如果将间隔保持在
2^k -1
(1,3,5,7,15等),谢尔排序时间复杂度为O(n^3/2)
。
归并排序
思路:是将数据表持续分裂为两半,对两半分别进行归并排序。
先将数据表进行分裂直至到一个数据项
再将相邻数据项进行合并,合并过程中进行排序
-
操作步骤
使用递归算法:
1、递归的基本结束条件是:数据表仅有1个数据项,自然排好序;
2、缩小规模:将数据表分裂为相等的两半,规模减为原来的二分之一;
3、调用自身:先将两半分别调用自身排序,然后将分别排好序的两半进行合并成一个新的有序列表,子序列表合成更大的有序列表,最后得到一个整体的有序列表。 -
代码实现
def merge_sort(alist): # 列表只剩一个元素时,即是有序列表 if len(alist) <= 1: return alist # 分裂 mid_index = len(alist) // 2 # 分成2半并调用自身,得到有序的左右2个子列表 left_list = merge_sort(alist[:mid_index]) right_list = merge_sort(alist[mid_index:]) # 归并 # 合并子列表得到一个新的有序列表 merge_list = [] # 存在子列表都不为空时 while left_list and right_list: # 小元素优先并入新表 if left_list[0] <= right_list[0]: merge_list.append(left_list.pop(0)) else: merge_list.append(right_list.pop(0)) # 将剩余较大元素并入总表后 if left_list: merge_list.extend(left_list) else: merge_list.extend(right_list) return merge_list
-
算法分析
- 将归并排序分为两个过程来分析:分裂和归并。
- 分裂的过程
len(alist) // 2
,借鉴二分查找中的分析结果,是对数复杂度,时间复杂度为O(log n)
。 - 归并的过程,相对于分裂的每个部分,其所有数据项都会被比较和放置一次(
while循环
),是线性复杂度,其时间复杂度是O(n)
,综合考虑每次分裂的部分都进行一次O(n)
的数据项归并,总的时间复杂度是O(nlog n)
len(alist) // 2
的次数*while
循环体的次数。
- 注意:归并排序会使用额外1倍的存储空间
merge_list
用于归并,所以在对特大数据集进行排序的时候需要考虑存储空间的占用。
快速排序
思路:依据一个“中值”数据项来把数据表分为两半:小于中值的一半和大于中值的一半,然后每部分分别进行快速排序(递归)。
如果希望这两半拥有相等数量的数据项,则应该找到数据表的“中位数”,但找中位数需要计算开销,没有开销的做法就是使用第1个数来充当“中值” 。
-
操作步骤-分裂
-
设置左右标(left/rightmark)。
-
左标向右移动,右标向左移动。
-
左标一直向右移动,碰到比中值大的就停止;
-
右标一直向左移动,碰到比中值小的就停止;
-
然后把左右标所指的数据项交换。
-
-
继续移动,直到左标移到右标的右侧,停止移动 。
-
这时右标所指位置就是“中值”应处的位置,将中值和这个位置交换。
-
分裂完成,左半部比中值小,右半部比中值大。
-
-
操作步骤-递归调用
对分裂后的数据项,使用相同的方式进行分裂,直至数据表仅有1个数据。
使用递归算法实现:
- 基本结束条件:数据表仅有1个数据项
- 缩小规模:根据“中值(首位数)”,将数据项分为两半
- 调用自身:将两半分别调用自身进行排序(基本操作在分析过程中,左小右大)
-
图例
-
以首位数54为中值,进行分裂,左标从索引2开始—>,右标从结尾开始<—。
-
当左标数据大于中值时,停止移动。右标出现小于中值时停止移动。接着交换左标和右标的数据(93/20 77/44)。
-
交换结束继续移动,直至右标大于左标索引值。再将右标值(31)与中值(54)换位。
-
将列表分为左至44,54,77-93的3部分,左右子表分别进行如上操作。
- 代码实现
def quick_sort(alist, begin, end):
# 中值索引
mid_index = begin
# 中值
mid_value = alist[mid_index]
# 左标起始索引
left_index = mid_index + 1
# 右标起始索引
right_index = end
# 移动
while 1:
# 左标小于等于右标,左标值小于等于中值,左标右移动
while left_index <= right_index and alist[left_index] <= mid_index:
left_index += 1
# 左标小于等于右标,右标值大于等于中值,右标向左移动
while left_index <= right_index and mid_value <= alist[right_index]:
right_index -= 1
# 左标大于右标,退出移动
if left_index > right_index:
break
# 左标的大数与右标的小数进行位置交换
alist[left_index], alist[right_index] = alist[right_index], alist[left_index]
# 交换中值和右标的数据
alist[mid_index], alist[right_index] = alist[right_index], alist[left_index]
# 分裂
# 分裂列表为[小数项][中值(right_index)][大数项]
# [小数项]、[大数项]子列表再排序
# 子列表长度大于1的需要排序
if (right_index - 1) - begin > 1:
quick_sort(alist, begin, right_index - 1)
if end - (right_index + 1) > 1:
quick_sort(alist, right_index + 1, end)
- 算法分析
快速排序过程分为两部分:分裂和移动
如果分裂总能把数据表分为相等的两部分,那么 就是O(log n)
的复杂度;
而移动需要将每项都与中值进行比对,是O(n)
;
综合起来就是O(nlog n)
;算法运行过程中不需要额外的存储空间。
缺陷:极端情况下,就是中值特别大or特别小,导致左右两部分数据不平衡。当有一部分始终没有数据时,时间复杂度就退化到O(n^2)
,加上递归调用开销,比冒泡排序效率更低。
中值选取方式的选择,如果是随机均匀分布的数据表,就是使用首位数作为中值。其他方式可以使用三点取样,从数据表的头、尾、中间之中选取中值。
散列
Hashing
目的:使的查找算法的复杂度降到O(1)
。
散列表
hash table,又称哈希表是一种数据集,其中数据项的存储方式尤其有利于将来快速的查找定位。
散列表中的每一个存储位置,称为槽(slot),可以用来保存数据项,每个槽有一个唯一的名称。
- 示例
- 一个包含11个槽的散列表,槽的名称分别为0~10;
- 在插入数据项之前,每个槽的值都是
None
,表示空槽。
散列函数
实现从数据项到存储槽名称的转换,称为散列函数(hash function)。
变量:数据项;值:存储槽的名称
-
示例
数据项:54,26,93,17,77,31。
散列方法:求余数,将数据项除以散列表的大小,得到的余数为槽号。
因为散列函数返回的槽号必须在散列表大小范围之内,所以一般会对散列表大小求余(余数小于除数)。
散列函数:
h(item) = item %11
这里使用一个容量为11的存储槽来保存所有数据项。结果:
负载因子:槽被数据项占据的比例称为散列表的“负载因子”, 如示例散列表的负载因子为6/11
。查找:查找某个数据项是否存在于表中,只需要使用同一个散列函数,对查找项进行计算,测试下返回的槽号所对应的槽中是否有数据项即可。以此来实现
O(1)
时间复杂度的查找方法。比如判断字典中的键值是否存在。
冲突:如果不同数据项的余数相同,意味着需要保存在同一个槽中,这种情况称为“冲突collision”。
exp:新增一个44数据项,余数为0,与77冲突,都需要保存在0号槽。
完美散列函数
- 概念
给定一组数据项,如果一个散列函数能把每个数据项映射到不同的槽中,那么这个散列函数就可以称为"完美散列函数” 。
对于固定的一组数据,总是能想办法设计出完美散列函数
如果数据项经常性的变动,很难有一个系统性的方法来设计对应的完美散列函数 。
- 应用
完美的散列函数能够对任何不同的数据生产不同的散列值,如果把散列值当作数据的“指纹”或者“摘要”,这种特性被广泛应用在数据的一致性校验上。
由任意长度的数据生成长度固定的“指纹”,还要求具备唯一性,设计巧妙的“准完美”散列函数就能在实用范围内做到这一点。
- 特性
- 压缩性:任意长度的数据(即使数据量达到T级别),得到的指纹长度是固定的;
- 易计算性:从原数据计算“指纹”很容易;(从指纹无法计算原数据)
- 抗修改性:对原数据的微小变动,都会引起指纹的大改变;
- 抗冲突性:已知原数据和“指纹”,要找到相同的指纹的数据(伪造)是非常困难的
- 实例
常用的近似完美函数是MD5
和SHA
系列函数
MD5(Message Digest)
将任何长度的数据变换为固定长为128位(16字节)的摘要
SHA(Secure Hash Algorithm)是另一组散列函数
SHA-0/SHA-1输出散列值160位(20字节)
SHA-256/SHA-224分别输出256位、224位
SHA-512/SHA-384分别输出512位和384位
- Python自带MD5和SHA系列的散列函数库:
hashlib
import hashlib
s = "hello world"
print(hashlib.md5(s.encode()).hexdigest())
print(hashlib.sha1(s.encode()).hexdigest())
s2 = "I am da hai gui"
m = hashlib.md5()
m.update(s.encode())
m.update(s2.encode()) # update 无限叠加
r = m.hexdigest()
print(r)
- 数据一致性校验
- 对每个文件计算散列值,对比散列值即可得知文件内容是否相同
- 加密形式保存密码
- 数据防篡改
散列函数设计
- 折叠法
折叠法设计散列函数的基本步骤是:
1. 将数据项按照位数为若干段
2. 再将几段数字相加
3. 最后对散列表大小求余,得到散列值
例如:对电话号码62767255,可以两位两位分为4段 62 76 72 55,相加 62+76+72+55=265,散列表包含11个槽,那么就是265%11=1,所以h(62767255)=1,1号槽
隔数反转,比如62 76 72 55
隔数反转为62
67 72
55,再累加62 + 67 +72 +55 = 256
,对11求余 256 %11 = 3
,所以h(62767255)=3
,放置在3号槽。
虽然隔数反转从理论上看来毫无必要,但这个步骤确实为折叠法得到散列函数提供了一种微调手段,以便更好符合散列特性。
- 平方取中法
平方取中法,首先将数据项做平方运算,然后取平方数的中间两位,再对散列表的大小求余。
对44进行散列,首先44*44=1936,然后取中间的93,对散列表大小11求余,93%11=5
- 对比两种散列函数的对比
两个都是完美散列函数,分散度都很好,平方取中值计算量稍大。
item | remainder | mid-square |
---|---|---|
54 | 10 | 3 |
26 | 4 | 7 |
93 | 5 | 9 |
17 | 6 | 8 |
77 | 0 | 4 |
31 | 9 | 6 |
- 非数项
对非数字的数据项进行散列,把字符串中每个字符看作ASCII
码即可
如cat,ord(‘c’)=1=99 ord(‘a’)==96,ord(‘t’) ==116,再将这些整数累加,对散列表大小求余,312%11 = 4
def hash_s(str1, table_size):
sum_a = 0
for p in str1:
sum_a += ord(p)
r = sum_a % table_size
return r
**注意:**这样的散列函数对所有的变位词(字符相同)都是返回相同的散列值,为了防止这点,将字符串所在的位置作为权重因子,乘以ord
值,比如索引1
,权重为1
,索引2
,权重为2
,以此类推。
99 * 1+96 * 2+ 116 * 3 = 641,641%11 = 3
- 总结
设计散列函数方法,需要坚持一个出发点,就是散列函数不能成为存储过程和查找过程的计算负担,如果设计太过复杂,花费大量计算资源计算槽号,可能不如直接使用顺序查找或者二分查找的效率,以此失去了散列本身的意义。
冲突解决方案
如果两个数据项被散列映射到同一个槽,需要一个系统化的方法在散列表中保存第二个数据项,这个过程称为“解决冲突”。
开放定址
解决散列的一种方法就是为冲突的数据项再找一个开放的空槽来保存。
最简单的就是从冲突的槽开始往后扫描,直到碰到一个空槽;
如果到散列表尾部还未找到,则从首部接着扫描。
这种寻找空槽的技术称为“开放定址”。
-
线性探测
往后逐个槽寻找的方法则是开放定址技术中的“线性探测”。
示例:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
77 | None | None | None | 26 | 93 | 17 | None | None | 31 | 54 |
把44/55/20
逐个插入到散列表中
h(44) = 0(44%11)
,0号槽已经被77占据,往后一个就是空槽1号,保存。
h(55) = 0
,同样0号槽已被占据,往后找到第一个空槽2号,保存。
h(20) = 9
,发现9号槽已经被31占据,往后,从头开始找到3号槽保存。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
77 | 44 | 55 | 20 | 26 | 93 | 17 | None | None | 31 | 54 |
查找:采用线性探测方法来解决散列冲突的话,则散列表的查找也遵循同样的规制。
如果在散列位置没有找到查找项的话,必须向后做顺序查找,直到找到查找项,或者碰到空槽(查找失败)
-
线性探测的改进-跳跃式探测
线性探测法的一个缺点是有聚焦的趋势,即如果同一个槽冲突的数据项多的话,这些数据项就会在槽附近聚集起来,从而连锁式影响其他数据项的插入,比如数字
12
,本应存放在1
号槽,可是1
号槽被44
占据,甚至往后的2
号槽被55
占用,直至7
号槽(严重偏离1号)才能存放12
。跳跃式探测:将线性探测扩展,从逐个探测改为跳跃式探测,比如每次+3
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
77 | 55 | None | 44 | 26 | 93 | 17 | 20 | None | 31 | 54 |
44,0+3
,3号槽为空槽,可以保存。
55,0,3,6,9
都已被占据,12%11=1
,1号槽为空,可以保存。
20,9,1(12%1),4
,都被占据,7号槽为空,可以保存。
- 再散列
rehashing
重新查找空槽的过程可以用再散列来概括,即有一个旧的散列值不满足需要了,我们把它放在rehash
函数里面得到一个新的散列值
new_hash_value = rehash(old_hash_value)
# 线性探测
rehash(pos) = (pos+1)%size_of_table
# +3 跳跃式探测
rehash(pos) = (pos+3)%size_of_table
#跳跃式再散列通式
rehash(pos) = (pos+skip)%size_of_table
注意:跳跃式探测中,需要注意的是skip
的取值,不能被散列表大小整除,否则会产生周期,造成很多空槽永远无法探测到,因为如果size
是skip
的整数倍,跳跃到槽是固定的,无法遍历全部槽。
比如假设size=8,skip=4
,从0
开始跳跃,4,0(8-1)
,4,0
这样就只能存在这两个槽上。
一个技巧是,把散列表的大小设为素数,如例子的11
- 二次探测
不再固定skip
的值,而是逐步增加skip
值,如1/3/5/7/9
,这样槽号就会是原散列值以平方数增加:h,h+1,h+4(1+3),h+9(1+3+5),h+16(1+3+5+7)
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
77 | 44 | 20 | 55 | 26 | 93 | 17 | None | None | 31 | 54 |
44,h(44) = 0 ,0+1 = 1
55,h(55) = 0,0+1,0+4,0+9,5(0+16-11),3(0+25-22)
,3
号槽空,可以保存。
还能表示为:0+1=1,1+3=4,4+5=9,9+16=25
20,h(20) = 9, 9+1=10,9+4=2(13-11)
,2号槽空,可以保存。
数据项链
由于开放定址寻找空槽的方法,会占用别的数据项的空槽,所以可以将容纳单个数据项的槽扩展为容纳数据项集合的方法来解决散列冲突。
当散列发生冲突时,只需要简单的将数据项添加到数据项集合中就行。
查找数据项时需要查找同一个槽中的整个集合,在集合中使用顺序查找O(n)目标项,因此随着散列冲突增加,对数据项的查找时间也会相应增加。
映射抽象数据类型
-
字典类型
字典是一种可以保存key-data
键值对的数据类型,其中关键码key
可用于查询关联的数据值data
,这种键值关联的方法称为“映射Map“。
ADT Map的结构是键-值关联的无序集合,关键码具有唯一性,通过关键码可以唯一确定一个数据值。 -
操作
Map()
:创建一个空映射,返回空映射对象;put(key, val)
:将key-val
关联对加入映射中 ,如果key
已存在,将va
l替换旧关联值;get(key)
:给定key
,返回关联的数据值,如不存在,则返回None
;del
:通过del map[key]
的语句形式删除key- val
关联;len()
:返回映射中key-val
关联的数目;in
:通过key in map
的语句形式,返回布尔值,表示key
是否存在于关联中。
实现ADT MAP
使用字典的优势在于给定关键码key
, 能够很快得到关联的数据值data
。使用前述的散列表来实现,就可以使查找可以达到最快O(1)
的性能。
-
步骤
- 使用2个列表保存数据,其中一个
slot
列表用于保存key
,另一个平行的data
列表用于保存数据项; - 在
slot
列表查找到一个key
的位置以后,在data
列表对应相同位置(索引)的数据项即为关联数据; - 保存
key
的列表就作为散列表来处理,这样就可以迅速查找到指定的key
(散列表的大小使用素数); - 使用简单求余方法来实现散列函数,解决冲突使用线性探索“加1”再散列函数。
- 使用2个列表保存数据,其中一个
-
代码实现
class HashTable: def __init__(self,size: self.size = size self.slot = self.size * [None] self.data = self.size * [None] def hash_function(self, key): """ 对key值求余,余即为索引值 求余可以保证位置索引在size内。 """ return key % self.size def rehash(self, old_hash): """ 解决冲突,对散列值(余)跳跃1步后,再判断,为防止跳跃后的值大于size,所以需要求余,保证在范围内 """ return (old_hash + 1) % self.size def put_value(self, key, val): """ 将key-val关联对加入映射中,如果key存在,将val替换旧关联值 先对key求余, 如果没有冲突,key存入slot列表,value存入相同索引(余数)的data列表 如果冲突,判断slot中存放的key值是否与当前相同, 相同时data表中的value替换成新的, 不相同,则使用再散列函数,获取新的索引值,新索引下是否冲突 不冲突,则存入key和value 冲突,判断slot中存放的key值是否与当前相同, 相同替换,不相同继续再散列直至达到不冲突或者相同值 同时记录历史余数,当出现重复值后,说明达到存储上限,无法继续添加 """ hash_id = self.hash_function(key) history_hash_list = [hash_id] # slot表值为None表示空槽,等于key表示key已存在,这两种场景直接存储 # 其他场景都表示冲突,需要rehash while self.slot[hash_id] not in [None, key]: # 再散列,直至满足条件 hash_id = self.rehash(hash_id) if hash_id in history_hash_list: raise IndexError history_hash_list.append(hash_id) else: # 存储or替换 self.slot[hash_id] = key self.data[hash_id] = val def get_value(self, key): """ 给定key,返回关联的数据值,如不存在,返回None 先对key求余(索引),对比slot表中对应槽存储的值 与key相同时,表示找到正确索引,返回data中的值 为None时,表示不存在,返回None 不相同且不为None则,继续rehash 同时记录历史余数,当出现重复值是,说明表格已满,且没有当前key值 """ hash_id = self.hash_function(key) history_hash_list = [hash_id] while self.slot[hash_id] not in [None, key]: hash_id = self.rehash(hash_id) if hash_id in history_hash_list: return None history_hash_list.append(hash_id) else: return self.data[hash_id] def del_key(self, key): """ 通过del_map[key]的语句形式删除key-val关联 同put,只不过将存储数据初始化为None """ hash_id = self.hash_function(key) history_hash_list = [hash_id] while self.slot[hash_id] not in [None, key]: hash_id = self.rehash(hash_id) if hash_id in history_hash_list: raise IndexError history_hash_list.append(hash_id) else: self.slot[hash_id] = None self.data[hash_id] = None def get_len(self): """ 返回映射中key-val关联的数目 非None值数量 """ return self.size - self.slot.count(None)
-
算法分析
- 散列最好情况(没有冲突)是提供
O(1)
常数级的时间复杂度的查找性能,由于散列冲突的存在,查找比较次数就没有这么简单。 - 评估散列冲突的最重要信息就是负载因子λ(存储数量/容量),一般来说:
- 如果λ较小,散列冲突几率就小,数据项通常会保存在其所属的散列槽中;
- 如果较大,意味着散列表填充较满,冲突会越来越多,冲突解决也越复杂,就需要更多的比较来找到空槽,如果采用数据链的话,意味着每条链上的数据项增多。
- 如果采用线性探测的开放定址法来解决冲突(λ在0-1之间),
1. 成功的查找,平均对比次数为0.5*(1+1/(1-λ))*
;
2. 不成功的查找,平均比对次数为0.5*(1+(1/(1-λ)^2)
; - 如果采用数据链来解决冲突(λ可大于1),
1. 成功查找的平均比较次数为1+(λ/2)
;
2. 不成功查找的平均比较次数为λ
。
- 散列最好情况(没有冲突)是提供
总结
-
查找
在无序表或有序表上的顺序查找,其时间复杂度
O(n)
。
在有序表上进行二分查找,其最差复杂度为O(log n)
。
散列表可以实现常数级时间的查找。 -
散列函数
完美散列函数作为数据一致性校验,应用很广。 -
排序
冒泡、选择和插入排序都是O(n^2)
的算法。
谢尔排序在插入排序的基础上进行了改进,采用对递增子表排序的方法,其时间复杂度可以在O(n)
和O(n^2)
之间。
归并排序的时间复杂度是O(nlog n)
,但归并的过程需要额外存储空间。
快速排序的最好时间复杂度是·(nlog n)
,也不需要额外的存储空间,但如果分裂点偏离列表中心的话,最坏情况下会退化到O(n^2)
。
要在特定的应用场合取得最高排序性能的话,还需要对数据本身进行分析,针对数据的特性来选择相应排序算法 -
空间复杂度
有时候空间复杂也是需要考虑的关键因素。归并排序时间复杂度O(nlog n),但需要额外一倍的存储空间
快速排序时间复杂度最好的情况是O(nlog n),且不需要额外存储空间,但“中值”的选择又成为性能的关键,选择的不好的话,极端情况下性能甚至低于冒泡排序
算法选择不是一个绝对的优劣判断,需要综合考虑各方面的因素,包括运行环境要求、处理数据对象的特性。