最新文章

20260228 145210 Cpp 入门第二十二课

第二十二章:树——层次结构的数据

你好!欢迎来到第二十二章!在前面的章节,我们学习了线性结构(数组、链表)和图。这一章我们将学习一种非常重要的非线性结构——。树就像你家里的家谱,或者电脑里的文件夹,一层一层地组织数据。树在计算机科学中无处不在:文件系统、HTML文档、数据库索引、人工智能决策树……这一章我们就来揭开树的神秘面纱!


22.1 什么是树?

22.1.1 树的定义

是由节点和连接节点的组成的一种数据结构,它具有以下特点:
- 有一个特殊的节点叫做根节点
- 除了根节点外,每个节点有且只有一个父节点。
- 没有父节点的节点是根节点。
- 没有子节点的节点称为叶子节点
- 树中没有环(即不会出现 A→B→C→A 的情况)。

直观地说,树就像一棵倒长的树——根在上,叶子在下。

22.1.2 基本术语

  • :最顶层的节点,整棵树的起点。
  • 父节点:某个节点的直接上层节点。
  • 子节点:某个节点的直接下层节点。
  • 兄弟节点:具有相同父节点的节点。
  • 祖先:从根到该节点路径上的所有节点。
  • 子孙:该节点以下的所有节点。
  • 深度:从根到该节点的层数(根深度为0或1)。
  • 高度:从该节点到最远叶子的层数。
  • 子树:树中任何一个节点和它的所有后代构成的集合也是一棵树。

22.2 树的存储

在程序中,我们需要用某种方式把树的结构存下来。常见的存储方法有:

22.2.1 父亲表示法

用一个数组 parent[i] 表示节点 i 的父节点。根节点的父节点可以设为 -1 或 0。这种方法找父亲很快,但找孩子需要遍历。

int parent[100];
// 初始化
parent[0] = -1;  // 节点0是根
parent[1] = 0;   // 节点1的父节点是0
parent[2] = 0;
parent[3] = 1;

22.2.2 孩子表示法

每个节点用一个列表(vector)存储它的所有孩子。这是最常用的方法。

#include <vector>
using namespace std;

vector<int> children[100];
// 添加一条父子关系
children[0].push_back(1);
children[0].push_back(2);
children[1].push_back(3);

22.2.3 左孩子右兄弟表示法

将一棵普通树转化为二叉树,每个节点存两个指针:左孩子(第一个孩子)和右兄弟(下一个兄弟)。这种方法常用于将树转化为二叉树处理。


22.3 二叉树

22.3.1 什么是二叉树?

二叉树是每个节点最多有两个子节点的树,分别称为左孩子右孩子。二叉树是最简单、最常用的树结构。

特殊形态
- 满二叉树:所有层都是满的(每个节点都有两个子节点)。
- 完全二叉树:除了最后一层,其他层都是满的,且最后一层的节点都靠左排列。堆就是用完全二叉树实现的。

22.3.2 二叉树的存储

链式存储(最常用)
struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
顺序存储(用数组,适合完全二叉树)

对于完全二叉树,可以用数组下标关系:
- 根节点下标为 1(或0)
- 节点 i 的左孩子下标为 2i
- 节点 i 的右孩子下标为 2
i + 1
- 节点 i 的父节点下标为 i/2

这样存省空间,访问方便。

22.3.3 二叉树的遍历

遍历就是按某种顺序访问树中的所有节点。主要有四种遍历方式:

前序遍历(根左右)

先访问根节点,再遍历左子树,最后遍历右子树。

void preorder(TreeNode* root) {
    if (root == NULL) return;
    cout << root->val << " ";
    preorder(root->left);
    preorder(root->right);
}
中序遍历(左根右)

先遍历左子树,再访问根节点,最后遍历右子树。对于二叉搜索树,中序遍历得到升序序列。

void inorder(TreeNode* root) {
    if (root == NULL) return;
    inorder(root->left);
    cout << root->val << " ";
    inorder(root->right);
}
后序遍历(左右根)

先遍历左子树,再遍历右子树,最后访问根节点。

void postorder(TreeNode* root) {
    if (root == NULL) return;
    postorder(root->left);
    postorder(root->right);
    cout << root->val << " ";
}
层序遍历(广度优先)

从上到下,从左到右一层一层地遍历。用队列实现。

#include <queue>
void levelOrder(TreeNode* root) {
    if (root == NULL) return;
    queue<TreeNode*> q;
    q.push(root);
    while (!q.empty()) {
        TreeNode* cur = q.front(); q.pop();
        cout << cur->val << " ";
        if (cur->left) q.push(cur->left);
        if (cur->right) q.push(cur->right);
    }
}

22.4 编程实例讲解

实例1:构建二叉树并遍历

题目:输入一棵二叉树的先序遍历(用 -1 表示空节点),构建二叉树,然后输出它的中序遍历。

#include <iostream>
using namespace std;

struct TreeNode {
    int val;
    TreeNode *left, *right;
    TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};

// 根据先序输入构建二叉树(-1表示空)
TreeNode* build() {
    int x;
    cin >> x;
    if (x == -1) return NULL;
    TreeNode* node = new TreeNode(x);
    node->left = build();
    node->right = build();
    return node;
}

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

int main() {
    cout << "请输入先序遍历序列(-1表示空节点):" << endl;
    TreeNode* root = build();
    cout << "中序遍历结果:";
    inorder(root);
    cout << endl;
    return 0;
}

输入示例1 2 -1 -1 3 4 -1 -1 5 -1 -1 对应一棵树:

    1
   / \
  2   3
     / \
    4   5

输出2 1 4 3 5

实例2:求二叉树的高度

高度定义为从根到最远叶子的节点数(通常根高度为1)。

int height(TreeNode* root) {
    if (root == NULL) return 0;
    int leftH = height(root->left);
    int rightH = height(root->right);
    return max(leftH, rightH) + 1;
}

实例3:求二叉树的叶子节点数

int leafCount(TreeNode* root) {
    if (root == NULL) return 0;
    if (root->left == NULL && root->right == NULL) return 1;
    return leafCount(root->left) + leafCount(root->right);
}

实例4:二叉搜索树(BST)

二叉搜索树是一棵二叉树,满足:对于任意节点,左子树所有节点的值小于它,右子树所有节点的值大于它。中序遍历得到升序序列。

插入操作

TreeNode* insert(TreeNode* root, int val) {
    if (root == NULL) return new TreeNode(val);
    if (val < root->val) root->left = insert(root->left, val);
    else if (val > root->val) root->right = insert(root->right, val);
    return root;
}

查找操作

bool search(TreeNode* root, int val) {
    if (root == NULL) return false;
    if (root->val == val) return true;
    if (val < root->val) return search(root->left, val);
    else return search(root->right, val);
}

实例5:堆(优先队列)

堆是一种完全二叉树,常用于实现优先队列。大根堆:每个节点的值大于等于其子节点的值。

这里不展开全部代码,但可以简单展示用数组实现的堆。


22.5 阶段性编程练习

  1. 练习1:手动构建一棵二叉树(如 1 左子2 右子3,2 左子4 右子5),写出它的前序、中序、后序遍历结果。
  2. 练习2:用代码实现二叉树的层序遍历(用队列)。
  3. 练习3:给定一棵二叉树,判断它是否对称(左右镜像)。
  4. 练习4:实现二叉搜索树的删除操作(选做,可以研究一下)。
  5. 练习5:用数组实现一个小根堆,支持插入和弹出最小值。

22.6 第22章编程作业

作业1:二叉树的最大宽度

给定一棵二叉树,求它的最大宽度。宽度定义为某一层最左非空节点到最右非空节点之间的节点数(包括空节点)。例如:

       1
      / \
     2   3
    /     \
   4       5

第3层宽度为 4(因为位置:4,空,空,5)。提示:可以用层序遍历,给每个节点编号。

作业2:重建二叉树

给定一棵二叉树的前序遍历和中序遍历序列,重建这棵树。例如前序 [1,2,4,5,3,6,7],中序 [4,2,5,1,6,3,7],重建并输出后序遍历。

作业3:二叉树的最近公共祖先

给定一棵二叉树和两个节点,求它们的最近公共祖先(LCA)。

作业4:判断一棵树是否是二叉搜索树

给定一棵二叉树,判断它是否是一棵有效的二叉搜索树(注意,需要保证整个子树满足大小关系,不能只判断当前节点)。

作业5:堆排序

用堆实现排序:先构建一个堆,然后不断弹出堆顶元素,得到排序结果。


恭喜你完成了第二十二章的学习!树是计算机科学中极其重要的数据结构,尤其是二叉树及其变种。掌握树的遍历和基本操作,将为后续学习平衡树、红黑树、B树等打下坚实基础。加油!🚀

20260228 145107 Cpp 入门第二十一课

第二十一章:图论基础——探索网络的世界

你好!欢迎来到第二十一章!你有没有坐过地铁?地铁线路图上有许多站点(顶点),站点之间有轨道连接(),这就构成了一个。图论就是研究这种由点和线组成的结构的数学分支,它在计算机科学中有着广泛的应用——从社交网络、导航系统到互联网路由。这一章我们就来学习图的基本概念和常用算法!


21.1 图的概念

21.1.1 什么是图?

顶点(Vertex)和(Edge)组成。顶点可以理解为一个个“点”,边是连接两个顶点的“线”。比如:

  • 地铁站是顶点,轨道是边。
  • 微信好友:每个人是顶点,好友关系是边。
  • 城市之间的公路:城市是顶点,公路是边。

21.1.2 图的分类

  1. 无向图:边没有方向,就像双行道。比如好友关系,A认识B,B也认识A。
  2. 有向图:边有方向,就像单行道。比如微博关注,A关注B,但B不一定关注A。
  3. 带权图:边上有数值,称为权值,表示距离、时间、花费等。比如城市之间的公路长度。

21.1.3 基本术语

  • :与一个顶点相连的边的数量。有向图中还分为入度(指向该顶点的边数)和出度(从该顶点出发的边数)。
  • 路径:从一个顶点到另一个顶点经过的顶点序列。
  • 连通图:任意两个顶点之间都有路径相连。
  • :一种特殊的图,没有环,且连通。

21.2 图的存储

在程序中,我们需要告诉计算机图的结构。常用的存储方式有两种:邻接矩阵邻接表

21.2.1 邻接矩阵

用二维数组 g[i][j] 表示顶点 i 和 j 之间的关系。对于无向图,g[i][j] = 1 表示有边,0 表示无边;对于带权图,g[i][j] 可以存储权值,无边时用无穷大(如 1e9)表示。

优点:简单直观,查找任意两点是否有边很快(O(1))。
缺点:当顶点很多但边很少时,浪费空间(需要 n² 的空间)。

// 无向图,顶点数 n
int g[100][100] = {0};
// 添加一条边 u - v
g[u][v] = 1;
g[v][u] = 1;

21.2.2 邻接表

对于每个顶点,用一个列表(如 vector)存储它所有的邻居。这样只存存在的边,节省空间。

优点:节省空间,遍历某个顶点的所有邻居很方便。
缺点:判断两点是否有边需要遍历列表,不如矩阵快。

#include <vector>
using namespace std;

vector<int> adj[100];  // 邻接表,adj[u] 存储 u 的所有邻居
// 添加一条无向边
adj[u].push_back(v);
adj[v].push_back(u);

对于带权图,可以存一个结构体或 pair:

struct Edge {
    int to, weight;
};
vector<Edge> adj[100];
adj[u].push_back({v, w});
adj[v].push_back({u, w});

21.3 图的遍历

和树一样,图也需要遍历才能访问所有顶点。常用的两种遍历是 DFS 和 BFS。

21.3.1 深度优先搜索(DFS)遍历图

从一个顶点出发,沿着一条路走到底,然后回溯。需要标记已访问的顶点,避免重复访问。

代码实现(邻接表)

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

vector<int> adj[100];
bool visited[100];

void dfs(int u) {
    visited[u] = true;
    cout << u << " ";  // 访问顶点
    for (int v : adj[u]) {
        if (!visited[v]) {
            dfs(v);
        }
    }
}

int main() {
    int n, m; // n顶点数,m边数
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    // 假设从顶点0开始遍历
    dfs(0);
    return 0;
}

21.3.2 广度优先搜索(BFS)遍历图

一层一层向外扩展,用队列实现。可以求无权图的最短路径(从起点到每个顶点的最少边数)。

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

vector<int> adj[100];
bool visited[100];
int dist[100];  // 记录到起点的距离

