当前位置:   article > 正文

《数据结构与算法Python语言描述》裘宗燕 笔记 第五章 栈和队列_数据结构与算法python语言描述裘宗燕答案

数据结构与算法python语言描述裘宗燕答案

《数据结构与算法Python语言描述》裘宗燕 笔记系列
该系列笔记结合PPT的内容整理的,方便以后复习,有需要的朋友可以看一下。

源码重新整理了

地址:https://github.com/StarsAaron/DS/tree/master

栈和队列

- 栈和队列的概念

- 数据的生成,缓存,使用和顺序

- 栈的实现和问题

- Python 的栈实现

- 队列的实现和问题

- Python 的队列实现

- 栈应用实例

- 队列应用实例

- 搜索问题和其他

 

栈是保证缓存元素后进先出( Last In First Out,LIFO)的结构

队列是保证缓存元素的先进先出(先存的先用, First In First Out,FIFO)关系的结构

 

栈的基本操作

- 创建空栈

- 判断栈是否为空(还可能需要判断满), is_empty()

- 向栈中插入(通常称推入/压入, push)一个元素, push(...)

- 从栈中删除( 弹出, pop)一个元素,空栈弹出报错, pop()

- 取当前(最新)元素的值(并不删除), top()

 

栈:特性和基本实现考虑

栈可以实现为(可以看作)只在一端插入和删除的表,进行插入或删除操作的一端称为栈顶,另一端称为栈底。

用线性表的技术实现栈时,由于只需要在一端操作,自然应该利用实现最方便而且能保证两个主要操作的效率最高的那一端

- 采用连续表方式实现,在后端插入删除都是 O(1) 操作

- 采用链接表方式实现,前端插入删除都是 O(1) 操作

栈通常都采用这两种技术实现

 

实现栈之前,我们定义一个自己的异常类( Python 的内部异常是一组类,都是 Exception 的子类,可以继承已有异常类定义自己的异常类)

 

class StackUnderflow(ValueError): # 栈下溢(空栈访问)

    pass

 

连续表技术实现要解决的问题

- 用简单连续表,还是采用动态连续表(分离式实现的连续表)?

- 如果用简单连续表,就可能出现栈满的情况

- 采用动态连续表,栈存储区满时可以置换一个更大的存储区。这时又会出现置换策略问题,以及分期付款式的 O(1) 复杂性

 

Python的list类实现的栈功能

Python list 及其操作实际上提供了一种栈功能,可以作为栈使用

- 建立空栈,对应于创建一个空表 [ ],判空栈对应于判空表

- 由于 list 采用动态连续表技术(分离式实现),作为栈的表不会满

- 压入元素操作应在表尾端进行,对应于 lst.append(x)

- 弹出操作也应在尾端进行,无参的 lst.pop() 默认弹出表尾元素

- 由于采用动态连续表技术,压入操作具有分期付款式的 O(1) 复杂性,其他操作都是 O(1) 操作

 

连续表实现栈

用 list 作为实现基础

  1. class SStack():
  2. def __init__(self):
  3. self.elems = []
  4. def is_empty(self):
  5. return self.elems == []
  6. def top(self):
  7. if self.elems == []:
  8. raise StackUnderflow
  9. return self.elems[len(self.elems)-1]
  10. def push(self, elem):
  11. self.elems.append(elem)
  12. def pop(self):
  13. if self.elems == []:
  14. raise StackUnderflow
  15. return self.elems.pop()


链接表实现栈

借用 LNode 类实现一个链接栈:

  1. class LStack(): # stack implemented as a linked node list
  2. def __init__(self):
  3. self.top = None
  4. def is_empty(self):
  5. return self.top is None
  6. def top(self):
  7. if self.top is None:
  8. raise StackUnderflow
  9. return self.top.elem
  10. def push(self, elem):
  11. self.top = LNode(elem, self.top)
  12. def pop(self):
  13. if self.top is None:
  14. raise StackUnderflow
  15. e = self.top.elem
  16. self.top = self.top.next
  17. return e

 

简单应用:括号匹配

问题处理的线索已经清楚了:

- 顺序检查所考虑的正文(一个字符串)里的一个个字符

- 无关字符统统跳过

- 遇到开括号时将其压入一个栈

- 遇到闭括号时弹出栈顶元素与之匹配

- 匹配成功则继续;遇到不匹配时检查以失败结束

 

