【读书笔记】《算法竞赛进阶指南》读书笔记——0x00基本算法

to-do:

例题: POJ 1845 Sumdiv
所有的课后习题;
随缘~~~


位运算

对于一个二进制数,通常称其最低位为第0位,从右往左依此类推

补码

unsigned int

直接将其看作32位二进制数。

signed int

以最高位位符号位,0表示非负数,1表示负数;
如果最高位为0,直接看做32位二进制数;
同时定义该编码按位取反后得到的新编码   C ~C 表示的数值为 1 S -1-S

补码1

可以发现,在补码下,每个数值都有唯一的表示方式,并且任意两个数做加减法运算,都相当于在32位补码下做最高位不进位的二进制加减法运算。

位移运算

左移
在二进制表示下把数字向左移动,低位以0填充,高位越界后舍弃;

算数右移
在二进制补码下把数字向右移动,高位以符号位填充,低位越界后舍弃。

算术右移等价于除以2并向下取整,而C++中的整数除法为向0取整。

逻辑右移
在二进制补码下将数字从左往右移动,高位填充0,低位越界后舍弃。

C++标准并未定义右移的实现方式,但是主流编译器都以算数右移实现。

例题: Contest Hunter 0101 a^b

a a b b 次方对 p p 取模的值;
相关题目:POJ1995

假设 b b 的二进制表示有 k k 位,其中第 i ( 0 i k ) i(0 \le i \le k) 的数字是 c i c_i ,那么:

b = c k 1 2 k 1 + c k 2 2 k 2 + + c 0 2 0 b = c_{k-1} * 2^{k-1} + c_{k-2} * 2^{k - 2} + \cdots + c_0 * 2^0

于是:

a b = a c k 1 2 k 1 a c k 2 2 k 2 2 c 0 2 0 a^b = a^{c_{k-1} * 2^{k-1}} * a^{c_{k-2} * 2^{k - 2}} * \cdots * 2^{c_0 * 2^0}

又因为:

a 2 i = ( a 2 i 1 ) 2 a^{2^i} = (a^{2^{i-1}})^2

所以:

using ll = long long;

ll power(ll a, ll b, ll p)
{
    ll ans = 1 % p; // 1 % 1 竟然是0,涨姿势了
    for ( ; b > 0; b >>= 1)
    {
        if (b & 1) ans = ans * a % p;
        a = a * a % p;
    }
    return ans; // 快速幂!!!
}

例题: Contest Hunter 0102 64位整数乘法

a b m o d    p a * b \mod p ,其中 1 a , b , p 1 0 18 1 \le a, b, p \le 10^{18}

方法一
类似于快速幂的思想,因为:

b = c k 1 2 k 1 + c k 2 2 k 2 + + c 0 2 0 b = c_{k-1} * 2^{k-1} + c_{k-2} * 2^{k - 2} + \cdots + c_0 * 2^0

所以:

a b = a c k 1 2 k 1 + a c k 2 2 k 2 + + a c 0 2 0 a * b = a * c_{k-1} * 2^{k-1} + a * c_{k-2} * 2^{k - 2} + \cdots + a * c_0 * 2^0

所以:

using ll = long long;

ll multiply(ll a, ll b, ll p)
{
    ll ans = 0;
    for ( ; b > 0; b >>= 1)
    {
        if (b & 1) ans = (ans + a) % p;
        a = a * 2 % p;
    }
    return ans; // 快速乘!!!
}

方法二
利用 a b m o d    p = a b a b / p p a * b \mod p = a * b - \lfloor a * b / p \rfloor * p

a , b < p a, b < p 时, a b / p a * b / p 向下取整后也一定小于 p p ,我们可以用浮点数执行 a b / p a * b / p ,因为long double的有效数字有18~19位,足够胜任,当精度不足时,浮点数会舍弃低位,正好符合要求。

另外,虽然 a b / p a * b / p a b a * b 可能很大,但二者的差一定在 [ 0 , p 1 ] [0, p - 1] 之间,我们只需要关心它们的低位,所以这两个结果可以用long long保存,整数在溢出时相当于舍弃高位,也正好符合我们的要求。

using ll = long long;
using ld = long double;

ll multiply(ll a, ll b, ll p)
{
    a %= p
    b %= p;

    ll c = (ld)a * b / p;
    ll ans = a * b - c * p;

    if (ans < 0) ans += p;
    if (ans >= p) ans -= p;

    return ans;
}

二进制状态压缩

二进制状态压缩,是指将一个长度为m的bool数组用一个m位二进制整数表示并储存的方法:

操作 运算
取出整数n在二进制表示下的第k位 ( n &gt; &gt; k )   &amp;   1 (n &gt;&gt; k)~\&amp;~1
取出整数n在二进制表示下的第0~k-1位(后k位) n   &amp;   ( ( 1 &lt; &lt; k ) 1 ) n~\&amp;~((1 &lt;&lt; k ) - 1)
把整数n在二进制表示下的第k位取反 n   x o r   ( 1 &lt; &lt; k ) n~xor~(1 &lt;&lt; k)
对整数n在二进制表示下的第k位赋值1 n     ( 1 &lt; &lt; k ) n~\|~(1 &lt;&lt; k)
对整数n在二进制表示下的第k位赋值0 n   &amp;   ( ! ( 1 &lt; &lt; k ) ) n~\&amp;~(!(1 &lt;&lt; k))

当m不太大时,可以直接使用整数储存,当m较大时,可以使用bitset<>

例题: Contest Hunter 0103 最短Hamilton路径

给定一张 n ( n 20 ) n(n \le 20) 个点的带权无向图,点从0~n-1标号,求从起点0到终点n-1的最短Hamilton路径。
Hamliton路径的定义是从0到n-1不重不漏的经过每一个点恰好一次。
扩展题目:POJ2288

使用一个n位的二进制数,若其第位位1,则表示第i个点已经被经过,如果是0则表示未被经过。在任意时刻还需要知道当前状态所处的位置,因此我们可以用一个二维数组 f [ i ] [ j ]   ( 0 i 2 n ,   0 j n ) f[i][j]~(0 \le i \le 2^n,~0 \le j \le n) 其中i表示点被经过的状态的二进制数,j表示当前点,数组储存此时的最短路径。

在起点时,有 f [ 1 ] [ 0 ] = 0 f[1][0] = 0 。方便起见,我们将数组的其他位置设为 \infty ,最终的目标是 f [ 1 &lt; &lt; n 1 ] [ n 1 ] f[1 &lt;&lt; n - 1][n - 1]

在任意时刻,有公式 f [ i ] [ j ] = m i n { f [ i   x o r   ( 1 &lt; &lt; j ) ] [ k ]   +   w e i g h t ( k ,   j ) } f[i][j] = min\{f[i~xor~(1&lt;&lt;j)][k]~+~weight(k,~j)\} ,其中 0 k &lt; n 0 \le k &lt; n 并且 ( i &gt; &gt; j )   &amp;   1 = 1 (i &gt;&gt; j)~\&amp;~1 = 1 ,请理解该状态转移方程。

核心程序:

int f[1 << 20][20], weight[20][20];

int hamilton(int n)
{
    memset(f, 0x3f, sizeof(f));
    f[1][0] = 0;

    for (int i = 1; i < (1 << n); ++i)
    {
        for (int j = 0; j < n; ++j)
        {
            if ((i >> j) & 1) // 对于当前状态 i,点 j 被访问过
            {
                for (int k = 0; k < n; ++k)
                {
                    // 对于当前状态 i,先把 j 的位置标记位为访问,然后看 k 有没有被访问过
                    // 可以换成 j != k →_→
                    if ((i ^ (1 << j)) >> k & 1)
                    {
                        f[i][j] = min(f[i][j], f[i ^ 1 << j][k] + weight[k][j]);
                    }
                }
            }
        }
    }

    return f[1 << n][n - 1];
}

完整代码:

int f[1 << 20][20], weight[20][20];

int hamilton(int n)
{
    memset(f, 0x3f, sizeof(f));
    f[1][0] = 0;

    for (int i = 1; i < 1 << 20; ++i)
    {
        for (int j = 0; j < n; ++j)
        {
            if (i >> j & 1) // 状态i中,j被访问过
            {
                for (int k = 0; k < n; ++k) // 状态转移,找前一个点
                {
                    // 就是在判断 j != k
                    if ((i ^ (1 << j)) >> k & 1) // k在i中被访问过且不与j重合
                    {
                        f[i][j] = min(f[i][j], f[i ^ 1 << j][k] + weight[k][j]);
                    }
                }
            }
        }
    }

    return f[(1 << n) - 1][n - 1];
}

int main()
{
    int n;
    while (cin >> n)
    {
        for (int i = 0; i < n; ++i)
        {
            for (int j = 0; j < n; ++j)
            {
                cin >> weight[i][j];
            }
        }

        cout << hamilton(n) << endl;
    }
}

成对变换

对于非负整数n:
当n为偶数时, n   x o r   1   =   n + 1 n~xor~1~=~n+1
当n为奇数时, n   x o r   1   =   n 1 n~xor~1~=~n-1

因此,0和1、2和3、4和5、……关于异或1构成成对变换

这一性质常用于图论邻接表中边的储存,在具有双向边的图中,把一对正反向边储存在相邻的位置,通过异或运算就可以很方便的取出当前边的反向边。

lowbit运算

lowbit(n)定义为非负整数n的二进制表示下最低位的1及其后面的0组成的数的数值,且:

