PAT 甲级 1057 Stack(30 分) 树状数组,二叉树和分块法对比

原题地址:PAT Adv 1057

题目正文:

1057 Stack(30 分)

Stack is one of the most fundamental data structures, which is based on the principle of Last In First Out (LIFO). The basic operations include Push (inserting an element onto the top position) and Pop (deleting the top element). Now you are supposed to implement a stack with an extra operation: PeekMedian -- return the median value of all the elements in the stack. With N elements, the median value is defined to be the (N/2)-th smallest element if N is even, or ((N+1)/2)-th if N is odd.

Input Specification:

Each input file contains one test case. For each case, the first line contains a positive integer N (≤10​5​​). Then N lines follow, each contains a command in one of the following 3 formats:

Push key
Pop
PeekMedian

where key is a positive integer no more than 10​5​​.

Output Specification:

For each Push command, insert key into the stack and output nothing. For each Pop or PeekMedian command, print in a line the corresponding returned value. If the command is invalid, print Invalid instead.

Sample Input:

17
Pop
PeekMedian
Push 3
PeekMedian
Push 2
PeekMedian
Push 1
PeekMedian
Pop
Pop
Push 5
Push 4
PeekMedian
Pop
Pop
Pop
Pop

Sample Output:

Invalid
Invalid
3
2
2
1
2
4
4
5
3
Invalid

 题目说明:

题意是对于一个栈,我们需要实现一种查询中值的方法,以满足栈基本操作的同时,兼顾频繁查询中值的需求。题意给出的数据结构操作量是100000,还是很大的。对于一个栈来说,如果我们为了某一次的低频查询中值,其实我们比较容易想到查询前对所有的数据排序后取中值位置数据即可,但是如果我们频繁的查询中值,比如高达上万次,如果还进行刚才说的方法,这里面的时间消耗就很可观了,对于一个已知未排序序列,即栈来说,我们拷贝一个副本进数组的时间开销为O(N),对数组进行排序的时间开销为O(N*logN),如果对于这个栈的各项操作(压入,弹出,查询中值)频度相当,那么我们期望使用O(N)次查询中值操作,简单计算一下就可以得到这是单纯的查询中值得时间复杂度就在O(N^2*logN),预估计算量在1e9次的量级。这种实现方法显然是不可能实现时间限制的要求的。

所以为了满足频繁查询中值的要求,我们需要给出一种快速的中值计算方式。这里的实现主要思想主要包括两种:

第一种,因为题意给出了入栈数字的取值范围(1 - 1e5),我们可以对于每个入栈的数字记录其在栈中的计数量,这里使用长度为1e5+1长度的数组来记录,count[ a-number ]=count-of-this-number。假设我们要查询的中值为第5个最小值,那么我们只需要找到使得\sum_{i=1} ^{index} count[i]\geq 5最小的index即可,这里涉及到了对于计数数组的区间求和。为了快速的区间求和,我们可以通过树状数组或者线段树等来实现O(logN)的时间复杂度,而为了快读的定位到我们需要的index值,我们可以使用二分探查法,所以对于一次中值查找来说,我们预期的时间复杂度在O((logN)^2),预期(log30000)^2约等于200次左右。考虑到占比为1/3的几万次的中值查询操作,时间复杂度O(N*(logN)^2)也不过几百多万而已,对于占比2/3的树状数组的维护,我们需要压入,弹出O(N)次,每次O(logN),所以,树状数组的维护为O(N*logN)的时间复杂度,这个值相对于查中值操作来说算是比较小的。总的预估操作次数(P_1\times log_2{N}+P_2)\times{N}\log_2{N},其中,P1代表查询中值对应的操作占比,P2对应的是出入栈操作占比,N代表数据取值范围长度值。

