「WTF」铁锅乱炖-实现篇

语言与环境

版本问题

由于 g++ 的版本与现行版本不一致,库的包含关系就不完全相同。这个时候如果缺少头文件也有可能会本地通过编译,但是提交后可能会 CE。

典型例子就是 Dev-C++cmath 还包含在 algorithm 里面。如果仅包含 algorithm 并且使用 cmath 内部的函数,在新版的 g++ 下编译就会报错。

迷之重名

  • 经典的“隐形”库函数 y0(double), y1(double), yn(int,double); j0(double), j1(double), jn(int,double),这几个小混蛋都在 cmath 里面,存在于编译器include 文件夹里的 math.h 里面。

    问题是,它们几乎找不到踪迹。如果去 C++ reference 里面查你根本找不到它们。

    如果在 Windows 环境下使用 9.3.0 版本的 g++ 编译也不会检查出问题,但是,但是,一旦包含 cmath 并且自定义了重名的变量/函数,那么在 Linux 环境下编译的时候才会 CE。😓

    去查了一下,发现 j.() 表示的是第一类贝塞尔函数,y.() 表示的是第二类贝塞尔函数。

初始化疑云

这里集中了一些由于不当初始化而导致的编译问题。

Compiler Explorer 上以 -std=c++17 编译,所以汇编码应该对应的是 Intel x86-64 的 CPU。

  • 经典错误一:使用大括号初始化数组:

    如果是平凡地直接使用大括号括起来,全部清空,那么初始化不会出问题:

    struct QwQ {
        // something  
    };
    
    int a[100000] = {};
    QwQ b[100000] = {};
    

    无论使用内置类型或自定义类型。

    补充:如果在局部使用空大括号初始化(甚至有可能是在全局)都有可能导致爆炸!!!问题是一样的,可执行文件巨大。

    但是,如果在大括号里面初始化了某些位置的值,那么会导致大括号被展开成完整的初始化列表(补上空白)。一个直接的表现就是编译出来的可执行文件非常大。当数组很大的时候效果尤其明显:

    int a[10000000] = { 1, 2 };
    
    // with a large *.exe appearing
    
  • 经典错误二:错误地使用结构体初始化:

    如果在结构体或类的初始化中对非内置类型数组进行初始化,无论如何赋初值,如下:

    struct Rubbish {
        int rub[1000];
        
        Rubbish(): rub{} {}
        // safe operation
    };
    
    struct RubbishBin {
        Rubbish a[1000];
        
        RubbishBin(): a{} {}
        // DANGEROUS operation
    };
    
    RubbishBin rb;
    

    只要对这样的类型进行实例化,那么在汇编码中,构造函数会被完全展开。在上面的这段代码中,实际效果就是对于 a 中每个对象都调用 Rubbish :: Rubbish()。直接看一下汇编码的样子:

    asm.png

    外在的表现就是编译时间变长,但是可执行文件大小正常,嗯。

    实际上,由于全局变量会自动清空(汇编码里面会有一个 .zero 的指令)所以写成:

    struct Rubbish {
        int rub[1000];
        
        Rubbish(): rub{} {}
        // safe operation
    };
    
    struct RubbishBin {
        Rubbish a[1000];
        
        RubbishBin()/*: a{}*/ {}
        // Fine
    };
    
    RubbishBin rb;
    

    也可以达到清空的效果,并且不会出问题。

    • 经典错误三:试图使用巨大的局部静态变量

      不知道为啥,使用 10^6 以上的 int 局部静态数组(或者相当的空间)都会导致编译出来的文件巨大。

      最搞笑的是,在本地似乎找不出这样的锅,但是交到 OJ 上反而会出问题,比如 CF 会报告 CE。

消失的操作符

在 2022.04.11,我遇到了一个神奇的问题。

struct Unit {
    int a, b;

    Unit(): a( 0 ), b( 0 ) {}
    Unit( int B ): a( 0 ), b( B % mod ) {}
    Unit( int A, int B ): a( A % mod ), b( B % mod ) {}

    inline operator bool() const {
        return a || b;
    }
};

