2014亚洲区鞍山H题研究

作者: | 更新日期:

亚洲区比赛又开始了,于是看了看鞍山现场赛的题,发现 H题挺有意思的,于是研究了一番。

本文首发于公众号:天空的代码世界,微信号:tiankonguse

题目

题目具体看 题目

简单的说就是告诉你三个01数对应的答案表, 让你求最少的步骤来得到这个答案表.

每个步骤只能使用 NAND(a,b) 操作.

NAND(a,b) 操作代表 !(b&&c) 运算.

问题转换

我们单独一位一位的看, 看不出眉目来. 但是整体来看, 即传入的三个数不是01数字, 而是三个8位01的数字, 那么经过 NAND(a,b) 操作, 返回8位要求的答案表即可.

这时 NAND(a,b) 操作 操作就 ~(b&c) 了.

开始搜索

问题转化后, 发现使用一个优先队列搜索就行了.

8位整数类型

但是第一步是定义一个8位的数字.

typedef unsigned char uchar;

结构体封装

然后定义结构体如下

struct T {
    vector<uchar>str;
    int n;//起初用的 int[]数组
    int ans;
    bool operator<(const T t)const {
        return ans > t.ans;
    }
};

字符串转化8位整数

//00010011 => 19
uchar toUchar(const char*  str) {
    uchar ans = 0;
    while(*str) {
        ans = (ans<<1) | (*str++ -'0');
    }
    return ans;
}

NAND 运算

inline uchar NAND(uchar a, uchar b) {
    return ~(a&b);
}

初始化

有了这些准备, 我们就可以开始搜索了.

不过搜索前需要准备一下初始条件.

由于 NAND 可以有常量, 即0与1, 转化为整数也就是0 与255了. 再加上a,b,c三个常量, 我们共有5个常量.

int a,b,c, zero, one;
a =    toUchar("00001111");
b =    toUchar("00110011");
c =    toUchar("01010101");
zero = toUchar("00000000");
one =  toUchar("11111111");

另外队列的第一个元素需要有上面5个常量, 于是把他们加进去.

我把这个初始化封装在 T 中了, 虽然只能初始化一次.

另外还封装了一个动态添加元素的 add 函数.

struct T {
    void init() {
        str.clear();
        n = 0;
        ans = 1;
        add(a);
        add(b);
        add(c);
        add(one);
        add(zero);
    }
    void add(uchar a) {
        str.push_back(a);
        n++;
    }
};

直观优化

have 优化

当一个元素出队时, 假设有n个不同的数字, 则会搜出 C(n,2) 个数字, 我们需要加入不在n个数字中的数字.

于是我在 T 中加入一个 have 数组.

struct T {
    bool have[N];
};

但是后来发现内存不够, 于是把 have 数字提取出来, 每次出队时生成 have 数组.

now = que.top();
que.pop();
tmpn = now.n;

memset(have, false, sizeof(have));
for(int i=0; i<now.n; i++) {
    have[now.str[i]] = 1;
}

这样, 循环的得到新的数字后只需要判断一下是否重复已经存在即可.

for(int i=0; i<tmpn; i++) {
    for(int j=i+1; j<tmpn; j++) {
        tmpNAND = NAND(now.str[i], now.str[j]);

        if(have[tmpNAND]) {
            continue;
        }
        have[tmpNAND] = true;
        
        //其他代码...
    }
}

map 优化

后来发现, 得到数字 a1,a2 和得到数字 a2,a1 是等价的, 但是由于在不同的分支中, 我们不能直接去重, 所以我加了一个map来判断.

数字范围为0~255,一字节可以储存8位, 所以需要 256/8 = 32 字节的字符串来保存.

又由于字符串以0为结尾, 所以我们不能有0这个字节, 所以我采用一个字节用7位来保存信息,最高位至1, 这样有 256/7 = 37 字节.

map<string,bool>bit;

void toString(const T&t, char* str) {
    int now;
    for(int i=0; i<t.n; i++) {
        now = t.str[i];
        str[now/7] |= 1<<(now % 7);
    }
}

bool haveString(const T& t) {
    char str[50];
    memset(str, 0, sizeof(str));
    toString(t, str);
    for(int i=0; i<38; i++) {
        str[i] |= (1<<7);
    }
    if(bit.find(str) == bit.end()) {
        bit[str] = 1;
        return false;
    } else {
        return true;
    }
}

for(int i=0; i<tmpn; i++) {
    for(int j=i+1; j<tmpn; j++) {
        tmpNAND = NAND(now.str[i], now.str[j]);

        if(have[tmpNAND]) {
            continue;
        }
        have[tmpNAND] = true;

        newT = now;
        newT.add(tmpNAND);

        if(haveString(newT)) {
            continue;
        }
    }
}

时间上的一个小优化

