一.选择排序
选择排序(Selection Sort)是最简单,最直观的排序算法,虽然效率不高,但是易于理解和实现。
原理:在未排序的数组中找到最小的(或最大的) → 将其放在已排序数组的末尾(通常是与为排序的数组的第一个元素交换位置) → 重复上述过程,每次从未排序部分选择最小元素,直到所有元素都被排序
整个过程将数组分为两个部分:
- 已排序部分(初始化为空)
- 未排序部分(初始化为整个数组)
选择排序的实现:
python
def selection_sort(arr):
n = len(arr)
for i in range(n-1): #执行n-1次操作把前面n-1个数排好序,最后一个自然就排好序了
min_index = i
for j in range(i+1, n):
if arr[min_index] > arr[j]:
min_index = j
arr[i], arr[min_index] = arr[min_index], arr[i]
if __name__ == '__main__':
arr = [5, 4, 9, 6, 9, 2, -3, 11, 3]
selection_sort(arr)
print(arr) # [-3, 2, 3, 4, 5, 6, 9, 9, 11]
小优化:避免不必要的交换
python
def selection_sort(arr):
n = len(arr)
for i in range(n-1):
min_index = i
for j in range(i+1, n):
if arr[min_index] > arr[j]:
min_index = j
if min_index != i: #只有在未排序的部分中的最小值不是第一个元素时才交换,即在需要交换时才交换
arr[i], arr[min_index] = arr[min_index], arr[i]
if __name__ == "__main__":
arr = [5, 4, 9, 6, 9, 2, -3, 11, 3]
selection_sort(arr)
print(' '.join(map(str, arr))) #-3 2 3 4 5 6 9 9 11
一段有错误的代码:
python
def selection_sort():
for i in range(n-1):
min_index = i
swap = False
for j in range(i+1, n):
if arr[min_index] > arr[j]:
min_index = j
swap = True
if swap:
arr[i], arr[min_index] = arr[min_index], arr[i]
n = int(input())
arr = list(map(int, input().split()))
selection_sort()
print(*arr)
1.问题一:函数selection_sort()无法访问外部变量arr和n,这两个变量在函数内部不可见
- 会破坏函数的可重用性和通用性:如果函数依赖于全局变量,它就只能操作那个特定的变量,无法用于其他数据;正确的方法是应该通过参数传入
- 会导致代码难以测试,在测试函数时,需要控制输入和预期输出。如果函数读写全局变量,那么每次测试前都需要设置全局变量,测试后又要清理这个全局变量(因为你需要新的输入来进行测试)。解决方法:使用参数和返回值
- 会引发隐蔽的错误:全局变量可以在程序的任何地方被修改,可能在不经意间会对全局变量进行修改而难以发现是什么地方修改了这个全局变量,从而导致"谁修改了我的数据"的调试噩梦(nightmare)
- 导致模块化与协作开发困难
2.问题二:swap=True逻辑正确,但是多此一举,可以在交换前使用if min_inde != i:来进行判断,设置swap会增加内存开销(虽然很微小),有点冗余,建议删除
复习一下global关键字:
1.先要知道什么是全局变量:全局变量是在函数外部定义的变量,可以在模块(.py文件)中的任何一个函数中读取(但修改需特殊声明:使用global关键字)
2.什么是局部变量:局部变量是在函数内部定义的变量,只在该函数内部中有效。函数执行完后,局部变量会被销毁。
3.global关键字的作用 :在函数内部声明一个变量为全局变量,从而允许你在函数内部修改(而不仅仅是读取)该全局变量
4.不建议频繁使用global关键字:过度使用global关键字会导致:
- 代码难以理解,不知道是哪个函数修改了全局状态
- 难以测试和调试
- 函数之间耦合度高
错误代码:
python
count = 0
def func1():
count += 1
func1()
报错: count += 1
^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
即UnboundLocalError:无法访问没有关联值的本地变量count
bound:必然的,受约束的,被绑定的,界限,边界
正确代码:
python
count = 0
def func1():
global count
count += 1
func1()
print(count) # 1
没有使用global关键字:
python
a = 10
def f():
a = 20
f()
print(a) # 10
使用了global关键字:
python
a = 10
def f():
global a
a = 20
f()
print(a) # 20
再来一段代码:
python
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # → local
inner()
outer()
选择排序的变体:选择最大的进行排序
python
def selection_sort(arr):
n = len(arr)
for i in range(1, n)[::-1]:
max_index = i
for j in range(0, i)[::-1]:
if arr[j] > arr[max_index]:
max_index = j
if max_index != i:
arr[i], arr[max_index] = arr[max_index], arr[i]
if __name__ == '__main__':
arr = [1,4,3,-6,6,2,9,0]
selection_sort(arr)
print(*arr) #-6 0 1 2 3 4 6 9
分析:
思路:选择未分组部分的最大值,放在未分组部分的末尾
问题:存在效率问题、可读性问题、潜在的反直觉设计
1.使用[::-1]导致不必要的内存开销
range()会生成一个range对象,在python中轻量的,但[::-1]会将其强制转换成列表(Python3中不会转换成列表,但即使这样也不如直接使用range并设置步长为-1简洁明了,可读性好)并反转
复习一下range()函数
python
print(range(10)) # range(0, 10)
print(list(range(10))) #[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(type(range(10))) #<class 'range'>
print(*range(10)) #0 1 2 3 4 5 6 7 8 9
print(range(10)[::-1]) #range(9, -1, -1)
print(*range(10)[::-1]) #9 8 7 6 5 4 3 2 1 0
print(*range(1,10)[::-1]) #9 8 7 6 5 4 3 2 1
print(*range(1,10,-1)) #返回一个换行,使用步长为负值时start必须大于stop
print(*range(10,1,-1)) #10 9 8 7 6 5 4 3 2
2.第二层循环又使用了一次倒序,从右往左遍历比较找最大值,其实没有必要,因为我们不过是要在这没有排序的部分找出那个最大值,正序比较和倒序比较都可以找到,只要你拿起始元素与未排序部分中的每一个元素都比较一次就可以找到那个最大值了,使用倒序比较反而不如正序比较容易理解,可读性反而更差了
优化(但是推荐还是使用先找最小元素然后交换这种方法)
python
def selection_sort(arr):
n = len(arr)
for i in range(n-1, 0, -1):
max_index = i
for j in range(i):
if arr[j] > arr[max_index]:
max_index = j
if max_index != i:
arr[i], arr[max_index] = arr[max_index], arr[i]
if __name__ == '__main__':
arr = [6,4,8,10,-5,3,7,0]
selection_sort(arr)
print(*arr) #-5 0 3 4 6 7 8 10
复习一下切片操作(顺便复习一下浅拷贝和深拷贝)
在Python中,并非所有的数据结构都能进行切片操作,但几乎所有对象都支持浅拷贝和深拷贝(只要它们是可复制的)
官方支持能够切片的类型:
| 数据结构 | 是否支持切片 | 说明 |
|---|---|---|
list |
✅ 是 | 返回新 list |
tuple |
✅ 是 | 返回新 tuple |
str |
✅ 是 | 返回新字符串 |
bytes |
✅ 是 | 返回新 bytes |
bytearray |
✅ 是 | 返回新 bytearray |
range |
✅ 是 | 返回新 range(惰性,不复制数据) |
memoryview |
✅ 是 | 返回新的 memoryview |
官方不支持切片的类型:
| 数据结构 | 原因 |
|---|---|
dict |
映射类型,无序(Python 3.7+ 虽有序,但语义上不是序列) |
set / frozenset |
无序集合,不支持索引 |
deque(来自 collections) |
不支持切片(虽支持索引,但切片会报错) |
| 自定义类 | 默认不支持,除非显式实现 __getitem__ 处理 slice(片,薄片,部分) |
在Python中,对可切片对象进行切片会得到一个新的与被切片对象相同类型的对象,这意味着Python会在内存中开辟一块新的空间来存储这个新的对象
python
original = [1, 2, 3, 4]
sliced = original[1:3]
# print(sliced)#[2, 3]
print(sliced is original) #False,说明sliced和original不是同一个对象
sliced[0] = 100
sliced.pop()
print(sliced)#[100]
print(original)
original.append(101) #[1, 2, 3, 4]
print(sliced)#[100]
从这段代码可以看出:切片会创建一个新的对象(新对象的内容是原对象的某一段副本或者是原对象整体的副本),新对象和原对象是独立的,互不影响的
python
a = [1, 2, [3, 4], 'hello']
b = a[:]
print(b) #[1, 2, [3, 4], 'hello']
print(a is b)#False
print(id(a) == id(b))#False
b [0] = 11
b[2]= [5,6]
print(b)#[11, 2, [5, 6], 'hello']
print(a)#[1, 2, [3, 4], 'hello']
修改切片过来的对象,不会改变原对象
但是你注意:切片是进行浅拷贝,浅拷贝仅仅复制最外层,如果原对象具有嵌套结构(具有子对象),嵌套内的对象是共享的,即一方修改了嵌套对象中的值,另一方对应位置的值也会被修改
浅拷贝与深拷贝的对比:
| 特性 | 浅拷贝(Shallow Copy) | 深拷贝(Deep Copy) |
|---|---|---|
| 复制层级 | 仅最外层 | 所有嵌套层级 |
| 子对象是否共享 | ✅ 共享(引用相同) | ❌ 不共享(全新副本) |
| 修改子对象影响原对象? | ✅ 会 | ❌ 不会 |
| 性能 | 快 | 较慢(需递归复制) |
| 适用场景 | 简单结构、无嵌套可变对象 | 嵌套结构、需要完全隔离 |
python
a = [1,2,[4,3]]
b = a[:]
b[2][0] = 345
print(a)#[1, 2, [345, 3]]
print(b)#[1, 2, [345, 3]]
#####修改了嵌套对象的内部元素:
#b = a[:]创建了一个新列表b,但b[2]和a[2]指向同一个元素(子列表对象)[4,3]
#b[2][0] = 345就地修改了这个共享的子列表,因此a中对应也会被修改
c = [10,20,[30,40]]
d = c[:]
c[2][0] = 320
print(c)#[10, 20, [320, 40]]
print(d)#[10, 20, [320, 40]]
#####也是修改了嵌套对象的内部元素,只是这里是在原对象中修改的,但c[2]和d[2]指向的是同一子列表[30,40],修改共享对象的内部元素,两者都会改变
e = [111, 222, [333, 444]]
f = e[:]
e[2] = [101, 102]
print(e)#[111, 222, [101, 102]]
print(f)#[111, 222, [333, 444]]
#####这里是替换整个子对象,而不是修改内部元素,e[2] = [101, 102]是重新赋值,让e[2]指向一个新的列表对象,但f[2]指向的还是原来的[333, 444]
#这是因为浅拷贝后,外层元素对象是独立的
进行深拷贝(需import copy)
python
import copy
a = [1, 2, [3, 4]]
b = copy.deepcopy(a)
print(a)#[1, 2, [3, 4]]
print(b)#[1, 2, [3, 4]]
b[2][0] = 999
print(a)#[1, 2, [3, 4]]
print(b)#[1, 2, [999, 4]]
注:列表的.copy()方法进行的复制是浅拷贝
python
a = [1, 2, [3, 4]]
b = a.copy()
print(a)#[1, 2, [3, 4]]
print(b)#[1, 2, [3, 4]]
a[2][0] = 999
print(a)#[1, 2, [999, 4]]
print(b)#[1, 2, [999, 4]]
| 操作 | 是否浅拷贝 | 是否深拷贝 |
|---|---|---|
b = a.copy() |
✅ 是 | ❌ 否 |
b = a[:] |
✅ 是 | ❌ 否 |
b = copy.copy(a) |
✅ 是 | ❌ 否 |
b = copy.deepcopy(a) |
❌ 否 | ✅ 是 |
选择排序的时间复杂度:
时间复杂度:描述算法执行所需要的基本操作(如比较、赋值、交换、循环控制i++,++i)次数随输入规模n增长的变换趋势
时间复杂度不是运行时间(秒),而是操作次数的函数,它关注的是增长速率(growth rate)而非精确值。时间复杂度使用大O表示法(Big O notation)来描述上界。
比较次数:
对于一个长度为n的数组,进行选择排序,需要排n-1次,排好n-1个数,最后那个数自然是有效的;每一次排序,需要的次数:第1次排序要比较n-1次,即拿一个元素和其余n-1个元素比较一次;第2次排序要比较n-2次...,第n-2次排序要比较(第1次排序要在n个元素中找,第2次在n-1个元素找,第3次排序要在n-2个元素中找...所以第n-2次排序要在剩下的3个没有排序的元素中找到最小值)需要比较2次,第n-1次排序要比较1次
所以总共要比较的次数是 (n-1) + (n-2) + (n-3) + ... + 2 + 1 = n(n-1) / 2;
交换次数:
每轮(每次排序)至多交换1次,总共n-1轮,最多交换n-1次;每次交换需要3次赋值(用临时变量)。所以总的交换次数最大为3(n-1)
总操作次数:
n(n-1) / 2 + 3(n-1)
所以时间复杂度是O(n^2)
选择排序的空间复杂度:
空间复杂度:描述的是算法在运行过程中所需的额外的内存空间随输入规模n增长的变化趋势
注意:空间复杂度不包括输入数据本身占用的空间;只计算算法执行中额外申请的内存
- 使用的额外变量:
i,j,min_index,n→ 总共常数个变量(O(1) 个) - 所有操作都在原数组上进行(in-place),没有创建新数组或递归调用栈
✅ 因此,空间复杂度是 O(1) ------ 常数空间
二.冒泡排序
冒泡排序是一种简单直观的排序算法
基本思想:重复地遍历待排序的列表,比较相邻元素的大小并交换顺序错误的元素,使得每一轮遍历后,最大的元素在未排序部分的末尾
将这个过程形象地比喻成"冒泡",最大的元素像气泡一样慢慢地"浮"到了顶端
原理详解:
- 比较相邻元素:从列表的第一个元素开始,依次比较相邻的两个元素
- 交换顺序:如果前一个元素比后一个元素大(即是逆序的),那么就交换它们的位置
- 重复过程:对整个列表(数组)重复上述过程,每完成一次完整遍历,就将当前未排序部分的最大值"冒泡"到正确位置上
- 优化:如果某一轮遍历中,没有发生任何交换,说明数组已经有序,可以提前结束遍历
冒泡排序的实现:
python
def bubble_sort(arr):
n = len(arr)
for i in range(n-1):
swapped = False
for j in range(n-i-1): # 顶端随着循环次数逐渐缩小,表明数组后面的部分已经有序
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
swapped = True
if not swapped:
break
if __name__ == '__main__':
arr = [3, 1, 5, 7, -5, 2, 6]
bubble_sort(arr)
print(arr)
冒泡排序的时间复杂度:
1.最坏情况:数组是完全逆序的,比如【5,4,3,2,1】
外层循环n-1次,内层循环n-i-1次,每次内层循环比较1次,交换位置操作数为3,把这些数值都相乘,得到时间复杂度为:O(n^2)
2.最好情况:数组已经有序,如:【1,2,3,4,5】
只要循环一次(内层循环一次),swapped为False就能退出循环了,时间复杂度为:O(n)
3.平均情况:对于随机排列的数组,平均需要一半的比较和交换,平均时间复杂度还是:O(n)
冒泡排序的空间复杂度:
只使用了常数个额外变量(i、j、swapped、临时存储的交换变量temp),因此空间复杂度为:O(1)
冒泡排序是稳定的
因为只有前一个元素比后一个元素大才会发生交换,两者相等时是不会发生交换的,因此相等的元素的相对位置不会发生变化
三.插入排序
思想:类似于我们整理扑克牌的方式,从未排序的部分取出一张牌,插入到已排序部分的合适位置
基本原理:
- 初始状态:将数组的第一个元素视为已排序部分,其余为未排序部分
- 遍历未排序部分:从第二个元素开始,依次取出当前元素
- 向前比较并移动:将该元素与已排序部分从后往前逐个比较,如果已排序元素大于当前元素,则将其往后移动一位
- 插入位置:找到合适的位置后,将当前元素插入到这个合适的位置上
- 重复步骤2~4,直到所有元素都被处理
python
def insertion_sort(arr):
n = len(arr)
for i in range(1, n):
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j+1] = arr[j] #将arr[j]向后移动一位,移动到j+1的位置上
j -= 1
arr[j+1] = key # 如果arr[i] <= key,说明找到了正确位置,将当前元素arr[i] 放到j的后一个位置j+1上
if __name__ == '__main__':
arr = [5,1,4,6,-4,0,7,2]
insertion_sort(arr)
print(arr)#[-4, 0, 1, 2, 4, 5, 6, 7]