算法学习笔记——利用栈解决实际问题- part 2

算法学习笔记:

part1 : http://blog.csdn.net/minghe_uestc/article/details/10416809

全文下载地址见part 1

1       利用栈解决实际问题

1.1     编写一个栈

         只要符合“后入先出”规则的数据结构都可以叫做栈,所以在实现栈的时候有很多方式:

Ø  可以利用数组、链表来组织数据;

Ø  使用链表也可以使用单向链表和双向链表;

Ø  存储的元素可以是指针、基本数据类型和复杂数据类型;

Ø  可以将栈抽象成一个复杂数据类型,也可以不抽象。

C++/Java中都提供了栈的抽象。提供的这些数据结构是十分好用的,个人觉得十分推荐。

C++中,stack底层使用dqueue数据结构,即stack是对dqueue的一个简单封装。dqueue其实是几段不连续的数组经过一个统一管理器管理后抽象出来的一个双向队列,参考《STL源码剖析》,书中给出了很详细的说明。

另外需要注意的就是,使用标准模板库的时候,不要在容器中存储复杂数据类型的“值对象”,因为这会引起大量的构造与析构,也不要试图用auto_ptr对容器中的指针进行自动堆管理,因为auto_ptr不能进行值传递。所以标准容器中一般是保存基本数据类型和指针,当然一些小的复杂数据也可以保存一下。但如果保存指针,那对这些对象就需要有效的堆管理机制。

16 C++中使用标准模板库中的栈

#include
#include
#include
usingnamespacestd;
typedefintItem;
intmain(){
    stack<Item>s;
    Itema=10;Itemb=20;
    s.push(a);
    cout<<s.top();
    s.push(b);
    cout<<s.top();
    s.pop();
    cout<<s.top();
    return0;
}

         17是使用数组实现的栈,在看网上那些恶心的算法代码的时候,他们基本选择这种方式去做。还有很多其他的实现方式,只要最后符合“后入先出”的游戏规则就可以了。

17 使用数组实现栈

typedefintItem;
#defineMAXSIZE 100
Items[MAXSIZE];
inttop=-1;
intmain(){
    Itema=10;
    s[++top]=a;//将元素插入栈顶
    if(top>=0)//判断栈不为空
        cout<<s[top];//查看栈顶元素
    if(top>=0)
        top--;//将栈顶元素出栈
    top=-1;//清空栈
    return0;
}

1.2     符号最大匹配子串