void bfs(int start) {
    queue<int> q;
    visited[start] = true;
    dist[start] = 0;
    q.push(start);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        cout << u << " ";
        for (int v : adj[u]) {
            if (!visited[v]) {
                visited[v] = true;
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }
}

21.4 最短路径问题

在带权图中,我们经常要问:从顶点 A 到顶点 B 的最短路径是哪条?总权值最小是多少?经典的算法有 Dijkstra(单源,边权非负)和 Floyd(多源)。

21.4.1 Dijkstra 算法

思想:维护一个距离数组,不断从未确定的顶点中选出当前距离最小的顶点,用它去更新邻居的距离。就像我们不断向外扩散,每次找到最近的点。

适用条件:边权非负。

代码实现(邻接表 + 优先队列优化)

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

struct Edge {
    int to, weight;
};
vector<Edge> adj[100];
int dist[100];
bool visited[100];

void dijkstra(int start, int n) {
    for (int i = 0; i < n; i++) dist[i] = INT_MAX;
    dist[start] = 0;
    // 优先队列:pair<距离, 顶点>,按距离从小到大
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
    pq.push({0, start});

    while (!pq.empty()) {
        int u = pq.top().second;
        int d = pq.top().first;
        pq.pop();
        if (visited[u]) continue;
        visited[u] = true;
        for (Edge e : adj[u]) {
            int v = e.to;
            int w = e.weight;
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
}

解释:优先队列每次取出当前距离最小的顶点,用它更新邻居。因为距离小的点一旦确定就不会再变小(边权非负),所以可以标记已访问。

21.4.2 Floyd-Warshall 算法

求所有点对之间的最短路径。用三重循环,动态规划思想:允许经过中间节点 k 来更新 i 到 j 的距离。

代码实现

int g[100][100]; // 邻接矩阵,初始为 INF,对角线为0
int n;

void floyd() {
    for (int k = 0; k < n; k++) {
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (g[i][k] < INF && g[k][j] < INF)
                    g[i][j] = min(g[i][j], g[i][k] + g[k][j]);
            }
        }
    }
}

21.5 最小生成树

一个连通的无向图,如果它是一棵树(没有环)且包含所有顶点,就叫生成树。权值之和最小的生成树叫最小生成树。常用于铺设管道、网络布线等。

21.5.1 Prim 算法

从一个顶点开始,每次选择一条连接已选集合和未选集合的最小权边,将新顶点加入集合。

代码实现(邻接矩阵)

const int INF = 1e9;
int g[100][100];
int n;

int prim() {
    int dist[100];   // 记录未选顶点到已选集合的最小距离
    bool vis[100] = {false};
    for (int i = 0; i < n; i++) dist[i] = INF;
    dist[0] = 0;
    int total = 0;
    for (int i = 0; i < n; i++) {
        // 选出未选顶点中距离最小的
        int u = -1;
        for (int j = 0; j < n; j++) {
            if (!vis[j] && (u == -1 || dist[j] < dist[u]))
                u = j;
        }
        vis[u] = true;
        total += dist[u];
        // 更新邻居的距离
        for (int v = 0; v < n; v++) {
            if (!vis[v] && g[u][v] < dist[v])
                dist[v] = g[u][v];
        }
    }
    return total;
}

21.5.2 Kruskal 算法

将所有边按权值从小到大排序,然后依次尝试加入,如果加入后不形成环就保留(用并查集判断是否连通)。

代码实现(需要并查集)

struct Edge {
    int u, v, w;
    bool operator<(const Edge& other) const {
        return w < other.w;
    }
};
Edge edges[10000];
int parent[100];

int find(int x) {
    if (parent[x] != x) parent[x] = find(parent[x]);
    return parent[x];
}

void unite(int x, int y) {
    x = find(x); y = find(y);
    if (x != y) parent[x] = y;
}

int kruskal(int n, int m) {
    sort(edges, edges + m);
    for (int i = 0; i < n; i++) parent[i] = i;
    int total = 0, cnt = 0;
    for (int i = 0; i < m; i++) {
        int u = edges[i].u, v = edges[i].v, w = edges[i].w;
        if (find(u) != find(v)) {
            unite(u, v);
            total += w;
            cnt++;
            if (cnt == n-1) break;
        }
    }
    return total;
}

21.6 编程实例讲解

实例1:判断图是否连通

用 DFS 或 BFS 遍历整个图,如果 visited 数组全为 true 则连通。

bool isConnected(int n) {
    dfs(0);
    for (int i = 0; i < n; i++)
        if (!visited[i]) return false;
    return true;
}

实例2:查找图中的环(无向图)

用 DFS 时,如果遇到一个已经访问过的邻居且不是父节点,就说明有环。

bool hasCycle(int u, int parent) {
    visited[u] = true;
    for (int v : adj[u]) {
        if (!visited[v]) {
            if (hasCycle(v, u)) return true;
        } else if (v != parent) {
            return true; // 遇到非父节点的已访问节点,有环
        }
    }
    return false;
}

实例3:最短路径示例

输入一个城市交通图(顶点数 n,边数 m),求从城市 0 到城市 n-1 的最短路径长度。

// 直接用 Dijkstra
dijkstra(0, n);
cout << dist[n-1] << endl;

21.7 阶段性编程练习

  1. 练习1:用邻接矩阵和邻接表两种方式存储下面的图,并编写代码遍历所有顶点(DFS和BFS)。
    0--1--2 | | 3--4
  2. 练习2:实现一个函数,判断有向图中是否存在从 u 到 v 的路径。
  3. 练习3:用 Dijkstra 求下面带权图从顶点0到其他顶点的最短距离。
    0--(2)--1 | | (4) (1) | | 3--(3)--2
  4. 练习4:用 Prim 或 Kruskal 求下面图的最小生成树权值。
    0--(1)--1--(2)--2 | | | (3) (4) (5) | | | 3--(2)--4--(1)--5
  5. 练习5:实现一个程序,读入一个无向图,输出每个顶点的度。

21.8 第21章编程作业

作业1:欧拉路径

在无向图中,如果存在一条路径恰好经过每条边一次,则称为欧拉路径。判断给定的图是否存在欧拉路径(条件:度数为奇数的顶点个数为0或2)。

作业2:拓扑排序

给定一个有向无环图(DAG),输出一个拓扑排序序列。可以用 Kahn 算法(基于入度)或 DFS。

作业3:二分图判定

给定一个无向图,判断它是否是二分图(可以用两种颜色染色,DFS 遍历,相邻顶点染不同色,如果冲突则不是)。

作业4:单源最短路径(含负权)

如果图中存在负权边,Dijkstra 可能失效。用 Bellman-Ford 算法求单源最短路径,并判断负环。

作业5:旅行商问题(TSP)小规模

给定 n 个城市(n≤10)和城市间的距离,求从0出发经过所有城市一次再回到0的最短路径。可以用状态压缩DP(DP[mask][i] 表示访问过的城市集合为 mask,当前在 i 的最短路径)。


恭喜你完成了第二十一章的学习!图论的世界非常广阔,本章只是打开了大门。掌握了这些基础,你就能解决很多实际问题,比如导航、网络规划、社交分析等。加油!🚀

20260228 144622 Cpp 入门第二十课

第二十章:动态规划——记住过去,规划未来

你好!欢迎来到第二十章!在前面的章节,我们学习了递推、递归、贪心等算法。这一章我们将学习一个更强大但也更有挑战的算法——动态规划。动态规划的核心思想是:把一个大问题拆分成许多小问题,并且记住这些小问题的答案,避免重复计算,从而高效地解决复杂问题。它就像我们做数学题时,记住已经算过的中间结果,直接拿来用,而不是每次都从头算起。


20.1 动态规划程序范例

先从一个最简单的例子开始:斐波那契数列。我们之前学过递归,但递归会重复计算很多次。比如算 fib(5) 时,fib(3) 被算了两次。动态规划就是用数组把算过的值存起来,避免重复。

#include <iostream>
using namespace std;

const int N = 100;
long long dp[N];  // dp[i] 存储第i个斐波那契数

long long fib(int n) {
    dp[1] = 1;
    dp[2] = 1;
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];  // 状态转移方程
    }
    return dp[n];
}

int main() {
    int n;
    cout << "请输入n:";
    cin >> n;
    cout << "第" << n << "项是:" << fib(n) << endl;
    return 0;
}

这个程序用数组记录了每个斐波那契数,从前往后依次计算,每个数只算一次,时间复杂度 O(n),比递归的指数级快多了。这就是最简单的动态规划。


20.2 动态规划的用法

20.2.1 什么是动态规划?

动态规划(Dynamic Programming,简称DP)是一种通过把原问题分解为相对简单的子问题,并保存子问题的解来避免重复计算的方法。它通常用于求解具有最优子结构重叠子问题性质的问题。

  • 最优子结构:一个问题的最优解包含其子问题的最优解。比如,你要从北京到上海的最短路径,如果经过南京,那么北京到南京的路径也必须是北京到南京的最短路径。
  • 重叠子问题:在求解过程中,相同的子问题会被多次用到。比如斐波那契数列,fib(3) 被多次用到。

20.2.2 动态规划的步骤

  1. 定义状态:用 dp 数组表示什么?比如 dp[i] 表示到达第 i 阶的方法数,或者 dp[i][j] 表示前 i 个物品在容量 j 下的最大价值。
  2. 确定状态转移方程:怎么从已知状态推出新状态?比如 dp[i] = dp[i-1] + dp[i-2]。
  3. 初始化:确定边界条件,比如 dp[1] = 1, dp[2] = 1。
  4. 计算顺序:通常是从小到大计算,保证计算当前状态时,所需子状态已经算好。
  5. 最终答案:从 dp 数组中取出所需结果。

20.2.3 动态规划的分类

  • 线性DP:状态是一维的,如斐波那契、爬楼梯、最大子段和。
  • 二维DP:状态是二维的,如背包问题、最长公共子序列。
  • 区间DP:状态表示区间,如石子合并。
  • 树形DP:在树上进行DP。

20.3 编程实例讲解

实例1:爬楼梯(线性DP)

题目:小明爬楼梯,每次可以跨1级或2级台阶。问爬到第n级台阶有多少种不同的爬法?

分析
- 状态:dp[i] 表示爬到第 i 级的方法数。
- 转移:到第 i 级可以从 i-1 跨1步,或从 i-2 跨2步,所以 dp[i] = dp[i-1] + dp[i-2]。
- 初始化:dp[1] = 1, dp[2] = 2。

#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "请输入台阶数:";
    cin >> n;
    if (n == 1) {
        cout << "1种" << endl;
        return 0;
    }
    int dp[100] = {0};
    dp[1] = 1;
    dp[2] = 2;
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    cout << "共有" << dp[n] << "种爬法" << endl;
    return 0;
}

实例2:最大子段和(线性DP)

题目:给定一个数组,求连续子数组的最大和。例如 [-2,1,-3,4,-1,2,1,-5,4] 的最大子段和是 4-1+2+1=6。

分析
- 状态:dp[i] 表示以第 i 个元素结尾的最大子段和。
- 转移:要么自己单独开始一段(nums[i]),要么接在前一段后面(dp[i-1] + nums[i]),取较大值。即 dp[i] = max(nums[i], dp[i-1] + nums[i])。
- 最终答案:所有 dp[i] 中的最大值。

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

int main() {
    int arr[] = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
    int n = sizeof(arr) / sizeof(arr[0]);
    int dp[100];
    dp[0] = arr[0];
    int ans = dp[0];
    for (int i = 1; i < n; i++) {
        dp[i] = max(arr[i], dp[i-1] + arr[i]);
        ans = max(ans, dp[i]);
    }
    cout << "最大子段和:" << ans << endl;
    return 0;
}

实例3:01背包问题(二维DP)

题目:有 n 个物品,每个物品有重量 w[i] 和价值 v[i]。有一个容量为 C 的背包,问最多能装多少价值的物品?每个物品只能选或不选(0-1选择)。

分析
- 状态:dp[i][j] 表示前 i 个物品中,选择总重量不超过 j 的最大价值。
- 转移:对于第 i 个物品,有两种选择:
- 不选:dp[i][j] = dp[i-1][j]
- 选(前提 j >= w[i]):dp[i][j] = dp[i-1][j - w[i]] + v[i]
取最大值。
- 初始化:dp[0][j] = 0(没有物品时价值为0)。
- 最终答案:dp[n][C]。

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

