《挑战程序设计竞赛》阅读笔记

《挑战程序设计竞赛》第一阶段阅读计划

Date Chapter Start Page End Page Finish on Time❓
2020-07-06 2.1 最基础的“穷竭搜索” 26 38 ✔️
2020-07-07 2.2 一往直前!贪心法 39 50
2020-07-08 2.3 记录结果再利用的“动态规划” 51 69
2020-07-09 2.4 加工并存储数据的数据结构 70 90
2020-07-10 2.5 它们其实都是“图” 91 112
2020-07-11 2.6 数学问题的解题窍门 113 124
2020-07-12 2.7 一起来挑战GCJ的题目(1) 125 135

第 2 章 初出茅庐——初级篇

2.1 最基础的“穷竭搜索”

2.1.2 栈

最后进入栈的数据最先被取出(这种行为被叫做 LIFO: Last In First Out,即后进先出)

2.1.3 队列

最初放入的元素最先被取出(这种行为被叫做 FIFO: First In First Out,即先进先出)

2.1.4 深度优先搜索

DFS, Depth-First Search

P30 部分和问题

给定整数 a1,a2,,ana_1, a_2, \cdots, a_n,判断是否可以从中选出若干数,使它们的和恰好为 k。

#include <bits/stdc++.h>
using namespace std;

const int MAX_N = 20 + 5;
int n;
int a[MAX_N];
int k;

void input() {
	scanf("%d", &n);
	for (int i = 0; i < n; i++) {
		scanf("%d", &a[i]);
	}
	scanf("%d", &k);
}

bool dfs(int i, int sum) {
	if (i == n) {
		return (sum == k);
	}
	if (dfs(i + 1, sum)) {
		return true;
	}
	if (dfs(i + 1, sum + a[i])) {
		return true;
	}
	return false;
}

int main() {
	input();
	if (dfs(0, 0)) {
		printf("Yes\n");
	}
	else {
		printf("No\n");
	}
	return 0;
}

/*
Input:
4
1 2 4 7
13
Output:
Yes

Input:
4
1 2 4 7
15
Output:
No
*/

a1a_1 开始按顺序决定每个数加或不加,在全部 n 个数都决定后再判断它们的和是不是 k 即可,复杂度是 O(2n)O(2^n)

P32 Lake Counting(POJ No.2386)

const int MAX_N = 100 + 5;
const int MAX_M = 100 + 5;
int n;
int m;
char field[MAX_N][MAX_M];

void dfs(int x, int y) {
	field[x][y] = '.';
	for (int dx = -1; dx <= 1; dx++) {
		for (int dy = -1; dy <= 1; dy++) {
			int nx = x + dx;
			int ny = y + dy;
			if (0 <= nx && nx < N && 0 <= ny && ny < M && field[nx][ny] == 'W') {
				dfs(nx, ny);
			}
		}
	}
	return;
}

void solve() {
	int res = 0;
	for (int x = 0; x < N; x++) {
		for (int y = 0; y < M; y++) {
			if (field[x][y] == 'W') {
				dfs(x, y);
				res++;
			}
		}
	}
	printf("%d\n", res);
}

