算法学习总结

算法总结

文章目录

  • 算法总结
    • 搜索遍历
      • dfs
        • 树的深度
        • 树的重心
        • 图的连通块划分
      • bfs
        • 双端队列bfs
        • bfs图问题
      • 迭代加深
      • 双向搜索
      • A*
      • IDA*
      • Morris遍历
      • Manacher
    • 数论
      • 质数
        • 判断质数
        • 分解质因数
        • 埃氏筛法
        • 线性筛法
      • 约数
        • 求N的正约数集合——试除法
        • 求1~N每个数的正约数集合——倍除法
      • 欧拉函数
      • 快速幂
      • 快速幂求逆元
      • 扩展欧几里得算法
        • 斐蜀定理
        • 扩展欧几里得算法
      • 线性同余方程
      • 中国剩余定理
      • 卡特兰数
    • 低阶数据结构
      • 链表
      • 邻接表
      • AVL树
      • 单调栈
      • 单调队列
      • KMP
      • 字符串hash
    • 高阶数据结构
      • 并查集
        • "边带权"并查集
        • "扩展域"并查集
      • 树状数组
        • 树状数组和逆序对
      • 线段树
        • 延迟标记
      • 分块
      • 资源限制类
    • 动态规划
      • 背包问题
        • 01背包
        • 完全背包
        • 分组背包
      • 区间DP
    • 图论
      • Dijkstra
      • spfa
      • 拓扑排序
      • Floyd
      • 无向图最小环
      • 有向图最小环
      • 中序后序构造二叉树
      • 字典树/前缀树
    • 小算法
      • 二维数组前缀和
      • 坐标旋转
      • 最小表示法
      • 虚拟索引/数组索引映射
    • 杂项
      • Part1
      • Part2
      • Part3
      • Part4

搜索遍历

dfs

// 邻接表head为表头,ver存边的终点,edge存权值
void dfs(int x){
	// a[++m]=x;		// dfs序
    v[x]=1;		// visit标记访问过
    for(int i=1;i;i=next[i]){
        int y=ver[i];
        if(v[y]) continue;
        dfs(y);
    }
    // a[++m]=x;		// dfs 序
}
// 在递归往下前和即将回溯前记录该点的编号,产生的2N序列即为dfs序
// 那么dfs序中x点出现的位置[L[x],R[x]]就是以x为根的子树的dfs序,就可以把子树统计转化为序列上的区间统计
树的深度
// 自顶向下统计, x节点的子节点y的深度即为d[y]=d[x]+1;
void dfs(int x){
    v[x]=1;
    for(int i=head[x];i;i=next[i]){
        int y=ver[i];
        if(v[y])continue;
        d[y]=d[x]+1;
        dfs(y);
	}
}
树的重心
// 自底向上统计各个子树的的节点个数,并找出重心
void dfs(int x){
    v[x]=1;size[x]=1;		// 子树x的大小
    int max_part=0;			// 删除x后分成的最大子树的大小
    for(int i=head[x];i;i=next[i]){
        int y=ver[i];
        if(v[y]) continue;		// 点y访问过
        dfs(y);
        size[x]+=size[y];		// 从子节点向父节点递推
        max_part=max(max_part,size[y]);	// 计算每个子树的大小
    }
    max_part=max(max_part,n-size[x]);	// 再计算整棵树出去x子树后的节点个数,n为整棵树的节点数目
    if(max_part<ans){
        ans=max_part;
        pos=x;
    }
}
图的连通块划分
// 利用dfs可以访问x能够到达的所有点和边,所以可以通过多次dfs,划分出一张无向图中的各个连通块
// cnt就是无向图包含的连通块的个数,v数组标记了每个点数属于哪一个连通块
void dfs(int x){
    v[x]=cnt;
    for(int i=head[x],i;i=next[i]){
        int y=ver[i];
        if(v[y]) continue;
        dfs(y);
    }
}
// main中
for(int i=1;i<=n;++i){
    if(!v[i]){
		++cnt;
        dfs(i);
    }
}

bfs

// bfs中顺便求了一个d数组,d[x]计算点x在树中的深度
// bfs有两个重要性质:1.一层一层访问。2.任意时刻,队列中最多有两个层次的节点,i+1层的节点排在i层节点之前,即bfs队列中的元素关于层次满足“两段性”和“单调性”。
// 两段性:队列中存的所有点,他们到起点的差值最多是1。指的是最多差是1,也可以是0
// 单调性:就是我们这个队列中存在的元素到起点的距离,一定是单调递增的,也就是他一定是前边是x后面是x+1,是排好序的
void bfs(){
	memset(d,0,sizeof d);
    queue<int> q;
    q.push(1);d[1]=1;
    while(q.size()>0){
        int x=q.front();q.pop();
        for(int i=head[x];i;i=next[i]){
            int y=ver[i];
            if(d[y]) continue;
            d[y]=d[x]+1;
            q.push(y);
        }
    }
}
双端队列bfs

解决一张边权要么是0,要么是1的无向图的问题,这样的图,可以使用双端队列广搜来计算。算法的整体框架与一般的广搜类型,只是在每个节点上沿分支扩展时稍作计算。

不同点有:1)如果边权是0,则把沿该分支到达新节点从对头入队,如果这些分支边权为1,就像一般广搜一样,队尾入队 2)每个节点虽然可能被更新(入队)多次,但它第一次扩展(出队)时,就能得到“最短距离”,之后再被取出可以直接忽略。

bfs图问题

对bfs的形式,按照对应在图上的边权情况进行分类总结

  1. 问题只计最少步数,等价于在边权都为1的图上求最短路
    使用普通的BFS,时间复杂度O(N),每个状态只访问(入队)一次,第一次入队时即为该状态的最少步数
  2. 问题每次扩展的代价可能是0或1,等价于在边权只有0和1的图上求最短路
    使用双端队列BFS,时间复杂度O(N),每个状态被更新(入队)多次,只扩展一次,第一次出队时就为该状态的最小代价。
  3. 问题每次扩展的代价时任意数值,等价于一般的最短路问题
    1)使用优先队列BFS,时间复杂度O(NlogN),每个状态被更新(入队)多次,只扩展一次,第一次出队时即为该状态的最小代价。
    2)使用迭代思想+普通的BFS,时间复杂度O(N^2),每个状态被更新(入队),扩展(出队)多次,最终完成搜索后,记录数值中共保存了最小代价。

迭代加深

搜索书的每个节点分支数量非常多,且问题的答案在某个较浅的节点上。所以我们可以从小到大限制搜索深度,如果在当前深度限制下搜不到答案,就把深度限制增加,重新进行一次搜索,这就是迭代加深思想。

双向搜索

问题具有“初态”,还具有明确的“终态”,并且从初态开始搜索与从终态开始逆向搜索产生的搜索树都能覆盖整个状态空间。这种情况下,就可以使用双向搜索,从初态和终态出发各搜索一半状态,产生两个深度减半的搜索树,在中间交会,组合成最终的答案。双向dfs,两边轮流进行,每次各扩展一整层。双向dfs均可。

A*

问题:对于优先队列BFS,如果给定一个“目标状态”,需要求出从初态到目标状态的最小代价,那么优先列 BFS的这个“优先策略”显然是不完善的。一个状态的当前代价最小,只能说明从起始状态到该状态的代价很小,而在未来的搜索中,从该状态到目标状态可能会花费很大的代价。另外一些状态虽然当前代价略大,但是未来到目标状态的代价可能会很小,于是从起始状态到目标状态的总代价反而更优。优先队列BFS 会优先选择前者的分支导致求出最优解的搜索量增大。

措施:为了提高搜索效率,我们很自然地想到,可以对未来可能产生的代价进行预估。详细地讲,我们设计一个“估价函数”,以任意“状态”为输入,计算出从该状态到目标状态所需代价的估计值。在搜索中,仍然维护一个堆,不断从堆中取出“当前代价+未来估价”最小的状态进行扩展。

估计函数的基本准则如下:

设当前状态 state到目标状态所需代价的估计值为f(state)。

设在未来的搜索中,实际求出的从当前状态 state到目标状态的最小代价为g(state)。

对于任意的state,应该有f(state)≤ g(state)。

利用估计函数优化搜索的顺序,进而更准确的找到最优路径。

关于估计函数f, f(x)<=g(x) ,g(x)是点x到目标点的实际距离,即要求估计值不得大于实际值,估计比实际更优。

IDA*

把估计函数与迭代加深的DFS算法结合,形成IDA*算法

以迭代加深DFS的框架为基础,把原来简单的深度的限制加强为:若当前深度+未来估计步数 > 深度限制,则立即从当前分支回溯。

Morris遍历

// Morris 遍历
// Morris 遍历是利用每个子树最右边的节点的空余右指针来实现O(1)空间的遍历
// 除左子树为空的节点只会遍历一次,其他节点都会遍历两次,我们可以定义第一次遍历到就打印,即为先序遍历;第二次遍历到就打印,即为中序遍历
// 可使用Morris 遍历可以在线性时间内,只占用常数空间来实现二叉树的遍历。
class MorrisTraversal {
public:
	class Node {
	public:
		int value;
		Node* left;
		Node* right;
		Node(int data) :value(data), left(nullptr), right(nullptr) {}
	};