l o w b i t ( n )   =   n   &amp;   n lowbit(n)~=~n~\&amp;~-n


枚举、模拟、递推

一个实际问题的各种可能情况构成的集合通常称为状态空间,而程序的运行则是对状态空间的遍历,算法和数据结构则是通过划分、归纳、提取、抽象来帮助提高程序遍历状态空间的效率。

按照状态空间的规模大小,有如下几种常见的枚举形式和遍历方式

枚举形式 状态空间规模 一般遍历方式
多项式 n k , k n^k,k为常数 循环、递推
指数 k n , k k^n,k为常数 递归、位运算
排列 n ! n! 递归、next_permutation
组合 C n m C_n^m 递归 + 剪枝

例题: Contest Hunter 0201 费解的开关

在上述01矩阵点击游戏中,很容易发现两个性质:

  1. 每个位置至多只会被点击一次;
  2. 若固定了第一行(不再点击第一行),则满足题意的点击方案至多有一种;

假设第i行已经固定(不再点击第i行),这时如果第i行第j列为0,则点击点 ( i + 1 , j ) (i + 1, j) 来改变 ( i , j ) (i, j) 的状态,在如此处理完第i行后,将i+1行固定,开始处理i+1行,直到最后一行;然后判断最后一行还有没有0,如果最后一行也没有0了,表示方案可行,返回总修改数。

这道题目只需要枚举第一行5个元素一共 2 5 2^5 种不同的点击情况,就可以求出最终答案。

道理是这个道理,代码写得不咋地,水题卡了一下午。

using namespace std;

char fuck[6][6], you[6][6];
int dir[4][2] = { 1, 0, -1, 0, 0, 1, 0, -1 }, ans;

void flip_single(int x, int y, char cc[6][6])
{
    if (x < 0 || x >= 5 || y < 0 || y >= 5)
        return;
    else if (cc[x][y] == '1')
        cc[x][y] = '0';
    else if (cc[x][y] == '0')
        cc[x][y] = '1';
}

void flip_all(int x, int y, char cc[6][6])
{
    flip_single(x, y, cc);
    for (int i = 0; i < 4; ++i)
        flip_single(x + dir[i][0], y + dir[i][1], cc);
}

void copy()
{
    for (int i = 0; i < 5; ++i)
    {
        for (int j = 0; j < 5; ++j)
        {
            fuck[i][j] = you[i][j];
        }
    }
}

int judge(int cnt)
{
    copy();
    for (int i = 1; i < 5; ++i)
    {
        for (int j = 0; j < 5; ++j)
        {
            if (fuck[i - 1][j] == '0')
            {
                flip_all(i, j, fuck);
                cnt++;

                if (cnt > 6) return cnt;
            }
        }
    }

    for (int i = 0; i < 5; ++i)
        if (fuck[4][i] == '0') return 7;

    return cnt;
}

void dfs(int cols, int cnt)
{
    ans = min(ans, judge(cnt));

    if (cols >= 5) return;

    flip_all(0, cols, you);

    dfs(cols + 1, cnt + 1);

    flip_all(0, cols, you);

    dfs(cols + 1, cnt);
}

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

    while (cases--)
    {
        for (int i = 0; i < 5; ++i)
        {
            for (int j = 0; j < 5; ++j)
            {
                cin >> you[i][j];
            }
        }

        ans = 7;
        dfs(0, 0);

        if (ans > 6) cout << -1 << endl;
        else cout << ans << endl;
    }
}

例题: POJ 1958 Strange Towers of Hanoi

解出n个盘子4座塔的汉诺塔问题最少需要多少步。

首先考虑n个盘子3座塔的经典汉诺塔问题,设 d [ n ] d[n] 表示求解该n盘3塔问题的最小步数,显然有 d [ n ] = 2 d [ n 1 ] + 1 d[n] = 2 * d[n - 1] + 1 ,即把前n-1个盘子从A挪到B,然后把第n个盘子从A移动到C,最后把n-1个盘子从B移到C。

对于这道题目,设 f [ n ] f[n] 表示n盘4塔问题的最少步数,则:

f [ n ] = m i n 1 i &lt; n { 2 f [ i ] + d [ n i ] } f[n] = min_{1 \le i &lt; n}\{2 * f[i] +d[n - i]\}

d [ ] d[] 是n盘3塔问题的解, f [ 1 ] = 1 f[1] = 1

上式的含义是,先把i个盘子在4塔模式下移动到B,然后把n-i个盘子在3塔模式下移动到D,最后把i个盘子在4塔模式下移动到D。考虑所有i可能的取值,取最小值。

int f[15], d[15];

int main()
{
    d[1] = 1;

    for (int i = 2; i <= 12; ++i) d[i] = 2 * d[i - 1] + 1;

    memset(f, 0x3f, sizeof(f));
    f[1] = 1;

    for (int i = 2; i <= 12; ++i)
    {
        for (int j = 1; j < i; ++j)
        {
            f[i] = min(f[i], 2 * f[j] + d[i - j]);
        }
    }

    for (int i = 1; i <= 12; ++i)
        cout << f[i] << endl;
}

前缀和

对于一个给定的数列A,它的前缀和S是通过递推能求出的基本信息之一。

S [ i ] = j = 1 i A [ j ] S[i] = \sum_{j=1}^i A[j]

区间和可以表示为前缀和相减的形式。

s u m ( l , r ) = S [ r ] S [ l 1 ] sum(l, r) = S[r] - S[l - 1]

在二维数组中,也有类似的递推形式,可以求出二维前缀和,进一步计算出二维部分和。

例题: BZOJ 1281 激光炸弹

极限暴力:

const int MAX = 5001;
int n, r, sum[5010][5010];

int main()
{
    while (cin >> n >> r)
    {
        memset(sum, 0, sizeof(sum));

        int x, y, v;
        while (n--)
        {
            cin >> x >> y >> v;
            sum[x + 1][y + 1] += v;
        }

        for (x = 1; x <= MAX; ++x)
        {
            for (y = 1; y <= MAX; ++y)
            {
                sum[x][y] += sum[x - 1][y];
            }
        }

        for (x = 1; x <= MAX; ++x)
        {
            for (y = 1; y <= MAX; ++y)
            {
                sum[x][y] += sum[x][y - 1];
            }
        }

        int ans = 0;

        for (x = r; x <= MAX; ++x)
        {
            for (y = r; y <= MAX; ++y)
            {
                int tmp = sum[x][y] - sum[x - r][y] - sum[x][y - r] + sum[x - r][y - r];
                ans = max(ans, tmp);
            }
        }

        cout << ans << endl;
    }
}

例题: POJ 3263 Tallest Cow

假设用 h e i g h t [ ] height[] 数组来储存牛的身高,对于每一对能互相看见的牛 l , r l,r ,假设 h e i g h t [ l ] = h e i g h t [ r ] height[l] = height[r] 并且 h e i g h t [ l + 1 ] = h e r i g h t [ l + 2 ] = = h e i g h t [ r 1 ] = h e i g h t [ l ] 1 = h e i g h t [ r ] 1 height[l +1] = heright[l + 2] = \cdots = height[r - 1] = height[l] - 1 = height[r] - 1

h e i g h t [ ] height[] 数组初始全部设为0,处理完所有能互相看见的牛之后, h e i g h t [ i ] + h m a x height[i] + h_{max} 就是最终答案。

为了降低时间复杂度,可以另外开一个数组 p r e [ ] pre[] ,对于每一对能互相看见的牛 ( l , r ) (l,r) ,执行操作 p r e [ l + 1 ] = 1 , p r e [ r ] + = 1 pre[l + 1] -= 1, pre[r] += 1 ,这样的话 h e i g h t [ i ] = j = 1 i p r e [ j ] height[i] = \sum_{j = 1}^i pre[j] ,即将对于 h e i g h t [ ] height[] 数组的区间修改对 h e i g h t [ ] height[] 数组中每一个值的影响反映到 p r e [ ] pre[] 数组的前缀和中。

const int MAX = 10000 + 5;
int pre[MAX], n, h, m, in;

int main()
{
    while (cin >> n >> in >> h >> m)
    {
        memset(pre, 0, sizeof(pre));

        int x, y;
        set <pair <int, int> > st;

        while (m--)
        {
            cin >> x >> y;

            if (x > y) swap(x, y);

            pair <int, int> p = make_pair(x, y);

            if (st.count(p)) continue;
            else st.insert(p);

            pre[x + 1] -= 1;
            pre[y] += 1;
        }

        int s = 0;
        for (int i = 1; i <= n; ++i)
        {
            s += pre[i];
            cout << h + s << endl;
        }
    }
}

递归

递归的宏观描述

递归的宏观描述

例题: Contest Hunter 0301 递归实现指数型枚举

int ans[20], n;

void solve(int num, int step)
{
    if (num == n + 1)
    {
        for (int i = 0; i < step; ++i)
        {
            cout << ans[i];
            if (i + 1 < step) cout << " ";
        }
        cout << endl;
    }
    else
    {
        solve(num + 1, step);

        ans[step] = num;

        solve(num + 1, step + 1);
    }
}

int main()
{
    while (cin >> n)
    {
        solve(1, 0);
    }
}

例题: Contest Hunter 0302 递归实现组合型枚举

int ans[20], n, m;

void solve(int num, int step)
{
    if (num == n + 1 && step == m)
    {
        for (int i = 0; i < step; ++i)
        {
            cout << ans[i];
            if (i + 1 < step) cout << " ";
        }
        cout << endl;
    }
    else if (num <= n && step <= m)
    {
        ans[step] = num;

        solve(num + 1, step + 1);

        solve(num + 1, step);
    }
}

