【机试C++】算法总结

参考文章

模拟

矩阵旋转 / 坐标映射

例题:P4924 [1007] 魔法少女小Scarlet
视频参考:透彻理解旋转矩阵

将矩阵上的一个奇数阶方阵按照顺时针或者逆时针旋转 90°,该子矩阵的中心为旋转中心。
image

本例中代码使用f[][]表示新数组,g[][]表示原数组
原坐标:(i, j)
旋转中心:(a, b)
dx = i - a → i = dx + a
dy = j - b → j = dy + b

1.逆时针 90°
(1)坐标变换:(dx, dy) → (-dy, dx)
(2)变成原坐标:
  i' = a - dy = a - (j - b) = a + b - j
  j' = b + dx = b + (i - a) = b - a + i
(3)总结变换代码:
  f[a+b-j][b-a+i] = g[i][j];

2.顺时针 90°
(1)坐标变换:(dx, dy) → (dy, -dx)
(2)变成原坐标:
  i' = a + dy = a + (j - b) = a - b + j
  j' = b - dx = b - (i - a) = a + b - i
(3)总结变换代码:
  f[a-b+j][a+b-i] = g[i][j];

例题:P1205 [USACO1.2] 方块转换 Transformations
原矩阵按照从左到右、从上到下的顺序将n×n个数填充到n×n的矩阵中,将整个矩阵旋转90°、180°、270°。

#define N 50

//旋转90°
void rotate90(int n){
    int c[N][N];
    int sum = 0;
    for(int i = n - 1;i >= 0;i--)
        for(int j = 0;j < n;j++)
            c[j][i] = sum++;// 从右到左,从上到下填充
}
//旋转180°
void rotate180(int n){
    int c[N][N];
    int sum = 0;
    for(int i = n -1;i >= 0;i--)
        for(int j = n - 1;j >= 0;j--)
            c[i][j] = sum++;//从右到左,从下到上填充
}
//旋转270°
void rotate270(int n){
    int c[N][N];
    int sum = 0;
    for(int i = 0;i < n;i++)
        for(int j = n - 1;j >= 0;j--)
            c[j][i] = sum++; //从下到上,从左到右填充
}

闰年 日期 回文数

例题:P2010 [NOIP 2016 普及组] 回文日期

法一:按照月份、日期枚举8位数以内的所有回文数,判断该日期是否为所求区间内

注意:2月不需要判断是否是闰年,因为0229反过来是9220,整个日期是92200229,而9220年是闰年。

int n,m,c,sum,ans;
int s[13]={0,31,29,31,30,31,30,31,31,30,31,30,31};

int main(){
	cin >> n >> m;
	for(int i = 1;i <= 12;i++){ // 月 
		for(int j = 1;j <= s[i];j++){ // 日 
			c = j % 10 * 1000 + j / 10 * 100 + i % 10 * 10 + i / 10; // 举个例子,1231 → 1321,回文数的前四位,即反转后的日期
			sum = c * 10000 + i * 100 + j; // 八位数日期
			if(sum >= n && sum <= m) ans++; 
		}
	} 
    cout << ans;
	return 0;
}

法二:枚举所求两个年份之间的所有日期,判断是否为所求日趋区间,然后看是否为回文数

#include<iostream>
#include <algorithm>    // 算法 
using namespace std;

int n, m, year1, year2, t, ans = 0;
int d[13] = {0,31,28,31,30,31,30,31,31,30,31,30,31};

bool check(int year, int month, int day){
	string y = to_string(year);
	string x = to_string(month / 10) + to_string(month % 10) + to_string(day / 10) + to_string(day % 10);
	reverse(x.begin(), x.end());
	if(y == x) return true;
	return false;
}

int main(){
	cin >> n >> m;
	year1 = n / 10000;
	year2 = m / 10000;
	for(int y = year1;y <= year2;y++){
		if(y % 400 == 0 || (y % 100 != 0 && y % 4 == 0)) d[2] = 29; // 是否是闰年
		for(int i = 1;i <= 12;i++){
			for(int j = 1;j <= d[i];j++){
				t = y * 10000 + i * 100 + j;  // 年月日
				if(t >= n && t <= m && check(y, i, j)) ans++;  // 判断是否在日期范围内,判断是否是回文数
			}
		}
		d[2] = 28;
	}
	cout << ans;
	return 0;
}

回文素数

例题:P1217 [USACO1.5] 回文质数 Prime Palindromes

求长度为5的回文素数,有两种方法

// 法一
for (d1 = 1; d1 <= 9; d1 += 2) {    // 只有奇数才会是素数
    for (d2 = 0; d2 <= 9; d2++) {
        for (d3 = 0; d3 <= 9; d3++) {
            palindrome = 10000*d1 + 1000*d2 + 100*d3 + 10*d2 + d1;
            //(处理回文数...)
        }
    }
}

