[算法] 线性代数法解Flip Game/Fliptile POJ 1753 1222 3279 解题报告

1,664 阅读11分钟

昨天有同学在别的群里问了一道题:4×4的格子,每个格子 有黑和白两种状态,每次点击一个格子,它和它相邻的所有格子 都会同时发生翻转,问怎么把所有格子恢复到全白。 以前大学时做这道题都是用枚举第一行策略、推导剩余行、验证最后一行的方法做的,虽然可以剪枝减少一些计算量但是其实意义不太大。后来研究了一段时间矩阵(尤其是异或矩阵),一直也没再遇到过这道题,昨天既然被人问了,那就刷一刷吧,翻出了POJ的三道题,大差不差,略有些细节区别,难度从低到高:

  • 1222:限定4乘4,可以到全黑或全白,只求一个最优解的操作的数量。
  • 1753:限定5乘6,只能到全0,没有要求最优解(但也没有Special Judge,会不会有多解呢?下文会解释这个问题)。
  • 3279:W和H小于等于15,只能到全0,求一个字典序最小的最优解。

最优解定义为 操作的次数最少(解中1的个数最少)

这些题用枚举第一行的方法都可以AC,但既然来写文章,我们肯定不会这么水的过去,今天我们来讲讲数学方法解这道题。

翻转问题的基础

在1222的描述中,已经说过了这个问题的一些基本策略,我们先简单回顾一下:

  • 操作的顺序和结果无关(最终只看每个格子被改变次数的“奇偶性”)
  • 任何一个格子都没有必要操作两次(同上)

在1753和3279的输出中,我们也可以看出,我们要求的也是一个矩阵,矩阵的每一个元代表了这个格子是否需要操作一次。

线性同余方程

考虑我们要求解的目标:每个格子是否需要操作一次,以及它对应的结果:操作完成之后每个格子的状态是否发生了变化。这两个矩阵的关联呢?则是“操作每个格子 影响到了哪些格子”。这个过程其实可以用一个线性同余方程组来表示(我们这里以2×2来举例):

x0 * a00 + x1 * a01 + x2 * a02 + x3 * a03 = r0  (mod 2)
x0 * a10 + x1 * a11 + x2 * a12 + x3 * a13 = r1  (mod 2)
x0 * a20 + x1 * a21 + x2 * a22 + x3 * a23 = r2  (mod 2)
x0 * a30 + x1 * a31 + x2 * a32 + x3 * a33 = r3  (mod 2)

这里,x0至x3 分别代表2×2的四个格子 是否需要操作一次,r0至r3 分别代表2×2的四个格子是否需要被改变一次状态,aij表示按下第j个格子,是否会改变第i个格子的状态,因此aij矩阵是已知的:

2乘2的矩阵
1 1 1 0
1 1 0 1
1 0 1 1
0 1 1 1
3乘3的矩阵
1 1 0 1 0 0 0 0 0 
1 1 1 0 1 0 0 0 0 
0 1 1 0 0 1 0 0 0 
1 0 0 1 1 0 1 0 0 
0 1 0 1 1 1 0 1 0 
0 0 1 0 1 1 0 0 1 
0 0 0 1 0 0 1 1 0 
0 0 0 0 1 0 1 1 1 
0 0 0 0 0 1 0 1 1 

上面的方程组,我们可以用向量和矩阵来表示(如果你缺乏相应知识,可以去补习线性代数!):

A * X = R  (mod 2) // 如果X和R是列向量,矩阵元的形式和前面的表示一致。
X * A = R  (mod 2) // 如果X和R是行向量,和上面这条等价,但是A矩阵需要转置。

这种表示法称作同余矩阵(向量也是一种特殊的矩阵)。特别的,模2的时候,也可以被称作异或矩阵,因为 a xor b = (a+b) mod 2。

同余矩阵的乘法也满足结合律(注意矩阵乘法不满足交换律),同余矩阵在许多方面也和普通矩阵有着相似的性质,因此我们可以利用这一点来做一些奇怪的事情。

