一、算法分析
1、大O表示法
1.1 算法时间度量指标
将一个算法实施的操作步骤数作为独立于具体程序/机器的度量指标。
在算法分析中一般将赋值语句作为算法步骤的度量。因为一条赋值语句中包含了计算(表达式)和存储(变量)两个程序设计中的基本内容。
1.2 数量级函数
根据赋值语句计算得到基本操作数量函数
T
(
n
)
T(n)
T(n),用数量级函数
O
(
f
(
n
)
)
O(f(n))
O(f(n))描述
T
(
n
)
T(n)
T(n)中随问题规模
n
n
n增加而变化速度最快的主导部分。
一般当问题规模较小时难以确定数量级之间的差异。
2、python数据类型的影响
2.1 list和dict操作对比
- 索引:list通过自然数进行索引
list[i]
,dict通过键名进行索引dict[key]
- 添加:list通过
append(), extend(), insert()
方法进行添加,dict通过语句ditc[key] = value
直接添加 - 删除:list通过
pop(), remove()
方法进行删除,dict通过del
语句进行删除:del dic[key]
- 更新:list:
a[i] = v
,dict:b[k] = v
2.2 List类型常用操作性能
- 按索引取值和赋值(
v = a[i], a[i] = v
),由于列表的随机访问特性,这两个操作执行时间与列表大小无关 - 列表增长:
list.append()
方法,执行时间 O ( 1 ) O(1) O(1);加法操作符:lst = lst + [v]
,执行时间 O ( n + k ) O(n+k) O(n+k)
通过编写代码来实际比较一下运行时间:
## 加法运算符添加列表
def test1():
l = []
for i in range(1000):
l = l + [i]
## append方法添加列表
def test2():
l = []
for i in range(1000):
l.append(i)
## 使用列表解析的方法生成数值型列表
## 将for循环和创建新元素的代码合并成一行
## 第一个式子是表达式,可以换成其他形式:l = [i ** 2 in range(100)]
def test3():
l = [i for i in range(1000)]
## 使用函数list()将range()的结果直接转换为列表
## 实际上使用range()函数时还可以指定步长:range(2,11,2)
def test4():
l = list(range(1000))
from timeit import Timer
## Timer(stmt = '', setup = '', time.time = <time function>)
## 第一个参数表示要测试的代码语句,第二个参数表示执行代码的准备工作
## Timer.timeit(number = )返回执行代码的平均耗时,类型为float
t1 = Timer("test1()", "from __main__ import test1")
## print语句的格式化输出,与C语言相似
## %字符表示转换说明符的开始
## 如果有多个转换说明符,后面用括号隔开
print("concat %f seconds\n" %t1.timeit(number = 1000))
t2 = Timer("test2()", "from __main__ import test2")
print("append %f senconds\n" %t2.timeit(number = 1000))
t3 = Timer("test3()", "from __main__ import test3")
print("comprehension %f senconds\n" %t3.timeit(number = 1000))
t4 = Timer("test4()", "from __main__ import test4")
print("list range %f senconds\n" %t4.timeit(number = 1000))
通过比较可以发现方法4最快,方法3(解析法)次之,加法运算符最慢
下图表示了List基本操作的数量级:
我们注意到在这里pop(i)
的复杂度为
O
(
n
)
O(n)
O(n)这是因为Python在中部移除元素后,需要把后面的元素全部向前挪一位,这种实现方法虽然降低了pop(i)
操作的速度,但是提高了列表按索引取值和赋值的操作速度。
用代码来观察一下pop()
和pop(0)
实现速度上的差别:
import timeit
popzero = timeit.Timer("x.pop(0)", "from __main__ import x")
popend = timeit.Timer("x.pop()", "from __main__ import x")
print("pop(0) pop()")
for i in range(1000000, 100000001, 1000000):
x = list(range(i))
pt = popend.timeit(number = 1000)
x = list(range(i))
pz = popzero.timeit(number = 1000)
print("%15.5f, %15.5f" %(pz,pt))
2.3 列表和字典操作性能
列表和字典中都有操作符in
,来比较一下哪个查找得更快:
import timeit
import random
num = 10000
t = timeit.Timer("random.randrange(%d) in x"%num,
"from __main__ import random,x")
x = list(range(num))
lst_time = t.timeit(number = 1000)
x = {j: None for j in range(num)}
d_time = t.timeit(number = 1000)
print("%d,%10.3f, %10.3f" %(num, lst_time, d_time))
2.4 习题
习题1:编程程序,验证List的按索引取值是 O ( 1 ) O(1) O(1)的
import timeit
import random
for i in range(100, 1000, 10):
t = timeit.Timer("x[random.randrange(%d)]"%i, "from __main__ import random,x")
x = list(range(i))
print("time is %f" %t.timeit(number = 1000))
习题2:编写程序,验证dict的get item和set item操作都是 O ( 1 ) O(1) O(1)的
import timeit
import random
for i in range(100, 1000, 10):
t1 = timeit.Timer("x[random.randrange(%d)]"%i,
"from __main__ import random,x")
x = {j : None for j in range(i)}
t2 = timeit.Timer("x[random.randrange(%d)] = 0" %i,
"from __main__ import random,x")
print("get item's time is %f, set item's time is %f"
%(t1.timeit(number = 1000), t2.timeit(number = 1000)))
习题3:编写程序,比较list和dict的del操作符性能
import timeit
import random
for i in range(100, 1000, 10):
t1 = timeit.Timer("del x[10]", "from __main__ import x")
x = list(range(i))
lst_time = t1.timeit(number = 1000)
t2 = timeit.Timer("del x[10]",
"from __main__ import x")
x = {j : None for j in range(i)}
dict_time = t2.timeit(number = 1000)
print("list's del time is %f, dic's del time is %f"
%(lst_time, dict_time))
习题4:编写程序,验证list.sort()的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn):
import timeit
import random
for i in range(1000, 10000, 100):
t = timeit.Timer("x.sort()", "from __main__ import x")
x = [random.randrange(10**6) for n in range(5*i)]
print("time is %f" %t.timeit(number = 1000))
OJ适应性测试:
习题5:给定若干个整数,找出这些整数中最小的,输出。
str_in = input("输入多个数字,用空格分隔")
num = [int(n) for n in str_in.split()] ##注意这种创建列表的方法
m = num[0]
for i in range(1,len(num)):
if num[i] > m:
m = num[i]
print(m)
二、基本结构
2.1 线性结构
线性结构时一种有序数据项的集合,其中每个数据项都有唯一的前驱和后驱
- 只有第一项没有前驱,最后一项没有后驱
- 新的数据项加入到数据集中时,只会加入到原有的某个数据项之前或之后
不同线性结构的关键区别在于数据项的增减方式。
根据这个特点可以将线性结构分成栈(stack)、队列(queue)、双端队列(deque)和列表(list)
2.2 栈(stack)
2.2.1 栈的定义
栈是一种线性结构,栈中数据项的加入和移除都只发生在线性结构的同一端。
栈的一个特点为后进先出,即距离栈底越近的数据项,留在栈中的时间越长。
2.2.2 用Python实现栈
一般来说栈应该有如下操作:
用列表数据类型来实现栈:
## 用Python实现ADT Stack
class stack():
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def push(self, item):
self.items.append(item)
def pop(self, item):
return self.items.pop()
def peek(self):
return self.items[-1]
def size(self):
return len(self.items)
注意:一般选用列表的最后一个位置作为栈顶,这样push/pop方法的复杂度较低,均为 O ( 1 ) O(1) O(1)
2.2.3 栈的应用:简单括号匹配
检验思路:对于所有的文本,检验是否为左括号,如果是的话压入栈,然后对于遇到的第一个右括号,将它与栈顶的左括号匹配,并且取出。
def strCheck(symbolString):
s = stack()
check = True
index = 0
while index < len(symbolString) and check:
symbol = symbolString[index]
if symbol == '(':
s.push(symbol)
else if symbol == ")":
if s.isEmpty():
check = False
else:
s.pop() ## 如果栈里有左括号,就“匹配”掉,即pop掉栈顶的元素
index += 1
if check and s.isEmpty(): ## 检验匹配完所有的右括号后,栈里是否还有剩余的左括号
return True
else:
return False
当然通常情况下,可能不止要匹配左右括号,还要匹配左右方括号或者大括号等,我们把上述代码做一个改进:
def matches(open, close):
opens = "([{"
closes = ")]}"
return opens.index(open) == closes.index(close)
def strCheck(symbolString):
s = stack()
check = True
index = 0
while index < len(symbolString) and check:
symbol = symbolString[index]
if symbol in '([{':
s.push(symbol)
elif symbol in ")]}":
if s.isEmpty():
check = False
else:
top = s.pop()
if not matches(top, symbol):
check = False
index += 1
if check and s.isEmpty():
return True
else:
return False
print(strCheck("print(i dont like it)"))
2.2.4 十进制转换
在将十进制进行转换时,一般是用取余法,例如转换为二进制时:
这里会发现:先得到的余数最后输出,很自然地,我们用栈来存储取余运算的结果。
def divideBy2(decNumber, base):
remstack = Stack()
digits = "0123456789ABCDEF"
while decNumber > 0:
rem = decNumber % base
remstack.push(rem)
decNumber = decNumber // base
newString = ""
while not remstack.isEmpty():
binString = binString + str(digits[remstack.pop()])
return newString
2.2.4 表达式转换
通常所用的表达式表达方法都为中缀表示法,但是这种方法存在混淆的可能,常用的解决方法为(1)引入操作符优先级的概念;(2)引入括号表示强制优先级。
但在计算机中最好能够明确所有的计算顺序,也就是避免引入操作符优先级的概念,由此引入全括号表达式:在所有表达式两边都加上括号。
全括号表达式以外,再通过移动符号的位置得到前缀表达式和后缀表达式。
前缀表达式:操作符 + 第一运算数 + 第二运算数。如
A
+
B
∗
C
A + B * C
A+B∗C写成
+
A
∗
B
C
+A*BC
+A∗BC,
(
A
+
B
)
∗
C
(A+B)*C
(A+B)∗C写成
+
A
B
C
+ABC
+ABC
后缀表达式:第一运算数 + 第二运算数 + 操作符
前缀表达式和后缀表达式中不再需要括号来明确优先级,操作符的次序完全决定了运算的内容,即离操作数越近的操作符越先做。
实际上表达式在计算机内部的表示方法就是前缀表示或后缀表示法。
现在需要解决如何用成套的算法来描述该过程:
- 将中缀表达式转换为全括号形式
- 将所有的操作符移到子表达式所在的左括号(前缀)或右括号(后缀)处,并将其替代,然后再删除所有的括号
2.2.5 习题
习题1:给定一个只包括(){}[]和空格的字符串,判断该字符串是否有效
## 用Python实现ADT Stack
class Stack():
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def push(self, item):
self.items.append(item)
def pop(self):
return self.items.pop()
def peek(self):
return self.items[-1]
def size(self):
return len(self.items)
def matches(open, close):
opens = "([{"
closes = ")]}"
return opens.index(open) == closes.index(close)
def check(newstring):
s = Stack()
pos = 0
check = True
while pos < len(newstring) and check:
symbol = newstring[pos]
if symbol in "([{":
s.push(symbol)
else:
if s.isEmpty():
check = False
else:
top = s.pop()
if not matches(top,symbol):
check = False
##print(check)
pos += 1
if check and s.isEmpty():
return True
else:
return False
print(check("()"))
### 本题本来用了newstring.split()方法,但是犯了个错误:
### split只会按照空格进行分割,不会将每个字符作为列表
习题2: 一维开心消消乐:输入一串字符,逐个消去相邻的相同字符对,如果字符全部被消完,则输出不带引号的"None"
def xiaoxiaole(string):
s = Stack()
pos = 0
for i in string:
if s.isEmpty():
s.push(i)
else:
if s.peek() == i:
s.pop()
else:
s.push(i)
xxlist = []
while not s.isEmpty():
xxlist.append(s.pop())
## [::-1]的含义是从取从后向前的元素, [-1]是取最后一个元素, [:-1]是取切片
return "".join(xxlist[::-1])
print(xiaoxiaole("aabbc"))
习题3:
def xipanzi(num):
s = Stack()
i = 0
j = 0
while i <= 10 and j <= 9:
if not s.isEmpty() and int(num[j]) == s.peek():
s.pop()
j += 1
else:
s.push(i)
i += 1
if s.isEmpty():
print("Yes")
else:
print("No")
## 本题解题思想有些奇怪,首先需要假定顾客都是按照正确的洗碗顺序得出的碗来取的
## 比如num[0] = 3,说明第一个顾客取得的碗是3,那么在此之前已经洗了4个碗
## 当取得的碗编号与当前编号不符时,说明洗碗工还没洗好碗,需要继续洗碗
2.3 队列
队列是指新数据项的添加总发生在一端(称为尾端rear),而现存数据项的移除总发生在另一端(称为首端front)。
相比于栈后进先出的特点,队列往往是先进先出。
2.3.1 队列的操作
用Python实现的代码如下:
class Queue():
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def enqueue(self, item):
self.items.insert(0, item)
def dequeue(self):
return self.items.pop()
def size(self):
return len(self.items)
注意在队列中,添加一项的复杂度为 O ( n ) O(n) O(n),弹出一项的复杂度为 O ( 1 ) O(1) O(1),而如果将队首和队尾反过来,则复杂度也会相反。这个复杂度与栈是不同的,对于栈来说,如果选择列表的尾部作为栈顶,则添加和删除项的复杂度都为 O ( 1 ) O(1) O(1)
2.3.2 队列的应用1:击鼓传花问题
利用队列来判断击鼓传花问题中,队列经过若干次传递后,是否会只剩下一个人:
- 首先用一个队列来存放所有参加游戏的人
- 用“队首”的人出队,并且到“队尾”入队表示一次传递
- 当传递了num次后,将此时位于队首的人移除,不再入队,表示他出队
- 如此反复进行,直到队列中只剩下一个人为止
class Queue():
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def enqueue(self, item):
self.items.insert(0, item)
def dequeue(self):
return self.items.pop()
def size(self):
return len(self.items)
def hotPotato(namelist, num):
simqueue = Queue()
for name in namelist:
simqueue.enqueue(name)
while simqueue.size() > 1:
for i in range(num):
simqueue.enqueue(simqueue.dequeue())
simqueue.dequeue()
return simqueue.dequeue()
print(hotPotato(["bill","david","susan","jane","brad"],7))
2.4 双端队列
2.4.1 双端队列抽象数据类型的实现
class Deque():
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def addFront(self, item):
self.items.append(item)
def addRear(self, item):
self.items.insert(0, item)
def removeFront(self):
self.items.pop()
def removeRear(self):
self.items.pop(0)
def size(self):
return len(self.items)
2.4.2 双端队列的应用(1):回文词判定
回文词判定的思路:把待判定的字符加入到双端队列中,然后从队首和队尾同时移除字符,判定字符是否相等即可。
def palchecker(aString):
chardeque = Deque()
for ch in aString:
chardeque.addRear(ch)
check = True
while chardeque.size() > 1 and check:
first = chardeque.removeFront()
last = chardeque.removeRear()
if first != last:
check = False
return check
2.5 无序表结构
以上的栈、队列、双端队列三种线性基本结构都是我们用Python内置的list数据类型实现的,这种列表数据类型提供了非常多的操作接口如append()``pop()
等,但是并不是所有编程语言都会提供list数据类型,因此我们还需要考虑如何实现这种数据类型。
2.5.1 列表的含义及其操作
一般来说我们把数据项按照相对位置存放的数据集称作列表,有时候还特别地将其称为无序表。在这种数据结构中数据项只按照存放的位置来索引。
对于无序表,我们应当有如下操作:
2.5.2 用链表实现无序表
列表是以数据的相对位置定义的数据集,但是并不要求数据项在存储空间中也是连续的。因此一般只需要在数据项之间建立连接指向即可。
对于链表来说,实现的基本单元是节点,每个节点需要包含数据项本身,以及指向下一个节点的引用信息。
当引用信息为None时表明没有下一个节点,链表结束。
由于不需要按照顺序存储数据,所以链表在插入新数据时的时间复杂度为
O
(
1
)
O(1)
O(1),但是查找节点或访问特点编号的节点需要
O
(
n
)
O(n)
O(n)(因为链表必须要遍历),而顺序表的复杂度则分别为
O
(
l
o
g
n
)
O(logn)
O(logn)和
O
(
1
)
O(1)
O(1)。
接下来首先定义单链表节点类:
class Node():
def __init__(self, data, next = None):
self.data = data
self.next = next
def getData(self):
return self.data
def getNext(self):
return self.next
然后实例化一下看看:
node1 = None ## node1是没有指向的节点对象
node2 = Node(1) ## node2的数据项为1,不指向下一个节点
node3 = Node('hello', node2) ## node3的数据项为2,指向节点node2
print(node1, node2.data, node2, node3.next, node3.next == node2)
从这4个输出结果可以看出一些东西:
node1的输出是None,这是无可争议的
node2.data的输出是1,因为node2存储的数据是1
node2的输出是它的地址
node3.next的输出时它指向的下一个节点,所以node3.next == node2的结果为True
当需要遍历整个列表的时候,首先需要找到第一个节点,然后依照该节点沿着链接遍历。因此还需要有对于第一个节点的引用:
class unorderedList():
def __init__(self):
self.head = None
self.length = 0
def demo(self):
##创建链表示例
for cnt in range(1, 10, 2):
self.head = Node(cnt, self.head)
self.length += 1
return self.head
在创建链表时,需要保证链表的首项始终指向第一个节点,也就是说每有一个新的数据项,都是加在链表的表头。而无序列表本身并不包含数据项,因为数据项包含在节点中。
2.5.3 无序表的操作实现
- 打印链表中的所有内容:
def printunorderedList(self):
temp = self.head
while temp != None:
print(temp.getData())
temp = temp.getNext()
- 搜索目标值:
def search(self, item):
temp = self.head
check = False
while temp != None and not check:
if temp.data == item:
check = True
else:
temp = temp.next
return check
- 搜索目标项:
def index(self, num):
temp = self.head
while num > 1 and temp != None:
temp = temp.next
num -= 1
return temp.data
add
方法,加在表头:
由于链表结构若要遍历,则需要从头开始,因此从性能角度考虑,新的数据项最容易加入的位置应当是表头:
def add(self, item):
temp = Node(item, self.head)
self.head = temp
size
方法:
取得链表size的方法通常是从表头遍历到表尾,且用变量累加经过的节点个数。
def size(self):
current = self.head
count = 0
while current != None:
count += 1
current = current.getNext()
return count
注意该方法的复杂度为 O ( n ) O(n) O(n)
- 删除末尾项
def removeEnd(self):
if self.head.next == None:
self.head = None
else:
temp = self.head
while temp.next.next != None:
temp = temp.next
temp.next = None
删除的时候需要注意,如果链表中只有一项的话,只需要把头设为None就可以了。同时还需要注意,下一个节点的引用信息是存储在当前节点中的。
- 删除任意位置的项
def removeAny(self, index):
if index <= 0 or self.head.next == None:
self.head = self.head.next
else:
temp = self.head
while index > 1 and temp.next.next != None:
temp = temp.next
index -= 1
temp.next = temp.next.next
- 删除指定项
def remove(self, item):
temp = self.head
previous = None
check = False
while not check:
if temp.data == item:
check = True
else:
previous = temp
temp = temp.next
if previous == None:
self.head = temp.next
else:
previous.next = temp.next
那么我们把所有功能整合一下,写出一个完整的链表类及其实现:
# 先定义节点类
class Node:
def __init__(self, data, next = None):
self.data = data
self.next = next
# 定义链表类
class LinkList:
#1 初始化链表
def __init__(self):
self.head = None
self.length = 0
#2 用数组初始化链表
def initlist(self, data):
for i in data:
self.head = Node(i, self.head)
self.length += 1
#3 输出链表内容
def printList(self):
temp = self.head
while temp != None:
print(temp.data)
temp = temp.next
#4 向链表中添加内容,
## 因为链表需要遍历,从性能角度考虑,新加入的数据项放在表头比较好
def add(self, data):
temp = Node(data, self.head)
self.head = temp
self.length += 1
#5 删除末尾项
def removeEnd(self):
if self.head.next == None:
self.head = None
else:
temp = self.head
while temp.next.next != None:
temp = temp.next
temp.next = None
#6 删除任意项
def removeAny(self, index):
if index <= 0 or self.head.next == None:
self.head = self.head.next
else:
temp = self.head
while index > 1 and temp.next.next != None:
temp = temp.next
index -= 1
temp.next = temp.next.next
三、递归
3.1递归的特性
递归作为解决问题的一种方法,其精髓在于将问题进行横向分解,划分为规模更小的相同问题,并且持续分解直到问题规模小到可以用非常简单的方法直接解决。
在算法方面,递归方法的重要特征就是在算法流程中调用自身。
例如,我们要求一个列表的和,用递归的形式来解决:
def listSum(nums):
if len(nums) == 1:
return nums[0]
else:
return nums[0] + listSum(nums[1:])
在该问题的解决中,我们把列表求和分解成了更小规模的相同问题:将列表中的第一个数与剩下的所有数求和,并且对于最小规模问题(即列表中只剩下一个数)的情况,直接返回该数自身。
递归算法三定律:
对于“调用自身”这一说法可能会较难理解,其实只需要将其理解为“问题分解成了规模更小的相同问题”就可以了。
3.1.1 递归调用的实现
现场数据:包括要返回的函数名称,以及调用函数时所包含的参数(局部变量)
调用栈:当一个函数被调用时,系统会把调用时的现场数据压入调用栈。此时将现场数据称为栈帧。每次函数返回时,可以用栈帧中的数据来恢复现场。
3.2 递归的应用
3.2.1 任意进制转换
我们之前说过十进制转换一般是用除法取余的方式,在那里是使用栈解决问题的:用栈储存每一次除法得到的余数,当被除数小于10时再将栈中的元素逐个弹出。
那么用递归解决该问题的方法为:
def toStr(n, base):
convertString = "0123456789ABCDEF"
if n < base:
return convertString[n]
else:
return toStr(n // base, base) + convertString[n % base]
3.2.2 分型图形绘制
先介绍一下turtle库。
画一个五角星:
import turtle
t = turtle.Turtle()
t.pencolor('red')
t.pensize(3)
t.hideturtle()
for i in range(5):
t.forward(100)
t.right(144)
turtle.done()
用递归的方法画一个螺旋线:
def drawSpiral(t, linelen):
if linelen > 0:
t.forward(linelen)
t.right(90)
drawSpiral(t, linelen - 5)
drawSpiral(t, 100)
现在利用递归来绘制分型图形。所谓分型图形是指每个局部与整体相似的图形。
import turtle
t = turtle.Turtle()
def tree(branch_len):
if branch_len > 5:
t.forward(branch_len)
t.right(20)
tree(branch_len - 15)
t.left(40)
tree(branch_len - 15)
t.right(20)
t.backward(branch_len)
t.left(90)
t.pencolor('green')
t.pensize(2)
tree(75)
turtle.done()
最后画出来这样的小树苗。
3.2.3 汉诺塔问题
汉诺塔的规则不再赘述,这里主要说如何将汉诺塔问题分解成递归形式:
假设只有两个盘子(将上面4个盘子视作1个盘子),先将上面盘子移到2#,然后将下面盘子移到3#,再把上面盘子移到3#就可以了。
那么现在需要考虑的是如何把上面4个盘子移动到2#,其实可以使用相同的办法,即相当于现在一共只有4个盘子,目标柱是2#,那么再将4个盘子分解成最下面1个盘子和上面3个盘子,先将上面3个盘子移到3#,下面盘子移到2#,再将上面3个盘子移到2#就可以。于是现在的问题又转化为如何移动上面3个盘子,可以发现此时问题的规模就减小了。
因此递归流程可以写成如下形式:
def hanoTower(height, fromPole, withPole, toPole):
if height >= 1:
hanoTower(height - 1, fromPole, toPole, withPole)
moveDisk(height, fromPole, toPole)
hanoTower(height - 1, withPole, fromPole, toPole)
def moveDisk(disk, fromPole, toPole):
print(f"moving disk[{disk}] from {fromPole} to {toPole}")
3.2.4 硬币找零问题
硬币找零问题中,我们的目标是找给顾客尽可能少的硬币。
那么根据递归三定律,首先确定基本结束条件:
需要对换的找零值恰好等于某个硬币的面值
然后确定减小问题规模的方法:
如果找零减去1分后,调用自身,即求兑换硬币最少数量 - 1
同理,如果找零减去25分后,调用自身,即求兑换硬币最少数量 - 25
def recMC(coinValueList, change):
minCoins = change
if change in coinValueList:
return 1
else:
for i in [c for c in coinValueList if c <= change]:
numCoins = 1 + recMC(coinValueList, change - i)
if numCoins < minCoins:
minCoins = numCoins
return minCoins
本解法中出现了生成列表的推导式方法:
L = [x ** 2 for x in range(10)]
该方法可以生成x平方的列表。在这个语句中我们还可以加上if语句进行筛选:
L = [x for x in range(10) if x % 2 == 0]
那么在这种情况下只会保留偶数项。此外还可以嵌套使用for循环:
L = [x + y for x in 'ab' for y in 'jk']
此时会按照从左到右,从外层到内层的顺序进行遍历,生成列表。
那么以上这个算法算是解决问题了,但是它仍然有一个问题:存在大量重复计算,这个问题我们一般把计算好的中间结果保存起来,在每次递归之前先检查之前是否已经计算过。
def recDC(coinValueList, change, knownResults):
minCoins = change
if change in coinValueList:
knownResults[change] = 1
return 1
elif knownResults[change] > 0:
return knownResults[change]
else:
for i in [c for c in coinValueList if c <= change]:
numCoins = 1 + recDC(coinValueList, change - i, knownResults)
if numCoins < minCoins:
minCoins = numCoins
knownResults[change] = minCoins
return minCoins
3.3 重新理解递归和动态规划
看到这里实际上感觉还不是很清楚。因此我补充看了较多的博客文章,主要有:
https://blog.csdn.net/m0_37907797/article/details/102767860
https://blog.csdn.net/u013309870/article/details/75193592
https://blog.csdn.net/baidu_28312631/article/details/47418773
结合之前所说的递归三定律,可以知道要写出一个递归程序,需要了解以下三点:
- 明确函数的目的
- 确定递归结束的条件
- 确定递归规模减小的函数等价关系式
比如我们要写一个阶乘的函数,那么首先定义一个函数,并且明确它的目的就是求阶乘的结果:
def f(n):
确定递归结束的条件,所谓结束的条件就是当参数为何值时,可以直接知道函数的结果:
def f(n):
if n == 1:
return 1
最后一步就是要确定能够使递归规模减小的函数等价式,比如这里我们知道f(n)表示n个数的阶乘结果,那么f(n-1)就是n-1个数的阶乘结果,于是可以写成 f ( n ) = n ∗ f ( n − 1 ) f(n) = n * f(n-1) f(n)=n∗f(n−1),从而可以写出完整的递归函数:
def f(n):
if n == 1:
return 1
else:
return n * f(n-1)
以上就是阶乘递归函数的写法,接下来换一个例子:小青蛙跳台阶。假设一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶,求问该青蛙跳上n级台阶共有多少种跳法?
按照程序来,首先定义一个函数,并且确定这个函数是计算跳上n级台阶的跳法的:
def f(n):
然后确定递归结束的条件:如果n = 1时,显然只有一种跳法,n=2时,有两种跳法(连跳两次1个台阶,或者跳一次2个台阶)
def f(n):
if n <= 2:
return n
最后确定使得函数规模减小的递推式:已知f(n)表示有n级台阶时的跳法,考虑小青蛙最开始的一跳,可能跳1阶,也可能跳2阶。如果跳1阶,那么剩下n-1个台阶,如果跳2阶,那么剩下n-2个台阶。所以可以知道 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n−1)+f(n−2)。于是可以完成我们的递归函数:
def f(n):
if n <= 2:
return n
else:
return f(n-1) + f(n-2)
在这个递归过程中,我们会遇到一个重复计算的问题,对于这种问题,常用的解决方法是将已有的计算结果存储起来,当需要再次计算时,检查是否已经计算过,如果计算过,直接取出结果就行。
def f(n, knownResults):
if n <= 2:
return n
else:
if knownResults[n] != n:
return knowResults[n]
else:
knownResults[n] = f(n-1) + f(n-2)
return knownResults[n]
以上介绍了递归方法和能够优化递归的备忘录方法,现在来介绍一下由递归演化而来的动态规划方法。
首先动态规划英文名Dynamic Programming,实际上与动态和规划的关系都不大,倒不如说是对递归的一种优化方法。programming也是指决策而非编程。
其次需要明确哪些问题能够使用动态规划解决:
- 问题的答案依赖于问题的规模,即所有问题的答案组成了一个数列
- 大规模问题的答案可以由小规模问题的答案递推得到,即可以写出一个状态转移方程。
一般来说DP方法比递归方法的效率更高。比如对于上述青蛙爬楼梯问题,用DP方法解决应当是:
def f1(n):
a = [0]*(n+1)
a[1] = 1
a[2] = 2
if n <= 2:
return n
else:
for i in range(3, n + 1):
a[i] = a[i-1] + a[i-2]
return a[n]
再举一些例子来说明,下面的例子是《算法》一书中的“钢条切割”问题:
首先用递归的方法解决这个问题:
递归方法解决问题的思路:确定问题的边界条件:当长度被切割到0的时候,收益为1。
然后倘若f(n)
能够返回当长度为n的时候的最大收益,那么f(n-i)
可以返回长度为n-i时的最大收益,于是我们可以把钢管切割成两段,第一段长度为i,第二段长度为n-i,然后只切割第二段,最后将所有的可能都进行比较:
def cut(n, price_dic):
max_price = price_dic[n]
if n == 0:
return 0
else:
for i in range(1, n + 1):
temp = price_dic[i] + cut(n-i, price_dic)
if temp > max_price:
max_price = temp
return max_price
当然这种方法的缺点就是:重复计算太多!用DP方法求解:
def cut_buttom(n, price_dic):
p = [0] * (n+1)
p[0] = 0
for i in range(1, n + 1):
for j in range(i+1):
p[i] = max(p[i], price_dic[j] + p[i-j])
return p[n]
其中用到了两个循环,最内层循环保留在长度为i时的最优收益。
一般来说适用于动态规划解决的问题都具有以下两个特点:
- 最优子结构
即一个问题的解结构包含了它子问题的最优解,则将该问题称为具有最优子结构性质 - 重叠子问题
如果使用递归算法时有很多重复计算的问题,反复求解相同的子问题而没有新的子问题生成,则该问题具有重叠子问题的性质。一般在DP中使用数组保存子问题的解。
四、排序与查找
4.1 排序查找算法
若可以按照数据的位置检索数据,则称这样的数据具有顺序关系,或称之为线性的。
如果能按照这种顺序访问和查找数据项,则称之为“顺序查找”。
- 无序列表顺序查找
即从列表的第一个数据项开始,按照下标增长的顺序逐个比对数据项。这种算法的复杂度为 O ( n ) O(n) O(n) - 有序列表顺序查找
有序列表的好处在于在比对过程中如果出现了比要查找的数据大的项,比对可以直接结束。但是这种算法的复杂度依然为 O ( n ) O(n) O(n)
4.1.1 二分查找
二分查找可能是我们最早接触的一种算法。它的思想很简单:先对列表进行排序,然后从中间开始查找,这样每次都可以把查找范围减小一半 ( n 2 ) (\frac n2) (2n)
def binarySearch(alist, item):
first = 0
last = len(alist) - 1
found = False
while first < last and not found:
midpoint = (first + last) // 2
if alist[midpoint] == item:
found = True
elif alist[midpoint] < item:
first = midpoint + 1
else:
last = midpoint - 1
return found
这里可以看出二分法是一种分治策略,而递归算法也是一种分治策略,所以二分法可以通过递归算法来实现:
def binarySearch(alist, item):
if len(alist) == 0:
return False
else:
midpoint = len(alist) // 2
if alist[midpoint] == item:
return True
else:
if alist[midpoint] < item:
return binarySearch(alist[midpoint + 1:], item)
else:
return binarySearch(alist[:midpoint], item)
二分法的复杂度分析:
由于每次比对之后都会减少
n
2
\frac n 2
2n的检索量,第一次剩余
n
2
\frac n 2
2n,第二次剩余
n
2
2
\frac{n}{2^2}
22n,第
i
i
i次后剩余
n
2
i
\frac{n}{2^i}
2in。无论这个数据是否存在于数列中,当剩余项个数为1时都会结束查找。此时有
n
2
i
=
1
\frac{n}{2^i} = 1
2in=1解得
i
=
l
o
g
2
(
n
)
i = log_2(n)
i=log2(n),因此二分法的复杂度为
O
(
l
o
g
n
)
O(\mathrm{log} n)
O(logn)。
但是需要注意,如果是用递归方法实现,列表切片会增加额外的复杂度;此外二分法的前提是需要先排序,这也会增加额外的复杂度,如果查找只需要用一次,那其实还是直接用顺序查找法比较方便。
4.1.2 冒泡排序和选择排序
冒泡排序也是比较基础的一个算法,每一遍循环的过程中将相邻两项进行比对,并且将逆序的数据项互换位置,从而能将该遍循环中的最大项放到最后。
def bubbleSort(alist):
for passnum in range(len(alist) - 1, 0, -1):
## range(start, end, step) step表示步长,不表示是否逆序
for i in range(passnum):
if alist[i] > alist[i+1]:
alist[i], alist[i+1] = alist[i+1], alist[i]
return alist
顺便再复习一下range()
函数和列表的操作:
对于一个列表,有以下取切片的方法:
array[1:] #列出第2个元素(包括第二个)后面的所有元素
array[:3] #列出从头到第4个元素(不包括第四个)前面的所有元素
array[:-1] #列出从头到倒数第一个数字(不包括最后一个)之前的所有元素
array[::2] #表示间隔取数,从第一个元素开始间隔两个元素取数
array[::-1] #表示按相反方向间隔取数,这里是按相反方向不筛选取数
array[::-2] #表示按相反方向间隔2取数
冒泡排序算法比对和交换的时间复杂度都为 O ( n 2 ) O(n^2) O(n2),效率较低,一般是作为其他排序算法的比较。它的一些优点包括不需要额外的存储空间,且可以在无序列表(如单链表)中进行。此外它可以通过监测每趟比对是否发生过交换,从而确定排序是否完成。在这个基础上可以对冒泡排序进行一些改进:
def bubbleSort_1(alist):
change = True
passum = len(alist) - 1
while passum > 0 and change:
change = False
for i in range(passum):
if alist[i] > alist[i+1]:
alist[i], alist[i+1] = alist[i+1], alist[i]
change = True
passum -= 1
return alist
选择排序算法是在冒泡排序算法基础上的改进,每一遍循环过程中,不再交换逆序项,而是记录下最大项的位置,然后在循环结束时把最大项挪到最后:
def selectionSort(alist):
for passum in range(len(alist)-1, 0, -1):
pos = 0
for i in range(passum+1):
if alist[i] > alist[pos]:
pos = i
alist[passum],alist[pos] = alist[pos],alist[passum]
return alist
4.1.3 插入排序算法
def insertSort(alist):
for i in range(1, len(alist)):
pos = i
temp = alist[pos]
while pos > 0 and temp < alist[pos - 1]:
alist[pos] = alist[pos - 1]
pos -= 1
alist[pos] = temp
return alist
4.1.4 谢尔排序算法
谢尔排序算法的基本思想在于:当列表越接近有序时,排序插入的比对次数就越少。
基于这个想法,谢尔排序通过对无序表划分子列表,每个子列表执行插入排序,使得列表尽可能达到有序结构。
def shellSort(alist):
sub = len(alist) // 2
while sub > 0:
for i in range(sub):
gapInsertSort(alist, i, sub)
sub = sub // 2
def gapInsertSort(alist,start, gap):
for i in range(start + gap, len(alist), gap):
pos = i
temp = alist[i]
while pos > 0 and temp < alist[pos - gap]:
alist[pos] = alist[pos - gap]
pos -= gap
alist[pos] = temp
谢尔排序算法的复杂度在 O ( n ) O(n) O(n)与 O ( n 2 ) O(n^2) O(n2)之间。
4.1.5 归并排序算法
归并排序算法的思路是将数据表分割成两半,直到分割成只有一个元素为止,此时就没有顺序可排,然后再逐个合并。
# 定义函数:用于排序
def mergeSort(alist):
# 递归边界条件:列表长度为1时
if len(alist) <= 1:
return alist
# 若列表长度不为1,则分成两半,对两边分别调用函数,进行排序,此时左右两边都已经排序完毕
mid = len(alist) // 2
left = mergeSort(alist[:mid])
right = mergeSort(alist[mid:])
# 对排序完毕的两部分进行合并
merged = []
while left and right:
if left[0] < right[0]:
merged.append(left.pop(0))
else:
merged.append(right.pop(0))
merged.extend(right if right else left)
return merged
复杂度分析:
分裂过程的时间复杂度与二分查找类似,都是
O
(
l
o
g
n
)
O(log n)
O(logn)
归并过程中分裂的每个部分数据项都会被比较一次,因此为线性复杂度,时间复杂度为
O
(
n
)
O(n)
O(n)
因此归并排序的时间复杂度为
O
(
n
l
o
g
n
)
O(n logn)
O(nlogn)
虽然归并排序的复杂度降低了,但它使用了额外1倍的存储空间用于归并。
4.1.6 快速排序算法
# 定义函数:快速排序
def quickSort(alist):
quickSortHelp(alist, 0, len(alist) - 1)
def quickSortHelp(alist, first, last):
if first < last:
## 分裂,依照随机选择的“中位数”分成两部分
splitpoint = partition(alist, first, last)
## 分裂之后,对两个部分分别调用函数进行排序
quickSortHelp(alist, first, splitpoint - 1)
quickSortHelp(alist, splitpoint + 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 +=1
while leftmark <= rightmark and alist[rightmark] >= pivotvalue:
rightmark -=1
if leftmark > rightmark:
done = True
else:
alist[leftmark], alist[rightmark] = alist[rightmark], alist[leftmark]
alist[first], alist[rightmark] = alist[rightmark], alist[first]
return rightmark
4.2 散列
4.2.1 散列的定义
若数据是按照顺序排列的话,可以按照二分法降低复杂度到
O
(
l
o
g
n
)
O(log n)
O(logn)
如果想要进一步降低查找复杂度到
O
(
1
)
O(1)
O(1),则需要构造一种新的数据结构:散列(哈希表)。
散列的特点是能够事先确定好数据集中每一项该放置的位置。一般来说把哈希表中的每个储存位置称为槽(slot),槽可以用于保存数据项,每个槽都有唯一的名称。
4.2.2 散列函数
散列函数能够实现从数据项到存储槽名称的转换。
一般来说最常见的散列函数是将数据项除以散列表的大小,将余数作为槽号:
def h(s,table):
num = s % len(table)
return num
这样如果要查找某个数据项是否存在于表中,只需要使用同样的散列函数,对待查找项计算它的槽号,再看槽号中是否有对应的数据项即可。此时时间复杂度为 O ( 1 ) O(1) O(1)。
当然这种散列函数也存在一些问题:可能会有不同的数字分配到相同的槽中,一般把这种情况称为“冲突”。如何解决冲突就是一个比较关键的问题。
4.2.3 完美散列函数
如果有一个散列函数能够把任意给定的一组数据映射到不同的槽中,就把这个散列函数叫做“完美散列函数”。一般完美散列函数需要具有以下特性:
- 近似完美,冲突较少
- 计算难度较低
- 充分分散数据项
完美散列函数的应用较多,由于它可以对任何不同的数据生成不同的散列值,因此可以广泛用于数据一致性校验上。
目前比较著名的近似完美散列函数是MD5和SHA系列函数:
五、树及算法
5.1 树
之前提到的栈、队列、链表,都是线性结构,而树则是一种非线性数据结构。
数据结构中的树分成根、枝、叶三个部分,图示中把根放在上方,叶放在下方。
树具有两个特点:
- 分类体系层次化:树作为一种分层结构,越靠近顶部的层越普遍,越靠近底部的层越独特
- 一个节点的子节点与另一个节点的子节点之间是相互独立的关系。
- 每个叶节点具有唯一性
5.1.1 树的相关术语
- 节点(组成树的基本部分):每个节点都具有名称,或称为键值。
- 子节点:入边均来自同一个节点的若干节点,称为该节点的子节点
- 父节点:一个节点是其所有出边所连接节点的父节点
- 兄弟节点:具有同一个父节点的节点之间称为兄弟节点
- 叶节点:没有子节点的节点称为叶节点
- 边(组成树的基本部分):每条边连接两个节点,表示节点之间的关联。每个节点只有一条来自其他节点的入边,但有多条连接到其他节点的出边。
- 根:树中唯一一个没有入边的节点
- 路径:由边依次连接在一起的节点的有序列表
- 子树:一个节点和其所有子孙节点,以及相关边的集合
- 层级:从根节点开始到达一个节点的路径,包含的边的数量
- 高度:树中所有节点的最大层级
因此得到树的定义:
- 定义1:树由若干节点,以及两两连接节点的边组成
- 定义2(递归定义):树是空集,或者由根节点及多个子树构成,每个子树的根到根节点具有边相连
5.1.2 树的嵌套列表实现法
我们用有三个元素的列表来实现一个二叉树:
[root, left, right]
其中第一个元素为根节点的值,第二个元素为左子树(一个列表),第三个元素为右子树(一个列表)
例如我们要创建下面这个树:
demoTree = ['a',
['b',
['d', [], []],
['e', [], []]]
['c',
['f',[],[]],[]]
]
现在考虑如何通过定义函数来实现树:
## 创建空的根节点
def BinaryTree(r):
return [r, [], []]
## 插入左侧节点
def insertLeft(root, newBranch):
t = root.pop(1)
if len(t) > 1:
root.insert(1, [newBranch, t, []])
else:
root.insert(1, [newBranch, [], []])
return root
## 插入右侧节点
def insertRight(root, newBranch):
t = root.pop(2)
if len(t) > 1:
root.insert(2, [newBranch, [], t])
else:
root.insert(2, [newBranch, [], []])
return root
## 查看根节点
def getRootVal(root):
return root[0]
## 重设根节点的值
def setRootVal(root, newVal):
root[0] = newVal
## 返回左子节点的值
def getLeftChild(root):
return root[1]
def getRightChild(root):
return root[2]
5.1.3 树的链表实现
我们之前试过用链表实现无序列表,同样也可以用链表实现树。这里我们定义一个BinaryTree类:
class BinaryTree:
def __init__(self, rootObj):
self.key = rootObj
self.leftChild = None
self.rightChild = None
以上是定义了根节点,之后我们需要定义插入左子节点和右子节点的接口:
def insertLeft(self, newNode):
if self.leftChild == None:
self.leftChild = BinaryTree(newNode)
else:
t = BinaryTree(newNode)
t.leftChild = self.leftChild
self.leftChild = t
def insertRight(self, newNode):
if self.rightChild == None:
self.rightChild = BinaryTree(newNode)
else:
t = BinaryTree(newNode)
t.rightChild = self.rightChild
self.rightChild = t
然后定义一下取得相应数据的方法:
def getRightChild(self):
return self.rightChild
def getLeftChild(self):
return self.leftChild
def setRootVal(self,obj):
self.key = obj
def getRootVal(self):
return self.key
5.2 树的应用
5.2.1 表达式解析
我们恰好可以用树的节点的层级反应表达式计算的优先级。
在进行表达式解析之前,需要首先把全括号表达式分解为单词列表,单词列表中包含括号()
、操作符+-*/
和操作数0-9
,且左括号是表达式的开始,右括号是表达式的阶数。
创建规则如下:
对于以上操作:
- 创建左右子树可以调用binaryTree类中的
insertLeft/Right
接口 - 设置当前节点的值,可以调用
setRootVal
- 下降到左右子树可以调用
getLeft/RightChild
- 上升到父节点暂时没有方法支持,可以创建一个栈来记录当前的节点
def buildParseTree(fpexp):
fplist = fpexp.split()
pStack = Stack()
eTree = BinaryTree('')
pStack.push(eTree)
currentTree = eTree
#表达式开始
for i in fplist:
# 左括号,入栈下降
if i == "(":
currentTree.insertLeft("")
pStack.push(currentTree)
currentTree = currentTree.getLeftChild
# 数字,保存值,出栈上升
elif i not in ['+','-','*','/',')']:
currentTree.setRootVal(int(i))
parent = pStack.pop()
currentTree = parent
elif i in ["+",'-','*','/']:
currentTree.setRootVal(i)
currentTree.insertRight("")
pStack.push(currentTree)
currentTree = currentTree.getRightChild()
elif i == ")":
currentTree = pStack.pop()
return eTree
接下来需要考虑如何利用二叉树存储的表达式进行计算,这里需要用到一些递归的方法:
import operator
def evaluate(parseTree):
opers = {
'+' : operator.add,
'-' : operator.sub,
'*' : operator.mul
'/' : operator.truediv
}
leftC = parseTree.getLeftChild()
rightC = parseTree.getRightChild()
if leftC and rightC:
fn = opers[parseTree.getRootVal()]
return fn(evaluate(leftC), evaluate(rightC))
else:
return parseTree.getRootVal()
5.2.2 优先队列和二叉堆
队列是一种先进先出的线性结构,它存在一种变体名为“优先队列”。
对于优先队列,它的数据项也是从队首出队,但是在内部,数据项的次序由优先级决定,一般高优先级的数据项排在队首,低优先级的数据项排在队尾。这种情况下,入队操作比较复杂,除了需要考虑到达的次序,还需要考虑数据项的优先级,按照优先级将高优先级的数据项排到队伍前方。
一般来说如果用列表来实现优先队列,入队的复杂度为
O
(
n
)
O(n)
O(n),出队的复杂度为
O
(
n
)
O(n)
O(n)或
O
(
1
)
O(1)
O(1),复杂度较高,需要寻找其他的数据结构降低复杂度。
一般实现优先队列的经典方案是采用二叉堆数据结构,保证入队和出队的复杂度均为
O
(
l
o
g
n
)
O(log n)
O(logn)。
二叉堆逻辑上像二叉树,却是用非嵌套列表实现的。把最小key排在队首的称为最小堆,最大key排在队首的称为最大堆。
为了让操作始终在对数数量级上,必须保持二叉树的平衡,即左右子树的节点个数相同。一般完美平衡较难实现,我们用“完全二叉树”近似实现“平衡”,完美二叉树的要求如下:
这样一个二叉树,任何一个节点中的key均大于其父节点中的key。进一步地,符合“堆”性质的二叉树,其中任何一条路径都是一个已排序数列,且根节点的key最小。
class BinHeap:
def __init__(self):
self.heapList = [0] ## 根节点从1开始,更好使用二叉堆的性质
self.currentSize = 0
# 操作1
## 添加key
def insert(self, k):
self.heapList.append(k) # 添加到末尾
## 添加到末尾之后,这条路径可能不满足堆的性质
self.currentSize += 1
## 由于可能不满足堆的性质,需要让新添加的key在路径中上浮
self.percUp(self.currentSize)
## 上浮key
def percUp(self, i):
# 添加change,表示如果某一次没有进行交换,则提前退出
change = True
while i // 2 > 0 and change:
change = False
if self.heapList[i] < self.heapList[i // 2]:
#与父节点进行比较,如果比父节点小,则进行交换
temp = self.heapList[i//2]
self.heapList[i//2] = self.heapList[i]
self.heapList[i] = temp
change = True
# 索引值上升
i = i // 2
# 操作2
## 删除并返回二叉堆中最小的key
def delMin(self):
# 弹出第一个数据项
retval = self.heapList[1]
# 为了尽量保持平衡,将最后一个数据项移到根节点上
self.heapList[1] = self.heapList[self.currentSize]
self.currentSize -= 1
self.heapList.pop()
# 修正堆的次序,将根节点下沉到合适的位置
self.percDown(1)
return retval
# 下沉函数
def percDown(self, i):
while ( i * 2) <= self.currentSize: # 表示一直下沉直到达到末尾
mc = self.minChild(i) #mc为当前节点的左右子节点中的最小key对应节点
if self.heapList[i] > self.heapList[mc]: #如果比子节点小,则交换
temp = self.heapList[i]
self.heapList = self.heapList[mc]
self.heapList[mc] = temp
i = mc
# 取较小节点
def minChild(self,i):
if i * 2 + 1 > self.currentSize: #表示只有一个节点,即只有左子节点
return i * 2
else:
if self.heapList[i * 2] < self.heapList[i * 2 + 1]:
return i * 2
else:
return i * 2 + 1
# 操作3
# 由无序表生成堆:直接先生成堆,然后用下沉法进行调整
def buildHeap(self, alist):
# 叶节点无需下沉,从叶节点的父节点开始下沉
i = len(alist) // 2
self.currentSize = len(alist)
# 直接生成二叉堆
self.heapList = [0] + alist[:]
print(len(self.heapList),i)
while(i>0):
print(self.heapList,i)
self.percDown(i)
i -= 1
print(self.heapList,i)
5.3 二叉查找树
我们之前已经介绍了两种查找的方法:
- 有序表数据结构+二分法,复杂度为 O ( l o g n ) O(log n) O(logn)
- 散列表数据结构+散列及冲突解决算法,复杂度为 O ( 1 ) O(1) O(1)
接下来我们尝试一种新的方法:用二叉查找树来保存key,实现key的快速搜索。根据理论分析,二叉查找树应当具有以下的性质:比父节点小的Key都出现在左子树,比父节点大的key都出现在右子树。
5.3.1 二叉查找树的实现:节点和链接结构
class BinarySearchTree:
def __init__(self):
self.root = None
self.size = 0
def length(self):
return self.size
def __len__(self):
return self.size
def __iter__(self):
return self.root.__iter__()
def TreeNode:
def __init__(self, key, val, left = None, right = None, parent = None):
self.key = key
self.payload = val
self.leftChild = left
self.rightChild = right
self.parent = parent
def hasLeftChild(self):
return self.leftChild
def hasRightChild(self):
return self.rightChild
## 还有其他一些有关树节点的操作不一一列出
接下来正式介绍二叉搜索树的实现:
BST.put
方法:向BST中插入一个新的key。插入之前需要先检查BST是否为空,如果是空的,将当前key作为根节点,否则如果不是空的,调用递归函数将key放置在当前的根节点之下
def put(self, key, val):
if self.root:
self._put(key, val, self.root)
else:
self.root = TreeNode(key, val)
self.size += 1
_put
辅助方法:额外添加了currentNode参数,如果当前的key比currentNode小,就插入到左子树,否则插入到右子树。如果当前节点没有左子树,则当前key成为当前节点的左子节点,递归结束。
def _put(self, key, val, currentNode):
if key < currentNode.key:
if currentNode.hasLeftChild():
self._put(key, val, currentNode.leftChild)
else:
currentNode.leftChild = TreeNode(key, val, parent = currentNode)
else:
if currentNode.hasRightChild():
self._put(key, val, currentNode.rightChild)
else:
currentNode.rightChild = TreeNode(key, val, parent = currentNode)
__setitem__
特殊方法:这个特殊方法实际上只是调用了put
方法,但是用了这个特殊方法之后,就可以直接像数组元素赋值一样赋值:如定义了mytree = BinarySearchTree()
之后,如果想要添加新元素,就可以直接写成mytree[3] = "red"
def __setitem__(self,k,v):
self.put(k,v)
BST.get()
方法,在二叉树中找到key所在的节点。这个同样也是用递归函数来实现。如果是一个空树,直接返回None,如果不是空树,那么久调用_get递归函数找到这个节点。
def get(self, key):
if self.root:
res = self._get(key, self.root)
if res:
return res.payload
else:
return None
else:
return None
def _get(self, key, currentNode):
if not currentNode:
return None
elif currentNode.key == key
return currentNode
elif key < currentNode.key:
return self._get(key, currentNode.leftChild)
else:
return self._get(key, currentNode.rightChild)
__getitem__
特殊方法以及__contains__
特殊方法:通过这些特殊方法可以直接获得取值,并判断二叉树中是否包含key:
def __getitem__(self,key):
return self.get(key)
def __contains(self,key):
if self._get(key, self.root):
return True
else:
return False
BST.delete
方法:删除方法最为复杂,因为删除了之后还要考虑剩下节点的安排。我们首先用_get()
函数找到要删除的节点,然后调用remove
函数来删除
def delete(self, key):
if self.size > 1:
nodeToRemove = self._get(key, self.root)
if nodeToRemove:
self.remove(nodeToRemove)
self.size -= 1
else:
raise KeyError("Error, key not in tree")
elif self.size == 1 and self.root.key == key:
self.root = None
self.size -= 1
else:
raise KeyError("Error, key not in tree")
接下来仔细分析一下remove
操作应该考虑的内容:
假设我们找到了这个节点,打算要移除它,并且要保证移除后仍然保持BST的性质,对于这个节点,有以下三种情况:
- 这个节点没有子节点(即自身为叶节点):对于这种情况,直接删除就可以
if currentNode.isLeaf():
if currentNode == currentNode.parent.leftChild:
currentNode.parent.leftChild = None
else:
currentNode.parent.rightChild = None
- 这个节点有1个子节点:把子节点上移,替换掉被删除节点位置。但是仍然需要注意考虑几个问题:被删节点的子节点是左子节点还是右子节点?被删节点本身是其父节点的左子节点还是右子节点?被删节点本身是否为根节点?
- 这个节点有2个子节点:这个时候不能简单地将某个子节点上移替换被删除节点,但是可以找到另一个合适的节点来替换被删节点,该节点即被删节点的下一个key值节点,即被删节点右子树中最小的那个,将其称为“后继”
5.3.2 AVL树
AVL树能够实现在key插入式一直保持二叉树的平衡。
AVL树种,每个节点要追加一个参数:平衡因子,它用来表示左右子树的高度差:
b
a
n
l
a
n
c
e
F
a
c
t
o
r
=
h
e
i
g
h
t
(
l
e
f
t
S
u
b
T
r
e
e
)
−
h
e
i
g
h
t
(
r
i
g
h
t
S
u
b
T
r
e
e
)
banlanceFactor = height(leftSubTree) - height(rightSubTree)
banlanceFactor=height(leftSubTree)−height(rightSubTree)
当平衡因子大于0时,该节点为左重,平衡因子小于0时,该节点为右重,平衡因子等于0时,该节点达到平衡。如果一个二叉查找树的每一个节点的平衡因子都在
[
−
1
,
1
]
[-1,1]
[−1,1]之间,则该BST为平衡树。
在AVL树中,如果有节点的平衡因子超出 [ − 1 , 1 ] [-1,1] [−1,1]范围,那么需要对该节点进行重新平衡
-
AVL树性能分析
考虑最差情形下的性能:平衡因子均为1或-1。这时我们先考虑此时AVL树的最大节点树与比对次数(树的高度)之间的关系:
因此在最坏情形下,AVL树搜索的时间复杂度也为 O ( l o g n ) O(log n) O(logn) -
AVL树的Python实现
接下来首先讨论向AVL树中插入一个新key时,如何保持AVL树的平衡性质:
首先作为二叉查找树,新key应当以叶节点的形式插入到AVL树中。那么叶节点本身的平衡因子是0,不需要再做调整。但是它会影响父节点的平衡因子:如果是作为左子节点加入,则父节点的平衡因子会增加1,否则会减少1。而且要命的是,这种影响会随着父节点到根节点的路径一直传递下去,直到达到根节点,或者某个父节点的平衡因子被调整为0.
根据以上分析,我们对BST中的_put
方法进行一定的修改,使得在加入一个节点后,以该节点为基础,对平衡因子进行一定的调整:
def _put(self, key, val, currentNode):
if key < currentNode.key:
if currentNode.hasLeftChild():
self._put(key, val, currentNode.leftChild)
else:
currentNode.leftChild = TreeNode(key,val,parent = currentNode)
self.updateBalance(currentNode.leftChild)
else:
if currentNode.hasRightChild():
self._put(key, val, currentNode.rightChild)
else:
currentNode.rightChild = TreeNode(key, val, parent = currentNode)
self.updateBalance(currentNode.rightChild)
def updateBalance(self, node):
if node.banlanFactor > 1 or node.balanceFactor < -1:
self.rebalance(node) ## 重新平衡
return
if node.parent != None:
if node.isLeftChild():
node.parent.balanceFactor += 1
elif node.isRightChild():
node.parent.banlanceFactor -= 1
# 看经过调整后的父节点平衡因子是否为0,不为0的话需要重新调整
if node.parent.balanceFactor != 0:
self.updateBalance(node.parent)
接下来要讨论的重点就是如何对不平衡的子树进行旋转,使其达到平衡。
主要的方法是将不平衡的子树进行旋转:左重子树进行右旋,右重子树进行左旋,旋转之后更新父节点的引用,并更新被影响节点的平衡因子
在上图中,经过左旋之后,新根节点需要将旧根节点作为左子节点。
那么现在我们来写一下左旋的代码:
def rotateLeft(self, rotRoot):
newRoot = rotRoot.rightChild # 指出新的根子节点应当是原根子节点的右子节点
#下面3行代码把新根子节点的左节点设置成旧根子节点的右节点(B的左子节点挂到A的右边)
rotRoot.rightChild = newRoot.leftChild
if newRoot.leftChild != None:
newRoot.leftChild.parent = rotRoot
#下面这段代码判断旧根子节点A是不是根节点,如果是,就把新子节点B设成新子节点
#如果A不是根节点,判断A是原来的父节点的左子节点还是右子节点,并调整新根子节点B的方向
newRoot.parent = rotRoot.parent
if rotRoot.isRoot():
self.root = newRoot
else:
if rotRoot.isLeftChild():
rotRoot.parent.leftChild = newRoot
else:
rotRoot.parent.rightChild = newRoot
#新根子节点B的左子节点指向A,A的父节点也做调整
newRoot.leftChild = rotRoot
rotRoot.parent = newRoor
#调整平衡因子,只有旧的根和新的根需要调整平衡因子
rotRoot.balanceFactor = rotRoot.balanceFactor + 1 - min(newRoot.balanceFactor, 0)
newRoot.balanceFactor = newRoot.balanceFactor + 1 + max(rotRoot.balanceFactor, 0)
接下来讲解一下最后两行代码里的公式是怎么弄出来的:
再看一种更复杂的情形:
这个处理方法为:
考虑完了以上所有情况后,我们可以写出rebalance
函数的代码:
def rebalance(self, node):
if node.balanceFactor < 0: #该节点右重,需要左旋
#左旋之前先检验右子节点是否左重,如果左重,先对右子节点进行一次右旋
#如果右子节点不是左重,单纯做一次左旋就可以了
if node.rightChild.balanceFactor > 0:
self.rotateRight(node.rightChild)
self.rotateLeft(node)
else:
self.rotateLeft(node)
elif node.balanceFactor > 0:
if node.leftChild.balanceFactor < 0:
self.rotateLeft(node.leftChild)
self.rotateRight(node)
else:
self.rotateRight(node)
最后来分析一下AVL树方法的复杂度:
get
方法:由于put方法使得AVL树始终能保持在平衡状态,所以get
方法的复杂度为 O ( l o g n ) O(log n) O(logn),效率较高put
方法:
六、图
6.1 图的基本概念
graph:由一些基本元素(如点、线段等)构造而来的图。它是一种比树更一般的结构。
图的基本术语:
- 顶点Vertex(或称为节点Node):是图的基本组成部分,顶点具有名称标识key,也可以携带数据项payload
- 边Edge(或称为弧Arc):连接两个顶点的线段,边可以是有向的也可以是无向的,相应地分别称为有向图和无向图。
- 权重Weight:表达从一个顶点到另一个顶点的代价,也可以用于赋权,具有权重的图称为赋权图
- 路径Path:由边依次连接起来的顶点序列。无权路径的长度为边的数量,加权路径的长度为所有边权重的和。
- 圈Cycle:首尾顶点相同的路径。如果一个有向图中不存在任何圈,就称为有向无圈图(directed acyclic graph:DAG)
一个图G可以定义为 G = ( V , E ) G=(V,E) G=(V,E),其中V是顶点的集合,E是边的集合,E中的每条边 e = ( v , w ) e=(v,w) e=(v,w),v和w都是V中的顶点。如果是赋权图,还可以在 e e e中添加权重分量
6.2 图抽象数据类型
图抽象数据类型的接口定义如下:
考虑实现图的方式:
- 邻接矩阵:邻接矩阵的每行和每列代表图中的顶点,如果两个顶点之间有边相连,就设定一个行列值。如果是无权边,就把矩阵分量标注为1或者0,如果是有权边,就把权重保存为矩阵分量值。
邻接矩阵实现法的优点是简单直观,缺点是如果图中的边数较少时效率较低,容易形成稀疏矩阵。 - 邻接列表:首先维护一个包含所有顶点的主列表(master list),然后对于主列表中的每个顶点,关联一个与自身有边相连的所有顶点的列表。
邻接列表法存储空间更加高效,能很容易地获得顶点所连接的所有顶点,以及连接边的信息。
6.3 图抽象数据类型的Python实现
先构建顶点Vertex类,顶点类中包含了顶点信息,以及顶点所连接的边的信息
class Vertex:
def __init__(self, key):
self.id = key
self.connectedTo = {} ##用邻接列表实现图
def addNeighbor(self, nbr, weight = 0):
self.connectedTo[nbr] = weight
def __str__(self): ##特殊的字符串化方法
return str(self.id) + " connectedTo: " \
+ str([x.id for x in self.connectedTo])
def getConnections(self):
return self.connectedTo.keys()
def getId(self):
return self.id
def getWeight(self, nbr):
return self.connectedTo[nbr]
注意在顶点类中使用了特殊化方法__str__
,这样就可以直接用print(实例)
的方法输出实例的内容
然后构建图Graph类,图类中包含了所有顶点的主表:
class Graph:
def __init__(self):
self.vertList = {} #创建顶点列表
self.numVertices = 0 #记录顶点个数
# 向图中添加顶点
def addVertex(self, key):
self.numVertices += 1
newVertex = Vertex(key)
self.verlist[key] = newVertex
return newVertex
# 通过key查找顶点
def getVertex(self, n):
if n in self.vertList:
return self.vertList[n]
else:
return None
# 特殊方法:key in graph
def __contains__(self,n):
return n in self.vertList
# 添加一条边
def addEdge(self, f, t, cost = 0):
if f not in self.vertList:
nv = self.addVertex(f)
if t not in self.vertList:
nv = self.addVertex(t)
self.vertList[f].addNeighbor(self.verlist[t],cost)
# 返回图中包含的所有顶点
def getVertices(self):
return self.vertList.keys()
# 特殊方法,进行遍历
def __iter__(self):
return iter(self.vertList.values())
6.4 广度优先搜索(BFS)
BFS是图算法的基础。BFS的含义:
当然这样搜索的过程中可能会有一些问题:例如有的节点距离可能会有多个,于是会导致重复搜索。为了解决这个问题,并且为了能够追踪搜索的过程,我们在顶点中再增加3个属性:
此外还需要用一个队列对已经发现的顶点进行排列,用于决定下一个要探索的是哪个顶点。
综上BFS算法过程如下:
看看BFS算法的代码实现:
def bfs(g, start): # 以图和起始顶点作为参数
#将起始顶点距离设为0,前驱设为None,并且加入到队列当中
start.setDistance(0)
start.setPred(None)
vertQueue = Queue()
vertQueue.enqueue(start)
#从队列中取出队首元素作为当前顶点
while(vertQueue.size() > 0):
currentVert = vertQueue.dequeue()
# 遍历所有邻接的点
for nbr in currentVert.getConnections():
if(nbr.getcolor() == "white"):
nbr.setColor("gray")
nbr.setDistance(currentVert.getDistance() + 1)
nbr.setPred(currentVert)
vertQueue.enqueue(nbr)
currentVert.setColor("black")
对BFS算法的复杂度进行分析:
while
循环对每个顶点都要访问一次,因此复杂度为
O
(
∣
V
∣
)
O(|V|)
O(∣V∣)
for
循环中,每条边只有在其起始顶点u出队时才会被检查一次,而每个顶点至多出队1次,因此边最多被检查1次,复杂度为
O
(
∣
E
∣
)
O(|E|)
O(∣E∣)
BFS复杂度为
O
(
∣
V
∣
+
∣
E
∣
)
O(|V|+|E|)
O(∣V∣+∣E∣)
6.5 深度优先搜索(DFS)
如果说广度优先搜索是逐层建立搜索树,那么深度优先搜索就是沿着树的单支尽可能深入地向下搜索,如果到了无法继续的程度还没有找到问题解,就回溯到上一层搜索下一支。
DFS一般有两个实现算法,其中一个专门用于解决骑士周游问题,特点是每个顶点仅访问一次,而且这种算法耗时比较大,在此基础上改进为Warnsdorff算法;另一个更为通用的算法允许顶点被重复访问,可以作为其他图算法的基础。
通用的深度优先搜索目标是在图上进行尽量深的搜索,连接尽量多的顶点,如果一次深度优先搜索不能覆盖图中全部的顶点,因此需要创立分支(建立新的树),有时深度优先搜索会创建多棵树,将其称为“深度优先森林”。
深度优先搜索要添加顶点的前驱属性,并额外设置“发现时间”和“结束时间”属性。“发现时间”属性指的是第几步访问到该顶点,并且把顶点的颜色设为灰色;“结束时间”属性是指第几步完成了顶点探索,并将其设置为黑色。
from pythonds.graphs import Graph # 引入图类
class DFSGraph(Graph):
def __init__(self):
## super().__init__()的作用在于执行父类的构造函数,从而可以调用父类的属性
super().__init__()
self.time = 0
def dfs(self):
# 首先进行颜色初始化,将所有顶点设置成白色(未探索状态)
for aVertex in self:
aVertex.setColor("white")
aVertex.setPred(-1)
# 对图中的每个顶点遍历,如果是未探索过的,就调用dfsvisit进行深度搜索
# 每调用一次创建一棵DFS树
for aVertex in self:
if aVertex.getColor() == "white":
self.dfsvisit(aVertex)
# dfsvisit创建单棵DFS树
def dfsvisit(self, startVertex):
# 将开始顶点设置成灰色,开始探索
startVertex.setColor("gray")
self.time += 1
startVertex.setDiscovert(self.time)
# 对当前顶点所直接连接的顶点逐个进行探索
for nextVertex in startVertx.getConnections():
# 只要顶点是白色的,就递归调用dfsvisit函数,进行更深层次深度优先搜索
if nextVertex.getColor == "white":
nextVertex.setPred(startVertex)
self.dfsvisit(nextVertex)
# 每一个节点都探索完了之后,将节点颜色设成黑色,退出该节点的探索
startVertex.setColor("black")
self.time += 1
startVertex.setFinish(self.time)
由DFS构建的树,顶点的发现时间总小于所有子顶点的发现时间,结束时间总大于所有子顶点的结束时间。
6.6 图的应用
6.6.1 骑士周游问题
问题描述:在国际象棋棋盘上,骑士按照“走日”的规则,从一个格子出发,走遍所有的棋盘恰好一次,将这样的走法称为一次周游。
对于骑士周游问题,采用图搜索算法比较容易理解。
解决方案分成两步:
- 将合法走棋次序表示为图
- 采用图搜索算法搜索一个长度为 行 × 列 − 1 行\times 列 - 1 行×列−1的路径,且路径上包含每个顶点恰好一次。
首先编写合法走棋位置函数:对于每个格子,按照“走日”法可以走8个格子,我们把能走的规则用一个列表保存下来:
def genLegalMoves(x, y, bdsize):
newMoves = []
# 保存所有可能走法
moveOffsets = [(-1,-2),(-1,2),(-2,-1),(-2,1),
(1,-2),(1,2),(2,-1),(2,1)]
for i in moveOffsets:
newX = x + i[0]
newY = y + i[1]
# 判断是否会走出棋盘,只有确定在棋盘内的走法才会加入newMoves
if legalCoord(newx, bdSize) and legalCoord(newY, bdsize):
newMoves.append((newX,newY))
return newMoves
## 判断是否在棋盘内
def legalCoord(x, bdsize):
if x >= 0 and x < bdsize:
return True
else:
return False
接下来构建走棋关系图
def knightGraph(bdsize):
# 创建空的图
ktGraph = Graph()
# 用双重循环遍历棋盘中的每个格子
for row in range(bdsize):
for col in range(bdsize):
# 用nodeID标记棋盘中的格子
nodeId = posToNodeId(row, col, bdsize)
# 用newpositions记录能够走到的格子坐标
newPositions = genLegalMoves(row, col, bdsize)
# 对能够走到的格子进行遍历
for e in newPositions:
# 把能够合法走到的点和边加到图中
nid = posToNodeId(e[0], e[1], bdsize)
ktGraph.addEdge(nodeId, nid)
return ktGraph
def posToNodeId(row, col, bdsize):
return row * bdsize + col
按照上面的方法构造出的走棋关系图如下:
构建完关系图之后,就要考虑如何解决骑士周游问题了,这里利用深度优先算法,其思路为:如果沿着单支深入搜索到无法继续时,路径长度还没有达到预期值,则返回到上一层,换一个分支继续深入搜索。
这其中实施的返回到上一层的回溯操作要引入栈来解决。
骑士周游算法代码如下(它是一个递归的函数):
# 4个参数的含义:
# n:当前的层次,表明当前已经走了多少步
# path:记录走过的路径
# u:当前搜索的顶点
# limit:搜索的总深度的限制,例如在8*8棋盘中限制为63
def knightTour(n, path, u, limit):
u.setColor("grey") #当前节点颜色为灰色,并加入到路径中
path.append(u)
if n < limit: # 当前层次小于限制的总深度时,继续搜索
nbrlist = list(u.getConnections()) # 对所有合法移动逐一深入
i = 0
done = False
while i < len(nbrlist) and not done:
# 对没有深入过的顶点(即颜色为白色)进行深入
# 深入就是递归调用
if nbrlist[i].getColor() == "white":
done = knightTour(n + 1,path, nbrlist[i], limit)
i += 1
# 如果回溯都没能达到总深度,就把当前节点pop出来,回溯到上一层
if not done:
path.pop()
u.setColor("white")
else:
done = True
return done
算法写出来了,但是可以发现这个算法的性能高度依赖于棋盘大小,其复杂度为 O ( k n ) O(k^n) O(kn),其中n是棋盘格的数目。
下面介绍改进后的Warnsdorff算法,在该算法中,对下一个遍历的次序进行修改:具有最少合法移动目标的格子优先搜索
def orderByAvail(n):
resList = []
for v in n.getConnections():
if v.getColor == "white":
c = 0
for w in v.getConnections():
if w.getColor() == "white":
c += 1
resList.append((c,v))
resList.sort(key = lambda x : x[0])
return [y[1] for y in resList]
该方法仅仅通过修改访问的次序就能大大优化算法的性能。这种采用先验知识改进算法性能的做法称为启发式规则。
启发式规则可以有效地减少搜索范围,更快达到目标。