算法(一) - 排序,查找

概念

递归特点:调用自身,有结束条件。

注意区分下面两种递归:

def func(x):
    if x > 0:
        print(x)
        func(x - 1)  
 func(5)输出 5 4 3 2 1

def func2(x):
    if x > 0:
        func2(x - 1)
        print(x)     
 func(5)输出 1 2 3 4 5

时间复杂度
是一个单位,也是一个估计值,这也就表示下面的代码:

print(x)
print(x)
print(x)      

不是O(3) 而是 O(1)

for i in range(n):
    print(x)
    for j in range(n):
        print(x)

不是O(N^2 + N) 而是 O(N^2)

for i in range(n):
    for j in range(i):
        print(x)

不是O(N^2 / 2)而是 O(N^2)

而这段代码:
while i > 0:
    print(i)
    i = i // 2  

他的时间复杂度为O(logN),假设输入64 则输出 64 32 16 8 4 2 ,即 6 次,则2的6次方为64,即log2 64 = 6(以2为底64的对数)

如何一眼粗略的看出复杂度?

  1. 是否有循环减半操作,减半就logN
  2. 几层循环就N的几次方

空间复杂度,用来评估算法内存占用大小的一个式子。
用几个变量的时候就是O(1),用一个列表的时候就是O(N)。

列表查找分为,

  • 顺序查找
  • 二分查找

tips:不要在一个递归函数上加装饰器,如果想加装饰器,则需要新开一个函数,加上装饰器,然后返回递归函数

tips:尾递归(return 递归函数)的运行速度跟正常函数一样。

tips:切片为什么慢?因为它要重新再创建一个列表或字符串来保存切好的。


二分查找

二分查的前提条件:有序列表,然后设立两个指针 low 和 high,得到他们的中间值 mid ,通过对待查找的值与候选区中间值的比较,可以使候选区减少一半。

def binary_search(li, target):
    low = 0
    high = len(li) - 1
    while low <= high:
        mid = (low + high) // 2
        if li[mid] == target:
            return mid
        elif li[mid] > target:
            high = mid - 1
        elif li[mid] < target:
            low = mid + 1
    return

排序:
首先看 LowB 三人组,冒泡,选择,插入。


冒泡排序

按升序排时,列表每两个相邻的数,如果前边的比后边的大,则交换着两个数。

一些名词:

一趟:表示的是当交换到最后,把最大的数交换上去的过程。趟数从0开始。

有序区,已经有序的区域,

无序区,还无序的区域。

冒泡的趟数为 N - 1,N 是数组长度,要减一的原因是当冒泡排到最后一个时,数组的最后一个也自然的有序了(肯定是最小的),而趟数就是最外层的循环。

趟数里的操作次数,假设趟数为 i ,则趟数操作次数为 N - i - 1,因为每完成一趟,则需要操作的数就减少一个,而交换时,不用交换有序区(因为交换是两个数的操作),所以减一。而趟数里的操作就是内层循环。

def bubble_sort(li):
    # 这里长度不用减一的原因是,range操作取不到最后,就相当于帮我们减一了。
    n = len(li) 
    for i in range(n - 1):
        for j in range(n - i - 1):
            if li[j] > li[j+1]:
                li[j], li[j+1] = li[j+1], li[j]

因为有两层循环,所以时间复杂度为O(N^2)

优化:如果冒泡排序中执行一趟而没有发生交换,则表明列表已经是有序的了,可以直接结束算法。可以通过设置一个 flag,通过 flag 来判断是否发生了交换。

def bubble_sort(li):
    for i in range(len(li) - 1):
        flag = False
        for j in range(len(li) - i - 1):
            if li[j] > li[j + 1]:
                li[j], li[j + 1] = li[j + 1], li[j]
                flag = True
        if not flag:
            break

但是时间复杂度还是O(N^2),因为还是两层循环,不过当遇到最好情况(已经排序好),时间复杂度是O(1)

选择排序

一趟遍历选出最小的数,放第一个位置,再一趟遍历选出剩余列表最小的,周而复始。

def select_sort(li):
    n = len(li)
    for i in range(n - 1):
        # 最开始假定下标为i 的元素是最小的
        min_loc = i
        # 因为设定 i 为最小的,所以range时从 i + 1 开始,就不用重复判断i 是否是最小的
        for j in range(i + 1, n - 1):
            if li[j] < li[min_loc]:
                min_loc = j
        # 当一趟执行完后,找到最小的元素的下标,然后和下标为 i 的进行交换
        li[i], li[min_loc] = li[min_loc], li[i]

复杂度 O(N^2)

插入排序

