数据结构与算法MOOC / 第十二章 高级数据结构 练习题 9:Training little cats(不构造矩阵的终极优化)

数据结构与算法MOOC / 第十二章 高级数据结构 练习题 9:Training little cats(不构造矩阵的终极优化)

  • AC代码
  • 解题思路
    • 变换矩阵的构造
    • 变换矩阵的压缩
    • 压缩形式的矩阵算法
      • 矩阵的表示
      • 矩阵乘法
      • 矩阵与向量的乘法
    • 复杂度分析
    • 为什么是这样?
    • 其他

题目链接

AC代码

#include 

#define swap(a, b) if ((a) != (b)) (a) ^= (b), (b) ^= (a), (a) ^= (b)

typedef unsigned long long uint64;

struct Matrix {
    uint64 b[101];
    int A[101];
} mat, matBuff;

uint64 results[101], buff[101], n, K, a, b, m;

char cmd[2];

void setMAT() {
    for (int i = 0; i <= n; ++i) {
        results[i] = 0;
        buff[i] = 0;
        mat.A[i] = i;
        mat.b[i] = 0;
    }
    for (int i = 0; i < K; ++i) {
        scanf("%s", cmd);
        switch (*cmd) {
            case 'g':
                scanf("%llu", &a);
                ++mat.b[a];
                break;
            case 'e':
                scanf("%llu", &b);
                mat.A[b] = 0;
                mat.b[b] = 0;
                break;
            case 's':
                scanf("%llu%llu", &a, &b);
                swap(mat.A[a], mat.A[b]);
                swap(mat.b[a], mat.b[b]);
                break;
            default:
                break;
        }
    }
}

void matLeftMul(Matrix a, Matrix &b) {
    matBuff.A[0] = 0, matBuff.b[0] = 0;
    for (int i = 1; i <= n; ++i) {
        matBuff.A[i] = b.A[a.A[i]];
        matBuff.b[i] = a.b[i] + b.b[a.A[i]];
    }
    for (int i = 1; i <= n; ++i) {
        b.A[i] = matBuff.A[i];
        b.b[i] = matBuff.b[i];
    }
}

void vecLeftMul(Matrix a, uint64 *b) {
    *buff = 0;
    for (int i = 1; i <= n; ++i)
        buff[i] = a.b[i] + b[a.A[i]];
    for (int i = 1; i <= n; ++i)
        b[i] = buff[i];
}


void calcResults() {
    while (m) {
        if (m & 1) vecLeftMul(mat, results);
        matLeftMul(mat, mat);
        m >>= 1;
    }
}

int main() {
    while (scanf("%llu%llu%llu", &n, &m, &K), n | m | K) {
        setMAT();
        calcResults();
        for (int i = 1; i <= n; ++i)
            printf("%llu ", results[i]);
        putchar('\n');
    }
}

解题思路

这是一道经典的问题,来自北京大学2009年校内赛。很早以前见过这道题,然后在查到了很多构造矩阵的方法。感觉北大挺喜欢数学类的问题的(毕竟是主打理科)。

这是笔者第一次写快速幂算法,写起挺快的,然后果不其然地就掉入稀疏矩阵的陷阱,被恭喜TLE,然后被提示需要对稀疏矩阵的乘法进行优化。发现网上大多数优化方法都很简单,记录一下每一行每一列是否全0,这样求解的时间代价从 O ( n 3 log ⁡ m ) O(n^3 \log m) O(n3logm)变为了 O ( n 2 log ⁡ m ) O(n^2 \log m) O(n2logm),就可以AC了。但是既然是稀疏矩阵,正好最近刷了些图论的题,就想试试用十字链表练练手吧。写着写着,突然灵光一现,发现了一个进一步把时间代价优化到 O ( n log ⁡ m ) O(n \log m) O(nlogm)的方法。

变换矩阵的构造

根据恭喜我TLE的那篇文章的说法,我们可以把原问题写成这样的形式:
( A b O 1 ) m × ( O 1 ) = ( x ′ 1 ) \begin{pmatrix} A & b \\ O & 1 \end{pmatrix}^m \times \begin{pmatrix} O \\ 1 \end{pmatrix} = \begin{pmatrix} x' \\ 1 \end{pmatrix} (AOb1)m×(O1)=(x1)
其中 A A A n × n n\times n n×n矩阵, b b b x ′ x' x都是 n n n维列向量, O O O代表全0填充,我们要求的就是 x ′ x' x