inline int Qkpow( int, int );
inline int Inv( const int &a ) { return Qkpow( a, mod - 2 ); }
inline int Mul( int x, const int &v ) { return 1ll * x * v % mod; }
inline int Sub( int x, const int &v ) { return ( x -= v ) < 0 ? x + mod : x; }
inline int Add( int x, const int &v ) { return ( x += v ) >= mod ? x - mod : x; }

inline int& MulEq( int &x, const int &v ) { return x = 1ll * x * v % mod; }
inline int& SubEq( int &x, const int &v ) { return ( x -= v ) < 0 ? ( x += mod ) : x; }
inline int& AddEq( int &x, const int &v ) { return ( x += v ) >= mod ? ( x -= mod ) : x; }

inline int Qkpow( int base, int indx ) {
    int ret = 1;
    while( indx ) {
        if( indx & 1 ) MulEq( ret, base );
        MulEq( base, base ), indx >>= 1;
    }
    return ret;
}

inline Unit operator + ( const Unit &a, const Unit &b ) {
    return Unit( Add( a.a, b.a ), Add( a.b, b.b ) );
}

inline Unit operator * ( const Unit &a, const Unit &b ) {
    return Unit( Add( Mul( a.a, b.b ), Mul( a.b, b.a ) ), Mul( a.b, b.b ) );
}

inline Unit operator * ( const Unit &a, const int &b ) {
    return Unit( Mul( a.a, b ), Mul( a.b, b ) );
}

inline Unit operator / ( const Unit &a, const Unit &b ) {
    int inv = Inv( b.b ), q = Mul( a.b, inv );
    return Unit( Mul( inv, Sub( a.a, Mul( q, b.a ) ) ), q );
}

inline Unit& operator += ( Unit &a, const Unit &b ) {
    return a = a + b;
}

inline Unit& operator -= ( Unit &a, const Unit &b ) {
    return a = a - b;
}

在上面这段代码中,我定义了 Unit 类型,并重载了若干个 operator。以上能看到的就是所有重载了的 operator

你可以注意到,这里并没有出现 operator - ( const Unit&, const Unit& ) 这样的运算符,然而在 operator -= ( Unit&, const Unit& ) 的位置我们却“调用”了 operator - ( const Unit&, const Unit& )!并且,这样的代码可以正常的过编译,一点错误都不会报。

但是调试一下,你就会发现——在执行 a - b 的时候,程序的行为实际上是先进入到 Unit :: operator bool() 里面。那么,也就不难推测,这实际上是先进行了两次类型转换,再用 bool 计算 a - b,再构造一个 Unit 并赋值给 a

观察汇编码也可以得到同样的结果:

operator -.png

你可以看到,这里面调用了两次 Unit::operator bool() const

其它

  • const& 修饰符确实可以卡常,但是用起来有风险

    如果传入的参数在外部被更改,那么不可避免地,函数内部的值也会被改变。外部修改并不会因为 const 修饰符而被阻止。

    尤其需要注意传入重复使用的全局变量的时候,尽量别用 const& 修饰。

