2

国庆七天乐——第四天

【【dp】】

【树形dp】

大致分为两种(1)从上往下搜,(2)从下往上搜

  1. 最长链

法一:贪心

把无根树中的随便一个点单做根节点,然后从根节点出发做遍历,找到当前的最长链的终点,再从这个点开始再做一次bfs/dfs

   法二:树形dp

   枚举每棵数中经过根节点的最长链。

   d[i] 以i为根节点的子树向下的最长链

   ans[i] 跨过根节点i的最长链

   ans[i]=2+max:d[si]+maxc : d[sj];  最短的两条链(从该节点的儿子为起点向下的最长链)

   ans[i]=1+d[si], 只有一个子节点

   d[x]=1+max:d[sk];

   s为所有的子节点

  1. 最长链+(边长为正)

ans[i]=e[i][si]+max:d[si]

e[i][sj]+maxc : d[sj];

  最短的两条链(从该节点的儿子为起点向下的最长链)

   ans[i]=e[i][si]+d[si], 只有一个子节点

   d[x]=e[x][sk]+max:d[sk];

  1. 最长链++(边长有负数)

注意,这个时候两遍的bfs是不能做的

反例如下:                        

Ans=4,但是因为有负数,所以得到了3,这显然是是不对的

但dp的方程还是可以用的,但是要加一些对负数的特判。

       If(max2==-inf)  ans[x]=0;

   Else if(max2==-inf)  ans[x]=maxl;

      Else ans[x]=max1+max2;

  Max1=-inf, max2=-inf;

  1. 最大点权独立值

f[i][0], 表示以i为根的子树,未选i,的最大权值和

f[i][1], 表示以i为根的子树,选i,的最大权值和

  对于一个根节点来说

选:其子节点不能选        sigma f[j][0]+ v[i]

不选:其子节点都能选 sigma max{f[j][0],f[j][1]}

这个是无负权影响的。

  1. 最大点权独立值+(环套数)

环套树:n个点,n条边的图

法一:选出一个点,其它两个点不能选,将环套树断成森林,再分别做最大点权独立值

法二:将环上的每个点所延伸出来的树都做一遍最大点权独立值,将结果存在环的节点上,破环成链,从环上找一个点,做最大点权独立值

判环:dfs搜一下,搜到自己了就说明有环(记录路径)

  1. 树上背包

F[i][j]=0   j<w[i]  至少空间》=w[i];

F[i][j]=c[i]+f[k1][t]+f[k1][j-t-w[i]]  0<=t<j-w[j-w[i]];

F[i][j]=c[i]   j=w[i];

F[i][j] =max(f[i][j])   t<=j;

7.树上背包+(多叉转二叉)

(左儿子右兄弟的转法也是成立的,但需要改上面的方程式,所以在此不多深究)

生成堆式转法

加s-2个节点(s为每个节点的儿子的个数)

 

毛毛虫转法(s-2)

 

 

【状压dp】

状态压缩动态规划,一般用于处理总状态较多,但总状态可

由一些小状态扩展得到。

这类问题一般是NP 问题,因而设计出了状态也有可能需要

注意空间问题。

  1. 简单题

F[i][mask]=f[i-1][mask]+f[i-1][mask’]

Mask=把每一行的1选的什么用一个二进制数来表示出来

         表示取或不取的情况,但有一些是不能去的,所以我们不仅要枚举每一行是否要取,还要筛掉不能选的情况,

所以用一个c[i]表示所有不可能的情况。

a)       简单题++

压一行状态,只考虑上一行的状态。每一行只会影响下一行的状态。

F[i][mask]=sigma f[i-1][mask’]

从最后一行开始向上推

Ans=p[i][mask] z最后一行

边界: f[0][000000…….00]=1

状压dp的特点:

  1. 范围小(n,m<10),2.限制状态的条件少

【位运算求mask】

Valid[mask]=1/0;

a-2^-1

mask’&~mask==mask’

    &&valid[mask’]

状态是成立的不受影响而且能够走得这一步

While(c>0)
{

C&(~mask)->mask’

C=(c-1)&~mask
}