函数定义:

  1. def check_pares(text):
  2. pares = "()[]{}"
  3. open_pares = "([{"
  4. opposite = {")": "(", "]": "[", "}": "{"} # 表示配对关系的字典
  5. def paretheses(text): # 括号生成器,定义见后
  6. i, text_len = 0, len(text)
  7. while True:
  8. while i < text_len and text[i] not in pares:
  9. i += 1
  10. if i >= text_len:
  11. return
  12. yield text[i], i
  13. i += 1
  14. st = SStack()
  15. for pr, i in paretheses(text): # 对 text 里各括号和位置迭代
  16. if pr in open_pares: # 开括号,压进栈并继续
  17. st.push(pr)
  18. elif st.pop() != opposite[pr]: # 不匹配就是失败,退出
  19. print("Unmatching is found at", i, "for", pr)
  20. return False
  21. # else 是一次括号配对成功,什么也不做,继续
  22. print("All paretheses are correctly matched.")
  23. return True

对比几种不同表达式形式。下面三个算术表达式等价

中缀: (3 - 5) * (6 + 17 * 4) / 3

前缀: / * - 3 5 + 6 * 17 4 3

后缀: 3 5 - 6 17 4 * + * 3 /

 

三个表达式描述的是同一个计算过程

 

后缀表达式的求值

假定

- 要处理的是算术表达式

- 其中的运算对象是浮点数形式表示的数

- 运算符只有 "+"、 "-"、 "*"、 "/",都是二元运算符

 

考虑计算过程

- 设有函数 nextItem() 得到下一个运算对象或运算符:

- 遇到运算对象,需要记录以备后面使用

- 遇到运算符(或函数名),需要根据其元数取得前面最近遇到的几个运算对象或已做运算得到的结果,实施计算并记录结果

 

用什么结构记录信息?

看计算的性质:

- 需要记录的是已经掌握但还不能立即使用的中间结果,需要缓存

- 遇到运算符时,要使用的是此前最后记录的几个结果

 

显然应该用栈作为缓存结构

 

后缀表达式的计算

定义一个函数把表示表达式的字符串转化为项的表

  1. def suffix_exp_evaluator(line):
  2. return suf_exp_evaluator(line.split())

定义一个扩充的栈类,增加一个检查栈深度的方法:

  1. class ESStack(SStack):
  2. def depth(self):
  3. return len(self.elems)

下面是核心求值过程的定义(与前面设计相比有一些小修改)

由于函数的输入就是一个项的表

  1. def suf_exp_evaluator(exp):
  2. """exp is a list of items representing a suffix expression.
  3. This function evaluates it and return its value.
  4. """
  5. operators = "+-*/"
  6. st = ESStack()
  7. for x in exp:
  8. if not x in operators:
  9. st.push(float(x))
  10. continue
  11. if st.depth() < 2:
  12. raise SyntaxError("Short of operand(s).")
  13. a = st.pop() # second argument
  14. b = st.pop() # first argument
  15. if x == "+":
  16. c = b + a
  17. elif x == "-":
  18. c = b - a
  19. elif x == "*":
  20. c = b * a
  21. elif x == "/":
  22. if a == 0: raise ZeroDivisionError
  23. c = b / a
  24. else:
  25. pass # This branch is not possible
  26. st.push(c)
  27. if st.depth() == 1:
  28. return st.pop()
  29. raise SyntaxError("Extra operand(s).")

定义一个交互式的驱动函数(主函数):

  1. def suffix_exp_calculator():
  2. """Repeatly ask for expression input until an 'end'."""
  3. while True:
  4. try:
  5. line = input("Suffix Expression: ")
  6. if line == "end":
  7. return
  8. res = suffix_exp_evaluator(line)
  9. print(res)
  10. except Exception as ex:
  11. print("Error:", type(ex), ex.args)

注意:

这里用一个 try 块捕捉用户使用时的异常,保证我们的计算器不会因为用户输入错误而结束

except Exception as ex 两个用途: Exception 表示捕捉所有异常保证交互继续; ex将约束到所捕捉异常,使处理器能用相关信息

 

中缀表达式到后缀表达式的转换

中缀表达式的情况比较复杂,求值不容易直接处理,可以考虑将其转换为后缀表达式,然后就可以借用前面定义的后缀表达式求值器。

 

例如

- 中缀: (3 - 5) * (6 + 17 * 4) / 3

- 后缀: 3 5 - 6 17 4 * + * 3 /

 

分析情况:

- 运算对象应直接输出(因为运算符应该在它们的后面)

- 处理中缀运算符的优先级(注意,输出运算符就是要求运算)

