c++算法入门:基础篇

第1章 枚举算法

枚举,说白了就是 把所有可能的情况都试一遍。你想啊,如果一个问题可能的答案只有有限的几种,那最直接的办法不就是挨个儿试吗?暴力出奇迹嘛!

1.1 什么时候用枚举?

  • 问题的可能情况是 有限的
  • 数据范围 比较小(一般在几万到几十万以内)
  • 实在想不出更好的算法了(保底方案)

1.2 例题1:求1~100中所有能被3整除的数

这题太简单了,直接遍历1到100,判断每个数能不能被3整除就行。

#include <iostream>
using namespace std;

int main()
{
for (int i = 1; i <= 100; i++)
{
if (i % 3 == 0)
{
cout << i << " ";
}
}
return 0;
}

运行结果会输出:3 6 9 12 … 99。这就是最基础的枚举——穷举所有可能,然后筛选出符合条件的。

1.3 例题2:百钱买百鸡(经典枚举题)

这是中国古代数学家张丘建在《算经》里提出的问题:

公鸡5文钱一只,母鸡3文钱一只,小鸡3只1文钱。用100文钱买100只鸡,问公鸡、母鸡、小鸡各多少只?

我们设公鸡x只,母鸡y只,小鸡z只,那么:

  • x + y + z = 100(总数量)
  • 5x + 3y + z/3 = 100(总价钱,注意z必须是3的倍数)

最暴力的做法:x从0到100枚举,y从0到100枚举,z = 100 - x - y,然后检查价格条件。

#include <iostream>
using namespace std;

int main()
{
for (int x = 0; x <= 100; x++)
{
for (int y = 0; y <= 100; y++)
{
int z = 100 - x - y;
if (z >= 0 && z % 3 == 0 && 5 * x + 3 * y + z / 3 == 100)
{
cout << "公鸡:" << x << " 母鸡:" << y << " 小鸡:" << z << endl;
}
}
}
return 0;
}

优化思路:其实不需要枚举到100。公鸡5文一只,最多20只;母鸡3文一只,最多33只。这样能大大减少枚举次数:

#include <iostream>
using namespace std;

int main()
{
for (int x = 0; x <= 20; x++)
{
for (int y = 0; y <= 33; y++)
{
int z = 100 - x - y;
if (z >= 0 && z % 3 == 0 && 5 * x + 3 * y + z / 3 == 100)
{
cout << "公鸡:" << x << " 母鸡:" << y << " 小鸡:" << z << endl;
}
}
}
return 0;
}

1.4 枚举的要点总结

  1. 确定枚举范围——能缩多小缩多小
  2. 确定枚举条件——要检查哪些约束
  3. 注意边界——小心数组越界、整数溢出

练习题:

第2章 模拟算法

模拟,就是 按照题目描述的过程,一步步用代码实现出来。题目怎么说,你就怎么写,不需要想什么高深的数学公式,老老实实按规则来就行。

很多竞赛题看起来复杂,其实就是让你模拟一个过程——比如模拟排队、模拟游戏规则、模拟物理过程等等。

2.1 例题:开关灯问题

有n盏灯,编号1~n。第一个人把所有灯都打开(操作开关);第二个人按下编号为2的倍数的开关;第三个人按下编号为3的倍数的开关……以此类推,一共k个人。问最后哪些灯是亮着的?

这道题就是典型的模拟——我们用一个数组表示灯的状态,然后模拟每个人的操作。

#include <iostream>
using namespace std;

bool light[1010]; // false表示关,true表示开

int main()
{
int n, k;
cin >> n >> k;

// 模拟每个人
for (int person = 1; person <= k; person++)
{
// 每个人操作自己编号倍数的灯
for (int j = person; j <= n; j += person)
{
light[j] = !light[j]; // 取反操作:关变开,开变关
}
}

// 输出亮着的灯
for (int i = 1; i <= n; i++)
{
if (light[i] == true)
{
cout << i << " ";
}
}
return 0;
}

2.2 例题2:约瑟夫问题

n个人围成一圈,从第一个人开始报数,数到m的人出列,然后从下一个人重新开始报数,直到所有人都出列。求输出出列顺序。

这也是典型的模拟,可以用数组标记每个人是否还在圈里。

#include <iostream>
using namespace std;

bool inCircle[110]; // true表示还在圈里

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

for (int i = 1; i <= n; i++) inCircle[i] = true;

int count = 0; // 报数计数器
int outCount = 0; // 出列人数
int idx = 1; // 当前报数的人

while (outCount < n)
{
if (inCircle[idx] == true)
{
count++;
if (count == m)
{
cout << idx << " ";
inCircle[idx] = false;
outCount++;
count = 0;
}
}
idx++;
if (idx > n) idx = 1;
}
return 0;
}

