2026-02 的文章

返回全部文章

20260228 145742 Cpp 入门第二十三课

第二十三章:字符串算法——文本处理的利器

你好!欢迎来到第二十三章!在第六章我们已经学习了字符串的基本操作,比如输入输出、拼接、查找等。但在实际应用中,我们经常需要更高效的字符串处理——比如在一篇文章中快速查找某个单词,或者判断两个字符串是否相似。这一章我们将学习一些经典的字符串算法,它们能帮助我们更快、更聪明地处理文本!


23.1 字符串基础回顾

在开始新知识前,我们先快速回顾一下 C++ 中字符串的常用操作(主要用 string 类):

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

int main() {
    string s1 = "hello";
    string s2 = "world";

    // 拼接
    string s3 = s1 + " " + s2;   // "hello world"

    // 长度
    cout << s3.length() << endl;  // 11

    // 访问字符
    cout << s3[0] << endl;        // 'h'

    // 查找子串
    int pos = s3.find("world");    // 返回6
    if (pos != string::npos) {
        cout << "找到了,位置:" << pos << endl;
    }

    // 截取子串
    string sub = s3.substr(6, 5);  // "world"

    // 比较
    if (s1 == "hello") { ... }

    return 0;
}

这些操作底层已经帮我们做了很多工作,但如果我们想要更快的查找(比如在一个很长的文本中找很多个模式串),就需要自己设计算法了。


23.2 字符串匹配——朴素算法

问题:给定一个文本串 text 和一个模式串 pattern,判断 pattern 是否在 text 中出现,并返回第一次出现的位置。

最简单直接的方法就是朴素匹配:从文本的每个位置开始,逐一比较字符。

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

int naiveSearch(const string& text, const string& pattern) {
    int n = text.length();
    int m = pattern.length();
    for (int i = 0; i <= n - m; i++) {
        int j;
        for (j = 0; j < m; j++) {
            if (text[i + j] != pattern[j]) break;
        }
        if (j == m) return i;  // 完全匹配
    }
    return -1;  // 未找到
}

int main() {
    string text = "hello world, this is a simple string.";
    string pattern = "world";
    int pos = naiveSearch(text, pattern);
    if (pos != -1) cout << "找到,位置:" << pos << endl;
    else cout << "未找到" << endl;
    return 0;
}

时间复杂度:O(n*m),最坏情况(比如 text = “aaaaaaaaab”, pattern = “aaab”)会很慢。


23.3 KMP算法——更快的匹配

KMP算法(Knuth-Morris-Pratt)通过预处理模式串,利用已经匹配过的信息,避免重复比较。它的核心是部分匹配表(next数组),告诉我们当匹配失败时,模式串可以向右滑动多远。

23.3.1 部分匹配表(next数组)

next[i] 表示在模式串的 [0, i-1] 子串中,最长的相等前后缀的长度。例如模式串 “ababc” 的 next 数组:

  • next[0] = -1(通常定义)
  • i=1: “a” 前后缀长度 0
  • i=2: “ab” 前后缀无相等 → 0
  • i=3: “aba” 前后缀 “a” → 1
  • i=4: “abab” 前后缀 “ab” → 2
  • i=5: “ababc” 前后缀无 → 0

23.3.2 KMP匹配过程

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

vector<int> buildNext(const string& pattern) {
    int m = pattern.length();
    vector<int> next(m + 1, 0);  // next[j] 表示当第j位失配时,j应该回退到的位置
    next[0] = -1;
    int i = 0, j = -1;
    while (i < m) {
        if (j == -1 || pattern[i] == pattern[j]) {
            i++; j++;
            next[i] = j;
        } else {
            j = next[j];
        }
    }
    return next;
}

int kmpSearch(const string& text, const string& pattern) {
    vector<int> next = buildNext(pattern);
    int n = text.length(), m = pattern.length();
    int i = 0, j = 0;
    while (i < n && j < m) {
        if (j == -1 || text[i] == pattern[j]) {
            i++; j++;
        } else {
            j = next[j];
        }
    }
    if (j == m) return i - m;
    return -1;
}

int main() {
    string text = "ababcabcabababd";
    string pattern = "ababd";
    int pos = kmpSearch(text, pattern);
    cout << "找到位置:" << pos << endl;  // 输出10
    return 0;
}

KMP算法的时间复杂度是 O(n+m),比朴素算法快很多。


23.4 字符串哈希——快速比较

哈希(Hash)就是把一个字符串映射成一个整数。如果两个字符串的哈希值相等,它们很可能相等。我们可以用哈希在 O(1) 时间内比较两个子串是否相等。

23.4.1 哈希公式

常用的方法是把字符串看作一个 base 进制数,并对一个大质数取模(比如 1e9+7)。例如字符串 “abc” 可以计算为:

hash = (a * base^2 + b * base^1 + c * base^0) % MOD

其中 a、b、c 是字符对应的数字(比如 ‘a’ = 1)。

为了快速得到任意子串的哈希,我们可以预处理前缀哈希:

h[i] = (h[i-1] * base + s[i]) % MOD

那么子串 s[l..r] 的哈希为:

hash(l, r) = h[r] - h[l-1] * base^(r-l+1)   (可能需要调整取模)

23.4.2 代码实现

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

typedef unsigned long long ULL;
const int BASE = 131;  // 常用基数
const int MOD = 1e9+7;

ULL pow_base[1000005];  // 预计算 base 的幂

void initPow(int n) {
    pow_base[0] = 1;
    for (int i = 1; i <= n; i++) {
        pow_base[i] = pow_base[i-1] * BASE % MOD;
    }
}

vector<ULL> buildHash(const string& s) {
    int n = s.length();
    vector<ULL> h(n+1, 0);
    for (int i = 1; i <= n; i++) {
        h[i] = (h[i-1] * BASE + s[i-1]) % MOD;
    }
    return h;
}

ULL getHash(const vector<ULL>& h, int l, int r) {
    // l, r 从0开始,子串 s[l..r]
    return (h[r+1] - h[l] * pow_base[r-l+1] % MOD + MOD) % MOD;
}

int main() {
    string s = "hello world";
    initPow(s.length());
    auto h = buildHash(s);
    // 比较 "hello" 和 "world" 是否相等
    ULL hash1 = getHash(h, 0, 4);
    ULL hash2 = getHash(h, 6, 10);
    if (hash1 == hash2) cout << "相等" << endl;
    else cout << "不相等" << endl;
    return 0;
}

23.4.3 应用:快速查找模式串

用哈希可以在 O(n) 时间预处理后,O(1) 比较任意子串和模式串的哈希值,从而快速匹配。这种方法简单且实用,不过要注意哈希冲突(可以选两个模数或大质数减少冲突)。


23.5 字典树(Trie)——高效存储和查找

字典树(Trie)是一种树形结构,用于高效地存储和查找字符串集合。每个节点代表一个字符,从根到叶子节点的路径就是一个字符串。

23.5.1 基本操作

  • 插入:从根开始,沿着字符串的字符往下走,如果节点不存在就创建。
  • 查找:同样从根开始,如果能完整走完字符串且最后一个节点标记为结束,则存在。

23.5.2 代码实现

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

const int MAXN = 100005;  // 假设最多这么多节点
int trie[MAXN][26];       // 每个节点有26个字母的指针
int cnt[MAXN];            // 以该节点结尾的单词个数
int nodeCount = 1;        // 根节点编号为0,下一个节点编号从1开始

void insert(const string& word) {
    int p = 0;
    for (char ch : word) {
        int id = ch - 'a';
        if (trie[p][id] == 0) {
            trie[p][id] = nodeCount++;
        }
        p = trie[p][id];
    }
    cnt[p]++;  // 标记单词结尾
}

bool search(const string& word) {
    int p = 0;
    for (char ch : word) {
        int id = ch - 'a';
        if (trie[p][id] == 0) return false;
        p = trie[p][id];
    }
    return cnt[p] > 0;
}

int main() {
    insert("hello");
    insert("world");
    insert("hi");
    cout << search("hello") << endl;  // 1
    cout << search("hell") << endl;   // 0
    return 0;
}

23.5.3 应用

  • 单词自动补全
  • 词频统计
  • 搜索引擎的词典

23.6 编程实例讲解

实例1:重复子串查找

题目:给定一个字符串,找出其中最长的不含重复字符的子串长度(滑动窗口+哈希集合)。

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

int lengthOfLongestSubstring(string s) {
    unordered_set<char> window;
    int left = 0, maxLen = 0;
    for (int right = 0; right < s.length(); right++) {
        while (window.count(s[right])) {
            window.erase(s[left]);
            left++;
        }
        window.insert(s[right]);
        maxLen = max(maxLen, right - left + 1);
    }
    return maxLen;
}

int main() {
    string s = "abcabcbb";
    cout << "最长无重复子串长度:" << lengthOfLongestSubstring(s) << endl; // 3 ("abc")
    return 0;
}

实例2:用Trie统计单词前缀数量

// 在Trie节点中增加一个pass统计经过该节点的次数
int pass[MAXN] = {0};

void insert(const string& word) {
    int p = 0;
    pass[p]++;
    for (char ch : word) {
        int id = ch - 'a';
        if (trie[p][id] == 0) trie[p][id] = nodeCount++;
        p = trie[p][id];
        pass[p]++;
    }
    cnt[p]++;
}

int countPrefix(const string& prefix) {
    int p = 0;
    for (char ch : prefix) {
        int id = ch - 'a';
        if (trie[p][id] == 0) return 0;
        p = trie[p][id];
    }
    return pass[p];  // 经过该节点的单词数就是以prefix为前缀的单词数
}

23.7 阶段性编程练习

  1. 练习1:用朴素算法和KMP分别实现查找,对比速度。
  2. 练习2:实现字符串哈希,并解决“字符串中两个子串是否相等”的问题。
  3. 练习3:用Trie实现一个简单的词典,支持插入和查找。
  4. 练习4:给定一个字符串,求它的最小循环节(提示:KMP的next数组)。
  5. 练习5:用哈希判断一个长字符串中有多少个不同的子串(可以枚举所有子串,用set存哈希,但要注意冲突)。

23.8 第23章编程作业

作业1:重复的DNA序列

给定一个DNA序列(由 A、C、G、T 组成),找出所有出现次数超过一次的长度为10的子串。用哈希或Trie实现。

作业2:单词搜索 II

给定一个字符矩阵和一个单词列表,找出所有同时在矩阵和列表中的单词(可以用Trie+DFS)。

作业3:最短回文串

给定一个字符串,在它前面添加字符,使其变成回文串,求最短的结果。提示:KMP或哈希。

作业4:字符串编码

有一种编码方式:将连续相同的字符替换成该字符加次数,如 “aaabbc” 编码成 “a3b2c1”。实现编码和解码函数。

作业5:敏感词过滤

给定一个文本和一系列敏感词,将文本中所有敏感词替换为 ***。要求高效(提示:可以用Trie + AC自动机,选做)。


恭喜你完成了第二十三章的学习!字符串算法在文本处理、搜索引擎、生物信息学等领域有着广泛的应用。掌握了这些工具,你就能更高效地处理各种文本数据。

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种),然后递推后面。


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

20260228 142212 Cpp 入门第十二课

第十二章:排序算法——让数据井然有序

你好!欢迎来到第十二章!在日常生活中,我们经常需要把东西排好序,比如按身高排队、按分数排名、按时间顺序排列照片。在计算机科学中,排序是最基础也最重要的算法之一。虽然C++的STL提供了强大的 sort 函数,但学习排序算法能帮助我们理解计算机如何工作,锻炼逻辑思维。这一章我们就来学习几种经典的排序算法,并亲手实现它们!


12.1 什么是排序算法?

排序就是将一组数据按照某种规则重新排列,比如从小到大(升序)或从大到小(降序)。

想象你有一堆乱序的扑克牌,你要把它们按数字从小到大整理好。你会怎么做?不同的方法对应不同的排序算法。

本章我们将学习:
- 冒泡排序
- 选择排序
- 插入排序
- 快速排序(进阶)
- 归并排序(进阶)


12.2 冒泡排序

12.2.1 算法思想

冒泡排序就像水中的气泡,轻的气泡会慢慢浮到上面。每次比较相邻的两个元素,如果顺序不对(比如前大后小),就交换它们。这样每一轮都会把最大的数“冒泡”到最后。

过程演示(升序):
初始数组:[5, 3, 8, 1, 2]

第一轮:
- 比较5和3,5>3,交换 → [3, 5, 8, 1, 2]
- 比较5和8,5<8,不交换 → [3, 5, 8, 1, 2]
- 比较8和1,8>1,交换 → [3, 5, 1, 8, 2]
- 比较8和2,8>2,交换 → [3, 5, 1, 2, 8](最大数8已就位)

第二轮:
- 比较3和5,3<5,不交换 → [3, 5, 1, 2, 8]
- 比较5和1,5>1,交换 → [3, 1, 5, 2, 8]
- 比较5和2,5>2,交换 → [3, 1, 2, 5, 8](次大数5就位)

第三轮:
- 比较3和1,3>1,交换 → [1, 3, 2, 5, 8]
- 比较3和2,3>2,交换 → [1, 2, 3, 5, 8](第三大3就位)

第四轮:
- 比较1和2,1<2,不交换 → 已完成

12.2.2 代码实现

#include <iostream>
using namespace std;

void bubbleSort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {          // 需要 n-1 轮
        for (int j = 0; j < n - 1 - i; j++) {  // 每轮比较次数减少
            if (arr[j] > arr[j + 1]) {          // 如果前大后小,交换
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

int main() {
    int arr[] = {5, 3, 8, 1, 2};
    int n = sizeof(arr) / sizeof(arr[0]);

    bubbleSort(arr, n);

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

    return 0;
}

12.2.3 优化

如果某一轮没有发生任何交换,说明数组已经有序,可以提前结束。

void bubbleSortOptimized(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        bool swapped = false;               // 标记是否交换
        for (int j = 0; j < n - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                swap(arr[j], arr[j + 1]);    // 可以用swap函数
                swapped = true;
            }
        }
        if (!swapped) break;                 // 没有交换,退出
    }
}

12.2.4 阶段性练习

  1. 练习1:用冒泡排序对数组 [9, 2, 5, 1, 7] 进行升序排序,写出每轮结果。
  2. 练习2:实现降序冒泡排序(从大到小)。
  3. 练习3:统计冒泡排序中交换的次数。

12.3 选择排序

12.3.1 算法思想

选择排序的思路很简单:每一轮从未排序的部分中找出最小的元素,放到已排序部分的末尾。

过程演示(升序):
初始:[5, 3, 8, 1, 2]
- 第一轮:找到最小元素1,与第一个元素5交换 → [1, 3, 8, 5, 2]
- 第二轮:从剩余[3,8,5,2]中找到最小2,与第二个元素3交换 → [1, 2, 8, 5, 3]
- 第三轮:从剩余[8,5,3]中找到最小3,与第三个元素8交换 → [1, 2, 3, 5, 8]
- 第四轮:从剩余[5,8]中找到最小5,与第四个元素5(自身)不交换 → 完成

12.3.2 代码实现

void selectionSort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int minIndex = i;                     // 假设当前是最小
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j;                  // 更新最小下标
            }
        }
        if (minIndex != i) {                   // 交换
            int temp = arr[i];
            arr[i] = arr[minIndex];
            arr[minIndex] = temp;
        }
    }
}

12.3.3 阶段性练习

  1. 练习1:用选择排序对数组 [8, 3, 6, 1, 4] 排序,写出每轮结果。
  2. 练习2:实现降序选择排序。
  3. 练习3:选择排序的交换次数最多是多少?

12.4 插入排序

12.4.1 算法思想

插入排序就像整理扑克牌:每次将一张牌插入到已经有序的手牌中的正确位置。

过程演示(升序):
初始:[5, 3, 8, 1, 2]
- 第一张牌5,有序部分:[5]
- 取3,插入到5前面 → [3, 5, 8, 1, 2]
- 取8,比5大,位置不变 → [3, 5, 8, 1, 2]
- 取1,依次比较8、5、3,插入最前 → [1, 3, 5, 8, 2]
- 取2,依次比较8、5、3,插入3和1之间 → [1, 2, 3, 5, 8]

12.4.2 代码实现

void insertionSort(int arr[], int n) {
    for (int i = 1; i < n; i++) {
        int key = arr[i];           // 当前要插入的元素
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];    // 向后移动
            j--;
        }
        arr[j + 1] = key;           // 插入正确位置
    }
}

12.4.3 阶段性练习

  1. 练习1:用插入排序对 [7, 2, 5, 1, 9] 排序,写出每一步后数组的变化。
  2. 练习2:实现降序插入排序。
  3. 练习3:插入排序在什么情况下最快?什么情况下最慢?

12.5 快速排序(进阶)

12.5.1 算法思想

快速排序采用分治策略:选择一个基准值(pivot),将数组分成两部分,左边都比基准小,右边都比基准大,然后递归地对左右两部分排序。

过程演示(以第一个元素为基准):
数组:[5, 3, 8, 1, 2]
- 基准5,从右找小于5的2,从左找大于5的8,交换2和8 → [5, 3, 2, 1, 8]
- 继续,从右找小于5的1,从左找大于5的没有,交换1和基准位置?实际上标准做法是用两个指针,最终将基准放到正确位置。
最终得到 [2, 3, 1, 5, 8],左边都小于5,右边大于5。
- 递归排序左边 [2,3,1] 和右边 [8]。

12.5.2 代码实现

int partition(int arr[], int low, int high) {
    int pivot = arr[low];          // 选第一个为基准
    int i = low, j = high;
    while (i < j) {
        while (i < j && arr[j] >= pivot) j--; // 从右找小于pivot的
        if (i < j) arr[i++] = arr[j];
        while (i < j && arr[i] <= pivot) i++; // 从左找大于pivot的
        if (i < j) arr[j--] = arr[i];
    }
    arr[i] = pivot;                // 基准归位
    return i;                      // 返回基准位置
}

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);
    }
}

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

12.5.3 阶段性练习

  1. 练习1:模拟快速排序对 [4, 7, 2, 1, 5, 3] 的过程。
  2. 练习2:尝试以中间元素为基准实现快速排序。

12.6 排序算法的比较

算法 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性
冒泡排序 O(n²) O(n²) O(1) 稳定
选择排序 O(n²) O(n²) O(1) 不稳定
插入排序 O(n²) O(n²) O(1) 稳定
快速排序 O(n log n) O(n²) O(log n) 不稳定
归并排序 O(n log n) O(n log n) O(n) 稳定

稳定性:如果两个相等的元素在排序前后的相对位置不变,则排序算法是稳定的。


12.7 编程实例讲解

实例1:成绩排序

题目:输入n个学生的姓名和成绩,按成绩从高到低排序,如果成绩相同则按姓名字典序排序。

#include <iostream>
#include <string>
#include <algorithm>  // 用sort,但我们可以手写排序
using namespace std;

struct Student {
    string name;
    int score;
};

// 冒泡排序(按成绩降序,成绩相同按姓名升序)
void sortStudents(Student stu[], int n) {
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - 1 - i; j++) {
            if (stu[j].score < stu[j + 1].score || 
                (stu[j].score == stu[j + 1].score && stu[j].name > stu[j + 1].name)) {
                swap(stu[j], stu[j + 1]);
            }
        }
    }
}

int main() {
    int n;
    cout << "请输入学生人数:";
    cin >> n;
    Student stu[100];
    for (int i = 0; i < n; i++) {
        cin >> stu[i].name >> stu[i].score;
    }
    sortStudents(stu, n);
    cout << "\n排序结果:" << endl;
    for (int i = 0; i < n; i++) {
        cout << stu[i].name << " " << stu[i].score << endl;
    }
    return 0;
}

实例2:排序算法效率对比

用随机数生成大量数据,测试不同排序算法的时间。

#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;

// 冒泡排序、选择排序、插入排序、快速排序函数声明...

int main() {
    const int N = 10000;
    int arr1[N], arr2[N], arr3[N], arr4[N];
    srand(time(0));
    for (int i = 0; i < N; i++) {
        int val = rand() % 10000;
        arr1[i] = arr2[i] = arr3[i] = arr4[i] = val;
    }

    clock_t start, end;

    start = clock();
    bubbleSort(arr1, N);
    end = clock();
    cout << "冒泡排序时间:" << double(end - start) / CLOCKS_PER_SEC << "秒" << endl;

    // 测试其他排序...
    return 0;
}

12.8 第12章编程作业

作业1:三种简单排序的对比

编写程序,生成1000个随机整数,分别用冒泡、选择、插入排序进行排序,并输出每种排序的交换次数和比较次数(在代码中加入计数器)。

作业2:排序字符串数组

输入若干单词(字符串),用插入排序按字典序排序输出。

作业3:快速排序的实现

实现快速排序,并测试其效率。

作业4:排序稳定性验证

创建一个包含姓名和分数的结构体数组,其中有两个同分不同名的学生。分别用稳定和不稳定的排序算法排序,观察他们的相对顺序是否改变。

作业5:自定义排序规则

编写一个程序,按以下规则对整数排序:奇数在前,偶数在后;奇数按升序,偶数按降序。例如 [3,2,5,1,8,4] 排序后应为 [1,3,5,8,4,2]。


恭喜你完成了第十二章的学习!现在你已经掌握了多种排序算法,能够处理各种排序问题了。加油!🚀

20260228 142049 Cpp 入门第十一课

第十一章:算法入门——模拟与高精度

你好!欢迎来到第十一章!从这一章开始,我们将正式进入算法学习。算法是解决问题的步骤和方法,就像菜谱一样,告诉计算机一步一步怎么做。本章我们学习两类基础算法:模拟高精度

  • 模拟:按照题目描述的过程,一步一步用程序实现,就像“照葫芦画瓢”。
  • 高精度:当数字太大,连 long long 都存不下时,我们需要用数组或字符串来存储和计算,比如求 100 的阶乘。

让我们一起探索吧!


11.1 模拟算法

11.1.1 什么是模拟?

模拟就是让计算机模仿现实世界的过程。比如,你要写一个程序模拟电梯的运行:有人按按钮,电梯就移动到那一层。你只需要按照电梯的规则一步一步写代码。

11.1.2 模拟程序范例

我们来看一个简单的模拟题:猜拳游戏。两个人玩石头剪刀布,规则是石头赢剪刀、剪刀赢布、布赢石头。输入两人的出拳(0代表石头,1代表剪刀,2代表布),输出谁赢。

#include <iostream>
using namespace std;

int main() {
    int a, b;
    cout << "请输入A和B的出拳(0石头 1剪刀 2布):";
    cin >> a >> b;

    if (a == b) {
        cout << "平局" << endl;
    } else if ((a == 0 && b == 1) || (a == 1 && b == 2) || (a == 2 && b == 0)) {
        cout << "A赢了" << endl;
    } else {
        cout << "B赢了" << endl;
    }

    return 0;
}

这就是一个简单的模拟:根据游戏规则判断胜负。

11.1.3 模拟的步骤

  1. 读懂题目:明确题目描述的规则和过程。
  2. 找出变量:哪些东西会变化,需要用变量表示。
  3. 模拟过程:用循环和判断一步步实现。
  4. 输出结果:根据模拟得到的结果输出。

11.1.4 模拟实例讲解

实例1:报数游戏(约瑟夫环简化)

题目:n个人围成一圈,从第1个人开始报数,数到m的人出列,然后从下一个人继续报数。输入n和m,输出出列顺序。

#include <iostream>
using namespace std;

int main() {
    int n, m;
    cout << "请输入总人数n和报数m:";
    cin >> n >> m;

    bool inGame[100] = {false}; // 标记是否还在游戏中,假设最多100人
    for (int i = 0; i < n; i++) inGame[i] = true;

    int count = 0;      // 当前报的数
    int outCount = 0;   // 已经出列的人数
    int i = 0;          // 当前人的下标

    while (outCount < n) {
        if (inGame[i]) {
            count++;
            if (count == m) {
                cout << i + 1 << " "; // 输出编号(从1开始)
                inGame[i] = false;
                outCount++;
                count = 0;
            }
        }
        i = (i + 1) % n; // 下一个,形成环
    }
    cout << endl;

    return 0;
}