int main() {
    int n, C;
    cout << "请输入物品数量和背包容量:";
    cin >> n >> C;
    int w[100], v[100];
    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> v[i];
    }

    int dp[100][100] = {0};  // dp[i][j] 表示前i个物品容量j的最大价值
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= C; j++) {
            dp[i][j] = dp[i-1][j];  // 不选第i个
            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[100] = {0};
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]);
    }
}

实例4:最长上升子序列(LIS)

题目:给定一个序列,求最长严格上升子序列的长度(不要求连续)。例如 [10,9,2,5,3,7,101,18] 的最长上升子序列是 [2,5,7,101] 或 [2,3,7,101],长度为4。

分析
- 状态:dp[i] 表示以第 i 个元素结尾的最长上升子序列长度。
- 转移:对于每个 j < i,如果 a[j] < a[i],则 dp[i] = max(dp[i], dp[j] + 1)。
- 初始化:dp[i] = 1(每个元素自己就是一个子序列)。
- 最终答案:所有 dp[i] 的最大值。

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

int main() {
    int arr[] = {10, 9, 2, 5, 3, 7, 101, 18};
    int n = sizeof(arr) / sizeof(arr[0]);
    int dp[100];
    int ans = 1;
    for (int i = 0; i < n; i++) {
        dp[i] = 1;
        for (int j = 0; j < i; j++) {
            if (arr[j] < arr[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        ans = max(ans, dp[i]);
    }
    cout << "最长上升子序列长度:" << ans << endl;
    return 0;
}

实例5:编辑距离(选做)

题目:给定两个字符串,允许插入、删除、替换字符,求将一个字符串变成另一个的最少操作次数。

分析
- 状态:dp[i][j] 表示将 s1 的前 i 个字符变成 s2 的前 j 个字符的最少操作。
- 转移:
- 如果 s1[i-1] == s2[j-1],则 dp[i][j] = dp[i-1][j-1]
- 否则,考虑三种操作:
- 插入:dp[i][j-1] + 1
- 删除:dp[i-1][j] + 1
- 替换:dp[i-1][j-1] + 1
取最小值。
- 初始化:dp[0][j] = j(插入j次),dp[i][0] = i(删除i次)。

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

int main() {
    string s1, s2;
    cout << "请输入两个字符串:" << endl;
    cin >> s1 >> s2;
    int m = s1.length(), n = s2.length();
    int dp[100][100];
    for (int i = 0; i <= m; i++) dp[i][0] = i;
    for (int j = 0; j <= n; j++) dp[0][j] = j;
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1[i-1] == s2[j-1]) {
                dp[i][j] = dp[i-1][j-1];
            } else {
                dp[i][j] = min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]}) + 1;
            }
        }
    }
    cout << "最少操作次数:" << dp[m][n] << endl;
    return 0;
}

20.4 阶段性编程练习

  1. 练习1:用动态规划求斐波那契数列的第50项(注意用 long long 或高精度)。
  2. 练习2:一个机器人从 (0,0) 走到 (m,n),每次只能向右或向下,问有多少条路径?
  3. 练习3:给定一个数组,求最长连续递增子序列的长度(注意是连续)。
  4. 练习4:完全背包问题:每个物品可以选无限次,求最大价值。
  5. 练习5:数字三角形问题:输入一个数字三角形,求从顶部到底部的最大路径和(可以向下或右下)。

20.5 第20章编程作业

作业1:硬币找零(最少硬币数)

给定不同面额的硬币 coins 和一个总金额 amount,求凑成 amount 所需的最少硬币个数(每种硬币数量无限)。如果无法凑成,返回 -1。

作业2:最长公共子序列(LCS)

给定两个字符串,求它们的最长公共子序列的长度(子序列可以不连续)。

作业3:0-1背包方案数

给定物品重量和价值,求恰好装满背包的方案数(或最大价值的方案数)。

作业4:回文串分割

给定一个字符串,将其分割成若干子串,使得每个子串都是回文串,求最少分割次数。

作业5:石子合并(区间DP)

有 n 堆石子排成一排,每次可以合并相邻的两堆,代价为两堆重量之和。求将所有石子合并为一堆的最小总代价。


恭喜你完成了第二十章的学习!动态规划是算法竞赛中非常重要的一环,需要大量的练习才能真正掌握。不要气馁,多思考状态的定义和转移方程,你会越来越熟练。加油!🚀

20260228 144515 Cpp 入门第十九课

第十九章:搜索算法——在迷宫中寻找出路

你好!欢迎来到第十九章!在生活中,我们经常需要“搜索”某样东西:在迷宫里找出口、在书架上找一本书、在数字迷宫中找一条路径……在计算机科学中,搜索算法就是系统地探索所有可能的状态,直到找到目标。本章我们将学习两种最基本的搜索策略:深度优先搜索(DFS)和广度优先搜索(BFS),以及它们的优化技巧——剪枝。


19.1 搜索算法概述

搜索就是从一个初始状态出发,按照一定的规则,不断尝试所有可能的选择,直到找到目标状态。就像你在一个陌生的大楼里找出口,你可以选择一直向右走(深度优先),也可以一层一层地探索(广度优先)。

搜索算法通常用于:
- 求解路径问题(如迷宫)
- 枚举所有可能解(如八皇后)
- 状态空间搜索(如拼图游戏)

两种基本搜索策略

  • 深度优先搜索(DFS):从一个分支走到底,再回溯换另一个分支。
  • 广度优先搜索(BFS):一层一层地探索,先走一步能到的所有点,再走两步能到的点。

19.2 深度优先搜索(DFS)

19.2.1 什么是深度优先?

想象你在一个迷宫里,你决定“一条路走到黑”:选择一个方向一直走,直到遇到死胡同(无路可走或找到出口),然后回退到上一个岔路口,选择另一个方向继续。这就是深度优先搜索。

DFS可以用递归来实现。递归最直观。

19.2.2 DFS程序范例:全排列

先看一个简单的DFS例子:生成1~n的所有全排列。这是一个经典的DFS问题,类似于走迷宫:每次选择一个没选过的数字,直到所有数字选完。

#include <iostream>
using namespace std;

const int N = 10;
int n;
int path[N];      // 记录当前排列
bool used[N];     // 标记数字是否用过

void dfs(int u) {
    if (u == n) { // 已经选了n个数,输出一个排列
        for (int i = 0; i < n; i++) cout << path[i] << " ";
        cout << endl;
        return;
    }

    for (int i = 1; i <= n; i++) {
        if (!used[i]) {
            used[i] = true;
            path[u] = i;
            dfs(u + 1);     // 递归下一层
            used[i] = false; // 回溯,恢复现场
        }
    }
}

int main() {
    cout << "请输入n:";
    cin >> n;
    dfs(0);
    return 0;
}

运行示例(n=3):

1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1

这里的关键是回溯:在递归返回后,要把标记还原,这样其他分支才能继续使用这个数字。

19.2.3 DFS走迷宫

题目:给定一个迷宫,用二维数组表示,0表示空地,1表示障碍。从起点 (sx, sy) 出发,判断能否到达终点 (ex, ey)。输出路径(可选)。

#include <iostream>
using namespace std;

const int N = 10;
int maze[N][N];     // 迷宫地图
int n, m;           // 行数、列数
bool visited[N][N]; // 访问标记
int sx, sy, ex, ey; // 起点、终点
bool found = false;

// 四个方向:上、下、左、右
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

void dfs(int x, int y) {
    if (x == ex && y == ey) {
        found = true;
        return;
    }
    visited[x][y] = true;
    for (int i = 0; i < 4; i++) {
        int nx = x + dx[i];
        int ny = y + dy[i];
        if (nx >= 0 && nx < n && ny >= 0 && ny < m && 
            maze[nx][ny] == 0 && !visited[nx][ny]) {
            dfs(nx, ny);
            if (found) return;  // 找到目标就提前返回
        }
    }
    // 注意:这里不需要把visited还原,因为不需要找所有路径,只找一条即可
    // 如果要求所有路径,则需要在递归后还原visited
}

int main() {
    cout << "请输入迷宫行数和列数:";
    cin >> n >> m;
    cout << "请输入迷宫(0空地 1障碍):" << endl;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++)
            cin >> maze[i][j];
    cout << "请输入起点坐标和终点坐标:";
    cin >> sx >> sy >> ex >> ey;

    dfs(sx, sy);
    if (found)
        cout << "可以到达终点" << endl;
    else
        cout << "无法到达终点" << endl;
    return 0;
}

如果需要输出路径,可以增加一个数组记录前驱节点,或者用栈存储。

19.2.4 DFS与回溯

回溯是DFS的一种重要技巧,它通过恢复现场来尝试所有可能性。上面的全排列例子中,used[i] = false; 就是回溯。在迷宫问题中,如果我们要找所有路径,也需要在递归返回后把 visited 还原。


19.3 广度优先搜索(BFS)

19.3.1 什么是广度优先?

想象你在一片水域里丢下一颗石子,波纹会一圈一圈向外扩散。BFS就是这样:从起点出发,先探索所有一步能到的点,再探索两步能到的点,以此类推。BFS天然适合求最短路径(因为第一次到达目标时就是最短步数)。

BFS用队列实现。

19.3.2 BFS程序范例:迷宫最短路径

用BFS求从起点到终点的最短路径长度(每步移动一格,不能穿越障碍)。

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

const int N = 10;
int maze[N][N];
int n, m;
int sx, sy, ex, ey;
int dist[N][N];   // 记录到起点的距离,-1表示未访问

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

struct Point {
    int x, y;
};

int bfs() {
    queue<Point> q;
    q.push({sx, sy});
    dist[sx][sy] = 0;

    while (!q.empty()) {
        Point cur = q.front();
        q.pop();
        int x = cur.x, y = cur.y;

        if (x == ex && y == ey) {
            return dist[x][y];
        }

        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i];
            int ny = y + dy[i];
            if (nx >= 0 && nx < n && ny >= 0 && ny < m && 
                maze[nx][ny] == 0 && dist[nx][ny] == -1) {
                dist[nx][ny] = dist[x][y] + 1;
                q.push({nx, ny});
            }
        }
    }
    return -1;  // 不可达
}

int main() {
    cout << "请输入迷宫行数和列数:";
    cin >> n >> m;
    cout << "请输入迷宫(0空地 1障碍):" << endl;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++)
            cin >> maze[i][j];
    cout << "请输入起点坐标和终点坐标:";
    cin >> sx >> sy >> ex >> ey;

    // 初始化dist为-1
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++)
            dist[i][j] = -1;

    int steps = bfs();
    if (steps != -1)
        cout << "最短路径长度:" << steps << endl;
    else
        cout << "无法到达终点" << endl;
    return 0;
}

19.3.3 BFS与队列

BFS的核心是队列:每次将当前点的邻居加入队尾,保证先入队的点先被处理,从而实现“逐层”探索。


19.4 DFS与BFS的比较

特性 DFS BFS
数据结构 栈(递归自动使用系统栈) 队列
空间复杂度 O(深度) O(宽度)(可能很大)
是否保证最短路径 不保证(除非遍历所有) 保证(无权图)
适用场景 枚举所有可能、连通性、回溯 最短路径、分层遍历

19.5 剪枝优化

搜索算法常常会面临“状态爆炸”的问题,比如八皇后有92种解,但搜索空间巨大。剪枝就是在搜索过程中,提前判断某些分支不可能得到解,从而剪掉它们,减少搜索量。

常见的剪枝:
- 可行性剪枝:当前状态已经不可能达到目标,直接返回。
- 最优性剪枝:当前路径长度已经超过已知最优解,直接返回。
- 重复状态剪枝:用记忆化避免重复搜索。

实例:N皇后问题(经典DFS+剪枝)

在 N×N 的棋盘上放置 N 个皇后,使它们互不攻击(不同行、不同列、不同对角线)。输出所有解。

#include <iostream>
using namespace std;

const int N = 20;
int n;
int col[N], dg[2*N], udg[2*N]; // 列、主对角线、副对角线的标记
int path[N];                    // path[i] 表示第i行皇后所在的列

void dfs(int row) {
    if (row == n) {  // 找到一个解
        for (int i = 0; i < n; i++) cout << path[i] + 1 << " "; // 输出列号(从1开始)
        cout << endl;
        return;
    }

    for (int c = 0; c < n; c++) {
        if (!col[c] && !dg[row + c] && !udg[row - c + n]) {
            col[c] = dg[row + c] = udg[row - c + n] = 1;
            path[row] = c;
            dfs(row + 1);
            col[c] = dg[row + c] = udg[row - c + n] = 0; // 回溯
        }
    }
}