2.3 模拟的要点总结

  1. 读懂题目规则——把题目里的流程搞清楚
  2. 选择合适的数据结构——数组、标记、队列等
  3. 注意边界条件——比如约瑟夫问题的"围成一圈"要用取模或判断
  4. 仔细调试——模拟题容易写错细节

练习题:

第3章 高精度算法

3.1 为什么需要高精度?

先看几个数:

  • int 最大能存约 21亿(2^31-1 ≈ 2.1×10^9)
  • long long 最大能存约 9×10^18(2^63-1)
  • 但如果要算 100的阶乘 呢?100! ≈ 9.3×10^157,long long 根本装不下!

这时候就需要 高精度算法——用数组来模拟数字的每一位,像小学做竖式计算一样,一位一位地算。

3.2 高精度加法

回忆一下小学的竖式加法:

  1 2 3
+ 4 5 6
--------
= 5 7 9

从个位开始,逐位相加,满十进一。我们用数组来模拟这个过程,为了方便,把数字倒着存(个位在数组下标0的位置),这样进位的时候只需要往后扩展。

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

int a[210], b[210], c[210]; // a + b = c

int main()
{
string s1, s2;
cin >> s1 >> s2;

int len1 = s1.size();
for (int i = 0; i < len1; i++)
a[i] = s1[len1 - 1 - i] - 48;

int len2 = s2.size();
for (int i = 0; i < len2; i++)
b[i] = s2[len2 - 1 - i] - 48;

int len = max(len1, len2);
for (int i = 0; i < len; i++)
{
c[i] += a[i] + b[i];
if (c[i] >= 10)
{
c[i + 1] += c[i] / 10;
c[i] %= 10;
}
}

if (c[len] > 0) len++;

for (int i = len - 1; i >= 0; i--)
cout << c[i];
return 0;
}

核心思想:用数组的每一个元素存一位数字,从低位到高位逐位计算,进位处理是关键!

3.3 高精度减法

减法比加法稍微复杂一点,因为涉及 借位。另外要处理谁减谁——如果被减数小于减数,结果就是负数。

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

int a[210], b[210], c[210];

bool cmp(string s1, string s2)
{
if (s1.size() != s2.size())
return s1.size() > s2.size();
return s1 >= s2;
}

int main()
{
string s1, s2;
cin >> s1 >> s2;

bool negative = false;
if (!cmp(s1, s2))
{
swap(s1, s2);
negative = true;
}

int len1 = s1.size();
for (int i = 0; i < len1; i++)
a[i] = s1[len1 - 1 - i] - 48;

int len2 = s2.size();
for (int i = 0; i < len2; i++)
b[i] = s2[len2 - 1 - i] - 48;

for (int i = 0; i < len1; i++)
{
if (a[i] < b[i])
{
a[i + 1]--;
a[i] += 10;
}
c[i] = a[i] - b[i];
}

int len = len1;
while (len > 1 && c[len - 1] == 0)
len--;

if (negative) cout << "-";
for (int i = len - 1; i >= 0; i--)
cout << c[i];
return 0;
}

3.4 总结

高精度核心就三点:

  1. 用字符串输入,因为数字太大
  2. 倒着存到数组,方便进位/借位
  3. 模拟竖式运算,逐位处理

学会了加法和减法,乘法和除法的思路也是一样的——用数组模拟竖式。

练习题:

第4章 递推

4.1 什么是递推

递推,就是从已知条件出发,利用递推关系式一步步推出未知结果。

递推有三个要素:

  1. 初始条件——已知的起点值
  2. 递推关系——当前项与前面项的关系式
  3. 递推方向——顺推(从前到后)或逆推(从后到前)

4.2 顺推

由初始条件开始,按顺序推导出后续结果,这叫顺推。

最经典的例子:斐波那契数列

斐波那契数列是这样的:1, 1, 2, 3, 5, 8, 13, 21…

  • 第1项:F(1) = 1
  • 第2项:F(2) = 1
  • 从第3项开始:F(n) = F(n-1) + F(n-2)(前两项之和)
#include <iostream>
using namespace std;

int a[110];

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

a[1] = 1; // 初始条件
a[2] = 1;

for (int i = 3; i <= n; i++)
{
a[i] = a[i - 1] + a[i - 2]; // 递推关系
}

cout << a[n];

return 0;
}

另一个例子:上楼梯问题

有n级台阶,一次可以上1级或2级,问上到第n级有多少种不同的走法?

思考:要上到第n级,可以从第(n-1)级跨1步上来,也可以从第(n-2)级跨2步上来。
所以:F(n) = F(n-1) + F(n-2),初始条件F(1)=1, F(2)=2。

#include <iostream>
using namespace std;

int f[50];

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

