CPT204

CPT204
迟然Lecture 1.1 - Arrays
一、数组基础:为什么需要数组?
核心问题引入
假设要读取 100 个数字并计算平均值,还要找出大于平均值的数字个数。如果为每个数字都创建一个变量,显然不现实。这时,数组就能派上用场,它可以用一个变量存储多个同类型的数据。
数组的定义与特点
- 定义:数组是相同数据类型元素的集合,有固定的长度,通过索引(从 0 开始)访问元素。
- 内存存储:数组在内存的堆区分配空间,变量存储的是数组的引用(内存地址)。
二、数组的声明与创建
1. 声明数组变量
- 语法:
1
2数据类型[] 数组名; // 推荐写法,如 double[] myList;
数据类型 数组名[]; // 允许但不推荐,如 double myList[];
2. 创建数组(分配内存)
- 语法:
1
数组名 = new 数据类型[长度]; // 如 myList = new double[10];
- 示例:
1
2double[] myList; // 声明
myList = new double[10]; // 创建,长度为 10
3. 声明与创建合并
1 | double[] myList = new double[10]; // 一步完成 |
三、数组初始化
1. 默认值
- 数值型(如
int、double):默认值为0。 - 布尔型:默认值为
false。 - 引用类型(如
String):默认值为null。
2. 手动赋值
- 通过索引赋值:
1
2myList[0] = 5.6; // 给第一个元素赋值
myList[1] = 4.5; // 给第二个元素赋值
3. 快捷初始化(数组初始化器)
- 语法:
1
数据类型[] 数组名 = {值1, 值2, 值3, ...}; // 声明、创建、赋值一步完成
- 示例:
1
int[] scores = {85, 90, 78}; // 长度为 3
四、数组常用操作
1. 遍历数组
- 普通 for 循环(带索引):
1
2
3for (int i = 0; i < myList.length; i++) {
System.out.println(myList[i]); // length 是数组属性,获取长度
} - 增强 for 循环(for-each,无索引):
1
2
3for (double value : myList) { // 依次取出每个元素赋值给 value
System.out.println(value);
}
2. 常见算法
- 求和:
1
2
3
4double sum = 0;
for (double num : myList) {
sum += num;
} - 找最大值:
1
2
3
4
5
6double max = myList[0]; // 假设第一个元素是最大值
for (int i = 1; i < myList.length; i++) {
if (myList[i] > max) {
max = myList[i]; // 发现更大值,更新 max
}
} - 数组复制:
- 错误做法(引用复制):
1
2int[] arr1 = {1, 2, 3};
int[] arr2 = arr1; // arr2 和 arr1 指向同一个数组,修改 arr2 会影响 arr1 - 正确做法(元素复制):
1
2
3
4
5int[] arr1 = {1, 2, 3};
int[] arr2 = new int[arr1.length];
for (int i = 0; i < arr1.length; i++) {
arr2[i] = arr1[i]; // 逐个元素复制
}
- 错误做法(引用复制):
五、数组作为方法参数
1. 传递数组(按值传递引用)
- Java 中传递数组时,实际传递的是数组的引用(内存地址),因此方法内对数组元素的修改会影响原始数组。
- 示例:
1
2
3
4
5
6
7
8
9public static void changeArray(int[] arr) {
arr[0] = 100; // 修改数组第一个元素
}
public static void main(String[] args) {
int[] numbers = {1, 2, 3};
changeArray(numbers); // 调用方法
System.out.println(numbers[0]); // 输出 100(原始数组被修改)
}
2. 返回数组
- 方法可以返回一个数组,例如反转数组:
1
2
3
4
5
6
7public static int[] reverse(int[] list) {
int[] result = new int[list.length];
for (int i = 0, j = result.length - 1; i < list.length; i++, j--) {
result[j] = list[i]; // 首尾元素交换
}
return result; // 返回新数组
}
六、搜索与排序算法
1. 线性搜索(适用于未排序数组)
- 原理:从数组第一个元素开始,逐个与目标值比较,直到找到或遍历完数组。
- 代码:
1
2
3
4
5
6
7
8public static int linearSearch(int[] list, int key) {
for (int i = 0; i < list.length; i++) {
if (list[i] == key) {
return i; // 找到,返回索引
}
}
return -1; // 未找到,返回 -1
}
2. 二分搜索(适用于已排序数组,效率更高)
- 原理:每次将数组分成两半,根据中间元素与目标值的大小关系,缩小搜索范围。
- 时间复杂度:O(log n)(最坏情况)。
- 代码:
1
2
3
4
5
6
7
8
9
10public static int binarySearch(int[] list, int key) {
int low = 0, high = list.length - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (key == list[mid]) return mid; // 找到
else if (key < list[mid]) high = mid - 1; // 目标值在左半部分
else low = mid + 1; // 目标值在右半部分
}
return -1; // 未找到
}
3. 选择排序(简单直观,效率较低)
- 原理:每次从剩余元素中找到最小值,与当前位置元素交换。
- 代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14public static void selectionSort(int[] list) {
for (int i = 0; i < list.length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < list.length; j++) {
if (list[j] < list[minIndex]) {
minIndex = j; // 更新最小值索引
}
}
// 交换当前元素与最小值
int temp = list[i];
list[i] = list[minIndex];
list[minIndex] = temp;
}
}
七、练习与 Quiz
练习 1:数组初始化判断
下面哪个是正确的数组初始化方式?
1 | A. int[] arr = new int {1, 2, 3}; |
答案:B(A 缺少长度;C 是 C/C++ 语法,Java 不支持)。
练习 2:增强 for 循环
1 | int[] numbers = {1, 2, 3, 4, 5}; |
答案:3(增强 for 循环中无法通过 num 修改原数组元素)。
练习 3:二分搜索时间复杂度
二分搜索的最坏时间复杂度是?
1 | A. O(1) B. O(n) C. O(log n) D. O(n²) |
答案:C(每次折半,时间复杂度为对数级)。
八、总结
- 重点知识:数组的声明与创建、初始化、遍历、作为方法参数传递、搜索与排序算法。
- 常见误区:
- 数组长度不可变,创建后无法修改。
- 直接赋值
arr2 = arr1是引用复制,而非元素复制。 - 二分搜索必须在已排序数组上使用。
Lecture 1.2 - Objects and Classes
一、核心概念:对象与类
1. 什么是对象?
- 定义:对象是现实世界中可唯一标识的实体,例如“一个圆形”“一个学生”。
- 特征:
- 状态(State):用数据字段(属性)表示,如圆形的半径
radius。 - 行为(Behavior):用方法(函数)表示,如计算面积的
getArea()。
- 状态(State):用数据字段(属性)表示,如圆形的半径
2. 什么是类?
- 定义:类是对象的模板/蓝图,用于创建具有相同属性和方法的对象。
- 组成:
- 数据字段(Fields):如
private double radius;(非静态,属于每个对象)。 - 方法(Methods):如
public double getArea()(非静态方法需通过对象调用)。 - 构造函数(Constructors):用于创建对象,名称必须与类名相同,无返回类型。
- 数据字段(Fields):如
示例:Circle类
1 | public class Circle { |
二、创建对象与内存分析
1. 对象创建步骤
- 声明引用变量:
Circle myCircle;(此时变量值为null,未指向任何对象)。 - 实例化对象:
myCircle = new Circle(5.0);(通过new关键字调用构造函数,分配内存)。
2. 内存示意图
1 | myCircle → { radius: 5.0 } // 对象在堆内存中,引用变量指向对象 |
3. 访问对象成员
- 访问属性:
对象名.属性(需注意权限,如private属性不可直接访问)。 - 调用方法:
对象名.方法名()。
示例:
1 | Circle c1 = new Circle(); // 调用无参构造,radius=1.0 |
三、关键知识点解析
1. 静态(Static) vs 非静态(Non-static)
| 对比项 | 静态成员(类成员) | 非静态成员(实例成员) |
|---|---|---|
| 归属 | 属于类本身,所有对象共享 | 属于单个对象 |
| 访问方式 | 直接通过类名访问(如ClassName.x) |
必须通过对象访问(如obj.x) |
| 常见用途 | 工具方法(如Math.pow())、全局计数器 |
对象的状态数据(如每个Circle的radius) |
示例:静态变量numberOfObjects
1 | public class Circle { |
2. 访问修饰符(Visibility Modifiers)
| 修饰符 | 作用范围 | UML符号 |
|---|---|---|
public |
任何类均可访问 | + |
private |
仅当前类内部可访问 | - |
| 默认 | 同一包(package)内可访问 | 无符号 |
封装最佳实践:
- 将数据字段设为
private,通过访问器(getter)和修改器(setter)方法间接操作。1
2
3
4
5
6
7
8
9
10
11
12
13public class Circle {
private double radius;
// 访问器(getter)
public double getRadius() {
return radius;
}
// 修改器(setter)
public void setRadius(double radius) {
this.radius = radius; // `this`指代当前对象
}
}
3. 基本类型 vs 引用类型
| 类型 | 示例 | 赋值行为 | 内存存储 |
|---|---|---|---|
| 基本类型 | int i = 5; |
复制值(如j = i后,j独立存储5) |
栈内存 |
| 引用类型 | Circle c = new Circle(); |
复制引用(如c2 = c1后,两者指向同一对象) |
引用在栈,对象在堆 |
注意:修改引用类型的属性会影响所有指向它的变量!
1 | Circle c1 = new Circle(5); |
四、UML类图快速入门
UML类图用于可视化类的结构,常见符号:
1 | +-----------------+ |
五、实战练习
练习1:定义学生类
需求:
- 定义
Student类,包含私有字段name(String)、age(int)、isScienceMajor(boolean)。 - 提供构造函数初始化
name和age,isScienceMajor默认false。 - 提供
getter和setter方法。 - 在
main方法中创建学生对象,设置专业为true,并输出信息。
参考答案:
1 | public class Student { |
练习2:静态方法应用
需求:在Circle类中添加静态方法calculateArea(double radius),无需创建对象即可计算面积。
参考答案:
1 | public class Circle { |
六、常见问题解答
为什么构造函数没有返回类型?
构造函数的作用是初始化对象,不是“返回”对象,因此无需声明返回类型(包括void)。静态变量什么时候初始化?
静态变量在类加载时初始化,早于对象创建,且仅初始化一次。this关键字的作用是什么?this指代当前对象,用于区分方法参数与类的字段(如this.radius = radius;)。
Lecture 2.1 - Thinking in Objects
第一部分:核心概念
1. 不可变对象与类(Immutable Objects and Classes)
定义:对象一旦创建,内容不可修改,其类称为不可变类。
条件(缺一不可):
- 所有数据字段为
private - 无
set方法(mutator) - 不返回可变对象的引用(如数组、自定义对象)
示例:不可变的 Circle 类
1 | public class Circle { |
反例:可变的 Student 类
1 | public class Student { |
为什么可变?
1 | Student student = new Student(...); |
练习题1:
判断以下类是否为不可变类?为什么?
1 | public class Book { |
2. 变量作用域(Scope of Variables)
| 类型 | 作用域 | 初始化规则 |
|---|---|---|
| 局部变量 | 从声明处到所在代码块结束 | 必须显式初始化后才能使用 |
| 实例变量 | 整个类范围 | 自动赋默认值(如 int=0) |
| 静态变量 | 整个类范围 | 自动赋默认值 |
示例:
1 | public class ScopeDemo { |
练习题2:
指出以下代码的错误:
1 | public class Test { |
3. this 关键字(The this Keyword)
作用:
- 引用当前对象的成员:当局部变量与成员变量同名时,用
this区分。1
2
3
4
5
6public class Person {
private String name;
public Person(String name) {
this.name = name; // this.name 指成员变量,name 指参数
}
} - 在构造方法中调用重载的构造方法:必须作为构造方法的第一条语句。
1
2
3
4
5public class Circle {
private double radius;
public Circle() { this(1.0); } // 调用 Circle(double radius)
public Circle(double radius) { this.radius = radius; }
}
注意:静态方法中不能使用 this(静态方法属于类,不属于对象)。
4. 类的抽象与封装(Class Abstraction and Encapsulation)
核心思想:
- 抽象:对外暴露功能(API),隐藏实现细节(如方法内部逻辑、数据存储方式)。
- 封装:通过
private修饰符隐藏数据字段,仅通过公共方法(getter/setter)访问。
示例:Loan 类的封装
1 | public class Loan { |
优点:
- 数据安全(防止非法修改)
- 易维护(修改内部实现不影响外部调用)
第二部分:具体类设计实践
1. 设计 Loan 类
需求:计算贷款的月还款额和总还款额。
属性:年利率、贷款年限、贷款金额、创建日期
方法:计算月还款(getMonthlyPayment)、总还款(getTotalPayment)
关键代码:
1 | public double getMonthlyPayment() { |
练习题3:
修改 Loan 类,添加一个 getDailyPayment() 方法,计算每日还款额(假设一年 365 天)。
2. 设计 BMI 类
需求:计算身体质量指数(BMI)并判断健康状态。
公式:
1 | BMI = 体重(磅) × 0.45359237 / (身高(英寸) × 0.0254)^2 |
状态判断:
- <16:严重偏瘦
- 16~18:偏瘦
- 18~24:正常
- 24~29:超重
- ≥29:严重超重
关键代码:
1 | public String getStatus() { |
练习题4:
修改 BMI 类,增加一个构造方法,允许传入体重(公斤)和身高(米),并重载 getBMI() 方法。
第三部分:常用类详解
1. String 类(不可变性与常用方法)
不可变性:
1 | String s = "Java"; |
字符串比较:
| 方法 | 用途 |
|---|---|
equals() |
比较内容是否相等(区分大小写) |
== |
比较引用是否指向同一对象 |
equalsIgnoreCase() |
忽略大小写比较内容 |
示例:
1 | String s1 = "Hello"; |
常用方法:
- 截取子串:
substring(start, end)(左闭右开)1
"HelloWorld".substring(3, 7); // "loWo"
- 查找字符/子串:
indexOf("sub")(返回首次出现的索引,未找到返回 -1) - 替换:
replace("old", "new")(返回新字符串,原字符串不变)
2. StringBuilder 与 StringBuffer
区别:
StringBuilder:非线程安全,效率高(推荐单线程使用)StringBuffer:线程安全,效率低(适合多线程环境)
核心操作:
1 | StringBuilder sb = new StringBuilder("Java"); |
何时使用?:需要频繁修改字符串时(如循环拼接),用 StringBuilder 代替 String,避免生成大量临时对象。
3. 正则表达式(Regular Expressions)
作用:用于字符串匹配、验证、替换、分割。
示例:
- 验证邮箱:
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ - 提取数字:
String[] nums = "a1b2c3".split("\\D"); // ["1", "2", "3"] - 替换特殊字符:
"a$b#c".replaceAll("[\\$#]", "X"); // "aXbXc"
练习题5:
编写正则表达式,验证手机号(以 1开头,第二位 3-9,共11位数字)。
第四部分:综合练习
设计 Course 类
需求:管理课程的学生列表,支持添加学生、获取学生列表、动态扩容。
属性:课程名、学生数组、学生人数
关键代码:
1 | public class Course { |
Lecture 2.2 - Inheritance and Polymorphism
一、继承的基本概念
1. 为什么需要继承?
- 动机:当多个类(如Circle、Rectangle)有共同属性(color、filled)和方法(getArea、getPerimeter)时,通过继承可以避免代码冗余。
- 父类(超类):提取公共属性和方法,如
GeometricObject。 - 子类:继承父类,并添加特有的属性和方法,如
Circle添加radius和getDiameter。
- 父类(超类):提取公共属性和方法,如
2. 如何声明子类?
- 语法:使用
extends关键字。1
2
3
4
5public class Circle extends GeometricObject {
private double radius; // 子类特有的属性
// 子类特有的方法
public double getDiameter() { return 2 * radius; }
} - 子类继承父类的哪些成员?
- 继承:非
private的属性和方法(如color是private,需通过getColor()访问)。 - 不继承:构造方法(需显式或隐式调用父类构造方法)。
- 继承:非
二、构造函数与super关键字
1. 构造函数链(Constructor Chaining)
- 规则:子类构造函数必须先调用父类构造函数(通过
super()或this()),默认调用父类无参构造函数。1
2
3
4
5
6
7
8
9
10
11
12public class Rectangle extends GeometricObject {
public Rectangle(double width, double height) {
super(); // 隐式调用父类无参构造函数(可省略)
this.width = width;
this.height = height;
}
public Rectangle(double width, double height, String color, boolean filled) {
super(color, filled); // 显式调用父类有参构造函数
this.width = width;
this.height = height;
}
} - 示例分析:
1
2
3
4class Person { public Person() { System.out.println("Person"); } }
class Employee extends Person { public Employee() { super(); System.out.println("Employee"); } }
class Faculty extends Employee { public Faculty() { super(); System.out.println("Faculty"); } }
// 执行 new Faculty() 时输出:Person → Employee → Faculty
2. super关键字的作用
- 调用父类构造函数:必须作为子类构造函数的第一行。
- 调用父类被重写的方法:
1
2
3
4
5
6public class Circle {
public String toString() {
return "Circle radius: " + radius + ", " + super.toString(); // 调用父类的toString()
}
}
三、方法重写(Override)与重载(Overload)
1. 方法重写(Override)
- 定义:子类重新实现父类的非
private、非static、非final方法,方法签名必须完全一致。1
2
3
4
5
6
7public class GeometricObject {
public abstract double getArea(); // 抽象方法,子类必须重写
}
public class Circle {
// 注解确保正确重写
public double getArea() { return Math.PI * radius * radius; }
} - 应用场景:多态的基础(后文会讲)。
2. 方法重载(Overload)
- 定义:同一类中,方法名相同但参数列表不同(类型、数量、顺序),与返回值无关。
1
2
3
4public class Calculator {
public int add(int a, int b) { return a + b; } // 重载1
public double add(double a, double b) { return a + b; } // 重载2
}
3. 对比表格
| 特征 | 重写(Override) | 重载(Overload) |
|---|---|---|
| 发生范围 | 子类与父类之间 | 同一类中 |
| 方法签名 | 必须相同 | 必须不同(参数列表) |
| 访问权限 | 子类方法不能比父类更严格(如父类public,子类不能是protected) |
无限制 |
| 多态性 | 支持(动态绑定) | 不支持(静态绑定) |
练习1:判断以下代码是重写还是重载?
1 | class A { public void m(int x) { } } |
四、多态(Polymorphism)与动态绑定(Dynamic Binding)
1. 多态的本质
- 定义:父类引用可以指向子类对象,运行时根据实际对象类型调用方法。
1
2GeometricObject obj1 = new Circle(5); // 父类引用指向子类对象
GeometricObject obj2 = new Rectangle(5, 3); - 优势:代码通用性强,如
displayGeometricObject方法可接收任何GeometricObject子类对象。1
2
3public static void displayGeometricObject(GeometricObject obj) {
System.out.println(obj.getArea()); // 动态绑定:根据obj实际类型调用Circle或Rectangle的getArea()
}
2. 动态绑定机制
- 过程:JVM在运行时根据对象的实际类型(而非引用类型)确定调用哪个方法。
- 若对象是
Circle,调用Circle的getArea();若是Rectangle,调用Rectangle的getArea()。
- 若对象是
- 示例:
1
2
3
4
5
6class Person { public String toString() { return "Person"; } }
class Student extends Person { public String toString() { return "Student"; } }
public static void main(String[] args) {
Person p = new Student(); // 父类引用指向子类对象
System.out.println(p.toString()); // 输出:Student(动态绑定)
}
五、类型转换与instanceof操作符
1. 向上转型(Upcasting)
- 自动转换:子类对象 → 父类引用(安全,因为子类是父类的一种)。
1
2Circle circle = new Circle(5);
GeometricObject geo = circle; // 向上转型(自动)
2. 向下转型(Downcasting)
- 强制转换:父类引用 → 子类对象(需确保引用实际指向子类对象,否则抛出
ClassCastException)。1
2GeometricObject geo = new Circle(5);
Circle circle = (Circle) geo; // 向下转型(安全,因为实际是Circle对象)
3. instanceof操作符
- 作用:在向下转型前检查对象类型,避免异常。
1
2
3
4if (geo instanceof Circle) { // 先判断是否是Circle类型
Circle circle = (Circle) geo;
System.out.println(circle.getRadius());
}
六、Object类的常用方法
1. toString()方法
- 默认实现:返回
类名@哈希码(如Circle@123456),无实际意义。 - 重写建议:返回对象属性的字符串表示。
1
2
3
4
public String toString() {
return "Circle[radius=" + radius + ", color=" + getColor() + "]";
}
2. equals()方法
- 默认实现:比较对象引用地址(
this == obj),而非内容。 - 重写逻辑:
- 判断是否为同一对象(
this == obj)。 - 判断
obj是否为null或类型是否匹配(用instanceof)。 - 比较关键属性是否相等。
1
2
3
4
5
6
7
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Circle other = (Circle) obj;
return Double.compare(other.radius, radius) == 0;
}
- 判断是否为同一对象(
七、容器类:ArrayList与自定义栈(MyStack)
1. ArrayList的优势
- 动态扩容:无需指定初始大小,自动扩展。
- 泛型支持:避免类型转换警告,如
ArrayList<Circle>只能存储Circle对象。1
2
3ArrayList<Circle> circles = new ArrayList<>();
circles.add(new Circle(2));
Circle c = circles.get(0); // 无需强制转换
2. 自定义栈(MyStack)
- 底层实现:用
ArrayList存储元素,实现栈的push(入栈)、pop(出栈)等操作。1
2
3
4
5public class MyStack {
private ArrayList<Object> list = new ArrayList<>();
public void push(Object o) { list.add(o); } // 入栈:添加到末尾
public Object pop() { return list.remove(list.size() - 1); } // 出栈:移除末尾元素
}
八、访问修饰符:protected与final
1. protected修饰符
- 作用:允许同一包内的类和子类(即使不同包)访问。
- 对比表格:
| 修饰符 | 同一类 | 同一包 | 子类(不同包) | 其他包 |
|---|---|---|---|---|
public |
✅ | ✅ | ✅ | ✅ |
protected |
✅ | ✅ | ✅ | ❌ |
默认 |
✅ | ✅ | ❌ | ❌ |
private |
✅ | ❌ | ❌ | ❌ |
2. final修饰符
final变量:常量,不可修改(如final static double PI = 3.14;)。final方法:不可被重写(防止子类修改核心逻辑)。final类:不可被继承(如String类)。
九、课堂测验与巩固
测验1:继承与构造函数
1 | class A { public A() { System.out.print("A"); } public A(int x) { System.out.print("B"); } } |
答案:B(子类构造函数调用super(1),执行父类A(int x)构造函数)。
测验2:重写与多态
1 | class Animal { public void speak() { System.out.println("Animal"); } } |
答案:Woof(动态绑定,调用Dog的speak())。
Lecture 3 - Abstract Classes and Interfaces
一、核心知识点总结
1. 抽象类(Abstract Classes)
- 定义:用
abstract修饰的类,包含抽象方法(abstract方法,无方法体)和具体方法。- 例:
GeometricObject是抽象类,包含getArea()、getPerimeter()抽象方法。
- 例:
- 特点:
- 不能直接实例化(不能用
new创建对象),但可作为父类被继承。 - 非抽象子类必须实现父类的所有抽象方法。
- 抽象类可包含构造方法,供子类通过
super()调用。
- 不能直接实例化(不能用
- 用途:定义通用模板,强制子类实现特定方法(如几何图形的面积计算)。
2. 接口(Interfaces)
- 定义:用
interface声明,仅包含 抽象方法 和 常量(默认public static final)。- 例:
Edible接口定义howToEat()方法,Comparable接口定义compareTo()方法。
- 例:
- 特点:
- 类通过
implements实现接口,需重写所有接口方法(默认public abstract)。 - 支持多继承(一个类可实现多个接口),弥补Java单继承限制。
- 接口可继承其他接口(
interface A extends B, C)。
- 类通过
- 用途:定义行为规范(如“可比较”“可克隆”),解耦实现与接口。
3. 关键接口示例
Comparable接口:- 用于对象排序(如
Arrays.sort()),需实现compareTo(Object o)方法。 - 例:自定义
ComparableRectangle类,通过面积比较大小。
- 用于对象排序(如
Cloneable接口:- 标记接口(无方法),允许对象通过
clone()方法克隆。 - 注意:
Object.clone()是浅拷贝,深拷贝需手动重写clone()。
- 标记接口(无方法),允许对象通过
4. 抽象类 vs. 接口
| 对比维度 | 抽象类 | 接口 |
|---|---|---|
| 成员 | 可包含抽象方法、具体方法、变量 | 只能包含抽象方法和常量(public static final) |
| 继承/实现 | 单继承(extends) |
多实现(implements) |
| 构造方法 | 有(供子类调用) | 无 |
| 实例化 | 不能直接实例化 | 不能直接实例化 |
| 设计目的 | 定义类的模板或部分实现 | 定义行为规范(“能做什么”) |
5. 包装类(Wrapper Classes)
- 作用:将基本数据类型(如
int、double)封装为对象,便于集合类使用。 - 常见类:
Integer、Double、Boolean等,均继承自Number类。 - 特性:
- 不可变(对象创建后值不可改)。
- 支持自动装箱/拆箱(JDK 1.5+):
1
2Integer num = 10; // 自动装箱(int → Integer)
int n = num; // 自动拆箱(Integer → int)
- 常用方法:
- 转换:
parseInt(String s)、doubleValue()。 - 比较:
compareTo()(实现Comparable接口)。
- 转换:
6. 大数类(BigInteger & BigDecimal)
- 用途:处理超出基本类型范围的大整数(
BigInteger)或高精度小数(BigDecimal)。 - 特点:
- 不可变,方法返回新对象(如
add()、multiply())。 BigDecimal需指定精度和舍入模式:1
BigDecimal result = a.divide(b, 20, BigDecimal.ROUND_HALF_UP);
- 不可变,方法返回新对象(如
7. 案例:Rational类(有理数)
- 功能:实现有理数的加减乘除、比较和类型转换(继承
Number接口)。 - 关键点:
- 重写
compareTo()实现比较逻辑。 - 使用
gcd()方法约分,确保分母为正。
- 重写
二、常见问题与解答
- Q:抽象类可以没有抽象方法吗?
A:可以。抽象类的存在意义是禁止实例化,即使没有抽象方法,也可作为基类强制子类继承。 - Q:接口能继承类吗?
A:不能。接口只能继承其他接口(interface A extends B),类通过implements实现接口。 - Q:为什么包装类是不可变的?
A:为保证线程安全和哈希值稳定(如作为HashMap键时),包装类的内部状态不可修改。 - Q:深拷贝和浅拷贝的实现区别?
A:浅拷贝直接复制引用(super.clone()),深拷贝需手动创建新对象并复制引用字段(如h.whenBuilt = (Date)whenBuilt.clone();)。
Lecture 4 - Generics
一、泛型基础:为何需要泛型?
核心作用
泛型能够将类型检查的阶段提前到编译期,避免在运行时出现类型错误,同时还能实现代码的复用。下面通过一个示例来对比说明:
- 无泛型(存在风险) 存在的问题:在运行时才能发现类型不匹配的问题。
1
2
3ArrayList list = new ArrayList();
list.add("1"); // 向列表中添加字符串
Integer i = (Integer) list.get(0); // 运行时会抛出 ClassCastException 异常 - 有泛型(更安全) 优势体现:在编译阶段就能捕获错误,代码的可读性也更强。
1
2
3
4ArrayList<Integer> list = new ArrayList<>(); // JDK 1.7 及之后版本支持钻石语法
list.add("1"); // 编译时就会报错,提示类型不匹配
list.add(1); // 正确操作,自动装箱
Integer i = list.get(0); // 无需进行强制类型转换
关键概念
泛型的本质是对类型进行参数化。就像定义一个“模板”,在使用的时候再指定具体的类型。例如:
- 泛型类模板:
class GenericStack<E>E是类型参数(也被称为类型变量),在创建实例时,需要用具体的类型(如Integer、String)来替换它。
- 实例化方式:
GenericStack<String> stack = new GenericStack<>();
二、动手实践:定义泛型类与接口
案例:自定义泛型栈(GenericStack)
1 | public class GenericStack<E> { |
使用方式:
1 | GenericStack<Integer> intStack = new GenericStack<>(); |
练习 1
尝试定义一个泛型队列GenericQueue<E>,要求包含enqueue(E element)(入队)和dequeue()(出队)方法。
三、深入探究:泛型方法与有界类型
1. 泛型方法
泛型方法可以独立于类而存在,在方法名前使用<T>来声明类型参数。
1 | public static <E> void printArray(E[] array) { // 适用于任何类型的数组 |
2. 有界类型参数(Bounded Type Parameters)
通过extends关键字来限制类型参数的范围,例如<E extends Comparable<E>>表示E必须实现Comparable接口。
案例:排序方法
1 | public static <E extends Comparable<E>> void sort(E[] array) { |
练习 2
定义一个泛型方法max(E a, E b),用于返回两个可比较对象中的最大值,要求使用有界类型参数。
四、通配符(Wildcards):解决类型兼容性问题
通配符用于处理泛型类型之间的继承关系,常见的有以下三种:
| 通配符 | 含义 |
|---|---|
? |
无界通配符,表示任意类型 |
? extends T |
上界通配符,表示类型必须是T的子类(包括T本身) |
? super T |
下界通配符,表示类型必须是T的父类(包括T本身) |
案例:计算栈中数字的最大值
1 | public static double max(GenericStack<? extends Number> stack) { // 接受 Number 及其子类(如 Integer、Double) |
注意事项
- 父子类型关系:
GenericStack<Integer>不是GenericStack<Number>的子类,但GenericStack<Integer>是GenericStack<? extends Number>的子类。 - 写入限制:使用
? extends T时,无法向集合中写入元素(除了null),因为类型不确定;使用? super T时,允许写入T及其子类元素。
练习 3
思考以下代码是否能编译通过,并解释原因:
1 | GenericStack<? extends Number> stack = new GenericStack<Integer>(); |
五、类型擦除(Type Erasure):Java 泛型的实现原理
核心机制
- 编译器会在编译阶段移除泛型类型信息,将其替换为原始类型(如
E替换为Object)。 - 因此,泛型类型在运行时并不存在,例如:这是因为擦除后两者的类型都是
1
System.out.println(new GenericStack<Integer>().getClass() == new GenericStack<String>().getClass()); // 输出:true
GenericStack(原始类型)。
限制条件
由于类型擦除的存在,泛型有以下限制:
- 不能实例化泛型类型:
new E()是不允许的,因为运行时E会被擦除为Object,无法确定具体类型。 - 静态成员不能使用类型参数:静态变量或方法属于类级别,而类型参数是实例级别的。
- 数组操作受限:不能创建泛型数组,如
E[] array = new E[10];,但可以使用ArrayList<E>。
六、实战应用:设计泛型矩阵类
文档中提供了GenericMatrix<E>的示例,它是一个抽象类,通过泛型实现了矩阵的加法和乘法运算,具体的元素操作由子类(如IntegerMatrix、RationalMatrix)来实现。
1 | // 抽象泛型矩阵类 |
练习 4
尝试为GenericMatrix添加一个RationalMatrix子类,用于处理有理数矩阵的运算(有理数类Rational已在文档中定义)。
七、常见问题与最佳实践
- 原始类型(Raw Types):为了兼容旧代码,可以使用原始类型(如
ArrayList),但这会失去泛型的类型安全保障,不建议在新代码中使用。 - 优先使用泛型集合:避免使用
ArrayList,而应使用ArrayList<String>等参数化类型。 - 合理使用通配符:在需要处理类型层次结构时,使用通配符来提高代码的灵活性。
总结:知识图谱
1 | 泛型 |
Lecture 5 - Lists, Stacks, Queues, and Priority Queues
第一部分:Java集合框架概述
1. 集合框架的核心结构
Java集合框架(Java Collections Framework, JCF)是一组用于存储和操作数据的API,包含接口、抽象类和具体类。
- 接口:定义操作规范(如
Collection、List、Queue)。 - 抽象类:提供部分实现(如
AbstractCollection、AbstractList)。 - 具体类:实现具体数据结构(如
ArrayList、LinkedList、PriorityQueue)。
2. 核心接口分类
- 单元素集合(
Collection):存储独立元素,分为:- 有序列表(
List):允许重复,有序(如ArrayList、LinkedList)。 - 集合(
Set):不允许重复(如HashSet、TreeSet)。 - 队列(
Queue):先进先出(FIFO)或优先级排序(如PriorityQueue)。
- 有序列表(
- 键值对集合(
Map):存储键值对(如HashMap、TreeMap)。
第二部分:列表(List)
1. List接口特点
- 有序性:元素按插入顺序存储,可通过索引访问。
- 允许重复:可存储相同元素。
- 核心方法:
add(index, element):在指定位置插入元素。get(index):获取指定索引的元素。remove(index):删除指定索引的元素。size():获取元素个数。
2. 实现类:ArrayList vs. LinkedList
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 数据结构 | 动态数组 | 双向链表 |
| 随机访问 | 快(O(1)) | 慢(O(n),需遍历链表) |
| 插入/删除 | 尾部快,中间/头部慢(需移动元素) | 快(O(1),仅需修改指针) |
| 适用场景 | 频繁查询、较少增删 | 频繁增删、首尾操作 |
3. 示例代码:List基本操作
1 | import java.util.ArrayList; |
第三部分:栈(Stack)
1. 栈的特点
- 后进先出(LIFO):最后插入的元素最先取出。
- 核心方法:
push(element):压入元素(栈顶)。pop():弹出栈顶元素(并删除)。peek():查看栈顶元素(不删除)。empty():判断栈是否为空。
2. 示例代码:栈的基本操作
1 | import java.util.Stack; |
第四部分:队列(Queue)和优先队列(PriorityQueue)
1. 队列(Queue)
- 先进先出(FIFO):元素从队尾插入,队头取出。
- 核心方法:
offer(element):添加元素到队尾(推荐用此方法,避免异常)。poll():取出队头元素(队列为空时返回null)。peek():查看队头元素(队列为空时返回null)。
2. 优先队列(PriorityQueue)
- 按优先级排序:元素按自然顺序(
Comparable)或自定义顺序(Comparator)排列,优先级高的元素先取出。 - 示例代码:优先队列排序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29import java.util.PriorityQueue;
public class PriorityQueueDemo {
public static void main(String[] args) {
// 默认按自然顺序(字符串字典序)
PriorityQueue<String> queue = new PriorityQueue<>();
queue.offer("Oklahoma");
queue.offer("Indiana");
queue.offer("Georgia");
System.out.println("默认排序(字典序):");
while (!queue.isEmpty()) {
System.out.print(queue.poll() + " "); // 输出:Georgia Indiana Oklahoma
}
// 自定义排序(逆序)
PriorityQueue<String> reverseQueue = new PriorityQueue<>(
(a, b) -> b.compareTo(a) // 匿名内部类实现Comparator
);
reverseQueue.offer("Oklahoma");
reverseQueue.offer("Indiana");
reverseQueue.offer("Georgia");
System.out.println("\n逆序排序:");
while (!reverseQueue.isEmpty()) {
System.out.print(reverseQueue.poll() + " "); // 输出:Oklahoma Indiana Georgia
}
}
}
第五部分:集合工具类(Collections)
Collections类提供大量静态方法,用于操作集合:
- 排序:
sort(list)(自然顺序)、sort(list, comparator)(自定义顺序)。 - 搜索:
binarySearch(list, key)(需先排序)。 - 其他:
reverse(list)(反转列表)、shuffle(list)(打乱顺序)、frequency(collection, element)(统计元素出现次数)。
示例代码:排序与搜索
1 | import java.util.ArrayList; |
第六部分:练习与思考
练习1:List性能对比
编写代码对比ArrayList和LinkedList在中间插入元素的性能差异,思考为什么会有这样的结果。
练习2:栈的应用
使用Stack实现一个简单的计算器,计算后缀表达式(如"3 4 + 5 *")。
![[Pasted image 20250604174439.png]]
等价于中缀表达式:(3 + 4) * 5 = 35
1 | import java.util.Stack; |
练习3:优先队列自定义排序
定义一个Student类(包含姓名和成绩),使用PriorityQueue按成绩从高到低排序学生。
Lecture 6 - Sets and Maps
第一部分:Set(集合)
1. Set 基础概念
- 定义:
Set是一种不允许存储重复元素的集合,继承自Collection接口。 - 核心特性:
- 无序性:元素存储顺序不确定(除了
LinkedHashSet和TreeSet)。 - 唯一性:通过
equals()方法判断元素是否重复,确保无重复元素。
- 无序性:元素存储顺序不确定(除了
- 实现类:
- **
HashSet**:基于哈希表实现,无序,查询速度快。 - **
LinkedHashSet**:继承自HashSet,通过链表维护插入顺序,遍历时按插入顺序输出。 - **
TreeSet**:基于红黑树实现,元素有序(自然排序或自定义排序)。
- **
2. HashSet 详解
2.1 创建 HashSet
1 | // 空 HashSet |
2.2 添加元素(add())
- 唯一性验证:通过
hashCode()和equals()判断重复。- 示例:向
HashSet中添加重复元素(字符串会自动去重)。1
2
3
4
5
6Set<String> cities = new HashSet<>();
cities.add("London");
cities.add("Paris");
cities.add("New York");
cities.add("New York"); // 重复,不会被添加
System.out.println(cities); // 输出:[London, Paris, New York](顺序不确定)
- 示例:向
2.3 遍历 HashSet
- 增强 for 循环:
1
2
3for (String city : cities) {
System.out.print(city + " ");
} forEach()方法(Lambda 表达式):1
cities.forEach(city -> System.out.print(city.toLowerCase() + " "));
2.4 常用方法
| 方法 | 说明 |
|---|---|
remove(obj) |
删除指定元素 |
contains(obj) |
检查是否包含元素 |
size() |
获取元素个数 |
clear() |
清空集合 |
3. LinkedHashSet 详解
- 特点:保持元素的插入顺序,遍历时按插入顺序输出。
- 示例:
1
2
3
4
5Set<String> linkedSet = new LinkedHashSet<>();
linkedSet.add("A");
linkedSet.add("B");
linkedSet.add("C");
System.out.println(linkedSet); // 输出:[A, B, C](顺序与插入一致)
4. TreeSet 详解
- 特点:元素自动排序,支持自然排序(
Comparable)或自定义排序(Comparator)。 - 示例:自然排序(字符串按字母顺序)
1
2
3
4
5Set<String> treeSet = new TreeSet<>();
treeSet.add("Z");
treeSet.add("A");
treeSet.add("B");
System.out.println(treeSet); // 输出:[A, B, Z](升序排列) - 自定义排序(Comparator):
1
2
3
4
5
6// 按字符串长度降序排列
Set<String> treeSet = new TreeSet<>(Comparator.comparingInt(String::length).reversed());
treeSet.add("Apple"); // 5字母
treeSet.add("Banana"); // 6字母
treeSet.add("Cat"); // 3字母
System.out.println(treeSet); // 输出:[Banana, Apple, Cat]
5. Set vs List 性能对比
- 场景总结:
- Set:适合存储唯一元素,查询效率高(尤其是
HashSet)。 - List:适合需要索引访问的场景(如
ArrayList、LinkedList)。
- Set:适合存储唯一元素,查询效率高(尤其是
- 性能测试示例(判断元素是否存在):
1
2
3
4
5
6// HashSet 耗时约 10-50ms
long time = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
set.contains(i);
}
System.out.println("HashSet耗时:" + (System.currentTimeMillis() - time) + "ms");
第二部分:Map(映射)
1. Map 基础概念
- 定义:存储键值对(
key-value)的集合,通过key快速查找value。 - 核心特性:
- 键唯一性:
key不能重复(通过equals()判断)。 - 值可重复:
value可以重复。
- 键唯一性:
- 实现类:
- **
HashMap**:无序,基于哈希表,查询速度快。 - **
LinkedHashMap**:保持插入顺序或访问顺序。 - **
TreeMap**:按键排序(自然排序或自定义排序)。
- **
2. HashMap 详解
2.1 创建 HashMap
1 | // 空 HashMap |
2.2 操作方法
- 添加/更新键值对:
put(key, value)(若key已存在,覆盖原有value)。1
2map1.put("Orange", 30); // 添加
map1.put("Apple", 15); // 更新 Apple 的值为 15 - 获取值:
get(key)(若key不存在,返回null)。1
int value = map1.get("Apple"); // value = 15
- 遍历键值对:
1
2
3
4
5
6
7
8
9
10
11
12// 遍历所有键
for (String key : map1.keySet()) {
System.out.println("Key: " + key + ", Value: " + map1.get(key));
}
// 遍历所有键值对(推荐)
for (Map.Entry<String, Integer> entry : map1.entrySet()) {
System.out.println(entry.getKey() + " -> " + entry.getValue());
}
// 使用 forEach()
map1.forEach((key, value) -> System.out.println(key + ": " + value));
2.3 常用方法
| 方法 | 说明 |
|---|---|
remove(key) |
删除指定键的键值对 |
containsKey(key) |
检查是否存在指定键 |
size() |
获取键值对个数 |
keySet() |
获取所有键的 Set |
values() |
获取所有值的 Collection |
3. LinkedHashMap 详解
- 特点:
- 插入顺序:默认按插入顺序存储和遍历。
- 访问顺序:设置
accessOrder = true时,按最后访问顺序排序(最近最少访问 → 最近最多访问)。
- 示例:访问顺序
1
2
3
4
5Map<String, Integer> linkedMap = new LinkedHashMap<>(16, 0.75f, true);
linkedMap.put("A", 1);
linkedMap.put("B", 2);
linkedMap.get("A"); // 访问键 "A"
System.out.println(linkedMap); // 输出:[B, A]("A" 被移动到最后)
4. TreeMap 详解
- 特点:按键的自然排序或自定义排序(
Comparator)排列。 - 示例:自然排序(字符串按字母顺序)
1
2
3
4
5Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("Z", 26);
treeMap.put("A", 1);
treeMap.put("B", 2);
System.out.println(treeMap); // 输出:{A=1, B=2, Z=26}(键升序) - 自定义排序(按值降序):
1
2
3
4
5Map<String, Integer> treeMap = new TreeMap<>(Comparator.comparingInt(Map.Entry::getValue).reversed());
treeMap.put("Apple", 15);
treeMap.put("Banana", 20);
treeMap.put("Cherry", 10);
System.out.println(treeMap); // 输出:{Banana=20, Apple=15, Cherry=10}(值降序)
第三部分:关键练习与注意事项
1. 练习建议
- Set 练习:
- 创建一个
HashSet,存储自定义对象Person(包含姓名和年龄),观察去重效果。若去重失败,重写equals()和hashCode()方法。 - 使用
TreeSet对整数列表进行排序,尝试自定义排序规则(如降序)。
- 创建一个
- Map 练习:
- 创建一个
HashMap,统计字符串中每个字符的出现次数(例如:”abracadabra” → a:5, b:2, r:2, c:1, d:1)。 - 使用
LinkedHashMap实现一个简单的LRU缓存(最近最少使用),设置accessOrder = true,当容量超过限制时删除最旧的元素。
- 创建一个
2. 注意事项
- Set 去重:自定义对象必须重写
equals()和hashCode(),否则HashSet无法正确去重。 - Map 键的选择:建议使用不可变对象(如
String、Integer)作为键,避免键值改变导致哈希冲突。 - 性能优化:
HashSet/HashMap适合高频查询和插入。TreeSet/TreeMap适合需要排序的场景,但性能略低于哈希结构。
总结:核心知识点速查表
| 类型 | 实现类 | 顺序性 | 去重机制 | 典型场景 |
|---|---|---|---|---|
| Set | HashSet |
无序 | hashCode() + equals() |
快速去重、唯一性校验 |
LinkedHashSet |
插入顺序 | 同上 | 需要保持插入顺序的场景 | |
TreeSet |
自然/自定义排序 | compareTo()/Comparator |
排序集合、范围查询 | |
| Map | HashMap |
无序 | hashCode() + equals() |
键值对快速查找 |
LinkedHashMap |
插入/访问顺序 | 同上 | 日志记录、LRU缓存 | |
TreeMap |
自然/自定义排序 | compareTo()/Comparator |
按键排序的统计、范围查询 |
Lecture 8 - Developing Efficient Algorithms
第一讲:算法效率与大O表示法
1. 为什么需要分析算法效率?
- 问题:同一任务可能有不同算法(如线性搜索 vs 二分搜索),如何比较它们的效率?
- 解决方案:用大O表示法(Big O Notation)衡量算法的时间复杂度,关注输入规模增长时的性能变化趋势。
2. 大O表示法的核心思想
- 忽略常数和低阶项:例如,$100n$和$n/2$的时间复杂度均为$O(n)$。
- 关注最坏情况:分析算法在最坏输入下的表现(因为平均情况通常与最坏情况同阶)。
3. 常见时间复杂度排序
$O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n)$
- 示例:
- 常数时间$O(1)$:数组索引访问。
- 线性时间$O(n)$:线性搜索。
- 对数时间$O(\log n)$:二分搜索。
- 平方时间$O(n^2)$:选择排序、插入排序。
第二讲:常见算法的时间复杂度分析
1. 线性搜索 vs 二分搜索
线性搜索:
- 代码:
1
2
3
4
5
6public static int linearSearch(int[] list, int key) {
for (int i = 0; i < list.length; i++) {
if (list[i] == key) return i;
}
return -1;
} - 复杂度:$O(n)$(最坏情况需遍历所有元素)。
- 代码:
二分搜索:
- 条件:数组必须有序。
- 代码:
1
2
3
4
5
6
7
8
9
10public static int binarySearch(int[] list, int key) {
int low = 0, high = list.length - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (key < list[mid]) high = mid - 1;
else if (key == list[mid]) return mid;
else low = mid + 1;
}
return -1;
} - 复杂度:$O(\log n)$(每次将问题规模减半)。
2. 选择排序
- 思想:每次从未排序部分选择最小元素,与未排序部分的第一个元素交换。
- 代码:
1
2
3
4
5
6
7
8
9
10
11public static void selectionSort(double[] list) {
for (int i = 0; i < list.length; i++) {
int minIndex = i;
for (int j = i + 1; j < list.length; j++) {
if (list[j] < list[minIndex]) minIndex = j;
}
double temp = list[i];
list[i] = list[minIndex];
list[minIndex] = temp;
}
} - 复杂度:$O(n^2)$(双重循环)。
3. 插入排序
- 思想:将未排序元素逐个插入已排序部分的正确位置。
- 代码:
1
2
3
4
5
6
7
8
9
10
11public static void insertionSort(int[] list) {
for (int i = 1; i < list.length; i++) {
int current = list[i];
int j = i - 1;
while (j >= 0 && list[j] > current) {
list[j + 1] = list[j];
j--;
}
list[j + 1] = current;
}
} - 复杂度:$O(n^2)$(最坏情况下需移动所有元素)。
第三讲:递归与动态规划
1. 斐波那契数列的递归实现
- 递归公式:$fib(n) = fib(n-1) + fib(n-2)$。
- 代码:
1
2
3
4public static int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
} - 复杂度:$O(2^n)$(重复计算大量子问题)。
2. 动态规划优化斐波那契数列
- 思想:用数组存储已计算的子问题结果,避免重复计算。
- 代码:
1
2
3
4
5
6
7
8
9
10public static int fib(int n) {
if (n <= 1) return n;
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
} - 复杂度:$O(n)$(线性时间)。
第四讲:分治法与回溯法
1. 最近点对问题(分治法)
- 步骤:
- 将点按x坐标排序。
- 递归求解左右两半的最近点对。
- 合并时检查中间带内的点对。
- 复杂度:$O(n \log n)$(排序和递归合并)。
2. 八皇后问题(回溯法)
- 思想:逐行放置皇后,若当前位置冲突则回溯到上一行。
- 代码关键逻辑:
1
2
3
4
5
6
7
8private boolean isValid(int row, int col) {
for (int i = 0; i < row; i++) {
if (queens[i] == col || Math.abs(row - i) == Math.abs(col - queens[i])) {
return false;
}
}
return true;
} - 复杂度:$O(n!)$(理论上最坏情况,但通过剪枝优化后实际效率更高)。
第五讲:其他高效算法
1. 欧几里得算法(求GCD)
- 思想:利用$gcd(m, n) = gcd(n, m % n)$。
- 代码:
1
2
3
4public static int gcd(int m, int n) {
if (n == 0) return m;
return gcd(n, m % n);
} - 复杂度:$O(\log n)$。
2. 筛法求素数(Sieve of Eratosthenes)
- 思想:标记非素数,从2开始筛去所有倍数。
- 代码:
1
2
3
4
5
6
7
8
9
10
11
12
13public static boolean[] sieve(int n) {
boolean[] isPrime = new boolean[n + 1];
Arrays.fill(isPrime, true);
isPrime[0] = isPrime[1] = false;
for (int i = 2; i * i <= n; i++) {
if (isPrime[i]) {
for (int j = i * i; j <= n; j += i) {
isPrime[j] = false;
}
}
}
return isPrime;
} - 复杂度:$O(n \log \log n)$。
总结与练习
- 大O表示法:分析算法复杂度的核心工具,关注主导项。
- 排序算法:选择排序和插入排序均为$O(n^2)$,适合小规模数据。
- 递归与动态规划:动态规划通过存储子问题结果优化递归的指数复杂度。
- 分治与回溯:分治法将问题分解为独立子问题,回溯法通过剪枝避免无效搜索。
练习题:
- 分析以下代码的时间复杂度:
1
2
3
4
5
6
7public static void printPairs(int[] arr) {
for (int i = 0; i < arr.length; i++) {
for (int j = i + 1; j < arr.length; j++) {
System.out.println(arr[i] + ", " + arr[j]);
}
}
}
答案:
这段代码的时间复杂度为$O(n^2)$($n$为数组长度)。
- 分析过程:
代码的核心是两层嵌套循环,用于打印数组中所有不重复的元素对(即每个元素对$(arr[i], arr[j])$满足$i < j$)。
1. 外层循环的迭代次数
外层循环的变量$i$从$0$遍历到$arr.length - 1$(共$n$次迭代,$n = arr.length$)。
2. 内层循环的迭代次数
对于外层循环的每一次迭代$i$,内层循环的变量$j$从$i + 1$遍历到$arr.length - 1$。因此:
- 当$i = 0$时,内层循环执行$n - 1$次($j = 1, 2, …, n-1$);
- 当$i = 1$时,内层循环执行$n - 2$次($j = 2, 3, …, n-1$);
- …
- 当$i = n - 2$时,内层循环执行$1$次($j = n - 1$);
- 当$i = n - 1$时,内层循环不执行($j = n$超出数组范围)。
3. 总操作次数
总操作次数为内层循环所有迭代次数的和:
$\text{总次数} = (n-1) + (n-2) + … + 2 + 1 + 0 = \frac{n(n-1)}{2}$
4. 时间复杂度结论
根据大$O$表示法的规则(忽略低阶项和常数系数),$\frac{n(n-1)}{2}$可简化为$O(n^2)$。
结论:该代码的时间复杂度为$O(n^2)$(平方阶)。
- 用动态规划实现斐波那契数列,尝试优化空间复杂度。
答案:
动态规划实现斐波那契数列(空间优化版)
斐波那契数列的定义为:
$F(0) = 0,\ F(1) = 1,\ F(n) = F(n-1) + F(n-2)$($n \geq 2$)
传统动态规划方法需要使用数组存储中间结果(空间复杂度$O(n)$),但通过观察可以发现,计算$F(n)$仅需前两项$F(n-1)$和$F(n-2)$,因此可以用两个变量代替数组,将空间复杂度优化至$O(1)$。
实现代码(Java)
1 | public class Fibonacci { |
代码说明
- 边界条件处理:
当$n = 0$或$n = 1$时,直接返回$0$或$1$,无需计算。 - 变量初始化:
prev1初始化为$F(0) = 0$(对应$F(n-2)$);prev2初始化为$F(1) = 1$(对应$F(n-1)$)。
- 迭代计算:
从$i = 2$开始循环至$n$,每次计算当前项$F(i) = prev1 + prev2$,然后更新prev1和prev2为下一次迭代的前驱值。 - 空间优化:
仅用两个变量保存前驱状态,避免了数组存储,空间复杂度从$O(n)$优化至$O(1)$。
复杂度分析
- 时间复杂度:$O(n)$(仅需一次循环遍历到$n$)。
- 空间复杂度:$O(1)$(仅用两个变量保存前驱状态)。
测试结果
运行 main 方法输出前10项斐波那契数:
1 | F(0) = 0 |
Lecture 9 - Sorting
第一部分:排序算法基础
1. 什么是排序算法?
排序算法是将一组数据按特定顺序(如升序、降序)排列的方法。评价标准包括:
- 时间复杂度:算法运行所需时间(如 O(n²)、O(n log n))。
- 空间复杂度:算法占用的额外内存空间(如原地排序 O(1))。
- 稳定性:相同元素的相对顺序在排序后是否保持不变。
2. 常见排序算法分类
| 算法名称 | 时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|
| 冒泡排序 | O(n²) | O(1) | 稳定 |
| 归并排序 | O(n log n) | O(n) | 稳定 |
| 快速排序 | 平均 O(n log n) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(1) | 不稳定 |
第二部分:冒泡排序(Bubble Sort)
1. 核心思想
通过反复比较相邻元素,将较大的元素逐步“冒泡”到数组末尾。
- 示例:排序
[4, 3, 2, 1]- 第1轮:比较
4↔3、4↔2、4↔1,最大元素4到位,数组变为[3, 2, 1, 4]。 - 第2轮:比较
3↔2、3↔1,次大元素3到位,数组变为[2, 1, 3, 4]。 - 第3轮:比较
2↔1,数组有序[1, 2, 3, 4]。
- 第1轮:比较
2. 代码实现(Java)
1 | public static void bubbleSort(int[] list) { |
3. 时间复杂度
- 最好情况(数组已排序):O(n)(仅需1轮遍历)。
- 最坏情况(完全逆序):O(n²)(需 n(n-1)/2 次比较)。
🎯 练习1
用冒泡排序对 [5, 1, 4, 2, 8] 排序,手动模拟每一轮过程。
第三部分:归并排序(Merge Sort)
1. 核心思想(分治算法)
- 分解:将数组分成两半,递归排序每一半。
- 合并:将两个已排序的子数组合并成一个有序数组。
- 示例:排序
[5, 2, 9, 1]- 分解:
[5,2]和[9,1]→ 继续分解为[5],[2],[9],[1]。 - 合并:
[2,5]和[1,9]→ 最终合并为[1, 2, 5, 9]。
- 分解:
2. 合并操作(双指针法)
1 | private static void merge(int[] left, int[] right, int[] list) { |
3. 时间复杂度
- 每一层合并时间为 O(n),递归深度为 log n,总时间 **O(n log n)**。
- 空间复杂度 **O(n)**(需临时数组存储合并结果)。
🎯 练习2
归并排序的稳定性如何?为什么?
第四部分:快速排序(Quick Sort)
1. 核心思想(分治算法)
- 选枢轴:选择一个元素(如第一个元素)作为枢轴。
- 分区:将数组分为两部分,左半部分 ≤ 枢轴,右半部分 > 枢轴。
- 递归排序:对左右两部分递归应用快速排序。
- 示例:排序
[5, 2, 9, 3, 8],选5为枢轴- 分区后:
[2, 3]5[9, 8]→ 递归排序左右部分,最终得到[2, 3, 5, 8, 9]。
- 分区后:
2. 分区操作(双指针法)
1 | private static int partition(int[] list, int first, int last) { |
3. 时间复杂度
- 平均情况:O(n log n)(枢轴平衡分区)。
- 最坏情况:O(n²)(枢轴每次为最小/最大值,如已排序数组)。
🎯 练习3
快速排序的空间复杂度为什么是 O(log n)?最坏情况如何优化?
第五部分:二叉堆与堆排序(Heap Sort)
1. 二叉堆(最大堆)
- 定义:完全二叉树,每个节点 ≥ 子节点(最大堆)。
- 数组表示:根节点下标0,左子节点2i+1,右子节点2i+2,父节点(i-1)/2。
- 操作:
- 上浮:插入元素后,与父节点比较并交换,直到堆性质满足。
- 下沉:删除根节点后,与子节点比较并交换,重建堆。
2. 堆排序步骤
- 建堆:将数组转换为最大堆(自底向上调整)。
- 排序:重复删除根节点(最大值),放到数组末尾,重建堆。
- 示例:数组
[10, 5, 3, 4, 1]- 建堆后:
[10, 5, 3, 4, 1](根节点最大)。 - 第1次删除10,数组变为
[5,4,3,1,10],重建堆后根节点5。 - 最终排序为
[1, 3, 4, 5, 10]。
- 建堆后:
3. 代码片段(建堆)
1 | public static void heapSort(int[] array) { |
4. 时间复杂度
- 建堆时间 O(n),排序时间 O(n log n),总时间 **O(n log n)**。
第六部分:总结与对比
| 算法 | 核心思想 | 最佳场景 |
|---|---|---|
| 冒泡排序 | 相邻交换 | 小规模数据,教育场景 |
| 归并排序 | 分治+合并 | 大规模数据,需稳定性 |
| 快速排序 | 分治+分区 | 通用场景,效率最高 |
| 堆排序 | 二叉堆+删除根 | 原地排序,内存敏感场景 |
课后任务
- 编程练习:
- 实现冒泡排序,测试最好/最坏情况性能。
- 用归并排序对
[3, 1, 4, 1, 5, 9, 2, 6]排序,输出每一步合并结果。
- 思考问题:
- 为什么快速排序平均效率高于归并排序?
- 堆排序如何实现原地排序?
Lecture 10 - Graphs and Applications
第一讲:图的基本概念
1.1 什么是图?
- 定义:图是由 顶点(Vertices/Nodes) 和 边(Edges) 组成的结构,记作 $G=(V, E)$。
- 顶点:表示对象(如城市、用户、节点)。
- 边:表示对象之间的关系(如路线、连接、依赖)。
- 示例:
- 社交网络:顶点是用户,边是“关注”关系(有向边)。
- 城市地图:顶点是城市,边是道路(无向边,可能带距离权重)。
1.2 图的分类
| 分类维度 | 类型 | 特点 |
|---|---|---|
| 边的方向 | 无向图 | 边无方向(如 $A-B$ 与 $B-A$ 是同一条边) |
| 有向图 | 边有方向(如 $A \to B$ 表示从 A 到 B 的单向关系) | |
| 边的权重 | 无权图 | 边仅表示连接,无数值属性 |
| 有权图 | 边带有权重(如距离、成本、时间) | |
| 特殊结构 | 简单图 | 无自环(顶点到自身的边)和并行边(两顶点间多条边) |
| 非简单图 | 允许自环或并行边 | |
| 连通性 | 连通图 | 任意两顶点之间存在路径 |
| 非连通图 | 存在至少两顶点无法通过路径连接 |
练习1:判断以下场景属于哪种图:
- 地铁线路图(站点连接,无方向)→ 无向无权图
- 航班路线图(城市间单向航线,带飞行时间)→ 有向有权图
- 化学分子结构(原子为顶点,化学键为边,无权重)→ 无向简单图
1.3 关键术语
- 相邻顶点(Adjacent Vertices):通过一条边直接连接的顶点(如 $A$ 和 $B$ 相邻)。
- 顶点的度(Degree):
- 无向图:顶点关联的边数(如顶点 $A$ 有3条边,度为3)。
- 有向图:分为 入度(In-Degree) 和 出度(Out-Degree)。
- 环(Cycle):起点和终点相同的路径(如 $A \to B \to C \to A$)。
- 树(Tree):连通且无环的无向图,边数为 $|V| - 1$。
- 生成树(Spanning Tree):包含图中所有顶点的树(边数为 $|V| - 1$,连通无环)。
第二讲:图的表示方法
图的表示方法影响算法的效率,常见方法有两种:邻接矩阵 和 邻接表。
2.1 邻接矩阵(Adjacency Matrix)
- 定义:用 $n \times n$ 矩阵($n$ 为顶点数)表示顶点间的连接关系。
- 无权图:矩阵元素 $matrix[i][j] = 1$ 表示存在边,$0$ 表示不存在。
- 有权图:矩阵元素为边的权重,不存在的边用 $\infty$ 表示。
- 示例:无向无权图(顶点 $A(0), B(1), C(2)$,边 $A-B, A-C, B-C$):
$\begin{bmatrix}0 & 1 & 1 \1 & 0 & 1 \1 & 1 & 0 \\end{bmatrix}$ - 优缺点:
- ✅ 优点:访问速度快(直接查矩阵)。
- ❌ 缺点:存储空间大(稀疏图浪费空间)。
2.2 邻接表(Adjacency List)
- 定义:每个顶点对应一个列表,存储其相邻顶点或边。
- 无权图:列表存储相邻顶点索引(如 $0: [1, 2]$ 表示顶点0相邻顶点1和2)。
- 有权图:列表存储边对象(包含目标顶点和权重)。
- 实现方式(Java示例):
1
2
3
4
5// 无权图邻接表
List<List<Integer>> adjList = new ArrayList<>();
adjList.add(Arrays.asList(1, 2)); // 顶点0的相邻顶点
adjList.add(Arrays.asList(0, 2)); // 顶点1的相邻顶点
adjList.add(Arrays.asList(0, 1)); // 顶点2的相邻顶点 - 优缺点:
- ✅ 优点:节省空间(仅存储存在的边)。
- ❌ 缺点:访问相邻顶点需遍历列表。
练习2:用邻接表表示有向有权图(顶点 $A(0) \to B(1)$ 权重5,$B(1) \to C(2)$ 权重3):
1 | // 有权图邻接表,使用自定义边类 |
第三讲:图的遍历算法
遍历图的目的是访问每个顶点一次,常见算法有 深度优先搜索(DFS) 和 广度优先搜索(BFS)。
3.1 深度优先搜索(DFS)
- 核心思想:从起点出发,尽可能深入访问相邻顶点,遇无法继续则回溯。
- 实现方式:递归或栈(此处用递归)。
- 算法步骤:
- 标记当前顶点为已访问。
- 递归访问所有未访问的相邻顶点。
- 示例(无向图 $0-1-2-3$):
- 起点0 → 访问0 → 访问1 → 访问2 → 访问3(假设邻接顺序为0→1→2→3)。
- 代码框架(Java):
1
2
3
4
5
6
7
8
9boolean[] visited; // 标记是否访问过
void dfs(int v) {
visited[v] = true; // 标记当前顶点
for (int neighbor : adjList.get(v)) { // 遍历相邻顶点
if (!visited[neighbor]) {
dfs(neighbor); // 递归访问
}
}
}
3.2 广度优先搜索(BFS)
- 核心思想:从起点出发,逐层访问相邻顶点(类似“水波扩散”)。
- 实现方式:队列(FIFO)。
- 算法步骤:
- 将起点入队,标记为已访问。
- 取出队首顶点,访问其所有未访问的相邻顶点,标记并加入队列。
- 示例(无向图 $0-1-2-3$):
- 起点0 → 队列:[0] → 访问0,相邻顶点1入队 → 队列:[1] → 访问1,相邻顶点2入队 → 队列:[2] → 访问2,相邻顶点3入队 → 访问3。
- 代码框架(Java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16boolean[] visited;
void bfs(int start) {
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
visited[start] = true;
while (!queue.isEmpty()) {
int v = queue.poll(); // 取出队首顶点
for (int neighbor : adjList.get(v)) {
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.offer(neighbor); // 相邻顶点入队
}
}
}
}
练习3:画出以下图的DFS和BFS遍历顺序(起点0):
1 | 邻接表:0: [1, 3], 1: [0, 2], 2: [1], 3: [0, 4], 4: [3] |
- DFS顺序:0 → 1 → 2 → 3 → 4
- BFS顺序:0 → 1 → 3 → 2 → 4
第四讲:有权图与经典算法
4.1 最小生成树(MST)
- 定义:连通有权图中,边权和最小的生成树。
- 应用场景:构建最低成本的网络(如城市间管道铺设)。
- 算法:
- Prim算法:从顶点出发,每次选当前生成树与未加入顶点的最小边(适用于稠密图)。
- Kruskal算法:从边出发,按权重排序后选最小边,用并查集避免成环(适用于稀疏图)。
4.2 最短路径算法(Dijkstra)
- 定义:求单源顶点到其他所有顶点的最短路径(边权非负)。
- 应用场景:导航系统(如最短驾车路线)。
- 核心思想:维护每个顶点到源点的当前最短距离,逐步更新(贪心策略)。
- 示例:源点0到顶点3的最短路径:
1
2图:0→1(权重2),0→2(权重5),1→3(权重3),2→3(权重1)
最短路径:0→2→3(总权重5+1=6)
Lecture 11 - Binary Search Trees
一、二叉树基础
1. 二叉树定义
- 结构:要么为空,要么由根节点、左子树、右子树组成。
- 节点:每个节点包含一个元素(
element),以及左右子节点指针(left/right)。 - 术语:
- 叶子节点:没有子节点的节点(如示例中的45、57、67、107)。
- 子树:节点的左/右分支形成的树。
2. 二叉树的表示(Java代码)
1 | class TreeNode<E> { |
- 每个节点是一个泛型类,可存储任意类型数据。
二、二叉搜索树(BST)核心性质
1. BST的特性
- 无重复元素(默认)。
- 有序性:
- 左子树所有节点的值 小于 根节点的值。
- 右子树所有节点的值 大于 根节点的值。
- 示例:
1
2
3
4
560
/ \
55 100
/ \ / \
45 57 67 107- 55 < 60 < 100,45 < 55 < 57,67 < 100 < 107,符合BST规则。
2. BST的优势
- 高效搜索、插入、删除(理想情况下时间复杂度为 $O(\log n)$)。
- 中序遍历有序:遍历结果为升序序列(如上述示例中序遍历结果:45, 55, 57, 60, 67, 100, 107)。
三、BST核心操作:插入元素
1. 插入逻辑
- 树为空:直接创建根节点。
- 树非空:
- 用
current和parent指针找到插入位置:- 若插入值 < 当前节点值,移动到左子树;
- 若插入值 > 当前节点值,移动到右子树;
- 若相等(重复),插入失败。
- 找到
parent后,根据值大小决定插入左或右子节点。
- 用
2. 代码实现(Java)
1 | public boolean insert(E element) { |
3. 示例:插入101到BST
- 步骤跟踪:
- 从根节点60开始,101 > 60 → 移动到右子节点100。
- 101 > 100 → 移动到右子节点107。
- 101 < 107 →
parent=107,current=null,插入到107的左子节点。
- 结果:107的左子节点为101,树结构保持BST性质。
四、BST核心操作:搜索元素
1. 搜索逻辑
- 从根节点开始,逐层比较:
- 若搜索值 < 当前节点值,搜索左子树;
- 若搜索值 > 当前节点值,搜索右子树;
- 相等则返回
true,遍历完仍未找到则返回false。
2. 代码实现(Java)
1 | public boolean search(E element) { |
3. 示例:搜索57
- 60 → 55(左子节点)→ 57(右子节点),找到,返回
true。
五、树的遍历
1. 深度优先遍历(递归实现)
- 前序遍历(根→左→右):访问根节点,递归遍历左子树,递归遍历右子树。
- 中序遍历(左→根→右):BST中序遍历结果为升序序列(重点!)。
- 后序遍历(左→右→根):常用于删除树前释放资源。
2. 代码示例(中序遍历)
1 | public void inorder() { |
3. 广度优先遍历(层序遍历)
- 按层访问:根节点→第一层子节点→第二层子节点→…
- 实现:使用队列,先入队根节点,每次出队一个节点,入队其左右子节点(若存在)。
好的!我们继续学习剩余内容,包括 BST删除操作、迭代器实现、Huffman编码 和 时间复杂度分析。
六、BST核心操作:删除元素
删除操作是BST中最复杂的操作,需分情况处理,确保删除后仍满足BST性质。
1. 准备工作:定位节点
- 用
current指向待删除节点,parent指向其父节点。 - 若
current为null,说明元素不存在,删除失败。
2. 情况1:当前节点无左子树
- 操作:直接用当前节点的右子树替代当前节点。
- 示例:删除节点10(父节点20):
1
2
3
4
5
6原树结构: 删除后结构:
20 20
/ \ / \
10 40 40
\ / \
16 30 80 - 代码逻辑:
1
2
3
4
5
6
7
8
9if (current.left == null) {
if (parent == null) { // 删除根节点
root = current.right;
} else if (current == parent.left) { // 当前节点是左子节点
parent.left = current.right;
} else { // 当前节点是右子节点
parent.right = current.right;
}
}
3. 情况2:当前节点有左子树
- 核心思想:找到当前节点左子树中的 最右节点(rightMost),用其值替换当前节点的值,然后删除该最右节点(因其无右子树,转化为情况1)。
- 原因:rightMost是左子树中最大的值,替换后不会破坏BST的有序性。
- 步骤:
- 找到
current左子树的最右节点rightMost及其父节点parentOfRightMost。 - 用
rightMost的值覆盖current的值。 - 删除
rightMost(若rightMost有左子树,连接到parentOfRightMost的右侧)。
- 找到
- 示例:删除节点20(左子树最右节点为16):
1
2
3
4
5
6原树结构: 替换后结构:
20 16
/ \ / \
10 40 10 40
\ / \
16 30 80 - 代码逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17else { // 当前节点有左子树
TreeNode<E> parentOfRightMost = current;
TreeNode<E> rightMost = current.left;
// 找到左子树的最右节点
while (rightMost.right != null) {
parentOfRightMost = rightMost;
rightMost = rightMost.right;
}
// 用rightMost的值替换current的值
current.element = rightMost.element;
// 删除rightMost节点(其无右子树,可能有左子树)
if (parentOfRightMost == current) { // rightMost是current的左子节点
parentOfRightMost.left = rightMost.left;
} else {
parentOfRightMost.right = rightMost.left;
}
}
七、使用迭代器遍历树
1. 为什么需要迭代器?
- 传统的
inorder()、preorder()方法只能打印元素,而迭代器允许通过foreach循环灵活处理元素(如转换为大写、过滤等)。
2. 实现步骤(以中序遍历为例)
- 定义迭代器类:实现
java.util.Iterator接口。 - 中序遍历存储元素:在迭代器构造函数中执行中序遍历,将元素存入列表
list。 - 实现迭代器方法:
hasNext():检查列表是否有剩余元素。next():返回当前元素并移动指针。remove():删除当前元素(需重新构建列表)。
3. 代码示例
1 | public Iterator<E> iterator() { |
4. 使用示例
1 | BST<String> tree = new BST<>(); |
八、Huffman编码:二叉树在数据压缩中的应用
1. 核心思想
- 用 频率高的字符分配短编码,频率低的分配长编码,减少整体编码长度。
- 通过构建Huffman树(带权路径长度最短的二叉树)实现。
2. 构建Huffman树的步骤(贪心算法)
- 统计字符频率:例如,字符串”Mississippi”中,’i’和’s’频率为4,’M’为1,’p’为2。
- 创建初始森林:每个字符作为一棵单节点树,权重为频率。
- 合并最小权重树:
- 每次选取权重最小的两棵树,创建父节点,权重为两者之和。
- 重复直到只剩一棵树(Huffman树)。
3. 生成字符编码
- 从根节点到叶子节点的路径中,左分支记为
0,右分支记为1。 - 示例:
1
2
3
4
512(根)
/ \
5 7
/ \ / \
M p s i (频率:1,2,4,4)
编码:M=000, p=001, s=01, i=1
4. 代码实现(简化版)
1 | // 构建Huffman树 |
九、时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 插入/搜索/删除 | $O(h)$(h为树高) | 最坏情况树退化为链表,$h=n$,$O(n)$;理想情况平衡树,$h=\log n$,$O(\log n)$ |
| 遍历(前/中/后序) | $O(n)$ | 每个节点访问一次 |
十、课程总结
1. 核心知识点
- BST性质:左<根<右,中序遍历有序。
- 三大操作:
- 插入:找到合适位置,保持BST性质。
- 搜索:逐层比较,高效查找。
- 删除:分情况处理,维护BST结构。
- 遍历方式:前序/中序/后序(递归)、层序(队列)。
- 迭代器:通过中序遍历实现,支持灵活遍历。
- Huffman编码:利用二叉树压缩数据,贪心算法构建最优树。
2. 代码结构建议
- TreeNode:作为内部类,封装节点属性。
- BST类:实现插入、删除、搜索、遍历、迭代器等方法。
- Huffman相关类:独立实现树构建和编码生成。
3. 练习建议
- 手动绘制:删除BST中的节点,如删除示例树中的60,观察结构变化。
- 编码实践:实现Huffman编码的完整流程(编码+解码)。
- 扩展思考:如何优化BST的性能?(提示:平衡树如AVL、红黑树)
课后问题
- 删除BST节点时,为什么选择左子树的最右节点替换,而不是右子树的最左节点?
- Huffman树中,为什么合并最小权重的两棵树能得到最优编码?
- 尝试用迭代方式实现前序遍历(非递归)。
Lecture 12 - AVL Trees
一、AVL树核心知识
1. 为什么需要AVL树?
- 问题:普通二叉搜索树(BST)在最坏情况下高度为O(n),导致搜索、插入、删除的时间复杂度退化为O(n)。
- 目标:通过保持树的“平衡”,使高度近似O(log n),从而保证操作的高效性。
- 平衡定义:每个节点的左右子树高度差(平衡因子)绝对值不超过1。平衡因子 = 右子树高度 - 左子树高度。
2. AVL树的平衡操作(四种旋转)
当插入或删除节点导致某节点平衡因子为±2时,需要通过旋转重新平衡。以下是四种旋转的核心逻辑:
LL旋转(左左失衡,右单旋)
- 场景:节点A左子树高度比右子树高2,且左子节点B是左重(平衡因子≤0)。
- 操作:将B提升为新根,A变为B的右子节点,B原来的右子树T3变为A的左子树。
- 示例:
1
2
3
4
5A(-2) B(0)
/ / \
B(-1) → T1 A(0)
/ \ / \
T1 T2 T2 T3
RR旋转(右右失衡,左单旋)
- 场景:节点A右子树高度比左子树高2,且右子节点B是右重(平衡因子≥0)。
- 操作:将B提升为新根,A变为B的左子节点,B原来的左子树T2变为A的右子树。
- 示例:
1
2
3
4
5A(+2) B(0)
\ / \
B(+1) → A(0) T3
/ \ / \
T1 T2 T1 T2
LR旋转(左右失衡,先左旋B再右旋A)
- 场景:节点A左子树高度比右子树高2,且左子节点B是右重(平衡因子+1)。
- 操作:先对B进行左旋转,再对A进行右旋转,中间节点C成为新根。
- 示例:
1
2
3
4
5
6
7A(-2) C(0)
/ / \
B(+1) → B(0) A(0)
\ / \
C(0) T1 T2
/ \
T1 T2
RL旋转(右左失衡,先右旋B再左旋A)
- 场景:节点A右子树高度比左子树高2,且右子节点B是左重(平衡因子-1)。
- 操作:先对B进行右旋转,再对A进行左旋转,中间节点C成为新根。
- 示例:
1
2
3
4
5
6
7A(+2) C(0)
\ / \
B(-1) → A(0) B(0)
/ / \
C(0) T1 T2
/ \
T1 T2
3. AVL树的实现关键点
- 节点设计:每个节点包含高度属性,用于计算平衡因子。
- 插入/删除流程1
- 按BST规则插入/删除节点。
- 从插入/删除节点向上遍历,更新路径上所有节点的高度。
- 检查每个节点的平衡因子,触发对应的旋转操作。
- 代码示例(插入后的平衡处理):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21private void balancePath(E e) {
ArrayList<TreeNode<E>> path = path(e); // 获取插入路径
for (int i = path.size() - 1; i >= 0; i--) {
AVLTreeNode<E> A = (AVLTreeNode<E>) path.get(i);
updateHeight(A); // 更新高度
int bf = balanceFactor(A);
if (bf == -2) { // 左重
if (balanceFactor((AVLTreeNode<E>) A.left) <= 0) {
balanceLL(A, parent); // LL旋转
} else {
balanceLR(A, parent); // LR旋转
}
} else if (bf == +2) { // 右重
if (balanceFactor((AVLTreeNode<E>) A.right) >= 0) {
balanceRR(A, parent); // RR旋转
} else {
balanceRL(A, parent); // RL旋转
}
}
}
}
4. 时间复杂度分析
- 树的高度为O(log n),平衡操作(旋转)是常数时间,因此插入、删除、搜索的时间复杂度均为**O(log n)**。
二、哈希表核心知识
1. 什么是哈希表?
- 目标:通过哈希函数将键映射到数组索引,实现O(1)平均时间复杂度的查找、插入、删除。
- 核心组件:
- 哈希函数:将键转换为哈希码(整数),再压缩为数组索引(如
hashCode % N,N为表大小)。 - 碰撞处理:当不同键映射到同一索引时,通过开放寻址法或链地址法处理。
- 哈希函数:将键转换为哈希码(整数),再压缩为数组索引(如
2. 哈希函数与哈希码
- 哈希码计算:
- 基本类型:直接转换(如int类型直接作为哈希码)。
- 长类型/双精度:通过高32位和低32位异或(XOR)避免信息丢失。
- 字符串:通过多项式哈希(如
hash = 31 * hash + charValue)。
- 压缩方法:使用取模运算
hashCode % N,N通常为质数以减少碰撞。
3. 碰撞处理方法
开放寻址法:在数组中寻找下一个可用位置。
- 线性探测:依次检查下一个位置(
(index + 1) % N,(index + 2) % N…),易形成“聚类”。 - 二次探测:检查
(index + j²) % N(j=1,2,3…),减少聚类但可能导致“二次聚类”。 - 双重哈希:使用第二个哈希函数确定步长(如
h'(key) = 7 - key % 7),步长需与N互质。
链地址法(分离链接):每个索引对应一个链表(或链表+红黑树,如Java 8后的HashMap)。
- 优点:处理碰撞简单,无负载因子上限。
- 缺点:链表过长时查找效率下降,需结合负载因子扩容。
4. 负载因子与重新哈希
- 负载因子:
λ = 元素数 / 表大小,开放寻址法通常需λ<0.5,链地址法λ<0.9。 - 重新哈希:当λ超过阈值时,增大表大小(如翻倍),重新计算所有键的索引,避免性能下降。
5. MyHashMap实现关键点
- 哈希函数优化:通过位移和异或(如Java的
supplementalHash)让哈希码更均匀分布。 - 链地址法实现:使用数组存储链表,插入时检查键是否存在,存在则更新值,否则添加到链表尾部。
- 代码示例(哈希函数):
1
2
3
4private int hash(int hashCode) {
hashCode = (hashCode >>> 20) ^ (hashCode >>> 12); // 混合高位
return hashCode ^ (hashCode >>> 7) ^ (hashCode >>> 4); // 进一步混合
}
三、学习建议
- AVL树部分:
- 手动绘制插入/删除示例(如文档中的插入25,20,5,34…的过程),观察旋转如何恢复平衡。
- 编写旋转函数时,先理清节点指针的变化(父节点、左右子树的连接),再处理高度更新。
- 哈希表部分:
- 对比不同碰撞处理方法的优缺点,思考为何Java的HashMap在链长较长时转为红黑树。
- 实现MyHashMap时,注意负载因子的阈值设置和重新哈希的时机,避免频繁扩容。
- 实践步骤:
- 先完成AVL树的节点定义、插入/删除的BST部分,再逐步添加平衡逻辑。
- 哈希表从简单的链地址法开始,实现put、get、containsKey方法,再考虑扩容和性能优化。
四、课后小问题(检验理解)
- AVL树中,LL旋转和LR旋转的区别是什么?什么场景下触发?
- 哈希表中,为什么负载因子过高会影响性能?重新哈希的代价是什么?
- 链地址法和开放寻址法在删除操作上有何不同?(提示:开放寻址法删除需标记“墓碑”)