// 法二:只能用于求长度大于2的回文素数
int get_huiwen(int x){ // 由于素数一定是奇数,所以所求回文数的长度是 2*len-1,一定是奇数
	string s = to_string(x);
	string s2 = to_string(x / 10);
	reverse(s2.begin(), s2.end());
	s = s + s2;
	return stoi(s);
}
int main(){
	for(int i = 10;i <= 999;i++){ //半个回文数用于生成回文数
		int x = get_huiwen(i);
		if(is_primes(x)) cout << x << endl;
	}
	return 0;
}

数学

进制转换

例题:年号字串,十进制转26进制

其他进制 → 十进制

//stoi函数,不需要string头文件
string s = "100";
int x = stoi(s, nullptr, 16); // 第三个参数表示s是哪个进制的

//手写
int base_to_ten(string x, int base){
	int ans = 0, r;
	for(char c : x){
		if(isdigit(c)) r = c - '0';
		else r = c - 'A' + 10;
		ans = ans * base + r;
	}
	return ans;
}
int y = base_to_ten("2E",16); // 调用

十进制 → 其他进制(base)

#include <algorithm>
string ten_to_base(int x, int base){ // 手写
	string ans = "";
	int r;
	while(x > 0){
		r = x % base;
		if(r < 10) ans += to_string(r); // ans += r + '0';
		else ans += r - 10 + 'A';
		x /= base;
	}
	reverse(ans.begin(), ans.end());
	return ans;
}
string x = ten_to_base(stoi("23452345"), 16); // 调用函数

大数加法(高精度)

例题:P1601 高精度加法

法一:字符串

#include<algorithm>    // 算法 

string add(string a, string b){
	string c;
	int i = a.size() - 1, j = b.size() - 1;
	int t = 0;
	while(i >= 0 || j >= 0 || t > 0){
		if(i >= 0) t += a[i--] - '0'; // 从个位获取加数  
		if(j >= 0) t += b[j--] - '0'; // 从个位获取加数,相加 
		c.push_back(t % 10 + '0'); // 相加后得到的数取各位作答案的当前位,剩余的作进位
		t /= 10; // 剩下的是进位 
	}
	reverse(c.begin(), c.end()); 
	return c;
}

法二:vector

#include<iostream>
#include<vector>
using namespace std;

string A, B;
vector<int> m, n, ans;

vector<int> add(vector<int> a, vector<int> b){ // 要求a.size() >= b.size() 
	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 > 0) c.push_back(t);
	return c;
}

int main(){
	cin >> A >> B;
	for(int i = A.size() - 1;i >= 0;i--) m.push_back(A[i] - '0');// 倒序 
	for(int i = B.size() - 1;i >= 0;i--) n.push_back(B[i] - '0');// 倒序 
	ans = add(m, n);
	for(int i = ans.size() - 1;i >= 0;i--) cout << ans[i];
	return 0;
}

大数减法

例题:AcWing 792. 高精度减法

#include<iostream>
#include<vector>
using namespace std;

string A, B;
vector<int> m, n, ans;

bool cmp(string a, string b){ // 判断是否a大于b 
	if(a.size() != b.size()) return a.size() > b.size();
	for(int i = 0;i < a.size();i++){
		if(a[i] != b[i]) return a[i] > b[i];
	}
	return true;// A == B
}