	// 当前节点cur,一开始cur来到整颗树头
	// 1) cur无左树,cur=cur.right
	// 2) cur有左树,找到左树最右节点,mostRight
	//      1.mostRight的右指针指向null, 则 mostRight.right=cur,cur=cur.left
	//      2.mostRight的右指针指向cur,  则 mostRight.right=nullptr,cur=cur.right
	void morris(Node* head) {
		if (head == nullptr) {
			return;
		}
		Node* cur = head;
		Node* mostRight = nullptr;
		while (cur) {
			// cur有没有左树
			mostRight = cur->left;
			if (mostRight) {	// 有左树的情况下
				// 找到cur左树上,真实的最右
				while (mostRight->right && mostRight->right != cur) {
					mostRight = mostRight->right;
				}
				// 从while中出来,mostRight一定是cur左树上的最后节点
				if (!mostRight->right) {
					mostRight->right = cur;
					cur = cur->left;
					continue;
				} else {	//mostRight->right==cur
					mostRight->right = nullptr;
				}
			}
			// 中序遍历
			//cout << cur.value << " "; 
			cur = cur->right;

		}
	}
	// 先序遍历是第一次遇到就打印(对于有左子树的),中序遍历是第二次遇到才打印,对于没有左子树的直接打印即可

	// 中序遍历
	void morrisIn(Node* head) {
		if (head == nullptr) {
			return;
		}
		Node* cur = head;
		Node* mostRight = nullptr;
		while (cur) {
			// cur有没有左树
			mostRight = cur->left;
			if (mostRight) {	// 有左树的情况下
				// 找到cur左树上,真实的最右
				while (mostRight->right && mostRight->right != cur) {
					mostRight = mostRight->right;
				}
				// 从while中出来,mostRight一定是cur左树上的最后节点
				if (!mostRight->right) {
					mostRight->right = cur;
					cur = cur->left;
					continue;
				} else {	//mostRight->right==cur
					mostRight->right = nullptr;
				}
			}
			// 中序遍历
			cout << cur->value << " ";
			cur = cur->right;

		}
	}
	// 先序
	void morrisPre(Node* head) {
		if (head == nullptr) {
			return;
		}
		Node* cur = head;
		Node* mostRight = nullptr;
		while (cur) {
			mostRight = cur->left;
			if (mostRight) {	// 有左树的情况下
				while (mostRight->right && mostRight->right != cur) {
					mostRight = mostRight->right;
				}
				if (!mostRight->right) {
					mostRight->right = cur;
					cout << cur->value << " ";
					cur = cur->left;
					continue;
				} else {	//mostRight->right==cur
					mostRight->right = nullptr;
				}
			} else {
				cout << cur->value << " ";
			}
			cur = cur->right;
		}
	}
	// 后序,利用每个子二叉树的右边界可以划分整个二叉树,每遍历到了第二次,就逆序打印其左子树的右边界,最后再打印整棵树的右边界
	void morrisPos(Node* head) {
		if (head == nullptr) {
			return;
		}
		Node* cur = head;
		Node* mostRight = nullptr;
		while (cur) {
			// cur有没有左树
			mostRight = cur->left;
			if (mostRight) {	// 有左树的情况下
				// 找到cur左树上,真实的最右
				while (mostRight->right && mostRight->right != cur) {
					mostRight = mostRight->right;
				}
				// 从while中出来,mostRight一定是cur左树上的最后节点
				if (!mostRight->right) {
					mostRight->right = cur;
					cur = cur->left;
					continue;
				} else {	//mostRight->right==cur
					mostRight->right = nullptr;
					printEdge(cur->left);	//逆序打印一颗树的右边界
				}
			}
			cur = cur->right;
		}
		printEdge(head);	//逆序打印一颗树的右边界
	}

	void printEdge(Node* head) {
		Node* tail = reverseEdge(head);
		Node* cur = tail;
		while (cur) {
			cout << cur->value << " ";
			cur = cur->right;
		}
		reverseEdge(tail);
	}
	Node* reverseEdge(Node* from) {
		Node* pre = nullptr;
		Node* next = nullptr;
		while (from) {
			next = from->right;
			from->right = pre;
			pre = from;
			from = next;
		}
		return pre;
	}
}

Manacher

Manacher 算法是在线性时间内求解最长回文子串的算法

// 线性时间内解决最大回文字串
// 先了解下变量含义,pArr[i] 表示以i为中心的最长回文串的半径,R是目前最长的回文串的右边界,C是目前最长的回文串的中点。R,C初始时均为-1
// 步骤:原串前后中间间隙插入字符,
//	   从左往右枚举回文中心i,
//		1).若i在R外,暴力扩
//		2).若i在R内(设i关于C的对称点为i1)
//			a. i1的回文区域全在在L,R内,pArr[i]=pArr[i1]
//			b. i1的回文区域在L,R外部,pArr[i]=R-i
//			c. i1的回文区域左边界和L压线,从R开始往外扩
//    R整体最多到N,而a,b分支是O(1),所以算法整体O(N)
class Manacher {
public:
	int manacher(const string& s) {
		if (s.size() == 0) {
			return 0;
		}
		//"12321" -> "#1#2#3#2#1#"
		string str = manacherString(s);
		// 回文半径的大小
		vector<int> pArr(str.size());	// 回文半径数组
		// C是让R扩张的回文中心
		int C = -1;
		// 讲述中R代表最右的扩成功的位置,在这里,最右的扩张成功位置,在下一个位置
		int R = -1;
		int ret = INT_MIN;	//返回值,记录半径
		// 以i为回文中心共有四中情况
		// 1)i在R外
		// (i在R内,设i1为i关于C的对称点)
		// 2)i1的回文半径 i1在L..R内
		// 3)i1回文半径的左边界在L..R外
		// 4)i1回文半径和L重合
		for (int i = 0; i < str.size(); ++i) {
			// i位置扩出来的答案,i位置扩的区域,至少是多少
			// 如果i在R外,即i>=R(R是第一个违规的位置)  (pArr[2*C-i]是i的对称点)
			pArr[i] = R > i ? min(pArr[2 * C - i], R - i) : 1;	//不用查验的位置先设置到pArr[i]
			//上面一行直接攘括了4种情况不用验的区域


			//情况2,3一进循环就会break
			while (i + pArr[i]<str.size() && i - pArr[i]>-1) {	//循环条件是防止扩的过程种超出左右边界
				// if就是以i为中心,左右扩,否则不能扩了
				if (str[i + pArr[i]] == str[i - pArr[i]]) {
					++pArr[i];
				} else {
					break;
				}

			}

			// i位置扩出来的答案超过了R
			if (i + pArr[i] > R) {
				R = i + pArr[i];
				C = i;
			}
             // 在这里可以累计,回文字串的个数,cnt+=pArr[i]/2
			ret = max(ret, pArr[i]);
		}

		return ret - 1;	//ret记录的是manacherString中的最大回文半径,还需-1,得到原本的长度
	}
	string manacherString(const string& s) {
		string res(s.size() * 2 + 1, '0');
		int index = 0;
		for (int i = 0; i != res.size(); ++i) {
			res[i] = (i & 1) == 0 ? '#' : s[index++];
		}
		return res;
	}
};

数论

质数

判断质数
// 试除法,时间固定O(sqrt(n))
bool prime(int x) {
    if (x < 2) return false;
    for (int i = 2; i <= n / i; ++i)
        if (n % i == 0) return false;
    return true;
}
分解质因数
// 试除法,最坏时间O(sqrt(n)),最好O(log(n))
void divide(int n) {
    for (int i = 2; i <= n / i; ++i)
        if (n % i == 0) {		// i一定是质数
            int s = 0;
            while (n % i == 0)n /= i, ++s;
            cout << i << ' ' << s << endl;
        }
    if (n > 1) cout << n << ' ' << 1 << endl;	// n中最多只包含一个大于sqrt(n)的质因子
    cout << endl;
}
埃氏筛法

用于快速找出2到n之间的质数。如果我们从小到大考虑每个数,把当前这个数的所有(比自己大的)倍数记为合数,那么运行结束的时候没有被标记的数就是素数了。

// 质数定理:1~n中有n/ln(n) 个质数
// 时间O(Nloglog(n))
// 关于只对素数的倍数筛去的原因:
//根据惟一分解定理:任何一个大于1的整数n都可以分解成若干个素因数的连乘积,所以我们就可以得到每个合数必定是某个比它小的质数的倍数 或 也可以理解为,一个数不是素数,那么它的倍数一定已经被筛去了
int primes[N], cnt;     // primes[]存储所有素数
bool st[N];         // st[x]存储x是否被筛掉

