跳到主要内容

小结

深搜和广搜的相同点

深搜和广搜的框架基本相同,都需要解决如下四个问题:

  1. 如何表示状态?
  2. 如何扩展状态?
  3. 在扩展状态的过程中,如何判断新状态是否有效?
  4. 在扩展状态的过程中,如何判断重复?

深搜和广搜的最显著区别,在于第三步,扩展状态的时候,顺序不一样。代码层面上,仅需要修改一行代码,就可以将广搜变成深搜,那就是,把队列queue替换为栈stack,就变成深搜了。

适用场景

输入数据:没什么特征,不像深搜,需要有“递归”的性质。如果是树或者图,概率更大。

状态转换图:树或者 DAG 图。

求解目标:多阶段最优化问题。

思考的步骤

  1. 是求路径长度,还是路径本身(或动作序列)?

    1. 如果是求路径长度,则状态里面要存路径长度(或双队列+一个全局变量)

    2. 如果是求路径本身或动作序列

      1. 要用一棵树存储宽搜过程中的路径
      2. 是否可以预估状态个数的上限?能够预估状态总数,则开一个大数组,用树的双亲表示法;如果不能预估状态总数,则要使用一棵通用的树。这一步也是第 4 步的必要不充分条件。
  2. 如何表示状态?即一个状态需要存储哪些些必要的数据,才能够完整提供如何扩展到下一步状态的所有信息。一般记录当前位置或整体局面。

  3. 如何扩展状态?这一步跟第 2 步相关。状态里记录的数据不同,扩展方法就不同。对于固定不变的数据结构(一般题目直接给出,作为输入数据),如二叉树,图等,扩展方法很简单,直接往下一层走,对于隐式图,要先在第 1 步里想清楚状态所带的数据,想清楚了这点,那如何扩展就很简单了。

  4. 如何判断重复?如果状态转换图是一颗树,则永远不会出现回路,不需要判重;如果状态转换图是一个图(这时候是一个图上的 BFS),则需要判重。

    1. 如果是求最短路径长度或一条路径,则只需要让“点”(即状态)不重复出现,即可保证不出现回路

    2. 如果是求所有路径,注意此时,状态转换图是 DAG,即允许两个父节点指向同一个子节点。具体实现时,每个节点要“延迟”加入到已访问集合visited,要等一层全部访问完后,再加入到visited集合。

    3. 具体实现

      1. 状态是否存在完美哈希方案?即将状态一一映射到整数,互相之间不会冲突。
      2. 如果不存在,则需要使用通用的哈希表(自己实现或用标准库,例如unordered_set)来判重;自己实现哈希表的话,如果能够预估状态个数的上限,则可以开两个数组,head 和 next,表示哈希表,参考第 ??? 节方案 2。
      3. 如果存在,则可以开一个大布尔数组,来判重,且此时可以精确计算出状态总数,而不仅仅是预估上限。
  5. 目标状态是否已知?如果题目已经给出了目标状态,可以带来很大便利,这时候可以从起始状态出发,正向广搜;也可以从目标状态出发,逆向广搜;也可以同时出发,双向广搜。

代码模板

广搜需要一个队列,用于一层一层扩展,一个 hashset,用于判重,一棵树(只求长度时不需要),用于存储整棵树。

对于队列,可以用queue,也可以把vector当做队列使用。当求长度时,有两种做法:

  1. 只用一个队列,但在状态结构体state_t里增加一个整数字段level,表示当前所在的层次,当碰到目标状态,直接输出level即可。这个方案,可以很容易的变成 A*算法,把queue替换为priority_queue即可。
  2. 用两个队列,current, next,分别表示当前层次和下一层,另设一个全局整数level,表示层数(也即路径长度),当碰到目标状态,输出level即可。这个方案,状态里可以不存路径长度,只需全局设置一个整数level,比较节省内存;

对于 hashset,如果有完美哈希方案,用布尔数组(bool visited[STATE_MAX]vector<bool> visited(STATE_MAX, false))来表示;如果没有,可以用 STL 里的setunordered_set

对于树,如果用 STL,可以用unordered_map<state_t, state_t > father表示一颗树,代码非常简洁。如果能够预估状态总数的上限(设为 STATE_MAX),可以用数组(state_t nodes[STATE_MAX]),即树的双亲表示法来表示树,效率更高,当然,需要写更多代码。

如何表示状态

/** 状态 */
struct state_t {
int data1; /** 状态的数据,可以有多个字段. */
int data2; /** 状态的数据,可以有多个字段. */
// dataN; /** 其他字段 */
int action; /** 由父状态移动到本状态的动作,求动作序列时需要. */
int level; /** 所在的层次(从0开始),也即路径长度-1,求路径长度时需要;
不过,采用双队列时不需要本字段,只需全局设一个整数 */
bool operator==(const state_t &other) const {
return true; // 根据具体问题实现
}
};

// 定义hash函数