奇数位与偶数位&为0,说明是合法的。

【数据结构】

1链表

概述:在不连续的物理空间上,利用元素间的相互链接实现一个元素序列的存储。

在链表的结构中,结点作为基本构成由两个部分组成:数据域和指针域,其中数据域负责承载元素,指针域则指示序列中这个元素的直接后继的位置。

概述:在不连续的物理空间上,利用元素间的相互链接实现一个元素序列的存储。

在链表的结构中,结点作为基本构成由两个部分组成:数据域和指针域,其中数据域负责承载元素,指针域则指示序列中这个元素的直接后继的位置。

*******************各种操作*****************************

单链表的部分操作耗时(仅有队头和后继的信息)

取得链头元素O(1)

从链头或已知元素后面插入元素O(1)

删除链头或已知元素直接后继的元素O(1)

从第k个元素后面插入元素O(k)

查询/删除第k个元素O(k)

查找/删除有特定值的元素O(n)

将一个数组转化为链表O(n)

******************部分操作实现*************************

部分操作的实现

链头L插入结点x:x->next=L; L=x;

结点p后面插入结点x:x->next=p->next; p->next=x;

删除L的第一个节点:tmp=L; L=L->next;

删除结点p的直接后继:tmp=p->next; p->next=tmp->next;

*******************链表同指针的区别**********************

1动态结构,存储空间可以为多个链表共用,但是指针会占用额外的空间。

2与数组不同,元素并不强调自身位序,因此查找较为困难,相对而言,插入和删除因为存储空间不要求连续而变得更为方便。

单向循环链表

特点:从任意结点出发都能够找到所有结点。

链尾结点的后继指针指向链头结点。

双向循环链表

特点:能够查找前驱的特性,使得单向循环链表里面的一些操作在双向循环链表里面能够更方便地进行。

  1. 2.    

定义:限定仅在其中一端进行插入和删除操作的线性表。

其中,进行插入和删除操作的一端称为栈顶,最靠近这端的元素称为栈顶元素,而另一端称为栈底。

*****************************主要操作********************

主要操作:入栈和出栈。

入栈,将栈顶前移,并在栈顶处加入新的元素。

出栈,将栈顶处的元素删除并将栈顶后移。

例子:

操作                          栈的状态  出栈顺序

初始                          A          

元素B/C/D/E依次入栈          ABCDE

元素E/D/C依次出栈              AB         EDC

可以看到,栈具有后进先出(LIFO)的特点。

**********************总结特点****************************

1栈是线性表,插入和删除操作仅限在栈顶进行,相对的只需要固定的时间就能进行一次操作。

2栈有有限个元素,而且所有元素都有相同的类型

********************实现方式*****************************

栈的实现方式:

顺序表

链表

*************************应用*****************************

括号匹配检验

表达式计算

递归函数

Etc

  1. 队列
  2. 定义:限定仅在其中一端插入元素,另一端删除元素的线性表。
  3. 其中,进行插入操作的一端称为队尾,进行删除操作的一端称为队头。
  4. 第一个删除的元素(位于队头)称为队头元素。
  5. 刚插入的元素(位于队尾)称为队尾元素。
  6. 由于其具有的先进先出的特性(就是先进入队列的元素一定比后进入队列的元素早被删除),所以出队序列为入队序列的前缀(所有元素出队时,两序列相等)

********************实现方式****************************

实现方式同样有顺序表实现和链表实现顺序表实现:

不同于栈只在一端有元素进出,队列在两端都有元素进出,因此如果用数组,队列A需要两个数h和t来标记队头元素Ah和队尾元素At-1,这样的好处是当我们需要计算队列长度的时候,h-t就是队列长度。

类似的队列的链表实现可以在栈的链表实现的基础上,将插入改为从栈底插入。因此队列就不在这里给出程序实现了。

顺序表实现的问题在于,如果进出的元素个数远大于队列长度的峰值,那么这种维护方式会造成空间的浪费。

改进方法:循环队列