int main()
{
    while (cin >> n >> m)
    {
        solve(1, 0);
    }
}

例题: Contest Hunter 0303 递归实现排列型枚举

bool vis[15];
int ans[15], n;

void solve(int step)
{
    if (step == n)
    {
        cout << ans[0];
        for (int i = 1; i < n; ++i) cout << " " << ans[i];
        cout << endl;
    }
    else
    {
        for (int i = 1; i <= n; ++i)
        {
            if (!vis[i])
            {
                vis[i] = true;
                ans[step] = i;
                solve(step + 1);
                vis[i] = false;
            }
        }
    }
}

int main()
{
    while (cin >> n)
    {
        memset(vis, 0, sizeof(vis));
        solve(0);
    }
}

例题: POJ 1845 Sumdiv(Undone)

A B A^B 的所有约数之和 m o d   9901 mod~9901

把A分解质因数,表示为: p 1 c 1 p 2 c 2 p n c n p_1^{c_1} * p_2^{c_2} * \cdots * p_n^{c_n}

那么, A B A^B 可以表示为 p 1 c 1 B p 2 c 2 B p n c n B p_1^{c_1 * B} * p_2^{c_2 * B} * \cdots * p_n^{c_n * B}

所以, A B A^B 的所有约数之和可以表示为集合 { p 1 k 1 , p 2 k 2 , &ThinSpace; , p n k n } \{p_1^{k_1}, p_2^{k_2}, \cdots, p_n^{k_n}\} ,其中 0 k i B c i ( 1 i n ) 0 \le k_i \le B * c_i (1 \le i \le n)

根据乘法分配律, A B A^B 的所有约数之和就是:

( 1 + p 1 + p 1 2 + + p 1 B c 1 ) ( 1 + p 2 + p 2 2 + + p 2 B c 2 ) ( 1 + p n + p n 2 + + p n B c n ) (1 + p_1 + p_1^2 + \cdots + p_1^{B * c_1}) * (1 + p_2 + p_2^2 + \cdots + p_2^{B * c_2}) * \cdots * (1 + p_n + p_n^2 + \cdots + p_n^{B * c_n})

剩下的等学了数论再来写,2019-1-22 留坑

例题: POJ 3889 Fractal Streets

typedef long long ll;

pair <ll, ll> calc(ll n, ll m)
{
    if (n == 0) return make_pair(0, 0);

    ll len = 1 << (n - 1), cnt = 1 << (2 * n - 2);

    pair <ll, ll> pos = calc(n - 1, m % cnt);

    ll x = pos.first, y = pos.second;
    ll z = m / cnt;

    if (z == 0) return make_pair(y, x);
    if (z == 1) return make_pair(x, y + len);
    if (z == 2) return make_pair(x + len, y + len);
    if (z == 3) return make_pair(2 * len - y - 1, len - x - 1);
}

int main()
{
    ll cases;
    cin >> cases;

    while (cases--)
    {
        ll n, h, o;
        cin >> n >> h >> o;

        pair <ll, ll> hp = calc(n, h - 1);
        pair <ll, ll> op = calc(n, o - 1);

        ll dx = hp.first - op.first, dy = hp.second - op.second;

        cout << fixed << setprecision(0) << 10.0 * sqrt(dx * dx + dy * dy) << endl;
    }
}

二分

整数集合上的二分

本书使用的二分的写法保证最终答案处于闭区间 [ l , r ] [l, r] 内,循环以 l = r l = r 结束,每次二分的中间值会归属于左半段与右半段之一。

单调递增序列a中查找 x \ge x 的数中最小的一个(即x或x的后继);

while (left < right)
{
    int mid = (left + right) >> 1;

    if (a[mid] >= x) right = mid;
    else left = mid + 1;
}

单调递增序列a中查找 x \le x 的数中最大的一个(即x或x的前驱)

while (left < right)
{
    int mid = (left + right + 1) >> 1;

    if (a[mid] <= x) left = mid;
    else right = mid - 1;
}

在第一段代码中,若 a [ m i d ] &gt; = x a[mid] &gt;= x ,根据序列a的单调性,mid之后的数会更大,所以 x \ge x 的数不可能在mid之后,可行区间应该缩小为左半段;因为mid也可能是答案,故此时取 r i g h t = m i d right = mid ;同理,若 a [ m i d ] &lt; x a[mid] &lt; x ,应该取 l e f t = m i d + 1 left = mid + 1

在第二段代码中,若 a [ m i d ] &lt; = x a[mid] &lt;= x ,根据序列a的单调性,mid之前的数只会更小,所以 x \le x 的最大的数不会在mid之前,可行区间缩小为右半段,因为mid也可能是答案,故此时取 l e f t = m i d left = mid ;同理,若 a [ m i d ] &gt; x a[mid] &gt; x ,取 r i g h t = m i d 1 right = mid - 1

上面两种mid的取法和缩小范围的方法是配套的,否则会造成死循环。

本书中使用的这种二分方法的优点是始终保持答案位于二分区间内,二分结束条件对应的值恰好在答案所处的位置,还可以很自然地处理无解的情况,形式优美,唯一的缺点是由两种形式共同组成,需要认真考虑实际问题决定选择的形式。

实数域上的二分

实数域上的二分较为简单,确定好所需要的精度eps,以 l + e p s &lt; r l + eps &lt; r 为条件,每次根据在mid上的判定选择l=mid或者r=mid两者之一即可,一般在需要保留k位小数时,取 e p s = 1 0 k + 2 eps = 10^{-k + 2} 即可。

有时精度不容易确定或者表示,就干脆采用循环固定次数的二分方法,也是一种相当不错的策略。这种方法得到的结果的精度通常比设置eps更高。

三分求单峰函数极值

有一类函数称为单峰函数,它们拥有唯一的极大值点,在极大值点左侧严格单调上升,在极大值点右侧严格单调下降;或者拥有唯一的极小值点,在极小值点左侧严格单调下降,在极小值点右侧严格单调上升。

为了避免混淆,我们称后一种函数为单谷函数,对于单峰或单谷函数,可以用三分求其极值。

以单峰函数 f ( ) f() 为例,我们在函数的定义域 [ l , r ] [l, r] 上人去两个点lmid和rmid,其中 l &lt; l i m d &lt; r m i d &lt; r l &lt; limd &lt; rmid &lt; r ,把函数分成三段:

  1. 如果 f ( l m i d ) &lt; f ( r i m d ) f(lmid) &lt; f(rimd) ,则limd和rimd要么同时处于极大值点左侧,要么分别位于极大值点两侧,无论是哪种情况,都可以确定极大值点在lmid右侧,可令 l = l m i d l = lmid

  2. 如果 f ( l m i d ) &gt; f ( r m i d ) f(lmid) &gt; f(rmid) ,则lmid和rmid要么同时处于极大值点右侧,要么分别位于极大值点两侧,无论是哪种情况,都可以确定极大值点在rimd左侧,可令 r = r m i d r = rmid

如果在三分中遇到了 l i m d = r m i d limd = rmid ,对于严格单调的函数,此时取 l = l m i d l = l mid 或者 r = r m i d r = rmid 均可,对于非严格单调的函数,此时三分法不再适用。

二分答案转化为判定

一个宏观的最优化问题也可以抽象为函数,其定义域是该问题下的可行方案,对这些可行方案进行评估得到的数值构成函数的值域,最优解就是评估最优方案(不妨设评估越高越优)。假设最优评分是S,显然对于所有>S的值,都不存在一个合法的方案达到此评分,否则就与S的最优性矛盾,而对于所有的<S,一定存在一个合法方案达到或超过此评分。这样的问题的值域就具有一种特殊的单调性,在S的一侧合法,另一侧不合法,就像一个在 ( , S ] (-\infty, S] 上为1,在 ( S , ) (S, \infty) 上值为0的分段函数,可以通过二分找到这个分界点S。

借助二分,我们把求解最优解问题,转化为给定一个值mid,判断是否存在一个可行方案评分达到mid的问题

接下来我们通过一个经典例子理解上述概念。

有N本书排成一行,已知第i本的厚度是 A i A_i
把它们分成连续的M组,使T最小,其中T表示厚度之和最大的一组的厚度。

bool valid(int size)
{
    int group = 1, reset = size;

    for (int i = 1; i <= n; ++i)
    {
        if (reset >= a[i])
        {
            reset -= a[i];
        }
        else 
        {
            group++;
            reset = size - a[i];
        }
    }

    return group <= m;
}

int main()
{
    int l = 0, r = sum;

    while (l < r)
    {
        int mid = (l + r) >> 2;

        if (valid(mid)) r = mid;
        else l = mid + 1;
    }

    cout << l << endl;
}

例题: POJ 2018 Best Cow Fences

给定正整数数列A,求一个平均数最大的,长度不小于L的连续的子段。

详细题解参阅书本

int n, l;
double num[100000 + 5], sum[100000 + 5];

int main()
{
    while (cin >> n >> l)
    {
        double left = 0, right = 0;
        for (int i = 1; i <= n; ++i)
        {
            scanf("%lf", &num[i]); // 输入输出坑死
            right = max(right, num[i]);
        }

        while (left + 1e-5 < right)
        {
            double mid = (left + right) / 2; // mid作为当前尝试的平均数最大值

            for (int i = 1; i <= n; ++i)
            {
                sum[i] = sum[i - 1] + num[i] - mid; // num[] 中每个数减去mid,再计算前缀和
            }

            double ans = -1e10, MIN = 1e10;
            for (int i = l; i <= n; ++i)
            {
                MIN = min(MIN, sum[i - l]); // 求区间长度大于等于l的最大子段和
                ans = max(ans, sum[i] - MIN);
            }

            if (ans >= 0) left = mid;
            else right = mid;
        }

        cout << int(right * 1000) << endl;
    }
}