比如题目中给出的sample input,就可以写成:
( 0 1 0 2 0 0 0 0 0 0 1 1 0 0 0 1 ) 1 × ( 0 0 0 1 ) = ( 2 0 1 1 ) \begin{pmatrix} 0&1&0&2\\ 0&0&0&0\\ 0&0&1&1\\ 0&0&0&1\\ \end{pmatrix}^1 \times \begin{pmatrix} 0\\ 0\\ 0\\ 1\\ \end{pmatrix}=\begin{pmatrix} 2\\ 0\\ 1\\ 1\\ \end{pmatrix} 00001000001020111×0001=2011
所以最终输出结果是2 0 1,如果 m m m变成了100,那么输出结果就是2 0 100,因为
( 0 1 0 2 0 0 0 0 0 0 1 1 0 0 0 1 ) 100 × ( 0 0 0 1 ) = ( 2 0 100 1 ) \begin{pmatrix} 0&1&0&2\\ 0&0&0&0\\ 0&0&1&1\\ 0&0&0&1\\ \end{pmatrix}^{100} \times \begin{pmatrix} 0\\ 0\\ 0\\ 1\\ \end{pmatrix}=\begin{pmatrix} 2\\ 0\\ 100\\ 1\\ \end{pmatrix} 0000100000102011100×0001=201001

变换矩阵的压缩

笔者注意到的是这样一个事实: A A A中大多数元素都是0,更具体地,A中每一行最多有一个元素是1,其它元素都必为0——这是因为 A A A在初始状态下是一个 n × n n\times n n×n的单位矩阵,而对 A A A的变换只有3种可能:

  • 两只小猫交换花生,相当于 A A A交换两行
  • 一只小猫吃光花生,相当于 A A A某行 × 0 \times 0 ×0
  • 一只小猫拿一个花生,相当于把变换矩阵的第 n + 1 n+1 n+1行加到其它行,而第 n + 1 n+1 n+1行的前 n n n个元素全为0,相当于 A A A没变

因此 A A A的每个行向量或为全0,或只有一个分量是1(剩下全0)

这说明 A A A可以进行这样的压缩存储为一个 n n n维的向量,对每一个 k k k A A A的第 k k k的分量代表 A A A的第 k k k行中1的列索引(如果该行没有1,则第 k k k维为0)

仍然用题目的样例数据举例,这次,我们可以把变换矩阵压缩成:
( 0 1 0 2 0 0 0 0 0 0 1 1 0 0 0 1 ) 1 → A = ( 2 0 3 ) , b = ( 2 0 1 ) \begin{pmatrix} 0&1&0&2\\ 0&0&0&0\\ 0&0&1&1\\ 0&0&0&1\\ \end{pmatrix}^1 \rarr A = \begin{pmatrix} 2\\ 0\\ 3\\ \end{pmatrix}, b = \begin{pmatrix} 2\\ 0\\ 1\\ \end{pmatrix} 00001000001020111A=203,b=201

压缩形式的矩阵算法

上述存储的一个好处当然是节省空间,从 ( n + 1 ) 2 (n+1)^2 (n+1)2个整数变成 2 n 2n 2n个整数,空间代价从 O ( N 2 ) O(N^2) O(N2)变为 O ( N ) O(N) O(N)

此外,笔者惊喜地发现,此后所有跟矩阵相关的操作——包括矩阵-矩阵乘法和矩阵-向量乘法——时间代价也全部变为线性 O ( N ) O(N) O(N)

这些算法的代码实现很简洁,在此省略证明过程,直接上代码自行参悟

矩阵的表示

struct Matrix {
    uint64 b[n + 1];
    int A[n + 1];
}; // 简化代码,与原代码不完全一致

矩阵乘法

Matrix mat_mat_multiply(Matrix m1, Matrix m2) {
    Matrix result;
    result.A[0] = 0, result.b[0] = 0;
    for (int i = 1; i <= n; ++i) {
        result.A[i] = m2.A[m1.A[i]];
        result.b[i] = m1.b[i] + m2.b[m1.A[i]];
    }
    return result;
} // 简化代码,与原代码不完全一致

矩阵与向量的乘法

uint64 *mat_vec_muliply(Matrix m, uint64 *v) {
    uint64 *result = new uint64[n + 1];
    *result = 0;
    for (int i = 1; i <= n; ++i)
        result[i] = m.b[i] + v[m.A[i]];
    return result;
} // 简化代码,与原代码不完全一致