多组不清空

  • [CF1383E]Strange Operation

    同一变量多次重复使用,中间没有清空,直接暴毙:

    int lst = N + 1;
    for( int i = N ; ~ i ; i -- )
    {
        nxt[i][1] = lst;
      if( S[i] == '1' ) lst = i;
    }
    //这里本应该清空 lst 的
    for( int i = N ; ~ i ; i -- )
    {
        if( S[i] == '1' || S[i + 1] == '0' ) nxt[i][0] = lst;
        if( S[i] == '0' ) lst = i;
    }
    
  • 对于判定性问题,如果我们一旦搜索到符合要求的结果就退出,那么在多次搜索的情况下,一定要注意在退出之前有没有将公共内存清空。无论是使用 return 结束语句还是使用 throw 跳转都需要注意这个问题。

  • 「UR #21」 挑战最大团

    注意分治过程中,公共数组的清空,特别是在改写法的时候,不要忘了加上被修改结构的清空过程

  • 「CF505E」Mr. Kitayuta vs. Bamboos

    提醒:需要重复使用的函数,尤其是可能中途退出函数过程的,一定要注意在执行函数的一开始就要将要用的东西清空!!!

    这道题的 Chk() 因为中途退出的原因,中途存储用的 vector 在函数体一开头没有清空,导致最终会算错东西

    补充:怎么又是一模一样的问题?!在 NOI Online (2022) 也犯了。T2 找到答案之后直接退出,没有清空数组直接就爆炸了!!!

  • 某道联测题目:

    在转移的时候,本应在所有数据计算完之后再对不合法数据进行清理,结果边清理边计算,导致较小的不合法数据影响了之后的结果。

    处理方法:注意操作的顺序,不止是在 DP 的转移中,写的时候就应该注意到顺序的问题。

  • 貌似我遇到的清空问题都有一个共性:

    首先,写法是在有效的运算过程中立即清空空间

    其次,其中存在一些分支,会导致程序直接退出当前计算并且跳过清空流程

    然后,在这种情况下,我几乎必然会忘记在分支出口清空数组。

    结论是:要么,在所有会进行跳过的分治出口,比如 return, continue, break 处清空空间;要么,使用 goto

  • 泪目了。DFS 的时候,用标记数组来记录有没有走过,结果重新 DFS 之前搞忘清空了,直接导致我调了 2h。

    规避不清空的最好方法:在运行某段代码之前把所有要用到的东西全部清空一遍

  • FFT,NTT,FWT 用到的数组,清空一定要清到可能访问的上界覆盖式赋值不能替代清空的效果

  • 听说结构体线段树读写压力比数组线段树小。然后一写就写错了

    注意多次使用线段树时,每个结点需要将所有可能用到的成员变量清空。光靠上传信息清空不了标记

  • 「NOIP2022」种花

    居然被阴了......多组没有检查所有数组,结果有的数组没有清空,就寄了。

注意边界

  • 预处理应该按照值域为范围来清,结果只清到了点数范围。

  • 模拟赛中出现的 typo 。按理说应该是很常见的问题,我居然还在犯

    遍历 \(n\times m\) 的平面的时候,弄混了 \(n\)\(m\) 的关系,于是写错了循环边界,成功地挂分了:

    for( int i = 1 ; i <= n ; i ++ )   //这里的 n 应该是 m 
      ...
    

