python数据结构课堂笔记5:排序与查找

本文深入探讨了排序与查找算法,包括顺序查找、二分查找及其复杂度分析。针对排序算法,文章详细介绍了冒泡排序、选择排序、插入排序、谢尔排序、归并排序和快速排序的原理、效率和适用场景。同时,文章还讨论了散列函数、区块链技术及其在数据一致性校验中的应用。通过对各种算法的分析,展示了在实际应用中如何选择合适的排序和查找策略。

排序与查找

查找算法

顺序查找

如果数据项保存在列表这样的集合中,我们会称这些数据项具有线性或者顺序关系
在python list中,这些数据项的存储位置称为下标(index),这些下标都是有序的整数
通过下标,我们就可以按照顺序来访问和查找数据项,这种技术叫做“顺序查找”

  • 要确定列表中是否存在需要查找的数据项
    首先从列表的第一个数据项开始,
    按照下标增长的顺序,逐个比对数据项
    如果到最后一个都未发现要查找的项,那么查找失败

无序表中进行顺序查找:

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

testlist = [1, 2, 32, 8, 17, 19, 42, 13, 0]
print(sequentialSearch(testlist, 3))
print(sequentialSearch(testlist, 13))
算法分析

要对查找算法进行分析,首先要确定其中的计算步骤
基本计算步骤必须足够简单,并且在算法中反复执行
在查找算法中,这种基本步骤就是进行数据项的比对
当前数据项等于还是不等于要查找的数据项,比对的次数决定了算法复杂度

在顺序的查找算法中,为了保证是讨论的一般情况,需要假定列表中的数据项并没有按值排序,而是随机放置在表中的各个位置。
即,数据项在列表中各处出现的概率是相同的。

  • 数据项是否存在列表中,比对次数是不一样的。
    • 如果数据项不在列表中,需要比对数据项才能得知,比对次数是n。
    • 如果数据项在列表中,要比对的次数,其情况就比较复杂。
      • 最好的情况是,第一次比对就找到
      • 最坏的情况是,要n次比对。

数据项在列表中各个位置出现的概率是相同的,平均情况下,比对的概率是n/2.
顺序查找的算法复杂度是O(n)。

顺序查找无序表复杂度:

CaseBest CaseWorst CaseAverage Case
item is present1nn/2
item is not presentnnn

如果对数据项排序:
当数据项存在时,比对过程与无序表完全相同
不同之处在于,当数据项不存在时,比对可以提前结束,即当比对到较大的数时,可直接返回
有序表查找:

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

testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42,]
print(orderedSequentialSearch(testlist, 3))
print(orderedSequentialSearch(testlist, 13))

顺序查找有序表复杂度:

CaseBest CaseWorst CaseAverage Case
item is present1nn/2
item is not present1nn/2

实际上,就算法复杂度而言,仍然是O(n)
只是在数据项不存在的时候,有序表的查找能节省一些比对次数,但并不改变其数量级

二分查找
  • 1.对于有序表,更好的查找算法,从列表中间开始比对:
    • 1.如果列表中间的项匹配查找项,则查找结束
    • 2.如果不匹配,那么就有两种情况:
      • 列表中间项比查找项大,那么查找项只可能出现在前半部分
      • 列表中间项比查找项小,那么查找项只可能出现在后半部分
    • 如论如何,我们都会将比对范围缩小为原来的一般:n/2
  • 2.继续采用上面的方法查找,每次都会将比对范围缩小一半

二分查找代码实现:

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

testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42,]#必须排好序
print(binarySearch(testlist, 3))
print(binarySearch(testlist, 13))

二分查找算法实际上体现了解决问题的典型策略:分而治之
将问题分为若干更小规模的部分
通过解决每一个小规模部分问题,并将结果汇总到原问题的解
二分算法也可以通过递归来实现

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)

testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42,]
print(binarySearch(testlist, 3))
print(binarySearch(testlist, 13))
算法分析

由于二分查找,每次比对都将下一步的比对范围缩小一半
每次比对后剩余数据项如下表所示

ComparisonsApproximate Number of Items Left
1n/2
2n/4
3n/8
in/(2**i)

当比对次数足够多的时候,比对范围内就会仅剩1个数据项
无论这个数据项是否匹配查找项,比对最终都会结束:
即i = log2(n)
二分查找的算法复杂度为:O(log n)
如果使用递归来求解,那么就会调用列表切片(binarySearch(alist[:midpoint],item))
切片操作的复杂度为O(k),这样会使整个算法的时间复杂度增加
也可以不用切片

虽然二分查找在时间复杂度上优于顺序查找,但也要考虑对数据项进行排序的开销
如果一次排序后可进行多次查找,那么排序的开销就可以摊薄
如果数据集经常变动,查找次数相对较少,那么可能还是直接用无序表加上顺序查找合适
算法的选择需要考虑实际应用

排序算法

冒泡排序和选择排序算法
冒泡排序Bubble Sort

冒泡排序的算法思路在于对无序表进行多趟比较交换
每趟包含了多次两两相邻比较,并将逆序的数据项互换位置,最终将本趟的最大项就位
经过n-1趟比较交换,实现整表排序
每趟的过程类似于“气泡”在水中不断上浮到水面的过程

  • 第一趟比较交换,共有n-1对相邻数据进行比较
    一旦经过最大项,则最大项会一路交换到最后一项
  • 第二趟比较交换时,最大项已经就位,需要排序的数据减少为n-1,共有n-2对相邻数据进行比较
  • 直到第n-1趟完成后,最小项一定在列表首位,就无需再处理
def bubbleSort(alist):
    for passnum in range(len(alist)-1,0,-1):#n-1趟
        for i in range(passnum):
            if alist[i]>alist[i+1]:
                temp = alist[i]
                alist[i] = alist[i+1]
                alist[i+1] = temp#序错,交换
                #或者改为alist[i],alist[i+1] = alist[i+1],alist[i]

alist = [54,26,93,17,77,31,44,55,20]
bubbleSort(alist)
print(alist)
算法分析

无序表初始数据项的排列状况对冒泡排序没有影响
算法过程总需要n-1趟,随着趟数的增加,比对次数逐步层n-1减少到1,并包括可能发生的数据项交换
比对次数是1~n-1的累加:
1/2 n2 - 1/2 n
比对的时间复杂度为O(n2)

关于交换次数,时间复杂度也是O(n2)
最好的情况是列表在排序前已经有序,交换次数为0
最差的情况是每次比对都要进行交换,交换次数等于比对次数
平均情况则是最差情况的一半

冒泡排序通常作为时间效率较差的排序算法,来作为其它算法的对比基准
其效率主要差在每个数据项在找到其最终位置之前
必须要经过多次比对和交换,其中大部分的操作是无效的
但有一点优势,就是无需任何额外的存储空间开销,适应性较广

改进:可以通过监测每趟比对是否发生过交换,可以提前确定排序是否完成

def shortBubbleSort(alist):
    exchanges = True
    passnum = len(alist)-1
    while passnum > 0 and exchanges:
       exchanges = False
       for i in range(passnum):
           if alist[i]>alist[i+1]:
               exchanges = True
               temp = alist[i]
               alist[i] = alist[i+1]
               alist[i+1] = temp
       passnum = passnum-1

alist=[20,30,40,90,50,60,70,80,100,110]
shortBubbleSort(alist)
print(alist)
选择排序

选择排序对冒泡排序进行了改进,保留了基本的多趟对比思路,每趟都使当前最大项就位
但选择排序对交换进行了削减,相比起冒泡排序进行多次交换,每趟仅进行一次交换,记录最大项的所在位置,最后再跟本趟最后一项交换。
选择排序的时间复杂度比冒泡排序稍优
比对次数不变,还是O(n2)
交换次数则减少为O(n)

def selectionSort(alist):
   for fillslot in range(len(alist)-1,0,-1):
       positionOfMax=0
       for location in range(1,fillslot+1):
           if alist[location]>alist[positionOfMax]:
               positionOfMax = location

       temp = alist[fillslot]
       alist[fillslot] = alist[positionOfMax]
       alist[positionOfMax] = temp

alist = [54,26,93,17,77,31,44,55,20]
selectionSort(alist)
print(alist)
插入排序

插入排序时间复杂度为O(n2),但算法思路与冒泡排序、选择排序不同
插入排序维持一个已排好序的子列表,其位置始终在列表的前部,然后逐步扩大这个子列表直到全表
类似于整理扑克牌

第一趟:子列表仅包含第1个数据项,将第2个数据项作为“新项”插入到子列表的合适位置,这样已排序的子列表就包含了2个数据项
第二趟:再继续将第3个数据项跟前两个数据项比对,并移动比自身大的数据项,空出位置来,以便加入到子列表中
第三趟:经过n-1趟比对和插入,子列表扩展到全表,排序完成

插入位置的比对主要用来寻找“新项”的插入位置
最差的情况是每趟都与子列表中所有项进行比对,总比对次数与冒泡排序相同,数量级仍是O(n2)
最好的情况,列表已经排好序的时候,每趟仅需1次比对,总次数是O(n)

def insertionSort(alist):
   for index in range(1,len(alist)):

     currentvalue = alist[index]#新项/插入项
     position = index#新项/插入项的位置

     while position>0 and alist[position-1]>currentvalue:#直到找到第一个比插入项小的项
         alist[position]=alist[position-1]
         position = position-1#比对,移动

     alist[position]=currentvalue#插入新项

alist = [54,26,93,17,77,31,44,55,20]
insertionSort(alist)
print(alist)

由于移动操作仅包含1次赋值,是交换操作的1/3,所以插入排序性能会好一些

谢尔排序

插入排序最好的情况下的复杂度为O(n),这种情况发生在列表已经是有序的情况下,实际上,列表越接近有序表,插入排序的比对次数越少
谢尔排序以插入排序作为基础,对无序表进行“间隔”划分子排序,每个子列表都执行插入排序

  1. 随着子列表的数量越来越少,无序表的整体越来越接近于有序,从而减少整体排序的比对次数
  2. 间隔为3的子列表,子列表分别插入排序后的整体状况接近于有序
  3. 最后一趟是标准的插入排序,但由于前面几趟已经将列表处理到接近有序,这一趟仅需少数几次移动即可完成
    子列表的间隔一般从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,
                                   "The list is",alist)

      sublistcount = sublistcount // 2 #间隔缩小

def gapInsertionSort(alist,start,gap):
    for i in range(start+gap,len(alist),gap):

        currentvalue = alist[i]
        position = i

        while position>=gap and alist[position-gap]>currentvalue:
            alist[position]=alist[position-gap] 
            position = position-gap

        alist[position]=currentvalue
        
alist = [54,26,93,17,77,31,44,55,20]
shellSort(alist)
print(alist)

谢尔排序的时间复杂度介于O(n)和O(n2)之间
如果将间隔保持在2k -1 ,谢尔排序的时间复杂度约为O(n3/2)

归并排序
  • 归并排序是递归算法,思路是将数据表持续分裂为两半,对两半分别进行归并排序
    1.递归的基本结束条件是:数据表仅有一个数据项,自然是排好序
    2.缩小规模:将数据项分裂为相等的两半,规模减为原来的二分之一
    3.调用自身:将两半分别调用自身排序,然后将分别排好序的两半进行归并,得到排好序的数据表

归并排序传统代码:

def mergeSort(alist):
   #print("Splitting ",alist)
   if len(alist)>1:#基本结束条件
       mid = len(alist)//2
       lefthalf = alist[:mid]
       righthalf = alist[mid:]

       mergeSort(lefthalf)#递归调用
       mergeSort(righthalf)

       i=0
       j=0
       k=0
       while i<len(lefthalf) and j<len(righthalf):
           if lefthalf[i]<righthalf[j]:#拉链式交错把左右半部从小到大归并到结果列表中
               alist[k]=lefthalf[i]
               i=i+1
           else:
               alist[k]=righthalf[j]
               j=j+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
   print("Merging ",alist)
   
alist = [54,26,93,17,77,31,44,55,20]
mergeSort(alist)
print(alist)

python风格代码

def merge_sort(lst):
    #递归结束条件
    if len(lst) <= 1:
        return lst
    
    #分解问题,并递归调用
    middle = len(lst)//2
    left = merge_sort(lst[:middle])#左半部排好序
    right = merge_sort(lst[middle:])#右半部排好序
    
    #合并左右半部,完成排序
    merged = []
    while left and right:
        if left[0] <= right[0]:
            merged.append(left[0])
        else:
            merged.append(right[0])

    merged.extend(right if right else left)
    return merged
算法分析
  • 将归并排序分为两个过程来分析:分裂和归并
  • 分裂的过程借鉴二分查找的分析结果,是对数复杂度,时间复杂度为O(log n)
  • 归并的过程,相对于分裂的每个部分,其所有数据项都会被比较和重置一次,所以是线性复杂度,其时间复杂度为O(n)
  • 综合考虑,每次分裂的部分都进行一次O(n)的数据项归并,总的时间复杂度为O(nlog n)
  • 归并排序使用了额外1倍的存储空间用于归并
快速排序算法
分拣+分裂

快速排序算法的思路是根据一个“中值”数据项来把数据项分为两半:小于中值的一半和大于中值的一半,然后每部分分别进行快速排序(递归)。
如果希望这两半拥有相等数量的数据项,则应该找到数据表的“中位数”
但找到中位数需要计算开销!要想没有开销,只能随意找一个数来充当“中值”,比如第一个数

快速排序的递归三要素

基本结束条件:数据表仅有一个数据项,自然是排好序的
缩小规模:根据“中值”,将数据表分为两半,最好的情况是相等规模的两半
调用自身:将两半分别调用自身进行排序(排序基本操作在分裂过程中)

分裂数据表的目标:找到“中值”的位置

  • 分裂数据表的手段:
    • 设置左右标(left/rightmark)
    • 左标向右移动,右标向左移动
      • 左标一直向右移动,碰到比中值大的就停止
      • 右标一直向左移动,碰到比中值小的就停止
      • 然后把左右标的数据项交换
    • 继续移动,直到左标移到右标的位置,停止移动
    • 这时右标所指位置就是“中值”应处的位置
    • 将中值和这个位置交换
    • 分裂完成,左半部比中值小,右半部比中值大
def quickSort(alist):
   quickSortHelper(alist,0,len(alist)-1)

def quickSortHelper(alist,first,last):
   if first<last:#基本结束条件

       splitpoint = partition(alist,first,last)#分裂点

       quickSortHelper(alist,first,splitpoint-1)
       quickSortHelper(alist,splitpoint+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 and \
               rightmark >= leftmark:
           rightmark = rightmark -1 #向左移动右标

       if rightmark < leftmark:#两标相错就结束移动
           done = True
       else:
           temp = alist[leftmark]
           alist[leftmark] = alist[rightmark]
           alist[rightmark] = temp#左右标值交换

   temp = alist[first]
   alist[first] = alist[rightmark]
   alist[rightmark] = temp#中值就位


   return rightmark#中值点也就是分裂点
   
alist = [54,26,93,17,77,31,44,55,20]
quickSort(alist)
print(alist)
算法分析

快速排序过程分为:分裂和移动
如果分裂总能把数据表分为相等的两部分,那么就是O(log n)的复杂度
而移动需要将每项都与中值进行对比,还是O(n)

综合起来就是O(nlog n)
而且算法运行过程中不需要额外的存储空间
极端情况:有一部分始终没有数据(分裂后,小于中值或者大于中值的数据始终没有),这样算法复杂度就是O(n2),还要加上递归调用的开销
可以适当改进中值的选取方法,让中值具有代表性
比如三点取样,从数据表的头、尾、中间选出中值

总结
  • 冒泡排序:最直观的排序,最稳定,O(n2),不需要额外的空间,空间复杂度:O(1)(小改进:一次交换都没有发生,就直接停)

  • 选择排序:同冒泡排序,记录最大值,比对O(n2)只做一次交换;

  • 插入排序:平均n2,最好n,不需要额外空间;严重依赖于数据的初始布局

    • 从第一项开始,逐步扩大排序子表,寻找“新项”插入位置
    • 在已排好子表中,从后往前,比对、移动所有比“新项”大的数据项
  • 谢尔排序:在n、n2之间;不需要额外空间;

    • 在子列表中采用插入排序gatInsertionSort
    • 从n/2倍增减小gap直到1,子列表个数直到1个,排好序
  • 归并排序:稳定 nlog n;需要1倍额外空间

    • 以“中间位置”作为分裂子列表的依据;
    • 递归->合并
  • 快速排序:最好、平均nlog n;最差n2;不需要额外空间

    • 以“中间值”作为分裂子列表的依据;
    • 分裂->递归
    • 数据的初始分布、取中间值的方法特别重要
稳定排序
  • 保证排序前2个相等数的前后位置,和排序后前后位置顺序相同
    稳定性的好处:支持多个键值排序;可以减少一些交换次数;
    稳定性判断依据:1.是否发生数据间跳跃式交换;2.比对方向和大小的方向是否一致

  • 冒泡排序:稳定,交换只发生在相邻两个数据项之间;

  • 选择排序:不稳定,可能会跨越多个数据项交换;

  • 插入排序:稳定,从后往前寻找插入位置,不改变相对;

  • 谢尔排序:不稳定,虽然以插入排序为基础,但多个子表是穿插的;

  • 基数排序:稳定,相同数据项始终按顺序进入队列,出队列;

  • 归并排序:稳定,在合并的过程中,左半部分始终在前面,也不破坏相同数据项的相对位置

  • 快速排序:不稳定,在右标左移的过程中,会把尾部的数,跳跃交换到前面,破坏相对位置

其他排序
睡眠排序

非比较
把每个数对应一个线程,同时启动这些线程
每个线程都是sleep对应数的时间,然后输出这个数
完成排序

import _thread
from time import sleep

items = [6,2,5,3,1,7]

def sleep_sort(i):
        sleep(i)
        print(i)

[_thread.start_new_thread(sleep_sort, (i,)) for i in items]
  • 面条排序
  • 猴子排序
    算法复杂度 n * n!
from random import shuffle,randint

def inorder(lst):
    for i in range(len(lst) - 1):
        if lst[i] > lst[i + 1]:
            return False
    return True

def bogo(x):
    while not inorder(x):
        shuffle(x)
    return x

l = bogo([randint(1,1000) for i in range(8)])
print(l)

散列:Harshing

构造一个新的数据结构,能使查找算法的复杂度降至O(1),就是“散列Harshing”
能够使得查找的次数降低到常数级别,就必须对数据项所在的位置有更多的先验知识
如果事先知道要找的数据项应该出现在数据集中的什么位置,就可以直接到那个位置看看数据项是否存在即可。

散列表(harsh table,又称哈希表)是一种数据集,其中数据项的存储方式尤其有利于将来快速的查找定位
散列表中的每一个存储位置,称为槽(slot),可以用来保存数据项,每个槽有一个唯一的名称。
实现从数据项到存储槽名称的转换的,称为“散列函数(harsh function)”

散列函数实例:

  • 为了将数据项保存在散列表中,设计第一个散列函数:
    数据项:54,26,93,17,77,31
  • 有一种常用的散列方法叫做“求余数”,将数据项除以散列表的大小,得到的余数作为槽号
    实际上“求余数”方法会以不同形式出现在所有散列函数中
    因为散列函数返回的槽号必须在散列表大小范围之内,所以一般会对散列表大小求余
  • 本例中的散列函数就是简单的求余:h(item) = item % 11
  • 按照散列函数h(item)为每个数据项计算出存放的位置之后,就可以将数据项存入相应的槽中。
ItemHash Value
5410
264
935
176
770
319
  • 6个数据项插入后,占据了散列表11个槽中的6个
    槽被数据项占据的比例称为散列表的“负载因子”,这里负载因子为6/11
  • 数据项都保存在散列表后,查找就无比简单
  • 要查找某个数据项是否存在于表中,只需要使用同一个散列函数,对查找项进行计算,测试下返回的槽号所对应的槽中是否有数据项即可
    实现了O(1)时间复杂度的查找算法
  • 如果有一个数与另一个数同时分配到同一个槽中,这种情况叫做“冲突collision”

完美散列函数

  • 给定一组数据项,如果一个散列函数能把每个数据项映射到不同的槽中,那么就把这个散列函数称为“完美散列函数”
    对于固定的一组数据,总是能想办法设计出完美散列函数
  • 但如果数据项经常性变动,很难有一个系统性的方法来设计对应的完美散列函数
  • 获得完美散列函数的一种方法是扩大散列表的容量,大到所有可能出现的数据项都能够占据不同的槽
  • 但这种方法对于可能数据项范围过大的情况并不适用
  • 好的散列函数需要具备以下特性:冲突最少(近似完美),计算难度低(额外开销小),充分分散数据项(节约空间)
  • 完美散列函数能够对任何不同的数据生成不同的散列值,如果把散列值当做数据的“指纹”或“摘要”,这种特性被广泛用于数据的一致性校验上。
    由任意长度的数据生成长度固定的“指纹”,还要求具备唯一性,设计巧妙的“准完美”散列函数能在实用范围上做到
  • 作为一致性校验的数据“指纹”函数需要具备以下特性:
    • 压缩性:任意长度的数据,得到的“指纹”长度是固定的;
    • 易计算性:从原数据计算“指纹”很容易;(从指纹计算原数据是不可能的)
    • 抗修改性:对原数据的微小变动,都会引起“指纹”的大改变;
    • 抗冲突性:已知原数据和“指纹”,要找到相同指纹的数据(伪造)是非常困难的
  • 最著名的近似完美散列函数是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位
    160位二进制相当于10的48次方,256位二进制相当于10的77次方
  • python自带了MD5和SHA系列的散列函数:
import hashlib
hashlib.md5("hello world!".encode('utf-8')).hexdigest()
  • 使用updata方法来对任意长的数据分部分计算
>>> import hashlib
>>> m = hashlib.md5()
>>> m.update("hello world!".encode('utf-8'))
>>> print(m.hexdigest())
  • 完美散列函数可以用在数据一致性校验
    • 数据文件一致性判断
    • 为每个文件计算其散列值,仅对比其散列值即可得知是否文件内容相同
    • 用于网络文件下载完整性校验
    • 用于文件分享系统:网盘中相同的文件可以无需存储多次
  • 完美散列函数用于数据一致性校验
    • 加密形式保存密码
    • 仅保存密码的散列值,用户输入密码后,计算散列值并比对
    • 无需保存密码的明文即可判断用户是否输入了正确的密码
  • 防文件篡改
  • 彩票投注应用

区块链技术

  • 区块链是一种分布式数据库
    通过网络连接的节点
    每个节点都保存着整个数据库所有数据
    任何地点存入的数据都会完成同步
  • 区块链最本质的特征是“去中心化”
    不存在控制中心、协调中心节点
    所有的节点都是平等的,无法被控制
  • 区块链由一个个区块(block)组成,区块分为头(head)和体(body)
  • 区块头记录了一些元数据和链接到前一个区块的信息
    生成时间、前一个区块(head+body)的散列值
  • 区块体记录了实际的数据
  • 区块链不可修改性
    • 由于散列值具有抗修改性,任何对某个区块数据的改动必然引起散列值的变化
      为了不导致这个区块脱离链条,就需要修改所有后续的区块
      由于有“工作量证明”的机制,这种大规模修改不可能实现的,除非掌握全网51%以上的计算力
  • 工作量证明:Proof of Work(POW)
    • 由于区块链是大规模的分布式数据库,同步较慢,新区块的添加速度需要得到控制
      目前最大规模区块链比特币Bitcoin采用的速度是平均每10分钟生成一个区块
    • 大家不惜付出海量的计算,去抢着算出一个区块的有效散列值
    • 最先算出的那位“矿工”才有资格把区块挂到区块链上
  • 为什么有效散列值那么难计算?
    • 因为很难算出,所以控制了新区块生成的速度,便于在整个分布式网络中进行同步
    • 每个区块设置了一个难度系数Difficulty,用常数targetmax除以它,得到一个target,难度系数越高,target越小
    • 矿工的工作是,找到一个数值Nonce,把它跟整个区块数据一起计算散列,这个散列值必须小于target,才是有效的散列值
    • 由于散列值无法回推原值,这个Nonce的寻找只能靠暴力穷举,计算工作量+运气是唯一的方法

散列函数设计

折叠法
  • 折叠法设计散列函数基本步骤:
    • 1.将数据项按照位数分为若干段
    • 2.再将几段数字相加
    • 3.最后对散列表大小求余,得到散列值
      62767255->(62,76,72,55)->sum=255->265%11 = 1->h(62767255)=1
  • 有时候折叠法还会包含一个隔数反转的步骤
  • 隔数反转为折叠法得到散列函数提供了一种微调手段,以便更好符合散列特性
平方取中法
  • 平方取中法,首先将数据项做平方运算,然后去平方数的中间两位,再对散列表的大小求余
    例:44*44=1936 -> 取中间两位 92 -> 93%11 = 5 -> h(44) = 5
对比

两个都是完美散列函数,分散度都很好,平方取中法计算量稍大

itemRemainderMid-Square
54103
2647
9359
1768
7704
3196
非数项

把字符串中的每个字符看成ASCII码即可,再将这些整数累加,对散列表大小求余

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

这样的散列函数对所有的变位词都会返回相同的散列值
为了防止出现这种情况,可以将字符串所在的位置作为权重因子,乘以ord值

散列函数不能成为存储过程和查找过程的负担

冲突解决方案

开放定址法

如果两个数据项被散列映射到同一个槽中,需要一个系统化方法在散列表中保存第二个数据项,这个过程就称为“解决冲突”

  • 解决散列的一种方法就是为冲突的数据项再找一个开放的空槽来保存
    • 最简单的就是从冲突的槽开始往后扫描,指导碰到一个空槽为止
    • 如果到散列表尾部还未找到,则从首部接着扫描
  • 这种寻找空槽的技术叫做“开放定址open addressing”
  • 向后逐个槽寻找的方法则是开放定址技术中的“线性探测linear probing”
  • 采用线性探测方法来解决散列冲突的话,那么散列表的查找也遵循同样的规则
    • 如果在散列位置没有找到查找项的话,就必须向后做顺序查找
    • 直到找到查找项,或者碰到空槽(查找失败)
线性探测的改进
  • 线性探测法的一个缺点就是有聚集(clustering)的趋势
    • 即如果同一个槽冲突的数据较多的话,这些数据项就会在槽附近聚集起来
    • 从而连锁式影响其他数据项的插入
  • 避免聚集的一种方法是将线性探测扩展,从逐个探测改为跳跃式探测
再散列rehashing
  • 重新寻找空槽的过程可以概括为“再散列rehashing”
    newhashvalue = rehash(oldhashvalue)
    对于线性探测,rehash(pos) = (pos+1)%sizeoftable
    "+3"的跳跃式探测则是:rehash(pos) = (pos+3)%sizeoftable
    跳跃式探测的再散列通式为:resh(pos) = (pos+ship)%sizeoftable
  • 跳跃式探测中,需要注意的是skip的取值,不能被散列表大小整除,否则会产生周期,造成很多空槽永远无法探测到
    一个技巧是散列表的大小设置为素数,如11
  • 还可以将线性探测变为“二次探测 quadratic probing”
  • 不再固定skip的值,而是逐步增加skip的值,如1,3,5,7
  • 这样槽号就会是原散列值以平方数增加:h,h+1,h+4,h+9…
数据项链

另一种解决散列冲突方案:将容纳单个数据项的槽扩展为容纳数据项集合(或者对数据项链表的引用)
散列表中每个槽就可以容纳多个数据项,如果有散列冲突发生,只需要简单将数据项添加到数据项集合中
查找数据项时则需要查找同一个槽中的整个集合,随着散列冲突的增加,对数据项的查找时间也会相应增加

映射抽象数据类型

抽象数据类型映射:Map
  • python最有用的数据类型之一:“字典”
  • 字典是一种可以保存key-data(value)键值对的数据类型
    其中关键码key可用于查询关联的数据值data
  • 这种键值关联的方法称为“映射Map”
  • ADT Map的结构是键-值关联的无序组合
    关键码具有唯一性
    通过关键码可以唯一确定一个数据值
  • ADT Map定义的操作如下:
    Map():创建一个空映射,返回空映射对象;
    put(key,val):将key-val关联对加入映射中,如果key已存在,将val替换旧关联值
    get(key):给定key,返回关联的数据值,如不存在,则返回None
    del:通过del map[key]的语句形式删除key-val关联
    len():返回映射中key-val关联的数目
    in:通过key in map的语句形式,返回key是否存在于关联中,布尔值
  • 使用字典的优势在于,给定关键码key,能够很快得到关联的数据值data
  • 为了达到快速查找的目的,需要一个支持高效查找的ADT实现
    可以采用列表数据结构加顺序查找或者二分查找
    更为合适的可以使用散列表
    这样可以实现最快O(1)的性能
  • 使用hashtable来实现ADT Map,该类包含两个列表作为成员
    slot列表用于保存key,data列表用于保存数据项
  • 在slot列表查找到一个key位置后,在data列表中对应相同位置的数据项即为关联数据
  • 保留key的列表就作为散列表来处理,这样可以迅速查找到指定的key

散列算法分析

散列在最好的情况下,会提供O(1)常数级时间复杂度的查找性能
由于散列冲突的存在,查找次数就没这么简单

评估散列冲突的最重要信息就是负载因子λ
如果λ较小,散列冲突的几率就小,数据项通常会保存在其所属的散列槽中
如果λ较大,散列表填充较满,冲突会越来越多,冲突解决也会越复杂,也就需要更多的比较来找到空槽(开放定址法);
如果采用数据链的话,意味着每条链上的数据项增多

  • 如果采用线性探测的开放定址法来解决冲突(λ在0~1之间):
    • 成功的查找,平均需要比对次数为:1/2(1+1/(1-λ))
    • 不成功的查找,平均需要比对次数为:1/2(1+(1/(1-λ)2))
  • 如果采用数据链来解决冲突(λ可大于1):
    • 成功的查找,平均需要比对次数为:1+λ/2(数据链的查找)
    • 不成功的查找,平均需要比对次数为:λ

总结

  • 在有序表或者无序表上的顺序查找,时间复杂度为O(n)
  • 在有序表上进行二分查找,其最差时间复杂度为O(log n)
  • 散列表可以实现常数级时间的查找
  • 完美散列函数作为数据一致性校验,应用很广
  • 区块链技术是一种去中心化的分布式数据库,通过“工作量证明”机制来维持运行
  • 冒泡、选择和插入排序都是O(n2)的算法
  • 谢尔排序在插入排序的基础上进行了改进,采用对递增子表排序的方法,其时间复杂度可在O(n)和O(n2)之间
  • 归并排序的时间复杂度是O(nlog n),但归并的过程需要额外一倍的存储空间
  • 快速排序最好的时间复杂度是O(nlog n),也不需要额外存储空间,但如果分裂点偏离列表中心,最坏情况下会退化到O(n2)

查找过程的信息

  • 存取(access):由位置存储/获取值
  • 查找(search):由值确定其存储位置
  • 数据存储位置与值无关:无序表
    只有相等或不相等:顺序查找
  • 数据存储的相对位置与其值相关:有序表#值与位置有弱相关
    如果还可以进行大小对比:按照大小排序后,二分查找
  • 数据存储的绝对位置与其值相关:散列表#值与位置有强相关
    值和存储位置有函数关系:通过散列函数直接映射到位置

排序过程中的比较信息

一阶信息

ai,aj之间的大小比较,比如ai<aj
冒泡、选择、插入、谢尔排序
每一轮都会将未排序的部分过一遍,未排序部分每轮缩小一个数
时间复杂度在n2级别

二阶隐含信息

ai,aj以及ai,ak之间大小的信息
如ai>aj,又有aj>ak,应该有ai>ak,ai和ak不需要再次比较
快速排序:中值就是aj,就把ai和ak分裂到不同子表,不会再次比较
归并排序:在合并过程中,比如ai属于一个子表,aj/ak属于另一个子表,也不会让ai和ak再次比较
未排序部分每轮缩小一半,时间复杂度在nlogn级别

具有先验信息的排序算法
  • 基数排序
    通过把整数表示为某个进制的符号表示,如十进制;
    我们已经预先知道了进制中数字符号之间的大小
    基数队列之间是有固定的先后次序
  • 这样甚至不需要去直接比较,只需要按照每一个符号排到相应的队列中
  • 所以具有更好的排序性能,时间复杂度是nlongB N级别,B是基数,N是这组数中最大的数,就是最大的数有几位
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值