排序

  1. 选择排序、插入排序、冒泡排序
  2. 堆排序、归并排序、快速排序
  3. 计数排序、基数排序、桶排序

前两类是基于比较的排序算法;对n个元素进行排序时,若元素比较大小的时间复杂度为 O ( 1 ) O(1) ,则第一类排序算法的时间复杂度为 O ( n 2 ) O(n^2) ,第二类排序算法的时间复杂度为 O ( n log n ) O(n \log{n})

实际上,基于比较的排序算法的时间复杂度下届为 O ( n log n ) O(n \log{n}) ,因此堆排序、归并排序、快速排序已经是时间复杂度最优的基于比较的排序算法。

第三类排序算法则换了一种思路,其时间复杂度不仅与n有关,还与数值大小范围m有关,本书不讲(+_+)?

### 离散化

通俗的讲,离散化就是把无穷大集合中的若干个元素映射为有限集合以便于统计的方法。

例如在很多情况下,问题的范围虽然定义在整数集合 Z Z 中,但是问题只涉及其中m个有限数值,并且与这些数值的大小无关(只是把这些数字作为代表,或者只与它们的相对顺序有关),此时我们就可以把这m个数字与1-m建立起映射关系。

具体地说,假设问题牵扯到int范围内的n个整数a[1] ~ a[n],这n个数有可能重复,去重以后共有n个数,我们要把每一个整数a[i]用一个1~m内的数代替,并且保证大小顺序不变,如果a[i]大于,等于或小于a[j],那么代替a[i]的数也大于、等于或小于代替a[j]的数。

我们可以把a数组排序并去掉重复的数值,得到有序数组b[1] ~ b[m],在b数组的下标i与数值b[i]之间建立映射关系;若要查询整数i代替的数值,直接返回b[i],若要查询整数a[j]被哪个1到m之间的数代替,只需要在数组b中二分查找a[j]的位置即可。

void discrete()
{
    sort(a + 1, a + 1 + n);
    m = unique(a + 1, a + 1 + n) - a;
}

void query(int x)
{
    return lower_bound(a + 1, a + 1 + m, x) - a;
}

例题: CodeForces 670C Cinema

超级暴力的代码,离散化n + 2 * m个语言,然后统计说每一种语言的人数,最后求最优解。

constexpr int MAX = 200000 + 5;

int sci[MAX], audio[MAX], subtitle[MAX], lang[3 * MAX], num[MAX], n, m;

int main()
{
    while (cin >> n)
    {
        for (int i = 0; i < n; ++i)
        {
            cin >> sci[i];
            lang[i] = sci[i];
        }

        cin >> m;

        for (int i = 0; i < m; ++i)
        {
            cin >> audio[i];
            lang[n + i] = audio[i];
        }

        for (int i = 0; i < m; ++i)
        {
            cin >> subtitle[i];
            lang[n + m + i] = subtitle[i];
        }

        sort(lang, lang + n + 2 * m);
        int len = int(unique(lang, lang + n + 2 * m) - lang);

        for (int i = 0; i < n; ++i)
        {
            sci[i] = int(lower_bound(lang, lang + len, sci[i]) - lang);
        }

        for (int i = 0; i < m; ++i)
        {
            audio[i] = int(lower_bound(lang, lang + len, audio[i]) - lang);
            subtitle[i] = int(lower_bound(lang, lang + len, subtitle[i]) - lang);
        }

        memset(num, 0, sizeof(num));
        for (int i = 0; i < n; ++i)
        {
            num[sci[i]]++;
        }

        int index = 0;
        for (int i = 1; i < m; ++i)
        {
            if (num[audio[i]] > num[audio[index]])
            {
                index = i;
            }
            else if (num[audio[i]] == num[audio[index]])
            {
                if (num[subtitle[i]] > num[subtitle[index]])
                    index = i;
            }
        }

        cout << index + 1 << endl;
    }
}

中位数

在有序序列中,中位数具有一些优美的性质,可以引出一系列与它相关的问题,动态维护序列的中位数也非常值得探讨,用例题感受一下:

例题: Contest Hunter 0501 货仓选址

在一条数轴上有N家商店,它们的坐标分别为A[1] ~ A[N],现在需要在数轴上建立一家货仓,每天清晨,从货仓到每一家商店都要运送一车商品,为了提高效率,求把货仓建立在何处,可以使货仓到每家商店的距离之和最小, 输出最小距离和。

把A[1] ~ A[N]排序,设货仓建立在坐标X处,X左侧有商店P家,右侧有商店Q家,若 P &lt; Q P &lt; Q ,则每把货仓向右移动一单位的距离,距离之和就会减小 Q P Q - P ;同理,若 P &gt; Q P &gt; Q ,则将货仓的选址向左移动距离之和就会变小。当 P = Q P = Q 时为最优解。

因此货仓应该建立在中位数处,当N为奇数时,货仓建立在A[(N + 1) / 2]处最优,当N为偶数时,货舱建立在A[N / 2] ~ A[N / 2 + 1]之间的任何位置都是最优解。

constexpr int MAX = 100000 + 5;

int n, arr[MAX];

int main()
{
    while (cin >> n)
    {
        for (int i = 1; i <= n; ++i)
        {
            cin >> arr[i];
        }

        sort(arr + 1, arr + 1 + n);

        int pos = arr[(n + 1) / 2], ans = 0;

        for (int i = 1; i <= n; ++i)
        {
            ans += abs(arr[i] - pos);
        }

        cout << ans << endl;
    }
}

例题: Contest Hunter 0502 七夕祭

经过分析,我们可以发现,交换左右两个相邻的摊点只会改变某两列中cl感兴趣的摊点数,不会改变每行中cl感兴趣的摊点数;

同理,交换上下两个相邻的摊点,只会改变某两行中cl感兴趣的摊点数,不会改变每一列中cl感兴趣的摊点数。

所以,我们可以把本题分成两个互相独立的问题计算:

  1. 通过最小次数的左右交换使每中cl感兴趣的摊点数相同。
  2. 通过最小次数的上下交换使每中cl感兴趣的摊点数相同。

以第一个问题为例进行探讨:

我们可以统计出在初始状态下,每一列中cl感兴趣的摊点总数,记为C[1] ~ C[M],若cl感兴趣的摊点的总数T不能被M整除,则不可能达到要求。如果T能够被M整除,那我们的目标就是让每一列中有T/M个cl感兴趣的摊点。

与此类似的有一个经典问题:均分纸牌:有M个人排成一行,他们手中分别有C[1] ~ c[M]张纸牌,在每一步操作中,可以让某一个人把自己手中的一张纸牌交给他旁边的一个人,求至少要多少步操作才能让每个人手中的纸牌数相等。

显然,均分纸牌问题当所有人手中的纸牌总数T能被M整除时有解,在有解时,我们可以先考虑第一个人:

  1. C [ 1 ] &gt; T / M C[1] &gt; T/M ,则第一个人需要给第二个人 C [ 1 ] T / M C[1] - T/M 张纸牌,即把C[2]加上 C [ 1 ] T / M C[1] - T/M

  2. C [ 1 ] &lt; T / M C[1] &lt; T/M ,则第一个人需要从第二个人手中拿 T / M C [ 1 ] T/M - C[1] 张纸牌,即把C[2]减去 T / M C [ 1 ] T/M - C[1]

们按照同样的方法依次考虑第2 ~ M个人,即使在某一个时刻C[i]被减为负数也没有关系,因为接下来c[i]就会从C[i + 1]处拿牌,在实际操作中可以认为C[i - 1]从C[i]处拿牌发生在C[i]从C[i + 1]处拿牌之后。

按照这种方法,达到目标所选要的最少步数是:

i = 1 M i T M G [ i ] G [ i ] C [ i ] G [ i ] = j = 1 i C [ i ] \sum_{i=1}^M \left| i * \frac{T}{M} - G[i] \right| 其中G[i]是C[i]的前缀和,即G[i] = \sum_{j=1}^{i} C[i]

如果我们设 A [ i ] = C [ i ] T / M A[i] = C[i] - T / M ,即一开始就让每个人的手中的纸牌数都减掉T/M,并最终让每个人的手中都恰好有0张纸牌,答案显然不变,就是:

i = 1 M S [ i ] S A S [ i ] = j = 1 i A [ j ] \sum_{i=1}^{M} \left| S[i] \right| 其中S是A的前缀和,即 S[i] = \sum_{j=1}^i A[j]

其含义是:每一个前缀最初拥有G[i]张纸牌,最后拥有i * T/M张纸牌,多退少补,会与后边的人发生二者绝对值之差张数的纸牌交换。

回到本题,如果不考虑第一列与最后一列也是相邻的这一条件,那么本题的两个问题与均分纸牌问题是等价的;若问题有解,一定存在一种适当的顺序,使得每一步传递纸牌的操作可以转化为交换一对左右相邻的摊点。

若考虑第一列与最后一列也相邻的条件,则问题等价于一个环形均分纸牌问题。仔细思考可以发现,一定存在一种最优解的方案,使环上某两个相邻的人之间没有发生交换纸牌的操作,于是有一种朴素的做法是,枚举这个没有发生交换的位置,把环断开成一行,转化为一般的均分纸牌问题进行计算。