此外,初始化一个单位矩阵只需要 O ( n ) O(n) O(n)的时间代价,对矩阵的 k k k次初等变换,则更是每一次都只需要 O ( 1 ) O(1) O(1)的时间代价,这些算法的实现都比较平凡,见AC代码中的setMAT函数。

复杂度分析

时间上,一次计算的步骤如下:

  1. 初始化变换矩阵—— O ( n ) O(n) O(n)
  2. 对矩阵进行 k k k次初等变换,即小猫的 k k k次吃拿换—— k ⋅ O ( 1 ) = O ( k ) k\cdot O(1) = O(k) kO(1)=O(k)
  3. 利用快速幂算法,求解变换矩阵的 m m m次幂—— O ( log ⁡ m ) ⋅ O ( n ) = O ( n log ⁡ ( m ) ) O(\log m)\cdot O(n)=O(n\log(m)) O(logm)O(n)=O(nlog(m))
  4. 输出结果—— O ( n ) O(n) O(n)

所以总时间代价是
O ( k + n log ⁡ ( m ) ) O(k+n\log(m)) O(k+nlog(m))

此外,只需要常数个压缩表示的变换矩阵即可完成上述所有4个步骤,因此空间代价是 O ( n ) O(n) O(n)

这一效果是显著的,大多数AC的提交代码的运行时间都是20~600ms,而本代码只需要4ms

为什么是这样?

笔者在代数结构课上曾学到过,复数有一种实矩阵的等价表示形式:
a + b i = ( a b − b a ) a+b\rm{i} = \begin{pmatrix}a&b\\-b& a \end{pmatrix} a+bi=(abba)
这种形式可以把矩阵的四则运算和向量的四则运算统一起来,另一种叫四元数的数系扩充也有它的 4 × 4 4\times 4 4×4实矩阵表示形式。这种扩充说明了这些代数结构具有矩阵变换的特性。此外, n n n元全排列置换群的变换亦可用 n ! n! n! n × n n\times n n×n的01矩阵来表示。

矩阵是个好东西,许多能够做运算、形成代数结构的数据结构都可以映射到实矩阵集的一个子集中,分析其运算特征。但大多数情况下,这样的映射都徒增了数据结构的复杂程度。比如上述3个例子,分别将数据量的大小从2变为4、从4变为16、从 n n n变为 n 2 n^2 n2;并且众所周知,矩阵运算——尤其是矩阵乘法,是极其消耗时间的运算。

因此,相比于矩阵,原始的、没有冗余扩充的数据结构——2个double表示的复数、一个 n n n元数组表示的 n n n元全排列,对于计算性能是更友好的。

本题的变换矩阵就是实矩阵集的一个子集,而本算法则是将这个子集“恢复”成了一个更为“原始”的数据结构,这个数据结构仍然能进行满足结合律的、可以使用快速幂算法的“乘法”。

对于一些更特殊的问题,有时可能连快速幂本身都是累赘,比这道全排列问题,也可以看成快速幂问题,但更多人可能还是会选择用置换群的轮换特性来解决,直接把快速幂的 log ⁡ m \log m logm变成常数(当然,如果你认为除法取余数是对数时间代价我也没办法),这道题中 A A A实际上有点置换群的感觉。

其他

#define swap(a, b) if ((a) != (b)) (a) ^= (b), (b) ^= (a), (a) ^= (b)

这个东西是郭炜dalao在MOOC上教的,不用中间变量直接交换两个数的值。

记得学java的时候老师说java里面基本类型只能传值,所以想交换两个整数会很麻烦,然后给我们介绍了一套乱七八九十糟(反正我记不住)的方法实现swap。当时我就想起了这个算法,然后呵呵一笑:)

位运算这东西,确实别有一般洞天

以及,这个题里面,一只喵最多可能拿到1011颗花生米(祝它健康),这个数比231-1、232-1都大,所以需要开64位整数(long long)数组来存储结果.


因为是自己独立想出来的算法,而且在AC之前莫名TLE了很多次,所以笔者做出来这道题稍微有点兴奋,扯的皮偏多一些,见谅>_<。

你可能感兴趣的:(数据结构与算法MOOC / 第十二章 高级数据结构 练习题 9:Training little cats(不构造矩阵的终极优化))