上面的have 数组从结构体中提取出来, 然后又加了map后, 发现跑的挺慢的, 于是向能不能提高速度, 发现有个地方可以优化.

放出队后, 我们又n个数字, 然后会双层循环去得到所有的合法值.

其实这n个值中, 假设标号为0到n-1,只有第n-1个数字数新加进入的.

也就是说前面的n-1个数字可能已经两两运算过了.

所以我加了一个直接从起始位置开始循环的优化

struct T {
    int start;//默认值0
}
for(int i=now.start; i<tmpn; i++) {
    for(int j=i+1; j<tmpn; j++) {
        tmpNAND = NAND(now.str[i], now.str[j]);

        if(have[tmpNAND]) {
            continue;
        }
        have[tmpNAND] = true;

        newT = now;
        newT.add(tmpNAND);
        newT.start = now.start+1;

        if(haveString(newT)) {
            continue;
        }

        if(tmpNAND == d) {
            return now.ans;
        }
        que.push(newT);

    }
}

但是得到的答案是错误的, 经过分析发现循环内, 对于新加入队列的值来说, 只新增了一个值.

意思也就是虽然依旧有n-1个旧值, 对应着 C(n-1, 2) 可能值, 但是产生新值时还有很多是没有遍历的, 即有些值被遗漏了.

那怎么做才能做到 now.start 之前的值全部已访问过, 或者不遗漏未访问的呢?

发现调整一下循环, 就可以做到了.

for(int i=now.start; i<tmpn; i++) {
    for(int j=0; j<i; j++) {
        tmpNAND = NAND(now.str[i], now.str[j]);

        if(have[tmpNAND]) {
            continue;
        }
        have[tmpNAND] = true;

        newT = now;
        newT.add(tmpNAND);
        newT.start = i;

        if(haveString(newT)) {
            continue;
        }

        if(tmpNAND == d) {
            return now.ans;
        }
        que.push(newT);

    }
}

我们对于now.start之前的都遍历过了, 所以没必要再次对now.start之前的遍历了, 而对于没有遍历的和新增的, 之后还会遍历.

这样优化后复杂度有 O(n\^3) 降为 O(n\^2) 了.

不过综合复杂度还是很大很大. 这个只算是常数优化, 因为 n 目前最大是10.

目前的结果

经过上面的优化, 对于0到255这些值, 只有104不能跑出来, 其他的全部可以跑出来了.

数据可以参考这里 打表数据 .

ps: 2014-10-20 19:48:00 已经把所有结果跑出来了,详见这里 完整数据1

还可以做得优化

其实还可以做得优化还有几个地方.

优先队列优化

后来发现先进入的一定比后进入的ans小,所以使用队列就行了。

map 优化

第一个是那个 map, 我们使用的内存是 38 * 2\^256, 我们可以优化为 tire(字典树), 这样内存变为 2\^256/8 ,算是节省100倍内存吧.

实际上我们需要的内存远远小于, 而且字典树也不容易做到压位, 所以实际节省内存大概是4~6倍.

错误的优化map

由于map最坏情况下空间也是指数级的,所以我就想着能不能优化这个map。

然后还真想出一个方法来。

用一个256位的数据结构来保存某个数字是否出现过。

const int bitLen=4;
ULL bit[bitLen];

void toBit(const T&t, ULL* bit) {
    int now;
    ULL one = 1;
    for(int i=0; i<t.n; i++) {
        now = t.str[i];
        bit[now>>6] |= (one<<(now & 63));
    }
}

void initBit(ULL* bit) {
    for(int i=0; i<bitLen; i++) {
        bit[i] = 0;
    }
}

void setBit(const T&t, ULL* bit) {
    ULL str[bitLen];
    initBit(str);
    toBit(t, str);
    for(int i=0; i<bitLen; i++) {
        bit[i] |= str[i];
    }
}


bool haveString(const T& t) {
    ULL str[bitLen];

    initBit(str);

    toBit(t, str);


    for(int i=0; i<bitLen; i++) {
        if((bit[i]  & str[i]) != str[i]) {
            bit[i] |= str[i];
            return false;
        }
    }
    return true;
}

主要是上面的 haveString 函数,用于判断这个 T 是否出现过,如果出现过,应该和 bit 位运算 与 之后的0.

如果是新增的 T, 则应该只有一位和 bit 不同,把新增的那一位加入到bit即可。

但是悲剧是发现跑出的答案和以前的答案不同。

分析后发现这个方案完全不行,假设在第n层可以得到a和b,我们会把a和b标志位出现过,然后到a的n+1层了,假设又出现b了,对于a这个分支,b是未出现的,但是我们已经把b标记为出现了,所以问题就来了。

tire 树优化 这个map 也不一定可行,或者实现比较复杂。

因为来一个 a,b 和来一个 b,a 实际上是等价的,但是我们的 tire 是不等价的。