遇到运算符不能简单地输出,只有下一运算符的优先级不高于本运算符时,才能做本运算符要求的计算(应输出本运算符)也就是说,读到运算符 o 时,需要用它与前一运算符 o' 比较,如果 o 的优先级不高于 o',就做 o'(输出 o'),而后记住 o

- 运行过程中可能需要记录多个运算符,新运算符需要与前面最后遇到的运算符比较,因此应该用一个栈

- 处理中缀表达式里的括号:遇到左括号时应该记录它;遇到右括号时反向逐个输出所记录的运算符(排在后面的肯定优先级更高),直到遇到左括号将其抛弃

- 最后可能剩下一些记录的运算符,应反向将其输出几种情况都是后来的先用,只用一个运算符栈就够了操作中需要特别注意检查栈空的情况

 

准备操作中使用的数据:

priority = {"(":1, "+":3, "-":3, "*":5, "/":5}

infix_operators = "+-*/()"     # 把 '(', ')' 也看作运算符特殊处理

 

priority 给每个运算符关联一个优先级。给 "(" 一个很低的优先级,可以保证它不会被其他运算符强制弹出,只有对应的 ")" 弹出它

  1. def trans_infix_suffix(line):
  2. st = SStack()
  3. llen = len(line)
  4. exp = []
  5. for x in tokens(line): # tokens 是一个待定义的生成器
  6. if x not in infix_operators: # 运算对象直接送出
  7. exp.append(x)
  8. elif st.is_empty() or x == '(': # 左括号进栈
  9. st.push(x)
  10. elif x == ')': # 处理右括号的分支
  11. while not st.is_empty() and st.top() != "(":
  12. exp.append(st.pop())
  13. if st.is_empty(): # 没找到左括号,就是不配对
  14. raise SyntaxError("Missing \'(\'.")
  15. st.pop() # 弹出左括号,右括号也不进栈
  16. else: # 处理算术运算符,运算符都看作是左结合
  17. while (not st.is_empty() and
  18. priority[st.top()] >= priority[x]):
  19. exp.append(st.pop())
  20. st.push(x) # 算术运算符进栈
  21. while not st.is_empty(): # 送出栈里剩下的运算符
  22. if st.top() == "(": # 如果还有左括号,就是不配对
  23. raise SyntaxError("Extra \'(\' in expression.")
  24. exp.append(st.pop())
  25. return exp

为测试方便,定义一个专门用于测试的辅助函数:

  1. def test_trans_infix_suffix(s):
  2. print(s)
  3. print(trans_infix_suffix(s))
  4. print("Value:", suf_exp_evaluator(trans_infix_suffix(s)))

逐一产生输入表达式里的各个项

  1. def tokens(line):
  2. """ This function cannot deal with signed numbers,
  3. nor unary operators.
  4. """
  5. i, llen = 0, len(line)
  6. while i < llen:
  7. while line[i].isspace():
  8. i += 1
  9. if i >= llen:
  10. break
  11. if line[i] in infix_operators: # 运算符的情况
  12. yield line[i]
  13. i += 1
  14. continue
  15. j = i + 1
  16. while (j < llen and not line[j].isspace() and
  17. line[j] not in infix_operators):
  18. if ((line[j] == 'e' or line[j] == 'E') and # 处理负指数
  19. j + 1 < llen and line[j + 1] == '-'):
  20. j += 1
  21. j += 1
  22. yield line[i:j] # 生成运算对象子串
  23. i = j

这个计算器不能处理负号(负数)和一元运算符

 

中缀表达式的求值比后缀表达式复杂,需要考虑

- 运算符的优先级

- 括号的作用

- 根据读入中遇到的情况,确定完成各运算的时机

- 运算时需要找到相应的运算对象

 

从中缀形式到后缀形式的转换算法已经解决了前三个问题

- 需要用一个运算符栈,在读入表达式的过程中比较运算符的优先级,并根据情况压入弹出

- 生成后缀表达式需要输出运算符的时刻,就是执行运算的时刻(后缀表达式的性质,计算这种表达式时,遇到运算符就计算)

- 根据后缀表达式的计算规则,每次执行运算时,相应的运算对象应该是最近遇到的数据,或最近的计算得到的结果

 

直接中缀表达式的求值

计算中缀表达式,可以用一个栈保存运算对象和中间结果

 

总结一下:

求值中缀表达式,一种方法是用两个栈,其中一个保存运算符,另一个保存运算对象和中间结果

- 遇到运算对象时入栈

- 遇到运算符时,根据情况考虑计算、入栈的问题