如果事先知道队列长度的最大值,而且我们不关心已经出队的元素的话,我们可以将储存队列的数组首尾相连(An-1的后继设为A0),这样当数组的空间用完的时候,我们可以把用完的数组空间循环利用,这时候t<h表示队列从头到尾为AhAh+1…An-1A0A1…At-2At-1,特别的,t=h-1的就表示队列达到了最大长度。

循环队列的基本操作的程序实现(队列长度为M):

void push(int x) { if ((t-h+1)%M!=0) { A[t]=x; t=(t+1)%M; } }

void pop(int &x) { if (t!=h) { x=A[h]; h=(h+1)%M; } }

4.单调队列在上述的题目中,我们维护了一个具有维护自身最小值功能的队列,这个队列内部的元素满足单调性,我们把这个队列叫做单调队列。

单调队列能够处理很多队列维护最小值或者类似的问题

滑动窗口:

求一个数列里连续k个数的最大最小值

队列是单调的,而且是双端队列

在访问每个数时,如果前面的数比它大,删掉,直到前面没有数比它大时就停下。

如果当前队列的长度(被删掉的也算在其内)>窗口的长度,就删掉队头的元素,来维护它单调递增的性质

每一段段窗口的最大值就是队头元素

*****************应用***********************************

       1.最大子序列和

首先我们可以把子序列和,看成是部分和的差。

对于以下标x结尾的子序列,其子序列(y,x]的子序列和为sum[x]-sum[y]

对于每一个x,在序列sum[x-k..x-1]中找一个最小的元素,就能得到最大的子序列和。

而求最小sum[y]的问题,就转化为前面的滑动窗口问题了。

2.广告印刷

这个问题同样需要线性时间的算法。

有2种考虑方向:固定序列位置/长度参数优化最小元素,固定最小元素优化序列长度。

如果考虑前一种方向的话,首先固定序列长度不是一个好的选择,因为相邻长度之间的信息较难共享。所以我们固定序列的右端,在序列长度和序列最小元素之间权衡找到最好的左端。

而后一种方向相对简单,就是对于每一个元素,找到以它为最小元素的最长子序列,这种方法只要找到左边第一个更小的元素,无需权衡众多影响因素。

解法1(固定最小元素)

这种方法只要对于每一个元素,找到左边第一个更小的元素和右边第一个更小的元素,就能知道以该元素为最小元素的子序列最多能有多长。

我们可以使用单调队列,注意到如果队头的出队操作,所有的元素都会等到一个比自己更小的元素入队才被删除。这样用单调队列扫描一遍,在删除一个元素时记下比自己更小的元素的信息。然后换个方向再扫一遍。

时间复杂度O(N)

解法2(固定序列右端)

这种方法麻烦得多。

因为随着左端的远离,序列长度会增加,但是最小元素可能会下降。把下降之前的点都记录下来,在队列中用数对(a,b)表示当左端点下标为a时,最小元素为b,这时候右端点x能够得到的乘积是bx-ab,可以看到数对(a,b)能用单调队列组织起来。

我们知道,对于右端点x,数对(a,b)优于数对(c,d) (a<c,b<d)当且仅当      x<(cd-ab)/(d-b)。也就是说x一旦超过了这个值,(a,b)就不再有用了。

5.

树的递归定义:有n(n≥0)个结点的有限集,其中有且仅有一个特定的结点,称为根(root),其它结点(当n>1时)分成m(m>0)个互不相交的有限集T1,T2,…Tm,其中每个集合本身也是一棵树,这些树称为根的子树,相对的这棵树的跟为这些子树的根的父结点。

*********************术语**********************************

(结点的)度数:这个结点的子树个数。

叶子结点:度数为0的结点。

父节点:如果b是a的一棵子树的根,那么a是b的父节点。

(结点的)高度:这个结点通过父节点追溯到整棵树的根所需要回溯的父节点的个数。

  1. 二叉树
  2. 如果一棵树的所有结点度数都不大于2,并且我们赋予每一个子树“左子树”或“右子树”的概念,这棵树称为二叉树。
  3. 一棵二叉树,它如果不是空树,那么就有一个根结点,而且除了这个根结点以外,它还有一个左子树和一个右子树,这两个也是互不相交的二叉树。
  4. 之所以严格强调左右,是因为在下面的应用中,二叉树的左右是不能任意颠倒的。