记清楚变量的含义,尤其是常见易混变量 \(n,m,x,y\) 之类的

  • 挑战多项式

    这次在预处理逆元的时候,恰好处理到了 \(2^{18}-1\) 的范围,但是实际调用时使用到了 \(2^{18}\) 的逆元,然后就爆炸了......

    处理方法:一定要注意,使用任何函数/循环/数组内存时,是否会越界或超出有效范围;此外,在对复杂度影响不大时也可以适当使用快速幂/exgcd 求逆元;

  • 「Gym102979L」Lights On The Road

    第一次写 K 短路,结果被坑到了两个地方:

    • 建图都建错了,头尾两个虚点不能连在一起,而 \(n=1\) 的时候我会连起来。这个写的时候确实没想到;

    • \(n=1\) 的时候,最短路树上没有非树边,这个时候堆为空。将空节点插进去做 K 短路就会 RE......

      这个问题更大一些,写的时候想到了这一点,但是没有想到堆会成为空,没有判。

      处理方法:注意边界情况,不要被细节卡了

  • 某道计数题。

    数据范围明确给出:

    mistake.png

    这就说明,输入数据可以有 \(n<k\)。但是我的程序,如果不特判 \(n<k\) 就会干出奇奇怪怪的事情,导致各种 WA。

  • 保证下标或指针指向的是有效的空间

    这里主要关注如何控制下标或指针不会越界,而不考虑空间开小了的情况。

    例如 「模板」最小表示法,如果给定的串是 aaa...aa 的形式,则需要判断长度指针 \(k\) 是否到达了 \(|S|\),否则会访问到有效的空间之外。

    又注:对于下标进行运算的时候也要注意是否会越界。例如,进行减法的时候需要确认是否会越过下界,进行加法的时候需要确认是否会超出上界。尤其是下标从 0 开始计算的时候尤其容易算错!!!

  • 经典错误:如果用 vector 实现多项式,那么在写加法的时候,很容易忘记边界,将任意位置的计算都写成 F[i]+G[i]。但实际上,如果 i 取遍所有有效位,那么下标很有可能越界,导致 UB,返回一些乱七八糟的值。

  • 阳间边界问题:左移超出边界Undefined Behaviour,比如不能算 1llu << 64,不然返回什么东西谁也说不清楚。

    顺便补充一下,右移超出边界也是 Undefined Behaviour,所以也别写。总之,写移位的时候一定要注意参数的范围

  • 使用公共内存(或者叫“内存池”)时的问题:

    • 长链剖分要注意指针移动方向不同,需要预设的空间也不同

      比如在 「POI2004」Hotel 中,\(g\) 转移时指针朝后移,所以空间得开两倍

    • 高维数组本质上是一维数组,只不过同时保存了多个指针。因此它们的低维是允许负下标的,只不过会访问到错误的值

  • 如果空间需要开 \(2\) 的整幂次,有可能会偷懒写成科学计数法的形式。这个时候一定要注意,实际开的空间不能小于需要的空间

    比如开 \(2^{19}\) 大小的数组,就不能近似地用 5e5 的空间替代,要开也得开 5.3e5

    此外,像这种只爆了一小段空间的,有可能不会告诉你 RE,而是因为访问到了莫名的位置而得到错误答案......

  • 如果有数组是倒着滚动访问的,并且还是多次使用,则一定一定要注意会不会超出清空的上界

    举个例子,多次滚动后缀和,并且每次的上界不一。这种情况下,如果滚动的上界是 \(n\),而清空的上界也是 \(n\),则会访问到错误的 \(n+1\) 号位!

  • 预处理的边界:“光速幂”

    也就是分块预处理幂次的算法。如果已知次数上界为 \(n\),则可以选择分块阈值 \(B=\lceil\sqrt n\rceil\)

    尤其需要注意的是,处理形如 \(a^{kB}\) 形式的幂的时候,\(k\) 应当要算到 \(B\)。因为有可能会出现 \(n=B^2\) 的情况。

  • 注意所有的边界都要找齐,边界必须足够紧。

    比如 「NOI2017」蚯蚓排队,由于必须只考虑跨过分割线的字符串,因此必须保证左端点不会越过分界线。如果忘了这条限制就会被卡很久!

  • 真的被 vector 恶心死了。

    写个分治 NTT 返回 vector 就算了,中途手动清了一下 0。结果因为某一项的 \([x^1]=0\),导致最终 vector 的结果比期望长度短一位,最后一个位置就莫名其妙读了一个脏数据。

    恶心坏了。刚刚超出 size 读一位也不会爆 capacity。就算要用 sanitizer 查出来也比较麻烦,谁会刻意去造一个 2 的整幂的长度来检查代码强度呢?

  • 如果不是粗估或者分析,不要做近似!更不要往小了近似!

    这个很危险,通常我们认为 \(2^{10}\approx 10^3,2^{20}\approx 10^6\),但是实际上这些估计都很危险——它们都把值估小了。特别是在开空间的时候,尤其不能随便估计!

  • 使用 vector 访问指定下标时,一定要注意这个下标是否在 vector 的空间范围内

    举个例子,做多项式运算,最后查 vector 指定项的时候,如果多项式长度不够就会读到 size 或者 capacity 之外,导致 RE 或者 WA。

