Oct.22 清北学堂

DAY1

  第一天讲了些什么?搜索、哈希。然后下午还考了场模拟赛,晚上被班主任叫去看电影,然后就没听课?
  主要还是搜索剪枝,hash的作用还是判断BFS时的状态。搜索的两种形式为深度优先搜索(DFS)和广度优先搜索(BFS),深搜的搜索过程类似为一课搜索树(递归实现)例如:树遍历、图遍历……,而广搜使用队列实现,例如:SPFA, Dijkstra。
  搜索的剪枝主要为最优性剪枝和可行性剪枝,优化有一些:记忆化搜索、搜索顺序剪枝、启发式搜索、迭代加深搜索、双向搜索……

1. 最优性剪枝
  在搜索的过程中容易遇到搜到最后才去判断ans是否成立,如果它不成立,就有可能在它之前就不已经是最优的了,就像通过当前的条件求出来的ans,发现已经求出过更好的ans了,那么剩下的就毫无必要,甚至可以剪掉了。
  若当前代价+到T期望最小代价 > 已知最优解,就可以剪掉,其中期望大多设为0。

2. 可行性剪枝
  在搜索的过程中,可能出现当前条件不合法(越界……)的情况,既然条件都不合法了,那ans还能合法吗,所以说剪掉就行,判断当前位置能否到T。

3. 迭代加深搜索
  本质上是深搜和广搜的互补,深搜对于一些答案很浅的问题有时候效率很低,广搜则需要大量的空间,以及难以剪枝,迭代加深搜索则兼具两者的优点设立一个限制\(limt\),假设答案不超过\(limt\),若\(f[i] + epx > limt\),则回溯;失败就加大\(limt\)的值

4. 记忆化搜索
  我们一般写的动态规划是bfs模式的,即一个点从与它相邻的点转移过来。如果用深搜写动态规划,就可以说是记忆化搜索。 基本思路:对于每一种状态,都存储与该状态相关的信息,每当遇到这个状态时,返回其相关的信息。(有小机率,记忆化可能没有暴搜优(smy。

5. 搜索顺序与有序化
  一般先考虑消耗较大的决策,或者考虑状态量少的子问题。如果搜索的状态与数值排列的顺序无关,可以考虑把数值排序(在有HASH判重的情况下,这样一般可以再减少一个阶乘的复杂度)。

6. 双向搜索
  可以知道搜索的起点与目标,那么就可以从两侧同时开始搜索,直到搜索到相同的位置。理论上可以将搜索的指数\(/2\),最常见写双向搜索的,大概就是双向广度优先搜索了吧。

7. 启发式搜索
  没听,去做全员核酸了。

深度搜索消耗时间 ≈ 每个节点操作系数 × 节点个数
1)减少节点个数——这就是剪枝优化;
2)减少每个节点的操作系数——即程序操作量。

Q:判断一个状态是否出现了?
A:康托展开、哈希Hash
  当然康托展开的使用局限性会比较大,只能通过康托展开公式确定其应是字典序中的第几个排列,使用性不大,但是它快
  哈希Hash在本质上是一种映射,一般用来判重,通过HASH值快速比较两个字符串是否相同,采用变为p进制数然后对质数取%的方式,用vector存储较多(用vector存的话,要求模数在106级别,因为开一个107的vector数组用时较多)