1.2.1  问题描述

         给定一个字符串”[[(()]]{{[[()()]]}}”,找到最长匹配子串,返回子串的起始位置与长度。

1.2.2  解决思路

         利用一个栈进行维护所有待考察的“左符号”,栈顶元素放置的是最当前考察的“左符号”,idx表示当前需要考察的符号,这时候的情况就有:

Ø  idx指向“左符号”,则将符号压入栈,然后idx右移。

Ø  idx指向“右符号”,这时候分成两种情况

n  如果栈为空,则什么都不做,idx右移

n  如果栈不为空,则比较栈顶元素与idx指向元素,这时候又分为两种情况

u  匹配,栈顶元素推出,idx右移

u  不匹配,清空栈

根据这个思路,不难得到表 18中的代码实现:

18 求解最长匹配子串

#defineleftSymbol(x)(x=='['||x=='('||x=='{')
boolmatch(charl,charr){
    if(l=='['&&r==']')returntrue;
    if(l=='('&&r==')')returntrue;
    if(l=='{'&&r=='}')returntrue;
    returnfalse;
}
#defineMAXSIZE100
charsk[MAXSIZE];
inttop=-1;
voidmaxMatch(charp[]){
    for(intidx=0;p[idx]!='\0';idx++){
        if(leftSymbol(p[idx])){
            sk[++top]=p[idx];
            continue;
        }
        if(top>=0&&match(sk[top],p[idx])){
            top--;
        }else{
            top=-1;
        }
    }
}

         然后开始维护答案,因为需要维护最长匹配子串的起始位置,新增一个栈skidx[]用于维护所有左符号的下标,这个栈与sk[]进行同步。

然后利用两个临时变量记录临时解,对比临时解与已知解的情况来更新最终的解。

19 最长匹配子串的答案维护

structAns{
    intidx;intlen;
    Ans():idx(0),len(0){}
}ans;
#defineleftSymbol(x)(x=='['||x=='('||x=='{')
boolmatch(charl,charr){
    if(l=='['&&r==']')returntrue;
    if(l=='('&&r==')')returntrue;
    if(l=='{'&&r=='}')returntrue;
    returnfalse;
}
#defineMAXSIZE100
charsk[MAXSIZE];
int  skidx[MAXSIZE];
inttop=-1;
voidmaxMatch(charp[]){
    intlen=0;intaidx=0;
    for(intidx=0;p[idx]!='\0';idx++){
        if(leftSymbol(p[idx])){
            top++;
            sk[top]=p[idx];skidx[top]=idx;//记录左符号及其下标
            continue;
        }
        if(top>=0&&match(sk[top],p[idx])){
            aidx=skidx[top];len+=2;//记录临时解
            top--;
        }else{
            if(ans.len<len){//维护答案,这里如果改成<=,记录最后一个最长子串
                ans.len=len;ans.idx=aidx;
            }
            top=-1;len=0;
        }
    }
}

1.3     寻找水王

1.3.1  题目描述

         在编程之美中有一个题叫“寻找水王”,题目的大意就是,有一组ID序列,其中有一个ID的重复率超过了1/2,最要要求找出这个ID号。

1.3.2  解决思路

         将这个题转换成一个匹配问题,匹配规则变成如果ID号不一致就算匹配。因为水王的ID号数量大于1/2,所以匹配到最后剩余的肯定是水王的ID号。

         例如,对于ID序列ID={2, 3, 1, 3, 2, 3, 2, 2, 1, 2, 2},最后匹配的对有:

ID[1]=ID[2]        ID[3]=ID[4]        ID[5]=ID[6]        ID[8]=ID[9]

         最后栈中剩余的元素就是2,也就是我们要找的答案。ID[3]ID[4]的匹配属于两个非水王间的“内耗”,不会对最后的结果产生影响。

         根据上述的思路,得到如下代码:

20 利用栈解决“寻找水王”问题

typedefintID;
#defineMAXSIZE100
IDsk[MAXSIZE];
inttop=-1;
IDfind(IDid[],intn){
    for(intidx=0;idx<n;idx++){
        if(top>=0&&sk[top]!=id[idx]){
            top--;
            continue;
        }
        sk[++top]=id[idx];
    }
    returnsk[0];
}

1.3.3  “寻找水王”问题优化

上述代码的时间复杂度是O(N),当输入序列全部都是水王的ID时候空间复杂度却可以达到O(N)。时间复杂度是无法优化了,因为最少需要遍历一次才能知道答案。空间复杂度却可以优化。

如果观察上述代码的运行过程,容易发现栈中存储的元素都是一样的,区别就是数量有时候增加有时候减少。所以我们没必要维护一个栈来记录,只需要2个变量,一个用于记录栈中的ID值,另外一个用于维护ID值的数量。所以修改后的代码就变成了:

21 改进后的“寻找水王”

typedefintID;
IDfind(IDid[],intn){
    intsklen=1;IDskval=id[0];
    for(intidx=1;idx<n;idx++){
        if(sklen>0&&id[idx]!=skval){
            sklen--;
            continue;
        }
        skval=id[idx];sklen++;
    }
    returnskval;
}

         或许这个代码和编程之美书中的代码不太一致,其实思路是一样的。编程之美里面关于这个题还有一个扩展,大意就是水王有3个,每个人的ID数量都超过1/4,这个扩展问题可以使用一样的思路进行求解,网上也搜的到。

1.4     后缀表达式

         平时使用的表达式规则是中缀表达式,此外还有前缀表达式和后缀表达式。后缀表达式也称作波兰表达式。

对人而言,中缀表达式更容易理解,对于机器而言,后缀表达式则更容易理解,因为利用一个栈辅助就可以对后缀表达式进行求解。

         后缀表达式的求解规则就是:遇到一个运算符,然后就取出运算符之前的两个数进行计算,然后将结果替换着三个符号。

         例如后缀表达式:34+23**

Ø  第一次遇到运算符“+”,则取出3,4,求解后替换,变成723**

Ø  第二次遇到运算符“*”,则取出2,3,求解后替换,变成76*

Ø  第三次遇到运算符“*”,则取出7,6,求解后替换,变成42

利用栈辅助,针对只有加法的后缀表达式的求解代码就是:

22 求解一个正确的后缀表达式

#defineMAXSIZE100
intsk[MAXSIZE];
inttop=-1;
intcal(charp[]){
    for(inti=0;p[i]!='\0';i++){
        switch(p[i]){
        case'+':
            assert(top>0);
            top--;
            sk[top]=sk[top]+sk[top+1];
            break;
        default:
            sk[++top]=p[i]-'0';
            break;
        }
    }
    returnsk[top];
}

         注意,这个代码只能求解一个表达正确的后缀表达式,如果后缀表达式有问题,该代码无法求解。

1.5     中缀表达式转后缀表达式

1.5.1  定义符号优先级

         要将中缀表达式转换成后缀表达式,首先需要转换符号优先级,考虑四则运算的优先级,我们查找ascii码,并编写优先级列表,游戏规则就是:

Ø  (”有最高优先级,“)”有最低优先级

Ø  +”“-”有相同优先级,“*”“/”有相同优先级

Ø  *”“/”的优先级比“+”“-”的优先级高

最后得到下表

23

二进制

十进制

十六进制

符号

优先级

0010 1000

40

28

(

4

0010 1001

41

29

)

1

0010 1010

42

2A

*

3

0010 1011

43

2B

+

2

0010 1100

44

2C

,

-1

0010 1101

45

2D

-

2

0010 1110

46

2E

.

-1

0010 1111

47

2F

/

3

         我们可以编写一个函数去做符号优先级的转换,更简单的就是使用一个数组去记录。代码如下:

24 定义符号优先级

#definehash(x)(x&0x7)
intpArray[8]={4,1,3,2,-1,2,-1,3};
#defineprio(x)pArray[hash(x)]

         注:这个方法其实并不好,因为并未对出错的情况进行处理。好处就是写起来很简单。

1.5.2  符号栈出入规则

         转换过程中,最重要的就是对运算符号的符号栈管理,设idx为当前指向的符号,这里的管理规则就是:

Ø  如果idx指向符号为“)”,将栈顶元素压出直到第一个“(”被压出,因为它们是相互匹配的符号,然后idx右移。(注:如果遇到栈底都没找到“(”,表明这个表达式肯定是有问题的)

Ø  在栈不为空的情况下,如果idx指向元素优先级小于栈中符号优先级,则将栈顶元素压出,因为高优先级的操作必须先完成,直到遇到第一个”(”,因为此时idx的符号优先级其实需要比“(”前的符号优先级高;最后将idx指向符号压入栈,idx右移。

根据这个游戏规则,我们可以写出如下代码:

25 中缀表达式转换后缀表达式的符号出入栈规则

#definehash(x)(x&0x7)
intpArray[8]={4,1,3,2,-1,2,-1,3};
#defineprio(x)pArray[hash(x)]
#defineMAXSIZE100
charsymsk[MAXSIZE];
inttop=-1;
#defineisNum(x)(x<='9'&&x>='0')
voidchange(charp[]){
    for(intidx=0;p[idx]!='\0';idx++){
        if(isNum(p[idx])){//暂时不考虑数字的情况
            continue;
        }
        if(p[idx]==')'){
            while(top>=0&&symsk[top]!='('){
                top--;
            }
            top--;//将'('压出栈
            continue;
        }
        while(top>=0&&symsk[top]!='('
               &&prio(p[idx])<prio(symsk[top])){
            top--;
        }
        symsk[++top]=p[idx];
    }
}

1.5.3  表达式转换

         最后再考虑表达式的转换,在之前的代码上稍加修改就可以,规则就是遇到数字直接记录,如果遇到非“(”的符号出栈,则记录。根据这个规则,可以修改表 25的代码,得到表 26中的最终代码:

26 将中缀表达式转换成后缀表达式

#definehash(x)(x&0x7)
intpArray[8]={4,1,3,2,-1,2,-1,3};
#defineprio(x)pArray[hash(x)]
#defineMAXSIZE100
charsymsk[MAXSIZE];
inttop=-1;
#defineisNum(x)(x<='9'&&x>='0')
voidchange(charp[],charret[]){
    intridx=0;
    for(intidx=0;p[idx]!='\0';idx++){
        if(isNum(p[idx])){
            ret[ridx++]=p[idx]; //维护答案
            continue;
        }
        
        if(p[idx]==')'){
            while(top>=0&&symsk[top]!='('){
                ret[ridx++]=symsk[top--]; //维护答案
            }
            top--;
            continue;
        }
        while(top>=0&&symsk[top]!='('
               &&prio(p[idx])<prio(symsk[top])){
            ret[ridx++]=symsk[top--]; //维护答案
        }
        symsk[++top]=p[idx];
    }
    while(top>=0){ //维护答案
        ret[ridx++]=symsk[top--];
    }
    ret[ridx]='\0';

}

1.5.4  考虑表达式错误检查

         这里的代码都是要求表达式必须是正确的,但是如果输入的表达式存在错误,上述的代码就无法正常工作。如何进行表达式的检错已经是文法领域的知识,我暂时见到的介绍相关问题解决方法是书籍就是《编译原理》,感兴趣不妨查查。

1.6     直方图的最大子矩形

1.6.1  问题描述

         给出一个宽度为1直方图,求解直方图中的最大矩形。

1.6.2  基本思路

         在遍历到第i个元素的时候,分别计算其“左边界”与“右边界”,然后就可以得以当前元素为高度的一个最大子矩形,最后的答案就是这些子矩形的最大值。如下图所示:

2

         这种办法比较笨拙,如当直方图每个元素等高的时候,算法复杂度可以达到O(N2),空间复杂度则是O(1)。显然不是一个很好的算法。

1.6.3  思维转换

确定第i个元素子矩形时,首先向左走,遇到第一个小于它的元素停止,此时确定“左边界”。然后向右走,遇到第一个小于它的元素停止,此时确定“右边界”,左右边界的距离差与元素高度的乘积就是该元素的子矩形。

         将这个想法推广,如果事先存有第i个元素左右边界,则就可以在O(1)的时间内知道第i个元素的子矩形。

那剩下的问题就是如何去保存每个元素的左右边界。这个思路很容易得到,我们首先使用一个数组保存左边界。在考察第i个元素的时候:

Ø  如果第i个元素高度大于第i-1个元素高度,则其左边界就是自身。

Ø  如果第i个元素高度小于等于第i-1个高度,则考察第i-2个元素,直到遇到比其小的元素,最后停止的这个元素就是第i个元素的左边界。

下图就是这样一个例子。

3

         右边界也可以使用类似的方法得到。这种办法的时间复杂度还是O(N2),空间复杂度却是O(N)

         看似这种方法没有对整个算法起到任何优化作用,甚至还增加了算法复杂度。不过,这种思路为下一节中的方法提供了一些基础。

1.6.4  单调栈求解

这时候如再果换一个思路,如果第i个元素比第i-1个元素小,则第i个元素就是第i-1个元素的右边界,如果这时候知道第i-1个元素的左边界,第i-1个元素的子矩形就可以知道了。

剩下的问题关键就是如何保存前i-1个元素的左边界,如果还像之前那样用一个数组进行保存,性能是可以得到提升(因为不用再找右边界了),但是最后时间复杂度还是渐进于O(N2),当然常数因子可以减少一半。

再深入一些分析,如下图,当i指向第3个元素的时候,第2个元素已经确定了其左右边界,此时第2个元素就可以求解其子矩形。在以后遍历的过程中不需要再考虑第2个元素的子矩形,这意味着数组中保存第2个元素的左边界是没有意义的。所以可以将第2个元素的左边界从数组中删除。

同时第3个元素的左边界可以确定是第一个元素,这与第一个元素保存的值重复了,可以将其进行合并。

在走到第4个元素的时候,第4个元素的左边界可以立马确定。

         所以可以发现在遍历到第i个元素的时候,我们需要的信息其实只是黄色标注的部分。

4

         通过上述的这种方式,我们不但可以缩小最后数组的大小,同时还可以提高在搜索左边界的速度。

如果观察这个上述黄色部分的数据变化过程,就可以发现其满足2个特点:

Ø  边界值总是按照栈的方式后入先出。(标记成黄色就是入栈,标记成绿色就是出栈)

Ø  边界值总是按照单调递增的顺序存储。(标记成黄色的部分总是单调递增的)

这种特性其实就是单调栈的特性,单调栈的规则就是:

Ø  只能从栈顶开始入栈与出栈操作。

Ø  栈中元素必须保持单调(单调递增,单调递减,单调不增,单调不减)

27是一个单调递增栈的代码实现。

Ø  i指向元素如果大于栈顶元素,将i指向元素压栈。

Ø  i指向元素小于等于当前栈顶元素,则将栈顶元素出栈知道栈顶元素小于i指向元素,然后再将i指向元素压栈。

27 单调递增栈的实现

#defineMAXSIZE100

intsk[MAXSIZE];

inttop=-1;

voidmonotoneStack(inta[],intn){

    for(inti=0;i<n;i++){

        if(top==-1||a[i]>sk[top]){

            sk[++top]=a[i];

            continue;

        }

        while(top>=0&&sk[top]>=a[i]){

            top--;

        }

        sk[++top]=a[i];

    }

}

         有了上面的这么多分析,最后要得到代码就相对容易很多了。单调栈中存储的元素内容就是元素的高度值与左边界(不过这里有一个小技巧,为了避免最后一个元素没有被考察到,所以会在整个数组最后加入一个元素0)。这样就可以得到表 28中的代码:

28 单调栈解决直方图最大子矩阵问题

intmaxZone(inta[],intn){

    a[n]=0;n++;

    for(inti=0;i<n;i++){

        if(top==-1||a[i]>skh[top]){

            skh[++top]=a[i];

            sklb[top]=i;

            continue;

        }

        while(top>=0&&skh[top]>=a[i]){

            top--;

        }

        skh[++top]=a[i];//此时栈顶元素恰好保存着a[i]的左边界

    }

    return0;

}

         这个解法的最大好处就是,算法复杂度可以降低到O(N),当然空间复杂度则是O(N)(例如所有元素按照单调递增的方式排列的时候空间复杂度就是O(N))

在有了这个模板以后,最后剩余的事情就是维护答案。非常容易就可以得到最后的代码:

29 在单调栈解决直方图最大子矩阵问题代码中加入答案维护代码

intmaxZone(inta[],intn){

    a[n]=0;n++;intret=0;

    for(inti=0;i<n;i++){

        if(top==-1||a[i]>skh[top]){

            skh[++top]=a[i];

            sklb[top]=i;

            continue;

        }

        while(top>=0&&skh[top]>=a[i]){

            ret=max((i-sklb[top])*skh[top],ret); // 维护答案

            top--;

        }

        skh[++top]=a[i];

    }

    returnret;

}

1.7     求解最大子矩阵

1.7.1  问题描述

         给出一个0-1矩阵,求矩阵中含1数量最大的子矩阵。

1.7.2  基本思路

         这个问题可以利用动态规划的思路进行实现,不过时间复杂度会很高。如果将这里的问题进行一个转换,就变成了一个直方图的最大子矩形问题。

         如下图所示,把纵向1的个数看做是高度,依次提高“水平线”,这样就可以将这个问题转换成直方图的最大子矩形问题。

5

         那这个方法剩下的事情就是如何去维护这个直方图:

30 维护子矩阵中的直方图

inthis[MAXSIZE];//直方图

intmaxCount(inta[][],intm,intn){

    for(inti=0;i<n;i++)his[i]=0;

    for(inthor=0;hor<m;hor++){//提升水平线

        for(intidx=0;idx<n;idx++){//更新直方图

            if(his[idx]>0){

                his[idx]--;

                continue;

            }

            for(wlk=hor;wlk<m&&a[wlk][idx]==1;wlk++){

                his[idx]++;

            }

        }

    }

}

         然后在利用之前的代码维护答案即可。这个算法的算法复杂度是O(MN),已经渐进最优。

         与这个题有些类似的就是,求最大子矩阵和,当时那个题没办法用这个思路进行解决,具体解法可以看《编程之美》中的解答。

1.8     小结

         该小结的内容主要是通过几个实际例子说明栈在实际中的一些应用。希望说明栈并不是为了用于优化递归问题而存在,有时候灵活应用它能够起到一些意想不到的效果。

 

你可能感兴趣的:(算法学习笔记——利用栈解决实际问题- part 2)