void get_primes(int n)
{
    for (int i = 2; i <= n; i ++ )
    {
        if (st[i]) continue;
        primes[cnt ++ ] = i;
        for (int j = i + i; j <= n; j += i)		// 朴素筛法中,每个数都要往后标记
            st[j] = true;
    }
}
线性筛法

又称 Euler 筛法(欧拉筛法),埃氏筛法仍有优化空间,它会将一个合数重复多次标记。有没有什么办法省掉无意义的步骤呢?

如果能让每个合数都只被标记一次,那么时间复杂度就可以降到 O(N)了

int primes[N], cnt;     // primes[]存储所有素数
bool st[N];         // st[x]存储x是否被筛掉

void get_primes(int n)
{
    for (int i = 2; i <= n; i ++ )
    {
        if (!st[i]) primes[cnt ++ ] = i;
        // 这里的循环条件primes[j] <= n / i,当i是合数时,会在if(i % primes[j] == 0)停下了
        //		当i是质数时,当primes[j]=i时,也会在if(i % primes[j] == 0)停下了
        for (int j = 0; primes[j] <= n / i; j ++ )
        {
            st[primes[j] * i] = true;		//使得每个合数i*primes[j]只会被它的最小质因子primep[j]筛一次
            if (i % primes[j] == 0) break;	
            // 上述判断成立时,pj一定是i的最小质因子,pj也一定是pj*i的最小质因子
            // !=0时,因为我们从小到大枚举,且还能循环,说明pj一定小于i的所有质因子,所以pj也一定是pj*i的最小质因子
        }
    }
}

约数

算术基本定理可表述为:任何一个大于1的自然数 N,如果N不为**质数,那么N可以唯一分解成有限个质数的乘积N**=P1a1P2a2P3a3…Pnan,这里P1

如果 N = p1^c1 * p2^c2 * ... *pk^ck
约数个数: (c1 + 1) * (c2 + 1) * ... * (ck + 1)
约数之和: (p1^0 + p1^1 + ... + p1^c1) * ... * (pk^0 + pk^1 + ... + pk^ck)

求N的正约数集合——试除法
// 推论:一个整数N的约数个数上界为2*sqrt(N)
// 时间O(sqrt(N))
vector<int> get_divisors(int x)
{
    vector<int> res;
    for (int i = 1; i <= x / i; i ++ )
        if (x % i == 0)
        {
            res.push_back(i);
            if (i != x / i) res.push_back(x / i);
        }
    sort(res.begin(), res.end());
    return res;
}
求1~N每个数的正约数集合——倍除法

若用试除法,时间复杂度过高,为O(N√N)。可以反过来考虑,对于每个数 d,1~N中以d 为约数的数就是d的倍数d, 2d,3d,…,[N /d] * d。以下程序采用“倍数法”求出 1~N每个数的正约数集合

vector<int> factor[500010];
for(int i=1;i<=n;++i)
    for(int j=1;j<=n/i;++j)
        factor[i*j].push_back(i);
for(int i=1;i<=n;++i)
	for(int j=0;j<factor[i].size();++j)
        printf("%d ",factor[i][j]);
puts("");
// 上述算法的时间复杂度为O(N + N/2+ N/3+…+ N/N)= O(N log N)。

欧拉函数

欧拉函数是小于n的正整数中与n互质的数的数目.

// phi(n)=n * (1-1/p1) * (1-1/p2) * (1-1/p3) ... (1-1/pk),其中pi是n的质因子
// O(sqrt(n))
int phi(int x)
{
    int res = x;
    for (int i = 2; i <= x / i; i ++ )
        if (x % i == 0)
        {
            res = res / i * (i - 1);	// res=res*(1-1/i),同*i计算
            while (x % i == 0) x /= i;
        }
    if (x > 1) res = res / x * (x - 1);

    return res;
}
// 欧拉函数的两个性质:
// 1) 任意的n>1,1~n中与n互质的数的和为n*phi(n)/2
// 2) 若a,b互质,则 phi(a*b)=phi(a)*phi(b)
// 欧拉定理:若a与n互质,则 a^phi(n) =(同余) 1(mod n)
// 一个欧拉定理的特例,当p是质数时,对于任意的整数x来说,x^(p-1)=1(mod p),又称为费马小定理

筛法求欧拉函数

int primes[N], cnt;     // primes[]存储所有素数
int euler[N];           // 存储每个数的欧拉函数
bool st[N];         // st[x]存储x是否被筛掉
void get_eulers(int n)
{
    euler[1] = 1;		// 记euler[1]=1
    for (int i = 2; i <= n; i ++ )
    {
        if (!st[i])
        {
            primes[cnt ++ ] = i;
            euler[i] = i - 1;		// 质数 euler[i]=i*(1-1/i)=i-1
        }
        for (int j = 0; primes[j] <= n / i; j ++ )
        {
            int t = primes[j] * i;
            st[t] = true;
            if (i % primes[j] == 0)
            {
                // 摸完等于0,pj是i的因子,即euler[i*pj]比euler[i]多了个因子pj,但欧拉函数的计算式与因子的次数无关,所以表达式有关因子的部分两个相同,所以euler[i*primes[j]]=primes[j]*i * (1-1/p1) *...* (1-1/pk)=primes[j]*euler[i]
                euler[t] = euler[i] * primes[j];		
                break;
            }
           	// pj不是i的因子,即pj和i互质,所以euler[i*primes[j]]=primes[j]*i * (1-1/p1)*(1-1/p2)*...*(1-1/pk) * (1-1/primes[j]) = euler[i] * (primes[j] - 1)
            euler[t] = euler[i] * (primes[j] - 1);
        }
    }
}

快速幂

int qmi(int a,int k,int p){
    int res=1;
    while(k){
        if(k&1) res=(LL) res * a % p;
        k>>=1;
        a=(LL)a*a%p;
	}
    return res;
}

快速幂求逆元

对于取模来说,除法是没有加减乘法那样直接运算的,所以我们希望将除法转化为乘法来计算,即我们希望找到一个数x使得,a/b 同余 a*x(mod m),我们就把x叫做b的逆元。

乘法逆元:若整数b, m互质,并且 b|a,则存在一个整数x,使得a/b = a * x (mod m)。称x为b 的模m 乘法逆元,记为b^(-1)(mod m)。

// 如果只是保证b,m互质,那么乘法逆元可通过求解 同余方程 b*x 同余 1(mod m),
// 根据费马小定理,b^(p-1) 同余 1(mod p)  =>  b*b^(p-2) 同余 1(mod p) , 所以说,x就是b^(p-2)
// 快速幂
// a^b , 假设3^11 , 11的二进制1011,即3^11=3^1 * 3^2 * 3^8,即将b看为二进制,若某第x位为1,则要乘上a^(2^x)
inline int qpow(long long a, int b) {	// 注意a的类型是long long
  int ans = 1;
  for (; b; b >>= 1) {
    if (b & 1) ans = a * ans % p;
    a = a * a % p;	// a^y * a^y = a^2y; 
  }
  return ans;
}

扩展欧几里得算法

斐蜀定理

对于任意正整数 a,b,一定存在非零整数x,y,使得 ax+by=gcd(a,b)

扩展欧几里得算法
// 求x, y,使得ax + by = gcd(a, b)
int exgcd(int a, int b, int &x, int &y)
{
    if (!b)
    {
        x = 1; y = 0;
        return a;
    }
    int d = exgcd(b, a % b, y, x);
    y -= (a/b) * x;
    return d;
}

线性同余方程

给定整数 a,b,m, 求一个整数x满足a*x 同余 b(mod m),或者给出无解。因为未知数的指数为1,所以我们称为一次同余方程,也称线性同余方程。

// a*x 同余 b(mod m) 等价于 a*x-b 是m的倍数,不妨设为-y倍,于是改方程可改写为a*x+m*y=b,根据斐蜀定理及其证明过程,线性同余方程有解当且仅当gcd(a,m) | b
// 有解时,先用欧几里得算法求出一组整数x0,y0,满足a*x0+m*y0=gcd(a,m),然后,x=x0*b/gcd(a,m)就是原线性同余方程的一个解,方程的通解则是所有模 m/gcd(a,m)与x同余的整数

中国剩余定理

// 设 m1,m2,...mn是两两互质的整数,m=累乘mi(i从1到n),Mi=m/mi,ti是线性同余方程 Mi*ti 同余 1(mod mi)的一个解,对于任意的n个整数 a1,a2,...,an,方程组 x 同余 a1(mod m1),x 同余 a2(mod m2),...,x 同余 an(mod mn),有整数解,解为x=累加ai*Mi*ti(i从1到n)

卡特兰数

公式

k(0)= 1, k(1)=1时,如果接下来的项满足︰
k(n)= k(0) * k(n - 1)+ k(1)* k(n - 2)+ ... + k(n - 2)* k(1) + k(n - 1)* k(0)
或者
k(n)= c(2n, n) - c(2n, n-1)
或者
k(n)= c(2n, n)/ (n+1)
就说这个表达式,满足卡特兰数,常用的是范式123几乎不会使用到

低阶数据结构

链表