- 如果需要计算,数据在运算对象栈里

- 如果做了计算,结果还需要压入运算对象栈

- 如果表达式读完了,运算符栈里剩下的运算符逐个弹出完成计算

 

递归过程和非递归过程

考虑递归定义的函数 fact

  1. def fact(n) :
  2. if n == 0:
  3. return 1
  4. else:
  5. return n * fact(n-1)

 

在递归调用中保存的数据,后保存的将先使用,后进先出的使用方式和数据项数无明确限制,说明应该用一个栈支持递归函数的实现

 

支持递归的实现需要一个栈(运行栈),实现递归函数时

- 每个具体递归调用都有一些局部信息需要保存,语言的实现在运行栈上为函数的这次调用建立一个帧,其中保存有关信息

- 函数执行总以栈顶帧作为当前帧,所有局部变量都在这里有体现

- 进入下次递归调用时,将为它建立一个新帧

- 从递归调用返回时,上层取得函数调用的结果,并弹出已经结束的调用对应的帧,然后回到上一层执行时的状态

 

所有递归程序的执行都是这种模式

 

调用的前序动作:

- 为被调用函数的局部变量分配存储区(函数帧/活动记录/数据区)

- 将所有实参和返回地址存入函数帧(实参形参的结合/传值)

- 将控制转到被调用函数入口

 

调用的后序动作(返回):

- 将被调用函数的计算结果存入指定位置;

- 释放被调用函数的存储区;

- 按以前保存的返回地址将控制转回调用函数

 

递归定义的函数每次递归函数调用,都将自动执行这些动作

要想把递归定义的函数变换成非递归的,就需要自己做这些事情,用一个栈保存使用的中间信息

 

队列( queue)

 

队列的特性:

- 保证任何时刻访问或删除的元素的先进先出( FIFO)顺序

- 是一种与“时间”有关的结构

- 队列可看作(可实现为)只在一端插入另一端访问和删除的表

- 出队操作的一端称为队头

- 入队操作的一端称为队尾

 

队列的链接表实现

用线性表的技术实现队列,就是利用元素位置的顺序关系表示入队时间的先后关系。先进先出需要在表的两端操作,实现起来比栈麻烦一些

 

首先考虑用链接表的实现,有效操作应该考虑带表尾指针的链接表

- 这样才能保证入队/出队操作都能在 O(1) 时间完成

- 如果没有表尾指针,入队就是 O(n) 操作,显然不理想

 

采用带表尾结点指针的链接表,后端插入为 O(1) 操作:

 

队列的顺序表实现

现在考虑用顺序表技术实现队列

- 假设用尾端插入实现 enqueue,出队操作应在表的首端进行

- 为了维护表的完整性,每次出队操作取出首元素后,必须把它之后的元素全部前移,这样得到的是一个 O(n) 操作

 

反过来实现:尾端弹出元素是 O(1) 操作,但首端插入也是 O(n) 操作。

这样也出现了 O(n) 操作,同样很不理想

 

考虑首元素出队后元素不前移,记住新队头位置。这一设计也有问题:

- 反复入队出队,如果元素存储区固定,一定会在某次入队时出现队尾溢出表尾(表满)的情况

    o  出现这种溢出时,顺序表前部通常会有一些空闲位置

    o  这是“假性溢出”,并不是真的用完了整个元素区

- 如果元素存储区自动增长(如 list),首端将留下越来越大的空区。而且这片空区永远也不会用到(完全浪费了)

人们提出的一种称为“环形队列”的技术,来解决这个问题

 

实现中的不变关系(不变式):

q.rear 是最后元素之后空位的下标

q.head 是首元素的下标

[q.head, q.rear) 是队列中所有元

素(看作按照环形排列)

入队时,先存入,后移位

 

当 q.head == q.rear 时队列空

队列满如何判断?

条件不能与队列空判断相同

 

一种方案,队列满用下面条件判断:

(q.rear + 1) % q.len == q.head

这样做实际上空闲了一个单元

 

入队出队时的下标更新语句

q.head = (q.head+1) % q.len

q.rear = (q.rear + 1) % q.len

保证更新后的下标的值正确

 

完全可以采用其他设计,例如:

- 用 head 域记录队头元素位置,elnum 记录队中元素个数

- 队尾空位在( q.len 是表长)(q.head+q.elnum)%q.len

- 基于这两个变量实现操作,可以不空闲单元

 

队列的 list 实现

SQueue 类的基本考虑:

- 用 SQueue 对象的一个 list 类型的成员elems 存放队列元素