从任意的 w 开始,不停地把邻接的部分用 ‘.’ 代替。1 次 DFS 后与初始的这个 w 连接的所有 w 就都被替换成了 ‘.’,因此直到图中不再存在 w 为止,总共进行 DFS 的次数就是答案了。8 个方向共对应了 8 种状态转移,每个格子作为 DFS 的参数至多被调用一次,所以复杂度为 O(8×N×M=O(N×M)O(8 \times N \times M=O(N \times M)

2.1.5 宽度优先搜索

BFS, Breadth-First Search

P34 迷宫的最短路径

// 使用 pair 表示状态时,使用 typedef 会更加方便一些
typedef pair<int, int> P;

const int MAX_N = 100 + 5;
const int MAX_M = 100 + 5;

// 输入
char maze[MAX_N][MAX_M];
int N, M;
int sx, sy;             // 起点座标
int gx, gy;             // 终点座标
int d[MAX_N][MAX_N];    // 到各个位置的最短距离的数组
int dx[4] = { 1, 0, -1, 0 };
int dy[4] = { 0, 1, 0, -1 };

int bfs() {
	for (int i = 0; i < N; i++) {
		for (int j = 0; j < M; j++) {
			d[i][j] = INF;
		}
	}
	d[sx][sy] = 0;
	queue<P> que;
	que.push(P(sx, sy));
	while (!que.empty()) {
		P p = que.front();
		que.pop();
		if (p.first == gx && p.second = gy) {
			break;
		}
		for (int i = 0; i < 4; i++) {
			int nx = p.first + dx[i];
			int ny = p.second + dy[i];
			if (0 <= nx && nx < N && 0 <= ny && ny <= M && maze[nx][ny] != '#' && d[nx][ny] == INF) {
				que.push(P(nx, ny));
				d[nx][ny] = d[p.first][p.second] + 1;
			}
		}
	}
	return d[gx][gy];
}

宽度优先搜索按照距开始状态由近及远的顺序进行搜索,因此可以很容易地用来求最短路径、最少操作之类问题的答案。这个问题中,状态仅仅是目前所在位置的座标,因此可以构造成 pair 或者编码成 int 来表达状态。当状态更加复杂时,就需要封装成一个类来表示状态了。转移的方式为四方向移动,状态数与迷宫的大小是相等的,所以复杂度是 O(4×N×M)=O(N×M)O(4 \times N \times M) = O(N \times M)

宽度优先搜索中,只要将已经访问过的状态用标记管理起来,就可以很好地做到由近及远的搜索。这个问题中由于要求最短距离,不妨用 d[N][M] 数组把最短距离保存起来。初始时用充分大的常数 INF 来初始化它,这样尚未到达的位置就是 INF,也就同时起到了标记的作用。

因为要向 4 个不同方向移动,用 dx[4]dy[4] 两个数组来表示四个方向向量。这样通过一个循环就可以实现四方向移动的遍历。

宽度优先搜索与深度优先搜索一样,都会生成所有能够遍历到的状态,因此需要对所有状态进行处理时使用宽度优先搜索也是可以的。但是递归函数可以很简短地编写,而且状态的管理也更简单,所以大多数情况下还是用深度优先搜索实现。反之,在求取最短路时深度优先搜索需要反复经过同样的状态,所以此时还是使用宽度优先搜索为好。

宽度优先搜索会把状态逐个加入队列,因此通常需要与状态数成正比的内存空间。反之,深度优先搜索是与最大的递归深度成正比的。一般与状态数相比,递归的深度并不会太大,所以可以认为深度优先搜索更加节省内存。

2.1.6 特殊状态的枚举

虽然生成可行解空间多数采用深度优先搜索,但在状态空间比较特殊时其实可以很简短地实现。比如,C++ 的标准库中提供了 next permutation 这一函数,可以把 n 个元素共 n! 种不同的排列生成出来。又或者,通过使用位运算,可以枚举从 n 个元素中取出 k 个的共 CnkC_n^k 种状态或是某个集合中的全部子集等。3.2节将介绍如何利用位运算枚举状态。

2.1.7 剪枝

专栏 栈内存和堆内存

  • 调用函数时,主调的函数所拥有的局部变量等信息需要存储在栈内存区;

  • 利用 new 或者 malloc 进行分配的内存区域被称为堆内存。

  • 栈内存在程序启动时被统一分配,此后不能再扩大。由于这一区域有上限,所以函数的递归深度也有上限。虽然与函数中定义的局部变量的数目有关,不过一般情况下 C 和 C++ 中进行上万次的递归是可以的。在 Java 中,在执行程序时可以用参数指定栈的大小。

  • 全局变量被保存在堆内存区。通常不推荐使用全局变量,但是在程序设计竞赛中,由于函数通常不是那么多,并且常常会有多个函数访问同一个数组,因此利用全局变量就很方便。此外,有时必须要申请巨大的数组,与放置在栈内存上相比,将其放置在堆内存上可以减少栈溢出的危险。同时,通常只需定义满足最大需要的数列大小,但如果再额外定义大一些,能很好地避免粗心导致的诸如忘记保留字符串末尾的’\0’的空间之类的漏洞。

第 1 章 蓄势待发——准备篇

1.1 何谓程序设计竞赛

Page 思想 复杂度
3 4 层循环 O(n4)O(n^4)
22 对原数组排序
3 层循环,二分查找 m - a[i] - a[j] - a[k]
O(nlogn)O(n \log n)
O(n3logn)O(n^3 \log n)
23 a[i] + a[j] 并排序
两层循环,二分查找 m - a[i] - a[j]
O(n2logn)O(n^2 \log n)
O(n2logn)O(n^2 \log n)

1.5 以高效的算法为目标

考虑 O(n2)O(n^2) 时间的算法,假设题目描述中的限制条件为 n1000n \leq 1000,将 n=1000n = 1000 代入 n2n^2 就得到了 100000。在这个数值的基础上,我们就可以结合下表进行判断了。

假设时间限制为1秒

1000000 游刃有余
10000000 勉勉强强
100000000 很悬,仅限循环体非常简单的情况

1.6 轻松热身


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章