int main() {
    cout << "请输入N:";
    cin >> n;
    dfs(0);
    return 0;
}

这里的对角线判断就是剪枝:利用数组快速检查是否有冲突,避免无效的递归。


19.6 编程实例讲解

实例1:八数码问题(BFS)

有一个 3×3 的棋盘,有 1~8 的数字和一个空格(用0表示)。每次可以将空格与上下左右相邻的数字交换。给定初始状态,问最少需要多少步能到达目标状态(例如 1 2 3 4 5 6 7 8 0)。

这题用BFS,状态用字符串或二维数组表示,需要判重。

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

int bfs(string start) {
    string target = "123456780";
    queue<string> q;
    unordered_map<string, int> dist;
    q.push(start);
    dist[start] = 0;

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

    while (!q.empty()) {
        string cur = q.front();
        q.pop();
        int d = dist[cur];
        if (cur == target) return d;

        int pos = cur.find('0');   // 空格位置
        int x = pos / 3, y = pos % 3;
        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i], ny = y + dy[i];
            if (nx >= 0 && nx < 3 && ny >= 0 && ny < 3) {
                int newPos = nx * 3 + ny;
                string next = cur;
                swap(next[pos], next[newPos]);
                if (!dist.count(next)) {
                    dist[next] = d + 1;
                    q.push(next);
                }
            }
        }
    }
    return -1;
}

int main() {
    string start;
    cout << "请输入初始状态(9个数字,0代表空格):";
    for (int i = 0; i < 9; i++) {
        char ch;
        cin >> ch;
        start += ch;
    }
    int steps = bfs(start);
    if (steps != -1)
        cout << "最少需要" << steps << "步" << endl;
    else
        cout << "无解" << endl;
    return 0;
}

实例2:素数环(DFS)

将从1到n的n个数排成一个环,使得相邻两个数之和都是素数。输出所有可能。

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

const int N = 20;
int n;
int ring[N];
bool used[N];

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

void dfs(int pos) {
    if (pos == n) {
        if (isPrime(ring[0] + ring[n - 1])) {
            for (int i = 0; i < n; i++) cout << ring[i] << " ";
            cout << endl;
        }
        return;
    }
    for (int i = 1; i <= n; i++) {
        if (!used[i] && isPrime(i + ring[pos - 1])) {
            used[i] = true;
            ring[pos] = i;
            dfs(pos + 1);
            used[i] = false;
        }
    }
}

int main() {
    cout << "请输入n:";
    cin >> n;
    ring[0] = 1;  // 通常固定第一个数为1,减少重复
    used[1] = true;
    dfs(1);
    return 0;
}

19.7 阶段性编程练习

  1. 练习1:用DFS求一个迷宫从起点到终点的任意一条路径(不要求最短),并输出路径坐标。
  2. 练习2:用BFS求迷宫的最短路径,并输出路径(可以用前驱数组记录)。
  3. 练习3:用DFS生成1~n的所有子集(每个数选或不选)。
  4. 练习4:用BFS解决“倒水问题”:有两个容量分别为 a 和 b 的壶,问能否量出 c 升水。
  5. 练习5:用DFS解决“八皇后”问题,并输出所有解(92种)。

19.8 第19章编程作业

作业1:数独求解器

用DFS+回溯实现一个数独求解器。输入一个 9×9 的数独(0表示空格),输出一个解。

作业2:单词搜索

给定一个字母矩阵和一个单词,判断单词是否可以在矩阵中相邻(上下左右)连续出现(不能重复使用同一格)。用DFS实现。

作业3:推箱子(简单版)

用BFS求推箱子游戏的最少步数。地图简单,只有一个箱子和一个目标点。

作业4:马的遍历

在 N×M 的棋盘上,马从起点出发,能否不重复地遍历所有格子?输出一条路径(或判断可行性)。用DFS+回溯。

作业5:迷宫生成

用DFS(递归回溯)生成一个随机迷宫。


恭喜你完成了第十九章的学习!搜索算法是算法竞赛的基石,很多复杂问题都可以转化为搜索。掌握了DFS和BFS,你就拥有了解决许多问题的基础。加油!🚀

20260228 143506 Cpp 入门第十八课

第十八章:分治与倍增——拆解与跳跃的智慧

你好!欢迎来到第十八章!在前面的章节,我们学习了递归、二分查找等算法。这一章我们将学习两种更强大的思想:分治倍增。分治就像“分而治之”,把大问题拆成小问题解决;倍增就像“一步跨两级”,利用已知信息快速跳跃。掌握它们,你就能解决更多有趣的问题!


18.1 分治算法

18.1.1 什么是分治?

分治(Divide and Conquer)就是把一个复杂的大问题,分解成若干个相同或相似的子问题,再把子问题分解成更小的子问题……直到最后子问题可以直接求解,然后合并子问题的解得到原问题的解。就像打扫一个大房间,可以分成几个小区域分别打扫,最后整体就干净了。

分治算法的三个步骤:
1. 分解:将原问题分解成若干个规模较小的相同子问题。
2. 解决:递归地求解子问题。如果子问题足够小,直接求解。
3. 合并:将子问题的解合并成原问题的解。

18.1.2 分治程序范例:归并排序

归并排序是分治思想的经典应用。它的思想是:把数组分成两半,分别排序,然后合并两个有序数组。

#include <iostream>
using namespace std;

// 合并两个有序数组
void merge(int arr[], int left, int mid, int right) {
    int n1 = mid - left + 1;
    int n2 = right - mid;
    int L[n1], R[n2];

    for (int i = 0; i < n1; i++) L[i] = arr[left + i];
    for (int i = 0; i < n2; i++) R[i] = arr[mid + 1 + i];

    int i = 0, j = 0, k = left;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k++] = L[i++];
        } else {
            arr[k++] = R[j++];
        }
    }
    while (i < n1) arr[k++] = L[i++];
    while (j < n2) arr[k++] = R[j++];
}

// 归并排序
void mergeSort(int arr[], int left, int right) {
    if (left < right) {
        int mid = left + (right - left) / 2;
        mergeSort(arr, left, mid);       // 排序左半
        mergeSort(arr, mid + 1, right);  // 排序右半
        merge(arr, left, mid, right);    // 合并
    }
}

int main() {
    int arr[] = {38, 27, 43, 3, 9, 82, 10};
    int n = sizeof(arr) / sizeof(arr[0]);

    mergeSort(arr, 0, n - 1);

    cout << "排序结果:";
    for (int i = 0; i < n; i++) cout << arr[i] << " ";
    cout << endl;
    return 0;
}

运行结果:3 9 10 27 38 43 82

归并排序体现了分治的精髓:先分后合。它的时间复杂度是 O(n log n),很稳定。

18.1.3 分治的其他例子

快速排序(另一种分治)

快速排序也是分治,它选择一个基准值,把小于基准的放左边,大于基准的放右边,然后递归排序左右。

int partition(int arr[], int low, int high) {
    int pivot = arr[high];
    int i = low - 1;
    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            i++;
            swap(arr[i], arr[j]);
        }
    }
    swap(arr[i + 1], arr[high]);
    return i + 1;
}

void quickSort(int arr[], int low, int high) {
    if (low < high) {
        int pi = partition(arr, low, high);
        quickSort(arr, low, pi - 1);
        quickSort(arr, pi + 1, high);
    }
}
汉诺塔(分治思想)

汉诺塔问题也是分治:要把 n 个盘子从 A 移到 C,可以分解为:
1. 把上面 n-1 个盘子从 A 移到 B(借助 C)
2. 把最大的盘子从 A 移到 C
3. 把 n-1 个盘子从 B 移到 C(借助 A)

棋盘覆盖(趣味问题)

在一个 $2^k × 2^k$ 的棋盘中,有一个特殊方格,用 L 型骨牌覆盖所有方格(除了特殊格)。可以用分治:将棋盘分成四个小棋盘,特殊格在其中一个,其他三个小棋盘用 L 型骨牌覆盖,然后递归。

18.1.4 阶段性练习(分治)

  1. 练习1:用分治思想求一个数组的最大值和最小值(分成两半,分别求最大最小值,再合并)。
  2. 练习2:用归并排序对一组分数进行排序。
  3. 练习3:用分治实现二分查找(其实就是分治,但我们已经学过了)。
  4. 练习4:棋盘覆盖问题,尝试编写代码(选做)。

18.2 倍增算法

18.2.1 什么是倍增?

倍增就是“成倍增长”的意思。它的思想是:利用已知信息,通过每次跳 2^k 步来快速前进。比如你想知道从第 i 个位置出发,走 k 步后会到哪,如果一步步走很慢,但如果你事先知道从每个位置走 1 步、2 步、4 步……的位置,就能快速组合出 k 步。

倍增常用于:
- 快速幂
- 求最近公共祖先(LCA)
- 求区间最值(ST表)
- 跳跃游戏

18.2.2 倍增程序范例:快速幂

计算 a 的 b 次方,如果 b 很大,用循环乘 b 次很慢。快速幂用倍增思想:把 b 拆成二进制,每次平方底数。

#include <iostream>
using namespace std;

// 快速幂,计算 a^b
long long quickPow(long long a, int b) {
    long long result = 1;
    while (b > 0) {
        if (b & 1) {          // 如果 b 的二进制最低位是1
            result *= a;
        }
        a *= a;               // a 平方
        b >>= 1;              // b 右移一位
    }
    return result;
}

int main() {
    int a, b;
    cout << "请输入底数和指数:";
    cin >> a >> b;
    cout << a << "^" << b << " = " << quickPow(a, b) << endl;
    return 0;
}

运行示例:输入 2 10,输出 1024。

原理:比如求 3^13,13 的二进制是 1101,即 3^13 = 3^8 × 3^4 × 3^1。我们通过不断平方得到 3^1, 3^2, 3^4, 3^8,然后根据需要乘起来。

18.2.3 ST表(区间最值查询)

ST表用于解决静态数组的区间最值查询(RMQ)问题。它用倍增预处理,查询时 O(1)。适合小学生理解的有趣应用:比如有一列数,快速问某个区间内的最大值。

思想:用 st[i][k] 表示从 i 开始,长度为 2^k 的区间内的最大值。那么:
- st[i][0] = arr[i]
- st[i][k] = max(st[i][k-1], st[i + (1 << (k-1))][k-1])(把区间分成两半,各长 2^(k-1))

查询区间 [l, r] 时,计算长度 len = r-l+1,找到最大的 k 使得 2^k ≤ len,那么答案就是 max(st[l][k], st[r - (1<<k) + 1][k])(覆盖整个区间可能重叠,但不影响最值)。

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

const int MAXN = 100005;
const int LOG = 17;   // 2^17 > 100000
int arr[MAXN];
int st[MAXN][LOG];

void buildST(int n) {
    for (int i = 0; i < n; i++) st[i][0] = arr[i];
    for (int k = 1; (1 << k) <= n; k++) {
        for (int i = 0; i + (1 << k) - 1 < n; i++) {
            st[i][k] = max(st[i][k - 1], st[i + (1 << (k - 1))][k - 1]);
        }
    }
}

int query(int l, int r) {
    int k = log2(r - l + 1);
    return max(st[l][k], st[r - (1 << k) + 1][k]);
}

int main() {
    int n, q;
    cout << "请输入数组大小:";
    cin >> n;
    cout << "请输入数组元素:";
    for (int i = 0; i < n; i++) cin >> arr[i];
    buildST(n);

    cout << "请输入查询次数:";
    cin >> q;
    while (q--) {
        int l, r;
        cin >> l >> r;
        cout << "区间[" << l << "," << r << "]的最大值是:" << query(l, r) << endl;
    }
    return 0;
}

18.2.4 倍增求LCA(简单介绍)

LCA(最近公共祖先)是树上的问题。倍增预处理每个节点向上跳 2^k 步的祖先,查询时先把两个节点跳到同一深度,然后一起向上跳找公共祖先。这里只做概念介绍,不展开代码。

18.2.5 阶段性练习(倍增)

  1. 练习1:用快速幂计算 $5^13$ 和 $2^30$。
  2. 练习2:给定一个数组,用ST表求多个区间的最小值(只需把 max 改成 min)。
  3. 练习3:用倍增思想模拟“跳台阶”问题:有 n 级台阶,每次可以跳 1、2、3 级,问从第 0 级跳到第 n 级有多少种跳法?这不是倍增,但可以体会倍增的另一种应用:用矩阵快速幂优化递推。
  4. 练习4:查找数组中的某个数,用倍增思想做“指数搜索”(先以 $2^k$ 步长扩大范围,找到区间后二分)。