首先,一般的均分纸牌问题相当于在第M个人和第1个人之间把环断开,此时这M个人站成一行,其持有的纸牌数、前缀和分别是:

A [ 1 ] S [ 1 ] A[1] \quad S[1]
A [ 2 ] S [ 2 ] A[2] \quad S[2]
\dots \quad \dots
A [ M ] S [ M ] A[M] \quad S[M]

如果在第K个人之后把环断开写成一行,这M个人持有的纸牌数、前缀和分别是:

A [ k + 1 ] S [ k + 1 ] S [ k ] A[k + 1] \quad S[k + 1] - S[k]
A [ k + 2 ] S [ K + 2 ] S [ k ] A[k + 2] \quad S[K + 2] - S[k]
\dots \quad \dots
A [ M ] S [ M ] S [ k ] A[M] \quad S[M] - S[k]
A [ 1 ] S [ 1 ] + S [ M ] S [ k ] A[1] \quad S[1] + S[M] - S[k]
\dots \quad \dots
A [ k ] S [ k ] + S [ M ] S [ k ] A[k] \quad S[k] + S[M] - S[k]

注意:此处的A是减去T/M的数组,所以S[M]=0;

根据我们之前推导的公式,所需要的最少步数为:

i = 1 M S [ i ] S [ k ] S A S [ i ] = j = 1 i A [ j ] \sum_{i=1}^M \left| S[i] - S[k] \right| 其中S是A的前缀和,即 S[i] = \sum_{j=1}^i A[j]

当k取何值时上式最小??这就是上一题货仓选址!!!,其中S[i]是数轴上商店的位置,S[k]是货仓的位置, S [ i ] S [ k ] \left| S[i] - S[k] \right| 就是二者之间的距离。根本不需要枚举,是需要将S数组排序,取中位数即可!!!

至此,本题得到完美解决,时间复杂度为 O ( N log N + M log M ) O(N \log N + M \log M)

上代码!!!:

using ll = long long;
constexpr ll MAX = 100000 + 5;
ll n, m, t, row_cnt[MAX], col_cnt[MAX], row_pre[MAX], col_pre[MAX];

int main()
{
    while (cin >> n >> m >> t)
    {
        bool row_possible = t % n == 0;
        bool col_possible = t % m == 0;

        ll x, y, row_ans = 0, col_ans = 0;
        for (ll i = 0; i < t; ++i)
        {
            cin >> x >> y;
            row_cnt[x]++;
            col_cnt[y]++;
        }

        if (row_possible)
        {
            for (ll i = 1; i <= n; ++i)
            {
                row_cnt[i] -= t / n;
                row_pre[i] = row_cnt[i] + row_pre[i - 1];
            }

            sort(row_pre + 1, row_pre + 1 + n);

            for (ll i = 1; i <= n; ++i)
            {
                row_ans += abs(row_pre[i] - row_pre[(n + 1) / 2]);
            }
        }

        if (col_possible)
        {
            for (ll i = 1; i <= m; ++i)
            {
                col_cnt[i] -= t / m;
                col_pre[i] = col_cnt[i] + col_pre[i - 1];
            }

            sort(col_pre + 1, col_pre + 1 + m);

            for (ll i = 1; i <= m; ++i)
            {
                col_ans += abs(col_pre[i] - col_pre[(m + 1) / 2]);
            }
        }

        if (row_possible && col_possible) cout << "both " << row_ans + col_ans << endl;
        else if (row_possible) cout << "row " << row_ans << endl;
        else if (col_possible) cout << "column " << col_ans << endl;
        else cout << "impossible" << endl;

        memset(row_cnt, 0, sizeof(row_cnt));
        memset(col_cnt, 0, sizeof(col_cnt));
    }
}

综上所述,本题可以类比为行、列两个方向上的两次环形均分纸牌问题,环形均分纸牌又类比为均分纸牌货舱选址问题,其中的每一步都仅使用了基本算法和性质,最后转化为了简单而经典的问题;读者应该时刻把各种模型之间的简化、扩展和联系作为算法设计与学习的脉络,以点成线,触类旁通才能产生质量到数量的飞跃!!!

例题: POJ 3784 Running Median

动态维护中位数问题:依次读入一个整数数列,每当读入的整数的个数为奇数时,输出已经读入的整数的构成的序列的中位数。

为了动态维护中位数,我们可以建立两个二叉堆,一个小根堆、一个大根堆;在依次读入这个整数序列的过程中,我们设当前的序列长度为M,我们始终保持:

  1. 序列中从小到大排名为1 ~ M/2的整数储存在大根堆中;
  2. 序列中从小到大排名为M/2+1 ~ M的整数储存在小根堆中;

任何时候,如果某一个堆中元素个数过多,打破了这个性质,就取出该堆堆顶的元素,插入另一个堆,这样一来,中位数就是小根堆堆顶的元素。

上述即为对顶堆算法。

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

    while (cases--)
    {
        int index, n, cnt = 1, tmp, mid;

        cin >> index >> n;
        cout << index << " " << (n + 1) / 2 << endl;

        priority_queue <int, vector <int>, less <int> > big;
        priority_queue <int, vector <int>, greater <int> > small;

        cin >> tmp;
        big.push(tmp);
        cout << tmp << " ";
        mid = tmp;

        for (int i = 2; i <= n; ++i)
        {
            cin >> tmp;

            if (tmp < mid) big.push(tmp);
            else small.push(tmp);

            if ((int)big.size() - (int)small.size() > 1)
            {
                small.push(big.top());
                big.pop();
            }
            else if ((int)big.size() - (int)small.size() < -1)
            {
                big.push(small.top());
                small.pop();
            }

            if (i & 1)
            {
                cnt++;
                if (big.size() > small.size()) mid = big.top();
                else mid = small.top();

                if (cnt % 10 == 0) cout << mid << endl;
                else cout << mid << " ";
            }
            else mid = (big.top() + small.top()) / 2;
        }

        cout << endl;
    }
}

第k大数

利用快速排序(Quick Sort)的思想;

从大到小进行快速排序的思想是,在每一层递归中,随机选取一个数作为基准,把比它大的数交换到左半段,把比它小的数交换到右半段,然后继续递归对左右两边分别进行排序,平均情况下的时间复杂度为 O ( n log n ) O(n \log n)

实际上在每一次选出基准值后,我们可以统计出大于基准值的数的个数cnt,如果 k c n t k \le cnt ,就在左半段中寻找第k大数,反之,就在右半段中寻找第k - cnt大数,平均情况下,复杂度为 O ( n ) O(n)

逆序对

使用归并排序,可以在 O ( n log n ) O(n \log n) 的时间内求出一个长度为n的序列的逆序数对个数。

合并两个有序序列a[l ~ mid]、a[mid + 1 ~ r],可以采取用两个指针i、j分别进行扫描的方式,不断比较两个指针指向的数字a[i]和a[j]的大小,将小的那个加入到排序的结果数组中,如果较小的那个是a[j],则a[i ~ mid]都比a[j]要大,他们都会与a[j]构成逆序对,可以统计到答案中。

void merge(int l, int mid, int r) // b是临时数组,cnt是逆序对个数
{
    int i = l, j = mid + 1;

    for (int j = l; k <= r; ++k)
    {
        if (j > r || i <= mid && a[i] < a[j]) 
            b[k] = a[i++];
        else 
        {
            b[k] = a[j++];
            cnt += mid - i + 1;
        }
    }

    for (int k = l; l <= r; ++k) a[k] = b[k];
}

后续章节中还会有树状数组求逆序对的方法。

例题: POJ 2299 Ultra-QuickSort

求逆序对的个数。

const int MAX = 500000 + 5;

int arr[MAX], tmp[MAX], n;
long long ans;

void merge(int l, int r)
{
    int mid = (l + r) / 2;
    int i = l, j = mid + 1;

    for (int k = l; k <= r; ++k)
    {
        if (j > r || (i <= mid && arr[i] < arr[j]))
        {
            tmp[k] = arr[i];
            ++i;
        }
        else
        {
            tmp[k] = arr[j];
            ans += mid - i + 1;
            ++j;
        }
    }

    for (int k = l; k <= r; ++k) arr[k] = tmp[k];
}

void merge_sort(int l , int r)
{
    if (l < r)
    {
        int mid = (l + r) / 2;

        merge_sort(l, mid);
        merge_sort(mid + 1, r);
        merge(l, r);
    }
}

int main()
{
    while (cin >> n && n)
    {
        ans = 0;
        for (int i = 1; i <= n; ++i) scanf("%d", &arr[i]);

        merge_sort(1, n);

        cout << ans << endl;
    }
}

例题: Contest Hunter 0503 奇数码问题

奇数码问题的两个局面可以互相到达,当且仅当两个局面下网格中的数依次写成一行 n n 1 n * n - 1 个元素的序列后,逆序对个数的奇偶性相同。

该结论的必要性很好证明,当空格左右移动时,写成的序列显然不变,当空格上下移动时,相当于某一个数和它的前/后n - 1个数交换了位置,因为n - 1是偶数,所以逆序数对变化的个数也只能是偶数。

该结论的充分性证明较为复杂,略略略……

上述结论还可以推广到更复杂的情况。

const int MAX = 505;

int a[MAX * MAX], b[MAX * MAX], tmp[MAX * MAX], n, cnt, cnt1, cnt2;