运行示例

请输入总人数n和报数m:5 3
3 1 5 2 4
实例2:电梯模拟

题目:有一部电梯,初始在第1层。输入一系列请求,每个请求包含目标楼层和方向(上或下)。电梯每次移动一层,每移动一层花费1秒,停靠开门关门花费5秒。按请求顺序处理(即先来先服务),计算总时间。

#include <iostream>
using namespace std;

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

    int currentFloor = 1;
    int totalTime = 0;

    for (int i = 0; i < n; i++) {
        int target;
        cout << "请输入目标楼层:";
        cin >> target;

        // 移动时间 = 楼层差绝对值
        totalTime += abs(target - currentFloor);
        currentFloor = target;
        // 停靠时间
        totalTime += 5;
    }

    cout << "总时间:" << totalTime << "秒" << endl;

    return 0;
}

11.1.5 阶段性编程练习(模拟)

  1. 练习1:模拟一个简单的计算器。输入两个数和运算符(+、-、*、/),输出结果,注意除数为0的情况。
  2. 练习2:模拟一个自动售货机。商品价格5元,投入硬币(1元、5元、10元),计算总金额,如果足够支付,输出找零,否则提示金额不足。
  3. 练习3:模拟一个猜数字游戏(电脑随机生成1-100的数,用户猜,提示大小,直到猜中,统计次数)。
  4. 练习4:模拟一个排队系统。有n个人排队,每分钟处理一个人,新来的人排到队尾。输入初始队列(用数组表示),然后模拟3分钟,每分钟输出当前队首被服务,并加入一个新来的人(编号为n+1、n+2…)。

11.2 高精度算法

11.2.1 为什么需要高精度?

C++中,int 大约能存到21亿(10位),long long 大约能存到9×10^18(19位)。如果要计算 100!(约100位),或者两个100位的整数相加,普通变量就不够用了。我们需要用数组字符串来存储每一位数字,然后模拟手工计算的过程。

11.2.2 高精度存储

通常用字符串读入,然后倒序存入整型数组(因为手工计算是从低位到高位)。

例如:数字 12345 存在数组里:a[0]=5, a[1]=4, a[2]=3, a[3]=2, a[4]=1

11.2.3 高精度加法

两个大数相加,从低位到高位逐位相加,处理进位。

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

// 高精度加法,返回结果字符串
string add(string s1, string s2) {
    // 将字符串反转,便于从低位开始计算
    reverse(s1.begin(), s1.end());
    reverse(s2.begin(), s2.end());

    // 确保s1较长
    if (s1.length() < s2.length()) swap(s1, s2);

    int carry = 0; // 进位
    for (size_t i = 0; i < s1.length(); i++) {
        int a = s1[i] - '0';
        int b = i < s2.length() ? s2[i] - '0' : 0;
        int sum = a + b + carry;
        s1[i] = sum % 10 + '0';
        carry = sum / 10;
    }
    if (carry) {
        s1 += carry + '0'; // 最高位有进位
    }
    reverse(s1.begin(), s1.end());
    return s1;
}

int main() {
    string a, b;
    cout << "请输入两个大整数:";
    cin >> a >> b;
    cout << "和:" << add(a, b) << endl;
    return 0;
}

11.2.4 高精度减法

减法需要注意借位,并且要保证结果是非负的(先比较大小)。

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

// 比较两个字符串表示的数,若a>=b返回true
bool greaterOrEqual(string a, string b) {
    if (a.length() != b.length()) return a.length() > b.length();
    return a >= b;
}

// 高精度减法,要求a>=b
string subtract(string a, string b) {
    reverse(a.begin(), a.end());
    reverse(b.begin(), b.end());

    string result;
    int borrow = 0;
    for (size_t i = 0; i < a.length(); i++) {
        int digitA = a[i] - '0' - borrow;
        int digitB = i < b.length() ? b[i] - '0' : 0;
        if (digitA < digitB) {
            digitA += 10;
            borrow = 1;
        } else {
            borrow = 0;
        }
        result += (digitA - digitB) + '0';
    }
    // 去除前导零
    while (result.length() > 1 && result.back() == '0') {
        result.pop_back();
    }
    reverse(result.begin(), result.end());
    return result;
}

int main() {
    string a, b;
    cout << "请输入两个大整数(a >= b):";
    cin >> a >> b;
    if (!greaterOrEqual(a, b)) {
        cout << "a必须大于等于b" << endl;
    } else {
        cout << "差:" << subtract(a, b) << endl;
    }
    return 0;
}

11.2.5 高精度乘法(高精度×低精度)

一个大数乘以一个普通整数(如 long long 范围内)。

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

string multiply(string a, int b) {
    reverse(a.begin(), a.end());
    int carry = 0;
    for (size_t i = 0; i < a.length(); i++) {
        int cur = (a[i] - '0') * b + carry;
        a[i] = cur % 10 + '0';
        carry = cur / 10;
    }
    while (carry) {
        a += carry % 10 + '0';
        carry /= 10;
    }
    reverse(a.begin(), a.end());
    return a;
}

int main() {
    string a;
    int b;
    cout << "请输入大整数a和普通整数b:";
    cin >> a >> b;
    cout << "积:" << multiply(a, b) << endl;
    return 0;
}

11.2.6 高精度乘法(高精度×高精度)

两个大数相乘,模拟竖式乘法。

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

string multiply(string a, string b) {
    int lenA = a.length(), lenB = b.length();
    vector<int> res(lenA + lenB, 0); // 结果最多 lenA+lenB 位

    // 将字符串反转存入整型数组
    reverse(a.begin(), a.end());
    reverse(b.begin(), b.end());
    for (int i = 0; i < lenA; i++) {
        for (int j = 0; j < lenB; j++) {
            res[i + j] += (a[i] - '0') * (b[j] - '0');
            res[i + j + 1] += res[i + j] / 10; // 进位
            res[i + j] %= 10;
        }
    }

    // 去除前导0
    while (res.size() > 1 && res.back() == 0) res.pop_back();
    // 转成字符串
    string result;
    for (int i = res.size() - 1; i >= 0; i--) {
        result += res[i] + '0';
    }
    return result;
}

int main() {
    string a, b;
    cout << "请输入两个大整数:";
    cin >> a >> b;
    cout << "积:" << multiply(a, b) << endl;
    return 0;
}

11.2.7 高精度阶乘

计算 n!,n 较大时结果很长,用高精度乘法。

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

string multiply(string a, int b) {
    // 复用之前的高精度乘低精度
    reverse(a.begin(), a.end());
    int carry = 0;
    for (size_t i = 0; i < a.length(); i++) {
        int cur = (a[i] - '0') * b + carry;
        a[i] = cur % 10 + '0';
        carry = cur / 10;
    }
    while (carry) {
        a += carry % 10 + '0';
        carry /= 10;
    }
    reverse(a.begin(), a.end());
    return a;
}

string factorial(int n) {
    string result = "1";
    for (int i = 2; i <= n; i++) {
        result = multiply(result, i);
    }
    return result;
}

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

11.2.8 阶段性编程练习(高精度)

  1. 练习1:输入两个大整数,输出它们的和、差(保证非负)。
  2. 练习2:输入一个大整数和一个普通整数,输出它们的积。
  3. 练习3:计算斐波那契数列的第100项(用高精度)。
  4. 练习4:输入两个大整数,输出它们的乘积。

11.3 编程实例讲解

实例3:高精度加法应用——大数求和

题目:输入若干个大整数(以0结束),求它们的总和。

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

string add(string a, string b) {
    // 复用之前的加法代码
    // ...
}

int main() {
    string sum = "0", num;
    while (true) {
        cin >> num;
        if (num == "0") break;
        sum = add(sum, num);
    }
    cout << "总和:" << sum << endl;
    return 0;
}

实例4:模拟+高精度——大数阶乘之和

题目:求 1! + 2! + … + n!,其中 n 较大(例如 n=50)。

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

string multiply(string a, int b) { /* ... */ }
string add(string a, string b) { /* ... */ }

string factorial(int n) { /* ... */ }

int main() {
    int n;
    cout << "请输入n:";
    cin >> n;
    string sum = "0";
    for (int i = 1; i <= n; i++) {
        sum = add(sum, factorial(i));
    }
    cout << "1!+2!+...+" << n << "! = " << sum << endl;
    return 0;
}

11.4 第11章编程作业

作业1:模拟银行排队

银行有2个窗口,客户到达时间和服务时间已知,模拟每个客户在哪个窗口办理,以及等待时间。输入客户数n,以及每个客户的到达时间和服务时间(整数),输出每个客户的开始服务时间和结束时间。

作业2:高精度加法器

编写一个程序,可以不断输入两个大整数,输出它们的和,直到用户输入”0 0”结束。

作业3:高精度乘法器

类似作业2,但做乘法。

作业4:大数比较

输入两个大整数,比较它们的大小(大于、小于、等于)。

作业5:回文数判断(高精度版)

输入一个很大的整数(可能100位),判断它是否是回文数(正读反读一样)。如果是,输出YES;否则输出NO。

作业6:模拟+高精度——大数进制转换

输入一个十进制大数(用字符串),将其转换为二进制(也用字符串输出)。


恭喜你完成了第十一章的学习!模拟是算法中最直接的思想,高精度则让我们突破了数据范围的限制。加油!🚀

20260227 152127 Cpp 入门第十课

第十章:C++的百宝箱——STL(标准模板库)

你好!欢迎来到第十章!在前面的章节,我们学习了数组、链表、栈、队列等数据结构,但每次都要自己写代码实现,很麻烦。其实C++已经给我们准备了一个强大的百宝箱,里面有很多常用的数据结构和算法,可以直接拿来用,这就是STL(Standard Template Library,标准模板库)。学完本章,你就能轻松地使用各种容器和算法,让编程变得事半功倍!


10.1 STL程序范例

先来看一个简单的STL程序:使用 vector(动态数组)存储一些整数,然后用 sort 排序,最后用迭代器输出。

#include <iostream>
#include <vector>   // vector容器
#include <algorithm> // 算法,比如sort
using namespace std;