18.3 编程实例讲解

实例1:用分治求数组的最大子段和

题目:给定一个数组,求连续子数组的最大和(至少包含一个数)。例如 [-2,1,-3,4,-1,2,1,-5,4] 的最大子段和是 4-1+2+1 = 6。

分治思路:将数组分成两半,最大子段和要么在左半,要么在右半,要么跨越中点。跨越中点的可以从中点向两边扩展。

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

int maxCrossingSum(int arr[], int left, int mid, int right) {
    int sum = 0;
    int leftSum = -1e9;
    for (int i = mid; i >= left; i--) {
        sum += arr[i];
        leftSum = max(leftSum, sum);
    }
    sum = 0;
    int rightSum = -1e9;
    for (int i = mid + 1; i <= right; i++) {
        sum += arr[i];
        rightSum = max(rightSum, sum);
    }
    return leftSum + rightSum;
}

int maxSubArraySum(int arr[], int left, int right) {
    if (left == right) return arr[left];
    int mid = left + (right - left) / 2;
    int leftMax = maxSubArraySum(arr, left, mid);
    int rightMax = maxSubArraySum(arr, mid + 1, right);
    int crossMax = maxCrossingSum(arr, left, mid, right);
    return max({leftMax, rightMax, crossMax});
}

int main() {
    int arr[] = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
    int n = sizeof(arr) / sizeof(arr[0]);
    cout << "最大子段和:" << maxSubArraySum(arr, 0, n - 1) << endl;
    return 0;
}

实例2:用倍增求区间最大公约数(选做)

ST表也可以处理其他可重复贡献的问题,如 GCD(最大公约数)。因为 gcd 也满足结合律,且重叠不影响。

// 只需把 max 改成 __gcd 即可

实例3:用分治求平面最近点对(趣味介绍,不要求代码)

在平面上有 n 个点,求最近的两个点之间的距离。可以用分治:按 x 坐标排序,分成左右两半,分别求左右的最小距离 d,然后考虑跨左右且距离中线小于 d 的点,在 y 方向排序后比较。


18.4 第18章编程作业

作业1:归并排序实现

用归并排序对一组整数排序,并输出排序过程(每轮合并后的数组)。

作业2:快速幂取模

计算 a^b mod p,其中 a, b, p 都较大(用 long long)。要求用快速幂实现。

作业3:ST表求区间最小值

给定一个数组,多次询问区间最小值。用 ST 表预处理,回答查询。

作业4:分治求逆序对

在归并排序过程中,可以统计逆序对的数量。逆序对是指 i a[j]。在合并时,如果左边大于右边,就产生逆序对。实现一个函数计算逆序对个数。

作业5:倍增法求数组中的“下一个更大元素”(单调栈变体,但可以用倍增思想预处理每个元素跳 $2^k$ 步后的位置)

例如,给定数组 [2,1,5,6,2,3],定义 next[i] 为从 i 开始往后第一个比 a[i] 大的元素的下标,若没有则为 -1。用倍增预处理,可以快速回答从 i 出发跳 k 步后的位置(但这里不是标准倍增,只是练习)。

作业6:棋盘覆盖问题(选做)

实现棋盘覆盖的代码,用分治算法,输出 L 型骨牌的放置。


恭喜你完成了第十八章的学习!分治和倍增是两种非常重要的算法思想,它们在很多高级算法中都有应用。分治让我们学会“拆解问题”,倍增让我们学会“快速跳跃”。加油!🚀

20260228 143225 Cpp 入门第十七课

第十七章:贪心算法——每次都选最好的

你好!欢迎来到第十七章!在生活中,我们经常要做选择:吃自助餐时,是先吃喜欢的菜还是随便拿?去游乐场玩,是先玩排队时间长的项目还是短的?贪心算法就是一种“每次都选当前最好的”的策略。虽然不一定能得到全局最优解,但在很多问题上却非常有效。这一章我们就来学习贪心算法,看看什么时候可以“贪心”。


17.1 贪心算法程序范例

先来看一个最简单的贪心问题:找零钱问题。假设我们需要用最少的硬币数量凑出一定的金额,硬币面额有 1元、2元、5元、10元。贪心策略是:每次都尽量用面额最大的硬币。

#include <iostream>
using namespace std;

int main() {
    int amount;
    cout << "请输入金额(元):";
    cin >> amount;

    int coins[] = {10, 5, 2, 1};   // 硬币面额,从大到小排序
    int count = 0;

    for (int i = 0; i < 4; i++) {
        int num = amount / coins[i];   // 用多少枚当前面额的硬币
        count += num;
        amount -= num * coins[i];
        if (num > 0) {
            cout << "需要" << coins[i] << "元硬币:" << num << "枚" << endl;
        }
    }
    cout << "总共需要" << count << "枚硬币" << endl;
    return 0;
}

运行示例(输入17):

需要10元硬币:1枚
需要5元硬币:1枚
需要2元硬币:1枚
总共需要3枚硬币

这里我们每次都用最大面额的硬币,这就是贪心:在当前步,选择当前最好的(面额最大的),最终得到了最少硬币数。注意,这个策略对这套面额有效,但如果面额是 1、3、4 元,要凑 6 元,贪心会先选4,再选1和1,共3枚,但最优是 3+3 只用2枚,说明贪心不一定总是最优。所以我们用贪心时要先确认问题是否适合贪心。


17.2 贪心算法的用法

17.2.1 什么是贪心算法?

贪心算法(Greedy Algorithm)是指在每一步选择中都采取当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。就像吃自助餐时,每次都拿自己最喜欢的菜,不一定能吃得最健康,但能吃得最开心。

贪心算法通常用于求解最优化问题,比如找最少硬币、安排最多活动、求最短路径等。

17.2.2 贪心算法的特点

  • 局部最优:每一步都选当前最优的。
  • 不可回溯:一旦做出选择,就不再改变。
  • 不一定得到全局最优:需要问题具有贪心选择性质(即局部最优能导致全局最优)。

17.2.3 贪心算法的适用条件

  1. 贪心选择性质:可以通过局部最优选择来得到全局最优解。
  2. 最优子结构性质:问题的最优解包含子问题的最优解。

17.2.4 贪心算法的一般步骤

  1. 建立数学模型来描述问题。
  2. 把问题分解成若干个子问题。
  3. 对每个子问题,按照某种规则(贪心策略)做出当前最优选择。
  4. 把所有子问题的局部最优解合成原问题的一个解。

17.3 编程实例讲解

实例1:活动安排问题

题目:有若干个活动,每个活动有开始时间和结束时间。如何安排活动,使得能参加的活动数量最多?(假设同一时间只能参加一个活动)

贪心策略:按结束时间最早的活动优先选择。因为结束早,留给后面的时间就多。

#include <iostream>
#include <algorithm>   // 用到sort
using namespace std;

struct Activity {
    int start;
    int end;
};

// 按结束时间升序排序
bool cmp(Activity a, Activity b) {
    return a.end < b.end;
}

int main() {
    int n;
    cout << "请输入活动个数:";
    cin >> n;
    Activity acts[100];
    for (int i = 0; i < n; i++) {
        cin >> acts[i].start >> acts[i].end;
    }

    // 按结束时间排序
    sort(acts, acts + n, cmp);

    int count = 1;               // 至少选第一个活动
    int lastEnd = acts[0].end;   // 上一个活动的结束时间

    for (int i = 1; i < n; i++) {
        if (acts[i].start >= lastEnd) {   // 如果当前活动开始时间在上一个活动结束后
            count++;
            lastEnd = acts[i].end;
        }
    }

    cout << "最多可以参加" << count << "个活动" << endl;
    return 0;
}

输入示例

5
1 3
2 5
4 7
6 9
8 10

输出:最多可以参加3个活动(比如选 1-3, 4-7, 8-10)。

实例2:背包问题(部分背包)

题目:有一个背包,容量为C。有n种物品,每种物品有重量 w[i] 和价值 v[i]。可以只取一部分(比如切分),问如何装使得总价值最大?

贪心策略:按单位重量价值(v[i]/w[i])从高到低选取,优先装价值率高的物品。

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

struct Item {
    int weight;
    int value;
    double ratio;   // 单位重量价值
};

bool cmp(Item a, Item b) {
    return a.ratio > b.ratio;
}

int main() {
    int n, capacity;
    cout << "请输入物品数量和背包容量:";
    cin >> n >> capacity;
    Item items[100];
    for (int i = 0; i < n; i++) {
        cin >> items[i].weight >> items[i].value;
        items[i].ratio = (double)items[i].value / items[i].weight;
    }

    sort(items, items + n, cmp);   // 按价值率降序

    double totalValue = 0;
    int remaining = capacity;

    for (int i = 0; i < n; i++) {
        if (items[i].weight <= remaining) {
            // 可以全拿
            totalValue += items[i].value;
            remaining -= items[i].weight;
        } else {
            // 只能拿一部分
            totalValue += items[i].ratio * remaining;
            break;
        }
    }

    cout << "最大总价值:" << totalValue << endl;
    return 0;
}

注意:这是部分背包问题(物品可分割),贪心有效。如果是0-1背包(物品不可分割),贪心不一定最优,需要用动态规划。

实例3:排队打水问题

题目:有n个人排队打水,每个人打水需要的时间不同。如何安排打水顺序,使得所有人等待的总时间最少?

贪心策略:让打水时间短的人先打。因为这样后面的人等待时间总和最小。

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

int main() {
    int n;
    cout << "请输入人数:";
    cin >> n;
    int times[100];
    for (int i = 0; i < n; i++) {
        cin >> times[i];
    }

    sort(times, times + n);   // 升序排序

    int totalWait = 0;
    int currentTime = 0;
    for (int i = 0; i < n; i++) {
        totalWait += currentTime;      // 第i个人等待的时间
        currentTime += times[i];       // 当前累计时间
    }

    cout << "总等待时间:" << totalWait << endl;
    return 0;
}

实例4:删数问题

题目:给定一个数字字符串(如 “1432219”),删除 k 个数字,使得剩下的数字最小(保持相对顺序)。

贪心策略:从左到右遍历,如果当前数字比下一个数字大,就删除当前数字,这样能让高位变小。

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

string removeKdigits(string num, int k) {
    string result;
    for (char digit : num) {
        while (!result.empty() && result.back() > digit && k > 0) {
            result.pop_back();   // 删除前面比当前大的数
            k--;
        }
        result.push_back(digit);
    }
    // 如果还有剩余删除次数,从末尾删
    while (k > 0 && !result.empty()) {
        result.pop_back();
        k--;
    }
    // 去除前导零
    int start = 0;
    while (start < result.size() && result[start] == '0') start++;
    if (start == result.size()) return "0";
    return result.substr(start);
}

int main() {
    string num;
    int k;
    cout << "请输入数字字符串和要删除的个数:";
    cin >> num >> k;
    cout << "删除后最小的数字:" << removeKdigits(num, k) << endl;
    return 0;
}

17.4 阶段性编程练习

  1. 练习1:用贪心算法解决找零钱问题,硬币面额包括 1、5、10、20、50、100,凑出指定金额,输出每种硬币的数量。
  2. 练习2:有 n 个任务,每个任务有截止时间和收益,每个任务耗时1个单位时间。如何安排任务使得总收益最大?(提示:按收益降序,尽量放在截止时间前完成)
  3. 练习3:区间覆盖问题:给定一个线段区间 [s, t] 和若干条线段,选择最少的线段覆盖整个区间。(提示:按左端点排序,每次选能覆盖当前起点的最右端点)
  4. 练习4:小船过河问题:n 个人要过河,只有一条船,每次最多载两人,每个人过河时间不同,求所有人过河的最短总时间。(经典贪心问题,有两种策略)
  5. 练习5:哈夫曼编码思想:给定一些字符及其出现频率,构造哈夫曼树(用优先队列模拟)。

17.5 第17章编程作业

作业1:加油站问题

有一条环形公路,上有 n 个加油站,每个加油站可以加油 gas[i] 升,从第 i 个加油站到第 i+1 个需要消耗 cost[i] 升。你开一辆油箱无限的车,开始时油箱为空,问能否从某个加油站出发绕一圈回到起点?如果能,返回起点编号。(经典贪心,只要总油量≥总消耗,就一定有解,然后找起点)

作业2:分发饼干

