APNA-C---数据结构笔记-全-

APNA C++ 数据结构笔记(全)

001:流程图、伪代码与安装 🚀

在本节课中,我们将要学习编程和数据结构与算法(DSA)的基础。我们将从理解如何将问题逻辑转化为流程图和伪代码开始,这是编写任何程序的第一步。最后,我们将介绍如何为C++编程设置开发环境。


概述:从逻辑到代码

在编写实际代码之前,清晰地规划解决方案至关重要。这通常涉及两个步骤:绘制流程图和编写伪代码。流程图使用图形符号直观地展示程序的流程,而伪代码则用类似英语的语句描述逻辑步骤。掌握这两种工具,能帮助我们更系统、更清晰地思考和设计程序。


什么是流程图? 📊

流程图是一种使用标准图形符号来表示算法或过程步骤的图表。它帮助我们可视化程序的逻辑流,从开始到结束,包括所有的决策和操作。

以下是流程图中常用的基本符号:

  • 起止框(椭圆形):表示程序的开始或结束。
  • 输入/输出框(平行四边形):表示输入或输出操作。
  • 处理框(矩形):表示计算或数据处理步骤。
  • 决策框(菱形):表示一个条件判断,程序流会根据判断结果(是/否)走向不同的分支。
  • 流程线(箭头):表示步骤之间的执行顺序和方向。

示例1:计算两数之和

让我们通过一个简单的例子来理解流程图:计算两个数字A和B的和。

逻辑步骤:

  1. 开始。
  2. 输入两个数字 A 和 B。
  3. 计算和:sum = A + B
  4. 输出 sum
  5. 结束。

对应的流程图清晰地展示了这个线性过程。


示例2:找出两个数中的较小值

上一个例子是线性流程。现在,我们来看一个包含决策的例子:找出两个数中的最小值。

逻辑步骤:

  1. 开始。
  2. 输入两个数字 A 和 B。
  3. 判断条件:A < B 是否成立?
    • 如果 ,则最小值为 A。
    • 如果 ,则最小值为 B。
  4. 输出找到的最小值。
  5. 结束。

在这个流程图中,决策框(菱形) 引入了分支,这是编程中 条件语句(if-else) 的图形化表示。


什么是伪代码? 📝

伪代码是一种用类似普通英语的语句来描述算法逻辑的方法。它没有严格的语法规则,重点是让逻辑易于被人理解,然后再翻译成具体的编程语言。

以下是上述“找最小值”问题的伪代码:

开始
输入 A 和 B
如果 A < B 则
    打印 A
否则
    打印 B
结束

可以看到,伪代码读起来就像一个简化的程序大纲。


示例3:判断奇偶数

为了巩固概念,我们解决另一个问题:判断一个数字 N 是奇数还是偶数。

核心逻辑: 如果一个数能被2整除(即 N % 2 == 0),那么它是偶数,否则是奇数。

流程图与伪代码要点:

  1. 输入数字 N。
  2. 计算 N % 2 并检查结果是否等于 0。
  3. 使用决策框:如果等于0,打印“偶数”;否则,打印“奇数”。
  4. 这个例子再次使用了条件判断。

示例4:计算前N个自然数的和

现在我们来处理一个需要重复执行(循环)的问题:计算 1 + 2 + 3 + ... + N 的和。

核心概念:变量与循环

  • 我们需要一个变量(例如 sum)来存储不断累加的和,初始值为0。
  • 我们需要一个计数器变量(例如 count)从1递增到N。
  • 我们需要一个循环来重复执行 sum = sum + count 这个操作。

逻辑步骤:

  1. 输入 N。
  2. 初始化 count = 1sum = 0
  3. 只要 count <= N,就重复执行以下步骤:
    • sum = sum + count
    • count = count + 1
  4. 循环结束后,输出 sum
  5. 结束。

在流程图中,循环是通过返回到决策框之前的一个步骤来实现的,这代表了 while 循环 的逻辑。


示例5:判断一个数是否为质数(基础方法)

让我们尝试一个更复杂的问题:判断数字 N 是否为质数。

基础逻辑: 质数是大于1的自然数,且除了1和它自身外,不能被其他自然数整除。我们可以检查从2到 N-1 的所有整数,看它们是否能整除 N。

算法步骤:

  1. 输入数字 N(假设 N > 1)。
  2. 初始化一个除数 i = 2
  3. 只要 i <= N-1,就重复执行:
    • 如果 N % i == 0,那么 N 能被 i 整除,N 不是质数,程序可以结束。
    • 否则,i = i + 1,继续检查下一个数。
  4. 如果循环完整结束(即没有找到能整除 N 的数),那么 N 是质数

注意: 这是判断质数最直观但并非最优的方法。我们将在后续课程中学习更高效的算法。


从逻辑到机器:为什么需要编程语言和编译器? 💻

到目前为止,我们一直在用流程图和伪代码描述逻辑。但计算机无法直接理解这些。计算机只能执行由 0 和 1 组成的机器码。

这就需要:

  1. 编程语言(如 C++):一种人类能相对容易读写,同时结构足够严谨,能被转化为机器码的语言。
  2. 编译器:一个特殊的程序(翻译器),它的工作就是将我们写的 C++ 代码(源代码)翻译成计算机能执行的 可执行文件(机器码)。

过程类比:伪代码/流程图 -> C++ 代码 ->(编译器)-> 可执行文件 -> 计算机运行并输出结果。


设置 C++ 开发环境 ⚙️

为了开始编写和运行 C++ 代码,我们需要安装两个主要工具:

  1. 编译器:例如 GCC (MinGW)。
  2. 代码编辑器/集成开发环境 (IDE):一个方便我们写代码、管理项目和调试的程序。

一个流行且功能强大的选择是 Visual Studio(注意不是 Visual Studio Code)。它是由微软提供的免费 IDE,集成了编译器和许多开发工具。

安装步骤简述:

  1. 访问 Visual Studio 官方网站。
  2. 下载 Visual Studio Community(免费版本)。
  3. 运行安装程序。在“工作负载”选择界面,务必勾选“使用 C++ 的桌面开发”
  4. 跟随安装向导完成安装。这个过程可能需要一些时间,因为它会下载并安装所有必要的组件,包括编译器。
  5. 安装完成后,你就可以启动 Visual Studio,创建新的 C++ 项目,开始你的编程之旅了!

总结与练习 🎯

本节课中我们一起学习了:

  • 流程图和伪代码的重要性,它们是设计程序逻辑的蓝图。
  • 如何使用决策框表示条件(if-else),以及如何用循环逻辑解决重复性问题。
  • 编程的基本概念:变量初始化条件判断循环
  • 从逻辑到可执行代码的流程,以及编译器的关键作用。
  • 如何设置 Visual Studio 作为我们的 C++ 开发环境。

练习题目(请先尝试画出流程图或写出伪代码):

  1. 编写一个程序计算单利:Simple Interest = P * R * T / 100
  2. 找出三个数字中的最大值。
  3. 计算一个数字 N 的阶乘(N! = 1 * 2 * 3 * ... * N)。
  4. 根据一个人的年龄判断他/她是否有资格获得驾照。

请确保你已经成功安装了开发环境。在下一讲中,我们将在 Visual Studio 中编写我们的第一个 C++ 程序。继续学习,不断探索!

002:变量、数据类型与运算符 📚

在本节课中,我们将学习C++编程的基础核心概念:变量、数据类型和运算符。我们将从编写第一个程序开始,逐步理解数据如何在计算机内存中存储,以及如何使用运算符进行各种计算。


概述

本节课的目标是掌握C++中变量的声明与使用、理解不同的数据类型(如整数、字符、浮点数、布尔值),并学习如何使用算术、关系和逻辑运算符进行基本操作。我们还将学习如何从用户那里获取输入并输出结果。


编写第一个C++程序 👨‍💻

上一节我们介绍了课程大纲,本节中我们来看看如何编写并运行一个简单的C++程序。

一个基本的C++程序结构包含预处理指令和主函数。

#include <iostream>
using namespace std;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/6b3cc1a9ec6918dde64b75212ab80f20_16.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/6b3cc1a9ec6918dde64b75212ab80f20_18.png)

int main() {
    // 你的代码写在这里
    return 0;
}
  • #include <iostream>:这是一个预处理指令,它告诉编译器包含输入输出流库,这样我们才能使用coutcin
  • using namespace std;:这行代码让我们可以直接使用std命名空间中的标准库函数,比如cout,而无需每次都写std::cout
  • int main() { ... }:这是程序的主函数。每个C++程序的执行都从这里开始。
  • return 0;:表示主函数成功执行完毕。

让我们写一个打印“Hello World”的程序。

#include <iostream>
using namespace std;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/6b3cc1a9ec6918dde64b75212ab80f20_20.png)

int main() {
    cout << "Hello World";
    return 0;
}

  • cout 用于向屏幕输出内容。
  • << 是流插入运算符,用于将右侧的内容发送到cout
  • 每行语句以分号 ; 结尾。

要输出多行文本,可以使用换行符 \n

cout << "Hello World\n";
cout << "from Apna College";

或者,将 \n 放在字符串内部:

cout << "Hello World\nfrom Apna College";

endl 也可以实现换行:

cout << "Hello World" << endl;
cout << "from Apna College";

编译并运行程序,你将在屏幕上看到输出。


变量与内存 🧠

上一节我们学会了输出文本,本节中我们来看看程序如何存储和操作数据。

变量是程序中用于存储数据的基本单元。你可以把变量想象成一个带标签的盒子,里面可以存放一个值。

声明一个变量的语法是:
数据类型 变量名;
例如:

int age; // 声明一个名为age的整数变量

给变量赋值:

age = 25; // 将值25存入age变量

声明和赋值也可以一步完成:

int age = 25;
char grade = 'A';
float price = 99.99;
bool isSafe = true;

当你在代码中写下 int age = 25; 时,计算机会在内存(RAM)中分配一小块空间(例如4个字节)来存储这个变量。这块空间被命名为 age,其中存储的值是 25


数据类型详解 🔢

变量可以存储不同类型的数据,这由数据类型决定。C++提供了几种基本(原始)数据类型。

以下是主要的基本数据类型:

  1. 整型 (int):用于存储整数(如 -10, 0, 42)。在大多数系统上占用4字节内存。

    int score = 100;
    int temperature = -5;
    
  2. 字符型 (char):用于存储单个字符(如 ‘a’, ‘B’, ‘$’)。占用1字节内存。字符在内存中实际存储的是其ASCII码值(例如 ‘A’ 对应 65)。

    char grade = 'A';
    char symbol = '$';
    

  1. 浮点型 (float):用于存储单精度浮点数(小数),如 3.14, 99.99。占用4字节内存。

    float pi = 3.14;
    float discount = 0.15;
    
  2. 双精度浮点型 (double):用于存储双精度浮点数,精度比float更高。占用8字节内存。默认的小数都被视为double类型。

    double precisePi = 3.1415926535;
    double price = 99.99;
    
  3. 布尔型 (bool):只有两个可能的值:true(真)或 false(假)。在底层,true通常用1表示,false用0表示。

    bool isRaining = true;
    bool isLoggedIn = false;
    

你可以使用 sizeof() 运算符来查看某种数据类型或变量占用的内存大小(字节数):

cout << sizeof(int) << endl; // 通常输出 4
cout << sizeof(grade) << endl; // 输出 1


类型转换 🔄

在编程中,有时需要将一种数据类型的值转换为另一种类型,这称为类型转换

  • 隐式转换:由编译器自动完成。通常发生在将较小范围类型的值赋给较大范围类型的变量时。

    int num = 10;
    double decimalNum = num; // 隐式将int转换为double,decimalNum变为10.0
    

    char赋给int会得到其ASCII码:

    char ch = 'a';
    int asciiValue = ch; // asciiValue 变为 97
    
  • 显式转换(强制转换):由程序员手动指定。当从大范围类型转换到小范围类型时,可能会丢失数据。

    double price = 99.99;
    int approxPrice = (int)price; // 显式将double转换为int,approxPrice变为99(丢失小数部分)
    


用户输入与输出 🖥️

一个完整的程序通常需要与用户交互:获取输入,进行处理,然后输出结果。

  • 输出:使用 cout<<
  • 输入:使用 cin>>

以下是一个获取用户年龄并输出的示例:

#include <iostream>
using namespace std;

int main() {
    int age;
    cout << "Enter your age: ";
    cin >> age; // 程序暂停,等待用户输入一个整数
    cout << "Your age is: " << age << endl;

    double price;
    cout << "Enter the price: ";
    cin >> price;
    cout << "You entered price: " << price << endl;

    return 0;
}

运算符 ⚙️

运算符用于对变量和值执行操作。上一节我们处理了输入输出,本节我们来学习如何用运算符处理数据。

1. 算术运算符

用于基本的数学计算。

int a = 10, b = 3;
int sum = a + b;      // 加法,结果为13
int difference = a - b; // 减法,结果为7
int product = a * b;   // 乘法,结果为30
int quotient = a / b;  // 除法,结果为3(整数除法舍去小数)
int remainder = a % b; // 取模(求余数),结果为1

注意:两个整数相除 (int / int) 结果仍是整数,小数部分被丢弃。若需要小数结果,至少一个操作数应为浮点类型。

int a = 5, b = 2;
double result = (double)a / b; // 将a显式转换为double,result为2.5

2. 关系运算符

用于比较两个值,返回布尔值 (truefalse)。

int x = 5, y = 3;
cout << (x > y);   // 大于,输出1 (true)
cout << (x < y);   // 小于,输出0 (false)
cout << (x >= 5);  // 大于等于,输出1
cout << (x <= 4);  // 小于等于,输出0
cout << (x == 5);  // 等于,输出1
cout << (x != 3);  // 不等于,输出1

3. 逻辑运算符

用于组合多个布尔条件。

  • 逻辑与 (&&):所有条件都为真时,结果才为真。
  • 逻辑或 (||):至少一个条件为真时,结果就为真。
  • 逻辑非 (!):反转布尔值。
bool condition1 = (5 > 3); // true
bool condition2 = (2 < 1); // false

cout << (condition1 && condition2); // 逻辑与,输出0 (false)
cout << (condition1 || condition2); // 逻辑或,输出1 (true)
cout << (!condition1);              // 逻辑非,输出0 (false)

4. 赋值与复合赋值运算符

= 是基本的赋值运算符。还有复合赋值运算符,可以简化操作。

int a = 10;
a += 5;  // 等价于 a = a + 5; 现在a是15
a -= 3;  // 等价于 a = a - 3; 现在a是12
a *= 2;  // 等价于 a = a * 2; 现在a是24
a /= 4;  // 等价于 a = a / 4; 现在a是6

5. 自增与自减运算符(一元运算符)

用于将变量的值增加或减少1。

  • 前缀形式 (++a, --a):先增减,再使用值。
  • 后缀形式 (a++, a--):先使用值,再增减。
int a = 10;
int b = ++a; // a先增加到11,然后赋值给b。结果:a=11, b=11

int x = 10;
int y = x++; // x的当前值(10)先赋值给y,然后x增加到11。结果:x=11, y=10

综合示例:简单计算器程序 🧮

让我们运用所学知识,编写一个执行两个数字加减乘除的程序。

#include <iostream>
using namespace std;

int main() {
    int num1, num2;
    
    cout << "Enter first number: ";
    cin >> num1;
    cout << "Enter second number: ";
    cin >> num2;
    
    cout << "Sum: " << (num1 + num2) << endl;
    cout << "Difference: " << (num1 - num2) << endl;
    cout << "Product: " << (num1 * num2) << endl;
    
    if(num2 != 0) {
        cout << "Quotient (int): " << (num1 / num2) << endl;
        cout << "Remainder: " << (num1 % num2) << endl;
    } else {
        cout << "Division by zero is not allowed." << endl;
    }
    
    return 0;
}


总结

本节课中我们一起学习了C++编程的基石:

  1. 程序结构:如何编写、编译和运行一个基本的C++程序。
  2. 变量:作为存储数据的命名容器,在内存中占有空间。
  3. 数据类型:包括 int, char, float, double, bool,它们定义了变量可以存储的数据种类和大小。
  4. 类型转换:隐式与显式转换数据类型的机制。
  5. 输入/输出:使用 cin 从用户获取输入,使用 cout 向屏幕输出信息。
  6. 运算符
    • 算术运算符 (+, -, *, /, %) 用于计算。
    • 关系运算符 (>, <, >=, <=, ==, !=) 用于比较。
    • 逻辑运算符 (&&, ||, !) 用于组合条件。
    • 自增/自减运算符 (++, --) 用于快速增减变量值。

课后作业:尝试扩展上面的计算器程序,使其能够根据用户输入的操作符(如 ‘+’, ‘-‘)执行相应的运算。

掌握这些概念是学习更复杂数据结构和算法的关键一步。继续练习,保持探索!

003:条件语句与循环 🚀

在本节课中,我们将学习C++编程中两个核心概念:条件语句循环。它们是控制程序流程的基础,能让我们编写出根据不同情况执行不同操作,以及重复执行特定任务的程序。


条件语句 🔀

条件语句允许程序根据特定条件的真假来决定执行哪一段代码。其核心是 if 语句。

if 语句

if 语句的基本结构是:如果条件为真,则执行大括号 {} 内的代码块。

if (条件) {
    // 条件为真时执行的代码
}

例如,判断一个数是否为正数:

int n = 45;
if (n >= 0) {
    cout << n << " 是正数" << endl;
}

if-else 语句

if-else 语句在条件为假时,提供了另一个执行路径。

if (条件) {
    // 条件为真时执行的代码
} else {
    // 条件为假时执行的代码
}

例如,判断一个人是否有投票权:

int age;
cout << "请输入年龄:";
cin >> age;
if (age >= 18) {
    cout << "你可以投票。" << endl;
} else {
    cout << "你不能投票。" << endl;
}

else-if 语句

当需要检查多个条件时,可以使用 else if

if (条件1) {
    // 条件1为真时执行
} else if (条件2) {
    // 条件2为真时执行
} else {
    // 以上条件都不为真时执行
}

例如,实现一个简单的评分系统:

int marks;
cout << "请输入分数:";
cin >> marks;
if (marks >= 90) {
    cout << "等级:A" << endl;
} else if (marks >= 80) {
    cout << "等级:B" << endl;
} else if (marks >= 70) {
    cout << "等级:C" << endl;
} else {
    cout << "等级:D" << endl;
}

嵌套 if 语句

if 语句可以嵌套在另一个 ifelse 语句内部,用于处理更复杂的逻辑。


字符判断示例 🔤

我们可以使用条件语句来判断一个字符是大写还是小写字母。

以下是判断逻辑:

  • 如果字符在 ‘a’‘z’ 之间,则为小写。
  • 如果字符在 ‘A’‘Z’ 之间,则为大写。
char c;
cout << "请输入一个字符:";
cin >> c;
if (c >= ‘a‘ && c <= ‘z‘) {
    cout << "这是一个小写字母。" << endl;
} else if (c >= ‘A‘ && c <= ‘Z‘) {
    cout << "这是一个大写字母。" << endl;
}

注意:在比较时,字符会隐式转换为其对应的ASCII码值(例如,‘A’ 对应65,‘a’ 对应97)。


三元运算符 ⚖️

三元运算符 ? :if-else 语句的简洁写法,适合简单的条件赋值。

语法条件 ? 表达式1 : 表达式2
如果条件为真,整个表达式的结果为 表达式1,否则为 表达式2

int n = 45;
cout << (n >= 0 ? “正数” : “负数”) << endl;

上一节我们介绍了如何根据条件做出一次性的决策,但在编程中,我们经常需要重复执行某些操作。本节中我们来看看如何利用循环来实现重复执行。

循环 🔁

循环用于重复执行一段代码块,直到满足特定条件。

while 循环

while 循环在条件为真时,会反复执行循环体内的代码。

while (条件) {
    // 条件为真时重复执行的代码
}

例如,打印数字1到3:

int count = 1;
while (count <= 3) {
    cout << count << “ ”;
    count++; // count = count + 1
}
cout << endl;
// 输出:1 2 3

注意:务必确保循环条件最终会变为假,否则会陷入无限循环。在终端中,通常可以按 Ctrl+C 来终止无限循环的程序。

for 循环

for 循环将循环变量的初始化、条件检查和更新集中在一行,结构更清晰,特别适合已知循环次数的情况。

语法

for (初始化; 条件; 更新) {
    // 循环体
}

例如,计算1到n所有整数的和:

int n = 5;
int sum = 0;
for (int i = 1; i <= n; i++) {
    sum = sum + i; // 或 sum += i
}
cout << “1到” << n << “的和是:” << sum << endl;
// 输出:1到5的和是:15

do-while 循环

do-while 循环先执行一次循环体,然后再检查条件。这意味着循环体至少会执行一次。

do {
    // 循环体(至少执行一次)
} while (条件);

例如,即使用户输入不满足条件,也至少会打印一次:

int i = 10;
do {
    cout << “Hello” << endl;
} while (i < 5); // 条件为假,但“Hello”已被打印一次

循环应用示例 🧮

让我们结合条件语句和循环来解决一些实际问题。

示例1:求1到n之间所有奇数的和

思路:遍历1到n,用 %(取模)运算符判断每个数是否为奇数(i % 2 != 0),如果是则累加。

int n = 10;
int oddSum = 0;
for (int i = 1; i <= n; i++) {
    if (i % 2 != 0) { // 判断是否为奇数
        oddSum += i;
    }
}
cout << “1到” << n << “之间所有奇数的和是:” << oddSum << endl;
// 输出:1到10之间所有奇数的和是:25

示例2:判断一个数是否为质数

质数是大于1的自然数,且除了1和它自身外,不能被其他自然数整除。

基础思路:检查从2到n-1之间,是否有数能整除n。

int n = 7;
bool isPrime = true; // 先假设是质数
for (int i = 2; i <= n-1; i++) {
    if (n % i == 0) {
        isPrime = false; // 如果能被整除,则不是质数
        break; // 发现一个因子即可确定不是质数,跳出循环
    }
}
if (isPrime) {
    cout << n << “ 是质数。” << endl;
} else {
    cout << n << “ 不是质数。” << endl;
}

优化思路:实际上,检查到 √n 即可。因为如果n有一个大于 √n 的因子,那么它必然对应一个小于 √n 的因子。

int n = 7;
bool isPrime = true;
for (int i = 2; i * i <= n; i++) { // i <= sqrt(n) 的等价写法
    if (n % i == 0) {
        isPrime = false;
        break;
    }
}
// ... 后续判断逻辑相同

嵌套循环 🎯

一个循环放在另一个循环内部,称为嵌套循环。外层循环每执行一次,内层循环会完整地执行其所有迭代。

例如,打印一个5行5列的星号矩形:

int rows = 5;
int cols = 5;
for (int i = 1; i <= rows; i++) { // 外层循环控制行
    for (int j = 1; j <= cols; j++) { // 内层循环控制列
        cout << “* ”;
    }
    cout << endl; // 每打印完一行后换行
}

输出

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

通过修改内层循环的条件,可以打印出三角形等多种图案,这将在后续关于“模式打印”的课程中详细探讨。


总结 📚

本节课中我们一起学习了控制程序流程的核心结构:

  1. 条件语句 (if, if-else, else-if,三元运算符):让程序能够根据不同条件做出决策。
  2. 循环 (while, for, do-while):让程序能够高效地重复执行任务。
  3. 嵌套循环:用于处理更复杂的重复模式,例如打印多维图形。

掌握这些概念是学习数据结构和算法(DSA)以及解决LeetCode等平台问题的基石。请务必通过练习题(如计算1到n中能被3整除的数之和、计算数字n的阶乘等)来巩固理解。下一讲,我们将深入研究循环的进阶应用——模式打印。

004:模式打印教程 🧩

在本节课中,我们将学习如何使用C++中的嵌套循环来打印各种图形模式。这是理解循环控制流和问题分解的绝佳练习。我们将从简单的方形模式开始,逐步过渡到更复杂的三角形和菱形模式。


方形模式 🔲

上一节我们介绍了嵌套循环的基本概念,本节中我们来看看如何打印一个简单的方形数字模式。

核心逻辑:使用两层循环。外层循环控制行数,内层循环控制每行打印的数字个数。

以下是打印一个 n x n 方形数字模式的步骤:

  1. 外层循环 i 从 1 运行到 n,代表每一行。
  2. 内层循环 j 也从 1 运行到 n,代表每一列。
  3. 在内层循环中,打印数字 j 或一个固定字符。
  4. 每完成一行内层循环后,输出一个换行符以开始新的一行。
int n = 4;
for(int i=1; i<=n; i++) {
    for(int j=1; j<=n; j++) {
        cout << j << " ";
    }
    cout << endl;
}

输出

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

递增三角形模式 📈

理解了方形模式后,我们来看看三角形模式。首先学习递增三角形,其特点是每行的元素数量逐行增加。

核心逻辑:内层循环的终止条件与当前行号 i 相关联。

以下是打印递增三角形模式的步骤:

  1. 外层循环 i 从 0 运行到 n-1
  2. 内层循环 j 从 0 运行到 i。这意味着第 i 行将打印 i+1 个元素。
  3. 在内层循环中,打印星号 * 或数字。
  4. 每行结束后换行。
int n = 4;
for(int i=0; i<n; i++) {
    for(int j=0; j<=i; j++) {
        cout << "* ";
    }
    cout << endl;
}

输出

*
* *
* * *
* * * *

反向三角形模式 📉

现在,我们尝试打印一个反向的三角形,即第一行元素最多,最后一行元素最少。

核心逻辑:内层循环从较大值开始,递减至较小值。

以下是打印反向三角形模式的步骤:

  1. 外层循环 i 从 0 运行到 n-1
  2. 内层循环 jn 开始,递减运行直到 j > i。这样第一行(i=0)将打印 n 个元素。
  3. 打印元素并换行。
int n = 4;
for(int i=0; i<n; i++) {
    for(int j=n; j>i; j--) {
        cout << "* ";
    }
    cout << endl;
}

输出

* * * *
* * *
* *
*

倒置三角形模式 ⏬

倒置三角形在每行开头包含空格,形成右对齐的视觉效果。

核心逻辑:每行由“空格”和“数字/星号”两部分组成。空格数递增,星号数递减。

以下是打印倒置三角形模式的步骤:

  1. 外层循环 i 从 0 运行到 n-1
  2. 第一个内层循环用于打印空格,循环 j 从 0 到 i,打印 i 个空格。
  3. 第二个内层循环用于打印星号,循环 j 从 0 到 n-i-1,打印 n-i 个星号。
  4. 换行。
int n = 4;
for(int i=0; i<n; i++) {
    // 打印空格
    for(int j=0; j<i; j++) {
        cout << "  ";
    }
    // 打印星号
    for(int j=0; j<n-i; j++) {
        cout << "* ";
    }
    cout << endl;
}

输出

* * * *
  * * *
    * *
      *

金字塔模式 🔺

金字塔模式是倒置三角形的对称组合,是更复杂的模式。

核心逻辑:将每行输出分为三部分:左侧空格、递增数字、递减数字。

以下是打印数字金字塔模式的步骤:

  1. 外层循环 i 从 0 运行到 n-1
  2. 打印左侧空格:循环 j 从 0 到 n-i-2,打印空格。
  3. 打印递增数字:循环 j 从 1 到 i+1,打印数字 j
  4. 打印递减数字:循环 ji 到 1,打印数字 j
  5. 换行。
int n = 4;
for(int i=0; i<n; i++) {
    // 左侧空格
    for(int j=0; j<n-i-1; j++) {
        cout << "  ";
    }
    // 递增数字
    for(int j=1; j<=i+1; j++) {
        cout << j << " ";
    }
    // 递减数字
    for(int j=i; j>=1; j--) {
        cout << j << " ";
    }
    cout << endl;
}

输出

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

菱形模式 💎

菱形模式可以看作是一个正金字塔和一个倒金字塔的组合。

核心逻辑:将图形分为上下两部分分别打印。

以下是打印菱形模式的步骤:
上半部分(正金字塔)

  1. 外层循环 i 从 0 运行到 n-1
  2. 打印左侧空格:循环次数为 n-i-1
  3. 打印星号:首行打印1个,后续行打印 2*i+1 个(奇数序列)。
  4. 换行。

下半部分(倒金字塔)

  1. 外层循环 i 从 0 运行到 n-2
  2. 打印左侧空格:循环次数为 i+1
  3. 打印星号:循环次数为 2*(n-i-1)-1
  4. 换行。
int n = 4;
// 上半部分
for(int i=0; i<n; i++) {
    for(int j=0; j<n-i-1; j++) cout << "  ";
    for(int j=0; j<2*i+1; j++) cout << "* ";
    cout << endl;
}
// 下半部分
for(int i=0; i<n-1; i++) {
    for(int j=0; j<=i; j++) cout << "  ";
    for(int j=0; j<2*(n-i-1)-1; j++) cout << "* ";
    cout << endl;
}

输出

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

本节课中我们一起学习了从基础到进阶的多种图形模式打印方法,包括方形、三角形、金字塔和菱形。掌握这些模式的关键在于理解外层循环控制行、内层循环控制列的基本思想,并学会将复杂模式分解为空格和图形两部分,或拆分成多个简单模式的组合。请尝试将这些模式改写成打印字母或特定数字的版本,以巩固学习成果。

005:函数 🧮

在本节课中,我们将学习C++编程中一个核心且强大的概念——函数。函数是编程的基石,它允许我们将代码组织成可重复使用的模块,从而避免冗余,使程序更加清晰和高效。

什么是函数?

上一节我们介绍了课程概述,本节中我们来看看函数的基本定义。

函数本质上是一个执行特定任务的代码块。当程序中需要反复执行某项工作时,我们可以将这项工作编写成一个函数。函数就像一个“黑盒”,它可以接收输入(参数),进行处理,并返回一个输出(返回值)。

一个函数的基本结构如下:

返回类型 函数名(参数列表) {
    // 函数体:执行任务的代码
    return 返回值; // 如果返回类型不是void
}

例如,程序执行的起点 main 函数本身就是一个函数。

创建第一个函数

理解了函数的概念后,我们来动手创建第一个简单的函数。

以下是一个打印“Hello”的函数示例。这个函数的返回类型是 void,意味着它不返回任何值。

void printHello() {
    cout << "Hello" << endl;
}

要使用这个函数,我们需要在 main 函数中“调用”它。

int main() {
    printHello(); // 函数调用
    return 0;
}

运行程序后,控制台会输出“Hello”。

带返回值的函数

并非所有函数都只是执行操作,很多函数需要计算结果并返回。本节我们学习如何创建带返回值的函数。

以下函数计算并返回整数3。注意,其返回类型是 int

int returnThree() {
    cout << "Hello" << endl;
    return 3;
}

我们可以在 main 函数中调用它,并将返回值存储在一个变量中,或直接使用。

int main() {
    int value = returnThree(); // 返回值3被存储在value中
    cout << value << endl; // 输出 3
    // 或者直接使用
    cout << returnThree() << endl; // 同样输出 3
    return 0;
}

带参数的函数

函数真正的威力在于能够处理不同的输入数据。本节我们学习如何向函数传递参数。

让我们创建一个计算两数之和的函数。它接收两个整数参数 ab

int sum(int a, int b) {
    int s = a + b;
    return s;
}

调用时,我们传入具体的数值。

int main() {
    int result = sum(10, 5); // 调用函数,传入10和5
    cout << result << endl; // 输出 15
    return 0;
}

通过参数,同一个函数可以计算任意两个整数的和,极大地提高了代码的复用性,避免了代码冗余。冗余是糟糕程序员的标志。

更多函数示例

掌握了函数的基本结构后,我们通过更多例子来巩固理解。以下是几个常见功能的函数实现。

1. 计算两数之和(支持小数)

double sumDouble(double a, double b) {
    return a + b;
}
// 调用:sumDouble(10.99, 5.65) 返回 16.64

2. 求两数中的最小值

int minOfTwo(int a, int b) {
    if (a <= b) {
        return a;
    } else {
        return b;
    }
}
// 调用:minOfTwo(5, 3) 返回 3

函数调用时传入的具体值(如5和3)称为实参。函数定义时指定的变量(如ab)称为形参

3. 计算1到n的累加和

void sumOneToN(int n) {
    int sum = 0;
    for(int i = 1; i <= n; i++) {
        sum += i;
    }
    cout << "Sum is: " << sum << endl;
}
// 调用:sumOneToN(5) 会计算1+2+3+4+5并打印结果

4. 计算n的阶乘

int factorial(int n) {
    int fact = 1;
    for(int i = 1; i <= n; i++) {
        fact *= i;
    }
    return fact;
}
// 调用:factorial(4) 返回 24, factorial(5) 返回 120

函数调用与内存(栈)

函数在内存中是如何工作的呢?这对于理解程序执行流程至关重要。

在C++中,函数调用使用了一种叫做“调用栈”的内存结构。当 main 函数开始执行时,系统会为它创建一个“栈帧”。当 main 调用另一个函数(如 sum)时,会暂停 main,为 sum 函数创建新的栈帧放在顶部,并开始执行 sum 的代码。sum 函数执行完毕后,它的栈帧被销毁,返回值被传递回来,程序控制权交还给 main 函数,并从之前暂停的地方继续执行。

每个函数的局部变量都存储在自己的栈帧中,其他函数无法直接访问。例如:

void fun() {
    int x = 25;
    cout << x << endl; // 可以访问自己的x
}
int main() {
    fun();
    // cout << x << endl; // 错误!main函数无法访问fun函数中的x
    return 0;
}

函数一旦执行到 return 语句就会立即结束,其后的代码不会被执行。

按值传递

C++中函数参数的默认传递方式是“按值传递”。这是一个非常重要的概念。

按值传递意味着当调用函数时,传入的实参的值会被复制一份给函数的形参。函数内部对形参的任何修改,都只影响这份副本,而不会改变原始实参变量的值。

void changeX(int x) {
    x = 10; // 修改的是副本
    cout << "Inside function, x = " << x << endl; // 输出 10
}
int main() {
    int x = 5;
    changeX(x); // 将x的值5复制一份传给函数
    cout << "In main, x = " << x << endl; // 输出 5,原始值未变
    return 0;
}

因此,对于普通变量(如 int, double),函数内部无法通过形参直接修改实参。

实践练习

现在,让我们运用所学知识解决两个具体问题。

问题1:计算一个数字的各位数之和
例如,数字 2356 的各位数之和是 2+3+5+6 = 16
思路:反复用 % 10 取最后一位,用 / 10 去掉最后一位,直到数字变为0。

int sumOfDigits(int number) {
    int digitSum = 0;
    while(number > 0) {
        int lastDigit = number % 10; // 获取最后一位
        digitSum += lastDigit;       // 加到总和中
        number /= 10;                // 去掉最后一位
    }
    return digitSum;
}
// 调用:sumOfDigits(2356) 返回 16

问题2:计算二项式系数 (nCr)
公式:nCr = n! / (r! * (n-r)!)
我们可以复用之前写的阶乘函数。

int factorial(int n) { /* 如前所述 */ }

int binomialCoeff(int n, int r) {
    int fact_n = factorial(n);
    int fact_r = factorial(r);
    int fact_nmr = factorial(n - r);
    int result = fact_n / (fact_r * fact_nmr);
    return result;
}
// 调用:binomialCoeff(8, 2) 计算 8C2,返回 28

总结与作业

本节课中我们一起学习了C++函数的核心知识:从定义、调用、参数传递到返回值,并理解了函数在内存栈中的工作原理以及“按值传递”的特性。

以下是巩固知识的作业:

  1. 打印1到n:编写一个函数,接收一个整数n,打印出从1到n的所有数字。
  2. 判断素数:编写一个函数,判断给定的数字是否为素数。
  3. 打印斐波那契数列:编写一个函数,打印斐波那契数列的前n项(如:1, 1, 2, 3, 5, 8...)。

恭喜你成功完成了本讲的学习!请在评论区分享你最喜欢的部分。持续学习,持续探索!

006:二进制数系统

在本节课中,我们将学习二进制数系统。这是一个非常重要的章节,因为它将教会我们很多关于数据如何在计算机内存中存储的知识。我们已经知道,数据是以二进制数的形式存储的。

数制系统概述

上一节我们提到了二进制数系统的重要性。本节中,我们先来了解不同的数制系统。我们常用的十进制数制系统使用0到9这十个数字进行计算。因此,它的基数是10。

除了十进制,还有其他数制系统,例如:

  • 二进制数制系统,基数为2。
  • 八进制数制系统,基数为8。
  • 十六进制数制系统,基数为16。

本节课我们将重点学习二进制数制系统,以便理解数字在计算机内存中是如何被处理的。

十进制数转换为二进制数

现在,我们来看看如何将十进制数转换为二进制数。转换的核心方法是重复除以2并记录余数

以下是转换步骤:

  1. 将十进制数除以2。
  2. 记录余数(0或1)。
  3. 将商作为新的被除数。
  4. 重复步骤1-3,直到商为0。
  5. 将记录的余数从下往上(即最后一个余数到第一个余数)排列,得到的就是二进制数。

示例:将十进制数42转换为二进制数

42 ÷ 2 = 21 ... 余数 0
21 ÷ 2 = 10 ... 余数 1
10 ÷ 2 = 5  ... 余数 0
5  ÷ 2 = 2  ... 余数 1
2  ÷ 2 = 1  ... 余数 0
1  ÷ 2 = 0  ... 余数 1

从下往上读取余数:101010
因此,十进制数 42(基数为10)等于二进制数 101010(基数为2)。

示例:将十进制数50转换为二进制数

50 ÷ 2 = 25 ... 余数 0
25 ÷ 2 = 12 ... 余数 1
12 ÷ 2 = 6  ... 余数 0
6  ÷ 2 = 3  ... 余数 0
3  ÷ 2 = 1  ... 余数 1
1  ÷ 2 = 0  ... 余数 1

从下往上读取余数:110010
因此,十进制数 50 等于二进制数 110010

二进制数转换为十进制数

上一节我们学习了十进制转二进制。本节中,我们来看看反向过程:如何将二进制数转换回十进制数。方法是将每一位二进制数字乘以2的相应次幂,然后求和

公式:对于一个n位的二进制数 b(n-1) b(n-2) ... b1 b0,其十进制值为:
十进制值 = b(n-1)*2^(n-1) + b(n-2)*2^(n-2) + ... + b1*2^1 + b0*2^0

示例:将二进制数101010转换为十进制数

从右向左(最低位开始),位置和值如下:

位:  1   0   1   0   1   0
幂: 2^5 2^4 2^3 2^2 2^1 2^0
值: 32  0   8   0   2   0

计算总和:32 + 0 + 8 + 0 + 2 + 0 = 42
因此,二进制数 101010 等于十进制数 42

示例:将二进制数110010转换为十进制数

位:  1   1   0   0   1   0
幂: 2^5 2^4 2^3 2^2 2^1 2^0
值: 32  16  0   0   2   0

计算总和:32 + 16 + 0 + 0 + 2 + 0 = 50
因此,二进制数 110010 等于十进制数 50

使用C++代码实现转换

理解了手动转换的原理后,我们可以用C++代码来实现这些过程。

十进制转二进制的C++代码

以下是实现十进制数转换为二进制数的函数:

#include <iostream>
using namespace std;

int decimalToBinary(int decimalNumber) {
    int power = 1; // 代表2的0次幂,在计算中用作位值权重(10^0, 10^1...)
    int answer = 0; // 存储最终的二进制数(以十进制形式表示,如1010)

    while (decimalNumber > 0) {
        int remainder = decimalNumber % 2; // 获取余数(0或1)
        answer += remainder * power;       // 将余数放到正确的位置
        power *= 10;                       // 更新位置权重(个位、十位、百位...)
        decimalNumber /= 2;                // 更新商,用于下一次循环
    }
    return answer; // 返回二进制形式(以整数表示)
}

int main() {
    int num = 50;
    cout << "Decimal " << num << " in binary is: " << decimalToBinary(num) << endl;
    // 输出:Decimal 50 in binary is: 110010
    return 0;
}

二进制转十进制的C++代码

以下是实现二进制数转换为十进制数的函数:

#include <iostream>
using namespace std;

int binaryToDecimal(int binaryNumber) {
    int power = 1; // 代表2的0次幂
    int answer = 0; // 存储最终的十进制数

    while (binaryNumber > 0) {
        int lastDigit = binaryNumber % 10; // 获取最后一位(0或1)
        answer += lastDigit * power;       // 将该位的值加到结果中
        power *= 2;                        // 更新2的幂次(1, 2, 4, 8...)
        binaryNumber /= 10;                // 移除已处理的最低位
    }
    return answer; // 返回十进制数
}

int main() {
    int binNum = 110010;
    cout << "Binary " << binNum << " in decimal is: " << binaryToDecimal(binNum) << endl;
    // 输出:Binary 110010 in decimal is: 50
    return 0;
}

二进制补码:表示负数

到目前为止,我们处理的是正数。在计算机中,负数使用一种称为二进制补码的特殊形式存储。这是理解计算机如何处理有符号整数的关键概念。

计算一个负数(如-10)的二进制补码表示,步骤如下:

  1. 取绝对值的二进制形式:先得到正数10的二进制形式(假设为8位):00001010
  2. 按位取反(得到反码):将所有位0变1,1变0。00001010 的反码是 11110101
  3. 加1(得到补码):将反码加1。11110101 + 1 = 11110110
  4. 因此,十进制数 -10 在计算机中的8位二进制补码表示是 11110110

从二进制补码还原为十进制负数:

  1. 观察补码 11110110,最高位(最左边)是1,表明这是一个负数。
  2. 对补码再次取补码(即步骤2和3的逆操作):
    • 按位取反:11110110 -> 00001001
    • 加1:00001001 + 1 = 00001010
  3. 得到二进制数 00001010,即十进制 10
  4. 由于原最高位为1,所以最终值是 -10

示例:求-8的8位二进制补码

  1. 8的二进制:00001000
  2. 按位取反:11110111
  3. 加1:11110111 + 1 = 11111000
  4. 因此,-8的8位二进制补码是 11111000

总结

本节课中,我们一起学习了二进制数系统。

  • 我们回顾了不同的数制系统,并重点学习了二进制。
  • 我们掌握了十进制数转换为二进制数的方法(重复除以2)。
  • 我们也掌握了二进制数转换为十进制数的方法(按权展开求和)。
  • 我们用C++代码实现了这两种转换。
  • 最后,我们介绍了计算机中表示负数的关键方法——二进制补码,并学习了其计算和还原过程。

理解二进制是学习计算机如何存储和处理数据的基石,对于后续学习数据结构和算法至关重要。

007:位运算符、数据类型修饰符及其他

在本节课中,我们将学习C++中一些重要的杂项概念,包括位运算符、运算符优先级、变量作用域以及数据类型修饰符。这些知识是理解更复杂程序和控制数据存储的基础。

位运算符

上一节我们介绍了逻辑运算符,本节中我们来看看位运算符。位运算符直接对整数的二进制位进行操作。它们包括与(&)、或(|)、异或(^)以及位移运算符(<<>>)。

位与运算符(&

位与运算符对两个操作数的每一个二进制位执行逻辑与操作。规则是:只有两个位都是1时,结果才是1。

公式:
1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0

代码示例:

int a = 4; // 二进制:0100
int b = 8; // 二进制:1000
int result = a & b; // 结果为 0 (0000)

位或运算符(|

位或运算符对两个操作数的每一个二进制位执行逻辑或操作。规则是:只要有一个位是1,结果就是1。

公式:
1 | 1 = 1
1 | 0 = 1
0 | 1 = 1
0 | 0 = 0

代码示例:

int a = 4; // 二进制:0100
int b = 8; // 二进制:1000
int result = a | b; // 结果为 12 (1100)

位异或运算符(^

位异或运算符对两个操作数的每一个二进制位执行异或操作。规则是:当两个位不同时,结果为1;相同时,结果为0。

公式:
1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

代码示例:

int a = 4; // 二进制:0100
int b = 8; // 二进制:1000
int result = a ^ b; // 结果为 12 (1100)

位移运算符

位移运算符将整数的所有二进制位向左或向右移动指定的位数。

左移运算符(<<): 将二进制位向左移动,右侧空出的位用0填充。左移一位相当于乘以2。

代码示例:

int num = 4; // 二进制:0100
int result = num << 1; // 结果为 8 (1000),相当于 4 * 2
int result2 = 10 << 2; // 结果为 40,相当于 10 * 4

右移运算符(>>): 将二进制位向右移动,左侧空出的位根据符号位填充(对于有符号数,通常用符号位填充;对于无符号数,用0填充)。右移一位相当于除以2(向下取整)。

代码示例:

int num = 8; // 二进制:1000
int result = num >> 2; // 结果为 2 (0010),相当于 8 / 4
int result2 = 10 >> 1; // 结果为 5,相当于 10 / 2

运算符优先级与结合性

理解了位运算后,我们需要知道表达式中各种运算符的执行顺序,这由运算符的优先级和结合性决定。

运算符优先级决定了在没有括号的情况下,哪个运算符先被计算。例如,乘法和除法的优先级高于加法和减法。

结合性决定了当表达式中有多个相同优先级的运算符时,它们的计算方向。大多数运算符是左结合的(从左到右计算),但赋值运算符(=)是右结合的(从右到左计算)。

以下是常见运算符的优先级(从高到低):

  1. 括号 ()
  2. 乘、除、取模 */%
  3. 加、减 +-
  4. 位移 <<>>
  5. 关系运算符 <<=>>=
  6. 相等性运算符 ==!=
  7. 位与 &
  8. 位异或 ^
  9. 位或 |
  10. 逻辑与 &&
  11. 逻辑或 ||
  12. 赋值运算符 =

代码示例:

int result = 5 - 2 * 6; // 先计算 2 * 6 = 12,再计算 5 - 12 = -7
int result2 = (5 - 2) * 6; // 括号改变优先级,先计算 5 - 2 = 3,再计算 3 * 6 = 18
int result3 = 4 * 5 % 2; // 乘法和取模优先级相同,左结合,先计算 4 * 5 = 20,再计算 20 % 2 = 0

变量作用域

在编写使用多个运算符的复杂表达式时,理解变量的可见范围——即作用域——至关重要。作用域决定了在程序的哪些部分可以访问一个变量。

局部作用域

在函数或代码块(如if语句、for循环内部)内声明的变量具有局部作用域。它们只能在其被声明的块内被访问。

以下是局部变量的几个例子:

  • if块内声明的变量,在if块外无法访问。
  • for循环初始化部分声明的计数器变量(如int i=0),在循环外部无法访问。
  • 函数内部声明的形参和变量,在其他函数中无法直接访问。

全局作用域

在所有函数(包括main函数)之外声明的变量具有全局作用域。它们可以在整个程序文件的任何地方被访问。

代码示例:

#include <iostream>
using namespace std;

int globalVar = 100; // 全局变量

void someFunction() {
    cout << globalVar << endl; // 可以访问全局变量
}

int main() {
    cout << globalVar << endl; // 可以访问全局变量
    someFunction();
    return 0;
}

数据类型修饰符

最后,我们来探讨如何改变基本数据类型(如int)的含义和存储能力,这通过数据类型修饰符实现。

数据类型修饰符用于改变基本数据类型的存储大小和取值范围。常见的修饰符有:

  • short:减小存储大小和取值范围。
  • long:增大存储大小和取值范围。
  • signed(默认):表示该类型可以存储正数和负数。
  • unsigned:表示该类型仅能存储非负数(0和正数),从而将负数的范围用于扩大正数的最大值。

代码示例:

#include <iostream>
using namespace std;

int main() {
    short int smallNumber = 32767; // 短整型,范围较小
    long int bigNumber = 2147483647L; // 长整型,范围较大
    long long int veryBigNumber = 9223372036854775807LL; // 长长整型,范围更大

    signed int normalInt = -10; // 有符号整型,可存负数
    unsigned int positiveOnly = 10; // 无符号整型,只能存非负数
    // unsigned int errorVar = -10; // 错误:尝试将负数存入无符号变量

    cout << "Size of short int: " << sizeof(smallNumber) << " bytes" << endl;
    cout << "Size of long int: " << sizeof(bigNumber) << " bytes" << endl;
    cout << "Size of long long int: " << sizeof(veryBigNumber) << " bytes" << endl;

    cout << "Signed int with -10: " << normalInt << endl;
    cout << "Unsigned int with 10: " << positiveOnly << endl;

    return 0;
}

总结

本节课中我们一起学习了C++中几个核心的杂项概念。我们首先了解了位运算符(&|^<<>>),它们允许我们在二进制级别上操作数据。接着,我们探讨了运算符优先级和结合性的规则,这对于正确理解和编写复杂表达式必不可少。然后,我们区分了局部作用域和全局作用域,明确了变量的生命周期和可访问性。最后,我们介绍了数据类型修饰符(shortlongsignedunsigned),它们让我们能够更精细地控制数据的存储空间和取值范围。掌握这些概念将为学习更复杂的数据结构和算法打下坚实的基础。

008:数组数据结构 - 第一部分

概述

在本节课中,我们将要学习数据结构与算法的基本概念,并重点探讨第一种数据结构——数组。我们将了解数组的定义、创建、初始化、访问元素的方法,以及如何通过数组执行一些基本操作,例如查找最小/最大值、线性搜索和反转数组。


数据结构与算法简介

上一节我们介绍了本系列课程,本节中我们来看看什么是数据结构与算法。

数据结构本质上是我们在编程时用来存储数据的代码结构。通过使用数据结构,我们能够构建真实的系统。在本系列中,我们将探索许多不同类型的数据和数据结构。

与数据结构紧密相关的另一个核心概念是算法。算法用于对数据结构中的数据执行高效的操作。例如,在数据结构中搜索、排序数据(按升序或降序排列)等过程都依赖于算法。在本系列中,我们将学习许多不同的数据结构和算法。


什么是数组?🤔

现在,让我们谈谈数组是什么。假设我们需要存储五个学生的分数。利用现有的C++知识,我们显然可以创建变量:marks1marks2marks3……以此类推,为五个学生创建五个不同的变量。

然而,如果班级有100名学生,问题就会变得非常繁琐。程序员需要创建100个不同的变量,并且在代码中跟踪这100个变量也会非常困难。这就是为什么当数据量很大时(例如,Instagram拥有数百万用户,其用户数据无法用单个变量存储),我们需要一种名为“数组”的数据结构来解决这个问题。

数组可以被可视化为一个连续的数据块。它在内存中连续存储信息。我们可以将其想象为一条直线上的多个“盒子”。


创建与初始化数组

以下是创建数组的方法。

语法:

数据类型 数组名[大小];

这与创建变量非常相似,但我们需要在方括号中指明数组的大小。

例如,创建一个存储5个学生分数的整型数组:

int marks[5];

我们也可以在创建时直接初始化数组的值。

示例:

int marks[5] = {99, 100, 54, 36, 88};

这样,五个“盒子”里就分别存储了值99、100、54、36和88。

如果我们在初始化时提供了所有值,并且值的数量与数组大小匹配,那么可以省略指定大小,编译器会自动计算。

示例:

double prices[] = {99.5, 30.00, 45.5}; // 编译器会推断大小为3

访问与修改数组元素

数组中的每个位置都有一个固定的索引。第一个位置的索引是0,第二个是1,依此类推。要访问或修改特定位置的元素,我们使用方括号和索引值。

语法:

数组名[索引]

例如,marks[3] 访问的是数组中第4个元素(因为索引从0开始)。

重要规则:

  • 有效索引范围是从 0(数组大小 - 1)
  • 尝试访问此范围之外的索引(例如 marks[5] 对于一个大小为5的数组)会导致错误。

我们可以使用循环来方便地访问所有元素。由于索引从0开始,循环通常也从0开始。

示例:打印所有分数

for(int i = 0; i < 5; i++) {
    cout << marks[i] << " ";
}

这个循环将遍历索引0到4,并打印每个位置的分数。

我们也可以使用同样的方式修改数组元素。

示例:修改第一个分数

marks[0] = 101; // 将第一个分数从99改为101

计算数组大小

有时我们需要知道数组中有多少个元素。我们可以使用 sizeof 运算符来计算数组占用的总内存字节数,然后除以单个元素的大小来得到元素数量。

公式:

int size = sizeof(数组名) / sizeof(数据类型);

例如,一个包含5个整数的数组,每个整数通常占4字节,总内存为20字节。sizeof(marks) / sizeof(int) 将得到 20 / 4 = 5

代码示例:

int size = sizeof(marks) / sizeof(int);
cout << "数组大小为: " << size << endl; // 输出 5

数组输入与遍历

我们可以使用循环从用户那里获取输入来填充数组。

以下是使用循环获取数组输入的示例。

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

用户输入的值(例如12, 13, 14, 15, 16)将被依次存储到 marks[0]marks[4] 中。


在数组中查找最小值与最大值 🎯

上一节我们学习了如何遍历数组,本节中我们来看看如何利用遍历查找数组中的最小值和最大值。

查找最小值的逻辑:

  1. 假设第一个元素就是最小值。
  2. 遍历数组,将每个元素与当前最小值比较。
  3. 如果找到更小的元素,就更新最小值。

代码示例:查找最小值

int nums[] = {5, 15, 22, 1, -15, 24};
int size = 6;
int smallest = INT_MAX; // 初始化为最大整数值

for(int i = 0; i < size; i++) {
    if(nums[i] < smallest) {
        smallest = nums[i];
    }
}
cout << "最小值是: " << smallest << endl; // 输出 -15

这里,INT_MAX 是C++中表示最大整数的常量。

查找最大值的逻辑:
逻辑类似,但我们将变量初始化为最小整数值 INT_MIN,并在遍历中寻找更大的值。

代码示例:同时查找最小值和最大值

int nums[] = {5, 15, 22, 1, -15, 24};
int size = 6;
int smallest = INT_MAX;
int largest = INT_MIN;

for(int i = 0; i < size; i++) {
    smallest = min(smallest, nums[i]); // 使用 min 函数
    largest = max(largest, nums[i]);   // 使用 max 函数
}
cout << "最小值: " << smallest << ", 最大值: " << largest << endl;
// 输出 最小值: -15, 最大值: 24

minmax 是C++标准库中的函数,分别返回两个值中的较小者和较大者。

课后练习:
修改上述代码,使其不仅能找到最小值和最大值,还能打印出它们所在的索引位置。


按引用传递数组 🔄

在C++中,将基本数据类型(如int)传递给函数时是“按值传递”,函数内对参数的修改不会影响原始变量。然而,数组传递给函数时是“按引用传递”。这意味着函数内部对数组元素的修改会直接影响原始数组。

代码示例:按引用传递数组

#include <iostream>
using namespace std;

// 函数接收一个整型数组
void changeArray(int arr[], int size) {
    for(int i = 0; i < size; i++) {
        arr[i] = arr[i] * 2; // 修改数组元素
    }
}

int main() {
    int myArray[] = {1, 2, 3};
    int size = 3;

    changeArray(myArray, size); // 传递数组

    // 打印修改后的数组
    for(int i = 0; i < size; i++) {
        cout << myArray[i] << " "; // 输出: 2 4 6
    }
    return 0;
}

可以看到,changeArray 函数内部对数组的加倍操作,直接反映在了 main 函数中的原始数组 myArray 上。这是因为数组名实际上代表数组在内存中的首地址。


线性搜索算法 🔍

线性搜索是最简单的搜索算法之一。它的逻辑是:从数组的第一个元素开始,逐个与目标值比较,直到找到匹配项或遍历完整个数组。

算法步骤:

  1. 从索引 i = 0 开始。
  2. 检查 arr[i] 是否等于目标值 target
  3. 如果相等,返回当前索引 i
  4. 如果不相等,i 增加1,重复步骤2。
  5. 如果遍历结束仍未找到,返回 -1 表示未找到。

代码示例:实现线性搜索

#include <iostream>
using namespace std;

int linearSearch(int arr[], int size, int target) {
    for(int i = 0; i < size; i++) {
        if(arr[i] == target) {
            return i; // 找到,返回索引
        }
    }
    return -1; // 未找到
}

int main() {
    int numbers[] = {4, 2, 7, 1, 9, 5, 2};
    int size = 7;
    int target = 7;

    int result = linearSearch(numbers, size, target);
    if(result != -1) {
        cout << "元素 " << target << " 在索引 " << result << " 处找到。" << endl;
    } else {
        cout << "元素 " << target << " 未在数组中找到。" << endl;
    }
    // 输出: 元素 7 在索引 2 处找到。
    return 0;
}

反转数组 ↩️

反转数组意味着将数组元素的顺序完全颠倒。我们可以使用“双指针”方法高效地完成这个操作。

算法思路(双指针法):

  1. 初始化两个指针:start 指向数组开头(索引0),end 指向数组末尾(索引 size-1)。
  2. 交换 arr[start]arr[end] 的值。
  3. start 指针向后移动一位(start++),end 指针向前移动一位(end--)。
  4. 重复步骤2和3,直到 start 不再小于 end

代码示例:反转数组

#include <iostream>
using namespace std;

void reverseArray(int arr[], int size) {
    int start = 0;
    int end = size - 1;

    while(start < end) {
        // 交换 start 和 end 位置的值
        int temp = arr[start];
        arr[start] = arr[end];
        arr[end] = temp;

        // 移动指针
        start++;
        end--;
    }
}

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

    cout << "原始数组: ";
    for(int i = 0; i < size; i++) {
        cout << myArray[i] << " ";
    }
    cout << endl;

    reverseArray(myArray, size);

    cout << "反转后数组: ";
    for(int i = 0; i < size; i++) {
        cout << myArray[i] << " ";
    }
    // 输出: 原始数组: 1 2 3 4 5
    //       反转后数组: 5 4 3 2 1
    return 0;
}

这种方法的时间复杂度是 O(n/2),可以简化为 O(n),是一种高效的反转方法。


总结与课后练习 📚

本节课中我们一起学习了数组这一基础数据结构。我们涵盖了数组的创建、初始化、访问、遍历,以及基于数组的几种基本操作:查找极值、线性搜索和反转数组。我们还理解了数组在函数中按引用传递的特性。

为了巩固知识,请尝试完成以下练习:

课后练习题目:

  1. 计算和与积:编写一个函数,计算数组中所有数字的总和与乘积。
  2. 交换极值:编写一个函数,找到数组中的最大值和最小值,并交换它们的位置。
  3. 打印唯一值:编写一个函数,打印数组中所有不重复(唯一)的值。例如,对于数组 {1, 2, 3, 1, 2},应打印 3
  4. 查找第二大的数:编写一个函数,找出数组中的第二大元素。

你可以将你的解答和进展分享到Twitter或课程讨论区。继续学习和探索,我们下节课再见!

009:向量(数组第二部分)🚀

在本节课中,我们将学习C++中一个非常重要的数据结构——向量(Vector)。向量是一种动态数组,它克服了普通数组大小固定的限制,是解决许多编程问题的强大工具。


什么是向量?🤔

上一节我们介绍了数组,本节中我们来看看向量。向量本质上是一个动态数组。你可以把它想象成一系列可以存储数据的连续内存块,并且像数组一样拥有索引。向量与数组的主要区别在于,向量的大小是动态的,可以在运行时改变。

向量是C++标准模板库(STL)的一部分,其实现和使用在面试中非常重要。

向量的基本操作

以下是向量的基本创建和访问方法。

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

int main() {
    // 创建一个大小为5的整数向量,所有元素初始化为0
    vector<int> vec(5);
    // 创建一个包含初始值的向量
    vector<int> vec2 = {1, 2, 3, 4};
    // 访问向量元素
    cout << vec2[0] << endl; // 输出 1
    // 使用 at() 函数访问,会进行边界检查
    cout << vec2.at(1) << endl; // 输出 2
    return 0;
}

遍历向量

了解如何创建向量后,我们来看看如何遍历它。除了使用普通的for循环,C++还提供了一种更简洁的“for-each”循环。

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

int main() {
    vector<char> chars = {'A', 'B', 'C', 'D', 'E'};

    // 使用 for-each 循环遍历
    for(char ch : chars) {
        cout << ch << " ";
    }
    // 输出: A B C D E

    // 获取向量大小
    cout << "\nSize: " << chars.size() << endl; // 输出 5
    return 0;
}

向量的动态特性

向量最强大的特性是其动态性。我们可以在运行时轻松地添加或删除元素。

以下是向量的核心动态操作方法。

  • push_back(value):在向量末尾添加一个元素。
  • pop_back():删除向量末尾的一个元素。
  • front():返回第一个元素。
  • back():返回最后一个元素。
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v; // 创建一个空向量
    cout << "初始大小: " << v.size() << endl; // 0

    v.push_back(25);
    v.push_back(35);
    v.push_back(45);
    cout << "添加元素后大小: " << v.size() << endl; // 3

    cout << "第一个元素 (front): " << v.front() << endl; // 25
    cout << "最后一个元素 (back): " << v.back() << endl; // 45

    v.pop_back(); // 删除最后一个元素 (45)
    cout << "pop_back 后大小: " << v.size() << endl; // 2
    cout << "新的最后一个元素: " << v.back() << endl; // 35

    return 0;
}

内存分配:静态 vs 动态

理解向量的工作原理,需要知道内存的静态和动态分配。

  • 静态分配:在编译时确定大小,内存分配在栈上。普通数组就是静态分配的。
  • 动态分配:在运行时确定大小,内存分配在堆上。向量内部使用动态分配来管理其可增长的内存。

向量内部有一个“容量”(capacity)的概念,它表示当前分配的内存可以容纳多少元素,而“大小”(size)表示实际存储了多少元素。当push_back新元素导致大小超过容量时,向量会自动分配一块更大的内存(通常是当前容量的两倍),并将所有元素复制过去。

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

int main() {
    vector<int> vec;
    cout << "大小: " << vec.size() << ", 容量: " << vec.capacity() << endl;

    for(int i=0; i<5; i++) {
        vec.push_back(i);
        cout << "添加 " << i << " 后 -> 大小: " << vec.size() << ", 容量: " << vec.capacity() << endl;
    }
    // 输出可能类似:
    // 大小: 0, 容量: 0
    // 添加 0 后 -> 大小: 1, 容量: 1
    // 添加 1 后 -> 大小: 2, 容量: 2
    // 添加 2 后 -> 大小: 3, 容量: 4 (容量翻倍)
    // 添加 3 后 -> 大小: 4, 容量: 4
    // 添加 4 后 -> 大小: 5, 容量: 8 (容量再次翻倍)
    return 0;
}

实战练习:寻找唯一元素

现在,让我们运用所学知识解决一个经典问题。给定一个非空整数数组,其中每个元素都出现两次,只有一个元素出现一次。找出那个唯一的元素。

示例
输入:[4, 1, 2, 1, 2]
输出:4

解决方案思路
这个问题可以利用异或(XOR)运算的特性完美解决:

  • a ^ a = 0 (任何数与自己异或结果为0)
  • a ^ 0 = a (任何数与0异或结果为自身)
  • 异或运算满足交换律和结合律。

因此,将数组中所有数字进行异或运算,成对的数字会抵消为0,最终剩下的就是那个唯一的数字。

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

int findUnique(vector<int>& arr) {
    int ans = 0;
    for(int num : arr) {
        ans = ans ^ num; // 对每个元素进行异或运算
    }
    return ans;
}

int main() {
    vector<int> nums = {4, 1, 2, 1, 2};
    cout << "唯一元素是: " << findUnique(nums) << endl; // 输出 4
    return 0;
}

注意:函数参数是vector<int>& arr(传引用),这避免了复制整个向量,提高了效率。这在处理大数据时很重要。


本节课中我们一起学习了C++向量的核心概念:它的动态特性、基本操作(如push_backpop_back)、遍历方式、内部内存管理机制(大小与容量),并通过一个寻找唯一元素的实战问题巩固了知识。向量是STL中最常用的容器之一,务必熟练掌握。

010:Kadane算法 - 最大子数组和 🧮

在本节课中,我们将学习如何解决“最大子数组和”问题。我们将从最直观的暴力解法开始,然后引入更高效的Kadane算法。通过对比,你将理解算法优化的思路,并掌握这一在线性时间内解决问题的核心技巧。


什么是子数组? 📚

上一节我们介绍了课程目标,本节中我们来看看问题的基本概念。首先,需要明确“子数组”的定义。

子数组是原数组中一个连续的部分。例如,对于数组 [1, 2, 3, 4, 5]

  • 单个元素 [1] 是一个子数组。
  • 三个连续元素 [2, 3, 4] 也是一个子数组。
  • [1, 3, 5] 不是子数组,因为元素不连续。

对于一个长度为 n 的数组,其子数组的总数可以通过以下公式计算:
公式: 总子数组数 = n * (n + 1) / 2

例如,上述5个元素的数组,子数组总数为 5 * 6 / 2 = 15。这相当于从1加到5的和。


暴力解法 💪

理解了子数组的概念后,我们来看看最直接的解决方法——暴力枚举。

暴力解法的核心思想是:枚举所有可能的子数组,计算它们的和,并找出最大值。

  • 一个子数组由其起始索引结束索引唯一确定。
  • 起始索引 start 可以从 0n-1
  • 对于每个 start,结束索引 end 可以从 startn-1

以下是暴力解法的伪代码逻辑:

int maxSum = INT_MIN; // 初始化最大和为最小整数
for (int start = 0; start < n; start++) {
    for (int end = start; end < n; end++) {
        int currentSum = 0;
        // 第三层循环计算从start到end的元素和
        for (int i = start; i <= end; i++) {
            currentSum += arr[i];
        }
        // 更新最大和
        maxSum = max(maxSum, currentSum);
    }
}
return maxSum;

这种方法的时间复杂度是 O(n³),因为有三层嵌套循环。当数组很大时,效率非常低。


优化暴力解法 ⚡

在暴力解法中,我们重复计算了许多子数组的和。本节我们引入一个优化,将复杂度降低到 O(n²)

优化思路是:当固定起始点 start 后,在遍历结束点 end 时,可以累加计算当前子数组的和,避免内层循环。

以下是优化后的伪代码:

int maxSum = INT_MIN;
for (int start = 0; start < n; start++) {
    int currentSum = 0; // 对每个新的起始点,当前和重置为0
    for (int end = start; end < n; end++) {
        currentSum += arr[end]; // 累加新元素
        maxSum = max(maxSum, currentSum); // 更新最大和
    }
}
return maxSum;

虽然优化到了 O(n²),但对于大规模数据仍然不够高效。接下来,我们将学习能在 O(n) 时间内解决问题的Kadane算法。


Kadane算法 🚀

Kadane算法是解决最大子数组和问题的最优解,其核心思想是动态规划。它只需遍历数组一次。

算法的直觉是:遍历数组,不断累加当前元素到 currentSum 中。但如果 currentSum 变成负数,那么它对于后续子数组的和只会产生负面影响,因此应该将其重置为0,从下一个元素重新开始累加。同时,在每一步都记录下出现过的最大 currentSum

以下是处理过程中可能遇到的情况:

  1. 初始化 currentSum = 0, maxSum = INT_MIN
  2. 遍历每个数字 num
    • currentSum = currentSum + num
    • maxSum = max(maxSum, currentSum)
    • 如果 currentSum < 0,则令 currentSum = 0

注意:初始化 maxSumINT_MIN 是为了处理数组全为负数的情况,此时最大和就是那个最大的负数。

让我们看一个例子:数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4]

  • 步骤:currentSum: 0 -> -2 -> 0 -> 1 -> -2 -> 0 -> 4 -> 3 -> 5 -> 6 -> 1 -> 5
  • 同时记录 maxSum 的变化,最终得到最大和为 6(对应子数组 [4, -1, 2, 1])。

代码实现与总结 📝

根据以上思路,我们可以写出完整的Kadane算法代码。以下是适用于LeetCode等平台的函数实现:

int maxSubArray(vector<int>& nums) {
    int currentSum = 0;
    int maxSum = INT_MIN;
    
    for (int num : nums) {
        currentSum += num;          // 累加当前元素
        maxSum = max(maxSum, currentSum); // 更新全局最大和
        if (currentSum < 0) {       // 如果当前和变为负数,则重置
            currentSum = 0;
        }
    }
    return maxSum;
}

算法复杂度分析:

  • 时间复杂度:O(n),只需一次遍历。
  • 空间复杂度:O(1),只使用了常数级别的额外空间。

本节课中我们一起学习了最大子数组和问题。我们从定义子数组开始,探讨了直观但低效的暴力解法(O(n³) 和 O(n²)),最终深入理解了高效且优雅的Kadane算法(O(n))。掌握该算法的关键在于理解“当当前子数组和为负时,它对后续结果无益,应被舍弃”这一核心思想。这是动态规划思想的一个经典应用,在解决许多序列问题上都非常有用。

011:两数之和与多数元素

在本节课中,我们将学习两个经典的算法问题:两数之和多数元素。我们将从暴力解法开始,逐步优化,并最终学习解决多数元素问题的高效算法——摩尔投票算法


两数之和

给定一个按升序排列的整数数组 nums 和一个目标值 target,请你在数组中找出和为目标值的那两个整数,并返回它们的数组下标。

暴力解法

最直观的方法是使用双重循环检查所有可能的数对。

以下是暴力解法的步骤:

  1. 使用外层循环遍历数组中的每个元素,将其作为第一个数。
  2. 使用内层循环遍历当前元素之后的所有元素,将其作为第二个数。
  3. 检查这两个数的和是否等于目标值 target
  4. 如果相等,则返回这两个数的下标。

对应的C++代码框架如下:

vector<int> twoSum(vector<int>& nums, int target) {
    int n = nums.size();
    vector<int> ans;
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (nums[i] + nums[j] == target) {
                ans.push_back(i);
                ans.push_back(j);
                return ans;
            }
        }
    }
    return ans; // 题目保证有解,此行不会执行
}

这种方法的时间复杂度是 O(n²),在数组较大时效率较低。

双指针优化解法

由于数组是已排序的,我们可以利用这个特性进行优化。我们使用两个指针,一个指向开头(left),一个指向末尾(right)。

以下是双指针解法的逻辑:

  1. 计算 nums[left] + nums[right] 的和。
  2. 如果和等于 target,则找到答案。
  3. 如果和大于 target,说明和太大了,需要减小,因此将 right 指针向左移动。
  4. 如果和小于 target,说明和太小了,需要增大,因此将 left 指针向右移动。

这种方法只需要遍历数组一次,时间复杂度为 O(n)

对应的C++代码如下:

vector<int> twoSum(vector<int>& nums, int target) {
    int left = 0;
    int right = nums.size() - 1;
    vector<int> ans;

    while (left < right) {
        int pairSum = nums[left] + nums[right];
        if (pairSum == target) {
            ans.push_back(left);
            ans.push_back(right);
            return ans;
        } else if (pairSum > target) {
            right--;
        } else { // pairSum < target
            left++;
        }
    }
    return ans;
}

多数元素 🗳️

上一节我们解决了有序数组的两数之和问题。现在,我们来看一个新的问题:多数元素

给定一个大小为 n 的数组 nums,返回其中的多数元素。多数元素是指在数组中出现次数 大于 n / 2 的元素。你可以假设数组总是存在多数元素。

暴力解法

我们可以统计数组中每个元素出现的频率。

以下是暴力解法的思路:

  1. 遍历数组中的每个元素。
  2. 对于每个元素,再次遍历整个数组,统计它出现的次数。
  3. 如果某个元素的出现次数大于 n / 2,则它就是多数元素。

这种方法需要嵌套循环,时间复杂度为 O(n²)

排序解法

一个更优的解法是先对数组进行排序。因为多数元素的数量超过一半,排序后,数组正中间的那个元素一定是多数元素。

步骤如下:

  1. 对数组 nums 进行排序。
  2. 返回位于索引 n / 2 的元素。

使用C++标准库的 sort 函数,代码非常简单:

int majorityElement(vector<int>& nums) {
    sort(nums.begin(), nums.end());
    return nums[nums.size() / 2];
}

这种方法的时间复杂度取决于排序算法,通常是 O(n log n)

摩尔投票算法 🏆

这是解决多数元素问题的最优算法,时间复杂度为 O(n),空间复杂度为 O(1)。其核心思想是“对拼消耗”。

算法步骤如下:

  1. 初始化一个候选者 candidate 和计数器 count = 0
  2. 遍历数组中的每个数字:
    • 如果 count0,则将当前数字设为候选者 candidate
    • 如果当前数字等于 candidate,则 count1
    • 如果当前数字不等于 candidate,则 count1(表示一次对拼消耗)。
  3. 遍历结束后,candidate 中存储的就是可能的多数元素。由于题目保证多数元素存在,可以直接返回。如果需要验证,可以再遍历一次数组统计 candidate 的实际出现次数。

这个算法模拟了投票过程,不同的元素相互抵消,由于多数元素的数量占优,最终剩下的必然是它。

对应的C++代码如下:

int majorityElement(vector<int>& nums) {
    int candidate = 0;
    int count = 0;

    for (int num : nums) {
        if (count == 0) {
            candidate = num;
        }
        if (num == candidate) {
            count++;
        } else {
            count--;
        }
    }
    // 题目假设多数元素一定存在,所以candidate就是答案
    return candidate;
}

总结

本节课中我们一起学习了两个重要的算法问题:

  1. 两数之和:对于已排序数组,使用双指针技巧可以在 O(n) 时间内高效解决。
  2. 多数元素:我们学习了三种方法,从暴力的 O(n²),到排序的 O(n log n),最后掌握了最优的 摩尔投票算法,它能在 O(n) 时间和 O(1) 空间内解决问题。

理解这些解法的演进过程,掌握双指针和摩尔投票算法的思想,对于解决更多复杂的算法问题非常有帮助。

012:时间复杂度与空间复杂度 📊

在本节课中,我们将学习数据结构与算法中两个最核心的概念:时间复杂度空间复杂度。理解这两个概念对于编写高效的程序、通过技术面试至关重要。我们将从基本定义出发,逐步深入到实际应用和计算方法。


什么是时间复杂度? ⏱️

上一节我们介绍了本课程的目标,本节中我们来看看时间复杂度的定义。

时间复杂度不是程序运行的实际时间。实际运行时间受机器性能、操作系统、服务器负载等多种因素影响,因此在不同环境下会变化。

时间复杂度衡量的是执行的操作数量,将其表示为输入大小 n 的函数。它关注的是随着输入规模增长,操作次数的变化趋势。

例如,对于一个简单的操作:

int x = 10; // 这是一个操作

对于一个循环:

for(int i=0; i<10; i++) { // 这个循环将运行10次,执行大约10次操作
    // 循环体内的操作
}

操作次数大致与输入大小 n(这里是10)成正比。

我们可以用图表来理解:X轴代表输入大小 n,Y轴代表操作次数。对于上述循环,其关系是一条穿过原点、斜率为1的直线,即 操作次数 = n。这被称为线性时间复杂度

在分析算法时,我们通常关注最坏情况下的时间复杂度,因为实际系统可能需要处理百万甚至上亿级别的数据。


大O表示法 📈

上一节我们理解了时间复杂度的概念,本节中我们来看看如何规范地表示它。

我们使用大O表示法来描述算法的渐进时间复杂度,它表示算法运行时间增长的上限(最坏情况)。

计算大O复杂度有三个简单步骤:

  1. 忽略常数:在函数 f(n) = 5n² + 3n + 20 中,常数项20和系数5、3都被忽略。
  2. 保留最高阶项:在 n² + n + 1 中,当 n 很大时, 的影响最大,因此保留
  3. 去掉系数5n² 简化为

因此,f(n) = 5n² + 3n + 20 的时间复杂度是 O(n²)

除了表示最坏情况的 大O,还有表示平均情况的 Θ 和表示最好情况的 Ω。在面试和编程竞赛中,最常用的是大O表示法。


什么是空间复杂度? 💾

理解了时间复杂度后,空间复杂度的概念就很容易类比了。

空间复杂度衡量的是算法运行所需的内存空间,同样表示为输入大小 n 的函数。它主要关注算法运行过程中额外辅助使用的空间,不包括存储输入数据本身所占用的空间。

例如,在线性搜索中,输入数组占用的空间不计入空间复杂度。算法本身可能只使用了几个额外变量(如索引 i, 一个临时变量),这些辅助空间是常数级别的,因此空间复杂度为 O(1)

与时间复杂度类似,空间复杂度也常用大O表示法来描述。


常见的时间复杂度类型 📊

以下是编程中几种最常见的时间复杂度,按效率从高到低排列:

  • O(1) - 常数时间复杂度:操作次数不随输入大小改变。例如,通过索引访问数组元素、哈希表查找。
    int x = arr[5]; // O(1)
    
  • O(log n) - 对数时间复杂度:效率非常高,通常出现在“分而治之”的算法中,如二分查找。每次操作都将问题规模减半。
  • O(n) - 线性时间复杂度:操作次数与输入大小成正比。例如,遍历数组、线性搜索。这已经是许多问题的“好”算法。
    for(int i=0; i<n; i++) { // O(n)
        // 操作
    }
    
  • O(n log n) - 线性对数时间复杂度:高效排序算法的常见复杂度,如归并排序、快速排序(平均情况)。
  • O(n²) - 平方时间复杂度:通常出现在嵌套循环中。例如,简单的选择排序、冒泡排序。
    for(int i=0; i<n; i++) { // 外层循环n次
        for(int j=0; j<n; j++) { // 内层循环n次
            // 操作,总共执行约 n*n 次
        }
    }
    
  • O(2^n) - 指数时间复杂度:增长极其迅速,通常出现在暴力穷举所有子集或排列组合的算法中,如解决旅行商问题的朴素算法。
  • O(n!) - 阶乘时间复杂度:增长最快,通常出现在排列相关的暴力算法中。

如何计算时间复杂度? 🧮

本节我们将学习计算时间复杂度的具体方法,特别是对于循环和递归函数。

对于循环:分析嵌套层次。单层循环通常是O(n),双层嵌套循环通常是O(n²)。需要仔细计算内层循环的执行次数。

对于递归函数:有两种主要方法:

  1. 递归树法:画出递归调用树,计算总的节点数(调用次数)。
  2. 主定理/递推关系法:建立递归式并求解。一个通用公式是:
    时间复杂度 = 递归调用总次数 × 每次递归调用的工作量

示例:计算阶乘的递归函数

int factorial(int n) {
    if (n == 0) return 1; // 基本操作,O(1)
    return n * factorial(n-1); // 递归调用
}
  • 递归总次数n+1 次(从 n 调用到 0)。
  • 每次调用的工作量:常数时间 O(1)(乘法和返回)。
  • 总时间复杂度:O(n) × O(1) = O(n)

如何计算空间复杂度? 🧠

空间复杂度的计算需要考虑:

  1. 算法中声明的辅助变量所占用的空间。
  2. 对于递归算法,还需要考虑调用栈的深度。

递归调用的空间复杂度公式为:
空间复杂度 = 递归调用栈的最大深度 × 每次调用所需的辅助空间

示例:阶乘递归函数

int factorial(int n) {
    if (n == 0) return 1;
    return n * factorial(n-1);
}
  • 最大递归深度n(从 n0)。
  • 每次调用的辅助空间:常数 O(1)(存储参数和返回值)。
  • 总空间复杂度:O(n) × O(1) = O(n)。这是因为调用栈需要同时保存 n 个未返回的函数调用。

相比之下,其迭代版本的阶乘函数只使用固定数量的变量,因此空间复杂度为 O(1)


归并排序复杂度分析案例 🔍

让我们分析一个经典算法——归并排序的复杂度。

归并排序过程

  1. 分割:递归地将数组分成两半,直到每个子数组只有一个元素。
  2. 合并:将两个已排序的子数组合并成一个有序数组。

时间复杂度分析

  • 递归树深度:每次分割一半,深度为 log₂ n
  • 每层的工作量:在每一层,我们需要合并所有子数组。虽然子数组变小,但每一层需要处理的总元素数仍然是 n 个。合并两个总长为 k 的数组需要 O(k) 时间。
  • 总时间复杂度:层数 (log n) × 每层工作量 (O(n)) = O(n log n)

空间复杂度分析

  • 合并步骤需要创建一个临时的辅助数组来存放合并后的元素,其最大大小为 O(n)
  • 递归调用栈的最大深度为 O(log n)
  • 因此,总空间复杂度为 O(n) + O(log n) = O(n)(因为 O(n) 是主导项)。

复杂度在实践中的应用 🚀

理论需要联系实际。在编程竞赛或面试中,题目通常会给出数据约束(如 n ≤ 10⁵)。我们需要根据约束选择合适复杂度的算法。

一个经验法则是:在现代在线判题系统上,1秒内大约可以执行 10⁸ 次基本操作。

我们可以进行快速估算:

  • 如果 n ≤ 10⁵,那么 O(n) 或 O(n log n) 的算法通常是安全的。
    • O(n log n) 算法:10⁵ * log₂(10⁵) ≈ 10⁵ * 17 ≈ 1.7 * 10⁶,远小于 10⁸。
  • 如果 n ≤ 10³,O(n²) 的算法可能勉强通过 (10⁶ 次操作)。
  • 如果 n ≥ 20,O(2^n) 的算法几乎肯定会超时。

因此,看到题目约束后,首先估算自己算法的大致操作次数,判断是否在时间限制内,这是解决问题的关键第一步。


总结 🎯

本节课中我们一起学习了数据结构与算法的基石——时间复杂度和空间复杂度。

  • 时间复杂度衡量操作次数随输入规模的增长趋势,用大O表示法描述,关注最坏情况。
  • 空间复杂度衡量算法运行所需的额外内存空间。
  • 我们认识了从O(1)、O(log n)到O(n!)的常见复杂度类型及其含义。
  • 我们学习了如何分析循环和递归算法的时间与空间复杂度。
  • 最后,我们探讨了如何根据题目数据约束选择合适的算法,这是将理论应用于实践的关键。

掌握复杂度分析,能帮助你写出更高效的代码,并能在面试中清晰评估算法优劣。请务必通过练习来巩固这些概念。

013:买卖股票问题及Pow(X,N)幂指数问题

在本节课中,我们将学习两个重要的算法概念:使用二分求幂法计算幂运算,以及解决股票买卖问题以获取最大利润。这两个问题都是编程面试中的常见题目。

概述

本节课分为两个主要部分。首先,我们将探讨如何高效地计算 xn 次幂,即 pow(x, n)。接着,我们将分析一个经典的数组问题:给定一系列股票价格,找出进行一次买卖交易能获得的最大利润。


二分求幂法 (Binary Exponentiation)

上一节我们介绍了本节课的两个核心主题。本节中,我们来看看第一个主题:如何快速计算幂运算。

计算 xn 次幂(x^n)是一个基础操作。最直接的方法是进行 n-1 次乘法,时间复杂度为 O(n)。然而,利用二分求幂法,我们可以将时间复杂度降低到 O(log n)

其核心思想是将指数 n 用二进制形式表示,并利用幂的乘法性质:x^(a+b) = x^a * x^b

以下是该算法的步骤:

  1. 初始化结果 ans 为 1。
  2. 当指数 n 大于 0 时,循环执行:
    • 检查 n 的二进制最后一位是否为 1(即 n % 2 == 1)。如果是,则将当前的 x 乘到结果 ans 中。
    • x 更新为 x * x(即计算 x^2, x^4, x^8...)。
    • 将指数 n 右移一位(即 n = n / 2),相当于检查下一个二进制位。
  3. 循环结束后,ans 即为 x^n 的结果。

用代码描述核心逻辑:

double myPow(double x, int n) {
    long long N = n; // 处理n为负数的情况
    if (N < 0) {
        x = 1 / x;
        N = -N;
    }
    double ans = 1.0;
    while (N > 0) {
        if (N % 2 == 1) { // 或 (N & 1)
            ans = ans * x;
        }
        x = x * x;
        N = N / 2; // 或 N = N >> 1
    }
    return ans;
}

在实现时,还需要考虑一些边界情况:

  • 如果指数 n 为 0,根据定义,任何数的 0 次幂都是 1(x^0 = 1)。
  • 如果底数 x 为 0,则 0^nn>0 时为 0,在 n=0 时为 1。通常可以简单处理为:若 x == 0,则返回 0.0。
  • 处理负指数:若 n 为负数,则计算 x^(-n) = 1 / (x^n)。在代码中,我们先将负数转换成正数处理。

这种方法的时间复杂度是 O(log n),空间复杂度是 O(1),非常高效。


买卖股票的最佳时机 (Best Time to Buy and Sell Stock)

学习了高效的幂运算算法后,我们来看第二个问题:股票买卖。这是一个典型的数组遍历与动态规划思想结合的问题。

问题描述:给定一个数组 prices,其中 prices[i] 表示第 i 天的股票价格。你只能进行一次交易(即买入一次并卖出一次),且必须在买入之后的日子卖出。设计一个算法来计算你所能获取的最大利润。如果你不能获取任何利润,则返回 0。

例如,给定价格 [7, 1, 5, 3, 6, 4],最佳策略是在第 2 天(价格 1)买入,在第 5 天(价格 6)卖出,利润为 6 - 1 = 5

解决这个问题的关键在于:将每一天都视为潜在的卖出日。对于第 i 天,我们只需要知道在前 i-1 天中的最低价格(即最佳买入点),就能计算出在第 i 天卖出的利润。

以下是解决该问题的步骤:

  1. 初始化两个变量:
    • min_price:记录遍历到当前位置时的历史最低价格(最佳买入点)。初始值设为第一天的价格 prices[0]
    • max_profit:记录当前能获得的最大利润。初始值设为 0。
  2. 从第二天(i = 1)开始遍历价格数组:
    • 计算如果在今天卖出能获得的利润:profit = prices[i] - min_price
    • 更新最大利润:max_profit = max(max_profit, profit)
    • 更新历史最低价格:min_price = min(min_price, prices[i]),为后续的卖出日做准备。
  3. 遍历结束后,max_profit 即为答案。

用代码描述核心逻辑:

int maxProfit(vector<int>& prices) {
    if (prices.empty()) return 0;
    int min_price = prices[0];
    int max_profit = 0;
    for (int i = 1; i < prices.size(); i++) {
        int profit = prices[i] - min_price; // 今天卖出的利润
        max_profit = max(max_profit, profit); // 更新最大利润
        min_price = min(min_price, prices[i]); // 更新历史最低价
    }
    return max_profit;
}

这个算法只需要一次线性扫描,时间复杂度为 O(n),空间复杂度为 O(1),是解决此问题的最优方法。


总结

本节课中我们一起学习了两个重要的算法:

  1. 二分求幂法:通过将指数转化为二进制形式,将求幂运算的时间复杂度从 O(n) 优化到 O(log n)。我们掌握了其原理、代码实现以及需要注意的边界条件。
  2. 买卖股票的最佳时机:通过一次遍历,在遍历过程中维护“历史最低价”和“当前最大利润”,以 O(n) 的时间复杂度解决了单次交易的最大利润问题。这种方法体现了动态规划中“记录历史信息以避免重复计算”的思想。

理解并掌握这两个算法,将有助于你解决更多类似的数学计算和数组优化问题。

014:盛最多水的容器问题 - 暴力与双指针解法

概述

在本节课中,我们将学习 LeetCode 第 11 题“盛最多水的容器”。我们将首先理解问题,然后分析其暴力解法,最后学习并实现更优的双指针解法。通过本课,你将掌握如何通过优化算法将时间复杂度从 O(n²) 降低到 O(n)。

问题描述

给定一个长度为 n 的整数数组 height。数组中的每个元素代表一条垂直线的长度。这些垂直线在坐标轴上等距排列(例如,索引 i 和 i+1 之间的距离为 1)。我们需要找出由其中两条线与 x 轴共同构成的最大容器面积,该面积代表容器能盛放的最大水量。

核心概念:容器的面积由 宽度高度 决定。

  • 宽度 = 右边界索引 - 左边界索引
  • 高度 = min(左边界线高度, 右边界线高度)
  • 面积 = 宽度 * 高度

我们的目标是找到能使这个面积最大化的两条线。

暴力解法

上一节我们明确了问题的核心是计算面积。最直观的解法是暴力枚举所有可能的容器组合。

以下是暴力解法的思路:我们使用两个嵌套循环,外层循环固定左边界,内层循环遍历所有可能的右边界,计算每个组合的面积,并记录最大值。

代码实现

int maxArea(vector<int>& height) {
    int n = height.size();
    int maxWater = 0; // 用于记录最大面积

    // 外层循环,选择左边界
    for (int i = 0; i < n; i++) {
        // 内层循环,选择右边界(必须位于左边界右侧)
        for (int j = i + 1; j < n; j++) {
            // 计算宽度
            int width = j - i;
            // 计算有效高度(取两者中较矮的线)
            int h = min(height[i], height[j]);
            // 计算当前容器面积
            int currentWater = width * h;
            // 更新最大面积
            maxWater = max(maxWater, currentWater);
        }
    }
    return maxWater;
}

时间复杂度分析:该算法使用了双重循环,时间复杂度为 O(n²)。当数组长度 n 很大时,效率较低。

双指针最优解法

上一节我们介绍了暴力解法,其效率不高。本节中我们来看看如何利用双指针技巧将时间复杂度优化到 O(n)

双指针解法的核心思想是:将两个指针分别置于数组的起始(left)和末尾(right),然后向中间移动。关键在于每次移动高度较小的那个指针

逻辑推导
容器的面积由宽度和最小高度决定。

  1. 初始时,宽度最大。
  2. 如果我们向内移动指针,宽度一定会减小。
  3. 为了有机会获得更大的面积,我们必须尝试增加最小高度
  4. 因此,我们总是移动高度较小的那个指针,希望找到一个更高的线,从而可能补偿宽度减少带来的面积损失。

算法步骤

  1. 初始化 left = 0, right = n - 1, maxWater = 0
  2. left < right 时,循环:
    • 计算当前宽度:width = right - left
    • 计算当前高度:h = min(height[left], height[right])
    • 计算当前面积:currentWater = width * h
    • 更新最大面积:maxWater = max(maxWater, currentWater)
    • 比较 height[left]height[right]
      • 如果 height[left] < height[right],则移动左指针 left++
      • 否则,移动右指针 right--
  3. 循环结束,返回 maxWater

代码实现

int maxArea(vector<int>& height) {
    int left = 0;
    int right = height.size() - 1;
    int maxWater = 0;

    while (left < right) {
        // 计算当前容器的宽度和高度
        int width = right - left;
        int h = min(height[left], height[right]);

        // 计算当前面积并更新最大值
        int currentWater = width * h;
        maxWater = max(maxWater, currentWater);

        // 移动高度较小的指针
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    return maxWater;
}

时间复杂度分析:两个指针从两端向中间遍历,每个元素只被访问一次,因此时间复杂度为 O(n)

总结

本节课中我们一起学习了“盛最多水的容器”问题。

  1. 我们首先理解了问题本质是寻找最大矩形面积(宽度 * 最小高度)。
  2. 接着,我们实现了直观的暴力解法,其时间复杂度为 O(n²)。
  3. 最后,我们重点学习了更高效的双指针解法。通过始终移动较矮的边以寻求可能的高度增加,我们在 O(n) 的时间内解决了问题。

这是一种非常重要的算法优化技巧,在解决许多数组和字符串相关问题时都非常有用。与此类似的题目还有“接雨水”问题,虽然逻辑不完全相同,但都涉及对高度和宽度的权衡,值得对比学习。

015:除自身以外数组的乘积 - 从暴力到最优解

概述

在本节课中,我们将学习如何解决LeetCode第238题“除自身以外数组的乘积”。我们将从最直观的暴力解法开始,逐步优化到空间复杂度为O(1)的最优解法。核心目标是计算一个数组,使得输出数组中的每个元素等于输入数组中除该索引元素外所有其他元素的乘积。


问题描述

给定一个整数数组 nums,返回一个数组 answer,使得 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。

题目数据保证数组之中任意元素的全部前缀元素和后缀元素的乘积都在32位整数范围内。请不要使用除法,且在O(n)时间复杂度内完成此题。

示例:
输入:nums = [1, 2, 3, 4]
输出:answer = [24, 12, 8, 6]
解释:

  • answer[0] = 2 * 3 * 4 = 24
  • answer[1] = 1 * 3 * 4 = 12
  • answer[2] = 1 * 2 * 4 = 8
  • answer[3] = 1 * 2 * 3 = 6

解法一:暴力解法

首先,我们来看最直接的思路。对于输出数组的每一个位置 i,我们需要计算输入数组中所有除了索引 i 以外的元素的乘积。

实现思路

我们可以使用两层循环。外层循环遍历每个索引 i,内层循环遍历所有索引 j 来计算乘积,但跳过 i == j 的情况。

以下是暴力解法的核心代码逻辑:

vector<int> productExceptSelf(vector<int>& nums) {
    int n = nums.size();
    vector<int> answer(n);
    for (int i = 0; i < n; i++) {
        int product = 1;
        for (int j = 0; j < n; j++) {
            if (i != j) {
                product *= nums[j];
            }
        }
        answer[i] = product;
    }
    return answer;
}

复杂度分析

  • 时间复杂度O(n²)。因为对于 n 个元素中的每一个,我们都遍历了 n 个元素。
  • 空间复杂度O(1)(不包括输出数组)。我们只使用了常数个额外变量。

暴力解法虽然直观,但在 n 较大时效率极低,无法满足题目对O(n)时间复杂度的要求。


解法二:前缀积与后缀积

为了将时间复杂度优化到O(n),我们需要避免嵌套循环。一个关键的思路是:answer[i] 可以分解为 i 左侧所有元素的乘积(前缀积)和 i 右侧所有元素的乘积(后缀积)的乘积。

核心概念

  • 前缀积 prefix[i]:表示 nums[0]nums[i-1] 的乘积。
  • 后缀积 suffix[i]:表示 nums[i+1]nums[n-1] 的乘积。
  • 那么,answer[i] = prefix[i] * suffix[i]

实现步骤

  1. 创建两个数组 prefixsuffix,大小均为 n
  2. 正向遍历计算 prefix 数组。
  3. 反向遍历计算 suffix 数组。
  4. 最后遍历一次,将 prefix[i]suffix[i] 相乘得到 answer[i]

以下是该解法的代码:

vector<int> productExceptSelf(vector<int>& nums) {
    int n = nums.size();
    vector<int> prefix(n, 1);
    vector<int> suffix(n, 1);
    vector<int> answer(n);

    // 计算前缀积
    for (int i = 1; i < n; i++) {
        prefix[i] = prefix[i-1] * nums[i-1];
    }
    // 计算后缀积
    for (int i = n-2; i >= 0; i--) {
        suffix[i] = suffix[i+1] * nums[i+1];
    }
    // 计算最终答案
    for (int i = 0; i < n; i++) {
        answer[i] = prefix[i] * suffix[i];
    }
    return answer;
}

复杂度分析

  • 时间复杂度O(n)。我们进行了三次独立的线性遍历。
  • 空间复杂度O(n)。我们额外使用了两个大小为 n 的数组(prefixsuffix)。

这个方法满足了时间复杂度的要求,但仍有优化空间,可以进一步降低空间复杂度。


解法三:空间优化(最优解)

上一节我们使用了额外的数组来存储前缀和后缀信息。本节中我们来看看如何在不使用额外数组的情况下完成计算,将空间复杂度优化到O(1)(不包括输出数组)。

优化思路

我们可以复用输出数组 answer 本身。

  1. 首先,用 answer 数组来存储前缀积。
  2. 然后,使用一个变量 suffix 从后向前遍历,动态计算后缀积,并同时与 answer 中已存储的前缀积相乘,直接得到最终结果。

实现步骤

以下是具体的实现过程:

vector<int> productExceptSelf(vector<int>& nums) {
    int n = nums.size();
    vector<int> answer(n, 1);

    // 第一步:answer 存储前缀积
    // answer[i] 初始为 nums[0]...nums[i-1] 的乘积
    for (int i = 1; i < n; i++) {
        answer[i] = answer[i-1] * nums[i-1];
    }

    // 第二步:用变量 suffix 计算后缀积,并直接与 answer 相乘
    int suffix = 1;
    for (int i = n-1; i >= 0; i--) {
        answer[i] = answer[i] * suffix; // 前缀积 * 当前后缀积
        suffix *= nums[i]; // 更新后缀积,为下一个元素(i-1)做准备
    }
    return answer;
}

逐步演算

nums = [1, 2, 3, 4] 为例:

  1. 计算前缀积存入 answer
    • answer[0] = 1
    • answer[1] = answer[0] * nums[0] = 1 * 1 = 1
    • answer[2] = answer[1] * nums[1] = 1 * 2 = 2
    • answer[3] = answer[2] * nums[2] = 2 * 3 = 6
    • 此时 answer = [1, 1, 2, 6]
  2. 从后向前计算后缀积并更新 answer
    • 初始化 suffix = 1
    • i = 3: answer[3] = 6 * 1 = 6,然后 suffix *= 4 -> suffix = 4
    • i = 2: answer[2] = 2 * 4 = 8,然后 suffix *= 3 -> suffix = 12
    • i = 1: answer[1] = 1 * 12 = 12,然后 suffix *= 2 -> suffix = 24
    • i = 0: answer[0] = 1 * 24 = 24,然后 suffix *= 1 -> suffix = 24
    • 最终 answer = [24, 12, 8, 6]

复杂度分析

  • 时间复杂度O(n)。两次线性遍历。
  • 空间复杂度O(1)(不包括输出数组 answer)。我们只使用了一个额外的整数变量 suffix

这是该问题的最优解法,完全满足了题目的所有要求。


总结

本节课中我们一起学习了“除自身以外数组的乘积”这道经典题目的三种解法:

  1. 暴力解法:时间复杂度 O(n²),空间复杂度 O(1)。思路简单但效率低下。
  2. 前缀积与后缀积法:时间复杂度 O(n),空间复杂度 O(n)。通过空间换时间,是向最优解过渡的关键一步。
  3. 空间优化法:时间复杂度 O(n),空间复杂度 O(1)。最优解法,巧妙地复用输出数组,用一个变量动态维护后缀积。

理解从暴力到最优的优化过程,对于掌握数组类问题的处理技巧和培养空间优化意识非常重要。请务必动手实现并理解每一步的推导。

016:C++中的指针详解

在本节课中,我们将要学习C++中一个核心且重要的概念——指针。我们将从内存地址开始,逐步理解指针的定义、创建、解引用操作,并探讨指针在数组和函数传参中的应用。课程内容设计简单直白,旨在让初学者能够轻松掌握。

概述:内存地址与取址运算符

上一节我们介绍了变量的基本概念,本节中我们来看看变量在计算机内存中是如何存储的。每个变量在内存中都有一个唯一的地址,用于标识其存储位置。这个地址是一个十六进制的数字。

在C++中,我们可以使用取址运算符 & 来获取一个变量的内存地址。请注意,单个 & 符号是取址运算符,而双 && 符号是逻辑与运算符。

以下是一个获取变量地址的示例:

int a = 10;
cout << "变量a的地址是: " << &a << endl;

执行这段代码,控制台会输出类似 0x7ffeeb5b9a3c 的地址值。这个以 0x 开头的数字就是变量 a 在内存中的实际位置。

理解内存地址是学习指针的基础。接下来,我们将正式引入指针的概念。

指针的定义与创建

指针是一种特殊的变量,它的值不是普通的数据,而是另一个变量的内存地址。你可以把指针想象成一个“地址簿”,它记录着某个数据的具体存放位置。

例如,我们有一个整型变量 a,其值为10。我们可以创建一个指向 a 的指针。

以下是创建指针的语法:

int a = 10; // 定义一个整型变量
int* ptr;   // 声明一个指向整型的指针
ptr = &a;   // 将变量a的地址赋值给指针ptr

也可以将声明和初始化合并为一行:

int* ptr = &a;

这里,int* 表示 ptr 是一个指向 int 类型数据的指针。星号 * 是声明指针的关键符号。

现在,指针 ptr 存储了变量 a 的地址。我们可以验证一下:

cout << “变量a的地址 (&a): ” << &a << endl;
cout << “指针ptr存储的值 (ptr): ” << ptr << endl;

你会发现,这两行代码输出的地址值是相同的。

指针的类型必须与其指向的变量类型相匹配。例如,指向浮点数的指针应声明为 float*

float price = 0.25f;
float* pricePtr = &price; // 正确:类型匹配
// int* wrongPtr = &price; // 错误:类型不匹配

指针的指针(多级指针)

指针本身也是一个变量,它也有自己的内存地址。因此,我们可以创建指向指针的指针,这被称为“多级指针”或“指针的指针”。

例如,我们有一个指向整型变量 a 的指针 ptr。我们可以再创建一个指向 ptr 的指针 pptr

以下是创建二级指针的示例:

int a = 10;
int* ptr = &a;      // 一级指针,指向a
int** pptr = &ptr;  // 二级指针,指向ptr

这里,int** 表示 pptr 是一个指向 int* 类型(即指针)的指针。

我们可以通过多级指针来访问原始变量的值:

cout << “a的值: ” << a << endl;
cout << “通过ptr访问a的值 (*ptr): ” << *ptr << endl;
cout << “通过pptr访问a的值 (**pptr): ” << **pptr << endl;

这三行代码的输出结果都是 10**pptr 的含义是:先解引用 pptr 得到 ptr,再解引用 ptr 得到 a 的值。

解引用运算符

解引用运算符 * 是操作指针的核心工具。它的作用是访问指针所指向地址中存储的值

简单来说,如果指针保存的是一个地址,那么在该指针前加上 *,就能得到该地址中存放的实际数据。

以下是解引用运算符的示例:

int a = 10;
int* ptr = &a;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/47b43e092d6b13a93e9507bb0e4321dc_17.png)

cout << “指针ptr存储的地址: ” << ptr << endl;
cout << “指针ptr指向地址中的值 (*ptr): ” << *ptr << endl; // 输出 10

// 通过指针修改其指向的值
*ptr = 20;
cout << “现在a的值是: ” << a << endl; // 输出 20

通过 *ptr = 20; 这行代码,我们直接修改了变量 a 的值。这展示了指针的强大能力:它允许我们间接地操作其他变量。

对于多级指针,解引用需要逐级进行:

int a = 10;
int* ptr = &a;
int** pptr = &ptr;

cout << “**pptr 的值是: ” << **pptr << endl; // 输出 10
// **pptr 等价于 *(*pptr),即先得到ptr,再得到a的值。

指针与数组

在C++中,数组名本身就是一个指向数组第一个元素(索引0)的常量指针。这意味着你可以像使用指针一样使用数组名。

以下是指针与数组关系的示例:

int arr[5] = {1, 2, 3, 4, 5};

cout << “数组名arr的值(首地址): ” << arr << endl;
cout << “第一个元素的地址 (&arr[0]): ” << &arr[0] << endl; // 与上一行输出相同

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/47b43e092d6b13a93e9507bb0e4321dc_19.png)

// 使用指针语法访问数组元素
cout << “第一个元素的值 (*arr): ” << *arr << endl; // 输出 1
cout << “第二个元素的值 (*(arr + 1)): ” << *(arr + 1) << endl; // 输出 2

arr 等价于 &arr[0]*(arr + i) 等价于 arr[i]。这种等价关系是理解数组和指针运算的关键。

由于数组名是常量指针,你不能修改它的值(即不能让它指向别的地址):

// arr = &someOtherVariable; // 错误!数组名是常量,不能重新赋值。

指针算术运算

指针支持有限的算术运算,主要是加法和减法。这些运算的单位不是简单的数字1,而是指针所指向数据类型的大小(字节数)。

以下是常见的指针算术运算:

  1. 递增 (++) / 递减 (--): 指针移动到下一个/上一个同类型元素的位置。
  2. 加法 (+ n) / 减法 (- n): 指针向前或向后移动 n 个元素。
  3. 指针相减: 计算两个指针之间相隔多少个元素。

以下是示例:

int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr; // ptr指向arr[0]

ptr++; // ptr现在指向arr[1]
cout << “*ptr after ptr++: ” << *ptr << endl; // 输出 20

ptr = ptr + 2; // ptr现在指向arr[3]
cout << “*ptr after ptr+2: ” << *ptr << endl; // 输出 40

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/47b43e092d6b13a93e9507bb0e4321dc_21.png)

int* ptr2 = &arr[4];
cout << “两个指针之间的元素差 (ptr2 - ptr): ” << (ptr2 - ptr) << endl; // 输出 1 (因为ptr指向arr[3], ptr2指向arr[4])

注意:指针之间可以比较大小(比较地址高低),也可以相减得到元素个数,但不能进行加法、乘法等其他算术运算。

指针与函数(传引用调用)

指针的一个极其重要的应用是在函数参数传递中实现“传引用调用”。这允许函数内部修改调用者传入的原始变量。

在默认的“传值调用”中,函数获得的是参数的一个副本,修改副本不影响原始值。

以下是对比示例:

// 传值调用 - 无法修改原始值
void passByValue(int x) {
    x = 20;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/47b43e092d6b13a93e9507bb0e4321dc_32.png)

// 传引用调用(使用指针)- 可以修改原始值
void passByReference(int* xPtr) {
    *xPtr = 20;
}

int main() {
    int a = 10;

    passByValue(a);
    cout << “传值调用后 a = ” << a << endl; // 输出 10,未改变

    passByReference(&a); // 将变量a的地址传入函数
    cout << “传引用调用后 a = ” << a << endl; // 输出 20,已改变

    return 0;
}

passByReference 函数中,我们通过指针 xPtr 接收了 a 的地址,并通过解引用 *xPtr 直接修改了 main 函数中变量 a 的值。

除了指针,C++还提供了“引用”这一更简洁的语法来实现传引用调用,我们将在后续课程中学习。

总结

本节课中我们一起学习了C++指针的核心知识:

  1. 内存地址:每个变量都有唯一地址,可用 & 运算符获取。
  2. 指针定义:指针是存储其他变量地址的特殊变量,声明时使用 *(如 int* ptr)。
  3. 多级指针:可以创建指向指针的指针(如 int** pptr)。
  4. 解引用运算符 *:用于访问指针所指向地址中的值。
  5. 指针与数组:数组名是指向首元素的常量指针,指针算术可用于遍历数组。
  6. 指针算术:支持以数据类型大小为单位的加减运算,用于在内存中移动。
  7. 函数传引用:通过传递指针作为参数,函数可以修改调用者的原始变量,这是实现高效编程的关键技术之一。

理解并掌握指针是深入学习C++和数据结构的基石。请务必动手练习代码示例,以巩固这些概念。

017:二分查找算法 🎯

概述

在本节课中,我们将要学习一种非常高效且重要的搜索算法——二分查找。我们将从理解其基本概念开始,逐步学习其工作原理、应用条件,并分别用迭代和递归两种方法来实现它。最后,我们会分析其时间复杂度和空间复杂度。


二分查找的概念与条件

上一节我们介绍了线性搜索,本节中我们来看看二分查找。二分查找是一种在有序数组中查找特定元素的算法。

它的核心思想是每次比较都使搜索范围缩小一半。为了应用二分查找,数组必须满足一个关键条件:数组必须是单调的。单调函数是指非递减或非递增的函数。在编程中,这通常意味着数组是按升序或降序排列的。

核心条件公式:
数组必须有序(升序或降序)


二分查找的工作原理

理解了应用条件后,我们来看看算法是如何工作的。二分查找通过不断将搜索区间对半分割来工作。

以下是二分查找的基本步骤:

  1. 确定当前搜索区间的起始点(start)和结束点(end)。
  2. 计算中间点的索引(mid)。
  3. 将目标值(target)与中间点的值(arr[mid])进行比较。
  4. 根据比较结果,将搜索区间缩小到左半部分或右半部分,或者直接找到目标。

核心比较逻辑(代码描述):

if (target > arr[mid]) {
    // 目标在右半部分
    start = mid + 1;
} else if (target < arr[mid]) {
    // 目标在左半部分
    end = mid - 1;
} else {
    // 找到目标,返回索引 mid
    return mid;
}

迭代法实现二分查找

现在,让我们用代码来实现迭代版本的二分查找。迭代法使用循环来重复执行查找步骤。

以下是迭代法实现的步骤:

  1. 初始化 start = 0end = n-1(n为数组长度)。
  2. start <= end 时,执行循环:
    a. 计算中间索引 mid。为避免整数溢出,使用公式 mid = start + (end - start) / 2
    b. 比较 targetarr[mid]
    c. 根据比较结果更新 startend
  3. 如果循环结束仍未找到,返回 -1 表示未找到。

迭代法代码示例:

int binarySearchIterative(vector<int>& arr, int target) {
    int start = 0;
    int end = arr.size() - 1;

    while (start <= end) {
        // 防止溢出的中间值计算
        int mid = start + (end - start) / 2;

        if (target > arr[mid]) {
            // 搜索右半部分
            start = mid + 1;
        } else if (target < arr[mid]) {
            // 搜索左半部分
            end = mid - 1;
        } else {
            // 找到目标
            return mid;
        }
    }
    // 未找到目标
    return -1;
}


递归法实现二分查找

上一节我们使用循环实现了二分查找,本节中我们来看看如何用递归实现。递归方法将问题分解为更小的子问题。

递归实现的逻辑与迭代法类似,但通过函数自身调用来缩小搜索范围。

递归法代码示例:

int binarySearchRecursive(vector<int>& arr, int target, int start, int end) {
    // 基准情况:搜索区间无效
    if (start > end) {
        return -1;
    }

    int mid = start + (end - start) / 2;

    if (target > arr[mid]) {
        // 在右半部分递归搜索
        return binarySearchRecursive(arr, target, mid + 1, end);
    } else if (target < arr[mid]) {
        // 在左半部分递归搜索
        return binarySearchRecursive(arr, target, start, mid - 1);
    } else {
        // 找到目标
        return mid;
    }
}
// 调用时:binarySearchRecursive(arr, target, 0, arr.size() - 1)


复杂度分析与优化

我们实现了两种方法的二分查找,现在来分析其效率。二分查找之所以高效,是因为它每一步都将数据规模减半。

时间复杂度分析:
最坏情况下,二分查找需要执行的步骤次数是将数组长度 n 不断除以 2,直到结果为 1。这可以表示为 log₂(n)
因此,二分查找的时间复杂度是 O(log n)。这远优于线性查找的 O(n)。

空间复杂度分析:

  • 迭代法:只使用了固定的额外空间(几个变量),因此空间复杂度是 O(1)
  • 递归法:由于递归调用会在调用栈上占用空间,最大深度为 log n,因此空间复杂度是 O(log n)

重要优化点:
计算中间索引 mid 时,使用 mid = start + (end - start) / 2 而不是 (start + end) / 2,可以防止 start + end 的值超过整数类型最大值时发生溢出。这是一个在处理大数据集时必须注意的优化。


总结

本节课中我们一起学习了二分查找算法。
我们首先了解了二分查找的应用前提是数组必须有序。
然后,我们深入探讨了其“折半查找”的核心工作原理。
接着,我们分别使用迭代递归两种方法实现了二分查找,并提供了完整的代码示例。
最后,我们分析了算法的时间复杂度为 O(log n),空间复杂度迭代法为 O(1),递归法为 O(log n),并强调了防止计算中间值时整数溢出的重要优化技巧。
二分查找是解决许多搜索问题的基础,理解并掌握它对于学习更复杂的数据结构和算法至关重要。

018:搜索旋转排序数组 - 二分查找

在本节课中,我们将学习如何在一个旋转过的有序数组中进行搜索。这是一个经典的面试问题,我们将使用一种修改过的二分查找算法来解决它,以达到 O(log n) 的时间复杂度。


问题描述

问题名为“搜索旋转排序数组”。给定一个在某个未知下标处旋转过的升序数组 nums 和一个目标值 target,如果目标值存在于数组中,则返回其下标,否则返回 -1

例如,数组 [4,5,6,7,0,1,2] 是在下标 3 处旋转得到的。我们需要高效地搜索目标值。

核心思路:修改二分查找

普通的二分查找要求数组完全有序。对于旋转排序数组,虽然整体无序,但它的一半总是有序的。我们的策略是:

  1. 找到中间元素 mid
  2. 判断左半部分 [start, mid] 或右半部分 [mid, end] 是否有序。
  3. 检查目标值 target 是否位于有序的那一半范围内。
  4. 根据判断结果,将搜索范围缩小到左半部分或右半部分。

以下是该算法的详细步骤。

算法步骤详解

以下是实现修改版二分查找的具体步骤:

  1. 初始化:设置两个指针,start = 0end = nums.size() - 1
  2. 循环条件:当 start <= end 时,执行循环。
  3. 计算中点:使用公式 mid = start + (end - start) / 2 计算中点下标,防止整数溢出。
  4. 检查是否找到:如果 nums[mid] == target,直接返回 mid
  5. 判断有序部分
    • 如果 nums[start] <= nums[mid],说明左半部分是有序的
      • 接着检查 target 是否在这个有序的左半部分内:即 nums[start] <= target && target < nums[mid]
      • 如果是,则将搜索范围缩小到左半部分:end = mid - 1
      • 否则,目标值在右半部分:start = mid + 1
    • 否则,说明右半部分是有序的
      • 接着检查 target 是否在这个有序的右半部分内:即 nums[mid] < target && target <= nums[end]
      • 如果是,则将搜索范围缩小到右半部分:start = mid + 1
      • 否则,目标值在左半部分:end = mid - 1
  6. 循环结束:如果循环结束仍未找到目标值,则返回 -1

代码实现

根据上述逻辑,我们可以写出如下 C++ 代码:

class Solution {
public:
    int search(vector<int>& nums, int target) {
        int start = 0;
        int end = nums.size() - 1;

        while (start <= end) {
            int mid = start + (end - start) / 2;

            if (nums[mid] == target) {
                return mid;
            }

            // 判断左半部分是否有序
            if (nums[start] <= nums[mid]) {
                // 目标值是否在有序的左半部分内
                if (nums[start] <= target && target < nums[mid]) {
                    end = mid - 1; // 在左半部分搜索
                } else {
                    start = mid + 1; // 在右半部分搜索
                }
            } 
            // 否则,右半部分有序
            else {
                // 目标值是否在有序的右半部分内
                if (nums[mid] < target && target <= nums[end]) {
                    start = mid + 1; // 在右半部分搜索
                } else {
                    end = mid - 1; // 在左半部分搜索
                }
            }
        }
        return -1; // 未找到目标值
    }
};

复杂度分析

  • 时间复杂度:O(log n),其中 n 是数组长度。我们每次都将搜索范围减半。
  • 空间复杂度:O(1),只使用了常数级别的额外空间。

总结

本节课我们一起学习了如何解决“搜索旋转排序数组”问题。关键在于利用旋转排序数组的一半总是有序的这一特性,对标准二分查找进行修改。我们通过判断有序部分,并检查目标值是否落在该范围内,来高效地缩小搜索范围,最终在 O(log n) 时间内找到答案或确认其不存在。请务必理解并掌握这个算法的核心判断逻辑。

019:山脉数组的峰值索引(二分查找 - Leetcode 852)

概述

在本节课中,我们将学习如何解决一个名为“山脉数组的峰值索引”的问题。这是一个经典的二分查找应用问题,可以帮助我们理解如何在非严格单调的序列中寻找特定元素。


问题定义与示例

上一节我们介绍了二分查找的基本原理,本节中我们来看看它在特殊数组结构中的应用。

这个问题被称为“峰值索引”,编号为 Leetcode 852。题目给定一个“山脉数组”,该数组满足以下特性:存在一个索引 i,使得数组在 i 之前严格递增,在 i 之后严格递减。这个索引 i 就是峰值元素的索引。

例如,数组 [0, 3, 8, 9, 5, 2] 是一个山脉数组。其中,索引 3(值为 9)是峰值,因为:

  • arr[2] (8) < arr[3] (9)
  • arr[3] (9) > arr[4] (5)

峰值元素 p 必须满足条件:
arr[p-1] < arr[p]arr[p] > arr[p+1]


解题思路与核心逻辑

理解问题后,我们需要设计一个高效的算法。由于数组具有先增后减的特性,我们可以使用修改版的二分查找。

以下是解题的关键步骤:

  1. 初始化指针:设置 start = 0end = arr.size() - 1
  2. 循环条件:当 start <= end 时,继续查找。
  3. 计算中点:使用公式 mid = start + (end - start) / 2 来避免整数溢出。
  4. 判断峰值
    • 如果 arr[mid] > arr[mid-1]arr[mid] > arr[mid+1],那么 mid 就是峰值索引。
    • 否则,我们需要判断当前位于山脉的“上升坡”还是“下降坡”。
  5. 调整搜索范围
    • 如果位于上升坡(即 arr[mid] < arr[mid+1]),说明峰值在右侧,将 start 更新为 mid + 1
    • 如果位于下降坡(即 arr[mid] > arr[mid+1]),说明峰值在左侧,将 end 更新为 mid - 1

代码实现

根据上述逻辑,我们可以写出如下 C++ 代码:

class Solution {
public:
    int peakIndexInMountainArray(vector<int>& arr) {
        int start = 0;
        int end = arr.size() - 1;

        while (start <= end) {
            int mid = start + (end - start) / 2;

            // 检查 mid 是否为峰值
            if (arr[mid] > arr[mid - 1] && arr[mid] > arr[mid + 1]) {
                return mid;
            }
            // 如果处于上升坡,峰值在右侧
            else if (arr[mid] < arr[mid + 1]) {
                start = mid + 1;
            }
            // 如果处于下降坡,峰值在左侧
            else { // arr[mid] > arr[mid + 1]
                end = mid - 1;
            }
        }
        // 根据题意,数组保证是山脉数组,所以理论上不会执行到此处
        return -1;
    }
};

复杂度分析

  • 时间复杂度O(log n),其中 n 是数组长度。我们每次都将搜索区间减半。
  • 空间复杂度O(1),只使用了常数级别的额外空间。

总结

本节课中我们一起学习了如何使用修改的二分查找算法来解决“山脉数组的峰值索引”问题。我们首先理解了山脉数组的定义和峰值元素的条件,然后分析了如何利用数组的单调性变化来缩小搜索范围,最后给出了完整的代码实现和复杂度分析。掌握这种方法,对于解决其他基于单调性变化的搜索问题非常有帮助。

020:有序数组中的单一元素 - 二分查找

在本节课中,我们将学习如何在一个有序数组中找到只出现一次的元素。这是一个常见的面试问题,要求我们设计一个时间复杂度为 O(log n) 的解决方案。

问题概述

给定一个有序数组,其中每个元素都出现两次,只有一个元素出现一次。我们需要找到这个单一元素。

例如,在数组 [1, 1, 2, 3, 3, 4, 4, 8, 8] 中,数字 2 只出现了一次,所以答案是 2

解决方案思路

解决这个问题有多种方法。最直接的方法是使用线性搜索,但时间复杂度是 O(n)。为了满足 O(log n) 的要求,我们必须使用二分查找。

二分查找的核心在于每次将搜索范围减半。在这个特定问题中,关键在于观察单一元素如何破坏了数组的“成对”模式。

关键逻辑与步骤

以下是使用二分查找解决此问题的核心逻辑。

首先,我们定义搜索的起点和终点:

int start = 0;
int end = n - 1; // n 是数组长度

在二分查找的循环中,我们计算中间索引 mid。为了防止整数溢出,推荐使用以下公式:

int mid = start + (end - start) / 2;

接下来是最重要的部分:判断 mid 指向的元素是否是单一元素。单一元素的特征是它不等于它的前一个元素,也不等于它的后一个元素。

if (nums[mid] != nums[mid-1] && nums[mid] != nums[mid+1]) {
    return nums[mid];
}

如果 mid 不是单一元素,我们需要决定是向左半部分还是右半部分继续搜索。这取决于单一元素位于哪一侧。

判断逻辑基于单一元素左右两侧元素数量的奇偶性。由于数组原本是成对出现的,单一元素的存在会使其一侧的元素数量为奇数,另一侧为偶数。

以下是决定搜索方向的逻辑:

  1. 如果 mid 是偶数索引,并且 nums[mid] == nums[mid+1],那么单一元素在右侧。否则,单一元素在左侧。
  2. 如果 mid 是奇数索引,并且 nums[mid] == nums[mid-1],那么单一元素在右侧。否则,单一元素在左侧。

这个逻辑可以简化为:检查 mid 与它配对元素的关系,从而确定被破坏的“成对”模式在哪一边。

边界情况处理

在实现时,需要特别注意数组的边界,即 mid 为第一个或最后一个元素的情况。

以下是处理边界情况的代码片段:

// 检查 mid 是否为第一个元素
if (mid == 0 && nums[mid] != nums[mid+1]) {
    return nums[mid];
}
// 检查 mid 是否为最后一个元素
if (mid == n-1 && nums[mid] != nums[mid-1]) {
    return nums[mid];
}

完整算法流程

现在,让我们将以上步骤整合成一个完整的算法。

  1. 初始化 start = 0, end = n-1
  2. start <= end 时,执行循环:
    a. 计算中间索引 mid = start + (end - start) / 2
    b. 检查 mid 是否为单一元素(处理边界情况)。
    c. 如果不是,则根据 mid 索引的奇偶性及其与相邻元素的关系,更新 startend 以缩小搜索范围。
  3. 循环结束后,返回找到的单一元素值。

总结

本节课我们一起学习了如何在有序数组中使用二分查找寻找单一元素。我们分析了问题的核心在于单一元素破坏了数组的成对结构,并据此设计了判断逻辑来指导二分搜索的方向。通过处理索引奇偶性和边界情况,我们实现了一个高效且健壮的 O(log n) 解决方案。

本节课程到此结束。建议大家动手实现代码,并尝试在 LeetCode 上提交练习。你可以将学习进度分享到 Twitter,描述中提供了相关链接。继续学习,继续探索。

021:书籍分配问题(二分查找应用)

在本节课中,我们将学习一个基于二分查找算法的重要且常见的问题——书籍分配问题。这是一个在技术面试中频繁出现的难题,我们将一步步拆解其核心思想与实现方法。

概述

书籍分配问题的核心是:给定一个包含多本书页数的数组,以及一定数量的学生,我们需要以连续的方式将这些书分配给所有学生。目标是最小化分配给任意一个学生的最大页数。换句话说,我们希望最“忙碌”的学生所读的页数尽可能少。

问题理解与思路分析

假设我们有一个页数数组 [10, 20, 30, 40],以及 M = 2 名学生。

我们需要将所有书连续地分配给这两名学生。例如:

  • 分配方案1:学生1得到 [10],学生2得到 [20, 30, 40]。此时,单个学生的最大页数是 max(10, 90) = 90
  • 分配方案2:学生1得到 [10, 20],学生2得到 [30, 40]。此时,最大页数是 max(30, 70) = 70
  • 分配方案3:学生1得到 [10, 20, 30],学生2得到 [40]。此时,最大页数是 max(60, 40) = 60

我们的目标是找到所有可能分配方案中,这个“最大页数”的最小值。在这个例子中,答案就是 60

直接遍历所有分配方案会非常低效。我们可以利用二分查找来优化。

核心思路:我们并非直接二分查找书籍的分配方式,而是二分查找“可能的最大页数”这个答案。

  1. 确定搜索范围

    • 下限 (start):至少有一本书会被分配,所以下限是数组中的最大单本书页数。因为任何学生分配到的页数都不能少于他所拿到的最厚的那本书。
    • 上限 (end):最坏情况下,所有书都分配给一个学生,所以上限是所有书页数的总和。
  2. 二分查找过程

    • 取中间值 mid,它代表我们当前假设的“允许分配给单个学生的最大页数”。
    • 我们需要验证:在每名学生分配到的页数不超过 mid 的前提下,能否用 M 名学生分配完所有书籍。
    • 如果能够分配完,说明 mid 是一个可行的上限,那么真正的答案可能等于或小于 mid。我们将搜索范围的上限 end 缩小到 mid
    • 如果不能分配完,说明 mid 这个上限太严格了,我们需要放宽限制。我们将搜索范围的下限 start 提高到 mid + 1
    • 重复此过程,直到找到最小的可行 mid 值,即为答案。

解决方案实现

以下是实现该算法的关键步骤与代码。

验证函数 isValid

这个函数用于判断在给定的“最大页数限制”下,能否完成分配。

bool isValid(vector<int>& arr, int n, int m, int maxPages) {
    int studentsRequired = 1;
    int currentPageSum = 0;

    for (int i = 0; i < n; i++) {
        // 如果单本书页数已超过限制,直接不可能
        if (arr[i] > maxPages) {
            return false;
        }

        // 尝试将当前书加入当前学生的分配中
        if (currentPageSum + arr[i] <= maxPages) {
            currentPageSum += arr[i];
        } else {
            // 当前学生已达页数上限,需要新开一名学生
            studentsRequired++;
            currentPageSum = arr[i]; // 新学生从当前这本书开始

            // 如果所需学生数已超过给定人数 m,则分配失败
            if (studentsRequired > m) {
                return false;
            }
        }
    }
    return true; // 分配成功
}

主函数 findPages

这个函数执行二分查找,寻找最小的最大页数。

int findPages(vector<int>& arr, int n, int m) {
    // 如果书少于学生数,无法分配
    if (n < m) return -1;

    int totalSum = 0;
    int maxElement = 0;

    // 计算搜索范围:下限和上限
    for (int i = 0; i < n; i++) {
        totalSum += arr[i];
        if (arr[i] > maxElement) {
            maxElement = arr[i];
        }
    }

    int start = maxElement; // 下限:最厚书的页数
    int end = totalSum;     // 上限:总页数
    int result = -1;

    while (start <= end) {
        int mid = start + (end - start) / 2; // 当前假设的“最大页数限制”

        // 检查 mid 是否是一个可行的分配方案
        if (isValid(arr, n, m, mid)) {
            // 可行,尝试寻找更小的限制(向左搜索)
            result = mid;
            end = mid - 1;
        } else {
            // 不可行,需要增大限制(向右搜索)
            start = mid + 1;
        }
    }
    return result; // 返回找到的最小可能的最大页数
}

复杂度分析

  • 时间复杂度O(N * log(Sum))。其中 N 是书籍数量,Sum 是所有书的总页数。二分查找的复杂度是 O(log(Sum)),每次验证需要 O(N) 时间。
  • 空间复杂度O(1),只使用了常数级别的额外空间。

示例与测试

让我们用之前的例子 [10, 20, 30, 40], M=2 来验证。

  • 搜索范围:start = 40, end = 100
  • 二分查找会最终收敛到答案 60

再考虑一个例子 [12, 34, 67, 90], M=2

  • 最终答案应为 113 (分配方案:[12, 34, 67][90])。

总结

本节课我们一起学习了书籍分配问题。我们掌握了如何将这类“最小化最大值”的问题转化为对答案本身的二分查找。关键在于:

  1. 确定答案的合理搜索范围(下限和上限)。
  2. 编写一个高效的验证函数 isValid,用于判断某个中间值是否可行。
  3. 利用二分查找框架不断缩小范围,直至找到最优解。

这种方法(对答案进行二分查找)是解决许多复杂优化问题的强大技巧,值得深入理解和掌握。

022:油漆匠分区问题

在本节课中,我们将学习一个经典问题——油漆匠分区问题。我们将理解问题描述,分析其核心逻辑,并最终使用二分查找算法来高效地解决它。

问题概述

想象一个场景:有 M 位油漆匠需要粉刷 N 块木板。每块木板 i 有一个长度 arr[i],这代表了粉刷它所需要的时间。所有油漆匠的工作效率相同,即粉刷一单位长度的木板需要一单位时间。

任务是将这些连续的木板分配给油漆匠,使得完成所有粉刷工作的总时间最短。分配规则是:

  1. 一位油漆匠只能粉刷连续的木板段。
  2. 所有油漆匠可以同时工作。

我们的目标是找到这个最短的可能时间

核心概念与思路分析

上一节我们明确了问题,本节我们来分析解决它的核心思路。

理解时间范围

首先,我们需要确定答案(最短时间)可能存在的范围。

  • 最小可能时间:即使有再多油漆匠,完成工作的时间也至少需要粉刷最长的单块木板所花的时间。因为这块木板必须由一位油漆匠单独完成。
    • 公式min_time = max(arr)
  • 最大可能时间:如果只有一位油漆匠,那么他必须粉刷所有木板,总时间就是所有木板长度之和。
    • 公式max_time = sum(arr)

因此,答案一定在区间 [max(arr), sum(arr)] 之内。

二分查找的应用

我们无法直接计算出精确的最短时间,但可以验证一个给定的时间 mid 是否可行。

  • 可行性检查:假设我们规定每个油漆匠最多工作 mid 单位时间。我们能否在 M 位油漆匠内,按照连续分配的规则,完成所有木板的粉刷?
    • 如果可以,说明 mid 是一个可行解,甚至可能存在更短的时间(在左侧区间)。
    • 如果不可以,说明 mid 时间太短,需要更长时间(在右侧区间)。

这正是二分查找的用武之地。我们在时间范围 [max(arr), sum(arr)] 内进行二分查找,不断猜测中间值 mid 并检查其可行性,从而逐步缩小范围,最终找到最小的可行时间。

算法步骤详解

基于以上分析,以下是解决问题的具体步骤。

1. 定义辅助函数:isPossible

这个函数用于检查在给定的最大允许时间 mid 和油漆匠数量 M 下,能否完成粉刷。

以下是 isPossible 函数的逻辑:

  1. 初始化油漆匠计数 painterCount = 1,当前油漆匠已用时间 currentTime = 0
  2. 遍历每一块木板:
    • 如果 currentTime + arr[i] <= mid,说明当前油漆匠可以粉刷这块木板。更新 currentTime += arr[i]
    • 否则,说明当前油漆匠时间已用尽,需要启用一位新油漆匠 (painterCount++)。新油漆匠从当前木板开始粉刷,所以 currentTime = arr[i]
  3. 遍历结束后,如果 painterCount <= M,说明在 mid 时间内用 M 位油漆匠可以完成任务,返回 true;否则返回 false

2. 主函数:二分查找最小时间

在主函数中,我们执行标准的二分查找来寻找最小可行时间。

以下是主函数的执行流程:

  1. 计算时间搜索范围的起点 start = max(arr),终点 end = sum(arr)。初始化答案 ans = -1
  2. start <= end 时,执行循环:
    • 计算中间值 mid = start + (end - start) / 2
    • 调用 isPossible(arr, N, M, mid) 进行检查。
    • 如果返回 true,说明 mid 可行,记录 ans = mid,并尝试寻找更小的时间,将搜索范围向左移:end = mid - 1
    • 如果返回 false,说明 mid 不可行,需要更长时间,将搜索范围向右移:start = mid + 1
  3. 循环结束后,返回记录的 ans,即为所求的最小时间。

代码实现

将上述逻辑转化为C++代码。

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

// 辅助函数:检查在给定最大时间限制下,M位油漆匠是否能完成工作
bool isPossible(vector<int>& boards, int n, int m, int mid) {
    int painterCount = 1;
    int currentTime = 0;

    for (int i = 0; i < n; i++) {
        if (currentTime + boards[i] <= mid) {
            // 当前油漆匠可以粉刷这块木板
            currentTime += boards[i];
        } else {
            // 需要一位新油漆匠
            painterCount++;
            if (painterCount > m || boards[i] > mid) {
                // 如果需要的油漆匠超过M,或单块木板时间已超限,则不可能
                return false;
            }
            currentTime = boards[i]; // 新油漆匠从当前木板开始
        }
    }
    return true;
}

// 主函数:使用二分查找求解最小时间
int findMinTimeToPaint(vector<int>& boards, int n, int m) {
    // 计算搜索范围
    int start = *max_element(boards.begin(), boards.end());
    int end = accumulate(boards.begin(), boards.end(), 0);
    int ans = -1;

    while (start <= end) {
        int mid = start + (end - start) / 2;

        if (isPossible(boards, n, m, mid)) {
            // 如果mid时间可行,尝试寻找更小的可行时间
            ans = mid;
            end = mid - 1;
        } else {
            // 如果mid时间不可行,需要更长时间
            start = mid + 1;
        }
    }
    return ans;
}

int main() {
    // 示例:木板长度数组,4块木板,2位油漆匠
    vector<int> boards = {40, 30, 10, 20};
    int n = boards.size();
    int m = 2;

    int minTime = findMinTimeToPaint(boards, n, m);
    cout << "完成所有粉刷工作的最短时间是: " << minTime << " 单位时间。" << endl;
    // 对于示例,输出应为 60
    return 0;
}

复杂度分析

  • 时间复杂度:二分查找的时间复杂度为 O(log(S)),其中 S 是木板长度之和 sum(arr)。在每次查找中,isPossible 函数需要 O(N) 的时间来遍历所有木板。因此,总时间复杂度为 O(N * log(S))
  • 空间复杂度O(1),我们只使用了常数级别的额外空间。

总结

本节课我们一起学习了油漆匠分区问题。我们首先理解了问题背景和约束条件,然后分析了答案所在的时间范围。解决问题的核心在于将“求最小值”转化为“验证可行性”,并利用二分查找算法在可能的时间范围内高效搜索。我们定义了 isPossible 辅助函数来验证一个给定时间是否可行,最终通过二分查找找到了最小的可行时间。这种方法的时间复杂度为 O(N * log(S)),对于此类“最小化最大值”的问题非常有效。

023:暴躁的牛问题

在本节课中,我们将学习一个经典的二分查找应用问题——“暴躁的牛问题”。我们将理解问题定义,分析解题思路,并最终用代码实现解决方案。

问题概述

农场主有 n 个牛棚(stalls),其位置由一个整数数组 stalls[] 表示。农场主还有 c 头牛。为了防止牛之间争斗,需要将它们分配到牛棚中,使得任意两头牛之间的最小距离尽可能大

我们的任务是找到这个最大的最小距离

核心概念与思路

上一节我们介绍了问题的目标。本节中我们来看看如何将问题转化为可计算的模型。

问题的核心是:给定一个可能的最小距离 mid,判断是否能够将 c 头牛以至少 mid 的距离安置在牛棚中。这是一个典型的可行性检查问题。

我们可以通过二分查找在可能的答案范围内搜索。答案的最小可能值是 1,最大可能值是 (最远牛棚坐标 - 最近牛棚坐标)

以下是解题的基本步骤:

  1. 对牛棚位置数组 stalls[] 进行排序。
  2. 设定二分查找的起点 start = 1,终点 end = stalls[n-1] - stalls[0]
  3. while(start <= end) 循环中,计算中间值 mid = start + (end - start) / 2
  4. 调用函数 isPossible(stalls, n, c, mid) 检查距离 mid 是否可行。
  5. 如果可行,说明可能存在更大的最小距离,我们记录 mid 为当前答案,并将搜索范围更新为 [mid+1, end]
  6. 如果不可行,说明距离太大,需要缩小距离,将搜索范围更新为 [start, mid-1]
  7. 循环结束后,记录的答案即为最大的最小距离

可行性检查函数

上一节我们确定了二分搜索的框架,本节中我们详细看看核心的 isPossible 函数如何实现。

该函数的目的是判断在给定的最小允许距离 minDist 下,能否放下所有的 c 头牛。

以下是 isPossible 函数的逻辑:

  1. 初始化已放置的牛数量 count = 1(第一头牛总是放在第一个牛棚 stalls[0])。
  2. 记录上一头牛放置的位置 lastPos = stalls[0]
  3. 从第二个牛棚开始遍历 (i = 1n-1)。
  4. 如果当前牛棚位置 stalls[i]lastPos 的距离 >= minDist,则可以在当前位置放置一头牛。更新 count++lastPos = stalls[i]
  5. 如果 count 达到 c,说明所有牛都已成功放置,函数返回 true
  6. 如果遍历结束 count 仍小于 c,则说明无法以 minDist 的距离放下所有牛,函数返回 false

其代码逻辑如下:

bool isPossible(vector<int> &stalls, int n, int c, int minDist) {
    int countCows = 1;
    int lastPlacedPos = stalls[0];
    
    for(int i = 1; i < n; i++) {
        if(stalls[i] - lastPlacedPos >= minDist) {
            countCows++;
            lastPlacedPos = stalls[i];
        }
        if(countCows >= c) return true;
    }
    return false;
}

整体解决方案与复杂度分析

理解了可行性检查后,我们现在可以组合出完整的解决方案。

完整的解决方案代码整合了排序、二分查找和可行性检查。

#include <bits/stdc++.h>
using namespace std;

bool isPossible(vector<int> &stalls, int n, int c, int minDist) {
    int countCows = 1;
    int lastPos = stalls[0];
    
    for(int i = 1; i < n; i++) {
        if(stalls[i] - lastPos >= minDist) {
            countCows++;
            lastPos = stalls[i];
        }
        if(countCows >= c) return true;
    }
    return false;
}

int aggressiveCows(vector<int> &stalls, int n, int c) {
    // 1. 排序牛棚位置
    sort(stalls.begin(), stalls.end());
    
    // 2. 定义二分查找范围
    int start = 1;
    int end = stalls[n-1] - stalls[0];
    int ans = -1;
    
    // 3. 执行二分查找
    while(start <= end) {
        int mid = start + (end - start) / 2;
        
        if(isPossible(stalls, n, c, mid)) {
            // 如果mid距离可行,尝试寻找更大的距离
            ans = mid;
            start = mid + 1;
        } else {
            // 如果mid距离不可行,必须减小距离
            end = mid - 1;
        }
    }
    return ans;
}

时间复杂度分析

  • 排序:O(n log n)
  • 二分查找:O(log R),其中 R 是牛棚坐标的范围 (end - start)。
  • 每次二分查找中的 isPossible 函数:O(n)
  • 总时间复杂度为 O(n log n + n log R),通常简化为 O(n log n),因为排序是主要开销。

空间复杂度O(1),除了输入数组,只使用了常数级别的额外空间。

模式总结与相关题目

本节课中我们一起学习了“暴躁的牛问题”及其二分查找解法。这是一个经典问题模式的代表。

我们注意到,这类问题的通用模式是寻找最小值的最大值最大值的最小值。解题框架固定:

  1. 确定答案的搜索范围 [start, end]
  2. 设计一个 isPossible(mid) 函数,用于判断 mid 值是否是一个可行的候选答案。
  3. 使用二分查找在范围内寻找满足条件的最优 mid 值。

掌握这个模式后,你可以解决一系列类似问题:

  • 书籍分配问题:将 n 本书分给 m 个学生,使每个学生获得的总页数最大值最小化。
  • 画家分区问题:将 n 块木板分给 k 个画家,使完成所有工作的总时间最小化。
  • 最小化最大负载问题等。

它们的核心都是二分答案 + 贪心验证

总结

在本节课中,我们深入探讨了“暴躁的牛问题”。

  1. 我们明确了问题的目标是找到可以安置所有牛时,两头牛之间最大的最小距离
  2. 我们将问题转化为对“最小距离”进行二分查找,并设计了 isPossible 函数来验证某个距离是否可行。
  3. 我们实现了完整的C++代码,并分析了其时间复杂度和空间复杂度。
  4. 最后,我们总结了这类“最小化最大值”或“最大化最小值”问题的通用二分查找模式。

你可以尝试在在线判题系统上提交此代码进行练习。继续学习和探索吧!😊

024:排序算法基础

概述

在本节课中,我们将学习三种基础的排序算法:冒泡排序、选择排序和插入排序。这些算法是理解更复杂排序技术的基础,并且在面试中经常被考察。我们将通过简单的例子和代码来理解它们的工作原理。


排序简介

排序是将一组数据按照特定顺序(如升序或降序)进行排列的过程。

例如,对于一组随机数字 [5, 2, 3, 1],排序后可以得到升序结果 [1, 2, 3, 5] 或降序结果 [5, 3, 2, 1]


冒泡排序 🫧

上一节我们介绍了排序的基本概念,本节中我们来看看第一种排序算法——冒泡排序。它的核心思想是重复地遍历要排序的列表,一次比较两个相邻元素,如果它们的顺序错误就把它们交换过来。

算法逻辑

  1. 从第一个元素开始,比较相邻的两个元素。
  2. 如果第一个比第二个大(对于升序排序),就交换它们。
  3. 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。这步做完后,最大的元素会“冒泡”到数列的末尾。
  4. 针对所有的元素重复以上的步骤,除了最后一个。
  5. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

以下是冒泡排序的伪代码逻辑:

for i from 0 to n-2:
    for j from 0 to n-i-2:
        if array[j] > array[j+1]:
            swap(array[j], array[j+1])

代码实现

void bubbleSort(int arr[], int n) {
    for (int i = 0; i < n-1; i++) {
        for (int j = 0; j < n-i-1; j++) {
            if (arr[j] > arr[j+1]) {
                swap(arr[j], arr[j+1]);
            }
        }
    }
}

时间复杂度分析

外层循环运行 n 次,内层循环平均也运行 n 次。因此,冒泡排序的时间复杂度是 O(n²)

优化

可以引入一个布尔变量来记录某次遍历中是否发生了交换。如果一次完整的遍历中没有发生任何交换,说明数组已经有序,可以提前终止排序。

void optimizedBubbleSort(int arr[], int n) {
    bool swapped;
    for (int i = 0; i < n-1; i++) {
        swapped = false;
        for (int j = 0; j < n-i-1; j++) {
            if (arr[j] > arr[j+1]) {
                swap(arr[j], arr[j+1]);
                swapped = true;
            }
        }
        if (swapped == false) // 如果没有发生交换,提前结束
            break;
    }
}

选择排序 ⚙️

理解了冒泡排序后,我们来看选择排序。选择排序的思路是将数组分为“已排序”和“未排序”两部分,每次从未排序部分中找到最小(或最大)的元素,将其放到已排序部分的末尾。

算法步骤

  1. 在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
  2. 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  3. 重复第二步,直到所有元素均排序完毕。

以下是寻找最小元素并交换的核心逻辑:

for i from 0 to n-2:
    minIndex = i
    for j from i+1 to n-1:
        if array[j] < array[minIndex]:
            minIndex = j
    swap(array[i], array[minIndex])

代码实现

void selectionSort(int arr[], int n) {
    for (int i = 0; i < n-1; i++) {
        int minIndex = i; // 假设当前索引 i 的元素是最小的
        for (int j = i+1; j < n; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j; // 更新最小元素的索引
            }
        }
        swap(arr[i], arr[minIndex]); // 将找到的最小元素与位置 i 的元素交换
    }
}

时间复杂度分析

和冒泡排序一样,选择排序也需要两层嵌套循环,因此其时间复杂度也是 O(n²)


插入排序 🃏

学习了前两种基于比较和交换的排序后,本节我们学习插入排序。它的灵感来源于整理扑克牌:每次将一张新牌插入到手中已经排好序的牌堆中的正确位置。

算法过程

  1. 将第一个元素视为已排序序列。
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描。
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置。
  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置。
  5. 将新元素插入到该位置后。
  6. 重复步骤2~5。

以下是插入排序的核心逻辑描述:

for i from 1 to n-1:
    key = array[i]
    j = i - 1
    while j >= 0 and array[j] > key:
        array[j+1] = array[j]
        j = j - 1
    array[j+1] = key

代码实现

void insertionSort(int arr[], int n) {
    for (int i = 1; i < n; i++) {
        int key = arr[i]; // 当前需要插入的元素
        int j = i - 1; // 已排序部分的最后一个索引

        // 将大于 key 的元素向后移动
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key; // 将 key 插入到正确位置
    }
}

时间复杂度分析

在最坏情况下(数组完全逆序),插入排序的时间复杂度是 O(n²)。但在数组近乎有序时,它的效率很高,接近 O(n)


算法对比与总结

本节课中我们一起学习了三种基础的排序算法。

  • 冒泡排序:通过不断交换相邻逆序元素来排序。实现简单,但效率较低。
  • 选择排序:每次选择未排序部分的最小元素放到已排序部分末尾。交换次数少。
  • 插入排序:像整理扑克牌一样,将每个新元素插入到已排序部分的正确位置。在数据近乎有序时效率高。

这三种算法的时间复杂度都是 O(n²),属于基础排序算法,适用于小规模数据或作为理解更高效算法(如归并排序、快速排序,时间复杂度为 O(n log n))的入门阶梯。

要按降序排列,只需在比较时改变条件(例如,将 > 改为 <)。掌握这些基础算法是迈向解决更复杂数据结构与算法问题的第一步。

025:排列0、1和2的数组 - DNF排序算法 - Leetcode 75

在本节课中,我们将学习如何解决LeetCode第75题“颜色分类”。这是一个经典的数组排序问题,要求我们仅包含0、1、2的数组进行原地排序。我们将探讨多种解决方案,并重点学习一种名为“荷兰国旗问题”的高效算法。

问题概述

假设我们有一个数组,其元素只包含数字0、1和2。我们的目标是在不使用库函数排序的情况下,对这个数组进行原地排序。

一种简单的暴力方法是直接使用库排序函数。但我们将探索更优的解法。

方法一:计数排序

首先,我们来看一种略微优化的方法。其思路是遍历数组,分别统计0、1和2的个数。

以下是实现步骤:

  1. 初始化三个计数器:count0count1count2
  2. 遍历数组,根据元素值增加对应计数器的值。
  3. 根据计数器的值,按顺序(先0,再1,最后2)重新填充数组。

对应的伪代码如下:

int count0 = 0, count1 = 0, count2 = 0;
for(int num : nums) {
    if(num == 0) count0++;
    else if(num == 1) count1++;
    else count2++;
}
int index = 0;
while(count0-- > 0) nums[index++] = 0;
while(count1-- > 0) nums[index++] = 1;
while(count2-- > 0) nums[index++] = 2;

这种方法需要遍历数组两次,时间复杂度为O(n),空间复杂度为O(1)。虽然可以成功提交,但问题要求我们尝试仅用一次遍历和常数空间完成。

方法二:荷兰国旗算法 🚩

接下来,我们介绍最优解——荷兰国旗算法。这是一个经典的“三向切分”算法,仅用一次遍历和常数额外空间。

算法核心思想

我们使用三个指针来维护数组的四个逻辑分区:

  • low指针:指向最后一个0之后的位置。
  • mid指针:用于遍历数组,处理当前元素。
  • high指针:指向第一个2之前的位置。

初始时,lowmid指向数组开头,high指向数组末尾。mid指针从左向右移动,根据其指向的元素值进行不同的操作,直到mid超过high

算法步骤详解

以下是算法的具体规则,根据nums[mid]的值分三种情况处理:

  1. 如果 nums[mid] == 0

    • nums[mid]nums[low]交换。
    • low指针和mid指针都向右移动一位。
    • 这样确保了low左侧(不含low)的元素都是0。
  2. 如果 nums[mid] == 1

    • 这是正确的位置,只需将mid指针向右移动一位。
    • lowhigh指针保持不变。
  3. 如果 nums[mid] == 2

    • nums[mid]nums[high]交换。
    • high指针向左移动一位。
    • 注意:此时mid指针不移动,因为从high位置交换过来的元素尚未检查。

算法伪代码

int low = 0, mid = 0, high = nums.size() - 1;
while(mid <= high) {
    if(nums[mid] == 0) {
        swap(nums[low], nums[mid]);
        low++;
        mid++;
    } else if(nums[mid] == 1) {
        mid++;
    } else { // nums[mid] == 2
        swap(nums[mid], nums[high]);
        high--;
    }
}

算法演示

假设初始数组为 [2, 0, 2, 1, 1, 0]

  • 初始:low=0, mid=0, high=5。数组状态:[2,0,2,1,1,0]
  • nums[mid]=2,与nums[high]交换,high--。数组状态:[0,0,2,1,1,2]
  • nums[mid]=0,与nums[low]交换,low++,mid++。数组状态:[0,0,2,1,1,2]
  • nums[mid]=0,与nums[low]交换,low++,mid++。数组状态:[0,0,2,1,1,2]
  • nums[mid]=2,与nums[high]交换,high--。数组状态:[0,0,1,1,2,2]
  • nums[mid]=1,mid++。
  • nums[mid]=1,mid++。此时mid > high,循环结束。

最终得到排序后的数组 [0, 0, 1, 1, 2, 2]

复杂度分析

荷兰国旗算法的时间复杂度是O(n),因为midhigh指针总共移动的距离之和是n。空间复杂度是O(1),因为我们只使用了三个指针变量。这是一种非常高效的原位排序算法。

总结

本节课我们一起学习了LeetCode第75题“颜色分类”的解决方案。

  1. 我们首先介绍了基于计数的两遍扫描方法。
  2. 然后,我们深入探讨了最优的荷兰国旗算法。该算法利用三个指针,在一次遍历内将数组划分为0、1、2三个区域,实现了时间O(n)和空间O(1)的高效排序。
  3. 掌握这个算法对于理解双指针、三向切分等思想非常有帮助,是解决类似分区问题的经典模板。

026:合并有序数组与下一个排列问题

概述

在本节课中,我们将学习两个经典的数组操作问题:合并两个有序数组寻找下一个排列。我们将详细讲解问题的核心思路、解题步骤,并提供清晰的代码实现。


合并两个有序数组

上一节我们介绍了数组的基本操作,本节中我们来看看如何高效地合并两个有序数组。

问题描述

给定两个按非递减顺序排列的整数数组 nums1nums2,以及两个整数 mn,分别表示 nums1nums2 中的元素数目。需要将 nums2 合并到 nums1 中,使合并后的数组同样按非递减顺序排列。nums1 的长度为 m + n,其中前 m 个元素是有效元素,后 n 个元素被初始化为 0,用于容纳 nums2 的元素。

核心思路

直接从前向后合并需要移动大量元素,效率低下。一个更高效的方法是从后向前填充 nums1。我们比较两个数组末尾的有效元素,将较大的那个放到 nums1 的末尾,然后向前移动指针。

以下是解题步骤:

  1. 初始化三个指针:
    • i 指向 nums1 的最后一个有效元素(索引 m-1)。
    • j 指向 nums2 的最后一个元素(索引 n-1)。
    • k 指向 nums1 数组的最后一个位置(索引 m+n-1)。
  2. i >= 0j >= 0 时,循环比较 nums1[i]nums2[j]
    • 如果 nums1[i] >= nums2[j],将 nums1[i] 放到 nums1[k],然后 i--k--
    • 否则,将 nums2[j] 放到 nums1[k],然后 j--k--
  3. 循环结束后,如果 nums2 中还有剩余元素(即 j >= 0),则将这些剩余元素依次复制到 nums1 的前部。

代码实现

void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
    int i = m - 1; // nums1 有效元素末尾
    int j = n - 1; // nums2 末尾
    int k = m + n - 1; // 合并后数组的末尾

    // 从后向前比较并填充
    while (i >= 0 && j >= 0) {
        if (nums1[i] >= nums2[j]) {
            nums1[k] = nums1[i];
            i--;
        } else {
            nums1[k] = nums2[j];
            j--;
        }
        k--;
    }

    // 如果 nums2 还有剩余元素,直接复制到 nums1 前面
    while (j >= 0) {
        nums1[k] = nums2[j];
        j--;
        k--;
    }
}

复杂度分析

  • 时间复杂度O(m + n)。我们最多遍历 nums1nums2 各一次。
  • 空间复杂度O(1)。我们只使用了常数级别的额外空间。

下一个排列

理解了数组合并后,我们来看一个更抽象的排列问题:如何找到给定序列的下一个字典序排列。

问题描述

实现获取数组 下一个排列 的函数。算法需要将给定数字序列重新排列成字典序中下一个更大的排列。如果不存在下一个更大的排列,则必须将数字重新排列成最小的排列(即升序排列)。

核心思路与步骤

寻找下一个排列有一个标准的三步法:

  1. 寻找“转折点”(Pivot):从数组末尾开始向前扫描,找到第一个满足 nums[i] < nums[i+1] 的索引 i。这个位置 i 就是需要被替换的“转折点”。如果找不到这样的 i,说明整个数组是降序排列,已经是最大排列,直接跳到第3步进行整体反转即可。
  2. 寻找交换元素:再次从数组末尾开始向前扫描,找到第一个大于 nums[i] 的元素 nums[j]。将 nums[i]nums[j] 交换。
  3. 反转后缀:将位置 i 之后的所有元素(从 i+1 到末尾)反转。因为 i 之后的序列原本是降序的,反转后变为升序,从而得到最小的下一个排列。

代码实现

void nextPermutation(vector<int>& nums) {
    int n = nums.size();
    int pivot = -1;

    // 步骤1:寻找转折点 (pivot)
    for (int i = n - 2; i >= 0; i--) {
        if (nums[i] < nums[i + 1]) {
            pivot = i;
            break;
        }
    }

    // 如果没找到转折点(整个数组降序),直接反转整个数组
    if (pivot == -1) {
        reverse(nums.begin(), nums.end());
        return;
    }

    // 步骤2:寻找大于 nums[pivot] 的最小元素并交换
    for (int i = n - 1; i > pivot; i--) {
        if (nums[i] > nums[pivot]) {
            swap(nums[i], nums[pivot]);
            break;
        }
    }

    // 步骤3:反转 pivot 之后的序列
    reverse(nums.begin() + pivot + 1, nums.end());
}

复杂度分析

  • 时间复杂度O(n)。我们最多对数组进行三次线性扫描。
  • 空间复杂度O(1)。所有操作都在原数组上进行。

总结

本节课中我们一起学习了两个重要的数组算法:

  1. 合并两个有序数组:通过从后向前的双指针法,在 O(m+n) 时间复杂度和 O(1) 空间复杂度内高效完成合并。
  2. 下一个排列:掌握了寻找字典序下一个排列的标准三步法:找转折点、找交换元素、反转后缀。这个方法高效且通用,是解决此类问题的关键。

理解并掌握这两个问题的解法,对于处理数组相关的面试题和算法挑战非常有帮助。

027:C++ STL完整教程 🚀

在本节课中,我们将深入学习C++标准模板库。STL是一个强大的库,提供了现成的数据结构和算法。使用STL,我们可以避免重复造轮子,例如,无需自己实现排序算法,直接使用 sort() 函数即可,这能节省大量时间。因此,STL在实践编码、求职面试和在线评测中至关重要。

STL主要包含四个部分:容器算法迭代器函数对象。本教程将重点讲解容器和算法,并涵盖迭代器及一些实用的函数对象。

向量容器 📦

上一节我们介绍了STL的概览,本节中我们来看看第一个也是最重要的容器:向量

向量类似于动态数组。普通数组的大小是固定的,而向量可以在运行时动态调整大小。创建一个空向量的语法很简单:vector<int> v;。初始时,其大小为0。

我们可以使用 size() 函数获取向量当前元素的数量:v.size()

向向量添加元素的主要方法是 push_back() 函数。例如,v.push_back(1); 会将元素1添加到向量末尾。

向量有两个关键属性:

  • 大小:当前包含的元素数量。
  • 容量:在必须重新分配内存之前,向量可以容纳的元素总数。容量通常会以特定策略(如翻倍)增长。

我们可以使用 capacity() 函数查看当前容量。

以下是向量的基本操作示例:

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

int main() {
    vector<int> v; // 创建一个空向量
    cout << "初始大小: " << v.size() << endl; // 输出 0
    cout << "初始容量: " << v.capacity() << endl; // 输出 0

    v.push_back(1);
    v.push_back(2);
    v.push_back(3);
    cout << "添加元素后大小: " << v.size() << endl; // 输出 3
    cout << "添加元素后容量: " << v.capacity() << endl; // 输出 4

    // 使用范围for循环遍历向量
    for(int value : v) {
        cout << value << " ";
    }
    cout << endl; // 输出: 1 2 3

    return 0;
}

向量还提供了其他常用成员函数:

  • pop_back(): 删除最后一个元素。
  • at(index)[index]: 访问特定索引处的元素(at会进行边界检查)。
  • front(): 获取第一个元素。
  • back(): 获取最后一个元素。
  • clear(): 清空所有元素。
  • empty(): 检查向量是否为空。

初始化向量有多种方式:

vector<int> v1 = {1, 2, 3, 4, 5}; // 初始化列表
vector<int> v2(5, 10); // 创建包含5个元素,每个元素值都是10的向量
vector<int> v3(v1.begin(), v1.end()); // 通过另一个向量的迭代器范围初始化

需要注意的是,在向量中间进行 insert()erase() 操作代价较高,因为可能涉及大量元素的移动。而 push_back()pop_back() 通常是常数时间复杂度。

列表与双端队列 🔄

了解了动态数组(向量)后,我们来看看其他顺序容器:列表和双端队列。

列表 在内部实现为双向链表。因此,它支持高效的 front 和 back 插入删除操作。

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

int main() {
    list<int> l;
    l.push_back(1);
    l.push_back(2);
    l.push_front(3); // 在头部插入
    // 列表内容: 3 -> 1 -> 2

    for(int val : l) {
        cout << val << " ";
    }
    cout << endl;

    l.pop_front(); // 删除头部元素 3
    l.pop_back();  // 删除尾部元素 2
    // 列表剩余: 1
    return 0;
}

列表不支持像数组那样的随机访问(即通过索引直接访问)。

双端队列 结合了向量和列表的一些特性,支持两端的快速插入删除,并且支持随机访问

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

int main() {
    deque<int> dq;
    dq.push_back(1);
    dq.push_front(2);
    dq.push_back(3);
    // 队列内容: [2, 1, 3]

    cout << "第二个元素是: " << dq[1] << endl; // 输出 1,支持随机访问
    return 0;
}

对组与栈、队列 🧱

现在我们来学习几种实用的数据结构和工具:对组、栈和队列。

对组 用于将两个值组合成一个单一对象,非常实用。

#include <iostream>
#include <utility> // 包含 pair
using namespace std;

int main() {
    pair<string, int> p = make_pair("Alice", 25);
    // 或者直接初始化: pair<string, int> p("Alice", 25);
    cout << p.first << " is " << p.second << " years old." << endl;

    // 对组可以嵌套
    pair<int, pair<char, double>> nestedPair = {1, {'A', 3.14}};
    cout << nestedPair.first << " " << nestedPair.second.first << endl;
    return 0;
}

是一种后进先出的数据结构。

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

int main() {
    stack<int> s;
    s.push(10);
    s.push(20);
    s.push(30);

    cout << "栈顶元素: " << s.top() << endl; // 输出 30
    s.pop(); // 弹出30
    cout << "弹出后栈顶元素: " << s.top() << endl; // 输出 20
    cout << "栈大小: " << s.size() << endl; // 输出 2
    return 0;
}

队列 是一种先进先出的数据结构。

#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(); // 移除10
    cout << "弹出后队首元素: " << q.front() << endl; // 输出 20
    return 0;
}

优先队列与关联容器 🗺️

本节我们将探讨两种强大的容器:能自动排序的优先队列,以及用于快速查找的关联容器。

优先队列 默认是一个最大堆,队首元素始终是当前最大的元素。

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

int main() {
    // 最大优先队列(默认)
    priority_queue<int> maxHeap;
    maxHeap.push(5);
    maxHeap.push(1);
    maxHeap.push(10);
    cout << "最大优先队列顶部: " << maxHeap.top() << endl; // 输出 10

    // 最小优先队列
    priority_queue<int, vector<int>, greater<int>> minHeap;
    minHeap.push(5);
    minHeap.push(1);
    minHeap.push(10);
    cout << "最小优先队列顶部: " << minHeap.top() << endl; // 输出 1
    return 0;
}

映射 存储键值对,键是唯一的,并且默认按键升序排序。

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

int main() {
    map<string, int> inventory;
    inventory["Apple"] = 50;
    inventory["Banana"] = 30;
    inventory["Orange"] = 20;

    // 遍历映射(按键排序)
    for(auto& item : inventory) {
        cout << item.first << ": " << item.second << endl;
    }
    // 输出:
    // Apple: 50
    // Banana: 30
    // Orange: 20

    // 查找键
    if(inventory.find("Banana") != inventory.end()) {
        cout << "找到香蕉,库存为: " << inventory["Banana"] << endl;
    }
    return 0;
}

映射的插入、删除、查找操作时间复杂度通常是 O(log n)

无序映射 与映射类似,但不保证顺序,其操作平均时间复杂度为 O(1)

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

int main() {
    unordered_map<string, int> quickLookup;
    quickLookup["zebra"] = 1;
    quickLookup["apple"] = 2;
    quickLookup["mango"] = 3;
    // 元素存储顺序不确定
    for(auto& p : quickLookup) cout << p.first << " ";
    return 0;
}

集合 存储唯一元素,并自动排序。无序集合 存储唯一元素但不排序。

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

int main() {
    set<int> orderedSet = {5, 2, 8, 2, 1}; // 插入 5,2,8,1
    for(int num : orderedSet) cout << num << " "; // 输出: 1 2 5 8

    unordered_set<int> unorderedSet = {5, 2, 8, 2, 1}; // 插入 5,2,8,1
    // 输出顺序不确定
    return 0;
}

STL算法 ⚙️

掌握了主要容器后,我们来看看STL提供的强大通用算法,它们通过迭代器作用于容器。

排序算法 是最常用的算法之一。

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

int main() {
    vector<int> nums = {5, 3, 1, 4, 2};
    sort(nums.begin(), nums.end()); // 默认升序排序
    for(int n : nums) cout << n << " "; // 输出: 1 2 3 4 5
    cout << endl;

    // 降序排序
    sort(nums.begin(), nums.end(), greater<int>());
    for(int n : nums) cout << n << " "; // 输出: 5 4 3 2 1
    cout << endl;

    // 对自定义类型(如pair)排序
    vector<pair<int, int>> points = {{1,3}, {2,2}, {1,2}};
    // 默认按first排序,再按second排序
    sort(points.begin(), points.end());
    return 0;
}

自定义排序规则 可以通过定义比较函数来实现。

bool sortBySecond(const pair<int,int> &a, const pair<int,int> &b) {
    if(a.second == b.second) {
        return a.first < b.first; // 如果second相等,按first升序
    }
    return a.second < b.second; // 主要按second升序
}
// 使用: sort(points.begin(), points.end(), sortBySecond);

其他常用算法包括:

  • reverse(): 反转序列。
  • next_permutation(): 生成下一个排列。
  • max_element(), min_element(): 查找最大/最小元素。
  • binary_search(): 在已排序范围中进行二分查找。
  • swap(): 交换两个值。
// 算法示例
vector<int> v = {1,2,3,4,5};
reverse(v.begin(), v.end()); // v变为 {5,4,3,2,1}

auto maxIt = max_element(v.begin(), v.end());
cout << "最大值是: " << *maxIt << endl;

bool found = binary_search(v.begin(), v.end(), 3); // 返回 true

总结 🎯

本节课中我们一起深入学习了C++标准模板库的核心内容。我们从向量这个动态数组开始,逐步了解了列表双端队列等顺序容器,以及对组队列等适配器。接着,我们探索了强大的关联容器,包括自动排序的映射集合及其无序版本。最后,我们学习了STL的通用算法,如排序、查找和反转,它们通过迭代器与容器协作,极大地提升了代码效率和简洁性。

STL是C++编程的利器,熟练掌握它能让你在解决数据结构和算法问题时事半功倍。请务必动手实践每个示例代码,加深理解。

028:如何在Mac上设置C++编译器 🍎

在本节课中,我们将学习如何在Mac系统上安装和配置C++编译器,以便能够运行和编译C++代码。整个过程主要涉及通过终端安装命令行开发者工具。

概述

要在Mac上编写和运行C++程序,我们需要一个编译器。最便捷的方式是安装Xcode Command Line Tools,它包含了必要的编译工具,如g++clang++

安装步骤

以下是安装C++开发环境的详细步骤。

1. 打开终端

首先,我们需要打开“终端”应用程序。你可以在“应用程序”文件夹的“实用工具”中找到它,或者使用Spotlight搜索(按下 Command + 空格键,然后输入“终端”)。

2. 安装命令行工具

在终端中,输入以下命令来触发命令行工具的安装:

xcode-select --install

执行此命令后,系统会弹出一个软件更新对话框。

3. 同意许可并开始安装

在弹出的对话框中,点击“安装”按钮以同意许可协议并开始下载安装程序。安装过程可能需要一些时间,具体时长取决于你的网络速度。

4. 验证安装

安装完成后,为了验证工具是否已成功安装,我们可以再次运行安装命令,或者检查编译器版本。在终端中输入:

g++ --version

或者

clang++ --version

如果命令返回了编译器的版本信息,例如 Apple clang version 14.0.0...,则表明安装成功。

总结

本节课中,我们一起学习了在Mac系统上设置C++编译环境。我们通过终端使用 xcode-select --install 命令安装了Xcode命令行工具,并验证了安装结果。现在,你的Mac已经准备好编译和运行C++程序了。希望你在观看本视频后能成功完成设置。请持续学习,不断探索。

029:C++中的字符串和字符数组 - 第一部分 🧵

在本节课中,我们将要学习C++编程中一个核心且重要的概念:字符串。我们将从基础开始,理解什么是字符串,它与字符数组的关系,并学习如何声明、初始化和操作它们。

概述

字符串是编程中用于表示文本数据的基本结构。在C++中,处理字符串主要有两种方式:使用字符数组和使用字符串类。本节课程将首先介绍字符数组,它是C语言风格的字符串表示方法,也是理解C++字符串类的基础。

字符数组:C语言风格的字符串

上一节我们介绍了数组的概念,本节中我们来看看如何用数组来存储文本,即字符数组。

在编程中,如果某些内容被双引号 " " 包围,它就是一个字符串。例如,"Hello" 就是一个字符串。字符串本质上是一个字符序列。

字符数组的表示

字符数组用于存储字符串。它是一系列字符的集合,以特殊的空字符 \0 结尾。这个空字符标志着字符串的结束。

代码示例:声明字符数组

char arr[100]; // 声明一个可以存储最多99个字符和1个空字符的数组

即使我们只写了一个单词,比如 "Hello",它在内存中实际上存储为 'H', 'e', 'l', 'l', 'o', '\0'。最后一个字符 \0 就是空字符。

字符数组的初始化与输出

我们可以像初始化普通数组一样初始化字符数组。

代码示例:初始化与输出

char arr[] = {'H', 'e', 'l', 'l', 'o', '\0'}; // 手动添加空字符
char arr2[] = "Hello"; // 编译器会自动添加空字符
cout << arr2; // 输出: Hello

需要注意的是,当我们用 cout 打印字符数组名时,它会从数组起始地址开始打印,直到遇到空字符 \0 为止。如果数组中没有 \0cout 会继续打印后面的内存内容,直到偶然遇到一个 \0,这会导致输出乱码。

字符数组的输入

从用户那里获取字符串输入也很简单。

代码示例:输入字符串

char str[100];
cout << "Enter a string: ";
cin >> str; // 输入字符串,但遇到空格会停止
cout << "You entered: " << str;

使用 cin 输入字符串时有一个限制:它会在遇到空格、制表符或换行符时停止读取。因此,它只能读取一个单词。

为了读取包含空格的整行文本,我们可以使用 cin.getline() 函数。

代码示例:使用 cin.getline()

char sentence[200];
cout << "Enter a sentence: ";
cin.getline(sentence, 200); // 读取一行,最多199个字符(留一个给\0)
cout << "Sentence is: " << sentence;

遍历字符数组

我们可以使用循环来逐个访问字符数组中的每个字符。

以下是遍历字符数组的两种常用方法:

方法一:使用 for 循环

char str[] = "Hello";
int length = 0;
for(int i = 0; str[i] != '\0'; i++) {
    cout << str[i] << " ";
    length++;
}
cout << "\nLength of string is: " << length;

方法二:使用 for-each 循环

char str[] = "Apna College";
for(char ch : str) {
    if(ch == '\0') break; // 遇到空字符时停止
    cout << ch << " ";
}

字符串类:C++风格的字符串

字符数组虽然基础,但操作起来有时不够方便,例如不能直接使用 + 进行拼接。C++提供了一个更强大的工具:string

string 是一个类(对象),它封装了字符数组,并提供了许多便捷的操作方法。它是动态的,意味着其大小可以根据需要自动调整。

字符串的声明与操作

代码示例:基本操作

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

int main() {
    string str1 = "Apna";
    string str2 = "College";
    
    // 1. 输出
    cout << str1 << endl;
    
    // 2. 拼接
    string str3 = str1 + " " + str2;
    cout << str3 << endl; // 输出: Apna College
    
    // 3. 比较
    if(str1 == "Apna") {
        cout << "Strings are equal" << endl;
    }
    
    // 4. 获取长度
    cout << "Length of str3: " << str3.length() << endl;
    
    return 0;
}

字符串的输入与遍历

对于 string 类,使用 getline(cin, str) 来读取包含空格的整行输入。

代码示例:输入与遍历

string str;
cout << "Enter your full name: ";
getline(cin, str); // 读取整行
cout << "Hello, " << str << "!" << endl;

// 遍历字符串
for(int i = 0; i < str.length(); i++) {
    cout << str[i] << "-";
}
cout << endl;

// 使用 for-each 循环
for(char ch : str) {
    cout << ch << " ";
}

实践练习:反转字符串

让我们解决一个基于字符串的简单问题:反转一个字符串。

问题描述:给定一个字符串,将其字符顺序反转。例如,"Hello" 反转为 "olleH"

解题思路:使用两个指针,一个从字符串开头(start)向后移动,一个从字符串末尾(end)向前移动,交换它们指向的字符,直到两个指针相遇。

代码实现

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

int main() {
    string s = "ApnaCollege";
    
    int start = 0;
    int end = s.length() - 1;
    
    while(start < end) {
        // 交换字符
        swap(s[start], s[end]);
        start++;
        end--;
    }
    
    cout << "Reversed string: " << s << endl; // 输出: egelloClanpA
    return 0;
}

C++的 string 类甚至内置了 reverse() 函数,可以更简洁地完成这个任务。

代码示例:使用 reverse() 函数

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

int main() {
    string str = "College";
    reverse(str.begin(), str.end());
    cout << str << endl; // 输出: egelloC
    return 0;
}

总结

本节课中我们一起学习了C++中字符串处理的基础知识:

  1. 字符数组:C语言风格的字符串,以空字符 \0 结尾,需要手动管理内存和边界。
  2. 字符串类 (string):C++风格的字符串,是一个类对象,动态管理内存,支持拼接、比较等直观操作,使用起来更加方便安全。

我们学习了如何声明、初始化、输入输出以及遍历这两种形式的字符串,并通过反转字符串的例子进行了实践。

课后作业

请尝试解决以下问题,以巩固所学知识:

问题:判断一个字符串是否是回文串。
描述:回文串是指正着读和反着读都一样的字符串,例如 "racecar""madam"
你的任务:编写一个函数 bool isPalindrome(string s),如果 s 是回文串则返回 true,否则返回 false

提示:你可以使用本节课中学到的双指针技巧,或者利用 reverse() 函数。

如果你成功完成了今天的课程,可以在学习记录中标记你的进度。继续探索,加油!

030:有效回文与移除所有出现 - 字符串第二部分

在本节课中,我们将学习两个重要的字符串问题:有效回文移除所有出现。我们将通过具体的代码示例和逻辑分析,帮助你掌握解决这类问题的核心技巧。

有效回文

上一节我们介绍了字符串的基础操作,本节中我们来看看如何判断一个字符串是否是有效回文。有效回文是指一个字符串在忽略非字母数字字符、忽略大小写后,正读和反读都相同。

以下是解决此问题的核心逻辑步骤:

  1. 使用两个指针,一个从字符串开头(start)向后移动,另一个从字符串末尾(end)向前移动。
  2. 在指针移动过程中,跳过所有非字母数字字符。
  3. 将指针指向的字符转换为小写后进行比较。
  4. 如果所有对应的字符都匹配,则字符串是有效回文。

以下是实现此逻辑的伪代码框架:

bool isPalindrome(string s) {
    int start = 0;
    int end = s.length() - 1;
    
    while(start <= end) {
        // 跳过 start 指针的非字母数字字符
        while(start < end && !isalnum(s[start])) {
            start++;
        }
        // 跳过 end 指针的非字母数字字符
        while(start < end && !isalnum(s[end])) {
            end--;
        }
        // 转换为小写后比较
        if(tolower(s[start]) != tolower(s[end])) {
            return false;
        }
        start++;
        end--;
    }
    return true;
}

其中,isalnum 函数用于判断一个字符是否是字母或数字。其逻辑可以用以下条件判断:

公式: 字符 c 是字母数字的条件是:
(c >= ‘0’ && c <= ‘9’) (tolower(c) >= ‘a’ && tolower(c) <= ‘z’)

这个算法的时间复杂度是 O(n),其中 n 是字符串的长度,因为我们只遍历了字符串一次。

移除所有出现

接下来,我们看第二个问题:从一个主字符串中移除所有给定的子字符串。例如,从 “daabcbaabcbc” 中移除所有 “abc”。

解决这个问题的思路是重复查找并删除子串,直到主串中不再包含该子串为止。

以下是解决此问题的步骤:

  1. 在主字符串 s 中查找子串 part 第一次出现的位置。
  2. 如果找到了(即位置索引有效),则从该位置开始,删除长度为 part.length() 的字符。
  3. 重复步骤1和2,直到 s 中再也找不到 part 为止。

C++ 的 string 类提供了两个非常实用的成员函数:

  • find(): 查找子串位置,返回其起始索引,若未找到则返回 string::npos
  • erase(): 从指定位置开始删除一定长度的字符。

以下是利用这两个函数的实现代码:

string removeOccurrences(string s, string part) {
    while(s.length() != 0 && s.find(part) < s.length()) {
        // 找到子串位置并删除
        s.erase(s.find(part), part.length());
    }
    return s;
}

代码解释:

  • s.find(part) 返回子串 parts 中第一次出现的起始索引。
  • s.erase(pos, len) 从索引 pos 开始,删除 len 个字符。
  • while 循环会持续执行,直到 s 中不再包含 part 子串。

总结

本节课中我们一起学习了两个字符串处理问题。

  1. 有效回文:我们学会了使用双指针技巧,在跳过非字母数字字符并统一大小写后,比较字符串的首尾字符。
  2. 移除所有出现:我们掌握了如何利用 find()erase() 函数,循环查找并删除字符串中所有指定的子串。

理解并熟练运用这些基础算法和字符串库函数,是解决更复杂数据结构与算法问题的关键。

031:字符串的排列

在本节课中,我们将学习一个重要的字符串问题:字符串的排列。我们将讨论其核心概念,并通过一个滑动窗口的解决方案来检查一个字符串的排列是否存在于另一个字符串中。课程内容简单直白,适合初学者理解。

概述

字符串的排列是指由相同字符组成的不同顺序的排列。例如,字符串 “ABC” 的排列包括 “ABC”, “ACB”, “BAC”, “BCA”, “CAB”, “CBA”。本节课的核心问题是:给定两个字符串 s1s2,判断 s2 是否包含 s1 的排列。

解决这个问题的关键在于比较字符的频率。如果 s2 的某个子串的字符频率与 s1 的字符频率完全相同,那么这个子串就是 s1 的一个排列。

核心概念与约束

首先,我们需要理解问题的约束条件。题目说明字符串仅由小写英文字母组成。这个约束非常关键,因为它允许我们使用一个固定大小的数组来高效地统计字符频率。

我们可以创建一个大小为 26 的整数数组,因为英文字母有26个。数组的每个索引对应一个字母(例如,索引0对应’a’,索引1对应’b’,依此类推)。数组的值存储对应字符出现的次数,即频率

字符到索引的转换公式
对于一个字符 ch,其对应的数组索引可以通过 ch - 'a' 计算得出。因为小写字母’a’到’z’在ASCII码表中是连续的,所以 'a' - 'a' = 0, 'b' - 'a' = 1,以此类推。

解决方案步骤

上一节我们介绍了使用频率数组的核心思想,本节中我们来看看具体的解决步骤。我们将采用滑动窗口的方法在 s2 中寻找与 s1 频率匹配的子串。

以下是解决此问题的详细步骤:

  1. 计算 s1 的频率:首先,遍历字符串 s1,统计其中每个字符出现的次数,并存储到频率数组 countS1 中。
  2. 初始化滑动窗口:在字符串 s2 上设置一个窗口,其大小等于 s1 的长度。
  3. 计算初始窗口的频率:计算 s2 中第一个窗口(从索引0开始,长度为 s1.length())的字符频率,存储到另一个数组 countWindow 中。
  4. 比较频率:编写一个辅助函数 isFrequencyEqual,用于比较 countS1countWindow 两个频率数组是否完全相同。
  5. 滑动窗口:如果当前窗口的频率不匹配,则将窗口向右滑动一位。这意味着我们需要更新 countWindow:减少离开窗口的字符频率,增加新进入窗口的字符频率。
  6. 检查匹配:每次更新窗口后,再次使用 isFrequencyEqual 函数比较频率。如果匹配,则返回 true。如果遍历完整个 s2 都没有找到匹配,则返回 false

代码实现

让我们将上述逻辑转化为代码。以下是一个简单的C++函数实现:

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

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        if (s1.length() > s2.length()) return false;

        vector<int> countS1(26, 0), countWindow(26, 0);

        // 步骤1: 计算 s1 的频率
        for (char ch : s1) {
            countS1[ch - 'a']++;
        }

        int windowSize = s1.length();

        // 步骤2 & 3: 初始化窗口并计算第一个窗口的频率
        for (int i = 0; i < windowSize; i++) {
            countWindow[s2[i] - 'a']++;
        }

        // 步骤5 & 6: 滑动窗口并检查
        for (int i = 0; i <= s2.length() - windowSize; i++) {
            // 步骤4: 比较当前窗口频率与 s1 频率
            if (isFrequencyEqual(countS1, countWindow)) {
                return true;
            }
            // 滑动窗口:更新频率数组
            if (i + windowSize < s2.length()) {
                countWindow[s2[i] - 'a']--;          // 移除窗口左端的字符
                countWindow[s2[i + windowSize] - 'a']++; // 添加窗口右端的新字符
            }
        }
        return false;
    }

private:
    // 辅助函数:比较两个频率数组是否相等
    bool isFrequencyEqual(const vector<int>& freq1, const vector<int>& freq2) {
        for (int i = 0; i < 26; i++) {
            if (freq1[i] != freq2[i]) {
                return false;
            }
        }
        return true;
    }
};

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串 s2 的长度。我们最多遍历 s2 一次,并且每次窗口滑动时更新频率数组的操作是常数时间 O(1)。频率比较函数也只需固定的26次操作。
  • 空间复杂度:O(1),因为我们只使用了两个固定大小(26)的数组,与输入字符串长度无关。

总结

本节课中我们一起学习了如何判断一个字符串是否包含另一个字符串的排列。我们掌握了以下关键点:

  1. 利用字符频率作为排列的判定标准。
  2. 针对小写字母的约束,使用固定大小的数组来高效统计频率。
  3. 应用滑动窗口算法在长字符串中高效地寻找匹配的子串。
  4. 实现了完整的解决方案,并分析了其时间和空间复杂度。

这是一个非常经典的滑动窗口应用问题,理解它对于解决其他类似的字符串匹配或子串搜索问题大有裨益。

032:字符串第四部分 - 反转字符串中的单词

在本节课中,我们将要学习一个基于字符串的重要问题:“反转字符串中的单词”。这个问题是LeetCode上的第151题,也是面试中的常见题目。我们将学习如何在不使用额外库函数的情况下,处理包含多余空格和单词顺序反转的字符串,并最终输出一个单词顺序正确、单词内部字符顺序也正确的字符串。

问题描述

给定一个字符串 s,其中可能包含前导空格、尾随空格以及单词间的多个空格。我们需要返回一个单词顺序反转后的字符串,同时单词内部字符的顺序也要反转,并且单词之间只用一个空格分隔。

例如,输入字符串 " hello world ",经过处理后,输出应为 "world hello"。注意,原字符串中的多余空格已被移除,单词顺序和单词内部的字符顺序都被反转。

核心思路

解决这个问题的核心思路可以分为三个主要步骤:

  1. 反转整个字符串。
  2. 识别并反转字符串中的每一个单词。
  3. 在构建最终答案时,处理多余的空格。

上一节我们介绍了问题的基本要求,本节中我们来看看实现这一思路的具体策略。

算法步骤详解

以下是实现“反转字符串中的单词”功能的详细步骤:

  1. 预处理与整体反转:首先,我们移除字符串开头和结尾的多余空格。然后,我们将整个字符串进行反转。这一步的目的是将单词顺序反转的任务,转化为后续逐个反转单词内部字符的任务。
  2. 识别并反转单个单词:接着,我们遍历反转后的字符串。在遍历过程中,我们识别出每一个单词(即连续的、非空格的字符序列),并将这个单词单独进行反转,以恢复其内部字符的原始顺序。
  3. 构建结果并处理空格:在反转每个单词后,我们将它们依次添加到一个新的结果字符串中,并在单词之间添加一个空格。通过控制添加空格的条件,我们可以自动处理原始字符串中可能存在的多个连续空格。

代码实现

以下是上述算法的C++实现代码。代码中包含了详细的注释,以帮助理解每一步的操作。

class Solution {
public:
    string reverseWords(string s) {
        // 步骤1: 反转整个字符串
        reverse(s.begin(), s.end());

        int n = s.size();
        int idx = 0; // 用于构建最终结果字符串的索引
        for (int start = 0; start < n; ++start) {
            if (s[start] != ' ') { // 找到一个单词的开头
                // 在结果字符串中,如果不是第一个单词,则先添加一个空格
                if (idx != 0) s[idx++] = ' ';

                // 步骤2: 找到单词的结尾
                int end = start;
                while (end < n && s[end] != ' ') s[idx++] = s[end++];

                // 步骤3: 反转这个刚找到的单词(在结果字符串中的部分)
                // 单词的范围是 [idx - (end-start), idx)
                reverse(s.begin() + idx - (end - start), s.begin() + idx);

                // 移动start到当前单词的末尾,继续寻找下一个单词
                start = end;
            }
        }
        // 擦除末尾可能多余的空格(由于我们精确控制添加,这里通常idx就是新长度)
        s.erase(s.begin() + idx, s.end());
        return s;
    }
};

复杂度分析

  • 时间复杂度:O(n)。我们遍历了整个字符串常数次(整体反转一次,识别单词遍历一次,反转每个单词一次),因此总时间复杂度是线性的。
  • 空间复杂度:O(1)。我们直接在输入的字符串上进行修改,没有使用额外的、与输入规模成比例的空间(除了几个整型变量)。

总结

本节课中我们一起学习了如何解决“反转字符串中的单词”这个问题。我们掌握了分步处理的策略:先整体反转字符串以交换单词顺序,再逐个识别并反转单词以恢复其内部顺序,最后在构建结果时巧妙地处理多余空格。这种方法高效且节省空间,是解决此类字符串处理问题的经典思路。理解并掌握这个算法,将有助于你应对面试中类似的字符串操作题目。

033:字符串压缩问题(Leetcode 443)

在本节课中,我们将学习一个重要的面试问题:字符串压缩。我们将详细解析问题,并一步步构建解决方案。

概述

字符串压缩的目标是减少字符串的存储空间。基本规则是:将连续出现的相同字符替换为该字符本身加上其出现的次数。如果某个字符只出现一次,则只保留字符本身。我们将在一个字符数组上原地完成这个操作,并返回压缩后新数组的长度。

问题解析

假设我们有一个字符数组 chars,例如 [‘a‘, ‘a‘, ‘b‘, ‘b‘, ‘c‘, ‘c‘, ‘c‘]

压缩过程如下:

  • 字符 ‘a‘ 连续出现 2 次,压缩为 [‘a‘, ‘2‘]
  • 字符 ‘b‘ 连续出现 2 次,压缩为 [‘b‘, ‘2‘]
  • 字符 ‘c‘ 连续出现 3 次,压缩为 [‘c‘, ‘3‘]

最终,原数组应被修改为 [‘a‘, ‘2‘, ‘b‘, ‘2‘, ‘c‘, ‘3‘],并返回新长度 6。

核心挑战在于我们需要在原地修改输入数组,并使用一个索引来跟踪压缩后字符应该放置的位置。

算法思路

上一节我们明确了问题,本节中我们来看看具体的解决思路。

我们将使用双指针(索引)法:

  1. 一个指针 i 用于遍历原数组。
  2. 另一个指针 index 用于指向下一个压缩后字符应该存放的位置。

以下是算法的核心步骤:

  1. 初始化:设置 index = 0n = chars 数组的长度。
  2. 遍历数组:使用 i0 遍历到 n-1
    • 记录当前字符 currentChar = chars[i]
    • 初始化计数器 count = 0
  3. 统计连续字符:使用一个 while 循环,当 i < nchars[i] == currentChar 时,增加 icount
  4. 写入压缩结果
    • 首先,将字符本身写入 chars[index],然后 index 加 1。
    • 接着,如果 count > 1,需要将计数转换为字符串,并将每个数字字符依次写入数组,同时更新 index
  5. 循环继续:外层 for 循环继续,处理下一个不同的字符序列。
  6. 返回结果:循环结束后,index 的值就是压缩后数组的新长度。

代码实现

理解了算法步骤后,现在让我们将其转化为具体的 C++ 代码。

class Solution {
public:
    int compress(vector<char>& chars) {
        int n = chars.size();
        int index = 0; // 指向压缩后数组的写入位置
        
        for (int i = 0; i < n; i++) {
            char currentChar = chars[i];
            int count = 0;
            
            // 统计相同字符连续出现的次数
            while (i < n && chars[i] == currentChar) {
                i++;
                count++;
            }
            i--; // 因为for循环本身会i++,这里需要回退一步
            
            // 写入字符
            chars[index] = currentChar;
            index++;
            
            // 如果计数大于1,写入数字
            if (count > 1) {
                string countStr = to_string(count);
                for (char digit : countStr) {
                    chars[index] = digit;
                    index++;
                }
            }
        }
        // 返回压缩后数组的新长度
        return index;
    }
};

复杂度分析

最后,我们来分析一下解决方案的效率。

  • 时间复杂度:O(n)。尽管代码中有嵌套循环,但每个字符只被访问常数次(外层 for 循环和内层 while 循环共同推进 i),因此总体是线性复杂度。
  • 空间复杂度:O(1)。除了几个变量,我们只使用了常数额外空间。注意,to_string 可能会创建临时字符串,但其最大长度由 count 的位数决定,对于字符数组长度 n,这可以视为常数空间。

总结

本节课中我们一起学习了 Leetcode 第 443 题——字符串压缩。我们掌握了如何通过双指针法在原地修改数组,将连续字符及其出现次数进行压缩编码,并返回新数组的长度。这是一个考察数组操作和编码思维的经典问题。

034:二维数组(第一部分)🚀

在本节课中,我们将学习数据结构与算法中一个重要的概念:二维数组。我们将了解其定义、内存表示、基本操作,并通过代码示例学习如何创建、初始化和遍历二维数组。


什么是二维数组?📊

上一节我们深入探讨了一维数组。本节中,我们来看看二维数组。

二维数组,在编程中通常被称为矩阵。它本质上是一个二维表格,我们可以通过特定的行和列来存储数据。例如,我们可以用它来存储一个3x3表格的数据。

一个矩阵或类表格格式包含行和列。行是水平的。例如,一个4行3列的矩阵,我们称之为 matrix[4][3]

在C++中,我们使用两个方括号来定义二维数组:int matrix[rows][columns];


如何创建和初始化二维数组?🔧

与一维数组类似,我们也可以在创建时为二维数组初始化数据。

以下是一个基础的C++代码模板,用于定义和初始化一个矩阵:

// 定义一个4行3列的矩阵
int matrix[4][3];

// 初始化一个3行3列的矩阵
int matrix[3][3] = {
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};

初始化时,我们使用多组花括号,每组对应一行数据。


如何访问二维数组的元素?🎯

要访问二维数组中的特定元素,我们需要行索引和列索引的组合。

例如,在 matrix[3][3] 中:

  • matrix[0][0] 是第0行第0列的元素(值为1)。
  • matrix[2][1] 是第2行第1列的元素(值为8)。

我们可以通过 matrix[row][column] 来读取或修改该位置的值。


如何遍历二维数组?🔄

对二维数组进行大多数操作(如输入、输出、搜索)都需要使用循环。我们通常使用嵌套循环来遍历。

外层循环遍历行,内层循环遍历列。

以下是打印矩阵所有元素的代码示例:

for(int i = 0; i < rows; i++) {
    for(int j = 0; j < columns; j++) {
        cout << matrix[i][j] << " ";
    }
    cout << endl; // 每行结束后换行
}

类似地,我们可以用嵌套循环来输入矩阵的元素:

for(int i = 0; i < rows; i++) {
    for(int j = 0; j < columns; j++) {
        cin >> matrix[i][j];
    }
}

二维数组在内存中如何存储?💾

一维数组在内存中是线性连续存储的。二维数组在内存中也是线性存储的,但有两种主要方式:行主序列主序

  • 行主序:系统先存储第一行的所有元素,接着是第二行,以此类推。这是大多数情况下的存储方式。
  • 列主序:系统先存储第一列的所有元素,接着是第二列,以此类推。

作为程序员,我们通常不需要控制这个过程,系统会自动处理。


在二维数组中实现线性搜索🔍

线性搜索的逻辑是遍历每个元素,与目标值进行比较。

以下是实现线性搜索的函数:

bool linearSearch(int matrix[][3], int rows, int cols, int key) {
    for(int i = 0; i < rows; i++) {
        for(int j = 0; j < cols; j++) {
            if(matrix[i][j] == key) {
                return true;
            }
        }
    }
    return false;
}

计算矩阵的行最大和📈

假设我们需要找出矩阵中所有行里,元素和最大的那一行的和。

思路是遍历每一行,计算该行元素的和,并与当前记录的最大行和进行比较。

以下是计算最大行和的函数:

int getMaxRowSum(int matrix[][3], int rows, int cols) {
    int maxRowSum = INT_MIN; // 初始化为最小整数值
    for(int i = 0; i < rows; i++) {
        int rowSum = 0;
        for(int j = 0; j < cols; j++) {
            rowSum += matrix[i][j];
        }
        maxRowSum = max(maxRowSum, rowSum);
    }
    return maxRowSum;
}

课后练习:请尝试编写代码,计算并打印矩阵中每一列的元素和,并找出最大的列和。


计算方阵的对角线之和➗

对于一个 n x n 的方阵,存在两条对角线:主对角线和次对角线。

  • 主对角线:元素的行索引等于列索引,即 i == j
  • 次对角线:元素满足 j == n - i - 1

一个直观的方法是遍历所有元素,判断其是否在对角线上并累加。但更高效的方法是直接遍历对角线元素。

以下是计算两条对角线总和的优化代码:

int diagonalSum(int matrix[][3], int n) {
    int sum = 0;
    for(int i = 0; i < n; i++) {
        // 主对角线元素
        sum += matrix[i][i];
        // 次对角线元素(避免中心元素被重复计算)
        if(i != n - i - 1) {
            sum += matrix[i][n - i - 1];
        }
    }
    return sum;
}

二维向量:动态的二维数组⚡

与一维向量类似,C++也提供了二维向量 vector<vector<int>>。它在运行时可以调整大小,通常比原生二维数组更灵活高效。

以下是定义和使用二维向量的示例:

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

int main() {
    // 定义并初始化一个3x3的二维向量
    vector<vector<int>> matrix = {
        {1, 2, 3},
        {4, 5, 6},
        {7, 8, 9}
    };

    // 访问元素
    cout << matrix[0][0] << endl; // 输出 1

    // 遍历二维向量
    for(int i = 0; i < matrix.size(); i++) { // matrix.size() 是行数
        for(int j = 0; j < matrix[i].size(); j++) { // matrix[i].size() 是第i行的列数
            cout << matrix[i][j] << " ";
        }
        cout << endl;
    }

    // 二维向量的优势:每行可以有不同数量的列
    vector<vector<int>> jaggedMatrix = {
        {1, 2, 3},
        {4, 5},
        {6, 7, 8, 9}
    };
    return 0;
}

二维向量的主要优势在于其动态大小和每行长度可以不同(锯齿数组),这使其能更好地适应多种问题场景。


总结✨

本节课中我们一起学习了二维数组的核心概念。我们掌握了如何创建、初始化、访问和遍历二维数组,理解了其在内存中的存储方式,并实践了线性搜索、计算行和与对角线之和等基本算法。最后,我们还介绍了更灵活的二维向量。掌握这些知识是解决更复杂矩阵问题的基础。

035:搜索二维矩阵变体I与II

概述

在本节课中,我们将学习如何在二维矩阵中高效地搜索目标值。我们将重点分析两种变体问题:第一种是矩阵的每一行和每一列都严格排序;第二种是矩阵的每一行从左到右递增,每一列从上到下递增。我们将探讨暴力解法,并重点讲解基于二分查找的优化解法。


搜索二维矩阵(变体I)

上一节我们介绍了二维数组的基本概念,本节中我们来看看一个经典问题:在完全排序的二维矩阵中搜索目标值。

问题描述:给定一个 m x n 的二维整数矩阵 matrix,该矩阵满足以下性质:

  • 每行中的整数从左到右按非递减顺序排列。
  • 每行的第一个整数大于前一行的最后一个整数。

我们需要编写一个高效的算法来判断目标值 target 是否存在于矩阵中。

暴力解法分析

最直观的方法是遍历整个矩阵的每一个元素。以下是暴力解法的核心逻辑:

bool searchMatrix(vector<vector<int>>& matrix, int target) {
    int m = matrix.size(); // 行数
    int n = matrix[0].size(); // 列数
    for(int i = 0; i < m; i++) {
        for(int j = 0; j < n; j++) {
            if(matrix[i][j] == target) {
                return true;
            }
        }
    }
    return false;
}

该解法的时间复杂度为 O(m * n),在矩阵较大时效率很低。

优化解法:两次二分查找

由于矩阵整体可以被视为一个展开后有序的一维数组,我们可以使用两次二分查找来优化。

核心思路

  1. 首先,对矩阵的“第一列”进行二分查找,找到最后一个其值小于等于目标值的行。这确定了目标值可能存在的行。
  2. 然后,在找到的特定行中,进行标准的二分查找,以确定目标值是否存在。

以下是实现步骤:

  1. 初始化变量:获取矩阵的行数 m 和列数 n。设置查找行的起始 startRow = 0 和结束 endRow = m - 1
  2. 查找目标行
    • startRow <= endRow 时,计算中间行 midRow = startRow + (endRow - startRow) / 2
    • 比较 matrix[midRow][0](中间行的第一个元素)与 target
    • 如果 target >= matrix[midRow][0]target <= matrix[midRow][n-1](目标值在该行的值域内),则锁定该行为 targetRow
    • 如果 target < matrix[midRow][0],说明目标值可能在更上面的行,令 endRow = midRow - 1
    • 如果 target > matrix[midRow][n-1],说明目标值可能在更下面的行,令 startRow = midRow + 1
  3. 在目标行中搜索:如果找到了 targetRow,则在该行 matrix[targetRow] 中使用标准二分查找来寻找 target

代码实现

bool searchMatrix(vector<vector<int>>& matrix, int target) {
    int m = matrix.size();
    int n = matrix[0].size();
    int startRow = 0, endRow = m - 1;
    int targetRow = -1;

    // 第一次二分:查找目标所在的行
    while(startRow <= endRow) {
        int midRow = startRow + (endRow - startRow) / 2;
        if(target >= matrix[midRow][0] && target <= matrix[midRow][n-1]) {
            targetRow = midRow;
            break;
        } else if(target < matrix[midRow][0]) {
            endRow = midRow - 1;
        } else { // target > matrix[midRow][n-1]
            startRow = midRow + 1;
        }
    }
    if(targetRow == -1) return false;

    // 第二次二分:在目标行中查找目标值
    int left = 0, right = n - 1;
    while(left <= right) {
        int mid = left + (right - left) / 2;
        if(matrix[targetRow][mid] == target) {
            return true;
        } else if(matrix[targetRow][mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return false;
}

时间复杂度:两次二分查找的时间复杂度分别为 O(log m)O(log n),总时间复杂度为 O(log m + log n),优于暴力解法。


搜索二维矩阵(变体II)

现在,我们来看一个更普遍的变体问题,其排序规则有所不同。

问题描述:给定一个 m x n 的二维整数矩阵 matrix,该矩阵满足以下性质:

  • 每行中的整数从左到右按非递减顺序排列。
  • 每列中的整数从上到下按非递减顺序排列。

与变体I不同,这里行与行之间的值域是重叠的。例如,下一行的最小值可能小于上一行的最大值。

解法分析:从右上角(或左下角)开始搜索

由于排序规则的特性,我们可以选择一个特殊的起点进行线性搜索。

核心思路:从矩阵的右上角 (0, n-1) 开始搜索。

  • 如果 target 等于当前元素,返回 true
  • 如果 target 小于当前元素,由于该列是递增的,当前元素下方的所有元素都更大,因此目标值不可能在当前列。我们将搜索位置向左移动一列col--)。
  • 如果 target 大于当前元素,由于该行是递增的,当前元素左方的所有元素都更小,因此目标值不可能在当前行。我们将搜索位置向下移动一行row++)。
  • 重复此过程,直到找到目标或越界。

选择左下角 (m-1, 0) 作为起点,逻辑是镜像对称的。

以下是实现步骤:

  1. 初始化指针:从右上角开始,row = 0, col = n - 1
  2. 搜索循环:在 row < mcol >= 0 的条件下循环。
    • 比较 matrix[row][col]target
    • 根据比较结果,更新行或列的索引。
  3. 返回结果:找到则返回 true,循环结束未找到则返回 false

代码实现(从右上角开始)

bool searchMatrix(vector<vector<int>>& matrix, int target) {
    int m = matrix.size();
    int n = matrix[0].size();
    int row = 0, col = n - 1; // 起始位置:右上角

    while(row < m && col >= 0) {
        if(matrix[row][col] == target) {
            return true;
        } else if(matrix[row][col] > target) {
            // 目标值更小,向左移动一列
            col--;
        } else {
            // 目标值更大,向下移动一行
            row++;
        }
    }
    return false;
}

时间复杂度:在最坏情况下(从右上角走到左下角),我们需要走 m + n 步,因此时间复杂度为 O(m + n)。这比变体I的解法慢,但在此问题的约束下是最优的线性解法之一,并且远优于 O(m * n) 的暴力解法。


总结

本节课中我们一起学习了两种在排序二维矩阵中搜索目标值的算法:

  1. 变体I(行首尾相接有序):利用矩阵可视为完全有序一维数组的特性,通过两次二分查找高效解决问题,时间复杂度为 O(log m + log n)
  2. 变体II(行、列分别有序):利用行列的排序规则,从右上角或左下角开始进行线性搜索,每次比较可以排除一行或一列,时间复杂度为 O(m + n)

理解这两种问题的区别及其对应解法的原理,对于解决基于二维数组的搜索问题至关重要。

036:螺旋矩阵(二维数组第三部分)Leetcode 54

概述

在本节课中,我们将学习一个经典的二维数组面试问题:螺旋矩阵。我们将详细解析Leetcode第54题,学习如何按螺旋顺序遍历一个给定的二维矩阵,并返回所有元素。核心在于理解并实现边界控制逻辑。


螺旋矩阵问题解析

上一节我们探讨了二维数组的基本操作,本节中我们来看看如何按螺旋顺序遍历矩阵。

给定一个 m x n 的矩阵,我们需要按照从外圈到内圈、顺时针的方向,依次返回所有元素。

核心思路:边界收缩法

解决此问题的关键在于使用四个变量来动态定义矩阵的边界:

  • 起始行 (startRow)
  • 结束行 (endRow)
  • 起始列 (startCol)
  • 结束列 (endCol)

初始时,边界就是整个矩阵的范围。我们按照 上边界 -> 右边界 -> 下边界 -> 左边界 的顺序遍历元素,每完成一个边界的遍历,就向内收缩对应的边界。

以下是遍历一个边界的通用逻辑框架:

// 遍历上边界 (从左到右)
for (int j = startCol; j <= endCol; j++) {
    result.push_back(matrix[startRow][j]);
}
// 完成后,上边界下移
startRow++;

// 遍历右边界 (从上到下)
for (int i = startRow; i <= endRow; i++) {
    result.push_back(matrix[i][endCol]);
}
// 完成后,右边界左移
endCol--;

// 遍历下边界 (从右到左)
for (int j = endCol; j >= startCol; j--) {
    result.push_back(matrix[endRow][j]);
}
// 完成后,下边界上移
endRow--;

// 遍历左边界 (从下到上)
for (int i = endRow; i >= startRow; i--) {
    result.push_back(matrix[i][startCol]);
}
// 完成后,左边界右移
startCol++;

关键细节与边界条件

上述循环需要持续进行,直到所有元素都被遍历。循环继续的条件是:

while (startRow <= endRow && startCol <= endCol)

然而,这里存在一个特殊情况需要处理。当矩阵的行数或列数为奇数时,最内层可能只剩一行或一列。如果继续执行完整的四步遍历,会导致元素被重复添加。

以下是需要处理的特殊情况:

  1. 只剩一行:当 startRow == endRow 时,只需遍历上边界,并跳过右、下、左边界的遍历,因为它们是同一行。
  2. 只剩一列:当 startCol == endCol 时,只需遍历右边界,并跳过下、左边界的遍历,因为它们是同一列。

因此,在遍历右边界和下边界之前,需要增加条件判断:

// 遍历上边界后...
// 遍历右边界前,检查是否还有多行
if (startRow <= endRow) {
    for (int i = startRow; i <= endRow; i++) {
        result.push_back(matrix[i][endCol]);
    }
    endCol--;
}

// 遍历下边界前,检查是否还有多列
if (startCol <= endCol) {
    for (int j = endCol; j >= startCol; j--) {
        result.push_back(matrix[endRow][j]);
    }
    endRow--;
}

// 遍历左边界前,检查是否还有多行
if (startRow <= endRow) {
    for (int i = endRow; i >= startRow; i--) {
        result.push_back(matrix[i][startCol]);
    }
    startCol++;
}

完整代码示例

结合以上逻辑,以下是该问题的完整C++解决方案:

class Solution {
public:
    vector<int> spiralOrder(vector<vector<int>>& matrix) {
        vector<int> result;
        if (matrix.empty()) return result;

        int startRow = 0;
        int endRow = matrix.size() - 1;
        int startCol = 0;
        int endCol = matrix[0].size() - 1;

        while (startRow <= endRow && startCol <= endCol) {
            // 遍历上边界
            for (int j = startCol; j <= endCol; j++) {
                result.push_back(matrix[startRow][j]);
            }
            startRow++;

            // 遍历右边界
            if (startRow <= endRow) {
                for (int i = startRow; i <= endRow; i++) {
                    result.push_back(matrix[i][endCol]);
                }
                endCol--;
            }

            // 遍历下边界
            if (startCol <= endCol) {
                for (int j = endCol; j >= startCol; j--) {
                    result.push_back(matrix[endRow][j]);
                }
                endRow--;
            }

            // 遍历左边界
            if (startRow <= endRow) {
                for (int i = endRow; i >= startRow; i--) {
                    result.push_back(matrix[i][startCol]);
                }
                startCol++;
            }
        }
        return result;
    }
};

总结

本节课我们一起学习了如何解决螺旋矩阵问题。我们掌握了边界收缩法这一核心策略,通过动态调整四个边界变量来模拟螺旋遍历的过程。更重要的是,我们深入分析了当矩阵行数或列数为奇数时可能出现的元素重复问题,并通过增加条件判断巧妙地解决了它。理解这个遍历顺序和边界处理是解决此类二维数组问题的关键。

037:哈希表应用问题详解

在本节课中,我们将学习哈希表在解决实际问题中的应用。我们将通过三个经典的LeetCode问题来深入理解哈希表的使用技巧,包括两数之和、查找重复与缺失值以及查找重复项。课程将从暴力解法开始,逐步过渡到使用哈希表的最优解,并介绍一种巧妙的常数空间解法。

两数之和问题 🎯

给定一个整数数组 nums 和一个整数目标值 target,请在数组中找出为目标值 target 的那两个整数,并返回它们的数组下标。你可以假设每种输入只会对应一个答案,并且你不能重复使用相同的元素。

暴力解法

首先,我们来看最直接的解法。思路是遍历数组中的每一个元素,并查找是否存在另一个元素,使得它们的和等于目标值。

以下是暴力解法的步骤:

  1. 使用一个外层循环,索引 i0 遍历到 n-1,选取第一个数字 nums[i]
  2. 使用一个内层循环,索引 ji+1 遍历到 n-1,选取第二个数字 nums[j]
  3. 检查 nums[i] + nums[j] 是否等于 target。如果相等,则返回 {i, j}

代码描述:

for (int i = 0; i < n; i++) {
    for (int j = i + 1; j < n; j++) {
        if (nums[i] + nums[j] == target) {
            return {i, j};
        }
    }
}

这种方法的时间复杂度是 O(n²),在数据量较大时效率较低。

哈希表优化解法

上一节我们介绍了暴力解法,本节中我们来看看如何使用哈希表进行优化。核心思路是:当我们遍历数组时,对于当前元素 nums[i](我们称之为 first),我们只需要知道互补数 second = target - first 是否在之前已经出现过。

我们可以使用一个哈希表(在C++中通常使用 unordered_map)来存储已经遍历过的数字及其索引。这样,检查一个数是否存在的时间复杂度可以降低到平均 O(1)

解题步骤:

  1. 创建一个 unordered_map<int, int>,用于存储 元素值 -> 索引 的映射。
  2. 遍历数组 nums,对于每个元素 nums[i]
    • 计算其互补数 complement = target - nums[i]
    • 检查 complement 是否存在于哈希表中。
    • 如果存在,则我们找到了答案:{ map[complement], i }
    • 如果不存在,则将当前元素 nums[i] 及其索引 i 存入哈希表,以便后续查找。

代码描述:

unordered_map<int, int> numMap;
for (int i = 0; i < nums.size(); i++) {
    int complement = target - nums[i];
    if (numMap.find(complement) != numMap.end()) {
        return {numMap[complement], i};
    }
    numMap[nums[i]] = i;
}

这种方法的时间复杂度是 O(n),空间复杂度也是 O(n),是解决此问题的最优方法之一。


查找重复与缺失值 🔍

给定一个 n x n 的矩阵,其中包含从 1 的所有整数,但恰好有一个数字重复,一个数字缺失。我们需要找出这个重复的数字 A 和缺失的数字 B

使用集合查找重复值

解决这个问题的第一步是找出重复的数字。我们可以利用集合(unordered_set)中元素唯一的特性。

以下是查找重复值的步骤:

  1. 创建一个空的 unordered_set<int>
  2. 遍历矩阵中的每一个元素。
  3. 对于每个元素,检查它是否已经存在于集合中。
  4. 如果存在,则该元素就是重复的数字 A
  5. 如果不存在,则将该元素插入集合。

利用数学公式查找缺失值

找到重复值 A 后,我们可以通过数学计算来找到缺失值 B。关键思路是利用从 1 的等差数列和。

计算公式:

  • 期望总和(Expected Sum):从1到n²的所有整数之和。
    expected_sum = n² * (n² + 1) / 2
  • 实际总和(Actual Sum):矩阵中所有元素之和。
  • 它们之间的关系是:expected_sum + A - B = actual_sum
  • 因此,缺失值 B = expected_sum + A - actual_sum

解题步骤:

  1. 使用集合找到重复值 A
  2. 计算矩阵所有元素的 actual_sum
  3. 利用上述公式计算 expected_sum
  4. 代入公式 B = expected_sum + A - actual_sum 得到缺失值。

这种方法的时间复杂度为 O(n²)(遍历矩阵),空间复杂度为 O(n²)(最坏情况下集合存储所有元素)。


查找重复项(常数空间) 🐢🐇

给定一个包含 n + 1 个整数的数组 nums,其数字都在 1n 之间(包括 1n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出它。要求不能修改原数组,并且只能使用常数级的额外空间。

链表环检测法(快慢指针)

这个问题可以巧妙地转化为链表中的环检测问题。我们将数组索引和值视为链表节点:索引 i 指向 值 nums[i](即下一个节点的索引)。

由于数组中有重复数字,那么这个“链表”中必然存在一个环,而环的入口节点索引对应的值就是重复的数字。我们可以使用快慢指针(Floyd判圈算法)来找到这个入口。

算法步骤:

  1. 第一阶段:检测环
    • 初始化两个指针 slowfast,都指向“链表”头(索引0,即 nums[0])。
    • slow 每次移动一步(slow = nums[slow])。
    • fast 每次移动两步(fast = nums[nums[fast]])。
    • slowfast 相遇时,说明链表中存在环。
  2. 第二阶段:寻找环入口(重复数字)
    • slow 重新置回起点(nums[0])。
    • slowfast 现在每次都只移动一步。
    • 当它们再次相遇时,相遇点就是环的入口。该点对应的数组值 nums[slow] 就是重复的数字。

代码描述:

int slow = nums[0];
int fast = nums[0];
// 第一阶段:寻找相遇点
do {
    slow = nums[slow];
    fast = nums[nums[fast]];
} while (slow != fast);
// 第二阶段:寻找环入口
slow = nums[0];
while (slow != fast) {
    slow = nums[slow];
    fast = nums[fast];
}
return slow; // 或 fast

这种方法的时间复杂度是 O(n),空间复杂度是 O(1),完美满足了题目的要求。


总结 📚

本节课中我们一起学习了哈希表的三个重要应用场景:

  1. 两数之和:通过哈希表将查找时间降至 O(1),从而在 O(n) 时间内解决问题。
  2. 查找重复与缺失值:结合哈希集合与数学公式,有效处理了矩阵中的特殊数字问题。
  3. 查找重复项(常数空间):通过将数组抽象成链表,并运用快慢指针算法,在不开辟额外空间的情况下找到了重复数字。

这些问题的解法展示了数据结构知识如何灵活运用于实际问题解决中,从暴力枚举到哈希优化,再到巧妙的数学与指针技巧,每一步优化都加深了我们对算法效率的理解。

038:三数之和问题详解 🎯

在本节课中,我们将学习如何解决一个重要的算法问题——“三数之和”(LeetCode 15)。我们将从最直观的暴力解法开始,逐步优化到最高效的双指针法,并理解每种方法背后的逻辑与复杂度。

概述

“三数之和”问题的目标是:给定一个整数数组 nums,找出所有满足 a + b + c = 0 的三元组 [a, b, c]。返回的三元组必须是不重复的。

例如,对于数组 [-1, 0, 1, 2, -1, -4],满足条件的三元组是 [-1, -1, 2][-1, 0, 1]。注意,即使有多个 -1[-1, 0, 1] 也只应出现一次。


方法一:暴力解法(三重循环)

最直接的思路是使用三重循环遍历所有可能的三元组组合,检查它们的和是否为零。

以下是该方法的实现步骤:

  1. 使用三层嵌套循环,分别用索引 ijk 选取三个数。
  2. 确保 i < j < k 以避免重复选取同一个元素。
  3. 检查 nums[i] + nums[j] + nums[k] == 0
  4. 将满足条件的三元组存储起来,并确保最终结果的唯一性。

代码实现:

vector<vector<int>> threeSum(vector<int>& nums) {
    int n = nums.size();
    set<vector<int>> uniqueTriplets; // 用于去重的集合
    vector<vector<int>> answer;

    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            for (int k = j + 1; k < n; k++) {
                if (nums[i] + nums[j] + nums[k] == 0) {
                    vector<int> triplet = {nums[i], nums[j], nums[k]};
                    sort(triplet.begin(), triplet.end()); // 排序以实现标准化
                    if (uniqueTriplets.find(triplet) == uniqueTriplets.end()) {
                        uniqueTriplets.insert(triplet);
                        answer.push_back(triplet);
                    }
                }
            }
        }
    }
    return answer;
}

复杂度分析:

  • 时间复杂度:O(n³)。三重循环嵌套。
  • 空间复杂度:O(1)O(T),其中 T 是结果三元组的数量,用于存储输出。

暴力解法虽然简单,但效率很低,在数据量较大时不可行。


方法二:哈希表优化法

上一节我们介绍了暴力解法,本节中我们来看看如何利用哈希表进行优化。核心思路是固定第一个数 a 后,将三数之和问题转化为寻找两数之和 b + c = -a 的问题。

以下是该方法的实现步骤:

  1. 外层循环固定第一个数 nums[i],计算目标值 target = -nums[i]
  2. 在内层循环中,遍历 ji+1n-1
  3. 对于每个 nums[j],计算所需的第三个数 complement = target - nums[j]
  4. 使用一个哈希集合(unordered_set)来存储内层循环中已经遍历过的数字。如果在集合中找到 complement,则说明找到了一个有效三元组。
  5. 将找到的三元组排序后存入一个集合中以去重。

代码实现:

vector<vector<int>> threeSum(vector<int>& nums) {
    int n = nums.size();
    set<vector<int>> uniqueTriplets;
    vector<vector<int>> answer;

    for (int i = 0; i < n; i++) {
        int target = -nums[i];
        unordered_set<int> s;
        for (int j = i + 1; j < n; j++) {
            int complement = target - nums[j];
            if (s.find(complement) != s.end()) {
                vector<int> triplet = {nums[i], nums[j], complement};
                sort(triplet.begin(), triplet.end());
                uniqueTriplets.insert(triplet);
            }
            s.insert(nums[j]); // 将当前数字加入集合,供后续查找
        }
    }
    // 将集合中的唯一三元组转移到答案中
    answer.assign(uniqueTriplets.begin(), uniqueTriplets.end());
    return answer;
}

复杂度分析:

  • 时间复杂度:O(n²)。外层循环 O(n),内层循环与哈希查找 O(n)。
  • 空间复杂度:O(n)。主要用于哈希集合存储。

哈希表法将时间复杂度从 O(n³) 降到了 O(n²),是一个显著的优化。


方法三:排序 + 双指针法(最优解)

上一节我们利用哈希表去除了一个循环,本节我们介绍最优解——排序后使用双指针法。此方法无需额外的集合来去重,效率更高。

以下是该方法的实现步骤:

  1. 排序:首先将数组排序。这是后续去重和双指针移动的基础。
  2. 固定第一个数:遍历数组,将 nums[i] 作为第一个数。如果 nums[i] > 0,由于数组已排序,后面的数都为正,和不可能为零,可以直接结束循环。
  3. 去重:如果 nums[i] 与前一个数相同(i > 0 && nums[i] == nums[i-1]),则跳过,以避免重复的三元组。
  4. 双指针寻找:在 i 之后的子数组中,设置左指针 j = i+1,右指针 k = n-1
    • 计算当前和 sum = nums[i] + nums[j] + nums[k]
    • 如果 sum == 0,找到一组解,记录。然后同时移动 jk,并跳过所有重复的 nums[j]nums[k]
    • 如果 sum < 0,说明总和太小,需要增大,因此将左指针 j 右移。
    • 如果 sum > 0,说明总和太大,需要减小,因此将右指针 k 左移。

代码实现:

vector<vector<int>> threeSum(vector<int>& nums) {
    int n = nums.size();
    vector<vector<int>> answer;
    sort(nums.begin(), nums.end()); // 关键步骤:排序

    for (int i = 0; i < n; i++) {
        // 优化1:第一个数大于0,后续无解
        if (nums[i] > 0) break;
        // 去重:跳过相同的第一个数
        if (i > 0 && nums[i] == nums[i-1]) continue;

        int j = i + 1;
        int k = n - 1;
        while (j < k) {
            int sum = nums[i] + nums[j] + nums[k];
            if (sum == 0) {
                answer.push_back({nums[i], nums[j], nums[k]});
                // 去重:跳过相同的第二个数和第三个数
                while (j < k && nums[j] == nums[j+1]) j++;
                while (j < k && nums[k] == nums[k-1]) k--;
                j++;
                k--;
            } else if (sum < 0) {
                j++; // 和太小,左指针右移
            } else {
                k--; // 和太大,右指针左移
            }
        }
    }
    return answer;
}

复杂度分析:

  • 时间复杂度:O(n²)。排序消耗 O(n log n),双指针循环消耗 O(n²),总体为 O(n²)。
  • 空间复杂度:O(1)O(log n)(取决于排序算法的空间开销,忽略存储答案的空间)。

这是解决“三数之和”问题的最优方法,也是面试中最常被要求实现的方法。


总结

本节课中我们一起学习了“三数之和”问题的三种解法:

  1. 暴力三重循环:思路简单但效率低下(O(n³)),仅适用于理解问题。
  2. 哈希表优化法:将问题转化为两数之和,利用集合查找,将时间复杂度优化到 O(n²)。
  3. 排序+双指针法:最优解法。通过排序预处理,利用双指针在单次扫描中高效地找到所有解,并巧妙地通过跳过重复值来实现去重,时间复杂度为 O(n²)。

理解从暴力解法到最优解的优化路径,对于培养算法思维至关重要。请务必掌握双指针法的实现细节和去重逻辑。

039:四数之和问题 - 最优方法

在本节课中,我们将学习如何解决“四数之和”问题。这是一个经典的算法问题,要求我们在一个数组中找到所有和为特定目标值的四元组。我们将使用一种基于排序和双指针技术的高效方法来解决它。


问题概述

给定一个整数数组 nums 和一个目标值 target,我们需要找出数组中所有满足 nums[i] + nums[j] + nums[k] + nums[l] == target 的四元组 (i, j, k, l)。其中,四个索引 ijkl 必须互不相同,并且返回的四元组组合必须是唯一的。

例如,对于数组 [-1, -1, 1, 1] 和目标值 0,四元组 (-1, -1, 1, 1) 的和为 0,是一个有效的答案。


核心思路

上一节我们介绍了三数之和问题,本节中我们来看看它的扩展——四数之和。基本思路是将其转化为类似三数之和的问题,然后使用双指针法进行优化。

核心策略如下:

  1. 首先对数组进行排序。
  2. 使用两层外层循环固定前两个数 nums[i]nums[j]
  3. 对于剩下的部分,使用双指针法寻找后两个数 nums[p]nums[q],使得四数之和等于目标值。

算法步骤详解

以下是解决四数之和问题的详细步骤:

1. 排序数组

首先,我们将输入数组 nums 按升序排序。排序是使用双指针法的基础,它允许我们根据和的大小来移动指针。

sort(nums.begin(), nums.end());

2. 外层循环与去重

我们使用两个外层循环来遍历前两个数的所有可能组合。

  • 第一个循环变量 i0 遍历到 n-4
  • 第二个循环变量 ji+1 遍历到 n-3

为了避免重复的四元组,我们需要跳过重复的值:

  • 如果 nums[i] 与前一个值相同,则跳过本次循环。
  • 如果 nums[j] 与前一个值相同,则跳过本次循环。

3. 双指针查找

在固定了 ij 之后,问题就转化为在 j+1 到数组末尾的区间内,寻找两个数 nums[p]nums[q],使得它们的和等于 target - nums[i] - nums[j]

我们初始化两个指针:

  • p = j + 1 (指向剩余区间的最小值)
  • q = n - 1 (指向剩余区间的最大值)

然后在一个 while(p < q) 循环中执行以下逻辑:

  • 计算当前四数之和 sum = nums[i] + nums[j] + nums[p] + nums[q]
  • 如果 sum < target,说明总和太小,需要增大,因此将 p 指针向右移动 (p++)。
  • 如果 sum > target,说明总和太大,需要减小,因此将 q 指针向左移动 (q--)。
  • 如果 sum == target,则找到了一个有效四元组,将其加入答案列表。然后,同样为了去重,我们需要将 p 指针移动到下一个不同的值,将 q 指针移动到前一个不同的值。

4. 返回结果

遍历完所有可能的 ij 的组合后,返回收集到的所有唯一四元组。


代码实现

以下是整合了所有优化步骤的C++代码实现:

vector<vector<int>> fourSum(vector<int>& nums, int target) {
    vector<vector<int>> result;
    int n = nums.size();
    if (n < 4) return result;

    // 1. 排序
    sort(nums.begin(), nums.end());

    for (int i = 0; i < n - 3; ++i) {
        // 跳过重复的 nums[i]
        if (i > 0 && nums[i] == nums[i - 1]) continue;

        for (int j = i + 1; j < n - 2; ++j) {
            // 跳过重复的 nums[j]
            if (j > i + 1 && nums[j] == nums[j - 1]) continue;

            long long twoSumTarget = (long long)target - nums[i] - nums[j];
            int p = j + 1;
            int q = n - 1;

            while (p < q) {
                long long currentSum = (long long)nums[p] + nums[q];

                if (currentSum < twoSumTarget) {
                    p++;
                } else if (currentSum > twoSumTarget) {
                    q--;
                } else {
                    // 找到一组解
                    result.push_back({nums[i], nums[j], nums[p], nums[q]});

                    // 跳过重复的 nums[p]
                    while (p < q && nums[p] == nums[p + 1]) p++;
                    // 跳过重复的 nums[q]
                    while (p < q && nums[q] == nums[q - 1]) q--;

                    p++;
                    q--;
                }
            }
        }
    }
    return result;
}

复杂度分析

  • 时间复杂度:O(n³)。排序需要 O(n log n) 时间。主体部分有两层嵌套循环和一层双指针遍历,最坏情况下复杂度为 O(n³)。因此总体复杂度为 O(n³)。
  • 空间复杂度:O(1) 或 O(n),取决于排序算法的实现。我们只使用了常数额外空间来存储指针和结果(结果空间不计入复杂度分析)。

总结

本节课中我们一起学习了如何解决“四数之和”问题。我们首先理解了问题定义,然后通过将其分解为“固定两个数,再用双指针法找另外两个数”的策略,设计出了一个高效的算法。关键点在于先排序,然后使用两层循环加双指针,并注意在每一步跳过重复值以避免输出重复的组合。这是解决此类“K数之和”问题的通用且高效的模式。

040:子数组和等于K问题详解 🎯

在本节课中,我们将学习一个重要的数据结构与算法问题:“子数组和等于K”。这个问题将教会我们一个特定的概念——前缀和与哈希映射的结合使用,这个概念可以应用于多种不同的DSA问题中。我们将在LeetCode问题560上实践。

问题概述

给定一个整数数组 nums 和一个整数 k,我们需要找出数组中连续子数组的和等于 k 的个数。

核心概念

  • 子数组:数组中的一个连续部分。
  • 目标:计算所有和为 k 的连续子数组的数量。

示例
输入:nums = [9, 4, 20, 3, 10, 5], k = 33
输出:2
解释:和为33的子数组有两个:[9, 4, 20][20, 3, 10]


方法一:优化版暴力解法

上一节我们明确了问题,本节中我们来看看第一种解法——优化版暴力法。其核心思想是枚举所有可能的子数组,并计算它们的和。

思路

  1. 固定子数组的起始点 i
  2. i 开始,逐步扩展结束点 j
  3. 在扩展 j 的同时累加元素,计算当前子数组 nums[i...j] 的和。
  4. 检查当前和是否等于 k,如果是,则计数器加一。

以下是该方法的伪代码描述:

int count = 0;
for (int i = 0; i < n; i++) {
    int currentSum = 0;
    for (int j = i; j < n; j++) {
        currentSum += nums[j]; // 扩展子数组并累加
        if (currentSum == k) {
            count++;
        }
    }
}
return count;

复杂度分析

  • 时间复杂度:O(n²)。外层循环 i 运行 n 次,内层循环 j 平均运行 n/2 次。
  • 空间复杂度:O(1)。只使用了常数级别的额外空间。

这种方法简单直观,但在数组较大时效率较低。面试中,通常需要先提出此法,再优化。


方法二:最优解(前缀和 + 哈希映射)

上一节我们介绍了时间复杂度较高的暴力法,本节中我们来看看最优解。这种方法利用前缀和的概念和哈希映射进行高效查找。

核心概念:前缀和

数组 nums 在索引 i 处的前缀和 prefixSum[i] 定义为从数组开头到 i 的所有元素之和。
prefixSum[i] = nums[0] + nums[1] + ... + nums[i]

重要公式
子数组 nums[i...j] 的和可以通过前缀和快速计算:
sum(nums[i...j]) = prefixSum[j] - prefixSum[i-1] (当 i > 0 时)
如果 i == 0,则 sum(nums[0...j]) = prefixSum[j]

问题转化

我们的目标是找到 sum(nums[i...j]) = k
根据上述公式,这等价于寻找满足以下条件的 ij
prefixSum[j] - prefixSum[i-1] = k
将其变形,得到解决问题的关键:
prefixSum[i-1] = prefixSum[j] - k

这意味着:对于当前的前缀和 prefixSum[j],如果我们在之前的前缀和中,能找到值为 (prefixSum[j] - k) 的前缀和,那么就找到了一个以 j 结尾、和为 k 的子数组。

算法步骤

以下是利用哈希映射实现此思路的步骤:

  1. 初始化一个哈希映射 map,用于存储前缀和的值及其出现的次数。初始时,前缀和为 0 出现了 1 次(表示空数组的情况)。
  2. 初始化变量 currentPrefixSum = 0count = 0
  3. 遍历数组 nums 中的每个元素 num
    a. 更新当前前缀和:currentPrefixSum += num
    b. 计算我们需要在历史中寻找的值:need = currentPrefixSum - k
    c. 检查 need 是否在 map 中。如果在,则 count 增加 map[need](因为每个 need 都对应一个有效的子数组起点)。
    d. 将当前的 currentPrefixSum 加入 map,并将其频率加1。
  4. 遍历结束后,返回 count

以下是该方法的代码实现:

int subarraySum(vector<int>& nums, int k) {
    unordered_map<int, int> prefixSumCount;
    prefixSumCount[0] = 1; // 初始化,前缀和为0出现1次
    int currentPrefixSum = 0;
    int count = 0;

    for (int num : nums) {
        currentPrefixSum += num; // 计算当前前缀和
        int need = currentPrefixSum - k; // 需要查找的历史前缀和

        // 如果 need 在映射中存在,则增加计数
        if (prefixSumCount.find(need) != prefixSumCount.end()) {
            count += prefixSumCount[need];
        }

        // 将当前前缀和加入映射
        prefixSumCount[currentPrefixSum]++;
    }
    return count;
}

复杂度分析

  • 时间复杂度:O(n)。我们只遍历数组一次,哈希映射的插入和查找操作平均时间复杂度为 O(1)。
  • 空间复杂度:O(n)。在最坏情况下,哈希映射可能需要存储 n 个不同的前缀和。

与暴力法相比,此方法用空间换取了时间的大幅提升,是面试中的期望解法。


总结

本节课中我们一起学习了“子数组和等于K”这一经典问题。

  1. 我们首先理解了问题定义:寻找数组中所有和为特定值 k 的连续子数组。
  2. 接着,我们探讨了优化版暴力解法,通过双重循环枚举所有子数组,其时间复杂度为 O(n²)。
  3. 最后,我们深入学习了最优解法,其核心是利用前缀和公式将问题转化为查找历史前缀和 (currentPrefixSum - k),并借助哈希映射实现 O(1) 时间的查找,从而将总时间复杂度降至 O(n)。

前缀和与哈希映射的结合是解决一系列子数组求和问题的强大工具,理解和掌握这个方法对面试和解决实际问题都至关重要。

041:从基础到高级(第一部分) 🌀

在本节课中,我们将要学习数据结构与算法中的一个重要概念——递归。递归在解决各类算法问题时被广泛使用。我们将从基础开始,完整地理解递归的工作原理,并学习如何计算递归算法的时间与空间复杂度。


什么是递归? 🔄

递归是指一个函数直接或间接地调用自身的过程。

在编程中,main函数是程序的入口。假设我们有一个函数A,如果A在其函数体内调用了自身,那么A就是一个递归函数。递归的核心在于,函数通过反复调用自身,将大问题分解为更小的同类问题,直到达到一个可以直接解决的基本情况

递归的数学类比
考虑一个数学函数 f(x) = x³。如果我们计算 f(f(2)),其过程是:
f(f(2)) = f(2³) = f(8) = 8³ = 512
这展示了函数“嵌套”自身的思想,类似于递归中函数调用自身。


递归的基本组成部分

一个完整的递归定义包含两个关键部分:

  1. 基本情况:这是递归的终止条件。它定义了最简单、可以直接求解的问题实例,防止函数无限调用下去。
  2. 递归情况:这是函数调用自身的部分。它将原始问题分解为一个或多个更小的、结构相同的子问题。

第一个递归示例:打印数字

让我们通过一个简单的例子来理解递归。我们将编写一个函数,按顺序打印从1到N的所有数字。

以下是实现思路:
我们定义一个函数 printNumbers(n)。它的逻辑是:要打印1到n,可以先打印1到n-1,然后再打印n

代码实现

void printNumbers(int n) {
    // 基本情况:当n为0时,不再打印,直接返回
    if (n == 0) {
        return;
    }
    // 递归情况:先打印1到n-1
    printNumbers(n - 1);
    // 然后打印当前的数字n
    cout << n << " ";
}

调用过程分析(以n=4为例)

  1. printNumbers(4) 调用 printNumbers(3)
  2. printNumbers(3) 调用 printNumbers(2)
  3. printNumbers(2) 调用 printNumbers(1)
  4. printNumbers(1) 调用 printNumbers(0)
  5. printNumbers(0) 满足基本情况,直接返回。
  6. 控制权返回给 printNumbers(1),它打印 1,然后返回。
  7. 控制权返回给 printNumbers(2),它打印 2,然后返回。
  8. 控制权返回给 printNumbers(3),它打印 3,然后返回。
  9. 控制权返回给 printNumbers(4),它打印 4,然后返回。

最终输出为:1 2 3 4


递归关系

递归关系是用数学方程形式化地描述递归函数的时间复杂度。对于 printNumbers 函数,其递归关系可以表示为:
T(n) = c + T(n-1)
其中 T(n) 表示处理规模为 n 的问题所需的时间,c 是每次递归调用中的常数时间操作(如判断和打印)。

通过展开这个关系式,我们可以推导出其时间复杂度为 O(n)


经典示例:计算阶乘

上一节我们介绍了打印数字的递归示例,本节中我们来看看另一个经典问题——计算阶乘。

阶乘的定义是:n! = n * (n-1) * (n-2) * ... * 1。例如,4! = 4*3*2*1 = 24
我们可以观察到:n! = n * (n-1)!。这正是递归的结构。

递归思路

  • 基本情况0! 定义为 1
  • 递归情况n! = n * (n-1)!

代码实现

int factorial(int n) {
    // 基本情况
    if (n == 0) {
        return 1;
    }
    // 递归情况
    return n * factorial(n - 1);
}

调用栈分析(以n=4为例)
函数调用顺序为 factorial(4) -> factorial(3) -> factorial(2) -> factorial(1) -> factorial(0)
然后逐层返回结果:factorial(0) 返回1,factorial(1) 返回 1*1=1factorial(2) 返回 2*1=2factorial(3) 返回 3*2=6,最终 factorial(4) 返回 4*6=24


递归的时间与空间复杂度分析

理解递归的复杂度对于评估算法效率至关重要。

时间复杂度计算

有两种常用方法:

  1. 使用递归关系:建立如 T(n) = k + T(n-1) 的方程并求解。
  2. 计算总调用次数:时间复杂度 ≈ 总递归调用次数 × 每次调用中的工作量

对于 factorial 函数:

  • 总调用次数为 n+1(从n到0)。
  • 每次调用执行常数时间 O(1) 的操作(乘法和返回)。
  • 因此,总时间复杂度为 (n+1) * O(1) = O(n)

空间复杂度计算

递归的空间复杂度主要取决于调用栈的深度

  • 每次递归调用都会在调用栈中占用一块内存(称为栈帧),用于存储参数、局部变量和返回地址。
  • 空间复杂度 ≈ 递归树的最大深度 × 每个栈帧占用的空间

对于 factorial 函数:

  • 最大深度为 n+1(从n到0)。
  • 每个栈帧占用常数空间 O(1)
  • 因此,总空间复杂度为 O(n)

注意:这是递归带来的额外空间开销(辅助空间)。在非递归(迭代)解法中,可能只需要 O(1) 的额外空间。


另一个示例:计算前N个自然数之和

让我们应用所学知识,用递归计算前N个自然数的和。

递归思路
前n个数的和 = n + (前n-1个数的和)

  • 基本情况:当 n=1 时,和为 1
  • 递归情况sum(n) = n + sum(n-1)

代码实现

int sumOfN(int n) {
    // 基本情况
    if (n == 1) {
        return 1;
    }
    // 递归情况
    return n + sumOfN(n - 1);
}

对于 sumOfN(4),计算过程为:4 + sumOfN(3) -> 4 + (3 + sumOfN(2)) -> 4 + (3 + (2 + sumOfN(1))) -> 4 + (3 + (2 + 1)) = 10

其时间复杂度和空间复杂度同样为 O(n)


总结 📝

本节课中我们一起学习了递归的核心概念:

  1. 递归定义:函数调用自身来解决问题。
  2. 两个必要部分基本情况用于终止递归,递归情况将问题分解为更小的子问题。
  3. 递归示例:我们实现了打印数字、计算阶乘和求和三个经典递归函数。
  4. 复杂度分析:我们学习了如何分析递归算法的时间复杂度(通常为 O(n))和空间复杂度(取决于调用栈深度,通常也为 O(n))。

递归是理解许多高级算法(如分治、回溯、动态规划和树/图遍历)的基石。掌握其基本原理和复杂度分析,将为后续学习更复杂的数据结构与算法打下坚实基础。

042:递归第二部分 - 斐波那契数、二分查找与数组有序性判断 📚

在本节课中,我们将深入学习递归的经典应用。我们将解决三个核心问题:计算斐波那契数列、使用递归实现二分查找,以及判断一个数组是否有序。通过这些问题,我们将进一步理解递归的思维方式和实现细节。


斐波那契数列问题 🐚

上一节我们介绍了递归的基本概念,本节中我们来看看如何用递归解决经典的斐波那契数列问题。

斐波那契数列是一个数列,其中每一项(从第三项开始)都等于前两项之和。其规则可以用以下公式描述:

F(n) = F(n-1) + F(n-2)

例如,数列的前几项是:0, 1, 1, 2, 3, 5, 8, 13...

我们的目标是编写一个递归函数,输入 n,返回第 n 个斐波那契数(通常认为 F(0)=0, F(1)=1)。

递归逻辑与代码实现

以下是解决该问题的递归函数逻辑:

  1. 基准情况(Base Case):当 n 为 0 或 1 时,直接返回 n 本身。
  2. 递归情况(Recursive Case):对于其他 n,返回 fibonacci(n-1)fibonacci(n-2) 之和。

对应的C++代码实现如下:

int fibonacci(int n) {
    // 基准情况
    if (n == 0 || n == 1) {
        return n;
    }
    // 递归情况
    return fibonacci(n-1) + fibonacci(n-2);
}

时间与空间复杂度分析

现在我们来分析上述递归解法的时间复杂度。我们可以将递归调用过程可视化为一棵递归树。

以计算 F(4) 为例,函数调用会像树一样展开。每一层的调用次数呈指数增长(大约为 2^k)。总调用次数是一个几何级数求和。

总调用次数 ≈ 2^0 + 2^1 + 2^2 + ... + 2^(n-1) = 2^n - 1

因此,时间复杂度为 O(2^n)。这是一个非常高的复杂度,意味着对于较大的 n,此方法效率极低。

空间复杂度主要取决于递归调用栈的最大深度,即树的高度 n。因此,空间复杂度为 O(n)


判断数组是否有序问题 📈

接下来,我们使用递归来判断一个给定的数组是否按升序排列。

判断数组是否有序的核心思想是:如果一个数组是升序的,那么对于任意位置 i,都有 arr[i] >= arr[i-1]。我们可以通过比较相邻元素来递归地验证这一点。

递归思路与代码实现

我们将从数组的末尾开始向前检查。函数 isSorted 接收数组和数组大小 n 作为参数。

  1. 基准情况:当 n 为 0 或 1 时,单个元素或空数组自然是有序的,返回 true
  2. 递归步骤:检查最后两个元素 arr[n-1]arr[n-2]
    • 如果 arr[n-1] >= arr[n-2] 成立,则问题缩小为判断前 n-1 个元素是否有序。
    • 否则,数组不是有序的,返回 false

以下是代码实现:

bool isSorted(int arr[], int n) {
    // 基准情况:数组为空或只有一个元素
    if (n == 0 || n == 1) {
        return true;
    }
    // 检查最后两个元素
    if (arr[n-1] < arr[n-2]) {
        return false;
    }
    // 递归检查前 n-1 个元素
    return isSorted(arr, n-1);
}

复杂度分析

  • 时间复杂度:函数最多被调用 n 次(从 n 递归到 1),每次调用执行常数时间操作。因此,时间复杂度为 O(n)
  • 空间复杂度:递归调用栈的最大深度为 n。因此,空间复杂度为 O(n)

递归实现二分查找问题 🔍

最后,我们探讨如何使用递归来实现高效的二分查找算法。

二分查找的前提是数组必须是有序的。其核心思想是不断将搜索范围对半分割,通过与中间元素的比较来决定下一步搜索左半部分还是右半部分。

递归逻辑与代码实现

我们将实现一个辅助函数来进行递归搜索,它需要数组、目标值、起始索引 start 和结束索引 end 作为参数。

  1. 基准情况:如果 start > end,说明搜索范围无效,未找到目标,返回 -1
  2. 计算中间索引mid = start + (end - start) / 2 (防止整数溢出)。
  3. 比较与递归
    • 如果 arr[mid] == target,找到目标,返回 mid
    • 如果 arr[mid] > target,目标在左半部分,递归搜索 (start, mid-1) 范围。
    • 如果 arr[mid] < target,目标在右半部分,递归搜索 (mid+1, end) 范围。

以下是完整的代码实现,包含一个对用户友好的主函数接口:

// 递归辅助函数
int binarySearchRecursive(int arr[], int target, int start, int end) {
    // 基准情况:搜索范围无效
    if (start > end) {
        return -1;
    }
    int mid = start + (end - start) / 2;
    if (arr[mid] == target) {
        return mid;
    } else if (arr[mid] > target) {
        // 搜索左半部分
        return binarySearchRecursive(arr, target, start, mid - 1);
    } else {
        // 搜索右半部分
        return binarySearchRecursive(arr, target, mid + 1, end);
    }
}
// 主函数接口
int binarySearch(int arr[], int size, int target) {
    return binarySearchRecursive(arr, target, 0, size - 1);
}

复杂度分析

  • 时间复杂度:每次递归调用都将搜索范围减半。因此,最多需要进行 log₂(n) 次递归调用,每次调用执行常数时间操作。时间复杂度为 O(log n)
  • 空间复杂度:递归调用栈的最大深度同样约为 log₂(n)。因此,空间复杂度为 O(log n)

总结 🎯

本节课中我们一起学习了递归在三个经典问题中的应用:

  1. 斐波那契数列:我们实现了直观的递归解法,但其时间复杂度为 O(2ⁿ),效率低下,揭示了递归可能带来的性能问题。
  2. 判断数组有序性:我们利用递归分解问题,每次比较相邻元素,实现了时间复杂度为 O(n) 的解法。
  3. 二分查找:我们使用递归实现了高效的搜索算法,其时间复杂度为 O(log n),展示了递归在分治算法中的强大能力。

通过对比这三个问题,我们深刻体会到:虽然递归代码通常简洁易懂,但必须仔细分析其时间与空间复杂度。在有些情况下(如斐波那契数列的朴素递归),递归可能并非最优选择,需要考虑迭代或记忆化(Memoization)等优化技术。理解递归树是分析递归复杂度的关键工具。

持续练习这些基础问题,将为你解决更复杂的数据结构与算法挑战打下坚实的基础。

043:递归第三部分-回溯详解-打印所有子集-子集II

概述

在本节课中,我们将深入学习递归中的回溯概念,并详细讲解基于子集的问题。这些知识对于求职和实习面试至关重要。我们将从打印给定字符串或数组的所有子集这一基本问题开始,逐步深入到处理包含重复元素的数组,并生成所有不重复的子集。

打印所有子集

上一节我们介绍了递归的基本概念,本节中我们来看看如何使用回溯法生成一个集合的所有子集。

核心思想是:对于集合中的每一个元素,我们都有两种选择:包含它或排除它。通过递归地做出这些选择,我们可以遍历所有可能的组合。

我们可以用一个索引 i 来跟踪当前正在处理的元素。递归函数的基本结构如下:

void printSubsets(vector<int>& nums, vector<int>& answer, int i) {
    // 函数体
}

以下是实现步骤:

  1. 基础情况:当索引 i 等于数组 nums 的大小时,表示我们已经处理完所有元素,此时打印 answer 中存储的当前子集。
  2. 包含当前元素:将 nums[i] 加入 answer,然后递归调用函数处理下一个元素(i+1)。
  3. 回溯:在递归调用返回后,我们需要撤销上一步的选择,即从 answer 中移除刚才添加的 nums[i](例如使用 answer.pop_back())。这一步就是回溯,它确保我们能够探索其他分支。
  4. 排除当前元素:不将 nums[i] 加入 answer,直接递归调用函数处理下一个元素(i+1)。

通过这种方式,递归树会生成所有 2^n 个子集。时间复杂度为 O(n * 2^n),其中 n 是数组长度。

返回所有子集(无重复元素)

现在,我们不再打印子集,而是将它们存储在一个集合中并返回。这被称为求解幂集

逻辑与打印所有子集几乎完全相同,唯一的区别是我们将结果存储在一个 vector<vector<int>> 中,而不是直接打印。

以下是修改后的函数签名和关键步骤:

void getAllSubsets(vector<int>& nums, vector<int>& answer, int i, vector<vector<int>>& allSubsets) {
    // 函数体
}

实现步骤:

  1. 当到达基础情况(i == nums.size())时,将当前 answer 添加到 allSubsets 中。
  2. 包含当前元素:answer.push_back(nums[i]),递归调用 getAllSubsets(nums, answer, i+1, allSubsets)
  3. 回溯:answer.pop_back()
  4. 排除当前元素:递归调用 getAllSubsets(nums, answer, i+1, allSubsets)

主函数初始化一个空的 answerallSubsets,然后调用此递归函数,最后返回 allSubsets。时间复杂度仍为 O(n * 2^n)

子集II(处理重复元素)

上一节我们处理了元素不重复的情况,本节中我们来看看当输入数组可能包含重复元素时,如何生成所有不重复的子集。

如果直接使用之前的方法,重复的元素会导致生成重复的子集。例如,数组 [1,2,2] 会生成两个 [1,2] 子集。

解决这个问题的关键在于排序跳过重复分支

  1. 排序:首先对输入数组 nums 进行排序,使得所有相同的元素相邻。
  2. 跳过逻辑:在“排除当前元素”的递归分支中,如果发现下一个要处理的元素 (nums[i+1]) 与当前元素 (nums[i]) 相同,那么直接跳过这些重复元素,直到找到一个不同的元素再开始递归。这样可以避免生成重复的子集。

以下是修改后的排除步骤伪代码:

// 排除当前元素 nums[i] 后
int nextIndex = i + 1;
// 跳过所有与 nums[i] 相同的元素
while (nextIndex < nums.size() && nums[nextIndex] == nums[i]) {
    nextIndex++;
}
// 从第一个不重复的元素开始递归
getAllSubsets(nums, answer, nextIndex, allSubsets);

包含当前元素的步骤保持不变。通过这种剪枝操作,我们确保了结果集中每个子集都是唯一的。时间复杂度在排序后为 O(n log n + n * 2^n)

总结

本节课中我们一起学习了回溯法的核心应用——生成子集。

  1. 我们首先学习了如何使用“包含/排除”的递归模型打印一个集合的所有子集,并理解了回溯pop_back)在这一过程中的关键作用。
  2. 接着,我们将打印逻辑改为存储逻辑,实现了返回幂集的函数。
  3. 最后,我们解决了更复杂的“子集II”问题,通过先排序,然后在递归的排除分支中跳过重复元素,高效地生成了所有不重复的子集。

理解并掌握这种回溯框架对于解决许多组合、排列和搜索问题都至关重要。

044:数组与字符串的排列(递归与回溯)

在本节课中,我们将学习如何使用递归与回溯算法解决一个核心问题:生成数组或字符串的所有可能排列。理解排列是掌握递归思想的关键一步。

概述

排列是指将一组元素进行所有可能的顺序安排。例如,对于元素 [1, 2, 3],其所有排列包括 [1,2,3][1,3,2][2,1,3] 等。对于 n 个不同的元素,其排列总数是 n的阶乘,数学公式表示为:

n! = n × (n-1) × (n-2) × ... × 2 × 1

本节课我们将以LeetCode第46题“全排列”为例,详细讲解其递归与回溯的实现逻辑。

问题分析与思路

我们的目标是编写一个函数,输入一个不含重复数字的数组,返回其所有可能的全排列。

我们可以将生成排列的过程想象成填充一系列空位。假设有 n 个空位需要填充 n 个不同的数字:

  • 第一个空位有 n 种选择。
  • 选定一个数字后,第二个空位剩下 n-1 种选择。
  • 以此类推,直到最后一个空位只有 1 种选择。

这个过程天然适合用递归来模拟。每一次递归调用负责填充一个特定位置(索引)。关键在于,我们需要一种方法来标记哪些数字已经被使用过,以确保排列中元素的唯一性。

一种高效的方法是原地交换,而不使用额外的存储空间来标记。思路如下:

  1. 对于当前需要填充的位置 index,我们将数组中从 index 到末尾的每一个元素,依次交换到 index 这个位置上。
  2. 每次交换后,我们固定了 index 位置的元素,然后递归地处理下一个位置 index + 1
  3. 当递归调用返回后,我们需要进行回溯,即将交换过的元素再交换回来,恢复数组状态,以便进行下一个选择。

算法实现步骤

以下是实现排列生成的核心步骤。

  1. 定义主函数:创建一个函数 permute,接收输入数组 nums
  2. 初始化结果容器:创建一个二维向量 ans 用于存储所有排列结果。
  3. 调用递归辅助函数:从第一个位置(索引0)开始调用递归函数。
  4. 实现递归辅助函数
    • 基准情况:如果当前索引 index 等于数组长度 n,说明所有位置都已填满,将当前数组 nums 的一个副本存入结果 ans
    • 递归情况:遍历 indexn-1 的每个位置 i
      • 交换 nums[index]nums[i],将 nums[i] 固定到 index 位置。
      • 递归调用函数处理下一个位置 index + 1
      • 回溯:再次交换 nums[index]nums[i],将数组恢复原状,以便进行下一轮循环尝试其他元素。

代码实现

根据上述思路,具体的C++实现代码如下。

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/45b8a528bb3f740d1ad1baae361522f9_3.png)

class Solution {
private:
    void solve(vector<int>& nums, int index, vector<vector<int>>& ans) {
        // 基准情况:所有位置都已确定,保存当前排列
        if (index >= nums.size()) {
            ans.push_back(nums);
            return;
        }
        // 递归情况:为当前位置 index 尝试所有可能的数字
        for (int i = index; i < nums.size(); i++) {
            // 将 nums[i] 交换到 index 位置
            swap(nums[index], nums[i]);
            // 递归处理下一个位置
            solve(nums, index + 1, ans);
            // 回溯:撤销交换,恢复状态
            swap(nums[index], nums[i]);
        }
    }
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ans;
        int index = 0;
        solve(nums, index, ans);
        return ans;
    }
};

复杂度分析

理解算法效率对于评估解决方案至关重要。

  • 时间复杂度O(n × n!)。总共有 n! 种排列,而生成每种排列我们需要 O(n) 的时间(例如,复制数组到结果中)。因此总时间复杂度为 O(n × n!)
  • 空间复杂度O(n!)。主要用于存储输出结果,需要容纳 n! 个长度为 n 的排列。递归调用栈的深度为 O(n),但通常不计入返回答案所需的空间。

延伸练习:字符串排列

我们已经学会了如何生成数字数组的排列。现在,我们可以将完全相同的逻辑应用到字符串上。

以下是给你的一个练习:编写一个函数,输入一个字符串(如 "ABC"),打印或返回其所有可能的排列。你可以尝试修改上面的代码,将 vector<int> 替换为 string,其核心的交换与回溯思想完全一致。

通过完成这个练习,你将能巩固对递归回溯生成排列的理解。

总结

本节课我们一起学习了排列问题的递归与回溯解法。我们首先理解了排列的数学定义 n!,然后通过“填充空位”的模型将问题分解。我们重点掌握了通过交换元素进行选择以及递归后必须回溯的核心技巧,并实现了完整的代码。最后,我们还探讨了如何将此法应用于字符串。掌握这个模式是深入理解递归算法的重要基石。

045:N皇后问题与回溯法 🏰

在本节课中,我们将学习一个经典的递归与回溯问题——N皇后问题。我们将深入理解回溯法的核心概念,并编写代码来解决这个LeetCode上的困难题目。


概述

N皇后问题要求在一个N×N的棋盘上放置N个皇后,使得它们彼此之间无法相互攻击。皇后在象棋中可以攻击同一行、同一列以及两条对角线上的任何棋子。因此,我们需要找到所有可能的皇后布局,确保任意两个皇后都不在同一行、同一列或同一对角线上。

问题理解与逻辑

首先,我们理解棋盘和皇后的规则。棋盘是N×N的网格。皇后的攻击范围覆盖其所在的行、列以及两条对角线。

我们的目标是:在每一行放置一个皇后,并且确保新放置的皇后与之前放置的所有皇后都不冲突。

核心思路是使用回溯法:

  1. 从第一行(row = 0)开始尝试。
  2. 在当前行中,遍历每一列(col = 0n-1),尝试将皇后放在该位置。
  3. 在放置前,检查该位置是否安全(即不与之前放置的皇后冲突)。
  4. 如果安全,则在该位置放置皇后,并递归地处理下一行(row + 1)。
  5. 如果递归调用成功找到了一个完整解(即所有行都成功放置了皇后),则记录当前棋盘布局。
  6. 如果递归调用失败(即下一行无法找到安全位置),或者当前列的所有位置都不安全,则进行“回溯”:撤销当前行在当前列的放置(将棋盘该位置恢复为空),然后尝试当前行的下一列。
  7. 当第一行的所有列都尝试完毕后,算法结束,我们就找到了所有可能的解。

代码实现框架

以下是解决N皇后问题的主函数框架:

void solveNQueens(int n, vector<vector<string>>& ans) {
    vector<string> board(n, string(n, '.')); // 初始化棋盘,所有位置为‘.’
    backtrack(board, 0, n, ans); // 从第0行开始回溯
}

void backtrack(vector<string>& board, int row, int n, vector<vector<string>>& ans) {
    if (row == n) { // 所有行都已成功放置皇后,找到一个解
        ans.push_back(board);
        return;
    }
    // 尝试在当前行的每一列放置皇后
    for (int col = 0; col < n; col++) {
        if (isSafe(board, row, col, n)) { // 检查位置是否安全
            board[row][col] = 'Q'; // 放置皇后
            backtrack(board, row + 1, n, ans); // 递归处理下一行
            board[row][col] = '.'; // 回溯,撤销放置
        }
    }
}

安全检查函数

安全检查是算法的关键。对于一个给定位置 (row, col),我们需要检查三个方向:

  1. 垂直方向(列):检查当前列 col0row-1 行是否已有皇后。
  2. 水平方向(行):由于我们每行只放一个皇后后立即进入下一行,所以同一行不会出现两个皇后,此行检查可省略。但为了逻辑清晰,可以检查当前行 row0col-1 列是否已有皇后(虽然我们的放置方式不会触发此情况)。
  3. 对角线方向:有两条对角线需要检查。
    • 左上对角线:行索引和列索引同时递减。检查从 (row-1, col-1) 开始,直到棋盘边界,看是否有皇后。
    • 右上对角线:行索引递减,列索引递增。检查从 (row-1, col+1) 开始,直到棋盘边界,看是否有皇后。

以下是安全检查函数 isSafe 的实现:

bool isSafe(vector<string>& board, int row, int col, int n) {
    // 检查同一列上方是否有皇后
    for (int i = 0; i < row; i++) {
        if (board[i][col] == 'Q') return false;
    }
    // 检查左上对角线是否有皇后
    for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
        if (board[i][j] == 'Q') return false;
    }
    // 检查右上对角线是否有皇后
    for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
        if (board[i][j] == 'Q') return false;
    }
    return true; // 位置安全
}

完整代码与执行

将上述 solveNQueensbacktrackisSafe 函数组合起来,就构成了N皇后问题的完整解决方案。当 n=4 时,程序会找到并输出所有不互相攻击的皇后布局。

例如,n=4 的一个有效解是:

[“.Q..”, “…Q”, “Q…”, “..Q.”]

这表示在第0行第1列、第1行第3列、第2行第0列、第3行第2列放置皇后。

总结

本节课我们一起学习了如何使用回溯法解决经典的N皇后问题。我们掌握了以下核心步骤:

  1. 递归框架:按行放置皇后,递归探索每一行的每一列。
  2. 回溯思想:当当前路径无法得到解时,撤销上一步操作,尝试其他可能性。
  3. 状态检查:实现 isSafe 函数来确保皇后放置位置的安全性,包括列冲突和对角线冲突的检查。

通过这个例子,我们深刻理解了回溯算法“尝试-失败-回退-再尝试”的精髓。这是一个解决约束满足问题和组合优化问题的强大工具。与此类似的问题还有“N骑士”问题等,你可以尝试用学到的回溯法思路去解决它们。

继续练习,保持探索,你会在算法学习的道路上越走越远。

046:数独求解器问题 - 使用回溯法

在本节课中,我们将学习如何使用递归和回溯算法解决一个经典的难题:数独求解器。这是一个LeetCode上的困难题目,编号为37。我们将从理解问题规则开始,逐步构建解决方案,最终实现一个能够填充并解决任何有效数独谜题的程序。

概述

数独是一个9x9的网格,部分单元格已预先填入数字(1-9)。我们的目标是填充所有空白单元格(用 ‘.’ 表示),使得网格满足以下三个规则:

  1. 每一行必须包含数字1-9,且不重复。
  2. 每一列必须包含数字1-9,且不重复。
  3. 每个3x3的子网格必须包含数字1-9,且不重复。

我们将使用回溯法来系统地尝试所有可能的数字组合,直到找到唯一解。

核心思路与伪代码

解决此问题的核心是递归和回溯。基本思路是遍历网格中的每个单元格,如果它是空的,我们就尝试填入一个安全的数字(1-9),然后递归地解决剩下的网格。如果后续递归失败(即找不到解),我们就回溯,撤销当前选择,尝试下一个数字。

以下是解决函数的主要逻辑框架:

bool solveSudoku(vector<vector<char>>& board) {
    return helper(board, 0, 0); // 从左上角(0,0)开始
}

bool helper(vector<vector<char>>& board, int row, int col) {
    // 基础情况与递归逻辑将在这里实现
}

helper 函数中,我们需要处理几种情况。

处理已填充的单元格

如果当前单元格 (row, col) 已经有一个数字(不是 ‘.’),我们不需要尝试填充它,直接跳到下一个单元格继续求解。

if(board[row][col] != ‘.’) {
    // 计算下一个单元格的位置并递归调用
    return helper(board, nextRow, nextCol);
}

到达网格末尾

当我们成功处理完最后一行最后一列的单元格(即 row == 9)时,意味着整个网格已被正确填充,我们找到了一个解,应返回 true

if(row == 9) {
    return true; // 所有单元格已成功填充
}

尝试填充数字

对于空白单元格,我们循环尝试数字1到9。对于每个数字,首先检查将其放入当前单元格是否安全(即不违反数独规则)。如果安全,则放入该数字,然后递归调用以解决剩余的网格。如果递归调用返回 true,说明我们找到了解,可以一路返回 true。如果返回 false,说明当前选择导致死路,我们需要回溯:将当前单元格重置为 ‘.’,然后尝试下一个数字。

for(char digit = ‘1’; digit <= ‘9’; digit++) {
    if(isSafe(board, row, col, digit)) {
        board[row][col] = digit; // 做出选择
        if(helper(board, nextRow, nextCol)) {
            return true; // 如果后续递归成功,返回成功
        }
        board[row][col] = ‘.’; // 回溯,撤销选择
    }
}
// 如果1-9所有数字都尝试过且都不行,返回false
return false;

实现安全检查函数

isSafe 函数是算法的关键,它检查将数字 digit 放入位置 (row, col) 是否违反数独的三条规则。

检查行和列

我们遍历当前行和当前列,检查 digit 是否已经存在。

// 检查行
for(int j=0; j<9; j++) {
    if(board[row][j] == digit) return false;
}
// 检查列
for(int i=0; i<9; i++) {
    if(board[i][col] == digit) return false;
}

检查3x3子网格

这是稍微复杂的一部分。我们需要确定当前单元格属于哪个3x3子网格,然后遍历该子网格中的所有单元格。

每个子网格的起始行和起始列可以通过以下公式计算:

  • startRow = (row / 3) * 3
  • startCol = (col / 3) * 3

例如,对于位置 (4, 4)

  • startRow = (4 / 3) * 3 = 1 * 3 = 3
  • startCol = (4 / 3) * 3 = 1 * 3 = 3
    因此它属于以 (3, 3) 为左上角的子网格。

确定起始点后,我们遍历这个3x3区域:

int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for(int i=startRow; i<=startRow+2; i++) {
    for(int j=startCol; j<=startCol+2; j++) {
        if(board[i][j] == digit) return false;
    }
}

如果以上所有检查都通过,则说明放置是安全的,函数返回 true

完整代码实现

结合以上所有部分,以下是完整的C++解决方案:

class Solution {
public:
    void solveSudoku(vector<vector<char>>& board) {
        helper(board, 0, 0);
    }

    bool helper(vector<vector<char>>& board, int row, int col) {
        // 基础情况:已处理完所有行
        if(row == 9) {
            return true;
        }
        // 基础情况:已处理完当前行的所有列,转到下一行
        if(col == 9) {
            return helper(board, row+1, 0);
        }
        // 如果当前单元格已有数字,跳过
        if(board[row][col] != ‘.’) {
            return helper(board, row, col+1);
        }

        // 尝试填入数字1-9
        for(char digit = ‘1’; digit <= ‘9’; digit++) {
            if(isSafe(board, row, col, digit)) {
                board[row][col] = digit; // 放置数字
                if(helper(board, row, col+1)) { // 递归解决下一个单元格
                    return true; // 如果成功,返回true
                }
                board[row][col] = ‘.’; // 回溯
            }
        }
        return false; // 无解
    }

    bool isSafe(vector<vector<char>>& board, int row, int col, char digit) {
        // 检查行
        for(int j=0; j<9; j++) {
            if(board[row][j] == digit) return false;
        }
        // 检查列
        for(int i=0; i<9; i++) {
            if(board[i][col] == digit) return false;
        }
        // 检查3x3子网格
        int startRow = (row / 3) * 3;
        int startCol = (col / 3) * 3;
        for(int i=startRow; i<=startRow+2; i++) {
            for(int j=startCol; j<=startCol+2; j++) {
                if(board[i][j] == digit) return false;
            }
        }
        return true; // 安全
    }
};

算法复杂度分析

该回溯算法的时间复杂度在最坏情况下非常高,因为我们需要尝试所有可能的数字组合。对于一个有 N 个空格的数独,每个空格有最多9种选择,因此时间复杂度可以近似为 O(9^N)。然而,由于数独规则的限制(行、列、子网格约束),实际尝试的分支会远少于这个上界。空间复杂度主要为递归调用栈的深度,即 O(N)

总结

本节课中,我们一起学习了如何使用递归和回溯法解决LeetCode上的数独求解器问题。我们首先理解了问题的规则,然后设计了递归函数来遍历和填充网格。我们实现了关键的安全检查函数 isSafe,用于确保每次放置数字都符合数独的三大规则。最后,我们整合代码并分析了算法复杂度。回溯法是解决此类约束满足问题的强大工具,通过“尝试-失败-回溯”的机制系统地搜索所有可能的解。掌握这个问题的解法,对于深入理解递归和回溯思想非常有帮助。

047:迷宫中的老鼠问题 - 回溯法 🐀

在本节课中,我们将学习一个重要的递归与回溯问题——“迷宫中的老鼠”问题。我们将理解问题定义,学习回溯法的核心思想,并一步步编写代码来找出老鼠从迷宫左上角到右下角的所有可能路径。


问题概述 🧩

问题“迷宫中的老鼠”描述如下:

  • 给定一个 N x N 的迷宫(矩阵)。
  • 老鼠起始于左上角单元格 (0, 0)
  • 目标点是右下角单元格 (N-1, N-1)
  • 矩阵中的值 1 表示可通行的单元格,0 表示障碍物(墙)。
  • 老鼠每次只能向四个方向移动:下 (D)右 (R)上 (U)左 (L)
  • 任务是找出所有从起点到终点的可能路径。

核心思路:回溯法 🔄

上一节我们明确了问题,本节中我们来看看解决方案的核心思路——回溯法。

回溯法是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是解(或者至少不是最后一个解),回溯算法会丢弃该解,并在上一步进行回退,尝试其他的可能性。

对于迷宫问题,我们的思路是:

  1. 从起点 (0, 0) 开始。
  2. 尝试向所有允许的方向(下、右、上、左)移动。
  3. 每移动到一个新单元格,就将其标记为“已访问”。
  4. 递归地从新单元格继续探索。
  5. 如果到达终点 (N-1, N-1),则记录当前路径。
  6. 如果从某个单元格出发的所有方向都探索完毕(或不可行),则回溯:取消标记该单元格为“未访问”,并返回到上一个单元格尝试其他方向。

算法设计与实现 ⚙️

理解了回溯思想后,我们来设计具体的递归函数。

递归函数设计

我们将创建一个递归辅助函数,它需要以下参数:

  • maze:原始的迷宫矩阵。
  • row, col:老鼠当前所在的单元格坐标。
  • path:一个字符串,记录从起点到当前单元格的移动序列(由 ‘D‘, ‘R‘, ‘U‘, ‘L‘ 组成)。
  • answer:一个字符串向量,用于存储所有找到的完整路径。

此外,为了避免老鼠在路径中绕圈子(重复访问单元格),我们需要一个机制来标记已访问的单元格。有两种常见方法:

  1. 创建一个独立的 N x N 布尔矩阵 visited
  2. 直接修改原始迷宫矩阵,将访问过的单元格值从 1 改为另一个值(例如 0-1),回溯时再改回来。

为了节省空间,我们采用第二种方法。

递归的步骤(伪代码)

以下是递归函数 findPath 的逻辑步骤:

function findPath(maze, row, col, path, answer):
    // 1. 基础情况:检查是否越界或遇到障碍/已访问单元格
    if (row < 0 || col < 0 || row >= N || col >= N || maze[row][col] != 1):
        return

    // 2. 基础情况:检查是否到达终点
    if (row == N-1 && col == N-1):
        answer.push_back(path) // 找到一条路径,存入结果
        return

    // 3. 标记当前单元格为已访问
    maze[row][col] = -1

    // 4. 尝试所有四个可能的移动方向
    // 向下移动
    findPath(maze, row+1, col, path + ‘D‘, answer)
    // 向右移动
    findPath(maze, row, col+1, path + ‘R‘, answer)
    // 向上移动
    findPath(maze, row-1, col, path + ‘U‘, answer)
    // 向左移动
    findPath(maze, row, col-1, path + ‘L‘, answer)

    // 5. 回溯:取消标记当前单元格,以便其他路径可以访问
    maze[row][col] = 1

代码实现

根据以上设计,以下是完整的C++实现代码:

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

class Solution {
public:
    vector<string> findPath(vector<vector<int>>& maze) {
        vector<string> answer;
        string path = "";
        // 从起点(0, 0)开始递归搜索
        search(maze, 0, 0, path, answer);
        return answer;
    }

private:
    void search(vector<vector<int>>& grid, int row, int col, string path, vector<string>& ans) {
        int n = grid.size();

        // 1. 边界条件与有效性检查
        if (row < 0 || col < 0 || row >= n || col >= n || grid[row][col] != 1) {
            return;
        }

        // 2. 到达终点,记录路径
        if (row == n - 1 && col == n - 1) {
            ans.push_back(path);
            return;
        }

        // 3. 标记当前单元格为已访问
        grid[row][col] = -1;

        // 4. 探索四个方向
        // 向下
        search(grid, row + 1, col, path + ‘D‘, ans);
        // 向右
        search(grid, row, col + 1, path + ‘R‘, ans);
        // 向上
        search(grid, row - 1, col, path + ‘U‘, ans);
        // 向左
        search(grid, row, col - 1, path + ‘L‘, ans);

        // 5. 回溯:恢复单元格状态
        grid[row][col] = 1;
    }
};

// 主函数用于测试
int main() {
    Solution sol;
    vector<vector<int>> maze = {
        {1, 0, 0, 0},
        {1, 1, 0, 1},
        {1, 1, 0, 0},
        {0, 1, 1, 1}
    };

    vector<string> paths = sol.findPath(maze);
    cout << "所有可能的路径:" << endl;
    for (const string& p : paths) {
        cout << p << endl;
    }
    return 0;
}

复杂度分析 📊

  • 时间复杂度:在最坏情况下,每个单元格都有4个方向可以选择。因此,时间复杂度可以近似为 O(4^(N²))。这是一个指数级复杂度,但对于N不大的迷宫是可行的。实际由于障碍物和边界限制,复杂度会低很多。
  • 空间复杂度:主要是递归调用栈的深度。在最坏情况下,路径可能遍历几乎所有单元格,因此递归深度为 O(N²)。我们修改了原矩阵,没有使用额外的 visited 矩阵,所以额外空间复杂度为 O(N²)(递归栈空间)。

总结与要点 🎯

本节课中我们一起学习了“迷宫中的老鼠”问题及其回溯解法。

以下是关键要点回顾:

  • 回溯法是解决此类需要找出所有可能解问题的强大工具。
  • 算法的核心是:尝试 -> 递归 -> 回溯
  • 使用修改原矩阵值的方法来标记访问状态,可以节省一个 visited 数组的空间。
  • 递归函数必须妥善处理边界条件终止条件状态恢复(回溯)
  • 该问题的时间复杂度较高,但对于规模有限的网格是有效的解决方案。

理解这个问题的解法,对于掌握回溯算法模板至关重要。你可以尝试修改问题条件(例如,允许对角线移动,或者要求找出最短路径),来进一步巩固所学知识。

048:组合总和问题(递归与回溯)🎯

在本节课中,我们将学习一个重要的递归与回溯问题——组合总和。我们将通过一个具体例子,详细解析如何生成所有可能的数字组合,使其总和等于给定的目标值。理解这个问题对于掌握递归、回溯以及如何处理重复元素等核心概念至关重要。


问题概述与示例 📋

组合总和问题要求我们:给定一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中所有可以使数字和等于 target不同组合candidates 中的同一个数字可以无限制重复被选取

例如,给定数组 candidates = [2, 3, 5] 和目标值 target = 8,所有可能的解为:

  • [2, 2, 2, 2]
  • [2, 3, 3]
  • [3, 5]

我们的任务是编写一个函数,返回所有这些组合的列表。


核心思路:为每个元素做出选择 🤔

解决此问题的关键在于理解,对于数组中的每一个元素,在构建组合时我们都有几种选择。

上一节我们介绍了问题的定义,本节中我们来看看如何通过“选择”来构建解。

对于数组中的任意一个元素,我们面临三种基本选择:

  1. 包含一次:将当前元素加入当前组合,然后移动到下一个元素继续决策。
  2. 包含多次:将当前元素加入当前组合,但停留在当前元素,以便后续可以再次选择它。
  3. 不包含:跳过当前元素,直接移动到下一个元素

通过递归地应用这些选择,我们可以探索所有可能的组合路径。


递归树的可视化 🌳

让我们以 candidates = [2, 3, 5], target = 8 为例,可视化递归过程。我们从索引 i = 0(元素 2)开始。

以下是决策树的简化表示:

  • 在根节点,我们对元素 2 做出三种选择。
  • 选择“包含一次”后,我们移动到元素 3,并对它做同样的三种选择。
  • 选择“包含多次”后,我们仍停留在元素 2,可以再次对它做选择。
  • 选择“不包含”后,我们直接移动到元素 3

在递归过程中,我们需要维护两个关键状态:

  • target:剩余需要达到的目标和。每次包含一个元素,我们就从 target 中减去该元素的值。公式表示为:新target = 原target - candidates[i]
  • combination:当前正在构建的组合列表。

递归的终止条件(Base Case)🚩

递归不能无限进行下去,我们需要定义明确的终止条件。

当我们在递归树中探索时,遇到以下两种情况就应该返回(回溯):

  1. target == 0:这意味着当前 combination 中的数字和正好等于原始目标值。我们找到了一个有效解,需要将其保存到最终答案列表中。
  2. target < 0:这意味着当前路径上选择的数字和已经超过了目标值。由于题目说明所有数字都是正数,继续向下探索只会让和更大,因此这条路径不可能产生有效解,应该立即剪枝返回。

回溯的关键步骤 🔄

回溯是递归算法中清理“现场”的重要步骤。在我们尝试了某条路径(例如选择包含某个元素)并返回后,在尝试其他选择之前,必须撤销之前选择造成的影响。

具体到代码中,当我们通过 combination.push_back(candidates[i]) 将元素加入当前组合后,在从该递归分支返回时,需要执行 combination.pop_back() 将该元素移除。这样才能保证 combination 状态正确,以便尝试“不包含”或其他选择。


算法伪代码与实现 🖥️

基于以上思路,我们可以勾勒出算法的骨架。

以下是核心递归函数的伪代码描述:

// 辅助递归函数
void getCombinations(vector<int>& candidates, int target, int index, vector<int>& current, vector<vector<int>>& result) {
    // 基础情况1:找到有效组合
    if (target == 0) {
        result.push_back(current);
        return;
    }
    // 基础情况2:已超出目标值,或已遍历完数组
    if (target < 0 || index >= candidates.size()) {
        return;
    }

    // 选择1:包含当前元素一次,然后处理下一个元素
    current.push_back(candidates[index]);
    getCombinations(candidates, target - candidates[index], index + 1, current, result);
    current.pop_back(); // 回溯

    // 选择2:包含当前元素多次,仍处理当前元素
    current.push_back(candidates[index]);
    getCombinations(candidates, target - candidates[index], index, current, result); // 注意index不变
    current.pop_back(); // 回溯

    // 选择3:不包含当前元素,直接处理下一个元素
    getCombinations(candidates, target, index + 1, current, result);
}

在实际编写代码时,我们通常会将“包含一次”和“包含多次”的逻辑合并,通过循环或更简洁的递归调用来实现。同时,需要初始化并调用这个辅助函数。


处理重复组合与优化 💡

由于我们允许数字重复使用,并且对每个元素独立做决策,上述方法可能会产生顺序不同但元素相同的重复组合(例如 [2,3,3][3,2,3])。

为了保证结果的唯一性,一个简单的方法是在将组合加入最终结果列表前,先对其进行排序,然后使用集合(set)进行去重。但更高效的方法是在递归时控制搜索起点,避免生成重复的路径,这通常通过传递一个 startIndex 参数来实现。


总结与要点回顾 📝

本节课中我们一起学习了如何用递归与回溯解决组合总和问题。

我们掌握了以下核心要点:

  • 问题核心:为数组中的每个元素定义“包含一次”、“包含多次”和“不包含”的选择。
  • 递归状态:通过 target 和当前 combination 来定义递归的当前状态。
  • 终止条件:当 target == 0(找到解)或 target < 0(路径无效)时返回。
  • 回溯操作:在递归调用返回后,使用 pop_back() 撤销选择,这是回溯算法的精髓。
  • 去重处理:需要注意并处理可能产生的重复组合,确保结果唯一。

理解这个问题是学习更复杂回溯问题(如排列、子集、N皇后等)的坚实基础。通过清晰地定义选择、状态和终止条件,你可以将这种模式应用到许多类似的搜索问题中。

049:回文分割问题 - 递归与回溯

在本节课中,我们将学习一个重要的递归与回溯问题:回文分割。我们将理解如何将一个给定的字符串分割成若干子串,使得每个子串都是一个回文串,并找出所有可能的分割方案。

概述

问题“回文分割”要求我们给定一个字符串 s,将 s 分割成一些子串,使得每个子串都是回文串。我们需要返回所有可能的分割方案。例如,字符串 "aab" 的可能回文分割是 [["a","a","b"], ["aa","b"]]。这是一个经典的递归回溯问题,也是 LeetCode 第 131 题。

问题理解

对于一个长度为 n 的字符串,我们可以在字符之间进行“切割”。总共有 n-1 个潜在的切割位置。我们的目标是尝试所有可能的切割组合,但只保留那些使得每一段子串都是回文串的组合。

上一节我们概述了问题,本节中我们来看看解决这个问题的核心思路:递归与回溯。

核心算法思路

基本思路是:我们从字符串的起始位置开始,尝试所有可能的前缀子串。如果当前前缀是回文串,我们就将其加入当前的分割路径中,然后对剩余的字符串部分递归地进行相同的操作。当递归处理完剩余部分后,我们需要“回溯”,即移除当前加入的前缀,以尝试下一个可能的前缀。

以下是实现此逻辑的关键步骤:

  1. 定义辅助函数:我们需要一个函数来判断一个子串是否是回文串。
  2. 递归回溯函数:这个函数接收当前待处理的字符串 s、当前已形成的分割路径 partitions 以及存储所有结果的答案列表 answer
  3. 基准情况:如果当前字符串 s 为空,说明我们已经处理完整个原始字符串,当前 partitions 就是一个有效的分割方案,将其加入 answer
  4. 递归情况:遍历所有可能的前缀子串(从索引 0i)。对于每个前缀,检查它是否是回文串。如果是,则将其加入 partitions,然后对剩余子串 s.substr(i+1) 进行递归调用。递归返回后,从 partitions 中移除刚才加入的前缀(回溯),以尝试下一个前缀。

代码实现

现在,让我们将上述思路转化为具体的 C++ 代码。首先,我们实现判断回文的辅助函数。

bool isPalindrome(const string& s) {
    string s2 = s;
    reverse(s2.begin(), s2.end());
    return s == s2;
}

接下来,我们实现核心的递归回溯函数。

void getAllPartitions(const string& s, vector<string>& partitions, vector<vector<string>>& answer) {
    // 基准情况:如果字符串为空,当前分割路径是一个有效答案
    if (s.size() == 0) {
        answer.push_back(partitions);
        return;
    }

    // 递归情况:尝试所有可能的前缀
    for (int i = 0; i < s.size(); i++) {
        // 获取当前前缀子串 s[0...i]
        string part = s.substr(0, i + 1);

        // 检查当前前缀是否是回文
        if (isPalindrome(part)) {
            // 选择:将当前回文前缀加入路径
            partitions.push_back(part);

            // 递归:对剩余部分进行分割
            string remaining = s.substr(i + 1);
            getAllPartitions(remaining, partitions, answer);

            // 回溯:撤销选择,尝试下一个前缀
            partitions.pop_back();
        }
    }
}

最后,我们提供主函数来调用上述逻辑。

vector<vector<string>> partition(string s) {
    vector<vector<string>> answer;
    vector<string> partitions;
    getAllPartitions(s, partitions, answer);
    return answer;
}

复杂度分析

  • 时间复杂度:在最坏情况下,我们需要检查所有可能的分割方式。对于一个长度为 n 的字符串,有 O(2^n) 种分割可能。对于每种分割,我们还需要 O(n) 的时间来验证每个子串是否为回文(使用上面的 isPalindrome 函数)。因此,总时间复杂度约为 O(n * 2^n)
  • 空间复杂度:主要空间消耗来自递归调用栈和存储答案的列表。递归深度最大为 n,存储一个答案需要 O(n) 空间。因此,空间复杂度为 O(n)(不考虑输出答案所占用的空间,若考虑则为 O(n * 2^n))。

总结

本节课中我们一起学习了如何使用递归与回溯算法解决“回文分割”问题。我们掌握了以下关键点:

  1. 问题的目标是找到所有将字符串分割为回文子串的方案。
  2. 递归回溯是解决此类“组合”问题的有效方法,其核心是“尝试-选择-递归-撤销(回溯)”。
  3. 我们实现了判断回文的辅助函数和核心的递归回溯函数。
  4. 我们分析了算法的时间与空间复杂度。

理解这个问题的解法对于掌握回溯思想至关重要。在后续学习动态规划时,我们还会遇到与此问题相关的优化解法。请继续练习,巩固递归与回溯的思维模式。

050:归并排序算法 - 递归与回溯 🧩

在本节课中,我们将要学习一个非常重要的递归算法——归并排序。我们将通过分析其不同步骤,来彻底理解整个递归过程及其在C++中的实现。

概述 📋

归并排序算法主要分为两个步骤。第一步是,即递归地将数组分成两个相等的部分。第二步是,即将已排序的子数组合并成一个完整的有序数组。

上一节我们介绍了递归的基本概念,本节中我们来看看如何应用递归来实现归并排序。

第一步:分(Divide)✂️

算法的第一步是反复地将数组分成两个相等的部分。

以下是“分”步骤的详细过程:

  1. 找到数组的中间索引 mid
  2. 将数组分为左半部分(从 startmid)和右半部分(从 mid+1end)。
  3. 对左半部分和右半部分递归地调用相同的“分”函数。
  4. 重复此过程,直到每个子数组只包含一个元素(此时认为它已排序)。

这个过程可以用以下递归函数框架表示:

void mergeSort(vector<int>& arr, int start, int end) {
    if(start < end) {
        int mid = start + (end - start) / 2; // 防止溢出
        mergeSort(arr, start, mid);   // 递归排序左半部分
        mergeSort(arr, mid+1, end); // 递归排序右半部分
        // 合并步骤将在后面添加
    }
}

第二步:治(Merge)🤝

当数组被分割成单个元素后,我们需要将这些小部分合并回有序的较大数组。这是算法的核心“治”步骤。

以下是“合并”两个已排序子数组的步骤:

  1. 创建两个指针 ij,分别指向左子数组和右子数组的起始位置。
  2. 创建一个临时数组 temp 来存放合并后的结果。
  3. 比较 arr[i]arr[j],将较小的元素放入 temp,并移动相应指针。
  4. 重复步骤3,直到其中一个子数组的所有元素都被处理完。
  5. 将剩余子数组的所有元素直接复制到 temp 的末尾。
  6. 最后,将 temp 数组中的有序元素复制回原数组 arr 的对应位置。

合并步骤的伪代码如下:

void merge(vector<int>& arr, int start, int mid, int end) {
    vector<int> temp;
    int i = start, j = mid + 1;

    // 合并两个有序子数组
    while(i <= mid && j <= end) {
        if(arr[i] <= arr[j]) {
            temp.push_back(arr[i++]);
        } else {
            temp.push_back(arr[j++]);
        }
    }

    // 复制左半部分剩余元素
    while(i <= mid) temp.push_back(arr[i++]);
    // 复制右半部分剩余元素
    while(j <= end) temp.push_back(arr[j++]);

    // 将排序好的元素复制回原数组
    for(int idx = 0; idx < temp.size(); idx++) {
        arr[start + idx] = temp[idx];
    }
}

完整代码实现 💻

将“分”与“治”的步骤结合起来,就得到了完整的归并排序算法。

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

void merge(vector<int>& arr, int start, int mid, int end) {
    vector<int> temp;
    int i = start, j = mid + 1;

    while(i <= mid && j <= end) {
        if(arr[i] <= arr[j]) {
            temp.push_back(arr[i++]);
        } else {
            temp.push_back(arr[j++]);
        }
    }
    while(i <= mid) temp.push_back(arr[i++]);
    while(j <= end) temp.push_back(arr[j++]);

    for(int idx = 0; idx < temp.size(); idx++) {
        arr[start + idx] = temp[idx];
    }
}

void mergeSort(vector<int>& arr, int start, int end) {
    if(start < end) {
        int mid = start + (end - start) / 2;
        mergeSort(arr, start, mid);
        mergeSort(arr, mid + 1, end);
        merge(arr, start, mid, end); // 调用合并函数
    }
}

int main() {
    vector<int> arr = {12, 31, 8, 17, 32, 35};
    mergeSort(arr, 0, arr.size() - 1);

    for(int val : arr) {
        cout << val << " ";
    }
    cout << endl;
    return 0;
}

复杂度分析 📊

理解了算法流程后,我们来分析其时间和空间复杂度。

  • 时间复杂度:归并排序的时间复杂度是 O(n log n)
    • 推导:数组被递归地分成两半,深度为 log₂ n 层。在每一层,合并所有子数组的总工作量是 O(n)。因此,总时间复杂度为 O(n log n)
  • 空间复杂度:需要额外的临时数组来合并,因此空间复杂度是 O(n)

总结 🎯

本节课中我们一起学习了归并排序算法。

  1. 我们首先了解了算法的两个核心阶段:
  2. 然后,我们详细探讨了如何用递归实现“分”的步骤,以及如何合并两个有序子数组的“治”的步骤。
  3. 接着,我们整合代码,实现了完整的归并排序。
  4. 最后,我们分析了算法的时间复杂度 O(n log n) 和空间复杂度 O(n)

归并排序是一种高效、稳定的排序算法,其 O(n log n) 的性能使其在处理大规模数据时非常有用。理解其递归和合并的过程,对于掌握更复杂的算法思想至关重要。

051:一次重大更新与未来展望 🚀

在本节课中,我们将分享关于Apna College的两个重要公告,涉及频道里程碑与课程系列的重大扩展计划。

概述

我们来自Apna College。在今天的课程中,我们将介绍与Apna College相关的两项重要公告。

里程碑:即将达到10亿播放量 🎉

上一节我们介绍了本次课程的主题,本节中我们来看看第一项令人振奋的公告。

Apna College作为一个科技教育YouTube频道,近期总播放量已达到9.8亿。我们很快将触及10亿播放量的里程碑,也就是10亿次观看。这一成就完全得益于我们学生们的热爱与支持。这绝对是我们在开始时未曾想象到的支持力度和影响力。

正因为如此,我们决定进一步扩大规模,为频道带来更多新内容。

课程扩展:C++ DSA系列将成为最全教程 💻

接下来,我们看看关于课程内容的重大更新。

第二项公告是关于我们的C++ DSA课程系列。该系列目前已有超过50节讲座。我们计划将这个系列扩展到至少100节讲座以上。我们的目标是打造YouTube上内容最全面、最完整的DSA(数据结构与算法)系列教程。

成功完成本系列后,它无疑将成为YouTube上在内容完整性和教学深度方面最出色的系列之一。系列将涵盖更多主题,确保内容详尽。

未来计划与额外福利 📚

在介绍了核心课程扩展后,本节将详细说明为支持这一目标而制定的具体计划。

为了确保大家能跟上更新并获取额外资源,我们制定了以下计划:

以下是确保学习连贯性的具体措施:

  • 开启频道通知功能,以便及时获取新视频提醒。
  • 我们将在准备一些额外的学习资源。

以下是关于频道内容多元化的安排:

  • 我们将在频道描述中更新完整的学习路径。
  • 我们将制作并上传完整的Python和完整的JavaScript课程系列。
  • 为了有空间上传这些新技术和新内容,我们需要对当前内容进行规划。

因此,今天是一个值得分享喜悦的日子。我们希望将这份关于教育学习的快乐与大家共享。

总结

本节课中,我们一起学习了Apna College的两项关键动态:一是频道即将达成10亿播放量的里程碑;二是我们的C++ DSA课程系列将进行大规模扩展,目标成为YouTube上最全面、最完整的教程,并辅以系统的学习支持计划和更多编程语言课程。

052:快速排序算法 🚀

在本节课中,我们将学习一种基于递归的重要排序算法——快速排序。我们将涵盖其核心思想、实现步骤、代码编写以及时间和空间复杂度分析。

概述

快速排序是一种高效的排序算法,其核心思想是“分而治之”。它通过选择一个“基准”元素,将数组划分为两个子数组,然后递归地对子数组进行排序。接下来,我们将详细探讨其工作原理。

算法核心思想

快速排序的核心在于“基准”和“分区”这两个概念。其目标是按升序或降序排列数组。为简化说明,我们以升序为例。

以下是快速排序的基本步骤:

  1. 从数组中选择一个元素作为“基准”。
  2. 执行“分区”操作:重新排列数组,使得所有小于基准的元素移到其左侧,所有大于基准的元素移到其右侧。等于基准的元素可以放在任一侧。分区完成后,基准元素就位于其最终的正确位置。
  3. 递归地将快速排序算法应用于基准左侧的子数组和右侧的子数组。

分区过程详解

上一节我们介绍了快速排序的宏观步骤,本节中我们来看看关键的“分区”过程是如何具体实现的。

分区函数接收数组以及起始和结束索引作为参数。其目标是围绕一个选定的基准元素重新排列数组的一部分,并返回基准元素的最终索引位置。这个最终索引被称为“基准索引”,它标志着左侧子数组的结束和右侧子数组的开始。

以下是分区过程的具体逻辑:

  • 选择基准元素。一种常见的简单实现是选择最后一个元素作为基准。
  • 初始化一个索引 i,它指向最后一个已确认的小于基准的元素的位置。初始时,i = start - 1
  • 使用另一个索引 j 遍历从 startend-1 的元素。
  • 在遍历过程中,如果 array[j] 小于或等于基准值,则将 i 增加1,然后交换 array[i]array[j] 的值。这确保了 i 及其左侧的所有元素都小于等于基准。
  • 遍历完成后,i+1 的位置就是基准元素应该放置的正确位置。交换 array[i+1]array[end](即基准元素)。
  • 函数最后返回基准元素的最终索引 i+1

通过这个过程,数组被划分为 [小于基准的元素, 基准, 大于基准的元素] 三部分。

代码实现

理解了分区原理后,现在我们可以着手实现完整的快速排序算法。代码主要包含两个函数:quickSort 主函数和 partition 辅助函数。

1. 分区函数实现

int partition(vector<int>& arr, int start, int end) {
    int pivot = arr[end]; // 选择最后一个元素作为基准
    int i = start - 1;    // 指向小于基准区域的最后一个元素

    for (int j = start; j < end; j++) {
        // 如果当前元素小于等于基准
        if (arr[j] <= pivot) {
            i++; // 扩大小于基准的区域
            swap(arr[i], arr[j]); // 将当前元素交换到该区域
        }
    }
    // 将基准元素放到正确位置
    swap(arr[i + 1], arr[end]);
    return i + 1; // 返回基准索引
}

2. 快速排序主函数实现

void quickSort(vector<int>& arr, int start, int end) {
    // 递归基:当子数组只有一个或零个元素时,已经有序
    if (start >= end) {
        return;
    }
    // 1. 分区操作,获取基准索引
    int pivotIndex = partition(arr, start, end);

    // 2. 递归排序左半部分 (start 到 pivotIndex-1)
    quickSort(arr, start, pivotIndex - 1);

    // 3. 递归排序右半部分 (pivotIndex+1 到 end)
    quickSort(arr, pivotIndex + 1, end);
}

3. 调用示例

int main() {
    vector<int> arr = {5, 2, 6, 4, 1, 3};
    quickSort(arr, 0, arr.size() - 1);

    for (int num : arr) {
        cout << num << " ";
    }
    // 输出:1 2 3 4 5 6
    return 0;
}

复杂度分析

在实现了算法之后,分析其性能至关重要。快速排序的复杂度在不同情况下差异显著。

  • 时间复杂度

    • 平均情况O(n log n)。这是最常见的情况,每次分区都能大致将数组对半分。
    • 最佳情况O(n log n),与平均情况相同。
    • 最坏情况O(n²)。当每次选择的基准都是当前子数组中的最小或最大元素时发生(例如数组已完全有序或完全逆序)。此时分区极度不平衡,递归树深度为 n
      • 总操作数近似为 n + (n-1) + ... + 1 = n(n+1)/2,即 O(n²)
  • 空间复杂度

    • 主要是递归调用栈所占用的空间。
    • 平均情况O(log n),对应递归树的深度。
    • 最坏情况O(n),对应深度为 n 的递归树。

总结

本节课中我们一起学习了快速排序算法。我们从其“分治”核心思想入手,详细剖析了“分区”这一关键步骤的工作原理。随后,我们逐步实现了 partition 函数和递归的 quickSort 函数,并提供了完整的可运行代码示例。最后,我们讨论了算法的时间与空间复杂度,特别指出了其平均高效 (O(n log n)) 但在特定情况下会退化 (O(n²)) 的特点。理解快速排序对于掌握高效排序技术和应对技术面试都至关重要。

053:计算逆序对问题

概述

在本节课中,我们将学习一个重要的递归问题:计算逆序对。我们将从理解问题定义开始,先探讨暴力解法,然后学习基于归并排序的最优解法。

什么是逆序对?

给定一个数组,逆序对是指满足以下两个条件的数对 (i, j)

  1. 索引 i 小于索引 j
  2. 数组在索引 i 处的值大于在索引 j 处的值,即 arr[i] > arr[j]

例如,对于数组 [6, 3, 5, 2, 7],我们可以找出所有逆序对。

暴力解法

首先,我们来看一种直观但低效的解法。以下是暴力解法的思路:
遍历数组中的每一对元素,检查它们是否构成逆序对。

具体步骤如下:

  1. 使用一个外层循环,索引 i0 遍历到 n-1
  2. 对于每个 i,使用一个内层循环,索引 ji+1 遍历到 n-1
  3. 检查条件 arr[i] > arr[j] 是否成立。如果成立,则逆序对计数加一。

代码描述:

int countInversionsBruteForce(vector<int>& arr) {
    int count = 0;
    int n = arr.size();
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (arr[i] > arr[j]) {
                count++;
            }
        }
    }
    return count;
}

这种解法的时间复杂度是 O(n²),对于大规模数组效率很低。

最优解法:基于归并排序

上一节我们介绍了暴力解法,本节中我们来看看更高效的最优解法。这个解法巧妙地利用了归并排序的合并步骤。

归并排序回顾

在归并排序中,我们将数组递归地分成两半,分别排序,然后在合并步骤中将两个已排序的数组合并成一个更大的有序数组。

关键洞察

在合并两个已排序的子数组(左半部分和右半部分)时,我们可以高效地计算跨越这两个子数组的逆序对数量。

假设左半部分 [1, 3, 5, 10] 和右半部分 [2, 6, 8, 9] 都是有序的。
在合并时,我们比较 arr[i](左半部分当前元素)和 arr[j](右半部分当前元素)。

  • 如果 arr[i] <= arr[j],这不是逆序对,我们将 arr[i] 放入临时数组。
  • 如果 arr[i] > arr[j],那么 arr[i] 不仅大于 arr[j],而且由于左半部分是有序的,arr[i] 之后的所有元素(直到左半部分末尾)也都大于 arr[j]

因此,当 arr[i] > arr[j] 时,我们可以一次性计算出多个逆序对:
逆序对增加的数量 = (mid - i + 1),其中 mid 是左半部分的结束索引。

算法步骤

以下是修改后的归并排序函数,用于计算逆序对:

  1. 分割:递归地将数组分成两半,直到子数组只有一个元素。
  2. 征服:递归调用函数计算左半部分和右半部分内部的逆序对数量。
  3. 合并与计数:在合并两个有序子数组时,计算跨越中点的逆序对数量。
  4. 汇总:总逆序对数量 = 左半部分逆序对 + 右半部分逆序对 + 合并时计算的逆序对。

核心合并步骤的代码描述:

int mergeAndCount(vector<int>& arr, int start, int mid, int end) {
    vector<int> temp;
    int i = start, j = mid + 1;
    int inversionCount = 0;

    while (i <= mid && j <= end) {
        if (arr[i] <= arr[j]) {
            temp.push_back(arr[i++]);
        } else {
            // arr[i] > arr[j],发现逆序对
            temp.push_back(arr[j++]);
            inversionCount += (mid - i + 1); // 关键步骤
        }
    }
    // 复制剩余元素...
    // 将temp中的元素复制回原数组arr...
    return inversionCount;
}

完整代码实现

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

int mergeAndCount(vector<int>& arr, int start, int mid, int end) {
    vector<int> temp;
    int i = start, j = mid + 1;
    int inversionCount = 0;

    while (i <= mid && j <= end) {
        if (arr[i] <= arr[j]) {
            temp.push_back(arr[i++]);
        } else {
            temp.push_back(arr[j++]);
            inversionCount += (mid - i + 1);
        }
    }
    while (i <= mid) {
        temp.push_back(arr[i++]);
    }
    while (j <= end) {
        temp.push_back(arr[j++]);
    }
    for (int idx = 0; idx < temp.size(); idx++) {
        arr[start + idx] = temp[idx];
    }
    return inversionCount;
}

int mergeSortAndCount(vector<int>& arr, int start, int end) {
    int inversionCount = 0;
    if (start < end) {
        int mid = start + (end - start) / 2;
        // 计算左半部分的逆序对
        inversionCount += mergeSortAndCount(arr, start, mid);
        // 计算右半部分的逆序对
        inversionCount += mergeSortAndCount(arr, mid + 1, end);
        // 计算跨越中点的逆序对并合并
        inversionCount += mergeAndCount(arr, start, mid, end);
    }
    return inversionCount;
}

int countInversions(vector<int>& arr) {
    return mergeSortAndCount(arr, 0, arr.size() - 1);
}

int main() {
    vector<int> arr1 = {6, 3, 5, 2, 7};
    cout << "Inversion count: " << countInversions(arr1) << endl; // 输出 5

    vector<int> arr2 = {8, 4, 2, 1};
    cout << "Inversion count: " << countInversions(arr2) << endl; // 输出 6
    return 0;
}

算法演示

让我们以数组 [6, 3, 5, 2, 7] 为例,手动推演算法过程。

  1. 分割数组:[6, 3][5, 2, 7]
  2. 左半部分 [6, 3] 继续分割为 [6][3]。合并 [6][3] 时,6 > 3,逆序对计数为 1。排序后为 [3, 6]
  3. 右半部分 [5, 2, 7] 分割为 [5][2, 7]
    • [2, 7] 分割为 [2][7],合并时无逆序对。
    • 合并 [5][2, 7] 时,5 > 2,逆序对计数增加 (mid - i + 1) = 1(因为 5 之后没有元素)。排序后为 [2, 5, 7]
  4. 合并左半部分 [3, 6] 和右半部分 [2, 5, 7]
    • 比较 323 > 2,逆序对计数增加 (mid - i + 1) = 2(因为左半部分中 36 都大于 2)。
    • 后续合并过程不再产生新的跨越逆序对。
  5. 总逆序对 = 左半部分内部(1) + 右半部分内部(1) + 合并时跨越计数(2) = 5

复杂度分析

  • 时间复杂度:与归并排序相同,为 O(n log n)
  • 空间复杂度:与归并排序相同,为 O(n),用于临时数组。

O(n²) 的暴力解法相比,归并排序算法在计算逆序对方面效率高得多。

总结

本节课中我们一起学习了如何计算数组中的逆序对数量。我们首先定义了问题并实现了简单的暴力解法。然后,我们深入探讨了基于归并排序的最优解法,理解了如何在合并两个有序子数组的过程中高效地计算跨越中点的逆序对。这种方法不仅时间复杂度更优,也加深了我们对分治算法和归并排序内部工作原理的理解。

054:骑士巡游问题 - 回溯法

概述

在本节课中,我们将学习一个重要的递归与回溯问题——骑士巡游问题。我们将分析问题描述,理解骑士的移动规则,并最终实现一个函数来验证给定的棋盘路径是否是有效的骑士巡游路径。这个问题对应于LeetCode第2596题。


问题描述

假设我们有一个 n x n 的棋盘。棋盘上有一个骑士,它必须按照国际象棋中骑士的移动规则,访问棋盘上的每一个格子恰好一次。题目会给出一个 n x n 的网格 grid,其中 grid[r][c] 表示骑士访问该格子的顺序(从0开始)。我们的任务是编写一个函数,检查这个给定的访问顺序是否是一个有效的骑士巡游路径。

骑士在国际象棋中的移动规则是“日”字形,即每次移动可以朝八个可能的方向前进。

骑士的移动规则

上一节我们介绍了问题,本节中我们来看看骑士具体的移动规则。骑士可以从当前位置 (r, c) 移动到以下八个位置之一:

以下是骑士的八个可能移动方向(用行r和列c的变化表示):

  1. (r - 2, c + 1)
  2. (r - 1, c + 2)
  3. (r + 1, c + 2)
  4. (r + 2, c + 1)
  5. (r + 2, c - 1)
  6. (r + 1, c - 2)
  7. (r - 1, c - 2)
  8. (r - 2, c - 1)

我们可以将这些移动存储在两个数组中,方便在代码中遍历:

int dr[8] = {-2, -1, 1, 2, 2, 1, -1, -2};
int dc[8] = {1, 2, 2, 1, -1, -2, -2, -1};

解题思路:回溯法

理解了移动规则后,我们需要设计算法来验证路径。我们将使用回溯法。核心思想是从起始位置(即 grid 中值为 0 的格子)开始,递归地尝试所有可能的下一步移动,检查它是否指向 grid 中标记的下一个顺序值。

我们将实现一个递归辅助函数 isValidHelper

函数参数与返回值

该函数需要以下参数:

  • grid: 给定的棋盘。
  • r: 当前所在的行。
  • c: 当前所在的列。
  • n: 棋盘的尺寸。
  • expectedValue: 当前格子应该具有的顺序值。

函数返回一个布尔值:从当前位置 (r, c) 开始,是否能完成剩余的有效巡游路径。

递归的基准情况

在编写递归逻辑前,我们先定义基准情况。

以下是需要返回 false 的情况:

  1. 当前位置 (r, c) 超出了棋盘边界(r < 0 || c < 0 || r >= n || c >= n)。
  2. 当前位置 grid[r][c] 的值不等于我们期望的 expectedValue。这意味着路径在此处已经出错。

以下是需要返回 true 的情况:
expectedValue 等于 n * n - 1 时,说明我们已经成功访问了棋盘上所有 n * n 个格子(从0到 n²-1),巡游完成。

递归步骤

如果上述基准情况都不满足,说明我们当前处于一个有效的中间步骤。那么,我们需要尝试所有八种可能的下一步移动。

对于每一种移动 i(从0到7):

  1. 计算下一个位置 (nr, nc) = (r + dr[i], c + dc[i])
  2. 递归调用 isValidHelper(grid, nr, nc, n, expectedValue + 1)

如果这八个递归调用中有任何一個返回 true,则说明从当前步骤出发存在一条有效路径,整个函数应返回 true。如果所有尝试都返回 false,则说明此路不通,返回 false

主函数

在主函数 checkValidGrid 中,我们需要:

  1. 找到棋盘上值为 0 的起始位置 (start_r, start_c)
  2. 调用 isValidHelper(grid, start_r, start_c, n, 0) 并返回其结果。

代码实现

根据以上思路,我们可以将伪代码转化为实际的C++代码。

class Solution {
public:
    // 骑士的八个移动方向
    int dr[8] = {-2, -1, 1, 2, 2, 1, -1, -2};
    int dc[8] = {1, 2, 2, 1, -1, -2, -2, -1};

    bool isValidHelper(vector<vector<int>>& grid, int r, int c, int n, int expectedValue) {
        // 基准情况1: 超出边界
        if (r < 0 || c < 0 || r >= n || c >= n) {
            return false;
        }
        // 基准情况2: 当前格子值不等于期望值
        if (grid[r][c] != expectedValue) {
            return false;
        }
        // 基准情况3: 已成功访问所有格子
        if (expectedValue == n * n - 1) {
            return true;
        }

        // 尝试所有八种可能的移动
        for (int i = 0; i < 8; ++i) {
            int nr = r + dr[i];
            int nc = c + dc[i];
            if (isValidHelper(grid, nr, nc, n, expectedValue + 1)) {
                return true; // 找到一条有效路径
            }
        }
        // 所有移动尝试都失败
        return false;
    }

    bool checkValidGrid(vector<vector<int>>& grid) {
        int n = grid.size();
        if (grid[0][0] != 0) return false; // 根据题意,必须从左上角(0,0)且值为0开始
        return isValidHelper(grid, 0, 0, n, 0);
    }
};

复杂度分析

  • 时间复杂度:在最坏情况下,每个格子都有8种可能的下一步选择。对于 n x n 的棋盘,递归树可能非常庞大,时间复杂度为 O(8^(n²))。这是一个理论上的上界,由于 grid 提供了确定的路径,实际递归分支会很快被 grid[r][c] != expectedValue 条件剪枝。
  • 空间复杂度:主要空间消耗来自递归调用栈。在最坏情况下,递归深度等于需要访问的格子数,即 O(n²)

总结

本节课中我们一起学习了如何利用回溯法解决骑士巡游问题。我们首先分析了骑士独特的移动规则,然后设计了递归函数,明确了返回 truefalse 的基准情况,最后通过尝试所有可能的下一步移动来验证给定路径的有效性。虽然这个问题在LeetCode上被标记为中等难度,但它是巩固递归和回溯基础思想的经典练习题。理解并实现这个算法,将帮助你更好地应对更复杂的回溯问题。

055:面向对象编程一站式教程

在本节课中,我们将要学习C++面向对象编程的核心概念。我们将从类和对象的基础开始,逐步深入到封装、构造函数、继承、多态和抽象等高级主题。本教程旨在通过简单直白的语言和示例,帮助初学者理解这些概念,为就业面试和实际编程打下坚实基础。

概述:什么是面向对象编程?

面向对象编程是一种围绕“对象”概念构建的编程范式。对象是现实世界实体的软件表示,它包含数据(属性)和操作数据的方法(函数)。OOP的核心思想是将数据和对数据的操作封装在一起,从而提高代码的可重用性、可维护性和可扩展性。

类和对象:蓝图与实例

上一节我们介绍了面向对象编程的基本思想,本节中我们来看看其核心构件:类和对象。

类是一个蓝图或模板,它定义了某一类对象共有的属性和行为。例如,“教师”这个类可以定义所有教师共有的属性(如姓名、部门、科目)和行为(如教学、评分)。

对象是类的一个具体实例。根据“教师”这个蓝图,我们可以创建出许多具体的教师对象,如“张老师”、“李老师”,每个对象都有自己独特的属性值。

以下是如何在C++中定义一个简单的类:

class Teacher {
    // 属性(数据成员)
    string name;
    string department;
    string subject;
    double salary;

    // 方法(成员函数)
    void teach() {
        // 教学逻辑
    }
};

访问修饰符:控制可见性

理解了类和对象的基本结构后,我们需要了解如何控制类成员的访问权限。这是通过访问修饰符实现的。

C++中有三个主要的访问修饰符:privatepublicprotected

  • private(私有):私有成员只能在类内部被访问。这是类的默认访问级别。
  • public(公有):公有成员可以在类的外部被任何代码访问。
  • protected(受保护):受保护成员与私有成员类似,但可以在派生类(子类)中被访问。

访问修饰符是实现封装数据隐藏的关键。通过将敏感数据设为私有,并通过公有的“getter”和“setter”方法来访问和修改它们,我们可以保护数据不被意外修改,并控制对数据的访问逻辑。

class Teacher {
private:
    double salary; // 私有数据,外部无法直接访问

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_60.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_62.png)

public:
    // 公有方法,用于安全地访问和修改私有数据
    void setSalary(double s) {
        if (s > 0) { // 可以添加验证逻辑
            salary = s;
        }
    }
    double getSalary() {
        return salary;
    }
};

封装:捆绑数据与操作

上一节我们通过访问修饰符接触了封装的思想,本节我们来详细探讨封装。

封装是面向对象编程的四大支柱之一。它指的是将数据(属性)和操作这些数据的方法(函数)捆绑在一个单元(即类)中。同时,它通过访问控制(如private)对外部隐藏对象的内部实现细节,只暴露必要的接口。

封装 = 数据隐藏 + 数据抽象

它就像将药物(数据)和胶囊壳(方法)包装在一起形成一个胶囊(类),外部只需要知道如何服用(调用公有方法),而无需了解内部的成分(私有数据)。

class BankAccount {
private:
    string accountId; // 隐藏内部数据
    string password;
    double balance;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_93.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_95.png)

public:
    // 暴露安全的操作接口
    bool withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
            return true;
        }
        return false;
    }
    double checkBalance() {
        return balance;
    }
};

构造函数与析构函数:对象的生与死

当我们创建对象时,经常需要初始化其属性。C++使用一种特殊的成员函数来自动完成初始化工作,这就是构造函数。

构造函数是一种特殊的成员函数,它在创建类的新对象时自动调用。它的名称与类名完全相同,并且没有返回类型。

class Teacher {
public:
    string name;
    string department;

    // 构造函数
    Teacher() {
        cout << "构造函数被调用!" << endl;
        department = "计算机科学"; // 初始化部门
    }
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_110.png)

int main() {
    Teacher t1; // 创建对象时,构造函数自动执行
    cout << t1.department << endl; // 输出:计算机科学
    return 0;
}

构造函数主要有三种类型:

  1. 默认构造函数:没有参数。
  2. 参数化构造函数:带有参数,用于用特定值初始化对象。
  3. 拷贝构造函数:用于用一个已存在的对象初始化一个新对象。

与构造函数对应的是析构函数。它在对象销毁时自动调用,用于执行清理工作(如释放动态分配的内存)。析构函数的名称是在类名前加一个波浪号~

class Teacher {
public:
    // 构造函数
    Teacher() {
        cout << "对象诞生" << endl;
    }
    // 析构函数
    ~Teacher() {
        cout << "对象销毁" << endl;
    }
};

深拷贝与浅拷贝

在使用拷贝构造函数或赋值操作时,理解深拷贝和浅拷贝至关重要,尤其是在类包含指针成员时。

  • 浅拷贝:只拷贝指针的值(即内存地址),使得两个对象中的指针指向同一块内存。修改一个对象的数据会影响另一个对象。
  • 深拷贝:为新对象分配新的内存空间,并拷贝原指针指向的实际数据。这样两个对象就拥有独立的数据副本。
class Student {
public:
    char *name;
    // 构造函数
    Student(const char *n) {
        name = new char[strlen(n) + 1];
        strcpy(name, n);
    }
    // 自定义深拷贝构造函数
    Student(const Student &other) {
        name = new char[strlen(other.name) + 1]; // 分配新内存
        strcpy(name, other.name); // 拷贝数据
    }
    // 析构函数
    ~Student() {
        delete[] name; // 释放内存
    }
};

继承:代码的重用与扩展

继承是面向对象编程中实现代码重用的强大机制。它允许我们基于一个已有的类(基类或父类)来定义一个新类(派生类或子类)。派生类继承了父类的属性和方法,并可以添加自己特有的属性和方法。

// 基类(父类)
class Person {
public:
    string name;
    int age;
};
// 派生类(子类)
class Student : public Person { // ‘public’ 表示继承方式
public:
    int rollNumber;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_139.png)

int main() {
    Student s1;
    s1.name = "小明"; // 继承自Person的属性
    s1.rollNumber = 123; // Student自己的属性
    return 0;
}

继承有多种类型:

  • 单继承:一个子类只继承一个父类。
  • 多级继承:类A被类B继承,类B又被类C继承。
  • 多重继承:一个子类继承多个父类。
  • 层次继承:一个父类被多个子类继承。
  • 混合继承:以上几种继承类型的组合。

多态:同一接口,多种实现

多态是面向对象编程的另一个核心概念,意为“多种形态”。它允许我们使用统一的接口来操作不同的底层对象。多态主要通过函数重载、运算符重载、函数重写和虚函数实现。

多态分为两类:

  1. 编译时多态(静态多态):在编译时确定调用哪个函数。例如:
    • 函数重载:在同一作用域内,函数名相同但参数列表不同。
    class Print {
    public:
        void show(int i) { cout << "整数: " << i << endl; }
        void show(char c) { cout << "字符: " << c << endl; } // 重载
    };
    
    • 运算符重载
  2. 运行时多态(动态多态):在程序运行时确定调用哪个函数。主要通过虚函数函数重写实现。
    class Animal { // 基类
    public:
        virtual void speak() { // 虚函数
            cout << "动物叫" << endl;
        }
    };
    class Dog : public Animal {
    public:
        void speak() override { // 重写基类虚函数
            cout << "汪汪!" << endl;
        }
    };
    
    int main() {
        Animal *a = new Dog();
        a->speak(); // 输出“汪汪!”,调用的是Dog类的speak
        delete a;
        return 0;
    }
    

抽象:隐藏复杂性

抽象是隐藏不必要的实现细节,只向用户展示核心功能的过程。在C++中,抽象主要通过:

  1. 访问修饰符(如将细节设为private)。
  2. 抽象类来实现。

抽象类是不能被实例化(即不能创建对象)的类,它通常包含至少一个纯虚函数。抽象类作为接口或蓝图,供其他类继承并实现其纯虚函数。

// 抽象类
class Shape {
public:
    virtual void draw() = 0; // 纯虚函数
};
// 具体类
class Circle : public Shape {
public:
    void draw() override {
        cout << "绘制圆形" << endl; // 实现纯虚函数
    }
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_156.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_158.png)

int main() {
    // Shape s; // 错误!不能创建抽象类的对象
    Circle c;
    c.draw(); // 正确
    return 0;
}

静态成员:属于类而非对象

静态成员(变量或函数)属于类本身,而不是类的某个特定对象。所有对象共享同一个静态成员副本。

  • 静态变量:在程序生命周期内只初始化一次,所有对象共享其值。
  • 静态函数:只能访问静态成员,不能访问非静态成员(因为没有this指针)。

class Counter {
public:
    static int count; // 静态变量声明

    Counter() {
        count++; // 每个对象创建时,共享的count加1
    }
    static void showCount() { // 静态函数
        cout << "对象总数: " << count << endl;
    }
};
int Counter::count = 0; // 静态变量定义和初始化

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_162.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/apna-cpp-dast/img/489e2eadd5da3b1b0c0c78249ff6c8ec_164.png)

int main() {
    Counter c1, c2, c3;
    Counter::showCount(); // 输出:对象总数: 3
    return 0;
}

总结

本节课中我们一起学习了C++面向对象编程的核心概念。我们从类和对象这一基础开始,理解了类是蓝图,对象是实例。接着,我们探讨了访问修饰符private, public, protected)如何控制封装性。

封装将数据和方法捆绑并隐藏细节。构造函数和析构函数管理对象的生命周期。深拷贝与浅拷贝在处理指针成员时尤为重要。

继承实现了代码的层次化重用,而多态(包括编译时和运行时)则提供了接口的一致性。抽象通过抽象类和纯虚函数定义了清晰的接口。最后,静态成员提供了类级别的数据和函数。

掌握这些概念是理解现代C++编程和应对技术面试的关键。建议通过实际编码练习来巩固这些知识。

056:链表简介 📚

在本节课中,我们将要学习一种新的数据结构——链表。我们将了解链表的基本概念、它与数组的区别,并学习如何从零开始实现一个链表,包括创建节点、插入元素、删除元素以及搜索元素等核心操作。


什么是链表?🔗

上一节我们介绍了数据结构的基本概念,本节中我们来看看链表。链表是一种线性数据结构,由一系列称为“节点”的元素组成。

  • 每个节点包含两部分:数据和指向下一个节点的指针
  • 节点在内存中不必连续存储,它们通过指针连接成一个链式结构。

这与数组(或向量)有显著区别。数组在内存中是连续存储的,而链表的节点可以分散在内存的任何位置。因此,链表是一种动态数据结构,可以在运行时轻松地添加或删除节点,从而调整大小。


链表的结构与核心概念 🧱

为了理解链表,我们需要先理解其基本构成单元——节点。

一个节点通常包含:

  • 数据:存储的实际值,可以是整数、字符等。
  • 下一个指针:存储下一个节点内存地址的指针。

此外,链表通常由两个特殊的指针来管理:

  • 头指针:指向链表的第一个节点。
  • 尾指针:指向链表的最后一个节点(可选,但常用)。

我们可以用类来定义节点和链表本身。以下是核心概念的代码表示:

// 定义节点类
class Node {
public:
    int data;       // 节点存储的数据
    Node* next;     // 指向下一个节点的指针

    // 构造函数
    Node(int value) {
        data = value;
        next = nullptr; // 初始时指向空
    }
};

// 定义链表类(管理头尾指针)
class LinkedList {
private:
    Node* head; // 头指针
    Node* tail; // 尾指针
public:
    // 构造函数
    LinkedList() {
        head = nullptr;
        tail = nullptr;
    }
    // 后续将在这里添加各种操作函数...
};

实现链表的基本操作 ⚙️

理解了链表的结构后,我们现在来实现它的基本操作。我们将重点关注以下几个函数:在头部插入、在尾部插入、删除头部节点、删除尾部节点、在任意位置插入以及搜索。

在链表头部插入节点

这个操作的目标是在链表的最前面添加一个新节点。

操作步骤如下:

  1. 创建一个新节点。
  2. 将新节点的 next 指针指向当前的头节点。
  3. 将链表的 head 指针更新为这个新节点。

需要考虑特殊情况:如果链表原本是空的,那么新节点既是头节点也是尾节点。

以下是 push_front 函数的实现代码:

void push_front(int value) {
    Node* newNode = new Node(value); // 步骤1:创建新节点
    if (head == nullptr) { // 链表为空的情况
        head = newNode;
        tail = newNode;
        return;
    }
    // 链表非空的情况
    newNode->next = head; // 步骤2:新节点指向原头节点
    head = newNode;       // 步骤3:更新头指针为新节点
}

打印链表内容

为了验证我们的操作,我们需要一个能打印链表所有元素的函数。

思路是使用一个临时指针,从头节点开始,逐个访问每个节点并打印其数据,直到遇到 nullptr

以下是 print 函数的实现代码:

void print() {
    Node* temp = head; // 从头部开始
    while (temp != nullptr) {
        std::cout << temp->data << " -> ";
        temp = temp->next; // 移动到下一个节点
    }
    std::cout << "NULL" << std::endl;
}

在链表尾部插入节点

这个操作的目标是在链表的末尾添加一个新节点。

操作步骤如下:

  1. 创建一个新节点。
  2. 找到当前的尾节点。
  3. 将当前尾节点的 next 指针指向新节点。
  4. 将链表的 tail 指针更新为新节点。

同样需要处理链表为空的情况。

以下是 push_back 函数的实现代码:

void push_back(int value) {
    Node* newNode = new Node(value);
    if (head == nullptr) { // 空链表
        head = newNode;
        tail = newNode;
        return;
    }
    // 非空链表
    tail->next = newNode; // 原尾节点指向新节点
    tail = newNode;       // 更新尾指针
}

删除链表头部节点

这个操作会移除链表的第一个节点。

操作步骤如下:

  1. 检查链表是否为空。若为空,则无法删除。
  2. 用一个临时指针保存原头节点。
  3. head 指针指向原头节点的下一个节点。
  4. 断开原头节点的连接,并释放其内存。

以下是 pop_front 函数的实现代码:

void pop_front() {
    if (head == nullptr) {
        std::cout << "List is empty!" << std::endl;
        return;
    }
    Node* temp = head;      // 保存原头节点
    head = head->next;      // 更新头指针
    temp->next = nullptr;   // 断开原头节点连接(可选,安全起见)
    delete temp;            // 释放内存
}

删除链表尾部节点

这个操作会移除链表的最后一个节点。它比删除头部稍复杂,因为我们需要找到倒数第二个节点。

操作步骤如下:

  1. 检查链表是否为空。
  2. 使用循环找到尾节点的前一个节点(即 next 指针指向 tail 的节点)。
  3. 将倒数第二个节点的 next 指针设为 nullptr
  4. 释放原尾节点的内存。
  5. 更新 tail 指针指向倒数第二个节点。

以下是 pop_back 函数的实现代码:

void pop_back() {
    if (head == nullptr) {
        std::cout << "List is empty!" << std::endl;
        return;
    }
    if (head == tail) { // 链表只有一个节点
        delete head;
        head = nullptr;
        tail = nullptr;
        return;
    }
    Node* temp = head;
    // 找到尾节点的前一个节点
    while (temp->next != tail) {
        temp = temp->next;
    }
    // temp 现在是倒数第二个节点
    delete tail;          // 删除尾节点
    tail = temp;          // 更新尾指针
    tail->next = nullptr; // 新的尾节点指向空
}

在链表任意位置插入节点

这个操作允许我们在链表的指定索引位置插入一个新节点。

操作步骤如下:

  1. 处理特殊情况:位置无效(如负数)或位置为0(相当于 push_front)。
  2. 使用临时指针遍历到目标位置的前一个节点。
  3. 创建新节点。
  4. 调整指针:新节点的 next 指向目标位置的节点,前一个节点的 next 指向新节点。

需要考虑插入位置超出链表长度的情况。

以下是 insert 函数的实现代码:

void insert(int value, int position) {
    if (position < 0) {
        std::cout << "Invalid position!" << std::endl;
        return;
    }
    if (position == 0) {
        push_front(value); // 在头部插入
        return;
    }

    Node* temp = head;
    // 遍历到 position-1 的位置
    for (int i = 0; i < position - 1 && temp != nullptr; i++) {
        temp = temp->next;
    }
    if (temp == nullptr) { // 位置超出链表长度
        std::cout << "Position out of bounds!" << std::endl;
        return;
    }

    Node* newNode = new Node(value);
    newNode->next = temp->next; // 新节点指向原位置节点
    temp->next = newNode;       // 前一个节点指向新节点

    // 如果插入到了最后,需要更新尾指针
    if (newNode->next == nullptr) {
        tail = newNode;
    }
}

在链表中搜索元素

最后,我们实现一个搜索函数,查找给定值是否存在于链表中,并返回其索引(从0开始)。

操作步骤如下:

  1. 使用临时指针从头开始遍历。
  2. 维护一个索引计数器。
  3. 将每个节点的数据与目标值比较。
  4. 如果找到,返回当前索引;如果遍历完都没找到,返回-1。

以下是 search 函数的实现代码:

int search(int key) {
    Node* temp = head;
    int index = 0;
    while (temp != nullptr) {
        if (temp->data == key) {
            return index; // 找到,返回索引
        }
        temp = temp->next;
        index++;
    }
    return -1; // 未找到
}

时间复杂度分析 ⏱️

了解操作效率很重要。以下是已实现函数的时间复杂度分析:

  • push_front, pop_front: O(1)。只涉及固定数量的指针操作。
  • print, search: O(n)。需要遍历整个链表。
  • push_back: O(1)。如果有尾指针,可以直接访问。
  • pop_back: O(n)。需要遍历找到倒数第二个节点。
  • insert: O(n)。在最坏情况下,需要遍历到链表末尾。

总结 🎯

本节课中我们一起学习了链表这一重要的数据结构。我们从链表的基本概念和结构入手,理解了节点、头指针和尾指针的作用。然后,我们一步步从零开始实现了一个完整的链表类,涵盖了在头部/尾部插入删除、在任意位置插入以及搜索等核心操作,并对每个操作的时间复杂度进行了分析。

通过本课的学习,你应该已经掌握了链表的原理和基础实现方法,这是深入学习更复杂数据结构(如双向链表、栈、队列)的重要基础。

057:反转链表 🔄

在本节课中,我们将学习如何解决一个经典且重要的数据结构问题:反转一个单链表。我们将详细讲解其核心逻辑,并通过代码实现来巩固理解。

概述

反转链表是面试和算法练习中的常见问题。其核心在于改变链表中每个节点的指向,将原本指向下一个节点的指针改为指向前一个节点。我们将使用一种迭代方法,通过三个指针来高效地完成这个操作。

问题描述

给定一个单链表的头节点 head,请反转该链表,并返回反转后的新头节点。

示例:
输入:1 -> 2 -> 3 -> 4 -> 5
输出:5 -> 4 -> 3 -> 2 -> 1

核心思路与算法

上一节我们概述了问题,本节中我们来看看具体的解决思路。关键在于使用三个指针来追踪节点的位置,并逐步改变它们的指向。

以下是反转链表的核心步骤:

  1. 初始化三个指针

    • previous:指向已经反转好的部分的最后一个节点,初始为 nullptr
    • current:指向当前待处理的节点,初始为链表的头节点 head
    • next:临时保存 current 节点的下一个节点,防止链表断裂。
  2. 迭代过程
    current 不为空时,重复以下步骤:

    • 保存 current 的下一个节点到 next
    • currentnext 指针指向 previous,完成反转。
    • previous 指针移动到 current 的位置。
    • current 指针移动到 next 的位置。
  3. 循环结束
    current 变为 nullptr 时,表示所有节点都已处理完毕。此时 previous 指针指向的就是新链表的头节点。

这个过程的时间复杂度O(n),其中 n 是链表的长度,因为我们只遍历了一次链表。空间复杂度O(1),因为我们只使用了固定的几个指针。

代码实现

理解了算法逻辑后,现在让我们将其转换为实际的 C++ 代码。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        // 初始化三个指针
        ListNode* previous = nullptr;
        ListNode* current = head;
        ListNode* next = nullptr;

        // 遍历链表
        while (current != nullptr) {
            // 1. 保存下一个节点
            next = current->next;
            // 2. 反转当前节点的指针
            current->next = previous;
            // 3. 移动 previous 和 current 指针
            previous = current;
            current = next;
        }
        // 循环结束后,previous 就是新的头节点
        return previous;
    }
};

总结

本节课中我们一起学习了如何反转一个单链表。我们掌握了使用三个指针(previouscurrentnext)的迭代方法,该方法逻辑清晰且效率高(O(n) 时间, O(1) 空间)。记住这个“保存、反转、移动”的三步循环,你就能轻松解决此类问题。

建议你在理解的基础上,在白板或纸上画图模拟这个过程,并尝试用这个思路解决其他链表相关问题来巩固知识。

058:链表的中间节点

在本节课中,我们将学习如何解决一个重要的经典链表问题:寻找链表的中间节点。这是一个在面试中常见的问题,对应力扣(LeetCode)上的第876题。我们将探讨两种不同的解题思路,并重点介绍一种高效且巧妙的“快慢指针”方法。

问题概述

题目要求是:给定一个单链表的头节点 head,返回链表的中间节点。链表可能包含奇数个节点,也可能包含偶数个节点。

  • 如果链表有奇数个节点(例如 1->2->3->4->5),则中间节点是 3
  • 如果链表有偶数个节点(例如 1->2->3->4->5->6),根据题目要求,我们需要返回第二个中间节点,即 4

我们的目标是实现一个函数 ListNode* middleNode(ListNode* head) 来完成这个任务。

方法一:朴素解法

首先,我们来看一种直观的解法。思路很简单:先遍历一次链表,计算出链表的总长度(节点数),记为 n。然后,根据 n 是奇数还是偶数,计算出中间节点的位置,最后再遍历一次链表找到该节点。

以下是具体步骤:

  1. 初始化一个计数器 count = 0 和一个临时指针 temp = head
  2. 使用 while 循环遍历链表,直到 temp 为空。在循环中,count++ 并移动 temp 指针。
  3. 循环结束后,count 即为链表长度 n
  4. 计算中间节点的索引 mid。对于需要返回第二个中间节点的要求,公式为:mid = n / 2 + 1
  5. 重置 temp = head,再次遍历链表 mid - 1 次,此时 temp 指向的就是中间节点。
  6. 返回 temp

这种方法需要遍历链表两次,时间复杂度为 O(2n) = O(n),空间复杂度为 O(1)。虽然可行,但效率不是最优。

方法二:快慢指针法

上一节我们介绍了需要两次遍历的朴素解法,本节中我们来看看一种更巧妙的“快慢指针”方法,它只需要一次遍历。

这种方法的核心思想是使用两个指针 slowfast,它们都从链表头 head 开始移动。

  • 慢指针 slow:每次向后移动 1 个节点。
  • 快指针 fast:每次向后移动 2 个节点。

由于快指针的速度是慢指针的两倍,当快指针到达链表末尾时,慢指针恰好位于链表的中间。这个规律对于奇数长度和偶数长度的链表都适用。

让我们通过例子来验证:

  • 奇数链表 1->2->3->4->5
    • 初始:slow1fast1
    • 第一步:slow 移动到 2fast 移动到 3
    • 第二步:slow 移动到 3fast 移动到 5
    • 此时 fast 到达末尾,slow 指向中间节点 3
  • 偶数链表 1->2->3->4->5->6
    • 初始:slow1fast1
    • 第一步:slow 移动到 2fast 移动到 3
    • 第二步:slow 移动到 3fast 移动到 5
    • 第三步:slow 移动到 4fast 移动到 null6next)。
    • 此时 fast 为空,slow 指向第二个中间节点 4

以下是实现快慢指针法的关键步骤:

  1. 初始化两个指针:slow = head;fast = head;
  2. 使用 while 循环,条件是 fast != NULL && fast->next != NULL。这个条件确保了 fast 指针可以安全地移动两步。
  3. 在循环体内:
    • 慢指针前进一步:slow = slow->next;
    • 快指针前进两步:fast = fast->next->next;
  4. 循环结束时,slow 指针指向的就是链表的中间节点。
  5. 返回 slow

以下是核心代码:

ListNode* middleNode(ListNode* head) {
    ListNode* slow = head;
    ListNode* fast = head;

    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;          // 慢指针走一步
        fast = fast->next->next;    // 快指针走两步
    }

    return slow; // 慢指针所在位置即为中间节点
}

这种方法的时间复杂度为 O(n)(只需一次遍历),空间复杂度为 O(1),比朴素解法更高效。

总结

本节课中我们一起学习了如何寻找链表的中间节点。我们首先分析了一个需要两次遍历的朴素解法,然后重点掌握了一种更优的“快慢指针”法。快慢指针法通过一次遍历就能定位中间节点,代码简洁且效率高,是解决此类问题的经典技巧。理解并熟练运用这种双指针技巧,对于解决更多复杂的链表问题(如检测循环)非常有帮助。

059:检测和移除链表中的环

概述

在本节课中,我们将学习一个重要的链表概念:如何检测并移除链表中的环或循环。我们将把问题分为两部分:检测环的存在,以及找到环的起始节点并将其移除。


检测链表中的环

上一节我们介绍了链表环的基本概念,本节中我们来看看如何检测一个链表中是否存在环。

核心思路是使用快慢指针法。我们创建两个指针,slowfast,都从链表头部开始。slow指针每次移动一步,fast指针每次移动两步。如果链表中存在环,这两个指针最终一定会相遇。如果fast指针到达了链表末尾(nullptr),则说明链表中没有环。

以下是检测环的逻辑步骤:

  1. 初始化两个指针 slowfast,都指向链表头节点 head
  2. 进入一个循环,条件是 fast != nullptrfast->next != nullptr
  3. 在循环内,slow 指针移动一步:slow = slow->next
  4. fast 指针移动两步:fast = fast->next->next
  5. 检查 slowfast 是否指向同一个节点。如果是,则链表存在环,返回 true
  6. 如果循环正常结束(fast 到达末尾),则链表无环,返回 false

代码描述

bool hasCycle(ListNode *head) {
    if (head == nullptr) return false;
    
    ListNode *slow = head;
    ListNode *fast = head;
    
    while (fast != nullptr && fast->next != nullptr) {
        slow = slow->next;          // 慢指针移动一步
        fast = fast->next->next;    // 快指针移动两步
        
        if (slow == fast) {
            return true; // 相遇,存在环
        }
    }
    return false; // 快指针到达末尾,无环
}

通过这种方法,我们可以高效地判断链表是否有环。


寻找环的起始节点

在确认链表存在环之后,下一个常见的问题是找到环开始的节点。

方法如下:在快慢指针相遇后,将其中一个指针(例如slow)重新指向链表头部。然后让slowfast指针都以每次一步的速度移动。当它们再次相遇时,相遇的节点就是环的起始节点。

以下是寻找环起始节点的步骤:

  1. 首先使用快慢指针法确认链表有环,并记录相遇点。
  2. slow 指针重新指向链表头 head
  3. fast 指针保持在相遇点。
  4. 同时移动 slowfast 指针,每次各移动一步。
  5. slowfast 再次相遇时,所在的节点即为环的入口节点。

代码描述

ListNode *detectCycle(ListNode *head) {
    ListNode *slow = head;
    ListNode *fast = head;
    bool hasCycle = false;
    
    // 第一步:检测环
    while (fast != nullptr && fast->next != nullptr) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) {
            hasCycle = true;
            break;
        }
    }
    
    // 如果没有环,返回 nullptr
    if (!hasCycle) return nullptr;
    
    // 第二步:寻找环的起点
    slow = head; // 慢指针回到头部
    while (slow != fast) {
        slow = slow->next; // 两者都每次移动一步
        fast = fast->next;
    }
    return slow; // 或 fast,此时它们指向环的起点
}

这个方法的正确性可以通过数学公式推导来理解。


数学推导与证明

设:

  • x = 从链表头到环入口节点的距离。
  • y = 从环入口节点到快慢指针第一次相遇点的距离。
  • D = 环的总长度。
  • 在相遇点,慢指针走过的总路程为 S_slow = x + y
  • 快指针走过的总路程为 S_fast = x + y + n * D,其中 n 是快指针在环内绕的圈数。
  • 因为快指针速度是慢指针的两倍,所以 S_fast = 2 * S_slow

由此得到方程:
x + y + n*D = 2*(x + y)
化简可得:
x = n*D - y
这个公式意味着,从链表头到环入口的距离 x,等于从相遇点再走 (n*D - y)。而 (n*D - y) 正好是从相遇点走到环入口的距离(可能绕环 n-1 圈)。这解释了为什么将指针重置后,以相同速度移动会再次在环入口相遇。


移除链表中的环

找到环的起始节点后,移除环就变得简单了。核心是找到环入口节点的前一个节点,并将其 next 指针设置为 nullptr

以下是移除环的步骤:

  1. 使用前述方法找到环的入口节点 cycleStart
  2. 初始化一个指针 prev,从 cycleStart 出发,在环内移动。
  3. 移动 prev,直到 prev->next 再次等于 cycleStart。此时 prev 就是环入口节点的前驱节点。
  4. prev->next 设置为 nullptr,从而断开环。

代码描述

void removeCycle(ListNode *head) {
    ListNode *cycleStart = detectCycle(head);
    if (cycleStart == nullptr) return; // 无环,直接返回
    
    ListNode *prev = cycleStart;
    // 在环内遍历,找到 cycleStart 的前一个节点
    while (prev->next != cycleStart) {
        prev = prev->next;
    }
    // 断开环
    prev->next = nullptr;
}

通过以上步骤,我们就能成功移除链表中的环。


总结

本节课中我们一起学习了关于链表环的三个核心问题:

  1. 检测环:使用快慢指针法,判断链表是否有环。
  2. 寻找环起点:在确认有环后,通过重置指针并同步移动来找到环的入口节点。
  3. 移除环:找到环入口的前驱节点,断开其 next 指针以移除环。

理解这些算法及其背后的数学原理,对于解决相关的面试和竞赛题目至关重要。

060:合并两个有序链表 📚

在本节课中,我们将学习如何解决一个经典的链表问题:合并两个有序链表。我们将通过递归的方法,一步步构建解决方案,并分析其时间复杂度。


概述

我们有两个已经按升序排列的链表。我们的目标是创建一个新的链表,它包含两个输入链表中的所有节点,并且这个新链表也必须是有序的。我们将通过递归比较两个链表的头节点值来实现这个功能。

核心逻辑

核心思想是递归地比较两个链表当前头节点的值。值较小的节点将成为新链表的下一个节点,然后我们递归地处理剩余的部分。

以下是解决问题的递归思路:

  1. 基础情况:如果其中一个链表为空,则直接返回另一个链表。
  2. 递归情况
    • 比较 list1list2 的头节点值。
    • 如果 list1 的值小于或等于 list2 的值,那么 list1 的节点将是结果链表的一部分。我们递归地合并 list1->next 和整个 list2,并将结果连接到 list1 节点之后。
    • 反之,如果 list2 的值更小,则 list2 的节点成为结果的一部分。我们递归地合并整个 list1list2->next,并将结果连接到 list2 节点之后。

代码实现

现在,让我们将上述逻辑转化为实际的 C++ 代码。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
        // 基础情况:如果其中一个链表为空,返回另一个
        if(list1 == NULL) return list2;
        if(list2 == NULL) return list1;

        // 递归情况:比较两个头节点的值
        if(list1->val <= list2->val) {
            // list1 的值更小,它将成为当前节点
            // 递归合并 list1->next 和 list2,并将结果接在 list1 后面
            list1->next = mergeTwoLists(list1->next, list2);
            return list1; // 返回 list1 作为当前子链表的头
        } else {
            // list2 的值更小,它将成为当前节点
            // 递归合并 list1 和 list2->next,并将结果接在 list2 后面
            list2->next = mergeTwoLists(list1, list2->next);
            return list2; // 返回 list2 作为当前子链表的头
        }
    }
};

复杂度分析

  • 时间复杂度O(n + m),其中 nm 分别是两个链表的长度。在最坏情况下,我们需要遍历两个链表中的所有节点。
  • 空间复杂度O(n + m),递归调用栈的深度最多为 n + m

总结

本节课我们一起学习了如何使用递归方法合并两个有序链表。我们首先分析了问题的核心逻辑,即通过比较头节点值来决定下一个节点,并递归处理剩余部分。接着,我们将伪代码转化为可运行的 C++ 代码。最后,我们分析了算法的时间复杂度和空间复杂度,均为 O(n + m)。掌握这种方法对于理解递归和链表操作至关重要。

061:复制带随机指针的链表

在本节课中,我们将学习如何解决一个重要的链表问题:复制带随机指针的链表。我们将通过一个清晰的、分步的方法来创建原链表的深度拷贝,包括其next指针和random指针的连接。

问题概述

我们被给予一个特殊的链表。链表的每个节点除了包含一个指向下一个节点的next指针外,还包含一个random指针。这个random指针可以指向链表中的任意一个节点,也可以指向null

我们的任务是创建这个链表的一个深度拷贝。这意味着我们需要生成一组全新的节点,这些新节点与原节点的值相同,并且新链表中的nextrandom指针连接关系必须与原链表完全一致。

解决方案:两步法

为了解决这个问题,我们将采用一个分为两步的策略。

第一步:创建节点并建立 next 指针连接

首先,我们忽略random指针,专注于复制节点并建立next指针的连接。同时,我们需要记录原节点与其对应新节点之间的映射关系,这将在第二步中至关重要。

以下是第一步的具体步骤:

  1. 初始化两个指针:oldTemp指向原链表的头节点,newTemp指向新链表的头节点(初始时新链表为空)。
  2. 创建一个哈希表(如 unordered_map),用于存储原节点到新节点的映射。
  3. 遍历原链表。对于每个oldTemp指向的原节点:
    • 根据原节点的值创建一个新节点,称为copyNode
    • newTempnext指针指向这个新创建的copyNode
    • 在哈希表中记录映射关系:map[oldTemp] = copyNode
    • 更新指针:oldTemp = oldTemp->nextnewTemp = newTemp->next
  4. 完成遍历后,我们就得到了一个只有next指针连接的新链表,并且拥有了原节点到新节点的完整映射。

第二步:建立 random 指针连接

上一节我们创建了新链表并记录了节点映射,本节中我们来看看如何利用这个映射来重建random指针。

现在,我们再次同时遍历原链表和新链表,利用第一步中建立的映射关系来设置新节点的random指针。

以下是第二步的具体步骤:

  1. 重新初始化指针:oldTemp指向原链表头,newTemp指向新链表头。
  2. 遍历链表。对于每一对节点(oldTempnewTemp):
    • 找到原节点oldTemprandom指针指向的节点,记为randomTarget
    • 在哈希表中,通过map[randomTarget]找到randomTarget对应的新节点。
    • 将新节点newTemprandom指针指向map[randomTarget]
    • 更新指针:oldTemp = oldTemp->nextnewTemp = newTemp->next
  3. 遍历完成后,新链表中所有节点的random指针就设置完毕了。

代码实现

以下是上述算法的C++代码实现:

class Solution {
public:
    Node* copyRandomList(Node* head) {
        if (head == nullptr) return nullptr;

        // 步骤1:创建节点,建立next连接,并记录映射
        unordered_map<Node*, Node*> nodeMap;
        Node* oldTemp = head;
        Node* newHead = new Node(head->val); // 创建新头节点
        Node* newTemp = newHead;
        nodeMap[oldTemp] = newTemp;

        while (oldTemp->next != nullptr) {
            oldTemp = oldTemp->next;
            Node* copyNode = new Node(oldTemp->val);
            newTemp->next = copyNode;
            newTemp = newTemp->next;
            nodeMap[oldTemp] = newTemp; // 记录映射
        }

        // 步骤2:建立random指针连接
        oldTemp = head;
        newTemp = newHead;
        while (oldTemp != nullptr) {
            if (oldTemp->random != nullptr) {
                // 通过映射找到原random节点对应的新节点
                newTemp->random = nodeMap[oldTemp->random];
            }
            oldTemp = oldTemp->next;
            newTemp = newTemp->next;
        }

        return newHead;
    }
};

复杂度分析

  • 时间复杂度:O(n)。我们遍历了原链表两次,每次都是线性时间。哈希表的插入和查找操作平均时间复杂度为O(1)。
  • 空间复杂度:O(n)。主要空间开销来自于存储n个节点映射关系的哈希表。

总结

本节课中我们一起学习了如何复制带随机指针的链表。我们通过一个高效的两步法解决了这个问题:

  1. 首先遍历原链表,创建新节点并建立next指针连接,同时用哈希表记录原节点到新节点的映射。
  2. 然后再次遍历,利用映射关系为新节点设置正确的random指针。

这种方法思路清晰,代码简洁,且时间复杂度和空间复杂度均为O(n),是解决此类问题的经典方案。

062:双向链表教程 🧩

在本节课中,我们将详细学习双向链表的概念。我们将了解其结构,并学习如何实现其核心功能,包括在链表头部和尾部插入节点,以及从头部和尾部删除节点。


双向链表的结构

上一节我们介绍了单向链表,本节中我们来看看双向链表。双向链表中的每个节点不仅包含指向下一个节点的指针,还包含指向前一个节点的指针。这使得我们可以从两个方向遍历链表。

每个节点的结构可以用以下代码描述:

class Node {
public:
    int data;        // 节点存储的数据
    Node* next;      // 指向下一个节点的指针
    Node* prev;      //指向前一个节点的指针

    // 构造函数
    Node(int value) {
        data = value;
        next = nullptr;
        prev = nullptr;
    }
};

双向链表本身是一个类,它包含指向链表头部和尾部的指针。以下是双向链表类的结构:

class DoublyLinkedList {
public:
    Node* head; // 指向链表第一个节点的指针
    Node* tail; // 指向链表最后一个节点的指针

    // 构造函数
    DoublyLinkedList() {
        head = nullptr;
        tail = nullptr;
    }

    // 其他成员函数(如插入、删除、打印等)将在这里实现
};

实现基本功能

现在我们已经定义了结构,接下来我们将实现双向链表的核心操作。

在链表头部插入节点(Push Front)

push_front 函数用于在链表的开头添加一个新节点。需要考虑链表为空和不为空两种情况。

以下是实现 push_front 的步骤:

  1. 创建一个新的 Node 对象。
  2. 如果链表为空(head == nullptr),则将 headtail 都指向这个新节点。
  3. 如果链表不为空:
    • 将新节点的 next 指针指向当前的 head
    • 将当前 head 节点的 prev 指针指向新节点。
    • 将链表的 head 指针更新为指向新节点。

让我们在 DoublyLinkedList 类中实现这个函数:

void push_front(int value) {
    Node* newNode = new Node(value); // 步骤1:创建新节点

    if (head == nullptr) { // 情况1:链表为空
        head = newNode;
        tail = newNode;
    } else { // 情况2:链表不为空
        newNode->next = head; // 新节点指向原头节点
        head->prev = newNode; // 原头节点指回新节点
        head = newNode;       // 更新头指针
    }
}

在链表尾部插入节点(Push Back)

push_back 函数用于在链表的末尾添加一个新节点。其逻辑与 push_front 类似。

以下是实现 push_back 的步骤:

  1. 创建一个新的 Node 对象。
  2. 如果链表为空(head == nullptr),则将 headtail 都指向这个新节点。
  3. 如果链表不为空:
    • 将当前 tail 节点的 next 指针指向新节点。
    • 将新节点的 prev 指针指向当前的 tail
    • 将链表的 tail 指针更新为指向新节点。

让我们在 DoublyLinkedList 类中实现这个函数:

void push_back(int value) {
    Node* newNode = new Node(value); // 步骤1:创建新节点

    if (head == nullptr) { // 情况1:链表为空
        head = newNode;
        tail = newNode;
    } else { // 情况2:链表不为空
        tail->next = newNode; // 原尾节点指向新节点
        newNode->prev = tail; // 新节点指回原尾节点
        tail = newNode;       // 更新尾指针
    }
}

打印链表(Print)

为了验证我们的操作,我们需要一个能打印链表所有节点数据的函数。

以下是实现 print 函数的步骤:

  1. 创建一个临时指针 temp,从 head 开始。
  2. 使用循环遍历链表,直到 temp 变为 nullptr
  3. 在循环中,打印当前节点 temp 的数据。
  4. temp 移动到下一个节点(temp = temp->next)。

让我们在 DoublyLinkedList 类中实现这个函数:

void print() {
    Node* temp = head; // 从头部开始
    while (temp != nullptr) {
        std::cout << temp->data << " "; // 打印数据
        temp = temp->next; // 移动到下一个节点
    }
    std::cout << std::endl;
}

实现删除功能

学会了插入节点,接下来我们看看如何从链表中删除节点。

删除链表头部节点(Pop Front)

pop_front 函数用于删除链表的第一个节点。需要处理链表为空、只有一个节点和多个节点的情况。

以下是实现 pop_front 的步骤:

  1. 检查链表是否为空。如果为空,则无法删除,直接返回或给出提示。
  2. 创建一个临时指针 temp 指向当前的 head 节点(即待删除节点)。
  3. head 指针移动到下一个节点(head = head->next)。
  4. 如果新的 head 不是 nullptr(即链表删除后不为空),则将其 prev 指针设为 nullptr,断开与前一个节点的连接。
  5. 如果删除后链表为空(即原链表只有一个节点),则需要将 tail 也设为 nullptr
  6. 使用 delete 关键字释放 temp 指向节点的内存。

让我们在 DoublyLinkedList 类中实现这个函数:

void pop_front() {
    if (head == nullptr) { // 情况1:链表为空
        std::cout << "List is empty, cannot pop front." << std::endl;
        return;
    }

    Node* temp = head; // 步骤2:临时存储头节点
    head = head->next; // 步骤3:移动头指针

    if (head != nullptr) { // 情况2:删除后链表不为空
        head->prev = nullptr; // 步骤4:断开新头节点的前向链接
    } else { // 情况3:删除后链表为空(原链表只有一个节点)
        tail = nullptr; // 步骤5:尾指针也置空
    }

    delete temp; // 步骤6:释放内存
}

删除链表尾部节点(Pop Back)

pop_back 函数用于删除链表的最后一个节点。其逻辑与 pop_front 对称。

以下是实现 pop_back 的步骤:

  1. 检查链表是否为空。如果为空,则无法删除,直接返回或给出提示。
  2. 创建一个临时指针 temp 指向当前的 tail 节点(即待删除节点)。
  3. tail 指针移动到前一个节点(tail = tail->prev)。
  4. 如果新的 tail 不是 nullptr(即链表删除后不为空),则将其 next 指针设为 nullptr,断开与后一个节点的连接。
  5. 如果删除后链表为空(即原链表只有一个节点),则需要将 head 也设为 nullptr
  6. 使用 delete 关键字释放 temp 指向节点的内存。

让我们在 DoublyLinkedList 类中实现这个函数:

void pop_back() {
    if (head == nullptr) { // 情况1:链表为空
        std::cout << "List is empty, cannot pop back." << std::endl;
        return;
    }

    Node* temp = tail; // 步骤2:临时存储尾节点
    tail = tail->prev; // 步骤3:移动尾指针

    if (tail != nullptr) { // 情况2:删除后链表不为空
        tail->next = nullptr; // 步骤4:断开新尾节点的后向链接
    } else { // 情况3:删除后链表为空(原链表只有一个节点)
        head = nullptr; // 步骤5:头指针也置空
    }

    delete temp; // 步骤6:释放内存
}

总结 🎯

本节课中我们一起学习了双向链表。我们从其基本结构开始,了解了每个节点如何通过 nextprev 指针实现双向连接。然后,我们逐步实现了双向链表的四个核心功能:

  • push_front:在链表头部插入新节点。
  • push_back:在链表尾部插入新节点。
  • pop_front:删除链表头部的节点。
  • pop_back:删除链表尾部的节点。

我们还实现了一个简单的 print 函数来遍历和显示链表内容。通过结合伪代码和实际的 C++ 代码,我们清晰地理解了每个操作的逻辑步骤和边界情况处理。掌握这些基础操作是理解和运用更复杂链表算法(如在任意位置插入删除)的关键。

063:循环链表 🔄

在本节课中,我们将详细学习循环链表的概念。循环链表是链表的一种变体,其最后一个节点的指针指向头节点,形成一个环。我们将从零开始实现循环链表,并学习如何插入、删除节点以及遍历链表。


循环链表的基本概念

上一节我们介绍了普通链表,本节中我们来看看循环链表。循环链表与普通单链表的主要区别在于其尾节点的指针指向头节点,而不是 null。这使得链表形成一个闭环。

在普通单链表中,我们有一个头指针和一个尾指针,尾指针的 next 指向 null。而在循环链表中,尾节点的 next 指针指向头节点。这意味着我们可以从链表的任何节点出发,最终都能回到起点。

循环链表的基本结构可以用以下伪代码表示:

class Node {
public:
    int data;
    Node* next;
    Node(int val) {
        data = val;
        next = nullptr;
    }
};

class CircularLinkedList {
private:
    Node* head;
    Node* tail;
public:
    CircularLinkedList() {
        head = nullptr;
        tail = nullptr;
    }
    // ... 其他成员函数
};

实现节点和链表类

首先,我们需要创建节点类和循环链表类。以下是实现步骤:

以下是节点类的定义:

class Node {
public:
    int data;
    Node* next;
    Node(int val) {
        data = val;
        next = nullptr;
    }
};

接下来是循环链表类的框架:

class CircularLinkedList {
private:
    Node* head;
    Node* tail;
public:
    CircularLinkedList() {
        head = nullptr;
        tail = nullptr;
    }
    // 后续将在这里添加成员函数
};

在头部插入节点

现在,我们来实现第一个操作:在循环链表的头部插入一个新节点。需要考虑链表为空和非空两种情况。

以下是插入操作的逻辑步骤:

  1. 创建一个新节点。
  2. 如果链表为空(head == nullptr),则新节点既是头节点也是尾节点,并且其 next 指针指向自身。
  3. 如果链表不为空,则将新节点的 next 指向当前头节点,更新尾节点的 next 指向新节点,最后将头指针更新为新节点。

对应的代码实现如下:

void insertAtHead(int val) {
    Node* newNode = new Node(val);
    if (head == nullptr) {
        head = newNode;
        tail = newNode;
        newNode->next = head; // 指向自身形成环
    } else {
        newNode->next = head;
        tail->next = newNode;
        head = newNode;
    }
}

遍历并打印链表

为了验证我们的插入操作,我们需要一个能打印链表所有元素的函数。由于链表是循环的,遍历的终止条件不再是 nullptr,而是回到头节点。

以下是打印链表的步骤:

  1. 如果链表为空,直接返回。
  2. 从头节点开始,打印当前节点的数据。
  3. 使用一个临时指针移动到下一个节点。
  4. 循环继续,直到临时指针再次回到头节点。

对应的代码实现如下:

void printList() {
    if (head == nullptr) {
        return;
    }
    Node* temp = head;
    do {
        std::cout << temp->data << " -> ";
        temp = temp->next;
    } while (temp != head);
    std::cout << "(head)" << std::endl; // 指示回到头部
}

在尾部插入节点

接下来,我们实现在循环链表尾部插入节点的功能。其逻辑与头部插入类似。

以下是插入尾部的逻辑步骤:

  1. 创建一个新节点。
  2. 如果链表为空,处理方式与头部插入相同。
  3. 如果链表不为空,则将新节点的 next 指向头节点,将当前尾节点的 next 指向新节点,最后更新尾指针为新节点。

对应的代码实现如下:

void insertAtTail(int val) {
    Node* newNode = new Node(val);
    if (head == nullptr) {
        head = newNode;
        tail = newNode;
        newNode->next = head;
    } else {
        newNode->next = head;
        tail->next = newNode;
        tail = newNode;
    }
}

删除头节点

现在,我们学习如何删除循环链表的头节点。我们需要处理三种情况:链表为空、只有一个节点、有多个节点。

以下是删除头节点的逻辑步骤:

  1. 链表为空:无节点可删,直接返回。
  2. 只有一个节点:删除该节点,并将 headtail 都设为 nullptr
  3. 有多个节点:将头指针移动到下一个节点,更新尾节点的 next 指针指向新的头节点,然后删除原头节点。

对应的代码实现如下:

void deleteAtHead() {
    if (head == nullptr) {
        return; // 情况1:空链表
    }
    if (head == tail) {
        delete head; // 情况2:只有一个节点
        head = nullptr;
        tail = nullptr;
    } else {
        Node* temp = head; // 情况3:多个节点
        head = head->next;
        tail->next = head;
        delete temp;
    }
}

删除尾节点

最后,我们实现删除循环链表尾节点的操作。这同样需要处理空链表、单节点链表和多节点链表的情况。

以下是删除尾节点的逻辑步骤:

  1. 链表为空:直接返回。
  2. 只有一个节点:与删除头节点处理方式相同。
  3. 有多个节点:遍历链表找到尾节点的前一个节点(prev)。将 prevnext 指向头节点,更新尾指针为 prev,然后删除原尾节点。

对应的代码实现如下:

void deleteAtTail() {
    if (head == nullptr) {
        return; // 情况1:空链表
    }
    if (head == tail) {
        delete head; // 情况2:只有一个节点
        head = nullptr;
        tail = nullptr;
    } else {
        Node* temp = tail; // 情况3:多个节点
        Node* prev = head;
        while (prev->next != tail) {
            prev = prev->next;
        }
        prev->next = head; // prev成为新的尾节点,指向头
        tail = prev;
        delete temp;
    }
}

本节课中我们一起学习了循环链表的核心概念和基本操作。我们实现了循环链表的类结构,并完成了在头部和尾部插入节点、遍历链表、以及删除头和尾节点的功能。循环链表因其闭环特性,在某些场景(如轮询调度)中非常有用。理解这些基础操作是解决更复杂链表问题的重要一步。

064:展平多级双向链表 - LeetCode 430

在本节课中,我们将学习如何解决一个重要的链表问题:展平多级双向链表。我们将深入理解递归在解决此类问题中的应用,并逐步构建解决方案。

概述

我们将要解决的问题是LeetCode第430题“展平多级双向链表”。题目给定一个带有多级子链表的双向链表,我们的目标是将它展平成一个单级的双向链表。核心解决思路是使用递归,我们将通过遍历链表,每当遇到有子节点的节点时,就递归地展平其子链表,并将其正确地插入到主链表中。

问题分析与递归思路

上一节我们介绍了问题的基本概念,本节中我们来看看具体的递归解法思路。

链表中的每个节点除了有nextprev指针,还可能有一个child指针指向另一个双向链表的头节点。我们的任务是将这些多级结构“拉平”。

递归的核心思想是:当我们遍历链表遇到一个具有子链表的节点(current->child不为空)时,我们首先递归地展平这个子链表。然后,我们需要将这个已经展平的子链表插入到当前节点和它的下一个节点之间。

以下是解决此问题的三个关键步骤:

  1. 处理子链表:如果当前节点有子节点,递归调用展平函数处理子链表。
  2. 寻找子链表的尾节点:为了将子链表插入主链,我们需要知道子链表的最后一个节点(尾节点)。
  3. 连接链表:将展平后的子链表插入到当前节点和原current->next节点之间,并正确设置prevnext指针。

代码实现与逐步讲解

理解了递归思路后,我们现在将其转化为具体的代码。我们将定义一个递归函数flatten来处理链表。

函数的框架如下,它接收链表的头节点,并返回展平后的链表头节点。

Node* flatten(Node* head) {
    // 递归基:如果链表为空,直接返回
    if (head == nullptr) return head;

    Node* current = head;
    // 遍历主链表
    while (current != nullptr) {
        // 主要逻辑将在这里实现
    }
    return head;
}

接下来,我们在循环中填充核心逻辑。以下是每一步的详细说明:

第一步:处理子链表
如果当前节点current有子节点,我们递归地展平该子链表,并准备将其插入。

if (current->child != nullptr) {
    // 递归展平子链表,得到其头节点
    Node* childHead = flatten(current->child);
    // ... 后续连接操作
}

第二步:连接子链表与当前节点
展平子链表后,我们需要将其头节点连接到当前节点的next位置,并建立双向链接。

// 保存当前节点原来的下一个节点
Node* nextNode = current->next;
// 将当前节点的next指向展平后的子链表头
current->next = childHead;
// 子链表头的prev指向当前节点
childHead->prev = current;
// 将当前节点的child指针置空(题目要求)
current->child = nullptr;

第三步:寻找子链表的尾节点并连接剩余部分
为了将原current->next之后的链表接回来,我们需要找到子链表的尾节点。

// 寻找子链表的尾节点
Node* tail = childHead;
while (tail->next != nullptr) {
    tail = tail->next;
}
// 将子链表的尾节点与原下一个节点连接
if (nextNode != nullptr) {
    tail->next = nextNode;
    nextNode->prev = tail;
}

第四步:移动当前指针
完成插入后,我们需要移动current指针。注意,由于我们已经将子链表插入,current->next现在指向了子链表的头,而循环会自动通过current = current->next遍历到我们刚插入的子链表节点。为了避免重复处理子链表,我们应该直接将current移动到原子链表末尾的下一个节点,即之前保存的nextNode的位置。但为了逻辑清晰并与遍历循环配合,我们可以在完成连接后,让循环自然迭代。更准确的做法是,在插入子链表后,current应该跳至tail的位置,因为tail之后的下一个节点才是原主链的下一个待处理节点。但为了简化,我们依靠while循环的current = current->next,它会遍历整个新连接起来的链表。关键在于,在连接完成后,currentchild已被置空,所以不会再次触发递归。

// 循环的末尾,指针向前移动
current = current->next;

将以上所有步骤整合到循环中,就得到了完整的递归解法。

复杂度分析与总结

本节课中我们一起学习了如何使用递归展平多级双向链表。

我们分析了问题的结构,并利用递归“深入优先”的特性,先处理最深层的子链表,再逐层向上合并。算法遍历了链表中的每一个节点,每个节点至多被访问一次,因此时间复杂度是O(N),其中N是展平后链表的总节点数。递归调用栈的深度取决于链表的级数,空间复杂度在最坏情况下是O(N)(例如链表完全是一条竖线)。

这个解决方案建立在递归的基本原理之上。通过这个问题,我们巩固了如何利用递归分解问题、处理子任务并组合结果的思维模式。记住处理链表连接时,要细心维护nextprev指针的正确性。

如果你成功理解了本讲内容,可以在评论区进行标记。你也可以通过Twitter(链接在描述框)与我分享你的学习进度。如果有任何疑问,欢迎提出。我们下节课再见,继续学习,继续探索。

065:K个一组反转链表

在本节课中,我们将学习如何解决一个重要的链表问题:K个一组反转链表。这是一个LeetCode上的困难级别问题(第25题)。我们将通过递归方法,分解问题步骤,并最终实现一个完整的解决方案。


概述

问题要求我们给定一个链表的头节点 head 和一个整数 k。我们需要将链表中的节点每 k 个分为一组,然后反转每一组内的节点连接。如果最后剩余的节点数量不足 k 个,则保持其原有顺序不变。本节课将详细讲解解决此问题的思路和代码实现。


问题分解

解决此问题可以分为三个主要步骤:

  1. 检查是否存在K个节点:首先,我们需要判断从当前头节点开始,是否至少有 k 个节点可供反转。
  2. 递归处理剩余链表:如果存在 k 个节点,在反转当前这 k 个节点后,我们需要对链表剩余的部分递归地执行相同的操作。
  3. 反转当前K个节点:这是核心操作,我们需要将当前组内的 k 个节点进行局部反转。

接下来,我们将逐一详细讲解每个步骤。


第一步:检查K个节点是否存在

在尝试反转之前,我们必须确保从当前 head 节点开始,链表中至少有 k 个节点。我们可以编写一个辅助函数来完成这个检查。

以下是检查功能的实现思路:

  • 我们使用一个临时指针 temp 从头节点 head 开始遍历。
  • 同时使用一个计数器 count,在遍历过程中递增。
  • 如果我们在计数器达到 k 之前遇到了 NULL(即链表末尾),则说明节点数量不足 k 个。
// 函数用于检查从当前head开始是否存在至少k个节点
bool checkKNodesExist(ListNode* head, int k) {
    ListNode* temp = head;
    int count = 0;
    while (count < k && temp != NULL) {
        temp = temp->next;
        count++;
    }
    // 如果count等于k,说明存在k个节点;否则不存在
    return count == k;
}

如果节点数量不足 k,我们直接返回当前的 head,无需进行任何反转操作。


第二步:递归处理后续链表

如果确认存在 k 个节点,那么在我们反转了当前这 k 个节点之后,链表剩余的部分也需要进行同样的“K个一组反转”处理。这是一个典型的递归应用场景。

假设我们有一个函数 reverseKGroup(ListNode* head, int k) 可以解决整个问题。那么,在反转了前 k 个节点后,新的头节点(假设叫 newHead)已知,而第 k 个节点的下一个节点(即 第k个节点->next)就是剩余链表的头节点。我们对这个剩余链表的头节点递归调用 reverseKGroup 函数即可。

这个递归调用将在反转当前组之后进行。


第三步:反转当前K个节点

这是算法的核心。我们需要实现一个函数,专门用于反转从给定头节点开始的连续 k 个节点。这个过程与反转整个链表类似,但我们需要在反转后,让新的尾节点(即原来的头节点)指向递归处理后的剩余链表的新头节点。

反转 k 个节点的步骤可以概括如下:

  1. 初始化三个指针:prev = NULLcurr = headnext = NULL
  2. 使用一个循环,循环 k 次。
  3. 在每次循环中:
    • 保存当前节点的下一个节点:next = curr->next
    • 反转指针方向:curr->next = prev
    • 移动 prevcurr 指针,为下一次迭代做准备:prev = curr; curr = next
  4. 循环结束后,prev 指向这 k 个节点的新头节点,而原来的头节点 head 现在变成了这组的尾节点。

关键的一步是:在反转完成后,我们需要让这个新尾节点(即原来的 head)的 next 指针,指向递归调用 reverseKGroup 返回的结果(即后续链表处理后的新头节点)。


完整代码实现与逻辑串联

现在,我们将以上三个步骤整合到 reverseKGroup 主函数中。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* reverseKGroup(ListNode* head, int k) {
        // 步骤1: 检查是否存在k个节点
        ListNode* temp = head;
        int count = 0;
        while (count < k && temp != nullptr) {
            temp = temp->next;
            count++;
        }
        // 如果节点数不足k,直接返回head,不进行反转
        if (count < k) {
            return head;
        }

        // 步骤3: 反转当前k个节点 (此时已知至少有k个节点)
        ListNode* prev = nullptr;
        ListNode* curr = head;
        ListNode* next = nullptr;
        count = 0;
        while (curr != nullptr && count < k) {
            next = curr->next;      // 保存下一个节点
            curr->next = prev;      // 反转指针
            prev = curr;            // 移动prev
            curr = next;            // 移动curr
            count++;
        }
        // 循环结束后,prev是当前组的新头节点,head变成了当前组的新尾节点

        // 步骤2: 递归处理剩余链表,并将当前组的尾节点与其连接
        // 此时curr指向第k+1个节点(即剩余链表的头节点)
        if (next != nullptr) { // 等价于 if (curr != nullptr)
            head->next = reverseKGroup(next, k);
        }

        // 返回当前组反转后的新头节点
        return prev;
    }
};

代码逻辑流程:

  1. 函数首先检查从当前 head 开始是否有 k 个节点。如果没有,直接返回 head
  2. 如果有,则进入反转当前 k 个节点的循环。
  3. 反转完成后,prev 成为这组的新头节点,head(原头节点)成为新尾节点,curr(即循环结束时的 next)指向下一组的头节点。
  4. 递归调用 reverseKGroup(curr, k) 来处理剩余链表,并将返回的新头节点赋值给 head->next,从而将当前组与后续处理好的链表连接起来。
  5. 最后,函数返回 prev,作为当前部分链表(已处理部分)的新头节点。

示例演示

让我们用一个例子 head = [1,2,3,4,5], k = 2 来演示过程:

  • 首先检查 [1,2,3,4,5] 是否有2个节点?有。
  • 反转前2个节点 [1,2],得到 [2,1]。此时 prev=2(新头),head=1(新尾),curr=3(下一组头)。
  • 递归处理以 3 为头的链表 [3,4,5]k=2
    • 检查 [3,4,5] 是否有2个节点?有。
    • 反转 [3,4],得到 [4,3]。此时 prev=4head=3curr=5
    • 递归处理以 5 为头的链表 [5]k=2
      • 检查 [5] 是否有2个节点?没有。直接返回 head=5
    • head->next (即 3->next) 指向递归结果 5。链表变为 [4,3,5]
    • 返回 prev=4 作为 [3,4,5] 部分的新头。
  • 将最外层的 head->next (即 1->next) 指向递归结果 4。链表变为 [2,1,4,3,5]
  • 返回最外层的 prev=2 作为最终结果的新头节点。

总结

在本节课中,我们一起学习了如何解决 “K个一组反转链表” 这个难题。我们通过将问题分解为三个清晰的步骤来降低复杂度:

  1. 检查:确认有足够节点进行反转。
  2. 递归:将剩余链表视为一个相同性质的子问题。
  3. 反转:实现标准的迭代反转算法,但只反转固定数量 k 的节点。

我们实现了完整的C++代码,并通过示例梳理了程序的执行流程。掌握这个问题的解法,对于深入理解链表操作和递归思想非常有帮助。

066:两两交换链表中的节点

在本节课中,我们将学习如何解决一个重要的链表问题:两两交换链表中的节点。我们将详细分析问题,理解其背后的逻辑,并最终用代码实现解决方案。

概述

问题要求我们给定一个链表,然后两两交换其中相邻的节点。如果链表节点数为奇数,则最后一个节点保持不变。例如,链表 1->2->3->4 经过交换后应变为 2->1->4->3。我们的目标是理解并实现这个交换逻辑。

问题分析与核心逻辑

上一节我们概述了问题,本节中我们来看看解决这个问题的核心逻辑。关键在于重新安排节点之间的连接,而不是仅仅交换节点中存储的值。

假设我们有一个链表,其中包含节点 1234。为了交换第一对节点(12),我们需要三个指针来辅助操作:

  • first:指向当前待交换对的第一个节点(节点 1)。
  • second:指向当前待交换对的第二个节点(节点 2)。
  • prev:指向上一对交换完成后,新链表的最后一个节点(对于第一对交换,初始时为 nullptr)。

以下是交换一对节点(例如 first=节点1second=节点2)的步骤:

  1. 首先,我们需要记录 second 节点的下一个节点,我们称之为 third(即节点 3)。这是为了在断开连接后能找到下一对节点。
    ListNode* third = second->next;
  2. 然后,我们改变 second 节点的 next 指针,使其指向 first 节点。这就建立了 2 -> 1 的连接。
    second->next = first;
  3. 接着,我们改变 first 节点的 next 指针,使其指向 third 节点(即节点 3)。这就建立了 1 -> 3 的连接。
    first->next = third;
  4. 最后,也是最关键的一步,我们需要更新 prev 指针。如果 prev 不是 nullptr(即这不是第一对交换),我们需要将 prev->next 指向新的当前对的第一个节点,即 second(节点 2)。这样就把前面已经处理好的链表部分和当前这对新交换的节点连接起来了。
    if(prev != nullptr) prev->next = second;
  5. 完成一对交换后,我们需要移动指针,为下一对交换做准备:
    • prev 移动到当前这对交换后的第二个节点(即原来的 first,现在的节点 1)。
    • first 移动到 third(即节点 3)。
    • 如果 first 不为空,则 second 移动到 first->next(即节点 4);否则 second 设为 nullptr

代码实现

理解了核心逻辑后,现在让我们将其转化为具体的 C++ 代码。我们将遵循上述步骤,并处理好边界情况。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution {
public:
    ListNode* swapPairs(ListNode* head) {
        // 处理空链表或只有一个节点的链表
        if(head == nullptr || head->next == nullptr) {
            return head;
        }

        // 初始化指针
        ListNode* first = head;
        ListNode* second = head->next;
        ListNode* prev = nullptr;

        // 新的头节点将是原链表的第二个节点
        ListNode* newHead = second;

        // 遍历链表,进行两两交换
        while(first != nullptr && second != nullptr) {
            // 步骤1:记录第二个节点的下一个节点
            ListNode* third = second->next;

            // 步骤2 & 3:交换 first 和 second 节点的连接
            second->next = first;
            first->next = third;

            // 步骤4:连接上一对交换后的节点与当前这对
            if(prev != nullptr) {
                prev->next = second;
            }

            // 步骤5:移动指针,为下一轮迭代做准备
            prev = first;      // prev 移动到当前对的第二个节点(交换后)
            first = third;     // first 移动到下一对的第一个节点

            if(first != nullptr) {
                second = first->next; // second 移动到下一对的第二个节点
            } else {
                second = nullptr;     // 如果没有下一对,second 设为空
            }
        }

        return newHead;
    }
};

复杂度分析

在实现了算法之后,我们来分析其效率。

  • 时间复杂度:O(n),其中 n 是链表的长度。我们只需要遍历链表一次,对每一对节点执行常数时间的操作。
  • 空间复杂度:O(1),我们只使用了固定数量的额外指针变量(firstsecondprevthirdnewHead),没有使用与链表规模相关的额外数据结构。

总结

本节课中我们一起学习了如何两两交换链表中的节点。我们首先分析了问题,明确了交换节点连接而非节点值的核心思想。接着,我们详细推导了使用三个指针(firstsecondprev)进行单对节点交换的步骤,并将其扩展为遍历整个链表的循环逻辑。最后,我们实现了完整的 C++ 代码,并分析了算法的时间和空间复杂度。掌握这个问题的解法,有助于加深对链表指针操作的理解。

067:栈数据结构简介 🥞

在本节课中,我们将要学习一种名为“栈”的基础数据结构。我们将了解它的核心特性、基本操作,并学习如何在C++中实现它。

概述

栈是一种遵循“后进先出”原则的线性数据结构。你可以把它想象成一个桶,你只能从顶部放入或取出物品。最后放进去的物品,会最先被取出来。

栈的核心特性与操作

上一节我们介绍了栈的比喻,本节中我们来看看它的核心特性与基本操作。

栈最重要的属性是 LIFO,即 Last In, First Out(后进先出)。这意味着最后被添加到栈中的元素,将是第一个被移除的元素。

栈主要支持三个基本操作:

  • push:将一个元素添加到栈的顶部。
  • pop:移除栈顶部的元素。
  • top:查看栈顶部的元素(不移除它)。

以下是栈操作的示意图:

使用向量实现栈

理解了栈的基本概念后,我们来看看如何在C++中实现它。一种常见的方法是使用标准库中的 vector 来模拟栈的行为。

我们将创建一个名为 Stack 的类,它内部使用一个 vector 来存储数据。通过封装 vector 的特定操作,我们可以让它表现得像一个栈。

以下是实现栈类的核心代码框架:

class Stack {
private:
    vector<int> v; // 内部使用向量存储数据
public:
    void push(int val) {
        v.push_back(val); // 在向量末尾添加元素,模拟入栈
    }
    void pop() {
        if(!v.empty()) {
            v.pop_back(); // 移除向量末尾元素,模拟出栈
        }
    }
    int top() {
        if(!v.empty()) {
            return v[v.size() - 1]; // 返回向量最后一个元素,即栈顶
        }
        // 处理栈为空的情况,这里简单返回-1
        return -1;
    }
    bool empty() {
        return v.empty(); // 判断向量是否为空
    }
};

代码解释

  • push 函数:调用 vectorpush_back,时间复杂度为 O(1)。
  • pop 函数:调用 vectorpop_back,时间复杂度为 O(1)。
  • top 函数:返回向量最后一个元素,时间复杂度为 O(1)。
  • empty 函数:检查向量是否为空。

使用链表实现栈

除了向量,我们也可以使用链表来实现栈。链表在头部插入和删除元素非常高效,这正好符合栈的操作特性。

我们将使用C++标准库中的 list(双向链表)来实现。核心思想是将链表的头部作为栈的顶部

以下是使用链表实现栈的关键逻辑:

class Stack {
private:
    list<int> l; // 内部使用链表存储数据
public:
    void push(int val) {
        l.push_front(val); // 在链表头部插入元素
    }
    void pop() {
        if(!l.empty()) {
            l.pop_front(); // 从链表头部删除元素
        }
    }
    int top() {
        if(!l.empty()) {
            return l.front(); // 返回链表头部元素
        }
        return -1;
    }
    bool empty() {
        return l.empty();
    }
};

实现逻辑

  • push:使用 push_front 将新元素加到链表头部,使其成为新的栈顶。
  • pop:使用 pop_front 移除链表头部元素。
  • top:使用 front 获取链表头部元素。

无论使用向量还是链表,栈的 pushpoptop 操作的时间复杂度都是 O(1)

栈的应用示例

现在,让我们使用实现的栈类来完成一个简单的操作,验证其LIFO特性。

int main() {
    Stack s;
    s.push(10);
    s.push(20);
    s.push(30);

    cout << s.top() << " "; // 输出 30
    s.pop();

    cout << s.top() << " "; // 输出 20
    s.pop();

    cout << s.top() << endl; // 输出 10
    s.pop();

    return 0;
}

运行这段代码,输出将是 30 20 10。这清晰地展示了“后进先出”的顺序:最后入栈的30最先被访问和移除。

总结

本节课中我们一起学习了栈数据结构。

  • 栈是一种 LIFO(后进先出)的线性数据结构。
  • 它的三个基本操作是:push(入栈)、pop(出栈)和 top(查看栈顶)。
  • 我们学习了两种实现栈的方法:基于向量和基于链表,两者的核心操作时间复杂度均为 O(1)。
  • 栈是许多算法(如函数调用、括号匹配、深度优先搜索)的基础,理解它至关重要。

在接下来的课程中,我们将探索栈的更多应用场景和高级用法。

068:有效的括号问题

在本节课中,我们将学习如何解决一个经典的算法问题——“有效的括号”。这是一个在技术面试中非常常见的问题,我们将使用栈这一数据结构来巧妙地解决它。

问题概述

问题的核心是判断一个仅由括号字符 (){}[] 组成的字符串是否有效。一个有效的字符串必须满足以下三个条件:

  1. 每个左括号必须用相同类型的右括号闭合。
  2. 左括号必须以正确的顺序闭合。
  3. 每个右括号都有一个对应的相同类型的左括号。

核心思路与规则

上一节我们明确了问题的要求,本节中我们来看看解决它的核心逻辑。关键在于观察有效括号字符串的闭合顺序:最后出现的左括号,必须最先被匹配和闭合。这种“后进先出”的特性,正是栈数据结构的典型应用场景。

因此,我们可以遵循一个简单的规则:遍历字符串,遇到左括号就将其压入栈中;遇到右括号时,检查栈顶的左括号是否能与之匹配。

算法步骤详解

以下是使用栈解决此问题的具体步骤:

  1. 初始化:创建一个空栈 stack<char> st
  2. 遍历字符串:对字符串中的每一个字符 s[i] 进行判断。
  3. 处理左括号:如果 s[i] 是左括号(({[),则将其压入栈中。
    if(s[i] == '(' || s[i] == '{' || s[i] == '[') {
        st.push(s[i]);
    }
    
  4. 处理右括号:如果 s[i] 是右括号()}]),则进入匹配检查流程:
    • 检查栈是否为空:如果栈为空,说明没有左括号可以匹配当前的右括号,字符串无效,直接返回 false
    • 检查栈顶元素:取出栈顶的左括号,检查它是否与当前的右括号 s[i] 匹配(例如 ( 匹配 ))。如果不匹配,字符串无效,返回 false
    • 匹配成功:如果匹配成功,则将这个栈顶的左括号弹出,表示这对括号已成功处理。
    else { // s[i] 是右括号
        if(st.empty()) return false;
        char topChar = st.top();
        if( (s[i] == ')' && topChar != '(') ||
            (s[i] == '}' && topChar != '{') ||
            (s[i] == ']' && topChar != '[') ) {
            return false;
        }
        st.pop();
    }
    
  5. 最终检查:遍历完整个字符串后,检查栈是否为空。
    • 如果栈为空,说明所有左括号都找到了匹配的右括号,字符串有效,返回 true
    • 如果栈不为空,说明还有未匹配的左括号,字符串无效,返回 false

复杂度分析

  • 时间复杂度O(n),其中 n 是字符串的长度。我们只需要遍历字符串一次。
  • 空间复杂度O(n),在最坏情况下(例如全是左括号),我们需要将所有字符都压入栈中。

完整代码示例

将上述逻辑整合,得到完整的C++解决方案代码如下:

bool isValid(string s) {
    stack<char> st;
    for(int i = 0; i < s.length(); i++) {
        char ch = s[i];
        // 如果是左括号,入栈
        if(ch == '(' || ch == '{' || ch == '[') {
            st.push(ch);
        }
        else { // 如果是右括号
            // 栈为空,无法匹配,无效
            if(st.empty()) return false;
            // 检查栈顶元素是否匹配
            char topChar = st.top();
            if( (ch == ')' && topChar != '(') ||
                (ch == '}' && topChar != '{') ||
                (ch == ']' && topChar != '[') ) {
                return false;
            }
            // 匹配成功,弹出栈顶元素
            st.pop();
        }
    }
    // 最终检查栈是否为空
    return st.empty();
}

总结

本节课中我们一起学习了如何使用栈来解决“有效的括号”问题。我们首先理解了有效括号的三个条件,然后抓住了“后进先出”这一关键特性,从而选择栈作为解决方案的数据结构。通过遍历字符串、分类处理左右括号、并及时进行匹配检查,我们能够高效地判断字符串的有效性。这个方法思路清晰,代码简洁,是栈的经典应用之一,请务必掌握。

069:股票跨度问题最优解 📈

概述

在本节课中,我们将学习并解决一个经典的数据结构与算法问题——股票跨度问题。我们将理解其定义,分析其逻辑,并使用这一数据结构来实现一个高效的解决方案。课程内容将涵盖从问题分析到代码实现的完整过程。


问题定义与理解

股票跨度问题描述如下:给定一系列每日的股票价格,我们需要计算每一天的“跨度”。某一天的跨度被定义为:从当前天开始,连续的、价格小于或等于当前天价格的最大天数(包含当前天本身)。

核心公式

对于第 i 天,其跨度 span[i] 的数学定义为:
span[i] = i - previousHigh(i)
其中,previousHigh(i) 是第 i 天之前、价格严格大于 price[i] 的最近一天的索引。如果不存在这样的天,则 previousHigh(i) 视为 -1

示例分析

假设我们有连续7天的股价:[100, 80, 60, 70, 60, 75, 85]
根据定义,我们可以手动计算出每天的跨度:

  • 第0天(价格100):之前没有价格大于100的天,previousHigh = -1span = 0 - (-1) = 1
  • 第1天(价格80):之前价格大于80的是第0天(100),previousHigh = 0span = 1 - 0 = 1
  • 第2天(价格60):之前价格大于60的是第1天(80),previousHigh = 1span = 2 - 1 = 1
  • 第3天(价格70):之前价格大于70的是第1天(80),previousHigh = 1span = 3 - 1 = 2
  • 第4天(价格60):之前价格大于60的是第3天(70),previousHigh = 3span = 4 - 3 = 1
  • 第5天(价格75):之前价格大于75的是第1天(80),previousHigh = 1span = 5 - 1 = 4
  • 第6天(价格85):之前价格大于85的是第0天(100),previousHigh = 0span = 6 - 0 = 6

因此,最终跨度数组为:[1, 1, 1, 2, 1, 4, 6]


核心思路与数据结构选择

上一节我们通过手动计算理解了跨度的定义。本节中我们来看看如何高效地通过编程实现。关键在于快速找到每一天对应的 previousHigh(i)

观察发现,previousHigh(i) 是第 i 天左侧第一个价格高于 price[i] 的索引。我们可以使用一个来维护一个“递减”的价格索引序列。栈顶始终保存着最近一个可能成为“之前高点”的索引。

以下是算法的核心步骤:

  1. 初始化一个空栈,用于存储价格索引。
  2. 遍历每一天 i(从0开始):
    • 当栈不为空,且栈顶索引对应的价格 <= 当前价格 price[i] 时,弹出栈顶元素。这保证了栈中剩余索引对应的价格都是严格递减的。
    • 计算跨度:
      • 如果栈为空,说明左边没有比 price[i] 更高的价格,span[i] = i + 1
      • 如果栈不为空,栈顶索引就是 previousHigh(i)span[i] = i - stack.top()
    • 将当前索引 i 压入栈中。

算法实现详解

理解了核心思路后,现在让我们将其转化为具体的C++代码。

我们将实现一个函数 calculateSpans,它接收一个股价向量,并返回跨度向量。

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

vector<int> calculateSpans(vector<int>& prices) {
    int n = prices.size();
    vector<int> spans(n, 0); // 初始化跨度数组,所有值先设为0
    stack<int> indexStack;   // 用于存储索引的栈

    for (int i = 0; i < n; ++i) {
        // 步骤1:弹出所有价格小于等于当前价格的栈顶索引
        while (!indexStack.empty() && prices[indexStack.top()] <= prices[i]) {
            indexStack.pop();
        }

        // 步骤2:计算当前天的跨度
        if (indexStack.empty()) {
            // 栈为空,说明左边没有更高的价格
            spans[i] = i + 1;
        } else {
            // 栈顶索引即为 previousHigh(i)
            spans[i] = i - indexStack.top();
        }

        // 步骤3:将当前索引压入栈中
        indexStack.push(i);
    }

    return spans;
}

int main() {
    vector<int> prices = {100, 80, 60, 70, 60, 75, 85};
    vector<int> result = calculateSpans(prices);

    cout << "股票跨度: ";
    for (int span : result) {
        cout << span << " ";
    }
    cout << endl;
    // 输出: 股票跨度: 1 1 1 2 1 4 6
    return 0;
}

代码逐行解析

以下是代码中关键部分的解释:

  • while (!indexStack.empty() && prices[indexStack.top()] <= prices[i]):这个循环确保栈中只保留比当前价格高的索引,维护了价格的递减单调性。
  • spans[i] = i + 1:对应公式中 previousHigh(i) = -1 的情况。
  • spans[i] = i - indexStack.top():直接应用核心公式计算跨度。
  • indexStack.push(i):当前索引在处理完后成为后续天数潜在的“之前高点”。

复杂度分析

最后,我们来分析一下这个解决方案的效率。

  • 时间复杂度:O(n)。虽然代码中有一个嵌套的 while 循环,但每个索引最多被压入和弹出栈各一次。因此,总操作次数与价格天数 n 成线性关系。
  • 空间复杂度:O(n)。在最坏情况下(价格严格递减),栈可能需要存储所有 n 个索引。

这个使用单调栈的解法是该问题已知的最优解


总结

本节课中,我们一起学习了股票跨度问题。我们从问题定义出发,通过示例理解了“跨度”的概念,并推导出其核心计算公式。接着,我们引入了单调栈这一数据结构来高效地找到每一天的“之前高点”,从而将时间复杂度优化至 O(n)。最后,我们完成了从伪代码到完整C++程序的实现,并分析了算法的时间与空间复杂度。掌握这个问题的解法,对于理解单调栈的应用场景非常有帮助。

070:下一个更大元素 - 最优解

概述

在本节课中,我们将学习一个重要的算法概念——“下一个更大元素”。我们将理解其基本定义,学习如何使用栈数据结构高效地解决这个问题,并分析其时间与空间复杂度。最后,我们将探讨该概念的一个直接变体问题。


下一个更大元素的概念

上一节我们介绍了栈的基本应用,本节中我们来看看“下一个更大元素”的具体含义。

对于一个数组,元素 arr[i] 的“下一个更大元素”是指在其右侧出现的、第一个值严格大于 arr[i] 的元素。

例如,对于数组 [6, 8, 0, 1, 3]

  • 元素 6 右侧第一个比它大的元素是 8
  • 元素 3 右侧没有比它大的元素,因此其下一个更大元素记为 -1

使用栈的解决方案

为了找出每个元素的下一个更大元素,我们将使用数据结构。使用栈的原因是,我们需要访问右侧的元素信息,并且处理过程具有“后进先出”的特性。

基本思路是从数组末尾向前遍历。栈用于存储可能成为“下一个更大元素”的候选值。

以下是解决此问题的一般步骤:

  1. 初始化一个空栈和一个与输入数组等长的答案数组。
  2. 从最后一个元素开始向前遍历数组。
  3. 对于当前元素 arr[i],循环检查栈顶元素是否小于或等于 arr[i]。如果是,则弹出栈顶元素,因为它不可能是 arr[i] 的下一个更大元素。
  4. 循环结束后,检查栈的状态:
    • 如果栈为空,说明右侧没有更大的元素,则 answer[i] = -1
    • 如果栈非空,则栈顶元素就是第一个大于 arr[i] 的元素,即 answer[i] = stack.top()
  5. 将当前元素 arr[i] 压入栈中,因为它可能成为前面元素的下一个更大元素。
  6. 重复步骤3-5,直到遍历完所有元素。

代码实现

现在,让我们将上述逻辑转化为实际的C++代码。

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

vector<int> nextGreaterElement(vector<int>& arr) {
    int n = arr.size();
    vector<int> answer(n);
    stack<int> st;

    // 从后向前遍历
    for (int i = n - 1; i >= 0; i--) {
        // 移除栈中所有小于等于当前元素的元素
        while (!st.empty() && st.top() <= arr[i]) {
            st.pop();
        }

        // 根据栈的状态决定答案
        if (st.empty()) {
            answer[i] = -1;
        } else {
            answer[i] = st.top();
        }

        // 将当前元素压入栈
        st.push(arr[i]);
    }
    return answer;
}

int main() {
    vector<int> arr = {6, 8, 0, 1, 3};
    vector<int> result = nextGreaterElement(arr);

    for (int val : result) {
        cout << val << " ";
    }
    // 输出: 8 -1 1 3 -1
    return 0;
}

复杂度分析

  • 时间复杂度:O(n)。虽然有一个嵌套的while循环,但数组中的每个元素最多被压入和弹出栈各一次,因此总操作次数与元素数量 n 成线性关系。
  • 空间复杂度:O(n)。在最坏情况下,栈可能需要存储所有 n 个元素。

概念变体:下一个更大元素 I

我们刚刚学习了基础问题的解决方案,本节中我们来看看一个直接的应用变体——LeetCode 496题“下一个更大元素 I”。

问题描述:给定两个没有重复元素的数组 nums1nums2,其中 nums1nums2 的子集。需要找出 nums1 中每个元素在 nums2 中的下一个更大元素。

解决思路

  1. 首先,使用上述栈方法,计算出 nums2每个元素的下一个更大元素,并将结果存储在一个哈希映射(unordered_map)中。键为元素值,值为其下一个更大元素值。
  2. 然后,遍历 nums1,直接从哈希映射中取出对应元素的下一个更大元素,组成答案数组。

以下是解决方案的代码框架:

vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
    unordered_map<int, int> nextGreaterMap;
    stack<int> st;
    vector<int> answer;

    // 步骤1:计算nums2中每个元素的下一个更大元素
    for (int i = nums2.size() - 1; i >= 0; i--) {
        while (!st.empty() && st.top() <= nums2[i]) {
            st.pop();
        }
        if (st.empty()) {
            nextGreaterMap[nums2[i]] = -1;
        } else {
            nextGreaterMap[nums2[i]] = st.top();
        }
        st.push(nums2[i]);
    }

    // 步骤2:为nums1中的元素查找结果
    for (int num : nums1) {
        answer.push_back(nextGreaterMap[num]);
    }
    return answer;
}

总结

本节课中我们一起学习了“下一个更大元素”问题。我们首先明确了概念定义,然后掌握了利用栈从后向前遍历的最优解法,并分析了其线性的时间和空间复杂度。最后,我们通过解决LeetCode 496题,实践了该算法在一个实际变体问题中的应用。理解这个模式是解决一系列类似单调栈问题的基础。

071:前一个更小元素 - 最优解代码 🧱

在本节课中,我们将学习如何解决一个重要的数据结构与算法问题:前一个更小元素。这是“下一个更大元素”问题的直接变体,我们将使用栈这一数据结构来高效地求解。

概述 📋

“前一个更小元素”问题要求我们为数组中的每个元素,找到其左侧第一个比它的元素。如果不存在这样的元素,则返回 -1。我们将沿用解决“下一个更大元素”问题的思路,但调整比较的方向和条件。

问题定义与示例

给定一个数组,我们需要为每个元素找到其左侧最近且值更小的元素。

示例
对于数组 [3, 1, 0, 8, 6]

  • 第一个元素 3 左侧没有元素,所以答案是 -1
  • 第二个元素 1 左侧第一个更小的元素不存在(31大),所以答案是 -1
  • 第三个元素 0 左侧第一个更小的元素不存在(10大),所以答案是 -1
  • 第四个元素 8 左侧第一个更小的元素是 0
  • 第五个元素 6 左侧第一个更小的元素是 0

因此,最终答案数组为 [-1, -1, -1, 0, 0]

核心思路与伪代码 🧠

我们将使用一个栈来辅助计算。栈用于按顺序存储可能成为后续元素“前一个更小元素”的候选值。核心逻辑是:在遍历数组时,我们不断从栈中弹出那些大于或等于当前元素的栈顶元素,因为这些元素不可能成为当前或之后元素的“更小”元素。

以下是解决问题的步骤伪代码:

1. 初始化一个空栈 `st`。
2. 初始化一个答案数组 `ans`,大小与输入数组相同。
3. 遍历输入数组中的每个元素 `arr[i]`:
   a. 当栈不为空,且栈顶元素 `>= arr[i]` 时:
      * 弹出栈顶元素。
   b. 如果此时栈为空:
      * `ans[i] = -1`。
   c. 否则:
      * `ans[i] = st.top()`。
   d. 将当前元素 `arr[i]` 压入栈中。
4. 返回答案数组 `ans`。

代码实现 💻

现在,让我们将上述逻辑转化为实际的 C++ 代码。

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

vector<int> previousSmallerElement(vector<int>& arr) {
    int n = arr.size();
    vector<int> ans(n, 0); // 初始化答案数组
    stack<int> st; // 辅助栈

    for (int i = 0; i < n; i++) {
        // 步骤a:弹出栈中所有大于等于当前元素的元素
        while (!st.empty() && st.top() >= arr[i]) {
            st.pop();
        }
        // 步骤b和c:确定当前元素的答案
        if (st.empty()) {
            ans[i] = -1;
        } else {
            ans[i] = st.top();
        }
        // 步骤d:将当前元素压入栈中
        st.push(arr[i]);
    }
    return ans;
}

int main() {
    vector<int> arr = {3, 1, 0, 8, 6};
    vector<int> result = previousSmallerElement(arr);

    cout << "前一个更小元素数组: ";
    for (int val : result) {
        cout << val << " ";
    }
    cout << endl;
    return 0;
}

复杂度分析 ⚙️

  • 时间复杂度:O(n)。数组中的每个元素最多被压入和弹出栈各一次。
  • 空间复杂度:O(n)。在最坏情况下(如数组严格递减),栈可能需要存储所有 n 个元素。

总结 🎯

本节课中,我们一起学习了如何解决“前一个更小元素”问题。我们首先理解了问题的定义,然后利用的特性,设计了一个高效的算法。其核心在于维护一个单调(非严格)递减的栈,从而能在 O(n) 时间内为每个元素找到答案。通过将伪代码转化为 C++ 实现,我们完成了从理论到实践的完整学习过程。掌握这个方法,对于解决一系列基于“下一个/上一个更大/更小元素”的问题至关重要。

072:设计一个最小栈 - 最优解代码 🧱

在本节课中,我们将学习如何设计一个“最小栈”(Min Stack)。这是一个经典的数据结构问题,要求我们实现一个栈,它除了支持常规的入栈(push)、出栈(pop)、查看栈顶(top)操作外,还能在常数时间内检索到栈中的最小元素。我们将探讨两种解决方案,并重点讲解第二种空间复杂度最优的巧妙方法。

问题概述

我们需要设计一个栈数据结构,它包含以下四个操作,且所有操作的时间复杂度都应为 O(1):

  1. push(val):将元素 val 推入栈中。
  2. pop():删除栈顶的元素。
  3. top():获取栈顶元素。
  4. getMin():检索栈中的最小元素。

让我们通过一个示例来理解需求:

  • 执行 push(-2)
  • 执行 push(0)
  • 执行 push(-3)
  • 执行 getMin() -> 应返回 -3
  • 执行 pop()
  • 执行 top() -> 应返回 0
  • 执行 getMin() -> 应返回 -2

方法一:使用配对栈(直观解法)

上一节我们明确了问题需求,本节中我们来看看第一种直观的解法。其核心思路是:栈中的每个元素不仅存储原始值,还额外存储到该元素为止的栈内最小值

以下是具体实现步骤:

  1. 我们使用一个栈,但其元素类型是 pair<int, int>
  2. 每个 pair 的第一个值(first)存储原始数据 val
  3. 每个 pair 的第二个值(second)存储从栈底到当前元素为止的最小值。
  4. 执行 push(val) 时,计算新的最小值 new_min = min(val, 当前栈顶元素的最小值),然后将 (val, new_min) 压入栈中。
  5. 执行 top() 时,返回栈顶 pairfirst 值。
  6. 执行 getMin() 时,返回栈顶 pairsecond 值。
  7. 执行 pop() 时,直接弹出栈顶元素即可。

代码描述

class MinStack {
private:
    stack<pair<int, int>> st; // pair<value, current_min>
public:
    void push(int val) {
        if(st.empty()) {
            st.push({val, val});
        } else {
            int current_min = st.top().second;
            st.push({val, min(val, current_min)});
        }
    }
    void pop() {
        st.pop();
    }
    int top() {
        return st.top().first;
    }
    int getMin() {
        return st.top().second;
    }
};

复杂度分析

  • 时间复杂度:所有操作均为 O(1)。
  • 空间复杂度:O(n),因为每个元素都额外存储了一个最小值。

虽然这种方法简单有效,但它使用了额外的空间来存储最小值。接下来,我们将探索一种空间复杂度更优的解法。

方法二:使用单个变量与编码公式(最优解)

上一节我们介绍了使用配对栈的方法,本节中我们来看看如何将空间复杂度优化到 O(1)。核心挑战在于:只使用一个栈和一个变量(min_val)来存储当前最小值,如何在弹出旧的最小值后,找回上一个最小值?

这里需要一个巧妙的编码公式。其核心思想是:当我们要压入的新值(val)比当前最小值(min_val)还小时,我们不直接压入 val,而是压入一个经过编码的值,这个值隐含了旧的最小值信息。

核心公式与逻辑

关键在于 pushpop 操作中对最小值的更新:

  1. Push 操作

    • 如果 val >= min_val,直接压入 val
    • 如果 val < min_val,这意味著最小值将更新。此时,我们压入一个编码值:encoded_val = 2 * val - min_val。然后更新 min_val = val
    • 公式解释encoded_val 必然小于新的 min_val(即 val)。这个值成为了一个“标记”,告诉我们最小值在此发生了变化。
  2. Pop 操作

    • 如果栈顶元素 top_val >= min_val,直接弹出。
    • 如果栈顶元素 top_val < min_val,这说明栈顶存储的是编码值。此时,在弹出之前,我们需要解码出旧的最小值:old_min_val = 2 * min_val - top_val。然后更新 min_val = old_min_val,再弹出栈顶。
  3. Top 操作

    • 如果栈顶元素 top_val >= min_val,直接返回 top_val
    • 如果栈顶元素 top_val < min_val,说明栈顶存储的是编码值,此时实际的栈顶值就是当前的 min_val,返回 min_val
  4. GetMin 操作

    • 直接返回变量 min_val 即可。

示例推演

让我们用示例 push(-2), push(0), push(-3) 来验证:

  • 初始:stack = [], min_val = ?
  • push(-2):栈空,直接压入 -2min_val = -2stack = [-2]
  • push(0)0 >= min_val(-2),直接压入 0stack = [-2, 0]
  • push(-3)-3 < min_val(-2),计算编码值 encoded = 2*(-3) - (-2) = -4,压入 -4,更新 min_val = -3stack = [-2, 0, -4]
  • getMin():返回 min_val = -3
  • pop():栈顶 -4 < min_val(-3),解码旧最小值 old_min = 2*(-3) - (-4) = -2,更新 min_val = -2,弹出栈顶。stack = [-2, 0]
  • top():栈顶 0 >= min_val(-2),返回 0
  • getMin():返回 min_val = -2

代码描述

class MinStack {
private:
    stack<long long> st; // 使用 long long 防止计算溢出
    long long min_val;
public:
    MinStack() {}
    void push(int val) {
        if(st.empty()) {
            st.push(val);
            min_val = val;
        } else {
            if(val < min_val) {
                // 压入编码值,更新最小值
                st.push(2LL * val - min_val);
                min_val = val;
            } else {
                st.push(val);
            }
        }
    }
    void pop() {
        if(st.empty()) return;
        if(st.top() < min_val) {
            // 栈顶是编码值,需要解码出旧的最小值
            min_val = 2LL * min_val - st.top();
        }
        st.pop();
    }
    int top() {
        if(st.top() < min_val) {
            // 栈顶是编码值,实际值就是当前最小值
            return min_val;
        }
        return st.top();
    }
    int getMin() {
        return min_val;
    }
};

复杂度分析

  • 时间复杂度:所有操作均为 O(1)。
  • 空间复杂度:O(1) 额外空间(仅使用一个变量),栈本身存储 n 个元素是问题要求,不计入额外空间。这是最优的空间复杂度。

总结

本节课中我们一起学习了如何设计一个“最小栈”。

  • 我们首先学习了方法一:配对栈法。该方法直观易懂,通过在每个栈元素中配对存储当前最小值,轻松实现了所有 O(1) 操作,但需要 O(n) 的额外空间。
  • 接着,我们深入探讨了方法二:编码公式法。这种方法非常巧妙,利用数学公式 2*val - min_val 在栈中编码旧的最小值信息,从而仅使用一个额外变量就实现了所有功能,达到了 O(1) 的额外空间复杂度,是最优解。

理解第二种方法的编码与解码逻辑是掌握本题的关键。它展示了如何通过精妙的设计,用时间换空间(这里指计算时间,复杂度仍是O(1)),解决复杂的数据结构问题。

073:直方图中的最大矩形 - 最优解

概述

在本节课中,我们将学习如何解决一个名为“直方图中的最大矩形”的经典算法问题。这是一个LeetCode上的难题(第84题),我们将重点探讨其最优解法。我们将从理解问题开始,然后分析暴力解法,最后深入讲解并实现基于栈的最优算法。


问题理解

给定一个代表直方图高度的整数数组 heights,每个柱子的宽度为1。目标是找出直方图中可以勾勒出的最大矩形的面积。

例如,对于直方图 [2,1,5,6,2,3],其最大矩形面积为10。

暴力解法思路

暴力解法的核心思想是枚举所有可能的矩形。对于每个柱子 i,我们将其作为矩形的高度,然后向左右两侧扩展,直到遇到比它矮的柱子,以此确定矩形的宽度。

以下是暴力解法的步骤:

  1. 遍历数组中的每个柱子 i
  2. heights[i] 作为矩形高度。
  3. 向左找到第一个高度小于 heights[i] 的柱子,记其索引为 left
  4. 向右找到第一个高度小于 heights[i] 的柱子,记其索引为 right
  5. 计算宽度:width = right - left - 1
  6. 计算面积:area = heights[i] * width
  7. 在遍历过程中,记录遇到的最大面积。

这种方法的时间复杂度为 O(n²),在数据量大时效率很低。

最优解法:单调栈

上一节我们介绍了暴力解法,本节中我们来看看如何利用单调栈将时间复杂度优化到 O(n)

核心思路是:对于每个柱子,我们需要快速找到其左右两侧第一个比它矮的柱子。这正是单调栈(具体是单调递增栈)擅长解决的问题。

核心概念与公式

对于数组中的每个索引 i

  • left[i] 为柱子 i 左侧第一个高度小于 heights[i] 的柱子的索引。如果左侧没有更矮的柱子,则 left[i] = -1
  • right[i] 为柱子 i 右侧第一个高度小于 heights[i] 的柱子的索引。如果右侧没有更矮的柱子,则 right[i] = nn 为数组长度)。

那么,以柱子 i 为高度的最大矩形的宽度为:
width = right[i] - left[i] - 1

其面积为:
area = heights[i] * width

全局最大面积就是所有 area 中的最大值。

算法步骤

以下是使用单调栈计算 leftright 数组的步骤:

1. 计算 right 数组(下一个更小元素的索引)
我们反向遍历数组,维护一个栈顶到栈底单调递增的栈。

  • 当栈不为空且当前柱子高度 heights[i] 小于等于栈顶索引对应的高度时,弹出栈顶元素。这意味着当前柱子 i 是栈顶元素右侧第一个更小(或相等)的柱子,因此更新 right[stack.top()] = i
  • 将当前索引 i 压入栈中。
  • 遍历结束后,栈中剩余元素的右侧没有更小的柱子,因此它们的 right 值设为 n

2. 计算 left 数组(上一个更小元素的索引)
我们正向遍历数组,同样维护一个单调递增栈。

  • 当栈不为空且当前柱子高度 heights[i] 小于等于栈顶索引对应的高度时,弹出栈顶元素。这意味着当前柱子 i 是栈顶元素左侧第一个更小(或相等)的柱子?不,这里逻辑需要调整。实际上,我们寻找的是 i 左侧第一个更小的柱子,所以当 heights[i] 小于等于栈顶高度时,栈顶元素对 ileft 值没有贡献,可以弹出,直到找到更小的。
  • 此时,如果栈为空,说明左侧没有更矮的柱子,left[i] = -1;否则,left[i] 就是当前栈顶的索引。
  • 将当前索引 i 压入栈中。

3. 计算最大面积
遍历每个柱子 i,利用公式计算面积并更新全局最大值。

代码实现

#include <vector>
#include <stack>
#include <algorithm>
using namespace std;

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        int n = heights.size();
        vector<int> left(n), right(n);
        stack<int> st;

        // 计算 right 数组:下一个更小元素的索引
        for (int i = n - 1; i >= 0; --i) {
            while (!st.empty() && heights[st.top()] >= heights[i]) {
                st.pop();
            }
            right[i] = st.empty() ? n : st.top();
            st.push(i);
        }

        // 清空栈以复用
        while (!st.empty()) st.pop();

        // 计算 left 数组:上一个更小元素的索引
        for (int i = 0; i < n; ++i) {
            while (!st.empty() && heights[st.top()] >= heights[i]) {
                st.pop();
            }
            left[i] = st.empty() ? -1 : st.top();
            st.push(i);
        }

        // 计算最大面积
        int maxArea = 0;
        for (int i = 0; i < n; ++i) {
            int width = right[i] - left[i] - 1;
            int area = heights[i] * width;
            maxArea = max(maxArea, area);
        }
        return maxArea;
    }
};

复杂度分析

  • 时间复杂度:O(n)。每个元素恰好被压入和弹出栈一次,三次遍历数组也是 O(n)。
  • 空间复杂度:O(n)。用于存储 leftright 数组和栈。

总结

本节课中我们一起学习了“直方图中的最大矩形”问题。我们从暴力枚举法入手,理解了问题的核心在于为每个柱子寻找边界。然后,我们引入了单调栈这一数据结构,高效地解决了寻找“下一个/上一个更小元素”的问题,从而将算法优化到线性时间复杂度。掌握这个解法对于理解栈的巧妙应用和解决类似二维问题(如最大全1矩形)非常有帮助。

课后练习:请手动对示例 [2,1,5,6,2,3] 模拟算法过程,计算出每个柱子的 leftright 值以及对应的面积,验证最终结果是否为10。

074:下一个更大元素 II

概述

在本节课中,我们将学习如何解决“下一个更大元素 II”问题。这是一个关于循环数组的问题,我们需要为数组中的每个元素找到其下一个更大的元素。我们将利用栈这一数据结构来实现一个高效的解决方案。


问题理解与概念

上一节我们介绍了栈的基本应用,本节中我们来看看如何将其应用于循环数组。

问题核心是:给定一个循环数组,需要为每个元素找出在数组中下一个比它大的元素。这里的“下一个”意味着在循环顺序中,从当前元素的下一个位置开始向右寻找,直到再次遇到该元素为止。

核心概念是循环数组下一个更大元素。我们可以通过将数组长度翻倍来模拟循环遍历,但更高效的方法是使用取模运算来映射索引。

公式有效索引 = i % n,其中 i 是遍历的索引,n 是原始数组长度。


算法思路

以下是解决问题的步骤:

  1. 初始化一个结果数组 ans,长度与输入数组 nums 相同,所有值预设为 -1
  2. 初始化一个栈 s,用于存储数组元素的索引。
  3. 我们从后向前遍历一个长度为 2*n 的虚拟数组。对于每个位置 i
    • 计算当前元素在原始数组中的实际索引:index = i % n
    • 当栈不为空,且栈顶索引对应的元素值小于或等于当前元素值 nums[index] 时,弹出栈顶元素(因为它不可能是当前元素的下一个更大元素)。
    • 如果此时栈不为空,则栈顶索引对应的元素就是当前元素 nums[index] 的下一个更大元素,将其存入 ans[index]
    • 将当前索引 index 压入栈中。
  4. 遍历完成后,返回结果数组 ans

代码实现

现在,让我们将上述思路转化为实际的 C++ 代码。

class Solution {
public:
    vector<int> nextGreaterElements(vector<int>& nums) {
        int n = nums.size();
        vector<int> ans(n, -1); // 初始化结果数组
        stack<int> s; // 存储索引的栈

        // 模拟遍历两倍长度的数组
        for (int i = 2 * n - 1; i >= 0; i--) {
            int currentIndex = i % n; // 映射到原始数组的实际索引

            // 弹出栈中所有不大于当前元素的索引
            while (!s.empty() && nums[s.top()] <= nums[currentIndex]) {
                s.pop();
            }

            // 如果栈不为空,栈顶元素就是下一个更大元素
            if (!s.empty()) {
                ans[currentIndex] = nums[s.top()];
            }

            // 将当前索引压入栈中
            s.push(currentIndex);
        }

        return ans;
    }
};

复杂度分析

  • 时间复杂度:O(n)。每个元素最多被压入和弹出栈各一次。
  • 空间复杂度:O(n)。最坏情况下,栈需要存储所有 n 个索引。

总结

本节课中我们一起学习了如何解决“下一个更大元素 II”问题。我们利用栈来高效地查找下一个更大元素,并通过取模运算巧妙地处理了数组的循环特性。这个方法的时间复杂度是线性的,非常适合处理大规模数据。

075:名人问题 - 栈和队列 🎭

在本节课中,我们将学习一个有趣的数据结构问题——名人问题。我们将理解其核心概念,并使用栈和队列来设计一个高效的解决方案。

名人问题的核心是:在一个由 N 个人组成的群体中,名人被定义为所有人都认识他,但他不认识任何人的人。题目会给出一个 N x N 的矩阵 M,其中 M[i][j] = 1 表示第 i 个人认识第 j 个人,M[i][j] = 0 则表示不认识。

问题分析

我们需要从 N 个人中找出可能存在的名人。名人必须满足两个条件:

  1. 除自己外,其他所有人都认识他。即,在矩阵中,名人所对应的(除了对角线位置)全部为 1。
  2. 他不认识任何人。即,在矩阵中,名人所对应的全部为 0。

例如,给定以下矩阵:

0 1 0
0 0 0
0 1 0

在这个例子中,第 1 号人物(索引从 0 开始)是名人。因为:

  • 第 0 行:[0, 1, 0],表示 0 号认识 1 号。
  • 第 2 行:[0, 1, 0],表示 2 号认识 1 号。
  • 第 1 行:[0, 0, 0],表示 1 号不认识任何人。
  • 第 1 列:[1, 0, 1],表示所有人都认识 1 号(除了他自己)。

上一节我们分析了名人的定义和条件,本节中我们来看看如何使用“淘汰法”来寻找潜在的名人。

解决方案:淘汰法

我们的思路是逐步淘汰不可能成为名人的人选,直到剩下一个潜在候选人,最后再验证他是否真的满足名人条件。

以下是使用栈来实现淘汰法的步骤:

  1. 初始化候选人:首先,将所有 N 个人的编号(0 到 N-1)压入栈中。
  2. 两两比较与淘汰:当栈中元素多于一个时,重复以下操作:
    • 从栈顶弹出两个人,记为 AB
    • 检查 A 是否认识 B
      • 如果 M[A][B] == 1,说明 A 认识 B。根据名人定义(名人不认识任何人),A 不可能是名人,淘汰 A,将 B 压回栈中。
      • 如果 M[A][B] == 0,说明 A 不认识 B。那么 B 就不可能是名人(因为名人是所有人都认识的),淘汰 B,将 A 压回栈中。
  3. 得到潜在候选人:经过多轮淘汰后,栈中会剩下一个候选人。这个人是唯一可能成为名人的人。
  4. 最终验证:最后,我们需要验证这个候选人是否真的满足名人的两个条件:
    • 检查他对应的行是否全为 0(除了自己)。
    • 检查他对应的列是否全为 1(除了自己)。
    • 如果都满足,他就是名人;否则,群体中没有名人。

代码实现

让我们将上述逻辑转化为 C++ 代码。函数接收一个二维向量 M 作为参数。

int findCelebrity(vector<vector<int>>& M) {
    int n = M.size();
    stack<int> s;

    // 步骤1: 将所有候选人入栈
    for(int i = 0; i < n; i++) {
        s.push(i);
    }

    // 步骤2: 两两比较,进行淘汰
    while(s.size() > 1) {
        int a = s.top(); s.pop();
        int b = s.top(); s.pop();

        if(M[a][b] == 1) {
            // a 认识 b,淘汰 a,保留 b
            s.push(b);
        } else {
            // a 不认识 b,淘汰 b,保留 a
            s.push(a);
        }
    }

    // 步骤3: 得到潜在候选人
    int potentialCeleb = s.top();

    // 步骤4: 验证候选人
    for(int i = 0; i < n; i++) {
        // 检查条件1: 候选人不认识任何人 (行全为0,除了自己)
        // 检查条件2: 所有人都认识候选人 (列全为1,除了自己)
        if(i != potentialCeleb) {
            if(M[potentialCeleb][i] == 1 || M[i][potentialCeleb] == 0) {
                return -1; // 不满足条件,没有名人
            }
        }
    }
    return potentialCeleb; // 找到名人
}

复杂度分析

  • 时间复杂度O(N)。淘汰过程需要进行 N-1 次比较,验证过程需要检查 N 个元素,总体是线性时间。
  • 空间复杂度O(N)。我们使用了一个栈来存储候选人。

这种方法以线性的时间复杂度和空间复杂度,优雅地解决了名人问题。

本节课中我们一起学习了如何使用栈和淘汰策略来解决“名人问题”。我们首先理解了名人的定义,然后设计了一个逐步淘汰候选人的算法,最后用代码实现了这个逻辑并分析了其效率。这是一个巧妙运用基本数据结构解决实际问题的经典案例。

076:实现LRU缓存

概述

在本节课中,我们将学习如何设计和实现一个LRU(Least Recently Used,最近最少使用)缓存。这是一个常见的数据结构设计问题,要求我们实现一个具有固定容量的缓存,能够以常数时间复杂度执行 getput 操作。我们将使用双向链表哈希表来构建这个高效的缓存系统。


LRU缓存的基本概念

LRU缓存是一种特殊的缓存策略。当缓存容量达到上限时,它会优先移除最近最少使用的数据项,为新数据腾出空间。

缓存以键值对的形式存储数据,以便快速检索。get 操作根据键获取值,put 操作插入或更新键值对。

核心操作要求

  • get(key):如果键存在,返回对应的值,并将该数据项标记为“最近使用过”。时间复杂度应为 O(1)
  • put(key, value):插入或更新键值对。如果插入后容量超出限制,则需移除“最近最少使用”的数据项。时间复杂度应为 O(1)

数据结构设计

为了实现O(1)时间复杂度的操作,我们需要结合两种数据结构:

  1. 双向链表:用于维护数据项的访问顺序。链表的头部代表最近使用的数据,尾部代表最近最少使用的数据。
  2. 哈希表(unordered_map):用于实现O(1)时间的键值查找。哈希表将键映射到双向链表中对应节点的指针(地址)

节点(Node)结构
每个链表节点需要存储键(key)、值(value),以及指向前驱(prev)和后继(next)节点的指针。

class Node {
public:
    int key;
    int value;
    Node* prev;
    Node* next;

    Node(int key, int value) {
        this->key = key;
        this->value = value;
        prev = NULL;
        next = NULL;
    }
};

缓存(LRUCache)类结构
缓存类将包含以下成员:

  • int capacity:缓存容量。
  • Node* headNode* tail:双向链表的虚拟头节点和尾节点,简化边界条件处理。
  • unordered_map<int, Node*> cacheMap:哈希表,用于快速定位节点。
class LRUCache {
public:
    LRUCache(int capacity) {
        // 初始化容量、虚拟头尾节点并连接它们
        // 初始化哈希表
    }

    int get(int key) {
        // 获取键对应的值,并调整节点顺序
    }

    void put(int key, int value) {
        // 插入或更新键值对
    }
};

辅助函数实现

在实现核心的 getput 函数之前,我们需要两个辅助函数来操作双向链表。

1. 添加节点到链表头部

此函数将一个节点插入到虚拟头节点(head)之后,使其成为新的“最近使用”节点。

操作步骤如下:

  1. 记录原第一个真实节点(head->next)为 oldNext
  2. 建立新节点与 headoldNext 之间的连接。
  3. 更新 headoldNext 的指针,完成插入。
void addNode(Node* newNode) {
    Node* oldNext = head->next; // 步骤1
    // 建立新节点的连接
    newNode->next = oldNext;
    newNode->prev = head;
    // 更新原节点的连接
    head->next = newNode;
    oldNext->prev = newNode;
}

2. 从链表中删除节点

此函数将给定的节点从链表中移除。

操作步骤如下:

  1. 获取待删除节点(oldNode)的前驱节点(oldPrev)和后继节点(oldNext)。
  2. oldPrevnext 指针指向 oldNext
  3. oldNextprev 指针指向 oldPrev
  4. (可选)释放 oldNode 的内存。
void deleteNode(Node* oldNode) {
    Node* oldPrev = oldNode->prev; // 步骤1
    Node* oldNext = oldNode->next;
    // 重新建立连接,跳过 oldNode
    oldPrev->next = oldNext; // 步骤2
    oldNext->prev = oldPrev; // 步骤3
}

核心函数实现

有了辅助函数,我们现在可以实现 getput 方法。

get(int key) 函数实现

get 函数的目标是获取值,并将对应的节点移动到链表头部,标记为最近使用。

以下是实现步骤:

  1. 检查键是否存在:在 cacheMap 中查找键。
    • 如果不存在,返回 -1
  2. 获取节点和值:从 cacheMap 中获取对应的节点指针,并读取其 value
  3. 更新节点位置
    • 调用 deleteNode(node) 将该节点从当前位置删除。
    • 调用 addNode(node) 将该节点重新插入链表头部。
  4. 更新哈希表:由于节点对象本身没变,只需更新其在链表中的位置,哈希表映射无需更改。
  5. 返回获取到的值。
int get(int key) {
    if (cacheMap.find(key) == cacheMap.end()) {
        return -1; // 键不存在
    }
    Node* targetNode = cacheMap[key];
    int ans = targetNode->value;
    // 将节点移至头部
    deleteNode(targetNode);
    addNode(targetNode);
    return ans;
}

put(int key, int value) 函数实现

put 函数用于插入或更新键值对。如果键已存在,则更新其值并移至头部;如果不存在,则创建新节点。插入后若超出容量,需删除尾部的“最近最少使用”节点。

以下是实现步骤:

  1. 检查键是否存在
    • 如果存在:获取旧节点,更新其值,然后通过 deleteNodeaddNode 将其移至链表头部。
  2. 如果键不存在
    • 检查容量:如果当前缓存大小已达到容量上限(cacheMap.size() == capacity),则需要执行淘汰。
      • 淘汰策略:删除链表尾部的“最近最少使用”节点(即 tail->prev)。
      • 同时从 cacheMap 中擦除该节点对应的键。
    • 创建新节点:用给定的键和值创建一个新节点。
    • 插入新节点:调用 addNode(newNode) 将新节点插入链表头部。
    • 更新哈希表:将新键和指向新节点的指针存入 cacheMap
void put(int key, int value) {
    // 情况1:键已存在
    if (cacheMap.find(key) != cacheMap.end()) {
        Node* existingNode = cacheMap[key];
        existingNode->value = value; // 更新值
        deleteNode(existingNode);
        addNode(existingNode);
        return;
    }

    // 情况2:键不存在
    // 检查容量,若满则淘汰
    if (cacheMap.size() == capacity) {
        Node* nodeToDelete = tail->prev; // LRU节点
        cacheMap.erase(nodeToDelete->key); // 从map中删除
        deleteNode(nodeToDelete); // 从链表中删除
        delete nodeToDelete; // 释放内存(可选,取决于实现)
    }

    // 创建并插入新节点
    Node* newNode = new Node(key, value);
    addNode(newNode);
    cacheMap[key] = newNode; // 更新哈希表
}

总结

本节课我们一起学习了如何设计和实现一个 LRU(最近最少使用)缓存

我们首先理解了LRU缓存的核心思想:优先淘汰最久未被访问的数据。为了实现 getput 操作都在 O(1) 时间内完成,我们结合使用了两种数据结构:

  • 双向链表:高效地维护数据的访问顺序,支持在常数时间内删除任意节点并将其插入头部。
  • 哈希表:提供键到链表节点指针的直接映射,实现常数时间的查找。

我们实现了两个核心函数:

  1. get(key):查找键,若存在则返回值并将对应节点移至链表头部。
  2. put(key, value):插入或更新键值对,若容量已满则淘汰链表尾部的节点。

这个设计问题不仅考察了对基础数据结构(链表、哈希表)的掌握,也是系统设计中的一个经典案例。理解并实现LRU缓存,对于提升解决复杂设计问题的能力非常有帮助。

077:队列数据结构

在本节课中,我们将要学习一种新的数据结构——队列。队列是一种非常重要的数据结构,在解决许多算法问题时都会用到,特别是在处理广度优先搜索(BFS)和图相关的问题时。我们将从零开始理解队列的概念,并用链表实现它,最后也会介绍C++标准模板库(STL)中现成的队列。

概述:什么是队列?🚶‍♂️🚶‍♀️

队列是一种遵循先进先出原则的数据结构。你可以把它想象成一个排队等候的队伍:最先加入队伍的人会最先被服务,新来的人则排在队伍末尾。

在队列中,我们主要执行两种操作:

  • 入队:在队列的末尾添加一个元素。
  • 出队:从队列的前端移除一个元素。

此外,我们还需要一些辅助操作来查看队列前端元素或检查队列是否为空。

上一节我们介绍了队列的基本概念,本节中我们来看看如何用代码实现它。

从零开始实现队列(使用链表)🔗

我们将使用链表作为底层结构来实现队列。链表由节点组成,每个节点包含数据和指向下一个节点的指针。

以下是实现一个队列所需的步骤:

第一步:定义节点结构

首先,我们需要定义构成链表的基本单元——节点。

class Node {
public:
    int data;       // 存储数据
    Node* next;     // 指向下一个节点的指针

    // 构造函数
    Node(int value) {
        data = value;
        next = nullptr; // 初始化为空指针
    }
};

第二步:创建队列类

接下来,我们创建一个队列类。这个类需要管理两个特殊的指针:head(指向队列前端)和 tail(指向队列末尾)。

class Queue {
private:
    Node* head; // 指向队列前端的指针
    Node* tail; // 指向队列末尾的指针

public:
    // 构造函数
    Queue() {
        head = nullptr;
        tail = nullptr;
    }

    // 接下来将在这里实现队列的各种操作函数
};

第三步:实现核心操作函数

现在,我们将在 Queue 类中实现四个核心函数:push(入队)、pop(出队)、front(查看队首)和 empty(检查是否为空)。

1. empty() - 检查队列是否为空

这个函数很简单,只需检查 head 指针是否为 nullptr

bool empty() {
    return head == nullptr; // 如果head为空,则队列为空
}

2. push(int value) - 入队操作

入队操作在队列末尾添加一个新元素。需要考虑队列为空和不为空两种情况。

void push(int value) {
    Node* newNode = new Node(value); // 创建新节点

    if (empty()) {
        // 如果队列为空,新节点既是头也是尾
        head = newNode;
        tail = newNode;
    } else {
        // 如果队列不为空,将新节点链接到当前尾节点之后,并更新尾指针
        tail->next = newNode;
        tail = newNode;
    }
}

3. pop() - 出队操作

出队操作移除队列前端的元素。同样需要先检查队列是否为空。

void pop() {
    if (empty()) {
        cout << "队列为空,无法出队" << endl;
        return;
    }

    // 保存当前头节点
    Node* temp = head;
    // 将头指针移动到下一个节点
    head = head->next;

    // 如果出队后队列变空,也需要更新尾指针
    if (head == nullptr) {
        tail = nullptr;
    }

    // 删除原头节点,释放内存
    delete temp;
}

4. front() - 获取队首元素

这个函数返回队列前端元素的值,但不移除它。

int front() {
    if (empty()) {
        cout << "队列为空" << endl;
        return -1; // 返回一个错误值,实际应用中可能需要更健壮的处理
    }
    return head->data; // 返回头节点的数据
}

我们已经完成了队列的基本实现。接下来,让我们看看如何使用C++标准库中现成的队列。

使用C++ STL中的队列📦

C++标准模板库提供了一个现成的 queue 容器适配器,它内部已经实现了所有队列操作,使用起来非常方便。

以下是使用STL队列的示例:

#include <iostream>
#include <queue> // 包含队列头文件
using namespace std;

int main() {
    queue<int> q; // 声明一个整数队列

    // 入队操作
    q.push(1);
    q.push(2);
    q.push(3);

    // 检查队列是否为空
    cout << "队列是否为空: " << (q.empty() ? "是" : "否") << endl;

    // 获取队首元素
    cout << "队首元素: " << q.front() << endl;

    // 出队操作
    q.pop();
    cout << "出队一次后,新的队首元素: " << q.front() << endl;

    // 遍历并清空队列
    cout << "按顺序出队所有元素: ";
    while (!q.empty()) {
        cout << q.front() << " ";
        q.pop();
    }
    cout << endl;

    return 0;
}

STL队列的 pushpopfrontempty 等操作的时间复杂度都是常数时间 O(1)。

扩展:双端队列(Deque)🔄

除了普通队列,STL还提供了双端队列。它允许在队列的前端和后端都能进行插入和删除操作。

#include <iostream>
#include <deque> // 包含双端队列头文件
using namespace std;

int main() {
    deque<int> dq;

    // 从后端插入
    dq.push_back(1);
    dq.push_back(2);
    dq.push_back(3);

    // 从前端插入
    dq.push_front(0);

    cout << "前端元素: " << dq.front() << endl; // 输出 0
    cout << "后端元素: " << dq.back() << endl;  // 输出 3

    // 从后端删除
    dq.pop_back();
    cout << "弹出后端后,新的后端元素: " << dq.back() << endl; // 输出 2

    return 0;
}

总结

本节课中我们一起学习了队列数据结构。我们从队列先进先出的核心概念讲起,然后一步步地用链表实现了队列的 pushpopfrontempty 操作。接着,我们介绍了如何使用C++ STL中现成的 queue,它让队列操作变得非常简单高效。最后,我们还简要了解了功能更强大的双端队列 deque

队列是算法中一个基础且强大的工具,理解其原理和实现对于解决LeetCode等平台上的许多问题至关重要。


078:循环队列 🌀

在本节课中,我们将要学习循环队列的概念与实现。循环队列是一种特殊的线性数据结构,它克服了普通队列在空间利用上的局限性。我们将从普通队列的局限性入手,逐步理解循环队列的工作原理,并最终用C++代码实现它。

普通队列的局限性

上一节我们介绍了队列的基本概念。普通队列通常有一个队首和一个队尾。元素从队尾插入,从队首删除。这种结构在数组实现中,随着元素的出队,队首指针会不断后移,导致队列前端出现无法使用的“空洞”,造成空间浪费。

循环队列的概念

为了解决上述空间浪费问题,我们引入了循环队列。想象一个首尾相连的环形结构,这就是循环队列的直观形态。它同样有队首队尾指针。

循环队列与普通队列的主要区别在于,它通常基于一个固定容量的数组实现。当指针移动到数组末尾时,它会绕回到数组的开头,从而形成一个逻辑上的“环”,实现空间的循环利用。

循环队列的实现原理

下面我们来具体看看如何实现一个循环队列。其核心是几个关键变量和一个用于存储的数组。

以下是循环队列的核心属性:

  • 数组:一个固定大小的数组,用于存储队列元素。
  • 容量:数组的大小,即队列的最大容量。
  • 队首:指向队列第一个元素的索引。
  • 队尾:指向队列下一个插入位置的索引。
  • 当前大小:队列中当前元素的数量。

初始化时,我们将队首和队尾指针设置为-1,表示队列为空,当前大小为0。

核心操作与算法

循环队列的核心操作包括入队、出队、查看队首元素和判断队列是否为空。所有操作的时间复杂度都是O(1)。

入队操作

入队操作在队尾插入一个新元素。

以下是入队步骤:

  1. 检查队列是否已满(当前大小 == 容量)。若满,则无法插入。
  2. 如果队列为空(队首 == -1),则将队首和队尾都设置为0。
  3. 否则,使用公式 队尾 = (队尾 + 1) % 容量 来更新队尾指针。这个取模运算确保了指针在到达数组末尾后能循环回到开头。
  4. 将新元素放入数组[队尾]
  5. 当前大小加1。

出队操作

出队操作移除并返回队首的元素。

以下是出队步骤:

  1. 检查队列是否为空(当前大小 == 0)。若空,则无法出队。
  2. 获取数组[队首]的值作为要返回的元素。
  3. 如果出队后队列变为空(当前大小 == 1),则将队首和队尾重置为-1。
  4. 否则,使用公式 队首 = (队首 + 1) % 容量 来更新队首指针。
  5. 当前大小减1。
  6. 返回之前保存的元素值。

辅助操作

除了入队和出队,我们还需要两个简单的辅助函数。

以下是两个关键的辅助函数:

  • 查看队首:如果队列不空,直接返回数组[队首]的值。
  • 判断为空:检查当前大小是否为0。

C++代码实现

现在,让我们将上述逻辑转化为实际的C++代码。我们将创建一个CircularQueue类。

#include <iostream>
using namespace std;

class CircularQueue {
private:
    int *arr;        // 动态数组
    int front;       // 队首指针
    int rear;        // 队尾指针
    int currentSize; // 当前元素数
    int capacity;    // 队列容量

public:
    // 构造函数
    CircularQueue(int size) {
        capacity = size;
        arr = new int[capacity];
        front = -1;
        rear = -1;
        currentSize = 0;
    }

    // 入队函数
    void push(int data) {
        if (currentSize == capacity) {
            cout << "Queue is full. Cannot push " << data << endl;
            return;
        }
        if (front == -1) { // 队列为空时初始化
            front = 0;
            rear = 0;
        } else {
            rear = (rear + 1) % capacity; // 循环移动队尾
        }
        arr[rear] = data;
        currentSize++;
    }

    // 出队函数
    int pop() {
        if (currentSize == 0) {
            cout << "Queue is empty. Cannot pop." << endl;
            return -1;
        }
        int poppedValue = arr[front];
        if (currentSize == 1) { // 弹出后队列变空
            front = -1;
            rear = -1;
        } else {
            front = (front + 1) % capacity; // 循环移动队首
        }
        currentSize--;
        return poppedValue;
    }

    // 查看队首元素
    int getFront() {
        if (currentSize == 0) {
            cout << "Queue is empty." << endl;
            return -1;
        }
        return arr[front];
    }

    // 判断队列是否为空
    bool isEmpty() {
        return currentSize == 0;
    }

    // 打印队列当前所有元素(按存储顺序,辅助理解)
    void printQueue() {
        if (currentSize == 0) {
            cout << "Queue is empty." << endl;
            return;
        }
        cout << "Current Queue (from front to rear): ";
        int count = 0;
        int index = front;
        while (count < currentSize) {
            cout << arr[index] << " ";
            index = (index + 1) % capacity; // 循环遍历
            count++;
        }
        cout << endl;
    }

    // 析构函数
    ~CircularQueue() {
        delete[] arr;
    }
};

// 主函数用于测试
int main() {
    CircularQueue q(3); // 创建容量为3的循环队列

    q.push(1);
    q.push(2);
    q.push(3);
    q.push(4); // 应提示队列已满

    q.printQueue(); // 输出: 1 2 3

    cout << "Popped: " << q.pop() << endl; // 弹出1
    q.printQueue(); // 输出: 2 3

    q.push(4); // 成功插入,队尾循环到开头
    q.printQueue(); // 输出: 2 3 4

    cout << "Front element is: " << q.getFront() << endl; // 输出: 2

    while (!q.isEmpty()) {
        cout << "Popping: " << q.pop() << endl;
    }
    q.pop(); // 尝试在空队列弹出

    return 0;
}

运行上述代码,你将看到队列如何循环利用空间。例如,在弹出元素1后,插入元素4,队列中的元素顺序在逻辑上是2, 3, 4,尽管它们在数组中的物理存储位置可能不是连续的。

总结

本节课中我们一起学习了循环队列。我们首先指出了普通队列在数组实现中的空间浪费问题,然后引入了循环队列的概念,它通过将队列视为一个环来解决这个问题。我们详细讲解了其入队出队操作中利用取模运算实现指针循环的核心公式,并最终用C++完整实现了一个循环队列类。循环队列是理解更复杂数据结构和解决许多算法问题(如CPU任务调度)的重要基础。

079:用栈实现队列与用队列实现栈

在本节课中,我们将学习两个重要的数据结构问题:如何使用两个队列实现一个栈,以及如何使用两个栈实现一个队列。这是数据结构与算法中的经典问题,也是LeetCode上的题目。理解这些实现有助于深入掌握栈和队列的核心概念。

概述

我们将解决两个问题:

  1. 使用两个队列实现栈(后进先出,LIFO)。
  2. 使用两个栈实现队列(先进先出,FIFO)。

这两个问题都要求我们在内部使用一种数据结构来模拟另一种数据结构的行为,同时实现其基本操作。

问题一:用队列实现栈 🧱

首先,我们来看如何用两个队列实现一个栈。我们需要实现的栈类(例如 MyStack)应包含以下基本功能:

  • push(x):将元素 x 压入栈顶。
  • pop():移除并返回栈顶元素。
  • top():返回栈顶元素。
  • empty():判断栈是否为空。

栈的内部结构应遵循后进先出原则,但我们将使用两个队列(q1q2)来模拟这一行为。

核心策略

基本策略是始终让一个队列(例如 q1)保持栈的顺序,栈顶元素位于队列前端。push 操作是关键的难点,因为我们需要将新元素放到队列的前端,而标准队列只允许在后端插入。

以下是 push 操作的逻辑步骤:

  1. 将新元素 x 推入空的辅助队列 q2
  2. 将主队列 q1 中的所有元素依次出队,并推入 q2。这样,新元素 x 就位于 q2 的前端(即栈顶)。
  3. 交换 q1q2 的名称,使得 q1 再次成为包含所有元素的主队列。

通过这种方式,q1 的前端始终是最后插入的元素,即栈顶。

代码实现

以下是基于上述逻辑的 C++ 代码实现:

class MyStack {
private:
    queue<int> q1, q2;
public:
    MyStack() {}

    void push(int x) {
        // 1. 将新元素推入 q2
        q2.push(x);
        // 2. 将 q1 中所有元素转移到 q2
        while (!q1.empty()) {
            q2.push(q1.front());
            q1.pop();
        }
        // 3. 交换 q1 和 q2
        swap(q1, q2);
    }

    int pop() {
        int val = q1.front();
        q1.pop();
        return val;
    }

    int top() {
        return q1.front();
    }

    bool empty() {
        return q1.empty();
    }
};

复杂度分析

  • push 操作的时间复杂度为 O(n),因为需要转移 n 个元素。
  • poptopempty 操作的时间复杂度均为 O(1)

上一节我们介绍了如何使用两个队列实现栈。接下来,我们看看相反的问题。

问题二:用栈实现队列 🚶‍♂️➡️🚶‍♂️

现在,我们来看如何使用两个栈实现一个队列。我们需要实现的队列类(例如 MyQueue)应包含以下基本功能:

  • push(x):将元素 x 推入队列末尾。
  • pop():移除并返回队列前端的元素。
  • peek():返回队列前端的元素。
  • empty():判断队列是否为空。

队列的内部结构应遵循先进先出原则,我们将使用两个栈(s1s2)来模拟。

核心策略

基本思路是使用一个栈(s1)作为主存储,其栈顶元素映射为队列的后端poppeek 操作需要访问队列的前端,这位于栈 s1底部。为了高效访问,我们需要第二个栈(s2)来反转元素顺序。

以下是关键操作逻辑:

  • push(x):直接将元素 x 压入栈 s1
  • pop()peek()
    1. 如果栈 s2 为空,则将栈 s1 中的所有元素依次弹出并压入 s2。这个过程反转了元素的顺序,使得 s2 的栈顶就是队列的前端。
    2. s2 的栈顶执行弹出(pop)或查看(peek)操作。

代码实现

以下是基于上述逻辑的 C++ 代码实现:

class MyQueue {
private:
    stack<int> s1, s2;
public:
    MyQueue() {}

    void push(int x) {
        s1.push(x);
    }

    int pop() {
        if (s2.empty()) {
            while (!s1.empty()) {
                s2.push(s1.top());
                s1.pop();
            }
        }
        int val = s2.top();
        s2.pop();
        return val;
    }

    int peek() {
        if (s2.empty()) {
            while (!s1.empty()) {
                s2.push(s1.top());
                s1.pop();
            }
        }
        return s2.top();
    }

    bool empty() {
        return s1.empty() && s2.empty();
    }
};

复杂度分析

  • push 操作的时间复杂度为 O(1)
  • poppeek 操作摊还时间复杂度为 O(1)。虽然最坏情况下(当 s2 为空时)需要 O(n) 时间转移元素,但每个元素只会被转移一次(从 s1s2),因此平均下来每次操作是常数时间。
  • empty 操作的时间复杂度为 O(1)

总结

本节课我们一起学习了两个经典的数据结构互转问题:

  1. 用队列实现栈:核心在于通过两个队列的协作,在 push 操作中确保新元素位于“栈顶”(即队列前端),该操作时间复杂度为 O(n)。
  2. 用栈实现队列:核心在于使用一个栈(s1)处理输入,另一个栈(s2)处理输出。通过将 s1 的元素倒入 s2 来反转顺序,从而在 O(1) 摊还时间内访问队列前端。

理解这些实现不仅有助于解决具体的编程问题,更能加深对栈和队列这两种基本数据结构特性的认识。

080:字符串中的第一个唯一字符

概述

在本节课中,我们将学习如何解决一个有趣的算法问题:在字符串中找到第一个不重复的字符。这个问题对应 LeetCode 的第 387 题。我们将探讨不同的解决思路,并最终实现一个高效的解决方案。

问题描述

给定一个字符串,需要找到其中第一个不重复字符的索引,并返回该索引。如果不存在不重复的字符,则返回 -1。

例如,对于字符串 "leetcode"

  • 字符 ‘l‘ 是第一个不重复的字符,其索引为 0。
  • 对于字符串 "loveleetcode",第一个不重复的字符是 ‘v‘,索引为 2。

核心思路

解决此问题的关键在于跟踪每个字符出现的频率。我们需要一种数据结构来记录字符及其出现次数,然后按字符串的原始顺序检查,找到第一个频率为 1 的字符。

我们将使用 哈希映射 来存储字符频率,并使用 队列 来维护字符的原始顺序,以便高效地找到第一个非重复字符。

算法步骤详解

以下是解决此问题的具体步骤。

步骤 1:初始化数据结构

首先,我们创建两个辅助数据结构:

  • unordered_map<char, int>:用于存储每个字符在字符串中出现的次数。
  • queue<int>:用于按顺序存储字符在字符串中的索引。

步骤 2:遍历字符串并填充数据结构

接着,我们遍历字符串中的每一个字符。

以下是遍历过程的伪代码:

for (int i = 0; i < s.length(); i++) {
    char ch = s[i];
    // 如果字符不在映射中,它是首次出现
    if (频率映射.find(ch) == 频率映射.end()) {
        队列.push(i); // 将其索引加入队列
    }
    // 更新该字符的频率(增加1)
    频率映射[ch]++;
}

步骤 3:清理队列中的重复字符

队列前端存储的索引对应的字符,可能是重复的。因此,我们需要清理队列前端所有频率大于 1 的字符。

以下是清理队列的伪代码:

while (!队列.empty()) {
    int frontIndex = 队列.front();
    char frontChar = s[frontIndex];
    // 获取队列前端字符的频率
    int freq = 频率映射[frontChar];
    // 如果频率大于1,说明是重复字符,将其从队列弹出
    if (freq > 1) {
        队列.pop();
    } else {
        // 找到第一个频率为1的字符,停止循环
        break;
    }
}

步骤 4:返回结果

最后,检查队列是否为空。

  • 如果队列不为空,队列前端的索引就是第一个唯一字符的索引。
  • 如果队列为空,说明没有唯一字符,返回 -1。

返回结果的伪代码如下:

if (队列.empty()) {
    return -1;
} else {
    return 队列.front();
}

完整代码实现

将以上步骤组合起来,得到完整的 C++ 解决方案。

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

class Solution {
public:
    int firstUniqChar(string s) {
        unordered_map<char, int> freqMap;
        queue<int> indexQueue;

        // 步骤 2:遍历字符串,填充映射和队列
        for (int i = 0; i < s.size(); i++) {
            char currentChar = s[i];
            // 如果是第一次出现,将其索引加入队列
            if (freqMap.find(currentChar) == freqMap.end()) {
                indexQueue.push(i);
            }
            // 更新字符频率
            freqMap[currentChar]++;
        }

        // 步骤 3:清理队列前端的重复字符
        while (!indexQueue.empty()) {
            int frontIndex = indexQueue.front();
            char frontChar = s[frontIndex];
            // 获取该字符的频率
            int frequency = freqMap[frontChar];
            // 如果频率大于1,弹出该索引
            if (frequency > 1) {
                indexQueue.pop();
            } else {
                // 找到第一个非重复字符,停止循环
                break;
            }
        }

        // 步骤 4:返回结果
        if (indexQueue.empty()) {
            return -1;
        } else {
            return indexQueue.front();
        }
    }
};

复杂度分析

  • 时间复杂度:O(n)。我们只需要遍历字符串一次来构建映射和队列,再遍历队列一次(最坏情况)来清理重复项。n 是字符串的长度。
  • 空间复杂度:O(n)。在最坏情况下(所有字符都不同),哈希映射和队列都需要存储 n 个元素。

总结

本节课我们一起学习了如何解决 “字符串中的第一个唯一字符” 问题。我们掌握了使用 哈希映射 记录频率,并结合 队列 维护顺序的核心方法。这种方法高效且直观,是处理此类“首次出现”或“唯一性”问题的典型策略。

成功完成本课学习后,你可以在评论区标记。你也可以通过描述框中的链接分享你的学习进度。我们下节课再见,继续学习和探索。

081:滑动窗口最大值 🪟

在本节课中,我们将学习一个重要的经典数据结构与算法问题——滑动窗口最大值。这是一个在LeetCode上编号为239的题目。我们将从理解问题开始,逐步探讨暴力解法,并最终实现一个使用双端队列最优线性时间复杂度解法

概述 📋

我们被给定一个整数数组 nums 和一个整数 kk 代表一个固定大小的滑动窗口。这个窗口从数组的最左端开始,每次向右滑动一个位置。我们需要找出每个窗口中的最大值,并将这些最大值组成一个数组返回。

例如,对于数组 [1, 3, -1, -3, 5, 3, 6, 7]k = 3,滑动窗口及其最大值如下:

  • 窗口 [1, 3, -1],最大值 3
  • 窗口 [3, -1, -3],最大值 3
  • 窗口 [-1, -3, 5],最大值 5
  • 窗口 [-3, 5, 3],最大值 5
  • 窗口 [5, 3, 6],最大值 6
  • 窗口 [3, 6, 7],最大值 7
    因此,返回的结果数组是 [3, 3, 5, 5, 6, 7]

暴力解法 💪

最直观的解法是遍历每一个可能的窗口,并在每个窗口中遍历所有元素以找到最大值。

以下是暴力解法的伪代码描述:

vector<int> result;
for (int i = 0; i <= n - k; i++) { // n 是数组长度
    int max_val = nums[i];
    for (int j = 1; j < k; j++) {
        if (nums[i + j] > max_val) {
            max_val = nums[i + j];
        }
    }
    result.push_back(max_val);
}
return result;

时间复杂度:外层循环运行 O(n-k) 次,内层循环运行 O(k) 次,因此总时间复杂度为 O(n*k)。当 k 较大时,效率很低。

上一节我们介绍了直观但低效的暴力解法。本节中,我们来看看如何使用双端队列来实现一个O(n)时间复杂度的最优解法。

最优解法:双端队列 🚀

核心思路是使用一个双端队列来存储数组元素的索引。这个队列将帮助我们维护一个“可能成为当前或未来窗口最大值”的候选元素序列。

我们需要保证双端队列中的元素(通过索引指向的数组值)始终满足两个规则:

  1. 索引在窗口内:队列中的索引对应的元素必须位于当前滑动窗口的范围内。
  2. 值递减顺序:队列中的索引,其对应的数组值是单调递减的。这意味着队首索引对应的元素永远是当前窗口的最大值。

算法步骤详解

让我们一步步拆解算法的执行过程。

第一步:处理第一个窗口

我们首先处理数组的前 k 个元素,初始化第一个窗口。
以下是处理逻辑:

  1. 遍历索引 i0k-1
  2. 对于每个 nums[i],在将它的索引 i 加入队列之前,需要从队列后端移除所有其值小于 nums[i] 的元素的索引。这确保了队列中的值保持递减顺序。
  3. 将当前索引 i 加入队列后端。

处理完成后,队列前端索引对应的元素就是第一个窗口的最大值

第二步:滑动窗口并计算后续窗口的最大值

现在,我们从索引 i = k 开始,处理数组剩余的元素。对于每个新的 nums[i]

  1. 获取上一个窗口的答案:此时,队列前端索引对应的元素就是上一个窗口(即结束于 i-1 的窗口)的最大值。将其存入结果数组。
  2. 移除过期索引:检查队列前端的索引。如果该索引等于 i - k(即该索引已经滑出窗口的起始位置),则将其从队列前端弹出。
  3. 维护递减顺序:与第一步相同,从队列后端移除所有其值小于 nums[i] 的元素的索引。
  4. 加入新索引:将当前索引 i 加入队列后端。

循环结束后,不要忘记将最后一个窗口的最大值(即队列前端索引对应的元素)加入结果数组。

关键操作总结

以下是算法中双端队列的核心操作:

  • pop_front(): 移除队首元素(当它滑出窗口时)。
  • pop_back(): 移除队尾元素(当新元素更大,需要维护递减顺序时)。
  • push_back(i): 将新元素的索引加入队尾。
  • front(): 获取队首索引(即当前窗口最大值的索引)。

代码实现 💻

根据上述逻辑,我们可以将伪代码转化为实际的C++代码。

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

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> result;
        deque<int> dq; // 存储的是元素的索引
        
        // 第一步:处理第一个窗口
        for (int i = 0; i < k; i++) {
            // 维护递减顺序:从后端移除比当前元素小的索引
            while (!dq.empty() && nums[dq.back()] < nums[i]) {
                dq.pop_back();
            }
            dq.push_back(i);
        }
        // 存储第一个窗口的答案
        result.push_back(nums[dq.front()]);
        
        // 第二步:滑动窗口,处理剩余元素
        for (int i = k; i < nums.size(); i++) {
            // 1. 移除滑出窗口的索引(从前端)
            if (!dq.empty() && dq.front() == i - k) {
                dq.pop_front();
            }
            
            // 2. 维护递减顺序:从后端移除比当前元素小的索引
            while (!dq.empty() && nums[dq.back()] < nums[i]) {
                dq.pop_back();
            }
            
            // 3. 加入新索引
            dq.push_back(i);
            
            // 4. 当前窗口的最大值在队首
            result.push_back(nums[dq.front()]);
        }
        
        return result;
    }
};

时间复杂度分析:数组中的每个元素恰好被加入队列一次,也最多被移除队列一次。因此,所有操作的总时间复杂度为 O(n),其中 n 是数组的长度。
空间复杂度:双端队列在最坏情况下可能存储 k 个元素(例如数组完全递减时),因此空间复杂度为 O(k)

总结 🎯

本节课中我们一起学习了滑动窗口最大值这一经典问题。

  • 我们首先分析了暴力解法,其时间复杂度为 O(n*k)。
  • 接着,我们深入探讨了基于双端队列最优解法。该解法的核心在于维护一个存储索引的队列,并确保其对应的值单调递减,从而使得队首元素始终是当前窗口的最大值。
  • 我们详细拆解了算法的每一步,并最终给出了完整的C++实现代码,该算法的时间复杂度为 O(n),空间复杂度为 O(k)

掌握这种利用数据结构特性来优化窗口类问题的技巧,对于解决许多复杂的算法题目至关重要。

082:加油站问题 - 贪心方法

概述

在本节课中,我们将学习如何解决LeetCode第134题“加油站”。这是一个经典的贪心算法问题,涉及在环形路线上寻找一个起点,使得汽车能够绕行一圈。我们将通过分析问题、推导关键条件,并最终实现一个高效的解决方案。


问题描述

n个加油站,它们位于一个环形路线上。给定两个整数数组:

  • gas[i] 表示在第i个加油站可以获得的汽油量。
  • cost[i] 表示从第i个加油站行驶到第i+1个加油站需要消耗的汽油量。

汽车开始时油箱为空。目标是找到一个起始加油站,使得汽车可以顺时针绕环形路线行驶一周,并返回起始加油站。如果存在这样的解,则返回起始加油站的索引;否则返回-1。题目保证如果存在解,则解是唯一的。

示例

假设我们有:

  • gas = [1, 2, 3, 4, 5]
  • cost = [3, 4, 5, 1, 2]

这表示有5个加油站。我们需要找到一个起点,使得汽车能够完成环形旅程。


核心逻辑分析

在深入代码之前,我们需要理解解决问题的两个关键逻辑。

全局可行性检查

汽车能够完成环形旅程的一个必要前提是:所有加油站提供的汽油总量必须大于或等于完成整个旅程所需消耗的汽油总量。

公式表示为:
sum(gas) >= sum(cost)

如果这个条件不满足,那么无论从哪里出发,汽油都不够用,直接返回-1

寻找唯一可行起点

如果全局汽油充足(即 sum(gas) >= sum(cost)),那么必然存在一个唯一的起点可以完成旅程。我们可以使用贪心策略来找到它:

  1. 从索引0开始模拟旅程,用current_gas记录当前油箱的剩余油量。
  2. 遍历每个加油站i,计算到达该站并加油后的净收益:gas[i] - cost[i],并将其累加到current_gas
  3. 如果在某个站点icurrent_gas变为负数,说明从之前的start起点无法到达站点i+1
  4. 那么,新的潜在起点不可能是starti之间的任何站点。我们将起点start更新为i+1,并将current_gas重置为0,重新开始计算。
  5. 遍历结束后,最后更新的start就是答案。

这个逻辑的直觉是:如果从A点无法到达B点,那么A点和B点之间的任何点作为起点都无法到达B点。因此,我们可以安全地将起点跳到B点之后。


算法实现步骤

以下是实现上述逻辑的具体步骤。

我们将在一个循环中同时计算总油量、总消耗,并寻找起点。

伪代码描述:

int start = 0;
int total_gas = 0;
int total_cost = 0;
int current_gas = 0;

for (int i = 0; i < n; i++) {
    total_gas += gas[i];
    total_cost += cost[i];
    current_gas += gas[i] - cost[i];
    
    if (current_gas < 0) {
        // 从当前start无法到达i+1,将起点设为i+1
        start = i + 1;
        current_gas = 0; // 重置当前油量
    }
}

// 检查全局条件
if (total_gas < total_cost) {
    return -1;
} else {
    return start;
}

完整C++代码

根据以上分析,我们可以写出简洁高效的代码。

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int n = gas.size();
        int total_gas = 0, total_cost = 0;
        int current_gas = 0;
        int start_index = 0;
        
        for (int i = 0; i < n; i++) {
            total_gas += gas[i];
            total_cost += cost[i];
            
            // 计算从当前假设的起点出发,到i+1站时的油量
            current_gas += gas[i] - cost[i];
            
            // 如果油量为负,说明当前起点不可行
            if (current_gas < 0) {
                // 将起点更新为下一个站点
                start_index = i + 1;
                // 重置当前油量,从新起点开始计算
                current_gas = 0;
            }
        }
        
        // 最终检查:总油量是否足够
        return (total_gas < total_cost) ? -1 : start_index;
    }
};

复杂度分析

上一节我们完成了代码实现,现在来分析其效率。

  • 时间复杂度:O(n)
    我们只遍历了gascost数组一次,其中n是加油站的数量。所有操作都在常数时间内完成。
  • 空间复杂度:O(1)
    我们只使用了几个整型变量(total_gas, total_cost, current_gas, start_index),没有使用任何与输入规模相关的额外数据结构。

这是一个时间上最优、空间上极简的解决方案。


总结

本节课我们一起学习了LeetCode第134题“加油站”的解法。

我们首先理解了问题的核心:汽车需要在一个环形路线上找到起点。然后,我们推导出两个关键点:

  1. 解决问题的必要条件是总油量不小于总消耗(sum(gas) >= sum(cost))。
  2. 在条件满足的前提下,可以使用贪心算法在一次遍历中找到唯一的可行起点。其策略是当累计油量不足时,将起点跳到无法到达的站点之后。

最后,我们实现了时间复杂度为O(n)、空间复杂度为O(1)的高效代码,并成功通过了测试。掌握这种“全局判断 + 局部贪心寻找起点”的思路,对解决许多类似的序列遍历问题非常有帮助。

083:数据结构中的二叉树与树遍历 🌳

概述

在本节课中,我们将要学习一种新的数据结构——二叉树。我们将从基本概念开始,理解其结构、术语,并学习如何从序列构建二叉树。随后,我们将深入探讨四种核心的树遍历算法:前序遍历、中序遍历、后序遍历以及层序遍历。这些是解决众多基于树的问题的基础。


二叉树基础

什么是树?

树是一种以层级形式组织数据的非线性数据结构。我们可以将其想象为由节点和边组成的结构。

核心概念与术语

以下是理解树结构所需的核心术语:

  • 节点:树中存储数据的基本单位。
  • 根节点:位于树顶层的节点,是整棵树的起点。
  • 父节点与子节点:如果一个节点直接连接到下层的一个或多个节点,那么它被称为这些下层节点的父节点,而下层节点则被称为它的子节点
  • 叶节点:没有子节点的节点。
  • 子树:树中的任意一个节点及其所有后代节点构成一棵子树

什么是二叉树?

二叉树是一种特殊的树结构,其中每个节点最多只能有两个子节点,通常称为左子节点右子节点

二叉树节点的代码表示

class Node {
public:
    int data;
    Node* left;
    Node* right;

    // 构造函数
    Node(int val) {
        data = val;
        left = NULL;
        right = NULL;
    }
};

从前序序列构建二叉树

上一节我们介绍了二叉树的基本结构,本节中我们来看看如何根据一个特定的序列(如前序序列)来构建一棵二叉树。

前序序列遵循 “根-左-右” 的访问顺序。给定一个包含空节点(通常用 -1 表示)信息的前序序列,我们可以递归地重建二叉树。

构建逻辑(伪代码)

  1. 从序列中读取当前索引的值。
  2. 如果该值为 -1(表示空节点),则返回 NULL
  3. 否则,用该值创建一个新节点作为根节点。
  4. 递归构建左子树(索引递增)。
  5. 递归构建右子树(索引继续递增)。
  6. 返回创建好的根节点。

C++ 实现代码

int index = 0; // 静态或引用传递的索引,用于遍历序列

Node* buildTree(vector<int> &preorder) {
    // 基线条件:如果遇到空节点标记
    if (index >= preorder.size() || preorder[index] == -1) {
        index++;
        return NULL;
    }

    // 创建当前根节点
    Node* root = new Node(preorder[index]);
    index++;

    // 递归构建左子树和右子树
    root->left = buildTree(preorder);
    root->right = buildTree(preorder);

    return root;
}

该算法的时间复杂度为 O(n),其中 n 是序列中节点的数量。


树的深度优先遍历(DFS)

构建好树之后,我们需要方法来访问或“遍历”树中的所有节点。深度优先遍历是一种沿着分支深入到底部,再回溯的遍历方式。主要有三种类型:

1. 前序遍历

前序遍历的顺序是:访问根节点 -> 遍历左子树 -> 遍历右子树

C++ 实现代码

void preorderTraversal(Node* root) {
    if (root == NULL) {
        return;
    }
    cout << root->data << " "; // 访问根
    preorderTraversal(root->left); // 遍历左
    preorderTraversal(root->right); // 遍历右
}

时间复杂度:O(n)

2. 中序遍历

中序遍历的顺序是:遍历左子树 -> 访问根节点 -> 遍历右子树

C++ 实现代码

void inorderTraversal(Node* root) {
    if (root == NULL) {
        return;
    }
    inorderTraversal(root->left); // 遍历左
    cout << root->data << " "; // 访问根
    inorderTraversal(root->right); // 遍历右
}

时间复杂度:O(n)

3. 后序遍历

后序遍历的顺序是:遍历左子树 -> 遍历右子树 -> 访问根节点

C++ 实现代码

void postorderTraversal(Node* root) {
    if (root == NULL) {
        return;
    }
    postorderTraversal(root->left); // 遍历左
    postorderTraversal(root->right); // 遍历右
    cout << root->data << " "; // 访问根
}

时间复杂度:O(n)


树的广度优先遍历(BFS)/ 层序遍历

上一节我们学习了三种深度优先遍历,本节我们来看看广度优先遍历,即层序遍历。它按层级从上到下、每层从左到右访问节点。

层序遍历通常借助队列数据结构来实现。

算法步骤

  1. 将根节点入队。
  2. 当队列不为空时循环:
    • 取出队首节点并访问。
    • 将该节点的左子节点(如果存在)入队。
    • 将该节点的右子节点(如果存在)入队。

基础层序遍历 C++ 实现

void levelOrderTraversal(Node* root) {
    if (root == NULL) return;

    queue<Node*> q;
    q.push(root);

    while (!q.empty()) {
        Node* current = q.front();
        q.pop();
        cout << current->data << " ";

        if (current->left != NULL) {
            q.push(current->left);
        }
        if (current->right != NULL) {
            q.push(current->right);
        }
    }
}

分层打印的层序遍历

为了使输出结果更清晰,我们经常希望将不同层的节点打印在不同的行。

改进思路:在队列中插入一个NULL作为层结束标记。

  1. 初始将根节点和NULL入队。
  2. 当出队元素为NULL且队列不为空时,说明一层结束,打印换行符,并再向队尾插入一个NULL

分层打印的 C++ 实现

void levelOrderTraversalLine(Node* root) {
    if (root == NULL) return;

    queue<Node*> q;
    q.push(root);
    q.push(NULL); // 第一层结束标记

    while (!q.empty()) {
        Node* current = q.front();
        q.pop();

        if (current == NULL) { // 遇到层结束标记
            cout << endl;
            if (!q.empty()) { // 如果队列还有下一层的节点
                q.push(NULL); // 为下一层添加结束标记
            }
        } else {
            cout << current->data << " ";
            if (current->left != NULL) q.push(current->left);
            if (current->right != NULL) q.push(current->right);
        }
    }
}

层序遍历的时间复杂度同样为 O(n)


总结

本节课中我们一起学习了数据结构中的二叉树

  • 我们首先理解了树和二叉树的基本定义、节点结构及核心术语。
  • 接着,我们学习了如何从一个前序序列递归地构建二叉树。
  • 然后,我们深入探讨了三种深度优先遍历方法:前序、中序和后序遍历,并理解了它们访问节点的不同顺序。
  • 最后,我们学习了广度优先的层序遍历,包括如何借助队列实现,以及如何改进算法以分层打印节点。

这些关于二叉树构建和遍历的基础知识,是理解和解决更复杂的树形结构问题(如二叉搜索树、AVL树、图算法等)的基石。

084:二叉树的高度与节点计数 📊

在本节课中,我们将学习二叉树的两个基础但至关重要的概念:计算树的高度和统计树的节点总数。我们将通过清晰的逻辑、代码示例和分步解释来掌握这些核心算法。

概述 🌲

二叉树是数据结构中的基石。理解如何计算其高度和节点数量是解决更复杂树形问题(如平衡检查、遍历优化等)的第一步。本节将介绍使用递归方法解决这两个问题的思路与实现。

二叉树的高度 📏

上一节我们介绍了二叉树的基本结构,本节中我们来看看如何计算一棵二叉树的高度(或深度)。树的高度定义为从根节点到最远叶子节点的最长路径上的节点数

核心概念与公式

计算任意节点为根的子树高度的递归公式如下:

height(node) = max(height(node.left), height(node.node.right)) + 1

这个公式的含义是:以当前节点为根的树的高度,等于其左子树和右子树中较大的高度值,再加上当前节点自身(因此需要 +1)。

算法步骤说明

以下是计算高度的递归步骤:

  1. 基准情况:如果当前节点为空(nullptr),则其高度为0。
  2. 递归计算:分别计算当前节点左子树和右子树的高度。
  3. 合并结果:取左右子树高度的最大值,然后加1,即为当前节点为根的树的高度。

代码实现

int heightOfBinaryTree(Node* root) {
    // 基准情况:空树高度为0
    if (root == nullptr) {
        return 0;
    }
    
    // 递归计算左子树高度
    int leftHeight = heightOfBinaryTree(root->left);
    // 递归计算右子树高度
    int rightHeight = heightOfBinaryTree(root->right);
    
    // 当前树的高度 = 左右子树最大高度 + 1 (当前节点)
    return max(leftHeight, rightHeight) + 1;
}

二叉树的节点计数 🔢

理解了如何计算高度后,我们再来看看如何统计二叉树中节点的总数。思路与计算高度类似,同样采用递归分治的策略。

核心概念与公式

计算以某个节点为根的子树中节点总数的递归公式如下:

countNodes(node) = countNodes(node.left) + countNodes(node.right) + 1

这个公式的含义是:以当前节点为根的树的节点总数,等于其左子树的节点数、右子树的节点数之和,再加上当前节点自身(因此需要 +1)。

算法步骤说明

以下是统计节点总数的递归步骤:

  1. 基准情况:如果当前节点为空(nullptr),则其节点数为0。
  2. 递归计算:分别计算当前节点左子树和右子树的节点数。
  3. 合并结果:将左右子树的节点数相加,再加1,即为当前节点为根的树的节点总数。

代码实现

int countNodesInBinaryTree(Node* root) {
    // 基准情况:空树节点数为0
    if (root == nullptr) {
        return 0;
    }
    
    // 递归计算左子树节点数
    int leftCount = countNodesInBinaryTree(root->left);
    // 递归计算右子树节点数
    int rightCount = countNodesInBinaryTree(root->right);
    
    // 当前树的节点总数 = 左子树节点数 + 右子树节点数 + 1 (当前节点)
    return leftCount + rightCount + 1;
}

扩展:计算节点值的总和 ➕

基于相同的递归模式,我们可以轻松扩展出另一个实用功能:计算二叉树中所有节点值的总和(假设节点存储整数值)。

核心概念与公式

计算节点值总和的递归公式如下:

sumOfNodes(node) = sumOfNodes(node.left) + sumOfNodes(node.right) + node.data

代码实现

int sumOfNodesInBinaryTree(Node* root) {
    // 基准情况:空树节点值总和为0
    if (root == nullptr) {
        return 0;
    }
    
    // 递归计算左子树节点值总和
    int leftSum = sumOfNodesInBinaryTree(root->left);
    // 递归计算右子树节点值总和
    int rightSum = sumOfNodesInBinaryTree(root->right);
    
    // 当前树的节点值总和 = 左子树总和 + 右子树总和 + 当前节点值
    return leftSum + rightSum + root->data;
}

总结 📝

本节课中我们一起学习了二叉树的三项基本操作:

  1. 计算高度:使用 max(leftHeight, rightHeight) + 1 的递归公式。
  2. 统计节点数:使用 leftCount + rightCount + 1 的递归公式。
  3. 计算节点值和:使用 leftSum + rightSum + node.data 的递归公式。

这三个问题都完美体现了分而治之的递归思想:将一个大问题(整棵树)分解为相同的小问题(左子树和右子树),解决小问题后合并结果。它们的时间复杂度都是 O(N),其中 N 是节点数,因为每个节点都会被访问一次。掌握这些基础是迈向更高级二叉树算法的重要一步。

085:相同的树与另一棵树的子树

在本节课中,我们将学习解决两个重要的二叉树问题:“相同的树”和“另一棵树的子树”。我们将通过递归方法,逐步分析问题并编写解决方案。

相同的树

首先,我们来看第一个问题:“相同的树”。题目要求判断两棵给定的二叉树是否完全相同。完全相同意味着两棵树的结构完全一致,并且每个对应节点的值也相等。

以下是判断两棵树是否相同的逻辑步骤:

  1. 检查当前节点:比较两棵树当前节点的值是否相等。
  2. 递归检查左子树:判断两棵树的左子树是否相同。
  3. 递归检查右子树:判断两棵树的右子树是否相同。

只有当以上三个条件同时满足时,两棵树才被认为是相同的。

递归函数设计

我们可以设计一个递归函数 isSameTree 来实现上述逻辑。函数的伪代码如下:

bool isSameTree(TreeNode* p, TreeNode* q) {
    // 基础情况处理
    if (p == nullptr && q == nullptr) {
        return true; // 两个节点都为空,视为相同
    }
    if (p == nullptr || q == nullptr) {
        return false; // 只有一个节点为空,结构不同
    }
    // 检查当前节点值,并递归检查左右子树
    return (p->val == q->val) 
           && isSameTree(p->left, q->left) 
           && isSameTree(p->right, q->right);
}

代码解释

  • if (p == nullptr && q == nullptr):如果两个节点都为空,说明这一分支已经结束且匹配,返回 true
  • if (p == nullptr || q == nullptr):如果只有一个节点为空,说明两棵树结构不一致,返回 false
  • return (p->val == q->val) && ...:检查当前节点值是否相等,并递归地对左、右子树进行相同的判断。使用逻辑与 && 连接,确保所有条件都满足。

这个算法的时间复杂度是 O(N),其中 N 是两棵树中节点数的较小值,因为我们需要遍历每个节点进行比较。


上一节我们学习了如何判断两棵树是否完全相同。接下来,我们将利用这个结论来解决一个更复杂的问题:“另一棵树的子树”。

另一棵树的子树

这个问题要求判断一棵给定的树 subRoot 是否是另一棵树 root 的子树。子树意味着在 root 中存在一个节点,以该节点为根的子树与 subRoot 完全相同(结构和值)。

解决思路是遍历主树 root 的每一个节点,并以该节点为根,判断其子树是否与 subRoot 相同。

算法步骤

以下是判断子树的逻辑步骤:

  1. 遍历主树:从 root 的根节点开始。
  2. 匹配检查:对于当前节点,检查以该节点为根的子树是否与 subRoot 相同。这可以直接使用我们刚才编写的 isSameTree 函数。
  3. 递归搜索:如果当前节点不匹配,则递归地在当前节点的左子树和右子树中继续寻找。

递归函数设计

我们可以设计一个递归函数 isSubtree 来实现上述逻辑。

bool isSubtree(TreeNode* root, TreeNode* subRoot) {
    // 基础情况处理
    if (subRoot == nullptr) {
        return true; // 空树是任何树的子树
    }
    if (root == nullptr) {
        return false; // 主树为空,不可能包含非空子树
    }
    
    // 检查当前根节点的子树是否与 subRoot 相同
    if (isSameTree(root, subRoot)) {
        return true;
    }
    // 如果不相同,则在左子树或右子树中继续寻找
    return isSubtree(root->left, subRoot) || isSubtree(root->right, subRoot);
}

代码解释

  • if (isSameTree(root, subRoot)):调用 isSameTree 函数检查以当前 root 节点为根的树是否与 subRoot 完全相同。如果相同,则找到子树,返回 true
  • return isSubtree(root->left, subRoot) || isSubtree(root->right, subRoot):如果当前节点不匹配,则问题转化为在左子树或右子树中是否存在 subRoot。使用逻辑或 || 连接,只要在任意一侧找到即可。

这个算法在最坏情况下(例如,subRoot 几乎与 root 相同但最后才匹配)的时间复杂度是 O(M * N),其中 M 和 N 分别是 rootsubRoot 的节点数。因为对于 root 中的每个节点,我们可能都需要进行一次 isSameTree 的 O(N) 比较。


总结

本节课中我们一起学习了两个基于递归的二叉树问题:

  1. 相同的树:我们通过递归比较两棵树每个节点的值及其左右子树,来判断它们是否完全相同。核心函数是 isSameTree
  2. 另一棵树的子树:我们通过遍历主树的每个节点,并利用 isSameTree 函数来判断以该节点为根的子树是否与目标子树完全相同。核心函数是 isSubtree

理解递归在树结构中的应用是解决此类问题的关键。通过将大问题分解为结构相同的子问题,我们可以写出简洁而有效的代码。

086:二叉树的直径 🌳

在本节课中,我们将学习如何计算二叉树的直径。二叉树的直径被定义为树中任意两个节点之间最长路径的长度。这个路径可能经过根节点,也可能不经过。我们将通过分析不同情况,并最终编写代码来解决这个问题。


概述

二叉树直径的计算是数据结构中的一个常见问题。理解其核心在于找到树中最长的路径。这条路径的长度由路径上的边数决定。我们将探讨两种主要情况:直径路径经过根节点和不经过根节点,并学习一种高效的算法来计算它。


直径的定义

二叉树的直径是树中任意两个节点之间最长路径的长度。这个路径的终点通常是树的叶子节点。路径的长度由连接这两个节点的边的数量来衡量。

例如,考虑以下二叉树:

        1
       / \
      2   3
     / \
    4   5

最长路径是节点 4 -> 2 -> 1 -> 3,它包含3条边。因此,这棵树的直径是 3


计算直径的思路

计算直径时,我们需要考虑以每个节点为“最高点”的最长路径。对于任意一个节点,经过它的最长路径长度可以通过其左子树的高度和右子树的高度之和得到。

公式表示如下:
当前节点直径 = 左子树高度 + 右子树高度

然而,整个树的直径并不一定经过根节点。它可能是:

  1. 完全位于左子树中。
  2. 完全位于右子树中。
  3. 经过当前根节点,横跨左右子树。

因此,对于以某个节点为根的树,其直径是以下三者的最大值:

  • 左子树直径
  • 右子树直径
  • 左子树高度 + 右子树高度

基础算法(O(n²))

根据上述思路,我们可以设计一个递归算法。上一节我们介绍了直径的计算逻辑,本节中我们来看看如何将其转化为代码。

以下是计算直径的递归函数框架:

int diameterOfBinaryTree(TreeNode* root) {
    if (root == nullptr) {
        return 0;
    }
    
    // 计算左子树直径
    int leftDiameter = diameterOfBinaryTree(root->left);
    // 计算右子树直径
    int rightDiameter = diameterOfBinaryTree(root->right);
    // 计算经过当前根节点的直径
    int currentDiameter = height(root->left) + height(root->right);
    
    // 返回三者中的最大值
    return max(currentDiameter, max(leftDiameter, rightDiameter));
}

其中,height 函数用于计算树的高度:

int height(TreeNode* node) {
    if (node == nullptr) {
        return 0;
    }
    int leftHeight = height(node->left);
    int rightHeight = height(node->right);
    return max(leftHeight, rightHeight) + 1;
}

复杂度分析:对于每个节点,我们都要调用 height 函数,而 height 函数本身是 O(n) 的。因此,这个算法总的时间复杂度是 O(n²),对于大型树来说效率较低。


优化算法(O(n))

为了将复杂度优化到 O(n),我们需要在计算高度的同时,顺便计算出直径。我们可以使用一个全局变量(或通过引用传递的变量)来记录遍历过程中遇到的最大直径。

思路是:修改 height 函数,在计算节点高度的同时,计算 左子树高度 + 右子树高度(即经过该节点的直径),并更新全局的最大直径。

以下是优化后的代码实现:

class Solution {
public:
    int diameter = 0; // 全局变量记录最大直径
    
    int diameterOfBinaryTree(TreeNode* root) {
        height(root);
        return diameter;
    }
    
    int height(TreeNode* node) {
        if (node == nullptr) {
            return 0;
        }
        // 递归计算左右子树的高度
        int leftHeight = height(node->left);
        int rightHeight = height(node->right);
        
        // 更新全局直径:经过当前节点的路径长度
        diameter = max(diameter, leftHeight + rightHeight);
        
        // 返回当前节点的高度
        return max(leftHeight, rightHeight) + 1;
    }
};

算法流程

  1. 从根节点开始深度优先遍历。
  2. 对于每个节点,计算其左右子树的高度。
  3. 左高度 + 右高度 即为“经过该节点的最长路径长度”。用这个值更新全局 diameter 变量。
  4. 该节点向上返回的高度是 max(左高度, 右高度) + 1
  5. 遍历结束后,diameter 中存储的就是整棵树的直径。

复杂度分析:每个节点只被访问一次,因此时间复杂度为 O(n)。空间复杂度取决于递归栈的深度,在最坏情况(树退化成链表)下为 O(n)。


总结

本节课中我们一起学习了如何计算二叉树的直径。

  • 我们首先明确了直径的定义:树中最长路径的边数。
  • 然后分析了计算思路,关键在于比较 左子树直径右子树直径经过根节点的路径长度
  • 我们实现了一个直观但效率较低(O(n²))的递归算法。
  • 最后,我们将其优化为一个高效的 O(n) 算法,该算法在计算节点高度的同时,通过维护一个全局最大直径变量来得到答案。

掌握这种“在递归遍历中同时计算多个值”的技巧,对于解决许多二叉树问题都非常有帮助。

087:二叉树的顶部视图 🌳

概述

在本节课中,我们将学习如何获取二叉树的“顶部视图”。顶部视图是指从二叉树正上方垂直向下看时,所有可见节点的集合。我们将使用层序遍历水平距离的概念来解决这个问题,并通过一个清晰的示例来理解整个过程。


核心概念:水平距离

为了计算顶部视图,我们需要引入“水平距离”的概念。想象一个数轴,将二叉树的根节点放在原点(0)。根节点左子节点的水平距离是父节点距离减1,右子节点的水平距离是父节点距离加1。

公式

  • 根节点水平距离:hd(root) = 0
  • 左子节点水平距离:hd(left) = hd(parent) - 1
  • 右子节点水平距离:hd(right) = hd(parent) + 1

具有相同水平距离的节点位于同一条垂直线上。对于顶部视图,我们只取每条垂直线上第一次访问到的节点。


算法策略 🧠

上一节我们介绍了水平距离的概念,本节中我们来看看如何利用它来获取顶部视图。我们的核心策略是进行层序遍历,并同时记录每个节点的水平距离。

以下是实现该策略的关键步骤:

  1. 创建一个队列,用于层序遍历。队列中的元素是pair<节点指针, 水平距离>
  2. 创建一个映射(如C++的map),用于记录每个水平距离首次出现的节点值。
  3. 从根节点(水平距离为0)开始层序遍历。
  4. 对于从队列中取出的每个节点:
    • 如果其水平距离在映射中是首次出现,则将该节点值存入映射。
    • 将其左子节点(水平距离-1)和右子节点(水平距离+1)加入队列。
  5. 遍历结束后,按水平距离从小到大的顺序输出映射中存储的节点值,即为顶部视图。

代码实现 💻

以下是基于上述策略的C++代码实现框架。

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

// 二叉树节点结构
struct Node {
    int data;
    Node* left;
    Node* right;
    Node(int val) : data(val), left(nullptr), right(nullptr) {}
};

void topView(Node* root) {
    if (root == nullptr) return;

    // 队列存储 <节点指针, 水平距离>
    queue<pair<Node*, int>> q;
    // 映射存储 <水平距离, 节点值>
    map<int, int> topNodeMap;

    q.push({root, 0}); // 根节点水平距离为0

    while (!q.empty()) {
        pair<Node*, int> temp = q.front();
        q.pop();

        Node* frontNode = temp.first;
        int hd = temp.second; // 当前节点的水平距离

        // 如果当前水平距离是第一次出现,则记录该节点
        if (topNodeMap.find(hd) == topNodeMap.end()) {
            topNodeMap[hd] = frontNode->data;
        }

        // 将左子节点和右子节点加入队列
        if (frontNode->left) {
            q.push({frontNode->left, hd - 1});
        }
        if (frontNode->right) {
            q.push({frontNode->right, hd + 1});
        }
    }

    // 输出顶部视图
    for (auto i : topNodeMap) {
        cout << i.second << " ";
    }
}

复杂度分析 ⚡

  • 时间复杂度O(N log N),其中N是节点数。我们需要遍历所有N个节点,每次对map的插入/查找操作平均需要O(log N)时间。
  • 空间复杂度O(N),最坏情况下队列和映射需要存储与节点数量相当的信息。

总结与延伸

本节课中我们一起学习了如何获取二叉树的顶部视图。我们结合了层序遍历水平距离映射的方法,有效地解决了这个问题。

理解顶部视图后,可以尝试解决一个相关的问题:二叉树的底部视图。在底部视图中,你需要输出每条垂直线上最后一次访问到的节点。这可以作为巩固本节课知识的练习。

如果你成功理解了本节课的内容,可以尝试自己实现底部视图的代码。在下一节课中,我们将继续探索更多有趣的二叉树问题。不断学习,持续探索!

088:二叉树的第K层 🌳

在本节课中,我们将学习如何解决一个关于二叉树的常见问题:打印出二叉树中第K层的所有节点。我们将使用递归的方法来实现这个功能,这种方法在二叉树问题中通常更简洁易懂。

概述

给定一个二叉树的根节点和一个整数K,我们的目标是打印出该二叉树在第K层上的所有节点值。我们约定,根节点位于第1层。例如,如果K=3,我们需要打印出第三层的所有节点。

问题分析与递归逻辑

上一节我们明确了问题目标,本节中我们来看看如何通过递归来解决它。

核心思路是:从根节点(第1层)开始,我们每向下一层,目标层数K就减1。当K减到1时,意味着我们到达了目标层,此时就可以打印当前节点的值。

以下是递归函数的核心步骤:

  1. 处理空节点:如果当前节点为空(root == NULL),则直接返回,不做任何操作。
  2. 到达目标层:如果当前层数k == 1,说明我们已经到达了需要打印的层,此时打印当前节点的值。
  3. 递归遍历:如果还未到达目标层(k > 1),则分别对当前节点的左子节点和右子节点递归调用函数,并将目标层数k减1。

用伪代码描述这个逻辑:

void printKLevel(Node* root, int k) {
    if (root == NULL) {
        return; // 基础情况:空节点
    }
    if (k == 1) {
        cout << root->data << " "; // 到达第K层,打印节点值
        return;
    }
    // 递归遍历左子树和右子树,层数k减1
    printKLevel(root->left, k - 1);
    printKLevel(root->right, k - 1);
}

代码实现与示例

理解了递归逻辑后,让我们将其转化为完整的C++代码并运行一个示例。

假设我们有如下二叉树:

        1
       / \
      2   3
     / \ / \
    4  5 6  7

k=3时,第3层的节点是4, 5, 6, 7。

以下是完整的函数实现:

#include <iostream>
using namespace std;

// 二叉树节点结构
struct Node {
    int data;
    Node* left;
    Node* right;
    Node(int val) : data(val), left(nullptr), right(nullptr) {}
};

// 打印第K层节点的函数
void printKLevel(Node* root, int k) {
    if (root == NULL) {
        return;
    }
    if (k == 1) {
        cout << root->data << " ";
        return;
    }
    printKLevel(root->left, k - 1);
    printKLevel(root->right, k - 1);
}

// 主函数用于测试
int main() {
    // 构建示例二叉树
    Node* root = new Node(1);
    root->left = new Node(2);
    root->right = new Node(3);
    root->left->left = new Node(4);
    root->left->right = new Node(5);
    root->right->left = new Node(6);
    root->right->right = new Node(7);

    int k = 3;
    cout << "第 " << k << " 层的节点是: ";
    printKLevel(root, k);
    cout << endl;

    // 输出:第 3 层的节点是: 4 5 6 7
    return 0;
}

时间复杂度分析

该算法的时间复杂度是 O(n),其中n是二叉树中的节点总数。在最坏情况下(例如,当K大于树的高度时),我们需要遍历树中的每一个节点。

总结

本节课中我们一起学习了如何找出并打印二叉树中指定第K层的所有节点。我们使用了递归的方法,通过逐层递减目标层数K,并在K等于1时打印节点值来实现功能。这个方法逻辑清晰,代码简洁,是解决二叉树层相关问题的有效技巧。

089:二叉树的最低公共祖先

概述

在本节课中,我们将学习如何解决二叉树中的一个重要问题:寻找两个节点的最低公共祖先。我们将理解其核心概念,并通过递归方法实现一个高效的解决方案。


什么是LCA?🧐

最低公共祖先是指在一棵二叉树中,同时为两个指定节点 pq 的祖先,且深度尽可能大的那个节点。一个节点也可以是其自身的祖先。

例如,考虑以下二叉树:

        1
       / \
      2   3
     / \ / \
    4  5 6  7
       / \
      8   9
  • 节点 5 的祖先有:5, 2, 1。
  • 节点 9 的祖先有:9, 5, 2, 1。
  • 节点 59 的公共祖先是 5, 2, 1。其中最低(最深)的是 5,因此 LCA(5, 9) = 5。
  • 节点 67 的 LCA 是 3
  • 节点 63 的 LCA 是 3(节点自身可以是祖先)。

核心思路 💡

LCA 是第一个在其左子树右子树中分别能找到 pq 的节点。我们可以通过递归遍历来寻找这个“第一个”节点。

以下是解决问题的递归逻辑步骤:

  1. 基线条件:如果当前节点为空,或者等于 pq,则直接返回当前节点。
  2. 递归搜索:分别在左子树和右子树中递归寻找 LCA。
  3. 结果分析:根据左右子树的递归结果,有四种情况:
    • 左右结果都为空:返回空。
    • 左结果非空,右结果为空:返回左结果。
    • 左结果为空,右结果非空:返回右结果。
    • 左右结果都非空:这意味着当前节点就是 pq 的“分岔点”,即我们要找的 LCA,返回当前节点。

代码实现 🖥️

根据上述思路,我们可以编写出以下 C++ 函数。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        // 基线条件
        if (root == NULL || root == p || root == q) {
            return root;
        }
        
        // 递归搜索左右子树
        TreeNode* leftLCA = lowestCommonAncestor(root->left, p, q);
        TreeNode* rightLCA = lowestCommonAncestor(root->right, p, q);
        
        // 分析递归结果
        if (leftLCA != NULL && rightLCA != NULL) {
            // 情况4:当前节点是分岔点,即LCA
            return root;
        } else if (leftLCA != NULL) {
            // 情况2:LCA在左子树中
            return leftLCA;
        } else {
            // 情况3:LCA在右子树中 (或情况1,返回NULL)
            return rightLCA;
        }
    }
};

复杂度分析 📊

  • 时间复杂度O(n)。在最坏情况下,我们需要访问树中的每一个节点,其中 n 是节点总数。
  • 空间复杂度O(h)。空间消耗取决于递归栈的深度,即树的高度 h。在最坏情况(树退化为链表)下,空间复杂度为 O(n)

总结

本节课我们一起学习了如何寻找二叉树中两个节点的最低公共祖先。我们理解了 LCA 的定义,掌握了通过递归遍历分治思想来定位 LCA 的核心算法。该算法思路清晰,代码简洁,是解决此类问题的最优方法之一。

090:根据前序和中序遍历构建二叉树 🌳

在本节课中,我们将学习如何根据给定的前序遍历序列和中序遍历序列,唯一地重建一棵二叉树。这是二叉树相关的一个经典问题,也是理解递归和树遍历的关键。

概述

我们面临的问题是:给定一棵二叉树的前序遍历序列和中序遍历序列,要求重建出原始的二叉树结构。前序遍历的顺序是根节点 -> 左子树 -> 右子树,而中序遍历的顺序是左子树 -> 根节点 -> 右子树。通过这两个序列,我们可以唯一地确定二叉树的结构。

核心思路解析

上一节我们介绍了问题的定义,本节中我们来看看解决这个问题的核心逻辑。

前序遍历序列的第一个元素永远是当前子树的根节点。在中序遍历序列中找到这个根节点,其左侧的所有元素构成了左子树的中序遍历,右侧的所有元素构成了右子树的中序遍历。知道了左右子树的大小,我们就能在前序遍历序列中划分出对应的左右子树部分。然后,对左右子树递归地应用相同的过程,即可构建出整棵树。

以下是构建过程的关键步骤:

  1. 确定根节点:前序序列的第一个值 preorder[startPre] 就是当前子树的根节点值。
  2. 在中序序列中定位根节点:在中序序列 inorder 中找到该根节点的位置,记为 inRootIndex
  3. 计算左子树大小:左子树包含的节点数为 inRootIndex - inStart
  4. 递归构建左子树
    • 左子树的前序序列范围:从 preorder[startPre + 1] 开始,长度为左子树大小的部分。
    • 左子树的中序序列范围:inorder[inStart]inorder[inRootIndex - 1]
  5. 递归构建右子树
    • 右子树的前序序列范围:紧接着左子树之后的部分。
    • 右子树的中序序列范围:inorder[inRootIndex + 1]inorder[inEnd]

代码实现

理解了核心思路后,我们来看看具体的代码实现。我们将创建一个递归的辅助函数来完成构建。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
private:
    // 辅助函数:在中序序列的指定区间 [inStart, inEnd] 内查找值的索引
    int findIndex(vector<int>& inorder, int inStart, int inEnd, int value) {
        for (int i = inStart; i <= inEnd; ++i) {
            if (inorder[i] == value) {
                return i;
            }
        }
        return -1; // 理论上根据输入有效性不会到达这里
    }

    // 主要的递归构建函数
    // preorder: 前序序列
    // preIndex: 当前要处理的前序序列元素的索引(通过引用传递,以便在递归中更新)
    // inorder: 中序序列
    // inStart, inEnd: 当前子树在中序序列中的区间
    TreeNode* buildTreeHelper(vector<int>& preorder, int& preIndex,
                              vector<int>& inorder, int inStart, int inEnd) {
        // 基准情况:如果区间无效,则返回空指针
        if (inStart > inEnd) {
            return nullptr;
        }

        // 步骤1:前序序列的当前元素是根节点
        int rootValue = preorder[preIndex];
        TreeNode* root = new TreeNode(rootValue);
        preIndex++; // 移动到下一个待处理的元素

        // 步骤2:在中序序列中找到根节点的位置
        int inRootIndex = findIndex(inorder, inStart, inEnd, rootValue);

        // 步骤3 & 4:递归构建左子树
        // 左子树的中序区间是 [inStart, inRootIndex - 1]
        root->left = buildTreeHelper(preorder, preIndex, inorder, inStart, inRootIndex - 1);

        // 步骤5:递归构建右子树
        // 右子树的中序区间是 [inRootIndex + 1, inEnd]
        root->right = buildTreeHelper(preorder, preIndex, inorder, inRootIndex + 1, inEnd);

        return root;
    }

public:
    TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
        int preIndex = 0; // 从前序序列的第一个元素开始
        int n = inorder.size();
        return buildTreeHelper(preorder, preIndex, inorder, 0, n - 1);
    }
};

执行流程示例

让我们通过一个简单的例子来跟踪代码的执行。假设:

  • 前序遍历 preorder = [3, 9, 20, 15, 7]
  • 中序遍历 inorder = [9, 3, 15, 20, 7]

以下是构建过程的简要推演:

  1. 初始调用 buildTreepreIndex = 0,中序区间为 [0, 4]
  2. 根节点值为 3 (preorder[0])。在中序序列中找到 3 的索引为 1
  3. 递归构建左子树:
    • 中序区间为 [0, 0] (即元素 9)。
    • 创建节点 9,其左右子树区间无效,返回 nullptr。左子树构建完成。
  4. 递归构建右子树:
    • 中序区间为 [2, 4] (即 [15, 20, 7])。
    • 此时 preIndex 已更新为 2,根节点值为 20
    • 在中序区间 [2,4] 中找到 20 的索引为 2
    • 递归构建 20 的左子树(中序区间 [2,1] 无效,返回 nullptr)和右子树(中序区间 [3,4],即 [7])。
    • 构建 7 的节点,其左右子树均为 nullptr
  5. 最终,我们得到了正确的二叉树结构。

总结

本节课中我们一起学习了如何根据前序遍历和中序遍历序列构建二叉树。我们掌握了利用前序序列确定根节点,利用中序序列划分左右子树的核心思想,并通过递归函数实现了这一算法。理解这个过程对于深入掌握二叉树的结构和遍历方式至关重要。

091:转换为和树

概述

在本节课中,我们将学习如何将一个普通的二叉树转换为“和树”。这是一个基础且重要的问题,有助于理解递归在树结构中的应用。我们将通过一个简单的例子,逐步讲解转换的逻辑和实现方法。

什么是和树?

和树是一种特殊的二叉树。在转换后的和树中,每个节点的值被替换为原节点值加上其左子树所有节点值的总和,再加上其右子树所有节点值的总和。

公式表示
新节点值 = 原节点值 + 左子树节点值总和 + 右子树节点值总和

问题解析

假设我们有一个二叉树。我们的目标是将其转换为和树。例如,考虑根节点值为1,其左子节点值为2,右子节点值为3。转换后,根节点的新值将是 1 + 2 + 3 = 6。这个过程需要递归地应用到每一个节点上。

上一节我们介绍了和树的概念,本节中我们来看看具体的转换算法。

递归转换算法

核心思想是使用递归。对于每个节点,我们先递归地转换其左子树和右子树,然后计算当前节点的新值。

以下是算法的伪代码步骤:

  1. 如果当前节点为空,返回0。
  2. 递归调用函数,转换左子树,并得到左子树节点值的总和。
  3. 递归调用函数,转换右子树,并得到右子树节点值的总和。
  4. 将当前节点的值更新为:原节点值 + 左子树总和 + 右子树总和
  5. 返回更新后的当前节点值(作为其父节点计算子树总和的一部分)。

代码实现

让我们将上述逻辑转化为实际的C++代码。我们将定义一个名为 sumTree 的函数。

int sumTree(Node* root) {
    // 基础情况:如果节点为空,返回0
    if (root == NULL) {
        return 0;
    }

    // 递归转换左子树并获取其节点值总和
    int leftSum = sumTree(root->left);
    // 递归转换右子树并获取其节点值总和
    int rightSum = sumTree(root->right);

    // 保存当前节点的原始值
    int oldValue = root->data;
    // 更新当前节点的值为新值
    root->data = oldValue + leftSum + rightSum;

    // 返回当前节点的新值,供其父节点使用
    return root->data;
}

复杂度分析

该算法会访问树中的每个节点恰好一次。因此,其时间复杂度是线性的,即 O(n),其中 n 是树中节点的数量。

示例与测试

为了验证我们的函数,我们可以在转换前后分别打印树的前序遍历序列来观察变化。

// 假设有一个打印前序遍历的函数 printPreorder
cout << "转换前的树(前序):";
printPreorder(root);
cout << endl;

// 执行转换
sumTree(root);

cout << "转换后的树(前序):";
printPreorder(root);
cout << endl;

运行代码后,如果转换前的序列是 1, 2, 3, 4, 5,转换后的序列可能会变为 15, 12, 5, 12, 4, 5,这展示了每个节点值已被其新值替换。

总结

本节课中我们一起学习了如何将二叉树转换为和树。我们理解了和树的定义,掌握了使用递归算法进行转换的核心步骤,并实现了相应的C++代码。这是一个很好的递归练习,有助于加深对树形数据结构和递归思想的理解。

092:二叉树路径问题

在本节课中,我们将学习如何解决一个关于二叉树的简单问题:找出从根节点到所有叶子节点的路径。这是一个来自 LeetCode 的简单级别问题(第 257 题),我们将用 C++ 实现解决方案。

问题概述

给定一个二叉树的根节点 root,我们需要返回所有从根节点到叶子节点的路径。每条路径需要以字符串的形式表示,节点值之间用 "->" 连接。

例如,对于以下二叉树:

    1
   / \
  2   3
   \
    4

应返回的路径字符串为:

["1->2->4", "1->3"]

结果将以字符串向量的形式返回。

核心思路

解决这个问题的核心是使用深度优先搜索(DFS) 进行递归遍历。当我们到达一个叶子节点时,就将当前累积的路径字符串保存下来。

上一节我们介绍了问题的基本要求,本节中我们来看看具体的解决步骤。

以下是解决问题的关键步骤:

  1. 从根节点开始递归遍历。
  2. 在递归过程中,记录从根节点到当前节点的路径字符串。
  3. 当遇到一个叶子节点(即左右子节点都为空)时,将当前路径字符串添加到答案列表中。
  4. 如果当前节点不是叶子节点,则分别递归遍历其左子树和右子树,并在进入子树前更新路径字符串。

代码实现

现在,让我们将上述思路转化为具体的 C++ 代码。我们将实现一个辅助函数来完成递归遍历。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<string> binaryTreePaths(TreeNode* root) {
        vector<string> answer; // 存储所有路径的容器
        if (root == nullptr) return answer; // 如果树为空,直接返回空容器
        
        string initialPath = to_string(root->val); // 初始化路径,从根节点值开始
        helper(root, initialPath, answer); // 调用辅助递归函数
        return answer;
    }
    
private:
    void helper(TreeNode* node, string currentPath, vector<string>& result) {
        // 递归终止条件:到达叶子节点
        if (node->left == nullptr && node->right == nullptr) {
            result.push_back(currentPath); // 将当前路径加入结果
            return;
        }
        
        // 如果存在左子节点,递归遍历左子树
        if (node->left != nullptr) {
            string newPath = currentPath + "->" + to_string(node->left->val);
            helper(node->left, newPath, result);
        }
        
        // 如果存在右子节点,递归遍历右子树
        if (node->right != nullptr) {
            string newPath = currentPath + "->" + to_string(node->right->val);
            helper(node->right, newPath, result);
        }
    }
};

算法分析

让我们分析一下上面解决方案的时间和空间复杂度。

  • 时间复杂度O(N),其中 N 是树中的节点数。每个节点我们只访问一次。
  • 空间复杂度O(H),其中 H 是树的高度。这主要是递归调用栈所占用的空间。在最坏情况下(树退化为链表),空间复杂度为 O(N)。

总结

本节课中我们一起学习了如何解决“二叉树的所有路径”问题。我们使用了深度优先搜索(DFS)的递归方法,从根节点遍历到每个叶子节点,并在过程中构建路径字符串。关键在于正确处理递归的终止条件(到达叶子节点)和路径的拼接。这是一个理解树遍历和递归思想的经典入门问题。

093:二叉树的最大宽度

概述

在本节课中,我们将学习如何计算二叉树的最大宽度。这是一个重要的数据结构问题,也是LeetCode上的第662号问题。我们将通过层序遍历和节点索引的概念来解决它。

什么是最大宽度?

最大宽度是指二叉树中任意一层的两个端点节点之间的最长距离。这里的“端点节点”是指该层最左边和最右边的非空节点,计算宽度时需要包含这两个节点本身。

核心概念与公式

为了计算宽度,我们需要为每个节点分配一个索引,类似于在完全二叉树中的索引方式。对于一个索引为 i 的节点:

  • 其左子节点的索引为:2 * i + 1
  • 其右子节点的索引为:2 * i + 2

某一层的宽度计算公式为:
width = (该层最右节点索引 - 该层最左节点索引) + 1

算法步骤

我们将使用层序遍历(广度优先搜索)来逐层处理节点。以下是算法的详细步骤:

  1. 初始化一个队列,用于存储节点及其索引的配对。将根节点及其索引(通常设为0)入队。
  2. 初始化一个变量 maxWidth 用于记录最大宽度,初始值为0。
  3. 当队列不为空时,进行循环,每次循环处理一层:
    a. 获取当前层的节点数量 levelSize
    b. 获取当前层队首节点的索引作为 startIndex(该层最左节点索引)。
    c. 获取当前层队尾节点的索引作为 endIndex(该层最右节点索引)。
    d. 根据公式计算当前层宽度:currentWidth = endIndex - startIndex + 1
    e. 更新 maxWidth = max(maxWidth, currentWidth)
    f. 遍历当前层的所有节点:
    * 弹出队首节点。
    * 如果该节点有左子节点,则将左子节点及其索引 2 * i + 1 入队。
    * 如果该节点有右子节点,则将右子节点及其索引 2 * i + 2 入队。
  4. 循环结束后,返回 maxWidth

代码实现

以下是基于上述逻辑的C++代码实现。注意,为了避免索引值过大导致整数溢出,我们使用 unsigned long long 类型来存储索引。

#include <queue>
using namespace std;

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

int widthOfBinaryTree(TreeNode* root) {
    if (root == nullptr) return 0;
    
    // 队列存储节点及其索引的配对
    queue<pair<TreeNode*, unsigned long long>> q;
    q.push({root, 0});
    
    unsigned long long maxWidth = 0;
    
    while (!q.empty()) {
        unsigned long long levelSize = q.size();
        unsigned long long startIndex = q.front().second; // 当前层最左节点索引
        unsigned long long endIndex = q.back().second;   // 当前层最右节点索引
        
        // 计算并更新最大宽度
        unsigned long long currentWidth = endIndex - startIndex + 1;
        if (currentWidth > maxWidth) {
            maxWidth = currentWidth;
        }
        
        // 处理当前层的所有节点
        for (unsigned long long i = 0; i < levelSize; ++i) {
            auto [node, index] = q.front();
            q.pop();
            
            // 将左子节点入队
            if (node->left != nullptr) {
                q.push({node->left, 2 * index + 1});
            }
            // 将右子节点入队
            if (node->right != nullptr) {
                q.push({node->right, 2 * index + 2});
            }
        }
    }
    
    return (int)maxWidth; // 根据题目要求返回int类型
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是树中的节点数。我们需要访问每个节点一次。
  • 空间复杂度:O(W),其中 W 是树的最大宽度。在最坏情况下(完全二叉树),队列中最多会存储一层的所有节点。

总结

本节课我们一起学习了如何计算二叉树的最大宽度。我们首先理解了宽度的定义,然后引入了完全二叉树索引的概念来辅助计算。核心算法是层序遍历,在遍历每一层时,通过记录该层首尾节点的索引来计算宽度。最后,我们实现了C++代码并分析了其复杂度。掌握这个方法,你就能高效解决此类二叉树宽度相关的问题。

094:莫里斯中序遍历

在本节课中,我们将学习一种名为“莫里斯中序遍历”的算法。这是一种用于二叉树遍历的巧妙方法,它可以在不使用递归或显式栈的情况下,实现中序遍历。我们将重点理解其核心概念和工作原理。

概述

莫里斯中序遍历算法通过临时修改二叉树的结构(创建临时线索)来实现遍历,遍历完成后会恢复原状。其核心思想是利用“中序前驱节点”来建立从叶子节点返回父节点的路径。

核心概念:中序前驱节点

在深入算法之前,我们需要理解“中序前驱节点”这个概念。

对于一个给定的节点(例如根节点),它的中序前驱节点定义为:在中序遍历序列中,排在该节点之前的那个节点。

具体寻找方法是:找到当前节点左子树中的最右侧节点

例如,考虑以下树结构:

    1
   / \
  2   3
 / \
4   5

对于根节点 1,它的中序前驱节点是节点 5(节点 2 的右子树中的最右节点)。对于节点 2,它的中序前驱节点是节点 4

算法步骤解析

上一节我们介绍了中序前驱节点的概念,本节中我们来看看莫里斯遍历如何利用它。算法的核心是在遍历过程中,动态地创建和删除临时线索(将中序前驱节点的右孩子指向当前节点),以模拟递归栈的回溯功能。

以下是算法的具体步骤,我们结合伪代码进行说明:

  1. 初始化:将 current 指针指向根节点。
  2. 循环条件:当 current 不为 null 时,执行循环。
  3. 处理左子树为空的情况
    • 如果 current 的左孩子为空,则访问当前节点(将其值加入结果)。
    • 然后将 current 移动到其右孩子 (current = current->right)。
  4. 处理左子树不为空的情况
    • 找到 current 节点的中序前驱节点 (predecessor)。
      • 方法:从 current 的左孩子开始,一直向右走,直到某个节点的右孩子为 null 指向 current 本身。
    • 创建临时线索:如果前驱节点的右孩子为 null,则将其右孩子指向 current。这创建了一条返回 current 的路径。然后将 current 移动到其左孩子 (current = current->left)。
    • 删除临时线索并访问节点:如果前驱节点的右孩子已经指向 current,说明左子树已被访问完毕。此时,将前驱节点的右孩子重新置为 null(恢复树结构),访问当前节点,然后将 current 移动到其右孩子 (current = current->right)。

代码实现

让我们将上述逻辑转化为 C++ 代码。以下函数实现了莫里斯中序遍历,并返回一个包含中序序列的向量。

vector<int> inorderTraversal(TreeNode* root) {
    vector<int> answer; // 存储遍历结果
    TreeNode* current = root; // 当前节点指针

    while (current != nullptr) {
        // 情况1:当前节点的左子树为空
        if (current->left == nullptr) {
            answer.push_back(current->val); // 访问节点
            current = current->right; // 转向右子树
        } else {
            // 情况2:当前节点的左子树不为空,寻找中序前驱节点
            TreeNode* predecessor = current->left;
            // 关键条件:predecessor->right != null 且 predecessor->right != current
            while (predecessor->right != nullptr && predecessor->right != current) {
                predecessor = predecessor->right;
            }

            // 子情况A:前驱节点的右孩子为空,创建临时线索
            if (predecessor->right == nullptr) {
                predecessor->right = current; // 创建线索
                current = current->left; // 转向左子树
            } 
            // 子情况B:前驱节点的右孩子指向当前节点,说明左子树已遍历完
            else {
                predecessor->right = nullptr; // 删除线索,恢复树结构
                answer.push_back(current->val); // 访问当前节点
                current = current->right; // 转向右子树
            }
        }
    }
    return answer;
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是树中的节点数。每个节点最多被访问两次(一次用于创建线索,一次用于遍历访问),因此是线性时间复杂度。
  • 空间复杂度:O(1)。该算法只使用了固定的几个指针变量,没有使用递归栈或额外的数据结构,因此是常数空间复杂度。这是莫里斯遍历相比递归和迭代栈方法的主要优势。

总结

本节课中我们一起学习了莫里斯中序遍历算法。我们首先理解了“中序前驱节点”这一关键概念,然后逐步剖析了算法如何通过创建和删除临时线索来模拟递归过程,最终实现了无需额外栈空间的中序遍历。该算法在时间复杂度上与递归法相同,但将空间复杂度优化到了 O(1),在处理大规模二叉树或空间受限的环境下非常有用。

095:将二叉树展开为链表

在本节课中,我们将学习如何将一个二叉树“展开”为一个链表。展开后的链表遵循二叉树的前序遍历顺序,并且所有节点都通过其 right 指针连接,left 指针则全部设置为 null

问题描述

给定一个二叉树的根节点 root,你需要将其展开为一个“链表”:

  1. 展开后的“链表”应使用与 TreeNode 相同的 TreeNode 类,其中 right 指针指向链表中的下一个节点,left 指针始终为 null
  2. 链表的节点顺序应与二叉树的前序遍历顺序相同。

示例:
给定二叉树:

    1
   / \
  2   5
 / \   \
3   4   6

展开为链表:

1 -> 2 -> 3 -> 4 -> 5 -> 6 -> null

解决方案思路

我们将采用一种反向后序遍历的递归方法。核心思想是:从最后一个节点开始处理,并维护一个指向“当前已处理链表”最后一个节点的指针 next_right(或称 last_visited)。

以下是算法的核心步骤:

  1. 递归处理右子树。
  2. 递归处理左子树。
  3. 处理当前根节点:
    • 将当前节点的 left 指针设为 null
    • 将当前节点的 right 指针指向 next_right(即上一个处理好的节点)。
    • 更新 next_right 指针为当前节点。

这样,当我们从递归调用返回时,链表就已经从后向前构建好了。

代码实现

以下是该算法的 C++ 实现:

class Solution {
private:
    TreeNode* next_right = nullptr; // 指向已构建链表末尾的指针
public:
    void flatten(TreeNode* root) {
        if (root == nullptr) {
            return;
        }
        
        // 1. 递归处理右子树
        flatten(root->right);
        // 2. 递归处理左子树
        flatten(root->left);
        
        // 3. 处理当前节点
        root->left = nullptr;          // 左指针置空
        root->right = next_right;      // 右指针指向上一个处理好的节点
        next_right = root;             // 更新链表末尾指针为当前节点
    }
};

算法流程详解

让我们通过一个简单的例子来逐步理解算法。考虑二叉树:

    1
   / \
  2   3

执行步骤:

  1. 调用 flatten(1)
  2. flatten(1) 中,先递归调用 flatten(3)
  3. flatten(3) 中,其右、左子树均为空,直接处理节点3:3->left = null3->right = next_right(初始为 null),然后 next_right = 3
  4. 回到 flatten(1),接着递归调用 flatten(2)
  5. flatten(2) 中,其右、左子树均为空,处理节点2:2->left = null2->right = next_right(此时 next_right3),然后 next_right = 2
  6. 回到 flatten(1),处理节点1:1->left = null1->right = next_right(此时 next_right2),然后 next_right = 1

最终链表为:1 -> 2 -> 3 -> null,符合前序遍历顺序。

复杂度分析

  • 时间复杂度:O(n),其中 n 是树中的节点数。每个节点恰好被访问一次。
  • 空间复杂度:O(h),其中 h 是树的高度。这是由于递归调用栈的深度。在最坏情况(树退化为链表)下,空间复杂度为 O(n)

总结

本节课我们一起学习了如何将二叉树展开为链表。我们掌握了一种巧妙的反向后序遍历递归方法,通过维护一个 next_right 指针来从后向前构建链表。这种方法代码简洁,但思路需要仔细理解。关键记住处理顺序:先右、后左、再根,并在处理根节点时完成指针的重定向。

096:二叉搜索树 (BST)

在本节课中,我们将学习一种重要的数据结构——二叉搜索树。我们将了解它的定义、核心属性,并学习如何实现插入、搜索和删除节点这三种基本操作。

概述

二叉搜索树是一种特殊的二叉树。它的核心特性是:对于树中的任意节点,其左子树中所有节点的值都小于该节点的值,其右子树中所有节点的值都大于该节点的值。这种结构使得数据的查找、插入和删除操作非常高效。

什么是二叉搜索树?

上一节我们介绍了数据结构的基本概念,本节中我们来看看二叉搜索树的定义。

二叉搜索树是一种节点值遵循特定排序规则的二叉树。具体规则如下:

  • 每个节点包含一个值。
  • 任意节点的左子树只包含值小于该节点的节点。
  • 任意节点的右子树只包含值大于该节点的节点。
  • 通常,树中所有节点的值都是唯一的。

例如,如果根节点是 5,那么所有小于 5 的值(如 1, 3, 4)都在左子树,所有大于 5 的值(如 6, 8, 9)都在右子树。

BST的节点结构

二叉搜索树的节点结构与普通二叉树相同。每个节点包含三部分:存储的数据、指向左子节点的指针和指向右子节点的指针。

以下是C++中节点的定义代码:

class Node {
public:
    int data;
    Node* left;
    Node* right;

    Node(int val) {
        data = val;
        left = NULL;
        right = NULL;
    }
};

在BST中搜索节点

搜索是BST的核心操作之一,它利用了BST的排序特性。搜索的逻辑类似于二分查找。

搜索算法的步骤如下:

  1. 从根节点开始。
  2. 如果当前节点为 NULL,说明值不存在,返回 false
  3. 如果目标值等于当前节点的值,搜索成功,返回 true
  4. 如果目标值小于当前节点的值,则递归地在左子树中搜索。
  5. 如果目标值大于当前节点的值,则递归地在右子树中搜索。

由于每次比较都能排除一半的子树,搜索的平均时间复杂度为 O(log n),其中 n 是树中节点的数量。在最坏情况下(树退化成链表),时间复杂度为 O(n)

以下是搜索函数的实现代码:

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

构建BST(插入节点)

构建BST的过程就是不断插入新节点的过程。插入操作也需要维持BST的属性。

插入一个新节点的逻辑如下:

  1. 如果树为空(root == NULL),则创建新节点作为根节点。
  2. 否则,将待插入的值与当前节点的值比较。
  3. 如果值小于当前节点值,递归地将其插入左子树。
  4. 如果值大于当前节点值,递归地将其插入右子树。
  5. 将更新后的子树指针连接回父节点。

以下是插入单个节点和构建整棵树的函数代码:

// 在给定子树中插入一个值,并返回新的根节点
Node* insert(Node* root, int val) {
    if (root == NULL) {
        return new Node(val);
    }
    if (val < root->data) {
        root->left = insert(root->left, val);
    } else { // 假设值不重复,val > root->data
        root->right = insert(root->right, val);
    }
    return root;
}

// 使用数组构建一棵BST
Node* buildBST(vector<int> values) {
    Node* root = NULL;
    for (int val : values) {
        root = insert(root, val);
    }
    return root;
}

BST的中序遍历

BST有一个非常重要的性质:对BST进行中序遍历,会得到一个升序排序的序列

这是因为中序遍历的顺序是“左-根-右”,而BST的性质保证了左子节点 < 根节点 < 右子节点。

以下是中序遍历的代码:

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

在BST中删除节点

删除节点是BST操作中最复杂的一部分,因为删除后必须保持BST的性质。根据要删除节点的子节点数量,分为三种情况。

情况1:删除叶子节点(无子节点)

这是最简单的情况。直接删除该节点,并将其父节点对应的指针设为 NULL

情况2:删除有一个子节点的节点

  • 用其唯一的子节点替代该节点的位置。
  • 将该子节点连接回原节点的父节点。

情况3:删除有两个子节点的节点

这是最复杂的情况,需要找到合适的节点来替代被删除的节点,以保持BST的性质。

  1. 找到该节点的中序后继节点(即其右子树中的最小节点)。
  2. 用这个后继节点的值替换要删除节点的值。
  3. 递归地删除右子树中的那个后继节点(此时它最多只有一个右子节点,属于情况1或2)。

中序后继节点的查找逻辑是:进入右子树,然后一直向左走,直到没有左子节点为止。

以下是删除节点的完整实现代码:

// 辅助函数:查找中序后继节点
Node* getInorderSuccessor(Node* root) {
    Node* curr = root;
    while (curr && curr->left != NULL) {
        curr = curr->left;
    }
    return curr;
}

// 删除值为key的节点,返回新的根节点
Node* deleteNode(Node* root, int key) {
    if (root == NULL) {
        return NULL;
    }

    // 查找要删除的节点
    if (key < root->data) {
        root->left = deleteNode(root->left, key);
    } else if (key > root->data) {
        root->right = deleteNode(root->right, key);
    } else {
        // 找到要删除的节点 root
        // 情况1 & 2:有一个子节点或无子节点
        if (root->left == NULL) {
            Node* temp = root->right;
            delete root;
            return temp;
        } else if (root->right == NULL) {
            Node* temp = root->left;
            delete root;
            return temp;
        }
        // 情况3:有两个子节点
        Node* successor = getInorderSuccessor(root->right);
        root->data = successor->data; // 用后继的值替换
        root->right = deleteNode(root->right, successor->data); // 删除后继节点
    }
    return root;
}

总结

本节课中我们一起学习了二叉搜索树这一核心数据结构。

  • 我们了解了BST的定义和排序特性。
  • 我们学习了如何在BST中搜索节点,其高效性源于类似二分查找的过程。
  • 我们掌握了构建BST的方法,即通过插入操作维持树的结构。
  • 我们认识到BST的中序遍历结果是一个有序序列。
  • 最后,我们深入探讨了最复杂的删除操作,并学会了如何处理具有零个、一个和两个子节点的不同情况。

理解并实现BST的这些基本操作,是学习更高级树形结构(如AVL树、红黑树)的重要基础。

097:有序数组转为平衡二叉搜索树

在本节课中,我们将要学习如何将一个有序数组转换为一棵平衡的二叉搜索树。这是一个在LeetCode上编号为108的简单级别问题。

概述

二叉搜索树是一种特殊的二叉树,它遵循一个关键属性:对于任意节点,其左子树中的所有节点值都小于该节点值,其右子树中的所有节点值都大于该节点值。这个属性使得对BST的中序遍历结果是一个有序序列。

平衡二叉搜索树是BST的一种,它要求树的左右子树的高度差尽可能小,这能保证树的操作效率。

问题理解

我们被给定一个按升序排序的整数数组。任务是将这个数组转换为一棵高度平衡的二叉搜索树。高度平衡二叉树是指一个二叉树每个节点的左右两个子树的高度差的绝对值不超过1。

由于输入数组已经排序,我们可以利用BST的属性来高效地构建树。核心思路是递归地将数组的中间元素作为树的根节点,这样可以自然地保证树的平衡性。

核心算法

上一节我们介绍了问题的基本概念,本节中我们来看看具体的构建算法。

算法的核心是分治策略。对于一个排序数组,其中间元素是构建平衡BST的最佳根节点选择。选择中间元素后,数组被分为左半部分和右半部分。左半部分用于递归构建左子树,右半部分用于递归构建右子树。

以下是构建平衡BST的递归步骤:

  1. 找到当前子数组的中间索引。计算公式为:mid = start + (end - start) / 2。使用这个公式可以避免整数溢出。
  2. 以中间索引处的元素值创建一个新的树节点,作为当前子树的根节点。
  3. 递归调用函数,使用左半部分子数组([start, mid-1])构建左子树,并将其连接到根节点的左孩子。
  4. 递归调用函数,使用右半部分子数组([mid+1, end])构建右子树,并将其连接到根节点的右孩子。
  5. 返回创建好的根节点。

递归的基本情况是当起始索引大于结束索引时,这意味着当前子数组为空,因此返回空指针(nullptr)。

代码实现

理解了算法步骤后,现在让我们将其转化为C++代码。我们将实现一个辅助函数来完成递归构建。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    TreeNode* sortedArrayToBST(vector<int>& nums) {
        // 调用辅助函数,初始范围为整个数组
        return helper(nums, 0, nums.size() - 1);
    }

private:
    TreeNode* helper(vector<int>& nums, int start, int end) {
        // 基本情况:子数组为空
        if (start > end) {
            return nullptr;
        }

        // 找到中间索引,避免溢出
        int mid = start + (end - start) / 2;

        // 创建根节点
        TreeNode* root = new TreeNode(nums[mid]);

        // 递归构建左子树和右子树
        root->left = helper(nums, start, mid - 1);
        root->right = helper(nums, mid + 1, end);

        // 返回构建好的子树根节点
        return root;
    }
};

复杂度分析

最后,我们来分析一下算法的时间复杂度和空间复杂度。

  • 时间复杂度:算法会访问数组中的每个元素恰好一次来创建节点。对于有 n 个元素的数组,时间复杂度为 O(n)
  • 空间复杂度:空间消耗主要来自递归调用栈。由于树是平衡的,递归深度为 O(log n)。此外,存储 n 个节点需要 O(n) 空间。因此,总的空间复杂度为 O(n)

总结

本节课中我们一起学习了如何将一个有序数组转换为平衡二叉搜索树。我们首先回顾了BST的定义和平衡树的概念。然后,我们深入探讨了利用数组有序特性、通过递归分治选择中间元素作为根节点来构建平衡BST的核心算法。接着,我们将思路转化为清晰的C++代码实现,并分析了算法的时间与空间复杂度。掌握这个方法,你就能高效解决这一类从有序序列构建平衡树的问题。

098:验证二叉搜索树

概述

在本节课中,我们将学习如何解决一个重要的数据结构问题:验证二叉搜索树。我们将理解二叉搜索树的定义,分析常见的错误判断,并学习一个使用递归和值范围检查的有效算法。


二叉搜索树定义

二叉搜索树是一种特殊的二叉树,其中每个节点都满足以下条件:

  • 该节点左子树中的所有节点值都小于该节点的值。
  • 该节点右子树中的所有节点值都大于该节点的值。
  • 左右子树也必须是二叉搜索树。

核心公式:对于树中的任意节点 node,需满足 左子树所有值 < node.val < 右子树所有值

常见误区与正确思路

一个常见的错误是只检查每个节点与其直接子节点的关系。然而,正确的验证需要确保整个子树都满足条件。

例如,考虑以下结构:

    5
   / \
  4   6
     / \
    3   7

节点5的直接子节点(4和6)满足条件,但节点6的左子节点3小于根节点5,违反了BST的定义。因此,这棵树不是有效的BST。

上一节我们介绍了BST的基本定义和常见误区,本节中我们来看看如何系统地解决这个问题。

基于值范围的验证算法

解决此问题的关键在于为树中的每个节点定义一个允许的值范围(最小值和最大值)。在遍历树时,我们检查每个节点的值是否在其允许的范围内。

算法思路

  1. 从根节点开始,其允许范围是 (-∞, +∞)
  2. 检查当前节点值是否在 (min, max) 范围内。如果不在,则树无效。
  3. 递归检查左子树:其允许范围更新为 (min, 当前节点值)
  4. 递归检查右子树:其允许范围更新为 (当前节点值, max)
  5. 如果所有节点都满足其范围,则树是有效的BST。

以下是该算法的伪代码描述:

函数 isValidBST(root):
    返回 helper(root, null, null) // 初始范围无限制

函数 helper(node, min, max):
    如果 node 为空:
        返回 true // 空树是有效的BST
    如果 (min 不为空 且 node.val <= min) 或 (max 不为空 且 node.val >= max):
        返回 false // 节点值超出允许范围
    返回 helper(node.left, min, node.val) 与 helper(node.right, node.val, max)

C++代码实现

现在,让我们将伪代码转换为实际的C++代码。

核心代码

class Solution {
public:
    bool isValidBST(TreeNode* root) {
        return helper(root, nullptr, nullptr);
    }

    bool helper(TreeNode* root, TreeNode* minNode, TreeNode* maxNode) {
        if (root == nullptr) {
            return true;
        }
        // 检查当前节点值是否在(min, max)范围内
        if ((minNode != nullptr && root->val <= minNode->val) ||
            (maxNode != nullptr && root->val >= maxNode->val)) {
            return false;
        }
        // 递归检查左右子树,并更新范围
        return helper(root->left, minNode, root) &&
               helper(root->right, root, maxNode);
    }
};

代码说明

  • 我们使用 TreeNode* 类型的 minNodemaxNode 来传递范围边界。使用 nullptr 表示无穷大或无穷小。
  • 递归基例:如果节点为空,返回 true
  • 范围检查:如果节点值小于等于最小值或大于等于最大值,则无效。
  • 递归调用:检查左子树时,最大值更新为当前节点;检查右子树时,最小值更新为当前节点。

复杂度分析

  • 时间复杂度:O(N),其中 N 是树中的节点数。我们恰好访问每个节点一次。
  • 空间复杂度:O(H),其中 H 是树的高度。这是递归调用栈的空间消耗。在最坏情况(树退化为链表)下,复杂度为 O(N)。

总结

本节课中我们一起学习了如何验证一棵二叉树是否为二叉搜索树。我们首先明确了BST的定义,然后指出了仅检查直接父子节点的常见错误。接着,我们引入了基于值范围检查的递归算法,该算法通过为每个节点维护一个 (min, max) 区间来确保全局有序性。最后,我们实现了该算法的C++代码,并分析了其时间和空间复杂度。这是一个重要的面试问题,掌握其核心逻辑对理解树形数据结构至关重要。

099:二叉搜索树节点间的最小距离

在本节课中,我们将学习如何解决一个重要的二叉搜索树问题:计算树中任意两个节点值之间的最小差值。这是一个常见的面试问题,理解其解法有助于巩固对二叉搜索树和中序遍历的理解。

概述

问题要求我们找出给定二叉搜索树中任意两个不同节点值之间的最小绝对差值。由于二叉搜索树具有有序的特性,我们可以利用中序遍历来高效地解决这个问题。

问题分析与核心思路

二叉搜索树有一个关键性质:中序遍历BST会得到一个升序序列。例如,对于节点值序列 [42, 52, 62, 82, 83, 88],任意两个相邻数字的差值可能是最小的。因此,问题转化为:在一个已排序的数组中,找出相邻元素的最小差值。

这是一个线性复杂度问题。我们只需要遍历排序后的数组,计算当前值与前一个值的差值,并记录遇到的最小差值即可。

核心公式
当前差值 = abs(当前节点值 - 前一个节点值)
我们需要找到所有此类差值中的最小值。

那么,如何获得BST的排序序列呢?答案是进行中序遍历。在中序遍历过程中,我们访问节点的顺序本身就是排序好的。

算法步骤详解

上一节我们分析了问题的核心是获取排序序列。本节中我们来看看具体的实现步骤。关键在于在中序遍历的同时,记录上一个被访问的节点值,并计算差值。

以下是实现算法的详细步骤:

  1. 初始化:创建一个全局或类成员指针 previous,用于指向上一个被访问的节点,初始化为 nullptr。同时初始化一个变量 minDiff 来记录最小差值,初始值可以设为 INT_MAX
  2. 中序遍历
    • 递归遍历左子树。
    • 处理当前节点:如果 previous 指针不为空,计算当前节点值与 previous 节点值的绝对差值,并更新 minDiff
    • previous 指针更新为当前节点。
    • 递归遍历右子树。
  3. 返回结果:遍历结束后,minDiff 中存储的就是最小距离。

伪代码与C++实现

让我们将上述逻辑转化为伪代码,并最终写成C++代码。

以下是算法的伪代码描述:

// 全局或类成员变量
TreeNode* previous = nullptr;
int minDiff = INT_MAX;

void inOrderTraversal(TreeNode* root) {
    if (root == nullptr) return;

    // 1. 遍历左子树
    inOrderTraversal(root->left);

    // 2. 处理当前节点
    if (previous != nullptr) {
        minDiff = min(minDiff, root->val - previous->val); // BST中序遍历保证root->val >= previous->val
    }
    previous = root; // 更新previous指针

    // 3. 遍历右子树
    inOrderTraversal(root->right);
}

// 主函数
int getMinimumDifference(TreeNode* root) {
    inOrderTraversal(root);
    return minDiff;
}

现在,我们将其转换为可以提交的C++代码。注意处理边界情况,例如树为空或只有一个节点。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
private:
    TreeNode* prev; // 记录中序遍历的前一个节点
    int minDiff;    // 记录最小差值

    void inOrder(TreeNode* node) {
        if (node == nullptr) return;

        // 遍历左子树
        inOrder(node->left);

        // 处理当前节点
        if (prev != nullptr) {
            minDiff = min(minDiff, node->val - prev->val); // 由于中序遍历有序,node->val 一定大于 prev->val
        }
        prev = node; // 更新前驱节点

        // 遍历右子树
        inOrder(node->right);
    }

public:
    int getMinimumDifference(TreeNode* root) {
        prev = nullptr;
        minDiff = INT_MAX;
        inOrder(root);
        return minDiff;
    }
};

复杂度分析

  • 时间复杂度:O(N),其中 N 是树中的节点数。我们只对树进行了一次中序遍历,每个节点访问一次。
  • 空间复杂度:O(H),其中 H 是树的高度。空间消耗主要来自递归调用栈。在最坏情况(树退化为链表)下,空间复杂度为 O(N)。

总结

本节课中我们一起学习了如何解决“二叉搜索树节点间的最小距离”问题。我们利用二叉搜索树中序遍历有序的特性,通过一次遍历,在访问每个节点时计算其与前驱节点的差值,从而在线性时间内找到了全局最小差值。这个方法高效且优雅,是处理BST相关问题的典型技巧。

100:二叉搜索树中第K小的元素 🎯

在本节课中,我们将学习如何在一个二叉搜索树(BST)中找到第K小的元素。这是一个常见的数据结构问题,我们将通过中序遍历的策略来解决它。

概述

给定一个二叉搜索树的根节点和一个整数K,我们的目标是找到树中第K小的元素值。二叉搜索树的性质是,对其中序遍历(左-根-右)会得到一个升序排列的节点值序列。因此,第K小的元素就是中序遍历序列中的第K个节点。

解决方案思路

上一节我们介绍了二叉搜索树的性质,本节中我们来看看如何利用中序遍历来定位第K小的元素。

核心思路是执行中序遍历,并在遍历过程中计数。当我们访问到第K个节点时,返回该节点的值。

以下是实现此逻辑的关键步骤:

  1. 定义一个全局或引用传递的计数器,用于记录当前访问的节点是第几个节点。
  2. 递归地进行中序遍历:
    • 首先递归遍历左子树。
    • 然后访问当前根节点。计数器加1,并检查计数器是否等于K。如果等于,则当前节点值就是答案。
    • 最后递归遍历右子树。
  3. 如果遍历完整棵树仍未找到第K个节点,则返回一个表示未找到的值(例如-1)。

伪代码与实现

以下是上述思路的伪代码描述:

// 全局变量或通过引用传递,用于记录中序遍历的次序
int count = 0;

int kthSmallest(TreeNode* root, int k) {
    // 基准情况:如果节点为空,返回-1表示未找到
    if (root == nullptr) {
        return -1;
    }

    // 1. 遍历左子树
    int leftResult = kthSmallest(root->left, k);
    // 如果在左子树中找到了答案,直接返回
    if (leftResult != -1) {
        return leftResult;
    }

    // 2. 访问根节点
    count++; // 计数器增加
    if (count == k) {
        return root->val; // 找到第K小的元素
    }

    // 3. 遍历右子树
    int rightResult = kthSmallest(root->right, k);
    // 如果在右子树中找到了答案,直接返回
    if (rightResult != -1) {
        return rightResult;
    }

    // 如果左右子树和当前节点都没有找到,返回-1
    return -1;
}

代码解析与优化

让我们将伪代码转换为实际的C++代码。为了不使用全局变量,我们可以通过引用传递计数器。

class Solution {
public:
    int kthSmallest(TreeNode* root, int k) {
        int count = 0;
        int result = -1;
        inorder(root, k, count, result);
        return result;
    }

private:
    void inorder(TreeNode* node, int k, int& count, int& result) {
        if (node == nullptr || result != -1) {
            return; // 节点为空或已找到结果,则返回
        }

        // 遍历左子树
        inorder(node->left, k, count, result);

        // 访问当前节点
        count++;
        if (count == k) {
            result = node->val; // 找到第K小的元素
            return;
        }

        // 遍历右子树
        inorder(node->right, k, count, result);
    }
};

代码说明:

  • 我们定义了一个辅助函数 inorder 来执行中序遍历。
  • 参数 countresult 通过引用传递,以便在递归调用中共享和修改它们的值。
  • 一旦 result 被赋值(即找到了答案),递归函数会提前返回,避免不必要的遍历。
  • 时间复杂度为 O(N),在最坏情况下需要遍历树中的所有节点(当K等于节点总数N时)。空间复杂度为 O(H),其中H是树的高度,由递归调用栈的深度决定。

总结

本节课中我们一起学习了如何在二叉搜索树中寻找第K小的元素。我们利用了BST中序遍历得到有序序列的特性,通过一个计数器在遍历过程中定位目标节点。这种方法直观且高效,是解决此类问题的标准思路。理解并掌握这个算法,将帮助你更好地处理与二叉搜索树相关的查询问题。

101:二叉搜索树中的最低公共祖先

在本节课中,我们将学习如何解决一个重要的数据结构与算法问题:在二叉搜索树中寻找两个节点的最低公共祖先。这是LeetCode上的第235题。我们将利用二叉搜索树的特性,以一种高效的方法来解决这个问题。

概述

最低公共祖先是指在一个树形结构中,两个指定节点共有的、深度最大的祖先节点。在二叉搜索树中,我们可以利用节点值的大小关系来简化寻找过程,无需遍历所有祖先节点。

问题定义

给定一个二叉搜索树的根节点 root,以及两个节点 pq。需要找到 pq 的最低公共祖先节点。

例如,在下图中,节点 2 和节点 8 的最低公共祖先是节点 6

核心思路

寻找最低公共祖先的关键在于找到第一个节点,使得 pq 分别位于该节点的左右子树中。在二叉搜索树中,我们可以通过比较节点值来快速判断搜索方向。

以下是判断逻辑:

  1. 如果当前根节点的值同时大于 pq 的值,说明 pq 都在当前节点的左子树中。因此,最低公共祖先必然在左子树中,我们应递归地在左子树中搜索。
  2. 如果当前根节点的值同时小于 pq 的值,说明 pq 都在当前节点的右子树中。因此,最低公共祖先必然在右子树中,我们应递归地在右子树中搜索。
  3. 如果以上两种情况都不满足,则意味着当前节点恰好是第一个将 pq 分隔在左右两侧的节点,或者当前节点就是 pq 之一。此时,当前节点就是我们要找的最低公共祖先。

算法实现

根据上述思路,我们可以将其转化为代码。以下是该算法的C++实现。

TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
    // 基础情况:如果根节点为空,返回空指针
    if (root == nullptr) {
        return nullptr;
    }

    // 情况1:p和q的值都小于根节点值,LCA在左子树
    if (root->val > p->val && root->val > q->val) {
        return lowestCommonAncestor(root->left, p, q);
    }
    // 情况2:p和q的值都大于根节点值,LCA在右子树
    else if (root->val < p->val && root->val < q->val) {
        return lowestCommonAncestor(root->right, p, q);
    }
    // 情况3:当前根节点即为LCA
    else {
        return root;
    }
}

复杂度分析

  • 时间复杂度O(H),其中 H 是树的高度。在最坏情况下(树退化为链表),复杂度为 O(N)。在平衡的二叉搜索树中,复杂度为 O(log N)
  • 空间复杂度O(H),主要是递归调用栈的空间消耗。

总结

本节课我们一起学习了如何在二叉搜索树中寻找两个节点的最低公共祖先。我们利用了二叉搜索树节点值有序的特性,通过比较节点值与目标值的大小关系,将搜索范围快速缩小到左子树或右子树,从而高效地找到了答案。这是一个概念上非常重要且实用的算法问题。

102:根据前序遍历构建二叉搜索树

在本节课中,我们将学习如何根据给定的前序遍历序列,构建一个二叉搜索树。这是一个重要的数据结构问题,可以帮助我们理解二叉搜索树的特性和递归构建过程。


上一节我们介绍了二叉搜索树的基本概念,本节中我们来看看如何从前序遍历序列重建它。

问题描述

给定一个前序遍历序列,例如 [6, 3, 1, 4, 8, 9],我们需要构建出对应的二叉搜索树,并返回其根节点。

核心思路

前序遍历的第一个元素总是二叉搜索树的根节点。利用二叉搜索树的性质:左子树的所有节点值小于根节点值,右子树的所有节点值大于根节点值。我们可以通过递归,为每个节点确定一个“上界”值,来界定其子树节点的取值范围。

算法步骤

以下是构建二叉搜索树的具体步骤:

  1. 初始化一个索引 i,用于遍历前序序列。
  2. 定义一个递归辅助函数,接收当前“上界”值作为参数。
  3. 在递归函数中:
    • 如果索引越界或当前节点值超过上界,则返回空指针。
    • 否则,以当前节点值创建一个新节点作为根节点,并递增索引。
    • 递归构建左子树,上界值设为当前根节点的值。
    • 递归构建右子树,上界值保持不变(即传入的上界值)。
    • 返回构建好的根节点。

代码实现

以下是核心算法的C++代码实现:

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

// 二叉树节点定义
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

class Solution {
    // 递归辅助函数
    TreeNode* helper(vector<int>& preorder, int& i, int upper_bound) {
        // 基准情况:如果遍历完序列,或当前值超过上界
        if (i == preorder.size() || preorder[i] > upper_bound) {
            return nullptr;
        }
        
        // 创建当前节点(即根节点)
        int root_val = preorder[i];
        i++; // 移动到下一个元素
        TreeNode* root = new TreeNode(root_val);
        
        // 递归构建左子树,上界为当前根节点值
        root->left = helper(preorder, i, root_val);
        // 递归构建右子树,上界保持不变
        root->right = helper(preorder, i, upper_bound);
        
        return root;
    }
    
public:
    TreeNode* bstFromPreorder(vector<int>& preorder) {
        int i = 0; // 初始化索引
        // 初始上界设为整型最大值
        return helper(preorder, i, INT_MAX);
    }
};

复杂度分析

  • 时间复杂度O(n),其中 n 是节点数量。每个节点恰好被访问一次。
  • 空间复杂度O(h),其中 h 是树的高度。空间消耗主要来自递归调用栈。

本节课中我们一起学习了如何根据前序遍历序列构建二叉搜索树。我们利用了BST的性质和递归思想,通过为每个节点设定数值边界,高效地完成了树的构建。掌握这个方法对解决相关的树形结构问题非常有帮助。

103:合并两棵二叉搜索树 🌳

在本节课中,我们将学习如何解决一个经典的算法问题:合并两棵二叉搜索树。我们将通过获取两棵树的中序遍历序列,合并它们得到一个有序序列,然后利用这个有序序列重建一棵新的二叉搜索树。


问题概述

我们有两棵二叉搜索树,分别以 root1root2 作为根节点。我们的目标是将它们合并成一棵新的、更大的二叉搜索树。

核心思路:利用二叉搜索树的中序遍历是有序序列这一性质。


解决方案步骤

上一节我们介绍了问题的核心思路,本节中我们来看看具体的实现步骤。

以下是合并两棵二叉搜索树的主要步骤:

  1. 获取中序遍历序列:分别对两棵二叉搜索树进行中序遍历,得到两个有序的整数数组。
  2. 合并有序数组:将两个有序数组合并成一个更大的有序数组。
  3. 重建二叉搜索树:利用这个合并后的有序数组,构建一棵新的、平衡的二叉搜索树。

详细实现

步骤一:获取中序遍历序列

我们首先需要一个辅助函数来执行中序遍历,并将节点值存储在一个向量中。

以下是中序遍历函数的代码:

void inorderTraversal(TreeNode* root, vector<int>& result) {
    if (root == nullptr) {
        return;
    }
    inorderTraversal(root->left, result);
    result.push_back(root->val);
    inorderTraversal(root->right, result);
}

步骤二:合并两个有序数组

接下来,我们需要一个函数来合并两个有序向量。这类似于归并排序中的合并步骤。

以下是合并两个有序向量的代码:

vector<int> mergeSortedVectors(const vector<int>& vec1, const vector<int>& vec2) {
    vector<int> merged;
    int i = 0, j = 0;
    while (i < vec1.size() && j < vec2.size()) {
        if (vec1[i] < vec2[j]) {
            merged.push_back(vec1[i]);
            i++;
        } else {
            merged.push_back(vec2[j]);
            j++;
        }
    }
    // 添加剩余元素
    while (i < vec1.size()) {
        merged.push_back(vec1[i]);
        i++;
    }
    while (j < vec2.size()) {
        merged.push_back(vec2[j]);
        j++;
    }
    return merged;
}

步骤三:从有序数组构建二叉搜索树

最后,我们需要一个函数,根据有序数组构建一棵高度平衡的二叉搜索树。我们采用递归的方法,每次取中间元素作为根节点。

以下是从有序数组构建二叉搜索树的代码:

TreeNode* sortedArrayToBST(const vector<int>& nums, int start, int end) {
    if (start > end) {
        return nullptr;
    }
    int mid = start + (end - start) / 2;
    TreeNode* root = new TreeNode(nums[mid]);
    root->left = sortedArrayToBST(nums, start, mid - 1);
    root->right = sortedArrayToBST(nums, mid + 1, end);
    return root;
}

整合:合并函数

现在,我们将上述三个步骤整合到一个主函数中。

以下是合并两棵二叉搜索树的完整函数:

TreeNode* mergeBSTs(TreeNode* root1, TreeNode* root2) {
    // 步骤1:获取中序遍历序列
    vector<int> inorder1, inorder2;
    inorderTraversal(root1, inorder1);
    inorderTraversal(root2, inorder2);

    // 步骤2:合并两个有序序列
    vector<int> mergedInorder = mergeSortedVectors(inorder1, inorder2);

    // 步骤3:从合并后的有序序列构建新的BST
    return sortedArrayToBST(mergedInorder, 0, mergedInorder.size() - 1);
}

复杂度分析

  • 时间复杂度:O(m + n),其中 mn 分别是两棵树的节点数。我们需要遍历两棵树各一次(O(m+n)),合并两个数组(O(m+n)),以及构建新树(O(m+n))。
  • 空间复杂度:O(m + n),主要用于存储中序遍历序列和递归调用栈。


总结

本节课中我们一起学习了如何合并两棵二叉搜索树。我们利用了BST中序遍历有序的特性,通过“获取遍历序列 -> 合并序列 -> 重建新树”三步法解决了问题。这个方法思路清晰,代码实现也相对直接,是处理此类问题的有效策略。

104:恢复二叉搜索树

在本节课中,我们将学习如何解决一个名为“恢复二叉搜索树”的算法问题。这个问题要求我们找出一个二叉搜索树中被错误交换的两个节点,并将它们恢复,使树重新满足二叉搜索树的性质。

问题概述

给定一个二叉搜索树的根节点,其中恰好有两个节点的值被错误地交换了。我们的任务是找出这两个节点,并交换它们的值,以恢复原始的二叉搜索树结构。

核心思路

二叉搜索树的中序遍历序列是一个升序排列的序列。如果树中有两个节点被错误交换,那么中序遍历得到的序列将不再是完全有序的。我们的目标就是通过分析这个无序的序列,找出被交换的两个节点。

关键步骤

  1. 对出错的二叉搜索树进行中序遍历,得到一个无序的序列。
  2. 在这个序列中,找到破坏升序规则的“问题对”。
  3. 根据找到的问题对,确定需要交换的两个节点。
  4. 交换这两个节点的值。

算法详解

上一节我们介绍了问题的核心思路,本节中我们来看看如何通过代码实现这个算法。

中序遍历与问题检测

我们通过中序遍历来访问树的节点。在遍历过程中,我们维护一个 prev 指针,指向当前访问节点的前一个节点(按中序遍历顺序)。

以下是检测问题节点的核心逻辑:

void inorder(TreeNode* root, TreeNode*& prev, TreeNode*& first, TreeNode*& second) {
    if (root == nullptr) return;

    // 遍历左子树
    inorder(root->left, prev, first, second);

    // 处理当前节点
    if (prev != nullptr && root->val < prev->val) {
        // 找到了破坏升序规则的“问题对”
        if (first == nullptr) {
            first = prev; // 第一个问题节点是前一个节点
        }
        second = root; // 第二个问题节点是当前节点
    }
    prev = root; // 更新前一个节点为当前节点

    // 遍历右子树
    inorder(root->right, prev, first, second);
}

确定问题节点

在遍历过程中,我们使用两个指针 firstsecond 来记录需要交换的节点。

以下是确定问题节点的规则:

  • 当第一次发现 root->val < prev->val 时,first 指向 prevsecond 指向 root
  • 如果后续又发现了第二个破坏规则的地方,则只更新 second 指针为新的 root

恢复树结构

遍历结束后,firstsecond 指针就指向了需要交换的两个节点。我们只需交换它们的值即可。

void recoverTree(TreeNode* root) {
    TreeNode *prev = nullptr, *first = nullptr, *second = nullptr;
    inorder(root, prev, first, second);
    // 交换两个错误节点的值
    if (first && second) {
        int temp = first->val;
        first->val = second->val;
        second->val = temp;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是树中的节点数。我们需要进行一次完整的中序遍历。
  • 空间复杂度:O(h),其中 h 是树的高度。这是递归调用栈的空间消耗。在最坏情况(树退化为链表)下为 O(n)。

总结

本节课中我们一起学习了如何恢复二叉搜索树。我们利用了二叉搜索树中序遍历有序的特性,通过一次遍历找出破坏顺序的两个节点,并进行交换。这个方法思路清晰,代码简洁,是解决此类问题的经典方案。

105:二叉树中的最大二叉搜索树

在本节课中,我们将学习如何在一个给定的二叉树中,找到节点数最多的二叉搜索树(BST)。这是一个经典的算法问题,我们将通过分析问题、设计递归策略并最终实现代码来解决它。

问题概述

给定一个二叉树,它本身不一定是二叉搜索树。我们的目标是找出这个二叉树中,节点数最多的二叉搜索子树(BST),并返回其节点数量。

例如,考虑以下二叉树:

       10
      /  \
     5    15
    / \   / \
   1   8 7  18

在这棵树中,以节点 5 为根的子树(包含节点 158)是一个有效的 BST,其大小为 3。而以节点 15 为根的子树(包含节点 71518)不是 BST,因为 7 小于 15 但位于其右子树,违反了 BST 的性质。因此,最大的 BST 大小为 3

核心思路

解决这个问题的关键在于,对于树中的每一个节点,我们需要判断以它为根的子树是否是一个有效的 BST。如果它是,我们就可以计算其大小。为了高效地完成这个判断,我们需要在递归遍历树的过程中,为每个节点收集并传递一些必要的信息。

上一节我们介绍了问题的基本概念,本节中我们来看看具体的解决策略。

递归所需信息

对于每个节点,我们需要从它的左右子树获取以下信息,才能判断以当前节点为根的子树是否是 BST:

  1. 子树的最小值 (minVal):该子树中所有节点的最小值。
  2. 子树的最大值 (maxVal):该子树中所有节点的最大值。
  3. 子树的最大 BST 大小 (maxBSTSize):在该子树中找到的最大 BST 的节点数。
  4. 子树本身是否是 BST (isBST):一个布尔值,表示该子树本身是否是 BST。

然而,我们可以巧妙地利用 minValmaxValmaxBSTSize 这三个信息来推断 isBST,从而简化设计。

判断 BST 的条件

一个以 root 为根的子树是 BST,当且仅当满足以下所有条件:

  • 它的左子树是 BST。
  • 它的右子树是 BST。
  • root->val > 左子树的最大值
  • root->val < 右子树的最小值

如果满足条件,那么以 root 为根的 BST 的大小为:左子树BST大小 + 右子树BST大小 + 1。同时,整棵树的最小值是 左子树的最小值,最大值是 右子树的最大值

如果不满足条件,那么以 root 为根的子树不是 BST。此时,我们向上返回的信息中:

  • maxBSTSize 应为左右子树中 maxBSTSize 的较大者。
  • 为了确保在父节点判断时不会错误地认为当前子树是 BST,我们需要返回一个“无效”的 [minVal, maxVal] 区间。通常的做法是令 minVal = INT_MAX, maxVal = INT_MIN。这样,在父节点判断条件 root->val > left.maxVal && root->val < right.minVal 时,一定会失败。

递归函数设计

基于以上分析,我们设计一个递归辅助函数。它返回一个结构体,包含 minVal, maxVal, maxBSTSize 三个信息。

以下是递归函数的伪代码逻辑:

信息结构体 遍历(节点* root) {
    if (root == nullptr) {
        // 空节点是“有效”的BST,大小为0,但区间设为[INT_MAX, INT_MIN]使其在父节点判断中失效
        return {INT_MAX, INT_MIN, 0};
    }

    // 获取左右子树信息
    信息结构体 leftInfo = 遍历(root->left);
    信息结构体 rightInfo = 遍历(root->right);

    // 判断当前子树是否为BST
    if (root->val > leftInfo.maxVal && root->val < rightInfo.minVal) {
        // 是BST
        int currentMin = min(root->val, leftInfo.minVal);
        int currentMax = max(root->val, rightInfo.maxVal);
        int currentSize = leftInfo.maxBSTSize + rightInfo.maxBSTSize + 1;
        return {currentMin, currentMax, currentSize};
    } else {
        // 不是BST
        // 最大BST大小继承自左右子树中较大的那个
        int largestBSTSize = max(leftInfo.maxBSTSize, rightInfo.maxBSTSize);
        // 返回无效区间,确保父节点判断失败
        return {INT_MIN, INT_MAX, largestBSTSize};
    }
}

主函数只需调用这个辅助函数,并返回其结果的 maxBSTSize 成员即可。

代码实现

现在,我们将上述逻辑转化为 C++ 代码。我们将定义一个 NodeInfo 结构体来封装信息。

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

// 二叉树节点定义
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

// 用于递归返回的信息结构体
struct NodeInfo {
    int minVal;      // 子树中的最小值
    int maxVal;      // 子树中的最大值
    int maxBSTSize;  // 子树中最大BST的节点数
    NodeInfo(int minV, int maxV, int size) : minVal(minV), maxVal(maxV), maxBSTSize(size) {}
};

class Solution {
public:
    // 主函数,返回整棵树中最大BST的大小
    int largestBSTSubtree(TreeNode* root) {
        NodeInfo result = helper(root);
        return result.maxBSTSize;
    }

private:
    // 递归辅助函数
    NodeInfo helper(TreeNode* root) {
        // 空节点处理:视为“有效”但大小为0的BST,返回特殊区间
        if (!root) {
            return NodeInfo(INT_MAX, INT_MIN, 0);
        }

        // 后序遍历:先获取左右子树信息
        NodeInfo leftInfo = helper(root->left);
        NodeInfo rightInfo = helper(root->right);

        // 判断以root为根的子树是否是BST
        if (root->val > leftInfo.maxVal && root->val < rightInfo.minVal) {
            // 是BST,计算当前子树的信息
            int currentMin = min(root->val, leftInfo.minVal);
            int currentMax = max(root->val, rightInfo.maxVal);
            int currentSize = leftInfo.maxBSTSize + rightInfo.maxBSTSize + 1;
            return NodeInfo(currentMin, currentMax, currentSize);
        } else {
            // 不是BST
            // 最大BST大小来自左右子树中较大的那个
            int largestSize = max(leftInfo.maxBSTSize, rightInfo.maxBSTSize);
            // 返回一个无效区间,确保在父节点判断时失败
            // 注意:这里minVal和maxVal的设置与空节点相反,是为了覆盖所有无效情况
            return NodeInfo(INT_MIN, INT_MAX, largestSize);
        }
    }
};

// 测试代码
int main() {
    /* 构建示例树:
            10
           /  \
          5    15
         / \   / \
        1   8 7  18
    */
    TreeNode* root = new TreeNode(10);
    root->left = new TreeNode(5);
    root->right = new TreeNode(15);
    root->left->left = new TreeNode(1);
    root->left->right = new TreeNode(8);
    root->right->left = new TreeNode(7);
    root->right->right = new TreeNode(18);

    Solution sol;
    int ans = sol.largestBSTSubtree(root);
    cout << "最大二叉搜索子树的大小是: " << ans << endl; // 输出应为 3

    // 简单内存清理(实际生产环境需更完善)
    delete root->right->right;
    delete root->right->left;
    delete root->right;
    delete root->left->right;
    delete root->left->left;
    delete root->left;
    delete root;

    return 0;
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是树中的节点数。每个节点我们只访问一次。
  • 空间复杂度:O(H),其中 H 是树的高度。这主要是递归调用栈的空间消耗。在最坏情况(树退化为链表)下,空间复杂度为 O(N)。

总结

本节课中我们一起学习了如何寻找二叉树中的最大二叉搜索子树。我们通过后序遍历,自底向上地为每个节点计算并传递关键信息(最小值、最大值、最大BST大小),从而在 O(N) 时间内高效地解决了问题。这个问题的核心在于理解 BST 的性质,并设计合适的递归返回结构来整合信息。掌握这种方法对于解决复杂的树形DP问题非常有帮助。

106:填充每个节点的下一个右侧节点指针

概述

在本节课中,我们将学习如何解决一个名为“填充每个节点的下一个右侧节点指针”的二叉树问题。我们将使用层序遍历的方法,为完美二叉树中的每个节点设置其指向同一层右侧节点的 next 指针。


问题描述

给定一个完美二叉树,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树的节点定义如下:

struct Node {
  int val;
  Node *left;
  Node *right;
  Node *next;
}

初始状态下,所有 next 指针都被设置为 NULL。我们的任务是填充每个节点的 next 指针,使其指向其同一层的下一个右侧节点。如果右侧没有节点,则 next 指针应保持为 NULL

示例
对于根节点 1,其 next 指针应为 NULL
对于节点 2,其 next 指针应指向节点 3
对于节点 4,其 next 指针应指向节点 5,依此类推。


解决方案思路

我们将使用层序遍历来解决这个问题。层序遍历允许我们逐层访问节点,这正是连接同一层节点所需要的。

上一节我们介绍了问题,本节中我们来看看具体的算法步骤。

核心算法步骤

以下是使用队列进行层序遍历并连接 next 指针的步骤:

  1. 首先检查根节点。如果根节点为空,直接返回。
  2. 创建一个队列,并将根节点和一个 NULL 标记入队。NULL 用于标记一层的结束。
  3. 初始化一个 previous 指针为 NULL,用于跟踪当前层的前一个节点。
  4. 当队列不为空时,循环执行:
    • 从队列中取出队首元素,记为 current
    • 如果 currentNULL(层结束标记):
      • 检查队列是否为空。如果为空,则遍历结束。
      • 如果不为空,则将一个新的 NULL 标记入队,以标记下一层的结束。
      • previous 指针重置为 NULL,准备处理下一层。
    • 如果 current 是一个有效节点:
      • 如果 previous 不为 NULL,则将 previous->next 指向 current
      • previous 更新为 current
      • current 节点的左子节点和右子节点(如果存在)依次入队。

代码实现

以下是上述算法的 C++ 实现。

Node* connect(Node* root) {
    // 基础情况:如果树为空,直接返回
    if (root == NULL) {
        return root;
    }

    // 创建队列用于层序遍历
    queue<Node*> q;
    // 将根节点和层结束标记 NULL 入队
    q.push(root);
    q.push(NULL);

    // 用于指向前一个节点的指针
    Node* previous = NULL;

    while (!q.empty()) {
        // 取出队首元素
        Node* current = q.front();
        q.pop();

        if (current == NULL) {
            // 到达一层末尾
            if (q.empty()) {
                // 如果队列已空,遍历结束
                break;
            } else {
                // 否则,为下一层添加结束标记
                q.push(NULL);
            }
            // 重置 previous 指针,准备处理下一层
            previous = NULL;
        } else {
            // 处理有效节点
            // 如果 previous 不为空,连接它和当前节点
            if (previous != NULL) {
                previous->next = current;
            }
            // 更新 previous 为当前节点
            previous = current;

            // 将当前节点的子节点入队
            if (current->left != NULL) {
                q.push(current->left);
            }
            if (current->right != NULL) {
                q.push(current->right);
            }
        }
    }
    return root;
}

复杂度分析

  • 时间复杂度O(N),其中 N 是树中的节点数。每个节点恰好被访问一次。
  • 空间复杂度O(N),在最坏情况下,队列需要存储一整层的节点,对于完美二叉树,底层最多有 N/2 个节点。

总结

本节课中我们一起学习了如何使用层序遍历算法解决“填充每个节点的下一个右侧节点指针”问题。我们掌握了以下关键点:

  1. 使用队列进行二叉树的层序遍历。
  2. 在遍历过程中,利用 NULL 作为层结束标记。
  3. 维护一个 previous 指针来连接同一层中相邻的节点。
  4. 算法的时间复杂度和空间复杂度均为 O(N)

通过理解和实现这个解法,你不仅解决了这个特定问题,也加强了对二叉树层序遍历的应用能力。

107:二叉搜索树迭代器

在本节课中,我们将学习如何实现一个二叉搜索树迭代器。这个迭代器能够以中序遍历的顺序,逐个返回BST中的节点值。我们将遵循特定的时间和空间复杂度要求来实现它。


概述

我们将设计一个名为 BSTIterator 的类。这个类需要实现两个核心方法:next()hasNext()next() 方法返回中序遍历序列中的下一个值,而 hasNext() 方法检查是否还有下一个值。实现的关键在于,next()hasNext() 操作的平均时间复杂度应为 O(1),并且空间复杂度应为 O(h),其中 h 是树的高度。


核心思路

为了实现 O(1) 的平均时间复杂度和 O(h) 的空间复杂度,我们不能一次性存储整个中序遍历序列。相反,我们采用一种“惰性”或“按需”遍历的策略。

核心思想是:利用一个栈来模拟中序遍历的过程。我们首先将根节点及其所有左子节点压入栈中。栈顶元素始终是当前中序遍历序列中的下一个节点。

算法步骤

以下是实现迭代器的基本步骤:

  1. 构造函数 (BSTIterator): 初始化一个栈,并从根节点开始,将所有左子节点压入栈中。
  2. next() 方法: 弹出栈顶节点,这个节点就是当前中序遍历的下一个节点。在返回该节点的值之前,如果这个节点有右子树,我们需要对这个右子树执行与构造函数相同的操作:将其及其所有左子节点压入栈中。
  3. hasNext() 方法: 检查栈是否为空。如果栈不为空,说明还有下一个节点;否则,遍历结束。

代码实现

现在,让我们将上述思路转化为具体的 C++ 代码。

#include <stack>
using namespace std;

// 假设 TreeNode 结构体已定义
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

class BSTIterator {
private:
    stack<TreeNode*> nodeStack;

    // 辅助函数:将给定节点及其所有左子节点压入栈中
    void pushAllLeft(TreeNode* node) {
        while (node != nullptr) {
            nodeStack.push(node);
            node = node->left;
        }
    }

public:
    // 构造函数
    BSTIterator(TreeNode* root) {
        pushAllLeft(root);
    }

    // 返回中序遍历序列中的下一个值
    int next() {
        TreeNode* currentNode = nodeStack.top();
        nodeStack.pop();
        int returnValue = currentNode->val;
        // 如果当前节点有右子树,处理右子树
        if (currentNode->right != nullptr) {
            pushAllLeft(currentNode->right);
        }
        return returnValue;
    }

    // 检查是否还有下一个值
    bool hasNext() {
        return !nodeStack.empty();
    }
};

复杂度分析

上一节我们介绍了代码实现,本节我们来分析其时间和空间复杂度。

  • 时间复杂度:
    • hasNext(): O(1),只需检查栈是否为空。
    • next(): 平均 O(1)。虽然单次 next() 调用可能涉及多次入栈操作(例如处理右子树时),但每个节点恰好会被压入和弹出栈各一次。在整个遍历过程中,总操作次数是 O(n),因此分摊到 n 次 next() 调用上,平均每次是 O(1)。
  • 空间复杂度: O(h),其中 h 是树的高度。栈中最多同时存储从根节点到某个叶子节点路径上的节点。

总结

本节课中,我们一起学习了如何实现一个高效的二叉搜索树迭代器。我们利用了栈数据结构来模拟中序遍历过程,从而满足了 next()hasNext() 方法在平均 O(1) 时间复杂度和 O(h) 空间复杂度下的运行要求。理解这个迭代器的实现,有助于加深对二叉树遍历和非递归算法设计的认识。

108:二叉搜索树中的中序前驱和后继

在本节课中,我们将学习如何在二叉搜索树中为一个给定的键值查找它的中序前驱和后继节点。这是数据结构与算法中的一个常见问题,理解其原理对于掌握树的操作至关重要。

概述

二叉搜索树的中序遍历会产生一个有序的节点值序列。对于序列中的任何一个节点(或键值),它的中序前驱是序列中恰好位于它之前的节点,而中序后继是序列中恰好位于它之后的节点。本节课我们将通过一个具体的例子,详细讲解查找这两个节点的逻辑和实现方法。

核心概念与问题定义

假设我们有一个二叉搜索树,其节点值序列为:1, 4, 5, 6, 7, 8, 9。
如果给定的键值是 7,那么它的中序前驱是 6,中序后继是 8
我们的目标是编写一个函数,输入一个二叉搜索树的根节点和一个键值,返回一个包含该键值的前驱和后继节点的向量。

解决思路

查找过程基于二叉搜索树的性质:对于任意节点,其左子树的所有值都小于该节点,右子树的所有值都大于该节点。我们将通过遍历树来寻找前驱和后继,主要分为三种情况。

以下是查找逻辑的三种核心情况:

  1. 当前节点值大于键值:如果键值小于当前节点的值,那么当前节点可能是键值的后继。我们将当前节点记录为潜在的后继候选,然后移动到左子树继续搜索。
  2. 当前节点值小于键值:如果键值大于当前节点的值,那么当前节点可能是键值的前驱。我们将当前节点记录为潜在的前驱候选,然后移动到右子树继续搜索。
  3. 当前节点值等于键值:我们找到了键值所在的节点。此时:
    • 它的中序前驱是其左子树中的最大值(即左子树的最右节点)。
    • 它的中序后继是其右子树中的最小值(即右子树的最左节点)。

算法实现步骤

现在,让我们将上述思路转化为具体的代码实现步骤。

首先,初始化两个变量 presuc 用于存储前驱和后继,并将当前节点 curr 指向根节点。然后,我们进入一个循环,根据比较结果更新这些变量。

以下是算法的主要循环逻辑:

  • 如果 key < curr->data:更新 suc = curr,然后令 curr = curr->left
  • 如果 key > curr->data:更新 pre = curr,然后令 curr = curr->right
  • 如果 key == curr->data:找到目标节点。
    • 如果 curr->left 不为空,则前驱 pre 是左子树中的最大值。
    • 如果 curr->right 不为空,则后继 suc 是右子树中的最小值。
    • 完成查找,跳出循环。

辅助函数

为了实现“找左子树最大值”和“找右子树最小值”,我们需要两个简单的辅助函数。

查找左子树中的最大值(最右节点)函数:

Node* findMax(Node* root) {
    Node* curr = root;
    while (curr && curr->right != nullptr) {
        curr = curr->right;
    }
    return curr;
}

查找右子树中的最小值(最左节点)函数:

Node* findMin(Node* root) {
    Node* curr = root;
    while (curr && curr->left != nullptr) {
        curr = curr->left;
    }
    return curr;
}

在第三种情况(key == curr->data)中,我们会调用 findMax(curr->left) 来获取前驱,调用 findMin(curr->right) 来获取后继。

复杂度分析

  • 时间复杂度O(h),其中 h 是树的高度。在最坏情况下(树退化为链表),复杂度为 O(n);在平衡树中,复杂度为 O(log n)
  • 空间复杂度O(1),只使用了常数级别的额外空间。

总结

本节课中,我们一起学习了如何在二叉搜索树中查找任意键值的中序前驱和后继节点。我们分析了三种核心情况,并逐步实现了查找算法。关键点在于利用BST的性质,在遍历过程中记录潜在的候选节点,并在找到目标节点时,在其子树中寻找确切的前驱和后继。掌握这个方法有助于加深对二叉树遍历和性质的理解。

109:图简介 📊

在本节课中,我们将开始学习一个重要的数据结构——图。图是一种没有明确层级关系的节点网络,广泛应用于地图、社交网络、神经网络等现实场景。我们将了解图的基本概念、不同类型,并学习如何在C++中使用邻接表来实现图。


什么是图?

上一节我们提到了图是一种重要的数据结构。本节中,我们来具体看看图的定义。

图本质上是一个节点网络。与二叉树等有层级结构的数据结构不同,图中节点之间没有父子关系,只有连接关系。每个节点称为顶点,每条连接称为

公式:一个图 G 可以表示为 G = (V, E),其中 V 是顶点集合,E 是边集合。


图的类型

了解了图的基本定义后,我们来看看图有哪些不同的类型。图的分类主要基于边的两个属性:方向性和权重。

以下是基于边属性的四种主要图类型:

  1. 无向无权图:边没有方向,也没有关联的权重值。
  2. 无向有权图:边没有方向,但每条边有一个关联的权重值(例如距离、成本)。
  3. 有向有权图:边有方向,并且每条边有一个关联的权重值。
  4. 有向无权图:边有方向,但没有关联的权重值。

此外,图还可以分为连通图和非连通图。连通图中所有节点都通过路径相连;非连通图则由多个互不连接的子图(称为连通分量)组成。


图的表示:邻接表

知道了图的类型,我们需要一种方法在程序中存储它。最常用和重要的方法之一是邻接表

邻接表的逻辑是:为图中的每一个顶点,存储一个它的所有邻居顶点的列表。

代码:在C++中,我们可以用一个vector容器来存储多个列表,从而实现邻接表。

vector<list<int>> adjList;

这里,adjList 的大小等于顶点数 VadjList[i] 是一个链表,存储了与顶点 i 直接相连的所有邻居顶点。

例如,对于一个有4个顶点的无向图,边为 (0,1), (1,2), (1,3),其邻接表表示如下:

  • 顶点0的邻居列表: [1]
  • 顶点1的邻居列表: [2, 3, 0]
  • 顶点2的邻居列表: [1]
  • 顶点3的邻居列表: [1]

C++ 图类实现

理解了邻接表的原理后,我们现在动手实现一个图类。我们将构建一个类,支持添加边和打印邻接表的功能。

以下是图类的基本实现步骤:

  1. 定义类成员:使用 vector<list<int>> adjList 作为核心数据结构。
  2. 编写构造函数:初始化顶点数量,并为邻接表分配空间。
  3. 实现添加边函数:对于无向图,需要在边的两个顶点的邻居列表中互相添加对方。
  4. 实现打印函数:遍历所有顶点,打印每个顶点及其邻居列表。

代码:一个简单的无向图类实现

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

class Graph {
    int V; // 顶点数
    vector<list<int>> adjList; // 邻接表

public:
    // 构造函数
    Graph(int vertices) {
        V = vertices;
        adjList.resize(V);
    }

    // 添加边(无向图)
    void addEdge(int u, int v) {
        adjList[u].push_back(v);
        adjList[v].push_back(u); // 如果是无向图,需要添加双向连接
    }

    // 打印邻接表
    void printAdjList() {
        for(int i = 0; i < V; i++) {
            cout << i << " -> ";
            for(int neighbor : adjList[i]) {
                cout << neighbor << " ";
            }
            cout << endl;
        }
    }
};

int main() {
    Graph g(5); // 创建有5个顶点的图
    // 添加边
    g.addEdge(0, 1);
    g.addEdge(1, 2);
    g.addEdge(2, 3);
    g.addEdge(3, 4);
    g.addEdge(4, 0);

    g.printAdjList(); // 打印邻接表
    return 0;
}

总结与展望

本节课中,我们一起学习了图数据结构的基础知识。我们了解了图的定义、不同的类型(有向/无向、有权/无权),并重点掌握了使用邻接表在C++中表示和实现图的方法。

图是一种非常强大和通用的数据结构,是解决许多复杂算法问题(如路径查找、网络流、社交网络分析)的基础。在接下来的课程中,我们将学习如何遍历图,即访问图中的所有节点,这将引出广度优先搜索深度优先搜索这两个核心算法。掌握图的表示是理解这些算法的重要第一步。

110:图中的广度优先搜索遍历 🗺️

在本节课中,我们将要学习图数据结构中的一种基础且重要的遍历算法——广度优先搜索。我们将从概念入手,通过示例理解其工作原理,并最终用C++代码实现它。

概述

广度优先搜索是一种用于遍历或搜索图或树的算法。它的核心思想是“由近及远”,即优先访问起始节点的所有直接邻居,然后再逐层向外探索。这与树的层序遍历非常相似。

BFS 核心概念与规则

上一节我们介绍了BFS的基本思想,本节中我们来看看其具体的工作规则。

BFS算法遵循一个基本原则:优先访问当前节点的所有直接邻居。这意味着在访问图中任何其他更远的节点之前,必须先完成当前“层级”所有节点的访问。

为了实现这一点,我们需要一个起始点。在树中,这个起点通常是根节点。在图(特别是无向图)中,我们可以选择任意一个顶点作为起点。算法还需要一个关键机制来避免重复访问和无限循环:记录节点的访问状态。我们使用一个布尔数组 visited 来标记每个顶点是否已被访问过。

算法从起点开始,将其标记为已访问并放入一个队列中。然后,只要队列不为空,就重复以下步骤:

  1. 从队列中取出一个节点。
  2. 访问该节点的所有邻居。
  3. 对于每一个尚未被访问过的邻居,将其标记为已访问并加入队列。

这个过程确保了节点是按照它们距离起点的层级顺序被访问的。

BFS 算法步骤详解

以下是BFS遍历的具体步骤,我们将通过一个例子来演示。

假设我们有一个简单的无向图,包含顶点 0, 1, 2, 3, 4。其邻接关系如下(以邻接表表示):

  • 0 连接 1, 2
  • 1 连接 0, 2
  • 2 连接 0, 1, 3, 4
  • 3 连接 2
  • 4 连接 2

我们选择顶点 0 作为起点。

初始化

  • 创建一个队列 q
  • 创建一个布尔数组 visited,大小等于顶点数,初始值全部为 false
  • 将起点 0 标记为已访问 (visited[0] = true),并将其加入队列 q

遍历过程

  1. 队列初始状态: q = [0]
  2. 取出队首 0。访问其邻居 12
    • 邻居 1 未被访问,将其标记为已访问并加入队列:visited[1]=true, q = [1]
    • 邻居 2 未被访问,将其标记为已访问并加入队列:visited[2]=true, q = [1, 2]
  3. 取出队首 1。访问其邻居 02
    • 邻居 0 已被访问,忽略。
    • 邻居 2 已被访问,忽略。队列变为 q = [2]
  4. 取出队首 2。访问其邻居 0, 1, 3, 4
    • 邻居 01 已被访问,忽略。
    • 邻居 3 未被访问,将其标记为已访问并加入队列:visited[3]=true, q = [3]
    • 邻居 4 未被访问,将其标记为已访问并加入队列:visited[4]=true, q = [3, 4]
  5. 取出队首 3。访问其唯一邻居 2(已访问,忽略)。队列变为 q = [4]
  6. 取出队首 4。访问其唯一邻居 2(已访问,忽略)。队列变空,遍历结束。

最终的BFS访问顺序(输出)是:0, 1, 2, 3, 4

BFS 的C++代码实现

理解了算法步骤后,现在让我们看看如何用C++代码来实现它。我们假设图使用邻接表(vector<vector<int>>)存储。

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

void bfsTraversal(vector<vector<int>> &adjList, int startVertex) {
    int numVertices = adjList.size();
    vector<bool> visited(numVertices, false); // 初始化访问数组
    queue<int> q; // 创建队列

    // 从起始顶点开始
    visited[startVertex] = true;
    q.push(startVertex);

    while (!q.empty()) {
        int currentVertex = q.front();
        q.pop();
        cout << currentVertex << " "; // 访问当前顶点(此处为打印)

        // 遍历当前顶点的所有邻居
        for (int neighbor : adjList[currentVertex]) {
            if (!visited[neighbor]) {
                // 如果邻居未被访问,则标记并加入队列
                visited[neighbor] = true;
                q.push(neighbor);
            }
        }
    }
    cout << endl;
}

// 示例:在主函数中调用
int main() {
    // 假设有5个顶点,构建上述示例图的邻接表
    vector<vector<int>> graph = {
        {1, 2}, // 顶点0的邻居
        {0, 2}, // 顶点1的邻居
        {0, 1, 3, 4}, // 顶点2的邻居
        {2}, // 顶点3的邻居
        {2}  // 顶点4的邻居
    };

    cout << "BFS Traversal starting from vertex 0: ";
    bfsTraversal(graph, 0); // 输出: 0 1 2 3 4

    return 0;
}

时间与空间复杂度分析

  • 时间复杂度O(V + E)。其中 V 是顶点数量,E 是边的数量。每个顶点被访问并出队一次(O(V)),在访问每个顶点时,我们会检查它的所有边(O(E))。
  • 空间复杂度O(V)。主要空间消耗来自存储访问状态的 visited 数组(大小V)以及队列 q(在最坏情况下可能存储所有顶点)。

初学者常见错误与提示

在实现BFS时,初学者常犯一个关键错误:错误地设置访问标记的时机

错误做法:在从队列中取出节点时才将其标记为已访问。

// 错误示例片段
while (!q.empty()) {
    int u = q.front();
    q.pop();
    visited[u] = true; // 错误!此时标记太晚
    // ... 处理邻居
}

问题:考虑一个三角形图(三个节点两两相连)。节点A先入队。当处理A的邻居B和C时,由于它们都未被标记,会被重复加入队列。这可能导致逻辑错误或非预期的访问顺序。

正确做法在将节点加入队列的同时,立即将其标记为已访问。这确保了每个节点只被加入队列一次,正如我们上面的代码所示。

visited[startVertex] = true; // 起点入队前标记
q.push(startVertex);

// 在循环中,邻居入队前标记
if (!visited[neighbor]) {
    visited[neighbor] = true; // 正确!入队前标记
    q.push(neighbor);
}

学习提示:多在不同的图例(如链状、星状、环状)上手动画出BFS的执行过程,并对照代码理解。这将极大地帮助你掌握BFS,并为后续解决更复杂的图论问题打下坚实基础。

总结

本节课中我们一起学习了图的广度优先搜索遍历。我们从BFS“由近及远”的核心思想出发,详细讲解了其算法规则和步骤,并通过一个具体例子进行了推演。随后,我们给出了完整的C++实现代码,并分析了算法的时间与空间复杂度。最后,我们指出了初学者在实现时容易犯的“访问标记时机”错误并给出了正确做法。BFS是图算法中的基石,理解并掌握它对于学习后续更高级的图算法至关重要。

111:图中的深度优先搜索遍历 🧭

在本节课中,我们将要学习图数据结构中的另一种基础遍历算法——深度优先搜索。我们将详细探讨其核心思想、与广度优先搜索的区别,并学习如何用C++代码实现它。

上一节我们介绍了广度优先搜索,本节中我们来看看深度优先搜索。

概述

深度优先搜索是一种用于遍历或搜索树或图的算法。其核心策略是尽可能深地探索图的分支,当到达尽头时再回溯。这与广度优先搜索“逐层”探索的策略截然不同。

DFS的核心规则

深度优先搜索遵循一个简单的规则:从当前节点出发,总是访问其第一个未被访问的邻居节点,并沿着这条路径一直深入下去,直到无法继续,然后回溯到上一个节点,尝试其他未访问的邻居。

DFS与BFS的遍历顺序对比

为了理解区别,我们以一个图为例。假设图的邻接关系如下:

  • 节点0的邻居是:1, 2, 3
  • 节点1的邻居是:0, 2
  • 节点2的邻居是:0, 1, 4
  • 节点3的邻居是:0
  • 节点4的邻居是:2

以下是两种遍历从节点0开始的访问顺序:

  • BFS顺序:0, 1, 2, 3, 4
  • DFS顺序:0, 1, 2, 4, 3

区别在于,DFS在访问节点1后,会立即深入访问节点1的第一个未访问邻居(节点2),接着访问节点2的第一个未访问邻居(节点4)。而BFS则会先访问完节点0的所有直接邻居(1,2,3)后,才访问下一层节点(4)。

DFS的算法实现

深度优先搜索通常使用递归来实现,这种方式代码简洁直观。其核心思想是“标记-访问-深入”。

以下是DFS的递归算法伪代码:

函数 DFS(当前节点 u, 访问标记数组 visited):
    1. 标记节点 u 为已访问 (visited[u] = true)
    2. 输出或处理节点 u
    3. 对于节点 u 的每一个邻居节点 v:
        如果 v 未被访问 (visited[v] == false):
            递归调用 DFS(v, visited)

将伪代码转化为C++

现在,我们将上述逻辑转化为实际的C++代码。我们通常编写一个辅助函数来执行递归逻辑,并在主函数中初始化必要的变量。

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

// DFS递归辅助函数
void DFS_Helper(int source, vector<bool> &visited, vector<int> adj[]) {
    // 1. 标记当前节点为已访问
    visited[source] = true;
    // 2. 处理当前节点(这里选择打印)
    cout << source << " ";

    // 3. 递归访问所有未访问的邻居
    for (int neighbor : adj[source]) {
        if (!visited[neighbor]) {
            DFS_Helper(neighbor, visited, adj);
        }
    }
}

// 供外部调用的DFS函数
void DFS(int source, int V, vector<int> adj[]) {
    // 初始化访问标记数组,所有节点初始状态为“未访问”(false)
    vector<bool> visited(V, false);
    // 调用递归辅助函数
    DFS_Helper(source, visited, adj);
    cout << endl; // 遍历结束,换行
}

// 主函数示例
int main() {
    int V = 5; // 图中节点数量
    vector<int> adj[V]; // 邻接表

    // 构建示例图 (0->1, 0->2, 0->3; 1->0, 1->2; 2->0, 2->1, 2->4; 3->0; 4->2)
    adj[0].push_back(1); adj[0].push_back(2); adj[0].push_back(3);
    adj[1].push_back(0); adj[1].push_back(2);
    adj[2].push_back(0); adj[2].push_back(1); adj[2].push_back(4);
    adj[3].push_back(0);
    adj[4].push_back(2);

    cout << "DFS 遍历顺序 (从节点0开始): ";
    DFS(0, V, adj); // 输出: 0 1 2 4 3

    return 0;
}

处理非连通图

上述代码假设图是连通的。如果图由多个互不连通的子图(组件)构成,从单一源点出发的DFS将无法访问所有节点。

以下是修改方案,确保遍历整个图的所有组件:

void DFS_Disconnected(int V, vector<int> adj[]) {
    vector<bool> visited(V, false);
    for (int i = 0; i < V; i++) { // 遍历所有节点
        if (!visited[i]) {        // 如果节点i未被访问过
            DFS_Helper(i, visited, adj); // 以i为新的起点开始DFS
            cout << endl; // 一个连通组件遍历完成
        }
    }
}

这段代码会检查每一个节点,如果它未被访问,就以它为起点发起一次DFS,从而覆盖图中所有的连通分量。

复杂度分析

  • 时间复杂度O(V + E),其中V是顶点数,E是边数。每个顶点和每条边都会被访问一次。
  • 空间复杂度O(V),主要消耗在递归调用栈(最坏情况深度为V)和访问标记数组visited上。

总结

本节课中我们一起学习了图的深度优先搜索遍历。我们理解了DFS“一路到底再回溯”的核心思想,掌握了其递归实现方法,并学会了如何用C++代码实现。同时,我们也了解了如何处理非连通图,并分析了算法的时间与空间复杂度。请务必动手实践代码,并比较DFS与BFS的输出结果,以加深理解。

112:使用DFS检测无向图中的环 🔄

在本节课中,我们将学习如何使用深度优先搜索算法来检测无向图中是否存在环。我们将从理解检测环的基本现象开始,逐步深入到具体的算法实现和代码逻辑。

概述

检测图中的环是图论中的一个基本问题。对于无向图,我们可以使用深度优先搜索算法,并配合一个记录访问状态的布尔数组来实现。本节课将详细介绍这一方法的逻辑和实现步骤。

检测环的基本概念

首先,我们需要理解在无向图中检测环的一般现象。无向图由节点和连接节点的边组成。当我们在图中进行遍历时,如果发现一条边指向了一个已经访问过的、且不是当前节点父节点的节点,那么就说明图中存在环。

使用DFS检测环的算法逻辑

以下是使用深度优先搜索检测无向图中环的核心算法逻辑。我们将通过一个布尔数组 visited 来跟踪节点的访问状态。

核心逻辑伪代码

bool isCyclicUtil(int v, bool visited[], int parent) {
    visited[v] = true;
    for (每个与v相邻的节点i) {
        if (!visited[i]) {
            if (isCyclicUtil(i, visited, v))
                return true;
        } else if (i != parent) {
            return true;
        }
    }
    return false;
}

算法步骤解释

  1. 将当前节点标记为已访问。
  2. 遍历当前节点的所有邻居节点。
  3. 如果邻居节点未被访问,则递归调用函数进行深度搜索,并将当前节点作为父节点传入。
  4. 如果邻居节点已被访问,且该邻居节点不是当前节点的父节点,则说明检测到了一个环。
  5. 如果遍历完所有邻居都没有发现环,则返回 false

完整的代码实现

现在,让我们将上述逻辑整合到一个完整的图类中。假设我们已经有一个图类,它能够添加边并存储邻接表。

完整的检测环函数示例

class Graph {
    int V; // 顶点数
    list<int> *adj; // 邻接表

public:
    Graph(int V);
    void addEdge(int v, int w);
    bool isCyclic(); // 检测环的主函数
};

bool Graph::isCyclicUtil(int v, bool visited[], int parent) {
    visited[v] = true;
    list<int>::iterator i;
    for (i = adj[v].begin(); i != adj[v].end(); ++i) {
        if (!visited[*i]) {
            if (isCyclicUtil(*i, visited, v))
                return true;
        } else if (*i != parent) {
            return true;
        }
    }
    return false;
}

bool Graph::isCyclic() {
    bool *visited = new bool[V];
    for (int i = 0; i < V; i++)
        visited[i] = false;

    for (int u = 0; u < V; u++) {
        if (!visited[u])
            if (isCyclicUtil(u, visited, -1))
                return true;
    }
    return false;
}

代码说明

  • isCyclicUtil 是递归辅助函数,实现了DFS和环检测的核心逻辑。
  • isCyclic 是主函数。它初始化访问数组,并遍历图中的每个连通分量。对于每个未访问的节点,调用 isCyclicUtil 函数。
  • 初始调用时,父节点参数设置为 -1

时间复杂度分析

该算法的时间复杂度为 O(V + E),其中 V 是顶点数,E 是边数。这是因为每个顶点和每条边在深度优先搜索中最多被访问一次。

运行示例

我们可以创建一个图实例并测试环检测功能。

测试用例1(有环图)

Graph g(3);
g.addEdge(0, 1);
g.addEdge(1, 2);
g.addEdge(2, 0); // 这条边构成了环
cout << g.isCyclic(); // 输出:1 (true)

测试用例2(无环图,例如树)

Graph g(3);
g.addEdge(0, 1);
g.addEdge(1, 2);
// g.addEdge(2, 0); // 注释掉构成环的边
cout << g.isCyclic(); // 输出:0 (false)

总结

本节课中,我们一起学习了如何使用深度优先搜索算法来检测无向图中的环。我们了解了检测环的核心条件:当遍历过程中遇到一个已访问的节点,且该节点不是当前节点的父节点时,即表示存在环。我们实现了完整的代码,并分析了算法的时间复杂度。掌握这一方法对于理解和解决更复杂的图论问题至关重要。

113:使用BFS检测无向图中的环 🕵️

概述

在本节课中,我们将要学习如何使用广度优先搜索算法来检测一个无向图中是否存在环。我们将从理解核心概念开始,逐步深入到具体的算法逻辑和代码实现。

核心概念与条件

上一节我们讨论了图的基本遍历。本节中我们来看看在无向图中检测环的特殊条件。

检测无向图中环的核心在于识别“后边”。后边是指连接当前节点与其非父节点的、已被访问过的邻居的边。在BFS遍历中,如果发现这样的边,就说明图中存在环。

用逻辑条件描述如下:

  • 如果节点 v 已被访问
  • 并且 v 不是当前节点 u 的父节点
  • 那么边 (u, v) 就是一条后边,表明图中存在环。

BFS检测环的算法逻辑

理解了环存在的条件后,我们来看看如何将其融入BFS算法框架。

以下是使用BFS检测环的逐步逻辑:

  1. 初始化一个访问标记数组 visited,全部设为 false
  2. 初始化一个队列 q,用于BFS遍历。队列中的元素是 (当前节点, 父节点) 对。
  3. 对于图中的每一个未访问节点,以其为起点开始BFS。
  4. 将起点节点及其父节点(初始为 -1)入队,并标记为已访问。
  5. 当队列不为空时:
    a. 取出队首元素,得到当前节点 u 及其父节点 parent
    b. 遍历节点 u 的所有邻居 v
    i. 如果邻居 v 未被访问,则将其标记为已访问,并将 (v, u) 入队(uv 的父节点)。
    ii. 如果邻居 v 已被访问,且 v 不等于 u 的父节点 parent,则发现后边,返回 true(检测到环)。
  6. 如果遍历完所有节点都未发现后边,则返回 false(图中无环)。

这个逻辑确保了即使对于非连通图,我们也能检查其所有连通分量。

代码实现

掌握了算法逻辑,现在我们可以将其转化为具体的C++代码。

以下是检测无向图中环的完整函数实现:

#include <bits/stdc++.h>
using namespace std;

bool isCycleBFS(int src, vector<int> adj[], vector<bool>& visited) {
    // 队列存储 (节点, 父节点) 对
    queue<pair<int, int>> q;
    // 将源节点入队,其父节点设为-1
    q.push({src, -1});
    visited[src] = true;

    while (!q.empty()) {
        int u = q.front().first;
        int parent = q.front().second;
        q.pop();

        // 遍历当前节点的所有邻居
        for (int v : adj[u]) {
            if (!visited[v]) {
                // 如果邻居未访问,标记并入队
                visited[v] = true;
                q.push({v, u});
            } else if (v != parent) {
                // 如果邻居已访问且不是父节点,发现环
                return true;
            }
        }
    }
    return false;
}

bool isCycleInUndirectedGraph(int V, vector<int> adj[]) {
    vector<bool> visited(V, false);

    // 遍历所有节点,处理非连通图的情况
    for (int i = 0; i < V; ++i) {
        if (!visited[i]) {
            if (isCycleBFS(i, adj, visited)) {
                return true;
            }
        }
    }
    return false;
}

总结

本节课中我们一起学习了使用广度优先搜索检测无向图中环的方法。关键点是利用BFS遍历,并通过检查是否存在连接当前节点与其非父节点的已访问边(即后边)来判断环的存在。我们详细分析了算法逻辑,并给出了完整的C++代码实现。对于非连通图,需要遍历所有连通分量以确保检查全面。

114:岛屿数量 - 矩阵中的连通分量

概述

在本节课中,我们将学习如何解决一个重要的数据结构与算法问题:“岛屿数量”。这是LeetCode上的第200题。我们将通过将二维网格视为一个图,并使用深度优先搜索算法来统计其中由“1”表示的陆地(岛屿)所形成的连通分量的数量。


问题理解

问题提供一个由字符 '0''1' 组成的二维网格 grid

  • '1' 代表陆地。
  • '0' 代表水域。

岛屿被定义为水平或垂直方向上相邻的陆地('1')组成的区域,并且被水域('0')完全包围。我们的目标是计算网格中岛屿的总数。

示例:

输入:grid = [
  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出:3

在上面的网格中,存在三个互不相连的由 '1' 组成的区域,因此岛屿数量为3。


核心思路:将网格视为图

解决这个问题的关键在于思维的转换。我们可以将整个二维网格想象成一个图:

  • 节点:网格中的每一个单元格。
  • :如果两个相邻的单元格(上、下、左、右)都是陆地('1'),那么它们之间就存在一条边。

这样,问题就转化为:在一个图中,找出所有互不相连的连通分量的数量。每个连通分量就是一个岛屿。


算法设计:深度优先搜索

我们将使用深度优先搜索算法来遍历图,并标记访问过的节点。基本步骤如下:

  1. 初始化一个计数器 islandCount = 0
  2. 遍历网格中的每一个单元格 (i, j)
  3. 如果当前单元格是未访问过的陆地(grid[i][j] == '1'visited[i][j] == false):
    • islandCount 加1。我们找到了一个新的岛屿(连通分量)的起点。
    • 从这个单元格开始,执行DFS,访问并标记所有与其相连的陆地单元格。
  4. 遍历完成后,islandCount 就是岛屿的总数。

DFS函数的作用是“淹没”一个岛屿:它会递归地访问当前单元格的上、下、左、右四个邻居。如果邻居是有效的、未访问过的陆地,就继续递归访问。


代码实现

以下是基于上述思路的C++代码实现。

class Solution {
public:
    int numIslands(vector<vector<char>>& grid) {
        if (grid.empty()) return 0;
        
        int n = grid.size();    // 网格的行数
        int m = grid[0].size(); // 网格的列数
        int islandCount = 0;
        
        // 创建访问标记数组,初始化为false
        vector<vector<bool>> visited(n, vector<bool>(m, false));
        
        // 定义DFS递归函数
        function<void(int, int)> dfs = [&](int i, int j) {
            // 递归边界条件:索引越界、已访问、或当前单元格是水域
            if (i < 0 || i >= n || j < 0 || j >= m || 
                visited[i][j] || grid[i][j] == '0') {
                return;
            }
            
            // 标记当前单元格为已访问
            visited[i][j] = true;
            
            // 递归访问四个方向的邻居
            dfs(i - 1, j); // 上
            dfs(i + 1, j); // 下
            dfs(i, j - 1); // 左
            dfs(i, j + 1); // 右
        };
        
        // 主循环:遍历整个网格
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < m; ++j) {
                // 找到一个未访问的陆地起点
                if (grid[i][j] == '1' && !visited[i][j]) {
                    ++islandCount; // 发现新岛屿
                    dfs(i, j);     // 用DFS“淹没”这个岛屿
                }
            }
        }
        
        return islandCount;
    }
};

关键点解析

上一节我们介绍了代码的整体结构,本节我们来详细看看其中的关键逻辑。

DFS递归函数的边界条件
这是确保递归正确终止的核心。条件包括:

  1. 索引 ij 超出网格范围。
  2. 单元格 (i, j) 已经被访问过 (visited[i][j] == true)。
  3. 单元格 (i, j) 是水域 (grid[i][j] == '0')。

只要满足以上任一条件,递归就立即返回。

主循环的逻辑
以下是主循环中寻找岛屿起点的步骤:

  1. 使用两层循环遍历每个单元格。
  2. 检查条件 grid[i][j] == '1' && !visited[i][j]
  3. 如果为真,说明找到了一个新的连通分量的起点。
  4. 岛屿计数器加1,并从这个起点发起DFS,标记整个连通分量。

复杂度分析

  • 时间复杂度:O(n * m)。其中 n 是行数,m 是列数。在最坏情况下,我们需要访问网格中的每一个单元格。
  • 空间复杂度:O(n * m)。主要开销来自存储访问状态的 visited 数组。此外,在最坏情况下(网格全是陆地),DFS的递归调用栈深度也可能达到 n * m

总结

本节课我们一起学习了如何解决“岛屿数量”问题。我们掌握了以下核心内容:

  1. 问题转化:将二维网格中的连通区域问题,转化为在图论中寻找连通分量的问题。
  2. 算法应用:使用深度优先搜索算法来遍历和标记一个连通分量中的所有节点。
  3. 实现细节:注意递归边界条件的设置,以及如何通过主循环寻找每一个新的连通分量起点。

通过这个经典问题,我们巩固了图遍历算法在矩阵类问题中的应用,这是解决许多更复杂搜索问题的基础。

115:腐烂的橘子 - 多源BFS - Leetcode 994 🍊

在本节课中,我们将学习一个经典的图论问题——“腐烂的橘子”。这是一个在网格上使用多源广度优先搜索的典型问题,对应LeetCode第994题。我们将从问题理解开始,逐步构建解决方案,并最终实现代码。

问题概述

问题描述如下:给定一个 m x n 的网格,每个单元格可以有以下三种状态之一:

  • 0 代表一个空单元格。
  • 1 代表一个新鲜橘子。
  • 2 代表一个腐烂的橘子。

每分钟,任何与腐烂橘子(4个正方向,上、下、左、右)相邻的新鲜橘子都会腐烂。你需要计算直到网格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能使所有橘子腐烂,则返回 -1

核心思路:将网格视为图 🗺️

这个问题初看是一个网格问题,但实际上可以抽象为一个图论问题。

  • 将网格中的每个单元格视为图中的一个节点
  • 如果两个单元格在水平或垂直方向上相邻(共享一条边),则它们之间存在一条
  • 这样,整个网格就构成了一张隐式的图。

理解了这一点后,解决“腐烂的橘子”问题就变得和标准的图遍历问题非常相似。

为什么使用BFS? ⏱️

广度优先搜索的特点是逐层扩展。在这个问题中,“层”的概念恰好对应着“分钟”。

  • 在第0分钟,所有初始腐烂的橘子是第0层。
  • 在第1分钟,所有与第0层橘子相邻的新鲜橘子会腐烂,成为第1层。
  • 以此类推,直到无法扩展或所有新鲜橘子都已腐烂。

因此,BFS能自然地帮我们计算出腐烂过程所需的总时间(即BFS的深度)。

关键概念:多源BFS 🔥

在标准BFS中,我们通常从一个起点开始。但在本问题中,初始时可能有多个腐烂的橘子。它们应该同时作为BFS的起点。

以下是多源BFS的实现思路:

  1. 初始化队列时,不是放入单个起点,而是将所有初始状态为“腐烂”(值为2)的单元格坐标及其时间(初始为0)一起加入队列。
  2. 然后进行常规的BFS遍历。这样保证了所有腐烂源是同时开始传播的。

算法步骤详解

以下是解决“腐烂的橘子”问题的详细步骤。

步骤1:初始化

  • 获取网格的行数 n 和列数 m
  • 初始化一个 visited 二维数组(布尔类型),记录每个单元格是否已被访问(即是否已腐烂)。初始时所有单元格为 false
  • 初始化一个队列 q,用于BFS。队列元素是一个三元组 (i, j, t),其中 (i, j) 是坐标,t 是该节点腐烂的时间。
  • 初始化答案变量 ans = 0,用于记录最大时间。

步骤2:多源入队

遍历整个网格。对于每个单元格 grid[i][j]

  • 如果其值为 2(腐烂橘子),则将其坐标 (i, j) 和时间 0 作为一个三元组 (i, j, 0) 加入队列 q
  • 同时,将 visited[i][j] 标记为 true

步骤3:执行BFS遍历

当队列 q 不为空时,循环执行以下操作:

  1. 从队首取出一个节点 (i, j, t)
  2. 更新全局答案:ans = max(ans, t)
  3. 检查当前节点的四个邻居(上、下、左、右)。对于每个邻居 (ni, nj)
    • 边界检查:确保 ninj 在网格范围内。
    • 访问检查:确保 visited[ni][nj]false(未腐烂)。
    • 状态检查:确保 grid[ni][nj]1(新鲜橘子)。
    • 如果以上条件都满足,则:
      • 将邻居 (ni, nj, t+1) 加入队列 q
      • visited[ni][nj] 标记为 true

步骤4:检查剩余新鲜橘子

BFS结束后,再次遍历整个网格。

  • 如果存在任何一个单元格满足 grid[i][j] == 1visited[i][j] == false,说明这个新鲜橘子无法被腐烂到。
  • 在这种情况下,返回 -1
  • 否则,返回答案 ans

代码实现

以下是基于上述思路的C++代码实现。

class Solution {
public:
    int orangesRotting(vector<vector<int>>& grid) {
        int n = grid.size();
        int m = grid[0].size();
        
        vector<vector<bool>> visited(n, vector<bool>(m, false));
        queue<pair<pair<int, int>, int>> q; // ((i, j), time)
        int ans = 0;
        
        // 多源入队:将所有初始腐烂的橘子加入队列
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < m; j++) {
                if(grid[i][j] == 2) {
                    q.push({{i, j}, 0});
                    visited[i][j] = true;
                }
            }
        }
        
        // 方向数组,方便遍历四个邻居
        int dir[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        
        // BFS遍历
        while(!q.empty()) {
            auto current = q.front();
            q.pop();
            
            int i = current.first.first;
            int j = current.first.second;
            int time = current.second;
            
            ans = max(ans, time);
            
            // 遍历四个方向
            for(int d = 0; d < 4; d++) {
                int ni = i + dir[d][0];
                int nj = j + dir[d][1];
                
                // 检查邻居是否有效、未访问且是新鲜橘子
                if(ni >= 0 && ni < n && nj >= 0 && nj < m 
                   && !visited[ni][nj] && grid[ni][nj] == 1) {
                    q.push({{ni, nj}, time + 1});
                    visited[ni][nj] = true;
                }
            }
        }
        
        // 检查是否还有无法腐烂的新鲜橘子
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < m; j++) {
                if(grid[i][j] == 1 && !visited[i][j]) {
                    return -1;
                }
            }
        }
        
        return ans;
    }
};

总结

本节课我们一起学习了LeetCode 994题“腐烂的橘子”。我们掌握了以下核心内容:

  1. 问题抽象:将网格问题转化为图遍历问题,每个单元格是节点,相邻关系是边。
  2. 算法选择:使用广度优先搜索,因为其逐层扩展的特性与时间流逝的分钟数完美对应。
  3. 关键技巧:使用多源BFS来处理多个同时存在的起点(初始腐烂的橘子)。
  4. 实现步骤:初始化 -> 多源入队 -> BFS遍历 -> 最终检查。
  5. 边界与状态检查:在BFS过程中,必须仔细检查数组边界、访问状态和单元格原始值。

通过这个例子,我们看到了BFS算法在解决具有“扩散”或“传播”特性问题时的强大能力。理解并掌握多源BFS,对于解决类似的网格最短路径、感染传播等问题至关重要。

116:使用DFS检测有向图中的环

在本节课中,我们将要学习如何使用深度优先搜索算法来检测有向图中是否存在环。这是解决许多图论问题的基础。

概述

上一节我们介绍了无向图中的环检测。本节中我们来看看有向图的情况。有向图中的环检测逻辑与无向图不同,核心在于识别“后向边”。

核心概念:后向边

在无向图中,我们通过检查邻接节点是否已被访问且不是父节点来判断环。但在有向图中,这个逻辑不适用。

有向图中环检测的关键是判断是否存在一条后向边。后向边是指一条指向当前递归调用栈中某个祖先节点的边。如果存在这样的边,则图中存在环。

我们可以用以下逻辑判断:

如果节点 `v` 已被访问,并且 `v` 位于当前递归路径中,则边 (u->v) 是一条后向边,表明存在环。

算法实现步骤

以下是使用DFS检测有向图环的步骤:

  1. 为所有节点维护两个状态数组:
    • visited[]:记录节点是否被全局访问过。
    • recStack[]:记录节点是否位于当前的递归路径(调用栈)中。
  2. 从任意未访问的节点开始进行DFS遍历。
  3. 对于当前节点 u
    • 将其标记为已访问 (visited[u] = true)。
    • 将其加入当前递归路径 (recStack[u] = true)。
  4. 遍历节点 u 的所有邻接节点 v
    • 如果 v 未被访问,则递归调用DFS函数检查 v。如果递归调用返回 true,则向上返回 true
    • 如果 v 已被访问并且 v 位于当前递归路径中 (recStack[v] == true),则发现后向边,返回 true 表示存在环。
  5. 在从节点 u 返回之前,将其从当前递归路径中移除 (recStack[u] = false)。
  6. 如果所有邻接节点检查完毕都未发现环,则返回 false
  7. 对图中所有节点重复步骤2-6,确保遍历所有连通分量。

代码逻辑解析

让我们通过一个简单的图来解析代码逻辑。假设图中有节点 0, 1, 2, 3,并存在环 (0->2->3->0)。

  1. 从节点0开始DFS,visited[0]recStack[0] 设为 true
  2. 访问节点0的邻居节点2。节点2未被访问,递归调用DFS(2)。
  3. 在DFS(2)中,标记节点2,并访问其邻居节点3。节点3未被访问,递归调用DFS(3)。
  4. 在DFS(3)中,标记节点3,并访问其邻居节点0。
  5. 此时,节点0已被访问 (visited[0] == true),并且它位于当前递归路径中 (recStack[0] == true),因为调用链是 0->2->3。这满足后向边条件,函数返回 true,表明检测到环。

处理不连通图

对于不连通的有向图,我们需要确保检查图中的每一个节点。主函数会遍历所有节点,对每个未访问的节点启动一次DFS。只要任何一次DFS调用返回 true,就说明整个图中存在环。

总结

本节课中我们一起学习了如何使用深度优先搜索检测有向图中的环。核心方法是维护一个递归栈来跟踪当前的遍历路径,并通过判断是否存在指向栈中祖先节点的边(后向边)来确定环的存在。这是一个比无向图检测更严谨的算法,也是理解拓扑排序等高级图算法的基础。

117:图中的拓扑排序(使用DFS) 🗺️

在本节课中,我们将学习图论中的一个重要概念——拓扑排序。我们将了解它的定义、应用场景,并重点学习如何使用深度优先搜索算法来实现它。

概述

拓扑排序是一种针对有向无环图的线性排序算法。它要求图中不能存在环。这种排序的结果是,对于图中的每一条有向边 (u, v),节点 u 在排序中都出现在节点 v 之前。拓扑排序常用于解决具有依赖关系的问题,例如课程安排、软件包安装顺序等。

核心概念与算法

拓扑排序的核心在于处理节点间的依赖关系。以下是使用DFS实现拓扑排序的关键步骤:

  1. 数据结构:我们需要一个图(通常用邻接表表示)、一个记录节点访问状态的布尔数组(visited)和一个栈(stack)来存储最终排序结果。
  2. DFS遍历:从任意一个未访问的节点开始,进行深度优先搜索。
  3. 后序处理:在DFS递归函数从某个节点返回时,将该节点压入栈中。
  4. 输出结果:当所有节点都被访问后,栈中从栈顶到栈底的顺序就是拓扑排序的结果。

其核心逻辑可以用以下伪代码描述:

void DFS(int node, vector<bool>& visited, stack<int>& st, vector<int> adj[]) {
    visited[node] = true;
    for (int neighbor : adj[node]) {
        if (!visited[neighbor]) {
            DFS(neighbor, visited, st, adj);
        }
    }
    st.push(node); // 关键步骤:在递归返回时压栈
}

vector<int> topologicalSort(int V, vector<int> adj[]) {
    vector<bool> visited(V, false);
    stack<int> st;
    for (int i = 0; i < V; i++) {
        if (!visited[i]) {
            DFS(i, visited, st, adj);
        }
    }
    vector<int> result;
    while (!st.empty()) {
        result.push_back(st.top());
        st.pop();
    }
    return result;
}

算法执行过程示例

为了更清晰地理解,让我们通过一个简单的例子来逐步分析算法的执行过程。

假设我们有一个包含5个节点(0到4)的有向无环图,其边为:0->1, 0->2, 1->3, 2->3, 3->4

以下是算法执行的步骤分解:

  1. 初始化所有节点为未访问状态。栈为空。
  2. 从节点0开始DFS。标记节点0为已访问。
  3. 遍历节点0的邻居:节点1和节点2。
  4. 首先访问节点1,标记为已访问。节点1的邻居是节点3。
  5. 访问节点3,标记为已访问。节点3的邻居是节点4。
  6. 访问节点4,标记为已访问。节点4没有出边,DFS递归开始返回。
  7. 返回到节点4的调用处,将节点4压入栈中。
  8. 返回到节点3的调用处,将节点3压入栈中。
  9. 返回到节点1的调用处,将节点1压入栈中。
  10. 回到节点0的调用处,继续处理下一个邻居节点2。
  11. 访问节点2,发现其邻居节点3已被访问,因此不再递归。从节点2返回,将节点2压入栈中。
  12. 最后,从节点0返回,将节点0压入栈中。
  13. 此时栈中内容(从栈顶到栈底)为:[0, 2, 1, 3, 4]
  14. 依次弹出栈中元素,得到的拓扑排序顺序为:0 -> 2 -> 1 -> 3 -> 4。检查所有边,均满足“起点在终点之前”的条件。

代码实现要点

上一节我们通过示例理解了算法的流程,现在来看看如何将其转化为具体的C++代码。以下是实现时需要注意的几个关键点。

  • 图的表示:通常使用邻接表(vector<int> adj[]vector<vector<int>>)来存储图结构,可以高效地访问每个节点的邻居。
  • 访问数组visited数组用于防止重复访问节点,这是DFS算法的基础。
  • 栈的使用:栈(stack<int>)用于在递归返回时收集节点,从而自然形成逆后的排序顺序。最终输出时需要反转。
  • 驱动函数topologicalSort函数负责初始化访问数组,并循环调用DFS以确保所有连通分量都被处理。

总结

本节课中,我们一起学习了拓扑排序。我们首先明确了拓扑排序只适用于有向无环图,其结果是满足所有边方向的一个线性序列。接着,我们深入探讨了如何利用深度优先搜索的后序遍历特性来实现拓扑排序,并通过示例逐步演示了算法的执行过程。最后,我们总结了代码实现的核心数据结构和关键步骤。掌握拓扑排序对于解决任务调度、依赖解析等实际问题非常有帮助。

118:课程表问题 - 使用图拓扑排序

概述

在本节课中,我们将学习如何解决一个经典的 LeetCode 问题——“课程表”。这是一个关于课程安排与先决条件依赖关系的图论问题。我们将通过检测有向图中是否存在环来判断是否能够完成所有课程的学习。


问题描述

给定一个变量 n,代表课程的总数(例如 0 到 n-1 门课程)。同时给定一个先决条件数组,其中每个元素 [a, b] 表示:要学习课程 a,必须先完成课程 b。换句话说,课程 b 是课程 a 的依赖项。

我们的任务是判断学生是否有可能完成所有课程。如果可能,返回 true;如果不可能(即存在循环依赖),则返回 false


问题示例与理解

让我们通过一个例子来理解问题。

假设有 4 门课程:0, 1, 2, 3。
给定的先决条件是:[[1,0], [2,0], [3,1]]
这表示:

  • 学习课程 1 前,需先完成课程 0。
  • 学习课程 2 前,需先完成课程 0。
  • 学习课程 3 前,需先完成课程 1。

这种依赖关系可以构成一个有向图。在这个例子中,存在一个有效的学习顺序,例如:0 -> 1 -> 30 -> 2。因此,答案是 true


何时返回 False?

现在,我们来看一个返回 false 的场景。

假设先决条件是:[[0,1], [1,0]]
这表示:

  • 学习课程 0 前,需先完成课程 1。
  • 学习课程 1 前,需先完成课程 0。

这形成了一个循环依赖(0 依赖 1,1 又依赖 0),学生无法开始任何一门课程。因此,答案是 false


问题本质与解决思路

通过以上分析,我们可以看出,这个问题本质上是一个有向图的环检测问题。

  • 我们将每门课程看作图中的一个节点。
  • 将一个先决条件 [a, b] 看作一条从节点 b 指向节点 a 的有向边(因为 ba 的前提)。
  • 如果在这个有向图中存在环,就意味着存在循环依赖,无法完成所有课程,返回 false
  • 如果图中不存在环,则意味着可以找到一个拓扑排序(即一种合理的学习顺序),返回 true

因此,解决这个问题的核心算法是:检测有向图中是否存在环


算法实现:深度优先搜索 (DFS) 环检测

我们将使用深度优先搜索 (DFS) 来检测图中是否存在环。关键思想是跟踪递归路径上的节点。

以下是实现步骤:

  1. 构建图:根据先决条件列表,构建图的邻接表表示。
  2. 初始化辅助数组
    • visited:标记节点是否被最终访问过。
    • recursionStack(或 path):标记节点是否在当前 DFS 的递归路径上。
  3. 定义 DFS 函数bool isCycleDFS(int node, vector<bool>& visited, vector<bool>& recursionStack, vector<vector<int>>& adj)
    • 将当前节点标记为已访问 (visited[node] = true) 并加入递归路径 (recursionStack[node] = true)。
    • 遍历当前节点的所有邻居:
      • 如果邻居未被访问过,则递归调用 isCycleDFS。如果递归返回 true,则当前路径存在环,向上返回 true
      • 如果邻居已被访问过并且也在当前的递归路径上 (recursionStack[neighbor] == true),则说明发现了一个环,返回 true
    • 在从当前节点返回前,将其从递归路径中移除 (recursionStack[node] = false)。
    • 如果上述情况均未发生,则从当前节点出发未发现环,返回 false
  4. 遍历所有节点:对每个未被访问过的节点,调用 isCycleDFS 函数。
  5. 得出结果:如果任何一次 DFS 调用检测到环,则整体返回 false(无法完成课程)。如果所有 DFS 都未检测到环,则返回 true(可以完成课程)。

以下是核心的环检测函数代码框架:

bool isCycleDFS(int node, vector<bool>& visited, vector<bool>& recursionStack, vector<vector<int>>& adj) {
    visited[node] = true;
    recursionStack[node] = true;

    for(int neighbor : adj[node]) {
        if(!visited[neighbor]) {
            if(isCycleDFS(neighbor, visited, recursionStack, adj)) {
                return true;
            }
        } else if(recursionStack[neighbor]) {
            // 发现环
            return true;
        }
    }

    recursionStack[node] = false; // 回溯
    return false;
}

主函数逻辑如下:

bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
    // 1. 构建邻接表
    vector<vector<int>> adj(numCourses);
    for(auto& pre : prerequisites) {
        // pre[1] -> pre[0]
        adj[pre[1]].push_back(pre[0]);
    }

    // 2. 初始化辅助数组
    vector<bool> visited(numCourses, false);
    vector<bool> recursionStack(numCourses, false);

    // 3. 对每个未访问节点进行DFS环检测
    for(int i = 0; i < numCourses; ++i) {
        if(!visited[i]) {
            if(isCycleDFS(i, visited, recursionStack, adj)) {
                return false; // 发现环,无法完成
            }
        }
    }
    return true; // 未发现环,可以完成
}

总结

本节课我们一起学习了如何解决“课程表”问题。

  1. 问题转化:我们首先将课程和先决条件转化为有向图模型,其中节点是课程,边表示依赖关系。
  2. 核心洞察:我们认识到,能否完成所有课程等价于判断对应的有向图中是否存在环。无环图意味着存在拓扑排序(可行的学习顺序)。
  3. 解决方案:我们使用了基于深度优先搜索 (DFS) 的环检测算法,通过维护一个递归栈 (recursionStack) 来跟踪当前的搜索路径,从而高效地判断环的存在。
  4. 算法实现:我们实现了 canFinish 函数,它构建图,初始化状态数组,并遍历所有节点进行 DFS 检测,最终返回是否存在环的布尔值。

掌握这个问题的解法,不仅解决了“课程表”这一特定问题,也巩固了有向图环检测这一重要的图论基础算法,它是理解拓扑排序和解决许多依赖关系问题的基础。

119:课程表II问题 - 使用图拓扑排序 🗓️➡️📚

在本节课中,我们将学习如何解决一个经典的图论相关问题——课程表II。这是一个关于拓扑排序的应用问题,我们将通过检测有向图中的环并找出一个合法的课程学习顺序来求解。


概述

问题“课程表II”要求我们根据给定的课程数量和先修课程关系,找出一个可以完成所有课程的顺序。如果不可能完成所有课程(例如存在循环依赖),则返回一个空数组。这本质上是一个在有向图中寻找拓扑排序顺序的问题。

上一节我们介绍了图的基本概念,本节中我们来看看如何将课程安排问题建模为图,并使用拓扑排序算法来解决它。


问题建模

首先,我们需要理解如何将问题转化为图论问题。

  • 顶点:每一门课程都是一个顶点。
  • :先修条件 [a, b] 表示要学习课程 a,必须先完成课程 b。这可以建模为一条从 b 指向 a 的有向边。

因此,给定的先修条件列表就定义了一个有向图。我们的目标是找到这个图的一个拓扑排序。如果图中存在环,则拓扑排序不存在,意味着无法完成所有课程。


核心算法逻辑

解决此问题需要两个核心步骤:

  1. 检测图中是否存在环:使用基于深度优先搜索的环检测算法。
  2. 进行拓扑排序:如果图是无环的,则通过DFS或BFS获取拓扑排序顺序。

以下是检测有向图中环的DFS逻辑伪代码:

函数 isCycleDFS(当前节点, 已访问数组, 递归栈数组, 图邻接表):
    标记 当前节点 为已访问
    将 当前节点 加入递归栈

    遍历 当前节点 的每一个邻居节点:
        如果 邻居节点 未被访问:
            如果 isCycleDFS(邻居节点, ...) 返回 true:
                返回 true
        否则如果 邻居节点 已在递归栈中:
            // 发现后向边,存在环
            返回 true

    将 当前节点 移出递归栈
    返回 false

代码实现步骤

现在,让我们将上述逻辑转化为C++代码。以下是解决问题的整体步骤:

  1. 构建图:根据先修条件列表构建图的邻接表表示。
  2. 初始化辅助数组:创建 visited 数组记录访问状态,创建 recStack 数组记录当前递归路径上的节点。
  3. 环检测:遍历每个未访问的节点,调用 isCycleDFS 函数。如果发现环,立即返回空数组。
  4. 拓扑排序:如果图是无环的,则进行拓扑排序。我们使用DFS来实现,并将完成访问的节点压入一个栈中。
  5. 生成结果:将栈中元素依次弹出,即可得到正确的课程学习顺序。

以下是关键部分的代码框架:

class Solution {
public:
    vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
        // 1. 构建邻接表
        vector<vector<int>> adj(numCourses);
        for(auto& edge : prerequisites) {
            adj[edge[1]].push_back(edge[0]); // edge[1] -> edge[0]
        }

        vector<bool> visited(numCourses, false);
        vector<bool> recStack(numCourses, false);
        vector<int> result;
        stack<int> st;

        // 2. 环检测
        for(int i = 0; i < numCourses; i++) {
            if(!visited[i]) {
                if(isCycleDFS(i, visited, recStack, adj)) {
                    return {}; // 发现环,返回空数组
                }
            }
        }

        // 3. 重置visited数组,进行拓扑排序
        fill(visited.begin(), visited.end(), false);
        for(int i = 0; i < numCourses; i++) {
            if(!visited[i]) {
                topologicalSort(i, visited, adj, st);
            }
        }

        // 4. 从栈中获取顺序
        while(!st.empty()) {
            result.push_back(st.top());
            st.pop();
        }
        return result;
    }

private:
    bool isCycleDFS(int node, vector<bool>& visited, vector<bool>& recStack, vector<vector<int>>& adj) {
        // ... 环检测DFS实现 ...
    }

    void topologicalSort(int node, vector<bool>& visited, vector<vector<int>>& adj, stack<int>& st) {
        visited[node] = true;
        for(int neighbor : adj[node]) {
            if(!visited[neighbor]) {
                topologicalSort(neighbor, visited, adj, st);
            }
        }
        st.push(node); // 在递归返回时压栈
    }
};

总结

本节课中我们一起学习了如何利用图论中的拓扑排序来解决“课程表II”问题。我们首先将课程和先修关系建模为有向图,然后通过DFS检测图中是否存在环。如果图是无环的,我们再次使用DFS来获取一个拓扑排序,这个排序结果就是可行的课程学习顺序。这个问题很好地融合了图建模、环检测和拓扑排序多个核心概念。

通过解决这个问题,你应该能够识别出类似依赖调度问题的图结构本质,并熟练运用拓扑排序算法。在下一讲中,我们将继续探索更多有趣的图算法应用。

120:洪水填充算法 - 图问题 🎨

概述

在本节课中,我们将学习一个名为“洪水填充”的经典算法问题。这个问题是LeetCode上的第733题。我们将通过一个简单的深度优先搜索方法来解决它,该方法用于修改图像网格中特定区域的颜色。


问题理解

首先,我们来理解问题。我们有一个 M x N 的二维网格 image,代表一张图像。每个单元格 image[i][j] 代表该像素的颜色值。

我们会获得一个起始坐标 (sr, sc) 和一个新的颜色值 newColor。任务是从这个起始像素开始,将所有在四方向(上、下、左、右)上相连的、且颜色值与起始像素原始颜色相同的像素,都填充为新的颜色。

核心概念

代码描述问题输入和输出:

vector<vector<int>> floodFill(vector<vector<int>>& image, int sr, int sc, int newColor) {
    // 算法实现部分
}

算法思路

上一节我们介绍了问题的定义,本节中我们来看看如何解决它。我们可以将图像网格视为一个,其中每个单元格是一个节点,与它上下左右相邻的单元格之间存在

我们的目标是从源节点 (sr, sc) 开始,遍历所有颜色相同的连通节点,并改变它们的颜色。这自然引出了深度优先搜索广度优先搜索 算法。

我们将使用DFS递归来实现,因为它代码简洁直观。

算法步骤

以下是洪水填充算法的核心步骤:

  1. 获取起始像素 (sr, sc) 的原始颜色 originalColor
  2. 如果 originalColor 已经等于 newColor,则直接返回原图像,无需修改。
  3. 否则,调用DFS函数,从起始点开始递归遍历。
  4. 在DFS函数中:
    • 将当前单元格的颜色改为 newColor
    • 检查当前单元格的四个邻居(上、下、左、右)。
    • 对于每个邻居,如果它在网格范围内,并且其颜色等于 originalColor,则对这个邻居递归调用DFS。

代码实现与解析

理解了算法步骤后,现在让我们将其转化为具体的C++代码。

我们将实现一个DFS辅助函数,它需要知道当前坐标 (i, j)、图像本身、原始颜色和新颜色。

class Solution {
public:
    void dfs(vector<vector<int>>& image, int i, int j, int originalColor, int newColor) {
        // 边界条件检查:索引越界
        if(i < 0 || j < 0 || i >= image.size() || j >= image[0].size())
            return;
        // 条件检查:颜色不是原始颜色,或者已经填充为新颜色
        if(image[i][j] != originalColor || image[i][j] == newColor)
            return;

        // 修改当前像素颜色
        image[i][j] = newColor;

        // 递归调用四个方向的邻居
        dfs(image, i-1, j, originalColor, newColor); // 上
        dfs(image, i+1, j, originalColor, newColor); // 下
        dfs(image, i, j-1, originalColor, newColor); // 左
        dfs(image, i, j-1, originalColor, newColor); // 右 - 注意:此处代码有笔误,应为 j+1
    }

    vector<vector<int>> floodFill(vector<vector<int>>& image, int sr, int sc, int newColor) {
        int originalColor = image[sr][sc];
        if(originalColor != newColor) {
            dfs(image, sr, sc, originalColor, newColor);
        }
        return image;
    }
};

注意:上面的示例代码中有一个故意的笔误(j-1 写了两次),正确的第四个调用应该是 dfs(image, i, j+1, ...); 用于探索右侧邻居。在实际编写和提交代码时务必修正。

关键点解析

  1. 递归基:DFS函数开头的两个if语句是递归终止条件,防止无限递归和无效访问。
  2. 颜色判断image[i][j] != originalColor 确保只修改颜色相同的区域。image[i][j] == newColor 是一个优化,避免对已修改的像素重复处理。
  3. 邻居探索:通过修改 ij 的坐标值(i-1, i+1, j-1, j+1)来访问上下左右四个邻居。

复杂度分析

最后,我们来分析一下这个算法的效率。

  • 时间复杂度O(M * N)。在最坏情况下,我们可能需要访问图像中的每一个像素。
  • 空间复杂度O(M * N)。这主要是递归调用栈的深度。在最坏情况下(例如整个图像都是同一种颜色),递归深度可能达到像素总数。

这个算法简单高效,是解决此类区域填充问题的标准方法。


总结

本节课中,我们一起学习了洪水填充算法

我们首先理解了问题背景:如何基于种子点填充图像中的连续区域。接着,我们将问题建模为图遍历,并使用深度优先搜索策略来访问所有符合条件的相邻像素。然后,我们一步步实现了DFS递归函数,并详细解释了其中的边界条件和颜色判断逻辑。最后,我们分析了算法的时间和空间复杂度。

通过这个例子,你不仅学会了一个LeetCode题目的解法,更重要的是掌握了如何将网格问题转化为图遍历问题,并运用DFS进行解决的通用思路。请务必自己动手实现代码,并尝试使用广度优先搜索 来重新解决这个问题,以加深理解。继续练习,祝你学习愉快!

posted @ 2026-03-28 12:21  布客飞龙V  阅读(1)  评论(0)    收藏  举报