- 用 head 和 elnum 记录首元素所在位置的下标和表中元素个数

- 为能判断存储区满以便换一个表,需要记录表的长度,用 len

  1. def __init__(self, init_len=8):
  2. self.len = init_len # length of mem-block
  3. self.elems = [0] * init_len
  4. self.head = 0 # index of head element
  5. self.elnum = 0 # number of elements

这里的队列实现是一个比较复杂的问题

- 要考虑一组操作和队列对象的一组成分,其中一些操作的执行可能改变一些对象成分的取值。问题:允许怎样的改变?

- 如果一个操作有错或与其他操作不一致,就会破坏整个对象。可见,所有操作在成分修改方面必须有统一的原则,相互合作

- 为保证对象的完整性,各操作的实现都必须遵循这些些原则

 

为解决这类问题(一个数据结构的操作需相互协调,具有某种统一性),人们提出了“数据不变式”概念,它刻画“什么是一个完好的对象”

- 数据不变式基于对象的成分,描述它们应满足的逻辑约束关系

- 对象的成分取值满足数据不变式,说明这是一个状态正确的对象

 

数据不变式提出了对操作的约束和基本保证:

- 构造对象的操作必须把对象成分设置为满足数据不变式的状态

- 每个操作保证其对于对象成分的修改不打破数据不变式

 

针对下面实现,考虑的数据不变式是(用非形式的描述方式):

- elems 成分引用着队列的元素存储区,是一个 list 对象, len 成分是这个存储区的有效容量(我们并不知道该 list 对象的实际大小)

- head 是队列首元素(当时在队列里的存入最早的那个元素)的下标, elnum 始终记录着队列中元素的个数

- 队列里的元素在 elems 里连续存放,但需要在下标 len 存入元素时,操作改在下标 0 的位置存入

- 在 elnum == len 的情况下,入队列操作将自动扩张存储区

  1. # 出队
  2. def dequeue(self):
  3. if self.elnum == 0:
  4. raise QueueUnderflow
  5. e = self.elems[self.head]
  6. self.head = (self.head + 1) % self.len
  7. self.elnum -= 1
  8. return e
  9. # 入队
  10. def enqueue(self, elem):
  11. if self.elnum == self.len:
  12. self.__extend()
  13. self.elems[(self.head + self.elnum) % self.len] = elem
  14. self.elnum += 1
  15. # 扩张存储区
  16. def __extend(self):
  17. old_len = self.len
  18. self.len *= 2
  19. new_elems = [0] * (self.len)
  20. for i in range(old_len):
  21. new_elems[i] = self.elems[(self.head + i) % old_len]
  22. self.elems, self.head = new_elems, 0

应用:迷宫问题

迷宫问题:

- 给定一个迷宫,包括一个迷宫图,一个入口点和一个出口点

- 设法找到一条从入口到出口的路径

 

搜索从入口到出口的路径的问题具有递归性质

 

实现不同的路径搜索过程,需要用不同缓存结构保存分支点信息

- 用栈保存和提供信息,实现的是回到之前最后选择点继续的过程

- 用队列保存信息,就是总从最早遇到的选择点继续搜索

 

用栈保存信息,实际上是尽可能利用已经走过的路(改变最少)

 

用一个序对的 list 表示从任一 (i, j) 得到其四邻位置应加的数对:

dirs = [(0,1), (1,0), (0,-1), (-1,0)]

 

先定义两个简单的辅助函数(设 pos 是形式为 (i, j) 的序对):

  1. # 给迷宫 maze 的位置 pos 标 2 表示“到过了”
  2. def mark(maze, pos):
  3. maze[pos[0]][pos[1]] = 2
  4. # 检查迷宫 maze 的位置 pos 是否可行
  5. def passable(maze, pos):
  6. return maze[pos[0]][pos[1]] == 0

先考虑用递归方式写出的算法,它比较简单,不需要辅助数据结构

如前所述,语言系统为支持递归程序,在内部也用了一个运行栈

 

迷宫的递归求解

 

递归的迷宫求解算法是上述描述的实现,开始时把入口作为当前位置

- mark 当前位置

- 检查它是否出口,如果是则成功结束

- 逐个检查当前位置的四邻是否可以通到出口(递归)

o 成功时,整个求解也成功

o 失败时放弃这个可能性

 

迷宫问题