矩阵的秩,解的存在性判定

在线性代数中,一个矩阵A的列秩是A的线性独立的纵列的极大数目。类似地,行秩是A的线性无关的横行的极大数目。即如果把矩阵看成一个个行向量或者列向量,秩就是这些行向量或者列向量的秩,也就是极大无关组中所含向量的个数。

矩阵的秩是其列秩和行秩的较小值。方阵的行秩和列秩总是相等。

线性方程组 解的存在性的关联:

  • 秩(系数矩阵)<秩(增广矩阵):不存在解
  • 秩(系数矩阵)=秩(增广矩阵):存在解:
    • 秩(系数矩阵)=未知数的个数:总是有唯一解
      • 如果未知数的个数=行数,称作“满秩”,此时系数矩阵存在逆矩阵
    • 秩(系数矩阵)<未知数的个数:
      • 所有的解可以表达为一个特解 和 K个线性独立的自由基 的线性组合,其中K=未知数的个数-秩(系数矩阵)

增广矩阵表示把方程每一行的结果 作为矩阵的最后一列出现。

这里特别说一下:对于同余矩阵,上述结论未必成立,满秩也未必代表存在逆矩阵,因为线性同余计算过程中乘法运算未必可逆,无法直接定义一个有效的除法运算。譬如 模4的 1×1矩阵 2 就不存在逆矩阵,因为0到3之间没有任何整数乘以2模4等于1。但是幸运的是这个问题对于异或矩阵来说不存在,满秩的异或矩阵总是存在逆矩阵的,异或矩阵解的存在性和秩也符合上述关联。

上面的描述比较抽象,我们可以具体的来看一看(这里用普通线性方程举例)

方程1
x0 * 1 + x1 * 2 = 4
x0 * 4 + x1 * 4 = 8

方程2:
x0 * 1 + x1 * 2 = 4
x0 * 2 + x1 * 4 = 6

方程3:
x0 * 1 + x1 * 2 = 4
x0 * 2 + x1 * 4 = 8

秩是什么意思呢,行秩(系数矩阵)代表了有多少个行,其系数不能通过前面的行的线性组合来得到。观察上面的三个方程:

  • 方程1的第二行的系数不能通过第一行的线性组合来得到,系数矩阵的秩为2。有两个未知量,方程的解唯一。
  • 方程2的第二行可以通过第一行✖️2来得到,因此系数矩阵的秩只有1。但是考虑增广矩阵就无法由第一行得到了,因此增广矩阵的秩仍为2,该线性方程组无解。
  • 方程3的第二行不论是系数矩阵还是增广矩阵,都可以由第一行得到,因此系数矩阵和增广矩阵的秩都为1,且小于未知数数量,该线性方程组有无穷多组解。

这是为什么呢?因为:

方程1
x0 * 1 + x1 * 2 = 4
x0 * 4 + x1 * 4 = 8
# 系数不能由前面方程得到,意味着这是一条独立的方程,可以参与求解。

方程2:
x0 * 1 + x1 * 2 = 4
x0 * 2 + x1 * 4 = 6 
# 系数可以由前面方程得到,但增广不行
# 意味着从前面的方程得到x0*2+x1*4=8,6=8矛盾,因此不存在解。

方程3:
x0 * 1 + x1 * 2 = 4
x0 * 2 + x1 * 4 = 8
# 系数和增广都可以由前面方程得到
# 意味着从前面的方程就可以得到x0*2+x1*4=8,这行方程去掉也不影响解的数量,等价于只有一行方程有意义。

上面的例子中,行秩就等于秩。如果行数大于等于未知数的数量呢?行秩还等于秩吗?前面的结论还成立吗?

高斯消元

上文讲了秩的定义和解的判定,但没有讲怎么求矩阵的秩,也没有讲到底怎么求出解来。事实上,这两件事都可以用同一个算法来求解,称作高斯消元。

先回忆我们求解线性方程组的消元法(还记得初中的N元一次方程吗……,没错就是那个)