***************************概念*************************

(树的)高度:所有结点的高度的最大值,特别的,空树高度为-1

左子结点(右子结点同理):如果一个结点存在非空左子树,那么左子树的根称为该结点的左子结点。

层:一棵树的第i层结点指的是这棵树所有高度为i的结点的总和,特别地,第0层为根结点。

满二叉树:一棵高度为i,而且从第0层到第i-1层每个结点都有2个子结点的二叉树,称为高度为i的满二叉树。

完全二叉树:一棵二叉树,其左子树高度与右子树相等或比右子树大1,而且两个子树分别是满二叉树和完全二叉树。

*******************性质*******************************

二叉树的一些性质:

一棵二叉树最多有2i个第i层结点。

一棵高度为i的二叉树最多有2i+1-1个结点,当且仅当其为满二叉树时达到最大值。

一棵高度为i的完全二叉树从第0层到第i-1层都是满的,而且第i层所有结点占据了第i层最左边的位置,也就是说存在一种满足以下条件的方式将这棵树所有结点编号为1~n(n为结点个数):

根结点编号为1,对任意正整数k,编号2k的结点都是编号k的结点的左子结点,编号2k+1的结点都是编号k的结点的右子结点。

*******************存储形式****************************

利用数组顺序存储

假设一棵树的元素存储在数组A里,则A[1]存根结点的元素,A[2i]存A[i]的左子结点的,A[2i+1]存A[i]的右子结点的。

这样做的优点是,从下标就能立刻得知这个结点在树中的位置,然而缺点也很明显,如右图所示,对于一个退化的树,如果使用这种方式,n个结点就必须用长度为2n的数组存储,造成极大浪费。

因此这种存储形式仅适合用于存储如完全二叉树之类的高度极其平衡的二叉树。

链式存储

就如前面链表用不连续的物理空间来存储序列一样,在这里我们可以把树的每一个结点拆开存储,并利用它们的相互连接来维护二叉树的结构。

二叉树的存储方法有三类,根据不同的需求使用。

一种是二叉链表,也就是每个结点记录的是数据和它的子结点的位置。

描述:struct TNode{ int data; TNode *Lchild,*Rchild; };

一种是在二叉链表的基础上加上关于父节点位置的记录。

描述:struct TNode{ int data; TNode *Lchild,*Rchild,*parent; };

还有一种是每个结点只记录父结点的位置,以及自己是左子还是右子。

描述:struct TNode{ int data,LRtag; TNode *parent; };

Struct Tree{TNode node[MAX_TREE_SIZE]; int nodes,root; };

******************遍历**********************************

Bfs,dfs,前序,中序,后序

  1. 11.  二叉查找树
  2. 概念:
  3. 二叉查找树是二叉树在查找方面的重要应用,对于一个非空的二叉查找树,其满足以下性质:
  4. 1根结点的关键字大于左子树的任何结点
  5. 2根结点的关键字小于右子树的任何结点
  6. 3左子树和右子树也是二叉查找树

*****************操作**********************************

1查找特定关键字是否存在于这棵树上

2插入一个结点的情况下维持二叉查找树的性质

3删除一个结点的情况下维持二叉查找树的性质

  删除分为三种情况:

  为叶子节点à直接删去

  有左子树或右子树(只有一个)à

  直接把它在左子树或是右子树链到它的父亲节点上

左右子树都有

找到左子树的最大值,右子树的最小值,将它和需要删掉的点交换,可以保证查找树依然成立

证明:

左子树:其他值都小于左子树最大值

右子树:其它值都大于右子树最小值

符合定义

12.二叉堆

在前面的问题当中,如果我们仅仅关心一组数据中的最小值(或仅关心最大值)的维护,那么我们可以使用二叉堆。

在不特别说明的情况下,下面描述的都是最小堆(维护最小值)。

二叉堆作为一个能够更高效地查询一堆元素的最小值的数据结构,它有如下性质:

二叉堆是一个完全二叉树

它的根结点有着最小的关键字

除了根结点以外的每个结点,关键字都不小于它的父结点。