递归实现的主函数如下:

  1. def find_path(maze, start, end):
  2. mark(maze, start)
  3. if start == end: # 已到达出口
  4. print(start, end=' ') # 输出这个位置
  5. return True # 成功结束
  6. for i in range(4): # 否则按四个方向顺序探查
  7. nextp = start[0] + dirs[i][0], start[1] + dirs[i][1] # 下一个考虑
  8. if passable(maze, nextp): # 不可行的相邻位置不管
  9. if find_path(maze, nextp, end): # 如果从 nextp 可达出口
  10. print(start, end=' ') # 输出这个点
  11. return True # 成功结束
  12. return False

函数按从出口到入口的顺序输出路径上经历的位置(下标序对)

 

迷宫问题:回溯法和栈

 

不用递归技术求解迷宫问题的一种方法称为回溯法,需要用一个栈:

- 前进:

    o 如果当前位置存在尚未探查的向前分支,就选定一个这样的分支向前探查。如这里还有其他未探查分支,需记录相关信息

    o 找到出口时成功结束,所有可能都已探查但不能成功时失败结束

- 后退(回溯)

    o 遇死路(已无向前的未探查分支)时退回最近记录的分支点,检查那里是否还存在未探查分支,如没有就将其删除并继续回溯

    o 找到存在未探查分支的位置时将其作为当前位置继续探查

由于分支位置的记录和使用/删除具有后进先出性质,应该用栈保存信息。遇到分支点将相关信息压入栈,删除分支点时将有关信息弹出

回溯法也是一种重要的算法设计模式, 通常总用一个栈作为辅助结构,保存工作中发现的回溯点,以便后面考虑其他可能性时使用

 

搜索中把哪些位置入栈?存在两种合理的选择

- 从入口到当前探查位置,途径的所有位置都入栈

- 只在栈里保存上述路径中存在未探查方向的那些位置。这一方式要求在入栈操作前检查所考虑位置的情况,有可能节省空间

 

仔细考虑,可以看到两个情况:

1. 把一个存在未探查方向的位置入栈,后来回溯到这里时也可能不再存在未探查方向了(原有的未探查方向在此期间已经检查过了)

2. 为在算法最后输出找到的路径,也需要知道路径上所有的位置

下面算法采用记录经过所有位置的方式,主要是为了输出结果路径

 

迷宫问题算法框架(用一个栈记录搜索中需保存的信息):

入口 start 相关信息(位置和尚未探索方向)入栈;

while 栈不空:

    弹出栈顶元素作为当前位置继续搜索

    while 当前位置存在未探查方向:

        求出下一探查位置 nextp

        if nextp 是出口: 输出路径并结束

        if nextp 尚未探查

            将当前位置和 nextp 顺序入栈并退出内层循环

 

根据迷宫的表示形式,以及回溯后能继续搜索的需要。需要在栈里保存序对 (pos, nxt),记录分支点位置和在该位置还需要考虑的下一方向

- 分支点位置 pos 用行、列坐标表示,可以用一个序对

- 在 pos 的下一探索方向 nxt 是整数,表示回溯到此时应探查的下一方向。 4 个方向编码为 0、 1、 2、 3( dirs 的下标)

  1. def maze_solver(maze, start, end):
  2. if start == end:
  3. print(start)
  4. return
  5. st = SStack()
  6. mark(maze, start)
  7. st.push((start, 0)) # start position into stack 入口和方向 0 的序对入栈
  8. while not st.is_empty(): # have possibility to try 走不通时回退
  9. pos, nxt = st.pop() # get last branch position 取栈顶及其探查方向
  10. for i in range(nxt, 4): # try to find unexploring dir(s) 依次检查未探查方向
  11. nextp = (pos[0] + dirs[i][0], pos[1] + dirs[i][1]) # next point 算出下一点
  12. if nextp == end: # find end, great! :-) 到达出口,打印路径
  13. print_path(end, pos, st)
  14. return
  15. if passable(maze, nextp): # new position is passable 遇到未探查的新位置
  16. st.push((pos, i + 1)) # original position in stack 原位置和下一方向入栈
  17. mark(maze, nextp)
  18. st.push((nextp, 0)) # new position into stack 新位置入栈
  19. break # 退出内层循环,下次迭代将以新栈顶为当前位置继续
  20. print("No path.") # :-( 找不到路径

注意,栈里一点之下总是到它的路径上的前一个点。这是搜索中的一个不变性质

 

问题求解方法( general problem solving)

搜索过程进展中的情况:

- 已经探查了从初始状态可达的一些中间状态

- 已经探查的某些中间状态存在着尚未探查的相邻状态

