Rust 数据结构与算法
一、算法分析
算法是通用的旨在解决某种问题的指令列表。
算法分析是基于算法使用的资源量来进行比较的。之所以说一个算法比另一个算法好,原因就在于前者在使用资源方面更有效率,或者说前者使用了更少的资源。
●算法使用的空间指的是内存消耗。算法所需的内存通常由问题本身的规模和性质决定,但有时部分算法会有一些特殊的空间需求。
●算法使用的时间指的是算法执行所有步骤经过的时间,这种评价方式被称为算法执行时间。
1、大 O 分析法
在时间方面,我们使用函数T表示总的执行次数,T(n) = 1 + n,参数n通常被称为问题的规模,T(n)则是解决规模为n的问题所要花费的时间。
在空间方面,我们使用函数S表示总的内存消耗,S(n) = 2,参数n仍然表示问题的规模,但S(n)已经和n无关了。
但大多数时候,我们主要分析时间复杂度,因为空间往往不好优化。
另外,随着摩尔定律的发展,存储越来越便宜,空间越来越大,这时候时间才是最重要的,因为时间无价。
当n很小时,函数彼此间并不能很好地区分,很难判断哪个是主导函数。但随着n变大,关系就比较明确了,一般情况下(n > 10),O(2n) > O(n3) > O(n2) > O(n log n) > O(n) >O(log n) > O(1)。这对于我们设计算法很有帮助,因为对于每个算法,我们都能计算其复杂度。
假设有这样一个算法,已确定操作步骤的数量是T(n) = 6n2 +37n+996。当n很小时,例如1或2,常数996似乎是函数的主要部分。然而,随着n变大,n2这一项变得越来越重要。事实上,当n很大时,其他两项在最终结果中所起的作用已变得不重要。当n变大时,为了近似T(n),我们可以忽略其他项,只关注6n2。系数6也变得不重要。此时,我们说T(n)具有的复杂度数量级为n2或O(n2)。
T(n) = n + 1。当n变大时,常数1对于最终结果将变得越来越不重要。如果我们要找的是T(n)的近似值,则可以删除1,此时运行时间为O(T(n)) = O(n + 1) = O(n)。注意,1对于T(n)肯定是重要的,但是当n变大时,不管有没有n,O(n)都是准确的。比如,对于T(n) = n3 + 1,当n为1时,T(n) = 2,此时舍掉1就不合理,因为这样相当于丢掉一半的运行时间。但是当n等于10时,T(n) = 1001,此时1已经不重要,即便舍掉,T(n)= 1000也仍然是一个很准确的指标。对于S(n)来说,因为其本身就是常数,所以O(S(n)) = O(2) =O(1)。大O分析法只表示数量级,因此虽然实际上是O(2),但其数量级是常量,可用O(1)代替。
2、乱序字符串检查
一个展示不同数量级复杂度的例子是乱序字符串检查。乱序字符串是指一个字符串s1只是另一个字符串s2的重新排列。例如,“heart”和“earth”是乱序字符串,“rust”和“trus”也是乱序字符串。
为简单起见,假设要讨论的两个字符串具有相同的长度,并且只由26个小写字母组成。我们的目标是写一个函数,它接收两个字符串作为参数并返回它们是不是乱序字符串的判断结果。
1、穷举法
解决乱序字符串问题的最笨方法是穷举法,也就是把每种情况都列举出来。当为字符串s1生成所有可能的乱序字符串时,第1个位置有n种可能,第2个位置有n-1种可能,第3个位置有n-3种可能,以此类推,总共有n×(n−1)×(n−2)×…×3×2×1种可能,即n!
2、检查法
乱序字符串问题的第二种解决方案是检测第一个字符串中的字符是否出现在第二个字符串中。如果检测到每个字符都存在,那么这两个字符串一定是乱序的。
代码:
/*
* @Description:
* @Author: tianyw
* @Date: 2024-02-15 10:22:41
* @LastEditTime: 2024-02-15 10:35:46
* @LastEditors: tianyw
*/
// 时间复杂度为 O(n²)
fn anagram_solution2(s1: &str, s2: &str) -> bool {
if s1.len() != s2.len() {
return false;
};
// 将 s1 和 s2 的字符分别添加到 vec_a 和 vec_b 中
let mut vec_a = Vec::new();
let mut vec_b = Vec::new();
for c in s1.chars() {
vec_a.push(c)
}
for c in s2.chars() {
vec_b.push(c)
}
// pos1 和 pos2 用于索引字符
let mut pos1: usize = 0;
let mut pos2: usize;
// 乱序字符串标识、控制循环
let mut is_angram = true;
// 标识字符是否在 s2 中
let mut found: bool;
// 时间复杂度为 O(n²)
while pos1 < vec_a.len() && is_angram {
pos2 = 0;
found = false;
while pos2 < vec_b.len() && !found {
if vec_a[pos1] == vec_b[pos2] {
found = true;
} else {
pos2 += 1;
}
}
// 某字符存在于 s2 中,将其替换成 '' 以免再次进行比较
if found {
vec_b[pos2] = ' ';
} else {
is_angram = false;
}
// 处理 s1 中的下一个字符
pos1 += 1;
}
is_angram
}
fn main() {
let s1 = "rust";
let s2 = "trus";
let result = anagram_solution2(s1, s2);
println!("rust and trus is anagram: {}", result);
let s1 = "tian";
let s2 = "wan";
let result = anagram_solution2(s1, s2);
println!("tian and wan is angram: {}", result);
}
运行结果:
时间复杂度为 O(n2)
3、排序和比较法
乱序字符串问题的第3种解决方案利用了如下事实:虽然字符串s1和s2不同,但它们是由完全相同的字符组成的。因此可以按照字母顺序从a到z排列每个字符串,如果排列后的两个字符串相同,则这两个字符串就是乱序字符串。
代码:
// 此算法的复杂度和排序算法在同一数量级
fn angram_solution3(s1: &str, s2: &str) -> bool {
if s1.len() != s2.len() {
return false;
};
// 将 s1 和 s2 的字符分别添加到 vec_a 和 vec_b 中
let mut vec_a = Vec::new();
let mut vec_b = Vec::new();
for c in s1.chars() {
vec_a.push(c)
}
for c in s2.chars() {
vec_b.push(c)
}
// 排序
vec_a.sort(); // sort 的时间复杂度为 O(n²) 或 O(nlogn)
vec_b.sort();
// 逐个比较排序的集合,只要有任何字符不匹配 就退出循环
let mut pos: usize = 0;
let mut is_angram = true;
// 时间复杂度是 O(n)
while pos < vec_a.len() && is_angram {
if vec_a[pos] == vec_b[pos] {
pos += 1;
} else {
is_angram = false;
}
}
is_angram
}
fn main() {
let s1 = "rust";
let s2 = "trus";
let result = angram_solution3(s1, s2);
println!("rust and trus is anagram by solution3: {}", result);
}
运行结果:
乍一看,因为只有一个while循环,所以复杂度应该是O(n)。调用排序函数sort( )也是有成本的,复杂度通常是O(n2)或O( nlog n),因此这个算法的复杂度和排序算法在同一数量级。
4、计数和比较法
上面的解决方案总是需要创建veca和vec_b,这非常浪费内存。当字符串s1和s2比较短时,vec_a和vec_b还算合适,但是当s1和s2达到百万字符呢?这时vec_a和vec_b就非常大。通过分析可知,s1和s2只含26个小写字母,因此只需要用两个长度为26的列表,统计各个字符出现的频次就可以了。每遇到一个字符,就增加这个字符在列表中对应位置的计数。最后,如果两个计数一样,则字符串为乱序字符串。
代码:
// 时间复杂度 T(n) = 2n + 26
fn anagram_solution4(s1:&str,s2:&str) -> bool {
if s1.len() != s2.len() { return false;};
// 大小为 26 的集合,用于将字符映射为 ASCII 值
let mut c1 = [0;26];
let mut c2 = [0;26];
// 时间复杂度 O(n)
for c in s1.chars() {
// 97 为字符 a 的 ASCII 值
let pos = (c as usize) - 97;
c1[pos] +=1;
}
// 时间复杂度 O(n)
for c in s2.chars() {
let pos = (c as usize) - 97;
c2[pos] += 1;
}
// 逐个比较 ASCII 值
let mut pos = 0;
let mut is_anagram = true;
// 时间复杂度 O(26)
while pos < 26 && is_anagram {
if c1[pos] == c2[pos] {
pos+=1;
}else {
is_anagram = false;
}
}
is_anagram
}
fn main() {
let s1 = "rust";
let s2 = "trus";
let result = anagram_solution4(s1, s2);
println!("rust and trus is angram: {}", result);
}
运行结果:
T(n) = 2n + 26,即时间复杂度O(n) = 2n + 26。这是一个具有线性复杂度的算法,其空间复杂度和时间复杂度都比较优秀。
3、Rust 数据结构的性能
1、标量类型和复合类型
Rust有两大基础数据类型:标量类型和复合类型。
标量类型代表单独的值,复合类型则是标量类型的组合。
Rust有4种基本的标量类型:整型、浮点型、布尔型、字符型。
复合类型则有两种:元组和数组。
标量类型都是最基本的且和内存结合最紧密的原生类型,运算效率非常高,复杂度为O(1);
复合类型则复杂一些,其复杂度随数据规模而变化。
Rust的其他数据类型都是由标量类型和复合类型构成的集合类型,如Vec、HashMap等。Vec是标准库提供的一种允许增大、减小和限定长度的类似于数组的集合类型。能用数组的地方都可以用Vec,所以当不知道使用何种类型时,用Vec不仅不算错,而且更有扩展性。
元组是将多个类型组合成单个复合类型的数据结构,长度固定。元组一旦声明,其长度就不能增大或减小。元组的索引从0开始,并且可以直接用“.”来获取值。数组一旦声明,其长度也不能增大或减小,但与元组不同的是,数组中每个元素的类型必须相同。
2、集合类型
Rust的集合类型是基于标量类型和复合类型构造的,其中又分为线性的和非线性的两大类。
线性的集合类型有String、Vec、VecDeque、LinkedList,
而非线性的集合类型有HashMap、BTreeMap、HashSet、BTreeSet、BinaryHeap。
这些线性和非线性的集合类型多涉及索引和增删操作,对应的复杂度多为O(1)、O(n)等。
Rust实现的String在底层基于Vec。若要使用String中的部分字符,可使用&str,&str实际上是基于String类型字符串的切片,目的是便于索引。因为&str是基于String的,所以&str不可更改,因为修改切片会更改String中的数据,而String还可能在其他地方用到。记住一点,在Rust中,可变字符串用String,不可变字符串用&str。
Vec则类似于其他编程语言中的列表,它们都基于分配在堆上的数组。VecDeque扩展了Vec,支持在序列的两端插入数据,是双端队列。LinkedList是链表,当需要未知大小的Vec时可以采用。
Rust实现的HashMap类似于其他编程语言中的字典,BTreeMap则是B树,节点上包含数据和指针,多用于实现数据库、文件系统等需要存储内容的对象。Rust实现的HashSet和BTreeSet类似于其他编程语言中的Set,用于记录单个值,比如出现过一次的值。HashSet在底层采用的是HashMap,而BTreeSet在底层采用的是BTreeMap。BinaryHeap类似于优先队列,里面存储了一堆元素,可在任何时候提取出最值。
Rust实现的集合数据类型都是非常高效的,复杂度最高也就是O(n)。
参考:
《数据结构与算法(Rust 语言描述)》