// 数组实现
int tot,head,next;
struct Node{
    int value;
    int prev,next;
}node[SIZE];
int init(){
    tot=2;
    head=1,tail=2;
    node[head].next=tail;	// 两个哑节点
    node[tail].prev=head;
}
int insert(int p,int val){	// 在节点p后插入值val
    // 新建节点q
    q=++tot;
    node[q].value=val;
    
    node[node[p].next].prev=q;	// p后的节点的前指针指向新节点q
    node[q].next=node[p].next;	// 新节点后指针指向q节点下一个
    node[p].next=q;
    node[q].prev=p;
}
void remove(int p){
    node[node[p].prev].next=node[p].next;
    node[node[p].next].prev=node[p].prev;
}
void clear(){
    memset(node,0,sizeof node);
    head=tail=tot=0;
}

邻接表

// ver存储的是每条边的终点,head数组和next数组中保存的是“ver数组的下标”
// head是表头,定义为 该边的起点编号相同的作为同一链表(一类),通过head[x]可以容易定位到第x类对应的链表,从而访问从点x出发的所有的边
// edge数组存储对应边的权值
void add(int x,int y,int z){
    ver[++tot]=y,edge[tot]=z;
    next[tot]=head[x],head[x]=tot;
}
// 访问从x出发的所有边
for(int i=head[x];i;i=next[i]){
    int y=ver[i],z=edge[i];
    // 找到了一条有向边(x,y),权值为z
}


// 或使用使用C++支持动态增加元素的vector
#include 
#include 

using namespace std;

int n, m;
vector<bool> vis;
vector<vector<int> > adj;

bool find_edge(int u, int v) {
  for (int i = 0; i < adj[u].size(); ++i) {
    if (adj[u][i] == v) {
      return true;
    }
  }
  return false;
}

void dfs(int u) {
  if (vis[u]) return;
  vis[u] = true;
  for (int i = 0; i < adj[u].size(); ++i) dfs(adj[u][i]);
}

int main() {
  cin >> n >> m;

  vis.resize(n + 1, false);
  adj.resize(n + 1);

  for (int i = 1; i <= m; ++i) {
    int u, v;
    cin >> u >> v;
    adj[u].push_back(v);
  }

  return 0;
}

AVL树

平衡二叉搜索树,(二叉搜索树)左子树所有值小于根,右子树所有值大于根,且,(平衡)左右子树高度差不超过1。

左旋右旋:都是对于根节点来说,左旋转就是将根节点向左倒,其原根节点右孩子作为新根,如图
算法学习总结_第1张图片

右旋类似
算法学习总结_第2张图片

搜索二叉树的删除节点:若该节点无左无右,直接删,若无左有右或无右有左,删除上提即可,若有左有右 ,用右树上的最左节点替换即可。

AVL树的四种不平衡情况

  1. LL型,右旋
  2. RR型,左旋
  3. LR型/RL型,让孙节点上来,对于LR型,孙就是C节点,先对B和C玩左旋,再对ABC整体玩右旋转。RL型类似,先右旋再左旋。
    算法学习总结_第3张图片

单调栈

单调栈主要解决,对序列中每个元素,找到下一个(上一个)比它大(小)的元素。找小的,用单调递增,找大的,用单调递减。同时,我们规定栈中违规的弹完之后,再对当前要入栈的进行结算,计算的结果即为栈顶的元素。

我们以找出序列中每个元素左边最近的比它小的,右边最近的比它小的为例。

  • 若序列中无重复元素
    算法流程:从左往右遍历序列,当前元素若大 于栈顶元素直接入栈,若小于栈顶元素,那么不断出栈,直到当前元素大于栈顶元素,出栈过程中每个元素的右边最近的比它小的即为迫使它出栈的元素,而左边最近的比它小的即为栈中它下面的元素。

  • 若序列中有重复元素
    变化的是,栈中的元素就不是一个单独的序列中的元素了,当有重复元素时,在入栈过程中,相同的元素下标存储在一起,出栈的时候,也就可能不是单个元素的出栈了,而是一批的一起出了。

    vector<int[2]> getNearLess(vector<int>& arr) {
        vector<int[2]> res(arr.size());
        stack<list<int>> stk;
        for (int i = 0; i < arr.size(); ++i) {
            // 当前元素比栈顶小
            while (!stk.empty() && arr[stk.top().front()] > arr[i]) {
                auto pos = stk.top();
                stk.pop();
                int l = stk.empty() ? -1 : stk.top().back();
                for (auto p : pos) {
                    res[p][0] = l;
                    res[p][1] = i;
                }
            }
            // 相等的,当前元素大于栈顶元素
            if (!stk.empty() && arr[stk.top().front()] == arr[i]) {
                stk.top().push_back(i);
            } else {
                stk.push({ i });
            }
        }
    
        // 栈中的全弹出
        while (!stk.empty()) {
            auto pos = stk.top();
            stk.pop();
            int l = stk.empty() ? -1 : stk.top().back();
            for (auto p : pos) {
                res[p][0] = l;
                res[p][1] = arr.size();		// 右边没有比它大的,人为记为n
            }
        }
        return res;
    
    }
    
    // 其实也可以分两次遍历,一次找左边,一次找右边。同时用数组代替栈,加快常数
    // q就是单调栈,l是左边的答案,r是右边的答案,h是序列中的数值
    int h[N], q[N], l[N], r[N];
    for (int i = 1; i <= n; i++)  scanf("%d", &h[i]);
    h[0] = h[n + 1] = -1;
    
    int tt = -1;
    q[++tt] = 0;
    for (int i = 1; i <= n; i++)
    {
        while (h[q[tt]] >= h[i])  tt--;
        l[i] = q[tt];
        q[++tt] = i;
    }
    d
    tt = -1;
    q[++tt] = n + 1;
    for (int i = n; i; i--)
    {
        while (h[q[tt]] >= h[i])  tt--;
        r[i] = q[tt];
        q[++tt] = i;
    }
    
    

单调队列

单调队列,从左往右遍历,若当前元素<=队尾元素,队尾元素弹出,直到当前元素>队尾元素,插入队尾,这样,队列从头到尾呈现单调递增,窗口最小值即为对头。

// 长度为n,窗口为k,在窗口内找最小值
int hh = 0, tt = -1;
for (int i = 0; i < n; ++i) {
    if (hh <= tt && q[hh] <= i - k) ++hh;       // 保持窗口长度,队头弹出
    while (hh <= tt && a[q[tt]] >= a[i]) --tt;  // 队尾元素>=a[i],队尾弹出
    q[++tt] = i;                                // 入队尾
    if (i >= k - 1) cout << a[q[hh]] << " ";    // 窗口长度满足k
}
puts("");
// 找最大值
hh = 0, tt = -1;
for (int i = 0; i < n; ++i) {
    if (hh <= tt && q[hh] <= i - k) ++hh;
    while (hh <= tt && a[q[tt]] <= a[i])--tt;
    q[++tt] = i;
    if (i >= k - 1) cout << a[q[hh]] << " ";
}
puts("");

KMP

KMP本质还是暴力匹配,不过利用next数组省去了许多无用的匹配过程。使得主串在匹配过程中不回退,模式串回退的距离尽可能少。

next数组含义:next[i]表示模式串p中“以i结尾的非前缀字串”与“p的前缀”能够匹配的最长长度。

// s[]是主串,p[]是模式串,n是s的长度,m是p的长度,下标从1开始
// 求模式串p的Next数组:
for (int i = 2, j = 0; i <= m; i ++ )	// ne[1]=0
{
    while (j && p[i] != p[j + 1]) j = ne[j];
    if (p[i] == p[j + 1]) j ++ ;
    ne[i] = j;
}

// 匹配
// 从i串中查找j串
for (int i = 1, j = 0; i <= n; i ++ )
{
    
    while (j && s[i] != p[j + 1]) j = ne[j];
    if (s[i] == p[j + 1]) j ++ ;
    // 对于每一个s[i]结尾的后缀都能求出p的前缀匹配的长度就是j 
    if (j == m)	// j串走完了,匹配成功
    {
        j = ne[j];
        // 匹配成功后的逻辑
    }
}
// 或 求nexts数组
int i = 2, j = 0;   //i是目前主串要比较的位置,j是模式串要匹配的位置
while (i <= n) {
    // 如果i和j位置元素相同,
    if (s[i] == s[j+1])
        Next[i++] = ++j;
    else if (j > 0)    // 不同,j需要回溯
        j = Next[j];
    else                // 既不同,又无法再往前退了
        Next[i++] = 0;
}

next数组求循环节长度

for (int i = 2, j = 0; i <= m; i ++ )	// ne[1]=0
{
    while (j && p[i] != p[j + 1]) j = ne[j];
    if (p[i] == p[j + 1]) j ++ ;
    ne[i] = j;
}
int len=n-m;	// len即为最小的循环节长度

字符串hash

字符串hash函数把任意一个长度的字符串映射成一个非负整数,并且其冲突概率机会为0

具体做法:取一固定值p(经验值取131或13331),并且给每个字符分配一个大于0的数值,一般来说,我们分配的数值都远小于P。
例如,对于小写字母构成的字符串,可以令a = 1,b = 2,…,z= 26。取一固定值M,求出该Р进制数对M的余数,作为该字符串的Hash 值。
一般来说,我们取P=131或P= 13331,此时 Hash值产生冲突的概率极低,只要Hash值相同,我们就可以认为原字符串是相等的。 通常我们取M= 264,即直接使用unsigned long long类型存储这个Hash值,在计算时不处理算术溢出问题,产生溢出时相当于自动对2^64取模,这样可以避免低效的取模(mod)运算。

char str[N];
ULL h[N], p[N];

ULL get(int l, int r) {
    return h[r] - h[l - 1] * p[r - l + 1];
}
scanf("%s", str + 1);
int n = strlen(str + 1);
p[0] = 1;
for (int i = 1; i <= n; ++i) {
    h[i] = h[i - 1] * 131 + str[i] - 'a' + 1;
    p[i] = p[i - 1] * base;
}

高阶数据结构

并查集

并查集是一种树形数据结构,用于处理一些不交集的合并和查询问题

int fa[MAXN];  // 记录某个人的爸爸是谁,特别规定,祖先的爸爸是他自己
//  初始化-------------------------------------------------------------
for(int i=0;i<n;++i) fa[i]=i;		// 初始时,自己单独一个集合
// 查找(路径压缩)----------------------------------------------------------------
int get(int x) {
    if(x==fa[x]) return x;	// x 是自身的父亲,即 x 是该集合的代表
    return fa[x]=get(fa[x]);	// 查找 x 的祖先直到找到代表,于是顺手路径压缩
}
// 合并-------------------------------------------------------------------
// x和y必须是处于不同集合
void merge(int x, int y) {
    // x 与 y 所在家族合并,把 x 的祖先变成 y 的祖先的儿子
   fa[get(x)]=get(y);
}

"边带权"并查集

并查集实际上是由若干棵树构成的森林,我们可以在树中的每条边上记录一个权值,即维护一个数组d,用d[x] 保存节点x到父节点fa[x]之间的边权。在每次路径压缩后,每个访问过的节点都会直接指向树根,如果我们同时更新这些节点的d值,就可以利用路径压缩过程来统计每个节点到树根之间的路径上的一些信息。这就是所谓“边带权”的并查集。

// 要基础路径压缩的功能,不只是简单的找父,路径压缩,还可能同时更新相关信息
int get(int x){
  	if(x==fa[x]) return x;
    int root=get(fa[x]);
    d[x]+=d[fa[x]];			// 维护d数值,对边权求和
    return fa[x]=root;
}
void merge(int x,int y){
    fa[get(x)]=get(y);
}
//并查集擅长维护具有传递性的关系及其连通性。在某些问题中,“传递关系” 不止一种,并且这些“传递关系”能够互相导出
// 如例题238. 银河英雄传说中的merge
void merge(int x, int y) {
    x = get(x), y = get(y);
    fa[x] = y, d[x] = sz[y];
    sz[y] += sz[x];
}

"扩展域"并查集

拓展域并查集解决了一种多个有相互关系的并查集,放在一起考虑的问题。一般的并查集应用一般就是判断在不在一个集合,拓展域并查集讲的是多个集合,之间有相互关系一般为相互排斥关系,判断是否在一个集合等。

首先对与最简单的并查集来说,如果两个是同一类,那么就 join(a,b)对吧,但是对于两个相互排斥类的怎么办呢,这就涉及到拓展与并查集了,首先想法就是建立两个并查集,但是怎么把两个并查集联系起来呢------拓展个体。

这里的拓展个体是什么意思呢,一个个体我们要拆成多个,比方说两个集合存在队立关系,那么对于一个个体a,我们假设存在一个个体 a+n ,a和a+n这两个是处于对立关系的,所以当我们说 a 和 b对立的时候,意思就是在说,a + n 和 b在同一并查集,b+n和a在同一并查集,当我们说,a和b是同类的时候,那么也就是说 a和b属于一个并查集,且a+n和b+n属于一个并查集。

这样就建出了多个并查集,解决了多个集合的相互关系。

树状数组

树状数组和线段树具有相似的功能,但他俩毕竟还有一些区别:树状数组能有的操作,线段树一定有;线段树有的操作,树状数组不一定有。但是树状数组的代码要比线段树短,思维更清晰,速度也更快,在解决一些单点修改的问题时,树状数组是不二之选。下面结束树状数组的两个函数,一个是给某个点加上某个值,另一个是查询区间的和。

导入:一个暴力的方法就是,每次查询就遍历原数组,但这需要O(N)的时间,容易想到,可以多开一些额外的数组,用于管理各自区间的的数据的和。进一步,可以发现,其中有一些数字用不到可以省去,如下。

算法学习总结_第4张图片

其次,这些额外的数共N个,空间复杂度也仅达到O(N)。数组中的每一个元素,都对应原数组某个区间的和,求和时,我们只需要找到对应的区间,将这些区间相加即可找到答案,修改某个数据时,也只要找到每个包含它的区间进行修改即可。

那么如何快速找到这些区间呢?可以发现,额外数组序号为i的序列正好就是长度为lowBit(i)且以a[i]结尾的序列。同时,一个序列b[i]正上方的序列,正好就是b[i+lowBit(i)],所以在修个某个位置的值时,只需要不断加上lowBit(i)就可以找到上方的所有序列,进行修改即可

树状数组的两个功能为查询前缀和和单点修改都可在O(logN)完成。

算法学习总结_第5张图片

// 查询[0,x]上的和,当然求区间[l,r] = ask(r)-ask(l-1)
int ask(int x){
    int rse=0;
    for(; x ;x -= x & -x) res+=c[x];
    return res;
}
// 给x位置加上值y
void add(int x,int y){
    for(; x<=N ; x += x & -x) c[x]+=y;
}
// 初始化
// c数组全为0,然后对每个位置x,执行add(x,a[x])	
树状数组和逆序对
// 序列a的取值范围
const int N;
int c[N];
// 倒序扫描给定的序列a
for(int i=n;i;--i){
    res += ask(a[i]-1);
    add(a[i],1);
}
// 时间复杂度O((n+N)logn),如果数据范围太大,不如直接用归并排序计算逆序对
// 在这个算法中,因为倒序扫描,“已经出现过的数”就是在 a[i]后边的数,所以我们通过树状数组查询的内容就是“每个a[i]后边有多少个比它小”。每次查询的结果之和当然就是逆序对个数。

线段树

线段树的基本用途是对序列进行维护,支持查询与修改指令。给定一个长度为N的序列A,我们可以在区间[1,N]上建立一棵线段树,每个叶节点[i,i]保存A[i]的值。线段树的二叉树结构可以很方便地从下往上传递信息。

// --------- 建立-----------------
struct SegmTree{
    int l,r,dat;
}t[N*4];
void build(int p,int l,int r){
    t[p].l=l,t[p].r=r;			// 节点p代表区间[l,r]
    if(l==r) {t[p].dat=a[l];return;}	// 叶节点
    int mid=l+r>>1;						// 折半
    build(p*2,l,mid);			// 左子节点[l,mid],编号p*2
    build(p*2+1,mid+1,r);		// 右子节点[mid+1,r],编号p*2+1
    t[p].dat=max(t[p*2].dat,t[p*2+1].dat);	// 从下往上传递信息
}
build(1,1,n);		// 调用入口

// ----------------单点修改------------
// 把A[x]的值修改为v
void change(int p,int x,int v){
    if(t[p].l==t[p].r) {t[p].dat=v;return;}
    int mid=t[p].l+t[p].r>>1;
    if(x<=mid) change(p*2,x,v);
    else change(p*2+1,x,v);
    t[p].dat=max(t[p*2].dat,t[p*2+1].dat);
}
change(1,x,v);	// 调用入口

// ---------------区间查询---------------
// 查询[l,r]区间上的最大值
int ask(int p,int l,int r){
    if(l<=t[p].l && r>=t[p].r) return t[p].dat;		// 完全包含
    int mid=(t[p].l+t[p].r)>>1;
    int val=-(1<<30);		// 负无穷大
    if(l<=mid) val=max(val,ask(p*2,l,r)); // 左子节点有重叠
    if(r>mid) val=max(val,ask(p*2+1,l,r));	// 右子节点有重叠
    return val;
}
cout<<ask(1,l,r);		// 调用入口




// ===== 规范模板=========
class SegmentTree {
public:
	int MAXN;
	vector<int> arr;
	vector<int> sum;	// sum[]模拟线段树维护区间和
	vector<int> lazy;	// lazy[]为累加懒惰标记
	vector<int> change;	// change[]为更新的值
	vector<bool> update;// update[]为更新懒惰标记,true表示change[i]中的数是更新的值,为了避免0产生歧义
	// 初始化,从下标1开始复制一遍原数组到arr,同时为其他数组开辟足够的空间
	SegmentTree(const vector<int>& origin) {
		MAXN = origin.size() + 1;
		arr.resize(MAXN);	// arr[0]不用,从下标1开始
		for (int i = 1; i < MAXN; ++i) {
			arr[i] = origin[i - 1];
		}
        // 4倍绝对够用
		sum.resize(MAXN << 2);
		lazy.resize(MAXN << 2);
		change.resize(MAXN << 2);
		update.resize(MAXN << 2);
	}
	// 初始化后,调用该函数完成二叉树数组的建立,build(1,n,1);
	// 初始化阶段,先把sum数组填好
	// 在arr[l..r]范围上,去build,1~N;
	// rt:这个范围在sum中的下标 
	void build(int l, int r, int rt) {
		if (l == r) {	// base casem,叶子节点
			sum[rt] = arr[l];
			return;
		}
       	// 二分建树
		int mid = (l + r) >> 1;
		build(l, mid, rt << 1);
		build(mid + 1, r, rt << 1 | 1);
		pushUp(rt);
	}
	// 之前所有的懒增加和懒更新,从父范围,发给左右两个子范围
	// ln表示左子树元素节点个数,rn表示右子树节点个数
	void pushDown(int rt, int ln, int rn) {
        // 先下发更新再下发懒增加
		if (update[rt]) {
			update[rt << 1] = true;
			update[rt << 1 | 1] = true;
		
			change[rt << 1] = change[rt];
			change[rt << 1 | 1] = change[rt];
			// 一旦更新的话,懒增加也就清除了
			lazy[rt << 1] = 0;	
			lazy[rt << 1 | 1] = 0;
			// 更新sum
			sum[rt << 1] = change[rt] * ln;
			sum[rt << 1 | 1] = change[rt] * rn;
			update[rt] = false;	// 标记false,说明该change[i]的更新数值失效
		}
	
		if (lazy[rt] != 0) {
			lazy[rt << 1] += lazy[rt];
			sum[rt << 1] += lazy[rt] * ln;
			lazy[rt << 1 | 1] += lazy[rt];
			sum[rt << 1 | 1] += lazy[rt] * rn;
			lazy[rt] = 0;
		}
	}
	// 返回两个孩子的和
	void pushUp(int rt) {
		sum[rt] = sum[rt << 1] + sum[rt << 1 | 1];
	}

	// L...R 为任务范围,所有的值累加上C
	// l...r 为表达的范围, rt 为去哪里找l...r范围上的信息
	void add(int L, int R, int C, int l, int r, int rt) {
		// 任务的范围彻底覆盖了当前表达的范围
		if (L <= l && R >= r) {
			sum[rt] += C * (r - l + 1);
			lazy[rt] += C;
			return;
		}
		// 任务并没有把l...r全包,要把当前任务下发
		int mid = (l + r) >> 1;
		// 任务没有全包,下发之前的攒的所有懒任务
		pushDown(rt, mid - l + 1, r - mid);
		// 左孩子是否需要接受任务
		if (L <= mid) {
			add(L, R, C, l, mid, rt << 1);
		}
		if (R > mid) {
			add(L, R, C, mid + 1, r, rt << 1 | 1);
		}
		// 左右孩子做完任务后,我更新我的sum信息
		pushUp(rt);
	}
	
	void upDate(int L, int R, int C, int l, int r, int rt) {
        // 任务懒
		if (L <= l && R >= r) {
			update[rt] = true;
			change[rt] = C;
			sum[rt] = C * (r - l + 1);
			lazy[rt] = 0;
			return;
		}
		// 当前任务懒不住,先下发
		int mid = (l + r) >> 1;
		pushDown(rt, mid - l + 1, r - mid);
		if (L <= mid) {
			upDate(L, R, C, l, mid, rt << 1);
		}
		if (R > mid) {
			upDate(L, R, C, mid + 1, r, rt << 1 | 1);
		}
		pushUp(rt);
	}

	long long query(int L, int R, int l, int r, int rt) {
		if (L <= l && R >= r) {
			return sum[rt];
		}
		int mid = (l + r) >> 1;
		pushDown(rt, mid - l + 1, r - mid);
		long long ans = 0;
		if (L <= mid) {
			ans += query(L, R, l, mid, rt << 1);
		}
		if (R > mid) {
			ans += query(L, R, mid + 1, r, rt << 1 | 1);
		}
		return ans;
	}
};
延迟标记

不过,在“区间修改”指令中,如果某个节点被修改区间 [l,r] 完全覆盖,那么以该节点为根的整棵子树中的所有节点存储的信息都会发生变化,若逐一进行更新,将使得一次区间修改指令的时间复杂度增加到 O(N),这是我们不能接受的。

试想,如果我们在一次修改指令中发现节点 p 代表的区间 [pl,pr] 被修改区间[l,r] 完全覆盖,并且逐一更新了子树 p 中的所有节点,但是在之后的查询指令中却根本没有用到 [l,r] 的子区间作为候选答案,那么更新 p 的整棵子树就是徒劳的。

换言之,我们在执行修改指令时,同样可以在区间被包含的情况下立即返回,只不过在回溯之前向节点 p 增加一个标记,标识“该节点曾经被修改,但其子节点尚未被更新”

如果在后续的指令中,需要从节点 p 向下递归,我们再检查 p 是否具有标记若有标记,就根据标记信息更新 p 的两个子节点,同时为 p 的两个子节点增加标记然后清除 p 的标记。

分块

思想:大段维护,局部朴素。

资源限制类

  • 布隆过滤器:位图+哈希
    布隆过滤器用于集合的建立与查询,并可以节省大量空间
    布隆过滤器对于一个数据使用多个hash,映射在位图上,若有1个为0,说明不存在,但全为1只能说明可能存在,会误判。
  • 一致性哈希解决数据服务器的负载管理问题
  • 利用并查集结构做岛问题的并行计算
  • 哈希函数可以把数据按照种类均匀分流
  • 位图解决某一范围上数字的出现情况,并可以节省大量空间
  • 利用分段统计思想、并进一步节省大量空间
  • 利用堆、外排序来做多个处理单元的结果合并

动态规划

背包问题

01背包
int N = w.size();
vector<int> dp(bag + 1);
for (int i = 0; i < N; ++i) {
    for (int rest = bag; rest >= w[i]; --rest) {		// 倒序
        dp[rest] = max(dp[rest], dp[rest - w[i]] + v[i]);
    }
}
return dp[bag];
完全背包
for (int i = 0; i < N; ++i) {
    for (int rest = w[i]; rest<=bag; ++rest) {		// 正序
        dp[rest] = max(dp[rest], dp[rest - w[i]] + v[i]);
    }
}
return dp[bag];
分组背包
// f[i][j]表示从前i组中选出总体积为j的物品放入背包,物品的最大价值
memset(f,0xcf,sizeof f);
f[0]=0;
for(int i=1;i<=n;++i)
    for(int j=m;j>=0;--j)
        for(int k=1;k<=c[i];++k)
            if(j>=v[i][k])
                f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);

区间DP

区间 DP 也属于线性 DP 中的一种,它以“区间长度”作为DP 的“阶段”,使用两个坐标(区间的左、右端点)描述每个维度。在区间 DP 中,一个状态由若干个比它更小且包含于它的区间所代表的状态转移而来,因此区间 DP 的决策往往就是划分区间的方法。区间 DP 的初态一般就由长度为 1的“元区间”构成。这种向下划分、再向上递推的模式与某些树形结构,如线段树,有很大相似之处。

图论

Dijkstra

// O(MlogN)
const int N = 100010, M = 1000010;
int head[N], ver[M], edge[M], Next[M],d[N];
bool v[N];
int n, m, tot;

priority_queue<pair<int, int>> q;
void add(int x, int y, int z) {
    // ver存储的是每条边的终点,edge是对应边的权值,
    ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
void dijkstra() {
    memset(d, 0x3f, sizeof d);
    memset(v, 0, sizeof v);
    d[1] = 0;
    q.push({ 0,1 });
    while (q.size()) {
        int x = q.top().second; q.pop();
        if (v[x]) continue;
        v[x] = 1;
        for (int i = head[x]; i; i = Next[i]) {
            int y = ver[i], z = edge[i];
            if (d[y] > d[x] + z) {
                d[y] = d[x] + z;
                q.push({ -d[y],y });        // 利用相反数变成小根堆   
            }
        }
    }
}
int main()
{
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        int x, y, z;
        scanf("%d%d%d", &x, &y, &z);
        add(x, y, z);
    }
    dijkstra();
    for (int i = 1; i <= n; ++i)
        printf("%d\n", d[i]);
}

spfa

// 在任意时刻,该算法的队列都保存了待扩展的节点。每次入队相当于完成一次 dist数组的更新操作,使其满足三角形不等式。一个节点可能会入队、出队多次。最终,图中节点收敛到全部满足三角形不等式的状态。在随机图中,效率为O(km),k是一个较小的常数,但在特殊构造的图上,很可能会退化为O(nm)
const int N = 10010, M = 1000010;
int head[N], ver[M], Next[M], edge[M], dist[N];
int n, m, tot;

queue<int> q;
bool st[N];

void add(int x, int y, int z) {
    ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}

void spfa() {
    memset(dist, 0x3f, sizeof dist);
    memset(st, 0, sizeof st);
    dist[1] = 0, st[1] = true;

    q.push(1);
    while (q.size()) {
        int t = q.front(); q.pop();
        for (int i = head[t]; i; i = Next[i]) {
            int y = ver[i], z = edge[i];
            if (dist[y] > dist[t] + z) {
                dist[y] = dist[t] + z;
                if (!st[y]) q.push(y), st[y] = true;
            }
        }
    }
}

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        int x, y, z;
        cin >> x >> y >> z;
        add(x, y, z);
    }
    spfa();
    for (int i = 1; i <= n; ++i)
        cout << dist[i] << endl;
}

拓扑排序

对于有向无环图,可以使用拓扑排序求最短路,O(n+e)

void add(int x,int y){
    ver[++tot]=y,next[tot]=head[x],head[x]=tot;
    deg[y]++;
}
void topsort(){
    queue<int> q;
    for(int i=1;i<=n;++i){
        if(deg[i]==0) q.push(i);		// 所有入度为0的入队列
	}
    while(q.size()){
        int x=q.front();q.pop();
        a[++cnt]=x;		// 拓扑序
        for(int i=head[x];i;i=next[i]){	
            int y=ver[i];
            if(--deg[y]==0) q.push(y);		// 入度为0的入队列
        }
    }
}

int main()
{
    cin>>n>>m;
    for(int i=1;i<=m;++i){
        int x,y;
        scanf("%d%d",&x,&y);
        add(x,y);
    }
    topsort();
    for(int i=1;i<=cnt;++i){
        printf("%d ",a[i]);
    }
    cout<<endl;
    return 0;
}

Floyd

// 设 D[k,i,j] 表示“经过若干个编号不超过 k 的节点”从i到j的最短路长度该问题可划分为两个子问题,经过编号不超过 k -1 的节点从i到j,或者从i先到k再到j。于是:D[k,i,j] = min(D[k - 1,i,j],D[k - 1,i,k]+ D[k - 1,k,j)
// 初值D[0,i,j]=A[i,j]
// 可以看到,Floyd 算法的本质是动态规划。k 是阶段,所以必须置于最外层循环中i和是附加状态,所以应该置于内层循环。
// 与背包问题的状态转移方程类似,k 这一维可被省略。最初,我们可以直接用 D保存邻接矩阵,然后执行动态规划的过程。当最外层循环到 k 时,内层有状态转移:D[i,j] = min(D[i,j],D[i, k] + D[k,j])最终 D[i,j] 就保存了i到j的最短路长度。
int dist[310][310], n, m;
int main() {
    cin >> n >> m;
    memset(dist, 0x3f, sizeof dist);
    for (int i = 1; i <= n; ++i) dist[i][i] = 0;
    for (int i = 1; i <= m; ++i) {
        int x, y, z;
        cin >> x >> y >> z;
        dist[x][y] = min(dist[x][y], z);
    }
    // floyd
    for (int k = 1; k <= n; ++k) 
        for (int i = 1; i <= n; ++i) 
            for (int j = 1; j <= n; ++j) {
                dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
            }
    return 0;
}
// 使用Floyd也可以解决传递闭包的问题,dist[i][j]=1,表i与j有关系,0,没关系,d[i][i]始终为1
int dist[310][310], n, m;
int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) dist[i][i] = 1;
    for (int i = 1; i <= m; ++i) {
        int x, y, z;
        cin >> x >> y >> z;
        dist[x][y] = dist[y][x]=1;
    }
    // floyd
    for (int k = 1; k <= n; ++k) 
        for (int i = 1; i <= n; ++i) 
            for (int j = 1; j <= n; ++j) {
                dist[i][j] |=dist[i][k] & d[k][j];
            }
    return 0;
}

无向图最小环

//考虑 Floyd 算法的过程。当外层循环 k 刚开始时,d[i,j] 保存着“经过编号不超过 k-1的节点”从到的最短路长度。
// 于是,min{d[i, j] + a[j, k] + a[k, i]}(1<=i
//    1.由编号不超过 k 的节点构成
//    2.经过节点 k。
//  上式中的 i,j 相当于枚举了环上与 k 相邻的两个点。故以上结论显然成立。
// 所有的k 属于[1, n],都对上式进行计算,取最小值,即可得到整张图的最小环。
// 在该算法中,我们对每个k只考虑了由编号不超过 k 的节点构成的最小环,没有考虑编号大于 k 的节点。事实上,由对称性可知,这样做并不会影响结果。
#include 
#include 
#include 

using namespace std;

const int N = 110, M = 10010;

int a[N][N], d[N][N], pos[N][N];    //pos记录当前状态由哪个点转移过来
int n, m, ans = 0x3f3f3f3f;
vector<int> path;       // 具体方案

// 将点x到点y的经过的点加入到path中,(不包括x和y)
void get_path(int x, int y) {
    if (pos[x][y] == 0) return;     // x和y是直接相连的,中间没有点
    // x->pos[x][y]->y
    get_path(x, pos[x][y]);             // 递归x->pos[x][y]
    path.push_back(pos[x][y]);          
    get_path(pos[x][y], y);             // 递归pos[x][y]->y
}
int main() {
    cin >> n >> m;
    memset(a, 0x3f, sizeof a);
    for (int i = 1; i <= n; ++i) a[i][i] = 0;
    for (int i = 1; i <= m; ++i) {
        int x, y, z;
        cin >> x >> y >> z;
        a[x][y] = a[y][x] = min(a[x][y], z);
    }
    memcpy(d, a, sizeof a);

    for (int k = 1; k <= n; ++k) {
        for (int i = 1; i < k; ++i) 
            for (int j = i + 1; j < k; ++j) 
                if ((long long)d[i][j] + a[j][k] + a[k][i] < ans) {     // 初始化默认值较大,需要long long
                    ans = d[i][j] + a[j][k] + a[k][i];
                    path.clear();
                    path.push_back(i);
                    get_path(i, j);
                    path.push_back(j);
                    path.push_back(k);
                }
            
        

        for (int i = 1; i <= n; ++i) 
            for (int j = 1; j <= n; ++j) 
                if (d[i][j] > d[i][k] + d[k][j]) {
                    d[i][j] = d[i][k] + d[k][j];
                    pos[i][j] = k;      // 记录由i到j是经过那个点来的
                }
            
        
    }

    if (ans == 0x3f3f3f3f) {
        puts("No solution.");
    } else {
        for (auto i : path)
            cout << i << ' ';
        cout << endl;
    }
    return 0;
}

有向图最小环

// 对于有向图的最小环问题,可枚举起点 s = 1~n,执行堆优化的 Diikstra 算法求解单源最短路径。s一定是第一个被从堆中取出的节点,我们扫描 s 的所有出边,当扩展、更新完成后,令 d[s] = +oo,然后继续求解。当 s 第二次被从堆中取出时,d[s]就是经过点 s 的最小环长度。

中序后序构造二叉树

class Solution {
    int post_idx;
    unordered_map<int, int> idx_map;
public:
    TreeNode* helper(int in_left, int in_right, vector<int>& inorder, vector<int>& postorder){
        // 如果这里没有节点构造二叉树了,就结束
        if (in_left > in_right) {
            return nullptr;
        }

        // 选择 post_idx 位置的元素作为当前子树根节点
        int root_val = postorder[post_idx];
        TreeNode* root = new TreeNode(root_val);

        // 根据 root 所在位置分成左右两棵子树
        int index = idx_map[root_val];

        // 下标减一
        post_idx--;
        // 构造右子树
        root->right = helper(index + 1, in_right, inorder, postorder);
        // 构造左子树
        root->left = helper(in_left, index - 1, inorder, postorder);
        return root;
    }
    TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) {
        // 从后序遍历的最后一个元素开始
        post_idx = (int)postorder.size() - 1;

        // 建立(元素,下标)键值对的哈希表
        int idx = 0;
        for (auto& val : inorder) {
            idx_map[val] = idx++;
        }
        return helper(0, (int)inorder.size() - 1, inorder, postorder);
    }
};

字典树/前缀树

Trie(字典树)是一种用于实现字符串快速检索的多叉树结构。Trie的每个节点都拥有若干个字符指针,若在插入或检索字符串时扫描到一个字符c,就沿着当前节点的c字符指针,走向该指针指向的节点。

// init
int trie[SIZE][26],tot=1;	// 初始化,假设字符串由小写字母构成
bool end[SIZE];
// insert
void insert(char* str){		// 插入一个字符串
    int len=strlen(str),p=1;
    for(int k=0;k<len;++k){
        int ch=str[k]-'a';
        if(trie[p][ch]==0) trie[p][ch]=++tot;
        p=trie[p][ch];
    }
    end[p]=true; 
}
// search
bool search(char* str){		// 检查字符串是否存在
    int len=strlen(str),p=1;
    for(int k=0;k<len;++k){
        p=trie[p][str[k]-'a'];
        if(p==0) return false;
    }
    return end[p];
}