vector<int> sub(vector<int> a, vector<int> b){ // 要求a > b
	vector<int> c;
	int t = 0;
	for(int i = 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();
	// 由于结果是倒序存储的,所以经过减法之后,c中最后面的0是高位的0,是多余的。
	// 但是如果最终结果是0,还不能让c位空,所以要求 c.size() > 0 
	return c;
}

int main(){
	cin >> A >> B;
	for(int i = A.size() - 1;i >= 0;i--) m.push_back(A[i] - '0');// 倒序 
	for(int i = B.size() - 1;i >= 0;i--) n.push_back(B[i] - '0');// 倒序 
	if(cmp(A, B)) {
		ans = sub(m, n);
	}else{
		ans = sub(n, m);
		cout << "-";
	}
	for(int i = ans.size() - 1;i >= 0;i--) cout << ans[i];
	return 0;
}

大数乘法

例题:P1303 A*B Problem

#include<iostream>
#include<vector>
using namespace std;

string A, B;
vector<int> m, n, ans;

vector<int> mul(vector<int> a, vector<int> b){
	// 预先开辟空间,并全部初始化为0,相乘后的数的位数一定小于两个数长度之和 
    vector<int> c(a.size() + b.size(), 0);		
	// 对每一位作相乘 
	for(int i = 0;i < a.size();i++){
		for(int j = 0;j < b.size();j++)
			c[i + j] += a[i] * b[j]; // 不对c初始化的时候,是在访问不存在的元素。 
	}
	// 进位处理 
	for(int i = 0;i < c.size() - 1;i++){
		int t = c[i]; 
		c[i] = t % 10; // 保留本位数,如果不保存c[i]会导致后面更新错误 
		c[i + 1] += t / 10; // 进位 
	} 
	// 去掉高位多余的0,由于初始化的位置是a.size() + b.size() ,很有可能多出来很多位置,所以要去0 
	while(c.size() > 1 && c.back() == 0) c.pop_back();
	return c;
}

int main(){
	cin >> A >> B;
	for(int i = A.size() - 1;i >= 0;i--) m.push_back(A[i] - '0');// 倒序 
	for(int i = B.size() - 1;i >= 0;i--) n.push_back(B[i] - '0');// 倒序 
	ans = mul(m, n);
	for(int i = ans.size() - 1;i >= 0;i--) cout << ans[i];
	return 0;
}

阶乘

例题:

#include<iostream>
#include<vector>
using namespace std;

int N, t, a, sum, tmp;
vector<int> m, n;

vector<int> mul(vector<int> a, vector<int> b){
    vector<int> c(a.size() + b.size(), 0);		
	// 对每一位作相乘 
	for(int i = 0;i < a.size();i++){
		for(int j = 0;j < b.size();j++)
			c[i + j] += a[i] * b[j]; // 不对c初始化的时候,是在访问不存在的元素。 
	}
	// 进位处理 
	for(int i = 0;i < c.size() - 1;i++){
		int t = c[i]; 
		c[i] = t % 10; // 保留本位数,如果不保存c[i]会导致后面更新错误 
		c[i + 1] += t / 10; // 进位 
	} 
	// 去掉高位多余的0,由于初始化的位置是a.size() + b.size() ,很有可能多出来很多位置,所以要去0 
	while(c.size() > 1 && c.back() == 0) c.pop_back();
	return c;
}

int main(){
	cin >> N;
	m.push_back(1); // NS = 1 
	if(N > 1){
		// 求阶乘 
		for(int i = 2;i <= N;i++){
			tmp = i;
			n.clear();
			while(tmp > 0){
				n.push_back(tmp % 10);
				 tmp /= 10;
			}
			m = mul(m, n);
		}
	}
	for(int i = m.size() - 1;i >= 0;i--) cout << m[i]; // N!的结果
	return 0;
}

素数(质数)

质数(prime number)又称素数,有无限个。一个大于1的自然数,除了1和它本身外,不能被其他自然数整除,换句话说就是该数除了1和它本身以外不再有其他的因数;否则称为合数。

#include<cmath>

bool is_primes(int x){
	if(x < 2) return false;
	for(int i = 2;i <= sqrt(x);i++){
		if(x % i == 0) return false; 
	}
	return true;
} 

最大公约数 gcd

数学定理:两个整数的最大公约数,等于其中较小的数和两数相除之余数的最大公约数。最大公约数,即为greatest common divisor。
欧几里得算法 | 辗转相除法(递归实现):是指用于计算两个非负整数a、b的最大公约数。公式表示为

gcd(a, b) = gcd(b, a mod b),其中 a mod b 表示 a 除以 b 的余数

求解方法:递归思想。

代码实现

int gcd(int a, int b){
	return b == 0 ? a : gcd(b, a % b);
} 

最小公倍数 lcm

数学原理:已知两个正整数a、b,则gcd(a,b) × lcm(a,b) = a × b。最小公倍数,即least common multiple。

代码实现

int lcm(int a, int b){
	return a * b / gcd(a, b);
}

前缀和 & 差分

一维前缀和

const int N = 100010;
int a[N]; // [1,n],n个数据
int S[N]; // [0,n],n个前缀和,S[0]用来求S[1]

void initS(int length) {//已知数据数组a[],初始化前缀和数组S[]
	for(int i = 1;i <= length;i++ ) //S[0] = 0
		S[i] = S[i - 1] + a[i];
}
int sumPrefix(int l,int r) {//利用前缀和求给定区间元素之和
	return S[r] - S[l-1];
}

二维前缀和

image

//s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]; 
//s[x2][y2] - s[x2][y1 - 1] - s[x1 - 1][y2] + s[x1 - 1][y1 - 1]
const int N = 1010;
int a[N][N], S[N][N];

void initS(int n,int m) {//n行,m列
	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 sumPrefix(int x1,int y1,int x2,int y2) {
	return S[x2][y2] - S[x2][y1-1] - S[x1-1][y2] + S[x1-1][y1-1];
}

如果题目不涉及使用原数组a,只需要前缀和数组s,那么可以使用a替代s省一半内存

void initS(int n,int m) {//n行,m列
	for(int i = 1;i <= n;i++ ) //上边空出一行、左边空出一列
		for(int j = 1;j <= m;j++)
			a[i][j] = a[i-1][j] + a[i][j-1] - a[i-1][j-1] +a[i][j];
}
int sumPrefix(int x1,int y1,int x2,int y2) {
    return a[x2][y2] - a[x2][y1-1] - a[x1-1][y2] + a[x1-1][y1-1];
}

一维差分

前缀和的主要应用是差分:差分是前缀和的逆运算。
与一维数组a[]对应的差分数组d[]的定义:d[k] = a[k] - a[k-1],即原数组a[]的相邻元素的差。

根据d[]的定义,可以推出:a[k] = d[1] + d[2] + … + d[k],即a[]是d[]的前缀和,所以“差分是前缀和的逆运算”。

const int N = 100010;
int a[N]; //原数组
int b[N]; //差分数组

void initb(int n) {  
	for (int i = 1; i <= n; i++) 
	    b[i] = a[i] - a[i - 1];  
}  
void insert(int l, int r, int c) {  
	b[l] += c;  
	b[r + 1] -= c;  
}  

二位差分

image

//给以(x1, y1)为左上角,(x2, y2)为右下角的子矩阵中的所有元素加上c
const int N = 1010;
int a[N][N]; // 原数组
int b[N][N]; // 差分数组
int S[N][N]; // 求前缀和

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;
}
void sumPrefiex(int n,int m) {
	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] + b[i][j];
}

