Loading

浅谈折半搜索

折半搜索(又称meet in the middle),顾名思义,就是将原有的数据分成两部分分别进行搜索,最后在中间合并的算法。
设对 \(n\) 的大小进行搜索所需要的时间复杂度为 \(O(f(n))\),合并时间复杂度为 \(O(g(n))\),那么折半搜索所需要的时间复杂度就为 \(O(2f(n/2)+g(n))\)
一般来说搜索的时间复杂度是指数级别的,而合并的时间复杂度通常不会太高,因此进行折半搜索基本上能让我们通过比暴力算法将近大一倍的数据范围。
下面通过两道经典的题目来对折半搜索做一个简单的讲解。

1. luoguP4799 [CEOI2015 Day2]世界冰球锦标赛

简明题意:
一个人有 \(m\) 元钱。有 \(n\) 场比赛,每场比赛的门票价格为 \(c_i\)
问这个人有多少种看比赛的方案(一场不看也算做一种,两种方案不同当且仅当两种方案所看的比赛中有至少一场不同)
数据范围:\(n\leqslant 40,\ m\leqslant 10^{18}\)

\(m\) 的范围过大,考虑搜索。但是枚举每场比赛看或者不看的方案数高达 \(2^{40}=1099511627776\approx 10^{12}\),显然不能通过本题。
于是我们就要使用折半搜索的思想。将比赛分为 \(l\)\(r\) 两部分,分别算出两部分的所有可能的花费的钱数。
这时情况总数只不过有 \(2\times2^{20}=2097152\approx 2\times10^6\) 种,存储起来简直是绰绰有余。
然后将 \(l\) 部分的比赛进行排序,然后每次取出 \(r\) 部分的一个比赛,进行二分查找统计可行的方案即可。(也可以双指针)

code:

#define int long long//不开long long见祖宗
int n,m,ans,price[100];//ans是总的方案数
int ansl,ansr,l[1<<21],r[1<<21];//ansl是l部分的方案总数,ansr是r部分的方案总数,l和r分别存储两部分的所有方案
void ldfs(int ll,int rr,int now)
{
    if(now>m)return;
    if(ll>rr)//注意判定方法
    {
        l[++ansl]=now;//增添一种新方案
        return;
    }
    ldfs(ll+1,rr,now+price[ll]);//看ll这场比赛
    ldfs(ll+1,rr,now);//不看
}
void rdfs(int ll,int rr,int now)//同上
{
    if(now>m)return;
    if(ll>rr)
    {
        r[++ansr]=now;
        return;
    }
    rdfs(ll+1,rr,now+price[ll]);
    rdfs(ll+1,rr,now);
}
signed main()
{
    scanf("%lld%lld",&n,&m);
    for(int i=1;i<=n;i++)scanf("%lld",&price[i]);
    ldfs(1,n/2,0);rdfs(n/2+1,n,0);//对两部分分别进行搜索
    sort(l+1,l+ansl+1);//对l数组进行排序,方便后续的合并操作
    for(int i=1;i<=ansr;i++)ans+=upper_bound(l+1,l+ansl+1,m-r[i])-l-1;//显然,如果两部分价钱的和不超过m,那就有了一种总的方案
    printf("%lld\n",ans);
    return 0;
}

2. luoguP3067 [USACO12OPEN]Balanced Cow Subsets G

简明题意:求从 \(n\) 个数 (\(m_i\)) 任意选出一些数,使这些数可以分为和相等的两部分的方案数。
数据范围:\(2\leqslant n\leqslant 20,\ 1\leqslant m_i\leqslant 10^8\)

这道题比刚刚的题目更加复杂了一些,每个数有三种状态,可以不选,可以放到左边集合,也可以放到右边集合。
\(3^{20}=3486784401\approx 3.5\times10^9\),依然爆炸。于是同样考虑折半搜索。

可以做一个转化, 对于每个数 \(x\), 它对当前集合的贡献可为 \(0,x,-x\), 分别代表不选,选到当前集合,选到对面集合. 这样我们就把一组合法的情况转化为了两边的和为 \(0\).
但是,这道题毒瘤的地方在于,可能会有重复的情况。
出现这种重复情况的原因在于这道题选出的数是没有顺序的,比如下面的数据:

4
1 1 1 1

我们用 \(l\) 来表示将元素放到左集合,用 \(r\) 来表示将元素放到右集合.
以四个数全选的情况为例. 会有下面的情况:
\(ll.rr,\ lr.lr,\ lr.rl\ ,rl.lr,\ rl.rl,\ rr.ll\)
(中间的.表示我们是分别对左右两部分进行搜索的)
实际上这就是 \(\dbinom{4}{2}=6\), 这显然不是除以一个 \(2\) 就能解决的了的。
但是我们可以直接保存每一种情况的具体选法。
因为 \(n\) 很小, 我们使用状态压缩即可.

code:

#define int long long
using namespace std;
int n,ans,lastpos,m[30],lcnt,rcnt,lp,rp;
bool book[1<<21];
struct node{int now,sum;}l[1<<21],r[1<<21];
bool lcmp(node xx,node yy){return xx.sum<yy.sum;}
bool rcmp(node xx,node yy){return xx.sum>yy.sum;}
void ldfs(int ll,int rr,int now,int sum)
{
    if(ll>rr)
    {
        lcnt++;
        l[lcnt].now=now;
        l[lcnt].sum=sum;
        return;
    }
    ldfs(ll+1,rr,now,sum);
    ldfs(ll+1,rr,now+(1<<(ll-1)),sum+m[ll]);
    ldfs(ll+1,rr,now+(1<<(ll-1)),sum-m[ll]);
}
void rdfs(int ll,int rr,int now,int sum)
{
    if(ll>rr)
    {
        rcnt++;
        r[rcnt].now=now;
        r[rcnt].sum=sum;
        return;
    }
    rdfs(ll+1,rr,now,sum);
    rdfs(ll+1,rr,now+(1<<(ll-1)),sum+m[ll]);
    rdfs(ll+1,rr,now+(1<<(ll-1)),sum-m[ll]);
}
signed main()
{
    scanf("%lld",&n);
    for(int i=1;i<=n;i++)scanf("%lld",&m[i]);
    ldfs(1,n/2,0,0);rdfs(n/2+1,n,0,0);
    //容易发现我们可以通过排序+双指针来进行较快速的枚举
    sort(l+1,l+lcnt+1,lcmp);sort(r+1,r+rcnt+1,rcmp);
    lp=rp=1;
    while(lp<=lcnt&&rp<=rcnt)
    {
        while(l[lp].sum+r[rp].sum>0&&rp<=rcnt)rp++;
        lastpos=rp;
        while(l[lp].sum+r[rp].sum==0&&rp<=rcnt)
        {
            if(book[l[lp].now|r[rp].now]==0)
            {
                book[l[lp].now|r[rp].now]=1;
                ans++;
            }
            rp++;
        }
        if(l[lp].sum==l[lp+1].sum)rp=lastpos;//注意, 如果有sum一样的还要再重新扫一遍
        //这样如果遇到一车相同的好像会比较慢, 不过我对这玩意的去重也没什么好想法==
        lp++;
    }
    printf("%lld\n",ans-1);//记得减掉啥都不选的
    return 0;
}

练习题:
CF888E Maximum Subsequence
CF1006F Xor-Paths
CF525E Anya and Cubes

posted @ 2021-10-05 22:29  pjykk  阅读(1025)  评论(0编辑  收藏  举报