目录
- 前言
- 一.算法
- 1. 哈希表是什么?
- 2. 什么是时间复杂度?
- 3. 空间复杂度
- 4. 递归
- 4. 查找
- 4.1、顺序查找
- 4.2. 二分查找
- 5. 排序
- 5.1. 冒泡排序
- 5.2. 选择排序
- 5.3. 插入排序
- 5.4. 快速排序
- 5.5. 堆排序
- 5.5.1.树
- 5.5.2. 堆
- 5.6. 归并排序
- 5.7. 希尔排序
- 5.8. 计数排序
- 5.9.桶排序
- 5.10. 基数排序
- 二.数据结构
- 2.1.列表/数组
- 2.2.栈
- 2.3. 队列
- 2.4. 链表
- 2.5. 哈希表
- 2.6. 树
- 2.6.1. 二叉树
- 2.6.2. 二叉搜索树
- 2.6.3. AVL树
- 三 . 算法高阶举例
- 3.1. 贪心算法
- 3.2. 动态规划
前言
最近在学习数据结构与算法,写个笔记记录一下。
一.算法
1. 哈希表是什么?
先来个开胃菜,我们经常听到哈希,那么哈希是什么,我们来浅浅的看一下,后面会详细介绍。
哈希表(Hash Table),也称为哈希映射或字典,是一种常见的数据结构,用于存储键-值对(key-value pairs)。它通过使用哈希函数(hash function)将键映射到存储桶(buckets)或槽位(slots)中,以实现快速的插入、查找和删除操作。哈希表的核心思想是使用键的哈希值作为索引来访问和存储值。
2. 什么是时间复杂度?
时间复杂度是算法运行时间与输入规模之间的关系,用来衡量算法的效率。它描述了算法执行所需的操作数量,通常以大O符号(O)表示。计算时间复杂度时,主要关注算法中执行次数最多的那部分代码。
- 常数时间复杂度(O(1)):无论输入规模大小,算法的执行时间都是固定的常量。例如,简单的赋值语句、访问数组元素等操作都是常数时间复杂度。
- 线性时间复杂度(O(n)):算法的执行时间与输入规模呈线性关系。例如,对一个包含n个元素的数组进行遍历操作的时间复杂度就是O(n)。
- 对数时间复杂度(O(log n)):算法的执行时间与输入规模的对数关系。通常出现在使用分治或二分搜索等算法时。例如,二分查找的时间复杂度就是O(log n)。
- 平方时间复杂度(O(n2))、立方时间复杂度(O(n3))等:算法的执行时间与输入规模的平方、立方等次方关系。例如,嵌套循环操作通常会导致平方时间复杂度。
- 指数时间复杂度(O(2^n)):算法的执行时间与输入规模的指数关系。通常出现在使用穷举搜索等指数级算法时,因为它的执行时间随着输入规模的增加而急剧增长。
快速判断算法复杂度(适用于绝大多数简单情况)
- 确定问题规模n
- 循环减半过程一logn
- k层关于n的循环一>
- 复杂情况:根据算法执行过程判断
3. 空间复杂度
空间复杂度:用来评估算法内存占用大小的式子,空间复杂度的表示方式与时间复杂度完全一样
- 算法使用了几个变量: O(1)
- 算法使用了长度为n的一维列表: O(n)
- 算法使用了m行n列的二维列表: O(mn)
- 空间换时间
4. 递归
两个限制条件:调用自身,结束条件
def fun1(x):
if x > 0:
print(x)
fun1(x - 1)
def fun2(x):
if x > 0:
fun2(x - 1)
print(x)
if __name__ == "__main__":
fun1(3)
fun2(3)
optput:
3
2
1
1
2
3
为什么下面一个是1,2,3?看下下面的图:函数的执行是从上往下,左边一个是递归完就打印,右边是递归完再打印。
看个汉诺塔的问题:
def han(n, a, b, c):
if n > 0:
han(n - 1, a, c, b)
print("moving from %s to %s" % (a, c))
han(n - 1, b, a, c)
if __name__ == "__main__":
han(10 , "A", "B", "C")
4. 查找
查找:在一些数据元素中,通过一定的方法找出与给定关键字相同的数据元素的过程。
- 列表查找(线性表查找) : 从列表中查找指定元素
- 输入:列表、待查找元素
- 输出: 元素下标 (未找到元素时一般返回None或-1)
- 内置列表查找函数:
index()
4.1、顺序查找
for循环
def linear_search(data_set, value):
for i in range(len(data_set)):
if data_set[i] == value:
return i
return
4.2. 二分查找
要求列表必须是有序列表,先排序,left和right记录两边的变量,找出中间元素mid,更新left,right,mid。
下面代码是假设已经排序好的。排序直接用sorted()排序即可。
def binary_search(data_list, value):
left = 0
right = len(data_list)
while left <= right:
mid = (left + right) //2
if data_list[mid] == value:
return mid
elif data_list[mid] > value:
right = mid -1
else:
left = mid + 1
5. 排序
排序: 将一组“无序”的记录序列调整为“有序”的记录序列。
列表排序:将无序列表变为有序列表
- 输入:列表
- 输出:有序列表
- 升序与降序
- 内置排序函数:
sorted()
5.1. 冒泡排序
大的数下沉或者上浮,两两比较进行交换。
def bubble_sort(array):
for i in range(len(array)):
for j in range(len(array) - 1 - i):
if array[j] > array[j + 1]:
array[j], array[j + 1] = array[j + 1], array[j]
return array
上面的代码有个不足之处,如果一个列表不需要走完所有的n-1趟,是不是可以改进一下?
def bubble_sort(array):
for i in range(len(array)):
exchange = False
for j in range(len(array) - 1 - i):
if array[j] > array[j + 1]:
array[j], array[j + 1] = array[j + 1], array[j]
exchange = True
return array
5.2. 选择排序
选择最小的或者最大的依次放到前面。
def select_sort(li):
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]
5.3. 插入排序
类似摸牌的时候把牌按照插入的方法排序。
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]
j -= 1
# 插入待排序元素到正确位置
arr[j + 1] = key
总结:
冒泡排序,选择排序,插入排序都是远低排序,复杂度都是,实现简单,缺点就是
5.4. 快速排序
快速排序:快
快速排序思路(有点类似二分法)
- 取一个元素p(第一个元素),使元素p归位(放到应该放的位置);
- 列表被p分成两部分,左边都比p小,右边都比p大;
- 递归完成排序
def partition(li, left, right):
tmp = li[left]
while left < right:
while left < right and li[right] >= tmp: # 从右面找比tmp小的数
# 往左走一步
right -= 1
li[left] = li[right]
# 把右边的值写到左边空位上
while left < right and li[left] <= tmp:
left += 1
li[right] = li[left]
# 把左边的值写到右边空位上
li[left] = tmp # 把tmp归位
return left
def quick_sort(data, left, right):
if left < right:
mid = partition(data, left, right)
quick_sort(data, left, mid - 1)
quick_sort(data, mid + 1, right)
return data
快速排序的时间复杂度
缺点:递归,会有最坏情况(初试列表是倒序的复杂度还是,打乱列表可以缓解这种情况出现。
5.5. 堆排序
5.5.1.树
堆排序要先知道下什么是树。
堆就是一个特殊的完全二叉树。
二叉树在计算机中怎么实现,即怎么存储?
两种方式:链式存储(后面介绍链表再讲),顺序存储。
顺序存储就是放到列表中处理。
下面以完全二叉树为例:
5.5.2. 堆
堆:一种特殊的完全二叉树结构
两种结构:
- 大根堆:一棵完全二叉树,满足任一节点都比其孩子节点大
- 小根堆:一棵完全二叉树,满足任一节点都比其孩子节点小
堆具有向下调整型:
假设根节点的左右子树都是堆,但根节点不满足堆的性质,可以通过一次向下的调整来将其变成一个堆
调整后:
堆怎么构造?
从最先开始一级一级往上调整,农村包围城市。
过程:
- 建立堆,以大根堆为例
- 得到堆顶元素,为最大元素
- 去掉堆顶,将堆最后一个元素放到堆顶,此时可通过仅需一次向下调整即可重新使堆有序
- 堆顶元素为第二大元素
- 重复步骤3,直到堆变空
堆排序的核心就是构建堆+一次向下调整,想要构建堆,核心是让下级先有序,只要下级满足堆了,逐级通过一次向下调整即可构建出完整的堆,最后就能逐个遍历堆顶元素进行排序整个列表
# 只是一次调整堆
def sift(li, low, high):
"""
:param li: 列表
:param low: 堆的根节点位置
:param high: 堆的最后一个元素的位置
:return:
"""
i = low # i最开始指向根节点
j = 2 * i + 1 # 开始是左孩子
tmp = li[low] # 把堆顶存起来
while j <= high: # 只要j位置有数
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: # tmp更大,把tmp放到i的位置上li[i] = tmp#把tmp放到某一级领导位置上break
li[i] = tmp # 把tmp放到叶子节点上
break
# 当j大于high的时候
else:
li[i] = tmp
def heap_sort(li):
n = len(li)
# 从最下面的左边的叶子结点开始调整建堆
for i in range((n - 2) // 2, -1, -1):
sift(li, i, n - 1)
# print(li)
# 排序
for i in range(n - 1, -1, -1):
# i一直指向当前堆的最后一个位置
li[0], li[i] = li[i], li[0]
sift(li, 0, i - 1)
return li
时间复杂度:
堆的内置模块:
- heapify(x)
- heappush(heap,item)
- heappop(heap)
现在有n个数,设计算法得到前k大的数。 (k<n)
解决思路:
- 排序后切片:O(nlogn)
- 排序LowB三人组:O(kn)
- 堆排序思路:O(nlogk),小根堆实现
堆排序实现方法:两组动图演示一下。
def sift(li, low, high):
"""
:param li: 列表
:param low: 堆的根节点位置
:param high: 堆的最后一个元素的位置
:return:
"""
i = low # i最开始指向根节点
j = 2 * i + 1 # 开始是左孩子
tmp = li[low] # 把堆顶存起来
while j <= high: # 只要j位置有数
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: # tmp更大,把tmp放到i的位置上li[i] = tmp#把tmp放到某一级领导位置上break
li[i] = tmp # 把tmp放到叶子节点上
break
else:
li[i] = tmp
def topk(li, k):
# 列表前k个取出来建堆
heap = li[0: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]
sift(heap, 0, i - 1)
# 输出
return heap
5.6. 归并排序
一个列表可以分为两部分,两边都是排序好的。如list = [1, 2, 3, 4, 2, 3, 7, 9]
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
return li
实际使用中列表不是一分为二排序好的,怎么处理?
- 分解: 将列表越分越小,直至分成一个元素
- 终止条件:一个元素是有序的。
- 合并:将两个有序列表归并,列表越来越大。
归并图解:
完整代码应该怎么实现?
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
ltmp.extend(li[i:mid + 1])
ltmp.extend(li[j:high + 1])
li[low:high + 1] = ltmp
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)
return li
python内置的sorted的排序方法就是基于归并排序优化的。
时间复杂度:
总结:
挨个移动的都是稳定的,不是挨个换的都是不稳定的。
5.7. 希尔排序
希尔排序(Shell Sort)是一种分组插入排序算法,插入排序算法的改进版本,非常稳定的算法。
- 首先取一个整数d=n/2,将元素分为d,个组,每组相邻量元素之间距离为d,在各组内进行直接插入排序;
- 取第二个整数d,=d,/2,重复上述分组排序过程,直到d;=1,即所有元素在同一组内进行直接插入排序。
- 希尔排序每趟并不使某些元素有序,而是使整体数据越来越接近有序;最后一趟排序使得所有数据有序。
def insert_sort_gap(li, gap):
for i in range(gap, len(li)):
tmp = li[i]
j = i -gap
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
return li
5.8. 计数排序
直接统计某个数出现的次数。
def count_sort(li, max_count):
count = [0 for _ in range(max_count + 1)]
for val in li:
count[val] += 1
li.clear()
for index, val in enumerate(count):
for i in range(val):
li.append(index)
return li
li = [3, 2, 6, 1, 7, 9, 6, 8]
# 注意这里传的是最大值
result = count_sort(li, max(li))
print(result)
print(sorted(li))
遇到字典的时候可以放到列表中。
缺点:列表短,数值大的时候不太友好。怎么解决,引出桶排序。
5.9.桶排序
对计数排序的改进,把某个范围的元素放到一起。 不是很重要,知道原理代码能写出了就行了。
桶排序(Bucket Sort):
- 首先将元素分在不同的桶中,再对每个桶中的元素排序 29 25 3 49 9 37 21 43
def bucket_sort(li, n, max_num):
# 创建桶
buckets = [[] for _ in range(n)]
for var in li:
# 去最小值是因为当值为最后一位的时候数组下标会越界,如10000分为100组,10000的时候数应该放在99下标的数组中,而不是100
i = min(var // (max_num // n), n - 1)
buckets[i].append(var)
# 装进桶的同时进行排序,只需要给前面一个数比较看谁大就行了,保证了每个桶的数按照顺序放入
for j in range(len(buckets[i]) - 1, 0, -1):
if buckets[i][j] < buckets[i][j-1]:
buckets[i][j], buckets[i][j - 1] = buckets[i][j - 1], buckets[i][j]
else:
break
sorted_li = []
for buc in buckets:
sorted_li.extend(buc)
return sorted_li
桶排序的表现取决于数据的分布。也就是需要对不同数据排序时采取不同的分桶策略。
- 平均情况时间复杂度: O(n+k)
- 最坏情况时间复杂度: O(n2k)
- 空间复杂度: O(nk)
5.10. 基数排序
桶排序的改进,先按照个位数进行排序,输出,再按照10位进行排序。
def radix_sort(li):
max_num = max(li)
# 求最大数的位数
it = 0
while 10 ** it <= max_num:
# 固定10个桶
buckets = [[] for _ in range(10)]
for var in li:
# 从前往后依次取数,如987,->7,8,9
digit = (var // 10 ** it) % 10
buckets[digit].append(var)
# 按照对应位数粪桶完成
li.clear()
for buc in buckets:
li.extend(buc)
it += 1
return li
时间复杂度:,k以10为底的数
稳定性: 排序算法的稳定性是指排序算法对于值相等的输入数据,在输出结果中它们的顺序也不变。也就是说,排序后的结果中,值相等的元素的前后顺序与排序前保持一致。
二.数据结构
数据结构是什么?
数据结构是计算机存储、组织数据的方式;通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构的优良将直接影响着我们程序的性能;常用的数据结构有:数组(Array
)、栈(Stack
)、队列(Queue
)、链表(Linked List
)、树(Tree
)、图(Graph
)、堆(Heap
)、散列表(Hash
)等;
2.1.列表/数组
列表(其他语言称数组) 是一种基本数据类型。
数组跟列表有两点不一样:数组元素类型必须一样,并且长度固定,Python中则不需要。那么python中的列表是如何实现元素可以不一样,长度固定呢?python
列表里面存放的地址不是值了,把元素的地址放到列表中,元素具体在哪里放着不管,可以理解成C中的指针。长度不够的时候列表会自己开辟。
列表的一些问题
- 列表中的元素是如何存储的?
- 列表的基本操作:按下标查找、插入元素、删除元素…
- 这些操作的时间复杂度是多少?
- Python的列表是如何实现的?
列表的一些操作可参考:一文详解列表,元组,字典,集合,生成器,迭代器,可迭代对象,zip,enumerate。
2.2.栈
栈(Stack)是一个数据集合,可以理解为只能在一端进行插入或删除操作的列表。
- 栈的特点:先进后出
- 栈的概念:栈顶、栈底
- 栈的基本操作:进栈(压栈):
push
出栈:pop
取栈顶:gettop
,只看最上面的元素是什么,不拿走- 使用一般的列表结构即可实现栈:进栈:
li.append
;出栈:li.pop
;取栈顶:li[-1]
栈的简单实现:
class Stack:
def __init__(self):
self.stack = []
def push(self, element):
self.stack.append(element)
def pop(self):
return self.stack.pop()
def get_top(self):
if len(self.stack) > 0:
return self.stack[-1]
else:
return None
stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)
print(stack.pop())
应用:栈能用在哪?
举个例子,看一个括号匹配问题。检查括号写法是否正确。
class Stack:
def __init__(self):
self.stack = []
def push(self, element):
self.stack.append(element)
def pop(self):
return self.stack.pop()
def get_top(self):
if len(self.stack) > 0:
return self.stack[-1]
else:
return None
def is_empty(self):
return len(self.stack) == 0
def brace_match(s):
match = {"}": "{", "]": "[", ")": "("}
stack = Stack()
for ch in s:
if ch in {"(", "{", "["}:
stack.push(ch)
else:
if stack.is_empty():
return False
elif stack.get_top() == match[ch]:
stack.pop()
else:
return False
if stack.is_empty():
return True
else:
False
print(brace_match("[][][]{}{}({})"))
print(brace_match("[{]}"))
2.3. 队列
仅允许在列表的一端进行插入,另一端进行删除。进行插入的一端称为队尾(rear),插入动作称为进队或入队进行删除的一端称为队头(front),删除动作称为出队队列。注意,队列可以对两边进行操作,栈只能进行一端的操作。
性质: 先进先出(First-in,First-out)
队列可以直接使用列表实现吗?看下面的几个例子,可以用,要么时间复杂度高,要么空间浪费。进而引出下面队列的实现方式,环形队列
队列实现方式:环形队列
环形队列:当队尾指针front == Maxsize - 1
时,再前进一个位置就自动到0.
队指针前进1: front =(front +1)% MaxSize
队尾指针前进1: rear =(rear + 1)% MaxSize
队空条件: rear == front
队满条件:(rear +1)% MaxSize == front
自己实现环形队列:
class Queue:
def __init__(self, size=100):
self.queue = [0 for _ in range(size)]
self.size = size
# 队首指针
self.rear = 0
# 队尾指针
self.front = 0
def push(self, element):
if not self.is_filled():
self.rear = (self.rear + 1) % self.size
self.queue[self.rear] = element
else:
raise IndexError("队列已满")
def pop(self):
if not self.is_empty():
self.front = (self.front + 1) % self.size
return self.queue[self.front]
else:
raise IndexError("队列为空")
def is_empty(self):
return self.rear == self.front
def is_filled(self):
return (self.rear + 1) % self.size == self.front
q = Queue(5)
for i in range(4):
q.push(i)
print(q.pop())
q.push(4)
上面的队列是单向队列,只能先进先出,不能双向进出。于是就有了双向队列,双向队列两头都可以进出的队列。注意栈是一头进队和出队别忘了。
python内置队列模块:
注意python中的queue包并不是队列包,只是保证线程安全的包。队列包通过from collections import deque
引入deque表示双向队列,d表示双向的意思,collections
中中存放了一些数据结构包。
使用方法: from collections import deque
# 创建的是个双向队列
创建队列: queue = deque()
# 只用下面两个就是单向队列
进队: append() # 队尾进队
出队: popleft() # 队首出队,从左边出,又进左出
# 双向队列时的操作,一般用的不多
双向队列队首进队: appendleft()
双向队列队尾出队: pop()
from collections import deque
# 队满自动出列
q = deque([1, 2, 3, 4, 5], 3)
print(q)
输出:
deque([3, 4, 5], maxlen=3)
下面再看一个用队列实现linux的tell函数的问题:
# file.txt文件
1223
rwqer
qewf
vdsg
q3wr
xczv
24141fswf
wqeq
fsgfd
from collections import deque
# 用队列实现linux的tell函数
def tell(n):
with open("file.txt", "r") as f:
# deque可以接收一个可迭代对象,deque会自动迭代文件对象f并将其行存储在队列中。
q = deque(f, n)
return q
for line in tell(5):
print(line, end="")
输出:
q3wr
xczv
24141fswf
wqeq
fsgfd
上面的问题如果改成前几行呢?直接用户for循环遍历前几个就行了。
再来看个迷宫的问题:
# 0表示路,1表示墙
maze = [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 1, 0, 0, 0, 1, 0, 1],
[1, 0, 0, 1, 0, 0, 0, 1, 0, 1],
[1, 0, 0, 0, 0, 1, 1, 0, 0, 1],
[1, 0, 1, 1, 1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 1, 1, 0, 1],
[1, 1, 1, 0, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 0, 0, 1, 0, 1, 0, 1],
[1, 0, 0, 1, 0, 0, 0, 0, 0, 1],
[1, 1, 0, 0, 0, 1, 1, 1, 1, 1]
]
dirs = [
lambda x, y: (x + 1, y),
lambda x, y: (x - 1, y),
lambda x, y: (x, y - 1),
lambda x, y: (x, y + 1),
]
def maze_path(x1, y1, x2, y2):
stack = []
stack.append((x1, y1))
# 栈不空时循环,栈空表示没路可走了
while len(stack) > 0:
curNode = stack[-1]
if curNode[0] == x2 and curNode[1] == y2:
for p in stack:
print(p)
return True
# 遍历四个方向
for dir in dirs:
nextNode = dir(curNode[0], curNode[1])
# 检查下一个节点是否越界
if nextNode[0] < 0 or nextNode[0] >= len(maze) or nextNode[1] < 0 or nextNode[1] >= len(maze[0]):
continue
# 如果下一个节点能走且未访问过
if maze[nextNode[0]][nextNode[1]] == 0:
stack.append(nextNode)
# 标记已经走过
maze[nextNode[0]][nextNode[1]] = 2
break
else:
# 四个方向都无法前进,回溯
stack.pop()
else:
print("没有路")
return False
maze_path(1, 1, 8, 8)
输出:
(1, 1)
(2, 1)
(3, 1)
(4, 1)
(5, 1)
(5, 2)
(5, 3)
(6, 3)
(7, 3)
(7, 4)
(8, 4)
(8, 5)
(8, 6)
(8, 7)
(8, 8)
链表队列可以解决环形队列固定长度问题。
2.4. 链表
链表是由一系列节点组成的元素集合。每个节点包含两部分,数据域item和指向下一个节点的指针next。通过节点之间的相互连接最终串联成一个链表。
看一个简单的链表演示
class Node:
def __init__(self, item):
self.item = item
self.next = None
a = Node(1)
b = Node(2)
c = Node(3)
a.next = b
b.next = c
print(a.next.next.item)
怎么创建链表?头插法,尾插法。
class Node:
def __init__(self, item):
self.item = item
self.next = None
# 头插
def creat_linklist_head(li):
head = Node(li[0])
for element in li[1:]:
node = Node(element)
node.next = head
head = node
return head
# 尾插,tail维护,head只是为了返回头结点用的
def creat_linklist_tail(li):
head = Node(li[0])
tail = head
for element in li[1:]:
node = Node(element)
tail.next = node
tail = node
return head
链表怎么插入删除?
插入的时候先跟后面一个连接起来在和前面节点链接,删除的时候需要前面一个节点和后面一个结点先连接起来在删除中间的节点。
双链表怎么实现?
双链表的每个节点有两个指针:一个指向后一个节点,另一个指向前一个节点。
p.next = curNode.next
curNode.next.prior =p
p.prior = curNode
curNode.next = p
2.5. 哈希表
python中的字典和集合都是通过哈希表实现的。哈希表通过一个哈希函数来计算数据存储位置的数据结构,通常支持如下操作:
- insert(key,value): 插入键值对(key,value)
- get(key): 如果存在键为key的键值对则返回其value,否则返回空值
- delete(key):删除键为key的键值对
哈希表从直接寻址表演化而来,直接寻址表是把key为k的元素放到k位置上。
改进直接寻址表:
构建大小为m的寻址表T
key为k的元素放到h(k)位置上h(k)
是一个函数,其将域U映射到表T[0,1,…,m-1]
由于哈希表的大小是有限的,而要存储的值的总数量是无限的,因此对于任何哈希函数,都会出现两个不同元素映射到同一个位置上的情况,这种情况叫做哈希冲突。
哈希冲突怎么解决?
哈希寻址法(用的很少),拉链法(推荐使用)
开放寻址法: 如果哈希函数返回的位置已经有值,则可以向后探查新的位置来存储这个值。
线性探查: 如果位置i被占用,则探查i+1,i+2,…v
二次探查:如果位置i被占用,则探查i+12,i12,i+22,i-22…
二度哈希:有n个哈希函数,当使用第1个哈希函数h1发生冲突时,则尝试使用h2,h3.
拉链法:
哈希表每个位置都连接一个链表,当冲突发生时,冲突的元素将被加到该位置链表的最后。
常见的哈希函数:
- 除法哈希法:h(k)=k%m
- 乘法哈希法:h(k)= foor(m*(A*key%1))
- 全域哈希法:hab(k)=((a*key + b)mod p) mod m a,b=1,2,…,P-1
自己实现拉链法哈希表:
class LinkList:
class Node:
def __init__(self, item=None):
self.item = item
self.next = None
class LinkListIterator:
def __init__(self, node):
self.node = node
def __next__(self):
if self.node:
cur_node = self.node
self.node = cur_node.next
return cur_node.item
else:
raise StopIteration
def __iter__(self):
return self
'''
下面的三个函数可以完成插入一个列表
'''
# iterable表示要传入的列表
def __init__(self, iterable=None):
self.head = None
self.tail = None
if iterable:
self.extend(iterable)
# 尾插
def append(self, obj):
# 先判断列表是否是空的
s = LinkList.Node(obj)
if not self.head:
self.head = s
self.tail = s
else:
self.tail.next = s
self.tail = s
def extend(self, iterable):
for obj in iterable:
self.append(obj)
'''
查找这个对象
'''
def find(self, obj):
# self是可迭代对象,调用这个函数的本身就是个可迭代对象。
for n in self:
if n == obj:
return True
else:
return False
# 让这个链表成为一个可迭代对象(返回的是迭代器,迭代器上面创建好了),支持for循环
def __iter__(self):
return self.LinkListIterator(self.head)
# 直接打印对象转为字符串
def __repr__(self):
return "<<" + ",".join(map(str, self)) + ">>"
# 哈希表的目的是做一个类似集合的东西
class HashTable:
def __init__(self, size=101):
self.size = size
# 拉链法,每个位置是个链表,每个位置初始化一个空链表
self.T = [LinkList() for i in range(self.size)]
def hash(self, k):
return k % self.size
def find(self, k):
# 先找打哈希值
i = self.hash(k)
return self.T[i].find(k)
def insert(self, k):
i = self.hash(k)
if self.find(k):
print("重复插入")
else:
self.T[i].append(k)
ha = HashTable()
ha.insert(1)
ha.insert(2)
ha.insert(3)
ha.insert(103)
print(",".join(map(str, ha.T)))
输出:
<<>>,<<1>>,<<2,103>>,<<3>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>
上面的函数用到了自定义迭代器和可迭代对象,对于这一点不懂的小伙伴可以看下我的另一篇博文:
一文详解列表,元组,字典,集合,生成器,迭代器,可迭代对象,zip,enumerate
哈希表的应用:
字典与集合都是通过哈希表来实现的。
a ={name': 'Alex,age': 18,gender: 'Man}
使用哈希表存储字典,通过哈希函数将字典的键映射为下标。假设h('name')=3,h('age')= 1,h('gender')= 4
,则哈希表存储为[None,18,None,'Alex','Man']
。如果发生哈希冲突,则通过拉链法或开发寻址法解决。
2.6. 树
之前在将堆的时候简单介绍了下树。树是一种数据结构,比如目录结构树是一种可以递归定义的数据结构树是由n个节点组成的集合,如果n=0,那这是一棵空树;如果n>0,那存在1个节点作为树的根节点,其他节点可以分为m个集合,每个集合本身又是一棵树。下面我们简单演示一下Linux的文件的操作。
"""
模拟文件系统
"""
class Node:
def __init__(self, name, type='dir'):
self.name = name
self.type = type
self.children = []
self.parent = None
def __repr__(self):
return self.name
class FileSystemTree:
def __init__(self):
self.root = Node("/")
self.now = self.root
def mkdir(self, name):
# name要以 / 结尾
if name[-1] != "/":
name += "/"
node = Node(name)
# 连接子目录
self.now.children.append(node)
# 连接上级目录
node.parent = self.now
def ls(self):
return self.now.children
def cd(self, name):
# 相对路径
if name[-1] != "/":
name += "/"
if name == "../":
self.now = self.now.parent
return
# 判断要进来的目录在不在
for child in self.now.children:
if child.name == name:
self.now = child
return
raise ValueError("invlaid dir")
tree = FileSystemTree()
tree.mkdir("var/")
tree.mkdir("bin/")
tree.mkdir("usr/")
tree.cd("bin/")
tree.mkdir("python/")
print(tree.ls())
2.6.1. 二叉树
一个节点最多分为两个树枝。简单实现下下面的二叉树:
class BiTreeNode:
def __init__(self, data):
self.data = data
self.lchild = None
self.rchild = None
a = BiTreeNode("A")
b = BiTreeNode("B")
c = BiTreeNode("C")
d = BiTreeNode("D")
e = BiTreeNode("E")
f = BiTreeNode("F")
g = BiTreeNode("G")
e.rchild = g
e.lchild = a
a.rchild = c
g.rchild = f
c.rchild = d
c.lchild = b
root = e
print(root.lchild.rchild.data)
二叉树遍历:四种方式
- 前序遍历
- 中序遍历
- 后序遍历
- 层次遍历
可以根据遍历结果反推出树的结构。
from collections import deque
class BiTreeNode:
def __init__(self, data):
self.data = data
self.lchild = None
self.rchild = None
a = BiTreeNode("A")
b = BiTreeNode("B")
c = BiTreeNode("C")
d = BiTreeNode("D")
e = BiTreeNode("E")
f = BiTreeNode("F")
g = BiTreeNode("G")
e.rchild = g
e.lchild = a
a.rchild = c
g.rchild = f
c.rchild = d
c.lchild = b
root = e
# 前序遍历
def pre_order(root):
if root:
print(root.data, end=",")
pre_order(root.lchild)
pre_order(root.rchild)
# 中序遍历
def in_order(root):
if root:
in_order(root.lchild)
print(root.data, end=",")
in_order(root.rchild)
# 后序遍历
def post_order(root):
if root:
in_order(root.lchild)
in_order(root.rchild)
print(root.data, end=",")
# 层次遍历
def layer_order(root):
queue = deque()
queue.append(root)
# 只要队不空就一直访问
while len(queue) > 0:
node = queue.popleft()
# 访问刚才出队的元素
print(node.data, end=",")
if node.lchild:
queue.append(node.lchild)
if node.rchild:
queue.append(node.rchild)
pre_order(root)
print("\n")
in_order(root)
print("\n")
post_order(root)
print("\n")
layer_order(root)
上面的前序遍历和中序遍历,后序遍历要熟悉,并且能够根据给的遍历结果反推出树的结构,一般会给出两个遍历结果让你反推。
2.6.2. 二叉搜索树
二又搜索树是一颗二叉树且满足性质: 设x是二叉树的一个节点。如果y是x左子树的一个节点,那么y.key ≤ x.key;
如果y是x右子树的一个节点,那么y.key > x.key
。
二叉搜索树的操作:查询,插入,删除
插入和查询的实现,以下面的树为例:
class BiTreeNode:
def __init__(self, data):
self.data = data
self.lchild = None # 左孩子
self.rchild = None # 右孩子
self.parent = None
class BST:
def __init__(self, li=None):
self.root = None
if li:
for val in li:
self.insert_no_rec(val)
# 递归方法
def insert(self, node, val):
if not node:
node = BiTreeNode(val)
elif val < node.data:
node.lchild = self.insert(node.lchild, val)
node.lchild.parent = node
elif val > node.data:
node.rchild = self.insert(node.rchild, val)
node.rchild.parent = node
return node
# 普通方法
def insert_no_rec(self, val):
p = self.root
# 空树,特殊处理
if not p:
self.root = BiTreeNode(val)
return # 空树
while True:
if val < p.data:
if p.lchild:
p = p.lchild # 左孩子不存在
else:
p.lchild = BiTreeNode(val)
p.lchild.parent = p
return
elif val > p.data:
if p.rchild:
p = p.rchild
else:
p.rchild = BiTreeNode(val)
p.rchild.parent = p
return
else:
return
# 递归查询
def query(self, node, val):
if not node:
return None
if node.data < val:
return self.query(node.rchild, val)
elif node.data > val:
return self.query(node.lchild, val)
else:
return
# 非递归查询
def query_no_rec(self, val):
p = self.root
while p:
if p.data < val:
p = p.lchild
elif p.data > val:
p = p.lchild
else:
return p
return None
# 前序遍历
def pre_order(self, root):
if root:
print(root.data, end=",")
self.pre_order(root.lchild)
self.pre_order(root.rchild)
# 中序遍历
def in_order(self, root):
if root:
self.in_order(root.lchild)
print(root.data, end=",")
self.in_order(root.rchild)
# 后序遍历
def post_order(self, root):
if root:
self.in_order(root.lchild)
self.in_order(root.rchild)
print(root.data, end=",")
tree = BST([4, 5, 3, 7, 8, 1, 2, 9])
tree.pre_order(tree.root)
print("")
# 一定是升序序列
tree.in_order(tree.root)
print("")
tree.post_order(tree.root)
print("")
print(tree.query_no_rec(4).data)
PS:
上面的中序遍历的输出是排序好的,因为中序序列是先左后右,一定是排序好的。
删除:
删除的时候需要注意,分为如下三种情况:
- 如果要删除的节点是叶子结点,直接删除
- 如果要删除的节点只有一个孩子: 将此节点的父亲与孩子连接,然后删除该节点,注意删除的时候是根的情况 。
- 如果要删除的节点有两个孩子: 将其右子树的最小节点 (该节点最多有一个右孩子) 删除,并替换当前节点。
class BiTreeNode:
def __init__(self, data):
self.data = data
self.lchild = None # 左孩子
self.rchild = None # 右孩子
self.parent = None
class BST:
def __init__(self, li=None):
self.root = None
if li:
for val in li:
self.insert_no_rec(val)
# 递归方法
def insert(self, node, val):
if not node:
node = BiTreeNode(val)
elif val < node.data:
node.lchild = self.insert(node.lchild, val)
node.lchild.parent = node
elif val > node.data:
node.rchild = self.insert(node.rchild, val)
node.rchild.parent = node
return node
# 普通方法
def insert_no_rec(self, val):
p = self.root
# 空树,特殊处理
if not p:
self.root = BiTreeNode(val)
return # 空树
while True:
if val < p.data:
if p.lchild:
p = p.lchild # 左孩子不存在
else:
p.lchild = BiTreeNode(val)
p.lchild.parent = p
return
elif val > p.data:
if p.rchild:
p = p.rchild
else:
p.rchild = BiTreeNode(val)
p.rchild.parent = p
return
else:
return
# 递归查询
def query(self, node, val):
if not node:
return None
if node.data < val:
return self.query(node.rchild, val)
elif node.data > val:
return self.query(node.lchild, val)
else:
return
# 非递归查询
def query_no_rec(self, val):
p = self.root
while p:
if p.data < val:
p = p.rchild
elif p.data > val:
p = p.lchild
else:
return p
return None
# 情况一:node是叶子结点,直接删掉
def __remove_mode_1(self, node):
# 先判断是不是根节点,就一个节点
if not node.parent:
self.root = None
# 将子节点与父节点断绝联系
if node == node.parent.lchild:
node.parent.lchild = None
else:
node.parent.rchild = None
# 情况2.1:要删除的节点node只有一个左孩子
def __remove_node_21(self, node):
# 先判断要删除的是不是根节点
if not node.parent:
# 如果要删除的是根节点,就把做孩子设置为根节点
self.root = node.lchild
# 子节点设置为父节点,父节点置为空
node.lchild.parent = None
# 判断这个删除的节点是父节点的左孩子还是右孩子
elif node == node.parent.lchild:
node.parent.lchild = node.lchild
node.lchild.parent = node.parent
else:
node.parent.rchild = node.lchild
node.lchild.parent = node.parent
# 情况2.2:要删除的节点node只有一个右孩子(原理同上)
def __remove_node_22(self, node):
if not node.parent:
self.root = node.rchild
elif node == node.parent.lchild:
node.parent.lchild = node.rchild
node.rchild.parent = node.parent
else:
node.parent.rchild = node.rchild
node.rchild.parent = node.parent
# 要删除的节点既有左孩子,又有右孩子
def delete(self, val):
if self.root: # 不是空树
# 找到要删除的节点
node = self.query_no_rec(val)
# 判断节点是否存在
if not node:
return False
# 判断节点的子节点情况
if not node.lchild and not node.rchild: # 叶子结点
self.__remove_mode_1(node)
elif not node.rchild: # 2.1只有一个左孩子,这里的写法要注意,不能判断是否有右孩子,不然无法判断左孩子是否存在
self.__remove_node_21(node)
elif not node.lchild: # 2.1只有一个右孩子
self.__remove_node_22(node)
else: # 3.两个孩子都有
min_node = node.rchild
# 找到右子树最小的节点
while min_node.lchild:
min_node = min_node.lchild
node.data = min_node.data
# 删除min_node
# 有右子节点
if min_node.rchild:
self.__remove_node_22(min_node)
else:
# 否者是叶子节点
self.__remove_mode_1(min_node)
# 前序遍历
def pre_order(self, root):
if root:
print(root.data, end=",")
self.pre_order(root.lchild)
self.pre_order(root.rchild)
# 中序遍历
def in_order(self, root):
if root:
self.in_order(root.lchild)
print(root.data, end=",")
self.in_order(root.rchild)
# 后序遍历
def post_order(self, root):
if root:
self.in_order(root.lchild)
self.in_order(root.rchild)
print(root.data, end=",")
tree = BST([4, 5, 3, 7, 8, 1, 2, 9])
# 一定是升序序列
tree.in_order(tree.root)
print("")
tree.delete(4)
tree.in_order(tree.root)
print("")
输出 :
1,2,3,4,5,7,8,9,
1,2,3,5,7,8,9,
2.6.3. AVL树
二叉树的时间复杂度是,但是会出现最坏的情况,二叉树非常的偏斜,一直往一边分支,如下图所示。从而引出了AVL树。
AVL树: 树是一棵自平衡(任何两边的树的高度差不会超过1)的二叉搜索树。AVL
树具有以下性质:
- 根的左右子树的高度之差的绝对值不能超过1
- 根的左右子树都是平衡二叉树
如上图所示平衡因子是左边树的高度减去右边树的高度,绝对值不会超过1。既然AVL树要求是平衡的,那么我们怎么插入数据呢,怎么保证在插入的时候是平衡的?我们看下下面的动图。
通过上面的动图可以看到在,在插入的时候如果出现了不平衡,通过旋转可以让其变成平衡树。
- 插入一个节点可能会破坏
AVL
树的平衡,可以通过旋转操作来进行修正- 插入一个节点后,只有从插入节点到根节点的路径上的节点的平衡可能被改变(动图中带颜色的路径)。我们需要找出第一个破坏了平衡条件的节点,称之为
K
。K
的两棵子树的高度差2。
不平衡的出现可能有4
种情况:
- 左旋:不平衡是由于对K的右孩子的右子树插入导致的
- 右旋:不平衡是由于对K的左孩子的左子树插入导致的
- 先右旋-再左旋:不平衡是由于对K的右孩子的左子树插入导致的
- 先左旋-再右旋:不平衡是由于对K的左孩子的左右子树插入导致的
记忆方法:
右右左旋,左左右旋,右左右左,左右左右。旋转即调换上下级关系。
下面展示一张四种旋转的动图:
AVL
树插入规则,规定平衡因子右边减去左边,插入的元素如果在节点的右边,节点平衡因子+1
,如果来自左边,节点平衡因子-1
,中间如果出现某个节点的平衡因子的绝对值大于2
,则对该节点进行旋转(四种旋转针对四种情况)。
代码示例:
# 导入之前创建好的类
from btree import BST, BiTreeNode
# 通过继承创建节点
class AVLNode(BiTreeNode):
def __init__(self, data):
BiTreeNode.__init__(self, data)
# 节点平衡因子
self.bf = 0
# 通过继承创建树
class AVLTree(BST):
def __init__(self, li=None):
BST.__init__(self, li)
# 针对插入导致不平衡的旋转,不适用于删除情况
def rotate_left(self, p, c):
# 参照示例图写出下面的代码,进行旋转(节点交换)
s2 = c.lchild
p.rchild = s2
# 判断s2是否是空的,不是空的再连接到父类,如果为None就不用连了
if s2:
s2.parent = p
c.lchild = p
p.parent = c
# 更新bc,只适用于插入,不适用删除
p.bf = 0
c.bf = 0
# 返回最后的根节点
return c
# 针对插入导致不平衡的旋转,不适用于删除情况
def rotate_right(self, p, c):
s2 = c.rchild
p.lchild = s2
# 判断s2是否是空的
if s2:
s2.parent = p
c.rchild = p
p.parent = c
# 更新bc
p.bf = 0
c.bf = 0
# 返回最后的根节点
return c
def rotate_right_lef(self, p, c):
# 通过给的示例图可以看到只有s2,s3的归属发生的变化
g = c.lchild
# 先右旋
s3 = g.rchild
c.lchild = s3
if s3:
s3.parent = c
g.rchild = c
# 这里不需要判断c是否为None,c不可能为None
c.parent = g
# 在左旋
s2 = g.lchild
p.rchild = s2
if s2:
s2.parent = g
g.lchild = p
p.parent = g
# 更新bf
if g.bf > 0:
p.bf = -1
c.bf = 0
elif g.bf < 0:
p.bf = 0
c.bf = 1
# s1~s4节点都没有,插入的是G节点
else:
p.bf = 0
c.bf = 0
# 返回最后的根节点
return g
def rotate_left_right(self, p, c):
# 通过给的示例图可以看到只有s2,s3的归属发生的变化
g = c.rchild
# 先右旋
s2 = g.lchild
c.rchild = s2
if s2:
s2.parent = c
g.lchild = c
# 这里不需要判断c是否为None,c不可能为None
c.parent = g
# 在左旋
s3 = g.rchild
p.lchild = s3
if s3:
s3.parent = g
g.rchild = p
p.parent = g
# 更新bf
if g.bf > 0:
p.bf = 0
c.bf = -1
elif g.bf < 0:
p.bf = 1
c.bf = 0
# s1~s4节点都没有,插入的是G节点
else:
p.bf = 0
c.bf = 0
# 返回最后的根节点
return g
# 插入
def insert_no_rec(self, val):
# 1.插入:和二叉搜索树插入差不多
p = self.root
# 空树,特殊处理
if not p:
self.root = BiTreeNode(val)
return # 空树
while True:
if val < p.data:
if p.lchild:
p = p.lchild # 左孩子不存在
else:
p.lchild = BiTreeNode(val)
p.lchild.parent = p
# node存储插入的节点
node = p.lchild
break
elif val > p.data:
if p.rchild:
p = p.rchild
else:
p.rchild = BiTreeNode(val)
p.rchild.parent = p
node = p.rchild
break
# 等于的时候什么也不做,不让节点有重复
else:
return
# 更新平衡因子,从node的父节点开始,要保证父节点不为空
while node.parent:
# 1. 传递是从左子树来的,左子树更沉
if node.parent.lchild == node:
# 更新node.parent的bf -= 1
if node.parent.bf < 0: # 原来的node.parent =-1 ,更新之后变为-2
# 继续看node的哪边沉
g = node.parent.parent # 保存节点的父节点的父节点,用于更新node,重新连接,目的就是为了连接旋转之后的子树
# 旋转前的子树的根
x = node.parent
if node.bf > 0:
n = self.rotate_left_right(node.parent, node)
else:
n = self.rotate_right(node.parent)
# 记得把n和g连接起来
elif node.parent.bf > 0: # 原来node.parent.bf = 1,更新之后变为0
# 变为0之后不需要再旋转了
node.parent.bf = 0
break
else:
node.parent.bf = -1
node = node.parent
continue
pass
# 2. 传递是从右子树来的,右子树更沉
else:
# 更新node.parent的bf -= 1
if node.parent.bf > 0: # 原来的node.parent =1 ,更新之后变为2
# 继续看node的哪边沉
g = node.parent.parent
# 旋转前的子树的根
x = node.parent
if node.bf < 0:
n = self.rotate_right_lef(node.parent, node)
else:
n = self.rotate_left(node.parent, node)
# 记得连起来
elif node.parent.bf < 0: # 原来的node.parent =-1 ,更新之后变为0
node.parent = 0
break
else: # 原来的node.parent =0 ,更新之后变为1
node.parent.bf = 1
node = node.ap
continue
# 连接旋转后的子树
n.parent = g
# g不是空
if g:
if x == g.lchild:
g.lchild = n
else:
g.rchild = n
break
pass
else:
self.root = n
break
tree = AVLTree([9, 8, 7, 6, 5, 4, 3, 2, 1])
tree.pre_order(tree.root)
tree.in_order()
AVL树扩展的B-Tree:一个节点连个数据,分为三路,小中大,如下图所示:
三 . 算法高阶举例
上一篇博文我们记录了数据结构与算法的基础知识,这篇博文我们再来看下算法的进阶。
3.1. 贪心算法
贪心算法 (又称贪婪算法) 是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。
贪心算法并不保证会得到最优解,但是在某些问题上贪心算法的解就是最优解。要会判断一个问题能否用贪心算法来计算。
看一个经典的问题:
假设商店老板需要找零n元钱,钱币的面额有: 100元、50元20元、5元、1元,如何找零使得所需钱币的数量最少?
t = [100, 50, 20, 5, 1]
def change(t, n):
m = [0 for _ in range(len(t))]
for i, money in enumerate(t):
m[i] = n // money
n = n % money
return m, n
print(change(t, 376))
输出:
([3, 1, 1, 1, 1], 0)
再看一个背包问题:
一个小偷在某个商店发现有n个商品,第i个商品价值v元,重w;千克。他希望拿走的价值尽量高,但他的背包最多只能容纳W千克的东西。他应该拿走哪些商品?
分为两种情况:
- 0-1背包:对于一个商品,小偷要么把它完整拿走,要么留下。不能只拿走部分,或把一个商品拿走多次。 (商品为金条)
- 分数背包:(商品为金砂)分数背包:对于一个商品,小偷可以拿走其中任意一部分。(算单位重量)
我们先看下分数背包怎么写(0-1背包后面在看):
def fractional_backpack(goods, w):
m = [0 for _ in range(len(goods))]
total_value = 0
for i, (price, weight) in enumerate(goods):
# 判断能不能一次拿完
if w >= weight:
m[i] = 1
w -= weight
total_value += price
# 只取一部分
else:
m[i] = w / weight
W = 0
total_value += m[i] * price
return total_value, m
print(fractional_backpack(goods, 50))
(240.0, [1, 1, 0.6666666666666666])
在看一个拼接最大数字问题:
有n个非负整数,将其按照字符串拼接的方式拼接为一个整数。如何拼接可以使得得到的整数最大?
例: 32,94,128,1286,6,71可以拼接除的最大整数为94716321286128
思路:首位比较,首位一样的时候比较末尾大小(前后拼接成字符串比较大小)。
from functools import cmp_to_key
li = [32, 94, 128, 1286, 6, 71]
def xy_cmp(x, y):
if x + y < y + x:
return 1
elif x + y > y + x:
return -1
else:
return 0
def number_join(li):
li = list(map(str, li))
li.sort(key=cmp_to_key(xy_cmp))
return "".join(li)
print(number_join(li))
输出:
94716321286128
在看一个经典问题:活动选择问题
- 假设有n个活动,这些活动要占用同一片场地,而场地在某时刻只能供一个活动使用。
- 每个活动都有一个开始时间和结束时间(题目中时间以整数表示),表示活动在[,)区间占用场地。
- 问:安排哪些活动能够使该场地举办的活动的个数最多?
思路:
- 贪心结论: 最先结束的活动一定是最优解的一部分。
- 证明: 假设a是所有活动中最先结束的活动,b是最优解中最先结束的活动。
- 如果a=b,结论成立。
- 如果,则b的结束时间一定晚于a的结束时间,则此时用a替换掉最优解中的b,a一定不与最优解中的其他活动时间重叠,因此替换后的解也是最优解
activities = [(1, 4), (3, 5), (0, 6), (5, 8), (3, 9), (6, 10), (8, 11), (8, 12), (2, 14), (12, 16)]
activities.sort(key=lambda x: x[1])
def activity_selection(a):
# 排序好的第一个肯定是最优的
res = [a[0]]
for i in range(1, len(a)):
if a[i][0] >= res[-1][1]:
res.append(a[i])
return res
print(activity_selection(activities))
输出:
[(1, 4), (5, 8), (8, 11), (12, 16)]
总结:贪心算法实际上就是一个最优化问题,也会有局限性,引出后面的动态规划算法。
3.2. 动态规划
动态规划(Dynamic Programming)是一种解决复杂问题的算法设计技术。它主要用于优化问题,即在给定的约束条件下,寻找最优解(最大值或最小值)。动态规划的核心思想是将复杂问题拆解为一系列子问题,并利用子问题的解来构建原问题的解。
直接看题,先来看一个我们非常常见的斐波那契数:F=Fn-1+Fn-2。
使用递归和非递归的方法来求解斐波那契数列的第n项
"""
递归函数
子问题重复计算,当计算的项数非常大的时候会非常慢,时间换空间
"""
import time
def fibancci(n):
if n == 1 or n == 2:
return 1
else:
return fibancci(n - 2) + fibancci(n - 1)
start = time.time()
print(fibancci(30))
end = time.time()
print(end - start)
'''
非递归函数,空间换时间
'''
def fibancci_no_recurision(n):
f = [0, 1, 1]
for i in range(n - 2):
num = f[-1] + f[-2]
f.append(num)
return f[n]
start = time.time()
print(fibancci_no_recurision(30))
end = time.time()
print(end - start)
输出:
832040
0.16655564308166504
832040
0.0
可以看到使用非递归时间接近0s,递归时间远大于非递归时间。非递归的求解方法就可以体现出动态规划思想(DP)。
动态规划思想 = 最优子结构(递推式) + 重复字问题(把需要的子问题存起来)
我们再来看一个钢条切割问题:
某公司出售钢条,出售价格与钢条长度之间的关系如下表
问题:现有一段长度为n的钢条和上面的价格表,求切割钢条方案,使得总收益最大。
看一下最优解切法:
从上面的表格可以看出,后m面长度的最优解都可以通过前面的最优解加出来,有点类似斐波那契数列。
思路:
设长度为n的钢条切割后最优收益值为,可以得出递推式第一个参数表示不切割,其他n-1个参数分别表示另外n-1种不同切割方案,对方案i=1,2,…,n-1将钢条切割为长度为i和n-i两段方案i的收益为切割两段的最优收益之和,考察所有的i,选择其中收益最大的方案。
核心:小的最优解能够构建大的最优解的时候就可以用动态规划思想。
上面的思路也可以换一种,一边切,一边不切了:
从钢条的左边切割下长度为i的一段,只对右边剩下的一段继续进行切割,左边的不再切割,递推式简化为
不做切割的方案就可以描述为: 左边一段长度为n,收益为p_n,剩余一段长度为0,收益为r_o=0。
def cut_rod_recurision_1(p, n):
if n == 0:
return 0
else:
res = p[n]
for i in range(1, n):
res = max(res, cut_rod_recurision_1(p, i) + cut_rod_recurision_1(p, n - i))
return res
def cut_rod_recurision_2(p, n):
if n == 0:
return 0
else:
res = 0
for i in range(1, n + 1):
res = max(res, p[i] + cut_rod_recurision_2(p, n - i))
return res
start = time.time()
print(cut_rod_recurision_1(p, 9))
end = time.time()
print(end - start)
start = time.time()
print(cut_rod_recurision_2(p, 9))
end = time.time()
print(end - start)
输出:
25
0.0019943714141845703
25
0.0
上面两种写法都是递归调用,但是第二种明显比第一种快很对,但是也不好,都是时间换空间,非常慢。引出下面的动态规划,空间换时间,把中间的计算结果进行保存。
def cut_rod_dp(p, n):
r = [0]
# 循环n次
for i in range(1, n + 1):
res = 0
for j in range(1, i + 1):
res = max(res, p[j] + r[i - j])
r.append(res)
return r[n]
使用动态规划可以极大地降低时间复杂度。自底向上看,中间结果保存。
上面得到的是得到的最大成本,那么应该怎么切才能的到最大成本呢?
def cut_rod_extend(p, n):
r = [0]
s = [0]
for i in range(1, n + 1):
res_r = 0
res_s = 0
for j in range(1, i + 1):
if p[j] + r[i - j] > res_r:
res_r = p[j] + r[i - j]
res_s = j
r.append(res_r)
s.append(res_s)
return r[n], s
def cut_rod_solution(p, n):
r, s = cut_rod_extend(p, n)
ans = []
while n > 0:
ans.append(s[n])
n -= s[n]
return ans
print(cut_rod_solution(p, 9))
输出:
[3, 6]
在看一个最长公共子序列的问题:
一个序列的子序列是在该序列中删去若干元素后得到的序列。例:“ABCD”和“BDF”都是“ABCDEFG”的子序列
最长公共子序列(LCS) 问题: 给定两个序列X和Y,求X和Y长度最大的公共子序列。
例: X="ABBCBDE”Y="DBBCDB”LCS(XY)="BBCD
应用场景: 字符串相似度比对,基因序列相比
def lcs_length(x, y):
m = len(x)
n = len(y)
c = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if x[i - 1] == y[j - 1]:
c[i][j] = c[i - 1][j - 1] + 1
else:
c[i][j] = max(c[i - 1][j], c[i][j - 1])
for _ in c:
print(_)
return c[m][n]
print(lcs_length("ABCBDAB", "BDCABA"))
输出:
[0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1]
[0, 1, 1, 1, 1, 2, 2]
[0, 1, 1, 2, 2, 2, 2]
[0, 1, 1, 2, 2, 3, 3]
[0, 1, 2, 2, 2, 3, 3]
[0, 1, 2, 2, 3, 3, 4]
[0, 1, 2, 2, 3, 4, 4]
4
上面给出了切割长度,怎么切割呢?也就是上图中的画圈的字母,对应着斜向箭头。
def lcs(x, y):
m = len(x)
n = len(y)
c = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
b = [["-" for _ in range(n + 1)] for _ in range(m + 1)] # ↖表示左上方,↑表示上方,←表示左方
for i in range(1, m + 1):
for j in range(1, n + 1):
if x[i - 1] == y[j - 1]: # i,j位置上的字符匹配的时候,来自于左上方
c[i][j] = c[i - 1][j - 1] + 1
b[i][j] = "↖" # 将箭头方向改为"↖"
elif c[i - 1][j] >= c[i][j - 1]: # 将大于号改为大于等于号
c[i][j] = c[i - 1][j]
b[i][j] = "↑"
else:
c[i][j] = c[i][j - 1]
b[i][j] = "←" # 将箭头方向改为"←"
return c, b
def lcs_trackback(x, y):
c, b = lcs(x, y)
i = len(x)
j = len(y)
res = []
while i > 0 and j > 0:
if b[i][j] == "↖":
res.append(x[i - 1])
i -= 1
j -= 1
elif b[i][j] == "↑":
i -= 1
else:
j -= 1
return "".join(reversed(res))
c, b = lcs("ABCBDAB", "BDCABA")
for _ in b:
print(_)
res = lcs_trackback("ABCBDAB", "BDCABA")
print(res)
输出:
['-', '-', '-', '-', '-', '-', '-']
['-', '↑', '↑', '↑', '↖', '←', '↖']
['-', '↖', '←', '←', '↑', '↖', '←']
['-', '↑', '↑', '↖', '←', '↑', '↑']
['-', '↖', '↑', '↑', '↑', '↖', '←']
['-', '↑', '↖', '↑', '↑', '↑', '↑']
['-', '↑', '↑', '↑', '↖', '↑', '↖']
['-', '↖', '↑', '↑', '↑', '↖', '↑']
BCBA
在看一个欧几里得算法,求最大公约数:
欧几里得算法: gcd(a, b)= gcd(b,a mod b) 例: gcd(60,21)= gcd(21, 18) =
gd(18,3)= gcd(3,0) = 3
# 递归
def gcd1(a, b):
if b == 0:
return a
else:
return gcd1(b, a % b)
# 非递归
def gcd2(a, b):
while b > 0:
r = a % b
a = b
b = r
return a
print(gcd1(12, 4))
print(gcd2(12, 4))
输出:
4
4