f[1] = 1; // 1级台阶:1种走法(跨1步)
f[2] = 2; // 2级台阶:2种走法(1+1 或 2)

for (int i = 3; i <= n; i++)
{
f[i] = f[i - 1] + f[i - 2];
}

cout << f[n] << endl;

return 0;
}

4.3 逆推

从结果反推回初始条件,叫逆推。

例子:猴子吃桃问题

猴子第一天摘了若干桃子,当天吃了一半加一个;第二天又吃了剩下的一半加一个;以后每天如此。到第n天早上想再吃时,只剩下1个桃子了。问第一天摘了多少个桃子?

逆推思路:第n天剩1个,说明前一天剩下的数量 = (第n天的数量 + 1) × 2。

#include <iostream>
using namespace std;

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

int peach = 1; // 第n天早上只剩下1个

for (int i = n - 1; i >= 1; i--)
{
peach = (peach + 1) * 2; // 逆推:前一天 = (当天 + 1) × 2
}

cout << peach << endl;

return 0;
}

4.4 递推的要点

  1. 找递推关系是最关键的一步——多举几个例子找规律
  2. 确定初始条件——边界值要算对
  3. 注意数据范围——斐波那契数列增长很快,int可能不够用

练习题:

第5章 递归

5.1 什么是递归

递归,就是函数自己调用自己。你可以想象成"俄罗斯套娃"——打开一个,里面还有一个。

递归由两部分组成:

  1. 递进——函数不断调用自己,深入下去
  2. 回归——到达终止条件后,一层层返回结果

5.2 递进

函数自己调用自己,如果没有终止条件就会无限循环下去(栈溢出)。

#include <iostream>
using namespace std;

void mf()
{
cout << "进入下一层" << endl;
mf(); // 自己调用自己
}

int main()
{
mf();
return 0;
}

运行这个程序会发现它在输出很多行后崩溃退出——因为栈空间是有限的,无限递归会把栈撑爆。

5.3 回归

加一个终止条件,让递归在合适的时候"回头":

#include <iostream>
using namespace std;

void mf(int x)
{
cout << x << ": 进入下一层" << endl;
x++;
if (x < 5) // 加条件限制
{
mf(x); // 递进
}
return; // 回归
}

int main()
{
mf(0);
return 0;
}

5.4 递进 + 回归 = 递归

下面的例子展示了递进时输出和回归时输出的区别:

#include <iostream>
using namespace std;

void fun(int x)
{
cout << x << " "; // 递进时输出
x++;
if (x <= 10)
{
fun(x);
}
return;
}

void fun2(int n)
{
if (n < 10)
{
fun2(n + 1);
}
int a = 11 - n;
cout << a << " "; // 回归时输出
return;
}

int main()
{
fun(1); // 输出:1 2 3 4 5 6 7 8 9 10
cout << endl;
fun2(1); // 输出:10 9 8 7 6 5 4 3 2 1
return 0;
}

fun先输出再递归,所以是从小到大输出;fun2先递归再输出,所以是从大到小输出。

5.5 递归的经典应用:斐波那契数列

斐波那契数列用递归来实现非常直观:

#include <iostream>
using namespace std;

int fib(int n)
{
if (n == 1 || n == 2)
return 1; // 终止条件
return fib(n - 1) + fib(n - 2); // 递归调用
}

int main()
{
int n;
cin >> n;
cout << fib(n) << endl;
return 0;
}

注意:这个递归实现虽然好理解,但效率很低——因为有很多重复计算。比如算fib(5)要算fib(4)和fib(3),算fib(4)又要算fib(3)和fib(2),fib(3)被重复算了两次。n越大,重复越严重。这就是为什么实际应用中常用递推(循环)代替递归。

5.6 递归 vs 递推

对比项 递归 递推
实现方式 函数调用自身 循环迭代
代码可读性 简洁、直观 稍复杂
效率 较低(有函数调用开销) 高
可能的问题 栈溢出 无
适用场景 树形结构、分治 线性递推

5.7 递归的要点

  1. 必须有终止条件——否则会无限递归
  2. 每次递归都要向终止条件靠近——参数要变化
  3. 递归能解决的问题通常有重复子结构——一个大问题可以拆成小问题

练习题:

第6章 贪心

6.1 贪心思想

贪心,顾名思义就是 只顾眼前——在每一步都做出当前看起来最优的选择,期望最终得到全局最优解。

用大白话说就是:“先挑最好的,剩下的再说”。

但要注意:贪心并不是万能的!只有满足 最优子结构 和 贪心选择性质 的问题才能用贪心。

6.2 例题1:找零问题

有面值100元、50元、20元、10元、5元、1元的人民币,要凑出n元钱,问最少需要多少张纸币?

