全排列
由m个数字里面选n个进行全排列,(0,1)和(1,0)不互斥:
def n_of_m_circle(total, num):
count = 0
time = 0
def circle(n, begin, queue):
if len(queue) == num:
nonlocal time
time = time + 1
print(queue)
# print(queue)
if n < 0:
return
nonlocal count
count += 1
for i in [k for k in range(begin, total) if k not in queue]:
# 每次递归的传参begin都等于0
circle(n - 1, 0, queue + [i])
circle(num, 0, [])
上面的代码每次递归的范围都是从0开始到total,除去已经遍历的数字。相当于不放回的取数字。示意图如下:
组合
组合也是不放回的取数字,但是类似(0,1)和 (1,0)是互斥的关系。
代码如下:
def circle(n, begin, queue):
if len(queue) == num:
nonlocal time
time = time + 1
print(queue)
# print(queue)
if n < 0:
return
nonlocal count
count += 1
for i in range(begin, total):
# 下次递归从i+1开始。这样选过的数字就不会再选。
circle(n - 1, i + 1, queue + [i])
circle(num, 0, [])
组合是全排列的子集,可以考虑在全排列的基础上剪枝。假设我们每次递归都从比当前点更大的点选取元素,那后加入的点必定比先加入的点大。这样就能保证出现(1,2,3)的序列后就不会出现(1,3,2),(3,2,1),(3,1,2),(2,1,3),(2,3,1)。其递归示意图如下:
回溯
回溯算法和枚举算法有一定的关联性。但是回溯算法都有类似树的性质。其回溯过程类似二叉树后续遍历和树的深度优先遍历(这里说的是递归调用栈总是调用到最底层,然后再调用上层)。假设我们在程序开始的时候用一个栈记录遍历的节点,然后在遍历完后退出。那栈里面存的序列总是从根节点到当前节点的序列(有点类似先序遍历的意思,但是遍历是使用的意思,例如print,不能把节点入栈作为遍历来看)。加入有一个按着层序排列的二叉树:
queue = []
def pre_order(root: Node):
if root is None:
return
# do something
queue.append(root)
# queue里面存的是根节点到当前节点的路径
# do something 先序遍历回溯法上来就从根节点开始
# print(self.circle)
# [0]
# [0, 1]
# [0, 1, 3]
# [0, 1, 3, 7]
# [0, 1, 3, 8]
# [0, 1, 4]
# [0, 1, 4, 9]
# [0, 2]
# [0, 2, 5]
# [0, 2, 6]
pre_order(root.left)
pre_order(root.right)
# do something 后序遍历回溯法上来就从叶节点开始
print(self.circle)
# [0, 1, 3, 7]
# [0, 1, 3, 8]
# [0, 1, 3]
# [0, 1, 4, 9]
# [0, 1, 4]
# [0, 1]
# [0, 2, 5]
# [0, 2, 6]
# [0, 2]
# [0]
queue.pop()
根据上面的输出可以看出,如果采用无论是先序遍历还是后续遍历,queue里存的都是根节点到当前节点的序列。但是先序更适合做加法操作,而后续更适合做减法操作。
所以从上面的例子可以总结出回溯的几个要点:
- 要在程序开始的时候入栈,然后结束的时候出栈。
- 先序做加法,后续做减法。
- 如果做剪枝操作的话,显然在递归前效果更好。
此外要注意的是这里用一个全局栈来做记录。可以考录用传参的方式,把已经走过的路径传给下一层,这样就不需要显示的出栈了。