author:SmallBoat
所谓基础,指的是base
,而非easy
。—— yxc
第一章:基础算法
1. 1 快速排序O ( n log n ) \mathcal{O}(n \log n) O ( n log n ) :快速排序
算法思想:分治
确定分界点 x = q[l + r >> 1]
调整区间:让第一个区间里的数小于等于x
, 让第二个区间的数大于等于x
递归处理左右两边
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public static void quick_sort (int [] q, int l, int r) { if (l >= r) return ; int i = l - 1 , j = r + 1 , x = q[l + r >> 1 ]; while (i < j) { do i ++; while (q[i] < x); do j --; while (q[j] > x); if (i < j) { int tmp = q[i]; q[i] = q[j]; q[j] = tmp; } } quick_sort(q, l, j); quick_sort(q, j + 1 , r); }
1.2 归并排序O ( n log n ) \mathcal{O}(n \log n) O ( n log n )
1.2.1 归并排序:归并排序
算法思想:分治
确定分界点 mid = r + l >> 1
递归处理左右两边
归并,将两个区间合二为一(从递归后的最底层开始合并,每次回溯后达到排序的效果)(O ( n ) \mathcal{O}(n) O ( n ) )
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public static void merge_sort (int [] q, int l, int r) { if (l >= r) return ; int mid = l + r >> 1 ; merge_sort(q, l, mid); merge_sort(q, mid + 1 , r); int k = 0 , i = l, j = mid + 1 ; while (i <= mid && j <= r) if (q[i] <= q[j]) tmp[k ++] = q[i ++]; else tmp[k ++] = q[j ++]; while (i <= mid) tmp[k ++] = q[i ++]; while (j <= r) tmp[k ++] = q[j ++]; for (i = l, j = 0 ; i <= r; i ++, j ++) q[i] = tmp[j]; }
1.2.2 归并求逆序对:逆序对的数量
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static long merge_sort (int l, int r) { if (l >= r) return 0 ; int mid = l + r >> 1 ; int k = 0 , i = l, j = mid + 1 ; long res = merge_sort(l, mid) + merge_sort(mid + 1 , r); while (i <= mid && j <= r) if (q[i] <= q[j]) tmp[k ++] = q[i ++]; else { tmp[k ++] = q[j ++]; res += mid - i + 1 ; } while (i <= mid) tmp[k ++] = q[i ++]; while (j <= r) tmp[k ++] = q[j ++]; for (i = l, j = 0 ; i <= r; i ++, j ++) q[i] = tmp[j]; return res; }
1.3 整数二分O ( log n ) \mathcal{O}(\log n) O ( log n ) :数的范围
算法思想:
时时刻刻保证答案在所维护的区间内部。
check()
函数为满足条件部分,例如:1、2、3、3、4、5
中,求3
的起始位置,则所求位置在所有3
范围的最左侧,于是需要保证每次mid
需要在最左边一个3
或其右侧,故check()
函数为if (q[mid] >= 3)
;若求3
的最后位置,同理check()
函数为if (q[mid] <= 3)
。每次判断后需要调整区间端点,例如在求3
的左端点时,如果满足q[mid] >= 3
,则说明所求位置在mid
左侧或就是mid
,则将区间右端点更新为mid
,即r = mid
。
每次二分之前需将l、r
初始化,注意: 更新时,若r
被更新为mid - 1
,则mid
应初始化为mid = l + r + 1 >> 1
,避免更新边界后范围不变,出现死循环。
能使用二分的条件:必须要有二段性 ,即一定存在一个分界点,使得分界点一边(包括分界点)满足题目要求,另一边不满足。(参考二分算法详解 )
二分的题目有两种,一种是直接裸的二分题目,另一种则是枚举可能的答案,然后check
这个答案是否可行 (二分答案)。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public static boolean check (int x) {} public static int bsearch_1 (int l, int r) { while (l < r) { int mid = l + r >> 1 ; if (check(mid)) r = mid; else l = mid + 1 ; } return l; } public static int bsearch_2 (int l, int r) { while (l < r) { int mid = l + r + 1 >> 1 ; if (check(mid)) l = mid; else r = mid - 1 ; } return l; }
1.4 浮点数二分O ( log n ) \mathcal{O}(\log n) O ( log n ) :数的三次方根
算法思想:
算法原理与整数二分相似,在区间划分的时候没有整数二分的各种边界情况,一般用左右端点的差值是否小于某个值ϵ \epsilon ϵ 来判定是否需要继续循环。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 public static boolean check (double x) {} public static double bsearch_3 (double l, double r) { final static double eps = 1e-8 ; while (r - l > eps) { double mid = (l + r) / 2 ; if (check(mid)) r = mid; else l = mid; } return l; }
1.5 一维前缀和:前缀和
算法思想:
在输入时,顺便记录前i
项的和,i
从1 ~ n
,记为:s[i]
,当需要求第i
个数到第j
个数的和时,直接使用s[j] - s[i - 1]
,时间复杂度为O(1)
,比重新遍历更快。
但是,如果数据会发生改变,则前缀和数组也会发生改变,而这种方式求解前缀和是O ( n ) \mathcal{O}(n) O ( n ) 的。则此时需要使用树状数组或者线段树进行求解(详见第二章2.9
)。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 s[i] = a[1 ] + a[2 ] + ... a[i] for (int i = 1 ; i <= n; i ++) { a[i] = sc.nextInt(); sum[i] = sum[i - 1 ] + a[i]; } for (int i = 1 ; i <= n; i ++) s[i] = s[i - 1 ] + sc.nextInt();a[l] + ... + a[r] = s[r] - s[l - 1 ]
算法思想:
二维前缀和思想类似于一维前缀和,用s[i][j]
表示以(i,j)
为右下角,(0,0)
为左上角的子矩阵中所有数之和。可用于求解以(x1, y1)
为左上角,(x2, y2)
为右下角的子矩阵的和为(可利用容斥原理推导)。
代码实现:
1 2 3 4 5 6 for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= m; j ++) s[i][j] = s[i - 1 ][j] + s[i][j - 1 ] - s[i - 1 ][j - 1 ] + a[i][j]; int res = s[x2][y2] - s[x1 - 1 ][y2] - s[x2][y1 - 1 ] + s[x1 - 1 ][y1 - 1 ];
1.7 一维差分:差分
算法思想:
实际上,差分与前缀和互为逆运算,例如,如果数组s
为数组a
的前缀和数组,那么a
则称为s
的差分数组,即a[i] = s[i] - s[i - 1]
。
差分数组的作用在于,如果需要对一个数组中的l ~ r
区间的k
个数字均加上c
,则时间复杂度与k
相关,而对其差分数组进行操作,则只需在其差分数组的第l
项加c
,再对第r + 1
项减去c
,时间复杂度降为O ( 1 ) \mathcal{O}(1) O ( 1 ) 。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public static void insert (int l, int r, int c) { a[l] += c; a[r + 1 ] -= c; } for (int i = 1 ; i <= n; i ++) s[i] = sc.nextInt(); for (int i = 1 ; i <= n; i ++) insert(i, i, s[i]); int l = sc.nextInt(), r = sc.nextInt(), c = sc.nextInt();insert(l, r, c); for (int i = i; i <= n; i ++) b[i] += b[i - 1 ];
1.8 二维差分:差分矩阵
算法思想:
类似于一维差分,二维差分与二维前缀和互为逆运算,例如,数组s
是数组b
的前缀和数组,那么b
就称为s
差分数组。
二维差分数组的作用在于,如果需要对二维矩阵中以(x1, y1)
为左上角,(x2, y2)
为右下角的区域内每个数加上c
,那么可以对差分矩阵中b[x1][y1]
加上c
,对b[x2 + 1][y1]
与b[x1][y2 + 1]
减去c
,再对b[x2 + 1][y2 + 1]
减去c
。因为对b[x1][y1]
,将会导致求前缀和后,s[x1][y1]
到右下角这部分区域每个数都加上c
,因此,根据容斥原理,需对其他部分进行如上处理。
同时,与一维差分类似,二维差分也不用去想如何构造差分数组,也可以利用插入操作完成。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public static void insert (int x1, int y1, int x2, int y2, int c) { b[x1][y1] += c; b[x2 + 1 ][y1] -= c; b[x1][y2 + 1 ] -= c; b[x2 + 1 ][y2 + 1 ] += c; } for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= m; j ++) s[i][j] = sc.nextInt(); for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= m; j ++) insert(i, j, i, j, s[i][j]); int x1 = sc.nextInt(), y1 = sc.nextInt(), x2 = sc.nextInt(), y2 = sc.nextInt(), c = sc.nextInt();insert(x1, y1, x2, y2, c); for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= m; j ++) b[i][j] += b[i][j - 1 ] + b[i - 1 ][j] - b[i - 1 ][j - 1 ];
算法思想:
双指针算法是优化枚举最常用的算法,其核心思想在于通过找到单调性,将O ( n 2 ) \mathcal{O}(n^2) O ( n 2 ) 的暴力枚举转变成O ( n ) \mathcal{O}(n) O ( n ) 的双指针算法。
该算法有两类,第一类为类似于归并排序中,在两个区间上归并的操作。第二类则在一个区间上动态维护一个小区间。
代码实现:
1 2 3 4 5 6 7 for (int i = 0 , j = 0 ; i < n; i ++) { while (j < i && check(i, j)) j ++; }
最长连续不重复子序列题解(第二类):
题目描述: 给定一个长度为n
的整数序列,请找出最长的不包含重复的数的连续区间,输出它的长度。输入格式: 第一行包含整数n
。 第二行包含n
个整数,表示整数序列。输出格式: 共一行,包含一个整数,表示最长的不包含重复的数的连续区间的长度。数据范围: 1 ≤ n ≤ 1 0 5 1 \leq n\leq 10^5 1 ≤ n ≤ 1 0 5 输入样例:
输出样例:
双指针算法 O ( n ) \mathcal{O}(n) O ( n ) : 定义两个指针i, j
,数组a
,i
从第一个元素开始往后走,每经过一个元素,用s
数组记录当前数字出现的次数,当走到某个元素a[i]
时,若s[a[i]] > 1
,则说明该数字前面出现过一次,此时让j
指针向后走,j
指针每经过一个元素,就让该元素出现次数就减一,当不满足s[a[i]] > 1
时,说明j
刚好经过与a[i]
重复的那个数值,则此时j
不再向后走,记录j ~ i
之间元素个数,然后i
继续向后走,重复此过程,直到走完整个数组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import java.util.Scanner;public class Main { static final int N = 100010 ; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(); int [] a = new int [n]; int [] s = new int [N]; for (int i = 0 ; i < n; i ++) a[i] = sc.nextInt(); int res = 0 ; for (int i = 0 , j = 0 ; i < n; i ++) { s[a[i]] ++; while (j < i && s[a[i]] > 1 ) { s[a[j]] --; j ++; } res = Math.max(res, i - j + 1 ); } System.out.println(res); } }
这道题还有滑动窗口解法,滑动窗口更好理解,许多双指针理解起来较为抽象的题目均可使用滑动窗口解决,详见第二章2.5
滑动窗口模板。
算法思想:
该部分不存在什么思想,更多的是语法,会用即可。用法一: 求n
的二进制表示中第k
位(从右往左看,最右边为第0
位)数字。原理: n >> k & 1。用法二: lowbit
操作,返回x
的最后一位1
,例如x = (1010)₂
,则lowbit(x) = 10
;若x = (101000)₂
,则lowbit(x) = 1000
,具体见代码实现。原理: 因为x & -x = x & (~x + 1)
,而x & (~x + 1)
能返回x
的最后一位1
,所以一般用x & -x
求解x
的最后一位1
。(lowbit
操作是树状数组的基础。)
代码实现:
1 2 3 4 5 6 7 8 9 10 11 int res = n >> k & 1 ;public static int lowbit (int x) { return x & -x; } int cnt = Integer.bitCount(x);
1.11 离散化:离散化
算法思想:
离散化的思想是将数值范围很大,但是数据量不大的一系列数映射到从0
开始的有序递增的区间,从而降低算法的时间和空间复杂度。离散化不改变数据间的相对大小,压缩数据间无用的距离。例如,1, 3, 200, 48, 67349, 6546646
这一系列数的范围为1 ~ 6546646
,中间有许多无用的距离,我们将其压缩到0 ~ 5
这几个位置(有时映射到从1
开始更加方便),此时就产生了映射关系0 -> 1, 1 -> 3, 2 -> 48, 3 -> 200, 4 -> 67349, 5 -> 6546646
。这与直接开一个数组将他们存进去再排序是有本质区别的,例如我们想对200
这个值加上50
,如果直接开数组,想要找到200
这个值,需要遍历一遍,而如果是通过离散化映射,我们可以直接利用映射关系找到在什么位置,然后直接进行操作,该步骤时间复杂度直接降低到O ( 1 ) \mathcal{O}(1) O ( 1 ) 。在映射之前,我们需要对数据进行排序,便于后面用整数二分找到每个数对应的映射值(下标)。同时需要对数据进行去重,因为即使有两个200
,我们每次对200
操作时,也是在同一个位置上进行操作的,因此重复的那个200
不但没有存在的意义,反而影响在二分时寻找其映射值(下标)。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Arrays.sort(alls); public static int find (int x) { int l = 0 , r = alls.size() - 1 ; while (l < r) { int mid = l + r >> 1 ; if (alls[mid] >= x) r = mid; else l = mid + 1 ; } return r + 1 ; }
1.12 区间合并:区间合并
算法思想:
区间合并是将数轴上所有有交集的区间进行合并,得到没有交集的区间。例如将区间[0, 2], [3, 7], [4, 5], [7, 10], [13, 15]
合并后的区间为[0, 2], [3, 10], [13, 15]
。其思想类似于贪心,先将所有区间按照左端点进行排序,每次维护一个区间,如果 枚举的区间与当前区间无交集,则将维护的区间放入答案中去,再将维护的区间更新为枚举的区间,否则 将维护的区间的右端点更新为维护区间与枚举区间右端点的最大值。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 class PII implements Comparable <PII> { int x, y; public PII (int x, int y) { this .x = x; this .y = y; } public int compareTo (PII p) { return x - p.x; } } public static ArrayList<PII> merge (ArrayList<PII> segs) { ArrayList<PII> res = new ArrayList (); Collections.sort(segs); int l = -2000000000 , r = -2000000000 ; for (var seg : segs) if (seg.x > r) { if (l != -2000000000 ) res.add(new PII (l, r)); l = seg.x; r = seg.y; } else r = Math.max(r, seg.y); if (l != -2000000000 ) res.add(new PII (l, r)); return res; }
新总结的模板(2024-03-12):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import java.util.*;public class Main { static class Segement implements Comparable <Segement> { int x, y; public Segement (int x, int y) { this .x = x; this .y = y; } @Override public int compareTo (Segement s) { if (x != s.x) return x - s.x; return s.y - y; } } static final int N = 100010 ; static Segement[] segments = new Segement [N]; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(); for (int i = 0 ; i < n; i ++) segments[i] = new Segement (sc.nextInt(), sc.nextInt()); Arrays.sort(segments, 0 , n); int lastEnd = segments[0 ].y, res = 1 ; for (int i = 1 ; i < n; i ++) { int x = segments[i].x, y = segments[i].y; if (y <= lastEnd) continue ; if (x > lastEnd) res ++; lastEnd = y; } System.out.println(res); } }
第二章:数据结构
2.1 ~ 2.3节
在算法题中一般不会被直接用到,但是却是后面的邻接表(图论)和单调栈及单调队列的基础(当然啦,也可以被Java的集合代替 ,毕竟Java是最强语言 )。
2.1 单链表:单链表
算法思想:
该部分主要通过数组模拟单链表,进行插入结点、删除结点等操作。之所以需要用数组进行模拟,是因为在C ++
或java
中,new
操作是非常慢的,在数据范围比较大的情况和容易TLE
,并且,在很多时候,容器可以做的数组都可以做,而数组可以做的,容器不一定可以做。那么利用数组模拟就显得十分有优势。通过数组模拟单链表,需要定义head, e[], ne[], idx
,其中head
表示头结点,e[]
表示每个点的值,ne[]
表示每个点所指向的下一个点的下标,idx
表示当前已经用到了哪个点(此时idx
还没被用)。
该部分是后面邻接表(图论)部分的基础,十分重要。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 static int head;static int [] e = new int [N];static int [] ne = new int [N];static int idx;public static void init () { head = -1 ; idx = 0 ; } public static void addHead (int x) { e[idx] = x; ne[idx] = head; head = idx; idx ++; } public static void add (int k, int x) { e[idx] = x; ne[idx] = ne[k]; ne[k] = idx; idx ++; } public static void remove (int k) { if (k == 0 && head != -1 ) head = ne[head]; else ne[k] = ne[ne[k]]; } for (int i = head; i != -1 ; i = ne[i]) System.out.printf("%d " , e[i]);
2.2 双链表:双链表
算法思想:
与单链表相似,用数组模拟双链表。需定义数组e, l, r
和变量idx
,其中e
表示每个结点的值,l
表示结点的上一个结点下标,r
表示结点的下一个结点下标,idx
表示当前用到了哪个点(此时idx
还未被使用)。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 int e[N], l[N], r[N], idx;public static void init () { r[0 ] = 1 ; l[1 ] = 0 ; idx = 2 ; } public static void insert (int k, int x) { e[idx] = x; l[idx] = k; r[idx] = r[k]; l[r[k]] = idx; r[k] = idx ++ ; } public static void remove (int k) { l[r[k]] = l[k]; r[l[k]] = r[k]; } for (int i = r[0 ]; i != 1 ; i = r[i]) System.out.printf("%d " , e[i]);
算法思想:
用数组模拟栈和队列,操作相对简单,具体见代码实现。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 static int [] stk = new int [N];static int tt = 0 ; stk[ ++ t] = x; tt --; static int [] queue = new int [N];static int hh = 0 , tt = -1 ;queue[ ++ tt] = x; hh ++;
2.4 单调栈:单调栈
算法思想:
单调栈用于维护一个递增或递减的序列,可以快速求出每个数左/右边离它最近的比它大/小 的数。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 static final int N = 100010 ;int [] stk = new int [N];int n = sc.nextInt();int tt = 0 ;for (int i = 0 ; i < n; i ++) { int x = sc.nextInt(); while (tt > 0 && check(stk[tt])) tt --; if (tt > 0 ) System.out.printf("%d " , stk[tt]); else System.out.printf("%d " , -1 ); stk[++ tt] = x; }
2.5 单调队列:滑动窗口
算法思想:
单调队列与单调栈比较类似,用一个队列动态维护一组有序序列。每次先判断队头是否需要出栈,然后从队尾开始向前检查队尾元素与当前枚举元素的关系,如果满足check(a[i])
,则让队尾元素弹出,直到队尾元素不满足条件。最后再将当前枚举元素加入队列。值得注意的是,队列q[]
存储的是数组中元素的下标。
该算法常用的场景为求解滑动窗口中的最大值或最小值、单调队列优化 dp
。
Leetcode一题秒懂单调队列! (利用Deque实现单调队列)
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 int [] a = new int [N]; int [] q = new int [N]; int n = sc.nextInt(), k = sc.nextInt();int hh = 0 , tt = -1 ;for (int i = 0 ; i < n; i ++) { if (hh <= tt && i - k + 1 > q[hh]) hh ++; while (hh <= tt && a[i] <= a[q[tt]]) tt --; q[++ tt] = i; }
tt = 0
和 tt = -1
的情况可参考烽火传递 与E. Rudolf and k Bridges 这两题。
子串类问题(滑动窗口算法框架):
注意:单调队列并不一定对所有的滑动窗口题目好用,只是上面这道题使用单调队列能完美解决。当涉及到字串类问题时,优先选择滑动窗口框架。
看不懂模板可以先做做Leetcode 438. 找到字符串中所有字母异位词 ,再看这个模板框架。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public static void slidingWindow (String s, String p) { char [] cs = s.toCharArray(), cp = p.toCharArray(); HashMap<Character, Integer> need = new HashMap <>(); HashMap<Character, Integer> window = new HashMap <>(); int L = p.length(); for (int i = 0 ; i < L; i ++) { need.put(cp[i], need.getOrDefault(cp[i], 0 ) + 1 ); } int n = s.length(), m = need.size(); int left = 0 , right = 0 ; int valid = 0 ; while (right < n) { char c = cs[right]; right ++; ... while (window needs shrink) { char d = cs[left]; left ++; ... } } }
算法思想:
kmp
算法是比较经典的字符串匹配算法,kmp
是其三个发明人名字缩写。kmp
算法是将模式串P
与主串S
进行匹配,其核心思想是将已经匹配过的字符利用起来,例如主串S
为abaabac
,模式串P
为abac
,当匹配到第四个字符发现不匹配时,主串中前三个字符已经匹配成功,我们可以将这个信息利用起来,那么下次匹配可以将P
串的第一个字符直接与S
串的第四个字符开始匹配,跳过了中间的一段,从而降低算法时间复杂度。而P
串最少可以往前移动多少且可能匹配成功只取决于P
串本身以P[i]
结束的字串的前缀和后缀相等的最大值(next[i])
,这便是KMP
算法中比较抽象的next
数组的含义。
掌握kmp
算法的同时建议掌握字符串哈希 ,很多抽象的kmp
题目能够使用字符串哈希通过。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 for (int i = 2 , j = 0 ; i <= n; i ++) { while (j != 0 && p[i] != p[j + 1 ]) j = ne[j]; if (p[i] == p[j + 1 ]) j ++; ne[i] = j; } for (int i = 1 , j = 0 ; i <= m; i ++) { while (j != 0 && s[i] != p[j + 1 ]) j = ne[j]; if (s[i] == p[j + 1 ]) j ++; if (j == n) { j = ne[j]; bw.write(i - n + " " ); } } bw.flush();
2.7 Trie树/字典树:字符串统计
算法思想:
Tree
树是一种用于存储字符串的高效的数据结构,以一棵树的形式存储字符串中的每个字符,如果该字符已经存在,则不重新创建,否则创建该字符,并在树中每个字符串结尾的地方做标记,表示树中含有以该字符结尾的字符串。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 static int [][] son = new int [N][26 ]; static int idx; static int [] cnt = new int [N]; public static void insert (char [] str) { int p = 0 ; for (int i = 0 ; i < str.length; i ++) { int u = str[i] - 'a' ; if (son[p][u] == 0 ) son[p][u] = ++ idx; p = son[p][u]; } cnt[p] ++; } public static int query (char [] str) { int p = 0 ; for (int i = 0 ; i < str.length; i ++) { int u = str[i] - 'a' ; if (son[p][u] == 0 ) return 0 ; p = son[p][u]; } return cnt[p]; }
算法思想:
并查集的作用是在O ( 1 ) \mathcal{O}(1) O ( 1 ) 的时间内将两个集合合并。其原理为:让一个集合的根节点直接指向另一个集合的根节点,成为另一个集合的一个子集。可以通过寻找一个节点的祖宗节点判断该元素属于哪个集合,其时间复杂度与树的高度相关,所以,需要对其进行优化。
优化思想 :将每个节点直接指向其祖宗节点,该操作是在寻找祖宗节点的回溯过程中完成的。
查找两个点是否在同一个集合当中时,可以通过其祖宗节点是否是同一个节点进行判断。
规定: 祖宗节点的父节点等于自己(递归的退出条件)
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static int find (int x) { if (p[x] != x) p[x] = find(p[x]); return p[x]; } for (int i = 1 ; i <= n; i ++) p[i] = i; p[find(a)] = find(b); if (find(a) == find(b))
算法思想:
树状数组是一种能在O ( log n ) \mathcal{O}(\log n) O ( log n ) 时间复杂度内修改某个数,并动态求出前缀和的数据结构。 能够在O ( log n ) \mathcal{O}(\log n) O ( log n ) 内求出前缀和的原因:动态维护了一个数组(树状数组)。 为什么树状数组修改某个数需要O ( log n ) \mathcal{O}(\log n) O ( log n ) :我们是在维护的树状数组上进行修改的,因此需要修改该数并维护该树状数组。
注意,树状数组的下标必须从1
开始,否则会死循环,因为l o w b i t ( 0 ) ≡ 0 lowbit(0) \equiv 0 l o w b i t ( 0 ) ≡ 0 。
树状数组的三个核心方法:
lowbit
:用于定位其关联的数组元素下标;
add
:对元素增加一个值(修改操作需要转为增加操作来完成);
query
:查询以某个下标结尾的前缀和。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import java.util.*;public class Main { static final int N = 100010 ; static int [] a = new int [N], tr = new int [N]; static int n, m; public static int lowbit (int x) { return x & -x; } public static void add (int x, int v) { for (int i = x; i <= n; i += lowbit(i)) tr[i] += v; } public static int query (int x) { int res = 0 ; for (int i = x; i > 0 ; i -= lowbit(i)) res += tr[i]; return res; } public static void main (String[] args) { Scanner sc = new Scanner (System.in); n = sc.nextInt(); m = sc.nextInt(); for (int i = 1 ; i <= n; i ++) a[i] = sc.nextInt(); for (int i = 1 ; i <= n; i ++) add(i, a[i]); while (m -- > 0 ) { int k = sc.nextInt(), x = sc.nextInt(), y = sc.nextInt(); if (k == 0 ) System.out.println(query(y) - query(x - 1 )); else add(x, y); } } }
算法思想:
线段树是一种能在O ( log n ) \mathcal{O}(\log n) O ( log n ) 内求出一段区间内的某个属性(最大值
、最小值
、sum
等)的数据结构。
一般来说,树状数组能干的,线段树都能干,因为线段树更加灵活。但是,由于线段树的常数太大,所以会比树状数组慢比较多。
线段树的四个核心方法:
pushup
:用子节点的信息更新当前节点信息(有时不用显式地写出);
build
:在一段区间上初始化线段树;
modify
:修改某个数的属性,同时维护线段树;
query
:查询属性。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import java.io.*;public class Main { static class Segment { int l, r, max; public Segment (int l, int r, int max) { this .l = l; this .r = r; this .max = max; } } static final int N = 100010 ; static int [] w = new int [N]; static Segment[] tr = new Segment [4 * N]; public static void build (int u, int l, int r) { if (l == r) tr[u] = new Segment (l, r, w[r]); else { tr[u] = new Segment (l, r, 0 ); int mid = l + r >> 1 ; build(u << 1 , l, mid); build(u << 1 | 1 , mid + 1 , r); tr[u].max = Math.max(tr[u << 1 ].max, tr[u << 1 | 1 ].max); } } public static int query (int u, int l, int r) { if (tr[u].l >= l && tr[u].r <= r) return tr[u].max; int mid = tr[u].l + tr[u].r >> 1 ; int max = Integer.MIN_VALUE; if (l <= mid) max = query(u << 1 , l, r); if (r > mid) max = Math.max(max, query(u << 1 | 1 , l, r)); return max; } public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); BufferedWriter bw = new BufferedWriter (new OutputStreamWriter (System.out)); String[] s1 = br.readLine().split(" " ); int n = Integer.parseInt(s1[0 ]), m = Integer.parseInt(s1[1 ]); String[] s2 = br.readLine().split(" " ); for (int i = 1 ; i <= n; i ++) w[i] = Integer.parseInt(s2[i - 1 ]); build(1 , 1 , n); while (m -- > 0 ) { String[] s = br.readLine().split(" " ); int x = Integer.parseInt(s[0 ]), y = Integer.parseInt(s[1 ]); bw.write(query(1 , x, y) + "\n" ); } bw.flush(); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 import java.util.*;public class Main { static class Segment { int l, r, sum; public Segment (int l, int r, int sum) { this .l = l; this .r = r; this .sum = sum; } } static final int N = 100010 ; static Segment[] tr = new Segment [4 * N]; static int [] w = new int [N]; public static void pushup (int u) { tr[u].sum = tr[u << 1 ].sum + tr[u << 1 | 1 ].sum; } public static void build (int u, int l, int r) { if (l == r) tr[u] = new Segment (l, r, w[r]); else { tr[u] = new Segment (l, r, 0 ); int mid = l + r >> 1 ; build(u << 1 , l, mid); build(u << 1 | 1 , mid + 1 , r); pushup(u); } } public static int query (int u, int l, int r) { if (tr[u].l >= l && tr[u].r <= r) return tr[u].sum; int mid = tr[u].l + tr[u].r >> 1 ; int res = 0 ; if (l <= mid) res = query(u << 1 , l, r); if (r >= mid + 1 ) res += query(u << 1 | 1 , l, r); return res; } public static void modify (int u, int x, int v) { if (tr[u].l == tr[u].r) tr[u].sum += v; else { int mid = tr[u].l + tr[u].r >> 1 ; if (x <= mid) modify(u << 1 , x, v); else modify(u << 1 | 1 , x, v); pushup(u); } } public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(), m = sc.nextInt(); for (int i = 1 ; i <= n; i ++) w[i] = sc.nextInt(); build(1 , 1 , n); while (m -- > 0 ) { int k = sc.nextInt(), x = sc.nextInt(), y = sc.nextInt(); if (k == 0 ) System.out.println(query(1 , x, y)); else modify(1 , x, y); } } }
2.11 字符串哈希:字符串哈希
算法思想:
这里的字符串哈希是一种特殊的哈希方式,即字符串前缀哈希,其思想是将字符串转换为一个P
进制的数,P
的经验取值为131
或13331
。 例如,将SmallBoat
转换为P
进制的数(该字符串的哈希值)为:H [ n ] = H [ 9 ] = H [ n − 1 ] × P + s t r [ n ] = H [ 8 ] × P + s t r [ 9 ] H[n] = H[9] = H[n - 1] \times P + str[n] = H[8] \times P + str[9] H [ n ] = H [ 9 ] = H [ n − 1 ] × P + s t r [ n ] = H [ 8 ] × P + s t r [ 9 ]
计算时会自动将字母转换为对应的ASCII
编码值
那么,给定任意的l , r ∈ [ 0 , n − 1 ] l, r \in [0, n - 1] l , r ∈ [ 0 , n − 1 ] ,l ∼ r l \sim r l ∼ r 这段字符的哈希值为:H [ l ∼ r ] = H [ r ] − H [ l − 1 ] × P r − l + 1 H[l \sim r] = H[r] - H[l - 1] \times P^{r - l + 1} H [ l ∼ r ] = H [ r ] − H [ l − 1 ] × P r − l + 1
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import java.io.*;public class Main { static final int N = 100010 , P = 131 ; static long [] h = new long [N], p = new long [N]; static int n, m; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); BufferedWriter bw = new BufferedWriter (new OutputStreamWriter (System.out)); String[] s1 = br.readLine().split(" " ); n = Integer.parseInt(s1[0 ]); m = Integer.parseInt(s1[1 ]); char [] str = (" " + br.readLine()).toCharArray(); p[0 ] = 1 ; for (int i = 1 ; i <= n; i ++) { p[i] = p[i - 1 ] * P; h[i] = h[i - 1 ] * P + str[i]; } while (m -- > 0 ) { String[] s2 = br.readLine().split(" " ); int l1 = Integer.parseInt(s2[0 ]), r1 = Integer.parseInt(s2[1 ]); int l2 = Integer.parseInt(s2[2 ]), r2 = Integer.parseInt(s2[3 ]); if (getHash(l1, r1) == getHash(l2, r2)) bw.write("Yes\n" ); else bw.write("No\n" ); } bw.flush(); } public static long getHash (int l, int r) { return h[r] - h[l - 1 ] * p[r - l + 1 ]; } }
第三章:搜索与图论
3.1 树与图的存储:
树是一种特殊的图,所以只需要会图的存储方式即可。在图中,无向图又是特殊的有向图,例如,对于一无向边a -- b
,只需要存储两条有向边即可,即a -> b、b -> a
,故只需要会有向图的存储即可。
图的存储常用的有两种,分别为邻接矩阵 和邻接表 存储法。一般用邻接矩阵存储稠密图,即使用二维数组g[][]
存储,g[a][b]
表示一条由a
指向b
权值为g[a][b]
的边。使用邻接表存储稀疏图,h[]
存储每一条单链表的头结点,e[]
存储每个顶点的值,ne[]
存储每个顶点的邻点的下标,有时还会用w[]
存储边的权重。
3.2 树与图的遍历:
3.2.1 DFS:
注意:
在dfs
过程中,必要时需要恢复现场 ,同时对于有的问题需要进行剪枝 ,如n
皇后问题。
代码实现:
1 2 3 4 5 6 7 8 public static void dfs (int u) { if (check()) return ; st[u] = true ; for (int i = h[u]; i != -1 ; i = ne[i]) { int j = e[i]; if (!st[j]) dfs(j); } }
DFS技巧:
优化搜索顺序:应当优先搜索分支较少的节点,或者以使得整体搜索次数更少的顺序搜索;
排除等效冗余:例如先选一再选二和先选二再选一是同一种,在搜索的时候要避免重复搜索;
可行性剪枝:搜索过程中发现该分支再往下已经不是想要的结果,直接return
;
最优化剪枝:例如查找某最小值时,当前分支已经大于当前的最优解时,直接return
;
记忆化搜索(DP
):加缓存,每次要搜寻某个方案时,先看缓存中是否已存在,存在直接返回,不存在再搜索。
3.2.2 BFS:
注意:
在BFS
过程中没有递归,初学时要分清BFS
和DFS
的区别,BFS
是维护一个队列。
代码实现(基于双端队列):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static int [] dx = {-1 , 0 , 1 , 0 }, dy = {0 , 1 , 0 , -1 };public static void bfs () { Deque<int []> q = new ArrayDeque <>(); q.addLast(new int []{0 , 0 }); st[0 ][0 ] = true ; while (!q.isEmpty()) { int [] t = q.pollFirst(); int x = t[0 ], y = t[1 ]; for (int i = 0 ; i < 4 ; i ++) { int a = x + dx[i], b = y + dy[i]; if (a < 0 || a >= n || b < 0 || b >= m || st[a][b]) continue ; ... st[a][b] = true ; q.addLast(new int [] {a, b}); } } }
算法思想:
拓扑排序(Topological Sorting
)是一个有向无环图(DAG, Directed Acyclic Graph
)的所有顶点的线性序列。且该序列必须满足下面两个条件:
每个顶点出现且只出现一次;
若存在一条从顶点A
到顶点 B
的路径,那么在序列中顶点A
必须出现在顶点 B
的前面。
拓扑排序利用队列,先将所有入度为0
的点放进队列中(用d
数组记录每个点的入度),然后从队头元素开始遍历,并让队头元素出队,对每个点的所有邻边遍历一遍,每次遍历让其入度减一,当某个点入度为0
时,将其放进队列中。当队列为空时,排序结束。
代码实现:
数组模拟队列可以不用单独存储答案,出队操作时逻辑出队,因此可以直接输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public static boolean topSort () { int hh = 0 , tt = -1 ; for (int i = 1 ; i <= n; i ++) if (d[i] == 0 ) q[++ tt] = i; while (hh <= tt) { int t = q[hh ++]; for (int i = h[t]; i != -1 ; i = ne[i]) { int j = e[i]; d[j] --; if (d[j] == 0 ) q[++ tt] = j; } } return tt == n - 1 ; }
代码实现(基于双端队列):
基于双端队列的实现则需要单独存一下序列,以便输出。下面代码中不用st
也可以,因为d[j]
每次都会被 -1
,所以不可能有元素能重复入队,只是习惯这样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 static List<Integer> res = new ArrayList <>();public static boolean top_sort () { Deque<Integer> q = new ArrayDeque <>(); for (int i = 1 ; i <= n; i ++) { if (d[i] == 0 ) { q.addLast(i); st[i] = true ; } } while (!q.isEmpty()) { int t = q.pollFirst(); res.add(t); for (int i = h[t]; i != -1 ; i = ne[i]) { int j = e[i]; if (st[j]) continue ; d[j] --; if (d[j] == 0 ) { st[j] = true ; q.addLast(j); } } } return res.size() == n; }
3.4 朴素Dijkstra算法O ( n 2 + m ) \mathcal{O}(n^2 + m) O ( n 2 + m ) :Dijkstra求最短路Ⅰ
算法思想:
迪杰斯特拉算法只能用于求解非负权 图的单源路径问题 。
迪杰斯特拉算法基于贪心,将第一个点到第一个点的距离赋值为0
,其他赋值为无穷大INF
,然后进行n - 1
次迭代,每次从还未确定与起点最短距离的点中选出与起点距离最小的点,然后用这个点更新其他点到起点的距离,并将这个点的状态改为已确定最短距离(即st[t] = true
)。
使用邻接矩阵 存储。
一般都不用朴素版Dijkstra,有堆优化版,谁还用朴素版啊 ~
有关图论的几个算法必须熟记时间复杂度,便于选择。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public static int dijkstra () { Arrays.fill(dist, INF); dist[1 ] = 0 ; for (int i = 0 ; i < n - 1 ; i ++) { int t = -1 ; for (int j = 1 ; j <= n; j ++) { if (!st[j] && (t == -1 || dist[j] < dist[t])) t = j; } if (t == n) break ; st[t] = true ; for (int j = 1 ; j <= n; j ++) dist[j] = Math.min(dist[j], dist[t] + g[t][j]); } if (dist[n] == INF) return -1 ; return dist[n]; }
3.5 堆优化版Dijkstra算法O ( m log n ) \mathcal{O}(m \log n) O ( m log n ) :Dijkstra求最短路Ⅱ
算法思想:
算法思想同朴素版,朴素版中,每次寻找当前距离最小的点时,该操作是O ( n ) \mathcal{O}(n) O ( n ) 级别,但是如果用堆进行维护,则该步骤时间复杂度降低为O ( 1 ) \mathcal{O}(1) O ( 1 ) ,降低了瓶颈处复杂度,不过当用堆维护后,在后面需要用该点更新其他点到起点的距离时,需要对堆进行操作,所以最终时间复杂度为O ( m log n ) \mathcal{O}(m \log n) O ( m log n ) 。
使用邻接表 存储。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 static class PII implements Comparable <PII> { int x, y; public int compareTo (PII p) { return this .x - p.x; } } static int n, m, idx;static final int N = 150010 ;static final int INF = 0x3f3f3f3f ;static int [] h = new int [N], e = new int [N], ne = new int [N], dist = new int [N], w = new int [N]; static boolean [] st = new boolean [N];static PriorityQueue<PII> heap = new PriorityQueue <>();public static void add (int a, int b, int c) { e[idx] = b; w[idx] = c; ne[idx] = h[a]; h[a] = idx ++; } public static int dijkstra () { Arrays.fill(dist, INF); dist[1 ] = 0 ; heap.add(new PII (0 , 1 )); while (!heap.isEmpty()) { PII t = heap.remove(); int vertex = t.y, distance = t.x; if (st[vertex]) continue ; st[vertex] = true ; for (int i = h[vertex]; i != -1 ; i = ne[i]) { int j = e[i]; if (dist[j] > distance + w[i]) { dist[j] = distance + w[i]; heap.add(new PII (dist[j], j)); } } } if (dist[n] == INF) return -1 ; return dist[n]; }
3.6 Bellman-Ford算法O ( n m ) \mathcal{O}(nm) O ( n m ) :有边数限制的最短路
算法思想:
Bellman-Ford
算法以边为单位,进行n
次迭代,每次迭代更新一遍每个点到起点的距离。Bellman-Ford
算法对边的存储没什么要求,直接用一个类存储即可。
当题目规定求只能经过k
条边的最短路径时,只能用Bellman-Ford
算法,此时算法复杂度为O ( m k ) \mathcal{O}(mk) O ( m k ) 。
值得一提的是每次更新时应该用上一次迭代后的dist
数组进行更新。如果用当前的dist
,则在更新过几条边后,dist
数组已经改变,此时再用当前的dist
去更新会导致本来不能更新的点也被更新掉了。例如下图中,如果要求k = 1
时,第一次迭代,会扫面一遍所有的边,当更新完编号为2
这个点的距离后,dist
数组已经发生变化,当扫描到2->3
这条边时,dist[3]
就会被更新为2
,而题目要求只经过1
条边,因此答案应该为3
,显然不对。而我们每扫描一条边,利用上一次迭代的结果,就不会因为当前一次迭代过程中dist
数组的改变而出现错误,这就是代码实现中backup
数组的作用。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 static class Edge { public int a, b, w; public Edge (int a, int b, int w) { this .a = a; this .b = b; this .w = w; } } public static int bellmanFord () { Arrays.fill(dist, INF); dist[1 ] = 0 ; for (int i = 0 ; i < k; i ++) { int [] backup = Arrays.copyOf(dist, dist.length); for (int j = 0 ; j < m; j ++) { int a = edges[j].a, b = edges[j].b, w = edges[j].w; dist[b] = Math.min(dist[b], backup[a] + w); } } if (dist[n] > INF / 2 ) flag = true ; return dist[n]; }
3.7 SPFA算法O ( m ) ∼ O ( n m ) \mathcal{O}(m) \sim \mathcal{O}(nm) O ( m ) ∼ O ( n m ) :SPFA求最短路
算法思想:
SPFA
算法是对Bellman-Ford
算法的宽搜优化 ,但是失去了k
的限制(有些题目就是要在k
次内),Bellman-Ford
每次都用当前点去更新其他点到起点的距离,如果当前点的距离没有变小的话,那么这个操作就是在浪费时间,所以SPFA
算法在此处进行了优化,利用一个队列,每当遍历到的点距离变小时,将其放入队列中,之后会用它去更新其他点的距离。
并且,SPFA
算法一般情况下很快,很多Dijkstra
的题都可以用SPFA
过掉,除非出题人故意编造数据,将SPFA
算法时间复杂度卡成O ( n m ) \mathcal{O}(nm) O ( n m ) 。SPFA
算法时间复杂度一般为O ( m ) \mathcal{O}(m) O ( m ) ,最坏O ( n m ) \mathcal{O}(nm) O ( n m ) 。
Bellman-Ford
使用类 存储每条边,SPFA
使用邻接表 存储图。
一句话总结SPFA
,用更新过距离的点再去更新其他点的距离。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public static boolean spfa () { Arrays.fill(dist, INF); Deque<Integer> q = new ArrayDeque <>(); q.addLast(1 ); dist[1 ] = 0 ; st[1 ] = true ; while (!q.isEmpty()) { int t = q.pollFirst(); st[t] = false ; for (int i = h[t]; i != -1 ; i = ne[i]) { int j = e[i]; int d = dist[t] + w[i]; if (dist[j] > d) { dist[j] = d; if (!st[j]) { q.addLast(j); st[j] = true ; } } } } return dist[n] != INF; }
算法思想:
该算法基于SPFA
算法,在该过程中增加一个cnt
数组,表示从起点到该点经过了多少个点,如果某条最短路径上除了自己有n
个点(假设总共有n
个点),那么加上自己之后一共有n + 1
个点,由抽屉原理 一定有两个点相同,所以存在环。并且在该背景下,这个环一定是负环,否则不会一直更新距离从而导致死循环。(当然,因为有cnt
限制,一旦cnt[j] >= n
,则直接return ture
,所以代码中不会存在死循环)。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 static List<Integer> q = new LinkedList ();public static boolean spfa () { for (int i = 1 ; i <= n; i ++) { q.add(i); st[i] = true ; } while (!q.isEmpty()) { int t = q.remove(); st[t] = false ; for (int i = h[t]; i != -1 ; i = ne[i]) { int j = e[i]; if (dist[j] > dist[t] + w[i]) { dist[j] = dist[t] + w[i]; cnt[j] = cnt[t] + 1 ; if (!st[j]) { q.add(j); st[j] = true ; } if (cnt[j] >= n) return true ; } } } return false ; }
算法思想:
Floyd
算法基于动态规划,使用三重循环,可以求解多源汇问题 。 由于Floyd
算法是最短路算法的最后一个算法,所以在此进行总结:
朴素Dijkstra
(单元最短路)常用于求解非负权图中的稠密图 ,时间复杂度O ( n 2 + m ) \mathcal{O}(n^2 + m) O ( n 2 + m ) ;
堆优化版Dijkstra
(单元最短路)常用于求解非负权图中的稀疏图 ,时间复杂度O ( m log n ) \mathcal{O}(m \log n) O ( m log n ) ;
Bellman-Ford
算法常用于求解有边数限制的最短路问题 ,时间复杂度O ( n m ) \mathcal{O}(nm) O ( n m ) ;
spfa
算法常用于求解存在负权边 的最短路问题(也可以求正权边最短路,有被卡风险),时间复杂度O ( m ) \mathcal{O}(m) O ( m ) ;
Floyd
算法常用于求解多源汇最短路 问题,时间复杂度O ( n 3 ) \mathcal{O}(n^3) O ( n 3 ) ,Floyd
算法的代码实现较简单,理解不了先直接背过即可,后面学了dp
后就好理解了。
代码实现:
1 2 3 4 5 6 7 public static void floyd () { for (int k = 1 ; k <= n; k ++) for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= n; j ++) d[i][j] = Math.min(d[i][j], d[i][k] + d[k][j]); }
算法思想:
Prim
算法是求最小生成树的一种算法,原理是从一个点出发,每次找到离集合最近的点,并将其加入到集合中,然后用这个点来更新剩下的点到集合的距离。常常用于求解稠密图 的最小生成树问题。
集合中维护的元素就是生成树中的节点,每个点到集合的距离定义为该点到集合中任意一点距离的最小值。
用邻接矩阵存储图。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public static int prim () { Arrays.fill(dist, INF); int res = 0 ; for (int i = 0 ; i < n; i ++) { int t = -1 ; for (int j = 1 ; j <= n; j ++) if (!st[j] && (t == -1 || dist[j] < dist[t])) t = j; if (i != 0 && dist[t] == INF) return INF; if (i != 0 ) res += dist[t]; st[t] = true ; for (int j = 1 ; j <= n; j ++) dist[j] = Math.min(dist[j], g[t][j]); } return res; }
算法思想:
Kruskal
是以边为对象,首先将所有边按照权值进行排序,然后枚举每一条边,如果一条边对应的两个顶点不在同一个集合中,那么我们就将其加入到一个集合中,在枚举过程中记录一个cnt
变量,每次有点加入集合中,则cnt ++
,如果枚举完成之后,cnt < n - 1
,则说明并不是所有点都加入集合了,故最小生成树不存在。
在以上过程中,判断两个点是否在集合中,可以通过并查集进行查询并维护。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 import java.util.*;public class Main { static class Edge { int a, b, w; public Edge (int a, int b, int w) { this .a = a; this .b = b; this .w = w; } } static final int N = 100010 , INF = 0x3f3f3f3f ; static Edge[] edges = new Edge [2 * N]; static int [] p = new int [N]; static int n, m; public static void main (String[] args) { Scanner sc = new Scanner (System.in); n = sc.nextInt(); m = sc.nextInt(); for (int i = 0 ; i < m; i ++) { int a = sc.nextInt(), b = sc.nextInt(), w = sc.nextInt(); edges[i] = new Edge (a, b, w); } Arrays.sort(edges, 0 , m, (o1, o2) -> o1.w - o2.w); int res = kruskal(); System.out.println(res == INF ? "impossible" : res); } public static int kruskal () { int res = 0 , cnt = 0 ; for (int i = 0 ; i <= n; i ++) p[i] = i; for (int i = 0 ; i < m; i ++) { int a = edges[i].a, b = edges[i].b, w = edges[i].w; a = find(a); b = find(b); if (a != b) { p[a] = b; cnt ++; res += w; } if (cnt == n - 1 ) return res; } return INF; } public static int find (int x) { if (p[x] != x) p[x] = find(p[x]); return p[x]; } }
算法思想:
二分图: 当且仅当图中不存在奇数环,可以利用抽屉原理进行证明;
染色法 顾名思义,就是将每个点染色,染色过程中需要保证每个点与它相邻的点的颜色不同,一共两种颜色。如果在染色过程中出现矛盾,那么该图就一定不是二分图。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public static boolean dfs (int u, int c) { color[u] = c; for (int i = h[u]; i != -1 ; i = ne[i]) { int j = e[i]; if (color[j] == 0 && !dfs(j, 3 - c)) return false ; else if (color[j] == c) return false ; } return true ; } boolean flag = true ; for (int i = 1 ; i <= n; i ++) if (color[i] == 0 && !dfs(i, 1 )) { flag = false ; break ; }
算法思想:
匈牙利算法由两位匈牙利的数学家提出,因此得名。用一个形象的例子解释,一个二分图中,所有顶点分为左右两个部分,左半部分的点与右半部分的点之间存在许多边,如果其中一条边与其他任意的边都不依附于同一个顶点,则称这条边为一个匹配。例如图1
的二分图中,图4
就是该二分图的最大匹配,最大匹配数为4
。
在整个匹配过程中,最开始1
号与5
号匹配,2
号和7
号匹配,此时没有任何问题,当3
号点匹配时,发现7
号点已经被匹配过了,此时,我们看看与7
号匹配的2
号点能否换个点匹配,而2
号点的另一个可匹配点5
号点被1
号点匹配,我们再看1
号点能不能换个点匹配,此时发现1
号点还可以和6
号点匹配,于是,1、2、3
号点都可以匹配,如图3
所示。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public static boolean find (int x) { for (int i = h[x]; i != -1 ; i = ne[i]) { int j = e[i]; if (!st[j]) { st[j] = true ; if (match[j] == 0 || find(match[j])) { match[j] = x; return true ; } } } return false ; } int res = 0 ;for (int i = 1 ; i <= n1; i ++) { Arrays.fill(st, false ); if (find(i)) res ++; }
第四章:数学知识
算法思想:
判定一个数n
是否是质数,主要判定2 ∼ n 2 \sim \sqrt{n} 2 ∼ n 之间是否有其约数。(所有小于2
的数不是质数)
代码实现:
1 2 3 4 5 6 7 public static boolean isPrime (int x) { if (x < 2 ) return false ; for (int i = 2 ; i <= x / i; i ++) if (x % i == 0 ) return false ; return true ; }
算法思想:
想要对一个数x
分解质因数,主要是通过从小到大枚举其约数,当枚举到一个约数i
时,通过一个循环,将x
反复更新为x = x / i
,并记录更新了多少次,这个次数就是i
这个质因子的指数。
当上述过程结束后,需要判定一下最后的x
是否被除成了1
,如果不是,则说明被除剩下的这个x
也是最开始的x
的一个质因子,且该质因子不能再被分解。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static void devide (int x) { for (int i = 2 ; i <= x / i; i ++) { if (x % i == 0 ) { int cnt = 0 ; while (x % i == 0 ) { x /= i; cnt ++; } System.out.println(i + " " + cnt); } } if (x > 1 ) System.out.println(x + " " + 1 ); }
4.3 筛质数:筛质数
4.3.1 朴素筛法:
算法思想:
筛质数的目的在于求出1 ~ n
之间的质数。那么比较快速的办法就是将1 ~ n
当中不是质数的数给筛出去
朴素筛法是利用已经确定了的质数进行筛除,其原理是将一个质数的所有小于等于n
的倍数全部筛除
一般都不用朴素筛法,都有线性筛了,谁还用朴素筛法呀 ~
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 int cnt = 0 ;int [] primes = new int [n];boolean [] st = new boolean [n + 10 ];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 ; }
4.3.2 线性筛法:
算法思想:
线性筛法可以理解为是对朴素筛法的优化,因为朴素筛法里会多次筛除同一个数,而线性筛法中,每个合数只会被筛掉一次。
其主要思想是通过每个合数的最小质因子将其筛掉。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 int cnt = 0 ;int [] primes = new int [n];boolean [] st = new boolean [n + 10 ];for (int i = 2 ; i <= n; i ++) { if (!st[i]) primes[cnt ++] = i; for (int j = 0 ; primes[j] <= n / i; j ++) { st[primes[j] * i] = true ; if (i % primes[j] == 0 ) break ; } }
算法思想:
与求质数差不多,从0
开始枚举到n \sqrt{n} n ,对每个数i
进行判断,如果是x
的约数,且i != x / i
,则将i
与x / i
放进答案中,最后排序输出即可。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import java.util.*;import java.io.*;public class Main { public static void main (String[] args) throws IOException { Scanner sc = new Scanner (System.in); BufferedWriter bw = new BufferedWriter (new OutputStreamWriter (System.out)); int T = sc.nextInt(); while (T -- > 0 ) { List<Integer> devisors = new ArrayList (); int x = sc.nextInt(); for (int i = 1 ; i <= x / i; i ++) { if (x % i == 0 ) { devisors.add(i); if (x / i != i) devisors.add(x / i); } } Collections.sort(devisors); for (int a : devisors) bw.write(a + " " ); bw.write("\n" ); } bw.flush(); } }
4.5 约数个数:约数个数
算法思想:
该算法求解的是一个数x
的所有2 ~ x - 1
中的约数个数
该算法基于约数个数公式:( α 1 + 1 ) × ( α 2 + 1 ) × . . . × ( α k + 1 ) (\alpha_1 + 1) \times (\alpha_2 + 1) \times ... \times (\alpha_k + 1) ( α 1 + 1 ) × ( α 2 + 1 ) × . . . × ( α k + 1 ) ,其中α 1 \alpha_1 α 1 为x
的第一个质因子的指数,α 2 \alpha_2 α 2 为x
的第二个质因子的指数,依次类推。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import java.util.*;public class Main { static final int mod = (int )1e9 + 7 ; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(); HashMap<Integer, Integer> primes = new HashMap <>(); while (n -- > 0 ) { int x = sc.nextInt(); for (int i = 2 ; i <= x / i; i ++) { int s = 0 ; while (x % i == 0 ) { x /= i; s ++; } primes.put(i, primes.getOrDefault(i, 0 ) + s); } if (x > 1 ) { primes.put(x, primes.getOrDefault(x, 0 ) + 1 ); } } long res = 1 ; for (int value : primes.values()) res = res * (value + 1 ) % mod; System.out.println(res); } }
4.6 约数之和:约数之和
算法思想:
4.5
节是求一个数的约数个数,而4.6
则是求这些约数的和。
该算法同样基于公式:( p 1 0 + p 1 1 + . . . + p 1 α 1 ) × . . . × ( p k 0 + p k 1 + . . . + p k α k ) (p_1^0 + p_1^1 + ... + p_1^{\alpha_1}) \times ... \times (p_k^0 + p_k^1 + ... + p_k^{\alpha_k}) ( p 1 0 + p 1 1 + . . . + p 1 α 1 ) × . . . × ( p k 0 + p k 1 + . . . + p k α k ) 。其中,p 1 p_1 p 1 是第一个质因子,α 1 \alpha_1 α 1 是第一个质因子的指数。
上式中,可以利用t = t × p + 1 t = t \times p + 1 t = t × p + 1 求解每一项,例如第一项t α 1 t_{\alpha_1} t α 1 :t 0 = 1 , t 1 = p + 1 , t 2 = p 2 + p + 1 , . . . , t α 1 = p α 1 + . . . + 1 t_0 = 1, t_1 = p + 1, t_2 = p^2 + p + 1, ..., t_{\alpha_1} = p^{\alpha_1} + ... + 1 t 0 = 1 , t 1 = p + 1 , t 2 = p 2 + p + 1 , . . . , t α 1 = p α 1 + . . . + 1 。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import java.util.*;public class Main { static final int mod = (int )1e9 + 7 ; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(); HashMap<Integer, Integer> primes = new HashMap <>(); while (n -- > 0 ) { int x = sc.nextInt(); for (int i = 2 ; i <= x / i; i ++) { int s = 0 ; while (x % i == 0 ) { x /= i; s ++; } primes.put(i, primes.getOrDefault(i, 0 ) + s); } if (x > 1 ) primes.put(x, primes.getOrDefault(x, 0 ) + 1 ); } long res = 1 ; for (Map.Entry prime : primes.entrySet()) { long t = 1 ; int key = (int )prime.getKey(), value = (int )prime.getValue(); while (value -- > 0 ) t = (t * key + 1 ) % mod; res = res * t % mod; } System.out.println(res); } }
补充map
遍历方式:
1 2 3 for (int key : map.keySet()) {} for (int value : map.values()) {} for (Map.Entry<Integer, Integer> p : entrySet()) {}
算法思想:
如果一个数d
能整除a
,且能整除b
,那么d
一定能整除c₁ * a + c₂ * b
。所以d
也能够整除a - c * b
,令c = (a / b)
向下取整,则a - c * b = a mod b
,所以d
也能整除a mod b
,故a、 b
两个数的最大公约数等于b、 a mod b
这两个数的最大公约数。这就是欧几里得算法的核心之处。
代码实现:
1 2 3 4 public static int gcd (int a, int b) { return b > 0 ? gcd(b, a % b) : a; }
4.8 欧拉函数:欧拉函数
算法思想:
欧拉函数是指:对于一个正整数x
,小于或等于x
的正整数中与x
互质的正整数个数(包括1
)的个数,记作φ ( n ) \varphi(n) φ ( n ) 。
欧拉函数的公式推导大致为:
其中p i p_i p i 为x
的每一个质因子。上述公式的除法均为整除(即下取整),上述公式的思想为:1 ~ x
中总共有x
个数,减去与x
有相同质因子的数后,剩下的数均与x
互质,而每次会重复减去相同的数,所以再加回来(容斥原理)。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 public static int getEuler (int x) { int res = x; for (int i = 2 ; i <= x / i; i ++) { if (x % i == 0 ) { res = res - res / i; while (x % i == 0 ) x /= i; } } if (x > 1 ) res = res - res / x; return res; }
算法思想:
主要思想还是基于4.8
中的公式,此算法适用于题目要求求解1 ~ x
中的每一个数的欧拉函数值。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static final int N = 1000010 ;static int [] primes = new int [N];static boolean [] st = new boolean [N];static int [] phi = new int [N]; public static void getEuler (int x) { int cnt = 0 ; phi[1 ] = 1 ; for (int i = 2 ; i <= x; i ++) { if (!st[i]) { primes[cnt ++] = i; phi[i] = i - 1 ; } for (int j = 0 ; primes[j] <= x / i; j ++) { st[primes[j] * i] = true ; if (i % primes[j] == 0 ) { phi[i * primes[j]] = primes[j] * phi[i]; break ; } phi[i * primes[j]] = (primes[j] - 1 ) * phi[i]; } } }
公式推导:
公式1: 由于此时i % primes[j] == 0
,说明primes[j]
是i
的最小质因子,则在计算phi[i * primes[j]]
时,(1 - 1 / primes[j])
已经在求解phi[i]
时被乘过一次,所以此时不需要乘这一项。
φ ( i × primes [ j ] ) = i × primes [ j ] × ( 1 − 1 p 1 ) × ( 1 − 1 p 2 ) × ⋯ × ( 1 − 1 p k ) = primes [ j ] × φ ( i ) \varphi(i \times\: \text{primes}[j]) = i \times\: \text{primes}[j] \times \left(1 - \frac{1}{p_1}\right) \times \left(1 - \frac{1}{p_2}\right) \times \cdots \times \left(1 - \frac{1}{p_k}\right) = \text{primes}[j] \times \varphi(i) φ ( i × primes [ j ] ) = i × primes [ j ] × ( 1 − p 1 1 ) × ( 1 − p 2 1 ) × ⋯ × ( 1 − p k 1 ) = primes [ j ] × φ ( i )
公式2: 由于此时i % primes[j] != 0
,说明primes[j]
不是i
的质因子,则在计算phi[i * primes[j]]
时,需要乘(1 - 1 / primes[j])
这一项,化简即可。
φ ( i × primes [ j ] ) = i × primes [ j ] × ( 1 − 1 p 1 ) × ( 1 − 1 p 2 ) × ⋯ × ( 1 − 1 p k ) × ( 1 − 1 primes [ j ] ) = primes [ j ] × ( 1 − 1 primes [ j ] ) × φ ( i ) = ( primes [ j ] − 1 ) × φ ( i ) \varphi(i \times\: \text{primes}[j]) = i \times\: \text{primes}[j] \times \left(1 - \frac{1}{p_1}\right) \times \left(1 - \frac{1}{p_2}\right) \times \cdots \times \left(1 - \frac{1}{p_k}\right) \times (1 - \frac{1}{\text{primes}[j]}) \\ = \text{primes}[j] \times (1 -\frac{1}{\text{primes}[j]}) \times \varphi(i) \\ = (\text{primes}[j] - 1) \times \varphi(i) φ ( i × primes [ j ] ) = i × primes [ j ] × ( 1 − p 1 1 ) × ( 1 − p 2 1 ) × ⋯ × ( 1 − p k 1 ) × ( 1 − primes [ j ] 1 ) = primes [ j ] × ( 1 − primes [ j ] 1 ) × φ ( i ) = ( primes [ j ] − 1 ) × φ ( i )
4.10 快速幂:快速幂
算法思想:
快速幂可以快速求解a b % p a^b \% p a b % p 的结果。a b a^b a b 在java
中虽然有方法pow
可以使用,但是在计算过程中很容易就爆long
,而快速幂计算的每一步都会% p \% p % p ,一般不会爆long
。
其思想为先预处理出a 2 0 , a 2 1 , . . . , a 2 log b a^{2^0}, a^{2^1}, ..., a^{2^{\log b}} a 2 0 , a 2 1 , . . . , a 2 l o g b 的结果,这些数每一个都是前一个的平方。这一步显然是O ( log b ) \mathcal{O}(\log b) O ( log b ) 复杂度的。
再将a b a^b a b 分解为若干个前面预处理出来的值相乘,即将b
分解为前面预处理出来的值的指数相加,这一步可以使用二进制进行计算,例如:十进制中的a 5 a^5 a 5 ,5
的二进制的101
,则5
可以写为2 0 + 2 2 2^0 + 2^2 2 0 + 2 2 ,那么a 5 a^5 a 5 就被分解为a 2 0 × a 2 2 a^{2^0} \times a^{2^2} a 2 0 × a 2 2 ,此时就可以用预处理出来的值相乘得到。而这一步也是O ( log b ) \mathcal{O}(\log b) O ( log b ) 的,因此时间复杂度为O ( log b ) \mathcal{O}(\log b) O ( log b ) 。
代码实现:
该模板相当精妙,在每次while
循环时,算出a 2 i a^{2^i} a 2 i ,同时判断这一个预处理出来的值需不需要乘进去,并达到了更新a
和b
的效果
1 2 3 4 5 6 7 8 9 10 11 12 13 public static int qmi (int a, int b, int p) { int res = 1 % p; while (b > 0 ) { if ((b & 1 ) == 1 ) res = (int )((long )res * a % p); a = (int )((long )a * a % p); b >>= 1 ; } return res; }
算法思想:
乘法逆元定义 :若整数b, m
互质,并且对于任意的整数a
,如果满足b|a
,则存在一个整数x
,使得a/b ≡ a * x (mod m)
,则称x
为b
的模m
乘法逆元,记作b⁻¹ (mod m)
。b
存在乘法逆元的充分必要条件是b
与模数m
互质。
a b = a ⋅ x ( m o d m ) b ⋅ x ≡ 1 ( m o d m ) ∵ m 为质数,由费马小定理知, b m − 1 ≡ 1 ( m o d m ) 又 ∵ b m − 1 = b ⋅ b m − 2 ( m ≥ 2 ) ∴ x ≡ b m − 2 ( mod m ) \frac{a}{b} = a \cdot x \pmod{m} \\ b \cdot x \equiv 1 \pmod{m} \\ \because m\text{为质数,由费马小定理知,} \\ b^{m-1} \equiv 1 \pmod{m} \\ \text{又} \because b^{m-1} = b \cdot b^{m-2}\quad (m \geq 2) \\ \therefore x \equiv b^{m-2}\quad (\text{mod } m) b a = a ⋅ x ( m o d m ) b ⋅ x ≡ 1 ( m o d m ) ∵ m 为质数,由费马小定理知 , b m − 1 ≡ 1 ( m o d m ) 又 ∵ b m − 1 = b ⋅ b m − 2 ( m ≥ 2 ) ∴ x ≡ b m − 2 ( mod m )
当b
为m
的倍数时,显然,b % m = 0
,不存在逆元;
当b
不是m
的倍数时,b
的逆元为b m − 2 % m b^{m - 2} \% m b m − 2 % m 。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static int qmi (int a, int b, int p) { int res = 1 % p; while (b != 0 ) { if ((b & 1 ) == 1 ) res = (int )((long )res * a % p); a = (int )((long )a * a % p); b >>= 1 ; } return res; } int a = sc.nextInt(), p = sc.nextInt();int res = qmi(a, p - 2 , p);if (b % m != 0 ) System.out.println(res);else System.out.println("impossible" );
算法思想:
想了解扩展欧几里得算法,先引入裴属定理: 若a, b
是整数,且 gcd(a , b) = d
,那么对于任意的整数x, y
, ax + by
都一定是d
的倍数,特别地,一定存在整数x, y
,使ax + by = d
成立。而扩展欧几里得算法则可以很方便的求解任意正整数a, b
的x, y
这两个系数。即通过函数exgcd(a, b, x, y)
求得系数x, y
。注意:x, y
并不唯一。
由欧几里得算法知,gcd(a, b) = gcd(b, a % b)
,而a % b = a - a / b * b
,那么在递归求exgcd(b, a % b, y, x)
时有by + (a % b)x = d
,化简得ax + (y - a / b * x)b = d
,说明在递归时系数x
不用更新(这里的x
是指exgcd
函数里的x
,因为在每次进行递归时,会将实参交换后再复制给形参),只需要更新y
。
在java
中没有类似C ++
的引用类型,可以用数组进行代替。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 static int [] x = new int [1 ];static int [] y = new int [1 ];public static int exgcd (int a, int b, int [] x, int [] y) { if (b == 0 ) { x[0 ] = 1 ; y[0 ] = 0 ; return a; } int d = exgcd(b, a % b, y, x); y[0 ] -= a / b * x[0 ]; return d; }
算法思想:
可以通过扩展欧几里得算法求解线性同余方程ax ≡ b (mod m)
。从取模的定义出发,可以根据ax ≡ b (mod m)
构造出ax = my' + b
,令y = -y'
,整理得ax + my = b
,当b
为a, m
的最小公倍数的倍数时,可以利用扩展欧几里得算法进行求解,而当b
不是其倍数时,则无解。
当用扩展欧几里得求出一组x 0 , y 0 x_0, y_0 x 0 , y 0 后,此时的x 0 , y 0 x_0, y_0 x 0 , y 0 满足的是a x 0 + m y 0 = g c d ( a , m ) ax_0 + my_0 = gcd(a, m) a x 0 + m y 0 = g c d ( a , m ) ,此时,我们将等式两边同时乘以b g c d ( a , m ) \frac {b}{gcd(a, m)} g c d ( a , m ) b ,得到a x 0 ( b g c d ( a , m ) ) + m y 0 ( b g c d ( a , m ) ) = b ax_0(\frac {b}{gcd(a, m)}) + my_0(\frac {b}{gcd(a, m)}) = b a x 0 ( g c d ( a , m ) b ) + m y 0 ( g c d ( a , m ) b ) = b ,令x = x 0 ( b g c d ( a , m ) ) x = x_0(\frac{b}{gcd(a, m)}) x = x 0 ( g c d ( a , m ) b ) ,y = y 0 ( b g c d ( a , m ) ) y= y_0(\frac{b}{gcd(a, m)}) y = y 0 ( g c d ( a , m ) b ) ,则此时的x, y
即为原线性同余方程的一组解。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 static int [] x = new int [1 ];static int [] y = new int [1 ];public static int exgcd (int a, int b, int [] x, int [] y) { if (b == 0 ) { x[0 ] = 1 ; y[0 ] = 0 ; return a; } int d = exgcd(b, a % b, y, x); y[0 ] -= a / b * x[0 ]; return d; } int d = exgcd(a, m, x, y);if (b % d != 0 ) bw.write("impossible\n" ); else bw.write((long )x[0 ] * (b / d) % m + "\n" ); bw.flush();
4.14 高斯消元O ( n 3 ) \mathcal{O}(n^3) O ( n 3 ) :高斯消元解线性方程组
算法思想:
高斯消元的原理是将线性方程组的增广矩阵进行初等行变换,使之成为上三角矩阵,再通过上三角矩阵倒着解出未知数。步骤如下:
依次遍历每一列c
;
找到这一列中绝对值最大的元素行号,若最大元素为0
,则不需要处理此列;
将其交换到第r
行(r
从0
开始);
将第r
行第c
列元素化为1
(初等行变换);
最后通过上三角矩阵判断解的情况。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 import java.util.*;import java.io.*;public class Main { static final int N = 110 ; static int n; static double [][] a = new double [N][N]; static final double eps = 1e-6 ; public static void swap (int x1, int y1, int x2, int y2) { double t = a[x1][y1]; a[x1][y1] = a[x2][y2]; a[x2][y2] = t; } public static int gauss () { int c, r; for (c = 0 , r = 0 ; c < n; c ++) { int t = r; for (int i = r; i < n; i ++) if (Math.abs(a[i][c]) > Math.abs(a[t][c])) t = i; if (Math.abs(a[t][c]) < eps) continue ; for (int i = c; i <= n; i ++) swap(t, i, r, i); for (int i = n; i >= c; i --) a[r][i] /= a[r][c]; for (int i = r + 1 ; i < n; i ++) { if (Math.abs(a[i][c]) > eps) for (int j = n; j >= c; j --) a[i][j] -= a[r][j] * a[i][c]; } r ++; } if (r < n) { for (int i = r; i < n; i ++) { if (Math.abs(a[i][n]) > eps) return 2 ; } return 1 ; } else { for (int i = n - 2 ; i >= 0 ; i --) { for (int j = i + 1 ; j < n; j ++) a[i][n] -= a[i][j] * a[j][n]; } return 0 ; } } public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); n = Integer.parseInt(br.readLine()); for (int i = 0 ; i < n; i ++) { String[] s = br.readLine().split(" " ); for (int j = 0 ; j <= n; j ++) { a[i][j] = Double.parseDouble(s[j]); } } int ans = gauss(); if (ans == 0 ) for (int i = 0 ; i < n; i ++) if (Math.abs(a[i][n]) < eps) System.out.printf("0.00\n" ); else System.out.printf("%.2f\n" , a[i][n]); else if (ans == 1 ) System.out.println("Infinite group solutions" ); else System.out.println("No solution" ); } }
4.15 简单博弈论:Nim游戏
算法思想:
博弈论又被称为对策论(Game Theory),既是现代数学的一个新分支,也是运筹学的一个重要学科。博弈论主要研究已公式化的激励结构间的相互作用,是研究具有斗争或竞争性质现象的数学理论和方法。博弈论考虑游戏中的个体的预测行为和实际行为,并研究它们的优化策略。 以Nim
游戏为例:给定n
堆石子,两位玩家轮流操作,每次操作可以从任意一堆石子中拿走任意数量的石子(可以拿完,但不能不拿),最后无法进行操作(即没有石子可拿)的人视为失败。 问如果两人都采用最优策略,先手是否必胜?结论:
每堆石子的异或为0
,则先手必败
每堆石子的异或为1
,则先手必胜
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import java.util.*;import java.io.*;public class Main { public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); int n = Integer.parseInt(br.readLine()); String[] s = br.readLine().split(" " ); int [] a = new int [100010 ]; int res = 0 ; for (int i = 0 ; i < n; i ++) { a[i] = Integer.parseInt(s[i]); res ^= a[i]; } if (res == 0 ) System.out.print("No" ); else System.out.print("Yes" ); } }
第五章:动态规划
5.1背包问题
考虑背包问题的时候,状态表示为:前i
个物品(第一维),每增加一个限制,增加一维,然后再考虑对代码做等价变形优化空间。
5.1.1 01背包问题
问题描述:
01
背包问题描述的是:有N
件物品和一个容量为V
的背包。每件物品只能使用一次 。其中,第i
件物品的体积是vi
,价值是wi
,问:将哪些物品装进背包,可使这些物品的体积不超过背包容量,且总价值最大。
问题分析:
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import java.io.*;public class Main { static final int N = 1010 ; static int [] v = new int [N], w = new int [N]; static int [][] f = new int [N][N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s = br.readLine().split(" " ); int n = Integer.parseInt(s[0 ]); int m = Integer.parseInt(s[1 ]); for (int i = 1 ; i <= n; i ++) { String[] s1 = br.readLine().split(" " ); v[i] = Integer.parseInt(s1[0 ]); w[i] = Integer.parseInt(s1[1 ]); } for (int i = 1 ; i <= n; i ++) for (int j = 0 ; j <= m; j ++) { f[i][j] = f[i - 1 ][j]; if (j >= v[i]) f[i][j] = Math.max(f[i][j], f[i - 1 ][j - v[i]] + w[i]); } System.out.println(f[n][m]); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import java.io.*;public class Main { static final int N = 1010 ; static int [] v = new int [N], w = new int [N]; static int [] f = new int [N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s = br.readLine().split(" " ); int n = Integer.parseInt(s[0 ]); int m = Integer.parseInt(s[1 ]); for (int i = 1 ; i <= n; i ++) { String[] s1 = br.readLine().split(" " ); v[i] = Integer.parseInt(s1[0 ]); w[i] = Integer.parseInt(s1[1 ]); } for (int i = 1 ; i <= n; i ++) { for (int j = m; j >= v[i]; j --) { f[j] = Math.max(f[j], f[j - v[i]] + w[i]); } } System.out.println(f[m]); } }
优化成一维的说明:对于01背包一维优化的理解
5.1.2 完全背包问题
问题描述:
有N
种物品和一个容量为V
的背包,每种物品都有无限 件可以使用。其中,第i
件物品的体积是vi
,价值是wi
。问:将哪些物品装进背包,可使这些物品的体积不超过背包容量,且总价值最大。
问题分析:
注: 上图中的k
从1
开始枚举。也可以从0
开始枚举,则f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i])
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import java.io.*;public class Main { public static final int N = 1010 ; public static int [][] f = new int [N][N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s1 = br.readLine().split(" " ); int n = Integer.parseInt(s1[0 ]); int m = Integer.parseInt(s1[1 ]); int [] v = new int [N]; int [] w = new int [N]; for (int i = 1 ; i <= n; i ++) { String[] s2 = br.readLine().split(" " ); v[i] = Integer.parseInt(s2[0 ]); w[i] = Integer.parseInt(s2[1 ]); } for (int i = 1 ; i <= n; i ++) for (int j = 0 ; j <= m; j ++) for (int k = 0 ; k * v[i] <= j; k ++) f[i][j] = Math.max(f[i][j], f[i - 1 ][j - k * v[i]] + k * w[i]); System.out.println(f[n][m]); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import java.io.*;public class Main { static final int N = 1010 ; static int [] v = new int [N], w = new int [N]; static int [][] f = new int [N][N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s = br.readLine().split(" " ); int n = Integer.parseInt(s[0 ]); int m = Integer.parseInt(s[1 ]); for (int i = 1 ; i <= n; i ++) { String[] s1 = br.readLine().split(" " ); v[i] = Integer.parseInt(s1[0 ]); w[i] = Integer.parseInt(s1[1 ]); } for (int i = 1 ; i <= n; i ++) for (int j = 0 ; j <= m; j ++) { f[i][j] = f[i - 1 ][j]; if (j >= v[i]) f[i][j] = Math.max(f[i][j], f[i][j - v[i]] + w[i]); } System.out.println(f[n][m]); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import java.io.*;public class Main { static final int N = 1010 ; static int [] v = new int [N], w = new int [N]; static int [] f = new int [N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s = br.readLine().split(" " ); int n = Integer.parseInt(s[0 ]); int m = Integer.parseInt(s[1 ]); for (int i = 1 ; i <= n; i ++) { String[] s1 = br.readLine().split(" " ); v[i] = Integer.parseInt(s1[0 ]); w[i] = Integer.parseInt(s1[1 ]); } for (int i = 1 ; i <= n; i ++) for (int j = v[i]; j <= m; j ++) f[j] = Math.max(f[j], f[j - v[i]] + w[i]); System.out.println(f[m]); } }
5.1.3 多重背包问题
问题描述:
有N
种物品和一个容量为V
的背包。第i
件物品最多有si
件,每件的体积为vi
,价值为wi
。问:将哪些物品装进背包,可使这些物品的体积不超过背包容量,且总价值最大。
问题分析:
多重背包问题的分析与完全背包问题几乎一样,只是对k
多了一个限制(k <= s[i]
)。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import java.io.*;public class Main { static final int N = 110 ; static int [] v = new int [N], w = new int [N], s = new int [N]; static int [][] f = new int [N][N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s1 = br.readLine().split(" " ); int n = Integer.parseInt(s1[0 ]); int m = Integer.parseInt(s1[1 ]); for (int i = 1 ; i <= n; i ++) { String[] s2 = br.readLine().split(" " ); v[i] = Integer.parseInt(s2[0 ]); w[i] = Integer.parseInt(s2[1 ]); s[i] = Integer.parseInt(s2[2 ]); } for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= m; j ++) for (int k = 0 ; k <= s[i] && k * v[i] <= j; k ++) f[i][j] = Math.max(f[i][j], f[i - 1 ][j - k * v[i]] + k * w[i]); System.out.println(f[n][m]); } }
多重背包二进制优化主要是将多重背包问题转换为01背包问题:将每种物品分解为若干堆,这些堆的数量总是能够凑出这种物品原来数量的任意一种取法(例如一种物品总共有8个,则将其分为1、2、4、1这几堆),然后将分堆后的每一堆物品都看作是一件新物品,再对这个些物品做一遍01背包就能求出最终的解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import java.io.*;public class Main { static final int N = 25000 ; static int [] v = new int [N], w = new int [N]; static int [] f = new int [N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s1 = br.readLine().split(" " ); int n = Integer.parseInt(s1[0 ]), m = Integer.parseInt(s1[1 ]); int cnt = 0 ; for (int i = 1 ; i <= n; i ++) { String[] s2 = br.readLine().split(" " ); int a = Integer.parseInt(s2[0 ]); int b = Integer.parseInt(s2[1 ]); int s = Integer.parseInt(s2[2 ]); int k = 1 ; while (k <= s) { cnt ++; v[cnt] = k * a; w[cnt] = k * b; s -= k; k *= 2 ; } if (s > 0 ) { cnt ++; v[cnt] = a * s; w[cnt] = b * s; } } n = cnt; for (int i = 1 ; i <= n; i ++) for (int j = m; j >= v[i]; j --) f[j] = Math.max(f[j], f[j - v[i]] + w[i]); System.out.println(f[m]); } }
5.1.4 分组背包问题:
问题描述:
有N
组物品和一个容量是V
的背包。每组物品有若干个,同一组内的物品最多只能选一个 。且每件物品的体积是 v[i][j]
,价值是 w[i][j]
,其中i
是组号,j
是组内编号。问:将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。
问题分析:
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import java.util.*;public class Main { static final int N = 110 ; static int [][] v = new int [N][N], w = new int [N][N]; static int [] s = new int [N]; static int [][] f = new int [N][N]; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(), m = sc.nextInt(); for (int i = 1 ; i <= n; i ++) { s[i] = sc.nextInt(); for (int j = 1 ; j <= s[i]; j ++) { v[i][j] = sc.nextInt(); w[i][j] = sc.nextInt(); } } for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= m; j ++) for (int k = 1 ; k <= s[i]; k ++) if (v[i][k] <= j) f[i][j] = Math.max(f[i][j], f[i - 1 ][j - v[i][k]] + w[i][k]); System.out.print(f[n][m]); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import java.io.*;public class Main { static final int N = 110 ; static int [][] v = new int [N][N], w = new int [N][N]; static int [] s = new int [N], f = new int [N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); String[] s1 = br.readLine().split(" " ); int n = Integer.parseInt(s1[0 ]); int m = Integer.parseInt(s1[1 ]); for (int i = 1 ; i <= n; i ++) { s[i] = Integer.parseInt(br.readLine()); for (int j = 1 ; j <= s[i]; j ++) { String[] s2 = br.readLine().split(" " ); v[i][j] = Integer.parseInt(s2[0 ]); w[i][j] = Integer.parseInt(s2[1 ]); } } for (int i = 1 ; i <= n; i ++) for (int j = m; j >= 0 ; j --) for (int k = 1 ; k <= s[i]; k ++) if (v[i][k] <= j) f[j] = Math.max(f[j], f[j - v[i][k]] + w[i][k]); System.out.println(f[m]); } }
5.2 线性dp
算法思想:
这一类Dp
先思考需要用几维来表示集合、每个集合表示的是什么、然后再思考需要将每个集合分成哪几个部分,且这几个部分均能够用已知集合推导出来。 线性Dp
以最长公共子序列 这道题为例,进行分析。
问题描述: 给定两个长度分别为 N 和 M 的字符串 A 和 B,求既是 A 的子序列又是 B 的子序列的字符串长度最长是多少。
问题分析:
首先思考需要用几维来表示集合:本题是两个字符串,且问的是这两个字符串之间的关系,那么根据经验,可以使用二维数组表示集合;
每个集合分别表示什么:f[i][j]
表示所有在第一个字符串的前i
个字母中出现,且在第二个字符串的前j
个字母中出现的子序列;
每个集合需要分为哪几个部分:可以按照是否包含第i
个字母或是否包含第j
个字母分为4
部分。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import java.util.*;public class Main { static final int N = 1010 ; static int [][] f = new int [N][N]; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(), m = sc.nextInt(); char [] a = (" " + sc.next()).toCharArray(); char [] b = (" " + sc.next()).toCharArray(); for (int i = 1 ; i <= n; i ++) for (int j = 1 ; j <= m; j ++) { f[i][j] = Math.max(f[i - 1 ][j], f[i][j - 1 ]); if (a[i] == b[j]) f[i][j] = Math.max(f[i][j], f[i - 1 ][j - 1 ] + 1 ); } System.out.println(f[n][m]); } }
5.3 区间dp
算法思想:
区间Dp
的题目通常的枚举顺序是:先枚举区间长度,再枚举左端点、再枚举决策。 下面将以石子合并 这道题为例进行分析:
题目描述:
设有 N N N 堆石子排成一排,其编号为 1 , 2 , 3 , … , N 1,2,3,…,N 1 , 2 , 3 , … , N 。 每堆石子有一定的质量,可以用一个整数来描述,现在要将这 N N N 堆石子合并成为一堆。 每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。 例如有 4 4 4 堆石子分别为 1 3 5 2
, 我们可以先合并 1 、 2 1、2 1 、 2 堆,代价为 4 4 4 ,得到 4 5 2
, 又合并 1 、 2 1、2 1 、 2 堆,代价为 9 9 9 ,得到 9 2
,再合并得到 11 11 1 1 ,总代价为 4 + 9 + 11 = 24 4+9+11=24 4 + 9 + 1 1 = 2 4 ; 如果第二步是先合并 2 、 3 2、3 2 、 3 堆,则代价为 7 7 7 ,得到 4 7
,最后一次合并代价为 11 11 1 1 ,总代价为 4 + 7 + 11 = 22 4+7+11=22 4 + 7 + 1 1 = 2 2 。 问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
问题分析:
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import java.io.*;public class Main { static final int N = 310 , INF = 0x3f3f3f3f ; static int [] s = new int [N]; static int [][] f = new int [N][N]; public static void main (String[] args) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (System.in)); int n = Integer.parseInt(br.readLine()); String[] s1 = br.readLine().split(" " ); for (int i = 1 ; i <= n; i ++) s[i] = Integer.parseInt(s1[i - 1 ]); for (int i = 1 ; i <= n; i ++) s[i] += s[i - 1 ]; for (int len = 2 ; len <= n; len ++) { for (int i = 1 ; i + len - 1 <= n; i ++) { int l = i, r = i + len - 1 ; f[l][r] = INF; for (int k = l; k < r; k ++) { f[l][r] = Math.min(f[l][r], f[l][k] + f[k + 1 ][r] + s[r] - s[l - 1 ]); } } } System.out.print(f[1 ][n]); } }
区间dp
总结:
枚举区间长度len
,len
通常为2 ~ n
;
枚举区间左端点(一般i
从第一个元素开始,右端点i + len - 1
不能越界);
进行决策:
若有区间分界点,则枚举分界点(一般左端点位置为第一个分界点,右端点上一个位置为最后一个分界点)
若不需要分界点,则直接进行决策
5.4 计数类dp
算法思想:
计数类Dp
实际上就是计数类完全背包问题,只是在集合表示和集合属性上略有不同。计数类Dp
中,f[i][j]
的每一部分代表的就是该部分的数量,不需要加上价值,而完全背包则需要加上其价值。
问题分析:
笔误:集合表示只从前i
个数中选,且总体积恰好是j
的选法
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import java.util.Scanner;public class Main { static final int N = 1010 , MOD = (int )(1e9 + 7 ); static int [][] f = new int [N][N]; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(); f[0 ][0 ] = 1 ; for (int i = 1 ; i <= n; i ++) { for (int j = 0 ; j <= n; j ++) { f[i][j] = f[i - 1 ][j] % MOD; if (j >= i) f[i][j] = (f[i - 1 ][j] + f[i][j - i]) % MOD; } } System.out.print(f[n][n]); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import java.util.Scanner;public class Main { static final int N = 1010 , MOD = (int )(1e9 + 7 ); static int [] f = new int [N]; public static void main (String[] args) { Scanner sc = new Scanner (System.in); int n = sc.nextInt(); f[0 ] = 1 ; for (int i = 1 ; i <= n; i ++) for (int j = i; j <= n; j ++) f[j] = (f[j] + f[j - i]) % MOD; System.out.print(f[n]); } }
5.5 状态压缩dp
算法思想:
状态压缩dp的思想就是用一个数的二进制表现形式表示二维状态中某一维的状态。以蒙德里安的梦想这道题为例:
蒙德里安的梦想 :求把N × M N \times M N × M 的棋盘分割成若干个1 × 2 1 \times 2 1 × 2 的长方形,有多少种方案。
例如2 × 4 2 \times 4 2 × 4 的棋盘的方案有:
题目中要求使用1 × 2
的小方格把棋盘填满,那么我们可以横着填,也可以竖着填。当我们把横着填的长方形填完,那么竖着填的小方格的方案就是唯一的。因此我们只需要考虑横着填的长方形有多少种填法。 我们分析每一列,用一个二进制数表示该列长方形的摆放状态。如果该列的某一行是0
,则表示该列的这行没有摆放长方形,1
则是摆放了长方形。例如上面例图中的2 × 4
棋盘,第二个摆放方案对应的状态表示为[ 1 1 0 0 1 1 0 0 ] \begin{bmatrix}
1 & 1 & 0 & 0 \\
1 & 1 & 0 & 0
\end{bmatrix} [ 1 1 1 1 0 0 0 0 ] ,那么我们可以用3
表示第一列和第二列的状态,用0
表示第三列和第四列的状态。如何判断每一列的状态是不是一个合法的状态? 我们要知道,不是每一个数都能表示一个列的状态的,因为这是实际问题。例如,11
的二进制表示1011
就不是一个合法的状态,因为如果当前列这样摆放,那么竖着的长方形就无法摆放。 因此,只要一个数的二进制表示中出现了连续的奇数个0
时,则该数就不是一个合法的状态。如何进行状态转移? 首先我们定义状态数组f [ i ] [ j ] f[i][j] f [ i ] [ j ] 表示前i i i 列摆放完成的所有方案数。f [ i ] [ j ] f[i][j] f [ i ] [ j ] 表示所有能从上一个列的状态转移过来的方案数之和。
假设第i − 1 i - 1 i − 1 列被戳的状态为k k k ,第i i i 列被戳状态为j j j 。(只考虑二进制表示中的1 1 1 全部由上一列戳出的情况),那么这样第 i − 1 i - 1 i − 1 列被戳和戳出的情况的考虑完了(即第 i − 1 i - 1 i − 1 列的状态为 j ∣ k j \mid k j ∣ k ) 。
能够从第 i − 1 i - 1 i − 1 列戳到第 i i i 列的条件(能转移的条件,实际就是在判断第 i − 1 i- 1 i − 1 列的状态和转移方式是否合法):
第i − 1 i - 1 i − 1 与第i i i 列不能同时被戳(j & k = 0
)
在确定第i − 1 i- 1 i − 1 列被戳或向后戳出后所剩下的位置要能够摆放竖着的长方形(状态的二进制表示不能有连续奇数个0
:即j | k
的二进制表示不能有连续奇数个0
)
状态转移方程:
1 2 if (满足能够从第i - 1 列戳到第i列的条件) f[i][j] += f[i - 1 ][k];
初始化与答案: 根据f [ i ] [ j ] f[i][j] f [ i ] [ j ] 的含义,第0 0 0 列是不会被戳的,因此其上一列(虚拟)全是竖着摆放方块的(只有这一种方案),所以f [ 0 ] [ 0 ] = 1 f[0][0] = 1 f [ 0 ] [ 0 ] = 1 。 当我们将全部的m m m 列(0 ∼ m − 1 0 \sim m - 1 0 ∼ m − 1 )摆好时,第m m m 列(不需要摆放方块的列)没有被戳的所有方案数之和即为答案,即f [ m ] [ 0 ] f[m][0] f [ m ] [ 0 ] 。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import java.util.*;public class Main { static final int N = 12 , M = 1 << 12 ; static long [][] f = new long [N][M]; static boolean [] st = new boolean [M]; public static void main (String[] args) { Scanner sc = new Scanner (System.in); while (true ) { int n = sc.nextInt(), m = sc.nextInt(); if (n == 0 && m == 0 ) break ; Arrays.fill(st, true ); for (int i = 0 ; i < 1 << n; i ++) { int cnt = 0 ; for (int j = 0 ; j < n; j ++) if (((i >> j) & 1 ) == 1 ) { if ((cnt & 1 ) > 0 ) { st[i] = false ; break ; } } else cnt ++; if ((cnt & 1 ) > 0 ) st[i] = false ; } for (int i = 0 ; i < N; i ++) Arrays.fill(f[i], 0 ); f[0 ][0 ] = 1 ; for (int i = 1 ; i <= m; i ++) for (int j = 0 ; j < 1 << n; j ++) for (int k = 0 ; k < 1 << n; k ++) if ((j & k) == 0 && st[j | k]) f[i][j] += f[i - 1 ][k]; System.out.println(f[m][0 ]); } } }
第六章:由不同数据范围反推时间复杂度和算法内容
n ≤ 30 ⇒ n \leq 30 \Rightarrow n ≤ 3 0 ⇒ 指数级别:dfs + 剪枝
、状态压缩dp
n ≤ 100 ⇒ O ( n 3 ) n \leq 100 \Rightarrow \mathcal{O}(n^3) n ≤ 1 0 0 ⇒ O ( n 3 ) :floyd
、dp
、高斯消元
n ≤ 1000 ⇒ O ( n 2 ) 或 O ( n 2 log n ) n \leq 1000 \Rightarrow \mathcal{O}(n^2) 或 \mathcal{O}(n^2 \log n) n ≤ 1 0 0 0 ⇒ O ( n 2 ) 或 O ( n 2 log n ) :dp
、二分
、朴素Dijkstra
、朴素版Prim
、Bellman-Ford
n ≤ 10000 ⇒ O ( n × n ) n \leq 10000 \Rightarrow \mathcal{O}(n \times \sqrt{n}) n ≤ 1 0 0 0 0 ⇒ O ( n × n ) :块状链表
、分块
、莫队
n ≤ 1 0 5 ⇒ O ( n log n ) n \leq 10^5 \Rightarrow \mathcal{O}(n \log n) n ≤ 1 0 5 ⇒ O ( n log n ) :各种sort
、线段树
、树状数组
、set/map
、heap
、拓扑排序
、dijkstra + heap
、prim + heap
、Kruskal
、spfa
、求凸包
、求半平面交
、二分
、CDQ分治
、整体二分
、后缀数组
、树链剖分
、动态树
n ≤ 1 0 6 ⇒ O ( n ) 或常数较小的 O ( n log n ) n \leq 10^6 \Rightarrow \mathcal{O}(n)或常数较小的\mathcal{O}(n \log n) n ≤ 1 0 6 ⇒ O ( n ) 或 常 数 较 小 的 O ( n log n ) :单调队列
、hash
、双指针
、并查集
,kmp
、AC自动机
、sort
、树状数组
、heap
、dijkstra
、spfa
n ≤ 1 0 7 ⇒ O ( n ) n \leq 10^7 \Rightarrow \mathcal{O}(n) n ≤ 1 0 7 ⇒ O ( n ) :双指针
、kmp
、AC自动机
、线性筛质数
n ≤ 1 0 9 ⇒ O ( n ) n \leq 10^9 \Rightarrow \mathcal{O}(\sqrt{n}) n ≤ 1 0 9 ⇒ O ( n ) :试除法判断质数
n ≤ 1 0 18 ⇒ O ( log n ) n \leq 10^{18} \Rightarrow \mathcal{O}(\log n) n ≤ 1 0 1 8 ⇒ O ( log n ) :最大公约数
,快速幂
,数位dp
n ≤ 1 0 1000 ⇒ O ( ( log n ) 2 ) ( log n 表示位数 ) n \leq 10^{1000} \Rightarrow \mathcal{O}((\log n)^2)(\log n 表示位数) n ≤ 1 0 1 0 0 0 ⇒ O ( ( log n ) 2 ) ( log n 表 示 位 数 ) :高精度加减乘除
n ≤ 1 0 100000 ⇒ O ( log k × log log k ) ( k 表示位数 ) n \leq 10^{100000} \Rightarrow \mathcal{O}(\log k \times \log\log k)(k表示位数) n ≤ 1 0 1 0 0 0 0 0 ⇒ O ( log k × log log k ) ( k 表 示 位 数 ) :高精度加减
、FFT/NTT