- 显然,对任何从初始状态可达的中间状态,与它相邻的状态也是可达的。因此,从一个中间状态向前探查可能得到新的可达状态

- 若新确定的可达状态是结束状态,就么找到了初始状态到结束状态的路径;否则,新可达状态应加入已探查的中间状态集显然,搜索中需要记录存在未探查邻居的中间状态,以备后面使用

 

状态空间搜索:栈和队列

要记录存在尚未完全探索后继状态的中间状态,需要用缓存结构。原则上说栈和队列都可用,但结构的选择将对搜索进展方式产生重大影响前面的迷宫算法里用的是栈,栈的特点是后进先出

- “后进”的状态是在搜索过程中较晚遇到的状态,即是与开始状态距离较远的状态。 “后进先出”意味着从最后遇到的状态考虑继续向前探索,尽可能向前检查,尽可能向远处探索

- 只有后来的状态已经无法继续前进时,才会退到前面最近保存的状态,换一种可能性继续,后退并考虑其他可能性的动作就是回溯如果用队列,队列的特点是先进先出

- “先进”的状态是在搜索过程中较早遇到的状态,即与开始状态距离较近的状态。 “先进先出”要求先考虑距离近的状态,从它们那里向外扩展,实际上是一种从各种可能性“齐头并进”式的搜索

- 在这种搜索过程中没有回溯,是一种逐步扩张的过程。作为示例,下面考虑基于队列的迷宫求解

 

基于队列的迷宫求解算法

 

新算法的基本框架:    

将 start 标记为已达

start 入队

while 队列里还有为充分探查的位置

    取出一个位置 pos

    检查 pos 的相邻位置

        遇到 end 成功结束

        尚未探查的都 mark 并入队

    队列空,搜索失败

 

很容易基于这一算法框架写出一个函数(这里没有递归)

 

基本设计与用栈的算法一样,只是改用队列作为缓存结构:

  1. def maze_solver_queue(maze, start, end):
  2. if start == end:
  3. print("Path finds.")
  4. return
  5. qu = SQueue()
  6. mark(maze, start)
  7. qu.enqueue(start) # start position into queue
  8. while not qu.is_empty(): # have possibility to try
  9. pos = qu.dequeue() # take next try position
  10. for i in range(4): # chech each direction
  11. nextp = (pos[0] + dirs[i][0],
  12. pos[1] + dirs[i][1]) # next position
  13. if passable(maze, nextp): # find new position
  14. if nextp == end: # end position, :-)
  15. print("Path finds.") # where is the path??
  16. return
  17. mark(maze, nextp)
  18. qu.enqueue(nextp) # new position into queue
  19. print("No path.") # :-(

这里遇到的最重要问题是如何获得所希望的路径

- 前面的栈算法里,栈里每个点下面是到达它路径上的前一个点找到出口时,当时的栈里正好保存着到从入口到出口一条路径

- 对于队列算法,栈中保存的位置点的顺序与路径无关

例如,如果一个点有几个“下一探查点”,它们将顺序进队列

 

结论:在找到出口时,无法基于当时的信息追溯出一条成功路径

要想在算法结束时得到路径,必须在搜索中另行记录有关信息

 

注意:在从当前点 a 找到下一点 b 时,如果从 b 能到达出口,那么 a 就是成功路径上 b 的前一个点,这个关系可能是最终路径里的一节

 

为搜索到达出口时能获得相关路径,遇到新位置时就需要记住其前驱

- 可以用一个字典记录搜索中得到的这方面信息

- 到达出口后反向追溯,就可以得到从迷宫入口到出口的路径了

 

对原算法做几处修改,让它返回得到的路径:

  1. def maze_solver_queue1(maze, start, end):
  2. if start == end:
  3. return [start]
  4. qu = SQueue()
  5. precedent = dict()
  6. ...
  7. while not qu.is_empty(): # have possibility to try
  8. pos = qu.dequeue() # take next try position
  9. for i in range(4): # chech each direction
  10. nextp = (pos[0] + dirs[i][0],
  11. pos[1] + dirs[i][1]) # next position
  12. if passable(maze, nextp): # find new position
  13. if nextp == end: # end position, :-)
  14. return build_path(start, pos, end, precedent)
  15. mark(maze, nextp)
  16. precedent[nextp] = pos # set precedent of nextp
  17. qu.enqueue(nextp) # new position into queue
  18. print("No path.") # :-(

- 用字典 precedent 记录前驱关系,遇到新探查点时记录其前驱

- 最后用一个过程追溯前驱关系,构造出路径返回

 

