跳转至

博客

使用哈希算法以及加盐来增强服务端密码存储安全

T20:57:20+08:00

一、明文

用户名和密码以明文方式存储在数据库中,若数据库被拿下,那么攻击者就可以直接拿到所有人的密码。

Text Only
1
2
3
4
|User|Password|
|  1 |  P(1)  |
|  2 |  P(2)  |
|  3 |  P(3)  |

二、使用哈希算法

哈希算法的本质是对数据进行一个不重复的单射。

Text Only
1
2
3
4
P(1) -> P'(1)
P(2) -> P'(2)
P(3) -> P'(3)
...

服务端只存储经过哈希后的密码。客户端在登陆时发送原本的密码,并在服务端使用同样的哈希算法计算,与哈希后的密码进行比对。

Text Only
1
2
3
4
|User|HashedPassword|
|  1 |     P'(1)    |
|  2 |     P'(2)    |
|  3 |     P'(3)    |

这样,若数据库被拿下,攻击者拿到的是 P',而 P' 不能被直接拿来登录(因为 P' 经过一次哈希 不能依然等于 P'),同时也不能反向求得 P。 如此,攻击者必须枚举 P,直至找到一个使用相同哈希算法正向计算后结果与 P' 相同的 P,才是真正的密码。

但是众所周知,万物皆可打表。 作为人类,大多数人设置的密码都缺乏随机性,比如姓名缩写+生日、更有甚者就是一个 123456。攻击者可以使用一个庞大的常见密码库并使用相同的哈希算法预先计算出一大张表,直接与 P' 进行匹配。

三、加盐

将不同用户的密码附加上不同的随机串来形成新的密码,保存对这个密码进行哈希的结果作为存储的密码。(不同的盐值也保存在服务端中)

Text Only
1
2
3
4
S(1) + P(1) -> SP(1) -> SP'(1)
S(2) + P(2) -> SP(2) -> SP'(2)
S(3) + P(3) -> SP(3) -> SP'(3)
...

这样,一来增强了用户密码的随机性,提高了打表难度, 另一方面攻击者计算出的一张表只能针对一个盐值,将对群体的攻击转化为了对一个个体的攻击,大大增加了攻击代价。

Text Only
1
2
3
4
|User| Salt |HashedPasswordWithSalt|
|  1 | S(1) |        SP'(1)        |
|  2 | S(2) |        SP'(2)        |
|  3 | S(3) |        SP'(3)        |

参考

谈谈密码安全:服务端密码保存 - 知乎 (zhihu.com)

KMP 算法 —— 前缀函数(前缀数组)

T17:09:00+08:00

前缀函数 / 前缀数组

对于一个给定的长度为 \(n\) 的字符串 \(s\) ,定义其前缀函数为一个长度为 \(n\) 的数组 \(\pi\) 。 其中 \(\pi[i]\) 为字串 \(s[0 \dots i]\) 的相等 真前缀(除了 s 本身的前缀)与 真后缀(除了 s 本身的后缀)的最长长度。

  • 例,对于字符串 aabcaabcd\(\pi[0] = 0\) ,因为 a 没有真前缀和真后缀,规定为 0 \(\pi[1] = 1\) ,因为 aa 相等的真前缀和真后缀有: a,其中最长长度为 1 \(\pi[2]=0\) ,因为 aab 无相等的真前缀和真后缀 \(\pi[3]=0\) ,因为 aabc 无相等的真前缀和真后缀 \(\pi[4]=1\) ,因为 aabca 相等的真前缀和真后缀有:a,其中最长长度为 1 \(\pi[5]=2\) ,因为 aabcaa 相等的真前缀和真后缀有: a aa ,其中最长长度为 2 \(\pi[6]=3\) ,因为 aabcaab 相等的真前缀和真后缀有: aab ,其中最长长度为 3 \(\pi[7]=4\) ,因为 aabcaabc 相等的真前缀和真后缀有: aabc ,其中最长长度为 4 \(\pi[8]=0\) ,因为 aabcaabcd 无相等的真前缀和真后缀