列表被分为有序区和无序区,最初有序区只有一个元素,每次从无序区选择一个元素,插入到有序区的位置,直到无序区变空。(可以想象打牌时抓牌的场景)。

def insert_sort(li):
    n = len(li)
    # 最初有序区有一个元素,所以从 1 开始, i 代表第几次来排序,也表示排的第几个元素,因为每次就排好一个元素
    for i in range(1, n):
        tmp = li[i]
        # 这个需要排序的来和他前面位置的比较大小
        j = i - 1
        while j >= 0 and li[j] > tmp:
            # 右移过程
            li[j + 1] = li[j]
            j -= 1
        # 因为右移过程会伴随的 j - 1 的操作,所以这里想将tmp存到合适位置j要 j + 1 
        li[j + 1] = tmp

复杂度O(N^2)

快速排序

取一个元素p(一般是第一个),使元素p归位(到他应该的位置),并使列表分为两部分,左边都比p小,右边都比p大。然后递归完成排序。

def quick_sort(li, left, right):
    # 递归终止条件,不等于的原因是有两个元素才需要进行排序
    if left < right:
        # 使元素归位,mid 是放置好的元素的下标
        mid = partition(li, left, right)
        quick_sort(li, left, mid - 1)
        quick_sort(li, mid + 1, right)

def partition(li, left, right):
    刚开始将最左边的数存起来,这样最左边就出现空位了,然后准备动右边的指针
    tmp = li[left]
    while left < right:
        # 当右边的数比这个存起来的左边数大,继续动右边指针,因为我们最终目的是想要右边的数大,左边数小
        while left < right and li[right] >= tmp:
            right -= 1
        # 当出现右边数比左边数小的情况,则交换这两个数,然后交换后,右边出现空位,开始动左边指针
        li[left] = li[right]
        while left < right and li[left] <= tmp:
            left += 1
        li[right] = li[left]
    # 运行到这里,left 已经等于 right 了,而且还剩一个空位,就是tmp的应该到的位置
    li[left] = tmp
    return left

复杂度,NlogN,复杂度原因,不严谨的假设有64个元素,
64
第一遍分为了 32 32
32
第二遍分为了 16 16
16
第三遍分为了 8 8
8
第四遍分为了 4 4
4
第五遍分为了 2 2
2
第六遍分为了 1 1

所以就有6次partition(logN),然后一次partition其实数组被遍历了一遍,所以为NlogN

tips:Python系统内置的sort,非常快,但是其实时间复杂度跟快排是一个数量级(NlogN),那他快在哪?
因为它是用C写的,所以比我们手写的快排快。所以还是老老实实的用系统自带的sort吧。

快排的问题:

  1. 最差情况
类型 最好情况 一般情况 最差情况
快排 O(NlogN) O(NlogN) O(N^2)[倒序的时候,每次partition都只减少了一次而不是分成两半]
冒泡 O(N) O(N^2) O(N^2)
  1. 递归最大深度限制
    当递归次数超过一定范围,程序就挂了,所以需要设置递归最大深度
import sys
sys.setrecursionlimit(N) # N 为设置的最大深度

堆排序

进化流程:树 -> 二叉树(度不超过2的树) -> 完全二叉树 -> 大根堆 / 小根堆

概念

  • 根节点,叶子节点
  • 树的深度(高度)[层数]
  • 树的度[最大的节点的分叉数]
  • 孩子节点 / 父节点
  • 子树


    算法(一) - 排序,查找_第1张图片

二叉树存储方式:

  1. 链式存储方式
  2. 顺序存储方式(列表)[我们下面用的]
算法(一) - 排序,查找_第2张图片

父节点和左孩子节点的编号下标有什么关系?

i ~ 2i + 1 当父节点为i 时,左孩子节点为2i + 1

父节点和右孩子节点的编号下标有什么关系?

i ~ 2i + 2
所以,可以通过以上规律从父亲找到孩子或者从孩子找到父亲

堆,一颗特殊的完全二叉树,分为大根堆和小根堆
大根堆:满足任一节点都比其孩子节点大


算法(一) - 排序,查找_第3张图片
大根堆

小根堆:满足任一节点都比其孩子节点小


算法(一) - 排序,查找_第4张图片
小根堆

当根节点的左右子树都是堆,但自身不是堆的时候,可以通过一次向下的调整来将其变换成一个堆。

堆排序过程:

  1. 建立堆
  2. 得到堆顶元素,为最大元素(这里用的大根堆)
  3. 去掉堆顶,将堆的最后一个元素放到堆顶,此时可通过一次调整重新使堆有序
  4. 堆顶元素为第二大元素
  5. 重复步骤3,直到堆变空

一次调整的.