第二种,既然中值是针对对已排序数组或集合来定义的,我们就干脆额外的维护两个集合(左集合和右集合),这两个集合中的元素可以重复,并且满足左集合中的最大值小于等于右集合的最小值,左集合的元素数量等于右集合的元素数量,或者左集合的元素数量等于右集合的元素数量加一,这样我们就可以比较容易想到,左集合的最大元素就是我们要找的中值。对于这两个集合来说,我们需要在压入或弹出栈过程中频繁的进行调整,以满足上述的前提条件,因此我们需要快速的定位到左集合的最大值和右集合的最小值。而且对于左集合和右集合的插入删除等操作应该属于较低的时间开销量级。这里不难想到通过维护左右两个已排序的平衡二叉树来实现。插入、删除操作的时间量级在O(logN),维护左右数量接近平衡的时间量级在O(logN),查询左集合的最大值的时间开销也在O(logN)。我们现在简单的分析一下如果插入操作使得右集合的数量比左集合的数量多了一,为了调整,我们首先查找到右集合的最小值(O(logN)),将该最小值压入左集合(O(logN)),更新中值(O(1)),将该值从右集合中删去(O(logN)),更新中值是为了应对下一次的压入操作。而对于查询中值的操作,我们可以在这里达到O(1)的时间复杂度。这些时间开销显然是较小的,总的来说算法的整体时间复杂度在O(N*logN)量级上。然而每次的为了维护左右集合的数量平衡,需要进行的调整操作基本为3-4次O(logN)。假设有一半的插入、删除操作需要进行左右数量平衡调整,总的预估操作次数P_1\times{N}+P_2\times(1+1/2\times3.5)\times{N}\log_2{N},其中,P1代表查询中值对应的操作占比,P2对应的是出入栈操作占比,N代表总元素数量。请注意,这里的N意义和前面的不一样。

如果查询操作占比0.3左右,那么两者的时间复杂度简单估计一下应该比较接近,第一种大于第二种,差距2-3倍,差距不会在数量级上。再想到树状数组的最小操作比二叉树的操作步骤要少,综合的考虑来说,二者的时间消耗可能比较接近。

然而,根据公式,不难看出,随着查询操作的占比的增加,树状数组的实现时耗增加,而二叉树的实现时耗减少。

现象和结果

下面给出两种实现的代码和运行时间截图:

树状数组:

#include<bits/stdc++.h>
using namespace std;
int const n=1e5;
int c[n+5];
#define lowbit(x) ( (x)&(-x) )
int sum(int index){
    int ans=0;
    while(index>0){
        ans+=c[index];
        index-=lowbit(index);
    }
    return ans;
}
void update(int index,int tmp){
    while(index<=n){
        c[index]+=tmp;
        index+=lowbit(index);
    }
}
int Median(int st_size){
    int low=1,high=n,median,median_sum,half=st_size&1?(st_size>>1)+1:st_size>>1;
    while(low<high){
        median=(low+high)>>1;
        median_sum=sum(median);
        if(median_sum>=half)
            high=median;
        else
            low=median+1;
    }
    return low;
}
int main(){
    int n,tmp;
    char str[15];
    stack<int> s;
    scanf("%d",&n);
    while(n--){
        scanf("%s",str);
        if(str[1]=='u'){
            scanf("%d",&tmp);
            s.push(tmp);
            update(tmp,1);
        }else if(str[1]=='o'){
            if(s.empty())
                puts("Invalid");
            else{
                printf("%d\n",s.top());
                update(s.top(),-1);
                s.pop();
            }
        }else if(str[1]=='e'){
            if(s.empty())
                puts("Invalid");
            else
                printf("%d\n",Median(s.size()));
        }
        getchar();
    }
    return 0;
}

 树状数组运行时间:

二叉树:

#include<bits/stdc++.h>
using namespace std;
struct medianStack{
    int mid;    //largest element in lower set, no need to initialize, not necessary but help to accelerate the medianStack.push() operation
    stack<int> st;
    multiset<int,greater<int>> lower;
    multiset<int> upper;
    void adjust(){
        //adjust to satisfy upper.size()==lower.size() or upper.size()==lower.size()-1
        if(upper.size()>lower.size()){
            lower.insert(*upper.begin());
            upper.erase(upper.begin());
        }else if(lower.size()>upper.size()+1){
            upper.insert(*lower.begin());
            lower.erase(lower.begin());
        }
        mid=*lower.begin();
    }
    void median(){
        if(st.empty())
            puts("Invalid");
        else{
            printf("%d\n",mid);  //print the updated mid value, i.e. the largest value in the lower multiset
        }
    }
    void push(int key){
        st.push(key);
        key>=mid?upper.insert(key):lower.insert(key);
        adjust();
    }
    void pop(){
        if(st.empty())
            puts("Invalid");
        else{
            int key=st.top(); st.pop();
            printf("%d\n",key);
            auto it=upper.find(key);
            it!=upper.end()?upper.erase(it):lower.erase(lower.find(key));
            adjust();
        }
    }
};
int main(){
    char str[20];
    int n,tmp;
    medianStack mst;
    scanf("%d",&n);
    while(n--){
        scanf("%s",str);
        if(str[1]=='u'){
            scanf("%d",&tmp);
            mst.push(tmp);
        }else if(str[1]=='o')
            mst.pop();
        else if(str[1]=='e')
            mst.median();
        getchar();
    }
    return 0;
}

二叉树运行时间:

二叉树的实现在该题测例下表现略逊于树状数组的实现。请仔细观察两次运行过程中中间三例的时耗变化,我猜测中间三例,尤其是测试点2和测试点3对应的查中值操作占比应该相对压入或弹出操作占比更少,而且测试点3的查中值操作占比比测试点2更少。

如果查询的操作比较少的话,那么本题还可以使用另一种解法,分块法。分块法就是指,类似于树状数组,我们仍需要维护一个元素计数数组,通过对计数数组的区间求和来实现查找中值的位置。为了快速的进行区间求和,我们维护两个计数数组,一个就是原始的单元素计数数组,另一个以定长区间为单位进行计数。为了查找某期望区间求和值,我们先遍历区间计数数组,确定区间,然后在区间内遍历查找,即如果区间长度为x,我们期望的查询次数为1/2\times(\frac{N}{x}+x),显然在x等于sqrt(N)时取得极小值。这时查询的时间复杂度为O(sqrt(N)),约320,而这时我们的压入、弹出操作时只需要更新两个技术数组的某一位地址的值,复杂度降低到了O(1)。总的预估操作次数 (P_1\times \sqrt{N}+P_2)\times{N},P1代表查询中值对应的操作占比,P2对应的是出入栈操作占比,N代表数据取值范围长度值。

因为上述分析得查询操作的占比比较小,所以我们预期使用分块法在本题的测例中能够得到较高的效率。

分块大法:

#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e5+10;
const int blocksize = sqrt(maxn);
const int blocklength = ceil(maxn/blocksize);
int table[maxn]={0};
int block[blocklength]={0};
stack<int> st;
void pop(){
    if(st.empty())
        puts("Invalid");
    else{
        int key=st.top(); st.pop();
        printf("%d\n",key);
        --table[key];
        --block[key/blocksize];
    }
}
void push(int key){
    st.push(key);
    ++table[key];
    ++block[key/blocksize];
}
void median(){
    if(st.empty())
        puts("Invalid");
    else{
        int objective=st.size()%2?(st.size()>>1)+1:st.size()>>1;
        int sum=0, index;
        //find block-id, i.e. the largest index that sum(block[0]...block[index])<objective
        for(index=0;index<blocklength && sum+block[index]<objective;index++)
            sum+=block[index];
        //find id, i.e. the smallest i that sum(table[0]...table[i])>=objective
        for(int i=index*blocksize;i<(index+1)*blocksize;i++){
            sum+=table[i];
            if(sum>=objective){
                printf("%d\n",i);
                return;
            }
        }
    }
}
int main(){
    char str[20];
    int n,tmp;
    scanf("%d",&n);
    while(n--){
        scanf("%s",str);
        if(str[1]=='u'){
            scanf("%d",&tmp);
            push(tmp);
        }else if(str[1]=='o')
            pop();
        else if(str[1]=='e')
            median();
        getchar();
    }
    return 0;
}

