楼教主男人八题(第六题)

第六题了。干吧,年轻人~

题目链接

http://poj.org/problem?id=1739

题目描述

A square township has been divided up into n*m(n rows and m columns) square plots (1<=N,M<=8),some of them are blocked, others are unblocked.

The Farm is located in the lower left plot and the Market is located in the lower right plot.

Tony takes her tour of the township going from Farm to Market by walking through every unblocked plot exactly once.

Write a program that will count how many unique tours Betsy can take in going from Farm to Market.

题目输入

The input contains several test cases.

The first line of each test case contain two integer numbers n,m, denoting the number of rows and columns of the farm.

The following n lines each contains m characters, describe the farm. A ‘#’ means a blocked square, a ‘.’ means a unblocked square.

The last test case is followed by two zeros.

题目输出

For each test case output the answer on a single line.

样例输入

2 2
..
..
2 3
#..
...
3 4
....
....
....
0 0

样例输出

1
1
4

样例解释

3 4
....
....
....

这组样例有四种路径:

解题思路

这个题是插头DP的模板题。插头DP一般有三种递推方式:

  • 逐格子递推
  • 逐行递推
  • 逐列递推

看了一下网上的题解,大都是逐格子递推,而且使用了太多优化技巧,我感觉这些题解对初学者不太友好。因此本文使用逐行递推,且没有使用晦涩难懂的优化,希望可以帮助大家理解。

当然,如果大家想看更加详细,全面,严谨,科学的讲解,可以阅读IOI国家集训队选手陈丹琦大佬的论文《基于连通性状态压缩的动态规划问题》。论文的下载方式附到文末了。

学习之前需要一些知识储备:

  • 哈密尔顿路径
  • 并查集
  • 状态压缩

下面简单讲下这三个点。

哈密尔顿路径

哈密尔顿路径是图论里的一个概念。

G = ( V , E ) G=(V,E) G=(V,E) 是一个图,若 G G G 中一条路径通过且仅通过每一个顶点一次,称这条路径为哈密尔顿路径。

对于本题,可以把每一个 ‘.’ 当做 G G G 的一个结点,两个相邻的 ‘.’ 对应的节点之间有一条边。

问题变成了, G G G 中,以左下角对应节点为起点,右下角对应节点为终点的,哈密尔顿路径有多少条

比如下面一组输入,

3 3
#..
...
.#.

可以构造出如下的图 G G G

图中,红色粗边标识了一条哈密尔顿路径。

并查集

并查集是一种树型数据结构,一般用于求解多个不相交集合的合并及查询问题。

在并查集中,每个集合 S i S_i Si 都有一棵对应的树 T i T_i Ti S i S_i Si 中的元素和 T i T_i Ti 中的节点一一对应。

并查集支持两种操作:

  • find:查找某个节点所在树的根节点。
  • merge: 将两个节点所在的树合并为一棵。即将一棵树变为另一棵的子树。一种实现方案是,把一棵树的根节点变为另一棵树的根节点的子节点。

更详细的讲解可以参考这里:TODO

状态压缩

状态压缩是一种编程技巧。主要适用于基本状态种类较少的情形,有时还需将多个状态进行组合。我们将把状态编码进一个整数的过程成为状态压缩。

比如本题,单个格子的基本状态有七种(后面会详细介绍有哪七种),一行最多有八个格子,所以一行的状态有 7 8 7^8 78 个。因此,一行的状态完全可以用一个 uint32(无符号32位整数) 表示。表示方法如下:

将一行 m m m 个格子标号为 0 0 0 m − 1 m-1 m1。第 i 个格子的状态为 s i , s i ∈ [ 0 , 7 ] s_i,s_i ∈ [0, 7] sisi[0,7],可用三个比特表示。

将一个 uint32 的 32 个比特从高位到低位依次标号为 0 0 0 31 31 31

通过位运算可将 s i s_i si 存放在 32 − 3 ∗ ( m − i ) 32-3*(m-i) 323(mi) 33 − 3 ∗ ( m − i ) 33-3*(m-i) 333(mi) 34 − 3 ∗ ( m − i ) 34-3*(m-i) 343(mi) 三个比特内。

必要的知识储备介绍完啦,下面进入正题。当然如果要看懂后续部分,可能需要刷一刷模板题,加深下理解(太无情了)。

插头DP

后面的大部分结论都是基于本题场景得出的。在遇到其他题目时,需要具体情况具体分析。

插头是什么

对于一个 4 连通的问题来说,它通常有上下左右 4 个插头,一个方向的插头存在表示这个格子在这个方可以与外面相连。

哈密尔顿路径的特性决定了:除起点和终点之外的所有非障碍格子一定是从一个方向进来,另一个方向出去。即 4 个插头恰好有 2 个插头存在,共 6 种情况。6 种情况如下图示,图中蓝色部分表示路径在该格子中的部分:

再算上障碍格子的无插头情形,一共是 7 种。

起点和终点的格子比较特殊,只能有一个插头,这个后面再讲如何处理这种差异。

每行有多少种状态?

一行有 m 个格子,则一行的状态有 7 m 7^m 7m 种。本题中 m = 8 m = 8 m=8,则要枚举的状态多达 5764801 种。

通过观察不难发现,这些状态很多都是不正确的。比如下图这种,相邻两格子的路径都没连通,显然是不行的。

通过观察不难发现,当格子 c j c_j cj 有右插头时, c j + 1 c_{j+1} cj+1 的状态只能是三,四,五中的一种,其他的都是错误的。

换言之,左格子的右插头和右格子的左插头,必须都存在或都不存在。因为只有这样才能联通

同样的,当第 i i i 行的第 j j j 个格子 c i , j c_{i,j} ci,j 有下插头时, c i + 1 , j c_{i+1, j} ci+1,j 的状态只能是一,四,六中的一种。

这意味着,在前一行状态已确定时,也就知道了当前行的每个格子只能选一,四,六或者只能选二,三,五

再考虑到是否为障碍格子,每个格子最多只有两种有效的选择。

综上所述,在已确定上一行的状态时,当前行的对应的有效状态不超过 2 m 2^m 2m 种。当 m = 8 m = 8 m=8 时,上限是 256,状态数量小了好几个量级 ~

行状态记录连通性

考虑如上两种情形,如果行状态只记录插头类型,则两种情形是一致的——都是四个第六种插头。

但是,在第四行补上同样的插头后,左侧构成了哈密尔顿路径,右侧却没有。

这显然不符合动态规划的无后效性原则

导致该问题的原因是行状态只记录了插头类型,未记录格子之间的连通性。

为了解决该问题,行状态需保存连通信息。

如何标识连通分量

首先,需解决表示 n 个格子的连通性的问题。

通常给每一个格子标记一个正数,属于同一个的连通块的格子标记相同的数。比如 {1,1,2,2} 和 {2,2,1,1} 都表示第 1,2 个格子属于一个连通块,第 3,4 个格子属于一个连通块。为了避免出现同一个连通信息有不同的表示,一般会使用最小表示法

一种最小表示法为: 所有的障碍格子标记为 0,第一个非障碍格子以及与它连通的所有格子标记为 1,然后再找第一个未标记的非障碍格子以及与它连通的格子标记为 2,…,重复这个过程,直到所有的格子都标记完毕。

比如这两种状态,分别被标为 {1,1,2,2},{1,2,2,1}。

行状态升级

升级前的行状态里面,记录了改行中每个格子的插头种类。仔细思考下,在推导下一行的后继状态时有用到插头种类吗?其实没有,确切的说,我们只用到了有无下插头这个信息。

所以,行状态中的 1,2,3,4,5,6 可以精简为:

  • 1,3,4 -> 0,表示无下插头
  • 2,5,6 -> 非零值,表示有下插头。

2,5,6 对应的非零值到底该填多少呢?机智的老铁应该已经猜到了,填连通分量的最小标号呀!

现在我们可以用一个 uint32 表示两种信息了:

  • i i i 行的所有格子的连通性:前 i i i 行的 m ∗ i m*i mi 个格子组成了几个连通分量。
  • i i i 行的格子有无下插头,若有下插头还记录了其所属连通分量的最小标号。

升级后的行状态有多少种

考虑到每个有效的连通分量其实是哈密尔顿路径的一部分,所以每个分量在第 i i i 行上有且只有两个下插头。

换言之,在递推过程中,最多有 ⌊ m 2 ⌋ \lfloor \frac{m}{2} \rfloor 2m 个分量。所以,我觉得一个不太紧确的上限是
C m 0 + C m 2 + . . . + C m k C_m^{0} + C_m^{2} + ... + C_m^{k} Cm0+Cm2+...+Cmk
种有效的行状态。其中 k k k 为不超过 m m m 的最大偶数。

行状态转移

设有一个 n n n m m m 列的输入,记为 d a t a data data。为方便处理边界,行下标从 1 开始,列下标从 0 开始。

unordered_map<uint32_t, int> dp[9];

设有如上的容器数组 d p dp dp 记录递推过程的数据。

初始时,设有 d p 0 , 0 = 1 dp_{0,0} = 1 dp0,0=1,可以理解为在第一行之前有一种无下插头的状态。如下图示:

递推的大体流程比较好理解,伪代码如下:

for i <- 1 to n :
  for j in dp[i] :
    for k in {根据 j 和 data[i] 构造的后继状态}:
      dp[i][k] += dp[i][j];

需保证在 k k k 表示的状态中,每个连通分量都有下插头。

具体实现可以参见文末的代码。如果已经你读到了这里,我觉得代码还是很好理解的。

几个细节

每个连通分量必有两个下插头

哈密尔顿路径的起点和终点均在第 n n n 行,那么在任意两行的连接处画一条直线,必会穿过路径偶数次。

线上部分形成的连通分量均可看做是一条局部的哈密尔顿路径,因此每个哈密尔顿路径必然需在直线处有两个下插头。

因此在递推过程中,需要处理下插头数量不正确的情况。比如下图,相邻插头都是匹配的,但是红色分量没有下插头,形成了局部回路。

处理起点/终点的差异

前面提到了,起点和终点只有存在一个插头。

其实,这可以理解为起点和终点都有一个未使用的下插头。所以对于一个 m m m 的输入,答案为:

dp[n][1<<((m-1)*3)|1]

代码

#include <stdio.h>
#include <string.h>
#include <iostream>
#include <algorithm>
#include <map>

typedef unsigned int uint32_t;

using namespace std;

char data[9][9];

class Hash {
 public:
  uint32_t& operator[](uint32_t k) {
    uint32_t index = k & 127;
    for (int i = head[index]; i != -1; i= node[i].next) {
      if (node[i].k == k) {
        return node[i].v;
      }
    }
    node[top].k = k;
    node[top].v = 0;
    node[top].next = head[index];
    head[index] = top;
    return node[top++].v;
  };
  void reset() {
    top = 0;
    memset(head, -1, sizeof(head));
  }

  struct Node {
    uint32_t k;
    uint32_t v;
    int next;
  } node[1000];

  int top;
  int head[128];
}dp[9];

uint32_t state[65536];
int state_count = 0;

int find(int *fa, int u) {
  int t = u;
  while (fa[u] != u) {
    u = fa[u];
  }
  while (fa[t] != t) {
    int tmp = fa[t];
    fa[t] = u;
    t = tmp;
  }
  return u;
}

void link(int *fa, int u, int v) {
  int fu = find(fa, u);
  int fv = find(fa, v);
  if (fu > fv) {
    fa[fu] = fv;
  } else {
    fa[fv] = fu;
  }
}

void generate_state_dfs(char *line, int *pre_id, uint32_t cur, int index, int m) {
  int pre_sta = cur&0x7;
  bool has_left = (pre_sta == 1 || pre_sta == 2 || pre_sta == 3);

  if (index == m) {
    if (has_left == false) {
      state[state_count++] = cur;
    }
    return;
  }

  bool has_top = (pre_id[index] != 0);
  
  if (line[index] == '#') {
    if (has_top == 0 && has_left == false) {
      generate_state_dfs(line, pre_id, cur<<3, index+1, m);
    }
    return;
  }

  if (has_top && has_left) {
      generate_state_dfs(line, pre_id, cur<<3 | 4, index+1, m);
      return ;
  }

  if (has_top && !has_left) {
      generate_state_dfs(line, pre_id, cur<<3 | 1, index+1, m);
      generate_state_dfs(line, pre_id, cur<<3 | 6, index+1, m);
      return ;
  }

  if (!has_top && has_left) {
      generate_state_dfs(line, pre_id, cur<<3 | 3, index+1, m);
      generate_state_dfs(line, pre_id, cur<<3 | 5, index+1, m);
      return ;
  }

  if (!has_top && !has_left) {
      generate_state_dfs(line, pre_id, cur<<3 | 2, index+1, m);
      return ;
  }
}

void generate_state(char *line, int *pre_id, int m) {
  if (m <= 1) {
    return ;
  }
  state_count = 0;
  generate_state_dfs(line, pre_id, 0, 0, m);
}

int main() {
  int n, m;
  dp[0].reset();
  dp[0][0] = 1;
  while (scanf("%d %d", &n, &m) != EOF && (n || m)) {
    for (int i=1;i<=n;i++)
    {
      scanf("\n");
      for (int j=0; j < m;j++) {
        scanf("%c",&data[i][j]);
      }
    }

    for (int i = 1; i <= n; i++) {
      dp[i].reset();
    }

    int min_id[16];
    int pre_id[8];
    int fa[16];
    int cur_vs[8];

    for (int i = 1; i <= n; i++) {
      for (int index = 0; index < dp[i-1].top; index++) {
        uint32_t status = dp[i-1].node[index].k;
        uint32_t count = dp[i-1].node[index].v;
        for (int k = m-1; k >= 0; k--) {
          pre_id[k] = (status&0x7);
          status >>= 3;
        }
        generate_state(data[i], pre_id, m);
        for (int j = 0; j < state_count; j++) {
          int new_fa[8] = {0};
          for (int k = 0; k < m; k++) { fa[k] = k; }
          for (int k = 0; k < m; k++) {
            if (new_fa[pre_id[k]] == 0) {
              new_fa[pre_id[k]] = m+k;
            }
            fa[k+m] = new_fa[pre_id[k]];
          }
          uint32_t cs = state[j];
          for (int k = m-1; k >= 0; k--, cs >>= 3) {
            cur_vs[k] = (cs&0x7);
            switch(cs&0x7) {
              case 1: 
              case 6:
                link(fa, k, k+m); break;
              case 3:
              case 5:
                link(fa, k, k-1); break;
              case 4:
                link(fa, k, k+m);
                link(fa, k, k-1); break;
            }
          }
          memset(min_id, -1, sizeof(min_id));
          int component_cnt = 0;
          for (int k = 0; k < m; k++) {
            if (data[i][k] == '.') {
              int f = find(fa, k);
              if (min_id[f] == -1) {
                min_id[f] = ++component_cnt;
              }
              min_id[k] = min_id[f];
            }
          }

          bool connect_flag = true;
          for (int k = 0; k < m && connect_flag; k++) {
            if (pre_id[k] != 0 && find(fa, k+m) >= m) {
              connect_flag = false;
            }
          }

          if (!connect_flag) {
            continue;
          }

          uint32_t next = 0;
          cs = state[j];
          bool mark[8] = {0};
          for (int k = 0; k < m; k++) {
            next <<= 3;
            switch(cur_vs[k]) {
              case 2: 
              case 5:
              case 6:
                next |= min_id[k];
                if (mark[min_id[k]] == false) {
                  mark[min_id[k]] = true;
                  component_cnt--;
                }
            }
          }

          if (component_cnt != 0) {
            continue;
          }

          dp[i][next] += count;
        }
      }
    }
    int index = (1<<(m-1)*3) | 1;
    printf("%d\n", dp[n][index]);
  }
  return 0;
}

相关资料

  • 陈丹琦大佬的《基于连通性状态压缩的动态规划问题》
    • https://pan.baidu.com/s/1bm1lYZ50kc7trgVMvE6RzQ
    • g8mk
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值