排序 & 双指针

时间复杂度

时间复杂度是衡量一个算法优秀与否的最重要标准。在算法竞赛或实际工程中,我们往往需要通过优化时间复杂度来防止程序运行超时(TLE)。

衡量一个算法的快慢,必须考虑数据规模的大小。所谓数据规模,一般指输入的数字个数、图的点数与边数等。一般来说,数据规模越大,算法的用时就越长。我们衡量算法效率时,最重要的不是看它在某个特定数据下的具体耗时,而是看它的用时随数据规模增长的趋势,这就是时间复杂度。

通俗来说,时间复杂度反映了程序中核心语句的执行次数。在竞赛中,我们主要关注循环和递归的执行层数。我们习惯用大O符号(O)来表示时间复杂度。

for(int i = 1;i <= 10;i++){} 	// 例1,运行 10 次 
for(int i = 1;i <= n;i++){} 	// 例2,运行 n 次
for(int i = 1;i <= n / 2;i++){} // 例3,运行 n/2 次
for(int i = 1;i <= n;i++){ 		// 例4,运行 n*m 次
	for(int j = 1;j <= m;j++){}
}
  • 例子1运行了10次,常数次运行统称为O(1)。
  • 例子2运行了n次,记为O(n)。
  • 例子3运行了n/2次,忽略系数1/2,依然记为O(n)。
  • 例子4运行了n×m次,记为O(n×m).

又比如等差数列

for(int i = 1;i <= n;i++){
  for(int j = i;j <= nj++){}
}

这段代码的执行次数是\(n+(n-1)+…+1=\frac{n^2 + n}{2}\)。忽略低次项n和最高次项的系数1/2,它的最终时间复杂度就是O(n)。
一个算法的标准时间复杂度应当是其最坏情况下的复杂度。例如从1到1000中猜一个数字,运气最好1次猜中(最优O(1)),运气最差要猜1000次,所以这个算法的复杂度是O(1000),即O(1)常数级别。

基础排序

排序 核心思想 平均时间复杂度 稳定性
选择排序 每次选出最小值放到当前位置 O(n²) ❌
冒泡排序 相邻元素不断比较交换 O(n²) ✅
插入排序 将当前元素插入前面已排序区间 O(n²) ✅
计数排序 统计每个值出现次数 O(n + k) ✅*
快速排序 选基准,将数据分成两边递归 平均 O(n log n) ❌
归并排序 分治,将两个有序区间合并 O(n log n) ✅

选择排序

将数组分成:已排序区间 | 未排序区间,每一轮从未排序区间中找到最小值,然后和当前位置交换。

for(int i = 0; i < n - 1; i++){
	int k = i; // 假设当前位置最小
	for(int j = i + 1; j < n; j++){
		if(a[j] < a[k])
			k = j;
	}
	swap(a[i], a[k]);
}

冒泡排序

不断比较相邻两个元素:a[j]和a[j+1],如果前面的大于后面,就交换。每一轮结束后,当前未排序部分的最大值会“冒泡”到最右边。

for(int i = 0; i < n - 1; i++){
	for(int j = 0; j < n - 1 - i; j++){
		if(a[j] > a[j + 1])
			swap(a[j], a[j + 1]);
	}
}

插入排序

将数组分成:已排序区间 | 当前待排序元素 | 未排序区间,每次取出一个待排序元素,把它插入到前面的有序区间中。
步骤:记录下该元素的值为x,然后从x的位置向前一个接一个遍历,如果大于该元素则将它们后移一位(覆盖掉x的位置),直到找到第一个小于x的元素位置为y,此时的y+1位置就是x应该插入的位置。

for(int i = 1; i < n; i++){
	int x = a[i];
	int j = i - 1;
	while(j >= 0 && a[j] > x){
		a[j + 1] = a[j];
		j--;
	}
	a[j + 1] = x;
}

计数排序(桶排序特例)

  • 利用一个额外的数组C,记录原数组中每个值出现的次数,然后根据C将元素排回正确位置。
  • 计数排序要求数据通常是整数,并且取值范围不能太大。
  • 缺点是空间复杂度极大。当数据中出现大于\(10^7\)的数字时,极易导致数组越界或内存溢出。

桶排序 vs 计数排序

  • 桶排序:将数据划分到多个桶中,每个桶内部再排序。
  • 计数排序:统计每个数字出现的次数,然后根据次数把数字重新输出。计数排序要求数据范围比较小,并且通常是整数。

image

int cnt[101] = {0}; // 例如数据范围为 0 ~ 100
// 统计每个数字出现次数
for(int i = 0; i < n; i++){
	cnt[a[i]]++;
}
// 按顺序取出
for(int i = 0; i <= 100; i++){
	while(cnt[i]--){
		cout << i << " ";
	}
}

快排

分治思想:字面上的意思就是“分而治之”,简单来说,就是把一个难搞的大问题拆分成很多相对来讲容易搞的小问题,然后把小问题的答案合并起来,导出大问题的答案。(递归)

步骤:

  1. 确定分界点x的几种方法(任选一个):a[r],a[l],a[(l+r)/2],随机取一个点;
  2. 调整数组,要求可以分成两段,一段上的数据全部大于等于x,另一段的数据全部小于等于x。
  3. 递归处理左右两段数据。
void quick_sort(int a[], int l, int r){
	if(l >= r) return;
	int i = l - 1;
	int j = r + 1;
	int x = a[l + r >> 1];
	
	while(i < j){ // i < j 防止j = l-1
		do i++; while(a[i] < x); //找到第一个大于等于x的数据
		do j--; while(a[j] > x); //找到第一个小于等于x的数据
		if(i < j) swap(a[i], a[j]);
	}
	quick_sort(a, l, j);
	quick_sort(a, j + 1, r);
}

归并排序

例题:P1908 逆序对

参考视频:三分钟认识归并排序

  • 每次将区间分割(递归),直至分割为只有一个数,然后进行合并。
  • 合并时,每次比较两个区间的最左边的数,较小的数先进入新数组(用于放排序好的数据的数组)。
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]; // tmp是临时数组,q是原数组
}

排序API

// 普通数组
int a[] = {31,12,3,54,25}, n = 5;
sort(a, a + n);                     // 升序排序
sort(a, a + n, greater<int>());     // 降序排序

//动态数组
vector<int> a = {31,12,3,54,25};
sort(a.begin(), a.end());                   // 升序排序
sort(a.begin(), a.end(), greater<int>());   // 降序排序

自定义排序

普通数组自定义排序

#include<iostream>
#include<algorithm>
using namespace std;
bool cmp(int x, int y){ // 降序排序 
	return x > y;
}
bool cmp2(int x, int y){ // 升序排序 
	return x < y;
}
int main(){
	int a[] = {31,12,3,54,25}, n = 5;
	sort(a, a + n, cmp);     // 使用自定义排序函数cmp     
	for(int x : a) cout << x << " ";
	return 0;
}

vector自定义排序

#include<iostream>
#include<algorithm>
using namespace std;
bool cmp(int x, int y){ // 降序排序
	return x > y;
}
bool cmp2(int x, int y){ // 升序排序
	return x < y;
}
int main(){
	vector<int> a = {31, 12, 3, 54, 25};
	sort(a.begin(), a.end(), cmp2);     // 使用自定义排序函数 cmp2
	for(int x : a) cout << x << " ";
	return 0;
}

结构体自定义排序 + vector

#include<iostream>
#include<algorithm>
using namespace std;

struct score{
	int yuwen, shuxue, total;
};

bool cmp(score x, score y){ // 自定义排序 
	if(x.total != y.total)
		return x.total > y.total;	// total 降序
	return x.yuwen > y.yuwen;		// total 相等,yuwen 降序
}
int main(){
	vector<score> a = {
		{90, 90, 180},
		{85, 90, 175},
		{88, 92, 180}
	};
	sort(a.begin(), a.end(), cmp);
	for(score x : a) cout << x.yuwen << " " << x.shuxue << " " << x.total << endl;
	return 0;
}

结构体自定义排序 + 普通数组
把vector<score>换成普通的score数组即可,sort的范围也从a.begin(), a.end()改成a, a + n。

#include<iostream> 
#include<algorithm> 
using namespace std; 

struct score{ 
	int yuwen, shuxue, total; 
}; 