这个问题的贪心策略很简单:能用大面额就用大面额。比如要凑378元,先拿3张100(剩78),再拿1张50(剩28),再拿1张20(剩8),再拿1张5(剩3),最后拿3张1元。一共3+1+1+1+3=9张。

为什么这样是最优的?因为人民币的面额设计使得大面额是小面额的整数倍,所以用大面额永远不会亏。

#include <iostream>
using namespace std;

int money[] = {100, 50, 20, 10, 5, 1}; // 面额从大到小

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

int count = 0;
for (int i = 0; i < 6; i++)
{
count += n / money[i];
n %= money[i];
}

cout << count << endl;
return 0;
}

6.3 例题2:活动安排(区间调度)

有n个活动,每个活动有开始时间s[i]和结束时间e[i]。一个人同时只能参加一个活动,问最多能参加多少个活动?

贪心策略:按结束时间最早排序,每次选结束时间最早且不与之前冲突的活动。

为什么?因为结束时间越早,留给后面的时间就越多。这就是典型的"局部最优→全局最优"。

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

struct Activity
{
int start, end;
};

bool cmp(Activity a, Activity b)
{
return a.end < b.end;
}

Activity act[1010];

int main()
{
int n;
cin >> n;
for (int i = 1; i <= n; i++)
cin >> act[i].start >> act[i].end;

sort(act + 1, act + n + 1, cmp);

int ans = 0;
int lastEnd = 0;
for (int i = 1; i <= n; i++)
{
if (act[i].start >= lastEnd)
{
ans++;
lastEnd = act[i].end;
}
}

cout << ans << endl;
return 0;
}

6.4 注意事项:贪心不一定正确!

贪心虽然好用,但 不是所有问题都能用贪心。比如下面这个例子:

背包问题:有一个容量为10的背包,有三样东西——A:重量6价值12,B:重量5价值10,C:重量5价值10。要装价值最大?

如果按"性价比"贪心,A的性价比是12/6=2,B和C都是2,都一样。但如果按"重量从小到大"贪心,选B+C=价值20,而选A+C=价值22(但超重了)。实际上选B+C=20是最优解,但如果物品换成A:重量6价值9,B:重量5价值10,C:重量5价值10,贪心就会选错。

所以 用贪心之前一定要确认问题是否满足贪心性质。

6.5 贪心要点

  1. 确定贪心策略——每一步选什么?
  2. 证明贪心正确——为什么局部最优能推出全局最优?
  3. 如果贪心不对,试试动态规划(以后会学)

练习题:

第7章 二分

7.1 二分思想

二分,就是 每次把搜索范围缩小一半。你想啊,如果有一本1000页的字典,要找第500页的单词,你不会从第1页开始翻——你会直接翻到中间,然后判断是往前找还是往后找。这就是二分的思想。

二分有两个主要应用:

  1. 二分查找——在有序数组中找某个数
  2. 二分答案——在范围内猜答案,验证对不对

7.2 二分查找(有序数组中找某个数)

给定一个有序数组(从小到大),找一个数x的位置。最笨的方法是遍历——O(n)。但用二分只需要O(log n)!100万的数组,遍历要100万次,二分只要20次。

#include <iostream>
using namespace std;

int a[100010];

int main()
{
int n, x;
cin >> n >> x;
for (int i = 1; i <= n; i++)
cin >> a[i];

int left = 1, right = n;
int ans = -1;

while (left <= right)
{
int mid = (left + right) / 2;
if (a[mid] == x)
{
ans = mid;
break;
}
else if (a[mid] < x)
left = mid + 1;
else
right = mid - 1;
}

cout << ans << endl;
return 0;
}

重要细节:

  • mid = (left + right) / 2 可能会溢出,可以写成 mid = left + (right - left) / 2
  • 循环条件是 left <= right 还是 left < right,看具体需求
  • 二分必须在 有序 的数据上进行

7.3 二分答案(最大值最小化/最小值最大化)

这是二分的高阶用法。有些问题我们不知道答案是多少,但知道答案的范围,而且可以快速验证某个值是否可行。这时候就可以 在答案范围内二分,每次验证中间值是否可行。

比如说:切木头问题——有n根木头,要切成k根长度相等的小段,问每段最长能有多长?

我们不知道答案是多少,但答案肯定在(0, 最长木头]之间。我们可以二分这个长度,每次检查能不能切出k根。

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

int wood[100010];
int n, k;

bool check(int len)
{
long long count = 0;
for (int i = 1; i <= n; i++)
count += wood[i] / len;
return count >= k;
}

