Python数据结构与算法分析 第五章-搜索和排序

129 阅读7分钟

Python数据结构与算法分析

第五章   搜索和排序

5.1 何谓搜索

搜索是指从元素集合中找到某个特定元素的算法过程。搜索过程通常返回 True 或 False,分别表示元素是否存在。有时,可以修改搜索过程,使其返回目标元素的位置。

5.1.1 顺序搜索

  • 存储于列表等集合中的数据项彼此存在线性或顺序的关系,每个数据项的位置与其他数据项相关。
  • 在 Python 列表中,数据项的位置就是它的下标(index),这些下标都是有序的整数。
  • 为下标是有序的,所以能够顺序访问,由此可以进行顺序搜索。

顺序查找步骤

确定列表总是否存在需要查找的数据项

 从列表中的第一个元素开始,沿着默认的顺序逐个查看,直到找到目标元素或者查完列表。如果查完列表后仍没有找到目标元素,则说明目标元素不在列表中

1665199296751.png

顺序搜索:无序表搜索
## 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次。

1665200022841.png

顺序搜索:有序表搜索

 假设列表中的元素按升序排列。如果存在目标元素,那么它出现在 n 个位置中任意一个位置的可能性仍然一样大,因此比较次数与在无序列表中相同。不过,如果不存在目标元素,那么搜索效率就会提高。

  • 如下图中查找目标元素50,当看到54时,可知道后面不存在50,可提前退出

1665200166343.png

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 二分搜索

 二分搜索从中间的元素着手,如果这个元素就是目标元素,那就立即停止搜索;如果不是,则可以利用列表有序的特性,排除一半的元素。如果目标元素比中间的元素大,就可以直接排除列表的左半部分和中间的元素。这是因为,如果列表包含目标元素,它必定位于右半部分

1665371769040.png

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)

1665454892185.png  尽管二分搜索通常优于顺序搜索,但当 n 较小时,排序引起的额外开销可能并不划算。实际上应该始终考虑,为了提高搜索效率,额外排序是否值得。如果排序一次后能够搜索多次,那么排序的开销不值一提。然而,对于大型列表而言,只排序一次也会有昂贵的计算成本,因此从头进行顺序搜索可能是更好的选择。

5.3 散列 hashing

散列表是元素集合,其中的元素以一种便于查找的方式存储,使搜索次数降低到常数级别。散列表中的每个位置通常被称为,其中可以存储一个元素。槽用一个从 0 开始的整数标记,例如 0 号槽、1 号槽、2 号槽,等等。可以用列表来实现散列表,并将每个元素都初始化为 Python 中的特殊值 None

1665455390272.png

散列函数将散列表中的元素与其所属位置对应起来。对散列表中的任一元素,散列函数返回一个介于 0 和 m – 1 之间的整数。

 假设有一个由整数元素 54、26、93、17、77 和 31 构成的集合。首先来看第一个散列函数,它有时被称作“取余函数”,即用一个元素除以表的大小,并将得到的余数作为散列值h(item) = item%11

1665456544491.png

 在 11 个槽中,有 6 个被占用了。占用率被称作载荷因子,记作

image.png

1665455670186.png

 如果集合中的下一个元素是 44,它的散列值是 0(44%11==0),而 77 的散列值也是 0,这就有问题了。散列函数会将两个元素都放入同一个槽,这种情况被称作冲突,也叫“碰撞”。

5. 3. 1 散列函数:完美散列函数

 给定一个元素集合,能将每个元素映射到不同的槽,这种散列函数称作完美散列函数

  • 构建方法

 构建完美散列函数的一个方法是增大散列表,使之能容纳每一个元素,这样就能保证每个元素都有属于自己的槽。当元素很多时,就不可行

  • 冲突最少(近似完美)
  • 计算难度低(额外开销小)
  • 充分分散数据项(节约空间)

1665457656170.png

  • 用途

1665457817441.png

1665457853312.png

5. 3. 2 散列函数:区块链技术

区块链是一种分布式数据库,通过网络连接的节点,每个节点都保存着整个数据库所有数据,任何地点存入的数据会完成同步

本质特征:“去中心化”

  • 不存在任何控制中心,协调中心节点
  • 所有节点都是平等,无法被控制

结构

  • 由区块(block)组成,分为头(head)和体(body)

    • 区块头记录了一些元数据和链接到前一个区块的信息

      -生成时间,前一个区块(head+body)的散列值

    • 区块体记录了实际数据

1665458405253.png

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”看作序数值序列,如下所示。

1665459773567.png

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

1665459825848.png

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

针对异序词,这个散列函数总是得到相同的散列值。要弥补这一点,可以用字符位置作为权重因子

1665460048757.png

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,保存

1665540709468.png

线性探测改进
  • 线性探测的缺点:会使散列表中的元素出现聚集,如果一个槽发生太多冲突,线性探测会填满其附近的槽,而这会影响到后续插入的元素。
  • 改进:从逐个探测改为跳跃探测

'+3' 探测插入44、55、20

1665541051718.png

再散列

重新寻找空槽的过程被称为再散列

采用线性探测时,再散列函数是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 链接法
  • 让每个槽有一个指向元素集合(或链表)的引用。

1665541554467.png

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趟比较交换,实现整表排序

1665545329822.png

  • 第一趟比较交换,共有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)

1665713714025.png

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趟对比和插入,子列表扩展到全表,排序完成

1665715100312.png

  • 插入排序主要用于比较寻找“新项”的插入位置
  • 最差情况是每趟都需要与子列表比较数量级为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

1665716609730.png

最终再进行插入排序

1665716776934.png


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个数据项

    • 缩小规模:改变:将数据项分裂为两半,规模减少到原来的二分之一

    • 调用自身:将两半分别调用自身排序,然后将分别排好序的两半进行归并

      • 拆分
      • 1665718885521.png
      • 归并

1665719001362.png

### 归并排序
 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 快速排序

和归并排序一样,快速排序也采用分治策略,但不使用额外的存储空间。

  • 思路:依据 “中值” 把数据项分为 少于中值的一半大于中值的一半,再对每部分进行快速排序(递归)

    • 找中值需要计算开销,随机找一个数来充当中值,比如第一个数
  • 递归思路

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

1665891412707.png

1665891201982.png

1665891367116.png

#快速排序
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²)