// 方法1:模板特化,当hash函数只需要状态本身,不需要其他数据时,用这个方法比较简洁
namespace std {
template<> struct hash<state_t> {
size_t operator()(const state_t & x) const {
return 0; // 根据具体问题实现
}
};
}

// 方法2:函数对象,如果hash函数需要运行时数据,则用这种方法
class Hasher {
public:
Hasher(int _m) : m(_m) {};
size_t operator()(const state_t &s) const {
return 0; // 根据具体问题实现
}
private:
int m; // 存放外面传入的数据
};

/**
* @brief 反向生成路径,求一条路径.
* @param[in] father 树
* @param[in] target 目标节点
* @return 从起点到target的路径
*/
vector<state_t> gen_path(const unordered_map<state_t, state_t> &father,
const state_t &target) {
vector<state_t> path;
path.push_back(target);

for (state_t cur = target; father.find(cur) != father.end();
cur = father.at(cur))
path.push_back(cur);

reverse(path.begin(), path.end());

return path;
}

/**
* 反向生成路径,求所有路径.
* @param[in] father 存放了所有路径的树
* @param[in] start 起点
* @param[in] state 终点
* @return 从起点到终点的所有路径
*/
void gen_path(unordered_map<state_t, vector<state_t> > &father,
const string &start, const state_t& state, vector<state_t> &path,
vector<vector<state_t> > &result) {
path.push_back(state);
if (state == start) {
if (!result.empty()) {
if (path.size() < result[0].size()) {
result.clear();
result.push_back(path);
} else if(path.size() == result[0].size()) {
result.push_back(path);
} else {
// not possible
throw std::logic_error("not possible to get here");
}
} else {
result.push_back(path);
}
reverse(result.back().begin(), result.back().end());
} else {
for (const auto& f : father[state]) {
gen_path(father, start, f, path, result);
}
}
path.pop_back();
}

求最短路径长度或一条路径

单队列的写法

#include "bfs_common.h"

/**
* @brief 广搜,只用一个队列.
* @param[in] start 起点
* @param[in] data 输入数据
* @return 从起点到目标状态的一条最短路径
*/
vector<state_t> bfs(state_t &start, const vector<vector<int>> &grid) {
queue<state_t> q; // 队列
unordered_set<state_t> visited; // 判重
unordered_map<state_t, state_t> father; // 树,求路径本身时才需要

// 判断状态是否合法
auto state_is_valid = [&](const state_t &s) { /*...*/ };

// 判断当前状态是否为所求目标
auto state_is_target = [&](const state_t &s) { /*...*/ };

// 扩展当前状态
auto state_extend = [&](const state_t &s) {
unordered_set<state_t> result;
for (/*...*/) {
const state_t new_state = /*...*/;
if (state_is_valid(new_state) &&
visited.find(new_state) != visited.end()) {
result.insert(new_state);
}
}
return result;
};

assert (start.level == 0);
q.push(start);
while (!q.empty()) {
// 千万不能用 const auto&,pop() 会删除元素,
// 引用就变成了悬空引用
const state_t state = q.front();
q.pop();
visited.insert(state);

// 访问节点
if (state_is_target(state)) {
return return gen_path(father, target); // 求一条路径
// return state.level + 1; // 求路径长度
}

// 扩展节点
vector<state_t> new_states = state_extend(state);
for (const auto& new_state : new_states) {
q.push(new_state);
father[new_state] = state; // 求一条路径
// visited.insert(state); // 优化:可以提前加入 visited 集合,
// 从而缩小状态扩展。这时 q 的含义略有变化,里面存放的是处理了一半
// 的节点:已经加入了visited,但还没有扩展。别忘记 while循环开始
// 前,要加一行代码, visited.insert(start)
}
}

return vector<state_t>();
//return 0;
}

双队列的写法

#include "bfs_common.h"

/**
* @brief 广搜,使用两个队列.
* @param[in] start 起点
* @param[in] data 输入数据
* @return 从起点到目标状态的一条最短路径
*/
vector<state_t> bfs(const state_t &start, const type& data) {
queue<state_t> next, current; // 当前层,下一层
unordered_set<state_t> visited; // 判重
unordered_map<state_t, state_t> father; // 树,求路径本身时才需要

int level = -1; // 层次

// 判断状态是否合法
auto state_is_valid = [&](const state_t &s) { /*...*/ };

// 判断当前状态是否为所求目标
auto state_is_target = [&](const state_t &s) { /*...*/ };

// 扩展当前状态
auto state_extend = [&](const state_t &s) {
unordered_set<state_t> result;
for (/*...*/) {
const state_t new_state = /*...*/;
if (state_is_valid(new_state) &&
visited.find(new_state) != visited.end()) {
result.insert(new_state);
}
}
return result;
};

current.push(start);
while (!current.empty()) {
++level;
while (!current.empty()) {
// 千万不能用 const auto&,pop() 会删除元素,
// 引用就变成了悬空引用
const auto state = current.front();
current.pop();
visited.insert(state);

if (state_is_target(state)) {
return return gen_path(father, state); // 求一条路径
// return state.level + 1; // 求路径长度
}

const auto& new_states = state_extend(state);
for (const auto& new_state : new_states) {
next.push(new_state);
father[new_state] = state;
// visited.insert(state); // 优化:可以提前加入 visited 集合,
// 从而缩小状态扩展。这时 current 的含义略有变化,里面存放的是处
// 理了一半的节点:已经加入了visited,但还没有扩展。别忘记 while
// 循环开始前,要加一行代码, visited.insert(start)
}
}
swap(next, current); //!!! 交换两个队列
}

return vector<state_t>();
// return 0;
}