实现有误或不精细

  • [HDU6334]Problem C. Problems on a Tree

    画蛇添足,本身不需要用 map 的地方偏偏使用了,导致程序及其慢, map 占用了将近 \(\frac 1 3\) 的时间。

    补充:可以使用 clock() 检查运行时间。

  • [CF446C]DZY Loves Fibonacci Numbers
    分块写法,清理标记的时候,没有判断有没有标记:

    void Normalize( int id )
    {
        //这里缺少了是否存在标记的判断
        for( int k = lef[id] ; k <= rig[id] ; k ++ )
            //......
    }
    

    最开始以为分块的标记和线段树类似,现在才意识到,分块下放标记的时间是 \(O(T)\),所以不判掉就会特别慢......

  • 求欧拉路一定要用当前弧优化

  • 对于编码方式比较复杂的问题,一定要注意各种编码是否正确转换了

    比如,二分图匹配的时候,经常会犯忘了特殊处理右部点标号的问题。

  • 注意,邻接表建图后,遍历边的顺序是与输入顺序相反的

  • 「Gym102268」Best Subsequence

    只要可以离散化,都建议写离散化之后再写线段树。权值线段树实在是太慢了......

    当年冰火战士也有这个诡异的坑点。

  • 「CEOI2006」 ANTENNA

    保证复杂度的剪枝写太弱了,导致复杂度是错的。

    需要注意写下来的代码(比如剪枝)是否和所想的相同。本质上还是在问思路是否清晰

    在洛谷上居然还可以卡过去,实在是误人子弟。

  • 「UOJ671」诡异操作

    nmd 这个东西是真的诡异。大一点的高维数组寻址奇慢无比,自带满打满算 x2 常数。

    警告:写比较大的高维数组的时候一定要谨慎,如果可以就开内存池,真的可以有效降低常数

  • 对某个数组进行平移(或者其它下标变换)时,并没能做到对于每个用到数组的位置都去修改下标,最终导致错误访问。

    这个问题解决起来比较麻烦,最好的方法还是一开始就确定好如何定下标,避免以后再去修改。

  • 少用大常数算法或者大常数数据结构

    包括但不限于:

    • 可以离散化但不离散化;
    • 可以不使用 STL 但非要使用 STL
    • 可以使用 BIT 等小常数数据结构但非要用分治等大常数算法;
    • 可以使用指针、链表等某些情境下 \(O(1)\) 结构但非要使用更慢的;
    • ......
  • 注意运算顺序

    包括但不限于:

    • 动态 DP 一定要先撤销再更新贡献

    • 高精度做比较时,应当是从高位到低维按顺序比较,而不是反过来。

      问题是,这个道理虽然简单,但是很容易弄错。

  • 关于时间戳清空:

    如果有多个东西都有可能清空,那么一定不要全局共用时间戳!!!写错了就有可能导致每次修改后所有内容都被清空一次......

    如果要用时间戳,则每个可能清空的东西都得开一个标记!。

    教训:「CF914F」Substrings in a String

  • 使用“维护前若干最值来达到'可删除'效果时”,需要注意更新最值和删除的元素是否都在同一集合中

    例如,进行点分治的时候,如果求最长路径,则需要保证两点不在同一棵子树。这个时候,维护的最大值和次大值必须是每个子树内先算出一个最值再更新,每个子树仅贡献一个值,而不是每个结点直接去更新。后者会导致剔除元素不完整,有可能出锅。

  • 写筛法需要注意的卡常方法:

    • 预处理,多多预处理:杜教筛需要预处理 \(O(n^{\frac 2 3})\)​ 的范围,最后复杂度是 \(O(n^{\frac 2 3})\)​ 的。但是如果遇到了分块套分块的情况,如果可以线性预处理一部分,则类似于杜教筛地预处理也可以得到 \(O(n^{\frac 2 3})\) 的算法
    • 加速数组访问:其一,尽量进行一维数组访问;其二,函数运算通常慢于宏运算,因此常用的 hash 整除值的方法最好使用宏而不是函数来计算下标(即使这样会略微多进行一次除法运算)。
  • 提醒一句,处理负数下标的时候要平移数组。容易在两个地方犯错误:第一是忘记了下标可以是负数;第二是忘了平移或者平移范围不够,还是爆炸。

  • 使用扩展欧拉公式降幂的时候,注意可能会出现如下情况:

    当我们计算 \(a^{b^c}\bmod p\) 的时候,如果 \(b^c\ge \varphi(p)\) 我们需要保留 \(b^c\mod \varphi(p)+\varphi(p)\)

    然而一般计算过程中我们会让 \(b\) 先去模掉 \(\varphi(p)\)。这样可能出现 \(b\ge \varphi(p)\)\((b\bmod \varphi(p))^c<\varphi(p)\) 的情况,就会算错!

    总结一句就是:不仅是算幂的时候取模要小心,对于底数的取模也要特别小心