每个孩子有一个胃口值 g[i],每个饼干有一个尺寸 s[j]。每个孩子最多只能给一块饼干,且饼干尺寸必须大于等于孩子的胃口。问最多能满足多少个孩子?(贪心:尽量用最小的饼干满足胃口最小的孩子)

作业3:跳跃游戏

给定一个非负整数数组,初始位置在第一个下标,数组每个元素表示在该位置能跳跃的最大长度。判断能否到达最后一个下标。(贪心:维护最远能到达的位置)

作业4:买卖股票的最佳时机 II

给定一个数组,第 i 天股票价格为 prices[i]。可以多次买卖(但只能持有一股),求最大利润。(贪心:只要第二天价格比前一天高,就前一天买第二天卖)

作业5:任务调度器

给定任务列表和冷却时间 n,相同任务之间必须间隔 n 个单位时间。求完成所有任务的最短时间。(贪心:每次安排剩余次数最多的任务)


恭喜你完成了第十七章的学习!贪心算法是一种简单而强大的思想,但要注意它不一定总是得到最优解,需要具体问题具体分析。加油!🚀

20260228 143122 Cpp 入门第十六课

第十六章:二分查找——快如闪电的查找方法

你好!欢迎来到第十六章!在前面的章节,我们学习了顺序查找——从第一个元素开始一个一个找,就像在一堆书里一本一本翻。但如果书已经按编号排好序了,我们还有更快的办法:先看中间那本,如果目标编号比中间的小,就去左边找;如果大,就去右边找。这样每次都能排除一半的书,这就是二分查找(也叫折半查找)。这一章我们就来学习这种高效的查找算法!


16.1 二分查找程序范例

假设我们有一个已经按升序排好的数组,要查找某个数是否在数组中,如果在,返回它的下标。

#include <iostream>
using namespace std;

// 二分查找函数,返回下标,没找到返回-1
int binarySearch(int arr[], int n, int target) {
    int left = 0;          // 查找范围的左边界
    int right = n - 1;     // 查找范围的右边界

    while (left <= right) {
        int mid = left + (right - left) / 2;   // 中间位置,防止溢出
        if (arr[mid] == target) {
            return mid;     // 找到了,返回下标
        } else if (arr[mid] < target) {
            left = mid + 1; // 目标在右半部分
        } else {
            right = mid - 1;// 目标在左半部分
        }
    }
    return -1;              // 没找到
}

int main() {
    int arr[] = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19};
    int n = sizeof(arr) / sizeof(arr[0]);
    int target;
    cout << "请输入要查找的数:";
    cin >> target;

    int pos = binarySearch(arr, n, target);
    if (pos != -1) {
        cout << "找到了,下标是:" << pos << endl;
    } else {
        cout << "没找到" << endl;
    }
    return 0;
}

运行示例(输入7):

请输入要查找的数:7
找到了,下标是:3

运行示例(输入6):

请输入要查找的数:6
没找到

16.2 二分查找的用法

16.2.1 二分查找的思想

二分查找就像猜数字游戏:对方想一个1-100之间的数,你每次猜中间的数,对方告诉你“大了”或“小了”,你就能排除一半的数字。这样最多猜7次就能猜到(因为2^7=128>100)。这就是二分查找的核心:每次把查找范围缩小一半

适用条件:数组必须是有序的(升序或降序)。如果数组无序,必须先排序才能用二分查找。

16.2.2 二分查找的步骤

  1. 确定查找范围的左边界 left 和右边界 right(通常初始为0和n-1)。
  2. left <= right 时:
    - 计算中间位置 mid = left + (right - left) / 2
    - 如果 arr[mid] == target,找到,返回 mid
    - 如果 arr[mid] < target,说明目标在右半部分,更新 left = mid + 1
    - 如果 arr[mid] > target,说明目标在左半部分,更新 right = mid - 1
  3. 如果循环结束还没找到,返回-1。

16.2.3 二分查找的代码实现

我们已经看到了迭代版本。也可以用递归实现:

int binarySearchRecursive(int arr[], int left, int right, int target) {
    if (left > right) return -1;   // 没找到
    int mid = left + (right - left) / 2;
    if (arr[mid] == target) {
        return mid;
    } else if (arr[mid] < target) {
        return binarySearchRecursive(arr, mid + 1, right, target);
    } else {
        return binarySearchRecursive(arr, left, mid - 1, target);
    }
}

调用时:binarySearchRecursive(arr, 0, n-1, target);

16.2.4 注意事项

  • 计算mid:用 left + (right - left) / 2 而不是 (left + right) / 2,防止 left+right 太大导致溢出。
  • 边界条件:循环条件 left <= right 要小心,如果写成 < 可能漏掉最后一个元素。
  • 数组必须有序:如果数组无序,结果不可预测。

16.2.5 二分查找的变种

有时候我们需要找的不是精确等于某个值的下标,而是满足某种条件的边界。比如:
- 找第一个等于target的位置
- 找最后一个等于target的位置
- 找第一个大于等于target的位置
- 找最后一个小于等于target的位置

这些变种在STL中对应 lower_boundupper_bound,我们也可以自己实现。


16.3 编程实例讲解

实例1:找第一个等于target的位置

当数组中有重复元素时,我们要找第一次出现的位置。

int firstOccurrence(int arr[], int n, int target) {
    int left = 0, right = n - 1;
    int result = -1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) {
            result = mid;      // 记录找到的位置
            right = mid - 1;   // 继续在左边找
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return result;
}

实例2:找最后一个等于target的位置

int lastOccurrence(int arr[], int n, int target) {
    int left = 0, right = n - 1;
    int result = -1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) {
            result = mid;
            left = mid + 1;    // 继续在右边找
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return result;
}

实例3:找第一个大于等于target的位置(即 lower_bound)

int lowerBound(int arr[], int n, int target) {
    int left = 0, right = n - 1;
    int result = n;   // 如果所有数都小于target,返回n(表示不存在)
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] >= target) {
            result = mid;
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return result;
}

实例4:在一个旋转有序数组中查找(选做)

题目:一个升序数组在某个点被旋转了,比如 [4,5,6,7,0,1,2],在这个数组中查找某个数。

思路:先判断哪一半是有序的,然后根据target是否在有序部分来缩小范围。

int searchRotated(int arr[], int n, int target) {
    int left = 0, right = n - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) return mid;
        // 左半部分有序
        if (arr[left] <= arr[mid]) {
            if (target >= arr[left] && target < arr[mid]) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        } 
        // 右半部分有序
        else {
            if (target > arr[mid] && target <= arr[right]) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
    }
    return -1;
}

16.4 阶段性编程练习

  1. 练习1:在有序数组 [2, 5, 8, 12, 16, 23, 38, 45, 56, 72] 中查找数字 23 和 50,分别输出结果。
  2. 练习2:编写一个函数,用二分查找求一个数的平方根(整数部分)。例如输入10,返回3(因为3²=9≤10,4²=16>10)。提示:在0到x之间二分查找。
  3. 练习3:在一个有序数组中,找第一个大于 target 的位置(即 upper_bound)。
  4. 练习4:在一个有序数组中,统计某个数出现的次数(可以用 firstOccurrence 和 lastOccurrence 计算)。
  5. 练习5:用递归实现二分查找,并在主函数中测试。

16.5 第16章编程作业

作业1:查找插入位置

给定一个有序数组和一个目标值,如果目标值在数组中,返回其下标;如果不在,返回它应该插入的位置(保持有序)。例如数组 [1,3,5,6],目标2,应返回1(插入在1和3之间)。

作业2:寻找峰值

在一个数组中,峰值是指该元素大于相邻元素(数组边界视为负无穷)。假设相邻元素不等,用二分查找找出任意一个峰值。例如 [1,2,1,3,5,6,4],峰值可能是2或6。提示:比较中间元素与右边元素,如果中间小于右边,则峰值在右边,否则在左边。

作业3:在二维矩阵中查找

有一个 m×n 的矩阵,每行从左到右递增,每列从上到下递增。编写一个高效的算法查找某个数是否存在。提示:从右上角开始,类似二分。

作业4:两个有序数组的中位数

给定两个有序数组,找出它们的中位数。要求时间复杂度 O(log(min(m,n)))。这是一个经典难题,但可以尝试。

作业5:寻找重复数

给定一个包含 n+1 个整数的数组,每个整数都在 1 到 n 之间,所以至少有一个重复。找出这个重复的数,要求不修改数组且只用O(1)额外空间。可以用二分查找思想(值域二分)。


恭喜你完成了第十六章的学习!二分查找是一种非常高效的算法,它体现了“分而治之”的思想。在实际编程中,只要遇到有序数组的查找问题,就可以考虑二分查找。加油!🚀

20260228 142501 Cpp 入门第十五课

第十五章:递归算法——函数调用自己

你好!欢迎来到第十五章!在前面的章节,我们学习了递推,从已知条件一步步推出结果。这一章我们学习另一种重要的思想:递归——函数自己调用自己。就像俄罗斯套娃,一个大娃娃里面套着一个一模一样的小娃娃。递归在解决某些问题时特别简洁,比如遍历文件夹、计算阶乘、汉诺塔等。让我们一起来探索递归的奥秘吧!


15.1 递归算法程序范例

先来看一个最简单的递归例子:计算阶乘 n!。阶乘的定义是:
- n! = 1 × 2 × 3 × … × n
- 也可以用递归定义:n! = n × (n-1)!,且 0! = 1。

#include <iostream>
using namespace std;

// 递归函数求阶乘
int factorial(int n) {
    if (n == 0) {          // 递归终止条件
        return 1;
    } else {
        return n * factorial(n - 1);   // 递归调用
    }
}

int main() {
    int n;
    cout << "请输入一个整数:";
    cin >> n;
    cout << n << "! = " << factorial(n) << endl;
    return 0;
}

运行示例(输入5):

5! = 120

这个程序里,factorial 函数在计算过程中又调用了自己,这就是递归。但递归不能无限进行下去,必须有一个终止条件(这里 n == 0 时返回1),否则就会陷入死循环(最终栈溢出)。


15.2 递归算法的用法

15.2.1 什么是递归?

递归就是一个函数直接或间接地调用自身。递归可以把一个复杂问题分解成与原问题相似但规模更小的子问题,直到子问题简单到可以直接求解(终止条件)。

生活中的递归例子:
- 俄罗斯套娃:打开一个娃娃,里面是一个更小的娃娃,直到最小的那个。
- 查字典:要查一个词,发现解释里用了另一个词,又去查那个词,直到某个词的解释简单易懂。

15.2.2 递归的要素

  1. 递归终止条件(基线条件):问题规模足够小,可以直接得到答案,不再调用自身。
  2. 递归关系(递归步骤):将原问题转化为规模更小的子问题,并调用自身解决子问题,然后组合结果。

15.2.3 递归的执行过程

factorial(3) 为例:
- 调用 factorial(3) → 3 != 0,执行 3 * factorial(2)
- 调用 factorial(2) → 2 != 0,执行 2 * factorial(1)
- 调用 factorial(1) → 1 != 0,执行 1 * factorial(0)
- 调用 factorial(0) → 0 == 0,返回 1
- 回到 factorial(1),得到 11 = 1,返回
- 回到 factorial(2),得到 2
1 = 2,返回
- 回到 factorial(3),得到 3*2 = 6,返回

这个过程像一层层深入,再一层层返回,所以递归需要系统栈来保存每一层的局部变量和返回地址。

15.2.4 递归与循环的比较

特点 递归 循环
代码简洁性 通常更简洁,符合数学定义 可能需要更多代码
可读性 对某些问题很直观 通用
效率 有函数调用开销,可能重复计算 通常更快
栈溢出风险 层次太深会栈溢出 无此问题
适用问题 树、图、分治等 线性重复

注意:能用循环解决的问题尽量用循环,但有些问题(如汉诺塔、树的遍历)用递归更自然。

15.2.5 递归的注意事项

  • 必须有终止条件,否则无限递归导致程序崩溃。
  • 递归层次不能太深,否则会栈溢出(系统栈空间有限,一般几千层)。
  • 避免重复计算:比如递归求斐波那契数会有大量重复,可以用记忆化(备忘录)优化。

15.3 编程实例讲解

实例1:递归求斐波那契数列(简单版,可能有重复计算)

#include <iostream>
using namespace std;

int fib(int n) {
    if (n == 1 || n == 2) {
        return 1;
    } else {
        return fib(n - 1) + fib(n - 2);   // 大量重复
    }
}