int main() {
    // 创建一个vector,存储整数
    vector<int> numbers;

    // 向尾部添加元素
    numbers.push_back(5);
    numbers.push_back(2);
    numbers.push_back(8);
    numbers.push_back(1);
    numbers.push_back(9);

    cout << "排序前:";
    for (int i = 0; i < numbers.size(); i++) {
        cout << numbers[i] << " ";
    }
    cout << endl;

    // 使用STL的排序算法
    sort(numbers.begin(), numbers.end());

    cout << "排序后:";
    // 使用迭代器遍历
    for (vector<int>::iterator it = numbers.begin(); it != numbers.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl;

    return 0;
}

运行结果

排序前:5 2 8 1 9 
排序后:1 2 5 8 9 

这个程序展示了STL的核心三部分:容器(vector)、算法(sort)、迭代器(iterator)。下面我们一一学习。


10.2 STL概述

STL是C++标准库的一部分,它提供了三大组件:

  • 容器:存储数据的结构,比如动态数组、链表、栈、队列、集合、映射等。
  • 算法:对容器中的数据进行操作的函数,比如排序、查找、替换等。
  • 迭代器:连接容器和算法的桥梁,像指针一样访问容器中的元素。

使用STL的好处:代码简洁、高效、可靠,不用自己重复造轮子。


10.3 容器

容器是用来存放数据的对象。根据组织方式不同,分为序列式容器关联式容器

10.3.1 vector(动态数组)

vector 是一个可以动态增长的数组,支持随机访问(用下标),在尾部插入删除效率高。

基本操作
#include <iostream>
#include <vector>
using namespace std;

int main() {
    // 定义
    vector<int> v1;               // 空vector
    vector<int> v2(5, 10);        // 5个元素,每个都是10
    vector<int> v3 = {1, 2, 3, 4}; // 初始化列表(C++11)

    // 添加元素
    v1.push_back(20);   // 尾部添加
    v1.push_back(30);

    // 访问元素
    cout << v1[0] << endl;         // 下标访问,不检查越界
    cout << v1.at(1) << endl;      // at会检查越界,抛出异常

    // 获取大小
    cout << "v1大小:" << v1.size() << endl;

    // 遍历
    for (int i = 0; i < v1.size(); i++) {
        cout << v1[i] << " ";
    }
    cout << endl;

    // 使用迭代器遍历
    for (vector<int>::iterator it = v1.begin(); it != v1.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl;

    // 使用范围for循环(C++11)
    for (int x : v1) {
        cout << x << " ";
    }
    cout << endl;

    // 删除最后一个元素
    v1.pop_back();

    // 清空
    v1.clear();

    return 0;
}

常用成员函数
- push_back():尾部添加
- pop_back():删除尾部
- size():元素个数
- empty():是否为空
- clear():清空
- begin()end():返回首尾迭代器
- insert()erase():插入和删除(较复杂,需要迭代器)

10.3.2 list(双向链表)

list 是双向链表,不支持随机访问,但插入和删除非常快(尤其在中间)。

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

int main() {
    list<int> lst = {5, 2, 8, 1, 9};

    // 尾部添加
    lst.push_back(10);
    // 头部添加
    lst.push_front(0);

    // 遍历(只能用迭代器,不能用下标)
    for (list<int>::iterator it = lst.begin(); it != lst.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl;

    // 排序(list有自己的sort成员函数,比通用算法快)
    lst.sort();

    for (int x : lst) {
        cout << x << " ";
    }
    cout << endl;

    return 0;
}

10.3.3 deque(双端队列)

deque 是双端队列,支持在头尾快速插入删除,也支持随机访问(但比vector稍慢)。

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

int main() {
    deque<int> dq = {1, 2, 3};
    dq.push_back(4);    // 尾部加
    dq.push_front(0);   // 头部加

    for (int i = 0; i < dq.size(); i++) {
        cout << dq[i] << " ";   // 支持下标
    }
    cout << endl;

    dq.pop_back();      // 删除尾部
    dq.pop_front();     // 删除头部
    return 0;
}

10.3.4 stack(栈)

stack 是适配器容器,它基于其他容器(默认deque)实现,提供后进先出(LIFO)接口。

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

int main() {
    stack<int> st;
    st.push(10);   // 入栈
    st.push(20);
    st.push(30);

    cout << "栈顶元素:" << st.top() << endl; // 30
    st.pop();      // 出栈(无返回值)
    cout << "栈顶元素:" << st.top() << endl; // 20
    cout << "栈大小:" << st.size() << endl;  // 2

    while (!st.empty()) {
        cout << st.top() << " ";
        st.pop();
    }
    return 0;
}

10.3.5 queue(队列)

queue 也是适配器容器,提供先进先出(FIFO)接口。

#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; // 10
    cout << "队尾:" << q.back() << endl;  // 30
    q.pop();      // 出队(队头)
    cout << "队头:" << q.front() << endl; // 20
    return 0;
}

10.3.6 set(集合)

set 是一个有序的集合,元素唯一,自动排序(默认升序)。查找效率高(红黑树)。

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

int main() {
    set<int> s;
    s.insert(5);
    s.insert(2);
    s.insert(8);
    s.insert(2);   // 重复插入无效

    // 遍历(有序)
    for (set<int>::iterator it = s.begin(); it != s.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl;

    // 查找
    set<int>::iterator it = s.find(5);
    if (it != s.end()) {
        cout << "找到了:" << *it << endl;
    } else {
        cout << "没找到" << endl;
    }

    // 删除
    s.erase(2);
    return 0;
}

10.3.7 map(映射)

map 存储键值对(key-value),键唯一,自动排序。

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

int main() {
    map<string, int> scores;
    scores["小明"] = 98;        // 通过键赋值
    scores["小红"] = 95;
    scores["小刚"] = 88;

    // 遍历
    for (map<string, int>::iterator it = scores.begin(); it != scores.end(); ++it) {
        cout << it->first << " : " << it->second << endl;   // first是键,second是值
    }

    // 查找
    string name = "小红";
    map<string, int>::iterator it = scores.find(name);
    if (it != scores.end()) {
        cout << name << "的成绩是:" << it->second << endl;
    } else {
        cout << "没找到" << endl;
    }

    return 0;
}

常用成员函数
- insert(make_pair(key, value)) 或直接用 [key] = value(但 [] 如果键不存在会创建)
- find(key):查找,返回迭代器,找不到返回 end()
- erase(key):删除


10.4 迭代器

迭代器是一种类似于指针的对象,用来遍历容器中的元素。所有容器都提供 begin()end() 成员函数,返回指向第一个元素和最后一个元素之后位置的迭代器。

迭代器分类(按功能):
- 输入迭代器:只读,一次遍历
- 输出迭代器:只写,一次遍历
- 前向迭代器:可读写,只能向前(如 forward_list
- 双向迭代器:可读写,能前后移动(如 listsetmap
- 随机访问迭代器:可读写,支持 +-[] 等(如 vectordeque

使用示例

vector<int> v = {1, 2, 3, 4, 5};

// 正向迭代器
for (vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
    *it = *it * 2;   // 修改元素
}

// 常量迭代器(只读)
for (vector<int>::const_iterator it = v.cbegin(); it != v.cend(); ++it) {
    cout << *it << " ";
}

// 反向迭代器
for (vector<int>::reverse_iterator rit = v.rbegin(); rit != v.rend(); ++rit) {
    cout << *rit << " ";
}

C++11 可以用 auto 简化:

for (auto it = v.begin(); it != v.end(); ++it) { ... }

10.5 算法

STL提供了大量通用算法,都在 <algorithm> 头文件中。它们通过迭代器操作容器,不依赖于具体容器类型。

10.5.1 sort 排序

#include <algorithm>
vector<int> v = {5, 2, 8, 1, 9};
sort(v.begin(), v.end());                // 升序
sort(v.begin(), v.end(), greater<int>()); // 降序

也可以自定义排序规则(用函数或函数对象):

bool cmp(int a, int b) {
    return a > b;   // 降序
}
sort(v.begin(), v.end(), cmp);

10.5.2 find 查找

vector<int> v = {5, 2, 8, 1, 9};
auto it = find(v.begin(), v.end(), 8);
if (it != v.end()) {
    cout << "找到了,位置:" << it - v.begin() << endl;
} else {
    cout << "没找到" << endl;
}

10.5.3 其他常用算法

  • reverse:反转
    cpp reverse(v.begin(), v.end());
  • count:计数
    cpp int cnt = count(v.begin(), v.end(), 5);
  • max_elementmin_element:找最大最小值
    cpp auto maxIt = max_element(v.begin(), v.end()); cout << "最大值:" << *maxIt << endl;
  • binary_search:二分查找(要求容器有序)
    cpp if (binary_search(v.begin(), v.end(), 8)) { ... }
  • copy:复制
    cpp vector<int> v2(v.size()); copy(v.begin(), v.end(), v2.begin());

10.5.4 算法与迭代器的配合

算法通过迭代器操作数据,所以同一个算法可以用于不同的容器。例如 find 可以用于 vector、list、deque 等。


10.6 编程实例讲解

实例1:统计学生成绩(使用vector和map)

题目:输入若干学生姓名和成绩,以“end”结束。然后输出所有学生的成绩,并按成绩从高到低排序输出。

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

struct Student {
    string name;
    int score;
};

bool cmp(const Student &a, const Student &b) {
    return a.score > b.score;   // 降序
}

int main() {
    vector<Student> students;
    string name;
    int score;

    cout << "请输入学生姓名和成绩(输入end结束):" << endl;
    while (true) {
        cin >> name;
        if (name == "end") break;
        cin >> score;
        students.push_back({name, score});
    }

    // 排序
    sort(students.begin(), students.end(), cmp);

    // 输出
    cout << "\n成绩单(按分数降序):" << endl;
    for (const auto &s : students) {
        cout << s.name << " : " << s.score << endl;
    }

    return 0;
}

实例2:单词计数器(使用map)

题目:输入一行英文句子,统计每个单词出现的次数(忽略大小写)。

#include <iostream>
#include <map>
#include <string>
#include <cctype>
#include <sstream>   // 字符串流
using namespace std;

string toLower(string s) {
    for (char &c : s) {
        c = tolower(c);
    }
    return s;
}

int main() {
    string line;
    cout << "请输入一行英文:";
    getline(cin, line);

    map<string, int> wordCount;
    stringstream ss(line);
    string word;

    while (ss >> word) {
        word = toLower(word);
        // 去掉标点符号(简单处理:只保留字母数字)
        string clean;
        for (char c : word) {
            if (isalnum(c)) {
                clean += c;
            }
        }
        if (!clean.empty()) {
            wordCount[clean]++;
        }
    }

    cout << "单词统计:" << endl;
    for (const auto &pair : wordCount) {
        cout << pair.first << " : " << pair.second << endl;
    }

    return 0;
}

实例3:集合运算(使用set)

题目:输入两个集合(整数),输出它们的并集、交集和差集。

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

int main() {
    set<int> set1, set2;
    int n, x;

    cout << "请输入第一个集合的元素个数:";
    cin >> n;
    cout << "请输入" << n << "个整数:";
    for (int i = 0; i < n; i++) {
        cin >> x;
        set1.insert(x);
    }

    cout << "请输入第二个集合的元素个数:";
    cin >> n;
    cout << "请输入" << n << "个整数:";
    for (int i = 0; i < n; i++) {
        cin >> x;
        set2.insert(x);
    }

    // 并集
    set<int> unionSet;
    set_union(set1.begin(), set1.end(), set2.begin(), set2.end(),
              inserter(unionSet, unionSet.begin()));
    cout << "并集:";
    for (int v : unionSet) cout << v << " ";
    cout << endl;

    // 交集
    set<int> interSet;
    set_intersection(set1.begin(), set1.end(), set2.begin(), set2.end(),
                     inserter(interSet, interSet.begin()));
    cout << "交集:";
    for (int v : interSet) cout << v << " ";
    cout << endl;

    // 差集(set1 - set2)
    set<int> diffSet;
    set_difference(set1.begin(), set1.end(), set2.begin(), set2.end(),
                   inserter(diffSet, diffSet.begin()));
    cout << "差集(set1 - set2):";
    for (int v : diffSet) cout << v << " ";
    cout << endl;

    return 0;
}

实例4:使用栈判断括号匹配

题目:输入一个字符串,包含 ()[]{},判断括号是否匹配。

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

bool isMatching(char open, char close) {
    return (open == '(' && close == ')') ||
           (open == '[' && close == ']') ||
           (open == '{' && close == '}');
}

bool checkBrackets(const string &s) {
    stack<char> st;
    for (char c : s) {
        if (c == '(' || c == '[' || c == '{') {
            st.push(c);
        } else if (c == ')' || c == ']' || c == '}') {
            if (st.empty() || !isMatching(st.top(), c)) {
                return false;
            }
            st.pop();
        }
    }
    return st.empty();
}

int main() {
    string expr;
    cout << "请输入表达式:";
    cin >> expr;
    if (checkBrackets(expr)) {
        cout << "括号匹配" << endl;
    } else {
        cout << "括号不匹配" << endl;
    }
    return 0;
}

10.7 阶段性编程练习

练习1:vector练习

输入n个整数,存入vector,然后删除所有偶数,输出剩下的奇数。

练习2:list练习

创建一个list,存放10个随机整数(1-100)。使用list的sort排序,然后反转,输出结果。

练习3:map电话簿

实现一个简单的电话簿,用map存储姓名和电话号码。支持添加、删除、查找、显示所有联系人。

练习4:set去重

输入一串整数,可能有重复,用set去除重复后按升序输出。

练习5:队列模拟

用queue模拟一个打印任务队列。每个任务有名称和页数,按先进先出处理,并输出处理顺序。

练习6:算法综合

生成一个vector包含20个随机整数(1-100),然后:
- 排序
- 查找是否存在50
- 统计大于50的个数
- 删除所有小于30的元素(提示:用erase配合remove_if)


10.8 第10章编程作业

恭喜你学完了STL!现在来挑战几个综合题目,用STL容器和算法解决问题。

作业1:学生成绩管理系统(STL版)

用vector存储学生信息(结构体包含姓名、学号、各科成绩)。实现:
- 添加学生
- 删除学生(按学号)
- 修改成绩
- 按总成绩排序并输出
- 按学号查找
- 统计各科平均分、最高分、最低分

作业2:文章词频统计

读入一篇文章(可以从文件读或手动输入),统计每个单词出现的次数,忽略大小写和标点,最后按词频降序输出前10个单词。

作业3:迷宫最短路径(选做)

用queue实现广度优先搜索(BFS)求解迷宫最短路径。迷宫用二维vector表示,0可走,1障碍。

作业4:多项式计算器

用map存储多项式(指数为键,系数为值)。实现两个多项式的加法、减法、乘法。

作业5:停车场模拟

用stack模拟停车场,queue模拟等待车道。车辆到达时,如果有空位则停入stack,否则进入queue;车辆离开时,需要将上面的车暂时移出(用另一个stack),然后让目标车离开,再移回。输出每次操作后的状态。

20260227 151910 Cpp 入门第九课

第九章:深入理解内存——指针、引用和动态内存

你好!欢迎来到第九章!在前面的章节,我们学习了变量、数组、函数和结构体。你有没有想过,变量在内存中到底存放在哪里?我们如何直接操作内存地址?这一章我们将学习C++中最强大也最需要小心的特性——指针。同时还会学习它的好兄弟引用,以及如何动态地分配内存。学完本章,你就能更深入地理解计算机内存的运作方式!


9.1 指针程序范例

先来看一个最简单的指针程序:定义一个整数变量,然后用指针指向它,通过指针修改它的值。

#include <iostream>
using namespace std;

int main() {
    int a = 10;          // 定义一个普通变量
    int *p;              // 定义一个指针变量,它可以存放整数的地址
    p = &a;              // 把a的地址赋值给p,现在p指向a

    cout << "a的值 = " << a << endl;
    cout << "a的地址 = " << &a << endl;   // &是取地址运算符
    cout << "p的值 = " << p << endl;       // p存的就是a的地址
    cout << "p指向的值 = " << *p << endl;  // *是间接访问运算符,得到p指向的内容

    *p = 20;             // 通过指针修改a的值
    cout << "修改后a的值 = " << a << endl;

    return 0;
}

运行结果(地址会因运行环境不同而变化):

a的值 = 10
a的地址 = 0x61ff08
p的值 = 0x61ff08
p指向的值 = 10
修改后a的值 = 20

这个程序展示了指针的基本操作:&取地址,*解引用。就像你可以通过门牌号找到房子,指针就是内存地址,通过它可以访问变量。


9.2 指针的用法

9.2.1 指针的概念

指针是一个变量,它存储的是另一个变量的内存地址。简单说,指针就是地址的“容器”。每个变量在内存中都有一个地址,就像每间教室都有一个门牌号。

  • &:取地址运算符,获得变量的地址。
  • *:间接访问运算符(解引用),通过地址访问该地址存储的值。

9.2.2 指针的定义和赋值

定义指针的格式:类型 *指针名;
- 例如:int *p; 表示p是一个指向整数的指针。
- double *dp; 表示指向double的指针。
- char *cp; 表示指向字符的指针。

指针必须初始化,否则会变成野指针(指向随机地址),非常危险。

int a = 5;
int *p = &a;   // 定义时初始化
int *q;        // 未初始化,危险!
q = &a;        // 之后赋值也可以

9.2.3 指针的运算

指针可以像整数一样进行加减运算,但它的加减是以指向的类型大小为单位的。

int a[5] = {10, 20, 30, 40, 50};
int *p = &a[0];   // 指向第一个元素

cout << "*p = " << *p << endl;        // 输出10
cout << "*(p+1) = " << *(p+1) << endl; // 输出20,p+1指向下一个整数

指针还可以做减法,得到两个指针之间的元素个数。

9.2.4 指针与数组

数组名在大多数情况下可以看作指向第一个元素的指针。

int a[5] = {1, 2, 3, 4, 5};
int *p = a;          // 等价于 p = &a[0]

for (int i = 0; i < 5; i++) {
    cout << *(p + i) << " ";   // 用指针访问数组
}

但数组名不是真正的指针,它是常量,不能修改。

9.2.5 指针与函数

指针作为函数参数

通过传递指针,函数可以修改调用者的变量(因为知道了地址)。

#include <iostream>
using namespace std;

void swap(int *x, int *y) {
    int temp = *x;
    *x = *y;
    *y = temp;
}

int main() {
    int a = 3, b = 5;
    swap(&a, &b);   // 传递地址
    cout << "a = " << a << ", b = " << b << endl;   // a=5, b=3
    return 0;
}
指针作为函数返回值

函数可以返回指针,但要小心不能返回局部变量的地址(因为函数结束局部变量就销毁了)。

int* findMax(int *arr, int size) {
    int *max = arr;
    for (int i = 1; i < size; i++) {
        if (arr[i] > *max) {
            max = &arr[i];
        }
    }
    return max;   // 返回指向最大元素的指针(安全,因为数组生命周期还在)
}

9.2.6 空指针和野指针

  • 空指针:指向空地址的指针,用 nullptr(C++11)或 NULL 表示。解引用空指针会导致程序崩溃。
    cpp int *p = nullptr; // 推荐使用nullptr if (p != nullptr) { *p = 10; // 安全 }

  • 野指针:未初始化或指向已释放内存的指针,使用它非常危险,可能导致程序崩溃或数据损坏。一定要避免!初始化指针是最好的预防。


9.3 引用

9.3.1 引用的概念

引用是给变量起的一个别名,它和原变量共享同一块内存。定义引用时,必须初始化,而且不能改变指向。

int a = 10;
int &b = a;   // b是a的引用,b就是a的别名
b = 20;       // 修改b相当于修改a
cout << a;    // 输出20

9.3.2 引用的用法

引用常用于函数参数,避免拷贝,并且可以直接修改实参。

#include <iostream>
using namespace std;

void swap(int &x, int &y) {   // 引用参数
    int temp = x;
    x = y;
    y = temp;
}

int main() {
    int a = 3, b = 5;
    swap(a, b);   // 直接传变量,不需要取地址
    cout << "a = " << a << ", b = " << b << endl;   // a=5, b=3
    return 0;
}

引用也可以作为函数返回值,但同样不能返回局部变量的引用。

9.3.3 引用与指针的区别

特性 指针 引用
初始化 可以不初始化(但危险) 必须初始化
可修改 可以改变指向其他变量 一旦绑定,不能改变
访问方式 * 解引用 直接像普通变量一样用
空值 可以为 nullptr 不能为空
用途 动态内存、数组操作等 函数参数、操作符重载等

简单说:引用是更安全、更方便的指针,但功能稍弱。


9.4 动态内存分配

有时候我们不知道程序运行时会需要多少内存(比如需要根据用户输入决定数组大小),这时就需要动态内存分配。C++中用 newdelete 来申请和释放内存。

9.4.1 new和delete

  • new 在堆上分配内存,返回指向该内存的指针。
  • delete 释放由 new 分配的内存。
int *p = new int;   // 分配一个整数大小的内存
*p = 10;
cout << *p << endl;
delete p;           // 释放内存,避免内存泄漏
p = nullptr;        // 将指针置空,避免野指针

也可以初始化:

int *p = new int(20);   // 分配并初始化为20

9.4.2 动态数组

new[] 分配数组,用 delete[] 释放。

int n;
cout << "请输入数组大小:";
cin >> n;
int *arr = new int[n];   // 动态分配数组

for (int i = 0; i < n; i++) {
    arr[i] = i * 10;
}

for (int i = 0; i < n; i++) {
    cout << arr[i] << " ";
}
cout << endl;

delete[] arr;            // 释放数组内存
arr = nullptr;

9.4.3 内存泄漏

如果 new 分配了内存,但忘记 delete,这块内存在程序结束前都无法再使用,造成内存泄漏。程序运行时间越长,占用的内存越多,最终可能崩溃。所以一定要成对使用 new/deletenew[]/delete[]

重要规则
- new 对应 delete
- new[] 对应 delete[]
- 不要重复 delete
- 释放后将指针置空。


9.5 编程实例讲解

实例1:用指针实现字符串复制(C风格)

#include <iostream>
using namespace std;

void stringCopy(char *dest, const char *src) {
    while (*src != '\0') {
        *dest = *src;
        dest++;
        src++;
    }
    *dest = '\0';   // 添加字符串结束符
}

int main() {
    char s1[100] = {0};
    char s2[] = "Hello, C++!";
    stringCopy(s1, s2);
    cout << "复制后的字符串:" << s1 << endl;
    return 0;
}

实例2:动态创建结构体

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

struct Student {
    string name;
    int age;
    double score;
};

int main() {
    Student *p = new Student;   // 动态分配一个学生结构体
    p->name = "小明";           // 用->访问成员,等价于 (*p).name
    p->age = 12;
    p->score = 98.5;

    cout << "姓名:" << p->name << ",年龄:" << p->age << ",成绩:" << p->score << endl;

    delete p;   // 释放内存
    p = nullptr;
    return 0;
}

实例3:动态数组求平均值

#include <iostream>
using namespace std;

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

    double *scores = new double[n];
    double sum = 0;

    for (int i = 0; i < n; i++) {
        cout << "请输入第" << i+1 << "个学生的成绩:";
        cin >> scores[i];
        sum += scores[i];
    }

    double avg = sum / n;
    cout << "平均分:" << avg << endl;

    delete[] scores;
    scores = nullptr;
    return 0;
}

实例4:用引用交换两个数(对比指针)

#include <iostream>
using namespace std;

void swapByPointer(int *x, int *y) {
    int temp = *x;
    *x = *y;
    *y = temp;
}

void swapByReference(int &x, int &y) {
    int temp = x;
    x = y;
    y = temp;
}

int main() {
    int a = 3, b = 5;
    swapByPointer(&a, &b);
    cout << "a=" << a << ", b=" << b << endl;   // 5,3

    int c = 10, d = 20;
    swapByReference(c, d);
    cout << "c=" << c << ", d=" << d << endl;   // 20,10
    return 0;
}

9.6 第9章编程作业

恭喜你学完了指针、引用和动态内存!现在来挑战几个综合题目。

作业1:动态数组排序

编写程序,让用户输入数组大小n,然后动态分配数组,输入n个整数,用冒泡排序(用指针操作数组)排序后输出,最后释放内存。

作业2:字符串统计(用指针)

编写函数 int countWords(const char *str),统计字符串中单词的个数(单词之间用空格分隔)。在 main 中测试。

作业3:动态学生信息管理

用结构体 Student 和动态内存实现:
- 输入学生人数n
- 动态创建学生数组
- 输入每个学生的姓名、年龄、成绩
- 按成绩从高到低排序(用指针或引用实现交换)
- 输出排序后的结果
- 释放内存

作业4:引用与指针对比

写三个函数,分别用传值、传指针、传引用的方式实现一个函数 increment,将参数增加1。在 main 中测试,并观察原变量的变化。

作业5:动态二维数组(选做)

动态创建一个 m×n 的二维数组(用指针的指针),输入矩阵并计算转置。注意释放内存。


好了,第九章的内容就到这里!指针是C++中最灵活也最复杂的部分,需要多加练习才能熟练掌握。记住:指针一定要初始化,new和delete要成对出现。下一章我们将学习更高级的内容——类和对象,进入面向对象编程的世界。加油!🚀

20260227 151824 Cpp 入门第八课

第八章:自定义数据类型——结构体和联合体

你好!欢迎来到第八章!在前面的章节,我们学习了基本数据类型(int、double、char)和数组(一组相同类型的数据)。但生活中很多事物是由不同类型的数据组成的,比如一个学生有姓名(字符串)、年龄(整数)、身高(小数)。如果能把它们组合成一个整体,就方便多了!这就是结构体。另外还有一种特殊的类型叫联合体,可以节省内存。这一章我们就来学习如何自定义这些数据类型。


8.1 结构体程序范例

先来看一个例子:定义一个学生结构体,包含姓名、年龄、成绩,然后创建学生变量并输出信息。

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

// 定义结构体类型 Student
struct Student {
    string name;   // 姓名
    int age;       // 年龄
    double score;  // 成绩
};

int main() {
    // 创建结构体变量并初始化
    Student stu1 = {"小明", 12, 98.5};
    Student stu2;

    // 给stu2的成员赋值
    stu2.name = "小红";
    stu2.age = 11;
    stu2.score = 95.0;

    // 输出学生信息
    cout << "学生1:" << stu1.name << ",年龄" << stu1.age << ",成绩" << stu1.score << endl;
    cout << "学生2:" << stu2.name << ",年龄" << stu2.age << ",成绩" << stu2.score << endl;

    return 0;
}

运行结果

学生1:小明,年龄12,成绩98.5
学生2:小红,年龄11,成绩95

8.2 结构体的用法

8.2.1 结构体的概念

结构体是一种可以包含多个不同数据类型的成员的数据类型。它就像一张表格,每一列都有不同的类型。你可以把结构体看作是自己创造的一种新类型,然后像使用int一样用它定义变量。

8.2.2 定义结构体类型

格式:

struct 结构体名 {
    数据类型 成员1;
    数据类型 成员2;
    ...
};   // 注意最后有分号

例如:

struct Point {
    int x;
    int y;
};

struct Book {
    string title;
    string author;
    double price;
};

8.2.3 创建结构体变量

有几种方式:

// 方式1:先定义类型,再定义变量
struct Point p1;   // C++中可以省略struct关键字,直接写 Point p1;

// 方式2:定义类型的同时定义变量
struct Point {
    int x;
    int y;
} p2, p3;   // 定义了p2和p3两个变量

// 方式3:直接定义匿名结构体变量
struct {
    int x;
    int y;
} p4;   // 但这种无法再用这个类型定义其他变量

8.2.4 初始化结构体变量

可以在定义时用大括号初始化:

Point p1 = {10, 20};           // x=10, y=20
Point p2 = {0};                // x=0, y=0(未指定的成员自动初始化为0)

如果使用C++11,也可以这样:

Point p3 {30, 40};   // 等号可选

8.2.5 访问结构体成员

使用点号 . 访问成员变量:

p1.x = 100;
p1.y = 200;
cout << "(" << p1.x << ", " << p1.y << ")" << endl;

8.2.6 结构体数组

可以把结构体放进数组,就像普通类型一样:

Student class3[3] = {
    {"小明", 12, 98.5},
    {"小红", 11, 95.0},
    {"小刚", 12, 88.0}
};

// 遍历输出
for (int i = 0; i < 3; i++) {
    cout << class3[i].name << " " << class3[i].age << " " << class3[i].score << endl;
}

8.2.7 结构体作为函数参数

结构体可以像普通变量一样传递给函数:

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

struct Student {
    string name;
    int age;
    double score;
};

// 输出学生信息(传值,会复制一份)
void printStudent(Student stu) {
    cout << stu.name << " " << stu.age << " " << stu.score << endl;
}

// 修改学生年龄(传引用,可以直接修改原变量)
void setAge(Student &stu, int newAge) {
    stu.age = newAge;
}

int main() {
    Student s = {"小明", 12, 98.5};
    printStudent(s);
    setAge(s, 13);
    printStudent(s);   // 年龄变为13
    return 0;
}

8.2.8 结构体作为返回值

函数可以返回结构体:

Student createStudent(string name, int age, double score) {
    Student s;
    s.name = name;
    s.age = age;
    s.score = score;
    return s;
}

int main() {
    Student s = createStudent("小红", 11, 95.0);
    printStudent(s);
    return 0;
}

8.2.9 结构体嵌套

结构体的成员可以是另一个结构体:

struct Date {
    int year;
    int month;
    int day;
};

struct Student {
    string name;
    int age;
    double score;
    Date birthday;   // 嵌套结构体
};

int main() {
    Student s = {"小明", 12, 98.5, {2012, 5, 20}};
    cout << "出生日期:" << s.birthday.year << "年" << s.birthday.month << "月" << s.birthday.day << "日" << endl;
    return 0;
}

8.2.10 阶段性编程练习(结构体基础)

  1. 练习1:定义一个结构体 Rectangle,包含长和宽(整数)。写一个函数计算面积并返回。
  2. 练习2:定义一个结构体 Time,包含时、分、秒。写一个函数输入一个时间,输出它过了多少秒(从0:0:0开始)。
  3. 练习3:定义结构体 Student,包含姓名、学号、3门课成绩。输入5个学生,计算每个学生的平均分,并按平均分从高到低排序输出。
  4. 练习4:用结构体嵌套表示一个学生的家庭地址(省、市、街道),并输入输出。

8.3 联合体

8.3.1 联合体程序范例

联合体(union)和结构体类似,但它的所有成员共用同一块内存,也就是说,同时只能存储一个成员的值。它主要用于节省内存或处理不同类型的数据。

#include <iostream>
using namespace std;

union Data {
    int i;
    double d;
    char c;
};

int main() {
    Data data;
    data.i = 10;          // 现在存储整数
    cout << "整数:" << data.i << endl;

    data.d = 3.14;        // 现在存储浮点数,覆盖了之前的整数
    cout << "浮点数:" << data.d << endl;

    data.c = 'A';         // 现在存储字符
    cout << "字符:" << data.c << endl;

    // 注意:此时 data.i 的值已经无效,因为内存被覆盖
    cout << "整数(已无效):" << data.i << endl;   // 可能输出乱码

    return 0;
}

8.3.2 联合体的用法

  • 联合体的定义和结构体类似,只是关键字是 union
  • 所有成员共享同一块内存,所以联合体的大小等于最大成员的大小。
  • 在同一时刻只能使用一个成员,否则会导致数据混乱。
  • 常用于需要多种类型但只存一种的场景,比如协议解析、节省内存等。
定义和使用
union Value {
    int i;
    float f;
    char str[20];
};

int main() {
    Value v;
    v.i = 100;
    cout << v.i << endl;
    v.f = 3.14;   // 覆盖
    cout << v.f << endl;
    // 注意:不能再访问 v.i,因为值已被覆盖
    return 0;
}

8.3.3 联合体和结构体的区别

  • 结构体:每个成员都有自己的内存空间,可以同时存储所有成员的值。
  • 联合体:所有成员共用同一块内存,只能存储一个成员的值。

8.3.4 阶段性编程练习(联合体)

  1. 练习1:定义一个联合体,包含 int、double、char,分别输入并输出,观察内存覆盖现象。
  2. 练习2:用联合体实现一个可以存储整数或浮点数的变量,并编写一个函数,根据类型打印(需要额外用一个变量标记当前类型,这通常和联合体一起使用,构成“带标记的联合体”)。

8.4 枚举类型(补充知识)

虽然大纲没有明确要求,但枚举类型也是自定义数据类型的一种,常和结构体一起使用,这里简单介绍一下。

枚举用于定义一组命名的整数常量,使代码更易读。

#include <iostream>
using namespace std;

enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN };
// 默认 MON=0, TUE=1, ...

int main() {
    Weekday today = WED;
    if (today == WED) {
        cout << "今天是星期三" << endl;
    }

    // 枚举值可以转成整数
    cout << "MON = " << MON << endl;   // 输出0

    return 0;
}

可以指定枚举的值:

enum Color { RED = 1, GREEN = 2, BLUE = 4 };

C++11引入了强类型枚举(enum class),但初学者了解基本枚举即可。


8.5 编程实例讲解

实例1:学生成绩管理系统(结构体数组)

题目:有N个学生,每个学生有姓名、学号、语文、数学、英语成绩。要求:
- 输入学生信息
- 计算每个学生的总分和平均分
- 输出所有学生信息(包括总分平均分)
- 按总分从高到低排序输出

#include <iostream>
#include <string>
#include <algorithm>   // 用sort需要,但我们自己实现排序函数
using namespace std;

struct Student {
    string name;
    int id;
    int chinese;
    int math;
    int english;
    int total;      // 总分,可以计算后存储
    double average; // 平均分
};

// 输入学生信息
void inputStudent(Student &s) {
    cout << "请输入姓名、学号、语文、数学、英语:";
    cin >> s.name >> s.id >> s.chinese >> s.math >> s.english;
    s.total = s.chinese + s.math + s.english;
    s.average = s.total / 3.0;
}

// 输出学生信息
void printStudent(const Student &s) {
    cout << s.name << "\t" << s.id << "\t" << s.chinese << "\t" << s.math << "\t" << s.english
         << "\t总分:" << s.total << "\t平均:" << s.average << endl;
}

// 按总分冒泡排序(降序)
void sortStudents(Student stu[], int n) {
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - 1 - i; j++) {
            if (stu[j].total < stu[j + 1].total) {   // 降序
                Student temp = stu[j];
                stu[j] = stu[j + 1];
                stu[j + 1] = temp;
            }
        }
    }
}

int main() {
    const int N = 3;   // 假设3个学生
    Student students[N];

    // 输入
    for (int i = 0; i < N; i++) {
        cout << "请输入第" << i+1 << "个学生信息:" << endl;
        inputStudent(students[i]);
    }

    // 排序
    sortStudents(students, N);

    // 输出
    cout << "\n排序后的学生信息(按总分降序):" << endl;
    cout << "姓名\t学号\t语文\t数学\t英语\t总分\t平均" << endl;
    for (int i = 0; i < N; i++) {
        printStudent(students[i]);
    }

    return 0;
}

实例2:点与矩形(结构体嵌套)

定义一个点结构体,一个矩形结构体(由左上点和右下点确定)。写函数判断一个点是否在矩形内(包括边界)。

#include <iostream>
using namespace std;

struct Point {
    int x;
    int y;
};

struct Rectangle {
    Point topLeft;     // 左上角
    Point bottomRight; // 右下角
};

// 判断点p是否在矩形r内
bool isPointInRect(const Point &p, const Rectangle &r) {
    return (p.x >= r.topLeft.x && p.x <= r.bottomRight.x &&
            p.y <= r.topLeft.y && p.y >= r.bottomRight.y);   // 注意y轴方向:左上角y大,右下角y小
}

int main() {
    Rectangle rect = { {0, 10}, {10, 0} };   // 左上(0,10),右下(10,0)
    Point p;
    cout << "输入点的坐标(x y):";
    cin >> p.x >> p.y;

    if (isPointInRect(p, rect)) {
        cout << "点在矩形内" << endl;
    } else {
        cout << "点不在矩形内" << endl;
    }

    return 0;
}

实例3:带标记的联合体(简单模拟)

有时我们需要一个变量能存储不同类型的数据,并记住当前是什么类型。可以用结构体包含一个枚举类型和一个联合体。

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

enum DataType { TYPE_INT, TYPE_DOUBLE, TYPE_CHAR };

struct Variant {
    DataType type;
    union {
        int i;
        double d;
        char c;
    } data;
};

void printVariant(const Variant &v) {
    switch (v.type) {
        case TYPE_INT:
            cout << "整数:" << v.data.i << endl;
            break;
        case TYPE_DOUBLE:
            cout << "浮点数:" << v.data.d << endl;
            break;
        case TYPE_CHAR:
            cout << "字符:" << v.data.c << endl;
            break;
    }
}

int main() {
    Variant v1, v2, v3;
    v1.type = TYPE_INT;
    v1.data.i = 100;

    v2.type = TYPE_DOUBLE;
    v2.data.d = 3.14159;

    v3.type = TYPE_CHAR;
    v3.data.c = 'A';

    printVariant(v1);
    printVariant(v2);
    printVariant(v3);

    return 0;
}

8.6 第8章编程作业

恭喜你学完了结构体和联合体!现在来挑战几个综合题目。

作业1:图书管理系统

定义一个结构体 Book,包含书名、作者、出版社、价格、库存数量。实现以下功能:
- 输入一批图书信息(假设最多100本)
- 按书名查找图书,输出信息
- 按价格区间查找(如输入最低价和最高价)
- 统计库存总量和总价值

作业2:日期计算器

定义结构体 Date(年、月、日)。写函数:
- 判断某年是否是闰年
- 计算两个日期之间相差多少天(假设在同一年,或不同年)
- 输入一个日期,输出它是该年的第几天

作业3:学生成绩统计(文件版扩展)

用结构体数组存储学生信息,包括姓名、学号、多门课成绩。要求:
- 从文件读取(暂时可以先从键盘输入)
- 计算每门课的平均分、最高分、最低分
- 按总成绩排名
- 输出不及格学生名单

作业4:联合体应用——解析IP地址

IP地址通常用点分十进制表示,但也可以看作一个32位整数。用联合体实现:可以分别以整数形式和4个字节形式访问同一个IP。写程序输入一个整数形式的IP,输出点分十进制;或者反过来。(提示:可以用 unsigned intunsigned char 数组共用内存)

作业5:简单通讯录

定义结构体 Contact,包含姓名、电话、邮箱。实现一个简单的通讯录管理系统,支持添加、删除、修改、查找、显示所有联系人。


好了,第八章的内容就到这里!你已经学会了如何用结构体组织不同类型的数据,用联合体节省内存,还了解了枚举类型。这些自定义类型让程序能更好地描述现实世界中的事物。加油!🚀

20260227 120331 Cpp 入门第七课

第七章:让程序更聪明——函数

你好!欢迎来到第七章!在前面的章节,我们写的程序都是顺序执行的,代码都挤在 main 函数里。如果有一段代码需要重复使用,难道要复制粘贴很多遍吗?那太麻烦了!这时候就需要函数——把一段代码打包成一个“积木块”,需要的时候随时调用。这一章我们就来学习如何自己创造函数,让程序更聪明、更简洁!


7.1 函数程序范例

先来看一个简单的例子:我们写一个函数,用来计算两个整数的和,然后在 main 里调用它。

#include <iostream>
using namespace std;

// 定义一个函数,名字叫 add,它的功能是返回两个整数的和
int add(int x, int y) {
    int result = x + y;
    return result;      // 把结果返回给调用者
}

int main() {
    int a = 10, b = 20;
    int sum = add(a, b);   // 调用 add 函数,把 a 和 b 传进去,得到返回值
    cout << "a + b = " << sum << endl;

    int c = 30, d = 40;
    cout << "c + d = " << add(c, d) << endl;   // 也可以直接输出返回值

    return 0;
}

运行结果

a + b = 30
c + d = 70

这个程序里,我们定义了一个 add 函数,它接受两个整数参数,返回它们的和。在 main 中,我们可以多次调用它,不用重复写加法代码。


7.2 函数的用法

7.2.1 函数的概念

函数就是一段可以重复使用的代码块,它有自己的名字,你可以通过这个名字来执行它。就像你有一个“做作业”的流程:拿出作业本、写作业、检查、合上本子。你可以把这个流程打包成一个函数叫 doHomework(),每次想写作业时就调用它。

函数的好处:
- 避免重复代码:相同的逻辑只写一次。
- 模块化:把大问题拆成小问题,每个函数解决一个小问题。
- 便于修改和维护:如果需要修改某个功能,只需改函数内部,不用到处找。

7.2.2 语句块与作用域

语句块就是用大括号 {} 括起来的一组语句。比如 if 语句的后面、循环的后面,还有函数体都是语句块。

作用域指的是变量在程序中的有效范围。在一个语句块内定义的变量,只能在这个块内部使用,块外面是访问不到的。这叫做局部变量

#include <iostream>
using namespace std;

int main() {
    int a = 10;   // 这是 main 函数内的局部变量

    if (a > 5) {
        int b = 20;   // b 只在这个 if 块内有效
        cout << a << " " << b << endl;   // 可以访问 a 和 b
    }

    // cout << b;   // 错误!b 在这里已经不存在了
    return 0;
}

函数也是语句块,函数内部定义的变量只属于这个函数,其他函数不能直接访问。

7.2.3 自定义函数介绍

定义一个函数的基本格式:

返回值类型 函数名(参数列表) {
    // 函数体:要执行的代码
    return 返回值;   // 如果返回值类型不是 void,必须返回对应类型的值
}
  • 返回值类型:函数执行完后要返回什么类型的数据,比如 intdoublechar,如果没有返回值就用 void
  • 函数名:自己起名字,要符合变量命名规则,最好能说明功能。
  • 参数列表:函数需要的输入数据,可以有多个,用逗号分隔,每个参数要写明类型和名字。也可以没有参数,写成 ()(void)
  • 函数体:具体执行的代码。
  • return 语句:把结果返回给调用者。如果返回值类型是 void,可以没有 return,或者只写 return; 表示结束函数。

例子:一个没有参数、没有返回值的函数

#include <iostream>
using namespace std;

// 输出欢迎信息
void sayHello() {
    cout << "你好,欢迎学习C++!" << endl;
}

int main() {
    sayHello();   // 调用函数
    sayHello();   // 可以多次调用
    return 0;
}

例子:有参数但没有返回值

#include <iostream>
using namespace std;

// 输出两个数的和,但不返回结果
void printSum(int x, int y) {
    cout << x << " + " << y << " = " << x + y << endl;
}

int main() {
    printSum(3, 5);
    printSum(10, 20);
    return 0;
}

7.2.4 函数的返回值

return 语句有两个作用:
1. 结束函数的执行,返回到调用它的地方。
2. 把后面的值返回给调用者。

例子:返回较大值的函数

#include <iostream>
using namespace std;

int max(int x, int y) {
    if (x > y) {
        return x;
    } else {
        return y;
    }
}

int main() {
    int a = 15, b = 20;
    int m = max(a, b);
    cout << "较大的数是:" << m << endl;
    return 0;
}

注意:如果函数声明了返回值类型(非 void),那么所有分支都必须有 return,否则编译错误。

7.2.5 函数的形参与实参

  • 形参(形式参数):定义函数时写的参数,就像占位符,告诉调用者需要传什么类型的数据。比如 int add(int x, int y) 中的 xy
  • 实参(实际参数):调用函数时实际传递的值,比如 add(3, 5) 中的 35

调用时,实参会复制给形参,然后在函数内部使用形参。函数内部对形参的修改不会影响实参(除非传的是指针或引用,但初学者先不管)。

#include <iostream>
using namespace std;

void change(int x) {
    x = 100;   // 修改形参
    cout << "函数内部 x = " << x << endl;
}

int main() {
    int a = 10;
    change(a);
    cout << "main 中 a = " << a << endl;   // a 还是 10,没变
    return 0;
}

7.2.6 函数的声明

在C++中,函数必须先声明或定义,然后才能调用。如果函数的定义写在调用之后,就需要提前声明(也叫函数原型)。

#include <iostream>
using namespace std;

// 函数声明,告诉编译器有这个函数,后面再定义
int max(int x, int y);

int main() {
    int a = 5, b = 8;
    cout << max(a, b) << endl;
    return 0;
}

// 函数定义
int max(int x, int y) {
    return (x > y) ? x : y;
}

函数声明只需要写返回值类型、函数名和参数类型,可以省略参数名(但建议保留,便于阅读)。

7.2.7 函数的调用与递归

调用很简单,写函数名加括号和实参即可。

递归:函数自己调用自己。就像俄罗斯套娃,一层套一层。递归必须有一个结束条件,否则会无限循环。

例子:用递归计算阶乘(n! = 1×2×…×n)

#include <iostream>
using namespace std;

int factorial(int n) {
    if (n == 0 || n == 1) {   // 递归结束条件
        return 1;
    } else {
        return n * factorial(n - 1);   // 自己调用自己
    }
}

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

执行过程(比如 n=3):
- factorial(3) 返回 3 * factorial(2)
- factorial(2) 返回 2 * factorial(1)
- factorial(1) 返回 1
- 然后一步步返回:factorial(2) = 21=2, factorial(3)=32=6。

递归虽然有趣,但初学者容易绕晕。刚开始只要理解概念就好。

7.2.8 数字查找之顺序和二分

这一节结合函数来实现查找算法。

顺序查找

在一个数组中找一个数,从第一个开始一个一个比较,直到找到或找完。

#include <iostream>
using namespace std;

// 顺序查找函数:在数组a中找key,返回下标,找不到返回-1
int sequentialSearch(int a[], int n, int key) {
    for (int i = 0; i < n; i++) {
        if (a[i] == key) {
            return i;   // 找到了,返回下标
        }
    }
    return -1;   // 没找到
}

int main() {
    int arr[] = {34, 67, 12, 89, 45, 23, 56};
    int n = sizeof(arr) / sizeof(arr[0]);
    int key;
    cout << "请输入要查找的数:";
    cin >> key;
    int pos = sequentialSearch(arr, n, key);
    if (pos != -1) {
        cout << "找到了,位置是:" << pos << endl;
    } else {
        cout << "没找到" << endl;
    }
    return 0;
}
二分查找

二分查找要求数组必须是有序的(升序)。它每次都和中间元素比较,如果等于就找到;如果小于中间,就在左半部分找;如果大于,就在右半部分找。这样每次都能排除一半。

#include <iostream>
using namespace std;

// 二分查找函数:在升序数组a中找key,返回下标,找不到返回-1
int binarySearch(int a[], int n, int key) {
    int left = 0, right = n - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;   // 防止溢出
        if (a[mid] == key) {
            return mid;
        } else if (a[mid] < key) {
            left = mid + 1;   // 去右半部分
        } else {
            right = mid - 1;  // 去左半部分
        }
    }
    return -1;
}

int main() {
    int arr[] = {12, 23, 34, 45, 56, 67, 89};   // 必须有序
    int n = sizeof(arr) / sizeof(arr[0]);
    int key;
    cout << "请输入要查找的数:";
    cin >> key;
    int pos = binarySearch(arr, n, key);
    if (pos != -1) {
        cout << "找到了,位置是:" << pos << endl;
    } else {
        cout << "没找到" << endl;
    }
    return 0;
}

对比:顺序查找适用于任何数组,但慢;二分查找快,但要求数组有序。


7.3 编程实例讲解

实例1:判断素数函数

写一个函数,判断一个整数是否是素数(只能被1和自身整除的数)。

#include <iostream>
#include <cmath>   // 用sqrt函数
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;
}