# low ~ high 范围的小堆,low 是堆顶
def shif(li, low, high):
    # i 永远指向的是根节点
    i = low 
    j = 2 * i + 1
    tmp = li[i]
    while j <= high:
        # 如果有右孩子(j tmp:
            li[i] = li[j]
            i = j
            j = 2 * i + 1
        # 当当前范围的根节点是最大时,跳出循环
        else:
            break
    # 最后将存储的tmp 放到调整完后的节点位置。
    li[i] = tmp

堆排序.

def heap_sort(li):
    n = len(li)
    # 最后一个有孩子的父亲的下标,数学证明出来的,记住即可
    last_father_index = n // 2 - 1
    # 循环顺序是从最后一个有孩子的父亲 -> 堆顶,倒着循环的。第二个参数为 -1 是因为range顾头不顾尾,想要取到堆顶,也就是index为0的位置,就要把他设为 -1
    # 注意这里不要被这个循环迷惑,这个循环作用就是找到根节点,以便下面的调整操作
    for i in range(last_father_index, -1, -1):
        # 这里可以永远写 n - 1(堆的最后一个)
        sift(li, i, n - 1)
    #############  这样一个堆就建立好了 #############
    
    for i in range(n - 1, -1, -1):
         li[0], li[i] = li[i], li[0]
         sift(li, 0, i - 1)
    ############# 以上操作就是去掉堆顶,将堆的最后一个元素放到堆顶,
    # 只是这里为了节省新开列表的空间,将要出来的堆顶存到了原来最后一个元素的位置,然后在调整时,通过改变high的范围来减小堆的范围,而且,由于他将最大的存在最后面,所以最后排出的列表是升序的。########

    li_new = []
    for i in range(n - 1, -1, -1):
        li_new.append(li[0])
        li[0] = li[i]
        sift(li, 0, n - 1)
    return li_new 
    ############ 这是第二种写法,比较好理解,就是新开一个列表,来存通过依次出数处理的堆顶元素,
    # 但是要注意的是他对原列表不会排序,而是返回一个已经排序好的新列表。 ##########

复杂度为O(NlogN)

归并排序

归并:将两段有序的列表合成为一个有序列表的过程。
一次归并.

# low: 第一段有序列表的开头
# mid: 第一段有序列表的结尾,所以mid + 1 就为第二段有序列表的开头
# high: 第二段有序列表的结尾
def merge(li, low, mid, high):
    i = low
    j = mid + 1
    ltmp = []
    # 两边都必须有数,这样才能比较两边数的大小
    while i <= mid and j <= high:
        if li[i] < li[j]:
            ltmp.append(li[i])
            i += 1
        else:
            ltmp.append(li[j])
            j += 1
    # 比到最后,肯定有一段列表有剩余,则将剩余全部添加进新列表
    while i <= mid:
        ltmp.append(li[i])
        i += 1
    while j <= mid:
        ltmp.append(li[j])
        j += 1
    # 这里用切片不会减慢速度。因为它在等号左边。
    li[low:high + 1] = ltmp

归并排序.

# 因为这里是先调用merge_sort递归,再进行merge操作,
# 所以就会将列表越分越小,直至分成一个元素,而一个元素时就是有序的。
# 这样merge操作的条件就满足了。然后通过merge将他们合并。
# 总结下来就是先分解,后合并。
def merge_sort(li, low, high):
    if low < high:
        mid = (low + high) // 2
        merge_sort(li, low, mid)
        merge_sort(li, mid + 1, high)
        merge(li, low, mid, high)

时间复杂度:O(nlogn)
空间复杂度:O(n)

快速排序、堆排序、归并排序-小结

三种排序算法的时间复杂度都是O(nlogn)

一般情况下,就运行时间而言:
快速排序 < 归并排序 < 堆排序

三种排序算法的缺点:

  • 快速排序:极端情况下排序效率低
  • 归并排序:需要额外的内存开销
  • 堆排序:在快的排序算法中相对较慢

希尔排序

希尔排序是一种分组插入排序算法

希尔排序每趟并不使得某些元素有序,而是使整体数据越来越接近有序,最后一趟使得所有数据有序。

def shell_sort(li):
    # gap表示分组的间隔
    gap = len(li) // 2
    while gap >= 1:
        for i in range(gap, len(li)):
            tmp = li[i]
            # j表示每组的前一张牌,因为每组都是间隔为gap
            j = i - gap
            while j >= 0 and tmp < li[j]:
                li[j+gap] = li[j]
                j -= gap
            li[i-gap] = tmp
        gap /= 2

时间复杂度:  O((1+τ)n)    τ:tuo , 0 < τ < 1
                一般认为是O(1.3n),比O(NlogN)慢

你可能感兴趣的:(算法(一) - 排序,查找)