数据杂糅

  • 「CF916D」Jamie and To-do List

    题目本身并不难,但是要注意,由于我们将值存储在 Trie 的末尾,所以被删除了的节点本质上就是变成了空节点。因此,空节点存储的值应该等于被删除了的值。例如,如果空节点值为 0,那么被删除的节点的值也应该是 0

    否则,Trie 的结构就可能导致将空节点判断为有值的节点的错误出现(例如,插入一个较长串,并查询它的前缀)。

    处理方式:同类标记尽量统一

  • 线段树(以及任何静态结构)上,如果需要删除结点,那么在维护最值的时候我们一般会将要删除的结点赋成一个极值。

    但是一定要注意,这里的极值应当区分初始化的极值,尤其是在需要同时取出极值所在的位置的时候。这里就需要明确:初始化所赋的极值是可以取的,但是删除所赋的极值就是为了避免取到的

  • 类似的,一些情况下,如果要限制一些值是无效值,我们可能会用 \(\pm \infty\) 来替代,而不能用可取到的极小值

运算

变量类型

  • 「LOJ 6207」米缇

    整除分块的时候,使用中间变量记录取整除的值,但这个中间变量没有开 long long

    for( long long l = 1, r ; l <= n ; l = r + 1 ) {
        int val = n / l; // 就是这里!!!应该开 long long!!!
    }
    
  • 「SPOJ」MAXOR

    答案的范围是 \([0,2^{31})\),所以计算 \(L,R\) 的时候,如果中途不取模可能会爆 int

    处理方式:涉及到的变量如果非常之大,每一步都取模是必要的;对拍的时候也应该造完全极限的数据

  • [BJWC2010]次小生成树

    最终求出来的次小生成树的答案可以达到 \(10^{14}\),但是最大值只开到了 \(10^9\)

    处理方法:注意,不同的数据类型应该单独设计最大值,最大值尽量不要多个数据类型通用。

  • 「CF1442D」Sum

    最开始,给每个数组开一个 vector,里面存的是真实值,所以范围是 \([0,10^8]\),用 int 完全装得下。

    后来,发现不应该装真实值,应该装前缀和,所以就改了内容,没改类型,范围变成了 \([0,10^{14}]\)int 就会爆炸。

    这样的问题应该在最初编写期就解决,这说明思考还不完全就着急写代码了。

  • 快速幂的指数、求逆元的数这样参数,很容易忽略数据范围是什么。结果就有了 long long 开成 int 之类的惨剧发生。

  • 斜率优化等可能涉及超大数的运算,如果对于精度要求不高,最好都用上 long double

    想一想:long double 可能有精度误差,但是 long long 一旦爆了,符号直接就错了。像斜率优化这种和 0 比较的运算很多的情景,用 long double 显然比 long long 要安全得多!


    然而这一条需要补充。double 的准确表示范围和 long long 差不多,但是一旦爆了之后,误差就挺大的。尤其是如果低位全部被溢出给去掉了的话,比大小的时候就比较尴尬。

    结论是:开类型之前先估计一下范围,太大的时候用 long double,如果 long long 开得下那就用 doublelong long。当然,现在用 __int128 应该也行

  • 注意存储 hash 值的变量类型。有可能 int 装不下的 hash 值还习惯性地用 long long 存了。

    更搞笑的是,注意更改 hash 值类型。hash 的范围变更之后,很可能出现范围扩大但是值的类型忘了改变的错误。改一两个地方可能还改不对,就有点烦人。

    最好是 typedef 或者 using 统一一下变量名称。

  • 注意运算过程是否会溢出。例如,在目标最大可达 \(2\times 10^9\) 时二分的话,两个边界指针可能在加法时溢出

    某一变量绝对值最大值为 \(L\) 时,执行 \(|x|>\frac{L}{2}\) 的加法需要注意溢出,执行 \(|x|>\sqrt L\) 的乘法也需要注意溢出。

取模安全

其实就是计算过程中,尤其是取模,很容易写着写着就忘记取模了。

简单的加减乘除还比较容易记住,但是进行像自加、自减、赋字面量这样的运算的时候很容易忘记取模。