实例2:求最大公约数函数(辗转相除法)

#include <iostream>
using namespace std;

int gcd(int a, int b) {
    while (b != 0) {
        int temp = a % b;
        a = b;
        b = temp;
    }
    return a;
}

int main() {
    int x, y;
    cout << "请输入两个整数:";
    cin >> x >> y;
    cout << "最大公约数是:" << gcd(x, y) << endl;
    return 0;
}

实例3:递归求斐波那契数列第n项

斐波那契数列:1, 1, 2, 3, 5, 8, 13, … 从第三项起,每一项等于前两项之和。

#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 << "请输入项数:";
    cin >> n;
    cout << "第" << n << "项是:" << fib(n) << endl;
    return 0;
}

注意:递归虽然简单,但效率低,因为重复计算很多。可以用循环改进。

实例4:数组排序函数

把冒泡排序封装成函数,可以对任意整数数组排序。

#include <iostream>
using namespace std;

// 冒泡排序函数,升序
void bubbleSort(int a[], int n) {
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - 1 - i; j++) {
            if (a[j] > a[j + 1]) {
                int temp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = temp;
            }
        }
    }
}

// 输出数组函数
void printArray(int a[], int n) {
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
}

int main() {
    int arr[] = {34, 67, 12, 89, 45};
    int n = sizeof(arr) / sizeof(arr[0]);

    cout << "排序前:";
    printArray(arr, n);

    bubbleSort(arr, n);

    cout << "排序后:";
    printArray(arr, n);

    return 0;
}

7.4 第7章编程作业

恭喜你学完了函数!现在来挑战几个综合题目,检验一下学习成果。

作业1:计算器函数

写四个函数:addsubmuldiv(除法要考虑除数为0的情况)。然后在 main 中让用户输入两个数和运算符,调用对应的函数计算结果。

作业2:数组统计函数

写一个函数,接受一个整数数组和数组长度,返回数组中的最大值、最小值、平均值(可以返回多个值吗?初学者可以用引用参数或返回结构体,但这里可以定义多个函数分别求,或者用全局变量。建议先定义多个函数:int maxArray(int a[], int n)int minArray(...)double avgArray(...))。

作业3:进制转换函数

写一个函数,将一个十进制整数转换为二进制字符串(用string返回)。提示:不断除以2取余,最后反转字符串。

作业4:递归求幂

用递归实现求 x 的 n 次幂(n为非负整数)。x^n = x * x^(n-1),当n=0时返回1。

作业5:猜数字游戏(函数版)

把猜数字游戏的主要逻辑写成函数:比如 void playGame(int maxNumber, int maxTries),在 main 中调用开始游戏。


好了,你已经学会了如何自己创造函数,把程序拆分成一个个小模块,这样代码更清晰、更容易维护。加油!🚀

20260227 120039 Cpp 入门第六课

第六章:处理文字——字符串

你好!欢迎来到第六章!在前面的章节,我们学习了数字和数组。但生活中我们更多接触的是文字,比如名字、句子、文章。在C++中,文字数据叫做字符串。这一章我们会学习如何存储和操作字符串,包括古老的C风格字符串和更方便的C++ string 类。学完本章,你就能编写处理文字的程序了!


6.1 字符串程序范例

我们先来看一个简单的程序,它使用了C++中的 string 类型(最方便的一种字符串)。

#include <iostream>
#include <string>   // 使用string需要包含这个头文件
using namespace std;

int main() {
    string name;    // 定义一个字符串变量,名字叫name

    cout << "请输入你的名字:";
    cin >> name;    // 输入字符串(注意:不能有空格)

    cout << "你好," << name << "!欢迎学习C++字符串!" << endl;

    return 0;
}

运行示例(假设输入“小明”):

请输入你的名字:小明
你好,小明!欢迎学习C++字符串!

这个程序展示了字符串的基本输入输出。接下来我们会深入学习字符串的各种用法。


6.2 字符串的用法

6.2.1 字符的操作

字符串是由一个个字符组成的。在C++中,字符用 char 类型表示。我们先学会操作单个字符。

字符的定义和赋值
char ch1 = 'A';      // 单个字符用单引号括起来
char ch2 = 'b';
char ch3 = '5';
char ch4 = '!';
字符的输入输出
char ch;
cout << "请输入一个字符:";
cin >> ch;           // 输入一个字符
cout << "你输入的字符是:" << ch << endl;
字符的ASCII码

每个字符在计算机中都有一个对应的整数编码,叫做ASCII码。例如 'A' 的ASCII码是65,'a' 是97,'0' 是48。字符可以和整数进行运算。

char ch = 'A';
cout << (int)ch << endl;   // 输出65,将字符转为整数
cout << char(65) << endl;  // 输出A,将整数转为字符
字符的运算

因为字符本质上是整数,所以可以进行加减运算。

char ch = 'A';
ch = ch + 1;                // 变成 'B'
cout << ch << endl;

// 小写转大写
char lower = 'b';
char upper = lower - 32;    // 因为小写字母比大写字母ASCII码大32
cout << upper << endl;      // 输出'B'
常用字符判断(需要包含 <cctype> 头文件)

C++提供了许多判断字符类型的函数,非常方便。

函数 作用
isalpha(ch) 判断是否是字母
isdigit(ch) 判断是否是数字
islower(ch) 判断是否是小写字母
isupper(ch) 判断是否是大写字母
isspace(ch) 判断是否是空格
tolower(ch) 转换为小写
toupper(ch) 转换为大写
#include <iostream>
#include <cctype>   // 字符处理函数
using namespace std;

int main() {
    char ch;
    cout << "请输入一个字符:";
    cin >> ch;

    if (isalpha(ch)) {
        cout << ch << " 是字母" << endl;
        if (islower(ch)) {
            cout << "它是小写字母,大写形式是:" << char(toupper(ch)) << endl;
        } else {
            cout << "它是大写字母,小写形式是:" << char(tolower(ch)) << endl;
        }
    } else if (isdigit(ch)) {
        cout << ch << " 是数字" << endl;
    } else {
        cout << ch << " 是其他字符" << endl;
    }

    return 0;
}

6.2.2 字符数组

字符数组是C语言风格的字符串,就是用一个数组来存放多个字符,最后以 '\0' 结尾。

定义和初始化
char str1[10] = {'H', 'e', 'l', 'l', 'o', '\0'};  // 手动加结束符
char str2[10] = "Hello";        // 自动在末尾加 '\0',最常用
char str3[] = "Hello";          // 编译器自动计算大小为6(包括'\0')
访问和遍历
char str[] = "Hello";
for (int i = 0; i < 5; i++) {
    cout << str[i] << " ";      // 输出 H e l l o
}
cout << endl;

// 也可以用while循环,直到遇到'\0'
int i = 0;
while (str[i] != '\0') {
    cout << str[i];
    i++;
}
修改字符数组中的元素
char str[] = "Hello";
str[0] = 'h';   // 修改第一个字符
cout << str;    // 输出 "hello"

注意:字符数组的大小一旦定义就不能改变,所以要保证足够大。

6.2.3 字符串的输入和输出

C风格字符串的输入
  • cin >> 输入,但遇到空格会停止。
  • cin.getline() 可以输入带空格的整行。
#include <iostream>
using namespace std;

int main() {
    char name[50];
    cout << "请输入你的名字(无空格):";
    cin >> name;                // 只能输入一个单词
    cout << "你好," << name << endl;

    // 清空输入缓冲区(因为后面要用getline,之前cin会留下换行符)
    cin.ignore();

    char sentence[100];
    cout << "请输入一句话(可带空格):";
    cin.getline(sentence, 100); // 读取整行,最多99个字符
    cout << "你输入的是:" << sentence << endl;

    return 0;
}
string类的输入
  • cin >> 输入,同样遇空格停止。
  • getline(cin, 字符串变量) 输入整行。
#include <iostream>
#include <string>
using namespace std;

int main() {
    string name, sentence;

    cout << "请输入你的名字:";
    cin >> name;
    cout << "你好," << name << endl;

    cin.ignore();   // 忽略输入缓冲区中的换行符

    cout << "请输入一句话:";
    getline(cin, sentence);   // 读取整行
    cout << "你输入的是:" << sentence << endl;

    return 0;
}

6.2.4 字符串结束符‘\0’

C风格字符串以 '\0'(空字符,ASCII码0)作为结束标志。它告诉程序字符串在哪里结束。

为什么需要 '\0'

因为字符数组的长度可能大于字符串的实际长度,如果没有结束符,程序就不知道到哪里是字符串的结尾。比如:

char str[10] = "Hello";   // 实际存储:H e l l o \0 ? ? ? ?

当输出 str 时,函数会从第一个字符开始一直输出直到遇到 '\0'。如果忘了加 '\0',可能会输出乱码直到遇到内存中的某个0。

手动添加 '\0'

如果自己逐个字符赋值,必须手动加上 '\0'

char str[5];
str[0] = 'C';
str[1] = '+';
str[2] = '+';
str[3] = '\0';   // 必须加!
cout << str;     // 输出 "C++"

如果忘记加,cout 会继续向后输出内存中的内容,直到偶然遇到0,可能导致程序崩溃或输出乱码。

6.2.5 字符串常用函数

C语言提供了一些处理字符串的函数,它们定义在 <cstring> 头文件中。注意这些函数都用于C风格字符串(字符数组)。