小算法

二维数组前缀和

long long sum[N][N];
long long arr[N][N];
for (register int i = 1; i <= n; ++i) {
		for (register int j = 1; j <= m; ++j) {
			cin >> arr[i][j];
			sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + arr[i][j];
		}
	}

坐标旋转

将坐标(x,y)绕原点旋转a度

算法学习总结_第6张图片

最小表示法

所有循环同构串的最小字典序称为最小表示法

// 求循环同构串的最小表示法, 本例中b是循环同构串的其中一个表示,长度为6
void get_min(int* b)
{
    static int a[12];
    for (int i = 0; i < 12; i++) a[i] = b[i % 6];   // 将b串复制两份到a

    int i = 0, j = 1, k;
    while (i < 6 && j < 6)
    {
        for (k = 0; k < 6 && a[i + k] == a[j + k]; k++);    // 找不同
        if (k == 6) break;  // 找完了还没找到,break

        if (a[i + k] > a[j + k])    
        {
            i += k + 1;  
            if (i == j) i++;    // 两个串得错开,要是一直相等,就找不出来了
        } else
        {
            j += k + 1;
            if (i == j) j++;
        }
    }

    k = min(i, j);
    // 将最小表示复制到b
    for (i = 0; i < 6; i++) b[i] = a[i + k];
}
// 一般形式代码
int n = strlen(s + 1);
for (int i = 1; i <= n; i++) s[n + i] = s[i];
int i = 1, j = 2, k;
while (i <= n && j <= n) {
    for (k = 0; k < n && s[i + k] == s[j + k]; k++);
    if (k == n) break; // s形如"catcat",, 它的循环元已扫描完成
    if (s[i + k] > s[j + k]) {
        i = i + k + 1; if (i == j) i++;
    } else {
        j = j + k + 1; if (i == j)j++;
    }
}
ans = min(i, j); // B[ans]是最小表示

虚拟索引/数组索引映射

  • 引入题目

首先引入一道题,将一个乱序数组排序后,将前面一半翻转,将后面一半翻转,例如:

输入:4,1,2,3,5
输出:3,2,1,5,4

输入:5,2,3,1
输出:2,1,5,3

其实这个题目很简单,只需要将数组排序后,分为前后两部分,分别翻转即可。

void solution(vector<int> &nums)
{
    int n = nums.size();
    //排序
    for (int i = 0; i < n; ++i)
    {
        for (int j = 0; j < n -  1 - i; ++j)
        {
            if (nums[j + 1] < nums[j])
            {
                swap(nums[j + 1], nums[j]);
            }
        }
    }
    //翻转
    for (int i = 0; i < n / 4; ++i)
    {
        swap(nums[i], nums[(n - 1) / 2  - i]);
        swap(nums[(n + 1) / 2 + i], nums[n - 1 - i]);
    }
}
  • 引入概念

基本上上面的做法是大家常规的一种做法,但是有其他也比较简洁的做法吗?

上面的算法分为了:1.排序, 2.翻转两个步骤。有没有可能将两个步骤合并一起做呢?

我们先看,翻转实际上是对原数组的一种映射:

1,2,3,4,5 ———> 3,2,1,5,4

映射为: 0→2,1→1,2→0,3→4,4→3

1,2,3,4 ———> 2,1,4,3

映射为: 0→1,1→0,2→3,3→2

如果将原来的索引记为x,映射后的索引为y,不难得到映射关系为:y = f(x) = ((n-1)/2-x)%n

我们可以将新索引看做是一个新的数组b,那么:a[x]=b[f(x)]

那么我们只需要对:b[f(j)] 排序即可。

void solution(vector<int> &nums)
{
    int n = nums.size();   
	#define a(i) nums[((3*n-1)/2 - (i))%n     

    //排序
    for (int i = 0; i < n; ++i)
    {
        for (int j = 0; j < n -  1 - i; ++j)
        {
            if (a(j+1) < a(j))
            {
                swap(a(j + 1), a(j));
            }
        }
    }
}

需要理解的是索引映射是对索引(下标)进行的映射,与其对应的元素无关,下标映射位置是固定的。

杂项

一下杂项与本文无关,仅为个人附带记录。

Part1

  1. 数据一定放在private里(封装性)

  2. 函数参数尽量使用引用,若不想修改引用的对象可在参数前加const(效率)

  3. 如果可以,函数返回值尽量使用引用(效率)

  4. 构造函数初始化尽量使用初始化列表(效率)

  5. 类的成员函数应该加const就要加,即不会对数据做修改的要加const(正确性)

  6. 使用模板中,在类内不用再<>类型说明了

  7. 对于函数返回值,可以返回列表来初始化,或者返回隐式构造的对象,能减少一次对象拷贝的操作

  8. 不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或返回值类型的函数。

  9. 必须在类的外部定义和初始化每个静态成员

  10. 想要使用移动构造和移动赋值得用std::move

  11. 若用户定义有参构造,编译器不会提供默认构造,

  12. 若用户定义拷贝构造,编译器不再提供其他构造函数

  13. 只有一个类没有定义任何自己版本的拷贝控制成员(拷贝构造,拷贝赋值,析构),且类的每个非static数据成员都可以移动时,编译器才会为其合成移动构造函数和移动赋值运算符。

  14. ps -Lf [PID] 查看线程相关信息

  15. int gcd(int a,int b){return b==0?a:gcd(b,a%b);}
    int lcm(int a,int b){return a/gcd(a,b)*b;}
    
  16. 取模运算,a%b,模的结果的符号取决于a的符号,如21%6=3,-21%6=-3,与b的符号无关

  17. 堆排,升序建大堆,降序建小堆,priority_queue默认less,大根堆,less<>:retuan a.x :return a.x>b.x

  18. 默认情况下,标准库在元素类型上使用<运算符来确定相对优先级,priority_queue中,默认less<>建大根堆,在自定义类型时,会调用operator<,若返回true,说明此此元素的父节点小于此节点,需要向上调整。所以priority_queue默认使用时,而又想建小堆,要重载operator<,需要return a.x>b.x,方可。

  19. sort以及priori_queue要求的比较器要求严格若排序,即自定义比较时不能出现<= 或 >=这样破坏严格弱排序的定义规则。

  20. C/C++中左移都是逻辑左移,而右移根据有符号或无符号分为算术右移以及逻辑右移,同时汇编指令也是这样,分为左移,逻辑右移,算术右移

Part2

  1. stl 的sort排序是不稳定的,顺序可能会改变,stable_sort可以稳定排序,基于归并排序

  2. 在传参时,如实参int *****,形参const int ***** 时是可以的,可以通过隐式转换为对应的类型,但若形参是const int *&时,则会报错,报“无法从int *****转为 const int *&”,说明隐式转换"只能转换一个属性",不能即加const又加&,当实参是const int *****时,只需要再隐式添加“一个&属性”即可。

  3. C++queue容器没有清空操作,可以采用swap(empty,Q);

  4. master公式
    算法学习总结_第7张图片

  5. 两个补码,负数相加负溢出截断后结果>=0,正数相加溢出后结果<0

Part3

汇编

MOV类

1.把数据从源位置复制到目的位置。源位置和目的位置可以是内存地址或寄存器,但两个位置不能都是内存地址。

2.寄存器的部分必须与指令最后一个字符(‘b’,‘w’,‘l’,‘q’)指定的大小相匹配,即若是两个操作数都是寄存器的话,两个寄存器的“长度”必须一样。

3.MOV指令只会只会更新目的操作数指定的那些寄存器字节或内存位置。唯一例外,movl指令以寄存器作为目的时,会把该寄存器的高4位字节设置为0。

MOVZ类和MOVS类

两类数据移动指令,都是将较小的源值复制到较大的目的位置。源可以时寄存器或内存地址,但目的地址只能是寄存器。

movz是零扩展,但其中并没有所谓的movzlq,而是通过movl来实现。

movs是符号扩展,若源是无符号,用零扩展,若源是有符号,用符号扩展。

Part4

  1. 谨记数据范围,范围太大,long long
  2. 谨记勿把测试案例数据本照搬到代码
  3. 谨记int 大概可以完整存储 13!,long long 大概可以完整存储20!
  4. 注意注意,看清数据特别是填空题。!!! 对于某些题,特别是填空题,若所给数据范围有所限制,可通过枚举求解
    算法学习总结_第8张图片
  • C++ API ,nth_element,让数组第nth位置上的元素去到其该去的地方(从0开始),同时其左边的数不大于右边的数。
//可以排序规则为自定义的 comp 排序规则,若不指定,默认使用<比较
void nth_element (RandomAccessIterator first,
                  RandomAccessIterator nth,
                  RandomAccessIterator last,
                  Compare comp);
// 时间O(N) ,空间O(1)

你可能感兴趣的:(算法与数据结构,算法,c++,ACM,数据结构)