求前缀函数 / 前缀数组

朴素算法

时间复杂度 \(O(n^3)\)

C++
vector<int> prefix_function(string s) {
    int n = s.length();
    vector<int> pi(n);
    for (int i = 1; i < n; i++)
        for (int j = i; j >= 0; j--)
            if (s.substr(0, j) == s.substr(i - j + 1, j)) {
                pi[i] = j;
                break;
            }
    return pi;
}

优化一

时间复杂度 \(O(n^2)\)

可以注意到,每一次前缀函数至多加一(因为考察的字串长度只增长一),所以内层循环没必要从 \(i\) 开始枚举:

Diff
vector<int> prefix_function(string s) {
    int n = s.length();
    vector<int> pi(n);
    for (int i = 1; i < n; i++)
-       for (int j = i; j >= 0; j--)
+       for (int j = pi[i - 1] + 1; j >= 0; j--)
            if (s.substr(0, j) == s.substr(i - j + 1, j)) {
                pi[i] = j;
                break;
            }
    return pi;
}

最好的情况下,每一次只需要进行一次字符串比较,一共只需比较 \(n-1\) 次。 最坏的情况下,前 \(n-2\) 次都一次匹配完成,最后一次匹配 \(n-1\) 次,一共 \(2n-3\) 次。 所以时间复杂度为 \(O(n^2)\)

优化二

时间复杂度 \(O(n)\)