函数 作用
strlen(s) 返回字符串s的长度(不包括\0
strcpy(dest, src) 将src复制到dest
strcat(dest, src) 将src连接到dest的末尾
strcmp(s1, s2) 比较s1和s2,相等返回0,s1s2返回正数
strchr(s, c) 在s中查找字符c第一次出现的位置,返回指针
strstr(s1, s2) 在s1中查找子串s2,返回指针
示例代码
#include <iostream>
#include <cstring>   // 字符串函数
using namespace std;

int main() {
    char s1[20] = "Hello";
    char s2[20] = "World";
    char s3[40];

    // 求长度
    cout << "s1长度:" << strlen(s1) << endl;   // 5

    // 复制
    strcpy(s3, s1);   // s3 = "Hello"
    cout << "s3 = " << s3 << endl;

    // 连接
    strcat(s3, " ");  // 加一个空格
    strcat(s3, s2);   // s3 = "Hello World"
    cout << "连接后:" << s3 << endl;

    // 比较
    if (strcmp(s1, s2) == 0) {
        cout << "s1和s2相等" << endl;
    } else {
        cout << "s1和s2不相等" << endl;
    }

    return 0;
}

注意:使用这些函数时,要确保目标数组足够大,否则会溢出。

6.2.6 string类

C++提供了一个更安全、更方便的字符串类型:string。它包含在头文件 <string> 中。使用 string 类可以像普通变量一样操作字符串,不需要担心数组大小和结束符。

定义和初始化
#include <string>
string s1;                // 空字符串
string s2 = "Hello";      // 直接赋值
string s3("World");       // 构造函数
string s4 = s2;           // 复制
string s5 = s2 + " " + s3; // 连接
常用操作
  • 长度s.length()s.size()
  • 访问字符s[i],下标从0开始
  • 赋值s = "new value";
  • 连接:用 ++=
  • 比较:直接用 ==!=<> 等运算符
  • 输入cin >> sgetline(cin, s)
  • 输出cout << s
示例代码
#include <iostream>
#include <string>
using namespace std;

int main() {
    string s1 = "Hello";
    string s2 = "World";
    string s3;

    // 连接
    s3 = s1 + " " + s2;
    cout << "s3 = " << s3 << endl;   // Hello World

    // 长度
    cout << "s3的长度:" << s3.length() << endl;   // 11

    // 访问字符
    for (int i = 0; i < s3.length(); i++) {
        cout << s3[i] << " ";   // H e l l o   W o r l d
    }
    cout << endl;

    // 比较
    if (s1 == s2) {
        cout << "相等" << endl;
    } else {
        cout << "不相等" << endl;
    }

    // 子串
    string sub = s3.substr(6, 5);   // 从下标6开始取5个字符
    cout << "子串:" << sub << endl; // World

    // 查找
    int pos = s3.find("World");
    if (pos != string::npos) {
        cout << "找到World,位置在:" << pos << endl;
    }

    return 0;
}
string类的更多成员函数
  • s.substr(pos, len):返回从pos开始长度为len的子串。
  • s.find(str):查找子串,返回第一次出现的位置,如果找不到返回 string::npos
  • s.replace(pos, len, str):将从pos开始长度为len的子串替换为str。
  • s.insert(pos, str):在pos位置插入str。
  • s.erase(pos, len):删除从pos开始的len个字符。
  • s.empty():判断是否为空。
  • s.clear():清空字符串。

6.2.7 阶段性编程练习(字符串基础)

  1. 练习1:输入一个字符,判断它是字母、数字还是其他字符,并输出对应信息。
  2. 练习2:输入一行字符串(可含空格),统计其中字母、数字、空格的个数。
  3. 练习3:输入一个字符串,将其中的小写字母转为大写,大写字母转为小写,其他字符不变,输出结果。
  4. 练习4:用C风格字符串实现字符串反转(不使用string类的reverse函数)。
  5. 练习5:用string类实现:输入两个字符串,连接后输出,并输出长度。

6.3 编程实例讲解

这里我们通过几个综合实例,进一步巩固字符串的操作。

实例1:统计单词个数(使用string)

题目:输入一行英文句子,统计其中有多少个单词(单词之间用空格分隔)。

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

int main() {
    string s;
    cout << "请输入一句英文:";
    getline(cin, s);   // 读取整行

    int count = 0;
    bool inWord = false;   // 标记是否正在一个单词中

    for (int i = 0; i < s.length(); i++) {
        if (s[i] != ' ') {          // 如果当前字符不是空格
            if (!inWord) {          // 并且之前不在单词中,说明新单词开始
                count++;
                inWord = true;
            }
        } else {                     // 遇到空格,表示单词结束
            inWord = false;
        }
    }

    cout << "单词个数:" << count << endl;
    return 0;
}

实例2:判断回文串

题目:输入一个字符串(忽略空格和标点,只考虑字母和数字),判断它是否是回文(正读反读一样,比如 “madam”、”12321”)。

#include <iostream>
#include <string>
#include <cctype>   // 字符处理函数
using namespace std;

int main() {
    string s;
    cout << "请输入一个字符串:";
    getline(cin, s);

    // 先过滤掉非字母数字字符,并转为小写
    string filtered;
    for (int i = 0; i < s.length(); i++) {
        if (isalnum(s[i])) {          // 如果是字母或数字
            filtered += tolower(s[i]); // 转为小写后加入
        }
    }

    // 判断回文
    bool isPalindrome = true;
    int len = filtered.length();
    for (int i = 0; i < len / 2; i++) {
        if (filtered[i] != filtered[len - 1 - i]) {
            isPalindrome = false;
            break;
        }
    }

    if (isPalindrome) {
        cout << "是回文" << endl;
    } else {
        cout << "不是回文" << endl;
    }

    return 0;
}

实例3:凯撒密码(加密)

题目:输入一个字符串和一个偏移量k,将字符串中的每个英文字母替换为字母表中后移k位的字母(循环,例如z后移1位变成a),其他字符不变,输出加密后的字符串。

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

int main() {
    string s;
    int k;
    cout << "请输入要加密的字符串:";
    getline(cin, s);
    cout << "请输入偏移量:";
    cin >> k;

    for (int i = 0; i < s.length(); i++) {
        char ch = s[i];
        if (ch >= 'a' && ch <= 'z') {
            ch = 'a' + (ch - 'a' + k) % 26;
        } else if (ch >= 'A' && ch <= 'Z') {
            ch = 'A' + (ch - 'A' + k) % 26;
        }
        s[i] = ch;
    }

    cout << "加密后的字符串:" << s << endl;
    return 0;
}

实例4:字符串排序(按字典序)

题目:输入若干个人名(字符串),按字典序从小到大排序后输出。

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

int main() {
    const int N = 5;
    string names[N];
    cout << "请输入" << N << "个人名:" << endl;
    for (int i = 0; i < N; i++) {
        cin >> names[i];   // 假设名字不含空格
    }

    // 冒泡排序
    for (int i = 0; i < N - 1; i++) {
        for (int j = 0; j < N - 1 - i; j++) {
            if (names[j] > names[j + 1]) {   // string可以直接比较
                string temp = names[j];
                names[j] = names[j + 1];
                names[j + 1] = temp;
            }
        }
    }

    cout << "排序后的名字:" << endl;
    for (int i = 0; i < N; i++) {
        cout << names[i] << endl;
    }

    return 0;
}

6.4 第6章编程作业

恭喜你学完了字符串!现在来挑战几个综合题目,检验一下学习成果。

作业1:统计字符串中每个字母出现的次数

输入一行字符串(可含空格),统计其中26个英文字母(不区分大小写)各自出现的次数,并输出。忽略非字母字符。

作业2:简单密码验证

设定一个密码字符串(如 “C++2024”),让用户输入密码,最多允许输入3次,如果正确显示“欢迎”,否则显示“密码错误”并提示剩余次数,3次错误后显示“账户锁定”。

作业3:手机键盘数字映射

在老式手机键盘上,字母映射到数字:2:abc, 3:def, 4:ghi, 5:jkl, 6:mno, 7:pqrs, 8:tuv, 9:wxyz。输入一个单词(只含字母),输出对应的数字串。例如输入 “hello”,输出 “43556”。

作业4:字符串去重

输入一个字符串,去掉其中重复的字符(只保留第一次出现的字符),输出结果。例如输入 “hello world”,输出 “helo wrd”(注意空格也要保留,但重复的空格只保留一个?可以自己定义规则)。

作业5:单词反转

输入一句英文,将句子中的单词顺序反转,但单词内部的字母顺序不变。例如输入 “I love C++”,输出 “C++ love I”。提示:可以用字符串流或手动分割单词。

20260227 120004 Cpp 入门第五课

第五章:批量存储数据——数组

你好!欢迎来到第五章!在前面的章节,我们学会了用变量存储单个数据,比如一个年龄、一个成绩。但如果要存储全班50个人的成绩,难道要定义50个变量吗?那太麻烦了!这时候就需要数组——它可以一次性定义多个相同类型的变量,就像一排带编号的柜子,每个柜子里可以放一个数据。这一章我们就来学习如何使用数组。


5.1 一维数组

5.1.1 数组程序范例

先看一个简单的例子:定义一个能装5个整数的数组,然后给它们赋值并输出。

#include <iostream>
using namespace std;

int main() {
    int a[5];          // 定义一个数组,名字叫a,里面可以放5个整数

    // 给数组的每个元素赋值
    a[0] = 10;         // 第一个格子(下标0)放10
    a[1] = 20;         // 第二个格子(下标1)放20
    a[2] = 30;
    a[3] = 40;
    a[4] = 50;

    // 输出数组的每个元素
    cout << a[0] << " " << a[1] << " " << a[2] << " " << a[3] << " " << a[4] << endl;

    return 0;
}

运行结果

10 20 30 40 50

5.1.2 数组的用法

什么是数组?

数组就是一组相同类型的数据的集合,它们在内存中连续存放。每个数据叫做数组元素,通过下标(也叫索引)来访问。下标从0开始编号。

  • 定义格式:数据类型 数组名[元素个数];
    例如:int score[5]; 定义了一个名为score的数组,可以存5个整数。

  • 访问元素:数组名[下标],下标范围是0到元素个数-1。
    例如:score[0] = 98; 给第一个元素赋值。

数组的初始化

可以在定义时直接赋值:

int a[5] = {10, 20, 30, 40, 50};   // 全部初始化
int b[] = {1, 2, 3, 4, 5};          // 不写个数,编译器自动计算为5
int c[5] = {1, 2};                   // 只给前两个赋值,后面3个默认为0
使用循环遍历数组

数组通常和循环一起使用,因为可以用循环变量作为下标。

int a[5] = {10, 20, 30, 40, 50};
for (int i = 0; i < 5; i++) {
    cout << a[i] << " ";   // 依次输出每个元素
}
数组的注意事项
  • 下标不能越界,比如定义 int a[5];,只能使用 a[0]a[4],使用 a[5] 会导致未定义行为(可能程序崩溃)。
  • 数组一旦定义,大小不能改变。

5.1.3 编程实例讲解

实例1:从键盘输入5个整数,求它们的和与平均值
#include <iostream>
using namespace std;

int main() {
    int a[5];
    int sum = 0;

    cout << "请输入5个整数:";
    for (int i = 0; i < 5; i++) {
        cin >> a[i];          // 输入存到数组
        sum += a[i];          // 累加
    }

    double avg = (double)sum / 5;   // 平均值
    cout << "和:" << sum << ",平均值:" << avg << endl;

    return 0;
}
实例2:找出数组中的最大值和最小值
#include <iostream>
using namespace std;

int main() {
    int a[5] = {34, 67, 12, 89, 45};
    int max = a[0];   // 假设第一个是最大值
    int min = a[0];   // 假设第一个是最小值

    for (int i = 1; i < 5; i++) {   // 从第二个开始比较
        if (a[i] > max) {
            max = a[i];
        }
        if (a[i] < min) {
            min = a[i];
        }
    }

    cout << "最大值:" << max << endl;
    cout << "最小值:" << min << endl;

    return 0;
}
实例3:数组元素逆序(把数组反过来)
#include <iostream>
using namespace std;

int main() {
    int a[5] = {1, 2, 3, 4, 5};
    int temp;

    // 交换对称位置的元素
    for (int i = 0; i < 5 / 2; i++) {
        temp = a[i];
        a[i] = a[4 - i];   // 4是最后一个元素的下标
        a[4 - i] = temp;
    }

    cout << "逆序后的数组:";
    for (int i = 0; i < 5; i++) {
        cout << a[i] << " ";
    }
    cout << endl;

    return 0;
}

运行结果:5 4 3 2 1

5.1.4 阶段性编程练习

  1. 练习1:定义一个包含10个整数的数组,用循环给数组赋值为1到10,然后输出。
  2. 练习2:输入8个整数,存入数组,然后输出所有大于平均数的数。
  3. 练习3:输入10个整数,查找某个数(由用户输入)是否在数组中,如果在,输出它的位置(下标),否则输出“未找到”。
  4. 练习4:输入一个正整数n(≤20),再输入n个整数,将它们从小到大输出(可以先不管排序,直接输出,排序下一节学)。但这里可以先练习用两个循环选择最小输出,或者简单用冒泡排序提前体验。

5.2 数组排序

排序就是把一组数按从小到大(升序)或从大到小(降序)排列。我们这里学习最简单的冒泡排序

5.2.1 排序程序范例

#include <iostream>
using namespace std;

int main() {
    int a[5] = {34, 67, 12, 89, 45};
    int n = 5;

    // 冒泡排序
    for (int i = 0; i < n - 1; i++) {           // 外层循环控制比较的轮数
        for (int j = 0; j < n - 1 - i; j++) {   // 内层循环控制每轮比较的次数
            if (a[j] > a[j + 1]) {               // 如果前一个比后一个大,交换
                int temp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = temp;
            }
        }
    }

    cout << "排序后的数组:";
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;

    return 0;
}

运行结果:12 34 45 67 89

5.2.2 数组排序的用法

冒泡排序的原理

想象有一排泡泡,轻的往上浮,重的往下沉。冒泡排序就是每次比较相邻的两个数,如果顺序不对(比如前大后小,我们要升序),就交换它们。这样每一轮都会把最大的数“沉”到最后。

  • 第一轮:从第一个元素开始,依次比较相邻的两个,如果前>后,交换。第一轮结束后,最大的数就到了最后。
  • 第二轮:再从第一个开始,比较到倒数第二个(因为最后一个已经最大了),把第二大的数放到倒数第二。
  • 重复n-1轮,所有数就排好了。
代码解释
  • 外层循环 i 控制轮数,一共需要 n-1 轮(因为最后剩下一个不用排)。
  • 内层循环 j 控制比较范围,每一轮比较的范围逐渐缩小,因为末尾已经排好的元素不用再比。所以 j 从0到 n-1-i。
  • 如果 a[j] > a[j+1],交换。
其他排序方法(拓展了解)
  • 选择排序:每一轮找到最小(或最大)的元素,放到前面。
  • 插入排序:像打扑克牌,每次把一张牌插入到已排好序的手牌中。

但初学者先掌握冒泡排序就好。

5.2.3 编程实例讲解

实例4:输入n个数,升序输出
#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "请输入数字个数:";
    cin >> n;
    int a[100];   // 假设最多100个数

    cout << "请输入" << n << "个整数:";
    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }

    // 冒泡排序
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - 1 - i; j++) {
            if (a[j] > a[j + 1]) {
                int temp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = temp;
            }
        }
    }

    cout << "排序后:";
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;

    return 0;
}
实例5:降序排序(从大到小)

只需要把比较条件 a[j] > a[j+1] 改成 a[j] < a[j+1] 即可。

if (a[j] < a[j + 1]) {   // 如果前一个小于后一个,交换,让大的往前移
    // 交换
}
实例6:对字符串数组排序(了解)

数组也可以是字符串类型,比如 string names[5]; 排序方法和整数类似,直接用 >< 比较字符串(按字典序)。

5.2.4 阶段性编程练习

  1. 练习1:输入10个整数,用冒泡排序将它们从大到小输出。
  2. 练习2:输入n个学生的成绩,排序后输出,并输出最高分和最低分。
  3. 练习3:用选择排序实现升序排列(自学选择排序算法并实现)。
  4. 练习4:输入n个整数,去掉重复的数字后输出(提示:可以先排序,然后输出时跳过相邻相同的)。

5.3 二维数组

5.3.1 二维数组程序范例

二维数组就像一张表格,有行和列。比如定义一个3行4列的二维数组,并输出:

#include <iostream>
using namespace std;

int main() {
    int a[3][4] = {      // 3行4列
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12}
    };

    // 用双重循环输出每个元素
    for (int i = 0; i < 3; i++) {        // i控制行
        for (int j = 0; j < 4; j++) {    // j控制列
            cout << a[i][j] << "\t";     // \t 是制表符,对齐
        }
        cout << endl;                     // 每行结束换行
    }

    return 0;
}

运行结果

1   2   3   4
5   6   7   8
9   10  11  12

5.3.2 二维数组的用法

定义二维数组

格式:数据类型 数组名[行数][列数];
例如:int score[5][3]; 表示5行3列,可以存放5个学生3门课的成绩。

初始化
  • 按行初始化:
    cpp int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
  • 连续赋值(会自动按行填):
    cpp int a[2][3] = {1, 2, 3, 4, 5, 6}; // 等价于上面
  • 部分初始化:未指定的元素默认为0。
访问元素

使用两个下标:数组名[行下标][列下标],行和列都从0开始。
例如:a[1][2] 表示第2行第3列的元素(在数学中常称为第2行第3列,但在编程中下标从0开始,所以实际是第2行第3个)。

遍历二维数组

几乎总是用嵌套循环:外层循环遍历行,内层循环遍历列。

5.3.3 编程实例讲解

实例7:输入一个3×4矩阵,求所有元素的和
#include <iostream>
using namespace std;

int main() {
    int a[3][4];
    int sum = 0;

    cout << "请输入3行4列的矩阵:" << endl;
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 4; j++) {
            cin >> a[i][j];
            sum += a[i][j];
        }
    }

    cout << "所有元素的和:" << sum << endl;
    return 0;
}
实例8:求矩阵中的最大值及其位置
#include <iostream>
using namespace std;

int main() {
    int a[3][4] = {
        {34, 56, 12, 89},
        {23, 45, 67, 90},
        {11, 22, 33, 44}
    };
    int max = a[0][0];
    int max_i = 0, max_j = 0;   // 记录最大值的位置

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 4; j++) {
            if (a[i][j] > max) {
                max = a[i][j];
                max_i = i;
                max_j = j;
            }
        }
    }

    cout << "最大值:" << max << ",位于第" << max_i+1 << "行第" << max_j+1 << "列" << endl;
    return 0;
}
实例9:矩阵转置(行列互换)

假设有一个3行2列的矩阵,转置后变成2行3列。

#include <iostream>
using namespace std;

int main() {
    int a[3][2] = {{1, 2}, {3, 4}, {5, 6}};
    int b[2][3];   // 转置后的矩阵

    // 转置:b[j][i] = a[i][j]
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 2; j++) {
            b[j][i] = a[i][j];
        }
    }

    // 输出转置后的矩阵
    cout << "转置后的矩阵:" << endl;
    for (int i = 0; i < 2; i++) {
        for (int j = 0; j < 3; j++) {
            cout << b[i][j] << " ";
        }
        cout << endl;
    }

    return 0;
}

运行结果

转置后的矩阵:
1 3 5
2 4 6

5.3.4 阶段性编程练习

  1. 练习1:定义一个4×4的二维数组,用循环赋值为1到16,然后按矩阵形式输出。
  2. 练习2:输入一个3×3矩阵,计算主对角线(从左上到右下)上元素的和。
  3. 练习3:输入两个2×3矩阵,求它们的和(对应位置相加),输出结果矩阵。
  4. 练习4:输入一个5×5矩阵,判断它是否关于主对角线对称(即转置后等于原矩阵)。

5.4 第5章编程作业

恭喜你学完了数组!现在来挑战几个综合题目,综合运用一维、二维数组和排序。

作业1:成绩统计系统

输入一个班的学生人数n(≤30),再输入每个学生的姓名和5门课的成绩。要求:
- 计算每个学生的总分和平均分
- 按总分从高到低排序,并输出排名、姓名、总分、平均分
- 输出每门课的平均分(全班平均)

提示:可以用多个一维数组,或者结构体(但还没学结构体,可以用平行数组:名字数组、总分数组等,排序时同时交换名字和总分)。或者先简单做,只处理成绩。

作业2:矩阵乘法

输入两个矩阵,第一个是m×n,第二个是n×p,计算它们的乘积并输出。m,n,p都不超过10。

作业3:约瑟夫问题

有n个人围成一圈,从第1个人开始报数,数到m的人出列,然后从下一个人继续报数,直到所有人都出列。输出出列顺序。n和m由用户输入。(提示:可以用数组标记是否出列,用循环模拟)

作业4:统计单词

输入一行英文句子(包含空格),统计其中有多少个单词,并输出每个单词的长度。例如输入 “I love C++ programming”,输出单词个数4,以及每个单词的长度(1 4 3 11)。提示:可以用字符串数组,但还没学,可以用字符数组,用循环判断空格。

作业5:杨辉三角(二维数组版)

用二维数组存储杨辉三角的前n行(n由用户输入),然后输出。杨辉三角每个数等于它上方两数之和。

20260227 085717 Cpp 入门第四课

第四章:让程序重复做事——循环结构

你好!欢迎来到第四章!在前面的章节,我们学会了让程序做判断。但是,如果想让程序重复做同一件事很多次,比如输出100遍“你好”,难道要写100行cout吗?当然不用!这时候就需要循环。循环就像跑步,一圈一圈重复,直到达到目标才停下。这一章我们会学习三种循环:forwhiledo...while,还有控制循环的小技巧。


4.1 for循环

4.1.1 for循环程序范例

for 循环就像体育老师喊口令:“从第1个同学开始,到第10个同学结束,每个同学报数!” 我们来看一个程序,输出1到10:

#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 10; i++) {
        cout << i << " ";
    }
    return 0;
}

运行结果

1 2 3 4 5 6 7 8 9 10

4.1.2 for循环的用法

for 循环的格式:

for (初始化; 条件; 更新) {
    // 循环体:要重复执行的代码
}
  • 初始化:在循环开始前执行一次,通常用来定义循环变量并赋初值,比如 int i = 1
  • 条件:每次循环开始前判断,如果为真,就执行循环体;如果为假,就退出循环。比如 i <= 10
  • 更新:每次循环体执行完后执行,通常用来改变循环变量的值,比如 i++(i增加1)。

执行顺序
1. 执行初始化(只一次)
2. 判断条件 → 如果真,进入循环体;如果假,结束循环
3. 执行循环体
4. 执行更新
5. 回到第2步

循环变量变化过程

比如上面例子:
- 初始 i=1,判断 1<=10 真 → 输出1,然后 i++ 变成2
- 判断 2<=10 真 → 输出2,i++ 变3
- … 直到 i=10 输出10,i++ 变11
- 判断 11<=10 假 → 退出循环

4.1.3 编程实例讲解

实例1:求1到100的和
#include <iostream>
using namespace std;

int main() {
    int sum = 0;          // 累加器,初始为0
    for (int i = 1; i <= 100; i++) {
        sum = sum + i;    // 把i加到sum上
    }
    cout << "1+2+...+100 = " << sum << endl;
    return 0;
}

运行结果:5050

实例2:输出偶数

题目:输出1到20之间的所有偶数。

#include <iostream>
using namespace std;

int main() {
    for (int i = 2; i <= 20; i += 2) {   // i每次增加2
        cout << i << " ";
    }
    return 0;
}

运行结果

2 4 6 8 10 12 14 16 18 20
实例3:倒序输出

题目:输出10到1。

#include <iostream>
using namespace std;

int main() {
    for (int i = 10; i >= 1; i--) {
        cout << i << " ";
    }
    return 0;
}

运行结果

10 9 8 7 6 5 4 3 2 1

4.1.4 阶段性编程练习

  1. 练习1:用for循环输出1到50,每行5个数。(提示:可以用if (i % 5 == 0) cout << endl;换行)
  2. 练习2:求1到100所有奇数的和。
  3. 练习3:输入一个整数n,计算n的阶乘(n! = 1×2×3×…×n)。
  4. 练习4:输出所有的“水仙花数”。水仙花数是指一个三位数,其各位数字的立方和等于该数本身,如153 = 1³+5³+3³。(提示:用循环遍历100-999,分离个十百位判断)

4.2 while循环

4.2.1 while循环程序范例

while 循环就像“当条件满足时,就一直做某事”。比如“当还有糖果时,就吃一颗”。看这个例子,输出1到10:

#include <iostream>
using namespace std;

int main() {
    int i = 1;           // 初始化
    while (i <= 10) {    // 条件
        cout << i << " ";
        i++;             // 更新
    }
    return 0;
}

运行结果:和for循环一样。

4.2.2 while循环的用法

格式:

while (条件) {
    // 循环体
}
  • 先判断条件,如果为真,执行循环体;然后再次判断,直到条件为假退出。
  • 注意:循环体内要有改变条件的语句,否则会变成死循环(无限循环)。

4.2.3 编程实例讲解

实例4:计算1到n的和(n由用户输入)
#include <iostream>
using namespace std;

int main() {
    int n, i = 1, sum = 0;
    cout << "请输入一个整数:";
    cin >> n;

    while (i <= n) {
        sum += i;   // 等价于 sum = sum + i
        i++;
    }
    cout << "1+2+...+" << n << " = " << sum << endl;
    return 0;
}
实例5:统计位数

题目:输入一个正整数,统计它是几位数。比如输入12345,输出5。

#include <iostream>
using namespace std;

int main() {
    int num, count = 0;
    cout << "请输入一个正整数:";
    cin >> num;

    while (num > 0) {
        num = num / 10;   // 去掉最后一位
        count++;          // 位数加1
    }
    cout << "它是" << count << "位数" << endl;
    return 0;
}

解释:每次除以10,就减少一位,直到变成0。

4.2.4 阶段性编程练习

  1. 练习1:用while循环输出1到50的平方数(1², 2², … 50²)。
  2. 练习2:输入一个整数,倒序输出它(比如输入123,输出321)。不能用数组,只能用循环。
  3. 练习3:求两个数的最大公约数(用辗转相除法,提示:用while循环,条件是b!=0,循环内求余并交换)。
  4. 练习4:猜数字游戏(用while循环,直到猜中为止)。随机生成一个1-100的数,用户猜,提示“大了”“小了”,直到猜中,统计猜的次数。

4.3 do…while循环

4.3.1 do…while循环程序范例

do...while 循环和 while 类似,但它是先执行一次循环体,再判断条件。就像“先做一次,然后看是否要继续做”。比如:

#include <iostream>
using namespace std;

int main() {
    int i = 1;
    do {
        cout << i << " ";
        i++;
    } while (i <= 10);
    return 0;
}

4.3.2 do…while循环的用法

格式:

do {
    // 循环体
} while (条件);
  • 先执行一次循环体,然后判断条件。如果条件为真,继续下一次循环;如果为假,退出。
  • 注意:最后的分号不能少。
  • 特点:至少执行一次循环体,即使条件一开始为假。
对比while
  • while:先判断,可能一次都不执行。
  • do...while:先执行,至少执行一次。

4.3.3 编程实例讲解

实例6:输入密码,直到正确

题目:假设密码是123456,让用户输入密码,如果错误就提示重新输入,直到正确。

#include <iostream>
using namespace std;

int main() {
    int password, correct = 123456;
    do {
        cout << "请输入密码:";
        cin >> password;
        if (password != correct) {
            cout << "密码错误,请重新输入。" << endl;
        }
    } while (password != correct);

    cout << "密码正确,欢迎进入系统!" << endl;
    return 0;
}
实例7:求平均分(至少输入一次)

题目:输入若干个成绩,以-1结束,计算平均分(保证至少输入一个有效成绩)。

#include <iostream>
using namespace std;

int main() {
    int score, sum = 0, count = 0;
    do {
        cout << "请输入成绩(-1结束):";
        cin >> score;
        if (score != -1) {
            sum += score;
            count++;
        }
    } while (score != -1);

    if (count > 0) {
        double avg = (double)sum / count;
        cout << "平均分:" << avg << endl;
    }
    return 0;
}

4.3.4 阶段性编程练习

  1. 练习1:用do…while循环输出1到10的立方。
  2. 练习2:编写一个菜单程序,显示选项,让用户选择,直到用户选择退出。(类似:1.开始游戏 2.设置 3.退出,输入3退出)
  3. 练习3:输入一个正整数,用do…while循环判断它是否是素数(提示:用i从2到sqrt(n)试除,一旦整除就标记不是素数)。
  4. 练习4:模拟ATM取款,初始余额1000元,每次输入取款金额,如果余额不足提示,直到输入0退出。

4.4 continue和break

4.4.1 continue和break程序范例

有时候我们需要在循环中间提前结束本次循环或整个循环。break 用来跳出整个循环,continue 用来跳过本次循环剩下的部分,直接进入下一次循环。

#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 10; i++) {
        if (i == 5) {
            break;    // 当i=5时,跳出整个循环
        }
        cout << i << " ";
    }
    cout << endl;

    for (int i = 1; i <= 10; i++) {
        if (i == 5) {
            continue; // 当i=5时,跳过输出,继续下一次循环
        }
        cout << i << " ";
    }
    return 0;
}

