mirror of
https://github.com/krahets/hello-algo.git
synced 2025-07-04 20:31:59 +08:00
Release Rust code to documents. (#656)
This commit is contained in:
@ -13,9 +13,9 @@ use list_node::ListNode;
|
||||
/* 在链表的节点 n0 之后插入节点 P */
|
||||
#[allow(non_snake_case)]
|
||||
pub fn insert<T>(n0: &Rc<RefCell<ListNode<T>>>, P: Rc<RefCell<ListNode<T>>>) {
|
||||
let n1 = n0.borrow_mut().next.take();
|
||||
P.borrow_mut().next = n1;
|
||||
n0.borrow_mut().next = Some(P);
|
||||
let n1 = n0.borrow_mut().next.take();
|
||||
P.borrow_mut().next = n1;
|
||||
n0.borrow_mut().next = Some(P);
|
||||
}
|
||||
|
||||
/* 删除链表的节点 n0 之后的首个节点 */
|
||||
@ -28,7 +28,7 @@ pub fn remove<T>(n0: &Rc<RefCell<ListNode<T>>>) {
|
||||
let n1 = node.borrow_mut().next.take();
|
||||
n0.borrow_mut().next = n1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 访问链表中索引为 index 的节点 */
|
||||
pub fn access<T>(head: Rc<RefCell<ListNode<T>>>, index: i32) -> Rc<RefCell<ListNode<T>>> {
|
||||
@ -37,7 +37,7 @@ pub fn access<T>(head: Rc<RefCell<ListNode<T>>>, index: i32) -> Rc<RefCell<ListN
|
||||
return access(node.clone(), index - 1);
|
||||
}
|
||||
return head;
|
||||
}
|
||||
}
|
||||
|
||||
/* 在链表中查找值为 target 的首个节点 */
|
||||
pub fn find<T: PartialEq>(head: Rc<RefCell<ListNode<T>>>, target: T, index: i32) -> i32 {
|
||||
@ -46,7 +46,7 @@ pub fn find<T: PartialEq>(head: Rc<RefCell<ListNode<T>>>, target: T, index: i32)
|
||||
return find(node.clone(), target, index + 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Driver Code */
|
||||
fn main() {
|
||||
@ -74,7 +74,7 @@ fn main() {
|
||||
remove(&n0);
|
||||
print!("删除节点后的链表为 ");
|
||||
print_util::print_linked_list(&n0);
|
||||
|
||||
|
||||
/* 访问节点 */
|
||||
let node = access(n0.clone(), 3);
|
||||
println!("链表中索引 3 处的节点的值 = {}", node.borrow().val);
|
||||
|
@ -4,72 +4,72 @@
|
||||
* Author: xBLACICEx (xBLACKICEx@outlook.com), sjinzh (sjinzh@gmail.com)
|
||||
*/
|
||||
|
||||
include!("../include/include.rs");
|
||||
include!("../include/include.rs");
|
||||
|
||||
/* Driver Code */
|
||||
fn main() {
|
||||
// 初始化列表
|
||||
let mut list: Vec<i32> = vec![ 1, 3, 2, 5, 4 ];
|
||||
print!("列表 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 访问元素
|
||||
let num = list[1];
|
||||
println!("\n访问索引 1 处的元素,得到 num = {num}");
|
||||
|
||||
// 更新元素
|
||||
list[1] = 0;
|
||||
print!("将索引 1 处的元素更新为 0 ,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 清空列表
|
||||
list.clear();
|
||||
print!("\n清空列表后 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 尾部添加元素
|
||||
list.push(1);
|
||||
list.push(3);
|
||||
list.push(2);
|
||||
list.push(5);
|
||||
list.push(4);
|
||||
print!("\n添加元素后 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 中间插入元素
|
||||
list.insert(3, 6);
|
||||
print!("\n在索引 3 处插入数字 6 ,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 删除元素
|
||||
list.remove(3);
|
||||
print!("\n删除索引 3 处的元素,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 通过索引遍历列表
|
||||
let mut _count = 0;
|
||||
for _ in 0..list.len() {
|
||||
_count += 1;
|
||||
}
|
||||
|
||||
// 直接遍历列表元素
|
||||
_count = 0;
|
||||
for _n in &list {
|
||||
_count += 1;
|
||||
}
|
||||
// 或者
|
||||
// list.iter().for_each(|_| _count += 1);
|
||||
// let _count = list.iter().fold(0, |_count, _| _count + 1);
|
||||
|
||||
// 拼接两个列表
|
||||
let mut list1 = vec![ 6, 8, 7, 10, 9 ];
|
||||
list.append(&mut list1); // append(移动) 之后 list1 为空!
|
||||
// list.extend(&list1); // extend(借用) list1 能继续使用
|
||||
print!("\n将列表 list1 拼接到 list 之后,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 排序列表
|
||||
list.sort();
|
||||
print!("\n排序列表后 list = ");
|
||||
print_util::print_array(&list);
|
||||
}
|
||||
fn main() {
|
||||
// 初始化列表
|
||||
let mut list: Vec<i32> = vec![ 1, 3, 2, 5, 4 ];
|
||||
print!("列表 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 访问元素
|
||||
let num = list[1];
|
||||
println!("\n访问索引 1 处的元素,得到 num = {num}");
|
||||
|
||||
// 更新元素
|
||||
list[1] = 0;
|
||||
print!("将索引 1 处的元素更新为 0 ,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 清空列表
|
||||
list.clear();
|
||||
print!("\n清空列表后 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 尾部添加元素
|
||||
list.push(1);
|
||||
list.push(3);
|
||||
list.push(2);
|
||||
list.push(5);
|
||||
list.push(4);
|
||||
print!("\n添加元素后 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 中间插入元素
|
||||
list.insert(3, 6);
|
||||
print!("\n在索引 3 处插入数字 6 ,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 删除元素
|
||||
list.remove(3);
|
||||
print!("\n删除索引 3 处的元素,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 通过索引遍历列表
|
||||
let mut _count = 0;
|
||||
for _ in 0..list.len() {
|
||||
_count += 1;
|
||||
}
|
||||
|
||||
// 直接遍历列表元素
|
||||
_count = 0;
|
||||
for _n in &list {
|
||||
_count += 1;
|
||||
}
|
||||
// 或者
|
||||
// list.iter().for_each(|_| _count += 1);
|
||||
// let _count = list.iter().fold(0, |_count, _| _count + 1);
|
||||
|
||||
// 拼接两个列表
|
||||
let mut list1 = vec![ 6, 8, 7, 10, 9 ];
|
||||
list.append(&mut list1); // append(移动) 之后 list1 为空!
|
||||
// list.extend(&list1); // extend(借用) list1 能继续使用
|
||||
print!("\n将列表 list1 拼接到 list 之后,得到 list = ");
|
||||
print_util::print_array(&list);
|
||||
|
||||
// 排序列表
|
||||
list.sort();
|
||||
print!("\n排序列表后 list = ");
|
||||
print_util::print_array(&list);
|
||||
}
|
||||
|
@ -6,8 +6,8 @@
|
||||
|
||||
include!("../include/include.rs");
|
||||
|
||||
#[allow(dead_code)]
|
||||
/* 列表类简易实现 */
|
||||
#[allow(dead_code)]
|
||||
struct MyList {
|
||||
nums: Vec<i32>, // 数组(存储列表元素)
|
||||
capacity: usize, // 列表容量
|
||||
@ -154,4 +154,4 @@ fn main() {
|
||||
print!("\n扩容后的列表 list = ");
|
||||
print_util::print_array(&list.to_array());
|
||||
print!(" ,容量 = {} ,长度 = {}", list.capacity(), list.size());
|
||||
}
|
||||
}
|
||||
|
@ -4,40 +4,40 @@
|
||||
* Author: xBLACICEx (xBLACKICEx@outlook.com), sjinzh (sjinzh@gmail.com)
|
||||
*/
|
||||
|
||||
include!("../include/include.rs");
|
||||
include!("../include/include.rs");
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::thread_rng;
|
||||
|
||||
/* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */
|
||||
fn random_numbers(n: i32) -> Vec<i32> {
|
||||
// 生成数组 nums = { 1, 2, 3, ..., n }
|
||||
let mut nums = (1..=n).collect::<Vec<i32>>();
|
||||
// 随机打乱数组元素
|
||||
nums.shuffle(&mut thread_rng());
|
||||
nums
|
||||
}
|
||||
|
||||
/* 查找数组 nums 中数字 1 所在索引 */
|
||||
fn find_one(nums: &[i32]) -> Option<usize> {
|
||||
for i in 0..nums.len() {
|
||||
// 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
|
||||
// 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
|
||||
if nums[i] == 1 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/* Driver Code */
|
||||
fn main() {
|
||||
for _ in 0..10 {
|
||||
let n = 100;
|
||||
let nums = random_numbers(n);
|
||||
let index = find_one(&nums).unwrap();
|
||||
print!("\n数组 [ 1, 2, ..., n ] 被打乱后 = ");
|
||||
print_util::print_array(&nums);
|
||||
println!("\n数字 1 的索引为 {}", index);
|
||||
}
|
||||
}
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::thread_rng;
|
||||
|
||||
/* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */
|
||||
fn random_numbers(n: i32) -> Vec<i32> {
|
||||
// 生成数组 nums = { 1, 2, 3, ..., n }
|
||||
let mut nums = (1..=n).collect::<Vec<i32>>();
|
||||
// 随机打乱数组元素
|
||||
nums.shuffle(&mut thread_rng());
|
||||
nums
|
||||
}
|
||||
|
||||
/* 查找数组 nums 中数字 1 所在索引 */
|
||||
fn find_one(nums: &[i32]) -> Option<usize> {
|
||||
for i in 0..nums.len() {
|
||||
// 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
|
||||
// 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
|
||||
if nums[i] == 1 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/* Driver Code */
|
||||
fn main() {
|
||||
for _ in 0..10 {
|
||||
let n = 100;
|
||||
let nums = random_numbers(n);
|
||||
let index = find_one(&nums).unwrap();
|
||||
print!("\n数组 [ 1, 2, ..., n ] 被打乱后 = ");
|
||||
print_util::print_array(&nums);
|
||||
println!("\n数字 1 的索引为 {}", index);
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@ fn coin_change_ii_dp(coins: &[i32], amt: usize) -> i32 {
|
||||
}
|
||||
|
||||
/* 零钱兑换 II:状态压缩后的动态规划 */
|
||||
fn coin_change_dp_ii_comp(coins: &[i32], amt: usize) -> i32 {
|
||||
fn coin_change_ii_dp_comp(coins: &[i32], amt: usize) -> i32 {
|
||||
let n = coins.len();
|
||||
// 初始化 dp 表
|
||||
let mut dp = vec![0; amt + 1];
|
||||
|
@ -26,7 +26,7 @@ fn bubble_sort(nums: &mut [i32]) {
|
||||
fn bubble_sort_with_flag(nums: &mut [i32]) {
|
||||
// 外循环:未排序区间为 [0, i]
|
||||
for i in (1..nums.len()).rev() {
|
||||
let mut flag = false; // 初始化标志位
|
||||
let mut flag = false; // 初始化标志位
|
||||
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
|
||||
for j in 0..i {
|
||||
if nums[j] > nums[j + 1] {
|
||||
@ -34,10 +34,10 @@ fn bubble_sort_with_flag(nums: &mut [i32]) {
|
||||
let tmp = nums[j];
|
||||
nums[j] = nums[j + 1];
|
||||
nums[j + 1] = tmp;
|
||||
flag = true; // 记录交换元素
|
||||
flag = true; // 记录交换元素
|
||||
}
|
||||
}
|
||||
if !flag {break}; // 此轮冒泡未交换任何元素,直接跳出
|
||||
if !flag {break}; // 此轮冒泡未交换任何元素,直接跳出
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
include!("../include/include.rs");
|
||||
|
||||
/*插入排序 */
|
||||
/* 插入排序 */
|
||||
fn insertion_sort(nums: &mut [i32]) {
|
||||
// 外循环:已排序元素数量为 1, 2, ..., n
|
||||
for i in 1..nums.len() {
|
||||
|
@ -4,14 +4,10 @@
|
||||
* Author: xBLACKICEx (xBLACKICE@outlook.com)
|
||||
*/
|
||||
|
||||
// 快速排序
|
||||
struct QuickSort;
|
||||
// 快速排序(中位基准数优化)
|
||||
struct QuickSortMedian;
|
||||
// 快速排序(尾递归优化)
|
||||
struct QuickSortTailCall;
|
||||
|
||||
/* 快速排序 */
|
||||
struct QuickSort;
|
||||
|
||||
impl QuickSort {
|
||||
/* 哨兵划分 */
|
||||
fn partition(nums: &mut [i32], left: usize, right: usize) -> usize {
|
||||
@ -19,15 +15,15 @@ impl QuickSort {
|
||||
let (mut i, mut j) = (left, right);
|
||||
while i < j {
|
||||
while i < j && nums[j] >= nums[left] {
|
||||
j -= 1; // 从右向左找首个小于基准数的元素
|
||||
j -= 1; // 从右向左找首个小于基准数的元素
|
||||
}
|
||||
while i < j && nums[i] <= nums[left] {
|
||||
i += 1; // 从左向右找首个大于基准数的元素
|
||||
i += 1; // 从左向右找首个大于基准数的元素
|
||||
}
|
||||
nums.swap(i, j); // 交换这两个元素
|
||||
}
|
||||
nums.swap(i, left); // 将基准数交换至两子数组的分界线
|
||||
i // 返回基准数的索引
|
||||
nums.swap(i, left); // 将基准数交换至两子数组的分界线
|
||||
i // 返回基准数的索引
|
||||
}
|
||||
|
||||
/* 快速排序 */
|
||||
@ -45,6 +41,8 @@ impl QuickSort {
|
||||
}
|
||||
|
||||
/* 快速排序(中位基准数优化) */
|
||||
struct QuickSortMedian;
|
||||
|
||||
impl QuickSortMedian {
|
||||
/* 选取三个元素的中位数 */
|
||||
fn median_three(nums: &mut [i32], left: usize, mid: usize, right: usize) -> usize {
|
||||
@ -66,17 +64,17 @@ impl QuickSortMedian {
|
||||
nums.swap(left, med);
|
||||
// 以 nums[left] 作为基准数
|
||||
let (mut i, mut j) = (left, right);
|
||||
while i < j {
|
||||
while i < j && nums[j] >= nums[left] {
|
||||
j -= 1; // 从右向左找首个小于基准数的元素
|
||||
}
|
||||
while i < j && nums[i] <= nums[left] {
|
||||
i += 1; // 从左向右找首个大于基准数的元素
|
||||
}
|
||||
nums.swap(i, j); // 交换这两个元素
|
||||
}
|
||||
nums.swap(i, left); // 将基准数交换至两子数组的分界线
|
||||
i // 返回基准数的索引
|
||||
while i < j {
|
||||
while i < j && nums[j] >= nums[left] {
|
||||
j -= 1; // 从右向左找首个小于基准数的元素
|
||||
}
|
||||
while i < j && nums[i] <= nums[left] {
|
||||
i += 1; // 从左向右找首个大于基准数的元素
|
||||
}
|
||||
nums.swap(i, j); // 交换这两个元素
|
||||
}
|
||||
nums.swap(i, left); // 将基准数交换至两子数组的分界线
|
||||
i // 返回基准数的索引
|
||||
}
|
||||
|
||||
/* 快速排序 */
|
||||
@ -94,23 +92,24 @@ impl QuickSortMedian {
|
||||
}
|
||||
|
||||
/* 快速排序(尾递归优化) */
|
||||
struct QuickSortTailCall;
|
||||
|
||||
impl QuickSortTailCall {
|
||||
/* 哨兵划分 */
|
||||
fn partition(nums: &mut [i32], left: usize, right: usize) -> usize {
|
||||
// 以 nums[left] 作为基准数
|
||||
let (mut i, mut j) = (left, right);
|
||||
|
||||
while i < j {
|
||||
while i < j && nums[j] >= nums[left] {
|
||||
j -= 1; // 从右向左找首个小于基准数的元素
|
||||
j -= 1; // 从右向左找首个小于基准数的元素
|
||||
}
|
||||
while i < j && nums[i] <= nums[left] {
|
||||
i += 1; // 从左向右找首个大于基准数的元素
|
||||
i += 1; // 从左向右找首个大于基准数的元素
|
||||
}
|
||||
nums.swap(i, j); // 交换这两个元素
|
||||
}
|
||||
nums.swap(i, left); // 将基准数交换至两子数组的分界线
|
||||
i // 返回基准数的索引
|
||||
nums.swap(i, left); // 将基准数交换至两子数组的分界线
|
||||
i // 返回基准数的索引
|
||||
}
|
||||
|
||||
/* 快速排序(尾递归优化) */
|
||||
@ -131,6 +130,7 @@ impl QuickSortTailCall {
|
||||
}
|
||||
}
|
||||
|
||||
/* Driver Code */
|
||||
fn main() {
|
||||
/* 快速排序 */
|
||||
let mut nums = [2, 4, 1, 0, 3, 5];
|
||||
@ -146,4 +146,4 @@ fn main() {
|
||||
let mut nums = [2, 4, 1, 0, 3, 5];
|
||||
QuickSortTailCall::quick_sort(0, (nums.len() - 1) as i32, &mut nums);
|
||||
println!("快速排序(尾递归优化)完成后 nums = {:?}", nums);
|
||||
}
|
||||
}
|
||||
|
@ -212,4 +212,4 @@ fn main() {
|
||||
/* 判断双向队列是否为空 */
|
||||
let is_empty = deque.is_empty();
|
||||
print!("\n双向队列是否为空 = {}", is_empty);
|
||||
}
|
||||
}
|
||||
|
@ -100,6 +100,12 @@
|
||||
List<int> nums = [1, 3, 2, 5, 4];
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
|
||||
```
|
||||
|
||||
## 数组优点
|
||||
|
||||
**在数组中访问元素非常高效**。由于数组元素被存储在连续的内存空间中,因此计算数组元素的内存地址非常容易。给定数组首个元素的地址和某个元素的索引,我们可以使用以下公式计算得到该元素的内存地址,从而直接访问此元素。
|
||||
@ -185,6 +191,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
[class]{}-[func]{randomAccess}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{random_access}
|
||||
```
|
||||
|
||||
## 数组缺点
|
||||
|
||||
**数组在初始化后长度不可变**。由于系统无法保证数组之后的内存空间是可用的,因此数组长度无法扩展。而若希望扩容数组,则需新建一个数组,然后把原数组元素依次拷贝到新数组,在数组很大的情况下,这是非常耗时的。
|
||||
@ -255,6 +267,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
**数组中插入或删除元素效率低下**。如果我们想要在数组中间插入一个元素,由于数组元素在内存中是“紧挨着的”,它们之间没有空间再放任何数据。因此,我们不得不将此索引之后的所有元素都向后移动一位,然后再把元素赋值给该索引。
|
||||
|
||||

|
||||
@ -325,6 +343,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
[class]{}-[func]{insert}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{insert}
|
||||
```
|
||||
|
||||
删除元素也类似,如果我们想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。值得注意的是,删除元素后,原先末尾的元素变得“无意义”了,我们无需特意去修改它。
|
||||
|
||||

|
||||
@ -395,6 +419,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
总结来看,数组的插入与删除操作有以下缺点:
|
||||
|
||||
- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。
|
||||
@ -471,6 +501,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
[class]{}-[func]{traverse}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{traverse}
|
||||
```
|
||||
|
||||
**数组查找**。通过遍历数组,查找数组内的指定元素,并输出对应索引。
|
||||
|
||||
=== "Java"
|
||||
@ -539,6 +575,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
[class]{}-[func]{find}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{find}
|
||||
```
|
||||
|
||||
## 数组典型应用
|
||||
|
||||
数组是最基础的数据结构,在各类数据结构和算法中都有广泛应用。
|
||||
|
@ -163,6 +163,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
!!! question "尾节点指向什么?"
|
||||
|
||||
我们将链表的最后一个节点称为「尾节点」,其指向的是“空”,在 Java, C++, Python 中分别记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。在不引起歧义的前提下,本书都使用 $\text{None}$ 来表示空。
|
||||
@ -360,6 +366,12 @@
|
||||
n3.next = n4;
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linked_list.rs"
|
||||
|
||||
```
|
||||
|
||||
## 链表优点
|
||||
|
||||
**链表中插入与删除节点的操作效率高**。例如,如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` ,我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多。
|
||||
@ -432,6 +444,12 @@
|
||||
[class]{}-[func]{insert}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linked_list.rs"
|
||||
[class]{}-[func]{insert}
|
||||
```
|
||||
|
||||
在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P` 。
|
||||
|
||||

|
||||
@ -502,6 +520,12 @@
|
||||
[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linked_list.rs"
|
||||
[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
## 链表缺点
|
||||
|
||||
**链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而,链表无法直接访问任意节点,这是因为系统需要从头节点出发,逐个向后遍历直至找到目标节点。例如,若要访问链表索引为 `index`(即第 `index + 1` 个)的节点,则需要向后遍历 `index` 轮。
|
||||
@ -572,6 +596,12 @@
|
||||
[class]{}-[func]{access}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linked_list.rs"
|
||||
[class]{}-[func]{access}
|
||||
```
|
||||
|
||||
**链表的内存占用较大**。链表以节点为单位,每个节点除了保存值之外,还需额外保存指针(引用)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。
|
||||
|
||||
## 链表常用操作
|
||||
@ -644,6 +674,12 @@
|
||||
[class]{}-[func]{find}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linked_list.rs"
|
||||
[class]{}-[func]{find}
|
||||
```
|
||||
|
||||
## 常见链表类型
|
||||
|
||||
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
@ -823,6 +859,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 链表典型应用
|
||||
|
@ -116,6 +116,12 @@
|
||||
List<int> list = [1, 3, 2, 5, 4];
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
|
||||
```
|
||||
|
||||
**访问与更新元素**。由于列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
|
||||
|
||||
=== "Java"
|
||||
@ -224,6 +230,12 @@
|
||||
list[1] = 0; // 将索引 1 处的元素更新为 0
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
|
||||
```
|
||||
|
||||
**在列表中添加、插入、删除元素**。相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(N)$ 。
|
||||
|
||||
=== "Java"
|
||||
@ -432,6 +444,12 @@
|
||||
list.removeAt(3); // 删除索引 3 处的元素
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
|
||||
```
|
||||
|
||||
**遍历列表**。与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
|
||||
|
||||
=== "Java"
|
||||
@ -599,6 +617,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
|
||||
```
|
||||
|
||||
**拼接两个列表**。给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
|
||||
|
||||
=== "Java"
|
||||
@ -690,6 +714,12 @@
|
||||
list.addAll(list1); // 将列表 list1 拼接到 list 之后
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
|
||||
```
|
||||
|
||||
**排序列表**。排序也是常用的方法之一。完成列表排序后,我们便可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法。
|
||||
|
||||
=== "Java"
|
||||
@ -768,6 +798,12 @@
|
||||
list.sort(); // 排序后,列表元素从小到大排列
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
|
||||
```
|
||||
|
||||
## 列表实现 *
|
||||
|
||||
为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点:
|
||||
@ -843,3 +879,9 @@
|
||||
```dart title="my_list.dart"
|
||||
[class]{MyList}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="my_list.rs"
|
||||
[class]{MyList}-[func]{}
|
||||
```
|
||||
|
@ -76,6 +76,12 @@
|
||||
[class]{}-[func]{preOrder}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="preorder_traversal_i_compact.rs"
|
||||
[class]{}-[func]{pre_order}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 尝试与回退
|
||||
@ -158,6 +164,12 @@
|
||||
[class]{}-[func]{preOrder}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="preorder_traversal_ii_compact.rs"
|
||||
[class]{}-[func]{pre_order}
|
||||
```
|
||||
|
||||
在每次“尝试”中,我们通过将当前节点添加进 `path` 来记录路径;而在“回退”前,我们需要将该节点从 `path` 中弹出,**以恢复本次尝试之前的状态**。
|
||||
|
||||
观察该过程,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作是互为逆向的。
|
||||
@ -271,6 +283,12 @@
|
||||
[class]{}-[func]{preOrder}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="preorder_traversal_iii_compact.rs"
|
||||
[class]{}-[func]{pre_order}
|
||||
```
|
||||
|
||||
剪枝是一个非常形象的名词。在搜索过程中,**我们“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而实现搜索效率的提高。
|
||||
|
||||

|
||||
@ -543,6 +561,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
接下来,我们基于框架代码来解决例题三。状态 `state` 为节点遍历路径,选择 `choices` 为当前节点的左子节点和右子节点,结果 `res` 是路径列表。
|
||||
|
||||
=== "Java"
|
||||
@ -721,6 +745,22 @@
|
||||
[class]{}-[func]{backtrack}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="preorder_traversal_iii_template.rs"
|
||||
[class]{}-[func]{is_solution}
|
||||
|
||||
[class]{}-[func]{record_solution}
|
||||
|
||||
[class]{}-[func]{is_valid}
|
||||
|
||||
[class]{}-[func]{make_choice}
|
||||
|
||||
[class]{}-[func]{undo_choice}
|
||||
|
||||
[class]{}-[func]{backtrack}
|
||||
```
|
||||
|
||||
根据题意,当找到值为 7 的节点后应该继续搜索,**因此我们需要将记录解之后的 `return` 语句删除**。下图对比了保留或删除 `return` 语句的搜索过程。
|
||||
|
||||

|
||||
|
@ -128,6 +128,14 @@
|
||||
[class]{}-[func]{nQueens}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="n_queens.rs"
|
||||
[class]{}-[func]{backtrack}
|
||||
|
||||
[class]{}-[func]{n_queens}
|
||||
```
|
||||
|
||||
逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n, n-1, \cdots, 2, 1$ 个选择,**因此时间复杂度为 $O(n!)$** 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
|
||||
|
||||
数组 `state` 使用 $O(n^2)$ 空间,数组 `cols` , `diags1` , `diags2` 皆使用 $O(n)$ 空间。最大递归深度为 $n$ ,使用 $O(n)$ 栈帧空间。因此,**空间复杂度为 $O(n^2)$** 。
|
||||
|
@ -133,6 +133,14 @@
|
||||
[class]{}-[func]{permutationsI}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="permutations_i.rs"
|
||||
[class]{}-[func]{backtrack}
|
||||
|
||||
[class]{}-[func]{permutations_i}
|
||||
```
|
||||
|
||||
## 考虑相等元素的情况
|
||||
|
||||
!!! question
|
||||
@ -249,6 +257,14 @@
|
||||
[class]{}-[func]{permutationsII}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="permutations_ii.rs"
|
||||
[class]{}-[func]{backtrack}
|
||||
|
||||
[class]{}-[func]{permutations_ii}
|
||||
```
|
||||
|
||||
假设元素两两之间互不相同,则 $n$ 个元素共有 $n!$ 种排列(阶乘);在记录结果时,需要复制长度为 $n$ 的列表,使用 $O(n)$ 时间。因此,**时间复杂度为 $O(n!n)$** 。
|
||||
|
||||
最大递归深度为 $n$ ,使用 $O(n)$ 栈帧空间。`selected` 使用 $O(n)$ 空间。同一时刻最多共有 $n$ 个 `duplicated` ,使用 $O(n^2)$ 空间。**因此空间复杂度为 $O(n^2)$** 。
|
||||
|
@ -105,6 +105,14 @@
|
||||
[class]{}-[func]{subsetSumINaive}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="subset_sum_i_naive.rs"
|
||||
[class]{}-[func]{backtrack}
|
||||
|
||||
[class]{}-[func]{subset_sum_i_naive}
|
||||
```
|
||||
|
||||
向以上代码输入数组 $[3, 4, 5]$ 和目标元素 $9$ ,输出结果为 $[3, 3, 3], [4, 5], [5, 4]$ 。**虽然成功找出了所有和为 $9$ 的子集,但其中存在重复的子集 $[4, 5]$ 和 $[5, 4]$** 。
|
||||
|
||||
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如下图所示,先选 $4$ 后选 $5$ 与先选 $5$ 后选 $4$ 是两个不同的分支,但两者对应同一个子集。
|
||||
@ -230,6 +238,14 @@
|
||||
[class]{}-[func]{subsetSumI}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="subset_sum_i.rs"
|
||||
[class]{}-[func]{backtrack}
|
||||
|
||||
[class]{}-[func]{subset_sum_i}
|
||||
```
|
||||
|
||||
如下图所示,为将数组 $[3, 4, 5]$ 和目标元素 $9$ 输入到以上代码后的整体回溯过程。
|
||||
|
||||

|
||||
@ -342,6 +358,14 @@
|
||||
[class]{}-[func]{subsetSumII}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="subset_sum_ii.rs"
|
||||
[class]{}-[func]{backtrack}
|
||||
|
||||
[class]{}-[func]{subset_sum_ii}
|
||||
```
|
||||
|
||||
下图展示了数组 $[4, 4, 5]$ 和目标元素 $9$ 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。
|
||||
|
||||

|
||||
|
@ -280,6 +280,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
## 推算方法
|
||||
|
||||
空间复杂度的推算方法与时间复杂度大致相同,只是将统计对象从“计算操作数量”转为“使用空间大小”。与时间复杂度不同的是,**我们通常只关注「最差空间复杂度」**,这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
|
||||
@ -412,6 +418,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
**在递归函数中,需要注意统计栈帧空间**。例如,函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。而递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。
|
||||
|
||||
=== "Java"
|
||||
@ -627,6 +639,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
## 常见类型
|
||||
|
||||
设输入数据大小为 $n$ ,常见的空间复杂度类型有(从低到高排列)
|
||||
@ -716,6 +734,12 @@ $$
|
||||
[class]{}-[func]{constant}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="space_complexity.rs"
|
||||
[class]{}-[func]{constant}
|
||||
```
|
||||
|
||||
### 线性阶 $O(n)$
|
||||
|
||||
线性阶常见于元素数量与 $n$ 成正比的数组、链表、栈、队列等。
|
||||
@ -788,6 +812,12 @@ $$
|
||||
[class]{}-[func]{linear}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="space_complexity.rs"
|
||||
[class]{}-[func]{linear}
|
||||
```
|
||||
|
||||
以下递归函数会同时存在 $n$ 个未返回的 `algorithm()` 函数,使用 $O(n)$ 大小的栈帧空间。
|
||||
|
||||
=== "Java"
|
||||
@ -856,6 +886,12 @@ $$
|
||||
[class]{}-[func]{linearRecur}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="space_complexity.rs"
|
||||
[class]{}-[func]{linear_recur}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 平方阶 $O(n^2)$
|
||||
@ -928,6 +964,12 @@ $$
|
||||
[class]{}-[func]{quadratic}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="space_complexity.rs"
|
||||
[class]{}-[func]{quadratic}
|
||||
```
|
||||
|
||||
在以下递归函数中,同时存在 $n$ 个未返回的 `algorithm()` ,并且每个函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $\frac{n}{2}$ ,因此总体占用 $O(n^2)$ 空间。
|
||||
|
||||
=== "Java"
|
||||
@ -996,6 +1038,12 @@ $$
|
||||
[class]{}-[func]{quadraticRecur}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="space_complexity.rs"
|
||||
[class]{}-[func]{quadratic_recur}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 指数阶 $O(2^n)$
|
||||
@ -1068,6 +1116,12 @@ $$
|
||||
[class]{}-[func]{buildTree}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="space_complexity.rs"
|
||||
[class]{}-[func]{build_tree}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 对数阶 $O(\log n)$
|
||||
|
@ -168,6 +168,12 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
然而实际上,**统计算法的运行时间既不合理也不现实**。首先,我们不希望预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
|
||||
|
||||
## 统计时间增长趋势
|
||||
@ -394,6 +400,12 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||

|
||||
|
||||
相较于直接统计算法运行时间,时间复杂度分析有哪些优势和局限性呢?
|
||||
@ -556,6 +568,12 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
$T(n)$ 是一次函数,说明时间增长趋势是线性的,因此可以得出时间复杂度是线性阶。
|
||||
|
||||
我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号称为「大 $O$ 记号 Big-$O$ Notation」,表示函数 $T(n)$ 的「渐近上界 Asymptotic Upper Bound」。
|
||||
@ -795,6 +813,12 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
### 第二步:判断渐近上界
|
||||
|
||||
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。
|
||||
@ -902,6 +926,12 @@ $$
|
||||
[class]{}-[func]{constant}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{constant}
|
||||
```
|
||||
|
||||
### 线性阶 $O(n)$
|
||||
|
||||
线性阶的操作数量相对于输入数据大小以线性级别增长。线性阶通常出现在单层循环中。
|
||||
@ -972,6 +1002,12 @@ $$
|
||||
[class]{}-[func]{linear}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{linear}
|
||||
```
|
||||
|
||||
遍历数组和遍历链表等操作的时间复杂度均为 $O(n)$ ,其中 $n$ 为数组或链表的长度。
|
||||
|
||||
!!! question "如何确定输入数据大小 $n$ ?"
|
||||
@ -1044,6 +1080,12 @@ $$
|
||||
[class]{}-[func]{arrayTraversal}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{array_traversal}
|
||||
```
|
||||
|
||||
### 平方阶 $O(n^2)$
|
||||
|
||||
平方阶的操作数量相对于输入数据大小以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$ 。
|
||||
@ -1114,6 +1156,12 @@ $$
|
||||
[class]{}-[func]{quadratic}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{quadratic}
|
||||
```
|
||||
|
||||

|
||||
|
||||
以「冒泡排序」为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1, n-2, \cdots, 2, 1$ 次,平均为 $\frac{n}{2}$ 次,因此时间复杂度为 $O(n^2)$ 。
|
||||
@ -1188,6 +1236,12 @@ $$
|
||||
[class]{}-[func]{bubbleSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{bubble_sort}
|
||||
```
|
||||
|
||||
### 指数阶 $O(2^n)$
|
||||
|
||||
!!! note
|
||||
@ -1262,6 +1316,12 @@ $$
|
||||
[class]{}-[func]{exponential}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{exponential}
|
||||
```
|
||||
|
||||

|
||||
|
||||
在实际算法中,指数阶常出现于递归函数。例如以下代码,不断地一分为二,经过 $n$ 次分裂后停止。
|
||||
@ -1332,6 +1392,12 @@ $$
|
||||
[class]{}-[func]{expRecur}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{exp_recur}
|
||||
```
|
||||
|
||||
### 对数阶 $O(\log n)$
|
||||
|
||||
与指数阶相反,对数阶反映了“每轮缩减到一半的情况”。对数阶仅次于常数阶,时间增长缓慢,是理想的时间复杂度。
|
||||
@ -1406,6 +1472,12 @@ $$
|
||||
[class]{}-[func]{logarithmic}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{logarithmic}
|
||||
```
|
||||
|
||||

|
||||
|
||||
与指数阶类似,对数阶也常出现于递归函数。以下代码形成了一个高度为 $\log_2 n$ 的递归树。
|
||||
@ -1476,6 +1548,12 @@ $$
|
||||
[class]{}-[func]{logRecur}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{log_recur}
|
||||
```
|
||||
|
||||
### 线性对数阶 $O(n \log n)$
|
||||
|
||||
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 $O(\log n)$ 和 $O(n)$ 。
|
||||
@ -1548,6 +1626,12 @@ $$
|
||||
[class]{}-[func]{linearLogRecur}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{linear_log_recur}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 阶乘阶 $O(n!)$
|
||||
@ -1626,6 +1710,12 @@ $$
|
||||
[class]{}-[func]{factorialRecur}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="time_complexity.rs"
|
||||
[class]{}-[func]{factorial_recur}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 最差、最佳、平均时间复杂度
|
||||
@ -1744,6 +1834,14 @@ $$
|
||||
[class]{}-[func]{findOne}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="worst_best_time_complexity.rs"
|
||||
[class]{}-[func]{random_numbers}
|
||||
|
||||
[class]{}-[func]{find_one}
|
||||
```
|
||||
|
||||
!!! tip
|
||||
|
||||
实际应用中我们很少使用「最佳时间复杂度」,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。相反,「最差时间复杂度」更为实用,因为它给出了一个“效率安全值”,让我们可以放心地使用算法。
|
||||
|
@ -139,3 +139,9 @@
|
||||
List<String> characters = List.filled(5, 'a');
|
||||
List<bool> booleans = List.filled(5, false);
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
@ -127,3 +127,11 @@
|
||||
|
||||
[class]{}-[func]{binarySearch}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search_recur.rs"
|
||||
[class]{}-[func]{dfs}
|
||||
|
||||
[class]{}-[func]{binary_search}
|
||||
```
|
||||
|
@ -147,6 +147,14 @@
|
||||
[class]{}-[func]{buildTree}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="build_tree.rs"
|
||||
[class]{}-[func]{dfs}
|
||||
|
||||
[class]{}-[func]{build_tree}
|
||||
```
|
||||
|
||||
下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(即引用)是在向上“归”的过程中建立的。
|
||||
|
||||
=== "<1>"
|
||||
|
@ -192,6 +192,16 @@
|
||||
[class]{}-[func]{hanota}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="hanota.rs"
|
||||
[class]{}-[func]{move_pan}
|
||||
|
||||
[class]{}-[func]{dfs}
|
||||
|
||||
[class]{}-[func]{hanota}
|
||||
```
|
||||
|
||||
如下图所示,汉诺塔问题形成一个高度为 $n$ 的递归树,每个节点代表一个子问题、对应一个开启的 `dfs()` 函数,**因此时间复杂度为 $O(2^n)$ ,空间复杂度为 $O(n)$** 。
|
||||
|
||||

|
||||
|
@ -100,6 +100,12 @@ $$
|
||||
[class]{}-[func]{minCostClimbingStairsDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="min_cost_climbing_stairs_dp.rs"
|
||||
[class]{}-[func]{min_cost_climbing_stairs_dp}
|
||||
```
|
||||
|
||||

|
||||
|
||||
本题也可以进行状态压缩,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降低至 $O(1)$ 。
|
||||
@ -170,6 +176,12 @@ $$
|
||||
[class]{}-[func]{minCostClimbingStairsDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="min_cost_climbing_stairs_dp.rs"
|
||||
[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
|
||||
```
|
||||
|
||||
## 无后效性
|
||||
|
||||
「无后效性」是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关**。
|
||||
@ -274,6 +286,12 @@ $$
|
||||
[class]{}-[func]{climbingStairsConstraintDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="climbing_stairs_constraint_dp.rs"
|
||||
[class]{}-[func]{climbing_stairs_constraint_dp}
|
||||
```
|
||||
|
||||
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题恢复无后效性。然而,许多问题具有非常严重的“有后效性”,例如:
|
||||
|
||||
!!! question "爬楼梯与障碍生成"
|
||||
|
@ -164,6 +164,12 @@ $$
|
||||
[class]{}-[func]{minPathSumDFS}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="min_path_sum.rs"
|
||||
[class]{}-[func]{min_path_sum_dfs}
|
||||
```
|
||||
|
||||
下图给出了以 $dp[2, 1]$ 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 `grid` 的尺寸变大而急剧增多。
|
||||
|
||||
本质上看,造成重叠子问题的原因为:**存在多条路径可以从左上角到达某一单元格**。
|
||||
@ -242,6 +248,12 @@ $$
|
||||
[class]{}-[func]{minPathSumDFSMem}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="min_path_sum.rs"
|
||||
[class]{}-[func]{min_path_sum_dfs_mem}
|
||||
```
|
||||
|
||||
引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 $O(nm)$ 。
|
||||
|
||||

|
||||
@ -316,6 +328,12 @@ $$
|
||||
[class]{}-[func]{minPathSumDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="min_path_sum.rs"
|
||||
[class]{}-[func]{min_path_sum_dp}
|
||||
```
|
||||
|
||||
下图展示了最小路径和的状态转移过程,其遍历了整个网格,**因此时间复杂度为 $O(nm)$** 。
|
||||
|
||||
数组 `dp` 大小为 $n \times m$ ,**因此空间复杂度为 $O(nm)$** 。
|
||||
@ -427,3 +445,9 @@ $$
|
||||
```dart title="min_path_sum.dart"
|
||||
[class]{}-[func]{minPathSumDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="min_path_sum.rs"
|
||||
[class]{}-[func]{min_path_sum_dp_comp}
|
||||
```
|
||||
|
@ -131,6 +131,12 @@ $$
|
||||
[class]{}-[func]{editDistanceDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="edit_distance.rs"
|
||||
[class]{}-[func]{edit_distance_dp}
|
||||
```
|
||||
|
||||
如下图所示,编辑距离问题的状态转移过程与背包问题非常类似,都可以看作是填写一个二维网格的过程。
|
||||
|
||||
=== "<1>"
|
||||
@ -249,3 +255,9 @@ $$
|
||||
```dart title="edit_distance.dart"
|
||||
[class]{}-[func]{editDistanceDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="edit_distance.rs"
|
||||
[class]{}-[func]{edit_distance_dp_comp}
|
||||
```
|
||||
|
@ -102,6 +102,14 @@
|
||||
[class]{}-[func]{climbingStairsBacktrack}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="climbing_stairs_backtrack.rs"
|
||||
[class]{}-[func]{backtrack}
|
||||
|
||||
[class]{}-[func]{climbing_stairs_backtrack}
|
||||
```
|
||||
|
||||
## 方法一:暴力搜索
|
||||
|
||||
回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
|
||||
@ -219,6 +227,14 @@ $$
|
||||
[class]{}-[func]{climbingStairsDFS}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="climbing_stairs_dfs.rs"
|
||||
[class]{}-[func]{dfs}
|
||||
|
||||
[class]{}-[func]{climbing_stairs_dfs}
|
||||
```
|
||||
|
||||
下图展示了暴力搜索形成的递归树。对于问题 $dp[n]$ ,其递归树的深度为 $n$ ,时间复杂度为 $O(2^n)$ 。指数阶属于爆炸式增长,如果我们输入一个比较大的 $n$ ,则会陷入漫长的等待之中。
|
||||
|
||||

|
||||
@ -322,6 +338,14 @@ $$
|
||||
[class]{}-[func]{climbingStairsDFSMem}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="climbing_stairs_dfs_mem.rs"
|
||||
[class]{}-[func]{dfs}
|
||||
|
||||
[class]{}-[func]{climbing_stairs_dfs_mem}
|
||||
```
|
||||
|
||||
观察下图,**经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 $O(n)$** ,这是一个巨大的飞跃。
|
||||
|
||||

|
||||
@ -400,6 +424,12 @@ $$
|
||||
[class]{}-[func]{climbingStairsDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="climbing_stairs_dp.rs"
|
||||
[class]{}-[func]{climbing_stairs_dp}
|
||||
```
|
||||
|
||||
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。
|
||||
|
||||
总结以上,动态规划的常用术语包括:
|
||||
@ -480,6 +510,12 @@ $$
|
||||
[class]{}-[func]{climbingStairsDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="climbing_stairs_dp.rs"
|
||||
[class]{}-[func]{climbing_stairs_dp_comp}
|
||||
```
|
||||
|
||||
观察以上代码,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降低至 $O(1)$ 。
|
||||
|
||||
**这种空间优化技巧被称为「状态压缩」**。在常见的动态规划问题中,当前状态仅与前面有限个状态有关,这时我们可以应用状态压缩,只保留必要的状态,通过“降维”来节省内存空间。
|
||||
|
@ -122,6 +122,12 @@ $$
|
||||
[class]{}-[func]{knapsackDFS}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="knapsack.rs"
|
||||
[class]{}-[func]{knapsack_dfs}
|
||||
```
|
||||
|
||||
如下图所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 $O(2^n)$ 。
|
||||
|
||||
观察递归树,容易发现其中存在重叠子问题,例如 $dp[1, 10]$ 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。
|
||||
@ -200,6 +206,12 @@ $$
|
||||
[class]{}-[func]{knapsackDFSMem}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="knapsack.rs"
|
||||
[class]{}-[func]{knapsack_dfs_mem}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 方法三:动态规划
|
||||
@ -272,6 +284,12 @@ $$
|
||||
[class]{}-[func]{knapsackDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="knapsack.rs"
|
||||
[class]{}-[func]{knapsack_dp}
|
||||
```
|
||||
|
||||
如下图所示,时间复杂度和空间复杂度都由数组 `dp` 大小决定,即 $O(n \times cap)$ 。
|
||||
|
||||
=== "<1>"
|
||||
@ -412,3 +430,9 @@ $$
|
||||
```dart title="knapsack.dart"
|
||||
[class]{}-[func]{knapsackDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="knapsack.rs"
|
||||
[class]{}-[func]{knapsack_dp_comp}
|
||||
```
|
||||
|
@ -96,6 +96,12 @@ $$
|
||||
[class]{}-[func]{unboundedKnapsackDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="unbounded_knapsack.rs"
|
||||
[class]{}-[func]{unbounded_knapsack_dp}
|
||||
```
|
||||
|
||||
### 状态压缩
|
||||
|
||||
由于当前状态是从左边和上边的状态转移而来,**因此状态压缩后应该对 $dp$ 表中的每一行采取正序遍历**。
|
||||
@ -188,6 +194,12 @@ $$
|
||||
[class]{}-[func]{unboundedKnapsackDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="unbounded_knapsack.rs"
|
||||
[class]{}-[func]{unbounded_knapsack_dp_comp}
|
||||
```
|
||||
|
||||
## 零钱兑换问题
|
||||
|
||||
背包问题是一大类动态规划问题的代表,其拥有很多的变种,例如零钱兑换问题。
|
||||
@ -301,6 +313,12 @@ $$
|
||||
[class]{}-[func]{coinChangeDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="coin_change.rs"
|
||||
[class]{}-[func]{coin_change_dp}
|
||||
```
|
||||
|
||||
下图展示了零钱兑换的动态规划过程,和完全背包非常相似。
|
||||
|
||||
=== "<1>"
|
||||
@ -418,6 +436,12 @@ $$
|
||||
[class]{}-[func]{coinChangeDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="coin_change.rs"
|
||||
[class]{}-[func]{coin_change_dp_comp}
|
||||
```
|
||||
|
||||
## 零钱兑换问题 II
|
||||
|
||||
!!! question
|
||||
@ -504,6 +528,12 @@ $$
|
||||
[class]{}-[func]{coinChangeIIDP}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="coin_change_ii.rs"
|
||||
[class]{}-[func]{coin_change_ii_dp}
|
||||
```
|
||||
|
||||
### 状态压缩
|
||||
|
||||
状态压缩处理方式相同,删除硬币维度即可。
|
||||
@ -573,3 +603,9 @@ $$
|
||||
```dart title="coin_change_ii.dart"
|
||||
[class]{}-[func]{coinChangeIIDPComp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="coin_change_ii.rs"
|
||||
[class]{}-[func]{coin_change_ii_dp_comp}
|
||||
```
|
||||
|
@ -94,6 +94,12 @@
|
||||
[class]{GraphAdjMat}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="graph_adjacency_matrix.rs"
|
||||
[class]{GraphAdjMat}-[func]{}
|
||||
```
|
||||
|
||||
## 基于邻接表的实现
|
||||
|
||||
设无向图的顶点总数为 $n$ 、边总数为 $m$ ,则有:
|
||||
@ -191,6 +197,12 @@
|
||||
[class]{GraphAdjList}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="graph_adjacency_list.rs"
|
||||
[class]{GraphAdjList}-[func]{}
|
||||
```
|
||||
|
||||
## 效率对比
|
||||
|
||||
设图中共有 $n$ 个顶点和 $m$ 条边,下表为邻接矩阵和邻接表的时间和空间效率对比。
|
||||
|
@ -90,6 +90,12 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
|
||||
[class]{}-[func]{graphBFS}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="graph_bfs.rs"
|
||||
[class]{}-[func]{graph_bfs}
|
||||
```
|
||||
|
||||
代码相对抽象,建议对照以下动画图示来加深理解。
|
||||
|
||||
=== "<1>"
|
||||
@ -233,6 +239,14 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
|
||||
[class]{}-[func]{graphDFS}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="graph_dfs.rs"
|
||||
[class]{}-[func]{dfs}
|
||||
|
||||
[class]{}-[func]{graph_dfs}
|
||||
```
|
||||
|
||||
深度优先遍历的算法流程如下图所示,其中:
|
||||
|
||||
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点。
|
||||
|
@ -119,6 +119,14 @@
|
||||
[class]{}-[func]{fractionalKnapsack}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="fractional_knapsack.rs"
|
||||
[class]{Item}-[func]{}
|
||||
|
||||
[class]{}-[func]{fractional_knapsack}
|
||||
```
|
||||
|
||||
最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。
|
||||
|
||||
由于初始化了一个 `Item` 对象列表,**因此空间复杂度为 $O(n)$** 。
|
||||
|
@ -85,6 +85,12 @@
|
||||
[class]{}-[func]{coinChangeGreedy}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="coin_change_greedy.rs"
|
||||
[class]{}-[func]{coin_change_greedy}
|
||||
```
|
||||
|
||||
## 贪心优点与局限性
|
||||
|
||||
**贪心算法不仅操作直接、实现简单,而且通常效率也很高**。在以上代码中,记硬币最小面值为 $\min(coins)$ ,则贪心选择最多循环 $amt / \min(coins)$ 次,时间复杂度为 $O(amt / \min(coins))$ 。这比动态规划解法的时间复杂度 $O(n \times amt)$ 提升了一个数量级。
|
||||
|
@ -143,6 +143,12 @@ $$
|
||||
[class]{}-[func]{maxCapacity}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="max_capacity.rs"
|
||||
[class]{}-[func]{max_capacity}
|
||||
```
|
||||
|
||||
### 正确性证明
|
||||
|
||||
之所以贪心比穷举更快,是因为每轮的贪心选择都会“跳过”一些状态。
|
||||
|
@ -129,6 +129,12 @@ $$
|
||||
[class]{}-[func]{maxProductCutting}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="max_product_cutting.rs"
|
||||
[class]{}-[func]{max_product_cutting}
|
||||
```
|
||||
|
||||

|
||||
|
||||
**时间复杂度取决于编程语言的幂运算的实现方法**。以 Python 为例,常用的幂计算函数有三种:
|
||||
|
@ -177,6 +177,18 @@ index = hash(key) % capacity
|
||||
[class]{}-[func]{rot_hash}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="simple_hash.rs"
|
||||
[class]{}-[func]{add_hash}
|
||||
|
||||
[class]{}-[func]{mul_hash}
|
||||
|
||||
[class]{}-[func]{xor_hash}
|
||||
|
||||
[class]{}-[func]{rot_hash}
|
||||
```
|
||||
|
||||
观察发现,每种哈希算法的最后一步都是对大质数 $1000000007$ 取模,以确保哈希值在合适的范围内。值得思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。
|
||||
|
||||
先抛出结论:**当我们使用大质数作为模数时,可以最大化地保证哈希值的均匀分布**。因为质数不会与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
|
||||
@ -431,6 +443,12 @@ $$
|
||||
// 节点对象 Instance of 'ListNode' 的哈希值为 1033450432
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="built_in_hash.rs"
|
||||
|
||||
```
|
||||
|
||||
在许多编程语言中,**只有不可变对象才可作为哈希表的 `key`** 。假如我们将列表(动态数组)作为 `key` ,当列表的内容发生变化时,它的哈希值也随之改变,我们就无法在哈希表中查询到原先的 `value` 了。
|
||||
|
||||
虽然自定义对象(比如链表节点)的成员变量是可变的,但它是可哈希的。**这是因为对象的哈希值通常是基于内存地址生成的**,即使对象的内容发生了变化,但它的内存地址不变,哈希值仍然是不变的。
|
||||
|
@ -97,6 +97,12 @@
|
||||
[class]{HashMapChaining}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="hash_map_chaining.rs"
|
||||
[class]{HashMapChaining}-[func]{}
|
||||
```
|
||||
|
||||
!!! tip
|
||||
|
||||
当链表很长时,查询效率 $O(n)$ 很差,**此时可以将链表转换为「AVL 树」或「红黑树」**,从而将查询操作的时间复杂度优化至 $O(\log n)$ 。
|
||||
@ -190,6 +196,12 @@
|
||||
[class]{HashMapOpenAddressing}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="hash_map_open_addressing.rs"
|
||||
[class]{HashMapOpenAddressing}-[func]{}
|
||||
```
|
||||
|
||||
### 多次哈希
|
||||
|
||||
顾名思义,多次哈希方法是使用多个哈希函数 $f_1(x)$ , $f_2(x)$ , $f_3(x)$ , $\cdots$ 进行探测。
|
||||
|
@ -250,6 +250,12 @@
|
||||
map.remove(10583);
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="hash_map.rs"
|
||||
|
||||
```
|
||||
|
||||
哈希表有三种常用遍历方式:遍历键值对、遍历键和遍历值。
|
||||
|
||||
=== "Java"
|
||||
@ -425,6 +431,12 @@
|
||||
});
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="hash_map.rs"
|
||||
|
||||
```
|
||||
|
||||
## 哈希表简单实现
|
||||
|
||||
我们先考虑最简单的情况,**仅用一个数组来实现哈希表**。在哈希表中,我们将数组中的每个空位称为「桶 Bucket」,每个桶可存储一个键值对。因此,查询操作就是找到 `key` 对应的桶,并在桶中获取 `value` 。
|
||||
@ -545,6 +557,14 @@ index = hash(key) % capacity
|
||||
[class]{ArrayHashMap}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array_hash_map.rs"
|
||||
[class]{Pair}-[func]{}
|
||||
|
||||
[class]{ArrayHashMap}-[func]{}
|
||||
```
|
||||
|
||||
## 哈希冲突与扩容
|
||||
|
||||
本质上看,哈希函数的作用是将所有 `key` 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,**理论上一定存在“多个输入对应相同输出”的情况**。
|
||||
|
@ -78,6 +78,12 @@
|
||||
[class]{MaxHeap}-[func]{MaxHeap}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="my_heap.rs"
|
||||
[class]{MaxHeap}-[func]{new}
|
||||
```
|
||||
|
||||
## 复杂度分析
|
||||
|
||||
为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。
|
||||
|
@ -307,6 +307,12 @@
|
||||
// Dart 未提供内置 Heap 类
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="heap.rs"
|
||||
|
||||
```
|
||||
|
||||
## 堆的实现
|
||||
|
||||
下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 $\geq$ 替换为 $\leq$ )。感兴趣的读者可以自行实现。
|
||||
@ -433,6 +439,16 @@
|
||||
[class]{MaxHeap}-[func]{_parent}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="my_heap.rs"
|
||||
[class]{MaxHeap}-[func]{left}
|
||||
|
||||
[class]{MaxHeap}-[func]{right}
|
||||
|
||||
[class]{MaxHeap}-[func]{parent}
|
||||
```
|
||||
|
||||
### 访问堆顶元素
|
||||
|
||||
堆顶元素即为二叉树的根节点,也就是列表的首个元素。
|
||||
@ -503,6 +519,12 @@
|
||||
[class]{MaxHeap}-[func]{peek}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="my_heap.rs"
|
||||
[class]{MaxHeap}-[func]{peek}
|
||||
```
|
||||
|
||||
### 元素入堆
|
||||
|
||||
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 Heapify」。
|
||||
@ -626,6 +648,14 @@
|
||||
[class]{MaxHeap}-[func]{siftUp}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="my_heap.rs"
|
||||
[class]{MaxHeap}-[func]{push}
|
||||
|
||||
[class]{MaxHeap}-[func]{sift_up}
|
||||
```
|
||||
|
||||
### 堆顶元素出堆
|
||||
|
||||
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤:
|
||||
@ -756,6 +786,14 @@
|
||||
[class]{MaxHeap}-[func]{siftDown}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="my_heap.rs"
|
||||
[class]{MaxHeap}-[func]{pop}
|
||||
|
||||
[class]{MaxHeap}-[func]{sift_down}
|
||||
```
|
||||
|
||||
## 堆常见应用
|
||||
|
||||
- **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。
|
||||
|
@ -131,3 +131,9 @@
|
||||
```dart title="top_k.dart"
|
||||
[class]{}-[func]{top_k_heap}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="top_k.rs"
|
||||
[class]{}-[func]{top_k_heap}
|
||||
```
|
||||
|
@ -154,6 +154,12 @@
|
||||
*/
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
## 在动画图解中高效学习
|
||||
|
||||
相较于文字,视频和图片具有更高的信息密度和结构化程度,因此更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。
|
||||
|
@ -110,6 +110,12 @@
|
||||
[class]{}-[func]{binarySearch}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search.rs"
|
||||
[class]{}-[func]{binary_search}
|
||||
```
|
||||
|
||||
时间复杂度为 $O(\log n)$ 。每轮缩小一半区间,因此二分循环次数为 $\log_2 n$ 。
|
||||
|
||||
空间复杂度为 $O(1)$ 。指针 `i` , `j` 使用常数大小空间。
|
||||
@ -186,6 +192,12 @@
|
||||
[class]{}-[func]{binarySearchLCRO}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search.rs"
|
||||
[class]{}-[func]{binary_search_lcro}
|
||||
```
|
||||
|
||||
如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
|
||||
|
||||
在“双闭区间”表示法中,由于左右边界都被定义为闭区间,因此指针 $i$ 和 $j$ 缩小区间操作也是对称的。这样更不容易出错。因此,**我们通常采用“双闭区间”的写法**。
|
||||
|
@ -118,6 +118,12 @@
|
||||
[class]{}-[func]{binarySearchLeftEdge}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search_edge.rs"
|
||||
[class]{}-[func]{binary_search_left_edge}
|
||||
```
|
||||
|
||||
## 查找右边界
|
||||
|
||||
类似地,我们也可以二分查找最右边的 `target` 。当 `nums[m] == target` 时,说明大于 `target` 的元素在区间 $[m + 1, j]$ 中,因此执行 `i = m + 1` ,**使得指针 $i$ 向大于 `target` 的元素靠近**。
|
||||
@ -190,6 +196,12 @@
|
||||
[class]{}-[func]{binarySearchRightEdge}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search_edge.rs"
|
||||
[class]{}-[func]{binary_search_right_edge}
|
||||
```
|
||||
|
||||
观察下图,搜索最右边元素时指针 $j$ 的作用与搜索最左边元素时指针 $i$ 的作用一致,反之亦然。也就是说,**搜索最左边元素和最右边元素的实现是镜像对称的**。
|
||||
|
||||

|
||||
|
@ -78,6 +78,12 @@
|
||||
[class]{}-[func]{twoSumBruteForce}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="two_sum.rs"
|
||||
[class]{}-[func]{two_sum_brute_force}
|
||||
```
|
||||
|
||||
此方法的时间复杂度为 $O(n^2)$ ,空间复杂度为 $O(1)$ ,在大数据量下非常耗时。
|
||||
|
||||
## 哈希查找:以空间换时间
|
||||
@ -166,6 +172,12 @@
|
||||
[class]{}-[func]{twoSumHashTable}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="two_sum.rs"
|
||||
[class]{}-[func]{two_sum_hash_table}
|
||||
```
|
||||
|
||||
此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降低至 $O(n)$ ,大幅提升运行效率。
|
||||
|
||||
由于需要维护一个额外的哈希表,因此空间复杂度为 $O(n)$ 。**尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法**。
|
||||
|
@ -102,6 +102,12 @@
|
||||
[class]{}-[func]{bubbleSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="bubble_sort.rs"
|
||||
[class]{}-[func]{bubble_sort}
|
||||
```
|
||||
|
||||
## 效率优化
|
||||
|
||||
我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 `flag` 来监测这种情况,一旦出现就立即返回。
|
||||
@ -174,6 +180,12 @@
|
||||
[class]{}-[func]{bubbleSortWithFlag}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="bubble_sort.rs"
|
||||
[class]{}-[func]{bubble_sort_with_flag}
|
||||
```
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度为 $O(n^2)$ 、自适应排序** :各轮“冒泡”遍历的数组长度依次为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ ,总和为 $\frac{(n - 1) n}{2}$ 。在引入 `flag` 优化后,最佳时间复杂度可达到 $O(n)$ 。
|
||||
|
@ -80,6 +80,12 @@
|
||||
[class]{}-[func]{bucketSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="bucket_sort.rs"
|
||||
[class]{}-[func]{bucket_sort}
|
||||
```
|
||||
|
||||
!!! question "桶排序的适用场景是什么?"
|
||||
|
||||
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。
|
||||
|
@ -78,6 +78,12 @@
|
||||
[class]{}-[func]{countingSortNaive}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="counting_sort.rs"
|
||||
[class]{}-[func]{counting_sort_naive}
|
||||
```
|
||||
|
||||
!!! note "计数排序与桶排序的联系"
|
||||
|
||||
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
|
||||
@ -191,6 +197,12 @@ $$
|
||||
[class]{}-[func]{countingSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="counting_sort.rs"
|
||||
[class]{}-[func]{counting_sort}
|
||||
```
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,时间复杂度趋于 $O(n)$ 。
|
||||
|
@ -148,6 +148,14 @@
|
||||
[class]{}-[func]{heapSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="heap_sort.rs"
|
||||
[class]{}-[func]{sift_down}
|
||||
|
||||
[class]{}-[func]{heap_sort}
|
||||
```
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n \log n)$ 、非自适应排序** :建堆操作使用 $O(n)$ 时间。从堆中提取最大元素的时间复杂度为 $O(\log n)$ ,共循环 $n - 1$ 轮。
|
||||
|
@ -85,6 +85,12 @@
|
||||
[class]{}-[func]{insertionSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="insertion_sort.rs"
|
||||
[class]{}-[func]{insertion_sort}
|
||||
```
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n^2)$ 、自适应排序** :最差情况下,每次插入操作分别需要循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和得到 $\frac{(n - 1) n}{2}$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。
|
||||
|
@ -139,6 +139,14 @@
|
||||
[class]{}-[func]{mergeSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="merge_sort.rs"
|
||||
[class]{}-[func]{merge}
|
||||
|
||||
[class]{}-[func]{merge_sort}
|
||||
```
|
||||
|
||||
合并方法 `merge()` 代码中的难点包括:
|
||||
|
||||
- **在阅读代码时,需要特别注意各个变量的含义**。`nums` 的待合并区间为 `[left, right]` ,但由于 `tmp` 仅复制了 `nums` 该区间的元素,因此 `tmp` 对应区间为 `[0, right - left]` 。
|
||||
|
@ -125,6 +125,12 @@
|
||||
[class]{QuickSort}-[func]{_partition}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="quick_sort.rs"
|
||||
[class]{QuickSort}-[func]{partition}
|
||||
```
|
||||
|
||||
## 算法流程
|
||||
|
||||
1. 首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组。
|
||||
@ -199,6 +205,12 @@
|
||||
[class]{QuickSort}-[func]{quickSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="quick_sort.rs"
|
||||
[class]{QuickSort}-[func]{quick_sort}
|
||||
```
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n \log n)$ 、自适应排序** :在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
|
||||
@ -311,6 +323,14 @@
|
||||
[class]{QuickSortMedian}-[func]{_partition}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="quick_sort.rs"
|
||||
[class]{QuickSortMedian}-[func]{median_three}
|
||||
|
||||
[class]{QuickSortMedian}-[func]{partition}
|
||||
```
|
||||
|
||||
## 尾递归优化
|
||||
|
||||
**在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 $0$ ,递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间。
|
||||
@ -382,3 +402,9 @@
|
||||
```dart title="quick_sort.dart"
|
||||
[class]{QuickSortTailCall}-[func]{quickSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="quick_sort.rs"
|
||||
[class]{QuickSortTailCall}-[func]{quick_sort}
|
||||
```
|
||||
|
@ -134,6 +134,16 @@ $$
|
||||
[class]{}-[func]{radixSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="radix_sort.rs"
|
||||
[class]{}-[func]{digit}
|
||||
|
||||
[class]{}-[func]{counting_sort_digit}
|
||||
|
||||
[class]{}-[func]{radix_sort}
|
||||
```
|
||||
|
||||
!!! question "为什么从最低位开始排序?"
|
||||
|
||||
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ ,而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。
|
||||
|
@ -111,6 +111,12 @@
|
||||
[class]{}-[func]{selectionSort}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="selection_sort.rs"
|
||||
[class]{}-[func]{selection_sort}
|
||||
```
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度为 $O(n^2)$ 、非自适应排序**:外循环共 $n - 1$ 轮,第一轮的未排序区间长度为 $n$ ,最后一轮的未排序区间长度为 $2$ ,即各轮外循环分别包含 $n$ , $n - 1$ , $\cdots$ , $2$ 轮内循环,求和为 $\frac{(n - 1)(n + 2)}{2}$ 。
|
||||
|
@ -312,6 +312,12 @@
|
||||
bool isEmpty = deque.isEmpty;W
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="deque.rs"
|
||||
|
||||
```
|
||||
|
||||
## 双向队列实现 *
|
||||
|
||||
双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。
|
||||
@ -427,6 +433,14 @@
|
||||
[class]{LinkedListDeque}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linkedlist_deque.rs"
|
||||
[class]{ListNode}-[func]{}
|
||||
|
||||
[class]{LinkedListDeque}-[func]{}
|
||||
```
|
||||
|
||||
### 基于数组的实现
|
||||
|
||||
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。
|
||||
@ -514,6 +528,12 @@
|
||||
[class]{ArrayDeque}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array_deque.rs"
|
||||
[class]{ArrayDeque}-[func]{}
|
||||
```
|
||||
|
||||
## 双向队列应用
|
||||
|
||||
双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。
|
||||
|
@ -279,6 +279,12 @@
|
||||
bool isEmpty = queue.isEmpty;
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="queue.rs"
|
||||
|
||||
```
|
||||
|
||||
## 队列实现
|
||||
|
||||
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。
|
||||
@ -364,6 +370,12 @@
|
||||
[class]{LinkedListQueue}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linkedlist_queue.rs"
|
||||
[class]{LinkedListQueue}-[func]{}
|
||||
```
|
||||
|
||||
### 基于数组的实现
|
||||
|
||||
由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
|
||||
@ -456,6 +468,12 @@
|
||||
[class]{ArrayQueue}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array_queue.rs"
|
||||
[class]{ArrayQueue}-[func]{}
|
||||
```
|
||||
|
||||
以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。
|
||||
|
||||
两种实现的对比结论与栈一致,在此不再赘述。
|
||||
|
@ -277,6 +277,12 @@
|
||||
bool isEmpty = stack.isEmpty;
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="stack.rs"
|
||||
|
||||
```
|
||||
|
||||
## 栈的实现
|
||||
|
||||
为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。
|
||||
@ -366,6 +372,12 @@
|
||||
[class]{LinkedListStack}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linkedlist_stack.rs"
|
||||
[class]{LinkedListStack}-[func]{}
|
||||
```
|
||||
|
||||
### 基于数组的实现
|
||||
|
||||
在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
|
||||
@ -447,6 +459,12 @@
|
||||
[class]{ArrayStack}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array_stack.rs"
|
||||
[class]{ArrayStack}-[func]{}
|
||||
```
|
||||
|
||||
## 两种实现对比
|
||||
|
||||
### 支持操作
|
||||
|
@ -108,6 +108,12 @@
|
||||
List<int?> tree = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15];
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||

|
||||
|
||||
值得说明的是,**完全二叉树非常适合使用数组来表示**。回顾完全二叉树的定义,$\text{None}$ 只出现在最底层且靠右的位置,**因此所有 $\text{None}$ 一定出现在层序遍历序列的末尾**。这意味着使用数组表示完全二叉树时,可以省略存储所有 $\text{None}$ ,非常方便。
|
||||
@ -185,6 +191,12 @@
|
||||
[class]{ArrayBinaryTree}-[func]{}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array_binary_tree.rs"
|
||||
[class]{ArrayBinaryTree}-[func]{}
|
||||
```
|
||||
|
||||
## 优势与局限性
|
||||
|
||||
二叉树的数组表示的优点包括:
|
||||
|
@ -182,6 +182,12 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
「节点高度」是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。
|
||||
|
||||
=== "Java"
|
||||
@ -272,6 +278,14 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
|
||||
[class]{AVLTree}-[func]{updateHeight}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="avl_tree.rs"
|
||||
[class]{AVLTree}-[func]{height}
|
||||
|
||||
[class]{AVLTree}-[func]{update_height}
|
||||
```
|
||||
|
||||
### 节点平衡因子
|
||||
|
||||
节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。
|
||||
@ -342,6 +356,12 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
|
||||
[class]{AVLTree}-[func]{balanceFactor}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="avl_tree.rs"
|
||||
[class]{AVLTree}-[func]{balance_factor}
|
||||
```
|
||||
|
||||
!!! note
|
||||
|
||||
设平衡因子为 $f$ ,则一棵 AVL 树的任意节点的平衡因子皆满足 $-1 \le f \le 1$ 。
|
||||
@ -440,6 +460,12 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉
|
||||
[class]{AVLTree}-[func]{rightRotate}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="avl_tree.rs"
|
||||
[class]{AVLTree}-[func]{right_rotate}
|
||||
```
|
||||
|
||||
### 左旋
|
||||
|
||||
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。
|
||||
@ -518,6 +544,12 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉
|
||||
[class]{AVLTree}-[func]{leftRotate}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="avl_tree.rs"
|
||||
[class]{AVLTree}-[func]{left_rotate}
|
||||
```
|
||||
|
||||
### 先左旋后右旋
|
||||
|
||||
对于下图中的失衡节点 3,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋,即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」。
|
||||
@ -617,6 +649,12 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉
|
||||
[class]{AVLTree}-[func]{rotate}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="avl_tree.rs"
|
||||
[class]{AVLTree}-[func]{rotate}
|
||||
```
|
||||
|
||||
## AVL 树常用操作
|
||||
|
||||
### 插入节点
|
||||
@ -711,6 +749,14 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉
|
||||
[class]{AVLTree}-[func]{insertHelper}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="avl_tree.rs"
|
||||
[class]{AVLTree}-[func]{insert}
|
||||
|
||||
[class]{AVLTree}-[func]{insert_helper}
|
||||
```
|
||||
|
||||
### 删除节点
|
||||
|
||||
类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡。
|
||||
@ -803,6 +849,14 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉
|
||||
[class]{AVLTree}-[func]{removeHelper}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="avl_tree.rs"
|
||||
[class]{AVLTree}-[func]{remove}
|
||||
|
||||
[class]{AVLTree}-[func]{remove_helper}
|
||||
```
|
||||
|
||||
### 查找节点
|
||||
|
||||
AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。
|
||||
|
@ -97,6 +97,12 @@
|
||||
[class]{BinarySearchTree}-[func]{search}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search_tree.rs"
|
||||
[class]{BinarySearchTree}-[func]{search}
|
||||
```
|
||||
|
||||
### 插入节点
|
||||
|
||||
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作分为两步:
|
||||
@ -174,6 +180,12 @@
|
||||
[class]{BinarySearchTree}-[func]{insert}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search_tree.rs"
|
||||
[class]{BinarySearchTree}-[func]{insert}
|
||||
```
|
||||
|
||||
为了插入节点,我们需要利用辅助节点 `pre` 保存上一轮循环的节点,这样在遍历至 $\text{None}$ 时,我们可以获取到其父节点,从而完成节点插入操作。
|
||||
|
||||
与查找节点相同,插入节点使用 $O(\log n)$ 时间。
|
||||
@ -275,6 +287,12 @@
|
||||
[class]{BinarySearchTree}-[func]{remove}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_search_tree.rs"
|
||||
[class]{BinarySearchTree}-[func]{remove}
|
||||
```
|
||||
|
||||
### 排序
|
||||
|
||||
我们知道,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。
|
||||
|
@ -155,6 +155,12 @@
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
|
||||
```
|
||||
|
||||
节点的两个指针分别指向「左子节点」和「右子节点」,同时该节点被称为这两个子节点的「父节点」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树」,同理可得「右子树」。
|
||||
|
||||
**在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。例如,在以下示例中,若将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
|
||||
@ -358,6 +364,12 @@
|
||||
n2.right = n5;
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_tree.rs"
|
||||
|
||||
```
|
||||
|
||||
**插入与删除节点**。与链表类似,通过修改指针来实现插入与删除节点。
|
||||
|
||||

|
||||
@ -486,6 +498,12 @@
|
||||
n1.left = n2;
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_tree.rs"
|
||||
|
||||
```
|
||||
|
||||
!!! note
|
||||
|
||||
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。
|
||||
|
@ -80,6 +80,12 @@
|
||||
[class]{}-[func]{levelOrder}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_tree_bfs.rs"
|
||||
[class]{}-[func]{level_order}
|
||||
```
|
||||
|
||||
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
||||
|
||||
**空间复杂度**:在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 $\frac{n + 1}{2}$ 个节点,占用 $O(n)$ 空间。
|
||||
@ -204,6 +210,16 @@
|
||||
[class]{}-[func]{postOrder}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="binary_tree_dfs.rs"
|
||||
[class]{}-[func]{pre_order}
|
||||
|
||||
[class]{}-[func]{in_order}
|
||||
|
||||
[class]{}-[func]{post_order}
|
||||
```
|
||||
|
||||
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
||||
|
||||
**空间复杂度**:在最差情况下,即树退化为链表时,递归深度达到 $n$ ,系统占用 $O(n)$ 栈帧空间。
|
||||
|
Reference in New Issue
Block a user