第①天的题目(基础算法

DAY2

  第二天上午讲了贪心和分治,下午讲了数据结构,由变量到数组、链表到单调队列到堆到线段树。
  时间来到了今天上午。
  贪心是通过局部的贪心选择来到达全局的最优答案,也就是只顾眼前的事情,而不顾大局的情况,但是有可能有的答案并不正确,所以并不是所有具有最优子结构的问题都能用贪心解。贪心主要的是一种思想,然后就讲了些题。
  常见思路是后(反)悔贪心和对数据排序。
1. 后悔贪心,按照贪心策略处理,拿到某一个时,如果无法再拿,去掉以往的某个最不行的,使问题更优。
2. 排序后思考。问题与顺序无关时,有序比混序更容易求解。

  分治分治,分而治之。分治就是把大的问题划分为小的相似的子问题,来解决。而常见的分治为二分查找、二分答案、三分查找和(归并排序、快速幂…………
1. 二分查找,对于一个有序的序列,可以通过\(\log n\)的时间内查到一个数的值。
2. 二分答案
  对于一个最优性问题,可以通过二分的方法在答案的范围内,枚举答案,判断其正确性。而答案需要满足可二分的性质,即若i可以成立,那么i+1也可以成立。二分答案通常情况下,存在于求解最大值最小或最小值最大的问题中(二分答案的标志
3. 三分查找,它的难度略低于二分的难度,主要用于求解一个单峰函数的最值

第②天的题目(基础算法

  时间来到了今天下午,zhx开始了他的数据结构。
  1. 数组与链表: 数组大家很熟悉,就是一个个变量按照下标的排在后面 eg.a[0],a[1],a[2],a[3]……,而链表是通过一个个指针将一个个变量连到一起 eg.a[0]->a[1]->a[2]->a[3]->……,
对于链表的优点就是它的链可以O1的断开、连接,也就代表它可以O1插入一个数,而数组需要On。
但是缺点也很明显,就是不能随时定位,需要一个个的通过链蹦(On),而数组只需要O1。

修改、查询 插入
数组 O1 On
链表 On O1

这里也很明显地体现到了一点,就是一个学习到的数据结构一定有其优点和缺点,如果有个数据结构啥也能做,你学其他的干嘛嘛,所以说,不会有一个学到数据结构比其他的数据结构更优。

  2. 单调队列:单调队列呢,其实就是在队列里维护了具有单调性的序列,可以去做一下滑动窗口这个题,这个题可以通过On2的线段树来维护,也可以用On的单调队列来做。

  单调队列做法
//单调队列做法
#include <bits/stdc++.h>
using namespace std;

int q1[10000000], q2[10000000], head = 0, tail = - 1;
int n, k, a[10000000];

int main() {
	scanf("%d%d", &n, &k);
	for (int i = 1; i <= n; ++ i) {
		scanf("%d", a + i);
		if (head <= tail && i - k + 1 > q1[head]) head ++ ;
		while (head <= tail && a[i] <= a[q1[tail]]) tail -- ;
		q1[++ tail] = i;
		if (i >= k) printf("%d ", a[q1[head]]);
	}
	puts("");
	head = 0, tail = - 1;
	for (int i = 1; i <= n; ++ i) {
		if (head <= tail && i - k + 1 > q2[head]) head++;
		while (head <= tail && a[i] >= a[q2[tail]]) tail --;
		q2[++ tail] = i;
		if (i >= k) printf("%d ", a[q2[head]]);
	}
	return 0;
}

EG. 滑动窗口(变式)
给出一个N和K,使得一个序列a中的区间[l,r]的最大值max-最小值min ≤ k 的区间长度L最长,求L的长度。
Onlogn:可以使用二分答案的方法枚举L的长度,使得max-min≤k的最长值。
 On   :使用双指针解决,将左端点l固定,让右端点r向后扩展,然后一步步扩大左端点l。

  双指针做法
for (int l = 1, r = 1; l <= n; ++ l) {//O(n) 
   把l-1从单调队列删掉;  	
       while  (r <= n && 当前最大值 - 当前最小值 <= k) {
   	push(r);
   	r ++;
   }
   Ans = max(Ans, r - l);
}

  3. 堆,可以做到:1. 插入一个数;2. 删除、询问最值。并且它有一个特性:父亲节点 ≥ 儿子节点,或小于。

  手写堆部分代码
struct HEAP {
	int n;//堆里面有几个元素
	int z[maxn];//z[i]代表堆里面第i个元素的值
	
	int top() {//询问最大值
		return z[1];
	}
	
	void push(int x) {//插入x
		n ++;
		z[n] = x;
		int p = n;
		while (p != 1) {
			if (z[p] > z[p/2]) {
				swap(z[p], z[p/2]);
				p = p/2;
			} else break;
		}
	}
	
	void pop() {//删除最大值
		swap(z[1], z[n]);
		n --;
		int p = 1;
		while (p*2 <= n) {//p还有左儿子
			int pp = p*2;
			if (pp + 1 <= n && z[pp + 1] > z[pp]) pp = pp + 1;//pp就是左右儿子中较大的那个 
			if (z[p] < z[pp]) {
				swap(z[p], z[pp]);
				p = pp;
			} else break;
		} 
	} 
}; 
  由于STL容器里的优先队列(堆)默认为**大根堆**,即`priority_queue heap;`所以当我们向堆中加入val的相反数-val,就可以快速而又简单的实现**小根堆**。

  给予一个长度为N的序列Ai,请你分别求出所有的区间内的中位数。

  当然这个时候很容易想到对顶堆来解决这个问题,但是对于所有的区间求解,其时间复杂度为On2logn,当数据范围大的时候(N<=5000)就可以使用链表来做。

  5. 线段树,一个序列可以看作线段,通过二分的思想将这个线段分为两个、四个、八个………就像是——
  由于下午的时间可能讲不完线段树了,所以就只讲了查询操作的部分,但是也无妨,毕竟还有明天一上午讲线段树。具体的来说,还算获得了一种新型的线段树写法(也有可能用不到),从当前来看还算比较简单的。(还有个4. ST表没讲)

  新型的线段树写法
#define root 1,n,1
#define lson l,m,rt<<1
#define rson m+1,r,rt<<1|1

struct node//线段树上的节点信息 
{
	int l,r;//这个节点左端点和右端点 所维护的区间 
	int sum;//当前这段区间的和
	int maxv;//当前区间的最大值
	int minv;//当前区间的最小值 
	int sum2;//|al-al+1| + |al+1 - al+2| + ... + |ar-1 - ar| 
	int lv,rv;//lv代表最左边的数 rv代表最右边的数 
	int ans;
	node()
	{
		l=r=sum=maxv=0;
	} 
}z[];

node operator+(const node &l,const node &r)//l左儿子 r右儿子
{
	node ans;
	ans.l = l.l;
	ans.r = r.r;
	ans.sum = l.sum + r.sum;
	ans.maxv = max(l.maxv,r.maxv);
	ans.minv = min(l.minv,r.minv);
	ans.sum2 = l.sum2 + r.sum2 + abs(l.rv - r.lv);
	ans.lv = l.lv;
	ans.rv = r.rv;
	ans.ans = max( l.ans , r.ans , l.maxv - r.minv);
	return ans;
} 

void update(int rt)
{
	z[rt] = z[rt<<1] + z[rt<<1|1];
} 

void build(int l,int r,int rt)//建树 当前线段树节点编号为rt 并且当前区间为l~r 
{
	if (l==r)//到最底层
	{
		z[rt].l = z[rt].r = l;
		z[rt].sum = y[l];//这段区间的和就等于第l个数 
		return;
	}
	int m=(l+r)>>1;//(l+r)/2
	//左儿子所对应的区间 [l,m] 右儿子所对应的区间 [m+1,r]
	build(lson);//build(l,m,rt*2)
	build(rson);//build(m+1,r,rt*2+1)
	update(rt);//更新rt这个节点的值 
} 

node query(int l,int r,int rt,int nowl,int nowr)
//当前线段树节点所对应的区间是l~r
//当前线段树节点编号是rt
//询问的区间是nowl~nowr
{ 
	if (nowl <= l && r <= nowr) return z[rt];
	int m=(l+r)>>1;
	if (nowl <= m)//询问区间和左儿子有交集 
	{
		if (m < nowr) return query(lson,nowl,nowr) + query(rson,nowl,nowr);//和右儿子有交集 
		else return query(lson,nowl,nowr);//只和左儿子有交集 
	}
	else return query(rson,nowl,nowr);//只和右儿子有交集 
}

build(root);
int l,r;
node ans = query(root,l,r);

DAY3

  第三天上午专门讲了线段树的修改操作和哈希,下午讲了动态规划(线性、区间、背包、树形、期望
  已经来到了第三天的上午了,继续线段树。线段树的修改操作,有一个技巧,对于修改区间相关的操作时,那就是“懒惰标记”的使用:具体点来讲就是将这段区间整体加上val,但是这件事情不告诉它的儿子,也就是不加到叶子节点,在它的某个根节点存放著。
做线段树时,要注意一些问题:

1、你的线段树要维护什么东西;
2、记得维护标记;
3、要仔细想想线段树的左、右儿子怎么合并;
4、标记怎么影响你维护的东西。

  稍微介绍一个简单的题目,(难度已经开始上来了……,

  eg. 在区间[l,r]中找出k个数相乘的方案数。

  我们设ans[i]为这段区间中i个数相乘的方案数的和,(这里需要运用到DP的思维),now.ans[i] += l.ans[i] + r.ans[i - j],枚举i和j,得到左、右儿子合并的状态转移方程,就可以做了。

ps. 在做数据结构或其他的题目时,我们可以考虑DP,(另外,不要觉得这是不会出现的。然鹅,今天下午就要讲DP了。

6. 字符串Hash

  将一个字符串通过某些方法,算为一个数,进行快速的进行判断,判重,这个数被叫做hash值。
加法: eg. bcd --> \(2 + 3 + 4\) --> 9,‘9’这个数字就是bcd的hash值。
乘法: eg. bcd --> \(2 \times p^2 + 3 \times p + 4\) --> ??。
但是在例子的情况下,会出现 dcb --> 4 + 3 + 2 --> 9 的情况,这种情况被叫做哈希冲突。
对于乘法的例子bcd的hash值不等于dcb的hash值,但是这样的运算代价是很大的。

哈希冲突 <=========> 运算代价

哈希方法 模数 运算代价 冲突概率
  1. 单模哈希法     ≈1e9的质数     快☆☆     大☆  
  2. 双模哈希法     两个≈1e9的质数     慢☆     最小☆☆☆  
  3. 自然溢出法     264(longlong)     最快☆☆☆     小☆☆  

  可以看出,自然溢出的效果是最好的,双模哈希法次之,你可以尝试去卡一卡这几种方法,HASH KILLER

 那么,自然溢出哈希的代码呢
#define ull unsigned long long
//0~2^64-1;

const ull k = 10087;

ull bit[maxn];
//bit[i] = k ^ i
bit[0] = 1;
for (int i=1;i<=n;i++)
	bit[i] = bit[i-1] * k;
	
scanf("%s",s+1);
int n=strlen(s+1);
ull h[maxn];//h[i] 字符串s前i个字符所对应的hash值 前缀hash值
for (int i=1;i<=n;i++)
	h[i] = h[i-1] * k + s[i];
	
ull get_hash(int l,int r)//求出s[l~r]这段区间所对应的hash值
{
	return h[r] - h[l-1] * bit[r-l+1]; 
} 

  1. l1~r1 和 l2~r2 长得一不一样,get_hash(l1,r1) == get_hash(l2,r2);

  2. l1~r1 和 l2~r2 字典序谁大谁小
先二分两个字符串一样的长度有多少,通过hash去判断这个长度下是否一样,
最后看第一位不一样的地方谁大谁小;

  3. 求字符串s的最长回文子串
枚举回文的中心位置,二分回文串的长度,通过hash判断向前走和向后走这两段是否一样。(manacher)

第②和③天的题目(数据结构

CDQZ
练习题
GSS系列
HASH KILLER Ⅰ
HASH KILLER Ⅱ
HASH KILLER Ⅲ


DAY4

  第四天是DP专项的一天,讲了概率与期望DP、记忆化搜索、状压DP、数位DP,还有去年的NOIpT2——数列。


DAY5

  第五天讲了图论,从最短路、差分约束、生成树、二分图,扩展了没讲的倍增、倍增求LCA和并查集。

posted @ 2022-10-04 21:43  Ciaxin  阅读(45)  评论(0编辑  收藏  举报