void merge(int arr[], int l, int r)
{
    int mid = (l + r) >> 1;
    int i = l, j = mid + 1;

    for (int k = l; k <= r; ++k)
    {
        if (j > r || (i <= mid && arr[i] < arr[j]))
        {
            tmp[k] = arr[i];
            ++i;
        }
        else
        {
            tmp[k] = arr[j];
            ++j;
            cnt += mid - i + 1;
        }
    }

    for (int k = l; k <= r; ++k) arr[k] = tmp[k];
}

void merge_sort(int arr[], int l, int r)
{
    if (l < r)
    {
        int mid = (l + r) >> 1;
        merge_sort(arr, l, mid);
        merge_sort(arr, mid + 1, r);
        merge(arr, l, r);
    }
}

int main()
{
    while (cin >> n)
    {
        int index = 1, t;
        for (int i = 0; i < n; ++i)
        {
            for (int j = 0; j < n; ++j)
            {
                scanf("%d", &t);
                if (t > 0)
                {
                    a[index] = t;
                    ++index;
                }
            }
        }

        index = 1;
        for (int i = 0; i < n; ++i)
        {
            for (int j = 0; j < n; ++j)
            {
                scanf("%d", &t);
                if (t > 0)
                {
                    b[index] = t;
                    ++index;
                }
            }
        }

        cnt = 0;
        merge_sort(a, 1, index - 1);
        cnt1 = cnt;

        cnt = 0;
        merge_sort(b, 1, index - 1);
        cnt2 = cnt;

        if ((cnt1 - cnt2) & 1) cout << "NIE" << endl;
        else cout << "TAK" << endl;
    }
}

倍增

在我们进行递推时,如果状态空间很大,通常的线性递推无法满足时间和空间复杂度的要求,那么我们可以通过成倍增长的方式,只递推状态空间中2的整数次幂的位置作为代表;当需要其他位置上的数值时,我们利用任意整数均可以表示成若干个二的次幂项的和这一性质,使用之前求出的代表值拼成所需的值。

也就是说,使用倍增算法要求我们递推的问题的状态空间关于2的次幂具有可划分性。

倍增二进制划分两个思想相互结合,降低了求解很多问题的时间与空间复杂度;之前的快速幂快速乘其实是倍增与二进制划分思想的一种体现。

在本节中,我们研究序列上的倍增问题,包括求解RMQ(区间最值)问题的ST算法,关于求解LCA(最近公共祖先)等在树上的倍增应用

试想这样一个问题:

给定一个长度为N的序列A,然后进行若干次询问,每次给定一个整数T,求出最大的k,满足 i = 1 k A [ i ] &lt; T \sum_{i=1}^k A[i] &lt; T 。你的算法必须是在线的,假设 0 T i = 1 N 0 \le T \le \sum_{i=1}^N

最朴素的做法当然是从前向后枚举k,时间复杂度最坏为 O ( N ) O(N)

如果能够先花费 O ( N ) O(N) 的时间预处理出A的前缀和S,就可以二分查找k的位置,时间复杂度为 O ( log N ) O(\log N)
这个算法在平均情况下表现很好,但其缺点在于,如果每一次的T都很小,造成答案k也很小,该算法可能还不如从前向后枚举更优。

我们可以设计这样一种倍增算法:

  1. p = 1 , k = 0 , s u m = 0 p=1,k=0,sum=0
  2. 比较A数组中k之后的p个数的和与T的关系,也就是说,如果 s u m + S [ k + p ] S [ k ] T sum + S[k + p] - S[k] \le T ,则令 s u m + = S [ k + p ] S [ k ] , k + = p , p = 2 sum += S[k + p] - S[k], k += p, p *= 2 ,即累加上这p个数的和;然后把p的跨度增长一倍。如果 s u m + S [ k + p ] S [ k ] &gt; T sum + S[k + p] - S[k] &gt; T ,则令 p / = 2 p /= 2
  3. 重复上一步,直到p的值变为0,此时k就是答案。

这个算法始终在答案大小的范围内实施倍增二进制划分的思想,通过若干长度为2的次幂的区间最后拼成k,时间复杂度级别为答案的对数,能够对应T的各种大小情况。

例题: Contest Hunter 0601 Genius ACM

给定一个整数M,对于任意一个整数集合S,定义校验值如下:
从集合S中取出M对数(不能重复使用,如果数量不够2*M就取到不能再取为止),使得每对数的差的平方之和最大,这个最大值即为集合S的校验值。

现在给定一个长度为N的数列A,以及一个整数T,我们要把A分成若干段,使得每一段的校验值都不超过T,求最少需要分成几段。

首先,对于一个集合S,显然应该取集合中最大的M个数和最小的M个数,最大和最小构成一对,次大和次小构成一对……这样求出的校验值最大。

为了让A数列分成的段数尽可能少,每一段都应该在校验值不超过T的情况下,尽可能包含更多的数,于是我们从头开始对A进行分段,让每一段尽量长,到达结尾时分成的段数就是答案。

于是,需要解决的问题变为:当确定一个左端点L之后,右端点R最大能取到多少。

求长度为N的一段的校验值需要排序,时间复杂度为 O ( n log n ) O(n \log n) ,当校验值的上限比较小时,如果在L ~ N上二分右端点R,二分的第一步就是要校验 ( N L ) / 2 (N - L) / 2 这么长一段,而最终右端点R却只可能扩展了一点儿,浪费时间。

所以,与上一个问题一样,可以对右端点使用倍增算法。

可以与上一题采用类似的倍增过程:

  1. 初始化 p = 1 , R = L p=1, R=L
  2. 求出 [ L , R + p ] [L, R + p] 这一段的校验值,如果校验值小于等于T, R + = p , p = 2 R+=p, p*=2 ,否则就 p / = 2 p/=2
  3. 重复上一步,直到p=0;

同时,在每次计算校验值时,不必每一次都采用快速排序,可以使用并归排序的思想,每一次计算都只对新加入的部分进行排序,然后将新旧部分合并。

自己写的代码太烂了,还是参照别人的。

using ll = long long;

constexpr ll MAX = 500000 + 5;

ll a[MAX], b[MAX], c[MAX], n, m, k, pos;

void merge(ll l, ll mid, ll r)
{
    ll i = l, j = mid + 1;

    for (ll k = l; k <= r; ++k)
    {
        if (j > r || (i <= mid && b[i] <= b[j])) c[k] = b[i++];
        else c[k] = b[j++];
    }
}

ll calc(ll l, ll r)
{
    for (ll i = pos + 1; i <= r; i++) b[i] = a[i]; // 排序新加入的部分

    sort(b + pos + 1, b + r + 1);

    merge(l, pos, r); // 合并

    ll low = l, high = r, cnt = m, ans = 0;

    while (low < high && cnt)
    {
        ans += (c[low] - c[high]) * (c[low] - c[high]); // c 是已经排好序的数组
        ++low;
        --high;
        --cnt;
    }

    return ans;
}

int main()
{
    ll cases;
    cin >> cases;

    while (cases--)
    {
        cin >> n >> m >> k;

        for (ll i = 1; i <= n; ++i) cin >> a[i];

        ll l = 1, r = 1, ans = 0;
        pos = 1; // 一开始已经排序好的部分为其一个元素
        b[1] = a[1];

        while (l <= n)
        {
            ll p = 1;
            while (p)
            {
                if (calc(l, min(r + p, n)) <= k)
                {
                    pos = r = min(r + p, n);
                    for (ll i = l; i <= r; ++i) b[i] = c[i]; // 复制c到b,为了merge
                    if (r == n) break;
                    p *= 2;
                }
                else
                {
                    p /= 2;
                }
            }
            l = r + 1;
            ans++;
        }

        cout << ans << endl;
    }
}

ST算法!!!

RMQ问题中,著名的ST算法就是倍增的产物,给定一个长度为N的数列A,ST算法能够在 O ( N log N ) O(N \log N) 的预处理之后,以 O ( 1 ) O(1) 的时间复杂度在线回答区间最值的询问。

一个序列的子区间个数显然有 O ( N 2 ) O(N^2) 个,根据倍增思想,我们首先在这个规模为 O ( N 2 ) O(N^2) 的状态空间内选择一些2的整数次幂的位置作为代表值。

F [ i , j ] F[i, j] 代表数列A中下标在区间 [ i , i + 2 j 1 ] [i, i + 2^j - 1] 里的数的最大值,也就是从i开始的 2 j 2^j 个数的最大值,递推边界显然是 F [ i , 0 ] = A [ i ] F[i, 0] = A[i]

在递推时,我们把子区间的长度成倍增长,有公式 F [ i , j ] = m a x ( F [ i , j 1 ] , F [ i + 2 j 1 , j 1 ] ) F[i, j] = max(F[i, j - 1], F[i + 2^{j-1}, j - 1]) ,即长度为 2 j 2^j 的子区间的最大值是左右两半长度为 2 j 1 2^{j-1} 的子区间的最大值中较大的一个。

void ST_prework()
{
    for (int i = 1; i <= n; ++i) f[i][0] = a[i];

    int t = log(n) / log(2) + 1;

    for (int j = 1; j < t; ++j)
    {
        for (int i = 1; i <= n - (1 << j) + 1; ++i)
        {
            f[i][j] = max(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]);
        }
    }
}