int main()
{
cin >> n >> k;
int maxLen = 0;
for (int i = 1; i <= n; i++)
{
cin >> wood[i];
maxLen = max(maxLen, wood[i]);
}

int left = 1, right = maxLen;
int ans = 0;

while (left <= right)
{
int mid = (left + right) / 2;
if (check(mid))
{
ans = mid;
left = mid + 1;
}
else
{
right = mid - 1;
}
}

cout << ans << endl;
return 0;
}

二分答案的套路:

  1. 确定答案的范围
  2. 写一个check函数验证某个值是否可行
  3. 在范围内二分,根据check结果缩小范围
  4. 最终得到最优解

7.4 二分要点

  1. 有序是二分的前提(查找时必须有序)
  2. 注意边界条件——left、right、mid的取值
  3. 二分答案的关键是check函数——验证速度要快
  4. 模板要熟练——建议背下来

练习题:

第8章 前缀和、差分

8.1 一维前缀和

前缀和是一个 预处理 技巧,用来 快速求区间和。

假设有一个数组a[1…n],我们想求区间[l, r]的和,最笨的方法是把l到r遍历一遍加起来——O(n)。但如果有m次询问,每次都是O(n),总复杂度就O(mn),太慢了。

前缀和的思路:先算出一个前缀和数组s,其中 s[i] = a[1] + a[2] + … + a[i](前i项的和)。那么区间[l, r]的和 = s[r] - s[l-1]。

看个例子:

a:   1  3  2  5  4
s: 1 4 6 11 15

问 a[2]~a[4]的和 = s[4] - s[1] = 11 - 1 = 10
(3+2+5=10)
#include <iostream>
using namespace std;

int a[100010], s[100010];

int main()
{
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)
cin >> a[i];

for (int i = 1; i <= n; i++)
s[i] = s[i - 1] + a[i];

for (int i = 1; i <= m; i++)
{
int l, r;
cin >> l >> r;
cout << s[r] - s[l - 1] << endl;
}
return 0;
}

威力:预处理一次O(n),之后每次查询O(1)。如果n=10万,m=10万,暴力需要10^10次运算,前缀和只需要10^5+10^5=2×10^5次。

8.2 一维差分

差分是前缀和的 逆运算,用来 快速区间修改。

什么叫区间修改?就是给数组的某个区间 所有元素加上同一个数。

最笨的方法:遍历区间,每个元素都加——O(n)。有m次修改的话就是O(mn)。

差分数组的思想:我们维护一个差分数组d,使得 d[i] = a[i] - a[i-1]。对a的区间[l, r]加x,等价于 d[l] += x, d[r+1] -= x。

最后,对差分数组求前缀和,就能还原出修改后的a。

8.3 差分代码示例

#include <iostream>
using namespace std;

int a[100010], d[100010];

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

// 读入原数组
for (int i = 1; i <= n; i++)
cin >> a[i];

// 构建差分数组 d[i] = a[i] - a[i-1]
for (int i = 1; i <= n; i++)
d[i] = a[i] - a[i - 1];

// m次区间修改
for (int i = 1; i <= m; i++)
{
int l, r, x;
cin >> l >> r >> x;
d[l] += x;
d[r + 1] -= x;
}

// 对差分数组求前缀和,还原出修改后的a
for (int i = 1; i <= n; i++)
{
a[i] = a[i - 1] + d[i];
cout << a[i] << " ";
}

return 0;
}

理解差分:差分和前缀和是一对"逆运算"。你可以把差分想象成一个"修改记录",最后通过一次前缀和把所有修改效果"累加"到原数组上。

8.4 前缀和与差分的对比

操作 暴力做法 优化做法 单次复杂度
区间求和 遍历区间 前缀和 O(n)预处理,O(1)查询
区间加数 遍历区间 差分 O(1)修改,O(n)还原

练习题:

第9章 深度优先搜索(DFS)

9.1 什么是DFS

深度优先搜索,英文叫 Depth First Search,简称DFS。它的思想是:一条路走到黑,走不通了再回头。

想象你在走迷宫:每次遇到岔路,随便选一条往前走,如果发现走不通了,就退回到上一个岔路口,换一条路继续走。这就是DFS。

9.2 DFS的实现

DFS通常用递归来实现:

void dfs(当前状态)
{
if (到达终点)
{
处理结果;
return;
}

for (所有可能的下一步)
{
if (下一步合法)
{
标记已访问;
dfs(下一步);
取消标记;
}
}
}

9.3 例题2:八皇后问题

在8×8的棋盘上放8个皇后,使它们互不攻击(任何两个不在同一行、同一列、同一对角线)。

#include <iostream>
using namespace std;

int col[10];
bool rowUsed[10];
bool diag1[20];
bool diag2[20];
int ans = 0;

