hello hello~ ,这里是绝命Coding——老白~💖💖 ,欢迎大家点赞🥳🥳关注💥💥收藏🌹🌹🌹
💥个人主页:绝命Coding-CSDN博客
💥 所属专栏:后端技术分享
这里将会不定期更新有关后端、前端的内容,希望大家多多点赞关注收藏💖
小建议:
大厂面试中的算法题大部分情况是力扣原题,并且难度可控,对于经常在力扣刷题的同学一般问题不大。而大厂笔试的算法题则往往是原创题,前几道难度稍微简单,后几道通常得刷题更多的或者有打算法的同学才能做出来。
在笔试当中,在遇到不会的题,通常可以采取骗分的技巧(文末有总结),只有能通过部分案例骗到部分分数。
在面试的算法题,流程如下:首先可以先思考,然后先告诉面试官算法思路(不用急着打代码),然后接下来就是打代码以及调试(这里注意要考虑好边界情况,体现自己的代码严谨;同时,也要注意一下代码规范)。
部分算法模板以及知识点来自:www.acwing.com (y总的算法课)
由数据范围反推算法复杂度以及算法内容
10^5 为界限,这个得是O(nlogn)
基础
排序
库函数
sort(q.begin,q.end());
sort(q.rbegin,q.rend());
快速排序算法模板
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) swap(q[i], q[j]);
}
quick_sort(q, l, j), quick_sort(q, j + 1, r);
}
归并排序算法模板
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)从数组的begin位置到end-1位置二分查找第一个小于或等于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标
lower_bound(begin,end,num)
//从数组的begin位置到end-1位置二分查找第一个大于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标。
upper_bound(begin,end,num)
整数二分算法模板
bool check(int x) {/* ... */} // 检查x是否满足某种性质
// 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1;
if (check(mid)) r = mid; // check()判断mid是否满足性质
else l = mid + 1;
}
return l;
}
// 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1; // 由于整数要下取整,所以要补上1
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
优化:对于(left + right) >> 1在数值较大时应该写成 left + (right - left) / 2 或者 left + ((right - left) >> 1) 防止溢出。
其实就是,第一个模板找到的是左边的(最小),第二个模板找到的是右边的(最大)
即
第一种找目标值在数组中最 左 边出现的位置
第二种找目标值在数组中最 右 边出现的位置
加强版
无论是求左边界还是右边界都是这一套写法
区别在于
如果要求的是左边界,那么就返回left
如果要求的是右边界,那么就返回right
int bsearch(int left, int right)
{
while (left <= right)
{
int mid = left + right >> 1;
if (check(mid)) left = mid + 1;
else right = mid - 1;
}
return left或right
}
浮点数二分算法模板
bool check(double x) {/* ... */} // 检查x是否满足某种性质
double bsearch_3(double l, double r)
{
const double eps = 1e-6; // eps 表示精度,取决于题目对精度的要求
while (r - l > eps)
{
double mid = (l + r) / 2;
if (check(mid)) r = mid;
else l = mid;
}
return l;
}
高精度
高精度加法 —— 模板题 AcWing 791. 高精度加法
// C = A + B, A >= 0, B >= 0
vector<int> add(vector<int> &A, vector<int> &B)
{
if (A.size() < B.size()) return add(B, A);
vector<int> C;
int t = 0;
for (int i = 0; i < A.size(); i ++ )
{
t += A[i];
if (i < B.size()) t += B[i];
C.push_back(t % 10);
t /= 10;
}
if (t) C.push_back(t);
return C;
}
高精度减法 —— 模板题 AcWing 792. 高精度减法
// C = A - B, 满足A >= B, A >= 0, B >= 0
vector<int> sub(vector<int> &A, vector<int> &B)
{
vector<int> C;
for (int i = 0, t = 0; i < A.size(); i ++ )
{
t = A[i] - t;
if (i < B.size()) t -= B[i];
C.push_back((t + 10) % 10);
if (t < 0) t = 1;
else t = 0;
}
while (C.size() > 1 && C.back() == 0) C.pop_back();
return C;
}
高精度乘低精度 —— 模板题 AcWing 793. 高精度乘法
// C = A * b, A >= 0, b >= 0
vector<int> mul(vector<int> &A, int b)
{
vector<int> C;
int t = 0;
for (int i = 0; i < A.size() || t; i ++ )
{
if (i < A.size()) t += A[i] * b;
C.push_back(t % 10);
t /= 10;
}
while (C.size() > 1 && C.back() == 0) C.pop_back();
return C;
}
高精度除以低精度 —— 模板题 AcWing 794. 高精度除法
// A / b = C ... r, A >= 0, b > 0
vector<int> div(vector<int> &A, int b, int &r)
{
vector<int> C;
r = 0;
for (int i = A.size() - 1; i >= 0; i -- )
{
r = r * 10 + A[i];
C.push_back(r / b);
r %= b;
}
reverse(C.begin(), C.end());
while (C.size() > 1 && C.back() == 0) C.pop_back();
return C;
}
数论
进制
//10 进制转换为其他进制
char get(int x)//将x转换为字符形式
{
if (x <= 9) return x + '0';
return x - 10 + 'A';
}
string base(int n, int b)//将n转换为b进制,返回对应字符串
{
string res;
while (n)
{
res += get(n % b);
n /= b;
}
reverse(res.begin(), res.end());//翻转回高位在左
return res;
}
// 转10进制
int uget(char c)
{
if (c <= '9') return c - '0';//'0' 代表 字符0 ,对应ASCII码值为 0x30 (也就是十进制 48)
return c + 10 - 'A';
}
int base10(string num, int b)
{
int res = 0;
for (auto c: num)
res = res * b + uget(c);
return res;
}
或者
将x以n进制的形式输出
#include<iostream>
#include<iomanip>
cout<<setbase(n)<<x<<endl;
最大公约数
int gcd(int a,int b){
if( b == 0 ) return a;
return gcd(b,a%b);
}
或者
int gcd(long a,long b){
return b==0?a:gcd(b,a%b);
}
自带的(两个下划线)
__gcd(a,b)
最小公倍数
LCD =(a*b)/ GCD
试除法判定质数
写成i <= x / i;
bool is_prime(int x)
{
if (x < 2) return false;
for (int i = 2; i <= x / i; i ++ )
if (x % i == 0)
return false;
return true;
}
试除法分解质因数(蓝桥杯考过)
void divide(int x)
{
for (int i = 2; i <= x / i; i ++ )
if (x % i == 0)
{
int s = 0;
while (x % i == 0) x /= i, s ++ ;
cout << i << ' ' << s << endl;
}
if (x > 1) cout << x << ' ' << 1 << endl;
cout << endl;
}
筛质数
朴素筛法求素数
对于每个数,筛掉他的所有倍数
时间复杂度为 logn * n
vector<int> primes; // primes[]存储所有素数
vector<bool> isPrime = vector<bool>(N,true); // isPrime[x]存储x是否是素数
void get_primes(int n)
{
for (int i = 2; i <= n; i ++ )
{
if (!isPrime[i]) continue; //如果不是素数(说明已经筛出过),跳下一个
primes.push_back(i);
for (int j = i + i; j <= n; j += i)
isPrime[j] = false;
}
}
埃氏筛法求素数(推荐)
对于上面的朴素筛法,可以发现不需要对每个数都进行筛倍数,只需要对质数筛即可,因为任何一个合数都会先被他的最小质因子筛掉。所以只需将筛倍数的循环放到 if 里面即可。
复杂度:质数定理:1 - n 中质数的数量是 n / lnn 个,
所以埃氏筛法的复杂度为 n * loglog n,很接近线性了
vector<int> primes; // primes[]存储所有素数
vector<bool> isPrime = vector<bool>(N,true); // isPrime[x]存储x是否是素数
void get_primes(int n)
{
for(int i = 2; i <= n; i ++ )
{
if(isPrime[i])
{
primes.push_back(i);
for(int j = i * 2; j <= n; j += i)
isPrime[j] = false;
}
}
}
线性筛法求素数
线性筛
线性筛法的核心是:n 只会被最小质因子筛掉,复杂度 n 。
例如 在埃氏筛中 6 会被 2 和 3 都筛一遍,增加了复杂度
代码中两种情况的解释:
当 i % pj == 0 时,pj 一定是 i 的最小质因子,因此 pj 一定是 pj * i 的最小质因子。
当 i % pj != 0 时,pj 一定小于 i 的最小质因子,因此 pj 一定是 pj * i 的最小质因子。
vector<int> primes; // primes[]存储所有素数
vector<bool> isPrime = vector<bool>(N,true); // isPrime[x]存储x是否是素数
void get_primes(int n)
{
for (int i = 2; i <= n; i ++ )
{
if (isPrime[i]) primes.push_back(i);
for (int j = 0; primes[j] <= n / i; j ++ )
{
isPrime[primes[j] * i] = false;
if (i % primes[j] == 0) break;
}
}
}
快速幂
int qmi(int a,int b,int p){
int res = 1%p;
while(b){
//如果是奇数则
if( b & 1 ) res=(long long)res * a %p;
a = (long long)a*a%p;
b >>= 1 ;
}
return res;
}
p和q均为正整数且互质,则用p和q凑不出来的最大整数为
(p - 1) * (q - 1) - 1;
位运算
求n的第k位数字: n >> k & 1
返回n的最后一位1:lowbit(n) = n & -n
最后一位是1则为真:n & 1
右移1位,相当于除以2:n >>= 1;
二进制枚举
1.朴素版
for(int i = 0; i < (1<<n); i++) // 从0~2^n-1个状态
{
for(int j = 0; j < n; j++) // 遍历二进制的每一位
{
if(i & (1 << j))// 判断二进制第j位是否存在
{
printf("%d ",j);// 如果存在输出第j个元素
}
}
// 后续处理
}
return 满足要求足的状态集或状态数目;
2.优化版
如果我们对n个元素的所有子集进行子集的枚举,下面的两重循环可以在O(3^n)的时间复杂度内完成。
// n => 总状态数
for (int i = 1; i < (1 << n); ++i) {
for (int j = i; j; j = (j - 1) & i) {
// ...
}
}
子集
class Solution {
public:
// 二进制枚举
// 时间复杂度 O(n * 2^n)
vector<vector<int>> subsets(vector<int>& nums) {
int n = nums.size();
vector<vector<int>> res;
// 相当于i < 2^n , 即n个1(二进制表现形式)
for( int i = 0 ; i < (1<<n) ; i ++ ){ // 0 ~ 2^(n-1) 枚举所有的情况
vector<int> tmp;
for( int j = 0 ; j < n ; j++ ){ // 右移的位数
if( i >> j & 1 ){ // 每位是否为1
tmp.push_back(nums[j]);
}
}
res.push_back(tmp);
}
return res;
}
};
动态规划
单个数组或者字符串要用动态规划时,可以把动态规划 dp[i] 定义为 nums[0:i] 中想要求的结果;当两个数组或者字符串要用动态规划时,可以把动态规划定义成两维的 dp[i][j] ,其含义是在 A[0:i] 与 B[0:j] 之间匹配得到的想要的结果。
背包问题
01背包
//本质:分解为子问题的求解
#include<iostream>
using namespace std;
const int N=1010; //(习惯性在数据范围+10)
int f[N][N]; //状态方程:前i个背包且不超过j的最大集合
int v[N],w[N]; //体积和价值
int main(){
int n,m; //背包件数和最大容量
cin>>n>>m;
for(int i=1;i<=n;i++){ //注意这里是从1开始的,所以下面i<=n
cin>>v[i]>>w[i];
}
//因为全局变量默认初始化为0的,所以f[0][]可以省略
//下面可以想成是填表格那种
for(int i=1;i<=n;i++){
for(int j=0;j<=m;j++){
//状态方程:前i个背包且不超过j的最大价值
f[i][j]=f[i-1][j]; //背包装不下
if(j>=v[i]){ //背包还能装下
//先减去第i个,并减去他的质量,再加上i的价值
f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
}
}
}
cout<<f[n][m];
}
优化做法
#include<iostream>
using namespace std;
const int N=1010;
int f[N];
int v[N],w[N];
int main(){
int n,m; //背包件数和最大容量
cin>>n>>m;
for(int i=1;i<=n;i++){ //注意这里是从1开始的
cin>>v[i]>>w[i];
}
for(int i=1;i<=n;i++){
//从后往前,对应的上一次的值(还未更新)
for(int j=m;j>0;j--){
if(j>=v[i]){
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
}
cout<<f[m];
}
线性DP(序列DP)
线性DP是指我们的递推方程是存在一个线性的递推关系。可以是一维线性的、二维线性的、三维线性的、…
最长公共子序列(最经典双串)
class Solution {
public:
// 最长公共子序列
// 序列dp
// dp[i][j]表示从[0……i]的nums1和[0……j]的nums2 的最大连线数
// nums[i] == num[j] dp = dp[i-1][j-1] + 1
// 否则dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
int minDistance(string word1, string word2) {
int n = word1.size(),m = word2.size();
vector<vector<int>> f = vector<vector<int>>(n+2,vector<int>(m+2,0));
for( int i = 1 ; i <= n ; i++ ){
for( int j = 1 ; j <= m ; j++ ){
if( word1[i-1] == word2[j-1] ){
f[i][j] = max(f[i][j],f[i-1][j-1]+1);
}else{
f[i][j] = max(f[i][j-1],f[i-1][j]);
}
}
}
int lcs = f[n][m];
return lcs;
}
};
最长递增子序列
class Solution {
public:
//f[n] 表示最大长度
//递推方程 f[i] = max(f[i],f[k]+1) k = 0 …… i-1 ( a[k] < a[i] )
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
vector<int> f = vector<int>(n+2);
for( int i = 0 ; i < n ; i ++ ){
f[i] = 1;
for( int k = 0 ; k < i ; k++ ){
if( nums[k] < nums[i] ){
f[i] = max(f[i],f[k]+1 );
}
}
}
return *max_element(f.begin(),f.end());
}
};
最大连续子序列(最经典单串)
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if(nums.size()==0) return nums[0];
int f[30005] = {0};
f[0] = nums[0];
for( int i = 1 ; i < nums.size() ; i++){
f[i] = max(f[i-1]+nums[i],nums[i]);
}
int amax = f[0];
for(int i = 1 ; i < nums.size() ; i++ ){
amax = max(amax,f[i]);
}
return amax;
}
};
区间DP
所谓区间DP是指在定义状态的时候定义了一个区间,我们根据区间长度len由小到大逐步递推。
最长回文字串
lass Solution {
public:
//动态规划
//dp[n][m]数组,其中dp[i][j]表示从字符串下标 i开始到j 的子串是否为回文字符串,是则保存为1,不是保存为0
//dp[i][j] 是不是回文字符串有两个条件
//s[i] == s[j]
//dp[i+1][j-1] 也是回文字符串
//当i,j离的比较近时,dp[i+1][j-1]就不存在了
string longestPalindrome(string s) {
int n = s.size();
if( n < 2 ) return s;
vector<vector<int>> dp = vector<vector<int>>(n,vector<int>(n,0));
//因为需要,需要知晓dp[i + 1][j - 1]是否为回文串,所以需要从下往上递推
int maxLen = 1;
int begin = 0;
//j+1-i即为长度
for( int i = n-1 ; i >= 0 ; i-- ){
dp[i][i] = 1;
for( int j = i+1 ; j < n ; j++ ){
if( s[i] == s[j] && (i + 1 >= j - 1 || dp[i+1][j-1] == 1)){
dp[i][j] = 1;
}else{
dp[i][j] = 0;
}
// 只要 dp[i][L] == true 成立,就表示子串 s[i..L] 是回文,此时记录回文长度和起始位置
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j - i + 1;
begin = i;
}
}
}
//第一个参数是起始位置,第二个参数是长度
return s.substr(begin,maxLen);
}
};
树形DP
在树上进行DP操作。
数位DP
和数字相关,一般让我们求方案数。
题目一般的套路:
(1)一般会让求解某个区间中满足某种性质的数的个数,可以转化为求[0, t]中满足条件的数的个数,如果求解[x, y]之间满足性质的数的个数,则结果为f(y) - f(x-1),类似于前缀和的思想。
(2)将t每一位数据抠出来,然后一位一位数字进行考虑,按照树的结构进行考虑,如下图
数位DP往往都是这样的题型,给定一个闭区间 [ l , r ] [l,r] [l,r],让你求这个区间中满足某种条件的数的总数。而这个区间可能很大,简单的暴力代码如下:
int ans=0;
for(int i=l;i<=r;i++){
if(check(i))ans++;
}
我们发现,若区间长度超过 1e8,我们暴力枚举就会超时了,而数位 DP则可以解决这样的题型。数位DP实际上就是在数位上进行 DP
数位 D P DP DP就是换一种暴力枚举的方式,使得新的枚举方式符合 D P DP DP的性质,然后预处理好即可。 我们来看:我们可以用 f(n)表示[0,n]的所有满足条件的个数,那么对于 [l,r]我们就可以用 [l,r]⟺f(r)−f(l−1),相当于前缀和思想。那么也就是说我们只要求出 f ( n ) f(n) f(n)即可。那么数位 D DP关键的思想就是从树的角度来考虑。将数拆分成位,从高位到低位开始枚举。我们可以视 N为n位数,那么我们拆分 N : an、an−1...a1。
数字1的个数
//时间0 ms 击败100%
//内存5.9 MB 击败36.79%
class Solution {
public:
//统计0到n出现1的个数
//时间复杂度O(s^2),s是n这个数字一共有多少位
//计数dp
//1.如果d=0,即abc0efg,左边可以取0~abc-1,共abc种,
//右边可以取0~999,共1000种情况,
//所以 abc*1000
//2.如果d=1,abc1efg,
//abc*1000+efg+1
//3.如果d>1,即abc2efg,左边0~abc,右边0~999
//(abc+1)*1000
int countDigitOne(int n) {
if( n <= 0 ) return 0;
vector<int> nums;
//低位到高位存储到vector中
while( n ){
nums.push_back(n%10);
n/=10;
}
reverse(nums.begin(),nums.end());
int res = 0;
for( int i = 0 ; i < nums.size() ; i ++ ){
int d = nums[i];
int left = 0 , right = 0 , p = 1; //left是i左边的,right是右边的
//比如 abciefg
for( int j = 0 ; j < i ; j ++ ){
left = left*10 + nums[j];
}
for( int j = i + 1 ; j < nums.size() ; j ++ ){
right = right * 10 + nums[j];
p = p *10;
}
if( d == 0 ) res += left * p;
else if( d == 1 ) res += left*p+right+1;
else res += (left+1) * p;
}
return res;
}
};
计数DP
此类问题一般让我们求解方案数。
状态压缩DP
状态压缩动态规划,就是我们俗称的状压DP,是利用计算机二进制的性质来描述状态的一种DP方式
使用场景
状态压缩的题目,一般都会有非常明显的标志:如果你看到有一个参数的数值小于20,同时这道题目中有涉及到是否选取、是否使用这样的二元状态,那么这道题目很可能就是一道状态压缩的题目。
枚举子集优化:
状态压缩的难题经常用到一个技巧:快速枚举子集
假设我们有一个用二进制数x表示的集合(某一位为1代表集合含有对应元素,反之则代表集合中不含对应元素),我们应该如何来枚举它的子集?
朴素的想法是,枚举所有小于等于该数的二进制数,逐个检查当前枚举到的y其是否是x的子集(是否满足x∣y =x)。
更优解:
for (int j = x; j; j = (j - 1) & x) {
// ...
}
该循环中的每个条件成立的j都是x的子集。
上面这段代码中最关键的部分就是j = (j - 1) & x。这一步操作,首先将j减一,从而把j最右边的1变成了0,然后把之后的所有0变成了1。再与x求与,就保证了得到的结果是x的子集,并且是小于前一个j的二进制数中最大的一个。利用这一方式,我们可以倒序枚举出j的所有子集,并且中间不会经过任何不合法的状态。
如果我们对n个元素的所有子集进行子集的枚举,下面的两重循环可以在O(3^n)的时间复杂度内完成。
for (int i = 1; i < (1 << n); ++i) {
for (int j = i; j; j = (j - 1) & i) {
// ...
}
}
博弈类DP
博弈DP解决的是两人轮流操作,且没有平局的两人博弈游戏,和博弈问题的形式相同。
其核心思路是在二维 dp 的基础上使用元组分别存储两个人的博弈结果。一般为倒序,和期望的求法类似。 状态的转移可以类比于博弈论中前后状态的关系,即必胜态和必败态。
优先队列
//升序队列(小顶堆)
priority_queue <int,vector<int>,greater<int> > q;
//降序队列(大顶堆)
priority_queue <int,vector<int>,less<int> >q; (相当于默认)
//大顶堆
priority_queue <int> q;
top 访问队头元素
empty 队列是否为空
size 返回队列内元素个数
push 插入元素到队尾 (并排序)
emplace 原地构造一个元素并插入队列
pop 弹出队头元素
swap 交换内容
树状数组
1 可以求一个序列的逆序对个数
2 可以解决 区间增加和单点查询的操作
3 可以解决 给定一个序列 序列中任意一个数an 求比an大的数或者小的数的个数,
和计数排序一样, 要知道这个序列的最大和最小数, c数组要开的大小等于最大减最小,c[ k ]的含义是指统计 [1,k] 范围内的数字出现的次数 求 比k小的数的个数 直接 query(k-1) 就可以得到结果
概要
树状数组是一个查询和修改复杂度都为log(n)的数据结构。主要用于数组的单点修改或者区间求和。
树状数组的重点就是利用二进制的变化,动态地更新树状数组。
树状数组的每一个节点并不是代表原数组的值,而是包含了原数组多个节点的值。
所以在更新A[1]时需要将所有包含A[1]的C[i]都加上val这也就利用到了二进制的神奇之处。
如果是更新A[i]的值,则每一次对C[i]中的 i 向上更新,即每次i+=lowbit(i),这样就能C[i] 以及C[i]的所有父节点都加上val
实现原理
模板中最常见的三个函数:
①取数组下标二进制非0最低位所表示的值;
②单点更新;
③区间查询。
模板代码
class BIT{
private:
vector<int> tree;
int n;
public:
BIT(int _n):n(_n),tree(_n+1){}
static int lowbit(int x){
return x & -x;
}
/**
* 单点更新:把序列 x 位置的数加上一个值 c
*/
void add(int x,int c){
for( int i = x ; i <= n ; i += lowbit(i) ){
tree[i] += c;
}
}
/**
* 查询序列: [1⋯i] 区间的区间和,即 i 位置的前缀和
*/
int sum(int x){
int res = 0;
for( int i = x ; i ; i -= lowbit(i)){
res += tree[i];
}
return res;
}
}
树状数组最经典的应用就是:对序列中的每个数,求出序列中它左边比它小的数的个数 。
正序遍历是求自己前面比自己大的数
倒序遍历是求自己后面比自己小的数
对树状数组进行离散化主要有两个场景:
- 序列中元素的 最大值 过大或者有负数。
- 降低空间复杂度。
区域和检索 - 数组可修改
class NumArray {
public:
vector<int> tree;
vector<int> &nums;
int lowbit(int x) {
return x & -x;
}
int sum(int x) {
int ans = 0;
for (int i = x; i > 0; i -= lowbit(i)) ans += tree[i];
return ans;
}
void add(int x, int u) {
for (int i = x; i <= n; i += lowbit(i))
tree[i] += u;
}
int n;
//树状数组
NumArray(vector<int>& nums) : tree(nums.size() + 1), nums(nums) {
n = nums.size() ;
for (int i = 0; i < n; i++) add(i + 1, nums[i]);
}
void update(int index, int val) {
add(index+1,val-nums[index]);
nums[index] = val;
}
int sumRange(int left, int right) {
return sum(right+1) - sum(left);
}
};
统计逆序对(离散化+树状数组)
思路:与逆序对一致,树状数组中前i-1表示有几个数比 i 小,也即 i 右侧有几个更小的元素,因为是从后往前加入树状数组的
比如 5 2 6 1 现在从后往前
1的时候,sum(1-1) 没东西,把1的位置标记为1
6的时候,sum(6-1)值等于1,所以比他小的有一个,继续把6的位置标记为1
以此类推
sum(i)表示[1,x]中出现数的个数。
每个元素的值都是其对应区间内元素的和
vector<int> countSmaller(vector<int>& nums) {
int n = nums.size();
vector<int> tmp = nums;
//离散化
sort(tmp.begin(),tmp.end());
for( int & num : nums ){
num = lower_bound(tmp.begin(),tmp.end(),num) - tmp.begin() + 1;
}
//树状数组统计逆序对
BIT bit(n);
vector<int> ans = vector<int>(n);
// 从右向左扫描,统计每个数右边比它小的数的个数
for( int i = n-1 ; i >= 0 ; i-- ){
// 统计位置k-1之前的元素个数
ans[i] = bit.sum(nums[i]-1);
// 每处理一个数,就把k加入到集合中去,相当于在k这个位置上+1
bit.add(nums[i],1);
}
return ans;
}
线段树
在树的每一层中,所有的区间和恰好就是整个区间,而且同一层中的不同区间不会存在交集。
线段树可以进行的操作,可以区间修改,区间查询,单点修改,单点查询,复杂度都是(logn)
线段树的基本思想就是二分
用数组实现线段树,一般需要维护4个数组
本质就是一个数组(但是形式是一个树,比如下标u为根,2u为左子树,2u+1为右子树,然后每个元素里面存储了l、r、sum,分别表示左端点、右端点和该区间内的总和;左子树为原本的左端点和父树的中间端点,右子树则为原本的中间端点到右子树)
l[id]:标号为id的区间的左边界
r[id];标号为id的区间的右边界
sum[id]:标号为id的区间的所有数的和(根据题目不同,这个数组具有不同的含义)
u<<1 表示将 u 的二进制数左移一位,相当于将 u 乘以 2,这就是 u 的左儿子的编号。
u<<1|1 表示将 u 的二进制数左移一位,然后在最低位上加上 1,相当于将 u 乘以 2 再加上 1,这就是 u 的右儿子的编号。
u + r >> 1 相当于 u + r 除以2
基础代码(线段树单点修改+区间查询)
#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
using namespace std;
const int N=100010;
int n,m;
int w[N];//记录一下权重
struct node{
int l,r;//左右区间
int sum;//总和
}tr[N*4];//记得开 4 倍空间
void push_up(int u)//利用它的两个儿子来算一下它的当前节点信息
{
tr[u].sum=tr[u<<1].sum+tr[u<<1|1].sum;//左儿子 u<<1 ,右儿子 u<<1|1
}
void build(int u,int l,int r)/*第一个参数,当前节点编号,第二个参数,左边界,第三个参数,右边界*/
{
if(l==r)tr[u]={l,r,w[r]};//如果当前已经是叶节点了,那我们就直接赋值就可以了
else//否则的话,说明当前区间长度至少是 2 对吧,那么我们需要把当前区间分为左右两个区间,那先要找边界点
{
tr[u]={l,r};//这里记得赋值一下左右边界的初值
int mid=l+r>>1;//边界的话直接去计算一下 l + r 的下取整
build(u<<1,l,mid);//先递归一下左儿子
build(u<<1|1,mid+1,r);//然后递归一下右儿子
push_up(u);//做完两个儿子之后的话呢 push_up 一遍u ,更新一下当前节点信息
}
}
int query(int u,int l,int r)//查询的过程是从根结点开始往下找对应的一个区间
{
if(l<=tr[u].l&&tr[u].r<=r)return tr[u].sum;//如果当前区间已经完全被包含了,那么我们直接返回它的值就可以了
//否则的话我们需要去递归来算
int mid=tr[u].l+tr[u].r>>1;//计算一下我们 当前 区间的中点是多少
//先判断一下和左边有没有交集
int sum=0;//用 sum 来表示一下我们的总和
if(l<=mid)sum+=query(u<<1,l,r);//看一下我们当前区间的中点和左边有没有交集
if(mid+1<=r)//看一下我们当前区间的中点和右边有没有交集
sum+=query(u<<1|1,l,r);
return sum;
}
void modify(int u,int x,int v)//第一个参数也就是当前节点的编号,第二个参数是要修改的位置,第三个参数是要修改的值
{
if(tr[u].l==tr[u].r)tr[u].sum+=v; //如果当前已经是叶节点了,那我们就直接让他的总和加上 v 就可以了
//否则
else
{
int mid=tr[u].l+tr[u].r>>1;
//看一下 x 是在左半边还是在右半边
if(x<=mid)modify(u<<1,x,v);//如果是在左半边,那就找左儿子
else modify(u<<1|1,x,v);//如果在右半边,那就找右儿子
//更新完之后当前节点的信息就要发生变化对吧,那么我们就需要 pushup 一遍
push_up(u);
}
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)scanf("%d",&w[i]);
// 注意是从1开始
build(1,1,n);/*第一个参数是根节点的下标,根节点是一号点,然后初始区间是 1 到 n */
//后面的话就是一些修改操作了
while(m--)
{
int k,a,b;
scanf("%d%d%d",&k,&a,&b);
if(!k)printf("%d\n",query(1,a,b));//求和的时候,也是传三个参数,第一个的话是根节点的编号 ,第二个的话是我们查询的区间
//第一个参数也就是当前节点的编号
else
modify(1,a,b);//第一个参数是根节点的下标,第二个参数是要修改的位置,第三个参数是要修改的值
}
return 0;
}
如果将求和变成了求最大值,维护线段树的节点即可
//维护区间内最大值
void pushup(int u){
tr[u].v=max(tr[u<<1].v,tr[u<<1|1].v);
}
区域和检测——数组修改
class NumArray {
public:
struct Node{
int l, r;
int sum;
}tr[30000*4];
void build(int u,int l,int r){
if( l == r ){
tr[u] = {l,r,0};
}else{
// 注意这里赋值初始值
tr[u] = {l,r};
int mid = l + r >> 1;
build(u<<1,l,mid);
build(u<<1|1,mid+1,r);
push_up(u);
}
}
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);
push_up(u);
}
}
// 利用两个儿子来更新节点信息
void push_up(int u){
tr[u].sum = tr[u<<1].sum + tr[u<<1|1].sum;
}
int query(int u,int l,int r){
if( l <= tr[u].l && tr[u].r <= r ) return tr[u].sum;
else{
int res = 0;
int mid = tr[u].l + tr[u].r >> 1;
if( l <= mid ) res += query(u<<1,l,r);
if( mid+1 <= r ) res += query(u<<1|1,l,r);
return res;
}
}
vector<int> nums;
NumArray(vector<int>& _nums) {
nums = _nums;
int n = nums.size();
build(1,1,n);
for( int i = 0 ; i < n ; i ++ ){
modify(1,i+1,nums[i]);
}
}
void update(int index, int val) {
modify(1,index+1,val-nums[index]);
nums[index] = val;
}
int sumRange(int left, int right) {
return query(1,left+1,right+1);
}
};
带惰性标记(区间修改,区间查询)
原来的线段树主要用于区间查询和单点修改,每个节点存储的是区间的信息,对于每次修改操作,需要递归地更新整棵线段树,时间复杂度为 O(log n)
带懒惰标记的线段树主要用于区间修改和区间查询,每个节点存储的是区间的懒惰标记和区间的信息。对于每次修改操作,只需要将修改的标记打在当前节点上,而不需要递归地更新整棵树,时间复杂度为 O(1)。对于查询操作,需要递归地将所有的懒惰标记全都更新下传,并计算出当前查询区间的信息,时间复杂度为 O(log n)
主要区别在 push_up 和 push_down 函数的实现上:
- 在节点结构体中添加懒惰标记(lazy 表示该区间需要加上的值)
struct node{
int l,r;//左右区间
int sum;//总和
int lazy;//懒惰标记
}tr[N*4];//记得开 4 倍空间
- 在 push_up 和 push_down 中加入懒惰标记的更新和下传
void push_up(int u)//利用它的两个儿子来算一下它的当前节点信息
{
tr[u].sum=tr[u<<1].sum+tr[u<<1|1].sum;//左儿子 u<<1 ,右儿子 u<<1|1
}
void push_down(int u)
{
if(tr[u].lazy)//如果有懒惰标记的话,就下传到子节点中
{
tr[u<<1].lazy+=tr[u].lazy;
tr[u<<1|1].lazy+=tr[u].lazy;
tr[u<<1].sum+=tr[u].lazy*(tr[u<<1].r-tr[u<<1].l+1);//更新左儿子的值
tr[u<<1|1].sum+=tr[u].lazy*(tr[u<<1|1].r-tr[u<<1|1].l+1);//更新右儿子的值
tr[u].lazy=0;//将当前节点的懒惰标记清零
}
}
- 在修改操作中加入懒惰标记的记录
void modify(int u,int l,int r,int v)//第一个参数也就是当前节点的编号,第二个参数是要修改的区间左端点,第三个参数是要修改的区间右端点,第四个参数是要修改的值
{
if(tr[u].l>=l&&tr[u].r<=r)//如果当前区间被包含在要修改的区间内,那么就直接修改
{
tr[u].sum+=v*(tr[u].r-tr[u].l+1);
tr[u].lazy+=v;
}
else
{
push_down(u);//先把当前节点的懒惰标记下传到它的儿子节点中
int mid=tr[u].l+tr[u].r>>1;//计算一下我们 当前 区间的中点是多少
//先判断一下和左边有没有交集
if(l<=mid) modify(u<<1,l,r,v);//看一下我们当前区间的中点和左边有没有交集
if(r>mid) modify(u<<1|1,l,r,v);//看一下我们当前区间的中点和右边有没有交集
push_up(u);//做完两个儿子之后的话 push_up 一遍u ,更新一下当前节点信息
}
}
- 在查询操作中加入懒惰标记的下传
int query(int u,int l,int r)//查询的过程是从根结点开始往下找对应的一个区间
{
if(l<=tr[u].l&&tr[u].r<=r)return tr[u].sum;//如果当前区间已经完全被包含了,那么直接返回这个区间的和
pushdown(u);//否则需要将懒惰标记下传到子节点
int mid=(tr[u].l+tr[u].r)>>1;
int res=0;
if(l<=mid)res+=query(tr[u].ls,l,r);//如果左区间有交集,就往左子树找
if(r>mid)res+=query(tr[u].rs,l,r);//如果右区间有交集,就往右子树找
return res;//返回区间和
}
或者
多维护一个懒标记即可,延迟修改子节点
//sum要用ll存储,防止爆掉int
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n, m;
int w[N];
struct node {
int l, r;
LL sum, add;
}tr[N * 4];
void pushup(int u) {
tr[u].sum = tr[u << 1].sum + tr[u << 1 | 1].sum;
}
void pushdown(int u) {
auto &root = tr[u], &left = tr[u << 1], &right = tr[u << 1 | 1];
if (root.add) {
left.add += root.add, left.sum += (LL)(left.r - left.l + 1) * root.add;
right.add += root.add, right.sum += (LL)(right.r - right.l + 1) * root.add;
root.add = 0;
}
}
void build(int u, int l, int r) {
if (l == r) tr[u] = {l, r, w[l], 0};
else {
tr[u] = {l, r};//不要忘
int mid = l + r >> 1;
build(u << 1, l, mid), build(u << 1 | 1, mid + 1, r);
pushup(u);//pushup
}
}
void modify(int u, int l, int r, int x) {
if ( l <= tr[u].l && tr[u].r <= r) {
tr[u].sum += (LL)(tr[u].r - tr[u].l + 1) * x;
tr[u].add += x;
}
else {
pushdown(u);//modify之间要pushdown
int mid = tr[u].l + tr[u].r >> 1;
if (l <= mid) modify(u << 1, l, r, x);
if (r > mid) modify(u << 1 | 1, l, r, x);
pushup(u);//注意pushup
}
}
LL query(int u, int l, int r) {
if ( l <= tr[u].l && tr[u].r <= r) return tr[u].sum;
pushdown(u);
int mid = tr[u].l + tr[u].r >> 1;
LL ans = 0;
if (l <= mid) ans = query(u << 1, l, r);
if (r > mid) ans += query(u << 1 | 1, l, r);
return ans;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i ++) cin >> w[i];
build(1, 1, n);
while (m --) {
char op; cin >> op;
int l, r, x;
cin >> l >> r;
if (op == 'Q') cout << query(1, l, r) << endl;
else {
cin >> x;
modify(1, l, r, x);
}
}
return 0;
}
动态加点
如果一道题仅仅是「值域很大」的离线题(提前知晓所有的询问),我们还能通过「离散化」来进行处理,将值域映射到一个小空间去,从而解决 MLE 问题。
但对于本题而言,由于「强制在线」的原因,我们无法进行「离散化」,同时值域大小达到
级别,因此如果我们想要使用「线段树」进行求解,只能采取「动态开点」的方式进行。
动态开点的优势在于,不需要事前构造空树,而是在插入操作 update 和查询操作 query 时根据访问需要进行「开点」操作。由于我们不保证查询和插入都是连续的,因此对于父节点
而言,我们不能通过 u << 1 和 u << 1 | 1 的固定方式进行访问,而要将节点 的左右节点所在 tr 数组的下标进行存储,分别记为 ls 和 rs 属性。对于 和 则是代表子节点尚未被创建,当需要访问到它们,而又尚未创建的时候,则将其进行创建。
为了降低空间复杂度,我们可以不建出整棵树的结构,需要时才建立,不过最初需要建立一个根节点代表整个区间,单我们需要用到某个区间才建立相应的节点,当然这只是一种建树方式,如果题目并不是所有区间都会用到,采用这种方法最佳,实现也很简单。
动态开点线段树不再具有left = u << 1, right = u << 1 | 1的性质,但是不需要离散化就可以处理大范围的数,包括负数
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n, m, nidx;
int w[N];
struct node {
int l, r;
LL sum, add;
}tr[N << 6];
void pushup(int u) {
tr[u].sum = tr[tr[u].l].sum + tr[tr[u].r].sum;
}
void pushdown(int u, int l, int r) {
if (!tr[u].l) tr[u].l = ++ nidx;
if (!tr[u].r) tr[u].r = ++ nidx;
auto &root = tr[u], &left = tr[tr[u].l], &right = tr[tr[u].r];
int mid = l + r >> 1;
if (root.add) {
left.add += root.add, left.sum += (LL)(mid - l + 1) * root.add;
right.add += root.add, right.sum += (LL)(r - mid) * root.add;
root.add = 0;
}
}
void modify(int u, int l, int r, int ll, int rr, int x) {
if ( ll <= l && r <= rr) {
int len = r - l + 1;
tr[u].sum += (LL)len * x;
tr[u].add += x;
}
else {
pushdown(u, l, r);//modify之间要pushdown
int mid = l + r >> 1;
if (ll <= mid) modify(tr[u].l, l, mid, ll, rr, x);
if (mid < rr) modify(tr[u].r, mid + 1, r, ll, rr, x);
pushup(u);//注意pushup
}
}
// `u`:当前节点编号;
// `l`、`r`:当前节点表示区间的左右端点;
// `ll`、`rr`:查询区间的左右端点;
LL query(int u, int l, int r, int ll, int rr) {
if ( ll <= l && r <= rr) return tr[u].sum;
if (l > rr || r < ll) return 0;
pushdown(u, l, r);
int mid = l + r >> 1;
LL ans = 0;
if (l <= mid) ans = query(tr[u].l, l, mid, ll, rr);
if ( mid < r ) ans += query(tr[u].r, mid + 1, r, ll, rr);
return ans;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i ++) cin >> w[i], modify(1, -INF, INF, i, i, w[i]);
while (m --) {
char op; cin >> op;
int l, r, x;
cin >> l >> r;
if (op == 'Q') cout << query(1, -INF, INF, l, r) << endl;
else {
cin >> x;
modify(1, -INF, INF, l, r, x);
}
}
return 0;
}
离散化
离散化是程序设计中一个常用的技巧,它可以有效的降低时间复杂度。其基本思想就是在众多可能的情况中,只考虑需要用的值。离散化可以改进一个低效的算法,甚至实现根本不可能实现的算法。
离散化,把无限空间中有限的个体映射到有限的空间中去,以此提高算法的时空效率。
通俗的说,离散化是在不改变数据相对大小的条件下,对数据进行相应的缩小。
离散化本质上可以看成是一种哈希
upper_bound(i) 返回的是键值为i的元素可以插入的最后一个位置(上界)
lowe_bound(i) 返回的是键值为i的元素可以插入的位置的第一个位置(下界)。
- 对于数组tmp,使用sort函数将其升序排序,得到离散化后的数值集合。
- 对于数组nums中的每个元素num,使用lower_bound函数在离散化后的数值集合中查找num应该插入的位置,得到其迭代器。
- 利用引用&将nums中的元素num直接修改为其在离散化后数值集合中的位置。
//离散化
sort(tmp.begin(),tmp.end());
for( int & num : nums ){
num = lower_bound(tmp.begin(),tmp.end());
}
完整
sort(a + 1, a + n + 1);//排序
x = unique(a + 1, a + n + 1) - a - 1;//去重并记录
for(int i = 1; i <= n; ++i){
b[i] = lower_bound(a + 1, a + x + 1, b[i]) - a; //查询并赋值
}
1331. 数组序号转换
# include<bits/stdc++.h>
class Solution {
public:
// 离散化
// 序号相同说明要去重
vector<int> arrayRankTransform(vector<int>& arr) {
vector<int> tmp = arr;
sort(tmp.begin(),tmp.end());
int m = unique(tmp.begin(),tmp.end())-tmp.begin(); // 去重并记录个数
for( auto & num : arr ){
// 从1开始,所以需要+1,注意这里需要begin+m
num = lower_bound(tmp.begin(),tmp.begin()+m,num) - tmp.begin() + 1;
}
return arr;
}
};
位运算
求n的第k位数字: n >> k & 1
返回n的最后一位1:lowbit(n) = n & -n
判断n的第i位(从最低位开始数)是否等于1:1 << (i - 1) & n
把n的第i位改成1:a | (1 << (i - 1))
把n的第i位改成0:a & (~(1 << i))
把n的最后一个1去掉:a & (a - 1)
判断n是否存在两个连续的1:(n >> i & 1) && (a >> i + 1 & 1)
2的n次方
1 << n;
双指针
for (int i = 0, j = 0; i < n; i ++ ) {
while (j < i && check(i, j)) j ++ ;
// 具体问题的逻辑
}
常见问题分类:
(1) 对于一个序列,用两个指针维护一段区间
(2) 对于两个序列,维护某种次序,比如归并排序中合并两个有序序列的操作
哈希函数
核心思想:将字符串看成P进制数,P的经验值是131或13331,取这两个值的冲突概率低。字母不能映射成0。
小技巧:取模的数用2^64,这样直接用unsigned long long存储,溢出的结果就是取模的结果
//模板 , base = 131 或 13331 或 ......
typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64
// 初始化
p[0] = 1;
for (int i = 1; i <= n; i ++ )
{
h[i] = h[i - 1] * base + str[i-1];
p[i] = p[i - 1] * base;
}
// 计算子串 str[l ~ r] 的哈希值
ULL get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];//是r-l,看清楚
}
字符串哈希
将字符串当做二十六进制的数,然后将其转为十进制
mod = 10000019 (10^7)
H[i] = ( H[i-1] * 26 + (str[i]-'a') ) % mod
前缀和
一维
前缀和主要适用的场景是原始数组不会被修改的情况下,频繁查询某个区间的累加和。
将 sumRange 函数的时间复杂度从 O(N)降为 O(1)
prefix[i] 就代表着 nums[0..i-1] 所有元素的累加和,如果我们想求区间 nums[i..j] 的累加和,只要计算 prefix[j+1] - prefix[i] 即可,而不需要遍历整个区间求和。
前缀和,核心代码就是下面这段:
class PrefixSum {
// 前缀和数组
int[] prefix;
/* 输入一个数组,构造前缀和 */
void PrefixSum(int[] nums) {
prefix = new int[nums.length + 1];
// 计算 nums 的累加和
for (int i = 1; i < prefix.length; i++) {
prefix[i] = prefix[i - 1] + nums[i - 1];
}
}
/* 查询闭区间 [i, j] 的累加和 */
int query(int i, int j) {
return prefix[j + 1] - prefix[i];
}
}
或者
第二种写法
一维前缀和
S[i] = a[1] + a[2] + ... a[i]
a[l] + ... + a[r] = S[r] - S[l - 1]
二维
class NumMatrix {
// 定义:preSum[i][j] 记录 matrix 中子矩阵 [0, 0, i-1, j-1] 的元素和
private int[][] preSum;
void NumMatrix(int[][] matrix) {
int m = matrix.length, n = matrix[0].length;
if (m == 0 || n == 0) return;
// 构造前缀和矩阵
preSum = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 计算每个矩阵 [0, 0, i, j] 的元素和
preSum[i][j] = preSum[i-1][j] + preSum[i][j-1] + matrix[i - 1][j - 1] - preSum[i-1][j-1];
}
}
}
// 计算子矩阵 [x1, y1, x2, y2] 的元素和
int sumRegion(int x1, int y1, int x2, int y2) {
// 目标矩阵之和由四个相邻矩阵运算获得
return preSum[x2+1][y2+1] - preSum[x1][y2+1] - preSum[x2+1][y1] + preSum[x1][y1];
}
}
二维前缀和
S[i, j] = 第i行j列格子左上部分所有元素的和
以(x1, y1)为左上角,(x2, y2)为右下角的子矩阵的和为:
S[x2, y2] - S[x1 - 1, y2] - S[x2, y1 - 1] + S[x1 - 1, y1 - 1]
第二种和第一种的区别就是都加上1
差分
差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。
比如说,我给你输入一个数组 nums,然后又要求给区间 nums[2..6] 全部加 1,再给 nums[3..9] 全部减 3,再给 nums[0..4] 全部加 2,再给…
一通操作猛如虎,然后问你,最后 nums 数组的值是什么?
常规的思路很容易,你让我给区间 nums[i..j] 加上 val,那我就一个 for 循环给它们都加上呗,还能咋样?这种思路的时间复杂度是 O(N),由于这个场景下对 nums 的修改非常频繁,所以效率会很低下。
这里就需要差分数组的技巧,类似前缀和技巧构造的 prefix 数组,我们先对 nums 数组构造一个 diff 差分数组,diff[i] 就是 nums[i] 和 nums[i-1] 之差:
int[] diff = new int[nums.length];
// 构造差分数组
diff[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
diff[i] = nums[i] - nums[i - 1];
}
通过这个 diff 差分数组是可以反推出原始数组 nums 的,代码逻辑如下:
int[] res = new int[diff.length];
// 根据差分数组构造结果数组
res[0] = diff[0];
for (int i = 1; i < diff.length; i++) {
res[i] = res[i - 1] + diff[i];
}
这样构造差分数组 diff,就可以快速进行区间增减的操作,如果你想对区间 nums[i..j] 的元素全部加 3,那么只需要让 diff[i] += 3,然后再让 diff[j+1] -= 3 即可:
只要花费 O(1) 的时间修改 diff 数组,就相当于给 nums 的整个区间做了修改。多次修改 diff,然后通过 diff 数组反推,即可得到 nums 修改后的结果。
现在我们把差分数组抽象成一个类,包含 increment 方法和 result 方法:
// 差分数组工具类
class Difference {
// 差分数组
private int[] diff;
/* 输入一个初始数组,区间操作将在这个数组上进行 */
public Difference(int[] nums) {
assert nums.length > 0;
diff = new int[nums.length];
// 根据初始数组构造差分数组
diff[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
diff[i] = nums[i] - nums[i - 1];
}
}
/* 给闭区间 [i, j] 增加 val(可以是负数)*/
public void increment(int i, int j, int val) {
diff[i] += val;
if (j + 1 < diff.length) {
diff[j + 1] -= val;
}
}
/* 返回结果数组 */
public int[] result() {
int[] res = new int[diff.length];
// 根据差分数组构造结果数组
res[0] = diff[0];
for (int i = 1; i < diff.length; i++) {
res[i] = res[i - 1] + diff[i];
}
return res;
}
}
这里注意一下 increment 方法中的 if 语句:
public void increment(int i, int j, int val) {
diff[i] += val;
if (j + 1 < diff.length) {
diff[j + 1] -= val;
}
}
当 j+1 >= diff.length 时,说明是对 nums[i] 及以后的整个数组都进行修改,那么就不需要再给 diff 数组减 val 了。
一维
给区间[l, r]中的每个数加上c:B[l] += c, B[r + 1] -= c
二维
https://zhuanlan.zhihu.com/p/439268614
S[i,j] = A[i,j] - A[i-1,j] - A[i,j-1] + A[i-1,j-1]
给以(x1, y1)为左上角,(x2, y2)为右下角的子矩阵中的所有元素加上c:
S[x1, y1] += c, S[x2 + 1, y1] -= c, S[x1, y2 + 1] -= c, S[x2 + 1, y2 + 1] += c
还原(计算差分矩阵的前缀和,也就是原矩阵的新值)
a[i][j] = a[i - 1][j] + a[i][j - 1] - a[i - 1][j - 1] + S[i][j];
异或前缀和
pre[i] 表示 1 到 i 的异或值
求 第m到第n项的异或值 pre[m-1] ^ pre[n]
如果
求 异或值 a = b 的情况,
SiSj=SjSk+1,可以化简成Si = Sk+1
深度优先搜索
递归实现指数型枚举
从 1∼n 这 n 个整数中随机选取任意多个,输出所有可能的选择方案。
本质:选或不选
int n;
int st[N]; // 状态,记录每个位置当前的状态:0表示还没考虑,1表示选它,2表示不选它
void dfs(int u)
{
if (u > n)
{
for (int i = 1; i <= n; i ++ )
if (st[i] == 1)
printf("%d ", i);
printf("\n");
return;
}
st[u] = 2;
dfs(u + 1); // 第一个分支:不选
st[u] = 0; // 恢复现场
st[u] = 1;
dfs(u + 1); // 第二个分支:选
st[u] = 0;
}
递归实现排列型枚举(全排列)
把 1∼n 这 n 个整数排成一行后随机打乱顺序,输出所有可能的次序。
思想:依次枚举每个位置放哪个数
void dfs(int u)
{
if (u > n) // 边界
{
for (int i = 1; i <= n; i ++ ) printf("%d ", state[i]); // 打印方案
puts("");
return;
}
// 依次枚举每个分支,即当前位置可以填哪些数
for (int i = 1; i <= n; i ++ )
if (!used[i])
{
state[u] = i;
used[i] = true;
dfs(u + 1);
// 恢复现场
state[u] = 0;
used[i] = false;
}
}
或者
使用next_permutation
int main(int argc,const char *argv[])
{
do{
if(check())
ans++;
}while(next_permutation(a,a+13));
cout<<ans<<endl;
return 0;
}
全排列2(含重复数字)
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
(这里是重复的,解决方法是先进行排序,再进行剪枝)
class Solution {
public:
//没顺序——回溯
vector<vector<int>> res;
vector<int> visit;
vector<int> tmp;
int n;
void dfs(vector<int> &nums,int u){
if( u == n ){
res.push_back(tmp);
return;
}
for( int i = 0 ; i < n ; i++ ){
if( i != 0 && nums[i-1] == nums[i] && visit[i-1] == 1 ) continue;
if( visit[i] == 0 ){
visit[i] = 1;
tmp.push_back(nums[i]);
dfs(nums,u+1);
//回溯
tmp.pop_back();
visit[i] = 0;
}
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
n = nums.size();
sort(nums.begin(),nums.end());
visit = vector<int>(n,0);
dfs(nums,0);
return res;
}
};
93. 递归实现组合型枚举
从 1∼n 这 n 个整数中随机选出 m 个,输出所有可能的选择方案。
思想:组合型枚举就是把指数型符合长度的结果挑出来
(优化:剪枝,优化dfs)
int n, m;
int way[N];
void dfs(int u, int start)
{
if (u + n - start < m) return; // 剪枝
if (u == m + 1)
{
for (int i = 1; i <= m; i ++ ) printf("%d ", way[i]);
puts("");
return;
}
for (int i = start; i <= n; i ++ )
{
way[u] = i;
dfs(u + 1, i + 1);
way[u] = 0; // 恢复现场
}
}
单调栈
stack<int> s;
while(n--) {
int x;
cin >> x;
while(!s.empty() && s.top() >= x) s.pop();
if(s.empty()) cout << -1 << ' ';
else cout << s.top() << ' ';
s.push(x);
}
滑动窗口
滑动窗口使用思路(寻找最长)
核心:左右双指针(L,R)在起始点,R向右逐位滑动循环
每次滑动过程中
如果:窗内元素满足条件,R向右扩大窗口,并更新最优结果
如果:窗内元素不满足条件,L向右缩小窗口
——R到达结尾
求最小窗口:窗口合法 → 移左变小:while/if → 内部目标捕捉
求最大窗口:窗口非法 → 移左变小:while/if → 外部目标捕捉
求固定窗口:窗口合适 → 移左变小:if → 内部目标捕捉
最长模板
//最长模板
初始化left,right,result,bestResult
While(右指针没有到达结尾)
{
窗口扩大,加入right对应元素,更新当前result
while(reslut不满足要求)
{
窗口缩小,移除left对应元素,left右移
}
更新最优结果bestResult
right++;
}
返回bestResult;
最短模板
//最短模板
初始化left,right,result,bestResult
While(右指针没有到达结尾)
{
窗口扩大,加入right对应元素,更新当前result
while(reslut满足要求)
{
更新最优结果bestResult
窗口缩小,移除left对应元素,left右移
}
right++;
}
返回bestResult;
链表
数组模拟链表
链表如果使用指针的方式比较慢会超时,所以一般会用数组模拟链表
vector<int> LinkedList;
//尾插法
void add(int x) {
LinkedList.push_back(x);
}
哈希
用hash存储,将字符串转换为唯一的数
int hashTable(char str[]){
int id=0;
for(int i=0; i<3; i++){
id = id*26 + str[i]-'A';
}
id = id*10 + str[3]-'0';
return id;
}
对顶堆
//大顶堆存左半边
priority_queue<int,vector<int>,less<int>> left;
//小顶堆存右半边
priority_queue<int,vector<int>,greater<int>> right;
/** initialize your data structure here. */
MedianFinder() {
}
//如果left大小等于right那么插入一个元素到left
//否则插入到right
//插入规则是先插入到另一个堆,然后拿这个堆顶插入到这个堆
void addNum(int num) {
if(left.size() == right.size()) {
right.push(num);
left.push(right.top());
right.pop();
}
else if(left.size() == right.size() + 1) {
left.push(num);
right.push(left.top());
left.pop();
}
}
//如果是奇数那么直接那左半边第一个数
//如果是偶数则左右各取一个算平均值
double findMedian() {
if(left.size() > right.size()) return left.top();
return 1.0 * (left.top() + right.top()) / 2;
}
图论
![[Pasted image 20230402160308.png]]
基础
图的两种表现形式
![[Pasted image 20230225125525.png]]
邻接表
vecor<vector<int>> map; // 只存储指向的
vector<int> map[N]; // 这样写更好
邻接矩阵
vecor<vector<int>> map;
邻接矩阵:(一般)
- 当N<1000
- 带权图
- 插删边 / 顶点
邻接表:
- 当N很大
- 题目明确说了,是稀疏图
(1)有向加权图
邻接表:
vecor<vector<pair<int,int>>> map; // 只存储指向的
邻接矩阵
map[x][y] = weight
(2)无向
无向即双向
邻接矩阵
map[x][y] = map[y][x] = 1
邻接表
在 x 的邻居列表里添加 y,同时在 y 的邻居列表里添加 x
图和多叉树最大的区别是,图是可能包含环的,你从图的某一个节点开始遍历,有可能走了一圈又回到这个节点,而树不会出现这种情况,从某个节点出发必然走到叶子节点,绝不可能回到它自身。
所以,如果图包含环,遍历框架就要一个 visited 数组进行辅助:
图的深度优先搜索
(无环的,如果有环得加上vector<int> visit)
注意加上根节点
class Solution {
public:
//图的深度优先搜索(类似树的深度优先)
vector<vector<int>> ans;
vector<int> res;
void dfs(vector<vector<int>> &graph,int x,int n){
if( x == n ){
ans.push_back(res);
return;
}
for(auto&y:graph[x]){
res.push_back(y);
dfs(graph,y,n);
res.pop_back(); //回溯
}
}
vector<vector<int>> allPathsSourceTarget(vector<vector<int>>& graph) {
int n = graph.size();
// 注意加上根节点
res.push_back(0);
dfs(graph,0,graph.size()-1);
return ans;
}
};
图的广度优先搜索
连通量
- 使图连通时,最少添加边数 = 连通分量数 - 1
- 若删除边、顶点,用邻接矩阵
- 删除顶点:标记为访问过
visit[v] = true;
// bfs
int cnt=0; //连通分量的个数
for(int i=1; i<=n; i++){
if(visit[i]==false){
bfs(i);
cnt++;
}
}
判断有无环
拓扑排序
BFS
思路:建立一个栈(用来存储入度为0的顶点),求出所有图顶点的入度,将入度为0的顶点i进栈,不断循环直至栈为空,循环里面是从栈退出一个顶点v,计数(count)+1,遍历顶点的所有邻接顶点w,将每个入度减一,入度减至0的顶点w进栈;最终如果count等于顶点数n,则返回真(为有向图无环图)
class Solution {
public:
//拓扑排序
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<int> inDegree(numCourses,0);
map<int,vector<int>> edge;
for(auto pre:prerequisites){
inDegree[pre[1]]++; //入度+1
edge[pre[0]].push_back(pre[1]); //保存路径
}
stack<int> stk;
vector<int> res;
//所有入度为0的进栈
for(int i = 0 ; i < numCourses ; i++ ){
if(inDegree[i]==0) stk.push(i);
}
while(!stk.empty()){
int j = stk.top();
res.push_back(j);
stk.pop();
for(int i = 0 ; i < edge[j].size() ; i++){
auto x = edge[j][i]; //进入的顶点
inDegree[x]--;
if(inDegree[x]==0) stk.push(x);
}
}
if( res.size() != numCourses ){
return {};
}
reverse(res.begin(),res.end());
return res;
}
};
二分图
给你一幅「图」,请你用两种颜色将图中的所有顶点着色,且使得任意一条边的两个端点的颜色都不相同,你能做到吗?
这就是图的「双色问题」,其实这个问题就等同于二分图的判定问题,如果你能够成功地将图染色,那么这幅图就是一幅二分图,反之则不是:
染色法
将colors在dfs的形式变量加个&可以解决超出时间限制
class Solution {
public:
//将
bool dfs(int v,vector<vector<int>>& graph,int color,vector<int>& colors){
colors[v] = color; //给这个点染色
for(int w:graph[v]){ //这条边上的另外一个点
if(colors[w]==-1){ //还没经过
if(!dfs(w,graph,1-color,colors)){ //进行递归染色,染其他色
return false;
}
}else if(colors[w]==colors[v]){ //如果颜色相同
return false;
}
}
return true;
}
//二分图:图的「双色问题」,其实这个问题就等同于二分图的判定问题,如果你能够成功地将图染色,那么这幅图就是一幅二分图,反之则不是
bool isBipartite(vector<vector<int>>& graph) {
int n = graph.size();
//染色全部初始化,colors初始化为-1
vector<int> colors(n,-1); //染色(-1——未染色,0——染为0,1——染为1)
//遍历
for(int i = 0 ; i < n ; i++ ){
if(colors[i] == -1){ //还未遍历过
if(!dfs(i,graph,0,colors)){
return false;
}
}
}
return true;
}
};
并查集
1.一开始为每个元素i都初始化为一个单元素子集合,即parent[i]都为-1
2.根的parent[]值为负数,最终的值就是整个集合元素个数*(-1)
3.每个下标代表元素的值,parent[]则是父结点的值(下标值)
4.搜索:不断寻找x的父结点,直至父结点为负数,则返回(根的parent[]值为负数)
5.合并:只要将表示其中一个集合i的树的根结点置为表示另一个集合j的树的根结点的子女
(parent[j]+=parent[i];parent[i]=j;),即i的parent为j,j的parent为-1*(i和j元素总个数)的值,第二步原理为第二点
vector<int> parent; //存储每个点的祖宗节点
// 返回x的祖宗节点
int Find(int x)
{
if (parent[x] < 0 ) return x;
else return Find(parent[x]);
}
// 合并a和b所在的两个集合:
void Union(int Root1,int Root2){
if( Root1 != Root2 ){
parent[Root1] += parent[Root2];
parent[Root2] = Root1;
}
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ ) parents[i] = -1;
路径压缩
![[Pasted image 20230225142909.png]]
让每个节点的父节点就是整棵树的根节点,find 就能以 O(1) 的时间找到某一节点的根节点,相应的,connected 和 union 复杂度都下降为 O(1)。
第一种
int find(int x) {
while (parent[x] != x) {
// 这行代码进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
第二种(效率更高)
// 第二种路径压缩的 find 方法
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
最小生成树之Kruskal
所谓最小生成树,就是图中若干边的集合(我们后文称这个集合为mst,最小生成树的英文缩写),你要保证这些边:
1、包含图中的所有节点。
2、形成的结构是树结构(即不存在环)。
3、权重和最小。
有之前题目的铺垫,前两条其实可以很容易地利用 Union-Find 算法做到,关键在于第 3 点,如何保证得到的这棵生成树是权重和最小的。
这里就用到了贪心思路:
将所有边按照权重从小到大排序,从权重最小的边开始遍历,如果这条边和mst中的其它边不会形成环,则这条边是最小生成树的一部分,将它加入mst集合;否则,这条边不是最小生成树的一部分,不要把它加入mst集合。
这样,最后mst集合中的边就形成了最小生成树,下面我们看两道例题来运用一下 Kruskal 算法。
1.Kruskal算法
(类似于离散的避圈法)
此算法可以称为“加边法”,初始最小生成树边数为0,每迭代一次就选择一条满足条件的最小代价边,加入到最小生成树的边集合里。
1.把图中的所有边按代价从小到大排序;
2.把图中的n个顶点看成独立的n棵树组成的森林;
3.按权值从小到大选择边,所选的边连接的两个顶点 ui,viu_i, v_iui,vi,ui,viu_i, v_iui,vi 应属于两颗不同的树(一棵树上的两条个顶点相连的话就形成环了),则成为最小生成树的一条边,并将这两颗树合并作为一颗树。
重复(3),直到所有顶点都在一颗树内或者有n-1条边为止。
struct Edge{
int a,b,w;
};
bool cmp(Edge& a,Edge& b){
return a.w < b.w;
}
class Solution {
public:
vector<int> parent;
vector<Edge> edges;
int find(int x){
if( x != parent[x] ) parent[x] = find(parent[x]);
return parent[x];
}
int minCostConnectPoints(vector<vector<int>>& points) {
int n = points.size();
parent.resize(n);
for(int i = 0 ; i < n ; i++ ) parent[i] = i; // 初始化并查集
//将点及其权重存储进edges容器
for(int i = 0 ; i < n ; i++ ){
for(int j = 0 ; j < n ; j++ ){
Edge e = {i,j,abs(points[i][0]-points[j][0])+abs(points[i][1]-points[j][1])};
edges.push_back(e);
}
}
sort(edges.begin(),edges.end(),cmp);
int res = 0,cnt = 0;
for(int i = 0 ; i < edges.size() ; i++ ){
int a = edges[i].a;
int b = edges[i].b;
int w = edges[i].w;
a = find(a);
b = find(b);
if( a != b ){ // 如果两个连通块不连通,则将这两个连通块合并
parent[a] = b;
res += w;
cnt++;
}
}
if( cnt < n-1 ) return -1;
return res;
}
};
Prim
首先,Prim 算法也使用贪心思想来让生成树的权重尽可能小,也就是「切分定理」,这个后文会详细解释。
其次,Prim 算法使用 BFS 算法思想 和 visited 布尔数组避免成环,来保证选出来的边最终形成的一定是一棵树
既然每一次「切分」一定可以找到最小生成树中的一条边,那我就随便切呗,每次都把权重最小的「横切边」拿出来加入最小生成树,直到把构成最小生成树的所有边都切出来为止。
Prim法
此算法可以称为“加点法”,每次迭代选择代价最小的边对应的点,加入到最小生成树中。算法从某一个顶点s开始,逐渐长大覆盖整个连通网的所有顶点。
1.图的所有顶点集合为 VVV;初始令集合 u=s,v=V−uu={s},v=V−uu=s,v=V−u;
2.在两个集合 u,vu,vu,v 能够组成的边中,选择一条代价最小的边 (u0,v0)(u_0,v_0)(u0,v0),加入到最小生成树中,并把 v0v_0v0 并入到集合 uuu 中。
3.重复上述步骤,直到最小生成树有 n-1 条边或者 n 个顶点为止。
即从一点出发,找另外一个点,两点之间的权重要最短
class Solution {
public:
//https://leetcode-cn.com/problems/min-cost-to-connect-all-points/solution/prim-and-kruskal-by-yexiso-c500/
int prim(vector<vector<int>>& points,int start){
int n = points.size();
int res = 0;
//将points转化为邻接矩阵
vector<vector<int>> g(n,vector<int>(n,INT_MAX));
for( int i = 0 ; i < n ; i++ ){
for( int j = i + 1 ; j < n ; j++ ){
int dist = abs(points[i][0]-points[j][0])+abs(points[i][1]-points[j][1]);
g[i][j] = dist;
g[j][i] = dist;
}
}
//记录v[i]到Vnew的最近距离
vector<int> lowcost(n,INT_MAX);
//记录v[i]是否加入到了Vnew
vector<int> v(n,-1);
//2.先将start加入vnew
v[start] = 0;
for(int i = 0 ; i < n ; i++ ){
if( i == start ) continue;
lowcost[i] = g[i][start];
}
//3.剩余n-1个节点未加入到vnew,遍历
for(int i = 1 ; i < n ; i ++ ){
int minIdx = -1;
int minVal = INT_MAX;
for(int j = 0 ; j < n ; j++ ){
if( v[j] == 0 ) continue; //已经加入的不要
if(lowcost[j] < minVal ){
minIdx = j;
minVal = lowcost[j];
}
}
//将该点加入vnew,更新lostcost和v
res += minVal;
v[minIdx] = 0;
lowcost[minIdx] = -1;
//更新集合v中所有点的lowcost
for(int j = 0 ; j < n ; j++ ){
if( v[j] == -1 && g[j][minIdx] < lowcost[j]){
lowcost[j] = g[j][minIdx];
}
}
}
return res;
}
int minCostConnectPoints(vector<vector<int>>& points) {
return prim(points,0);
}
};
Dijkstra算法
**其实,Dijkstra 可以理解成一个带 dp table(或者说备忘录)的 BFS 算法
朴素版
class Solution {
public:
//最短路径算法(Dijkstra,单源最短路径)
//https://leetcode-cn.com/problems/network-delay-time/solution/gtalgorithm-dan-yuan-zui-duan-lu-chi-tou-w3zc/
int networkDelayTime(vector<vector<int>>& times, int n, int k) {
const int inf = INT_MAX / 2; //∞
vector<vector<int>> g(n,vector<int>(n,inf)); //邻接矩阵
for(auto& t:times){
//标记为 1 到 n,边序号从0开始
int x = t[0]-1,y = t[1]-1;
g[x][y] = t[2]; //存储在邻接表里面
}
vector<int> dist(n,inf); //辅助数组 dist[i]表示从v0到vi最小的路径
//因为从1开始,所以k即为源点
dist[k-1] = 0;
//节点是否被更新
vector<int> used(n);
for(int i = 0 ; i < n ; i++ ){
//在还未确定最短路的点中,寻找距离最小的点
int amin = INT_MAX/2;
int v;
for(int y = 0 ; y < n ; y++){ //循环过后x存储的是最小的
if( !used[y] && dist[y] < amin){
v = y;
amin = dist[y];
}
}
//用该点更新所有其他点的距离
used[v] = true;
for(int y = 0 ; y < n ; y++){
//两种情况 直接权值(v0,vj) 权值之和(v0,vk,vj)
dist[y] = min(dist[y],dist[v]+g[v][y]);
}
}
//找到距离最远的点
int ret = *max_element(dist.begin(), dist.end());
return ret == INT_MAX / 2 ? -1 : ret;
}
};
堆优化
class Solution {
public:
// 单源最短路:都是正数
// 使用小根堆来寻找“未确定节点”中 与起点距离最近的点
int networkDelayTime(vector<vector<int>>& times, int n, int k) {
// 为了防止溢出 int 型,所以除以 2。
const int inf = INT_MAX / 2;
// 邻接表
vector<vector<pair<int,int>>> g(n);
for( auto & t : times ){
// 边序号从0开始
int x = t[0] - 1 , y = t[1] - 1;
g[x].push_back({y,t[2]});
}
// 从源点到某点的距离数组
vector<int> dist(n,inf);
// 由于从k开始,所以该点距离为0,即源点
dist[k-1] = 0;
priority_queue<pair<int,int>,vector<pair<int,int>>,greater<>> q;
// 将源点加入小根堆 q 中。
q.push({0,k-1});
while( !q.empty() ){
// 当小根堆 q 不为空时,取出堆顶元素,即未确定节点中距离源点最近的点。
auto p = q.top();
q.pop();
int time = p.first, x = p.second;
if( dist[x] < time ){
continue;
}
// 遍历当前点所有出边,如果到达出边相邻节点的距离小于目前存储的最短距离,则更新距离,并将该节点加入小根堆 q 中。
for( auto &e : g[x] ){
int y = e.first, d = dist[x] + e.second;
if( d < dist[y] ){
dist[y] = d;
q.push({d,y});
}
}
}
// 计算距离数组 dist 中的最大值,即为从源点到其他所有点的最短距离。
int ans = *max_element(dist.begin(),dist.end());
return ans == inf ? -1 : ans;
}
};
Bellman-Ford算法 —— 模板题 AcWing 853. 有边数限制的最短路
时间复杂度 O(nm)
, n 表示点数,m
表示边数
注意在模板题中需要对下面的模板稍作修改,加上备份数组,详情见模板题。
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
struct Edge // 边,a表示出点,b表示入点,w表示边的权重
{
int a, b, w;
}edges[M];
// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n; i ++ )
{
for (int j = 0; j < m; j ++ )
{
int a = edges[j].a, b = edges[j].b, w = edges[j].w;
if (dist[b] > dist[a] + w)
dist[b] = dist[a] + w;
}
}
if (dist[n] > 0x3f3f3f3f / 2) return -1;
return dist[n];
}
spfa 算法(队列优化的Bellman-Ford算法) —— 模板题 AcWing 851. spfa求最短路
时间复杂度 平均情况下 O(m)
,最坏情况下 O(nm), n 表示点数,m
表示边数
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储每个点到1号点的最短距离
bool st[N]; // 存储每个点是否在队列中
// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
q.push(1);
st[1] = true;
while (q.size())
{
auto t = q.front();
q.pop();
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];
if (!st[j]) // 如果队列中已存在j,则不需要将j重复插入
{
q.push(j);
st[j] = true;
}
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
spfa判断图中是否存在负环 —— 模板题 AcWing 852. spfa判断负环
时间复杂度是 O(nm)
, n 表示点数,m
表示边数
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N], cnt[N]; // dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
bool st[N]; // 存储每个点是否在队列中
// 如果存在负环,则返回true,否则返回false。
bool spfa()
{
// 不需要初始化dist数组
// 原理:如果某条最短路径上有n个点(除了自己),那么加上自己之后一共有n+1个点,由抽屉原理一定有两个点相同,所以存在环。
queue<int> q;
for (int i = 1; i <= n; i ++ )
{
q.push(i);
st[i] = true;
}
while (q.size())
{
auto t = q.front();
q.pop();
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 (cnt[j] >= n) return true; // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
if (!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
floyd算法 —— 模板题 AcWing 854. Floyd求最短路
时间复杂度是 O(n3), n表示点数
// 初始化:
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) d[i][j] = 0;
else d[i][j] = INF;
// 算法结束后,d[a][b]表示a到b的最短距离
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] = min(d[i][j], d[i][k] + d[k][j]);
}
朴素版prim算法 —— 模板题 AcWing 858. Prim算法求最小生成树
时间复杂度是 O(n2+m), n 表示点数,m
表示边数
int n; // n表示点数
int g[N][N]; // 邻接矩阵,存储所有边
int dist[N]; // 存储其他点到当前最小生成树的距离
bool st[N]; // 存储每个点是否已经在生成树中
// 如果图不连通,则返回INF(值是0x3f3f3f3f), 否则返回最小生成树的树边权重之和
int prim()
{
memset(dist, 0x3f, sizeof dist);
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[t] > dist[j]))
t = j;
if (i && dist[t] == INF) return INF;
if (i) res += dist[t];
st[t] = true;
for (int j = 1; j <= n; j ++ ) dist[j] = min(dist[j], g[t][j]);
}
return res;
}
Kruskal算法 —— 模板题 AcWing 859. Kruskal算法求最小生成树
时间复杂度是 O(mlogm)
, n 表示点数,m
表示边数
int n, m; // n是点数,m是边数
int p[N]; // 并查集的父节点数组
struct Edge // 存储边
{
int a, b, w;
bool operator< (const Edge &W)const
{
return w < W.w;
}
}edges[M];
int find(int x) // 并查集核心操作
{
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
int kruskal()
{
sort(edges, edges + m);
for (int i = 1; i <= n; i ++ ) p[i] = i; // 初始化并查集
int res = 0, cnt = 0;
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;
res += w;
cnt ++ ;
}
}
if (cnt < n - 1) return INF;
return res;
}
博弈论
(1) 博弈类的问题常常让我们摸不着头脑。当我们没有解题思路的时候,不妨试着写几项试试;(打表)
(2)
必胜态:至少有一个必败态
必败态:无必败态
很多时候可以用dp来做
用数学归纳法
常用库函数
// 下一轮排列数
next_permutation(it1 it2)
// 返回第一个小于或等于num的数字
lower_bound( it1, it2, x)
// 返回第一个大于num的数字
upper_bound( it1, it2, x)
// 取整 (向下 / 小)
double = floor(double x)
// 取整 (向上 / 大)
double = ceil(double x)
//四舍六入五成双(不是四舍五入)——四舍五入的话直接+0.5
double = round(double x)
// 判断 数字
bool isdigit(char)
//判断 字母
bool isalpha(char)
//判断 字母数字
bool isalnum(char)
//判断 大写
bool islower(char)
//判断 小写
bool isupper(char)
//转换为小写
char tolower(char)
//转换为大写
char toupper(char)
STL
字符串
str.find()
str.erase(pos, len)
str.substr(pos, len)
// 得到子串:str[pos]处开始,到str尾
str.substr(pos)
str.replace(pos, len, str2)
// string转换为 常数 字符数组(不可变)
str.c_str()
‘0’的ASCII码为48
‘a’的ASCII码为97,’A’为65,大写比小写小了32
string s;
cin >> s;//可以是数字字符串
scanf("%s", s + 1);//不用加&,碰到空格和回车就停止
s.size();//字符串的长度
//字符串的下标从0开始
string s1 = s.substr(2, 3);//从下标为2开始,截取长度为3的字符
strcmp(a, b);//比较a,b字符串数组的字典序大小
strcpy(a, b);//将char数组b赋给a
string s = to_string(a);//把数值a转换为字符串
a - '0';//转换为数字
a - 'a';//转换为字母
scanf("A%d", &n);//输入A0,则n就是0,一个小技巧
set
(自动重新排序和去重)
set<int> st;
vector<set<int> > vects(N); //N个set,(N)用小括号!!!
//排序
set<int, greater<int> > st; //降序
set<int, cmp> st; // cmp自定义
优先队列
//升序队列(小顶堆)
priority_queue <int,vector<int>,greater<int> > q;
//降序队列(大顶堆)
priority_queue <int,vector<int>,less<int> >q; (相当于默认)
其他
log2[N];//以2为底的对数
for (int i = 0; i < n; i ++ ) log2[1 << i] = i;
数组的下标
//给一个数组元素的编号 v,求该元素的行号和列号,数组下标从0开始
//m表示列的宽度
//行号: v / m;
//列号: v % m;
//如果列号要反转,则:m - 1 - v % m;
技巧
1.头文件的使用
#include <bits/stdc++.h>
2.long long类型的写法
typedef long long ll;
// 或者
#define ll long long
3.数据范围的写法
const int N = 1e5 + 10;
4.输入用C语言
scanf("%d %d",&n,&m);
或者
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
scanf/printf和cin/cout的区别及用法:
scanf/printf速度快于cin/cout,当数据>=10^5时推荐使用scanf/printf,速度大约比cin/cout快一倍左右
5.哈希写法
( auto &[name,cnt] : freq )
6.向下向上取整
a / b 是向下取整
(a + b - 1) / b 是向上取整
7.求a - b 模k的余数且为正数
a - b为正数时可以直接(a - b) % k
为负数的时候 (a + k - b % k) % k
可以直接记:(a + k - b % k) % k
8.sstream
用于解决给定数据行数,但每行的数据个数不确定的输入问题。
getline(cin, line);//忽略第一行的回车
while (cnt -- ) {
getline(cin, line);
stringstream ssin(line);
while (ssin >> a[n]) n++;
}
9.负数取模
((x%MOD)+MOD)%MOD
10.字符串分隔
以分隔"->"为例
str = expeditions[0] + "->";
while ((pos = str.find("->")) != string::npos) {
freq[str.substr(0, pos)] ++;
str.erase(0, pos + 2);
}
经验
(1)
开数组要开在main函数外面,另外数组不能开得过大,比如1000010000的二维数组绝对内存超限了,10001000左右的没问题
(2)
当外层循环为10^5时,内层查找用二分,不能用遍历(超时)
(3)
cin / scanf 后接 gets / getline 会多读一个空格或只读\n,必须加cin.ignore()
scanf("%d", &n);
// 必须先忽略一个字符
cin.ignore();
//
getline(cin, str);
(4)
hash:直接定址,当MAXN较大时,不能用数组,如下
int arr[MAXN];
最好用map
map<int, int> mp;
(5)相乘时,注意需不需要转为long long类型
注意点
string a;
a.resize(100); //需要预先分配空间
scanf("%s", &a[0]); puts(a.c_str());
printf也能输出string
printf("%s", s.c_str());
读行之前出现过cin或者scanf,一定要把最后一个回车换行给吞掉,不然输入会出错
//这段读输入的代码是错误的,banana那行不能正常读入
scanf("%d", &a);
getline(cin, s1);
getline(cin, s2);
//正确代码1
scanf("%d", &a);
getchar(); //把换行给“吞掉”
getline(cin, s1);
getline(cin, s2);
//正确代码2
scanf("%d\n", &a); //加上\n回车符,这样也能跳过
getline(cin, s1);
getline(cin, s2);
小代码
取出各位数
while (n > 0) {
t = n % 10;
// 其他操作
n /= 10;
}
字符串转数字
int n = 0;
for(int i = 0; i < len; i++){
n = n * 10 + (s[i] - '0');
}
更多精彩内容请关注:绝命Coding(获取本文骗分技巧以及本文电子版)