求所有路径

单队列

/**
* @brief 广搜,使用一个队列.
* @param[in] start 起点
* @param[in] data 输入数据
* @return 从起点到目标状态的所有最短路径
*/
vector<vector<state_t> > bfs(const state_t &start, const type& data) {
queue<state_t> q;
unordered_set<state_t> visited; // 判重
unordered_map<state_t, vector<state_t> > father; // DAG

auto state_is_valid = [&](const state_t& s) { /*...*/ };
auto state_is_target = [&](const state_t &s) { /*...*/ };
auto state_extend = [&](const state_t &s) {
unordered_set<state_t> result;
for (/*...*/) {
const state_t new_state = /*...*/;
if (state_is_valid(new_state)) {
auto visited_iter = visited.find(new_state);

if (visited_iter != visited.end()) {
if (visited_iter->level < new_state.level) {
// do nothing
} else if (visited_iter->level == new_state.level) {
result.insert(new_state);
} else { // not possible
throw std::logic_error("not possible to get here");
}
} else {
result.insert(new_state);
}
}
}

return result;
};

vector<vector<string>> result;
state_t start_state(start, 0);
q.push(start_state);
visited.insert(start_state);
while (!q.empty()) {
// 千万不能用 const auto&,pop() 会删除元素,
// 引用就变成了悬空引用
const auto state = q.front();
q.pop();

// 如果当前路径长度已经超过当前最短路径长度,
// 可以中止对该路径的处理,因为我们要找的是最短路径
if (!result.empty() && state.level + 1 > result[0].size()) break;

if (state_is_target(state)) {
vector<string> path;
gen_path(father, start_state, state, path, result);
continue;
}
// 必须挪到下面,比如同一层A和B两个节点均指向了目标节点,
// 那么目标节点就会在q中出现两次,输出路径就会翻倍
// visited.insert(state);

// 扩展节点
const auto& new_states = state_extend(state);
for (const auto& new_state : new_states) {
if (visited.find(new_state) == visited.end()) {
q.push(new_state);
}
visited.insert(new_state);
father[new_state].push_back(state);
}
}

return result;
}

双队列的写法

#include "bfs_common.h"

/**
* @brief 广搜,使用两个队列.
* @param[in] start 起点
* @param[in] data 输入数据
* @return 从起点到目标状态的所有最短路径
*/
vector<vector<state_t> > bfs(const state_t &start, const type& data) {
// 当前层,下一层,用unordered_set是为了去重,例如两个父节点指向
// 同一个子节点,如果用vector, 子节点就会在next里出现两次,其实此
// 时 father 已经记录了两个父节点,next里重复出现两次是没必要的
unordered_set<string> current, next;
unordered_set<state_t> visited; // 判重
unordered_map<state_t, vector<state_t> > father; // DAG

int level = -1; // 层次

// 判断状态是否合法
auto state_is_valid = [&](const state_t &s) { /*...*/ };

// 判断当前状态是否为所求目标
auto state_is_target = [&](const state_t &s) { /*...*/ };

// 扩展当前状态
auto state_extend = [&](const state_t &s) {
unordered_set<state_t> result;
for (/*...*/) {
const state_t new_state = /*...*/;
if (state_is_valid(new_state) &&
visited.find(new_state) != visited.end()) {
result.insert(new_state);
}
}
return result;
};

vector<vector<state_t> > result;
current.insert(start);
while (!current.empty()) {
++ level;
// 如果当前路径长度已经超过当前最短路径长度,可以中止对该路径的
// 处理,因为我们要找的是最短路径
if (!result.empty() && level+1 > result[0].size()) break;

// 1. 延迟加入visited, 这样才能允许两个父节点指向同一个子节点
// 2. 一股脑current 全部加入visited, 是防止本层前一个节点扩展
// 节点时,指向了本层后面尚未处理的节点,这条路径必然不是最短的
for (const auto& state : current)
visited.insert(state);
for (const auto& state : current) {
if (state_is_target(state)) {
vector<string> path;
gen_path(father, path, start, state, result);
continue;
}

const auto new_states = state_extend(state);
for (const auto& new_state : new_states) {
next.insert(new_state);
father[new_state].push_back(state);
}
}

current.clear();
swap(current, next);
}

return result;
}