并且,二叉堆堆能够实现元素的插入/删除并维持二叉堆的性质。

**********************操作*****************************

二叉堆的查询操作

询问最小值(根结点的关键字就是最小值)

询问k小值(依次删除前k-1小元素之后,询问最小值)

二叉堆的维护操作

插入元素

删除堆顶元素

其他操作

将数组转化为二叉堆(就是将元素一个个插入空的堆里面)

将一个数组中的元素做成二叉堆的过程由于二叉堆为完全二叉树,因此对于规模为N的二叉堆,其修改单个元素的时间复杂度为O(logN)。

********************应用*************************

1.数组排序

我们知道,二叉堆可以很方便地取出最小元素,如果我们不断取出其中的最小元素,次小元素……直到取完为止,那么我们就完成了一次排序,称为堆排序。

void Heap_sort(int *a, int n) { //对元素a[1..n]从小到大排序

       将数组a转化为最大堆(也就是维护其最大元素的堆)

       while (n>0) {

              swap(a[1],a[n]); n--;

              for (int i=2;i<=n;i<<=1) {

                     if (i<n&&a[i]<a[i+1]) i++;

                     if (a[i]<a[i>>1]) break;

                     swap(a[i],a[i>>1]);

              }

       }

}

2.有序表多路归并

二路归并的过程,是不断比较两个有序表的最小值,并取出更小者加入新的有序表的过程。这个过程可以推广到k路归并,我们将这k个有序表的最小值合成一个堆,每次取出其中的最小值,并加入同序列的下一个元素。

堆的高度为logk,设元素总个数为n,则该算法具有O(nlogk)的时间复杂度,特别地,如果每个有序表只有一个元素,就是前面的堆排序。

3.最短路径(堆优化)

13.并查集

并查集是一类数据结构的统称,这一类数据结构能够实现询问元素之间是否有直接或间接的联系,以及在两个元素之间建立联系的操作。

 

换言之,并查集维护若干个不相交的的集合并支持以下三个操作:

加入一个集合,该集合里面只有一个元素

将两元素各自所在的集合合并为一个集合

判断一个元素在哪个集合(通常返回代表这个集合的元素)

并查集是一类数据结构的统称,这一类数据结构能够实现询问元素之间是否有直接或间接的联系,以及在两个元素之间建立联系的操作。

 

换言之,并查集维护若干个不相交的的集合并支持以下三个操作:

加入一个集合,该集合里面只有一个元素

将两元素各自所在的集合合并为一个集合

判断一个元素在哪个集合(通常返回代表这个集合的元素)

并查集的实现

数组实现

链表实现

树实现

数组实现

直接用数组实时记录每个元素属于哪个集合,遇到集合合并的时候将属于其中一个集合的元素全部修改为属于另一个集合

这样做的问题是,在合并集合的时候,需要枚举所有元素,效率太低

链表实现

将属于同一个集合的元素拉到同一个链表,同样实时记录每个元素所属集合的表头。在合并集合的时候,把其中一个集合的表头接到另一个集合的表尾,遇到询问就直接比较表头

这样做在合并时,复杂度和接尾部的集合的大小成正比,如果简单地随便选取一个集合接尾部,最坏情况下时间复杂度会达到O(n2)。

如果我们每次选取较小的集合接尾部,也就是启发式合并,那么可以证明在最坏情况下,时间复杂度为O(nlogn)。

链表实现可以看作数组实现的优化版,因为它们都实时地记录元素所属。

树实现

如果我们放弃绝对实时的查询,那么我们可以采用树实现的方式,我们将同一个集合的元素组织成有根树,并以树根代表整个集合,而其余结点都用一个父指针表示结点的归属。

例如右图,结点1,2分别代表各自的集合,结点6,4,9附属于结点1所在的集合,而结点3,5,7,8附属于结点2所在的集合,其中结点8的父指针虽然指向结点3,但这仅仅表示结点8属于结点3所在的元素的集合,说到底,这个集合的代表还是结点2。父指针最终指向的是集合的代表元素。如果我们放弃绝对实时的查询,那么我们可以采用树实现的方式,我们将同一个集合的元素组织成有根树,并以树根代表整个集合,而其余结点都用一个父指针表示结点的归属。