当询问任意区间 [ l , r ] [l, r] 的最值时,我们先计算出一个k,满足 2 k &lt; r l + 1 &lt; 2 k + 1 2^k &lt; r - l + 1 &lt; 2^{k + 1} ,那么以l开头的 2 k 2^k 个数和以r结尾的 2 k 2^k 个数一定覆盖了整个区间 [ l , r ] [l, r] ,而且这两段的最大值分别是 F [ l , k ] F [ r 2 k + 1 , r ] F[l, k] 和 F[r - 2^k+ 1, r] ,因为是求最大值,所以即使有重合也没有关系。

int ST_query(int l, int r)
{
    int k = log(r - l + 1) / log(2);
    return max(f[l][k], f[r - (1 << k) + 1][k]);
}

简便起见,代码中使用了log函数,该函数效率较高,一般来说对程序性能影响不大。更严格来讲,为了保证复杂度为 O ( 1 ) O(1) ,应该预处理出1 ~ N这N种区间长度各自对应的k值,在询问时直接使用。


贪心

贪心是一种在每一次决策时都采用当前意义下最优策略的算法,因此,使用贪心算法要求问题的整体最优性可以由局部最优性导出。

贪心算法的正确性需要证明,常见的证明手段有:

  1. 微扰(邻项交换)
    证明在任意局面下,任何对局部最优策略的微小改变都会造成整体结果变差,经常用于以排序为贪心策略的证明。

  2. 范围缩放
    证明任何对局部最优策略作用范围的扩展都不会造成整体结果变差。

  3. 决策包容性
    证明在任意局面下,做出局部最优策略以后,在问题状态空间中的可达到集合包含了做出其他任何决策之后的可到达集合。换言之,这个局部最优解提供的可能性包含其他所有策略提供的可能性。

  4. 反证法

  5. 数学归纳法

上例题!

例题: POJ 3614 Sunscreen

有C头奶牛晒日光浴,每一只奶牛需要minSPF[i]到maxSPF[i]之间强度的阳光(闭区间),现在有L种防晒霜,第i种防晒霜可以将奶牛收到的光照强度固定为SPF[i],第i种防晒霜有cover[i]瓶,求最多能让几头奶牛晒上日光浴。

按照minSPF递增的顺序将奶牛排序,然后按顺序遍历奶牛,对于每一头奶牛,在所有能用的防晒霜中选择SPF最小的那一种。

证明:假设当前奶牛能够用上x,y两种防晒霜,且 S P F [ x ] &lt; S P F [ y ] SPF[x] &lt; SPF[y] ,则对于下一头奶牛(按照上面排序后的顺序),只会出现下面几种情况:

  1. x、y对下一头奶牛都能使用;
  2. x、y对下一头奶牛都不能使用;
  3. x对下一头奶牛不能使用,y对下一头奶牛能使用;

所以,当前奶牛选择SPF较小的防晒霜更有可能让结果更大,而且每一头奶牛对结果的贡献都是一样的,因此对于当前奶牛和下一头奶牛,让两者任意一头涂防晒霜对结果的影响是一样的。

代码使用了优先级队列进行优化,结合上面的原理体会;

// I hate C++98!
struct cow
{
    int minSPf, maxSPF;
} cows[3000];

struct bottle
{
    int SPF, num;
} bottles[3000];

bool cmpCow(cow a, cow b)
{
    if (a.minSPf == b.minSPf) return a.maxSPF < b.maxSPF;
    else return a.minSPf < b.minSPf; // minSPF递增排序
}

bool cmpBottle(bottle a, bottle b)
{
    return a.SPF < b.SPF; // SPF递增排序
}

int c, l;

int main()
{
    while (cin >> c >> l)
    {
        for (int i = 0; i < c; ++i)
        {
            cin >> cows[i].minSPf >> cows[i].maxSPF;
        }

        for (int i = 0; i < l; ++i)
        {
            cin >> bottles[i].SPF >> bottles[i].num;
        }

        sort(cows, cows + c, cmpCow);
        sort(bottles, bottles + l, cmpBottle);

        int ans = 0, j = 0;
        priority_queue <int, vector <int>, greater <int> > q; // 从小到大

        for (int i = 0; i < l; ++i)
        {
            while (j < c && cows[j].minSPf <= bottles[i].SPF)
            {
                q.push(cows[j].maxSPF);
                ++j;
            }

            while (!q.empty() && bottles[i].num > 0) // 上一个循环遗留在队列中的数据也满足minSPF的条件,因为SPF是递增的
            {
                int currentMaxSPF = q.top(); q.pop();

                if (bottles[i].SPF <= currentMaxSPF)
                {
                    ++ans;
                    bottles[i].num--;
                }
            }
        }

        cout << ans << endl;
    }
}

例题: POJ 1328 Radar Installation

有N头牛在畜栏中吃草,每一个畜栏在同一时刻只能给一头牛吃草,可以可能会用到多个畜栏;给定N头奶牛开始吃草和吃草结束的时刻,奶牛在这个时间段内会一直吃草,求最小需要的畜栏数和每一头奶牛对应的方案;

按照开始吃草的时间把奶牛排序;
维护一个数组S,记录当前每一个畜栏进去的最后一头奶牛结束吃草的时间,最开始没有畜栏;
对于每一头新加进去的奶牛,扫描S数组,找到一个可是使用的畜栏(数组S储存的结束时间小于当前奶牛的开始时间),如果不存在,可以新建畜栏;
时间复杂度为 O ( N 2 ) O(N^2) ,如果使用优先级队列优化S,可以缩减到 O ( N log N ) O(N \log{N})
原书:正确性请读者自行证明

要关流,POJ嘛

struct cow
{
    int from, to, index;
} cows[50000 + 5];

struct stall
{
    int time, index;
    stall(int t, int i) : time(t), index(i) {}
};

int n, ans[50000 + 5];

bool operator<(cow a, cow b)
{
    return a.from < b.from;
}

bool operator<(stall a, stall b)
{
    return a.time > b.time;
}

int main()
{
    cin.sync_with_stdio(false);
    cin.tie(0);

    while (cin >> n)
    {
        for (int i = 0; i < n; ++i)
        {
            cin >> cows[i].from >> cows[i].to;
            cows[i].index = i;
        }

        sort(cows, cows + n);
        priority_queue <stall> q;

        for (int i = 0; i < n; ++i)
        {
            if (q.empty())
            {
                q.push(stall(cows[i].to, 1));
                ans[cows[i].index] = q.top().index;
            }
            else if (q.top().time < cows[i].from)
            {
                int index = q.top().index;
                q.pop();
                q.push(stall(cows[i].to, index));
                ans[cows[i].index] = index;
            }
            else
            {
                ans[cows[i].index] = q.size() + 1;
                q.push(stall(cows[i].to, q.size() + 1));
            }
        }

        cout << q.size() << endl;
        for (int i = 0; i < n; ++i)
        {
            cout << ans[i] << endl;
        }
    }
}

例题:POJ 1328 Radar Installation

监控设备的覆盖半径为R,给出N座建筑的坐标(x, y),监控设备只能装在x轴上,求最少需要几台监控设备能让所有建筑都处于监控之下。

对于每一幢建筑物,根据x,y,R三个值,能够计算出x轴上一段能监控它的坐标区间,则问题转化为:给定N个区间,求在x轴上最少需要放置几个点,能让每一个区间都至少有一个点。

按照每一个区间的左端点坐标从小到大排序,用一个变量pos维护上一个点的位置,初始值为 -\infty
之后,对于每一个区间,如果:

  1. 区间左端点值大于pos,则新增一台设备,并令pos = 当前区间右端点的值;
  2. 区间左端点的值小于等于pos,则可以使用上一个点来接管当前区间,并更新pos的值: p o s = m i n ( p o s , ) pos = min(pos, 当前区间右端点值) ,(考虑一个区间被一个更大的区间全包裹的情况);

注意一下题目里无解的情况,和,关流。

int n, ans;
double d;

struct interval
{
    double from, to;
} intervals[2000];

bool operator<(interval a, interval b)
{
    if (a.from == b.from) return a.to < b.to;
    else return a.from < b.from;
}

int main()
{
    cin.sync_with_stdio(false);
    cin.tie(0);

    int caseCnt = 1;
    while (cin >> n >> d && n > 0)
    {
        double x, y, tmp;
        bool flag = true;
        for (int i = 0; i < n; ++i)
        {
            cin >> x >> y;

            if (y > d || y < 0)
            {
                flag = false;
            }
            else
            {
                tmp = d * d - y * y;
                intervals[i].from = x - sqrt(tmp);
                intervals[i].to = x + sqrt(tmp);
            }
        }

        if (flag)
        {
            sort(intervals, intervals + n);

            double pos = intervals[0].to;
            ans = 1;

            for (int i = 1; i < n; ++i)
            {
                if (pos < intervals[i].from)
                {
                    ans++;
                    pos = intervals[i].to;
                }
                else
                {
                    pos = min(pos, intervals[i].to);
                }
            }

            cout << "Case " << caseCnt << ": " << ans << endl;
        }
        else
        {
            cout << "Case " << caseCnt << ": -1" << endl;
        }

        caseCnt++;
    }
}

例题: Contest Hunter 0701 国王游戏

国王和他的大臣们玩⚦游⚦戏⚦,每一个大臣在左手和右手上个写一个数字,国王也在左手和右手上个写一个数字,之后所有人站成一排,国王始终站在队首,站好之后,每一个大臣会获得一定的金币奖励,数量是这个大臣之前的所有人左手上的数字的乘积除以大臣右手上的数字向下取整之后的数字;

现在国王想重新安排一下队伍,使得获得最多金币的大臣获得的金币的数量尽可能少。