方程1
x0 * 1 + x1 * 2 = 4
x0 * 4 + x1 * 4 = 8
第一行乘以2,得到
x0 * 2 + x1 * 4 = 8
第二行减去第三行,得到
x0 * 2 + x1 * 0 = 0
所以求解出x0 = 0,并可继续求解x1

高斯消元本质上就是把这个过程变成一个规范化的过程,它这么规定,首先通过消元把系数矩阵变成一个三角矩阵(即左下方三角区域全0)

方程1
x0 * 1 + x1 * 2 = 4
x0 * 4 + x1 * 4 = 8 <-- 减去第一行*4

x0 * 1 + x1 * 2  = 4
       + x1 * -4 = -8

第二步,把系数变成一个对角矩阵

x0 * 1 + x1 * 2  = 4   < --减去第二行*-0.5
       + x1 * -4 = -8
       
x0 * 1 +         = 0
       + x1 * -4 = -8

第三步,约去系数

x0 = 0
x1 = 2

从而完成方程的求解。

注意在第一步的时候,我们可能会遇到一些奇怪的情况,譬如:

         x1 * 2 = 4  <-- 这一行是x0系数为0,怎么办呢?
x0 * 4 + x1 * 4 = 8

只要x0在后续行的系数还有非0值,那就有很多办法可以解决这个问题,最常见的方法有两种:

x0 * 4 + x1 * 6 = 12  <-- 可以加上该行
x0 * 4 + x1 * 4 = 8

x0 * 4 + x1 * 4 = 8   <-- 可以交换这两行
         x1 * 2 = 4  

如果一个系数列在后续的所有行都为0呢?很不幸,这意味着这个未知量是一个“自由变量”,也就是说:

  • 系数矩阵的秩可能会小于未知量的个数,这个自由变量的取值不影响解的存在性
  • 如果后续继续进行第二步,就可以将这一行的所有系数都变成0
    • 此时常数列的对应行如果非0,就代表方程无解。
    • 如果所有“系数全为0的行”常数列也为0,就代表方程可能有无穷多组解
  • 如果存在解:
    • 一定存在一个“全部自由变量都为0”的特解
    • 这个“自由变量”以及它在之前方程中的系数*-1可以组成一个“自由基”,任何一个方程的解加上这个自由基的倍数都一定还是方程的解。
      • 特别的,如果每个自由基仅包含一个自由变量,那么这些自由基之间必然是线性无关的,自由基的数量 等于 自由变量的数量 等于 未知量的个数减去矩阵的秩

注意存在自由变量时,消元无法消到对角矩阵,自由变量所在列的上方可能还存在非0系数。这些非0系数与自由基相关。

我们来具体看一下方程2和3的高斯消元结果:

方程2:
x0 * 1 + x1 * 2 = 4
x0 * 2 + x1 * 4 = 6  <-- 减去第一行*2

x0 * 1 + x1 * 2 = 4
x0 * 0 + x1 * 0 = -2 <-- 所有系数为0,第二步不涉及此列。
# 常数列非0,代表方程无解。

方程3:
x0 * 1 + x1 * 2 = 4
x0 * 2 + x1 * 4 = 8 <-- 减去第一行*2

x0 * 1 + x1 * 2 = 4
x0 * 0 + x1 * 0 = 0 <-- 所有系数为0,第二步不涉及此列。
# 常数列为0,方程有无穷多组解,x1可以作为自由变量。
# 一定存在一组x1=0的特解,此时x0=4,x1=0
# 该列系数*-1 加上自由变量所在列为1 一组自由基,即为[-2, 1],意味着如果x0,x1是方程的解,对于任意的c,x0+(-2)*c, x1+1*c都是方程的解(称作通解)
# 该方程只有这一组自由基,意味着它的任何一个解都必须符合4+(-2)*c, 0+1*c

注意有的高斯消元算法的描述会消去这一行,得到的矩阵并非对角矩阵。这更利于笔算但不利于计算机计算,因此我们这里采用保留这一行,且对应行系数全部为0的做法。这两种做法是等价的。