在这种情况下,查询所属的集合,只需要追溯到根结点,而合并两个集合则是两个集合的代表元素(通过查询获得)用父指针连接。

如果考虑运行时间的话,查询的时间复杂度是和树的高度成正比的,也就是说,如果没有任何优化,那么总的时间复杂度最坏情况下会达到O(qn),其中q为操作个数。

那么,如何改进运行时间?

**********************优化********************************

树实现的优化

启发式合并

想要减少运行时间,最简单的思路就是限制树的高度,使得高度的最大值限定在合理的区域内,如果从合并入手,每次将高度较低的树的父指针指向高度较高的树(或者将较小的树的父指针指向较大的树,原理一样),那么我们就能限制高度的增长,使得高度为h的树,其大小至少为2h,从而将询问的时间复杂度降至O(qlogn)。

路径压缩

这是另外一种思路,就是限制树的某条路径被重复访问的可能性。在第57页这张图上,如果我们要找8号点所属的集合,我们先经过3号点,再经过5号点,最后到达2号点,这时我们事实上同时知道了,直到下次合并之前,8,3,5号三个点所属的集合,为了下次询问的方便,我们可以将这3个点的父指针都直接指向2号点,减少它们的询问时间,

 

这两种方法能够一起使用,并且,根据《算法导论》的相关描述,可以证明,单次操作的均摊时间接近常数级别。

**************************代码实现************************

并查集结构描述

struct  UnionF { int parent[max_size],rank[max_size]; }

并查集初始化

void UnionF::init(int siz) { for (i=1;i<=siz;i++) { parent[i]=I; rank[i]=0; } }

查询所属集合

int UnionF::find(int x)  //路径压缩

       { if (parent[x]!=x) parent[x]=find(parent[x]); return parent[x]; }

合并两个集合

int UnionF::union(int x,int y) { //x和y是各自集合的代表元素

       if (rank[x]<rank[y]) swap(x,y); //启发式合并

       if (rank[x]==rank[y]) rank[x]++; parent[y]=x; }

***********************应用****************************

1.          食物链

a)  这一题乍看并不像是并查集所解决的判断两点是否连通的问题,其实不然。首先如果把每一句真话看作一条边,那么如果两个点是连通的,那么这两个点所代表的动物之间的关系就早已明确了。

b)  我们用0/1/2表示动物A/B/C,然后用(X-Y)=1(mod 3) 表示Y吃X。

c)   也就是说我们将捕食关系变成了同余关系,这样我们只要知道两个点的值的差,就知道这两个点的关系,更进一步,在并查集里面,在存储父指针的时候也可以存储自身和父结点的差值,这样只要在同一个集合里面,两个结点之间的关系是可以简单地计算出来的。

  1. 关押罪犯

a)    参考例题1,这一题也可以用并查集维护,在同一个集合就意味着两个犯人之间是否在同一监狱就确定了。

b)    为了减少最大的影响力,我们应当尽可能分开仇恨值最高的罪犯,先按照仇恨值由高到低排序,问题就转化为最多能够分开前几组罪犯(最多能够满足前几组关系),可以用类似例题1的方法解决。

c)     另外一个做法是,将一个罪犯v拆成2个点:v和自己一个监狱,v’和自己不一个监狱。每次如果两个罪犯u和v要分开,就把u和v’合并,把v和u’合并,如果在某次合并后,u和u’在一个集合,就表示这两个罪犯没法分开,必定产生冲突。

  1. 最小生成树

a)    最小权值生成树

b)    在kruskal算法中利用并查集以变得更为高效

c)     将会在讲图论的时候详细介绍

**********************练习题**************************

并查集

nyoj925(国王的烦恼,http://acm.nyist.net/JudgeOnline/problem.php?pid=925)

tyvj1680(银河英雄传说,noi2002)

单调队列

poj2823(滑动窗口)

bzoj1293(生日礼物,scoi2009)

posted @ 2017-10-05 09:23  DDYYZZ  阅读(604)  评论(0编辑  收藏  举报