因为找工作的原因,第一次接触到了算法题,对于没做过算法题的小白来说,算法题真的是噩梦。但是为了能够追上大佬的算法能力以及避免让算法题成为自己的短板,所以开始了算法学习的旅行。
首先旅行的第一站是阅读[挑战程序设计竞赛],这是一本关于算法竞赛的经验总结,是实验室一位算法大佬强力推荐的。
书中的第一章是准备篇,这里就不介绍了,有兴趣的同学可以自己买一本去了解。
今天我们直接来学习第二章初级篇的第一小节,“穷竭搜索”。在这一小节中,我们会认识到递归函数、栈、队列、深度优先搜索、宽度优先搜索、特殊状态的枚举和剪枝的概念与使用。
或许很多人像我一样,听到深度优先搜索、宽度优先搜索和剪枝会觉得很惶恐,觉得难以理解其过程。但是,跟着我阅读,从浅入深,系统性学习,就会慢慢掌握从而利用它。
本次旅行与Go同行。
2.1 最基础的“穷竭搜索”
2.1.1 递归函数
递归函数的概念: 一个函数中再次调用自身的行为叫做递归,这样的函数被称作递归函数。
例一:编写一个计算阶乘的函数func fact(n int)int
。
根据阶乘的递归式 n ! = n × ( n − 1 ) ! n!=n\times(n-1)! n!=n×(n−1)!,我们可以写出如下的形式:
func fact(n int) int{
return n * fact(n - 1)
}
需要注意的是:递归函数是必须要有停止条件的,不然函数就会无限地递归下去,程序就会崩溃。
因此,完整的代码应该如下所示:
func fact(n int) int{
if n == 0{
return 1
}
return n * fact(n - 1)
}
例二:斐波纳契数列的函数func fib(n int)int
。斐波纳契数列的定义是
a
0
=
0
、
a
1
=
1
a_0=0、a_1=1
a0=0、a1=1以及
a
n
=
a
n
−
1
+
a
n
−
1
(
n
>
1
)
a_n=a_{n-1}+a_{n-1}(n>1)
an=an−1+an−1(n>1)。这里初项的条件就对应了递归的停止条件。
func fib(n int) int{
if n <= 1{
return n
}
return fib(n-1) + fib(n-2)
}
2.1.2 栈
栈的概念: 栈(Stack)是一种支持push
和pop
两种操作的数据结构。
因为最后入栈的数据可以最先被取出,因此有LIFO:Last In First Out,后进先出的特性。
Go不像C++一样有封装好的Stack数据结构,但是Go可以通过Slice
来模拟栈的使用。
// 其中type指代使用的数据类型
stack := []type{}
// push
stack = append(stack, type(a))
// pop
b := stack[len(stack)-1]
stack = stack[:len(stack)-1]
函数的调用过程是通过栈实现的。因此,递归函数的递归过程也可以改用栈上的操作来实现。
2.1.3 队列
队列(Queue)与栈一样支持push
和pop
两个操作。与栈不同的是,pop
是取出最先进入的元素,也就是先进先出(FIFO:First In First Out)。
在Go语言中同样可以用Slice
实现队列的用法。
// 其中type指代使用的数据类型
queue := []type{}
// push
stack = append(stack, type(a))
// Pop
b := stack[0]
stack = stack[1:]
2.1.4 深度优先搜索
深度优先搜索的概念: 深度优先搜索(DFS,Depth-First Search)是搜索的常用手段之一。从某个状态开始,不断地转移状态,一直到无法转移。然后回退到前一步的状态,继续转移到其他状态,如此不断重复,直至找到最终的解。
例如,求解数独的解。首先可以在某个格子内填入合适的数字,然后继续往下一个格子内填入数字,如此继续下去。如果发现某个格子无解了,就放弃前一个格子上选择的数字,改用其他可行的数字。
根据深度优先搜索的特点,采用递归函数实现比较简单。
题目:部分和问题
给定整数 a 1 、 a 2 、 ⋯ 、 a n a_1、a_2、\cdots、a_n a1、a2、⋯、an,判断是否可以从中选出若干数,使它们的和恰好为 k k k。
限制条件:
1 ≤ n ≤ 20 − 1 0 8 ≤ a i ≤ 1 0 8 − 1 0 8 ≤ k ≤ 1 0 8 1\le n \le 20\\ -10^8 \le a_i \le 10^8 \\ -10^8 \le k \le 10^8 1≤n≤20−108≤ai≤108−108≤k≤108
样例一
输入:
n = 4
a = {1,2,4,7}
k = 13
输出:
Yes (13 = 2 + 4 + 7)
样例二
输入:
n = 4
a = {1,2,4,7}
k = 15
输出:
No
深度优先搜索从最开始的状态出发,遍历所有可以到到达的状态。因此可以对所有的状态进行操作,或者列举出所有的状态。
func main() {
n = 4
k = 13
a = [21]int{1, 2, 4, 7}
var dfs func(i,sum int)bool
dfs = func(i,sum int){bool{
if i == n{
return sum == k
}
if dfs(i+1, sum) || dfs(i+1, sum+a[i]){
return true
}
return false
}
if dfs(0, 0) {
fmt.Println("Yes")
} else {
fmt.Println("No")
}
}
题目:Lake Counting(POJ No.2386)
由于POJ没有Go的语言环境,这里只测试了一个样例。这题和Leetcode ****200. 岛屿数量****相似,只是8个方向变成了4个方向。
package main
import "fmt"
func main() {
var n, m, cnt int
var s string
fmt.Scanf("%d %d", &n, &m)
mp := make([][]byte, n)
for i := 0; i < n; i++ {
fmt.Scanf("%s", &s)
mp[i] = []byte(s)
}
dir := [][]int{{-1, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, -1}, {1, 1}, {-1, 1}, {1, -1}}
var dfs func(x, y int)
dfs = func(x, y int) {
mp[x][y] = '.'
for _, d := range dir {
nx, ny := x+d[0], y+d[1]
if nx >= 0 && nx < n && ny >= 0 && ny < m && mp[nx][ny] == 'W' {
dfs(nx, ny)
}
}
return
}
for i := 0; i < n; i++ {
for j := 0; j < m; j++ {
if mp[i][j] == 'W' {
cnt++
dfs(i, j)
}
}
}
fmt.Println(cnt)
}
2.1.5 宽度优先搜索
宽度优先搜索(BFS,Breadth-First Search)也是搜索的手段之一,与深度优先搜索类似。与深度优先搜索不同之处在于其搜索的顺序。宽度优先搜索总是先搜索距离初始状态最近的状态。
深度优先搜索隐式地利用了栈进行计算,宽度优先搜索则利用了队列。
用宽度优先搜索时,首先将初始状态添加到队列中,然后从队列最前端不断取出状态,然后把未访问过的转移状态添加到队列中,如此往复。
题目 : 迷宫的最短路径
给定一个大小为 N × M N\times M N×M的迷宫。迷宫由通道和墙壁组成,每一步可以向邻接的上下左右四格的通道移动。请求出从起点到终点所需的最小步数。请注意,本题假定从起点一定可以移动到终点。
限制条件: N , M ≤ 100 N,M\le 100 N,M≤100
输入:
N=10,M=10(迷宫如下图所示。'#',',','S','G'分别表示墙壁、通道、起点和终点)
#S######.#
......#..#
.#.##.##.#
.#........
##.##.####
....#....#
.#######.#
....#.....
.####.###.
....#...G#
输出
22
代码
package main
import "fmt"
func main() {
var n, m int
var s string
fmt.Scanf("%d %d", &n, &m)
mp := make([][]byte, n)
vis := make([][]bool, n)
for i := 0; i < n; i++ {
vis[i] = make([]bool, m)
fmt.Scanf("%s", &s)
mp[i] = []byte(s)
}
direction := [][]int{{-1, 0}, {0, -1}, {1, 0}, {0, 1}}
type point struct{ x, y, step int }
q := []point{point{0, 1, 0}}
for len(q) > 0 {
node := q[0]
q = q[1:]
if node.x == n-1 && node.y == m-2 {
fmt.Println(node.step)
return
}
for _, d := range direction {
x, y := node.x+d[0], node.y+d[1]
if x >= 0 && x < n && y >= 0 && y < m && mp[x][y] != '#' && !vis[x][y] {
q = append(q, point{x, y, node.step + 1})
vis[x][y] = true
}
}
}
}
BFS和DFS一样,都会生成所有能够遍历的状态,因此需要对所有状态进行遍历时,BFS和DFS都可以使用。因为递归函数比较简单,所以用DFS比较多。反正,求取最短路径时,DFS会反复经过相同的状态,所以用BFS比较好。
BFS会把状态逐个加入队列,因此通常需要与状态数成正比的内存空间。DFS与最大递归深度成正比。一般与状态数相比,递归的深度不会太大,可以认为DFS更节省内存。
2.1.6 特殊状态的枚举
例如:把n个元素共n!种不同的排列。
例如:可以使用位运算,枚举从n个元素中抽取k个共 C n k C_n^k Cnk种状态或是某个集合中的全部子集等。
2.1.7 剪枝
穷竭搜索会把所有可能的解都检查一遍,当解空间非常大时,复杂度也会相应变大。比如n个元素排列,有n!个状态,复杂度也就是O(n!)。这样即使n=15计算也很慢。
因此,采用深度优先搜索时,有时明确地知道当前状态是不存在解的。这种情况下不再继续搜索而是直接跳过,这一方法称之为剪枝。
比如题目:部分和问题,当sum超过k的时候就没必要继续搜索。