int main() {
    int n;
    cout << "请输入n:";
    cin >> n;
    cout << "第" << n << "项是:" << fib(n) << endl;
    return 0;
}

问题:n=40 时就会非常慢,因为重复计算太多。可以用记忆化优化。

实例2:记忆化递归(备忘录)

用一个数组保存已经算过的值,避免重复计算。

#include <iostream>
using namespace std;

const int N = 100;
long long memo[N] = {0};   // 记忆数组,初始0表示未计算

long long fib(int n) {
    if (n == 1 || n == 2) return 1;
    if (memo[n] != 0) return memo[n];   // 直接返回已计算结果
    memo[n] = fib(n - 1) + fib(n - 2);   // 计算并保存
    return memo[n];
}

int main() {
    int n;
    cout << "请输入n:";
    cin >> n;
    cout << "第" << n << "项是:" << fib(n) << endl;
    return 0;
}

实例3:汉诺塔问题

题目:有三根柱子A、B、C,A柱上有n个大小不同的圆盘,大盘在下小盘在上。要求把所有圆盘从A移到C,每次只能移动一个,且小盘不能压在大盘上。输出移动步骤。

递归思路
- 要把n个盘子从A移到C,可以先把上面n-1个盘子从A移到B(借助C),然后把最大的盘子从A移到C,最后把n-1个盘子从B移到C(借助A)。

#include <iostream>
using namespace std;

void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        cout << "移动盘子 1 从 " << from << " 到 " << to << endl;
        return;
    }
    hanoi(n - 1, from, aux, to);          // 把上面n-1个从from移到aux
    cout << "移动盘子 " << n << " 从 " << from << " 到 " << to << endl;
    hanoi(n - 1, aux, to, from);          // 把n-1个从aux移到to
}

int main() {
    int n;
    cout << "请输入盘子数:";
    cin >> n;
    hanoi(n, 'A', 'C', 'B');
    return 0;
}

运行示例(n=3):

移动盘子 1 从 A 到 C
移动盘子 2 从 A 到 B
移动盘子 1 从 C 到 B
移动盘子 3 从 A 到 C
移动盘子 1 从 B 到 A
移动盘子 2 从 B 到 C
移动盘子 1 从 A 到 C

实例4:递归遍历文件夹(伪代码,需要用到系统函数)

虽然不能直接运行,但可以展示思想:遍历文件夹时,如果是文件就处理,如果是文件夹就递归进入。

#include <iostream>
#include <filesystem>  // C++17 需要
namespace fs = std::filesystem;

void listFiles(const fs::path &path) {
    for (const auto &entry : fs::directory_iterator(path)) {
        if (entry.is_directory()) {
            listFiles(entry.path());   // 递归子文件夹
        } else {
            cout << entry.path() << endl;
        }
    }
}

实例5:递归求最大公约数(辗转相除法)

辗转相除法的递归定义:gcd(a, b) = gcd(b, a % b),直到 b=0。

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

实例6:全排列生成

用递归生成一个数组的所有排列(经典回溯)。

#include <iostream>
using namespace std;

void permute(int arr[], int l, int r) {
    if (l == r) {
        for (int i = 0; i <= r; i++) cout << arr[i] << " ";
        cout << endl;
    } else {
        for (int i = l; i <= r; i++) {
            swap(arr[l], arr[i]);
            permute(arr, l + 1, r);
            swap(arr[l], arr[i]);   // 回溯
        }
    }
}

int main() {
    int arr[] = {1, 2, 3};
    int n = sizeof(arr) / sizeof(arr[0]);
    permute(arr, 0, n - 1);
    return 0;
}

15.4 阶段性编程练习

  1. 练习1:用递归求 1+2+…+n 的和。
  2. 练习2:用递归判断一个字符串是否是回文(如 “abcba”)。
  3. 练习3:用递归实现二分查找(在有序数组中找某个数)。
  4. 练习4:用递归输出杨辉三角的第n行(提示:杨辉三角可以用递归公式 C(n,k) = C(n-1,k-1) + C(n-1,k))。
  5. 练习5:用递归解决“数字反转”问题(输入一个整数,输出反转后的数,如 1234 → 4321,考虑负数?)

15.5 第15章编程作业

作业1:递归求幂

写一个递归函数 double power(double x, int n) 计算 x 的 n 次幂(n 为非负整数)。要求时间复杂度 O(n)。

作业2:猴子吃桃(递归版)

猴子第一天摘了若干桃子,当即吃了一半,还不过瘾,又多吃了一个。第二天又将剩下的桃子吃了一半,又多吃了一个。以后每天都这样。到第10天早上想再吃时,只剩一个桃子了。用递归函数求第一天摘了多少个桃子。

作业3:整数划分

将一个正整数 n 划分成若干个正整数之和,求划分的种数。例如 n=4 有 5 种划分:4, 3+1, 2+2, 2+1+1, 1+1+1+1。用递归实现。

作业4:汉诺塔步数计算

在汉诺塔问题中,移动 n 个盘子需要多少步?推导出公式并用递归验证。

作业5:八皇后问题(选做)

在 8×8 的国际象棋棋盘上放置八个皇后,使得它们互不攻击(即任意两个不在同一行、同一列、同一对角线上)。用递归回溯法求解并输出所有解(或一个解)。


恭喜你完成了第十五章的学习!递归是一种强大的编程思想,它让我们能简洁地解决许多复杂问题。虽然递归有时效率不高,但它的思维方式非常重要,也是学习更高级算法(如动态规划、回溯、分治)的基础。加油!🚀

20260228 142406 Cpp 入门第十四课

第十四章:递推算法——从已知推未知

你好!欢迎来到第十四章!你有没有玩过“多米诺骨牌”?只要推倒第一张,后面的就会一张接一张倒下。这种“由前面推出后面”的思想,就是递推算法。递推在数学和编程中非常重要,比如斐波那契数列、爬楼梯问题,都可以用递推轻松解决。这一章我们就来学习如何用递推思想解决问题!


14.1 递推算法程序范例

先来看一个经典的递推问题:斐波那契数列。数列定义:第一项和第二项都是1,从第三项开始,每一项等于前两项之和。即:
- F(1) = 1
- F(2) = 1
- F(n) = F(n-1) + F(n-2) (n≥3)

用递推算法(循环)求第 n 项:

#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "请输入n:";
    cin >> n;

    if (n <= 2) {
        cout << "第" << n << "项是:1" << endl;
    } else {
        int a = 1, b = 1, c;
        for (int i = 3; i <= n; i++) {
            c = a + b;   // 递推关系
            a = b;       // 向前移动
            b = c;
        }
        cout << "第" << n << "项是:" << b << endl;
    }
    return 0;
}

运行示例(输入10):

第10项是:55

这个程序用循环不断更新前两项的值,一步步推出第 n 项。这就是递推:从已知的初始条件出发,按照递推公式逐步计算。


14.2 递推算法的用法

14.2.1 什么是递推?

递推就是利用已知的初始值,通过一定的递推关系(公式),逐步推出后面的结果。就像搭积木:先放好第一块,然后根据它放第二块,再根据前两块放第三块……

递推通常分为:
- 顺推:从已知条件向后推,比如斐波那契数列。
- 逆推:从结果向前推,但初学者先掌握顺推。

14.2.2 递推的要素

  1. 初始条件:最开始已知的值,比如 F(1)=1, F(2)=1。
  2. 递推关系:如何从前面推出后面,比如 F(n) = F(n-1) + F(n-2)。
  3. 计算顺序:通常用循环从小到大计算,避免重复。

14.2.3 递推 vs 递归

  • 递归:函数自己调用自己,代码简洁,但容易重复计算,效率低(比如斐波那契递归会算很多次)。
  • 递推:用循环从前往后算,每个值只算一次,效率高。

所以,当可以用递推时,优先用递推(循环)。


14.3 编程实例讲解

实例1:爬楼梯问题

题目:小明爬楼梯,每次可以跨1级或2级台阶。问爬到第n级台阶有多少种不同的爬法?

分析
- 到第1级:只有1种(1步)
- 到第2级:有2种(1+1 或 2)
- 到第3级:可以从第1级跨2步,或从第2级跨1步,所以方法数 = 到第1级的方法 + 到第2级的方法 = 1+2=3
- 到第4级:可以从第2级跨2步,或从第3级跨1步,所以 = 2+3=5
- 发现规律:F(n) = F(n-1) + F(n-2)(和斐波那契一样,只是初始值 F(1)=1, F(2)=2)

#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "请输入台阶数n:";
    cin >> n;

    if (n == 1) {
        cout << "爬法总数:1" << endl;
    } else if (n == 2) {
        cout << "爬法总数:2" << endl;
    } else {
        int a = 1, b = 2, c;
        for (int i = 3; i <= n; i++) {
            c = a + b;
            a = b;
            b = c;
        }
        cout << "爬法总数:" << b << endl;
    }
    return 0;
}

实例2:数字三角形(简单版)

题目:输入一个正整数n,输出n行的数字三角形,第i行有i个数字,数字为1~i。

例如 n=4:

1
1 2
1 2 3
1 2 3 4

这不是递推,但可以用递推思想:第i行的数字 = 第i-1行的数字再添加一个 i。但这里我们只是输出,其实用循环即可。但我们换个真正的递推问题:

题目:数字三角形求最大路径(简单版)。有一个数字三角形,从顶部出发,每次可以向下或向右下走,求从顶部到底部的最大路径和。

例如:

   7
  3 8
 8 1 0
2 7 4 4

从顶部7开始,向下走到8,再走到1,再走到7,和为7+8+1+7=23。但最大路径是7+8+1+4=20?等等,需要计算。这个经典问题可以用递推解决:从底部向上递推。

递推思路:用二维数组 a[i][j] 存数字,dp[i][j] 表示从底部到 (i,j) 的最大路径和。从最后一行往上推:
- 最后一行 dp[n-1][j] = a[n-1][j]
- 对上面每一行:dp[i][j] = a[i][j] + max(dp[i+1][j], dp[i+1][j+1])

最后 dp[0][0] 就是答案。

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

int main() {
    int n;
    cout << "请输入三角形行数:";
    cin >> n;
    int a[100][100];
    for (int i = 0; i < n; i++) {
        for (int j = 0; j <= i; j++) {
            cin >> a[i][j];
        }
    }

    // 从倒数第二行开始向上递推
    for (int i = n - 2; i >= 0; i--) {
        for (int j = 0; j <= i; j++) {
            a[i][j] += max(a[i+1][j], a[i+1][j+1]);
        }
    }
    cout << "最大路径和:" << a[0][0] << endl;
    return 0;
}

这个例子展示了逆推的思想。

实例3:蜜蜂爬行

题目:蜜蜂从蜂房1出发,只能爬到编号相邻的蜂房(比如从1到2,从2到3或1,等等)。问从1爬到n有多少种不同的路径?

分析
- 到1:1种(已经在)
- 到2:可以从1来,1种
- 到3:可以从2来,也可以从1来?但1只能到2,不能直接到3?题目说相邻,所以从1只能到2,从2可以到1或3。所以到3的路径:1→2→3,只有1种。实际上这是一个斐波那契数列变种。我们仔细分析:
- 设 f[i] 表示从1到i的路径数。
- f[1] = 1
- f[2] = 1(1→2)
- f[3] = f[2] + f[1]?但1到3不能直接,所以实际上 f[3] = f[2](因为只能从2来)?不对,还可以从哪?蜜蜂可以来回爬,但一般这种问题是不允许重复的,所以通常是从左向右爬,即只能爬到比当前大的编号?这样题目才合理。通常这类题目是“蜜蜂只能向右爬”,那么 f[i] = f[i-1] + f[i-2](因为可以从i-1和i-2来)。初始 f[1]=1, f[2]=1(1→2)。这样 f[3]=f[2]+f[1]=2,即1→2→3 和 1→3(如果允许1直接到3?但题目说相邻,所以1不能直接到3)。所以矛盾。我们假设蜜蜂只能爬到编号大1的蜂房,那就是线性的,只有1种。所以需要明确规则。