bool cmp(score x, score y){ // 自定义排序  
	if(x.total != y.total) 
		return x.total > y.total;	// total 降序 
	return x.yuwen > y.yuwen;		// total 相等,yuwen 降序 
} 
int main(){ 
	score a[] = { 
		{90, 90, 180}, 
		{85, 90, 175}, 
		{88, 92, 180} 
	}; 	
	int n = 3;	
	sort(a, a + n, cmp); 	
	for(score x : a) cout << x.yuwen << " " << x.shuxue << " " << x.total << endl; 
	return 0; 
}

双指针

同向双指针(滑动窗口 / 尺取法)

应用场景:寻找满足特定条件的最短/最长连续子区间。

运行机制:像毛毛虫一样在数组上爬行。两个指针L和R都从左端点出发,绝不回退。

  • R指针主动向右扩张,负责把新元素吞入窗口,直到窗口内的状态满足题目要求。
  • L指针负责向右收缩,负责把旧元素吐出窗口,用于在满足要求的前提下寻找更优解。

应该分:定长滑动窗口,变长滑动窗口。后者考的比较多吧

int l = 1, r = l, sum = , ans = 2e9;
while(r <= n){
	sum += a[r]; // 吞入新元素,更新窗口状态 
	// 窗口满足题目要求的条件时,比如求 某个子区间之和不小于target的最小子区间长度 
	while(sum >= target){
		ans = min(ans, r - l + 1); // 记录合法窗口长度 
		sum -= a[l]; // 吐出左端点元素 
		l++; // 左端点右移 
	}
	r++; // R指针右移
} 

相向双指针(对撞指针)
应用场景:已排序数组中的“两数之和“问题,或回文判定。

运行机制:左指针L从数组头部1开始,右指针R从数组尾部开始,二者面对面向中间靠拢,直到相遇。

  • 如果a[L]+a[R]>target,说明当前和太大了,必须让R左移(变小)。
  • 如果a[L]+a[R]<target,说明当前和太小了,必须让L右移(变大)

二分

整数二分

  • 二分法的应用条件是能写出一个计算量很小的check()。
  • 注意二分法的应用条件是:序列是单调有序的,从小到大,或从大到小。在无序的序列上无法二分,如果是乱序的,应该先排序再二分。
  • 二分法的思想:每次把搜索范围缩小到一般,直到找到答案为止。
  • 如果在乱序序列上只搜一次,不需要用二分法。如果用二分,需要先排序,排序复杂度O(nlogn),再二分是O(logn),排序加二分的总复杂度O(nlogn)。如果用暴力法直接在乱序的n个数里面找,复杂度是O(n)的,比排序加二分快。
  • 如果不是搜一个数,而是搜m个数。那么先排序再做m次二分的计算复杂度是O(nlogn+mlogn),而暴力法是O(mn)的,当m很大时,二分法远好于暴力法。

目标答案在左区间、右区间这两种情况时,缩小区间的写法是不同的。主要看bool check()函数,得到某个比较函数的返回值之后,应该继续向左还是向右寻找,主要是为了向目标值趋近

int[] arr;
// 在左区间找
int binarySreach(int x) {
	int l = 0;
	int r = arr.length - 1;
	while(l < r) { //一直二分,直到区间[L,R]缩小到L=R
		int mid = arr[(l+r)/2];     //mid是L、R的中间值
		if(check(x, mid)) r = mid;  //答案在左半部分[L,mid],更新R=mid
		else l = mid + 1;           //答案在右半部分[mid+1, R],更新L=mid+1
	}
	return arr[l];
}

找第一个大于等于 x 的数,如果check函数是return a[mid] >= score,那么则向左查,因为会有比当前mid还小的,使得a[mid] >= score。(原数组升序)

// 在右区间找
int binarySreach(int x) {
    int l = 0;
	int r = arr.length -1;
	while(l < r) {
		int mid = arr[l+r+1>>1];//(l+r+1)/2,好像是防止某个点取不到,防止死循环
		if(check(mid)) l = mid;
		else r = mid - 1;
	}
}

找第一个小于等于 x 的数,如果check函数是return a[mid] <= score,那么则向右查,因为会有比当前mid还大的,使得a[mid] <= score。(原数组升序)

画个图演示一下:
image

以上两个方法也可以直接调用下面两个函数

#inlcude<algorithm>
int a[] = {...};
lower_bound(a, a + n, x)               // 返回第一个 >= x 的位置
upper_bound(a, a + n, x)               // 返回第一个 > x 的位置

找第一个 大于等于 score 的数
找最后一个<= score的数

小数二分

浮点数是不能判断是否相等的,精度问题,只能判断两个数无限接近。

bool check(double mid) {
	if(...) return true;
	else ...
	return false;
}
double bSrearch(double l,double r) {
	//eps 表示精度,取决于题目对精度的要求, 一般比所求精度高 2
	double eps = 1e-8;
	while(r-l > eps) {
		double mid = (l + r)/2;
		if(check(mid))  r = mid;
		else l = mid;
	}
	return l;
}

二分答案

当我们面对一个问题,直接顺向推导答案极其困难,但验证一个猜测的答案是否合法却非常容易(一般在O(N)内),且答案具有单调性时,直接在可能的答案范围内二分猜答案,用check(mid)验证。
1.最小值最大化模型

  • 典型问法:使得两个物品之间的距离尽可能大。
  • 单调性分析:猜的距离越小越容易满足,越大越不满足(true->false,找最后一个true)。

2.最大值最小化模型

  • 典型问法:将数组分成M段,使得和最大的那一段尽可能小。
  • 单调性分析:猜的最大和越小越难满足,越大越容易满足(false->true,找第一个true)。

并查集

并查集的经典应用有:判断连通性、最小生成树Kruskal算法(并查集 + 贪心)、最近公共祖先(LCA)等。

初始化、合并、查找(路径压缩)

经过合并之后,得到的是是树的森林,一个集是一棵树,有多少棵树就有多少集。有些树的高度可能很大,递归步骤是O(n)的。此时这个集变成了一个链表,出现了并查集的退化现象,使得递归查询十分耗时。
因此就需要路径压缩,递归查找该元素所在集合的根节点,并在查找过程中查过的点都更新为根节点。本次递归是O(n)的,但是后续查找的都是O(1),即为均摊O(1)。

// 初始化
for(int i = 1;i <= n;i++) s[i] = i;
// 查找(路径压缩)
int Find(int x){ // 不能与关键字 find 重名
    if(s[x] != x) s[x] = find(s[x]);
    return s[x];
}
// 合并x,y
void merge(int x, int y){
    x = find(x);
    y = find(y);
    if(x != y) s[x] = s[y]; // y成为x的上级,x的根节点改成y
}

按秩合并

这里的秩可以指树的高度或树的节点数,不影响时间复杂度。

按秩合并的主要思想是:将小的结构合并到大的结构里(小树挂大树)。这样,合并操作能劣化的节点也要少一些。单独使用按秩合并优化也能将均摊时间复杂度优化到 O(logn)。理论上按节点数合并慢于按高度合并。

下面的是按照集合数量的合并。

// 初始化
int a[1005], size[1005];
for(int i = 1;i <= n;i++){
    a[i] = i;
    size[i] = 1;
}
// 查找(没变)
int Find(int x){
    if(a[x] != x) a[x] = Find(a[x]);
    return a[x];
}
// 合并
void merge(int x, int y){
    x = Find(x);
    y = Find(y);
    if(x != y){
        if(size[x] < size[y]) {
            a[x] = a[y]; // x所在集合小,挂到y上,根节点为y
            size[y] += size[x];
        }else{
            a[y] = a[x];
            size[x] += size[y];
        }
    }
}

带权并查集,不学,太难

DFS & BFS

DFS 模板

bool visited[N]; // 标记哪些节点已被访问,防止死循环

void dfs(int u, ...其他参数...) {
    // ① 递归终止条件(必须有!)
    if (满足某种条件) {
        // 记录答案 / 返回
        return;
    }

    // ② 遍历所有可能的"下一步"选择
    for (int v : all_possible_next_steps) {
        // ③ - a 剪枝:如果这个选择不合法,直接跳过
        if (is_invalid(v) || visited[v]) continue;

        // ③ - b 做出选择 & 保护现场
        visited[v] = true;
        path.push_back(v);

        // ③ - c 进入下一层递归
        dfs(v, ...);

        // ③ - d 撤销选择 & 恢复现场(回溯,DFS 的灵魂!)
        path.pop_back();
        visited[v] = false;
    }
}

例题:P1706 全排列问题

#include<iostream>
#include<iomanip>
using namespace std;

int a[11], vis[11], b[11], n; // 原数组a,是否访问过vis,暂存本次排列b

void dfs(int num){ // 本次排的是第num个位置,可以将n个数往里面填
    if(num == n + 1){
        for(int i = 1;i <= n;i++) cout << setw(5) << b[i];
        cout << endl;
        return;
    }
    for(int i = 1;i <= n;i++){
        if(vis[i] == 0){
            b[num] = a[i];// 加入当前排列
            vis[i] = 1;     // 标记一下用过了
            dfs(num + 1);   // 深搜下一个位置
            vis[i] = 0;     // 回溯
        }
    }
}

int main(){
    cin >> n;
    for(int i = 1;i <= n;i++) a[i] = i;
    dfs(1);
    return 0;
}

BFS 例题

使用队列 queue

例题:844. 走迷宫

image

需要注意的是:一定要在新数据入队的时候就记录已经访问过,如果出队时才标记会在已经入队但是没有标记且没有出队的时候重复入队,导致MLE

#include<iostream>
#include<queue>
#include<cstring> // memset 需要
using namespace std;