void dfs(int r)
{
if (r > 8)
{
ans++;
return;
}

for (int c = 1; c <= 8; c++)
{
if (!rowUsed[c] && !diag1[r - c + 8] && !diag2[r + c])
{
col[r] = c;
rowUsed[c] = diag1[r - c + 8] = diag2[r + c] = true;
dfs(r + 1);
rowUsed[c] = diag1[r - c + 8] = diag2[r + c] = false;
}
}
}

int main()
{
dfs(1);
cout << ans << endl;
return 0;
}

9.4 DFS的要点

  1. 回溯是DFS的精髓——递归前标记,递归后取消标记
  2. 剪枝可以大幅提高效率——提前判断不可行的情况,跳过
  3. DFS适合解决"是否存在"和"所有方案"的问题

练习题:

第10章 广度优先搜索(BFS)

10.1 什么是BFS

广度优先搜索,英文叫 Breadth First Search,简称BFS。它的思想是:层层推进,逐层扩展。

还是走迷宫的比喻——DFS是一条路走到黑,而BFS是"广撒网":先看离起点1步能到哪些位置,再看2步能到哪些位置,一层层往外扩散。

BFS最重要的特性:第一次到达某个状态时,走的步数就是最短步数。

10.2 BFS的实现

BFS通常用队列来实现:

#include <queue>
queue<状态类型> q;

void bfs(起点)
{
q.push(起点);
visited[起点] = true;

while (!q.empty())
{
当前状态 = q.front();
q.pop();

for (所有可能的下一步)
{
if (下一步合法且未访问)
{
标记已访问;
记录步数(步数 = 当前步数 + 1);
q.push(下一步);
}
}
}
}

10.3 例题1:走迷宫

给定一个n×m的迷宫,'S’表示起点,‘T’表示终点,’#‘表示墙,’.'表示路。求从起点到终点的最短步数。

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

char maze[110][110];
bool visited[110][110];
int step[110][110];

int dx[] = {-1, 1, 0, 0};
int dy[] = {0, 0, -1, 1};

struct Point
{
int x, y;
};

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

int sx, sy, tx, ty;
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= m; j++)
{
cin >> maze[i][j];
if (maze[i][j] == 'S') { sx = i; sy = j; }
if (maze[i][j] == 'T') { tx = i; ty = j; }
}
}

queue<Point> q;
q.push({sx, sy});
visited[sx][sy] = true;
step[sx][sy] = 0;

while (!q.empty())
{
Point cur = q.front();
q.pop();

if (cur.x == tx && cur.y == ty)
{
cout << step[cur.x][cur.y] << endl;
return 0;
}

for (int i = 0; i < 4; i++)
{
int nx = cur.x + dx[i];
int ny = cur.y + dy[i];

if (nx >= 1 && nx <= n && ny >= 1 && ny <= m
&& maze[nx][ny] != '#' && !visited[nx][ny])
{
visited[nx][ny] = true;
step[nx][ny] = step[cur.x][cur.y] + 1;
q.push({nx, ny});
}
}
}

cout << -1 << endl;
return 0;
}

10.4 BFS的要点

  1. 队列是BFS的核心数据结构
  2. 第一次到达就是最短路径——因为BFS是逐层扩展的
  3. BFS适合解决"最短路径"和"最少步数"问题

10.5 DFS vs BFS

对比项 DFS(深度优先) BFS(广度优先)
实现方式 递归(栈) 队列
空间复杂度 与深度有关 与宽度有关
能否找最短路径 不能(需全部搜索) 能(第一次到达就是最短)
适用场景 所有方案、存在性 最短路径、最少步数

练习题:

数据结构部分

第11章 栈

11.1 什么是栈

栈(Stack)是一种 后进先出(LIFO, Last In First Out) 的数据结构。就像一摞盘子——你洗好的盘子会放在最上面,要拿的时候也是先从最上面拿。后放进去的先拿出来。

栈的基本操作:

  • push(x):把x压入栈顶
  • pop():弹出栈顶元素
  • top():查看栈顶元素
  • empty():判断栈是否为空

11.2 C++ STL中的栈

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

int main()
{
stack<int> s;

s.push(10);
s.push(20);
s.push(30);

cout << s.top() << endl;
s.pop();
cout << s.top() << endl;
cout << s.size() << endl;
cout << s.empty() << endl;

return 0;
}

11.3 例题:括号匹配

给定一个只包含 () [] {} 的字符串,判断括号是否匹配。

思路:遇到左括号就入栈,遇到右括号就检查栈顶是否是对应的左括号。

#include <iostream>
#include <stack>
#include <string>
using namespace std;

bool match(char left, char right)
{
return (left == '(' && right == ')') ||
(left == '[' && right == ']') ||
(left == '{' && right == '}');
}

