查找与排序

162 阅读1分钟

顺序查找

什么是顺序查找

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

顺序查找的实现

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

image.png

假设中途已经把数据项找到了,我们返回成功的信息即可

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

算法分析

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

在顺序查找算法中,为了保证是讨论的一般情形,需要假定列表中的数据项并没有按值排列顺序,而是随机放置在列表中的各个位置

换句话说,数据项在列表中各处出现的概率是相同的

数据项是否在列表中,比对次数是不一样的:

  • 如果数据项不在列表中,需要比对所有数据项才能得知,比对次数是n

  • 如果数据项在列表中,要比对的次数,其情况就较为复杂:

    • 最好的情况,第1次比对就找到
    • 最坏的情况,要n次比对

数据项在列表中,比对的一般情形如何?

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

最坏的情况最好的情况平均复杂度
元素在列表中11nnn2\frac{n}2
元素不在列表中nnnnnn

这里我们假定列表中的数据项是无序的那么如果数据项排了序,顺序查找算法的效率又如何呢?

当数据项存在时,比对过程与无序表完全相同,不同之处在于,如果数据项不存在,比对可以提前结束

def sequentialSearch(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

此时的时间复杂度如下:

最坏的情况最好的情况平均复杂度
元素在列表中11nnn2\frac{n}2
元素不在列表中11nnn2\frac{n}2

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

二分查找

什么是二分查找

我们上面所说顺序查找对于有序表而言只能节省一些比对次数,不能改变数量级,那么对于有序表,有没有更好更快的查找算法?在顺序查找中,如果第1个数据项不匹配查找项的话,那最多还有n-1个待比对的数据项。那么,有没有方法能利用 有序表的特性 迅速缩小待比对数据项的范围呢?

答案是有办法的!我们可以从列表中间开始对比!

  • 如果列表中间的项匹配查找项,则查找结束

  • 如果不匹配,那么就有两种情况:

    • 列表中间项比查找项大,那么查找项只可能出现在前半部分
    • 列表中间项比查找项小,那么查找项只可能出现在后半部分

无论如何,我们都会将比对范围缩小到原来的一半:n2\frac{n}2

image.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<len([midpoint]):
                return binarySearch(alist[:midpoint], item)
            else:
                return binarySearch(alist[midpoint+1:], item)

算法分析

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

比较剩余的数据项
1n2\frac{n}2
2n4\frac{n}4
3n8\frac{n}8
......
in2i\frac{n}{2^i}

当比对次数足够多以后,比对范围内就会仅剩余1个数据项,无论这个数据项是否匹配查找项,比对最终都会结束,也就是说:i=log2(n) ,n2i=1i=log_2(n) ,\frac{n}{2^i}=1 ,所以二分查找的算法复杂度是O(log n)

细节分析:

虽然我们根据比对的次数,得出二分查找的复杂度O(log n),但本算法中除了比对,还有一个因素需要注意到:

binarySearch(alist[:midpoint],item)

这个递归调用使用了列表切片,而切片操作的复杂度是O(k),这样会使整个算法的时间复杂度稍有增加

当然,我们采用切片是为了程序可读性更好,实际上也可以不切片,而只是传入起始和结束的索引值即可,这样就不会有切片的时间开销了。

另外,虽然二分查找在时间复杂度上优于顺序查找,但也要考虑到对数据项进行排序的开销:

  • 如果一次排序后可以进行多次查找,那么排序的开销就可以摊薄
  • 但如果数据集经常变动,查找次数相对较少,那么可能还是直接用无序表加上顺序查找来得经济

所以,在算法选择的问题上, 光看时间复杂度的优劣是不够的,还需要考虑到实际应用的情况。

冒泡排序

什么是冒泡排序

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

image.png

第1趟比较交换,共有n-1对相邻数据进行比较一旦经过最大项,则最大项会一路交换到达最后一项;第2趟比较交换时,最大项已经就位,需要排序的数据减少为n-1,共有n-2对相邻数据进行比较,直到第n-1趟完成后,最小项一定在列表首位,就无需再处理了。

冒泡排序的实现

def bubbleSort(alist):
    for passnum in range(len(alist)-1,0,-1):
        for i in range(passnum):
            if alist[i] > alist[i+1]:
                temp = alist[i]
                alist[i] = alist[i+1]
                alist[i+1] = temp
                """
                python支持直接交换:
                alist[i],alist[i+1]=alist[i+1],alist[i]
                """

算法分析

无序表初始数据项的排列状况对冒泡排序没有影响

算法过程总需要n1n-1趟,随着趟数的增加,比对次数逐步从n1n-1减少到1,并包括可能发生的数据项交换。比对次数是1n11 \sim n-1 的累加:12n212n\frac12n^2-\frac12n ,比对的时间复杂度是O(n2)O(n^2) ,关于交换次数,时间复杂度也是O(n2)O(n^2) ,通常每次交换包括3次赋值:

  • 最好的情况是列表在排序前已经有序,交换次数为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

注意:即使是提前退出也无法改变算法的复杂度

选择排序

什么是选择排序

选择排序对冒泡排序进行了改进,保留了其基本的多趟比对思路,每趟都使当前最大项就位。但选择排序对交换进行了削减,相比起冒泡排序进行多次交换,每趟仅进行1次交换,记录最大项的所在位置,最后再跟本趟最后一项交换选择排序的时间复杂度比冒泡排序稍优:

  • 比对次数不变,还是O(n2)O(n^2)
  • 交换次数则减少为O(n)O(n)

image.png

选择排序的实现

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

插入排序

什么是插入排序

插入排序时间复杂度仍然是O(n2)O(n^2) ,但算法思路与冒泡排序、选择排序不同插入排序维持一个已排好序的子列表,其位置始终在列表的前部,然后逐步扩大这个子列表直到全表。

image.png

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

image.png

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

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

谢尔排序

什么是谢尔排序

我们注意到插入排序的比对次数,在最好的情况下是O(n),这种情况发生在列表已是有序的情况下,实际上,列表越接近有序,插入排序的比对次数就越少

从这个情况入手,谢尔排序以插入排序作为基础对无序表进行“间隔”划分子列表,每个子列表都执行插入排序

image.png

随着子列表的数量越来越少,无序表的整体越来越接近有序,从而减少整体排序的比对次数

image.png

间隔为3的子列表,子列表分别插入排序后的整体状况更接近有序

谢尔排序的实现

算法思路

最后一趟是标准的插入排序,但由于前面几趟已经将列表处理到接近有序,这一趟仅需少数几次移动即可完成

image.png

子列表的间隔一般从n2\frac{n}2 开始,每次倍增:n4,n8,...\frac{n}4,\frac{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

算法分析

粗看上去,谢尔排序以插入排序为基础,可能并不会比插入排序好,但由于每趟都使得列表更加接近有序,这过程会减少很多原先需要的“无效”比对

对谢尔排序的详尽分析比较复杂,大致说是介于O(n)O(n)O(n2)O(n^2)之间

如果将间隔保持在2k1(13571531...)2^{k-1}(1、3、5、7、15、31...) , 谢尔排序的时间复杂度约为O(n32)O(n^{\frac32})

归并排序

什么是归并排序

归并排序是分治策略在排序算法中一个很好的应用,归并排序是递归算法,思路是将数据表持续分裂为两半

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

流程图.jpg

归并算法的实现

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=j=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

我们对上面代码的可读性进行一下增强

# merge sort
#归并排序
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.pop(0))
        else:
            merged.append(right.pop(0))
    merged.extend(right if right else left)
    return merged

算法分析

将归并排序分为两个过程来分析:分裂和归并

  • 分裂的过程:借鉴二分查找中的分析结果是对数复杂度,时间复杂度为O(logn)O(log n)
  • 归并的过程:相对于分裂的每个部分,其所有数据项都会被比较和放置一次,所以是线性复杂度,其时间复杂度是O(n)O(n)

综合考虑,每次分裂的部分都进行一次O(n)O(n)的数据项归并,总的时间复杂度是O(nlogn)O(nlog n)

最后,我们还是注意到两个切片操作,为了时间复杂度分析精确起见,可以通过取消切片操作,改为传递两个分裂部分的起始点和终止点,也是没问题的,只是算法可读性稍微牺牲一点点。

缺点:我们注意到归并排序算法使用了额外1倍的存储空间用于归并。这个特性在对特大数据集进行排序的时候要考虑进去

快速排序

什么是快速排序

快速排序的思路是依据一个“中值”数据项来把数据表分为两半:小于中值的一半和大于中值的一半,

,然后每部分分别进行快速排序(递归)。

注意:如果希望这两半拥有相等数量的数据项,则应该找到数据表的“中位数”

但找中位数需要计算开销!要想没有开销,只能随意找一个数来充当“中值”

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

流程图 (1).jpg

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

分裂数据表的手段:设置左右标(left/rightmark),左标向右移动,右标向左移动

  • 左标一直向右移动,碰到比中值大的就停止
  • 右标一直向左移动,碰到比中值小的就停止
  • 然后把左右标所指的数据项交换

继续移动,直到左标移到右标的右侧,停止移动,这时右标所指位置就是“中值”应处的位置,将中值和这个位置交换,分裂完成,左半部比中值小,右半部比中值大

快速排序的实现

def partition(alist, frist, last):
    """分裂函数"""
    pivotvalue = alist[frist]   # 中值选定
    leftmark = frist + 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[frist]  # 中值和右标交换值
    alist[frist] = alist[rightmark]
    alist[rightmark] = temp
    return 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)

