Bootstrap

【算法专题】开关问题

开关问题

1. 概述

  • 此类问题有一个共同的特点,即如果当前位置状态发生变化时,会有一些位置跟着变化。

  • 我们需要求解出从初始状态到达终止状态需要的最少步数。

2. 例题

AcWing 1208. 翻硬币

问题描述

在这里插入图片描述

分析

  • 因为翻动两次相当于没有翻动,因此每个位置最多翻动一次,因此本题是一个确定性过程,从目标状态的第一个硬币开始考虑,如果不相同则翻动。

  • 因为保证有解,直接统计翻动次数,输出即可。

代码

  • C++
#include <iostream>

using namespace std;

string a, b;

void turn(int i) {
    if (a[i] == '*') a[i] = 'o';
    else a[i] = '*';
}

// 递推
int main() {
    
    cin >> a >> b;
    
    int res = 0;
    for (int i = 0; i + 1 < a.size(); i++) {
        if (a[i] != b[i]) {
            res++;
            turn(i), turn(i + 1);
        }
    }
    
    cout << res << endl;
    
    return 0;
}

AcWing 95. 费解的开关

问题描述

在这里插入图片描述

分析

  • 本题中每个位置有一个灯,当我们按下这个灯的开关后,会改变周围四个灯以及自己的状态,问我们最少按多少次可以让所有灯全部等变亮。

  • 因为题目中要求最多按6次,多于6次直接输出-1即可。因此一种做法是从全亮的状态向前回推(宽搜),使用哈希表记录下所有能到达的状态以及次数,然后判断题目给的状态是否在哈希表中存在,如果不存在,输出-1,存在输出次数。状态数大于为 2 25 2^{25} 225个,大于三千多万个状态。

  • 为了更加保险,这里介绍另一种做法,采用递推求解。

  • 本题中每个位置最多被按一次,按多次没有用;另外和按的顺序没有关系。因此可以使用从上到下递推的方式求解。

  • 假设第一行的状态已经确定,然后需要操作第二行使得第一行全部变为1,可以发现对第二行的操作是唯一的:因为如果第一行某个位置为1,则第二行对应位置必定不能被按;如果第一行某个位置为0,则第二行对应位置必定需要被按。

  • 然后第二行的状态就确定了,然后需要操作第三行使得第二行全部变为1,依次类推到最后一行。最后判断最后一行是否全部为1即可确定是否有解。

  • 为了枚举到所有方式,我们可以枚举第一行的所有状态,一共 2 5 = 32 2^5=32 25=32种方式。这种方式的计算量大约为 32 × 25 × 5 × 500 = 2 × 1 0 6 32 \times 25 \times 5 \times 500=2 \times 10^6 32×25×5×500=2×106500是测试数据数量)。

代码

  • C++
#include <cstdio>
#include <cstring>

using namespace std;

const int N = 6;

char g[N][N], bg[N][N];  // 一定要注意这里是char
int dx[5] = {-1, 0, 1, 0, 0}, dy[5] = {0, 1, 0, -1, 0};

void turn(int x, int y) {
    for (int i = 0; i < 5; i++) {
        int a = x + dx[i], b = y + dy[i];
        if (a < 0 || a >= 5 || b < 0 || b >= 5) continue;
        // '0'(48): 01001000    <->    '1'(49): 01001001
        g[a][b] ^= 1;
    }
}

int main() {
    
    int T;
    scanf("%d", &T);
    while (T--) {
        
        for (int i = 0; i < 5; i++) scanf("%s", &bg[i]);
        
        int res = 10;
        for (int op = 0; op < 1 << 5; op++) {
            
            int cnt = 0;
            memcpy(g, bg, sizeof bg);
            
            // 操作第一行的开关
            for (int i = 0; i < 5; i++)
                if (op >> i & 1) {
                    turn(0, i);
                    cnt++;
                }
            
            // 递推出后四行的状态
            for (int i = 0; i < 4; i++)
                for (int j = 0; j < 5; j++)
                    if (g[i][j] == '0') {
                        turn(i + 1, j);
                        cnt++;
                    }
            
            // 判断最后一行是否全部为1
            bool success = true;
            for (int i = 0; i < 5; i++)
                if (g[4][i] == '0')
                    success = false;
            if (success && res > cnt) res = cnt;
        }
        
        if (res > 6) res = -1;
        printf("%d\n", res);
    }
    
    return 0;
}

AcWing 116. 飞行员兄弟

问题描述

在这里插入图片描述

分析

  • 暴力求解即可。即枚举所有可能的操作方案,找到使用步数最少的方案。

  • 使用二进制表示整个面板状态,记为state,给每个格子编号,从左到右,从上到下依次是0~15

  • 使用二维数组change[i][j]表示改变(i, j)所引起的整个棋盘的变换情况。这样我们当我们改变(i, j)的状态时,只需要将state异或上change[i][j]即可。