int main()
{
string s;
cin >> s;

stack<char> st;
bool ok = true;

for (char c : s)
{
if (c == '(' || c == '[' || c == '{')
{
st.push(c);
}
else
{
if (st.empty() || !match(st.top(), c))
{
ok = false;
break;
}
st.pop();
}
}

if (ok && st.empty())
cout << "匹配" << endl;
else
cout << "不匹配" << endl;

return 0;
}

练习题:

第12章 队列

12.1 什么是队列

队列(Queue)是一种 先进先出(FIFO, First In First Out) 的数据结构。就像排队买奶茶——先排队的人先买到,后排队的人后买到。先来的先服务。

队列的基本操作:

  • push(x):把x加入队尾
  • pop():弹出队首元素
  • front():查看队首元素
  • back():查看队尾元素
  • empty():判断队列是否为空

12.2 C++ STL中的队列

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

int main()
{
queue<int> q;

q.push(10);
q.push(20);
q.push(30);

cout << q.front() << endl;
q.pop();
cout << q.front() << endl;
cout << q.size() << endl;
cout << q.empty() << endl;

return 0;
}

12.3 例题:约瑟夫问题(用队列实现)

约瑟夫问题我们在模拟算法那章用数组模拟过了,这次用队列来实现更直观:

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

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

queue<int> q;
for (int i = 1; i <= n; i++)
q.push(i);

int count = 0;
while (!q.empty())
{
count++;
int cur = q.front();
q.pop();

if (count == m)
{
cout << cur << " ";
count = 0;
}
else
{
q.push(cur);
}
}

return 0;
}

练习题:

第13章 树

13.1 什么是树

树是一种非线性的数据结构,由节点(node)和节点之间的边(edge)组成。

树的常见术语:

  • 根节点:树的最顶层节点
  • 父节点/子节点:上下层关系
  • 叶子节点:没有子节点的节点
  • 深度:从根到某个节点的层数
  • 子树:一个节点及其所有后代

13.2 二叉树

二叉树是最常用的树结构,每个节点最多有2个子节点(左孩子和右孩子)。

struct TreeNode
{
int val;
TreeNode* left;
TreeNode* right;
};

13.3 树的存储(数组方式)

竞赛中最常用的存树方式是数组模拟:

int parent[1010];
int leftChild[1010], rightChild[1010];
#include <vector>
vector<int> children[1010];

练习题:

第14章 图

14.1 什么是图

图(Graph)由顶点(vertex)和边(edge)组成。相比树,图更自由——可以有环、可以没有根。

图的分类:

  • 有向图 vs 无向图:边是否有方向
  • 有权图 vs 无权图:边是否有权重

14.2 图的存储

1. 邻接矩阵

int graph[1010][1010];

void addEdge(int u, int v)
{
graph[u][v] = 1;
graph[v][u] = 1;
}

优点:直接判断两点间是否有边,O(1)
缺点:空间O(n²),n=10万时存不下

2. 邻接表

#include <vector>
vector<int> graph[1010];

void addEdge(int u, int v)
{
graph[u].push_back(v);
graph[v].push_back(u);
}

优点:空间O(n+m),m是边数
缺点:判断两点是否相连需要遍历列表

14.3 练习题

算法入门部分

第15章 动态规划

15.1 什么是动态规划

动态规划(Dynamic Programming,简称DP)是一种把大问题拆成小问题,先解决小问题,再用小问题的答案拼出大问题答案的方法。

DP的核心思想:记住已经算过的结果,避免重复计算。

15.2 DP三要素

  1. 状态定义——dp[i]表示什么
  2. 状态转移方程——dp[i]和前面的dp值有什么关系
  3. 初始条件——边界值

15.3 例题1:斐波那契数列(DP视角)

int dp[50];
dp[1] = dp[2] = 1;
for (int i = 3; i <= n; i++)
dp[i] = dp[i-1] + dp[i-2];

15.4 例题2:最大子段和

给定一个数组a[1…n],求连续子数组的最大和。

比如:[-2, 1, -3, 4, -1, 2, 1, -5, 4],最大子段和是 [4, -1, 2, 1] = 6。

状态定义:dp[i] = 以a[i]结尾的最大子段和。
转移方程:dp[i] = max(a[i], dp[i-1] + a[i])

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

int a[200010], dp[200010];

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

dp[1] = a[1];
int ans = dp[1];

for (int i = 2; i <= n; i++)
{
dp[i] = max(a[i], dp[i - 1] + a[i]);
ans = max(ans, dp[i]);
}

cout << ans << endl;
return 0;
}

15.5 例题3:01背包问题

有n件物品,每件有重量w[i]和价值v[i]。有一个容量为C的背包,问最多能带走多少价值的物品?

状态定义:dp[i][j] = 前i件物品中选,总重量不超过j的最大价值。

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

int w[1010], v[1010];
int dp[1010][1010];