所以这里就需要给 tire 树排序了,这就需要在新增数据的时候,对树进行调整。

也可以实现,还是log 级,但是常数大了点。

原来写个 tire 树来优化 map 也是不容易的呀。

数组优化

我们看到, T 中使用的 vector, 实际上我们新增一个 T 后, 相对于父节点, 我们只是新增了一个数值, 所以也可以作为 tire(字典树)优化.

这里节省内存大概节省几倍倍内存.

这个 tire 树倒是可行,实际上也不能称为 tire 树,我们新增一个节点的复杂度不是 log 级,而是 O(1) 级,只需要指定父节点即可。

要得到当时数组的所有数字,需要从叶子顺着去头部,这更像链表。

偷懒的做法

实际上我们只剩下一个没有打表计算出来, 而那个值大概也就可能是9或10, 最坏情况下是11.

我们可以尝试提交三次, 谁ac了, 答案就是谁, 这样就知道答案是几了.

另一个优化

实际上上面两个优化节省十几倍的内存, 应该可以跑出最后那个答案了, 但是为了可靠, 我们可以使用 IDA* 来优化.

因为我们现在只剩下一个了, 其他的最大在9层, 所以这个最后的答案应该也在9或者10层.

而一层我们只增加一个数组, 所以大概有15个数字, 暴力递归来找的话, 也没多少.

但是说到这里, 一种新的做法出来了 IDA* .

质的变化

上面虽然在各种优化,但还是 bfs 搜索。

但是最后发现可以递归若干层来找答案,这就转向了另一个算法:启发式搜索。

IDA* 算法

上面使用 IDA* 的思想来解决内存不足的问题, 这次就完全使用IDA*的算法来打整张表.

噢, 说错了, 我们使用的还不是 IDA* , 而是 迭代加深搜索. 我们没有一个良好的估价函数, 所以估价函数就是一个常数了.

迭代加深搜索

看看自己的模板,上面记录着这样一句话

对于一般的搜索,复杂度是O(2\^n)的复杂度。
对于不知道比较好的算法时,只有进行暴力搜索了。
但是DFS 可能进去出不来,对于BFS 又可能爆栈。这是就要进行迭代搜索了。
每当加深一层深度时,次层的搜索的时间可以忽略不计了,因为相差一个数量级。

这些优化和 迭代加深算法的代码就交给我的学弟李淼洋吧.

PS: 2014-10-20 19:52 自己在当天晚上已经跑出了结果。

目前的方法

之前使用队列加各种优化把255个数字跑出来,而只剩下一个104没有跑出来。

后来,加了一个 dfs 优化:当bfs 搜索到第9 层的时候,开始 dfs 搜索,最深搜不超过11层。

struct T {
    void pop(){
        str.pop_back();
        n--;
    }
};

int dfs(T& now, int lev, int d, int& ans) {
    if(ans != -1 && ans <= now.ans+lev) {
        now.pop();
        return -1;
    }
    if(now.ans+lev > 11) {
        now.pop();
        return -1;
    }
    bool have[N];
    memset(have, false, sizeof(have));
    for(int i=0; i<now.n; i++) {
        have[now.str[i]] = 1;
    }
    int tmpNAND;
    int tmpAns;
    for(int i=now.start; i<now.n; i++) {
        for(int j=0; j<i; j++) {
            tmpNAND = NAND(now.str[i], now.str[j]);
            if(tmpNAND == d) {
                now.pop();
                return now.ans  + lev;
            }

            if(have[tmpNAND]) {
                continue;
            }
            have[tmpNAND] = true;
            now.add(tmpNAND);
            now.start = i;
            tmpAns = dfs(now, lev+1,d, ans);
            if(tmpAns != -1) {
                if(ans == -1) {
                    ans = tmpAns;
                } else {
                    ans = min(ans,tmpAns);
                }
            }

        }
    }
    now.pop();
    return ans;
}

for(int i=now.start; i<tmpn; i++) {
    for(int j=0; j<i; j++) {
        tmpNAND = NAND(now.str[i], now.str[j]);

        if(have[tmpNAND]) {
            continue;
        }
        have[tmpNAND] = true;

        newT = now;
        newT.add(tmpNAND);
        newT.start = i;

        if(haveString(newT)) {
            continue;
        }

        if(tmpNAND == d) {
            return now.ans;
        }

        if(newT.ans > MIN_LEV) {
            tmpAns = dfs(newT, 1, d, ans);
            if(tmpAns != -1) {
                if(ans == -1) {
                    ans = tmpAns;
                } else {
                    ans = min(ans, tmpAns);
                }
            }

        } else {
            que.push(newT);
        }
    }
}

完整数据见这里 完整数据

最终版本的代码参见这里 .

AC代码

AC 代码见这里 这里

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越