运行结果

1 2 3 4 
1 2 3 4 6 7 8 9 10

4.4.2 continue和break的用法

  • break:立即终止当前循环(for、while、do…while、switch),程序继续执行循环后面的代码。
  • continue:跳过本次循环中剩余的代码,直接进入下一次循环(对于for循环,会先执行更新部分,再判断条件)。
使用场景
  • break:当找到想要的结果后,没必要继续循环了。
  • continue:当遇到某些不需要处理的情况时,跳过本次,继续处理下一个。

4.4.3 编程实例讲解

实例8:找出第一个能被7整除的数

题目:从1开始找,找到第一个能被7整除的数就停止。

#include <iostream>
using namespace std;

int main() {
    int i = 1;
    while (true) {   // 无限循环,用break退出
        if (i % 7 == 0) {
            cout << "第一个能被7整除的数是:" << i << endl;
            break;
        }
        i++;
    }
    return 0;
}
实例9:输出1-20中不能被3整除的数
#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 20; i++) {
        if (i % 3 == 0) {
            continue;   // 跳过输出
        }
        cout << i << " ";
    }
    return 0;
}

4.4.4 阶段性编程练习

  1. 练习1:输出1-100中所有能被5整除但不能被3整除的数。
  2. 练习2:求两个数的最大公约数,用循环和break优化(找到即停止)。
  3. 练习3:输入一个正整数,判断它是否是素数(用break优化,一旦发现整除就跳出循环)。
  4. 练习4:模拟一个简单的加法考试,随机出10道题,每道题答对加10分,答错可以继续做下一题,最后显示得分。如果中途输入-1,则提前退出考试。

4.5 嵌套循环

4.5.1 嵌套循环程序范例

循环里面再套循环,就是嵌套循环。最常见的是打印图形。比如打印一个3行5列的矩形:

#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 3; i++) {        // 控制行数
        for (int j = 1; j <= 5; j++) {    // 控制列数
            cout << "* ";
        }
        cout << endl;                     // 每行结束后换行
    }
    return 0;
}

运行结果

* * * * * 
* * * * * 
* * * * * 

4.5.2 嵌套循环的用法

  • 外层循环执行一次,内层循环会完整执行一轮。
  • 常用于处理二维结构,如打印图形、遍历表格等。
  • 注意循环变量的作用域和命名,通常用 i, j, k 表示。
执行过程:

外层 i=1,内层 j 从1到5输出5个星号,然后换行;
外层 i=2,内层 j 再从1到5输出5个星号,换行;
外层 i=3,同样。

4.5.3 编程实例讲解

实例10:打印直角三角形

题目:输入行数n,打印一个直角三角形,第一行1个,第二行2个,…第n行n个*。

#include <iostream>
using namespace std;

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

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= i; j++) {   // 每行输出i个星号
            cout << "* ";
        }
        cout << endl;
    }
    return 0;
}

运行示例(n=5):

* 
* * 
* * * 
* * * * 
* * * * * 
实例11:打印九九乘法表
#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 9; i++) {
        for (int j = 1; j <= i; j++) {
            cout << j << "×" << i << "=" << i * j << "\t"; // \t是制表符,用于对齐
        }
        cout << endl;
    }
    return 0;
}
实例12:冒泡排序(简单介绍)

虽然还没学数组,但可以先看看嵌套循环的威力:排序。这里用最简单的冒泡排序对5个数排序。

#include <iostream>
using namespace std;

int main() {
    int a[5] = {5, 3, 8, 1, 2};   // 数组,暂时理解为5个盒子
    int n = 5;

    // 冒泡排序
    for (int i = 0; i < n - 1; i++) {           // 外层控制比较轮数
        for (int j = 0; j < n - 1 - i; j++) {   // 内层控制每轮比较次数
            if (a[j] > a[j + 1]) {               // 如果前面的比后面的大,交换
                int temp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = temp;
            }
        }
    }

    // 输出排序后的结果
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    return 0;
}

运行结果:1 2 3 5 8

4.5.4 阶段性编程练习

  1. 练习1:打印倒直角三角形:第一行n个,第二行n-1个,…最后一行1个*。
  2. 练习2:打印菱形(输入奇数行数,打印菱形图案)。
  3. 练习3:输出100以内的所有素数(用嵌套循环,外层遍历2-100,内层判断每个数是否是素数)。
  4. 练习4:模拟一个简单的“猜数字”游戏升级版:程序随机生成一个4位数(每位不重复),用户猜,每次给出提示:数字正确且位置正确的个数,数字正确但位置不对的个数。用嵌套循环实现比较。

4.6 第4章编程作业

恭喜你学完了循环!现在来挑战几个综合题目,检验一下学习成果。

作业1:斐波那契数列

斐波那契数列:1, 1, 2, 3, 5, 8, 13, … 从第三项开始,每一项等于前两项之和。输入一个正整数n,输出前n项。

作业2:百钱百鸡问题

公鸡5元一只,母鸡3元一只,小鸡1元三只。用100元买100只鸡,问公鸡、母鸡、小鸡各多少只?用循环枚举所有可能。

作业3:求完数

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

作业4:猜数字游戏(带难度选择)

实现一个猜数字游戏,可以选择难度:
- 简单:1-50,猜10次
- 中等:1-100,猜7次
- 困难:1-200,猜5次
每次提示猜大了还是猜小了,如果次数用完还没猜中,游戏结束。

作业5:打印杨辉三角

输入行数n,打印杨辉三角(等腰三角形格式)。例如n=5:

    1
   1 1
  1 2 1
 1 3 3 1
1 4 6 4 1

提示:可以用二维数组或组合数公式,但这里要求只用循环(需要一点数学知识,可以选做)。


好了,第四章的内容就到这里!你已经学会了如何让程序重复做事,这是编程中非常强大的能力。加油!🚀

20260227 085102 Cpp 入门第三课

第三章:让程序学会判断——分支结构

你好!欢迎来到第三章!在前两章,我们学会了让计算机输入、输出和计算。但程序只会按顺序执行,就像一条直线。如果想让程序根据不同情况做出不同反应,就需要用到分支结构。这一章我们会学习如何让程序“动脑筋”,根据条件决定做什么。就像你在生活中,如果下雨就带伞,如果晴天就去公园,程序也可以这样!


3.1 if语句

3.1.1 if语句程序范例

最简单的判断就是:如果……就……。看看这个程序,它判断你的年龄是否满18岁。

#include <iostream>
using namespace std;

int main() {
    int age;
    cout << "请输入你的年龄:";
    cin >> age;

    if (age >= 18) {
        cout << "你已经成年了!" << endl;
    }

    cout << "程序结束。" << endl;
    return 0;
}

运行结果示例1(输入20):

请输入你的年龄:20
你已经成年了!
程序结束。

运行结果示例2(输入15):

请输入你的年龄:15
程序结束。

3.1.2 if语句的用法

if 语句的格式:

if (条件) {
    // 条件为真时执行的语句
}
  • 条件:通常是一个比较表达式,比如 age >= 18score == 100。条件的结果要么是(true),要么是(false)。
  • 如果条件为真,就执行大括号 {} 里的代码;如果为假,就跳过这些代码,继续执行后面的语句。
  • 如果大括号里只有一条语句,可以省略大括号,但初学者建议都加上,避免出错。
比较运算符
运算符 含义 例子
== 等于 a == b
!= 不等于 a != b
> 大于 a > b
< 小于 a < b
>= 大于等于 a >= b
<= 小于等于 a <= b

注意:判断是否相等要用两个等号 ==,一个等号 = 是赋值,千万不能搞混!

3.1.3 编程实例讲解

实例1:判断奇偶数

题目:输入一个整数,如果它是偶数,就输出“偶数”。

#include <iostream>
using namespace std;

int main() {
    int num;
    cout << "请输入一个整数:";
    cin >> num;

    if (num % 2 == 0) {   // 能被2整除就是偶数
        cout << num << " 是偶数" << endl;
    }

    return 0;
}
实例2:判断是否及格

题目:输入一门课的成绩(0-100),如果大于等于60,输出“及格”。

#include <iostream>
using namespace std;

int main() {
    int score;
    cout << "请输入成绩:";
    cin >> score;

    if (score >= 60) {
        cout << "恭喜,及格了!" << endl;
    }

    return 0;
}

3.1.4 阶段性编程练习

  1. 练习1:输入一个整数,如果它是负数,输出“这是一个负数”。
  2. 练习2:输入一个整数,如果它能被5整除,输出“能被5整除”。
  3. 练习3:输入一个字符,如果它是大写字母(’A’到’Z’),输出“大写字母”。(提示:字符比较可以用 ch >= 'A' && ch <= 'Z',但逻辑运算符还没学,可以先不练,或者用后面知识。)
  4. 练习4:输入两个整数,如果第一个数大于第二个数,输出“第一个数更大”。

3.2 if…else语句

3.2.1 if…else程序范例

if 只能处理条件为真的情况,但如果条件为假也想做点什么,就需要 if...else,就像“如果……就……否则……”。

#include <iostream>
using namespace std;

int main() {
    int age;
    cout << "请输入你的年龄:";
    cin >> age;

    if (age >= 18) {
        cout << "你已经成年了,可以看电影!" << endl;
    } else {
        cout << "你还未成年,需要家长陪同。" << endl;
    }

    return 0;
}

运行示例(输入15):

请输入你的年龄:15
你还未成年,需要家长陪同。

3.2.2 if…else语句的用法

格式:

if (条件) {
    // 条件为真时执行的代码
} else {
    // 条件为假时执行的代码
}
  • ifelse 是成对出现的。
  • 条件为真,执行 if 后面的代码块;条件为假,执行 else 后面的代码块。
  • 两者只会执行其中一个。

3.2.3 编程实例讲解

实例3:判断奇偶,输出不同信息
#include <iostream>
using namespace std;

int main() {
    int num;
    cout << "请输入一个整数:";
    cin >> num;

    if (num % 2 == 0) {
        cout << num << " 是偶数" << endl;
    } else {
        cout << num << " 是奇数" << endl;
    }

    return 0;
}
实例4:判断是否通过考试
#include <iostream>
using namespace std;

int main() {
    int score;
    cout << "请输入成绩:";
    cin >> score;

    if (score >= 60) {
        cout << "通过考试!" << endl;
    } else {
        cout << "未通过,继续努力!" << endl;
    }

    return 0;
}

3.2.4 阶段性编程练习

  1. 练习1:输入一个整数,判断它是否大于0,输出“正数”或“非正数”(包括0和负数)。
  2. 练习2:输入一个年份,判断它是否是闰年。闰年条件:能被4整除但不能被100整除,或者能被400整除。(提示:条件较复杂,可以先留到学了逻辑运算再做,或者直接给出条件。)
  3. 练习3:输入一个整数,判断它是否是7的倍数,输出相应信息。
  4. 练习4:输入两个整数,输出较大的那个数。

3.3 分支的嵌套

3.3.1 分支嵌套程序范例

有时候,我们需要在一个判断里面再做判断,就像“如果下雨,就带伞;如果雨很大,就穿雨衣”。这就是分支嵌套

#include <iostream>
using namespace std;

int main() {
    int score;
    cout << "请输入你的成绩:";
    cin >> score;

    if (score >= 60) {
        cout << "及格了!";
        if (score >= 90) {
            cout << "而且成绩优秀!" << endl;
        } else {
            cout << "继续加油!" << endl;
        }
    } else {
        cout << "不及格,要努力了!" << endl;
    }

    return 0;
}

运行示例(输入95):

及格了!而且成绩优秀!

3.3.2 分支嵌套的用法

  • ifelse 的代码块里,可以再放其他的 if...else 语句。
  • 嵌套的层数不要太多,否则代码会变得难读。一般两层就够用了。

注意else 总是和它上面最近的 if 配对。为了避免混乱,一定要用好大括号 {}

3.3.3 编程实例讲解

实例5:根据分数评定等级

题目:输入成绩(0-100),输出等级:
- 90-100:优秀
- 80-89:良好
- 70-79:中等
- 60-69:及格
- 0-59:不及格

#include <iostream>
using namespace std;

int main() {
    int score;
    cout << "请输入成绩:";
    cin >> score;

    if (score >= 60) {
        if (score >= 90) {
            cout << "优秀" << endl;
        } else if (score >= 80) {   // 这里用了else if,是多重选择,但也可以嵌套
            cout << "良好" << endl;
        } else if (score >= 70) {
            cout << "中等" << endl;
        } else {
            cout << "及格" << endl;
        }
    } else {
        cout << "不及格" << endl;
    }

    return 0;
}

这个例子其实混合了嵌套和 else if,我们会在下一节正式学习多重选择。

实例6:判断三个数中的最大值
#include <iostream>
using namespace std;

int main() {
    int a, b, c;
    cout << "请输入三个整数:";
    cin >> a >> b >> c;

    if (a >= b) {
        if (a >= c) {
            cout << "最大值是:" << a << endl;
        } else {
            cout << "最大值是:" << c << endl;
        }
    } else {
        if (b >= c) {
            cout << "最大值是:" << b << endl;
        } else {
            cout << "最大值是:" << c << endl;
        }
    }

    return 0;
}

3.3.4 阶段性编程练习

  1. 练习1:输入三个整数,输出最小值。(参考上面的最大值)
  2. 练习2:输入一个年份,判断它是否是闰年,并输出对应的信息。如果是闰年,再判断它是否是世纪闰年(能被400整除)。
  3. 练习3:输入一个整数x,求分段函数的值:
    - 当 x > 0 时,y = 2x + 1
    - 当 x = 0 时,y = 0
    - 当 x < 0 时,y = x - 1
    输出y的值。
  4. 练习4:模拟自动售货机:输入金额(整数),如果金额大于等于商品价格(比如5元),则输出“购买成功”,并找零;否则输出“金额不足”。(可以嵌套判断,比如金额足够时再判断是否恰好等值)

3.4 多重选择分支

3.4.1 多重选择分支程序范例

当有多个条件需要依次判断时,可以用 if...else if...else 结构。就像成绩分等级一样。

#include <iostream>
using namespace std;

int main() {
    int score;
    cout << "请输入成绩:";
    cin >> score;

    if (score >= 90) {
        cout << "优秀" << endl;
    } else if (score >= 80) {
        cout << "良好" << endl;
    } else if (score >= 70) {
        cout << "中等" << endl;
    } else if (score >= 60) {
        cout << "及格" << endl;
    } else {
        cout << "不及格" << endl;
    }

    return 0;
}

3.4.2 多重选择分支的用法

格式:

if (条件1) {
    // 条件1为真执行
} else if (条件2) {
    // 条件1为假且条件2为真执行
} else if (条件3) {
    // 条件1、2为假且条件3为真执行
} else {
    // 所有条件都为假执行
}
  • 程序从上往下判断,遇到第一个为真的条件,就执行对应的代码块,然后跳出整个 if...else if...else 结构。
  • 最后的 else 是可选的,表示所有条件都不满足时做什么。

3.4.3 编程实例讲解

实例7:根据月份判断季节

题目:输入月份(1-12),输出对应的季节:
- 3-5月:春季
- 6-8月:夏季
- 9-11月:秋季
- 12,1,2月:冬季

#include <iostream>
using namespace std;

int main() {
    int month;
    cout << "请输入月份:";
    cin >> month;

    if (month >= 3 && month <= 5) {
        cout << "春季" << endl;
    } else if (month >= 6 && month <= 8) {
        cout << "夏季" << endl;
    } else if (month >= 9 && month <= 11) {
        cout << "秋季" << endl;
    } else if (month == 12 || month == 1 || month == 2) {
        cout << "冬季" << endl;
    } else {
        cout << "月份输入错误" << endl;
    }

    return 0;
}
实例8:简单的计算器(加减乘除)
#include <iostream>
using namespace std;

int main() {
    double a, b;
    char op;   // 运算符
    cout << "请输入表达式(如 3 + 5):";
    cin >> a >> op >> b;

    if (op == '+') {
        cout << a + b << endl;
    } else if (op == '-') {
        cout << a - b << endl;
    } else if (op == '*') {
        cout << a * b << endl;
    } else if (op == '/') {
        if (b != 0) {
            cout << a / b << endl;
        } else {
            cout << "除数不能为0" << endl;
        }
    } else {
        cout << "不支持的运算符" << endl;
    }

    return 0;
}

3.4.4 阶段性编程练习

  1. 练习1:输入一个整数,判断它是正数、负数还是零。
  2. 练习2:输入一个0-6的数字,输出对应的星期几(0代表星期日,1代表星期一,以此类推)。
  3. 练习3:输入一个年份和月份,输出该月的天数(考虑闰年2月)。
  4. 练习4:编写一个程序,根据用户输入的消费金额,计算折扣后的应付金额:
    - 满100元打9折
    - 满200元打8折
    - 满300元打7折
    - 其他情况无折扣

3.5 switch语句

3.5.1 switch语句程序范例

当判断的条件是整型字符型固定值时,用 switch 语句更清晰。比如根据数字输出星期几。

#include <iostream>
using namespace std;

int main() {
    int day;
    cout << "请输入星期几(1-7):";
    cin >> day;

    switch (day) {
        case 1:
            cout << "星期一" << endl;
            break;
        case 2:
            cout << "星期二" << endl;
            break;
        case 3:
            cout << "星期三" << endl;
            break;
        case 4:
            cout << "星期四" << endl;
            break;
        case 5:
            cout << "星期五" << endl;
            break;
        case 6:
            cout << "星期六" << endl;
            break;
        case 7:
            cout << "星期日" << endl;
            break;
        default:
            cout << "输入错误,请输入1-7之间的数字" << endl;
    }

    return 0;
}

3.5.2 switch语句的用法

格式:

switch (表达式) {
    case 常量1:
        语句;
        break;
    case 常量2:
        语句;
        break;
    ...
    default:
        语句;
}
  • 表达式的结果必须是整型(int、char等),不能是浮点型或字符串。
  • case 常量:常量必须是固定的值,不能是变量。
  • 程序会找到与表达式值相等的 case,然后从那里开始执行,直到遇到 breakswitch 结束。
  • break 用来跳出 switch,如果不写 break,会继续执行下一个 case 的代码(这叫做“穿透”)。
  • default 是可选的,当所有 case 都不匹配时执行。

3.5.3 编程实例讲解

实例9:根据成绩等级给出评语
#include <iostream>
using namespace std;

int main() {
    char grade;
    cout << "请输入成绩等级(A、B、C、D):";
    cin >> grade;

    switch (grade) {
        case 'A':
            cout << "优秀,继续努力!" << endl;
            break;
        case 'B':
            cout << "良好,再接再厉!" << endl;
            break;
        case 'C':
            cout << "中等,要加油!" << endl;
            break;
        case 'D':
            cout << "及格边缘,需加强!" << endl;
            break;
        default:
            cout << "无效等级" << endl;
    }

    return 0;
}
实例10:简易四则运算(用switch)
#include <iostream>
using namespace std;

int main() {
    double a, b;
    char op;
    cout << "请输入表达式(如 3+5):";
    cin >> a >> op >> b;

    switch (op) {
        case '+':
            cout << a + b << endl;
            break;
        case '-':
            cout << a - b << endl;
            break;
        case '*':
            cout << a * b << endl;
            break;
        case '/':
            if (b != 0)
                cout << a / b << endl;
            else
                cout << "除数不能为0" << endl;
            break;
        default:
            cout << "不支持的运算符" << endl;
    }

    return 0;
}

3.5.4 阶段性编程练习

  1. 练习1:输入一个数字1-12,输出对应的月份英文名(January, February…)。
  2. 练习2:输入一个字符,判断它是元音字母(a, e, i, o, u,包括大小写)还是辅音字母。(提示:多个case可以共用一段代码,比如 case 'a': case 'e': ...
  3. 练习3:模拟一个简单的菜单:
    - 输入1:显示“开始游戏”
    - 输入2:显示“加载存档”
    - 输入3:显示“退出游戏”
    - 其他:显示“无效选项”
  4. 练习4:输入两个整数和一个运算符(+、-、*、/、%),用switch完成运算。

3.6 逻辑运算

3.6.1 逻辑运算程序范例

有时候一个条件需要多个判断组合,比如“成绩在80到90之间”需要同时满足大于等于80且小于等于90。这就需要逻辑运算符。

#include <iostream>
using namespace std;

int main() {
    int score;
    cout << "请输入成绩:";
    cin >> score;

    if (score >= 80 && score <= 90) {   // 并且
        cout << "成绩良好" << endl;
    }

    if (score < 60 || score > 100) {    // 或者
        cout << "成绩异常" << endl;
    }

    if (!(score >= 60)) {                // 非
        cout << "不及格" << endl;
    }

    return 0;
}

3.6.2 逻辑运算的用法

运算符 名称 含义 例子
&& 两边都为真,结果才为真 a>0 && a<10
|| 两边至少一个为真,结果为真 a==0 || b==0
! 取反,真变假,假变真 !(a>b)

优先级! 最高,然后是 &&,最后是 ||。可以用括号改变顺序。

真值表(简单理解)
  • true && truetrue
  • true && falsefalse
  • false && truefalse
  • false && falsefalse
  • true || truetrue
  • true || falsetrue
  • false || truetrue
  • false || falsefalse
  • !truefalse
  • !falsetrue

3.6.3 编程实例讲解

实例11:判断闰年(用逻辑运算)
#include <iostream>
using namespace std;

int main() {
    int year;
    cout << "请输入年份:";
    cin >> year;

    if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) {
        cout << year << " 是闰年" << endl;
    } else {
        cout << year << " 不是闰年" << endl;
    }

    return 0;
}
实例12:判断三角形类型

题目:输入三条边长,判断是否能构成三角形,如果能,是等边、等腰还是普通三角形。

#include <iostream>
using namespace std;

int main() {
    double a, b, c;
    cout << "请输入三条边长:";
    cin >> a >> b >> c;

    // 三角形条件:任意两边之和大于第三边
    if (a + b > c && a + c > b && b + c > a) {
        if (a == b && b == c) {
            cout << "等边三角形" << endl;
        } else if (a == b || a == c || b == c) {
            cout << "等腰三角形" << endl;
        } else {
            cout << "普通三角形" << endl;
        }
    } else {
        cout << "不能构成三角形" << endl;
    }

    return 0;
}

3.6.4 阶段性编程练习

  1. 练习1:输入一个整数,判断它是否同时满足:能被3整除,且能被5整除。
  2. 练习2:输入三个整数,判断它们是否都相等。
  3. 练习3:输入一个字符,判断它是否是大写字母或小写字母。
  4. 练习4:输入一个年份和月份,判断该月有多少天(使用逻辑运算处理闰年2月)。

3.7 第3章编程作业

恭喜你学完了分支结构!来挑战几个综合题目吧。

作业1:简单的猜拳游戏

编写程序,让用户输入剪刀(0)、石头(1)、布(2),电脑随机出拳(用 rand() % 3),判断胜负并输出结果。提示:需要随机数种子 srand(time(0))

作业2:个人所得税计算器

输入月收入(整数),计算应缴个人所得税。假设税率如下:
- 不超过3000元的部分,税率3%
- 超过3000至12000元的部分,税率10%
- 超过12000至25000元的部分,税率20%
- 超过25000元的部分,税率25%

输出应缴税额(保留两位小数)。比如月收入10000,计算方法是:30003% + (10000-3000)10% = 90 + 700 = 790元。

作业3:日期合法性判断

输入年、月、日,判断这个日期是否合法(考虑闰年2月天数)。

作业4:求解一元二次方程

输入一元二次方程的系数 a, b, c(a≠0),计算判别式 Δ = b² - 4ac,根据Δ的情况输出根:

  • Δ > 0:两个不同的实根
  • Δ = 0:两个相等的实根
  • Δ < 0:无实根

输出根的值(如果有),保留两位小数。


好了,第三章的内容就到这里!你已经学会了让程序做判断。加油!

20260227 084620 Cpp 入门第二课

欢迎回来!在上一章,我们学会了如何让计算机开口说话(输出)。现在,我们要学习如何让计算机拥有“记忆”和“计算”的能力,这就是变量输入。这一章我们将一起探索C++中最重要的基础概念,保证让你轻松掌握!