通常这类问题是在一个环上?常见的递推题:蜜蜂从蜂房a爬到蜂房b,只能爬向相邻的蜂房,且不能回头,求路径数。这实际上就是斐波那契数列:f(n) = f(n-1) + f(n-2),初始 f(1)=1, f(2)=2?我们来看标准版:在一个直线上,蜜蜂只能向右爬,那么到第i个蜂房的路径数 = 到i-1的路径数 + 到i-2的路径数(因为可以从i-1跨一步,或从i-2跨两步)。这样 f(1)=1, f(2)=2(1→2 或 直接到2?但1不能直接到2?实际上,如果蜂房编号连续,那么从1到2只有一种走法:1→2。但如果我们允许跨两步,那从1可以到2?不,跨两步就到3了。所以常见的是“每次可以走1步或2步”,那就是爬楼梯问题,初始 f(1)=1, f(2)=2。我们以爬楼梯为例即可。

所以我们可以直接用爬楼梯的代码。

实例4:平面分割问题

题目:n条直线最多能把平面分成多少个区域?

分析
- 0条直线:1个区域
- 1条直线:2个区域
- 2条直线:最多4个区域(相交)
- 3条直线:最多7个区域
- 递推关系:第k条直线最多与前面k-1条直线相交于k-1个点,从而被分成k段,每段将原来的一个区域一分为二,所以增加k个区域。
- 设 f(n) 表示n条直线最多区域数,则 f(0)=1, f(n)=f(n-1)+n。

#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "请输入直线条数:";
    cin >> n;
    int f = 1;  // 0条直线
    for (int i = 1; i <= n; i++) {
        f = f + i;   // 递推公式
    }
    cout << "最多能分成" << f << "个区域" << endl;
    return 0;
}

14.4 阶段性编程练习

  1. 练习1:求斐波那契数列的第20项(用递推循环)。
  2. 练习2:一个人上台阶,每次可以跨1级、2级或3级,求上10级台阶有多少种不同的走法。
  3. 练习3:用递推求 n!(阶乘),n! = n * (n-1)!,初始 1! = 1。
  4. 练习4:一对兔子,从出生后第3个月起每个月都生一对兔子。假设兔子不死,问第n个月有多少对兔子?(这就是斐波那契数列的由来,但初始值不同:第1个月1对,第2个月1对,第3个月2对,第4个月3对,第5个月5对… 即 f(1)=1, f(2)=1, f(3)=2, f(4)=3… 其实 f(n)=f(n-1)+f(n-2) 对 n≥3,f(1)=1, f(2)=1。就是斐波那契。)
  5. 练习5:用递推求杨辉三角的第n行(用一维数组递推)。

14.5 第14章编程作业

作业1:母牛的故事

有一头母牛,它每年年初生一头小母牛。每头小母牛从第4个年头开始,每年年初也生一头小母牛。假设没有死亡,问第n年时共有多少头牛?(经典递推题:f(n) = f(n-1) + f(n-3),初始 f(1)=1, f(2)=2, f(3)=3)

作业2:铺砖问题

用 1×2 的砖铺满 2×n 的地面,有多少种铺法?(递推关系:f(n) = f(n-1) + f(n-2),初始 f(1)=1, f(2)=2)

作业3:骨牌铺满

用 2×1 和 2×2 的骨牌铺满 2×n 的地面,有多少种铺法?(递推:f(n) = f(n-1) + 2*f(n-2),初始 f(1)=1, f(2)=3)

作业4:猴子吃桃

猴子第一天摘了若干桃子,当即吃了一半,还不过瘾,又多吃了一个。第二天又将剩下的桃子吃了一半,又多吃了一个。以后每天都这样。到第10天早上想再吃时,只剩一个桃子了。问第一天共摘了多少桃子?(用逆推:从第10天剩1个,往前推第9天的桃子数等。)

作业5:数字三角形最大路径(文件版)

从文件读入一个数字三角形(第一行是行数,后面是三角形数字),用递推求最大路径和,并输出路径(可选)。


恭喜你完成了第十四章的学习!递推是一种非常实用的算法思想,它把复杂问题分解成简单步骤,一步一步推进。加油!🚀

20260228 142314 Cpp 入门第十三课

第十三章:枚举算法——一一列举,找出答案

你好!欢迎来到第十三章!你有没有遇到过这样的问题:一个密码锁有三位数字,忘了密码,只能一个一个试,直到打开。这种“把所有可能都试一遍”的方法,就是枚举算法(也叫穷举法)。虽然听起来有点笨,但计算机最擅长的就是重复劳动,而且速度飞快!这一章我们就来学习如何用枚举思想解决各种有趣的问题。


13.1 枚举算法程序范例

先来看一个最简单的枚举问题:找出1到100之间所有能被3整除的数。

#include <iostream>
using namespace std;

int main() {
    cout << "1到100之间能被3整除的数有:" << endl;
    for (int i = 1; i <= 100; i++) {
        if (i % 3 == 0) {
            cout << i << " ";
        }
    }
    cout << endl;
    return 0;
}

运行结果

1到100之间能被3整除的数有
3 6 9 12 15 18 21 24 27 30 33 36 39 42 45 48 51 54 57 60 63 66 69 72 75 78 81 84 87 90 93 96 99

这就是枚举:把所有可能的数(1到100)都拿出来,逐个判断是否满足条件(能被3整除)。虽然简单,但枚举是很多复杂算法的基础。


13.2 枚举算法的用法

13.2.1 什么是枚举算法?

枚举算法就是把所有可能的情况都列举出来,然后逐个检查哪些是符合要求的。就像警察破案时,把所有嫌疑人一个个调查,找出真正的罪犯。

枚举算法通常包含三个步骤:
1. 确定枚举范围:要试哪些可能性?比如1到100、所有的两位数等。
2. 确定判断条件:什么样的结果是我们要的?比如能被3整除、等于某个值等。
3. 用循环遍历所有可能:用 forwhile 循环,对每个可能进行判断。

13.2.2 枚举的适用场景

  • 搜索空间有限,能全部枚举完(比如最多几百万次)。
  • 没有更高效的数学方法,或者问题很简单。
  • 常用于密码破解、组合问题、方程求解等。

13.2.3 枚举的优化——剪枝

有些时候枚举的范围很大,但很多情况明显不可能,我们可以提前排除,减少循环次数。这叫做剪枝

例如,找三位数中满足 a² + b² = c² 的勾股数,如果 a 和 b 都从1枚举到100,c 也要从1到100,三重循环就是100万次。但我们可以利用 c = sqrt(a²+b²) 直接计算,并检查是否为整数,这样大大减少循环。


13.3 编程实例讲解

实例1:百钱买百鸡

题目:公鸡5元一只,母鸡3元一只,小鸡1元三只。用100元买100只鸡,问公鸡、母鸡、小鸡各多少只?

分析
- 设公鸡 x 只,母鸡 y 只,小鸡 z 只。
- 满足两个方程:
- x + y + z = 100
- 5x + 3y + z/3 = 100(注意小鸡价格是1元3只,所以 z 必须是3的倍数)
- 枚举范围:x 最多 20(因为5×20=100),y 最多 33(因为3×33=99),z = 100 - x - y 且必须是3的倍数且非负。

#include <iostream>
using namespace std;

int main() {
    cout << "所有可能的买法:" << endl;
    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;
}

运行结果

所有可能的买法:
公鸡:0只,母鸡:25只,小鸡:75只
公鸡:4只,母鸡:18只,小鸡:78只
公鸡:8只,母鸡:11只,小鸡:81只
公鸡:12只,母鸡:4只,小鸡:84只

实例2:水仙花数

题目:水仙花数是指一个三位数,其各位数字的立方和等于该数本身。例如 153 = 1³ + 5³ + 3³。找出所有的水仙花数。

分析
- 枚举范围:100 到 999。
- 对每个数,分离出百位、十位、个位,计算立方和,判断是否相等。

#include <iostream>
using namespace std;

int main() {
    cout << "水仙花数有:" << endl;
    for (int num = 100; num <= 999; num++) {
        int a = num / 100;            // 百位
        int b = (num / 10) % 10;       // 十位
        int c = num % 10;              // 个位
        if (a*a*a + b*b*b + c*c*c == num) {
            cout << num << " ";
        }
    }
    cout << endl;
    return 0;
}

运行结果

水仙花数有:
153 370 371 407

实例3:完美数

题目:完美数是指一个数恰好等于它的因子之和(不包括自身)。例如 6 = 1+2+3。找出1000以内的所有完美数。

分析
- 枚举范围:2 到 1000(因为1不算)。
- 对每个数,找出所有小于它的因子,求和,判断是否相等。

#include <iostream>
using namespace std;

int main() {
    cout << "1000以内的完美数有:" << endl;
    for (int num = 2; num <= 1000; num++) {
        int sum = 0;
        for (int i = 1; i <= num / 2; i++) {   // 因子最大不超过 num/2
            if (num % i == 0) {
                sum += i;
            }
        }
        if (sum == num) {
            cout << num << " ";
        }
    }
    cout << endl;
    return 0;
}

运行结果

1000以内的完美数有
6 28 496

实例4:找零问题(枚举+优化)

题目:用1元、2元、5元纸币凑成10元,有多少种凑法?

分析
- 设1元 x 张,2元 y 张,5元 z 张。
- 方程:x + 2y + 5z = 10,且 x,y,z ≥ 0。
- 枚举范围:z 从0到2(因为5*2=10),y 从0到5,x = 10 - 2y - 5z,检查是否非负。

#include <iostream>
using namespace std;

int main() {
    int count = 0;
    for (int z = 0; z <= 2; z++) {
        for (int y = 0; y <= 5; y++) {
            int x = 10 - 2*y - 5*z;
            if (x >= 0) {
                cout << "1元:" << x << "张, 2元:" << y << "张, 5元:" << z << "张" << endl;
                count++;
            }
        }
    }
    cout << "共有" << count << "种凑法" << endl;
    return 0;
}

实例5:质数判断(枚举因子)

虽然质数判断通常用数学优化,但也可以用枚举思想。

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

bool isPrime(int n) {
    if (n <= 1) return false;
    for (int i = 2; i <= sqrt(n); i++) {   // 枚举可能的因子
        if (n % i == 0) return false;
    }
    return true;
}

int main() {
    int num;
    cout << "请输入一个整数:";
    cin >> num;
    if (isPrime(num)) {
        cout << num << " 是质数" << endl;
    } else {
        cout << num << " 不是质数" << endl;
    }
    return 0;
}

13.4 枚举算法的优化技巧

  1. 缩小枚举范围:分析问题,去掉明显不可能的情况。例如百钱买百鸡中,公鸡最多20只,而不是100。
  2. 减少循环层数:能用公式计算的就不循环。比如勾股数中,c 可以用 a、b 计算。
  3. 利用对称性:如果 a 和 b 互换结果相同,可以只枚举 a ≤ b,避免重复。
  4. 剪枝:在循环中提前判断,如果某个条件已经不满足,就跳过后续循环。

13.5 阶段性编程练习

  1. 练习1:找出100到200之间所有的素数。
  2. 练习2:一个两位数,十位与个位之和是9,十位与个位之积等于这个两位数的一半,求这个两位数。
  3. 练习3:用1元、2元、5元、10元纸币凑成20元,有多少种凑法?
  4. 练习4:找出所有满足 a³ + b³ = c³ + d³ 的1到30之间的整数组合(a,b,c,d互不相等,且 a≤b, c≤d,避免重复)。
  5. 练习5:一个四位数的密码,已知千位是百位的2倍,十位是个位的3倍,且这个四位数各位数字之和是20,求这个密码。

13.6 第13章编程作业

作业1:搬砖问题

36块砖,36人搬,男人一次搬4块,女人一次搬3块,小孩两人抬1块。一次搬完,问男人、女人、小孩各多少人?

作业2:三色球问题

有红、黄、蓝三种颜色的球,红球3个,黄球3个,蓝球6个。从中任意摸出8个球,计算摸出的球的各种颜色搭配。

作业3:ABCD × 4 = DCBA

有一个四位数ABCD(A≠0),乘以4后等于DCBA,求这个四位数。(提示:A只能是1或2,D只能是4或8等)

作业4:完美立方

找出所有满足 a³ = b³ + c³ + d³ 的1到30之间的整数,其中 a,b,c,d 互不相等,且 b≤c≤d。

作业5:熄灯问题(选做)

有一个5×6的灯阵,按下一个灯会改变它自身和上下左右四个灯的状态(亮变灭,灭变亮)。给定初始状态,求一种按法使得所有灯都熄灭。这是一个经典的枚举问题,可以用枚举第一行所有可能(2^6=64种),然后递推后面。


恭喜你完成了第十三章的学习!枚举算法虽然简单,但它是很多复杂算法的基础。当你遇到一个问题不知道怎么做时,不妨想一想:能不能把所有可能都试一遍?加油!🚀