比较安全的做法是:

  • 封装几个函数替代常用的运算,然后在函数内部取模。运算时强制使用它们
  • 封装模域类,然后只用这个类运算。这个对于多模数的情况比较友好;

常见的问题有:

  • 注意特殊的模数,尤其是题目输入模数的时候,注意模数是否可能为 1

    例如,有的题目取模的模数是单独输入的,特别注意模数可否为 1,特别注意赋的初始值是否落在正确的范围内,不确定可以取一下模

    没有把握就在输出取模,虽然这看起来也只是权益之计

  • 注意模数到底是不是质数。如果模数是输入的,则需要注意。同时,如果模数确定但并非常见的质数,也一定要验证一下它到底是不是质数。

    毕竟,是质数与否差别很大!

  • 又是不定模数的问题。

    当模数比较小的时候,计算组合数 \(\binom{n}{m}\) 是比较危险的,尤其是\(n,m\) 有可能大于等于模数的时候

    此时,组合数必须使用 Lucas 定理来计算,甚至还需要用上扩展 Lucas,这是后话。

浮点数

  • 「Gym102798E」So Many Possibilities...

    实数概率 DP,因为 \(\epsilon\) 设得太大,导致用它判空状态的时候将有效状态也判成空,漏了不少结果,答案偏小......

    用实数算 DP 的时候,没有必要用上 \(\epsilon\)。需要用来卡常的时候,算好可能的系数量级,然后反推 \(\epsilon\) 的大小;宁可设得小一点,也不要漏掉有效状态。不要再出现这样不知所谓还被卡了半天的错误!


    「SDOI2017」硬币游戏

    比较特别的情况。这道题里面会出现 \(2^{-m}\) 这样极小的系数,如果再加上 \(\epsilon\) 的因素,有的行可能根本就不会参与消元!

    总结起来就是:\(\epsilon\) 比较不是必要的,尤其是对精度要求很高的时候!

  • 模拟赛题目。

    需要对浮点数进行 \(10^6\) 组运算,每组运算 +,-,*,/ 都齐了。

    结果就出意外地爆掉了 double 的精度,只有开 long double 才能通过。

    注意 double 在大数小数混合运算时的精度问题!!!

  • 注意任何数学运算是否安全

    例如 「POI2011 R1」避雷针 Lightning Conductor 这一题,需要注意的细节是,实值函数 \(a_j+\sqrt{|i-j|}\) 才具有单调性。虽然实际求答案的时候,\(a_j+\sqrt{|i-j|}\)\(a_j+\lceil\sqrt{|i-j|}\rceil\) 是一样的,但是前者是实值函数,后者是整值函数。而在整值函数上单调性已经被破坏了,因此只要涉及到与决策有关的比较,都必须用实值而非整值。

  • 浮点数遇到除法就应当注意,是否会除以 0

    有的时候可能根本想不到......

    比如说「JSOI2004」平衡点,对于某个向量计算 \(\vec e\) 的时候,没有注意的话就可能遇到 \(\vec 0\),就会 RE 或者算出 nan

  • 对于大数慎用 doubledouble 系函数。

    譬如在 \(|x|\le 10^{18}\) 的范围下,使用 floor(x) 会有明显的精度误差,所以尽量不要用 floor()double 真正存数字的 bit 甚至少于 long long,所以单独存整数的话精度比 long long 还差。

    实际上计算 \(\lfloor\frac{n}{m}\rfloor\) 可以根据 C++ n/m 的计算方式进行修正,从而规避 floor()

STL 的奇妙特性

  • 众所周知,为了使操作更麻烦简化操作,STL 的 setmap 等关联容器内部基本上都提供了 reverse_iterator 这种迭代器。

    从字面意思就可以知道,这种迭代器是逆向访问容器的。比如 set 默认使用 < 比较,如果用 reverse_iterator 来访问它则会从大到小遍历 set 的元素。相应地,容器也会有 rbegin()rend() 这样的函数,一看就懂了。

    问题是,reverse_iterator 的运算也是和 iterator 呈镜像对称。比如 ++ reverse_iterator,那么从 set 原本的顺序来看,就相当于向变小的方向移动,反过来也类似。

    如果和 iterator 混用就很容易弄错,比如今天上午我就调了 0.5 h。

    此外,一般来说容器的 erase() 都只会接受 iterator。既不可以往里面丢 reverse_iterator,也并不存在 rerase() 这样的函数。

  • 对于 vector 这样使用 random access iterator 的容器,一般来说使用迭代器访问的效率低于下标访问

    另外,如果用 for( x : y ) 这样的循环,那么内部是使用迭代器实现的,因此需要注意大数据下的运行效率

  • set 如果删除不存在的元素,那么它不会 RE,而是会直接略过这一次操作。相当于是它会自己检查元素是否存在。

  • vectorresize() 只会对新添加的元素执行构造函数(或者进行指定的初始化,如果存在第二个参数的话),不会修改已有的元素。

  • STL 真的真的真的很慢!!!,如果能自己手写一些替代的数据结构,就少写一点 STL!!!

  • 关于新库 <random> 中的函数

    uniform_int_distribution 这样的分布类,它需要接受一个类型,并且存在默认参数为 int(应该是 int)。

    但是,如果在生成 int 类型的变量时经常偷懒,则在生成其它类型是很容易忘掉填写类型。这样的后果是,在某些 OJ 上编译执行会得到 MLE 的结果实质上写了一个 UB。

    最离谱的是,这样的错误在 Windows 和 NOI Linux 2.0 下面都不会报错(因为编译器难以检查),所以只能防而很难治。

    不过在 NOI Linux 2.0 下编译并用 gdb 调试似乎还是可以发现这个问题的。但是不用虚拟机就比较麻烦了。

  • vector 的“动态”实现方式:

    只考虑元素增多的情况。当 size 超过 capacity 的时候,vector 会新开一段长度两倍于当前 capacity 的内存,然后给旧的元素全部搬家到新的内存上,然后再加入新元素。

    这就意味着,如果遇到了 vector 扩容的情况,则旧的迭代器、旧的指针会全部失效。因此,不建议长期保存变化的 vector 的指针或迭代器,保存下标倒是可以的。

读题有误

  • 「SGU176」Flow Construction

    有点奇怪。题目里面说 There is no pipe which connects nodes number 1 and N,但是它的真正含义是不存在从 1 流向 \(n\) 的管道,但是可以存在从 \(n\) 流向 1 的管道。这个时候就会出现环流,因此需要建立新的源点与汇点,避免“源”和“汇”出现环。

    思考的时候要细致一点,每个方面都要想到;同时对于题目理解要清晰到位,结合好题目前提与背景。

  • 「NOI Online 2022」讨论

    丢人啊

    直接看到一个 \(0\le k_i\le n\),就以为是“所有的问题编号都落在 \([1,n]\)” 里面了。草我究竟是怎么联想到它的

    然后直接不搞离散化,成为挂掉 100pts 的 nt 选手。

其它

  • 「POJ3155」Hard Life

    奇妙的题目。构造割的时候,如果源点的出边全部满流了,那么一种割就是 \(\newcommand\set[1]{\{#1\}}[s,V\setminus \set{s}]\),然而这显然不是我们想要的。

    为了避免这种情况,我们必须调整答案,使得源点的出边略有剩余容量;具体操作起来就是让答案变小一点,而后从 \(s\) 开始遍历寻找一组割。

  • 任何时候,使用 DFS 传回 bool 信息表示某个过程是否已经结束时,应该在任何一次递归调用结束之后,查询返回值并判断是否应该结束过程。否则会导致各种乱七八糟的问题

  • 多组数据,注意输出换行!!!

    不是开玩笑,尤其是在用 cout 输出的时候。由于不常用,就很容易忘记输出 endl

  • [CF1408E]Avoid Rainbow Cycles

    最大生成树,运算符直接重载为了小于。

  • 【UR #2】跳蚤公路

    循环变量用上了不常用的名字,结果之后就写错名字:

        for( 对 i 循环 )
            for( int u = 1 ; i <= n ; u ++ ) //......
    

    处理方式:尽量规避奇怪的循环变量名称

posted @ 2022-02-12 16:00  crashed  阅读(148)  评论(0编辑  收藏  举报