typedef pair<int, int> PII; // 定义坐标的数据结构

int n, m, direction[4][2] = {{0,1},{0,-1},{1,0},{-1,0}}; // 四个方向
int g[105][105]; // 原始数据
int d[105][105]; // 从起点到该点的距离(走了几步,因为没有权重)
int vis[105][105]; // 是否访问过
queue<PII> q; // 队列,存储访问过的一层的数据,但是未访问它们的子节点

void bfs(){
    q.push({1,1}); // 将左上角顶点放进队列
    d[1][1] = 0; // 源点到源点的距离是0
    vis[1][1] = 1; 
    while(!q.empty()){
        PII tmp = q.front(); // 获取队头元素
        q.pop(); //出队
        for(int i = 0;i < 4;i++){ // 向四个方向遍历
            int xx = tmp.first + direction[i][0];
            int yy = tmp.second + direction[i][1];
            if(xx >= 1 && xx <= n && yy >= 1 && yy <= m && vis[xx][yy] == 0 && g[xx][yy] != 1){
                d[xx][yy] = d[tmp.first][tmp.second] + 1; // 多走一步
                q.push({xx, yy}); // 被访问到的节点入队
                vis[xx][yy] = 1; // 入队时就访问过,防止重复入队 
            }
        }
    }
}

int main(){
    cin >> n >>m;
    for(int i = 1;i <= n;i++)
        for(int j = 1;j <= m;j++)
            cin >> g[i][j];
    bfs();
    cout << d[n][m];
    return 0;
}

更优化的方案,将记录距离的数组d初始化未-1,这样就减少了一个数组的使用,运行更快。

也可以将访问过的点 g[x][y] = 1,这样也可以,但是会破坏原数组。

点击查看代码
#include<iostream>
#include<queue>
#include<cstring> // memset 需要
using namespace std;

typedef pair<int, int> PII; // 定义坐标的数据结构

int n, m, direction[4][2] = {{0,1},{0,-1},{1,0},{-1,0}}; // 四个方向
int g[105][105]; // 原始数据
int d[105][105]; // 从起点到该点的距离(走了几步,因为没有权重)
queue<PII> q; // 队列,存储访问过的一层的数据,但是未访问它们的子节点

void bfs(){
    q.push({1,1}); // 将左上角顶点放进队列
    memset(d, -1, sizeof d);    //初始化各个点到源点的距离为-1
    d[1][1] = 0; // 源点到源点的距离是0
    while(!q.empty()){
        PII tmp = q.front(); // 获取队头元素
        q.pop(); //出队
        for(int i = 0;i < 4;i++){ // 向四个方向遍历
            int xx = tmp.first + direction[i][0];
            int yy = tmp.second + direction[i][1];
            if(xx >= 1 && xx <= n && yy >= 1 && yy <= m && d[xx][yy] == -1 && g[xx][yy] != 1){
                d[xx][yy] = d[tmp.first][tmp.second] + 1; // 多走一步
                q.push({xx, yy}); // 被访问到的节点入队
            }
        }
    }
}

int main(){
    cin >> n >>m;
    for(int i = 1;i <= n;i++)
        for(int j = 1;j <= m;j++)
            cin >> g[i][j];
    bfs();
    cout << d[n][m];
    return 0;
}

注意事项

  1. DFS 爆栈惨案:蓝桥杯评测机的栈空间有限(通常为 8MB)。当递归深度可能超过 10^5 时,递归版 DFS 几乎必定 RE(运行错误)。此时必须放弃递归,手写一个 stack 来模拟 DFS,或者思考能否用 BFS 解决。

  2. BFS visited 标记时机:visited 数组必须在节点入队时就标记为 true,而不是在出队时。否则,同一个节点可能被重复入队多次,导致 TLE 甚至 MLE。

  3. 方向数组越界:在写 nx = x + dx[i] 时,要时刻检查 nx 和 ny 是否超出了地图边界。这是最常见的 RE 原因之一。

  4. 多组测试数据的清空:如果题目包含多组测试数据,每一组开始前,必须清空 visited 数组、dist 数组以及队列等容器。queue 没有 clear() 函数,清空方法是 q = queue();。

  5. DFS 传参用引用:当你把一个巨大的 vector 或 string 传给 DFS 递归函数时,千万不要写 void dfs(vector v)(值传递),这会每次都把整个数组复制一遍,100% 导致超时。正确写法:void dfs(vector &v)。

动态规划(DP)

动态规划就是记忆化搜索,它的状态都是无后向性,即旧状态生成新状态,但是新状态不能生成旧状态。

参考题目笔记:【机试C++】题目合集-2

线性DP

P1216 数字三角形
P1020 最长上升子序列
P1091
P1004

0-1背包问题

P1048
P1616
P1060
P1164
P1049

posted @ 2026-09-27 12:41  idle_life  阅读(14)  评论(0)    收藏  举报