系列文章目录
文章目录
前言
笔记来源于视频(侵删):
一、查找
查找:
- 在一些数据元素中,通过一定的方法找出与给定关键字相同的数据元素的过程。
列表查找(线性表查找):从列表中查找指定元素
-
输入:列表,待查找元素
-
输出:元素下标(未找到元素的一般返回None或者-1)
-
内置列表查找函数:li.index(x)
顺序查找:
-
也叫线性查找,从列表第一个元素开始,顺序进行搜索,直到找到元素或者搜索到列表最后一个元素为止
-
时间复杂度:O(n)
def linear_search(data_set, value):
for i in range(range(data_set)):
if data_set[i] == value
return i
return
二分查找
- 前提是列表有序
def bin_search(data_set, value):
low = 0
high = len(data_set) - 1
while low <= high:
mid = (low + high)//2
if data_set[mid] == value:
return mid
elif data_set[mid] > value:
high = mid - 1
else:
low = mid + 1
二、排序
算法基础知识学习——非线性比较排序算法
排序:将一组无序的记录序列调整为有序的记录序列
列表排序:将无序列表变为有序列表
内置排序函数:sort
常见排序算法:
-
冒号排序(稳定),直接选择排序(不稳定),直接插入排序(稳定)
时间复杂度一般情况下都是 O(n^2)
特例:冒泡排序最好情况下时间复杂度为 O(n) -
快速排序(不稳定),堆排序(不稳定),归并排序(稳定,需要额外的内存开销)
时间复杂度一般都是 O(nlogn)
特例:快排当原列表为倒序时,时间复杂度变为 O(n^2)
实际运行时:快排的速度 > 归并 > 堆 -
希尔排序,基数排序(O(kn)),计数排序
-
桶排序(O(n+k)):改进的计数排序
总结:
冒号排序:
-
遍历n-1趟,每次两两交换,原地排序。
-
外层循环为for i in range(len(li)),内层循环为for j in range(len(li)-1-i)。
-
可以通过加一个判断(如果这一趟中没有发生交换,则说明已经全部排好序,终止循环)进行优化
选择排序:
-
遍历一遍列表,选择最小的值,放到新列表中,遍历n遍。
直接插入排序:
-
假定手里的牌已经有序,然后插入。
-
即从第二个数开始遍历n-1趟,每次将当前的数与前面已经排好的数进行比较,将其插入到合适的位置。
-
外层循环为for i in range(1,len(li)),令j=i-1,内层循环为while tmp<li[j] and j>=0,即当升序排列时,当当前数tmp小于前一个数的时候就把前一个数往后挪,即令 li[j] = li[j-1],j =j-1
-
时间复杂度O(n^2)
def insert_sort(li):
for i in range(1, len(li)):
tmp = li[i]
j = i - 1
while j >= 0 and tmp < li[j]:
li[j+1] = li[j]
j = j - 1
li[j+1] = tmp
快排:
-
选择第一个数tmp,使其左边的数小于tmp,右边的数大于tmp。
-
参数为left、right、li:
-
先往前从right开始遍历找到比tmp小的数,把它放到li[left]位置上,因为将第一个数tmp取出后左边有空位;
-
然后在从left开始往后遍历,找到比tmp大的数,把它放到li[right]上,因为之前取出了right位置上的数,所以有空位,做循环,最后把tmp归位即可。
-
然后做递归,左右两边分别递归,每次循环问题减半。
def partition(li, left, right):
tmp = li[left]
while left<right:
while left < right and tmp >= li[right]: # 从右边找比tmp小的数
right -= 1 # 往左走一步
li[left] = li[right] # 把右边的数写到左边空位上
while left < right and tmp <= li[left]:
left += 1
li[right] = li[left] # 把左边的数写到右边空位上
li[left] = tmp # 把tmp归位
return left
def quick_sort(li, left, right):
if left < right: # 至少有两个元素
mid = partition(li, left, right)
quick_sort(li, left, mid-1)
quick_sort(li, mid+1, right)
堆排序:
def sift(li, low, high):
'''
low: 堆的根节点位置
high:堆的最后一个元素的位置
'''
i = low # 根节点
j = 2 * i + 1 # 左孩子
tmp = li[i] # 把堆顶存起来
while j <= high: # 只要j位置上有数,就循环
if j+1 <= high and li[j+1] > li[j]: # 如果有右孩子,并且右孩子大于左孩子
j = j + 1 # j指向右孩子
if li[j] > tmp:
li[i] = li[j] # 把右孩子放到堆顶
i = j # 往下看一层
j = 2 * i + 1
else:
li[i] = tmp
break
else:
li[i] = tmp # 把tmp放到叶子结点上
def heap_sort(li):
n = len(li)
# 建堆
for i in range((n-2)//2, -1, -1):
# i 表示建堆的时候调整的部分的根的下标
sift(li, i, n-1)
for j in range(n-1, -1, -1):
# j 指向当前堆的最后一个元素
li[0], li[i] = li[i], li[0]
sift(li, 0, j-1) # j-1是新的high
归并排序:
-
假设两边分别有序,把两边合到一起使其整体有序。
-
参数为low,mid,high,li:
-
令i=low,j=mid+1,tmp=[],当升序排列时,当左右两边都有数的时候:依次比较li[i]和li[j]的大小,选择小的一个添加到tmp列表中去;
-
当只有一端有数的时候,直接将剩下的数全部添加到tmp列表后面去,因为剩下的数是有序且大于之前添加的数的。
-
然后做递归:当low<high,即最少有两个数的时候,先求出中间值mid,然后左右分别做递归,递归之后再对最小层的数做排序。
def mergesort(li, low, high):
if low < high:
mid = (low + high)//2 # mid是下标
mergesort(li, low, mid)
mergesort(li, mid+1, high)
merge(li, low, mid, high)
1、时间复杂度
-
算法的时间复杂度反映了程序执行时间随输入规模增长而增长的量级,在很大程度上能很好地反映出算法的优劣与否。( 反映了程序执行时间的增长趋势,可以从数学曲线和运算简化两个方面理解)
-
O(1):执行一次基本操作的时间(近似),1是指1个单位(若算法中语句执行次数为一个常数(即不随被处理数据量n的大小而改变时),可表示为O(1),不看到底执行了几次,只要不上升到n次的时候 就是O(1))
-
O(n^2):当是n的(2+n)次方时,只保留大的即可,只需大概的时间即可,不需要很精确。
-
O(logn):当循环减半时,(即每次迭代让问题规模缩小一半)
-
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n ^2 logn) < O(n ^3)
-
一般来说,时间复杂度高的算法比复杂度低的算法慢
-
在时间频度不相同时,时间复杂度有可能相同,如T(n)=n^2 +3n+4与T(n)=4n^2 +2n+1它们的频度不同,但时间复杂度相同,都为O(n^2)。
-
频度:一个算法中的语句执行次数称为语句频度或时间频度,记为T(n)
求解算法的时间复杂度的具体步骤是:
- ⑴ 找出算法中的基本语句;
算法中执行次数最多的那条语句就是基本语句,通常是最内层循环的循环体。
- ⑵ 计算基本语句的执行次数的数量级;
只需计算基本语句执行次数的数量级,这就意味着只要保证基本语句执行次数的函数中的最高次幂正确即可,可以忽略所有低次幂和最高次幂的系数。这样能够简化算法分析,并且使注意力集中在最重要的一点上:增长率。
- ⑶ 用大Ο记号表示算法的时间性能。
将基本语句执行次数的数量级放入大Ο记号中。
如果算法中包含嵌套的循环,则基本语句通常是最内层的循环体,如果算法中包含并列的循环,则将并列循环的时间复杂度相加。
for (i=1; i<=n; i++)
x++;
for (i=1; i<=n; i++)
for (j=1; j<=n; j++)
x++;
第一个for循环的时间复杂度为Ο(n),第二个for循环的时间复杂度为Ο(n2),则整个算法的时间复杂度为Ο(n+n2)=Ο(n2)。
时间比空间重要。
2、空间复杂度
空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度。
一个算法在计算机存储器上所占用的存储空间,包括存储算法本身所占用的存储空间,算法的输入输出数据所占用的存储空间和算法在运行过程中临时占用的存储空间这三个方面。
-
算法的输入输出数据所占用的存储空间是由要解决的问题决定的,是通过参数表由调用函数传递而来的,它不随本算法的不同而改变。
-
存储算法本身所占用的存储空间与算法书写的长短成正比,要压缩这方面的存储空间,就必须编写出较短的算法。
-
算法在运行过程中临时占用的存储空间随算法的不同而异,
有的算法只需要占用少量的临时工作单元,而且不随问题规模的大小而改变,我们称这种算法是“就地"进行的,是节省存储的算法;
有的算法需要占用的临时工作单元数与解决问题的规模n有关,它随着n的增大而增大,当n较大时,将占用较多的存储单元 -
当一个算法的空间复杂度为一个常量,即不随被处理数据量n的大小而改变时,可表示为O(1),使用了几个变量
-
当一个算法的空间复杂度与n成线性比例关系时,可表示为O(n),(使用了长度为n的一维列表)
-
使用了m行n列的二维列表:O(mn)
-
当一个算法的空间复杂度与以2为底的n的对数成正比时,可表示为O(1og2n);
-
若形参为数组,则只需要为它分配一个存储由实参传送来的一个地址指针的空间,即一个机器字长空间;
-
若形参为引用方式,则也只需要为其分配存储一个地址的空间,用它来存储对应实参变量的地址,以便由系统自动引用实参变量。
3、排序算法稳定度
能保证排序前两个相等的数据其在序列中的先后位置顺序与排序后它们两个先后位置顺序相同。即:如果A i == A j,Ai 原来在 Aj 位置前,排序后 Ai 仍然是在 Aj 位置前。
-
选择排序、快速排序、希尔排序、堆排序不是稳定的排序算法
-
冒泡排序、插入排序、归并排序和基数排序都是稳定的排序算法。
4、冒号排序
需要n-1趟排序,升序,时间复杂度:O(n^2)
import random
def bubble_sort(li):
for i in range(len(li)-1):
for j in range(0,len(li)-i-1):
if li[j] > li[j+1]:
li[j], li[j+1] = li[j+1], li[j]
li = [random.randint(0,1000) for i in range(1000)]
bubble_sort(li)
优化:
- 如果冒号排序中的一趟排序没有发生交换,则说明列表已经有序,可以直接结束算法。
def bubble_sort(li):
for i in range(len(li)-1):
exchange = False
for j in range(0,len(li)-i-1):
if li[j] > li[j+1]:
li[j], li[j+1] = li[j+1], li[j]
exchange = True
if not exchange:
return
6、选择排序
遍历一遍列表,选择最小的值,放到新列表中,遍历n遍。
def select_sort_simple(li):
li_new = []
for i in range(len(li)-1):
min_val = min(li)
li_new.append(min_val)
li.remove(min_val)
return li_new
缺点:
-
占用了两倍内存,不是原地排序(冒号排序是原地排序),
-
时间复杂度为O(n^2)
-
min函数的时间复杂度为O(n),找列表最小值时,需要遍历列表
-
remove函数的时间复杂度也是O(n)
(删除列表中的某个数字后,需要将后面的数字依次往前移一位,否则会造成一个空位)
所以整个算法的时间复杂度为O(n^2)(循环里面的两个O(n)记为一个)
优化:时间复杂度O(n^2)
def select_sort(li):
li_new = []
for i in range(len(li)-1):
min_loc = i
for j in range(i+1, len(li)):
if li[j] < li[min_loc]:
min_loc = j
li[i], li[min_loc] = li[min_loc], li[i]
7、插入排序
初始时有序区只有一个数,每次从无序区取一个数,插入到有序区已有数字的正确位置。
时间复杂度:O(n^2)
升序
def insert_sort(li):
for i in range(1, len(li)):
tmp = li[i]
j = i - 1
while j >= 0 and tmp < li[j]:
li[j+1] = li[j]
j -= 1
li[j+1] = tmp
8、快速排序
def partition(li, left, right):
tmp = li[left]
while left<right:
while left < right and tmp >= li[right]: # 从右边找比tmp小的数
right -= 1 # 往左走一步
li[left] = li[right] # 把右边的数写到左边空位上
while left < right and tmp <= li[left]:
left += 1
li[right] = li[left] # 把左边的数写到右边空位上
li[left] = tmp # 把tmp归位
return left
def quick_sort(li, left, right)
if left < right: # 至少有两个元素
mid = partition(li, left, right)
quick_sort(li, left, mid-1)
quick_sort(li, mid+1, right)
时间复杂度:
-
每一层的partition的复杂度是O(n),一共logn层,所以时间复杂度为O(nlogn)
-
最坏情况:传入的列表为倒序,每次partition只变动一个值,则就不是logn层
优化:
-
加入随机划分,即随机选择一个数,将其与第一个位置的数进行交换,然后再对其进行归位,会降低出现最坏情况的概率。
-
三分法:选取low,mid,high三个值,把中间大小的那个数和第一个位置上的数进行交换。
9、堆排序
堆排序时间复杂度是nlog(n)级别,和快速排序一样,但是在实际执行时,快排要快于堆排序
1、树与二叉树
树是一种可以递归定义的数据结构
树是由n个节点组成的集合
- 如果n=0,则这是一颗空树
- 如果n>0,那么存在1个节点作为树的根节点,其他节点可以分为m个集合,每个集合又是一棵树。
度:节点的分叉数
树的度 = 整个树的最大的节点的度
E的度为2,F的度为3,
最大节点为A, 度为6,所以整个树的度为6
二叉树:度不超过2的树
- 每个节点最多有两个孩子节点
- 两个孩子节点被区分为左孩子和右孩子节点
二叉树的存储方式:
- 链式存储方式
- 顺序存储方式
由子节点找父节点:设子节点下标为i,则父节点为(i-1)//2
2、堆
- 一种特殊的完全二叉树结构
- 大根堆:一颗完全二叉树,满足任一节点都比其孩子节点大
- 小根堆:一颗完全二叉树,满足任一节点都比其孩子节点小
2.1 堆的向下调整性质
假设根节点的左右子树都是堆,但是根节点不满足根的性质,则可以通过一次向下调整将其变成一个堆。
堆的向下调整性质:
- 1)在9和7(2的两个子节点)中进行比较,选择大的一个放到根节点(原2的位置)处
- 2)比较2与8、 5(两个子节点)的大小,发现2小于子节点,继续向下调整,
def sift(li, low, high):
'''
low: 堆的根节点位置
high:堆的最后一个元素的位置
'''
i = low # 根节点
j = 2 * i + 1 # 左孩子
tmp = li[i] # 把堆顶存起来
while j <= high: # 只要j位置上有数,就循环
if j+1 <= high and li[j+1] > li[j]: # 如果有右孩子,并且右孩子大于左孩子
j = j + 1 # j指向右孩子
if li[j] > tmp:
li[i] = li[j] # 把右孩子放到堆顶
i = j # 往下看一层
j = 2 * i + 1
else:
li[i] = tmp
break
else:
li[i] = tmp # 把tmp放到叶子结点上
2.2 堆挨个出数的过程
挨个出数过程(以大根堆为例):
-
1)出9
-
2)将3放到堆顶(根节点)(3为最后一个叶子结点)
-
3)对3进行向下调整,恢复成大根堆
-
4)出8(根节点),将最后一个叶子结点放到堆顶,进行向下调整
-
5)继续上述过程,直到所有数字出完
2.3 构造堆
1)找到最后一个非叶子节点,比较父节点和子节点的大小,使父节点大于子节点
2)调整倒数第二个非叶子节点,
2.4 堆排序算法
def sift(li, low, high):
'''
low: 堆的根节点位置
high:堆的最后一个元素的位置
'''
i = low # 根节点
j = 2 * i + 1 # 左孩子
tmp = li[i] # 把堆顶存起来
while j <= high: # 只要j位置上有数,就循环
if j+1 <= high and li[j+1] > li[j]: # 如果有右孩子,并且右孩子大于左孩子
j = j + 1 # j指向右孩子
if li[j] > tmp:
li[i] = li[j] # 把右孩子放到堆顶
i = j # 往下看一层
j = 2 * i + 1
else:
li[i] = tmp
break
else:
li[i] = tmp # 把tmp放到叶子结点上
def heap_sort(li):
n = len(li)
# 建堆
for i in range((n-2)//2, -1, -1):
# i 表示建堆的时候调整的部分的根的下标
sift(li, i, n-1)
for j in range(n-1, -1, -1):
# j 指向当前堆的最后一个元素
li[0], li[i] = li[i], li[0]
sift(li, 0, j-1) # j-1是新的high
2.5 堆排序的应用:topk问题
有n个数,设计算法得到前k大的数。(k<n)
解决思路:
-
排序后切片:O(nlogn)
-
冒号排序、直接选择排序、插入排序 :O(kn)
-
堆排序:O(nlogk)
堆排序的解决思路:
-
取列表前k个元素建立一个小根堆,则堆顶就是目前第 k 大的数
-
依次向后遍历原列表,对于列表中的元素,
如果小于堆顶,则忽略该元素
如果大于堆顶,则将堆顶更换为该元素,并且对堆进行一次t调整 -
遍历列表所有元素后,倒序弹出堆顶
def sift(li, low, high):
i = low
j = 2 * i + 1
tmp = li[i]
while j <= high:
if j+1 <= high and li[j+1] > li[j]:
j = j + 1
if li[j] > tmp:
li[i] = li[j]
i = j
j = 2 * i + 1
else:
break
li[i] = tmp
def topk(li, k):
heap = li[:k]
for i in range((k-2)//2, -1, -1):
sift(heap, i, k-1)
for i in range(k, len(li)-1):
if li[i] > heap[0]:
heap[0] = li[i]
sift(heap, 0, k-1)
for i in range(k-1, -1, -1):
heap[0], heap[i] = heap[i], heap[0]
sort(heap, 0, i-1)
return heap
10、归并排序
假设列表分两段有序,将其合成一个有序列表,这种操作称为一次合并。
-
分解:将列表越分越小,直至分成一个元素
-
终止条件:一个元素是有序的
-
合并:将两个有序列表合并,列表越来越大
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 <= high:
ltmp.append(li[j])
j += 1
li[low : high+1] = ltmp
def mergesort(li, low, high):
if low < high:
mid = (low + high)//2 # mid是下标
mergesort(li, low, mid)
mergesort(li, mid+1, high)
merge(li, low, mid, high)
11、希尔排序
希尔排序(shell sort)是一种分组插入排序算法。
-
首先取一个整数d1 = n / 2,将元素分为d1个组,每组相邻两元素之间距离为d1,在各组内进行直接插入排序
-
取第二个整数d2 = d1 / 2,重复上述分组排序过程,直到d=1,即所有元素在同一组内进行直接插入排序
-
希尔排序每趟并不使某些元素有序,而是使整体数据越来越接近有序,最后一趟排序使得所有数据有序。
希尔排序的时间复杂度讨论比较复杂,并且和选取的gap序列有关。
def insert_sort_gap(li, gap):
for i in range(gap, len(li)): # i表示摸到的牌的下标
tmp = li[i]
j = i - gap # j指的是手里的牌的下标
while j >= 0 and li[j] > tmp:
li[j+gap] = li[j]
j -= gap
li[j+gap] = tmp
def shell_sort(li):
d = len(li) // 2
while d >= 1:
insert_sort_gap(li, d)
d //= 2
12、计数排序
def count_sort(li, max_count=100):
count = [0 for _ in range(max_count+1)]
for val in li:
count[val] += 1 # count列表中,索引为原列表的元素值,对应的元素为出现的次数
li.clear()
for ind, val in enumerate(count):
for i in range(val): # val为出现的次数,ind为原列表中的元素
li.append(ind)
13、桶排序
桶排序:
-
首先将元素分到不同的桶里,在对每个桶中的元素进行排序。
-
改进计数排序:当元素的范围比较大时(比如在1到1亿之间)
-
桶排序的表现取决于数据的分布,也就是需要对不同数据排序时采取不同的分桶策略
-
平均时间复杂度:O(n+k)
-
最坏时间复杂度:O(n^k)
-
空间复杂度:O(nk)
def bucket_sort(li, n=100, max_num=10000):
bucket = [[] for _ in range(n)]
for var in li:
i = min(var // (max_num //n), n-1) # i表示把var放到几号桶里
bucket[i].append(var)
# 保持桶内的有序
# 桶内原本是有序的,进行插入排序
for j in range(len(bucket[i]-1, 0, -1)):
if bucket[i][j] < bucket[i][j-1]:
bucket[i][j], bucket[i][j-1] = bucket[i][j-1], bucket[i][j]
else:
break
sorted_li = []
for buc in buckets:
sorted_li.append(buc)
return sorted_li
13、基数排序
- 时间复杂度:O(kn)
- 空间复杂度:O(k+n)
- k表示数字位数
多关键字排序:
比如:现在有一个员工表,要求按照薪资排序,年龄相同的员工按照年龄排序
- 先按照年龄排序,在按照薪资进行稳定的排序
def radix_sort(li):
max_num = max(li) # 最大值 9->1, 99->2, 888->3, 10000->5
it = 0
# 当it=0时,digit为var的个位上的数值,it=1时,digit为var的十位上的数值
# 把个位数相同的放到一个桶里,然后进行桶内排序,然后将排序过后的数重新给li
# 然后在按照十位上的数在排序
# 然后百位,千位,,,,
while 10 ** it <= max_num:
buckets = [[] for _ in range(10)]
for var in li:
digit = (var // (10 ** it)) % 10
buckets[digit].append(var)
li.clear()
for buc in buckets:
li.extend(buc)
it += 1
import random
li = list(range(10000))
random.shuffle(li)
radix_sort(li)