分块大法运行时间:

事实也是如上述分析,在测试点1、2和3中,查中值操作最少的测试点3运行时间得到了显著的优化。

分析和结论

对于测试点1来说,压入,弹出,查询中值的操作应该强度比较均衡,在三种实现中均表现稳定。对于测试点2,其查询中值操作相对测试点1来说进行了减少,而测试点3的查询中值操作则相较于测试点2进行了进一步的减少。我认为,测例并没有给出查询操作占比较高的测试场景,算是测例拟定的一些不足。

接下来,我们分析一下三种实现的适用场景。通过观察运行时间数据,(1)树状数组的表现最为均衡,主要原因是查询操作占比不足够高(基本符合三种操作频度分布较为均匀的情况,同时也是期望的实际情况);(2)二叉树适用于查询操作密集的场景,而不适于压入和弹出操作密集的场景,二叉树还有一个显著的优点就是不受限于数据范围,影响其效率的因素是元素的数量,而元素的取值范围可以拓展到int类型取值范围等。在这道题目内,元素的取值范围和元素数量基本相当,故这个因素的影响体现的不明显;(3)分块法的实现与二叉树的实现的特点正好相反,适合于压入、弹出操作密集,而不适于查询操作密集的场景。我们对比一下树状数组和分块法的实现,在树状数组中,影响查询效率的因素O((logN)^2)≈200多,而对于分块法,影响查询效率的子区间长度(块区数量)为O(sqrt(N))≈320,这两个数字在数据取值范围为\left [ {1, 1e5} \right ]时差距还算不明显。如果将取值范围扩充到[1,2^31],那么对数表示相对于开方表示就会小一个数量级,类似的,维护操作的复杂度也是相差相反的一个量级。当然一旦将取值范围扩充到[1,2^31],我们用来存储元素值计数的数组分配的空间也会超过题目中内存的限制,显然不现实。

如果这是一个实际的问题,我们为了得到最佳的实现方式,我们需要综合的考量取值范围、操作占比情况和取值分布情况。(1)对于取值范围,如果范围太大,比如远超过[1,1e6],那么就不适合使用树状数组和分块法的实现,因为为了记录元素的计数而使用的数组会占用很大的空间。推荐使用二叉树的实现;(2)如果操作中取中值的操作占比较大,推荐取中值复杂度为O(1)的二叉树实现,如果取中值的操作占比非常小,则需要在综合考量取值范围的约束下,看能不能使用压入、弹出复杂度为O(1),而取中值复杂度为O(sqrt(N))的分块法实现。或者在操作占比分布较为均匀的情况下考虑性能均衡的树状数组实现;(3)对于中值操作,我们还可以利用中值的特点,就是对于大量的随机数来说,中值倾向于收敛到取值范围中间的区域内。在这个接近随机的限定条件下,我们可以简单的认为,在压入大量数据后,分块法的区间号查询可以不从头部开始,而从中间,或者记录上一次的位置处开始。这样从而可以减少区间号查询的时耗。

特定场景的复杂度可以根据实际情况根据上面的公式进行分析比较,或者提前用不等式判断使用场景。        

最后,我们还可以考量是否可以使用两种或以上的方法结合,也许能够根据使用场景的特点进行进一步的优化。例如考虑这样的情景,如果元素取值足够随机,我们可以使用分块法和树状数组的结合,对于区间号,我们使用记录上一次位置的方式加速线性查找过程,对于区间内部的定位,我们使用树状数组和二分法来实现,此时的维护复杂度为O(log(sqrt(N))),即仅需进行区间内维护,总预估操作次数为(P_1\times(\sqrt{N}/2+(log_2\sqrt{N})^{2})+P_2\times log_2\sqrt{N})\times N,其中P1代表查询中值对应的操作占比,P2对应的是出入栈操作占比,N代表数据取值范围长度值。

写的有点啰嗦,那么在这里真诚感谢能够读完本篇博文的博友。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值