代码

  • C++
#include <iostream>
#include <cstring>
#include <algorithm>

#define x first
#define y second

using namespace std;

typedef pair<int, int> PII;

const int N = 4, INF = 100;

int change[N][N];  // change[i][j]记录改变(i, j)状态导致整个棋盘状态的变化

// 二维坐标转化为一维,(0, 0) -> 0
int get(int x, int y) {
    return x * N + y;
}

int main() {
    
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) {
            for (int k = 0; k < N; k++) change[i][j] += (1 << get(i, k)) + (1 << get(k, j));
            change[i][j] -= (1 << get(i, j));
        }

    int state = 0;  // 初始状态
    for (int i = 0; i < N; i++) {
        string line;
        cin >> line;
        for (int j = 0; j < N; j++)
            if (line[j] == '+')
                state += (1 << get(i, j));
    }
    
    vector<PII> path, tmp;
    for (int i = 0; i < 1 << 16; i++) {
        int now = state;
        tmp.clear();
        for (int j = 0; j < 16; j++)
            if (i >> j & 1) {
                int x = j / 4, y = j % 4;
                now ^= change[x][y];
                tmp.push_back({x, y});
            }
        if (!now) {  // 说明i对应的操作是一组合法操作
            if (path.empty() || path.size() > tmp.size())
                path = tmp;
        }
    }
    
    cout << path.size() << endl;
    for (auto &p : path)
        cout << p.x + 1 << ' ' << p.y + 1 << endl;
    
    return 0;
}

AcWing 208. 开关问题

问题描述

在这里插入图片描述

分析

  • 这里以一个例子讲解这个题目,有三个开关,分别为1、2、3,规则是:如果按1,则1、2的状态会改变;如果按2,则1、2、3的状态会改变;如果按3,则3的状态会改变。初始状态是0、0、0,转变为1、1、1存在多少种方案?

  • 我们使用 x 1 、 x 2 、 x 3 x_1、x_2、x_3 x1x2x3 分别表示每个开关有没有发生变化,没变为0,变了为1。根据上述规则,按1、2会影响1的状态,因此x1 ^ x2 = 1,同理我们可以得到如下异或方程组:

{ x 1 ⊕ x 2 = 1 第 一 个 开 关 x 1 ⊕ x 2 = 1 第 二 个 开 关 x 2 ⊕ x 3 = 1 第 三 个 开 关 \begin{cases} x_1 \oplus x_2 = 1 \quad 第一个开关 \\ x_1 \oplus x_2 = 1 \quad 第二个开关 \\ x_2 \oplus x_3 = 1 \quad 第三个开关 \end{cases} x1x2=1x1x2=1x2x3=1

  • 因此此时我们就将问题转化成为了AcWing 884. 高斯消元解异或线性方程组。对增广矩阵进行变换,变为上三角矩阵,最后如果有k个自由元(即k个行全0),最终的结果就为 2 k 2^k 2k

  • 上述例题存在一个自由元,因此答案是2,两个答案分别是 ( x 1 = 0 , x 2 = 1 , x 3 = 0 ) 、 ( x 1 = 1 , x 2 = 0 , x 3 = 1 ) (x_1=0, x_2=1, x_3=0)、(x_1=1, x_2=0, x_3=1) (x1=0,x2=1,x3=0)(x1=1,x2=0,x3=1)

代码

  • C++
#include <iostream>
#include <cstring>

using namespace std;

const int N = 35;

int n;
int a[N][N];

int gauss() {
    
    int r, c;
    for (r = 1, c = 1; c <= n; c++) {
        // 找主元
        int t = r;
        for (int i = r; i <= n; i++)
            if (a[i][c])
                t = i;
        if (a[t][c] == 0) continue;
        // 交换
        for (int i = c; i <= n + 1; i++) swap(a[r][i], a[t][i]);
        // 消
        for (int i = r + 1; i <= n; i++)
            for (int j = n + 1; j >= c; j--)
                a[i][j] ^= a[i][c] & a[r][j];
        r++;
    }
    
    int res = 1;
    if (r < n + 1) {
        for (int i = r; i <= n; i++) {
            if (a[i][n + 1]) return -1;  // 出现了 0 == !0,无解
            res *= 2;
        }
    }
    return res;
}

int main() {
    
    int T;
    scanf("%d", &T);
    while (T--) {
        scanf("%d", &n);
        memset(a, 0, sizeof a);
        for (int i = 1; i <= n; i++) scanf("%d", &a[i][n + 1]);
        for (int i = 1; i <= n; i++) {
            int x;
            scanf("%d", &x);
            a[i][n + 1] ^= x;  // a[i][n + 1]存储的是第i个灯的状态是否变化
            a[i][i] = 1;  // 默认开关可以控制自己
        }
        
        int x, y;
        while (scanf("%d%d", &x, &y), x || y) a[y][x] = 1;  // 影响的是第y个方程
        
        int t = gauss();
        if (t == -1) puts("Oh,it's impossible~!!");
        else printf("%d\n", t);
    }
    
    return 0;
}

POJ 3185. The Water Bowls

问题描述

一共有一行20个数,每个数字都是0或1,每次翻一个数都会将这个数两边的数取反,保证有解,求全部反转为0,最少的反转次数?

分析

  • 本题相当于每次翻转3个数,且中间那个数是驱动翻转的那个数,因此从第一个数开始考虑,如果第一个数为1,则必须翻转第二个数,这样前三个数都会被翻转,第一个数可以变为0,这样递推计算即可。

  • 本题和AcWing 1208. 翻硬币很类似,都是一个确定性的过程。

代码

  • C++
#include <iostream>

using namespace std;

int n = 20, k = 3;
int a[25];

void turn(int i) {
    a[i - 1] ^= 1;
    a[i] ^= 1;
    a[i + 1] ^= 1;
}

int main() {
    
    /*
    测试用例:
    0 0 1 1 1 0 0 1 1 0 1 1 0 0 0 0 0 0 0 0
    */
    for (int i = 0; i < n; i++) cin >> a[i];
    
    int res = 0;
    for (int i = 0; i + k - 1 < n; i++)
        if (a[i]) {
            res++;
            turn(i + 1);
        }
    
    cout << res << endl;
    
    return 0;
}

POJ 3276. Face The Right Way

问题描述

在这里插入图片描述

分析

  • 从下到达枚举k,计算翻转需要的次数,更新答案即可。

  • 在确定k之后,如何计算需要翻转的次数呢?从第一个位置开始考虑,如果当前位置是向后,则将从当前位置开始的长度为k的区间全部翻转。最后检查最后k-1头牛的朝向是否正确,如果都是朝前,则说明有解,否则对于当前的k无解。

  • 对于每个k求解时,如果翻转区间直接暴力枚举,则对于每个k来说,时间复杂度是 O ( n 2 ) O(n^2) O(n2)的,因此总体时间复杂度为 O ( n 3 ) O(n^3) O(n3),不可取,因此考虑优化这一步。

  • 可以使用数组f,其中f[i]表示区间[i~i+k-1]是否翻转,翻转为1,未反转为0,使用sum记录当前考察位置翻转次数,这样就可以知道当前考察的位置的朝向了。此时对于每个k,时间复杂度被优化为 O ( n ) O(n) O(n)的了,因此总体时间复杂度为 O ( n 2 ) O(n^2) O(n2)的。

代码

  • C++
#include <iostream>
#include <cstring>

using namespace std;

const int N = 5010;

int n;
int d[N];  // 初始朝向, 1表示向前,最终要变为全1
int f[N];  // f[i~i+k-1]是否翻转

int calc(int k) {
    
    memset(f, 0, sizeof f);
    
    int res = 0;  // 需要翻转的次数
    int sum = 0;  // 当前遍历位置被翻转的次数
    for (int i = 0; i + k - 1 < n; i++) {
        
        if ((d[i] + sum) % 2 == 0) {  // 说明第i头牛面向后, 需要翻转
            res++;
            f[i] = 1;  // 表示f[i~i+k-1]需要翻转
        }
        
        sum += f[i];
        // f[i-k+1]影响的区间为[i-k+1~i], 不会影响i+1
        // 因此考虑下次i时,需要减去
        if (i - k + 1 >= 0) {
            sum -= f[i - k + 1];
        }
    }
    
    // 单独检查后面k-1头牛的朝向是否正确
    for (int i = n - k + 1; i < n; i++) {
        if ((d[i] + sum) % 2 == 0) return -1;
        if (i - k + 1 >= 0) sum -= f[i - k + 1];
    }
    return res;
}

int main() {
    
    /*
    测试用例:
    7
    BBFBFBB
    */
    cin >> n;
    for (int i = 0; i < n; i++) {
        char c;
        cin >> c;
        if (c == 'F') d[i] = 1;
        else d[i] = 0;
    }
    
    int res = n, k = 1;  // res: 操作次数, k: 翻转的连续数目
    
    for (int i = 1; i <= n; i++) {
        int t = calc(i);
        if (t != -1 && t < res) {
            res = t;
            k = i;
        }
    }
    
    cout << k << ' ' << res << endl;
    
    return 0;
}
;