zoukankan      html  css  js  c++  java
  • 算法竞赛专题解析(5):简单数据结构

    本系列是这本算法教材的扩展资料:《算法竞赛入门到进阶》(京东 当当 ) 清华大学出版社
    如有建议,请联系:(1)QQ 群,567554289;(2)作者QQ,15512356

      本文写给刚学过编程语言,正在学数据结构的新队员。
      在《数据结构》教材中,一般包含这些内容:线性表(数组、链表)、栈和队列、串、多维数组和广义表、哈希、树和二叉树、图(图的存储、遍历等)、排序等。
      本文给出几个简单数据结构的详细代码和习题:链表、栈和队列。
      其他几种数据结构的代码和习题,例如串、二叉树、图,在《算法竞赛入门到进阶》一书中有详细说明,这里不再重复。

    1 链表

      链表的特点是:用一组任意的存储单元存储线性表的数据元素(这组存储单元可以是连续的,也可以不连续)。链表是容易理解和操作的基本数据结构,它的操作有:初始化、添加、遍历、插入、删除、查找、排序、释放等。
      下面用例题洛谷 P1996,给出动态链表、静态链表、STL链表等5种实现方案。其中有单向链表,也有双向链表。在竞赛中,为加快编码速度,一般用静态链表或者STL list。
      本文给出的5种代码,经过作者的详细整理,逻辑和流程完全一样,看懂一个,其他的完全类似,可以把注意力放在不同的实现方案上,便于学习。

    洛谷P1996 https://www.luogu.com.cn/problem/P1996
    约瑟夫问题
    题目描述:n个人围成一圈,从第一个人开始报数,数到 m 的人出列,再由下一个人重新从 1 开始报数,数到 m 的人再出圈,依次类推,直到所有的人都出圈,请输出依次出圈人的编号。
    输入输出:输入两个整数 n,m。输出一行n个整数,按顺序输出每个出圈人的编号。1≤m,n≤100。
    输入输出样例
    输入
    10 3
    输出
    3 6 9 2 7 1 8 5 10 4

    1.1 动态链表

      教科书都会讲动态链表,它需要临时分配链表节点、使用完毕后释放链表节点。这样做,优点是能及时释放空间,不使用多余内存。缺点是很容易出错。
      下面代码实现了动态单向链表。

    #include <bits/stdc++.h>
    struct node{          //链表结构
        int data;
        node *next;
    };
    
    int main(){
        int n,m;
        scanf("%d %d",&n,&m);
        node *head,*p,*now,*prev;   //定义变量
        head = new node; head->data = 1; head->next=NULL; //分配第一个节点,数据置为1        
        now = head;                 //当前指针是头
        for(int i=2;i<=n;i++){
            p = new node;  p->data = i; p->next = NULL;  //p是新节点
            now->next = p;        //把申请的新节点连到前面的链表上
            now = p;              //尾指针后移一个
        }    
        now->next = head;            //尾指针指向头:循环链表建立完成
    
     //以上是建立链表,下面是本题的逻辑和流程。后面4种代码,逻辑流程完全一致。
    
        now = head, prev=head;      //从第1个开始数
        while((n--) >1 ){ 
            for(int i=1;i<m;i++){       //数到m,停下
                prev = now;             //记录上一个位置,用于下面跳过第m个节点
                now = now->next; 
            }
            printf("%d ", now->data);       //输出第m节点,带空格
            prev->next = now->next;         //跳过这个节点
            delete now;                     //释放节点
            now = prev->next;               //新的一轮        
        }
        printf("%d", now->data);            //打印最后一个,后面不带空格
        delete now;                         //释放最后一个节点
        return 0;
    }
    

    1.2 用结构体实现单向静态链表

      上面的动态链表,需要分配和释放空间,虽然对空间的使用很节省,但是容易出错。在竞赛中,对内存管理要求不严格,为加快编码速度,一般就静态分配,省去了动态分配和释放的麻烦。这种静态链表,使用预先分配的大数组来存储链表。
      静态链表有两种做法,一是定义一个链表结构,和动态链表的结构差不多;一种是使用一维数组,直接在数组上进行链表操作。
      本文给出3个例子:用结构体实现单向静态链表、用结构体实现双向静态链表、用一维数组实现单向静态链表。
      下面是用结构体实现的单向静态链表。

    #include <bits/stdc++.h>
    const int maxn = 105;        //定义静态链表的空间大小
    struct node{                 //单向链表
        int id;
        //int data;   //如有必要,定义一个有意义的数据
        int nextid;
    }nodes[maxn];
    
    int main(){
        int n, m;
        scanf("%d%d", &n, &m);
        nodes[0].nextid = 1;
        for(int i = 1; i <= n; i++){
            nodes[i].id = i;
            nodes[i].nextid = i + 1;
        }
        nodes[n].nextid = 1;                     //循环链表:尾指向头
    
        int now = 1, prev = 1;                   //从第1个开始
        while((n--) >1){
            for(int i = 1; i < m; i++){          //数到m,停下
                prev = now;  
                now = nodes[now].nextid;
            }
            printf("%d ", nodes[now].id);        //带空格
            nodes[prev].nextid = nodes[now].nextid;  //跳过节点now,即删除now
            now = nodes[prev].nextid;            //新的now
        }    
        printf("%d", nodes[now].nextid);         //打印最后一个,后面不带空格
        return 0; 
    }
    

    1.3 用结构体实现双向静态链表

    #include <bits/stdc++.h>
    const int maxn = 105;
    struct node{      //双向链表
        int id;       //节点编号
        //int data;   //如有必要,定义一个有意义的数据
        int preid;    //前一个节点
        int nextid;   //后一个节点
    }nodes[maxn];
    
    int main(){
        int n, m;
        scanf("%d%d", &n, &m);
        nodes[0].nextid = 1;
        for(int i = 1; i <= n; i++){  //建立链表
            nodes[i].id = i;
            nodes[i].preid = i-1;     //前节点
            nodes[i].nextid = i+1;    //后节点
        }
        nodes[n].nextid = 1;          //循环链表:尾指向头
        nodes[1].preid = n;           //循环链表:头指向尾
    
        int now = 1;                  //从第1个开始
        while((n--) >1){
            for(int i = 1; i < m; i++)     //数到m,停下
                now = nodes[now].nextid;
            printf("%d ", nodes[now].id);  //打印,后面带空格
    
            int prev = nodes[now].preid;   
            int next = nodes[now].nextid;
            nodes[prev].nextid = nodes[now].nextid;  //删除now
            nodes[next].preid = nodes[now].preid;   
            now = next;                    //新的开始
        }    
        printf("%d", nodes[now].nextid);   //打印最后一个,后面不带空格
        return 0; 
    }
    

    1.4 用一维数组实现单向静态链表

      这是最简单的实现方法。定义一个一维数组(nodes[])(nodes[i])(i)是节点的值,(nodes[i])的值是下一个节点。
      从上面描述可以看出,它的使用环境也很有限,因为它的节点只能存一个数据,就是(i)

    #include<bits/stdc++.h>
    int nodes[150];
    int main(){   
        int n, m;
        scanf("%d%d", &n, &m); 
        for(int i=1;i<=n-1;i++)          //nodes[i]的值就是下一个节点
            nodes[i]=i+1;
        nodes[n]=1;                      //循环链表:尾指向头
    
        int now = 1, prev = 1;           //从第1个开始
        while((n--) >1){
            for(int i = 1; i < m; i++){   //数到m,停下
                prev = now;  
                now = nodes[now];         //下一个
            }
            printf("%d ", now);  //带空格
            nodes[prev] = nodes[now];     //跳过节点now,即删除now
            now = nodes[prev];            //新的now
        }    
        printf("%d", now);                //打印最后一个,不带空格
        return 0;
    }
    

    1.5 STL list

      竞赛或工程中,常常使用C++ STL list。list 是双向链表,它的内存空间可以是不连续的,通过指针来进行数据的访问,它能高效率地在任意地方删除和插入,插入和删除操作是常数时间的。
      请读者自己熟悉list的初始化、添加、遍历、插入、删除、查找、排序、释放 [参考]

      下面是洛谷P1996的list实现。

    #include <bits/stdc++.h>
    using namespace std;
    
    int main(){
        int n, m;
        cin>>n>>m;
        list<int>node;
        for(int i=1;i<=n;i++)         //建立链表
            node.push_back(i);     
        list<int>::iterator it = node.begin();
        while(node.size()>1){         //list的大小由STL自己管理
            for(int i=1;i<m;i++){     //数到m
                 it++; 
                 if(it == node.end()) //循环链表,end()是list末端下一位置
                    it = node.begin();                                              
            }
            cout << *it <<"";
            list<int>::iterator next = ++it;
            if(next==node.end())  next=node.begin();  //循环链表
            node.erase(--it);         //删除这个节点,node.size()自动减1
            it = next;
        }
        cout << *it;
        return 0;
     }
    

    1.6 链表习题

      畅销书《剑指offer》给出了练习链表的OJ地址:https://leetcode-cn.com/problemset/lcof/
      其中这些题是链表习题:
      面试题06-从尾到头打印链表
      面试题22-链表中倒数第k个节点
      面试题24-反转链表
      面试题25-合并两个有序链表
      面试题35-复杂链表的复制
      面试题52-两个链表的第一个公共节点
      面试题18-删除链表中的节点

    2 队列

      队列中的数据存取方式是“先进先出”。例如食堂打饭的队伍,先到先服务。
      队列有两种实现方式:链队列和循环队列。

      链队列,可以把它看成是单链表的一种特殊情况,用指针把各个节点连接起来。   循环队列,是一种顺序表,使用一组连续的存储单元依次存放队列元素,用两个指针front和rear分别指示队列头元素和队列尾元素。由于队列是先进先出的一个“队伍”,所以在存储单元中,front和rear都是一直往前走,走到存储空间的最后面,可能会溢出。为了解决这一问题,把队列设计成环状的循环队列。   队列和栈的主要问题是查找较慢,需要从头到尾一个个查找。在某些应用情况下,可以用优先队列,让优先级最高(比如最大的数)先出队列。   由于队列很简单,而且往往是固定大小的,所以在竞赛中一般就用静态数组来实现队列,或者使用STL queue。   下面是一个例题,在2.1和2.2节中分别给出了静态数组和STL queue这2种代码。

    洛谷P1540 https://www.luogu.com.cn/problem/P1540
    机器翻译
    题目描述:内存中有M个单元,每单元能存放一个单词和译义。每当软件将一个新单词存入内存前,如果当前内存中已存入的单词数不超过M-1,软件会将新单词存入一个未使用的内存单元;若内存中已存入M个单词,软件会清空最早进入内存的那个单词,腾出单元来,存放新单词。
    假设一篇英语文章的长度为N个单词。给定这篇待译文章,翻译软件需要去外存查找多少次词典?假设在翻译开始前,内存中没有任何单词。
    输入:共2行。每行中两个数之间用一个空格隔开。
    第一行为两个正整数M,N,代表内存容量和文章的长度。
    第二行为N个非负整数,按照文章的顺序,每个数(大小不超过1000)代表一个英文单词。文章中两个单词是同一个单词,当且仅当它们对应的非负整数相同。。
    输出:一个整数,为软件需要查词典的次数。
    输入输出样例
    输入
    3 7
    1 2 1 5 4 4 1
    输出
    5

    2.1 STL queue

      STL queue的有关操作:
      queue q;    //定义栈,Type为数据类型,如int,float,char等
      q. push(item);  //把item放进队列
      q.front();    //返回队首元素,但不会删除
      q.pop();     //删除队首元素
      q.back();     //返回队尾元素
      q.size();     //返回元素个数
      q.empty();     //检查队列是否为空
      下面是洛谷P1540的代码,由于不用自己管理队列,代码很简洁。
      注意代码中检查内存中有没有单词的方法。如果一个一个地搜索,太慢了;用hash不仅很快而且代码简单。

    #include<bits/stdc++.h>
    using namespace std;
    
    int hash[1003]={0};  //用hash检查内存中有没有单词,hash[i]=1表示单词i在内存中
    queue<int> mem;      //用队列模拟内存
    
    int main(){
        int m,n;
        scanf("%d%d",&m,&n);
        int cnt=0;          //查词典的次数
        while(n--){ 
    	int en;
    	scanf("%d",&en);    //输入一个英文单词
    	if(!hash[en]){      //如果内存中没有这个单词
    		++cnt; 
    		mem.push(en);   //单词进队列,放到队列尾部
    		hash[en]=1;     //记录内存中有这个单词
    		while(mem.size()>m){         //内存满了
    			hash[mem.front()] = 0;   //从内存中去掉单词
    			mem.pop();               //从队头去掉
    			}
    		}
    	}
    	printf("%d
    ",cnt);
    	return 0;
    }
    

    2.2 手写循环队列

      下面是循环队列的模板。代码中给出了静态分配空间和动态分配空间两种方式。竞赛中用静态分配更好。

    #include<bits/stdc++.h>
    #define MAXQSIZE 1003      //队列大小
    int hash[MAXQSIZE]={0};    //用hash检查内存中有没有单词
    
    struct myqueue{                  
        int data[MAXQSIZE];    //分配静态空间
        /* 如果动态分配,就这样写: int *data;    */
        int front;             //队头,指向队头的元素
        int rear;              //队尾,指向下一个可以放元素的空位置
    
        bool init(){           //初始化
        /*如果动态分配,就这样写:
            Q.data = (int *)malloc(MAXQSIZE * sizeof(int)) ; 
            if(!Q.data) return false; */
            front = rear = 0;
            return true;
        }
        int size(){            //返回队列长度
            return (rear - front + MAXQSIZE) % MAXQSIZE;
        }
        bool push(int e){      //队尾插入新元素。新的rear指向下一个空的位置
             if((rear + 1) % MAXQSIZE == front ) return false;    //队列满
             data[rear] = e;
             rear = (rear + 1) % MAXQSIZE;
             return true;
        }
        bool pop(int &e){      //删除队头元素,并返回它
             if(front == rear) return false;   //队列空
             e = data[front];
             front = (front + 1) % MAXQSIZE;
             return true;
        }
    }Q;  
    
    int main(){
        Q.init();   //初始化队列
        int m,n;  scanf("%d%d",&m,&n);
        int cnt = 0;
        while(n--){ 
    	int en;  scanf("%d",&en);    //输入一个英文单词
    	if(!hash[en]){               //如果内存中没有这个单词
    		++cnt;
    		Q.push(en);              //单词进队列,放到队列尾部
    		hash[en]=1;
    		while(Q.size()>m){       //内存满了
                    int tmp;
                    Q.pop(tmp);      //删除队头
    			hash[tmp] = 0;       //从内存中去掉单词    			
    			}
    		}
    	}
    	printf("%d
    ",cnt);
    	return 0;
    }
    

    2.3 双端队列和单调队列

      前面讲的队列,是很“规矩”的,队列的元素都是“先进先出”,队头的只能弹出,队尾只能进入。有没有不那么“规矩”的队列呢?
      这就是双端队列。双端队列是一种具有队列和栈性质的数据结构,它能在两端进行插入和删除,而且也只能在两端插入和删除。
      STL中的deque是双端队列,它的用法是:
      dq[i]:返回q中下标为i的元素;
      dq.front():返回队头;
      dq.back():返回队尾;
      dq.pop_back():删除队尾。不返回值;
      dq.pop_front():删除队头。不返回值;
      dq.push_back(e):在队尾添加一个元素e;
      dq.push_front(e):在队头添加一个元素e。
      双端队列的经典应用是单调队列。单调队列有2个特征:
      (1)队列中的元素是单调有序的,且元素在队列中的顺序和原来在序列中的顺序一致;
      (2)单调队列的队头和队尾都能入队和出队。
      其中(1)是我们期望的结果,它是通过(2)来实现的。
      单调队列用起来非常灵活,在很多问题中应用它可以获得优化。简单地说是这样实现的:序列中的n个元素,用单调队列处理时,每个元素只需要进出队列一次,复杂度是O(n)。
      下面用两个模板题来讲解单调队列的应用,了解它们如何通过单调队列获得优化。注意队列中“删头、去尾、窗口”的操作。

    2.3.1 滑动窗口

    洛谷 P1886 https://www.luogu.com.cn/problem/P1886
    滑动窗口 /【模板】单调队列
    题目描述:有一个长为 n 的序列 a,以及一个大小为 k 的窗口。现在这个从左边开始向右滑动,每次滑动一个单位,求出每次滑动后窗口中的最大值和最小值。
    例如:
    The array is [1,3,-1,-3,5,3,6,7], and k = 3。

    输入输出:输入一共有两行,第一行有两个正整数 n,k。 第二行 n 个整数,表示序列 a。输出共两行,第一行为每次窗口滑动的最小值,第二行为每次窗口滑动的最大值。
    注意:(1 ≤ k ≤ n ≤ 10^{6},a_iin[-2^{31}, 2^{31}])
    输入输出样例
    输入
    8 3
    1 3 -1 -3 5 3 6 7
    输出
    -1 -3 -3 -3 3 3
    3 3 5 5 6 7

      这一题用暴力法很容易编程,从头到尾扫描,每次检查(k)个数,一共检查(O(nk))次。暴力法显然会超时,这一题需要用(O(n))的算法。
      下面用单调队列来求解,它的复杂度是(O(n))的。
      在这一题中,单调队列有以下特征
      (1)队头的元素始终是队列中最小的;根据题目需要输出队头,但是不一定弹出。
      (2)元素只能从队尾进入队列,从队头队尾都可以弹出。
      (3)序列中的每个元素都必须进入队列。例如a进队尾时,和原队尾b比较,如果a≤b,就从队尾弹出b;弹出队尾所有比a大的,最后a进入队尾。入队的这个操作,保证了队头元素是队列中最小的。
      直接看上述题解可能有点晕,这里以食堂排队打饭为例子来说明它。
      大家到食堂排队打饭时都有一个心理,在打饭之前,先看看里面有什么菜,如果不好吃就走了。不过,能不能看到和身高有关,站在队尾的人如果个子高,眼光能越过前面队伍的脑袋,看到里面的菜;如果个子矮,会被挡住看不见。
      矮个子希望,要是前面的人都比他更矮就好了。如果他会魔法,他来排队的时候,队尾比他高的就自动从队尾离开,新的队尾如果仍比他高,也会离开。最后,新来的矮个子成了新的队尾,而且是最高的。他终于能看到菜了,让人兴奋的是,菜很好吃,所以他肯定不会走。
      假设每一个新来的魔法都比队列里的人更厉害,这个队伍就会变成这样:每个新来的人都能排到队尾,但是都会被后面来的矮个子赶走。这样一来,这个队列就会始终满足单调性:从队头到队尾,由矮到高。
      但是,让这个魔法队伍郁闷的是,打饭阿姨一直忙她的,顾不上打饭。所以排头的人等了一会儿,就走了,等待时间就是k。这有一个附带的现象:队伍长度不会超过k。
      输出什么呢? 每当新来一个排队的人,如果排头还没走,就跟阿姨喊一声,这就是输出。
      以上是本题的现实模型。
      下面举例描述算法流程,队列是{(1,3,-1,-3,5,3,6,7)},读者可以把数字想象成身高。以输出最小值为例,下面表格中的“输出队首”就是本题的结果。

    元素进入队尾 元素进队顺序 队列 窗口范围 队首在窗口内吗? 输出队首 弹出队尾 弹出队首
    1 1 {1} [1]
    3 2 {1,3} [1 2]
    -1 3 {-1} [1 2 3] -1 3,1
    -3 4 {-3} [2 3 4] -3 -1
    5 5 {-3,5} [3 4 5] -3
    3 6 {-3,3} [4 5 6] -3 5
    6 7 {3,6} [5 6 7] -3否,3是 3 -3
    7 8 {3,6,7} [6 7 8] 3

      单调队列的时间复杂度:每个元素最多入队1次、出队1次,且出入队都是(O(1))的,因此总时间是(O(n))。题目需要逐一处理所有(n)个数,所以(O(n))已经是能达到的最优复杂度。
      从以上过程可以看出,单调队列有两个重要操作:删头、去尾。
      (1)删头。如果队头的元素脱离了窗口,这个元素就没用了,弹出它。
      (2)去尾。如果新元素进队尾时,原队尾的存在破坏了队列的单调性,就弹出它。
      下面是P1886的代码[参考]。在代码中,用双端队列实现了单调队列。

    #include<bits/stdc++.h>
    using namespace std;
    
    int a[1000005];
    deque<int>q;        //队列中的数据,实际上是元素在原序列中的位置
    
    int main(){
        int n,m;
        scanf("%d%d",&n,&m);  
        for(int i=1;i<=n;i++) scanf("%d",&a[i]);  
        for(int i=1;i<=n;i++){                       //输出最小值 
            while(!q.empty() && a[q.back()]>a[i])    //去尾
                q.pop_back(); 
            q.push_back(i);
            if(i>=m){                                //每个窗口输出一次
                while(!q.empty() && q.front()<=i-m)  //删头 
                    q.pop_front();
                printf("%d ", a[q.front()]);
            }
        }
        printf("
    ");
    
        while(!q.empty())  q.pop_front();            //清空,下面再用一次
        for(int i=1;i<=n;i++){                       //输出最大值 
            while(!q.empty() && a[q.back()]<a[i])    //去尾
                q.pop_back();     
            q.push_back(i);
            if(i>=m){
                while(!q.empty() && q.front()<=i-m)  //删头
                    q.pop_front(); 
                printf("%d ", a[q.front()]);
            }
        }
        printf("
    ");
        return 0;
    }
    

    2.3.2 最大子序和

      给定长度为n的整数序列A,它的“子序列”定义是:A中非空的一段连续的元素。子序列和,例如序列(6,-1,5,4,-7),前4个元素的和是6 + (-1) + 5 + 4 = 14。
      最大子序和问题,按子序列有无长度限制,有两种:
      (1)不限制子序列的长度。在所有可能的子序列中,找到一个子序列,该子序列和最大。
      (2)限制子序列的长度。给一个限制m,找出一段长度不超过m的连续子序列,使它的和最大。
      问题(1)比较简单,用贪心或DP,复杂度都是O(n)的。
      问题(2)用单调队列,复杂度也是O(n)的。通过这个例子,读者可以理解为什么单调队列能用于DP优化
      问题(1)不是本节的内容,不过为了参照,下面也给出题解。

    1. 问题(1)的求解
      用贪心或DP,在O(n)时间内求解。例题是hdu 1003。

    hdu 1003 http://acm.hdu.edu.cn/showproblem.php?pid=1003
    Max Sum
    题目描述:给一个序列,求最大子序和。
    输入:第1行是整数T,表示测试用例个数,1<=T<=20。后面跟着T行,每一行第1个数是N,后面是N个数,1<=N<=100000,每个数在[-1000, 1000]内。
    输出:对每个测试,输出2行,第1行是"Case #:",其中"#"是第几个测试,第2行输出3个数,第1个数是最大子序和,第2和第3个数是开始和终止位置。
    输入输出样例
    输入
    2
    5 6 -1 5 4 -7
    7 0 6 -1 1 -6 7 -5
    输出
    Case 1:
    14 1 4

    Case 2:
    7 1 6

    题解1:贪心。 逐个扫描序列中的元素,累加。加一个正数时,和会增加;加一个负数时,和会减少。如果当前得到的和变成了负数,这个负数和在接下来的累加中,会减少后面的求和。所以抛弃它,从下一位置开始重新求和。

    点击查看hdu 1003的贪心代码
    
    #include "bits/stdc++.h"
    using namespace std;
    

    const int INF = 0x7fffffff;
    int main(){
    int t; cin >> t; //测试用例个数
    for(int i = 1; i <= t; i++){
    int n; cin >> n;
    int maxsum = -INF; //最大子序和,初始化为一个极小负数
    int start=1, end=1, p=1; //起点,终点,扫描位置
    for(int j = 1; j <= n; j++){
    int a; cin >> a; //读入一个元素
    int sum = 0; //子序和
    sum += a;
    if(sum > maxsum){
    maxsum = sum;
    start = p;
    end = j;
    }
    if(sum < 0){ //扫到j时,前面的最大子序和是负数,那么从下一个j重新开始求和。
    sum = 0;
    p = j+1;
    }
    }
    printf("Case %d: ",i);
    printf("%d %d %d ", maxsum,start,end);
    if(i != t) cout << endl;
    }
    return 0;
    }

    题解2:DP。用dp[i]表示到达第i个数时,a[1]~a[i]的最大子序和。状态转移方程为dp[i] = max(dp[i-1]+a[i], a[i])。

    点击查看hdu 1003的DP代码
    
    #include "bits/stdc++.h"
    using namespace std;
    

    int dp[100005]; //dp[i]: 以第i个数为结尾的最大值
    int main(){
    int t; cin>>t;
    for(int i=1;i<=t;i++){
    int n; cin >> n;
    for(int j=1;j<=n;j++) cin >> dp[j];
    int start=1, end=1, p=1; //起点,终点,扫描位置
    int maxsum = dp[1];
    for(int j=2; j<=n; j++){
    if(dp[j-1] >= 0) //dp[i-1]大于0,则对dp[i]有贡献
    dp[j] = dp[j-1]+dp[j]; //转移方程
    else p = j;
    if(dp[j]> maxsum ) {
    maxsum = dp[j];
    start = p;
    end = j;
    }
    }
    printf("Case %d: ",i);
    printf("%d %d %d ", maxsum,start,end);
    if(i != t) cout << endl;
    }
    }

    2. 问题(2)的求解
      和2.3.1节的滑动窗口类似,可以用单调队列的“窗口、删头、去尾”来解决问题(2)。
      首先求前缀和s[i]。s[i]是a[1]~a[i]的和,算出所有的s[i]~s[n],时间是O(n)的。
      问题(2)转换为:找出两个位置i, k,使得s[i] - s[k]最大,i - k≤ M。对于某个特定的s[i], 就是找到与它对应的最小s[k]。如果简单地暴力检查,对每个i,检查比它小的m个s[k],那么总复杂度是O(nm)的。
      用单调队列,可以使复杂度优化到O(n)。其关键是,s[k]只进入和弹出队列一次。基本过程是这样的,从头到尾检查s[],当检查到某个s[i]时,在窗口m内:
      (1)找到最小的那个s[k],并检查s[i]-s[k]是不是当前的最大子序和,如果是,就记录下来。
      (2)比s[i]大的所有s[k]都可以抛弃,因为它们在处理s[i]后面的s[i']时也用不着了,s[i']-s[i]要优于s[i']-s[k],留着s[i]就可以了。
      这个过程用单调队列最合适:s[i]进队尾时;如果原队尾比s[i]大就去尾;如果队头超过窗口范围m就去头;而最小的那个s[k]就是队头。因为每个s[i]只进出队列一次,所以复杂度为O(n)。
      下面是代码。

    #include<bits/stdc++.h>
    using namespace std;
    
    deque<int> dq;
    int s[100005];
    int main(){
        int n,m;
        scanf("%d%d",&n,&m);
        for(int i=1;i<=n;i++) scanf("%lld",&s[i]);
        for(int i=1;i<=n;i++) s[i]=s[i]+s[i-1];         //计算前缀和
        int ans = -1e8;
        dq.push_back(0);
        for(int i=1;i<=n;i++) {
            while(!dq.empty() && dq.front()<i-m)        //队头超过m范围:删头
                dq.pop_front();
            if(dq.empty()) 
                ans = max(ans,s[i]);
            else 
                ans = max(ans,s[i]-s[dq.front()]);       //队头就是最小的s[k]
            while(!dq.empty() && s[dq.back()] >= s[i])   //队尾大于s[i],去尾
                dq.pop_back();
            dq.push_back(i);
        }
        printf("%d
    ",ans);
        return 0;
    }
    

      在这个例子中,s[i]的操作实际上符合DP的特征。通过这个例子,读者能理解,为什么单调队列可以用于DP的优化。

    2.4 队列习题

      (1)单调队列简单题[https://blog.csdn.net/sinat_40471574/article/details/90577147]:洛谷 P1440,P2032,P1714,P2629,P2422。

      (2)单调队列可以用于优化DP,例如多重背包的优化等。请参考:
    https://blog.csdn.net/FSAHFGSADHSAKNDAS/article/details/52825227
      优化DP:洛谷 P3957、P1725。
      (3)二维队列:洛谷 P2776

    3 栈

      栈的特点是“先进后出”。例如坐电梯,先进电梯的被挤在最里面,只能最后出来;一管泡腾片,最先放进管子的药片位于最底层,最后被拿出来。
      编程中常用的递归,就是用栈来实现的。栈需要用空间存储,如果栈的深度太大,或者存进栈的数组太大,那么总数会超过系统为栈分配的空间,就会爆栈,即栈溢出。这是递归的主要问题。
      本节的栈用到STL stack,或者自己写栈。为避免爆栈,需要控制栈的大小。

    3.1 STL stack

      STL stack的有关操作:
      stack s;   //定义栈,Type为数据类型,如int,float,char等
      s.push(item);    //把item放到栈顶
      s.top();    //返回栈顶的元素,但不会删除。
      s.pop();    //删除栈顶的元素,但不会返回。在出栈时需要进行两步操作,先top()获得栈顶元素,再pop()删除栈顶元素
      s.size();    //返回栈中元素的个数
      s.empty();   //检查栈是否为空,如果为空返回true,否则返回false
      下面用一个例题说明栈的应用。

    hdu 1062 http://acm.hdu.edu.cn/showproblem.php?pid=1062
    Text Reverse
    翻转字符串。例如,输入“olleh !dlrow”,输出“hello world!”。

      下面是hdu 1062的代码。

    #include<bits/stdc++.h>
    using namespace std;
    int main(){
    	int n;
    	char ch;
    	scanf("%d",&n);  getchar();
    	while(n--){
    		stack<char> s;
    		while(true){
    			ch = getchar();                   //一次读入一个字符
    	        if(ch==' '||ch=='
    '||ch==EOF){
    				while(!s.empty()){
    					printf("%c",s.top());     //输出栈顶
    					s.pop();                  //清除栈顶
    				}
    				if(ch=='
    '||ch==EOF)  break;
    				printf("");
    			}
    			else  
                    s.push(ch);                   //入栈
    		}
    		printf("
    ");
    	}
    	return 0;
    }
    

    3.2 手写栈

      自己写个栈,很节省空间。下面是hdu 1062的代码。

    #include<bits/stdc++.h>
    const int maxn = 100000 + 100;
    
    struct mystack{
        char a[maxn];                         //存放栈元素,字符型
        int t = 0;                            //栈顶位置
        void push(char x){ a[++t] = x; }      //送入栈
        char top()       { return a[t]; }     //返回栈顶元素
        void pop()       { t--;         }     //弹出栈顶
        int empty()      { return t==0?1:0;}  //返回1表示空
    }st;
    
    int main(){
    	int n;
    	char ch;
    	scanf("%d",&n);  getchar();
    	while(n--){
    		while(true){
    			ch = getchar();                    //一次读入一个字符
    	        if(ch==' '||ch=='
    '||ch==EOF){
    				while(!st.empty()){
    					printf("%c",st.top());     //输出栈顶
    					st.pop();                  //清除栈顶
    				}
    				if(ch=='
    '||ch==EOF)  break;
    				printf("");
    			}
    			else  
                    st.push(ch);                   //入栈
    		}
    		printf("
    ");
    	}
    	return 0;
    }
    

    3.3 单调栈

      单调栈可以处理比较问题。单调栈内的元素是单调递增或递减的的,有单调递增栈、单调递减栈。
      单调栈比单调队列简单,因为栈只有一个出入口。
      下面的例题是单调栈的简单应用。

    洛谷 P2947 https://www.luogu.com.cn/problem/P2947
    向右看齐
    题目描述:N(1≤N≤10^5)头奶牛站成一排,奶牛i的身高是Hi(l≤Hi≤1,000,000)。现在,每只奶牛都在向右看齐。对于奶牛i,如果奶牛j满足i<j且Hi<Hj,我们说奶牛i仰望奶牛j。求出每只奶牛离她最近的仰望对象。
    输入输出:第 1 行输入 N,之后每行输入一个身高 H_i。输出共 N 行,按顺序每行输出一只奶牛的最近仰望对象,如果没有仰望对象,输出 0。
    输入输出样例
    输入
    6
    3
    2
    6
    1
    1
    2
    输出
    3
    3
    0
    6
    6
    0

    题解:从后往前遍历奶牛,并用一个栈保存从低到高的奶牛,栈顶的奶牛最矮,栈底的最高。具体操作是:遍历到奶牛i时,与栈顶的奶牛比较,如果不比i高,就弹出栈顶,直到栈顶的奶牛比i高,这就是i的仰望对象;然后把i放进栈顶,栈里的奶牛仍然保持从低到高。
    复杂度:每个奶牛只进出栈一次,所以是(O(n))的。
      下面分别用STL stack和手写栈来实现。
    (1)用STL stack实现

    #include<bits/stdc++.h>
    using namespace std;
    
    int h[100001], ans[100001];
    int main(){
        int n;
    	scanf("%d",&n);
    	for (int i=1;i<=n;i++)  scanf("%d",&h[i]);
        stack<int>st; 
    	for (int i=n;i>=1;i--){
    		while (!st.empty() && h[st.top()] <= h[i])  //栈顶奶牛没我高,弹出它,直到栈顶奶牛更高
                st.pop();
    		if (st.empty())       //栈空,没有仰望对象
                ans[i]=0; 
            else                  //栈顶奶牛更高,是仰望对象
                ans[i]=st.top();
    		st.push(i);
    	}
    	for (int i=1;i<=n;i++) 
            printf("%d
    ",ans[i]);
    	return 0;
    }
    

    (2)手写栈
      和3.2节几乎一样,只是改了栈元素的类型。

    #include<bits/stdc++.h>
    using namespace std;
    
    const int maxn = 100000 + 100;
    struct mystack{
        int a[maxn];                        //存放栈元素,int型
        int t = 0;                          //栈顶位置
        void push(int x){ a[++t] = x;  }    //送入栈
        int  top()      { return a[t]; }    //返回栈顶元素
        void pop()      { t--;         }    //弹出栈顶
        int empty()     { return t==0?1:0;} //返回1表示空
    }st;
    
    int h[maxn], ans[maxn];
    
    int main(){
        int n;
    	scanf("%d",&n);
    	for (int i=1;i<=n;i++)  scanf("%d",&h[i]);
    	for (int i=n;i>=1;i--){
    		while (!st.empty() && h[st.top()] <= h[i])  //栈顶奶牛没我高,弹出它,直到栈顶奶牛更高
                st.pop();
    		if (st.empty())       //栈空,没有仰望对象
                ans[i]=0; 
            else                  //栈顶奶牛更高,是仰望对象
                ans[i]=st.top();
    		st.push(i);
    	}
    	for (int i=1;i<=n;i++) 
            printf("%d
    ",ans[i]);
    	return 0;
    }
    

    3.4 栈习题

      洛谷 P5788
      https://leetcode-cn.com/problemset/lcof/
        面试题09-用两个栈实现队列
        面试题30-包含min函数的栈
        面试题31-栈的压入、弹出序列
        面试题58-翻转单词顺序列(栈)

    4 堆

    4.1 二叉堆概念

      堆的特征是:堆顶元素是所有元素的最优值。堆的应用有堆排序和优先队列。
      堆有两种:最大堆、最小堆。最大堆的根结点元素有最大值,最小堆的根结点元素有最小值。下面都以最小堆为例进行讲解。
      堆可以看成一棵完全二叉树。用数组实现的二叉树堆,树中的每个结点与数组中存放的元素对应。树的每一层,除了最后一层可能不满,其他每一层都是满的。
      二叉堆中的每个结点,都是以它为父结点的子树的最小值。

    图4.1 用数组实现的二叉树堆

      用数组A[]存储完全二叉树,结点数量为n,A[0]不用,A[1]为根结点,有以下性质:
      (1)i > 1的结点,其父结点位于i/2;
      (2)如果2i > n,那么i没有孩子;如果2i+1 > n,那么i没有右孩子;
      (3)如果结点i有孩子,那么它的左孩子是2i,右孩子是2i+1。
       堆的操作有进堆和出堆。
      (1)进堆:每次把元素放进堆,都调整堆的形状,使得根结点保持最小。
      (2)出堆:每次取出的堆顶,就是整个堆的最小值;同时调整堆,使得新的堆顶最小。
       复杂度:二叉树只有O(logn)层,进堆和出堆逐层调整,都是O(logn)的。

    4.2 二叉堆的实现

      堆的具体实现有两个方法[参考《算法》, Robert Sedgewick,人民邮电出版社]:上浮、下沉。

      上浮:某个结点的优先级上升,或者在堆底加入一个新元素(建堆,把新元素加入堆),此时需要从下至上恢复堆的顺序。
      下沉:某个结点的优先级下降,或者将根结点替换为一个较小的新元素(取出堆顶,用其他元素替换它),此时需要从上至下恢复堆的顺序。
      (1)上浮

    图4.2 新元素2 的上浮
      (2)下沉
    图4.3 弹出堆顶后,元素7的下沉

      上浮和下沉的代码实现,见下一节的例题。
      堆经常用于实现优先队列,上浮对应优先队列的插入push(),下沉对应优先队列的删除队头pop()。

    4.3 手写堆

      用下面的例题给出手写堆实现。
      类似的题目见洛谷P2278

    洛谷P3378堆 https://www.luogu.com.cn/problem/P3378
    题目描述
    初始小根堆为空,我们需要支持以下3种操作:
    操作1: 1 x 表示将x插入到堆中
    操作2: 2 输出该小根堆内的最小数
    操作3: 3 删除该小根堆内的最小数
    输入格式
    第一行包含一个整数N,表示操作的个数,N<=1000000。
    接下来N行,每行包含1个或2个正整数,表示三种操作,格式如下:
    操作1: 1 x
    操作2: 2
    操作3: 3
    输出格式
    包含若干行正整数,每行依次对应一个操作2的结果。
    输入输出样例
    输入
    5
    1 2
    1 5
    2
    3
    2
    输出
    2
    5

    题解
      下面给出代码。
      上浮用push()实现,完成插入新元素的功能,对应优先队列的入队。
      下沉用pop()实现,完成删除堆头的功能,对应优先队列的删除队头。

    #include<bits/stdc++.h>
    using namespace std;
    
    const int maxn = 1e6 + 5;
    int heap[maxn], len=0;             //len记录当前二叉树的长度
    
    void push(int x) {                 //上浮,插入新元素
        heap[++len] = x;
        int i = len;
        while (i > 1 && heap[i] < heap[i/2]){
            swap(heap[i], heap[i/2]);
    		i = i/2;
    	}
    }
    
    void pop() {                         //下沉,删除堆头,调整堆
        heap[1] = heap[len--];           //根结点替换为最后一个结点,然后结点数量减1
        int i = 1;
        while ( 2*i <= len) {            //至少有左儿子
            int son = 2*i;               //左儿子
    		if (son < len && heap[son + 1] < heap[son])   
                                         //son<len表示有右儿子,选儿子中较小的
    			son++;                   //右儿子更小
            if (heap[son] < heap[i]){    //与小的儿子交换
               	swap(heap[son], heap[i]);
    			i = son;                 //下沉到儿子处
    		}
    		else break;                  //如果不比儿子小,就停止下沉
        }
    }
    
    int main() {
        int n;   scanf("%d",&n);
        while(n--){
            int op;	scanf("%d",&op);
            if (op == 1) {
    			int x;  scanf("%d",&x);
                push(x);                  //加入堆
    		}
            else if (op == 2)  
    			printf("%d
    ", heap[1]);  //打印堆头
            else pop();                   //删除堆头
        }
        return 0;
    }
    

    4.4 STL priority_queue

      STL的优先队列priority_queue,实际上是一个堆。
      下面是洛谷P3378的STL代码。

    #include<bits/stdc++.h>
    using namespace std;
    
    priority_queue<int ,vector<int>,greater<int> >q;  //定义堆
    int main(){
        int n;  scanf("%d",&n);
        while(n--) {
            int op;   scanf("%d",&op);
            if(op==1) {
                int x;   scanf("%d",&x);
                q.push(x);
            }
            else if(op==2)
                printf("%d
    ",q.top());
            else  q.pop();
        }
        return 0;
    }
    
  • 相关阅读:
    ubuntu安装jdk的两种方法
    LeetCode 606. Construct String from Binary Tree (建立一个二叉树的string)
    LeetCode 617. Merge Two Binary Tree (合并两个二叉树)
    LeetCode 476. Number Complement (数的补数)
    LeetCode 575. Distribute Candies (发糖果)
    LeetCode 461. Hamming Distance (汉明距离)
    LeetCode 405. Convert a Number to Hexadecimal (把一个数转化为16进制)
    LeetCode 594. Longest Harmonious Subsequence (最长的协调子序列)
    LeetCode 371. Sum of Two Integers (两数之和)
    LeetCode 342. Power of Four (4的次方)
  • 原文地址:https://www.cnblogs.com/luoyj/p/12409990.html
Copyright © 2011-2022 走看看