int main()
{
int n, C;
cin >> n >> C;
for (int i = 1; i <= n; i++)
cin >> w[i] >> v[i];

for (int i = 1; i <= n; i++)
{
for (int j = 0; j <= C; j++)
{
dp[i][j] = dp[i - 1][j];
if (j >= w[i])
dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + v[i]);
}
}

cout << dp[n][C] << endl;
return 0;
}

空间优化(一维数组):

int dp[1010];
for (int i = 1; i <= n; i++)
for (int j = C; j >= w[i]; j--)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

15.6 DP的要点

  1. 状态定义要准确
  2. 转移方程是核心
  3. 初始化很重要
  4. DP需要满足"最优子结构"和"无后效性"

练习题:

第16章 树的遍历

16.1 二叉树的三种遍历方式

1. 前序遍历

顺序:根节点 → 左子树 → 右子树

void preorder(TreeNode* root)
{
if (root == NULL) return;
cout << root->val << " ";
preorder(root->left);
preorder(root->right);
}

2. 中序遍历

顺序:左子树 → 根节点 → 右子树

void inorder(TreeNode* root)
{
if (root == NULL) return;
inorder(root->left);
cout << root->val << " ";
inorder(root->right);
}

3. 后序遍历

顺序:左子树 → 右子树 → 根节点

void postorder(TreeNode* root)
{
if (root == NULL) return;
postorder(root->left);
postorder(root->right);
cout << root->val << " ";
}

16.2 完整示例

#include <iostream>
using namespace std;

struct TreeNode
{
char val;
TreeNode* left;
TreeNode* right;
TreeNode(char v) : val(v), left(NULL), right(NULL) {}
};

void preorder(TreeNode* r)
{
if (!r) return;
cout << r->val;
preorder(r->left);
preorder(r->right);
}

void inorder(TreeNode* r)
{
if (!r) return;
inorder(r->left);
cout << r->val;
inorder(r->right);
}

void postorder(TreeNode* r)
{
if (!r) return;
postorder(r->left);
postorder(r->right);
cout << r->val;
}

int main()
{
TreeNode* root = new TreeNode('A');
root->left = new TreeNode('B');
root->right = new TreeNode('C');
root->left->left = new TreeNode('D');
root->left->right = new TreeNode('E');

cout << "前序: "; preorder(root); cout << endl;
cout << "中序: "; inorder(root); cout << endl;
cout << "后序: "; postorder(root); cout << endl;

return 0;
}

练习题:

第17章 图的算法

17.1 图的遍历

DFS遍历图

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

vector<int> graph[1010];
bool visited[1010];

void dfs(int u)
{
visited[u] = true;
cout << u << " ";
for (int v : graph[u])
{
if (!visited[v])
dfs(v);
}
}

BFS遍历图

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

vector<int> graph[1010];
bool visited[1010];

void bfs(int start)
{
queue<int> q;
q.push(start);
visited[start] = true;

while (!q.empty())
{
int u = q.front();
q.pop();
cout << u << " ";

for (int v : graph[u])
{
if (!visited[v])
{
visited[v] = true;
q.push(v);
}
}
}
}

17.2 最短路径:Floyd算法

求任意两点之间的最短路径。核心思想:尝试让每个顶点作为"中转点"。

int dist[510][510];

void floyd(int n)
{
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (dist[i][k] + dist[k][j] < dist[i][j])
dist[i][j] = dist[i][k] + dist[k][j];
}

注意:Floyd的复杂度是O(n³),只适合n ≤ 500的情况。

17.3 最小生成树:Prim算法

最小生成树:在一个无向连通图中,选n-1条边,把所有顶点连起来,且总权重最小。

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

int graph[1010][1010];
int dist[1010];
bool selected[1010];

int prim(int n)
{
memset(dist, 0x3f, sizeof(dist));
dist[1] = 0;
int ans = 0;

for (int i = 1; i <= n; i++)
{
int u = -1;
for (int j = 1; j <= n; j++)
{
if (!selected[j] && (u == -1 || dist[j] < dist[u]))
u = j;
}

if (dist[u] == 0x3f3f3f3f) break;
selected[u] = true;
ans += dist[u];

for (int v = 1; v <= n; v++)
{
if (!selected[v] && graph[u][v] < dist[v])
dist[v] = graph[u][v];
}
}

return ans;
}

17.4 欧拉路径 & 哈密尔顿路径

欧拉路径:一笔画问题——从某点出发,经过每条边恰好一次,最后到达另一点(或回到起点)。

哈密尔顿路径:经过每个顶点恰好一次的路径。

欧拉路径有简洁的判定条件(度数为奇数的顶点个数为0或2),而哈密尔顿路径至今没有高效的判定算法(NP问题)。

练习题: