最新文章

20260311 171720 2026年Ai Agent元年来了Openclaw掀起浪潮人类工作方式正在被彻底改写

“它不只是回答问题,而是直接帮你干活。”

2026年刚过去不到三个月,一个词已经火遍全球AI圈:AI Agent(智能体)

从OpenAI发布Operator,到国产团队推出Manus,再到Google、Anthropic纷纷跟进——这场关于“能干活的人工智能”的竞赛,已经全面开打。

🤖 什么是AI Agent?它和ChatGPT有什么区别?

简单来说:

  • 传统AI助手(如ChatGPT):你问它答,像个聪明的百科全书
  • AI Agent(智能体):你告诉它目标,它自己想办法完成,像个靠谱的助理

举个例子:

你说“帮我订一张下周去上海的机票”
- ChatGPT会回复你:“好的,我来帮你查找…”
- AI Agent会自己打开订票APP、比较价格、完成支付、把行程发给你

它不只是思考,而是行动。


🔥 为什么2026年是AI Agent元年?

1️⃣ 技术突破:从“嘴炮”到“动手”

2024-2025年,大语言模型的能力已经足够强大——推理、规划、多模态理解都不再是瓶颈。

真正的质变在于:Agent有了“手”

  • 连接浏览器自动化
  • 调用各种API和工具
  • 自主操作电脑、手机、软件

Anthropic CEO达里奥·阿莫代伊曾说:“未来AI最重要的能力不是回答问题,而是完成复杂任务。”

这句话正在变成现实。

2️⃣ 玩家进场:巨头集体ALL IN

国际巨头:
- 🌍 OpenAI Operator - 帮你操作浏览器、订餐、订票
- 🌍 Google Gemini Agent - 深度集成Google生态
- 🌍 Anthropic - 计算机使用能力大幅提升

国内玩家:
- 🇨🇳 Manus - 中国团队发布的通用AI Agent,震惊业界
- 🇨🇳 字节跳动Trae - AI原生IDE,编程Agent新秀
- 🇨🇳 阿里、腾讯 - 也在密集布局

3️⃣ 市场需求:效率革命

对企业来说,AI Agent意味着:

场景 传统方式 Agent方式
客服 人工回复 Agent自主解决80%问题
财务 人工报销 Agent自动审核+付款
运营 人工做报表 Agent自动抓数+分析+产出
编程 人工写代码 Agent自主完成需求

省钱、省时、7×24小时待命。


🏆 现在有哪些值得关注的AI Agent?

📌 Manus —— 中国首个通用Agent

“它不是工具,它是员工。”

  • 官网:monica.im/manus
  • 特点:自主完成复杂任务,比如“帮我整理这份财报的关键数据”
  • 亮点:多Agent协作,能自己规划、执行、检查

📌 OpenAI Operator —— 你的数字分身

  • 特点:帮你操作浏览器,自动完成订餐、购物、订票
  • 适合:日常繁琐操作

📌 字节跳动 Trae —— 编程Agent

  • 特点:国内首个AI原生IDE
  • 适合:程序员、开发者

🤔 AI Agent会让人类失业吗?

这是一个被问烂了的问题。

我的看法是:

AI Agent不是取代人类,而是“放大”人类。

它替代的是那些重复性、流程化的工作。而需要创意、情感、决策的工作,反而变得更珍贵。

就像当年Excel取代了算盘,但会计并没有消失——只是工作方式变了。

未来最稀缺的人才,是会“指挥AI干活”的人。


💡 普通人如何抓住这波红利?

  1. 现在开始试用 - 体验Manus、Operator等工具
  2. 学会写好Prompt - 与Agent沟通的能力越来越重要
  3. 关注行业动态 - AI Agent还在早期,早入局有优势
  4. 不要恐慌 - 技术是工具,使用工具的人永远有价值

📢 写在最后

2026年,AI Agent从概念走向落地。

这不仅是技术的进步,更是工作方式的彻底变革。

你准备好了吗?


你用过哪些AI Agent工具?感受如何?在评论区聊聊!


感谢阅读,欢迎转发、在看、分享!

20260311 171718 2026年Ai Agent元年来了Openclaw掀起浪潮人类工作方式正在被彻底改写

“它不只是回答问题,而是直接帮你干活。”

2026年刚过去不到三个月,一个词已经火遍全球AI圈:AI Agent(智能体)

从OpenAI发布Operator,到国产团队推出Manus,再到Google、Anthropic纷纷跟进——这场关于“能干活的人工智能”的竞赛,已经全面开打。

🤖 什么是AI Agent?它和ChatGPT有什么区别?

简单来说:

  • 传统AI助手(如ChatGPT):你问它答,像个聪明的百科全书
  • AI Agent(智能体):你告诉它目标,它自己想办法完成,像个靠谱的助理

举个例子:

你说“帮我订一张下周去上海的机票”
- ChatGPT会回复你:“好的,我来帮你查找…”
- AI Agent会自己打开订票APP、比较价格、完成支付、把行程发给你

它不只是思考,而是行动。


🔥 为什么2026年是AI Agent元年?

1️⃣ 技术突破:从“嘴炮”到“动手”

2024-2025年,大语言模型的能力已经足够强大——推理、规划、多模态理解都不再是瓶颈。

真正的质变在于:Agent有了“手”

  • 连接浏览器自动化
  • 调用各种API和工具
  • 自主操作电脑、手机、软件

Anthropic CEO达里奥·阿莫代伊曾说:“未来AI最重要的能力不是回答问题,而是完成复杂任务。”

这句话正在变成现实。

2️⃣ 玩家进场:巨头集体ALL IN

国际巨头:
- 🌍 OpenAI Operator - 帮你操作浏览器、订餐、订票
- 🌍 Google Gemini Agent - 深度集成Google生态
- 🌍 Anthropic - 计算机使用能力大幅提升

国内玩家:
- 🇨🇳 Manus - 中国团队发布的通用AI Agent,震惊业界
- 🇨🇳 字节跳动Trae - AI原生IDE,编程Agent新秀
- 🇨🇳 阿里、腾讯 - 也在密集布局

3️⃣ 市场需求:效率革命

对企业来说,AI Agent意味着:

场景 传统方式 Agent方式
客服 人工回复 Agent自主解决80%问题
财务 人工报销 Agent自动审核+付款
运营 人工做报表 Agent自动抓数+分析+产出
编程 人工写代码 Agent自主完成需求

省钱、省时、7×24小时待命。


🏆 现在有哪些值得关注的AI Agent?

📌 Manus —— 中国首个通用Agent

“它不是工具,它是员工。”

  • 官网:monica.im/manus
  • 特点:自主完成复杂任务,比如“帮我整理这份财报的关键数据”
  • 亮点:多Agent协作,能自己规划、执行、检查

📌 OpenAI Operator —— 你的数字分身

  • 特点:帮你操作浏览器,自动完成订餐、购物、订票
  • 适合:日常繁琐操作

📌 字节跳动 Trae —— 编程Agent

  • 特点:国内首个AI原生IDE
  • 适合:程序员、开发者

🤔 AI Agent会让人类失业吗?

这是一个被问烂了的问题。

我的看法是:

AI Agent不是取代人类,而是“放大”人类。

它替代的是那些重复性、流程化的工作。而需要创意、情感、决策的工作,反而变得更珍贵。

就像当年Excel取代了算盘,但会计并没有消失——只是工作方式变了。

未来最稀缺的人才,是会“指挥AI干活”的人。


💡 普通人如何抓住这波红利?

  1. 现在开始试用 - 体验Manus、Operator等工具
  2. 学会写好Prompt - 与Agent沟通的能力越来越重要
  3. 关注行业动态 - AI Agent还在早期,早入局有优势
  4. 不要恐慌 - 技术是工具,使用工具的人永远有价值

📢 写在最后

2026年,AI Agent从概念走向落地。

这不仅是技术的进步,更是工作方式的彻底变革。

你准备好了吗?


你用过哪些AI Agent工具?感受如何?在评论区聊聊!


感谢阅读,欢迎转发、在看、分享!

20260302 100639 Msn文章下载工具

我们把它升级成 专业批量下载版,支持:

  • ✅ 多个 URL 一次粘贴(每行一个)
  • ✅ 自动识别文章标题作为文件名
  • ✅ 自动清理非法字符
  • ✅ 批量打包成 ZIP 下载
  • ✅ 稳定 Playwright 渲染
  • ✅ 内存生成,无临时文件

一、安装依赖

pip install flask playwright beautifulsoup4 reportlab
playwright install

二、完整升级版 Flask(批量 + 自动文件名 + ZIP)

保存为:

app.py

✅ 完整代码

import asyncio
import re
import io
import zipfile

from flask import Flask, render_template_string, request, send_file
from playwright.async_api import async_playwright
from bs4 import BeautifulSoup

from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch

app = Flask(__name__)

# =========================
# 抓取文章
# =========================
async def fetch_article(url):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        await page.goto(url, timeout=60000)
        await page.wait_for_selector("article")

        html = await page.content()
        await browser.close()

    soup = BeautifulSoup(html, "html.parser")

    title_tag = soup.find("h1")
    title = title_tag.get_text(strip=True) if title_tag else "No Title Found"

    article = soup.find("article")
    paragraphs = []

    if article:
        for p in article.find_all("p"):
            text = p.get_text(strip=True)
            if not text:
                continue

            # 去广告关键词
            if any(keyword in text.lower() for keyword in [
                "advertisement",
                "sponsored",
                "read more",
                "subscribe",
                "sign up",
                "related",
                "continue reading"
            ]):
                continue

            paragraphs.append(text)

    content = "\n\n".join(paragraphs)
    content = clean_text(content)

    return title, content


def clean_text(text):
    text = re.sub(r'\n\s*\n', '\n\n', text)
    return text.strip()


# =========================
# 生成 PDF
# =========================
def generate_pdf(title, content):
    buffer = io.BytesIO()
    doc = SimpleDocTemplate(buffer, pagesize=letter)
    elements = []

    styles = getSampleStyleSheet()
    title_style = styles["Heading1"]
    body_style = styles["BodyText"]

    elements.append(Paragraph(title, title_style))
    elements.append(Spacer(1, 0.5 * inch))

    for paragraph in content.split("\n\n"):
        elements.append(Paragraph(paragraph, body_style))
        elements.append(Spacer(1, 0.2 * inch))

    doc.build(elements)
    buffer.seek(0)
    return buffer


# =========================
# HTML 页面
# =========================
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
    <title>MSN 批量下载器</title>
</head>
<body style="font-family: Arial; text-align: center; margin-top: 50px;">
    <h1>MSN 文章批量下载 PDF</h1>
    <p>每行一个链接</p>
    <form method="post">
        <textarea name="urls" rows="10"
                  style="width: 700px; padding: 10px;"
                  placeholder="每行粘贴一个文章链接"
                  required></textarea>
        <br><br>
        <button type="submit" style="padding: 10px 20px;">
            批量下载
        </button>
    </form>
</body>
</html>
"""


@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == "POST":
        urls_text = request.form.get("urls")
        urls = [u.strip() for u in urls_text.split("\n") if u.strip()]

        zip_buffer = io.BytesIO()

        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
            for url in urls:
                try:
                    title, content = asyncio.run(fetch_article(url))

                    if not content:
                        continue

                    pdf_buffer = generate_pdf(title, content)

                    # 自动清理文件名
                    safe_title = re.sub(r'[\\/*?:"<>|]', "", title)
                    safe_title = safe_title.strip()[:80]

                    filename = safe_title + ".pdf"

                    zipf.writestr(filename, pdf_buffer.read())

                except Exception as e:
                    print("抓取失败:", url, e)

        zip_buffer.seek(0)

        return send_file(
            zip_buffer,
            as_attachment=True,
            download_name="msn_articles.zip",
            mimetype="application/zip"
        )

    return render_template_string(HTML_TEMPLATE)


if __name__ == "__main__":
    app.run(debug=True)

三、运行方式

python app.py

打开浏览器:

http://127.0.0.1:5000

四、批量流程

粘贴多个URL
        ↓
逐个 Playwright 渲染
        ↓
提取标题 + 正文
        ↓
生成独立 PDF
        ↓
打包 ZIP
        ↓
浏览器下载

五、当前版本能力

功能 状态
单篇下载
批量下载
自动标题文件名
去广告
ZIP 打包
内存处理

你想往“企业级采集系统”方向升级吗 😎

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 个单位时间。求完成所有任务的最短时间。(贪心:每次安排剩余次数最多的任务)


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