目录
算法的基本概念
什么是算法
引用来自《算法导论》的一段话:
所谓算法(algorithm)就是定义良好的计算过程,它取一个或一组值作为输入,并且产生出一个或一组值作为输出。亦即,算法就是一系列的计算步骤,用来将输入数据转换成输出结果。
-
算法的表示形式
自然语言表示法
流程图表示法
伪代码表示法
编程语言表示法 -
算法的特征
有穷性
确切性(输入、输出)
可行性 -
鸡兔同笼问题
有若干只鸡兔同在一个笼子里,从上面数,有35个头,从下面数,有94只脚。问笼中各有多少只鸡和兔? -
伪代码:
input n
input m
if m - 2n >= 0 and (m - 2n) is even then
y <- (m - 2n) / 2
if y <= n then
x = n - y
output x, y
else
output "Invalid"
else
output "Invalid"
- 代码实现:
#include <bits/stdc++.h>
using namespace std;
int main() {
// 头和腿的数量
int n, m;
cin >> n >> m;
// 检查合法性,计算 y = (m - 2n) / 2;
int tmp1 = m - 2 * n;
if (tmp1 < 0 || tmp1 % 2 != 0) {
cout << "Invalid!" << '\n';
return 0;
}
int y = tmp1 / 2;
// 将y代入第一个方程,检查合法性,并计算x
int tmp2 = n - y;
if (tmp2 < 0) {
cout << "Invalid!" << '\n';
return 0;
}
int x = tmp2;
// 输出结果
cout << "x = " << x << ", y = " << y << '\n';
return 0;
}
总结:算法通常是由一段有输入有输出的代码描述的,该代码的输入输出就是一个具体问题的条件和答案。
什么是数据结构
-
程序=算法+数据结构,所以,掌握数据结构的关键是充分理解:
每一种数据结构利用的是数据结构中怎样的关系;
在不同操作上的优势劣势
以及常见的使用场景。 -
常见的数据结构有数组、链表、栈、队列、树、图等:
数组、链表、栈和队列都在描述数据的线性结构,
而树和图分别描述数据的树形结构和图形结构。 -
数据结构的逻辑结构
指的是数据元素之间的前后关系,与计算机的存储位置无关。 -
逻辑结构通常分为以下几类:
集合:数据结构中的元素属于“同属于一个集合”的关系,除此之外没有其他关系。
比如“三年二班中的男生”和“三年二班中的女生”就是两个学生集合。
线性结构:数据结构中存在“一对一”的相互关系。
比如学生根据学号的前后关系。在班级打印一些信息时,学号小的就会在表格的前面,学号大的就会在班级的后面。
树形结构:数据结构中的元素存在“一对多”的相互关系。
比如说学生会中的组织关系。比如学生会主席负责领导副主席,副主席负责领导各部门部长,而各部门部长负责领导副部长、干事等等,除了主席,每一个职位都会有一个直接领导。除了跟元素外,每个节点有且仅有一个前驱,后继数目不限。 -
数据结构常见操作:创建、清除、插入、删除、检索、更新、访问、遍历等:
创建: 创建一个空的数据结构。
比如创建一个用于记录“三年二班学生学号和姓名”的数据结构。
清除: 清空数据结构。
比如清除记录“三年二班学生学号和姓名”的数据结构。
插入: 往数据结构中增加新的节点。
假设”三年二班“新转来一名同学,那么”将该学生的姓名和班级录入学校系统“就是一个插入操作。
删除: 把指定的节点从数据结构中去掉。
假设”三年二班“转走了一名同学,那么”将该学生的姓名和班级从学校系统中删除“就是一个删除操作。
检索: 检索就是在数据结构里查找满足一定条件的节点。一般是给定一个某字段的值,找具有该字段值的节点。
假设一条学生的记录了学生的班级。那么”找到所有三年二班的学生“就是一个检索操作。
更新: 改变指定节点的一个或多个字段的值。
假如一个学生改名了,那么”将姓名由小红修改成小芳“就是一个更新操作。
访问: 访问数据结构中的某个元素。
遍历: 按照某种次序,访问数据结构中的每一个元素。
模拟与高精度
模拟算法
- 模拟算法就是按照题目的要求或者情境的设定完整实现其描述过程的方法。
Tips:它虽然在思维上没有太高的要求,但是对于一些复杂的过程,选择实现的方法从而形成一段代码简洁而运行高效的算法,很考验程序员的基本功和经验积累。 - ISBN 号码问题:
完整代码:
#include <bits/stdc++.h>
using namespace std;
char str[20];
int main() {
scanf("%s", str);
int idx = 0;
int tmp = 0; // 识别码
int len = strlen(str);
for (int i = 0; i < len - 1; ++i)
if ('0' <= str[i] && str[i] <= '9') {
++idx;
// 在对字符数组中的数字字符做乘法运算时不要忘记减’0’转换为阿拉伯数字再进行计算;
tmp = (tmp + (str[i] - '0') * idx) % 11;
}
char c = tmp == 10 ? 'X' : tmp + '0'; // 计算识别码
if (str[len - 1] == c)
printf("Right");
else {
str[len - 1] = c;
printf("%s", str);
}
return 0;
}
高精度运算
- 高精度整数可以由数位数组和长度两部分组成。数位数组存储整数时使用的是小端序。
使用小端序的理由:
因为加法、减法及后面介绍的乘法等,都是从低位算到高位。这样存储符合我们平时习惯的枚举顺序。
因为数位计算结束后,需要更新数位数组的长度。把高位放在数组后面比较方便数组伸缩。 - 高精度整数使用字符串输入。由于存储顺序和打印顺序不一致,所以输入输出时都需要翻转操作。
- 高精度加法、减法和乘法都可分为数位操作和维护长度两部分。维护长度时,注意不要将长度减小到0。
- 以高精度减法为例:
数位操作:
根据减法竖式计算的规则:从低位开始,逐位相减,若该位不够减,需向下一位借位,并且借一当十。
上面的例子中,可以发现,第i
位的结果等于被减数第i
位减去减数第i
位和低位的借位。
**维护长度**在两个数相减时,若我们另初始长度为被减数的长度。
可以想象,最终计算的差的长度,和被减数相比,很可能变小。
代码实现
这里,我们总假设被减数大于等于减数。
下面的代码依旧分成数位操作、维护长度和输出三个部分。
#include <bits/stdc++.h>
#define N 110
using namespace std;
// 同样,这里采用小端序存储
int a_digits[N] = {8, 6, 3, 2}, a_len = 4;
int b_digits[N] = {7, 9, 9}, b_len = 3;
int ans_digits[N], ans_len;
int main() {
// 1. 数位操作
// 我们依旧是从低位到高位开始逐位相减
// 因为我们总假设a>=b,所以初始长度先设为a的长度
// 考虑每一位,需要计算的部分是被减数的当前位,减去减数的当前位,再减去低位的借位
// 如果上一步的计算得出当前位<0,那我们需要向高位借位,然后给当前位+10
ans_len = a_len; // 初始长度
int k = 0; // 维护借位
for (int i = 0; i < ans_len; ++i) {
ans_digits[i] = a_digits[i] - b_digits[i] - k;
if (ans_digits[i] < 0) {
k = 1;
ans_digits[i] += 10;
} else k = 0; // 这里赋值成0很关键,而且容易遗漏
}
// 2. 维护长度
// 想象一下,如果实际数字是1,但是长度记录是4的话,那么输出该数字结果将是0001,
// 也就是出现了“前导0”,所以维护长度的目的是为了去掉前导0
// 所以,我们用while循环实现这样的逻辑:只要最高位是0,我们就把位数缩小1位。
// 但是需要注意,只有位数>1的时候才可以缩小,否则当保存的数字是0时,长度也会减为0.
while (ans_len > 1 && !ans_digits[ans_len - 1]) // 只有长度大于1才可以去掉前导零
--ans_len;
// 3. 输出
for (int i = ans_len - 1; i >= 0; --i) cout << ans_digits[i];
cout << endl;
return 0;
}
- 使用小端序的好处
回顾一下小端序的存储方式:数字的低位在地址的低位。由此,我们可以看到使用小端序的理由:
1、因为加法、减法以及后面介绍的乘法等,都是从低位算到高位。这样存储符合我们平时习惯的枚举顺序。
2、因为数位计算结束后,需要更新数位数组的长度。把高位放在数组后面比较方便数组伸缩。
算法评价与算法复杂度
算法“评价员”
总结一下,为什么需要评价算法呢?
在现实生活中,计算资源,包括CPU的计算速度和内存的大小,是有限的,而我们的等待时间也是有限的。所以,我们需要用更快(或内存利用率更高)的算法来应对时间紧张(或者内存紧张)的开发场景。
-
时间限制:一方面,CPU的运算速度是有限制的;另一方面,等待问题结果的时间也是有限的。所以,衡量算法运行时间的一个重要指标是时间复杂度。
例如,打开个人电脑的配置,可以看到cpu的主频。主频,表示CPU每秒钟产生脉冲信号的次数(也就是每秒钟的时钟周期个数)。
以2.1GHz为例,一秒钟该CPU可以产生 2.1 × 1 0 9 2.1×10^9 2.1×109次脉冲信号,如果一台计算机每个时钟周期可以完成1条指令,那么该计算机1s之内就可以运行 2.1 × 1 0 9 2.1×10^9 2.1×109条指令。 -
空间限制:计算机内存的大小是有限制的。 所以,衡量算法内存消耗的一个重要指标是空间复杂度。
例如,一个二维数组 i n t a [ 5000 ] [ 5000 ] int a[5000][5000] inta[5000][5000]所耗内存为:
5000 × 5000 × 4 ÷ 1024 ÷ 1024 ≈ 95 M B 5000×5000×4÷1024÷1024≈95 MB 5000×5000×4÷1024÷1024≈95MB -
另外,在一些更有针对性的场景(如机器学习场景),在算法开发中,可能有更具体的需求,所以就需要设计更具体的指标(例如机器学习中的准确率、精确率和召回率等)。
如何评价算法
算法运行环境是一个物理的机器,该机器的计算能力和存储空间都是有限的。所以,对于一个算法能不能在有限资源下成功输出结果,我们需要对其时间和空间进行评估。
时间复杂度:
- 时间复杂度通常是该算法在用最坏情况下的时间复杂度,使用大O记号用来表示估算的结果。
举例:
- O(1)——常数条语句:交换两个元素
// 输入
int a = 4;
int b = 6;
// 计算
int t = a;
a = b;
b = t;
- O(n)——单重循环求数组和
// 输入
int a[] = {2, 0, 1, 3, 5};
int n = 5;
int sum = 0;
for (int i = 0; i < n; ++i) sum += a[i];
- O ( n 2 ) O(n^2) O(n2)——双重循环求数组中相等元素对数
// 输入
int a[] = {1, 1, 3, 5, 5};
int n = 5;
// 计算
int cnt = 0;
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
cnt += (a[i] == a[j]);
- O ( 2 n n ) O(2^nn) O(2nn)——枚举n个数字组成集合的所有子集,输出子集和。
// 输入
int a[] = {2, 1, 3, 6, 5};
int n = 5;
// 计算
int tot = 1 << 5; // 相当于求2的5次方
for (int i = 0; i < tot; ++i) {
// 变量i的二进制形式用于表示每个元素选(1)与不选(0)。
int sum = 0;
for (int j = 0; j < n; ++j)
if ((i >> j) & 1) sum += a[j]; // 检查i的第j位是否是1
cout << sum << endl;
}
空间复杂度:
举例:
// 假设数据规模最大为N
int a; // 常数空间复杂度
int a[N]; // 此时空间复杂度为 O(N)
int a[N][N]; // 此时空间复杂度为 O(N^2)
暴力枚举
枚举
-
枚举法是一种通过枚举所有可能解,检查该可能解是否符合要求,并将符合要求的解计入答案的方法。
-
在解决问题的过程中,我们需要枚举的对象有很多种,比如数值、区间、矩形、日期等等。
-
在设计枚举算法时,一些思路直接的算法虽然很容易理解,但是通常会导致高昂的时间代价。所以我们可以通过加入数学计算、并且存储尽可能多的信息的方法,来降低时间复杂度。
-
严谨描述一下枚举法的过程:
确定枚举对象、枚举范围和判定条件;
枚举可能的解,验证是否是问题的解。
枚举示例
比如引入中水仙花数的例子,对象就是整数,范围在100到999之间。假设枚举的数字百位十位和个位分别为a、b、和c,那么判定条件就是:
a
3
+
b
3
+
c
3
=
?
a
∗
100
+
b
∗
10
+
c
a^3 + b^3 + c^3 \stackrel{?}{=} a*100+b*10+c
a3+b3+c3=?a∗100+b∗10+c
所以,我们用C++中的for循环语句来实现这个过程,并用if语句来检查判定条件是否成立。
代码框架
首先,我们给出该算法的整体框架,其中省略了十进制拆分的部分。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
for (int i = 100; i <= 999; ++i) { // 枚举所有100到999的数
// 十进制拆分
int a, b, c;
/* 这里我们省略了十进制拆分的过程 */
// 判断是否满足水仙花数条件
if (a * a * a + b * b * b + c * c * c == i) {
printf("%d ", i);
}
}
return 0;
}
完整代码
这里我们给出水仙花数的完整代码:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
for (int i = 100; i <= 999; ++i) { // 枚举所有100到999的数
// 十进制拆分
int a = i / 100; // 拆解i的百位
int b = i / 10 % 10; // 拆解i的十位
int c = i % 10; // 拆解i的个位
// 判断是否满足水仙花数条件
if (a * a * a + b * b * b + c * c * c == i) {
printf("%d ", i);
}
}
return 0;
}
子集枚举
-
如何表示子集:用01比特串表示。
-
如何枚举子集:将01比特串与其表示的整数等价,转化为简单的枚举整数的问题。
-
如何提取第i个元素是否存在于子集的信息:位运算,具体思路为将第i位右移至第1位,转换为检查第1位的问题。检查第1位,可以将原数字与1取与运算得到。
-
示例分析
假设我们有个集合{1,2,3,…,n},输出所有满足集合中所有数求和是3的倍数的子集的个数。 -
代码实现
#include <bits/stdc++.h>
using namespace std;
int n;
int main() {
scanf("%d", &n); // 集合大小,也就是01比特串的长度
int tot = 1 << n; // 枚举数字代替01比特串,范围为0到2^n - 1
int ans = 0;
for (int num = 0; num < tot; ++num) { // 枚举每个代表01比特串的数字
long long sum = 0;
for (int i = 0; i < n; ++i) // 枚举01比特串的每一位
if ((num >> i) & 1) { // 检查第j位是否为1,注意这里是从0开始枚举
sum += (i + 1); // 如果该位是1,就把对应的数字加到求和的变量里
}
if (sum % 3 == 0) ++ans; // 如果满足题目要求(3的倍数),计入答案
}
printf("%d\n", ans);
}
- 01比特串复杂度分析
用01比特串进行子集枚举,根据上面的代码我们可以给出该算法的复杂度分析:
第一层for
循环,一共循环
2
n
2^n
2n次。
for
循环里,枚举n
个二进制位的每一位,复杂度为O(n)
所以最终复杂度为 O ( 2 n ⋅ n ) O(2^n\cdot n) O(2n⋅n)。
珠心算测验
珠心算是一种通过在脑中模拟算盘变化来完成快速运算的一种计算技术。
珠心算训练,既能够开发智力,又能够为日常生活带来很多便利,因而在很多学校得到普及。
某学校的珠心算老师采用一种快速考察珠心算加法能力的测验方法。
他随机生成一个正整数集合,集合中的数各不相同,然后要求学生回答:
其中有多少个数,恰好等于集合中另外两个(不同的)数之和?
输入描述:
共两行,第一行包含一个整数𝑛,表示测试题中给出的正整数个数。
第二行有𝑛个正整数,每两个正整数之间用一个空格隔开,表示测试题中给出的正整数。
输出描述:
一个整数,表示测验题答案。
示例 1:
输入:
4
1 2 3 4
输出:
2
#include <bits/stdc++.h>
#define N 105
using namespace std;
int a[N], n;
bool check(int i) {
for (int j = 0; j < n; ++j)
for (int k = j + 1; k < n; ++k)
if (a[j] + a[k] == a[i]) return true;
return false;
}
int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i)
scanf("%d", &a[i]);
int sum = 0;
for (int i = 0; i < n; ++i)
if (check(i)) ++sum; // 也可以是 sum += check(i)
printf("%d\n", sum);
return 0;
}
排列枚举
枚举排列时,我们考虑了如下几项内容:
- 排列的表示形式:数组。
- 排序的大小:按照字典序大小规定。
字典序,又叫字母序,是规定两个序列比较大小的一种方式。其规则是对于两个序列a
和b
:
- 从第一个字母开始比较,如果在第
i
个位置满足,i没有超过两个序列的长度,小于i
处两个序列对应位置的元素都相等,而第i位两个序列对应位置元素不等的话,则若a[i] < b[i]
,那么序列a
小于序列b
,否则序列b
小于序列a
。 - 若从第一个位置直到其中一个序列的最后一个位置都相等的话,则比较
a
和b
的长度,若a
的长度小于b
,则序列a
小于序列b
(此时a
是b
的前缀),而如果b
序列的长度小于a
,那么序列b
小于序列a
。 - 若两个序列长度相等,并且所有元素相等,则序列
a
等于序列b
。 举例:
abc < bbc // 因为第一个字母a < b
ab < abc // 因为两个串前面所有对应位置字母相同,但第一个串长度小于第二个串
ac > abb // 因为第二个字母c > b
生成下一个排列的方式:调用STL中的next_permutation
函数。
排列枚举示例:
【取宝石问题】
假设在一个大房间有nn个宝石,每一处宝石用一个坐标(x, y)(x,y)表示。
如果你从任意一处宝石的地方出发,依次经过每个放宝石的地方并取走宝石,
最终要求回到出发地点,问最短需要走的距离是多少。
在这个情境里,经过不同地点的顺序会改变最终的行走距离。所以,我们要枚举的就是经过1~n一共n个位置的顺序。
- 用next_permutation函数解决“取宝石问题”
因为要用枚举法解决第一个问题,所以,代入到题目的情境中,我们可以设计如下算法:
- 枚举所有n个点的排列
- 维护最短距离。检查新枚举的排列产生的行走距离是否比之前的最短距离还短。如果短,就更新答案。
下面是解决这个问题的完整代码:
#include <bits/stdc++.h>
#define N 15
using namespace std;
int n, id[N];
double x[N], y[N];
// 求两个点(x_1, y_1)和(x_2, y_2)之间的直线距离
double dis(double x_1, double y_1, double x_2, double y_2) {
double dx = x_1 - x_2;
double dy = y_1 - y_2;
return sqrt(dx * dx + dy * dy);
}
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> x[i] >> y[i];
id[i] = i; // 因为我们枚举标号的排列,所以要将标号存进数组里
}
double ans = -1; // 因为最开始ans中没有值,所以我们可以将其设置为一个不合法的值
// 用do...while循环是为了防止第一次调用时数组id中的值已经被重排
// 所以会导致标号为1, 2, ..., n的排列没有被计算。
do {
// 求解按照id[1], id[2], ..., id[n], id[1]作为行走路线的总距离。
double cur = dis(x[id[1]], y[id[1]], x[id[n]], y[id[n]]);
for (int i = 1; i < n; ++i)
cur += dis(x[id[i]], y[id[i]], x[id[i + 1]], y[id[i + 1]]);
// 如果当前路线的总距离小于之前最优解,就更新。
if (ans < 0 || cur < ans) ans = cur;
} while (next_permutation(id + 1, id + n + 1));
// 输出答案,这里因为是浮点数,所以我们设置精度为4。
cout << setprecision(4) << ans << endl;
return 0;
}
- 使用
next_permutation
函数枚举排列代码的复杂度分析:
do while
循环的循环次数,也就是长度为n的排列个数为n!n!。
调用next_permutation
函数一次的复杂度为 O ( n ) O(n) O(n)
所以枚举排列的复杂度为 O ( n ! × n ) O(n!×n) O(n!×n)。
三连击
将 1,2,…,9 共 9 个数分成三组,每组组成三个三位数,且使这三个三位数的比例是a : b : c,
试求出所有满足条件的三个三位数,若无解,输出 No!!!。
输入描述:
三个数,a, b, c。
输出描述:
若干行,每行 3 个数字。按照每行第一个数字升序排列。
示例 1:
输入:
1 2 3
输出:
192 384 576
219 438 657
273 546 819
327 654 981
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std ;
int main(){
int a , b , c ;
cin >> a >> b >> c ;
int arr[10] = {1,2,3,4,5,6,7,8,9} ;
bool check = false ;
do{
int num1 = arr[0] * 100 + arr[1] * 10 + arr[2] ;
int num2 = arr[3] * 100 + arr[4] * 10 + arr[5] ;
int num3 = arr[6] * 100 + arr[7] * 10 + arr[8] ;
if ( a * num2 == b * num1 && c * num2 == b * num3 ){
cout << num1 << " " << num2 << " " << num3 << endl ;
check = true ;
}
} while(next_permutation(arr , arr + 9)); // 利用STL中的 next_permutation 全排列算法按照字典序产生排列
if ( !check ){
cout << "No!!!" << endl ;
}
return 0 ;
}
排序
选择排序
选择排序的基本思路是:
- 按照
1~n
的顺序,将每个元素依次归位。 - 当归位第
i
个元素时,我们需要选择出第i
个元素到第n
个元素的最小值,并且与第i个位置的元素交换。此时,1~i
的元素分别为第1
小到第i小的元素。 - 当第
n
个元素归位完毕以后,整个序列的排序过程结束。
完整选择排序的代码实现:
#include <bits/stdc++.h>
using namespace std;
int a[1010];
int n;
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
// 选择排序过程
for (int i = 1; i < n; ++i) { // 枚举应该归位的第i个元素,这里因为前n-1位归为以后,
// 第n位也会归位,所以我们只枚举到n-1。
int min_pos = i; // 将最小值位置设置为当前范围i~n的首位
for (int j = i + 1; j <= n; ++j) { // 将第i个元素和剩下的元素相比较
if (a[j] < a[min_pos]) { // 如果当前元素小于之前维护的最小值
min_pos = j; // 更改最小值出现的位置
}
}
swap(a[i], a[min_pos]); // 将最小值与第i个位置交换
}
// 输出
for (int i = 1; i <= n; ++i)
cout << a[i] << ' ';
return 0;
}
选择排序的时间复杂度是:
O
(
n
2
)
O(n^2)
O(n2)
明明的随机数_选择排序
明明想在学校中请一些同学一起做一项问卷调查,为了实验的客观性,
他先用计算机生成了N个1到1000之间的随机整数(N≤100),
对于其中重复的数字,只保留一个,把其余相同的数去掉,不同的数对应着不同的学生的学号。
然后再把这些数从小到大排序,按照排好的顺序去找同学做调查。
请你协助明明完成“去重”与“排序”的工作。
注:请使用选择排序方法完成此题。
输入描述:
每组输入有2行,第1行为1个正整数,表示所生成的随机数的个数N,第2行有N个用空格隔开的正整数,为所产生的随机数。
输出描述:
每组输出也是2行,第1行为1个正整数M,表示不相同的随机数的个数。第2行为M个用空格隔开的正整数,为从小到大排好序的不相同的随机数。
示例 1:
输入:
10
20 40 32 67 40 20 89 300 400 15
输出:
8
15 20 32 40 67 89 300 400
#include <cstdio>
#include <cstring>
#include <iostream>
#include <algorithm>
#define N 110
using namespace std;
int a[N], n, cnt;
int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i)
scanf("%d", &a[i]);
for (int i = 0; i < n; ++i) {
int min_pos = i;
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[min_pos])
min_pos = j;
}
swap(a[i], a[min_pos]);
}
cnt = 0;
for (int i = 0; i < n; ++i)
if (i == 0 || a[i] != a[i - 1])
a[cnt++] = a[i];
printf("%d\n", cnt);
for (int i = 0; i < cnt; ++i)
printf("%d ", a[i]);
return 0;
}
冒泡排序
- 冒泡排序和选择排序一样,都是将原问题转换为长度减一的子问题的过程。
- 冒泡排序分为
n-1
个阶段,每个阶段通过“冒泡”的过程,将未排序序列中的最大值移动到最后一位。 - 冒泡的过程,具体是通过一段连续交换过程使得最大元素被“传送”到最后一位。
- 冒泡排序的思路:
1、冒泡排序分为n-1
个阶段。
2、在第1个阶段,通过“冒泡”,我们将前n
个元素的最大值移动到序列的最后一位。此时只剩前n-1
个元素未排序。
3、在第i个阶段,此时序列前n-i+1
个元素未排序。通过“冒泡”,我们将前n-i+1
个元素中的最大值移动到最后一位。此时只剩前n-i
个元素未排好序。
对一段序列从左到右连续做交换操作的代码:
if (a[i] > a[i + 1]) swap(a[i], a[i + 1]);
4、最终到第n-1
个阶段,前2
个元素未排序。我们将其中的较大值移动到后一位,则整个序列排序完毕。 - 完整冒泡排序的代码实现:
#include <bits/stdc++.h>
#define N 1010
using namespace std;
int n, a[N];
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
// 冒泡排序
for (int i = 1; i < n; ++i) { // 一共n-1个阶段,在第i个阶段,未排序序列长度从n-i+1到n-i。
for (int j = 1; j <= n - i; ++j) // 将序列从1到n-i+1的最大值,移到n-i+1的位置
if (a[j] > a[j + 1]) // 其中j枚举的是前后交换元素的前一个元素序号
swap(a[j], a[j + 1]);
}
// 输出
for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
cout << endl;
}
- 复杂度分析
从代码中,我们可以看到冒泡排序的主干部分有两层循环,并且每一层的循环次数都在 O ( n ) O(n) O(n)左右的数量级。
所以完整的冒泡排序时间复杂度是 O ( n 2 ) O(n^2) O(n2)。
插入排序
-
插入排序的基本思想就是不断扩展有序序列的长度。
具体方式是对于一个有序序列,如果想在其中新加入一个元素,就应通过插入操作找出正确的插入位置,并且将插入位置空出来,然后插入新元素。 -
插入操作的基本思想就是从后向前不断“试探”分界线的位置。
一个合法的分界线,分界线前的元素需满足小于等于新元素大小,分界线后元素需满足大于新元素大小。所以寻找分界线的过程,就是不断把当前在分界线前,但本应该在分界线后的元素向后移动。 -
插入操作的算法描述:
1、假设序列1~(i-1)
已经有序, 从i
到1
枚举分界线的下标j
;
2、如果分界线前面的元素a[j-1]
大于x
,说明a[j-1]
应该在分界线后面。所以将a[j-1]
移动到a[j]
,分界线前移变成j-1
。
3、如果分界线前面没有元素(j=1)
,就将x放在数组第1
位。否则如果碰到一个j-1
号元素小于等于x
,说明分界线位置正确,就将x
插到j
位。
完整插入排序的代码实现:
#include <bits/stdc++.h>
#define N 1550
using namespace std;
int a[N], n;
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
// 插入排序
for (int i = 2; i <= n; ++i) { // 按照第2个到第n个的顺序依次插入
int j, x = a[i]; // 先将i号元素用临时变量保存防止被修改。
// 插入过程,目的是空出分界线位置j,使得所有<j的部分<=x,所有>j的部分>x。
// 循环维持条件,j>1,并且j前面的元素>x。
for (j = i; j > 1 && a[j - 1] > x; --j) {
// 满足循环条件,相当于分界线应向前移,
// 分界线向前移,就等于将分界线前面>x的元素向后移
a[j] = a[j - 1];
}
// 找到分界线位置,插入待插入元素x
a[j] = x;
}
// 输出
for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
cout << endl;
return 0;
}
- 时间复杂度分析:
插入排序的总时间复杂度是 O ( n 2 ) O(n^2) O(n2)。
快速排序
-
快速排序是一种基于分治法的排序。其基本思想在于固定一个分界线,将整个序列按照小于分界线和大于分界线划分,然后再分别对划分好的子段进行排序。
-
快速排序的时间复杂度在理想情况下是 O ( n l o g n ) O(nlogn) O(nlogn),但如果选取分界线每次都是子段中的最大值或最小值的话,时间复杂度可能会退化到 O ( n 2 ) O(n^2) O(n2)在内存使用上,因为整个移动过程都在原数组中进行的,所以属于原地排序。
-
sort函数是C++标准模板库(STL)中一种对快速排序的优化实现,可以通过传入头指针、尾指针和比较函数来对数组中的对象进行排序。
-
快速排序示例:
将数组{2, 3, 1, 5, 4}从小到大排列。 -
不使用sort函数
将「整体框架」和「移动元素」进行合并,我们得到快速排序完整代码:
// 该代码参考 https://www.geeksforgeeks.org/quick-sort/
#include <bits/stdc++.h>
#define N 100010
using namespace std;
int n;
int a[N];
void quick_sort(int l, int r) {
// 设置最右边的数为分界线
int pivot = a[r];
// 元素移动
int k = l - 1;
for (int j = l; j < r; ++j)
if (a[j] < pivot) swap(a[j], a[++k]);
swap(a[r], a[++k]);
if (l < k - 1) quick_sort(l, k - 1); // 如果序列的分界线左边的子段长度>1,排序
if (k + 1 < r) quick_sort(k + 1, r); // 如果序列的分界线右边的子段长度>1,排序
// 上面的过程结束后,到这里左子段和右子段已经分别排好序。又因为确定分界线以后的移动操作
// 保证了左子段中的元素都小于等于分界线,右子段中的元素都大于分界线。所以整个序列也是有序的。
}
int main() {
// 输入
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
// 快速排序
quick_sort(1, n);
// 输出
for (int i = 1; i <= n; ++i) printf("%d ", a[i]);
return 0;
}
使用sort函数
sort
函数有三个参数,分别为头指针、尾指针和比较函数,其中如果排序对象定义了小于号的话,比较函数可省略。例如对于一个长为n
的数组排序:
#include <bits/stdc++.h>
using namespace std;
int a[10] = {2, 3, 1, 5, 4};
int n = 5;
int main() {
sort(a, a + n); //sort函数的两个参数,头指针和尾指针
for (int i = 0; i < n; ++i) cout << a[i] << ' ';
cout << endl;
}
统计数字_快速排序
某次科研调查时得到了n个非零自然数,每个数均不超过1500000000(1.5*10^9 )。已知不相同的数不超过10000个,现在需要统计这些自然数各自出现的次数,并按照自然数从小到大的顺序输出统计结果。
输入描述:
每组输入数据包含n+1行;
第一行是整数n,表示自然数的个数;
第2~n+1行,每行一个自然数(非零)。
数据规模:
40%的数据满足:1<=n<=1000;
80%的数据满足:1<=n<=50000;
100%的数据满足:1<=n<=200000,每个数均不超过1500000000(1.5*10^9)。
输出描述:
每组输出包含m行(m为n个自然数中不相同数的个数),按照自然数从小到大的顺序输出。每行输出两个整数,分别是自然数和该数出现的次数,其间用一个空格隔开。
#include <bits/stdc++.h>
using namespace std;
int a[300000]; // 防止越界
int main(){
int n, s = 1;
cin >> n;
for(int i = 1;i <= n;i++){
cin >> a[i];
}
sort(a + 1,a + n + 1); // 使用`sort`函数进行快速排序
for(int i = 1; i <= n; i++){
if(a[i] == a[i+1]){
s++; // 计数
}
else{
cout << a[i] << " " << s << endl;
s = 1;
}
}
return 0;
}
归并排序
-
和快速排序一样,归并排序也是基于分治法的排序算法。其基本思想在于将待排序序列分成长度相差不超过1的两份,分别对左右子段进行同样的划分和排序过程,最后将两个已经排好序的子段合并成整个完整的有序序列。
-
归并排序的时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn),在实现时,需要辅助数组帮助合并子段,所以是一种非原地排序算法。
-
和快速排序不同的是,归并排序是一种稳定排序,即相同元素在排序前后的数组中相对位置不变。
-
stable_sort
函数是C++标准模板库(STL)中一种对归并排序的优化实现,可以通过传入头指针、尾指针和比较函数来对数组中的对象进行排序。 -
归并排序示例:
将数组{2, 3, 1, 5, 4}从小到大排列。
- 不使用
stable_sort
函数
将「整体框架」和「归并操作」进行合并,我们得到快速排序完整代码:
#include <bits/stdc++.h>
#define N 100010
using namespace std;
int n;
int a[N], b[N];
// 合并操作
void merge(int l, int r) {
for (int i = l; i <= r; ++i) b[i] = a[i]; // 将a数组对应位置复制进辅助数组
int mid = l + r >> 1; // 计算两个子段的分界线
int i = l, j = mid + 1; // 初始化i和j两个指针分别指向两个子段的首位
for (int k = l; k <= r; ++k) { // 枚举原数组的对应位置
if (j > r || i <= mid && b[i] < b[j]) a[k] = b[i++]; // 上文中列举的条件
else a[k] = b[j++];
}
}
void merge_sort(int l, int r) { // l和r分别代表当前排序子段在原序列中左右端点的位置
if (l >= r) return; // 当子段为空或者长度为1,说明它已经有序,所以退出该函数
int mid = l + r >> 1; // 取序列的中间位置,并将序列分成两部分(左右长度相差最多为1)
merge_sort(l, mid);
merge_sort(mid + 1, r);
merge(l, r); // 将l..mid和mid+1..r两个子段合并成完整的l..r的有序序列
}
int main() {
// 输入
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
// 归并排序
merge_sort(1, n);
// 输出
for (int i = 1; i <= n; ++i) printf("%d ", a[i]);
return 0;
}
- 使用stable_sort函数
#include <bits/stdc++.h>
using namespace std;
int a[10] = {0, 2, 3, 1, 5, 4}; // 1-base,0号元素无意义
int n = 5;
bool cmp(int x, int y) { // 比较函数,函数的参数是当前比较的两个数组中的元素
return x > y; // x和y分别为排序数组中的两个元素。
} // 当函数返回值为true时,x应该排在y的前面。
int main() {
stable_sort(a + 1, a + n + 1, cmp); // 比较函数作为第三个参数传入sort函数
for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
cout << endl;
}
计数排序
-
计数排序的基本思想是通过统计序列中不同的值出现的次数来排序。因为要用数组统计个数,所以要求在计数排序之前,整个序列中的元素需转换成在很小范围[0…K]的非负整数。
-
计数排序的算法描述:
统计原序列中每个值的出现次数,记为cnt
数组。
从小到大枚举值的范围,对cnt数组求前缀和,记为sum
数组。
从后往前枚举每个元素a[i]
,分配其在答案中的位置idx[i]
为当前的sum[a[i]]
,也就是将其放在所有值等于a[i]
中的最后一个。并且将sum[a[i]]
减少1
,保证下次再遍历到同样的值时,它分配的位置正好在idx[i]
前面一个。 -
计数排序的代码实现1:
#include <bits/stdc++.h>
#define N 1000005
#define K 1000001 // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K];
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
++cnt[a[i]]; // 这里通过计数数组cnt来维护每一种值出现的次数
}
// 维护最终有序序列
for (int i = 0, j = 0; i < K; ++i) // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
for (int k = 1; k <= cnt[i]; ++k) // 根据该值出现的次数
b[++j] = i; // 添加对应个数的i到答案序列
// 输出
for (int i = 1; i <= n; ++i)
cout << b[i] << ' ';
cout << endl;
return 0;
}
-
其中:
在计数排序的输入部分,我们用cnt数组统计了每种值出现的个数。
在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应cnt个元素到答案数组里。 -
上述计数排序实现方法的时间和空间复杂度都是 O ( n + K ) O(n+K) O(n+K)。正因为它不是基于比较的排序,所以才能达到比 O ( n l o g n ) O(nlogn) O(nlogn)更好的时间复杂度。
-
计数排序的基本思想还可以拓展成桶排序和基数排序。使用桶排序和基数排序的,可以对更大范围内的,甚至不是整数的序列进行排序。
计数排序的代码实现2
找出原序列中的元素和答案数组中的对应
这里,我们给出另外一种计数排序的实现方法。其中
- 在输入部分,我们统计每一种值出现的次数
- 在求原序列和答案序列的位置对应关系的部分,我们对
cnt
数组求前缀和,并存储在sum
中。对于一个值x
,sum[x]
的含义是“小于等于x
的数字个数”,同时,也可以看作指向答案序列中最后一个x
出现的位置的指针。 - 然后,我们从后向前枚举原序列的每个元素
x
,将sum[x]
指向的位置分配给它,存在idx
数组中,然后将sum[x]
前移。这里“从后向前”是因为考虑到对于同一个值,分配位置的顺序是从后向前。所以,我们从后向前枚举原序列,可以保证在值相同的情况下,在原序列中出现在后面的元素会被分配到更大的位置,也就保证列排序的稳定性。 - 因为原序列中
i
位置的数字,在答案序列中出现在idx[i]
处。所以我们据此生成答案序列。
#include <bits/stdc++.h>
#define N 1000005
#define K 1000001 // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K], sum[K];
int idx[N]; // 用来记录原序列中每个元素在新序列中的位置
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
++cnt[a[i]]; // 这里通过计数数组cnt来维护每一种值出现的次数
}
// 求原序列和答案序列中的位置对应
sum[0] = cnt[0]; // 假设最小值为0
for (int i = 1; i < K; ++i) // 求cnt的前缀和
sum[i] = sum[i - 1] + cnt[i];
for (int i = n; i; --i) // 给每个元素分配位置
idx[i] = sum[a[i]]--; // 之所以倒循环,是因为对于相等的元素我们是从后向前分配位置
// 这样我们可以保证排序的稳定性
// 根据求出的位置将每个元素放进答案序列中
for (int i = 1; i <= n; ++i)
b[idx[i]] = a[i];
// 输出
for (int i = 0; i <= n; ++i)
cout << b[i] << ' ';
cout << endl;
return 0;
}
二分查找
二分查找
一个猜数字的游戏:
你正在参加一个电视节目,主持人邀请你上台玩一个游戏:主持人在心中想一个[1,1000]范围内的整数,你需要用尽量少的询问猜出这个数是什么。你可以询问主持人“这个数是x吗?”,主持人会告诉你,他心中所想的数是大于x,还是小于x,还是恰好等于x。
从数字1开始慢慢地一个一个地问是种比较笨的办法,我们不难想到一些方法:询问100、200、300、400……来确定答案位于哪个区间内,然后在这个区间内故技重施(假设答案在300和400之间),询问310、320、330、340……来缩小这个区间。当这个区间足够小的时候,我们再对区间中的每个数字挨个询问。
实际上,每次询问当前区间的中点是最优的策略。我们根据主持人的回答来确定最终答案是位于哪半边区间里面。由于我们每次会把当前区间的长度大约除以2,因此我们在大约10次询问之后,就一定能知道最终答案。下面这个游戏记录就展示了这个策略。
这个策略就是二分查找。现在我们来看一下二分查找这个神奇的算法:
-
二分查找的原理:每次排除掉一半答案,使可能的答案区间快速缩小。
-
二分查找的时间复杂度:
O(log n)
,因为每次询问会使可行区间的长度变为原来的一半。 -
我们再来看一下二分查找的思路:我们设定一个初始的
L
和R
,保证答案在[L,R]
中,当[L,R]
中不止有一个数字的时候,取区间的中点M
,询问这个中点和答案的关系,来判断答案是M
,还是位于[L,M-1]
中,还是位于[M+1,R]
中。二分查找的伪代码如下:
int L = 区间左端点;
int R = 区间右端点; // 闭区间
while( L < R ) { // 区间内有至少两个数字
int M = L+(R-L)/2; // 区间中点
if( M是答案 ) 答对啦;
else if( M比答案小 ) L = M+1;
else R = M-1; // M比答案大
}
// 若运行到这里,因为答案一定存在,所以一定有L==R,且L是答案
正如之前说的,二分查找中其实还有很多细节问题没有处理,比如:
- 如果循环最后因为不满足
L < R
条件而退出,这时候L和R到底是什么关系?答案是什么? - 如果答案不存在会怎么样?
二分查找方法
二分查找可能会遇到哪些边界情况?为什么示例代码能完美的解决这些边界情况?
答:总是可以通过问题转换写出满足L < R
的优美代码。
- 二分查找伪代码
while( L < R ) {
int M = L+(R-L)/2;
if( 答案在[M+1,R]中 ) { // 思考一下,什么情况下能够说明“答案在[M+1,R]中”
L = M+1;
} else { // 答案在[L,M]中
R = M;
}
}
-
写二分查找遇到了死循环,考虑是不是遇到了“差一点”问题。
如果出现了L = M
,那么中点向上取整(M = L+(R-L+1)/2)
。
如果出现了R = M
,那么中点向下取整(M = L+(R-L)/2)
。 -
找最后一个满足条件的数字,一般是
L = M
。 -
找第一个满足条件的数字,一般是
R = M
。 -
代码示例:
有一个从小到大排好序的数组,你要找到从右向左数第一个大于等于x的数字,应该怎么做?
输入n,x,以及一个长度为n的数组a(已经从小到大排好序了)
输入样例:
9 4
2 3 3 3 3 4 4 4 4
-
代码样例:
#include <iostream>
using namespace std;
int n, x, a[100000];
int main() {
cin >> n >> x; // n为数组元素个数,x为
// 输入数组
for( int i = 0; i < n; ++i )
cin >> a[i];
// 考虑数组中不存在大于等于x的数字的情况
if( x > a[n-1] ) {
cout << -1 << endl;
return 0;
}
// 二分查找
int L = 0, R = n-1; // 数组下标从0到n-1,闭区间
while( L < R ) { // 当区间中至少有两个数字的时候,需要继续二分
int M = L + (R - L) / 2; // 求出区间中点
if( a[M] <= x ) { // 答案一定出现在[M+1,R]中
L = M+1;
} else { // a[M] >= x,答案一定出现在[L,M]中
R = M;
}
}
// 此时L == R,a[L]就是第一个大于等于x的数字
if ( a[L] == x) {
cout << L << endl; // 如果答案存在,则输出答案
} else {
cout << -1 << endl; // 如果答案不存在,则输出-1
}
return 0;
}
- 最后,再回顾一下在上一知识点中,我们推导了二分查找的时间复杂度。只有当我们询问区间中点的时候,我们才能让可行区间的长度以最快的速度变短——每次大约变为原来长度的一半,所以二分查找的时间复杂度是
l
o
g
2
(
n
)
log_2(n)
log2(n)。
二分查找时间复杂度的计算方法:
比如,在猜数字的游戏中,假设我们一开始有n个数字。每次把剩余数字的区间分成两半,直到xx次后只剩下最后一个数字,就是我们想要的答案啦。 计算公式如下:
n ∗ 1 / ( 2 x ) = 1 n * 1/(2^x) = 1 n∗1/(2x)=1
x x x次后只剩下最后一个数字
x = l o g 2 ( n ) x = log_2(n) x=log2(n)
那么, x x x的值就是 l o g 2 ( n ) log_2(n) log2(n)咯
在二分查找问题中(L为区间左边界,R为区间右边界,M为区间中点),如果代码中是用的R = M,把R不断往左push时应该向下取整(M=L+(R-L)/2);如果代码中出现了L = M时向下取整(M=L+(R-L+1)/2)。。
给定一个长度为n (1 ≤ n ≤ 10000)且由小到大排序的序列,查找数字x在该序列中的位置。
输入描述:
第一行一个整数n,表示该序列的长度。
接下来n行,每行一个整数,表示该序列。
最后一行一个整数x,表示要查询的数字。
输出描述:
一行,若x存在,则输出x是从左向右第几个;若不存在,则输出not find。
示例 1:
输入:
5
1
2
3
4
5
3
输出:
3
#include <bits/stdc++.h>
using namespace std;
int a[100010];
int main() {
int n, x;
cin >> n;
for (int i = 1; i <= n; i++)
cin >> a[i];
cin >> x;
int pos = lower_bound(a + 1, a + n + 1, x) - a;
if (a[pos] != x)
cout << "not find" << endl;
else
cout << pos << endl;
return 0;
}
二分查找的应用范围
如果我们想要在一个数组上进行二分查找,那么这个数组必须是有序的,不管是升序还是降序,它必须是有序的。
为什么呢?
注意二分查找的本质是什么:通过比较数组中间那个值和我们要求的值的关系,来判断出“答案不可能出现在数组的某一半”,从而让我们的查找范围缩小为原来的一半。
这也就是为什么我们要求数组中的元素是满足单调性的:只有这样,我们才能保证当a[M]不满足条件的时候,它左边(或者右边)的所有元素都不满足条件。
所以:
-
要进行二分,数组必须是有序的。
-
基本上所有可以比较的数据都可以进行二分查找。
比如:日期、字符串、二维数组 -
如果数据可以方便的计算“中点”,那么就可以在大区间上二分查找指定的数据(比如日期)
二分查找的应用
我们总结一些二分查找的常见应用:
lower_bound
和upper_bound
lower_bound
的用途是:在指定的升序排序的数组中,找到第一个大于等于x的数字。
upper_bound
的用途是:在指定的升序排序的数组中,找到第一个大于x的数字。
使用lower_bound
和upper_bound
可以帮我们解决绝大多数二分查找问题。
这两个函数会返回对应数字的指针。示例代码如下:
int a[100000], n;
cin >> n;
for( int i = 0; i < n; ++i )
cin >> a[i];
sort(a, a + n);
int *p = lower_bound(a, a + n, 13); // 第一个大于等于13的数字
int *q = upper_bound(a, a + n, 13); // 第一个大于13的数字
-
假如我们使用
lower_bound
和upper_bound
二分查找同一个数字13,容易发现,我们得到的两个指针构成了一个左闭右开区间,这个区间里全部都是数字13。 -
巧妙地运用这两个函数,可以完成所有常见的二分查找操作:
找到第一个大于等于x的数字
找到第一个大于x的数字
找到最后一个等于x的数字
查找数组中是否有数字x
查询数组中有几个数字x
找到最后一个小于x的数字 -
二分法可以求方程的近似解。
-
二分法可以用来优美地实现离散化操作。
-
在double上二分时,尽量使用固定次数二分的方法。
二分法求方程的根
已知方程2x^3 - 4x^2 + 3x - 6 = 0,用二分法求下面方程在区间(a,b)之间的根,数据保证输入区间内有根。计算至误差小于10^-6为止。
输入描述:
一行两个数字,表示区间的左右端点,数据保证输入区间内有根。
输出描述:
方程的根,小数点后保留6位小数,末尾换行。
示例 1:
输入:
-10 10
输出:
2.000000
#include <bits/stdc++.h>
using namespace std;
double f(double x) {
return (2 * pow(x, 3) - 4 * pow(x, 2) + 3 * x - 6);
}
int main(){
double l, r, mid;
cin >> l >> l;
do {
mid = (l + r) / 2;
if (f(mid) * f(l) > 0)
l = mid;
else
r = mid;
} while (fabs(f(mid)) >= 1e-7);
printf("%.6lf\n", mid);
return 0;
}
递推与递归
递推
-
递推
思想:
根据已有的东西一点点地推出未知的东西。 -
使用递推解题三步骤:
数学建模
找出递推式和初始条件
写出代码。 -
张爽的青蛙(斐波那契)问题
:地上有nn个石头从左到右排成一排,张爽同学养的青蛙要从第一个石头跳到最后一个石头上,每次可以选择向右跳一格或者跳两格,问总共有多少种不同的走法? -
递推式:
f[n] = f[n-1] + f[n-2]
;
初始条件:f[1] = f[2] = 1
。因为从1
走到1
只有一种方案(呆在原地不动),从1
走到2
也只有一种方案(走一格);
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353; // 答案对998244353取模。
int k, f[1000010];
int main() {
cin >> k;
f[1] = f[2] = 1; // 初始条件
for( int i = 3; i <= k; ++i )
f[i] = (f[i-1] + f[i-2]) % MOD; // 递推式,记得取模
cout << f[k] << endl;
return 0;
}
卡特兰数问题
:由n对括号组成的括号序列,有多少种是合法的括号序列?
递推式:f[n] = f[0] * f[k-1] + ... + f[k-1] * f[0]
;
初始条件:f[0] = 1
,因为0
对括号只能组成一种括号序列(空序列);
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;
int n, f[100010];
int main() {
cin >> n;
f[0] = 1; // 初始条件
for( int i = 1; i <= n; ++i ) { // 求f[i]的值
for( int k = 0; k < i; ++k ) {
f[i] += int((long long)f[k] * f[i-k-1] % MOD); // 递推式
// 注意,两个int相乘的结果可能爆int,因此乘法的过程要转换成long long以避免整数溢出
f[i] %= MOD; // 记得取模
}
}
cout << f[n] << endl;
return 0;
}
时间复杂度: O ( n 2 ) O(n^2) O(n2)。
递推思想的应用
-
递推思想:
根据已有的东西一点点地推出未知的东西。 -
使用递推解题三步骤:
数学建模
找出递推式和初始条件
写出代码。 -
错位排列问题:有 n n n个信封和 n n n个信件,第ii个信件属于第ii个信封,我们想知道,有多少种不同的方法,使得没有任何一个信件被装入正确的信封中?
递推式:f[n] = (n-1)(f[n-1] + f[n-2])
;
初始条件:f[1] = 0
,因为只有1个信件和信封的时候,没有办法错位排列;f[2] = 1
,只有2个信件和信封的时候,只有一种方法(交叉放)。
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;
int f[1000010], n;
int main() {
cin >> n;
f[1] = 0; // 初始条件
f[2] = 1;
for( int i = 3; i <= n; ++i ) {
f[i] = (long long)(i-1) * (f[i-1] + f[i-2]) % MOD;
// 注意取模,并且小心乘法会爆int
}
cout << f[n] << endl;
return 0;
}
- 杨辉三角(二维递推)问题:从
n
n
n个不同的物品中选取
m
m
m个,有多少种不同的选择方法?这是一个经典的组合数问题,而本题需要你解决一个更难的问题:给出
k
k
k,你需要输出一个
(
k
+
1
)
∗
(
k
+
1
)
(k+1)∗(k+1)
(k+1)∗(k+1)的矩阵,其中第
i
i
i行第
j
j
j列表示,从
i
i
i个不同的物品中选
j
j
j个,有多少种不同的方法(行和列的标号从0开始)。
递推式:f[i][j] = f[i-1][j-1] + f[i-1][j]
;
初始条件:f[i][0] = f[i][i] = 1
; // 递推边界条件;
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;
int f[2010][2010] = {0}, k; // 初始化f数组为全0
int main() {
cin >> k;
for( int i = 0; i <= k; ++i ) {
f[i][0] = f[i][i] = 1; // 递推边界条件
for( int j = 1; j < i; ++j ) {
f[i][j] = (f[i-1][j-1] + f[i-1][j]) % MOD; // 递推式,记得取模
}
for( int j = 0; j <= k; ++j ) {
cout << f[i][j] << ' '; // 输出这一整行
}
cout << endl;
}
return 0;
}
时间复杂度: O ( n 2 ) O(n^2) O(n2)。
递归
-
递归是一个函数自己调用自己。
-
递归的本质是数学归纳法。
我们总是需要从数学归纳法的角度去思考递归,永远不要尝试展开递归过程。 -
斐波那契数列问题:输入正整数n,使用递归法求出斐波那契数列的第n项,答案对998244353取模。
数学公式:
f(1) = f(2) = 1 // 初始值
f(n) = f(n-1) + f(n-2) // 递归公式
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;
int f( int n ) {
if( n == 1 || n == 2 )
return 1; // 边界条件
else
return (f(n-1) + f(n-2)) % MOD; // 不要忘记取模
}
int main() {
int n;
cin >> n;
cout << f(n) << endl;
return 0;
}
- 求阶乘问题:输入非负整数n,使用递归法求出n的阶乘,答案对998244353取模。
数学公式:
f(0) = 1 // 初始值
f(n) = f(n-1) * n // 递归公式
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;
int f( int n ) {
if( n == 0 )
return 1; // 0的阶乘等于1
else return
(long long)f(n-1) * n % MOD; // 注意取模,小心爆int
}
int main() {
int n;
cin >> n;
cout << f(n) << endl;
return 0;
}
-
理解递归的三板斧:
确认并牢记这个递归函数的功能,始终不能改。
仔细检查,写对边界条件。
递归调用自己时,放心大胆地用,假设这个函数就是对的。 -
写递归算法时,要牢记该函数只干一件事情,要写出所有边界条件,要放心大胆地递归调用自己。
-
递归算法的复杂度往往难以精确把握,只需要根据边界条件的执行次数和递归调用次数估算即可。
倒序输出
输入一个自然数,输出它的倒序数。
输入描述:
一个正整数
输出描述:
这个正整数的倒序数
示例 1:
输入:
1234
输出:
4321
#include <iostream>
using namespace std;
void daoxu(int n){
if (n < 10)
cout << n;
else{
cout << n % 10;
daoxu(n / 10); // 递归
}
}
int main(){
int n;
cin >> n;
daoxu(n);
return 0;
}
动态规划
动态规划
-
动态规划是一种解决某种最优化问题的方法。
最优化问题的目的就是在一些场景限制下,通过不同的决策,达到最大收益或者最小损失。 -
使用动态规划的条件是:最优子结构、无后效性和重复子问题。
最优子结构保证了我们能够通过选取子问题的最优解最终拼成原问题的解;
无后效性保证了整个过程的推导是同一个方向的,不会出现环的情况;
重复子问题一定程度上保证了总状态个数不会与每个状态的选择数呈指数增长。 -
使用动态规划解决问题,需要明确状态设计、转移方程、初始状态和转移方向四个方面。这样,我们就可以用类似根据递推式计算数列第 n n n项的方法得到最终结果。
-
数字金字塔问题:给定一个 n n n层的金字塔,求一条从最高点到底层任意点的路径使得路径经过的数字之和最大。注:每一步可以走到左下方的点也可以到达右下方的点。
-
完整代码:
#include <bits/stdc++.h>
#define N 1005
#define M 110
using namespace std;
int n;
int a[N][N], f[N][N];
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= i; ++j)
cin >> a[i][j];
// 动态规划过程
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= i; ++j)
f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j];
// 此处没有讨论 j == 1 和 i == j 的情况
// 是因为当 j == 1 时,f[i - 1][j] == 0
// 是因为在数字金字塔所有数字都是正数的情况下
// max函数一定不会选择用f[i - 1][j]来转移
// i == j 的情况同理
// 输出
int ans = 0;
for (int i = 1; i <= n; ++i) ans = max(ans, f[n][i]); // 求第n行的最大值
cout << ans << endl;
return 0;
}
-
复杂度分析
空间复杂度:== 数字金字塔==的空间复杂度是 O ( n 2 ) O(n^2) O(n2)。
时间复杂度:动态规划因为大部分都是由一些for
循环组成,所以复杂度分析相对简单。在本例中,因为有两层for
循环,并且都是 n n n左右的数量级,所以整个算法的复杂度为 O ( n 2 ) O(n^2) O(n2)。 -
动态规划算法的关键在于解决冗余,舍空间而取时间。
最大子段和
给出一个长度为 n 的序列 a,选出其中连续且非空的一段使得这段和最大。
输入描述:
第一行是一个整数,表示序列的长度 n。
第二行有 n 个整数,第 i 个整数表示序列的第 i 个数字 a_i。
输出描述:
输出一行一个整数表示答案。
示例 1:
输入:
7
2 -4 3 -1 2 -4 3
输出:
4
#include <bits/stdc++.h>
#define N 100000
using namespace std;
int n;
int a[N];
int b[N]; // 用于存储当前位置元素的最大字段和
int main() {
// 输入
cin >> n;
for (int i = 0; i < n; ++i)
cin >> a[i];
b[0] = a[0];
// 动态规划过程
for (int i = 1; i <= n; ++i)
if (b[i - 1] < 0) {
b[i] = a[i];
} else {
b[i] = b[i-1] + a[i];
}
// 输出
int ans = 0;
for (int i = 0; i < n; ++i)
ans = max(ans, b[i]); // 求第n行的最大值
cout << ans << endl;
return 0;
}
最长上升子序列
给定一个长度为n的序列,在其中找出一个子序列,并且满足该序列中的数字单调上升,那么该子序列最长的长度是多少。
输入描述:
第一行一个整数n (1 ≤ n ≤ 1000),表示该序列的长度。
第二行n个整数,表示该序列,中间用空格隔开。
输出描述:
一行一个整数,表示最长的长度。
示例 1:
输入:
5
1 4 3 2 5
输出:
3
#include <bits/stdc++.h>
using namespace std;
int a[1010], f[1010];
int main(){
int n, ans = 0;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
f[i] = 1;
for (int j = 1; j < i ; j++)
if (a[j] < a[i])
f[i] = max(f[j] + 1, f[i]);
ans = max(ans, f[i]);
}
cout << ans << endl;
return 0;
}
0-1背包问题
-
背包问题是一种经典的动态规划问题。
问题描述为有n个物品,每个物品有一定的体积和价值。我们想挑选一些物品放入容量为V的背包里,求最大价值。 -
背包问题的分析方式是通过讨论每个物品“放与不放”,连接前 i − 1 i−1 i−1个物品的状态和前ii个物品的状态之间的关系。所以最终该状态的值就是两种选择下,获得收益的较大值。
-
背包问题的时间复杂度和空间复杂度都是 O ( n V ) O(nV) O(nV),但是我们可以使用滚动数组,或者进一步精细分析,将空间复杂度下降为 O ( V ) O(V) O(V)。
-
背包问题中动态规划四要素:
1、状态:
用f[i][j]
表示前ii个物品,放在空间为 j j j的背包里,能获得的最大收益。
2、转移方程:
因为每一个阶段有至多两种选择,所以需要分别计算两种选择的收益后取较大值。
// j < v[i],表示装不下第i个物品
f[i][j] = f[i - 1][j];
// otherwise
f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + p[i]);
3、初始状态:
在一个物品都没放的时候,无论背包大小多少,总价值都是
0
0
0,即
f[0][j] = 0; // 0 <= j <= V
4、转移方向:
观察转移方程,发现要想保证等式右边的状态一定比左边的状态先算出来,只需要保证
i
i
i从小到大计算即可。
背包问题代码实现:
#include <bits/stdc++.h>
#define N 1002
using namespace std;
int n, V, v[N], p[N];
int f[N][N];
int main() {
// 输入
cin >> V >> n; // V是总体积,n是物品总个数
for (int i = 1; i <= n; ++i)
cin >> v[i] >> p[i];
// 动态规划
for (int i = 1; i <= n; ++i) {
for (int j = 0; j <= V; ++j) {
if (j < v[i]) // 当前背包容量不够装第i个物品
f[i][j] = f[i - 1][j];
else // otherwise
f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + p[i]);
}
}
// 输出
cout << f[n][V] << endl;
return 0;
}
采药
山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值,阿晓需要在有限的时间T内,采集尽可能高价值的草药。请你帮她算一算在时间限制内能采到的价值总和最高是多少。
输入描述:
第一行两个整数T N,分别表示限制的时间和草药的数量,其中1 ≤ T ≤ 1000,1 ≤ N ≤ 100。
接下来N行,每行两个整数vi wi,分别表示该草药所需的时间和它的价值,其中1 ≤ vi wi ≤ 100。
输出描述:
一个整数,表示价值的最大值。
示例 1:
输入:
70 3
71 100
68 1
1 2
输出:
3
#include <bits/stdc++.h>
using namespace std;
int f[1010];
int main(){
int n, t, v, w;
cin >> n >> t;
for (int k = 1; k <= t; k++) {
cin >> v >> w;
for (int i = n; i >= v; i--)
f[i] = max(f[i], f[i - v] + w);
}
cout << f[n];
return 0;
}