一句话题解:按照每一个大臣做右手上数字的乘积升序排序,就是最优方案!

证明
设n名大臣左右手上面的数字分别是 A [ 1 ] A [ n ] , B [ 1 ] B [ n ] A[1] \cdots A[n], B[1] \cdots B[n] ,国王手里的数字是 A [ 0 ] , B [ 0 ] A[0], B[0] ;
如果我们交换两个大臣 i i i + 1 i + 1 ,在交换之前,这两个大臣的奖励是:

1 B [ i ] j = 0 i 1 A [ j ] 1 B [ i + 1 ] j = 0 i A [ j ] \frac{1}{B[i]} * \prod_{j = 0}^{i - 1} A[j] \quad 和 \quad \frac{1}{B[i + 1]} * \prod_{j = 0}^{i} A[j]

交换之后,这两个大臣的奖励变为:

A [ i + 1 ] B [ i ] j = 0 i 1 A [ j ] 1 B [ i + 1 ] j = 0 i 1 A [ j ] \frac{A[i + 1]}{B[i]} * \prod_{j = 0}^{i - 1}A[j] \quad 和 \quad \frac{1}{B[i + 1]} * \prod_{j = 0}^{i - 1} A[j]

其他大臣的奖励显然都不变,比较上面两组最大值的变化,提取公因式之后,只需要比较:

m a x ( 1 B [ i ] , A [ i ] B [ i + 1 ] ) m a x ( A [ i + 1 ] B [ i ] , 1 B [ i + 1 ] ) max(\frac{1}{B[i]}, \frac{A[i]}{B[i + 1]}) \quad 和 \quad max(\frac{A[i + 1]}{B[i]}, \frac{1}{B[i + 1]})

化简后:

m a x ( B [ i + 1 ] , A [ i ] B [ i ] ) m a x ( A [ i + 1 ] B [ i + 1 ] , B [ i ] ) max(B[i + 1], A[i] * B[i]) \quad 和 \quad max(A[i + 1] * B[i + 1], B[i])

又因为大臣手上的都是正整数,所以:

A [ i ] A [ i ] B [ i ] , A [ i + 1 ] A [ i + 1 ] B [ i + 1 ] A[i] \le A[i] * B[i], \quad A[i + 1] \le A[i + 1] * B[i + 1]

综上所述:
A [ i ] B [ i ] A [ i + 1 ] B [ i + 1 ] 当 A[i] * B[i] \le A[i + 1] * B[i + 1]时,左式 \le 右式, 交换前结果更优
反之,交换后结果更优。

证毕!

下面的代码是书本的标程,需要大数,算了算了不自己写了…(* ̄0 ̄)ノ

struct num
{
	int s, a[1100];
} ans, now, temp;
struct rec
{
	int l, r;
} a[1010];
int n, i;

bool operator<(rec a, rec b)
{
	return a.l * a.r < b.l * b.r;
}
bool operator<(num a, num b)
{
	if (a.s < b.s)
		return 1;
	if (a.s > b.s)
		return 0;
	for (int i = a.s; i; i--)
	{
		if (a.a[i] < b.a[i])
			return 1;
		if (a.a[i] > b.a[i])
			return 0;
	}
}

num operator*(num a, int b)
{
	num c;
	int i, jin = 0;
	memset(&c, 0, sizeof(c));
	for (i = 1; i <= a.s; i++)
	{
		jin += a.a[i] * b;
		c.a[i] = jin % 10000;
		jin /= 10000;
	}
	c.s = a.s;
	while (jin)
		c.a[++c.s] = jin % 10000, jin /= 10000;
	return c;
}

num operator/(num a, int b)
{
	num c;
	int i, rest = 0;
	memset(&c, 0, sizeof(c));
	for (i = a.s; i; i--)
	{
		rest = rest * 10000 + a.a[i];
		c.a[i] = rest / b;
		rest %= b;
	}
	c.s = a.s;
	while (c.s > 1 && !c.a[c.s])
		c.s--;
	return c;
}

int main()
{
	cin >> n >> a[0].l >> a[0].r;

	for (i = 1; i <= n; i++)
		scanf("%d%d", &a[i].l, &a[i].r);

	sort(a + 1, a + n + 1);

	now.s = now.a[1] = ans.s = 1;

	for (i = 1; i <= n; i++)
	{
		now = now * a[i - 1].l;
		temp = now / a[i].r;
		if (ans < temp)
			ans = temp;
	}
	printf("%d", ans.a[ans.s]);
	for (i = ans.s - 1; i; i--)
		printf("%04d", ans.a[i]);
	puts("");
	return 0;
}

例题: POJ 2054 Color a Tree

有一颗n个节点的树,每个节点都有一个权值,现在要将这棵树全部染色,染色的规则是:每一个节点被染色前其根节点必须先被染色,每一次染色的代价为 T w i e g h t [ i ] T * wieght[i] 其中T代表当前是第几次染色,weight是被染色节点的权值,求这整棵树最小的染色代价。

有一种错误的贪心做法:“在每一步可以染色的点中取权值最大的点染色”,反例:一个权值很小的点下面有一堆权值很大的点,另一个权值很大的点却没有子节点。

不过从这个错误的解法中,可以提取出一个正确的性质:树中除根节点以外权值最大的点,一定在其根节点被染色后立即染色。

于是我们可以确定:树中权值最大的点的染色和其父节点的染色是连续进行的,我们可以将这两个节点合并起来,合并后的新节点的权值是原来两个节点的权值的平均值。

例如有权值为x,y,z的三个节点,已知x、y的染色是连续进行的,那么有两种染色方案:

  1. z + 2 x + 3 y z + 2x + 3y
  2. x + 2 y + 3 z x + 2y + 3z

将上面的式子同时加上 z y z - y 再除以2,可以得到:

  1. z + 2 ( ( x + y ) / 2 ) z + 2 * ((x + y) / 2)
  2. 2 z + ( x + y ) / 2 2 * z + (x + y) / 2

恰好为权值为z和(x + y) / 2的两个节点的染色顺序情况,换言之,以下两种情况可以相互转化:

  1. z,y,z三个节点,其中xy连续染色;
  2. z,(x + y) / 2两个节点;

进一步推进,我们可以规定一个点的“等效权值”为:

÷ 该节点包含的原始权值总和 \div 该节点包含的原始节点的总数

根据一开始的性质,我们只需要每一次在树中取等效权值最大的点p,与其父节点fa合并,直到整棵树合并为一个点,详细过程见代码。

按照书本标程写的,自叹不如(编程水平,不是代码颜值)

int n, r, ans;

struct node
{
    int cnt, father, cost; // 合并了几个节点, 父节点, 原始权值
    double weight; // 等效权值
} tree[2000];

int find() // 每次找出等效权值最大的节点
{
    double maxW = 0;
    int res;
    for (int i = 1; i <= n; ++i)
    {
        if (i != r && tree[i].weight > maxW)
        {
            maxW = tree[i].weight;
            res = i;
        }
    }
    return res;
}

int main()
{
    while (cin >> n >> r && n + r > 0)
    {
        ans = 0;
        for (int i = 1; i <= n; ++i)
        {
            cin >> tree[i].cost; // 初始状态:等效权值=原始权值
            tree[i].weight = tree[i].cost; 
            tree[i].cnt = 1; // 合并的节点个数为1,自己
            ans += tree[i].cost; // 答案的统计方式很有意思
        }

        for (int i = 1, a, b; i < n; ++i)
        {
            cin >> a >> b; // 输入父节点数据
            tree[b].father = a;
        }

        for (int i = 1; i < n; ++i) // 执行n - 1次
        {
            int pos = find();

            tree[pos].weight = 0; // 重置等效权值,已经被染色过了

            int father = tree[pos].father;

            ans += tree[pos].cost * tree[father].cnt; // 累计答案的方式很巧妙,自己感受

            for (int j = 1; j <= n; ++j)
            {
                if (pos == tree[j].father)
                {
                    tree[j].father = father; // pos的子节点合并到father上
                }
            }

            tree[father].cost += tree[pos].cost; // 更新原始权值
            tree[father].cnt += tree[pos].cnt; // 更新合并的点数
            tree[father].weight = (double)tree[father].cost / tree[father].cnt; // 计算新的等效权值
        }

        cout << ans << endl;
    }
}

总结与练习

知识点归纳:

位运算
快速乘,快速幂,各种按位运算,二进制状态压缩;

枚举、模拟、递推
能想象问题的“状态空间”,理解各种算法本质是对状态空间进行遍历和映射

递归
理解递归的思想、子问题、递归边界、回溯时还原现场
分治思想

二分
整数集合二分,实数域二分
单峰函数三分求极值
二分答案,把求解转化为判定

排序
各种排序算法:数据结构基础
离散化
中位数相关问题
求第k大数的 O ( n ) O(n) 算法
归并排序求解逆序数对数

倍增
序列上的倍增算法及其应用
RMQ-ST算法

贪心
贪心的思想及其证明手段
多通过题目开拓视野,归纳总结

练习:

题目 提示
POJ 2965 枚举/位运算
CH 0802 模拟
POJ 2083 递归/分形
POJ 3714 分治/平面最近点对
CH 0805 二分
POJ 3179 二分/离散化/前缀和
CH 0807 排序/中位数/环形纸牌均分
POJ 1732 排序/中位数/货舱选址问题扩展
POJ 1220 高精度运算/进制转换
POJ 1050 贪心
HDU 4864 贪心

猜你喜欢

转载自blog.csdn.net/weixin_41429999/article/details/87223512