对于满阶方阵来说,高斯消元法还可以用于求解方阵的逆矩阵。我们首先在系数矩阵右侧补充一个单位矩阵;然后可以注意到高斯消元的任何一部操作(不论是交换、线性叠加、约去系数)都等价于左乘一个矩阵 Pi,并且满秩矩阵高斯消元后可以得到一个单位矩阵(对角线系数为1,其余矩阵元全为0)因此操作过程相当于:

Pn*(...(P2*(P1* ([A, I]))))
 = Pn * ... * P1 * ([A,I])
 = [I, B]
设Pn*...*P1 = V,因此有:
V * [A, I] = [I, B]
可以得到:
V * A = I
V * I = B => V = B
因此B * A = IBA互为逆矩阵。

即使矩阵不满秩,也就是不存在逆矩阵,我们在消元过程中得到的“消元矩阵”仍然可以用于快速对一个新的问题消元,从而可以通过预计算降低每次判定的时间复杂度。可惜的是POJ的这几道题没有这样的应用机会。

上述过程和结论对于异或矩阵也都基本符合(但对于非质数的线性同余方阵,因为没有合适的除法运算,所以可能无法进行消元,也就无法应用高斯消元法了)。

但是对于异或矩阵来说,自由基的系数也只能是0或1,因此不是无穷多组解,而是pow(2, 自由变量数量)组解。

解题

在编程表示异或矩阵的时候,我们通常可以用一整个int来表示矩阵的一行(如果int位数不够的话,用多个int,或者使用STL的std::bitset 也可以!),这样我们可以用 AND运算 来快速运算 按位的乘法,用 XOR运算 来快速运算按位的异或。这使得异或矩阵的编程可以极其简练。

并且异或矩阵的取值只有0和1,因此不必进行高斯消元的第三步(约去系数)

   // 注意这里假设unsigned int足够表示一行。如果不足够的话,需要将行封装到多个unsigned int里并依次进行异或
   // 高斯消元的第一步
    for (int i = 0; i < N; i++) {
        unsigned int mask = (1U<<i);
        if ((A[i] & mask) == 0) {
            for (int j = i+1; j<N; j++) {
                if (A[j] & mask) {
                    // 这里采用线性叠加上一个非0行的方法,因为交换似乎开销更大
                    A[i] ^= A[j];
                    B[i] ^= B[j];
                    break;
                }
            }
        }
        if ((A[i] & mask) != 0) {
            for (int j =i+1; j < N;j++) {
               if (A[j] & mask) {
                    // 消去后面行的该列
                    A[j] ^= A[i];
                    B[j] ^= B[i];
                }
            }
        }
    }

    // 第二步
    for (int i = N-1; i>0; i--) {
        unsigned int mask = (1U<<i);
        for (int j = 0; j < i; j++) {
            if (A[j] & mask) {
            	// 仅当非自由时,消去前面行的该列。
                A[j] ^= A[i];
                B[j] ^= B[i];
            }
        }
    }

消元之后,我们可能会得到两种结果,或者得到一个单位矩阵(对角全为1),那么满秩&存在逆矩阵,或者部分行系数为0,那么不满秩(可能无解或有多组解)

具体到题目:

  • 1753: 有4个自由基,意味着要么无解 要么有16种解,自由基全为0的特解可以根据输入得到(可能无解),然后根据自由基遍历出所有的解并选择最佳的一个。可以切换到白或切换到黑,也可以理解为把问题本身白黑对调再算一遍(和0xffff异或),取最优解即可。
  • 1222:矩阵恰好满秩,因此可以直接用高斯消元法计算出逆矩阵,直接将输入乘以逆矩阵得到解。逆矩阵与问题输入无关,可以预先计算好。
  • 3279:题目的大小不确定,意味着满秩和不满秩都可能会遇到,也无法预先计算。因此统一采用高斯消元法直接求解,得到所有的自由基遍历出所有的解并选择最佳的一个。