2.1 变量

2.1.1 变量程序范例

我们先来看一个简单的程序,它定义了两个变量,并进行加法运算。

#include <iostream>
using namespace std;

int main() {
    int a;          // 定义一个整型变量,名字叫a
    int b;          // 定义另一个整型变量b
    int c;          // 定义变量c,用来存结果

    a = 10;         // 把10赋值给a
    b = 20;         // 把20赋值给b
    c = a + b;      // 计算a加b,结果存到c

    cout << c;      // 输出c的值

    return 0;
}

运行结果

30

2.1.2 变量的用法

什么是变量?

变量就像是一个小盒子,我们可以在里面放东西(数据)。每个盒子都有名字(变量名)和能放的东西的类型(数据类型)。

  • 变量名:盒子的名字,比如 ascoreage
  • 数据类型:盒子能装什么类型的东西,比如整数、小数、字符等。
常用的数据类型
类型名 中文含义 能装什么 例子
int 整型 整数 10, -5, 0
double 双精度浮点型 小数 3.14, -2.5
char 字符型 单个字符 ‘A’, ‘b’, ‘9’
string 字符串型 一串文字 “Hello”, “C++”

注意string 类型需要包含 <string> 头文件,但我们先主要学 intdouble

变量的定义和赋值
  • 定义变量:告诉计算机我要一个新盒子,并指定类型。
  int age;        // 定义整型变量age
  double price;   // 定义双精度变量price
  char grade;     // 定义字符变量grade
  • 赋值:往盒子里放东西。
  age = 10;           // 把10放进age盒子
  price = 19.9;       // 把19.9放进price盒子
  grade = 'A';        // 把字符A放进grade盒子
  • 定义时直接初始化(一边定义一边放东西):
  int age = 10;
  double price = 19.9;
  char grade = 'A';
变量的命名规则

给变量起名字要遵守规则,就像每个人要有合法的身份证号一样:
1. 只能由字母(a-z,A-Z)、数字(0-9)和下划线(_)组成。
2. 不能以数字开头。
3. 不能是C++的关键字(比如 intreturn 等)。
4. 尽量起有意义的名字,比如 scores 好。

合法名字myAge, _temp, number1
非法名字2num(数字开头),int(关键字),my-name(有连字符)

2.1.3 编程实例讲解

实例1:交换两个变量的值

题目:有两个盒子a和b,分别装着苹果和橘子,现在要交换它们的内容。

思路:需要第三个临时盒子来帮忙。

#include <iostream>
using namespace std;

int main() {
    int a = 5;      // 盒子a有5个苹果
    int b = 3;      // 盒子b有3个橘子
    int temp;       // 临时盒子

    cout << "交换前:a=" << a << ", b=" << b << endl;

    temp = a;       // 把a的东西放到临时盒子
    a = b;          // 把b的东西放到a
    b = temp;       // 把临时盒子的东西放到b

    cout << "交换后:a=" << a << ", b=" << b << endl;

    return 0;
}

运行结果

交换前:a=5, b=3
交换后:a=3, b=5

2.1.4 阶段性编程练习

  1. 练习1:定义三个整型变量 x, y, z,分别赋值为 7, 8, 9,然后计算它们的和并输出。
  2. 练习2:定义两个 double 型变量,赋值为 2.5 和 3.7,输出它们的乘积。
  3. 练习3:定义一个字符变量存储你名字的首字母,输出这个字母。
  4. 练习4:交换两个变量的值,但不使用临时变量(提示:可以用加法,比如 a = a + b; b = a - b; a = a - b; 试试看)。

2.2 输入

2.2.1 输入程序范例

前面的程序都是我们直接赋值,现在我们要让用户从键盘输入数据。看这个例子:

#include <iostream>
using namespace std;

int main() {
    int age;                // 定义一个变量存放年龄

    cout << "请输入你的年龄:";   // 提示用户输入
    cin >> age;             // 从键盘读取一个整数,存到age里

    cout << "你的年龄是:" << age << "岁" << endl;

    return 0;
}

运行示例(假设用户输入15):

请输入你的年龄:15
你的年龄是:15岁

2.2.2 输入的用法

cin 的基本用法

cin 是 C++ 的输入命令,和 cout 对应。>> 是提取运算符,意思是从键盘输入流中提取数据存到变量里。

  • 格式:cin >> 变量名;
  • 可以连续输入多个变量:cin >> 变量1 >> 变量2 >> 变量3; 输入时用空格或回车分隔。
输入不同类型的数据
int a;
double b;
char c;

cin >> a >> b >> c;   // 输入:10 3.14 x
  • 输入整数给 int 变量。
  • 输入小数给 double 变量。
  • 输入一个字符(注意不要有空格,比如直接输入 x)。
注意事项
  • 输入的数据类型必须和变量类型匹配,否则可能出错或得到奇怪的结果。
  • 输入字符串(string)需要包含 <string> 头文件,并用 cin >> 字符串变量;,但字符串不能有空格(空格会被当作分隔符)。如果要输入带空格的整行,需要用 getline(cin, 变量);,我们以后再学。

2.2.3 编程实例讲解

实例2:输入两个数,求它们的和
#include <iostream>
using namespace std;

int main() {
    int num1, num2;

    cout << "请输入两个整数(用空格隔开):";
    cin >> num1 >> num2;

    int sum = num1 + num2;
    cout << "它们的和是:" << sum << endl;

    return 0;
}

运行示例

请输入两个整数(用空格隔开):12 8
它们的和是:20

2.2.4 阶段性编程练习

  1. 练习1:编写程序,让用户输入自己的身高(厘米),然后输出“你的身高是xxx厘米”。
  2. 练习2:编写程序,输入一个圆的半径(整数),计算并输出圆的周长(周长 = 2 * 3.14 * 半径)。
  3. 练习3:输入两个小数,计算它们的乘积并输出。
  4. 练习4:输入一个字符,输出该字符在ASCII表中的下一个字符(比如输入 ‘A’,输出 ‘B’)。提示:字符也可以像整数一样加减。

2.3 变量的运算

2.3.1 运算程序范例

C++ 可以对变量进行各种数学运算。看这个例子:

#include <iostream>
using namespace std;

int main() {
    int a = 10, b = 3;
    int sum, diff, product, quotient, remainder;

    sum = a + b;          // 加法
    diff = a - b;         // 减法
    product = a * b;      // 乘法
    quotient = a / b;     // 除法(整数除法,结果只取整数部分)
    remainder = a % b;    // 取余(求余数)

    cout << "a + b = " << sum << endl;
    cout << "a - b = " << diff << endl;
    cout << "a * b = " << product << endl;
    cout << "a / b = " << quotient << endl;
    cout << "a % b = " << remainder << endl;

    return 0;
}

运行结果

a + b = 13
a - b = 7
a * b = 30
a / b = 3
a % b = 1

2.3.2 变量运算的用法

算术运算符
运算符 含义 例子 结果(a=10,b=3)
+ 加法 a + b 13
- 减法 a - b 7
* 乘法 a * b 30
/ 除法 a / b 3(注意:整数除法会丢弃小数部分)
% 取余(模) a % b 1(10除以3余1)

注意
- 如果两个数都是整数,/ 执行整数除法,结果也是整数,直接去掉小数部分(不是四舍五入)。
- 如果想要小数结果,至少其中一个数要是 double 类型。例如:double c = 10.0 / 3; 结果约为 3.33333。
- % 只能用于整数。

复合赋值运算符

有时候我们需要对一个变量自身进行运算,比如 a = a + 5;,可以简写为 a += 5;

复合运算符 含义 例子 等价于
+= 加等于 a += 5 a = a + 5
-= 减等于 a -= 3 a = a - 3
*= 乘等于 a *= 2 a = a * 2
/= 除等于 a /= 4 a = a / 4
%= 模等于 a %= 3 a = a % 3
自增和自减
  • a++ 相当于 a = a + 1(先使用a的值,再自增)
  • ++a 相当于 a = a + 1(先自增,再使用a的值)
  • a----a 同理,是减1。

初学者可以先记 a++ 就是 a = a + 1 的简写。

运算符优先级

和数学一样,乘除取余优先级高于加减,括号 () 可以改变优先级。

例如:a + b * c 先算 b * c,再加 a
(a + b) * c 先算括号里的 a + b,再乘 c

2.3.3 编程实例讲解

实例3:计算圆的面积和周长

题目:输入圆的半径(可以是小数),输出面积和周长。公式:面积 = π * r²,周长 = 2 * π * r。取 π = 3.14159。

#include <iostream>
using namespace std;

int main() {
    double r, area, perimeter;
    const double PI = 3.14159;   // 常量,值不能改变

    cout << "请输入圆的半径:";
    cin >> r;

    area = PI * r * r;           // 计算面积
    perimeter = 2 * PI * r;      // 计算周长

    cout << "圆的面积是:" << area << endl;
    cout << "圆的周长是:" << perimeter << endl;

    return 0;
}
实例4:求两个数的平均值
#include <iostream>
using namespace std;

int main() {
    int a, b;
    double avg;   // 平均值可能是小数,用double

    cout << "请输入两个整数:";
    cin >> a >> b;

    avg = (a + b) / 2.0;   // 除以2.0,确保结果是小数

    cout << "平均值为:" << avg << endl;

    return 0;
}

2.3.4 阶段性编程练习

  1. 练习1:输入一个三位整数,分离出它的个位、十位、百位,并输出。例如输入 123,输出“百位是1,十位是2,个位是3”。(提示:用除法和取余)
  2. 练习2:输入两个整数,交换它们的值(使用加减法交换,不用临时变量)。
  3. 练习3:输入一个华氏温度,转换为摄氏温度。公式:摄氏 = (华氏 - 32) * 5 / 9。
  4. 练习4:输入一个秒数(整数),转换为小时:分钟:秒的形式。例如输入 3661,输出 1小时1分钟1秒。

2.4 第2章编程作业

现在,你已经学会了变量、输入和运算,来挑战一下综合题目吧!

作业1:计算总分和平均分

编写一个程序,要求用户输入三门课的成绩(整数),计算总分和平均分(平均分保留一位小数)。

输入示例

请输入三门课的成绩:80 90 75

输出示例

总分:245
平均分:81.7

作业2:简单的计算器

编写程序,让用户输入两个整数,然后分别输出它们的和、差、积、商(如果第二个数是0,则输出“除数不能为0”)、余数。

输入示例

请输入两个整数:10 3

输出示例

10 + 3 = 13
10 - 3 = 7
10 * 3 = 30
10 / 3 = 3
10 % 3 = 1

作业3:数字反转

输入一个三位整数,输出反转后的数(例如输入 123,输出 321)。注意:如果个位是0,反转后要输出没有前导0的数,比如输入 120,输出 21。

提示:先分离个十百,再组合。

作业4:猜数字游戏(拓展)

程序随机生成一个1-100之间的整数(用 rand() 函数),让用户猜,每次告诉用户猜大了还是猜小了,直到猜中为止。(提示:需要用到 #include <cstdlib>#include <ctime>,以及 srand(time(0)); 初始化随机种子,rand() % 100 + 1 生成1-100的随机数。)这个题目有点挑战,但你可以尝试!


恭喜你完成了第2章的学习!你已经掌握了C++编程中最核心的基础:变量、输入和运算。

20260227 084059 Cpp 入门第一课

欢迎来到C++编程的第一课!我是你的编程老师,今天我们要一起探索C++世界的第一个大门——输出。想象一下,你刚学会说话,第一次对世界喊出“你好”,C++的输出语句就是让计算机“说话”的方式。让我们开始吧!


1.1 程序范例:第一个C++程序

我们先来看一个最简单的C++程序,它的作用是在屏幕上显示一行文字。

📝 程序范例:输出“Hello, World!”

#include <iostream>   // 头文件:告诉计算机我们要用到输入输出功能
using namespace std;   // 使用标准命名空间(暂时理解为固定搭配)

int main() {           // 主函数:程序的入口,所有代码从这里开始执行
    cout << "Hello, World!";  // 输出语句:将双引号里的内容显示在屏幕上
    return 0;          // 返回0:告诉操作系统程序正常结束
}

🔍 逐行解释(像讲故事一样)

  • #include <iostream>
    这就像你做饭前要准备锅碗瓢盆。iostream是C++里负责“输入输出”的工具箱,有了它,你才能用cout(读作“see out”)来输出文字。

  • using namespace std;
    这个暂时可以理解为“我要用标准工具箱里的东西”。很多C++程序都写着这一行,我们先记住它就好。

  • int main()
    这是程序的“心脏”,所有代码都要写在大括号{}里。计算机一运行程序,就会先找main()函数。

  • cout << "Hello, World!";
    cout是输出命令,<<是“流向”符号,意思就是把右边的文字送到屏幕上。注意双引号里的内容会原样显示,分号;表示这句话结束(就像句号)。

  • return 0;
    程序结束前给操作系统报个平安:“一切正常,我退出了!”

🖥️ 运行结果

Hello, World!

1.2 程序编译错误处理

写代码就像写作文,难免会写错字或漏标点。编译器(把代码翻译成计算机能懂的语言的工具)会帮你找出错误。

1.2.1 当程序报错的时候怎么处理

常见错误1:忘记分号 ;
cout << "Hello"   // 少了分号
  • 错误信息(可能的样子)
    expected ';' before 'return'
    (意思是:在return前面应该有一个分号)

  • 解决方法:在句子末尾加上;

常见错误2:拼写错误
c0ut << "Hello";   // 把cout写成c0ut(数字0代替字母o)
  • 错误信息
    'c0ut' was not declared in this scope
    c0ut这个单词没有被声明,编译器不认识它)

  • 解决方法:检查单词拼写,注意大小写(C++区分大小写)。

常见错误3:忘记引号
cout << Hello;   // Hello没有加双引号
  • 错误信息
    'Hello' was not declared in this scope
    (编译器以为Hello是一个变量,但它没定义)

  • 解决方法:文字内容必须用双引号括起来。

小技巧:如何读懂错误信息?

  • 错误信息里通常会有行号,告诉你哪一行出错了。
  • 关键词如expected(期望)、not declared(未声明)能帮你推测原因。
  • 别怕,多看几次就熟悉了!

1.2.2 编译过程展示

你可能好奇,我们写的代码是怎么变成计算机能运行的程序的呢?这个过程叫做编译,就像把一份中文菜谱翻译成计算机能懂的机器语言。

步骤:
1. 写代码(源文件,后缀为.cpp)—— 你写的英文/符号组成的程序。
2. 编译(用编译器)—— 编译器把源文件翻译成机器能懂的目标文件(后缀为.obj.o)。
3. 链接—— 把目标文件和需要的库(比如iostream)合并成一个可执行文件.exe)。
4. 运行—— 双击.exe文件,程序就开始执行了!

我们平时在IDE(集成开发环境,如Dev-C++、Code::Blocks)里点一下“运行”按钮,其实它自动完成了编译+链接+运行。


1.3 编程题库介绍

学编程就像学游泳,光看书不下水永远学不会。编程题库就是让你练习的“游泳池”。

1.3.1 编程题库的使用方法和技巧

  1. 先看懂题目:题目会描述输入和输出要求,比如“输出两行文字”“输出一个数字”等。
  2. 在脑子里构思:先想想要用哪些语句,顺序是怎样的。
  3. 写代码:在电脑上敲出来。
  4. 运行测试:看看结果是不是和题目要求的一样。
  5. 如果错了,就调试:检查代码哪里出了问题。

小技巧:一开始可以模仿范例,把里面的文字改成题目要求的文字。


1.3.2 实战练习

下面给你几个简单的输出题目,你可以自己试试看!

🥇 练习1:输出你的名字

在屏幕上输出你的名字,比如“我叫小明”。

预期输出

我叫小明

提示:只需要修改cout语句里的文字。


🥈 练习2:输出两行文字

输出两行文字,第一行是“Hello”,第二行是“C++”。

预期输出

Hello
C++

提示:可以用两个cout语句,每个后面都要有分号。或者用endl换行,比如:

cout << "Hello" << endl;
cout << "C++";

endl是“end line”的缩写,作用是换行。


🥉 练习3:输出一个简单的算式结果

计算 3 + 5 并在屏幕上显示结果。

预期输出

8

提示cout可以直接输出数字,比如 cout << 3+5; 会直接计算并输出8。


🎉 本章总结

  • 学习了第一个C++程序的基本结构:#includemaincoutreturn 0
  • 知道了常见的编译错误和解决方法。
  • 了解了编译的简单过程。
  • 学会了使用编程题库进行练习。

现在,你可以打开你的C++编程环境,动手试试上面的练习啦!如果遇到错误,别着急,对照错误信息慢慢检查,你一定能搞定!

20260223 111222 增长函数Growth Functions概述

增长函数(Growth Functions)概述

在算法分析中,增长函数(也称 asymptotic growth)用于描述一个算法随输入规模 (n) 增大时所需的资源(时间、空间)如何变化。
它们的核心思想是 忽略常数因子和低阶项,只关注最“显著”的那一部分——因为当 (n) 足够大时,这部分支配了整体的资源消耗。

下面从 数学定义 → 记号体系 → 典型例子 → 对比技巧 → 常见误区 → 实践使用 四个层面,系统地介绍增长函数。


1️⃣ 形式化定义

设 $(f, g : \mathbb{N} \rightarrow \mathbb{R}_{\ge 0}) $为非负函数(常表示算法的运行次数或所占空间)。
我们用 渐近记号(asymptotic notation)来刻画它们的相对增长速度。

记号 形式化定义 直观解释
大 O (\displaystyle O(g(n))) (f(n) \in O(g(n)) \iff \exists\,c>0,\,n_0) 使得 (\forall n \ge n_0,\; 0 \le f(n) \le c\cdot g(n)). 上界:(f) 最多和 (g) 同阶(常数因子无关紧要)。
大 Ω (\displaystyle \Omega(g(n))) (f(n) \in \Omega(g(n)) \iff \exists\,c>0,\,n_0) 使得 (\forall n \ge n_0,\; 0 \le c\cdot g(n) \le f(n)). 下界:(f) 至少和 (g) 同阶。
大 Θ (\displaystyle \Theta(g(n))) (f(n) \in \Theta(g(n)) \iff f(n) \in O(g(n))) (f(n) \in \Omega(g(n))). 紧确界:(f) 与 (g) 同阶,常数因子可忽略。
小 o (\displaystyle o(g(n))) (\displaystyle f(n) \in o(g(n)) \iff \forall c>0,\;\exists n_0:\forall n\ge n_0,\;0\le f(n) < c\cdot g(n).) 严格上界:(f) 的增长速度严格低于 (g)。
小 ω (\displaystyle \omega(g(n))) (\displaystyle f(n) \in \omega(g(n)) \iff \forall c>0,\;\exists n_0:\forall n\ge n_0,\;0\le c\cdot g(n) < f(n).) 严格下界:(f) 的增长速度严格高于 (g)。

等价的极限公式(极限判别法)
[
\begin{aligned}
f \in O(g) &\iff \limsup_{n\to\infty}\frac{f(n)}{g(n)} < \infty,\
f \in \Omega(g) &\iff \liminf_{n\to\infty}\frac{f(n)}{g(n)} > 0,\
f \in \Theta(g) &\iff 0<\lim_{n\to\infty}\frac{f(n)}{g(n)}<\infty,\
f \in o(g) &\iff \lim_{n\to\infty}\frac{f(n)}{g(n)} = 0,\
f \in \omega(g) &\iff \lim_{n\to\infty}\frac{f(n)}{g(n)} = \infty.
\end{aligned}
]


2️⃣ 常见函数族(Growth Hierarchy)

类别 典型函数 记号示例 备注
常数 (1) (\Theta(1)) 与输入规模无关
对数 (\log n, \log_{2} n, \ln n) (\Theta(\log n)) 底数不同只差常数因子
线性 (n) (\Theta(n)) 常见遍历、线性搜索
线性对数 (n\log n) (\Theta(n\log n)) 归并排序、堆排序的复杂度
多项式 (n^{k})((k) 为常数) (\Theta(n^{k})) (k=2) → (\Theta(n^{2}))(插入/冒泡)
指数 (c^{\,n})((c>1)) (\Theta(c^{n})) 递归暴力搜索、背包 2‑划分
超指数/阶乘 (n!) (\Theta(n!)) 旅行商(TSP)暴力求解
双指数 (2^{\,2^{n}}) (\Theta(2^{2^{n}})) 极少数理论构造
多项式乘以指数 (n^{k}c^{n}) (\Theta(n^{k}c^{n})) 部分 DP 状态压缩的复杂度

增长顺序(从慢到快)(省略常数因子):

[
1 \;<\; \log n \;<\; n \;<\; n\log n \;<\; n^{2} \;<\; n^{3} \;<\; 2^{\,\log n}=n^{\log 2} \;<\; 2^{n} \;<\; n! \;<\; 2^{\,2^{n}} \;<\; \dots
]

重要事实
- (\log^{k} n = o(n^{\epsilon})) 对任意常数 (k) 与 (\epsilon>0)。
- (n^{c} = o(c^{n})) 对任何常数 (c>1)。
- (\log n = o(n^{\epsilon})) —— 这解释了 “对数时间” 远优于 “线性时间”。


3️⃣ 如何比较两个增长函数

3.1 直接使用极限(L’Hôpital、级数)

例子 1:比较 (f(n)=n^{2}) 与 (g(n)=n\log n)。
[
\lim_{n\to\infty}\frac{n^{2}}{n\log n}= \lim_{n\to\infty}\frac{n}{\log n}= \infty.
]
因此 (n^{2}\in \omega(n\log n)),即 (n\log n = o(n^{2}))。

例子 2:比较 (f(n)=2^{n}) 与 (g(n)=n!)。
利用斯特林公式 (n! \sim \sqrt{2\pi n}\,(n/e)^{n}),则
[
\frac{2^{n}}{n!}\approx \frac{2^{n}}{(n/e)^{n}}= \left(\frac{2e}{n}\right)^{n}\xrightarrow{n\to\infty}0,
]
所以 (2^{n}=o(n!))。

3.2 递归树/主定理的经验法则

  • 多项式 vs. 指数:若递归树的分支因子是常数(如 2),则最终复杂度往往是指数,而如果每层的工作量是 多项式,则整体仍是 多项式(主定理 ①‑③)。
  • 对数乘常数:任何 (c\cdot\log n) 都在 (\Theta(\log n)) 里,因为常数因子被忽略。

3.3 摊销分析(Amortized)

  • 例子:向动态数组(如 vector)中逐次 push_back
  • 每次扩容成本是 (O(n)),但均摊后每次操作为 (O(1)),故总体为 (\Theta(n)),单次操作的 增长函数(O(1))(均摊视角)。

3.4 规则记忆技巧(助记口诀)

规则 解释 示例
常数 < 对数 < 线性 < 线性对数 < 多项式 < 指数 < 阶乘 只看 最高阶,忽略常数 (5n^2 + 3n + 12 = \Theta(n^2))
底数不同的对数等价 (\log_{a} n = \frac{1}{\log a}\log n) (\log_{10} n = \Theta(\log n))
指数函数的底数不同也等价 (a^{n} = \Theta(b^{n}))((a,b>1)) (2^{n} = \Theta(3^{n}))(仅常数因素差)
多项式乘指数 = 指数 (n^{k}c^{n} = \Theta(c^{n})) (n^{5}2^{n} = \Theta(2^{n}))
对数的多项式次方仍是对数 ((\log n)^{k}=o(n^{\epsilon})) ((\log n)^{10} = o(\sqrt{n}))

4️⃣ 典型增长函数实例与常见算法对应