算法分析

快速排序过程分为两部分:分裂和移动

如果分裂总能把数据表分为相等的两部分,那么就是O(1ogn)O(1og n)的复杂度;而移动需要将每项都与中值进行比对,还是O(n)O(n),综合起来就是O(nlogn)O(nlog n);而且,算法运行过程中不需要额外的存储空间。但是,如果不那么幸运的话,中值所在的分裂点过于偏离中部,造成左右两部分数量不平衡

极端情况,有一部分始终没有数据,这样时间复杂度就退化到O(n2)O(n^2) ,还要加上递归调用的开销(比冒泡排序还糟糕)

所以我们说中值选取是冒泡排序的关键,可以适当改进下中值的选取方法,让中值更具有代表性,比如“三点取样”:从数据表的头、尾、中间选出中值

会产生额外计算开销,仍然不能排除极端情况,还有什么采样具有代表性?

散列

什么是散列

前面我们利用数据集中关于数据项之间排列关系的知识,来将查找算法进行了提升,如果数据项之间是按照大小排好序的话,就可以利用二分查找来降低算法复杂度。现在我们进一步来构造一个新的数据结构,能使得查找算法的复杂度降到O(1),这种概念称为“散列Hashing

能够使得查找的次数降低到常数级别,我们对数据项所处的位置就必须有更多的先验知识。如果我们事先能知道要找的数据项应该出现在数据集中的什么位置,就可以直接到那个位置看看数据项是否存在即可。

由数据项的值来确定其存放位置,如何能做到这一点呢?

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

我们在juejin.cn/post/720725…

示例

image.png

实现从数据项到存储槽名称的转换的,称为散列函数(hash function ),下面示例中,散列函数接受数据项作为参数,返回整数值0~ 10,表示数据项存储的槽号(名称):

  • 为了将数据项保存到散列表中,我们设计第一个散列函数数据项: 54,26,93,17,77,31
  • 有一种常用的散列方法是"求余数”,将数据项除以散列表的大小,得到的余数作为槽号。

实际上“求余数”方法会以不同形式出现在所有散列函数里,因为散列函数返回的槽号必须在散列表大小范围之内,所以一般会对散列表大小求余。

itemHash value
5410
264
935
176
710
319

本例中我们的散列函数是最简单的求余:h(item)=item%11h(item)= item \% 11 ,按照散列函数h(item)h(item),为每个数据项计算出存放的位置之后,就可以将数据项存入相应的槽中。

例子中的6个数据项插入后,占据了散列表11个槽中的6个。槽被数据项占据的比例称为散列表的 “负载因子” ,这里负载因子为611\frac6{11}

数据项都保存到散列表后,查找就无比简单要查找某个数据项是否存在于表中,我们只需要使用同一个散列函数,对查找项进行计算,测试下返回的槽号所对应的槽中是否有数据项即可。实现了O(1)O(1) 时间复杂度的查找算法。

但是一般情况下,我们不太可能所有的数据都可以各自占据不同的槽(假如还要保存44,h(44)=0,它跟77被分配到同一个0#槽中)。这种情况称为“冲突collision”

解决方法在后面!!!

完美散列函数

给定一组数据项,如果一个散列函数能把每个数据项映射到不同的槽中,那么这个散列函数就可以称为 “完美散列函数”

对于固定的一组数据,总是能想办法设计出完美散列函数

但如果数据项经常性的变动,很难有一个系统性的方法来设计对应的完美散列函数

当然,冲突也不是致命性的错误,我们会有办法处理的。

获得完美散列函数的一种方法是扩大散列表的容量,大到所有可能出现的数据项都能够占据不同的槽,但这种方法对于可能数据项范围过大的情况并不实用

假如我们要保存手机号(11位数字),完美散列函数得要求散列表具有百亿个槽!会浪费太多存储空间

退而求其次,好的散列函数需要具备特性:冲突最少(近似完美)、计算难度低(额外开销小)、充分分散数据项(节约空间)

完美散列函数的用途

除了用于在散列表中安排数据项的存储位置,散列技术还用在信息处理的很多领域。由于完美散列函数能够对任何不同的数据生成不同的散列值,如果把散列值当作数据的"指纹”或者“摘要”,这种特性被广泛应用在数据的一致性校验上:由任意长度的数据生成长度固定的“指纹”还要求具备唯一性,这在数学上是无法做到的,但设计巧妙的“准完美”散列函数却能在实用范围内做到这一点。

完美散列函数的性质

作为一致性校验的数据"指纹”函数需要具备如下的特性:

压缩性: 任意长度的数据,得到的“指纹”长度是固定的;

易计算性: 从原数据计算“指纹”很容易;(从指纹计算原数据是不可能的) ;

抗修改性: 对原数据的微小变动,都会引起“指纹”的大改变;

抗冲突性: 已知原数据和“指纹”,要找到相同指纹的数据(伪造)是非常困难的

典型的完美散列函数

最著名的近似完美散列函数是MD5和SHA系列函数

MD5 ( Message Digest )将任何长度的数据变换为固定长为128位( 16字节)的“摘要”

128位二进制已经是一一个极为巨大的数字空间:据说是地球沙粒的数量

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次方,地球上水分子数量估计是47次方

256位二进制相当于10的77方,已知宇宙所有基本粒子大约是72~ 87次方

虽然近年发现MD5/SHA-0/SHA-1三种散列函数能够以极特殊的情况来构造个别碰撞(散列冲突)但在实用中从未有实际的威胁。

数据一致性校验

数据文件一致性判断,为每个文件计算其散列值,仅对比其散列值即可得知是否文件内容相同;

  • 用于网络文件下载完整性校验;
  • 用于文件分享系统:网盘中相同的文件(尤其是电影)可以无需存储多次。
  • 加密形式保存密码,仅保存密码的散列值,用户输入密码后,计算散列值并比对;无需保存密码的明文即可判断用户是否输入了正确的密码。
  • 防文件篡改:原理同数据文件一致性判断

当然还有更多密码学机制来保护数据文件,防篡改,防抵赖,是电子商务的信息技术基础。

  • 彩票投注应用

彩民下注前,机构将中奖的结果散列值公布,然后彩民投注,开奖后,彩民可以通过公布的结果和散列值对比,验证机构是否作弊。

python的散列函数库hashlib

docs.python.org/3/library/h…

Python自带MD5和SHA系列的散列函数库: hashlib,包括了md5/sha1/sha224/sha256/sha384 / sha512等6种散列函数

除了对单个字符串进行散列计算之外,还可以用update方法来对任意长的数据分部分来计算这样不管多大的数据都不会有内存不足的问题

区块链技术

散列函数的最酷应用

什么是区块链

区块链是一种分布式数据库,通过网络连接的节点,每个节点都保存着整个数据库所有数据,任何地点存入的数据都会完成同步。区块链最本质特征是“去中心化”,不存在任何控制中心、协调中心节点所有节点都是平等的,无法被控制。

如何做到不需要相互信任和权威,即可防止篡改和破坏?

区块链由一个个区块(block)组成,区块分为头(head)和体(body),区块头记录了一些元数据和链接到前一个区块的信息(生成时间、前一个区块(head+body)的散列值),区块体记录了实际数据

image.png

由于散列值具有抗修改性,任何对某个区块数据的改动必然引起散列值的变化。为了不导致这个区块脱离链条,就需要修改所有后续的区块,由于有“工作量证明”的机制,这种大规模修改不可能实现的,除非掌握了全网51%以的计算力。

工作量证明

由于区块链是大规模的分布式数据库,同步较慢,新区块的添加速度需要得到控制(目前最大规模区块链Bitcoin采用的速度是平均每10分钟生成一个区块),大家不惜付出海量的计算,去抢着算出一个区块的有效散列值,最先算出的那位“矿工”才有资格把区块挂到区块链中。

为什么有效散列值那么难算出?

因为很难算出,所以控制了新区块生成的速度,便于在整个分布式网络中进行同步,每个区块设置了一个难度系数Difficulty,用常数targetmax除以它,得到一个target,难度系数越高,target越小。矿工的工作是,找到一个数值Nonce,把它跟整个区块数据一起计算散列,这个散列值必须小于target,才是有效的散列值由于散列值无法回推原值,这个Nonce的寻找只能靠暴力穷举,计算工作量+运气是唯一的方法。

Bitcoin的一个区块:blockexplorer.com/

为什么矿工抢着生成区块?

因为有利益。在加密货币Bitcoin中,区块内包含的数据是“交易记录”,也就是“账本”,这对于货币体系至关重要。Bitcoin规定,每个区块中包含了一定数量的比特币作为“记账奖励”,这样就鼓励了更多人加入到抢先记账的行列

是不是难度越大,生成区块的速度越慢呢?

由于硬件摩尔定律的存在,计算力将持续递增,为了维持每10分钟生成一个区块的速度,难度系数Difficulty也将持续递增挖矿计算力升级:

CPU(20MHash/s)GPU(400MHash/s)FPGA(25GHash/s)ASIC(3.5THash/s)大规模集群挖矿(3.5THash/sX)CPU (20MHash/s) →GPU (400MHash/s)→FPGA (25GHash/s)→ASIC (3.5THash/s)→大规模集群挖矿(3.5THash/s*X)

另外,为了保持货币总量不会无限增加,每4年奖励的比特币减半

2008年开始是50个,2019年为12.5个

散列函数设计

折叠法

折叠法设计散列函数的基本步骤是

  1. 将数据项按照位数分为若干段,
  2. 再将几段数字相加,
  3. 最后对散列表大小求余,得到散列值

例如,对电话号码62767255可以两位两位分为4段(62、 76、72、55)相加(62+76+72+55=265)(62+76+72+55=265)散列表包括11个槽,那么就是265%11=1265\%11=1所以h(62767255)=1h(62767255)=1

有时候折叠法还会包括一个隔数反转的步骤:

比如(62、76、72、55)隔数反转为(62、67、72、55)再累加(62+67+72+55=256)(62+67+72+55=256)对11求余(256%11=3)(256\%11=3) ,所以h(62767255)=3h' (62767255)=3

虽然隔数反转从理论上看来毫无必要,但这个步骤确实为折叠法得到散列函数提供了一种微调手段,以便更好符合散列特性。

平方取中法

平方取中法,首先将数据项做平方运算,然后取平方数的中间两位,再对散列表的大小求余:

例如,对44进行散列:

首先44×44=193644\times 44=1936

然后取中间的93

对散列表大小11求余,93%11=593\%11=5

下表是两种散列函数的对比

数值折叠法平方取中法
54103
2647
9359
1768
7704
3196

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

非数值

我们也可以对非数字的数据项进行散列,把字符串中的每个字符看作ASCII码即可,如:

catord(c)==99ord(a)==96,ord(t)==116cat:ord('c')==99, ord('a')==96,ord('t')==116

再将这些整数累加,对散列表大小求余

image.png

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

当然,这样的散列函数对所有的变位词(abc与bca或cba......)都返回相同的散列值,为了防止这一点,可以将字符串所在的位置作为权重因子,乘以ord值

image.png

设计原则

我们还可以设计出更多的散列函数方法,但要坚持的一个基本出发点是,散列函数不能成为存储过程和查找过程的计算负担,如果散列函数设计太过复杂,去花费大量的计算资源计算槽号,可能还不如简单地进行顺序查找或者二分查找,失去了散列本身的意义

冲突的解决方案

如果两个数据项被散列映射到同一个槽,需要一个系统化的方法在散列表中保存第二个数据项,这个过程称为“解决冲突”,前面提到,如果说散列函数是完美的,那就不会有散列冲突,但完美散列函数常常是不现实的。解决散列冲突成为散列方法中很重要的部分。

开放定址

解决散列的一种方法就是为冲突的数据项再找一个开放的空槽来保存

最简单的就是从冲突的槽开始往后扫描,直到碰到一个空槽,如果到散列表尾部还未找到,则从首部接着扫描

这种寻找空槽的技术称为“开放定址open addressing

向后逐个槽寻找的方法则是开放定址技术中的 “线性探测linear probing”

image.png

采用线性探测方法来解决散列冲突的话,则散列表的查找也遵循同样的规则

如果在散列位置没有找到查找项的话,就必须向后做顺序查找,直到找到查找项,或者碰到空槽(查找失败)。

线性探测的改进

线性探测法的一个缺点是有聚集(clustering)的趋势,即如果同一个槽冲突的数据项较多的话,这些数据项就会在槽附近聚集起来,从而连锁式影响其它数据项的插入。避免聚集的一种方法就是将线性探测扩展从逐个探测改为跳跃式探测

image.png

再散列

另外一种解决办法叫再散列,重新寻找空槽的过程可以用一个更为通用的"再散列rehashing"来概括newhashvalue=rehash(oldhashvalue)newhashvalue = rehash( oldhashvalue)

  • 对于线性探测来说,rehash(pos)=(pos+1)%sizeoftablerehash(pos)= (pos+ 1)\%sizeoftable
  • “+3”的跳跃式探测则是:rehash(pos)=(pos+3)%sizeoftablerehash(pos)=(pos+ 3)\% sizeoftable
  • 跳跃式探测的再散列通式是: rehash(pos)=(pos+skip)%sizeoftablerehash(pos )=(pos+skip )\% sizeoftable

跳跃式探测中,需要注意的是skip的取值不能被散列表大小整除,否则会产生周期,造成很多空槽永远无法探测到

一个技巧是,把散列表的大小设为素数,如例子的11

还可以将线性探测变为“二次探测quadratic probing"不再固定skip的值,而是逐步增加skip值如1、3、5、7、9...这样槽号就会是原散列值以平方数增加:h, h+1, h+4, h+9, h+16...

数据项链

除了寻找空槽的开放定址技术之外,另种解决散列冲突的方案是将容纳单个数据项的槽扩展为容纳数据项集合(或者对数据项链表的引用)。这样,散列表中的每个槽就可以容纳多个数据项,如果有散列冲突发生,只需要简单地将数据项添加到数据项集合中。查找数据项时则需要查找同一个槽中的整个集合,当然,随着散列冲突的增加,对数据项的查找时间也会相应增加。

image.png

映射现象数据类型及python实现

什么是映射现象数据类型

抽象数据类型“映射”(ADT Map)。Python最有用的数据类型之一:“字典”,字典是一种可以保存key-data键值对的数据类型,其中关键码key可用于查询关联的数据值data。这种键值关联的方法称为“映射Map”,ADT Map的结构是键---值关联的无序集合:关键码具有唯一性,通过关键码可以唯一确定一个数据值

ADT Map的操作

ADT Map定义的操作如下:

Map():创建一个空映射,返回空映射对象;

put(key, val):将key-val关联对加入映射中,如果key已存在,将val替换旧关联值;

get(key):给定key,返回关联的数据值,如不存在,则返回None;

del:通过del map[key]的语句形式删除key-val关联;

1en():返回映射中key-val关联的数目;

in:通过key in map的语句形式,返回key是否存在于关联中,布尔值

ADT Map的实现

使用字典的优势在于,给定关键码key,能够很快得到关联的数据值data,为了达到快速查找的目标,需要一个支持高效查找的ADT实现

  • 可以采用列表数据结构加顺序查找或者二分查找
  • 当然,更为合适的是使用前述的散列表来实现,这样查找可以达到最快O(1)O(1) 的性能

下面,我们用一个HashTable类来实现ADT Map,该类包含了两个列表作为成员,其中一个slot列表用于保存key另一个平行的data列表用于保存数据项,在slot列表查找到一个key的位置以后,在data列表对应相同位置的数据项即为关联数据

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

保存key的列表就作为散列表来处理,这样可以迅速查找到指定的key

注意散列表的大小,虽然可以是任意数但考虑到要让冲突解决算法能有效工作,应该选择为素数。

def put(self,key,data):
    hashvalue = self.hashfunction(key)
    if self.slots[hashvalue] == None:  # key不存在,未冲突
        self.slots[hashvalue] = key
        self.data[hashvalue] = data
    else:
        if self.slots[hashvalue] == key:   # key已存在,替换value
            self .data [hashvalue] = data
        #replace
        else:
            nextslot三self.rehash(hashvalue)
            while self.slots[nextslot] != None and self.slots[nextslot] != key:   # 散列冲突,再散列,直到找到空槽或者key
                nextslot = self. rehash(nextslot)
            if self.slots[nextslot] == None:
                self.slots[nexts1ot]=key
                self.data[nextslot]=data
            else:
                self.data[nexts1ot] = data #replace
                
def hashfunction(self, key):
    return key% self.size
def rehash(self, oldhash):
    return (oldhash+ 1)% self.size

def get(self,key):
    startslot = self.hashfunction(key)  # 标记散列值为查找起点
    data = None
    stop = False
    found = False
    position = startslot
    while self.slots [position] != None and not found and not stop:  # 找key,直到空槽或回到起点
        if self.slots [position] == key:
            found = True
            data = self.data[position]
        else:
            position=self.rehash(position)  # 未找到key,再散列继续找
            if position == startslot:
                stop = True   # 回到起点,结束
    return data

hashfunction方法采用了简单求余方法来实现散列函数,而冲突解决则采用线性探测“加1”再散列函数

def __getitem__(self, key):
    return self. get(key)
def __setitem__(self,key,data):
    self.put(key, data)

通过特殊方法实现[]访问

散列算法分析

散列在最好的情况下,可以提供O(1)O(1) 常数级时间复杂度的查找性能由于散列冲突的存在,查找比较次数就没有这么简单。评估散列冲突的最重要信息就是负载因子λ\lambda ,一般来说:

  • 如果λ\lambda 较小,散列冲突的几率就小,数据项通常会保存在其所属的散列槽中
  • 如果λ\lambda 较大,意味着散列表填充较满,冲突会越来越多,冲突解决也越复杂,也就需要更多的比较来找到空槽;如果采用数据链的话,意味着每条链上的数据项增多

如果采用线性探测的开放定址法来解决冲突(λ\lambda 在0~1之间) .

  • 成功的查找,平均需要比对次数为:12(1+11λ)\frac12(1+\frac1{1-\lambda})
  • 不成功的查找,平均比对次数为:12(1+(11λ)2)\frac12(1+(\frac1{1-\lambda})^2)

如果采用数据链来解决冲突(λ\lambda 可大于1)

  • 成功的查找,平均需要比对次数为: 1+λ2\frac{1+\lambda}2
  • 不成功的查找,平均比对次数为: λ\lambda