构造路径的函数很简单:

  1. def build_path(start, pos, end, precedent):
  2. path = [end]
  3. while pos != start:
  4. path.append(pos)
  5. pos = precedent[pos]
  6. path.append(start)
  7. path.reverse()
  8. return path

- 最后调用 reverse 方法,得到从迷宫入口到出口的路径

- 这个算法具有 O(n) 复杂性,如果用直接在前端插入的方式构造正确顺序的路径,就会是 O(n2) 算法

 

可见,在需要做搜索时,栈或队列都可以用作缓存结构。

 

基于栈搜索迷宫的两个场景:

蓝色表示栈里记录的位置,红色表示已搜索过不会再去检查的位置

 

请注意基于栈的搜索过程的一些特点:

- 搜索比较“冒进”,可能在一条路走很远, “勇往直前/不撞南墙不回头”

- 有可能在探查了不多的状态就找到了解;也可能陷入很深的无解区域。

如果陷入的无解子区域包含无穷个状态,这种搜索方法就找不到解了

 

基于队列搜索迷宫的两个场景(蓝色表示队列里记录的位置):

 

基于队列搜索的一些特点:

- 是稳扎稳打的搜索,只有同样距离的状态都检查完后才更多前进一步

- 队列里保存的是已搜索区域的前沿状态

- 如果有解(通过有穷长路径可以到达结束状态),这种搜索过程一定能找到解,而且最先找到的必定是最短的路径(最近的解)

 

由于这两种搜索的性质,

- 基于栈的搜索被称为“深度优先搜索”( depth-first search)

- 基于队列的搜索被称为“宽度优先搜索”( width-first search)

 

如果找到了解,如何得到有关的路径:

- 基于栈的搜索,可以让栈里保存所找到路径上历经的状态序列

- 基于队列的搜索,需要用其他方法记录经过的路径。一种方式是在到达每个状态时记录其前一状态,最后追溯这种“前一状态”链,就可以确定路径上的状态(相当于对每个状态形成一个栈)

 

搜索所有可能的解和最优解

- 基于栈搜索,找到一个解之后继续回溯,有可能找到下一个解。遍历完整个状态空间就能找到所有的解,从中可以确定最优解

- 基于队列搜索,找到一个解之后继续也有可能找到其他解,但是到各个解的路径将越来越长,第一个解就是最优解

 

空间开销:搜索状态空间需要保存中间状态,空间开销就是搜索过程中栈或队列里的最大元素项数。两种搜索在这方面的表现也很不同

 

基于栈的深度优先搜索,所需的栈空间由找到一个解(或所有解)之前遇到的最长搜索路径确定,这种路径越长,需要的存储量就越大

 

基于队列的宽度优先搜索,所需的队列空间由搜索过程中最多的可能路径分支确定。可能分支越多,需要的存储量就越大。此外,为能得到路径还需要另外的存储,其存储量与搜索区域的大小线性相关

 

总结:如果一个问题可以看作空间搜索问题,栈和队列都可以使用,具体用什么要看实际问题的各方面性质两种方式遍历状态空间的方式不同。形象地说,基于栈的深度优先搜索像“横扫”,基于队列的宽度优先搜索像“蚕食”

 

数据结构教科书里通常会提出一些相关结构,例如

超栈:允许两端弹出

超队列:允许两端入队

双端队列:允许两端插入和

弹出元素

 

Python 标准库的 deque 类

Python 标准库包 collections 里定义了一个 deque 类,实现前面说的双端队列,可看作栈和队列的推广,支持两端的高效插入和删除操作使用 deque,应从 collections 导入

from collections import deque

dequ1 = deque()

 

deque 创建可以有两个(带默认值的)参数 deque([iter [,maxlen])

- iter 可为任何可迭代对象,其值将作为 deque 里元素序列

- maxlen 为 deque 指定最大元素个数,默认为不限,自动增长

 

deque 最重要的操作是两端插入和删除

- append(.) 和 appendleft(.)

- pop(.) 和 popleft(.)

- 其他操作请查阅标准库手册 data types/collections

 

另外,标准库的 deque 还保证 thread-safe 性质

- 也就是说,可以安全地用于多线程的程序

- 多个线程使用同一个 deque,而且它们都在运行中,各自独立地根据需要去操作和使用这个 deque。线程安全性保证在这种情况下该deque 不会乱套了,还是按 deque 的性质活动

 

 

 

 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/从前慢现在也慢/article/detail/169100
推荐阅读
相关标签
  

闽ICP备14008679号