增长函数 典型算法/问题 关键技术
(\Theta(1)) 哈希表的 查找/插入(均摊)、数组随机访问 均摊分析、哈希冲突概率
(\Theta(\log n)) 二分搜索、平衡二叉搜索树(红黑树、AVL) 递归/迭代的分治结构
(\Theta(n)) 线性遍历、计数排序的计数阶段、链表遍历 直接扫描
(\Theta(n\log n)) 归并排序、堆排序、快速排序(平均)、线性时间排序的 基数排序(基数常数) 分治+合并、堆操作
(\Theta(n^{2})) 冒泡/插入/选择排序、所有 (O(n^{2})) 的动态规划(如 LCS 再无优化) 双层循环、DP 表填充
(\Theta(n^{3})) Floyd‑Warshall、三层嵌套循环的矩阵乘法(朴素) 三重循环
(\Theta(2^{n})) 旅行商(暴力 TSP)、子集枚举、递归背包(指数) 位掩码、递归分支
(\Theta(n\,2^{n})) DP on subsets(如 Hamiltonian Path DP) 状态压缩 DP
(\Theta(n!)) 全排列生成、全排列搜索(TSP 朴素) 全排列递归
(\Theta(\log^{k} n)) 递归树高度为 (\log^{2} n) 的 分治 + 合并(如分块 & 线段树合并) 多层分治
(\Theta(\sqrt{n})) 区间 sqrt decomposition(块大小 (\sqrt{n})) 块分技术
(\Theta(n^{\log_{b} a})) 主定理中 (T(n)=a\,T(n/b)+f(n)) 的平衡情况 主定理(Case 1)

5️⃣ 常见误区与如何避免

误区 说明 正确做法
把常数因子当成阶层 认为 “5nn 更差”,忽视渐进意义。 Θ 表示法里,系数被视为 O(1),只关注最高阶项。
低阶项能忽略但不一定 实际运行 中,若 (n) 较小,n^2 可能慢于 100n. 理论实验 结合:对目标规模做基准测试。
把对数底看成意义重大 误以为 log_2 nlog_10 n 差别很大。 使用 (\log n) 表示通用对数,底数差仅常数因子。
oO 混为一谈 认为 o(g)O(g) 相同。 o(g)严格 上界(极限为 0),O(g) 只要求有常数上限。
使用极限时忘记整数化 直接把极限写成 0 而不说明 n → ∞ 的离散性。 说明使用 连续延拓(把函数放到实数)或采用 Stirling、L’Hôpital 等严谨手段。
忘记对函数做非负化 对负数函数直接套用 Θ/Ω 定义会出错。 在算法分析里,运行次数空间 均为非负;若出现负数,先取绝对值或重新建模。

6️⃣ 实践:自检练习

下面列出 10 组 常见比较题,建议自行手算极限或用 Python/Mathematica 验证。

# 比较 你的结论 解释/极限
1 (n \log n) vs. (n^{1.1}) (n\log n = o(n^{1.1})) (\frac{n\log n}{n^{1.1}} = \frac{\log n}{n^{0.1}} \to 0)
2 (2^{n}) vs. (n^{\log n}) (2^{n} = \omega(n^{\log n})) (\log n \cdot \log n = (\log n)^2) vs. (n) in exponent → exponential dominates
3 (\sqrt{n}) vs. (\log n) (\log n = o(\sqrt{n})) (\frac{\log n}{\sqrt{n}} \to 0)
4 (n!) vs. (2^{n}) (2^{n}=o(n!)) Stirling: (n! \approx (n/e)^{n})
5 ((\log n)^{2}) vs. (n^{0.01}) ((\log n)^{2}=o(n^{0.01})) (\frac{(\log n)^{2}}{n^{0.01}} \to 0)
6 (n^{\log n}) vs. (2^{\log^{2} n}) 两者同阶 (\Theta(2^{\log^{2} n})) (\log n = \log_{2} n) → (n^{\log n}=2^{(\log n)^{2}})
7 (n^{\log_{2} 3}) vs. (3^{\log_{2} n}) 同阶 (\Theta(n^{\log_{2} 3})) 通过指数换底公式相同
8 (n^{k}) vs. (c^{n})((c>1)) (n^{k}=o(c^{n})) Exponential dominates any polynomial
9 (n^{\log n}) vs. (2^{n}) (n^{\log n}=o(2^{n})) (\log n\cdot \log n = (\log n)^{2}) vs. linear (n) in exponent
10 (\log (n!)) vs. (n\log n) (\log (n!) = \Theta(n\log n)) Stirling: (\log(n!) = n\log n - n + O(\log n))

自检:如果你对任意一行没有把握,请回到极限公式或使用 Pythonsympy.limit 功能验证。


7️⃣ 代码实验:用 Python 自动判断 O/Θ

下面提供一个简易脚本,使用 SymPy(符号计算)判断两函数的关系。仅作为教学示例,实际使用时请结合手动推理。

# growth_compare.py
import sympy as sp

def compare(f, g, var=sp.Symbol('n', positive=True)):
    """Return relationship between f(n) and g(n):
       'Theta', 'O', 'Omega', 'o', 'omega', or 'incomparable'."""
    ratio = sp.simplify(f/g)
    lim = sp.limit(ratio, var, sp.oo)  # limit as n -> ∞

    if lim.is_number:
        if lim == 0:
            return 'o'          # f=o(g)
        elif lim.is_infinite:
            return 'omega'      # f=ω(g)
        else:  # finite non‑zero constant
            return 'Theta'     # f=Θ(g)
    else:
        # fallback: check limsup/liminf numerically for some large values
        vals = [10**k for k in range(3, 8)]
        nums = [float(ratio.subs(var, v)) for v in vals]
        if max(nums) < 10 and min(nums) > 0.1:
            return 'Theta (numeric)'
        elif max(nums) < 2:
            return 'O (numeric)'
        elif min(nums) > 0.5:
            return 'Omega (numeric)'
        else:
            return 'inconclusive'

# --------------------------------------------------
if __name__ == '__main__':
    n = sp.Symbol('n', positive=True, integer=True)

    examples = [
        (n*sp.log(n), n**1.1),
        (2**n, n*sp.log(n)),
        (sp.sqrt(n), sp.log(n)),
        (sp.factorial(n), 2**n),
        ((sp.log(n))**2, n**0.01),
        (n**sp.log(n,2), 2**(sp.log(n,2)**2)),
        (n**sp.log(3,2), 3**sp.log(n,2)),
    ]

    for f, g in examples:
        print(f"f = {sp.simplify(f)}")
        print(f"g = {sp.simplify(g)}")
        print("Relation :", compare(f, g))
        print("-" * 40)

运行后会输出每对函数的关系(如 Theta, o, omega),帮助 快速验证 直觉。


8️⃣ 进一步阅读与练手资源

类型 资源 链接 适合人群
教材 《算法导论》第 4 版(Cormen、Leiserson、Rivest、Stein) https://mitpress.mit.edu/books/introduction-algorithms 所有层次
讲义 MIT 6.006 (Intro to Algorithms) 课程笔记 https://ocw.mit.edu/courses/6-006-introduction-to-algorithms-fall-2020 初学者
视频 Stanford CS161 (Design & Analysis) 2023 https://www.youtube.com/playlist?list=PL4B9E7E6E6EE8C71A 进阶
交互式 VisuAlgo – 视图所有常见增长函数的比较图 https://visualgo.net/en 直观理解
练习 LeetCode “Algorithm” 标签(排序、搜索、图等) https://leetcode.com/problemset/all/?topicSlugs=algorithm 刷题
理论 《算法设计》(Kleinberg & Tardos)第 7 章 https://www.cs.cornell.edu/home/kleinber/algorithms/ 深入证明
数学 《Concrete Mathematics》章节 3(渐近分析) https://doi.org/10.1017/CBO9781139644032 确切极限技巧
工具 Anki 记忆卡片(常用函数、记号) https://apps.ankiweb.net/ 长期记忆
项目 实现一个 “增长函数可视化” WebApp(输入两函数,显示极限、绘制曲线) 自行开发 巩固概念 + 编程实践

9️⃣ 小结

  1. 增长函数 抽象掉常数和低阶项,只保留“最高阶”行为。
  2. 大 O / Ω / Θ 给出上、下、紧确界;小 o / ω 表示严格的上下界。
  3. 使用 极限摊销递归树主定理 等工具可以 系统地比较 任意两个函数。
  4. 常见函数族 按速率从慢到快排列,帮助快速判断算法的优劣。
  5. 误区(常数因子、对数底、o 与 O 的混淆)要时刻警惕,理论与实际的差距要通过实验验证。
  6. 实践:写代码实现、手算极限、用可视化工具或 Python 脚本自动对比,逐步形成对“函数增长速率直觉”的肌肉记忆

掌握了这些概念后,你就能在阅读新算法时立刻判断它的 时间/空间规模,并在面试、研究或系统设计时做出 最合适的算法选择。祝学习愉快,成长为 增长函数的驾驭者! 🚀

20260223 111221 增长函数Growth Functions概述

增长函数(Growth Functions)概述

在算法分析中,增长函数(也称 asymptotic growth)用于描述一个算法随输入规模 (n) 增大时所需的资源(时间、空间)如何变化。
它们的核心思想是 忽略常数因子和低阶项,只关注最“显著”的那一部分——因为当 (n) 足够大时,这部分支配了整体的资源消耗。

下面从 数学定义 → 记号体系 → 典型例子 → 对比技巧 → 常见误区 → 实践使用 四个层面,系统地介绍增长函数。


1️⃣ 形式化定义

设 $(f, g : \mathbb{N} \rightarrow \mathbb{R}_{\ge 0}) $为非负函数(常表示算法的运行次数或所占空间)。
我们用 渐近记号(asymptotic notation)来刻画它们的相对增长速度。

记号 形式化定义 直观解释
大 O (\displaystyle O(g(n))) (f(n) \in O(g(n)) \iff \exists\,c>0,\,n_0) 使得 (\forall n \ge n_0,\; 0 \le f(n) \le c\cdot g(n)). 上界:(f) 最多和 (g) 同阶(常数因子无关紧要)。
大 Ω (\displaystyle \Omega(g(n))) (f(n) \in \Omega(g(n)) \iff \exists\,c>0,\,n_0) 使得 (\forall n \ge n_0,\; 0 \le c\cdot g(n) \le f(n)). 下界:(f) 至少和 (g) 同阶。
大 Θ (\displaystyle \Theta(g(n))) (f(n) \in \Theta(g(n)) \iff f(n) \in O(g(n))) (f(n) \in \Omega(g(n))). 紧确界:(f) 与 (g) 同阶,常数因子可忽略。
小 o (\displaystyle o(g(n))) (\displaystyle f(n) \in o(g(n)) \iff \forall c>0,\;\exists n_0:\forall n\ge n_0,\;0\le f(n) < c\cdot g(n).) 严格上界:(f) 的增长速度严格低于 (g)。
小 ω (\displaystyle \omega(g(n))) (\displaystyle f(n) \in \omega(g(n)) \iff \forall c>0,\;\exists n_0:\forall n\ge n_0,\;0\le c\cdot g(n) < f(n).) 严格下界:(f) 的增长速度严格高于 (g)。

等价的极限公式(极限判别法)
[
\begin{aligned}
f \in O(g) &\iff \limsup_{n\to\infty}\frac{f(n)}{g(n)} < \infty,\
f \in \Omega(g) &\iff \liminf_{n\to\infty}\frac{f(n)}{g(n)} > 0,\
f \in \Theta(g) &\iff 0<\lim_{n\to\infty}\frac{f(n)}{g(n)}<\infty,\
f \in o(g) &\iff \lim_{n\to\infty}\frac{f(n)}{g(n)} = 0,\
f \in \omega(g) &\iff \lim_{n\to\infty}\frac{f(n)}{g(n)} = \infty.
\end{aligned}
]


2️⃣ 常见函数族(Growth Hierarchy)

类别 典型函数 记号示例 备注
常数 (1) (\Theta(1)) 与输入规模无关
对数 (\log n, \log_{2} n, \ln n) (\Theta(\log n)) 底数不同只差常数因子
线性 (n) (\Theta(n)) 常见遍历、线性搜索
线性对数 (n\log n) (\Theta(n\log n)) 归并排序、堆排序的复杂度
多项式 (n^{k})((k) 为常数) (\Theta(n^{k})) (k=2) → (\Theta(n^{2}))(插入/冒泡)
指数 (c^{\,n})((c>1)) (\Theta(c^{n})) 递归暴力搜索、背包 2‑划分
超指数/阶乘 (n!) (\Theta(n!)) 旅行商(TSP)暴力求解
双指数 (2^{\,2^{n}}) (\Theta(2^{2^{n}})) 极少数理论构造
多项式乘以指数 (n^{k}c^{n}) (\Theta(n^{k}c^{n})) 部分 DP 状态压缩的复杂度

增长顺序(从慢到快)(省略常数因子):

[
1 \;<\; \log n \;<\; n \;<\; n\log n \;<\; n^{2} \;<\; n^{3} \;<\; 2^{\,\log n}=n^{\log 2} \;<\; 2^{n} \;<\; n! \;<\; 2^{\,2^{n}} \;<\; \dots
]

重要事实
- (\log^{k} n = o(n^{\epsilon})) 对任意常数 (k) 与 (\epsilon>0)。
- (n^{c} = o(c^{n})) 对任何常数 (c>1)。
- (\log n = o(n^{\epsilon})) —— 这解释了 “对数时间” 远优于 “线性时间”。


3️⃣ 如何比较两个增长函数

3.1 直接使用极限(L’Hôpital、级数)

例子 1:比较 (f(n)=n^{2}) 与 (g(n)=n\log n)。
[
\lim_{n\to\infty}\frac{n^{2}}{n\log n}= \lim_{n\to\infty}\frac{n}{\log n}= \infty.
]
因此 (n^{2}\in \omega(n\log n)),即 (n\log n = o(n^{2}))。

例子 2:比较 (f(n)=2^{n}) 与 (g(n)=n!)。
利用斯特林公式 (n! \sim \sqrt{2\pi n}\,(n/e)^{n}),则
[
\frac{2^{n}}{n!}\approx \frac{2^{n}}{(n/e)^{n}}= \left(\frac{2e}{n}\right)^{n}\xrightarrow{n\to\infty}0,
]
所以 (2^{n}=o(n!))。

3.2 递归树/主定理的经验法则

  • 多项式 vs. 指数:若递归树的分支因子是常数(如 2),则最终复杂度往往是指数,而如果每层的工作量是 多项式,则整体仍是 多项式(主定理 ①‑③)。
  • 对数乘常数:任何 (c\cdot\log n) 都在 (\Theta(\log n)) 里,因为常数因子被忽略。

3.3 摊销分析(Amortized)

  • 例子:向动态数组(如 vector)中逐次 push_back
  • 每次扩容成本是 (O(n)),但均摊后每次操作为 (O(1)),故总体为 (\Theta(n)),单次操作的 增长函数(O(1))(均摊视角)。

3.4 规则记忆技巧(助记口诀)

规则 解释 示例
常数 < 对数 < 线性 < 线性对数 < 多项式 < 指数 < 阶乘 只看 最高阶,忽略常数 (5n^2 + 3n + 12 = \Theta(n^2))
底数不同的对数等价 (\log_{a} n = \frac{1}{\log a}\log n) (\log_{10} n = \Theta(\log n))
指数函数的底数不同也等价 (a^{n} = \Theta(b^{n}))((a,b>1)) (2^{n} = \Theta(3^{n}))(仅常数因素差)
多项式乘指数 = 指数 (n^{k}c^{n} = \Theta(c^{n})) (n^{5}2^{n} = \Theta(2^{n}))
对数的多项式次方仍是对数 ((\log n)^{k}=o(n^{\epsilon})) ((\log n)^{10} = o(\sqrt{n}))

4️⃣ 典型增长函数实例与常见算法对应

增长函数 典型算法/问题 关键技术
(\Theta(1)) 哈希表的 查找/插入(均摊)、数组随机访问 均摊分析、哈希冲突概率
(\Theta(\log n)) 二分搜索、平衡二叉搜索树(红黑树、AVL) 递归/迭代的分治结构
(\Theta(n)) 线性遍历、计数排序的计数阶段、链表遍历 直接扫描
(\Theta(n\log n)) 归并排序、堆排序、快速排序(平均)、线性时间排序的 基数排序(基数常数) 分治+合并、堆操作
(\Theta(n^{2})) 冒泡/插入/选择排序、所有 (O(n^{2})) 的动态规划(如 LCS 再无优化) 双层循环、DP 表填充
(\Theta(n^{3})) Floyd‑Warshall、三层嵌套循环的矩阵乘法(朴素) 三重循环
(\Theta(2^{n})) 旅行商(暴力 TSP)、子集枚举、递归背包(指数) 位掩码、递归分支
(\Theta(n\,2^{n})) DP on subsets(如 Hamiltonian Path DP) 状态压缩 DP
(\Theta(n!)) 全排列生成、全排列搜索(TSP 朴素) 全排列递归
(\Theta(\log^{k} n)) 递归树高度为 (\log^{2} n) 的 分治 + 合并(如分块 & 线段树合并) 多层分治
(\Theta(\sqrt{n})) 区间 sqrt decomposition(块大小 (\sqrt{n})) 块分技术
(\Theta(n^{\log_{b} a})) 主定理中 (T(n)=a\,T(n/b)+f(n)) 的平衡情况 主定理(Case 1)

5️⃣ 常见误区与如何避免

误区 说明 正确做法
把常数因子当成阶层 认为 “5nn 更差”,忽视渐进意义。 Θ 表示法里,系数被视为 O(1),只关注最高阶项。
低阶项能忽略但不一定 实际运行 中,若 (n) 较小,n^2 可能慢于 100n. 理论实验 结合:对目标规模做基准测试。
把对数底看成意义重大 误以为 log_2 nlog_10 n 差别很大。 使用 (\log n) 表示通用对数,底数差仅常数因子。
oO 混为一谈 认为 o(g)O(g) 相同。 o(g)严格 上界(极限为 0),O(g) 只要求有常数上限。
使用极限时忘记整数化 直接把极限写成 0 而不说明 n → ∞ 的离散性。 说明使用 连续延拓(把函数放到实数)或采用 Stirling、L’Hôpital 等严谨手段。
忘记对函数做非负化 对负数函数直接套用 Θ/Ω 定义会出错。 在算法分析里,运行次数空间 均为非负;若出现负数,先取绝对值或重新建模。

6️⃣ 实践:自检练习

下面列出 10 组 常见比较题,建议自行手算极限或用 Python/Mathematica 验证。

# 比较 你的结论 解释/极限
1 (n \log n) vs. (n^{1.1}) (n\log n = o(n^{1.1})) (\frac{n\log n}{n^{1.1}} = \frac{\log n}{n^{0.1}} \to 0)
2 (2^{n}) vs. (n^{\log n}) (2^{n} = \omega(n^{\log n})) (\log n \cdot \log n = (\log n)^2) vs. (n) in exponent → exponential dominates
3 (\sqrt{n}) vs. (\log n) (\log n = o(\sqrt{n})) (\frac{\log n}{\sqrt{n}} \to 0)
4 (n!) vs. (2^{n}) (2^{n}=o(n!)) Stirling: (n! \approx (n/e)^{n})
5 ((\log n)^{2}) vs. (n^{0.01}) ((\log n)^{2}=o(n^{0.01})) (\frac{(\log n)^{2}}{n^{0.01}} \to 0)
6 (n^{\log n}) vs. (2^{\log^{2} n}) 两者同阶 (\Theta(2^{\log^{2} n})) (\log n = \log_{2} n) → (n^{\log n}=2^{(\log n)^{2}})
7 (n^{\log_{2} 3}) vs. (3^{\log_{2} n}) 同阶 (\Theta(n^{\log_{2} 3})) 通过指数换底公式相同
8 (n^{k}) vs. (c^{n})((c>1)) (n^{k}=o(c^{n})) Exponential dominates any polynomial
9 (n^{\log n}) vs. (2^{n}) (n^{\log n}=o(2^{n})) (\log n\cdot \log n = (\log n)^{2}) vs. linear (n) in exponent
10 (\log (n!)) vs. (n\log n) (\log (n!) = \Theta(n\log n)) Stirling: (\log(n!) = n\log n - n + O(\log n))

自检:如果你对任意一行没有把握,请回到极限公式或使用 Pythonsympy.limit 功能验证。


7️⃣ 代码实验:用 Python 自动判断 O/Θ

下面提供一个简易脚本,使用 SymPy(符号计算)判断两函数的关系。仅作为教学示例,实际使用时请结合手动推理。

# growth_compare.py
import sympy as sp

def compare(f, g, var=sp.Symbol('n', positive=True)):
    """Return relationship between f(n) and g(n):
       'Theta', 'O', 'Omega', 'o', 'omega', or 'incomparable'."""
    ratio = sp.simplify(f/g)
    lim = sp.limit(ratio, var, sp.oo)  # limit as n -> ∞

    if lim.is_number:
        if lim == 0:
            return 'o'          # f=o(g)
        elif lim.is_infinite:
            return 'omega'      # f=ω(g)
        else:  # finite non‑zero constant
            return 'Theta'     # f=Θ(g)
    else:
        # fallback: check limsup/liminf numerically for some large values
        vals = [10**k for k in range(3, 8)]
        nums = [float(ratio.subs(var, v)) for v in vals]
        if max(nums) < 10 and min(nums) > 0.1:
            return 'Theta (numeric)'
        elif max(nums) < 2:
            return 'O (numeric)'
        elif min(nums) > 0.5:
            return 'Omega (numeric)'
        else:
            return 'inconclusive'

# --------------------------------------------------
if __name__ == '__main__':
    n = sp.Symbol('n', positive=True, integer=True)

    examples = [
        (n*sp.log(n), n**1.1),
        (2**n, n*sp.log(n)),
        (sp.sqrt(n), sp.log(n)),
        (sp.factorial(n), 2**n),
        ((sp.log(n))**2, n**0.01),
        (n**sp.log(n,2), 2**(sp.log(n,2)**2)),
        (n**sp.log(3,2), 3**sp.log(n,2)),
    ]

    for f, g in examples:
        print(f"f = {sp.simplify(f)}")
        print(f"g = {sp.simplify(g)}")
        print("Relation :", compare(f, g))
        print("-" * 40)

运行后会输出每对函数的关系(如 Theta, o, omega),帮助 快速验证 直觉。


8️⃣ 进一步阅读与练手资源

类型 资源 链接 适合人群
教材 《算法导论》第 4 版(Cormen、Leiserson、Rivest、Stein) https://mitpress.mit.edu/books/introduction-algorithms 所有层次
讲义 MIT 6.006 (Intro to Algorithms) 课程笔记 https://ocw.mit.edu/courses/6-006-introduction-to-algorithms-fall-2020 初学者
视频 Stanford CS161 (Design & Analysis) 2023 https://www.youtube.com/playlist?list=PL4B9E7E6E6EE8C71A 进阶
交互式 VisuAlgo – 视图所有常见增长函数的比较图 https://visualgo.net/en 直观理解
练习 LeetCode “Algorithm” 标签(排序、搜索、图等) https://leetcode.com/problemset/all/?topicSlugs=algorithm 刷题
理论 《算法设计》(Kleinberg & Tardos)第 7 章 https://www.cs.cornell.edu/home/kleinber/algorithms/ 深入证明
数学 《Concrete Mathematics》章节 3(渐近分析) https://doi.org/10.1017/CBO9781139644032 确切极限技巧
工具 Anki 记忆卡片(常用函数、记号) https://apps.ankiweb.net/ 长期记忆
项目 实现一个 “增长函数可视化” WebApp(输入两函数,显示极限、绘制曲线) 自行开发 巩固概念 + 编程实践

9️⃣ 小结

  1. 增长函数 抽象掉常数和低阶项,只保留“最高阶”行为。
  2. 大 O / Ω / Θ 给出上、下、紧确界;小 o / ω 表示严格的上下界。
  3. 使用 极限摊销递归树主定理 等工具可以 系统地比较 任意两个函数。
  4. 常见函数族 按速率从慢到快排列,帮助快速判断算法的优劣。
  5. 误区(常数因子、对数底、o 与 O 的混淆)要时刻警惕,理论与实际的差距要通过实验验证。
  6. 实践:写代码实现、手算极限、用可视化工具或 Python 脚本自动对比,逐步形成对“函数增长速率直觉”的肌肉记忆

掌握了这些概念后,你就能在阅读新算法时立刻判断它的 时间/空间规模,并在面试、研究或系统设计时做出 最合适的算法选择。祝学习愉快,成长为 增长函数的驾驭者! 🚀