\[ \overbrace{ \underbrace{ s_0 s_1 \colorbox{#aaffaa}{$\dots$} s_{j - 2} }_{j - 1} \colorbox{#aaaaff}{$s_{j - 1}$} }^{j = \pi[i-1]} \colorbox{#ffaaaa}{$s_{j}$} \dots \overbrace{ s_{i - j} \underbrace{ s_{i-j + 1} \dots s_{i-2} s_{i-1} }_{j - 1} }^{j = \pi[i-1]} \colorbox{#ffaaaa}{$s_i$} \dots \]

\(j = \pi[i-1]\)

可以发现:

  • 如果 \(s[j] = s[i]\) 由于 \(j = \pi[i-1]\)\(s[0 \dots j-1] = s[i-j \dots i-1]\) 也就是长度为 \(j\) 的前后缀均相等 也就是长度为 \(j\) 的前后缀后面拼上 \(s[j]\)\(s[i]\) 后形成的前后缀可以保证相等,此时 \(\pi[i] = j + 1\)
  • 如果 \(s[j] \neq s[i]\) ,也就是长度为 \(j\) 的前后缀后面拼上 \(s[j]\)\(s[i]\) 形成的前后缀无法保证相等,那么可以退而求其次去试一试 \(j-1\) 长度是否可行。

    • 如果 \(s[j-1] = s[i]\) 也就是长度为 \(j-1\) 的前后缀后面拼上 \(s[j-1]\)\(s[i]\) 后形成的前后缀可以保证相等,此时 \(\pi[i] = j - 1 + 1\) 如果仍不相等,就再次退而求其次直到 \(0\)。 要注意,此处的 \(j\) “退而求其次”到何种程度才能满足条件,其实是我们可以得到的。
    \[ \overbrace{ \underbrace{ \colorbox{#ffaaaa}{$ s_0 s_1 $} }_{j^{(2)}} \dots \underbrace{ \colorbox{#ffaaaa}{$ s_{j - 2} s_{j - 1} $} }_{j^{(2)}} }^{j} s_{j} \dots \overbrace{ s_{i - j} s_{i-j + 1} \dots \underbrace{ \colorbox{#ffaaaa}{$ s_{i-2} s_{i-1} $} }_{j^{(2)}} }^{j} s_i \dots \]

    \(j^{(2)}\) 为“退而求其次”得到的第 \(2\)\(j\)\(j^{(n)}\) 为“退而求其次”得到的第 \(n\)\(j\)

    我们想要找到最长的 \(j^{(2)}\) 使得 \(s[0\dots j^{(2)}-1] = s[i-j^{(2)} \dots i-1]\) 相等。

    但是由于我们已知 \(s[0\dots j-1] = s[i-j\dots i-1]\) ,那么我们其实等价于想要找到最长的 \(j^{(2)}\) 使得 \(s[0\dots j^{(2)}-1] = s[j-j^{(2)} \dots j-1]\) 相等。

    而这,其实就是 \(\pi[j - 1]\) 嘛。

    所以其实我们也就得到了一个递推公式:\(j^{(n)} = \pi[j^{(n-1)} - 1], \;(j^{(n-1)} > 0)\)

最终的码:

C++
// C++ Version
vector<int> prefix_function(string s) {
    int n = s.length();
    vector<int> pi(n);
    for (int i = 1; i < n; i++) {
        int j = pi[i - 1];
        while (j > 0 && s[i] != s[j]) j = pi[j - 1];
        if (s[i] == s[j]) j++;
        pi[i] = j;
    }
    return pi;
}

那么这时我们的函数中不包含任何一次字符串比较了,时间复杂度为 \(O(n)\)

NBT格式

T21:43:31+08:00 NBTNamed Binary Tags)为MC向文件中存储数据的一种格式。

以特殊二进制标签与数据的线性排列来表示树形数据结构。

标签

ID 二进制 标签类型 SNBT(仅Java版) 描述 存储范围
0 00 TAG_End - 用于标记复合标签的结尾。本标签无任何名称所以只有一个零字节。 N/A
1 01 TAG_Byte <number>b<number>B 8位有符号整数 -27到27-1(-128到127)
2 02 TAG_Short <number>s<number>S 16位有符号整数 -215到215-1(-32,768到32,767)
3 03 TAG_Int <number> 32位有符号整数 -231到231-1(-2,147,483,648到2,147,483,647)
4 04 TAG_Long <number>l<number>L 64位有符号整数 -263到263-1(-9,223,372,036,854,775,808到9,223,372,036,854,775,807)
5 05 TAG_Float <number>f<number>F 32位有符号浮点数 数据精度根据数值而定,见单精度浮点数
6 06 TAG_Double <decimal number><number>d<number>D 64位有符号浮点数 数据精度根据数值而定,见双精度浮点数
7 07 TAG_Byte_Array [B;<byte>,<byte>,...] 数组的大小size + size个TAG_Byte 根据JVM的不同,数组成员最大数量可能在231 - 9和231 - 1(2,147,483,639和2,147,483,647)之间。
8 08 TAG_String <a-zA-Z0-9 text>"<text>""需使用\"转义)或'<text>''需使用\'转义) 无符号16位整数的大小size + size长的UTF-8字符串。没有空结束符。 可解释为UTF-8字符串的最多65,535个字节(见变种UTF-8;ASCII字符均为1字节,大多数中文字符为3字节)
9 09 TAG_List [<value>,<value>,...] 无名称同类型标签列表。8位有符号整数的tagId + 32位有符号整数的大小size + size个tagId类型的标签负载 由于JVM的限制以及ArrayList的实现问题,列表成员最大数量为231 - 9(2,147,483,639)。另外,List和Compound标签的嵌套深度不能超过512。
10 0A TAG_Compound {<tag name>:<value>,<tag name>:<value>,...} 以TAG_End结束。一系列完整的标签信息,包括ID、名称以及负载等。任意两个标签都不能有相同的名称。 不像列表,Compound标签内的标签数量没有硬性限制(不过仍受JVM分配的内存限制)。另外,List和Compound标签的嵌套深度不能超过512。
11 0B TAG_Int_Array [I;<integer>,<integer>,...] 数组的大小size + size个TAG_Int 根据JVM的不同,数组成员最大数量可能在231 - 9和231 - 1(2,147,483,639和2,147,483,647)之间。
12 0C TAG_Long_Array [L;<long>,<long>,...] 数组的大小size + size个TAG_Long 根据JVM的不同,数组成员最大数量可能在231 - 9和231 - 1(2,147,483,639和2,147,483,647)之间。
Text Only
标签 TAG_STRING 负载

例:

Text Only
0A 00 00 0A 00 04 44 61 74 61 ...... 00 00 00 00 00 00 00 00
Text Only
1
2
3
4
5
0A [00 00]
    0A [00 04] [44 61 74 61]
        ......
    00
00

0A 为 TAG_COMPOUND 标签,00 00 指示标签名字符串长度为0(level.dat 最外层被一个无名称的 TAG_COMPOUND 包裹)

0A 为 TAG_COMPOUND 标签,00 04 指示标签名字符串长度为 4,标签名字符串为 44 61 74 61data

​ ......

00 为 TAG_COMPOUND 标签结束

00 为 TAG_COMPOUND 标签结束

Dissect MC

level.dat

GZip 方式压缩后的NBT格式,将其添加后缀 .gzip 后解压,再用HEX Editor打开即可。

CF1201C Maximum Median

T15:16:00+08:00 Codeforces:Problem - 1201C - Codeforces

题意

给定一个有 \(n\) 个整数的数组,可以做 \(k\) 次如下操作:

  • 任选一个元素,使其增大1

求最终得到数组的中位数的最大值。

思路

![[../../ Obsidian /Excalidraw/CF1201C Maximum Median 2022-07-28 20.43.27.excalidraw]]

一个贪心结论:不需要管小的那一半数,只需向中间以及大的那一半数增加即可。

结论:要想使中位数增大一,则需要“填平”一层。

照这样模拟就好啦~

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

#define ll long long

using namespace std;

const int MAXN = 2E5 + 1;

int a[MAXN];
int differ[(MAXN >> 1) + 1];

int n, k;
int main() {
    cin >> n >> k;

    for (int i = 0; i < n; i++)
        scanf("%d", a + i);

    sort(a, a + n);

    int mid = n >> 1;

    // 差分
    a[n] = a[n-1];
    for (int i = mid; i < n; i++)
        differ[i - mid + 1] = a[i+1] - a[i];

    for (int i = 1; i <= mid + 1; i++) {
         // 还没填到最后一个数,且还够填平当前这一层
        if (k >= (ll) i * differ[i] && i != mid + 1) {
            k -= (ll) i * differ[i];
            a[mid] += differ[i];
        } else {
            a[mid] += k / i;
            break;
        }
    }

    cout << a[mid] << endl;

    return 0;
}

CF1223C Save the Nature

T15:18:00+08:00 洛谷: Codeforces:Problem - 1223C - Codeforces

题意

给定 \(n\) 个数,可以任意调整顺序。

按照以下方式计算总和:

  • \(a, 2a, 3a, \dots\) 个数的 \(x\%\)
  • \(b, 2b,\ 3b, \dots\) 个数的 \(y\%\)

求能够使总和达到 \(k\) 所需要用到的数的个数的最小值。

思路

用到的数的个数 进行二分,它满足二分所需性质:

  • 用到的数的个数 <= 某个值时,总和无法达到 \(k\)
  • 用到的数的个数 <= 某个值时,总和可以达到 \(k\)

可以得到一个宽泛的范围 \([1, n)\)

Text Only
invalid valid
------- [-]--

如何计算总和?这里是一个贪心的思路。

\(i\) 个数有三种情况:

  1. \(a\)\(b\) 的公倍数
  2. 只是 \(a\) 的倍数
  3. 只是 \(b\) 的倍数

不妨令 \(x > y\),那么大的数一定优先给 \(1\) 用,然后是 \(2\),再然后是 \(3\)

再然后,当我们确定下来 用到的数的个数 的时候,其实也就确定了这三种情况的数量:n / lcm(a, b)n / an / b

所以先排个序,然后按照顺序以此累加出总和即可。 最后判断是否 >= \(k\)(二分得到答案后记得再判断一次,因为有可能无解)。

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

#define ll long long

using namespace std;

const int MAXN = 2E5 + 1;

int n;
int p[MAXN];
int x, a, y, b; ll k;

ll gcd(ll x, ll y) {
    return y == 0 ? x : gcd(y, x % y);
}

bool check(ll cnt) {
    ll sum = 0; ll tot = 1;

    ll cntab = cnt / (1ll * a * b / gcd(a, b));
    ll cnta = cnt / a - cntab, cntb = cnt / b - cntab;

    for (ll i = 1; i <= cntab + cnta + cntb; i++) {
        if (i <= cntab) {
            sum += p[tot++] / 100 * (x + y);
        } else if (i <= cntab + cnta) {
            sum += p[tot++] / 100 * x;
        } else{
            sum += p[tot++] / 100 * y;
        }
        if (sum >= k) return true;
    }
    return false;
}

int main () {
    int q; cin >> q;

    while (q--) {
        cin >> n;
        for (int i = 1; i <= n; i++) {
            cin >> p[i];
        }
        sort(p + 1, p + 1 + n, greater<int>());
        cin >> x >> a >> y >> b >> k;
        if (y > x) { swap(x, y); swap(a, b); }

        // invalid valid(probably)
        // ------- [-]--
        int l = 1, r = n;
        while (l < r) {
            int m = l + r >> 1;
            if (check(m)) r = m;
            else          l = m + 1;
        }
        if (!check(l)) cout << -1 << endl;
        else           cout << l << endl;
    }

    return 0;
}

CF939E Maximize!

T15:15:00+08:00 Codeforces:Problem - 939E - Codeforces

题意

考虑一个由正整数组成的集合 \(s\)(初始为空集),有两种操作:

  • \(S\) 中添加一个 \(\geq max(S)\) 中最大值的正整数
  • 找到一个 \(S\) 的子集 \(s\) 满足 \(max(s) - mean(s)\) 最大(最大值 - 平均值)。

思路

可以得到两个贪心结论:

  1. 要想使 \(mean(s)\) 尽量小,那么一定是尽可能从最小的数开始连续选。
  2. 要想使 \(max(s)\;\;\) 尽量大,那么一定是要选最大的一个数。

考虑选了一个相同的最大的数,子集中最大数以外的数都 \(\leq mean(s)\) 的时候,\(max(s) - mean(s)\) 越大(\(max(s)\) 不变,\(mean(s)\) 减小)。

此处可以对 子集中最大数以外的数个数 进行二分,按照 结论1 的选法,那么 子集中最大数以外的数个数 满足二分要求的性质:

  • 子集中最大数以外的数个数 <= 某个值时,子集中第二大的数 <= \(mean(s)\)
  • 子集中最大数以外的数个数 >= 某个值时,除最大数的最大数 >= \(mean(s)\)
Text Only
valid invalid
--[-] -------

C++
#include <iostream>

#define ll long long

using namespace std;

const int MAXN = 5E5;

int a[MAXN], sz;
ll prefixSum[MAXN];

bool check(int x) {
    double mean = (prefixSum[x] + a[sz-1]) / (x + 1);
    // cout << " #check: " << a[x-1] << " " << mean << endl;
    return a[x-1] <= mean;
}

double getAns() {
    if (sz == 1) return 0;
    int l = 1, r = sz-1;
    while (l < r) {
        int m = l + r + 1 >> 1;
        // cout << " > " << l << " " << m << " " << r << endl;
        if (check(m)) l = m;
        else          r = m-1;
    }

    // cout << "l: " << l << ", max: " << a[sz-1] << ", mean: " << (prefixSum[l] + a[sz-1]) / (l + 1.0) << endl;

    return a[sz-1] - (prefixSum[l] + a[sz-1]) / (l + 1.0);
}

int main() {
    int Q; cin >> Q;

    int f, x;
    while (Q--) {
        cin >> f;
        if (f == 1) {
            cin >> a[sz++];
            prefixSum[sz] = prefixSum[sz-1] + a[sz-1];
        } else {
            printf("%.10lf\n", getAns());
        }
    }

    return 0;
}

P1182 数列分段 Section II

T15:19:00+08:00 洛谷:P1182 数列分段 Section II - 洛谷

题意

给定一个数列,要将其分为 \(M\) 段,且每段的和的最大值最小,求这个值。

思路

最大值最小,这种东西一般都是二分。 可以知道一个宽泛的范围:最大元素的值 <= 每段的和的最大值 <= 数列总和。 每段的和的最大值 小于某个值时无法在指定分段数内将数列完成分段,也就得到了我们要判断的二分区间的性质。

Text Only
invalid     valid
------- [-]-------

分界点左侧都无法完成分段,右侧都可以,而我们的目标分界点在右侧区间内。

C++
#include <iostream>

using namespace std;

const int MAXN = 1E5 + 1;

int N, M;
int a[MAXN];

bool check(int m) {
    int cnt = 1, sum = 0;
    for (int i = 1; i <= N; i++) {
        sum += a[i];
        if (sum > m) {                  // 当前分段在限制内放不下当前这个数
            if (cnt == M) return false; // 且不能再分新的段 -> 无法完成分段
            cnt++; sum = a[i];          // 将当前元素分到新的段中
        }
    }
    return true;
}

int main () {
    cin >> N >> M;

    int maxa = 0, sum = 0;
    for (int i = 1; i <= N; i++) {
        cin >> a[i];
        maxa = max(maxa, a[i]);
        sum += a[i];
    }

    // invalid     valid
    // ------- [-]-------
    int r = sum, l = maxa;
    while (l < r) {
        int m = (l + r) >> 1;
        if (check(m)) r = m;
        else          l = m + 1;
    }
    cout << r << endl;

    return 0;
}

P1873【COCI 2011+2012 5】EKO 砍树

T15:20:00+08:00 看到这么多二分,我来发一篇差分的。其实只有 \(O(n)\)

假如有这么一组树:

Text Only
10 6 12 6 9 3 9

把树倒过来看:

Text Only
1
2
3
4
5
6
7
1 | # # # # # # # # # #
2 | # # # # # #
3 | # # # # # # # # # # # #
4 | # # # # # #
5 | # # # # # # # # #
6 | # # #
7 | # # # # # # # # #

那么很显然如果我在高度 \(7\) 切,将是这样:

Text Only
1
2
3
4
5
6
7
8
    1 2 3 4 5 6 7 | 8 9 10 11 12
1 | # # # # # # # | # #  #
2 | # # # # # #   |
3 | # # # # # # # | # #  #  #  #
4 | # # # # # #   |
5 | # # # # # # # | # #
6 | # # #         |
7 | # # # # # # # | # #

如果我把树横着叠起来(对每一列计数求和):

Text Only
1
2
3
4
5
6
7
8
9
      1 2 3 4 5 6 7 | 8 9 10 11 12
sum | 7 7 7 6 6 6 4 | 4 4  2  1  1
  1 | # # # # # # # | # #  #
  2 | # # # # # #   |
  3 | # # # # # # # | # #  #  #  #
  4 | # # # # # #   |
  5 | # # # # # # # | # #
  6 | # # #         |
  7 | # # # # # # # | # #

那么不就变成求个后缀和,直到求和到某一个高度 \(h\) 下满足要求,就要在 \(h-1\) 锯么。

于是就变成了 区间加 & 后缀和。(当然你反着存用前缀和也行)

那最简单的区间加就是利用差分数组了吧:

dif[i] = a[i] - a[i-1]

\(a\)\([l, r]\) 区间加上 \(k\) 也就是:

dif[l] += kdif[r+1] -= k

然后求一边前缀和就是原数组啦。(当然你要是反着差的分就后缀和)

码:

C++
#include <iostream>

using namespace std;

const int MAXN = 1E6;

long long differ[MAXN + 2];

void add(int l, int r) {
    differ[l]++;
    differ[r+1]--;
}

int main () {
    int N, M; cin >> N >> M;

    int maxL = 0;
    for (int i = 1, l; i <= N; i++) {
        cin >> l;
        maxL = max(maxL, l);
        add(1, l);
    }

    for (int i = 1; i <= maxL + 1; i++) {
        differ[i] += differ[i-1];
    }
    for (int i = maxL; i >= 1; i--) {
        differ[i] += differ[i+1];
        if (differ[i] >= M) {
            cout << i-1 << endl;
            break;
        }
    }

    return 0;
}

P3799 妖梦拼木棒

T15:19:00+08:00 洛谷:P3799 妖梦拼木棒 - 洛谷

题意

\(n\) 根木棒,从中选 \(4\) 根组成正三角形,求有几种选法(答案对 \(10^9 + 7\) 取模)

思路

\(4\) 根,也就是说三角形的三条边分别是 \(2 + 1 + 1\),其中一条边由两条木棒拼成,而另外两条边长度相同(用的相同一根木棒)。

首先对不同长度木棒进行计数,然后进行枚举。

先枚举三角形边长 \(i\)(要满足该长度木棒数 >= 2),再根据边长拼接一条边所使用的木棒 \(j\)\(i - j\),这里会产生两种情况:

  • \(i = i - j\),那么拼成此正边长三角形的方案数即为 \(C_{cnt_i}^2 \cdot C_{cnt_j}^2\)
  • \(i \neq i - j\),那么拼成此边长正三角形的方案数为 \(C_{cnt_i}^2 \cdot cnt_j \cdot cnt_{i - j}\)

最后再累加起来,即为总的方案数。 记得随着计算取模。

C++
#include <iostream>

#define ll long long

using namespace std;

const int MAXA = 5E3;
const int MOD =  1E9 + 7;

int cnt[MAXA];

ll cn2(ll n) {
    return n * (n-1) / 2;
}

int main() {
    int n; cin >> n;

    int maxa = 0;
    for (int i = 1, a; i <= n; i++) {
        cin >> a;
        maxa = max(maxa, a);
        cnt[a]++;
    }

    ll ans = 0;
    for (int i = 2; i <= maxa; i++) {
        if (cnt[i] >= 2) {
            for (int j = 1; j<<1 <= i; j++) {
                if (j == i-j && cnt[j] >= 2) {
                    ans = (ans + ((cn2(cnt[j]) % MOD) * (cn2(cnt[i]) % MOD)) % MOD) % MOD;
                } else if (j != i-j && cnt[j] && cnt[i-j]) {
                    ans = (ans + (((cnt[j] * cnt[i-j]) % MOD) * (cn2(cnt[i]) % MOD)) % MOD) % MOD;
                }
            }
        }
    }
    cout << ans << endl;

    return 0;
}

P2324 [SCOI2005]骑士精神

T22:05:00+08:00 洛谷:P2324 SCOI2005骑士精神 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

Lv0 暴力大法师 0pts

由于要求得最小的步数,使用 dfs 的话就只有全部跑完才能确定得到的结果是最小的结果。

C++
const int dx[] = {0, 1, 1, -1, -1, 2, 2, -2, -2};
const int dy[] = {0, 2, -2, 2, -2, 1, -1, 1, -1};

int ans = 16;

void dfs(int x, int y, int depth = 0) {
    if (depth > 15) return;
    if (check()) {
        ans = min(ans, depth-1);
        return;
    }
    for (int i = 1; i <= 8; i++) {
        int xx = x + dx[i];
        int yy = y + dy[i];
        if (xx < 1 || xx > 5 || yy < 1 || yy > 5) continue;
        swap(m[xx][yy], m[x][y]);
        dfs(xx, yy, depth+1);
        swap(m[xx][yy], m[x][y]);
    }
}

而每一步决策有 8 个方向,这颗树的增长速度十分恐怖 \(8^n\),而最大步数为 15,那么就需要 \(8^{15}\) 次才能得到结果,绝对超时。

Lv1 迭代加深 20pts

考虑到答案可能在较低层中,而由于深搜的特点,到最深层才会返回,这样会多搜很多东西。

于是可以考虑限制搜索深度,迭代放宽深度。

C++
int ans = 16;

void dfs(int x, int y, int maxdepth, int depth = 0) {
    if (depth > maxdepth) return;
    // ...
}

int main () {
    // ...
    for (int i = 1; i <= 15; i++) {
        dfs(x, y, i);
        if (ans < 16) break;
    }
    // ...
}

然而这种“优化”也只能保证答案在较低层时不会超时,若答案在最深层,仍然要遍历所有状态才能得到结果。

Lv2 结合估价函数 —> IDA* 100pts

考虑有这样一个函数:

\[ f(x) = g(x) + h(x) \]

其中 \(g\) 为距离起点的距离函数,\(h\) 为距离终点的距离函数。

那么很显然,结合迭代加深,如果当前的 \(f\) 大于了 \(maxdepth\),那么不能在限定步数内到达终点。

然而我们并不知道确切的 \(h\),所以我们可以找出一个能够估计到终点的距离函数 \(h^*(x) \leq h(x)\),保证小于等于是由于为防止估计错误而使 \(f\) 比实际大而导致判断不能到达。

针对本题来说,不难想到一个 \(h^*(x) = 不在原位置的棋子数(不包括空位)\),可以想象一个最理想的过程,当前空位依次移动到每一个不在原位置的棋子处,每一次移动归位一个棋子。

完整代码:

C++
#include <iostream>

using namespace std;

char m[6][7];
const char goal[6][7] = {
    {"000000"},
    {"011111"},
    {"001111"},
    {"000*11"},
    {"000001"},
    {"000000"},
};

char read() {
    char c;
    while ((c = getchar()) != '0' && c != '1' && c != '*');
    return c;
}

const int dx[] = {0, 1, 1, -1, -1, 2, 2, -2, -2};
const int dy[] = {0, 2, -2, 2, -2, 1, -1, 1, -1};

int eval() {
    int cnt = 0;
    for (int i = 1; i <= 5; i++) {
        for (int j = 1; j <= 5; j++) {
            if (m[i][j] != goal[i][j]) cnt++;
        }
    }
    return cnt ? cnt-1 : 0;
}

int ans;

void dfs(int x, int y, int maxdepth, int depth = 0) {
    int e = eval();
    if (depth + e > maxdepth) return;
    if (!e) {
        ans = min(ans, depth);
        return;
    }
    for (int i = 1; i <= 8; i++) {
        int xx = x + dx[i];
        int yy = y + dy[i];
        if (xx < 1 || xx > 5 || yy < 1 || yy > 5) continue;

        swap(m[xx][yy], m[x][y]);
        dfs(xx, yy, maxdepth, depth+1);
        swap(m[xx][yy], m[x][y]);
    }
}


int main() {
    int T; cin >> T;

    int x, y;
    while (T--) {
        ans = 16;

        for (int i = 1; i <= 5; i++) {
            for (int j = 1; j <= 5; j++) {
                m[i][j] = read();
                if (m[i][j] == '*') {
                    x = i;
                    y = j;
                }
            }
        }

        for (int i = 1; i <= 15; i++) {
            dfs(x, y, i);
            if (ans < 16) break;
        }

        cout << (ans < 16 ? ans : -1) << endl;

    }

    return 0;
}