一、🚀 什么是 Big O?
Big O 记号用于描述算法的效率或性能,尤其是在输入数据量增加时,算法的运行时间或空间需求如何变化。它帮助我们理解一个算法在最坏情况下的表现。为了让这个概念更加通俗易懂,我会用生活中的例子来解释。
🏷️ 让我们先看看这些符号对应的生活场景:
- O(1):按门铃,不管多少人按,时间一样。
- O(n):超市结账,商品越多,时间越久。
- O(n²):班级握手,每增加一个人,握手次数增加得更快。
- O(log n):查字典,每次减半查找。
- O(n log n):图书馆里快速排序书籍。
⚡ O(1) —— 常数时间
O(1) 表示无论输入数据量多大,操作所需的时间都是一样的。
💡 例子:就像你在家门口按门铃,无论你家多大,按门铃的动作所需的时间不会变。门铃按钮就是一个 O(1) 操作。
JS:
// 定义常量数组
// 我们只需要 第一项,所以不管数组有多少内容,都是一样的
const smallNumbers = [1, 2];
const bigNumbers = [1, 2, 3, 4, 5];
const getElement = (arr, index) => arr[index];
console.log(getElement(smallNumbers, 0));
console.log(getElement(bigNumbers, 0));
GO:
// getElement 函数接受一个整数切片和一个索引,返回该索引处的元素
func getElement(arr []int, index int) int {
return arr[index]
}
func main() {
smallNumbers := []int{1, 2}
bigNumbers := []int{1, 2, 3, 4, 5}
// 打印 smallNumbers 和 bigNumbers 中索引为 0 的元素
fmt.Println(getElement(smallNumbers, 0))
fmt.Println(getElement(bigNumbers, 0))
}
📏 O(n) —— 线性时间
O(n) 表示算法的运行时间随着输入数据的增大而成比例增加。
💡 例子:想象你在超市购物,购物车里的物品越多,结账时的时间也会相应增加。如果有 5 件商品就需要 5 次扫描,有 100 件商品就需要 100 次扫描。这就是 O(n)。
JS:
// 在查找的时候,只有2个内容的数组,一定比有6 个内容的数组更快
const small = ["milk", "eggs"];
const big = ["milk", "bread", "eggs", "flour", "cheese", "sugar"];
// 写一个函数,找到 eggs
const searchForItem = () => {
for (let i = 0; i < big.length; i++) {
if (big[i] === eggs) {
console.log("Found eggs");
}
}
};
GO:
package main
import (
"fmt"
)
var small = []string{"milk", "eggs"}
var big = []string{"milk", "bread", "eggs", "flour", "cheese", "sugar"}
// getElement 函数接受一个整数切片和一个索引,返回该索引处的元素
func searchForItem() {
for i := 0; i < len(big); i++ {
if big[i] == "eggs" {
fmt.Println("found eggs")
}
}
}
func main() {
searchForItem()
}
🤝 O(n²) —— 二次时间
O(n²) 通常出现在有嵌套循环的算法中。
💡 例子:就像班级里所有同学要互相握手,每个人都要和其他所有人握手。如果有 5 个人,总共需要 5 × 4 = 20 次握手。如果增加到 100 人,需要 100 × 99 = 9900 次握手,急剧增加。这就是 O(n²)。
JS:
function findPairs(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
console.log(`Pair: ${arr[i]}, ${arr[j]}`);
}
}
}
GO:
func findPairs(arr []int) {
for i := 0; i < len(arr); i++ {
for j := i + 1; j < len(arr); j++ {
fmt.Println("Pair:", arr[i], arr[j])
}
}
}
func main() {
// 示例数组
arr := []int{1, 2, 3, 4}
findPairs(arr)
}
📚 O(log n) —— 对数时间
O(log n) 通常出现在像二分查找这样的算法中。
💡 例子:想象你有一本字典,想找一个特定的单词。你可以从中间开始查找,如果目标单词在前面,就可以忽略后面的部分。每次都能排除一半的可能性,查找速度非常快。这就是 O(log n)。
JS:
function binarySearch(sortedArray, target) {
let left = 0; // 左边界,初始值为数组的第一个索引
let right = sortedArray.length - 1; // 右边界,初始值为数组的最后一个索引
while (left <= right) {
// 计算中间位置(避免整型溢出,使用 Math.floor)
let middle = Math.floor((left + right) / 2);
if (sortedArray[middle] === target) {
return middle; // 如果找到了目标值,返回它的索引
} else if (sortedArray[middle] < target) {
left = middle + 1; // 目标值在右边,缩小范围到右半部分
} else {
right = middle - 1; // 目标值在左边,缩小范围到左半部分
}
}
return -1; // 如果遍历完后仍找不到,返回 -1
}
// 测试案例
const arr = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19];
const target = 9;
const result = binarySearch(arr, target);
console.log(result); // 输出 4(9 的索引)
GO:
func binarySearch(sortedArray []int, target int) int {
left := 0 // 左边界,初始值为数组的第一个索引
right := len(sortedArray) - 1 // 右边界,初始值为数组的最后一个索引
for left <= right {
middle := (left + right) / 2 // 计算中间位置
if sortedArray[middle] == target {
return middle // 如果找到了目标值,返回它的索引
} else if sortedArray[middle] < target {
left = middle + 1 // 目标值在右边,缩小范围到右半部分
} else {
right = middle - 1 // 目标值在左边,缩小范围到左半部分
}
}
return -1 // 如果遍历完后仍找不到,返回 -1
}
func main() {
// 测试案例
arr := []int{1, 3, 5, 7, 9, 11, 13, 15, 17, 19}
target := 9
result := binarySearch(arr, target)
fmt.Println(result) // 输出 4(9 的索引)
}
📖 O(n log n) —— 线性对数时间
O(n log n) 常见于高效排序算法,比如快速排序。
💡 例子:想象你在一个图书馆,想按字母顺序排列书籍。你会把书分成两部分,再把每一部分分别排序。这样既涉及遍历所有书,也涉及分割的操作,所以是 O(n log n)。
❓ 这里说一个大家可能会产生疑问的点:为什么快速排序是 O(n log n) 而查字典是 O(log n)?
我们可以通过对比来解释 快速排序 是 O(n log n),而 查字典 是 O(log n)。
🔍 查字典(O(log n))
在查字典的过程中,你的任务是 找到一个特定的单词。通过二分查找法快速缩小搜索范围,每次查找可以排除一半的可能性。因此,查字典的时间复杂度是 O(log n),因为你只需要对字典进行有限次的查找操作,而不需要遍历每一页。
📚 快速排序(O(n log n))
快速排序的任务是 对整个书库进行排序。排序需要对所有书进行操作,不仅涉及 分割,还涉及 遍历。
每次分割书籍并分别排序的过程涉及 对数分割 和 线性遍历,因此时间复杂度是 O(n log n)。
- n:代表遍历每本书的操作。
- log n:代表每次分割时问题规模减半的操作。
虽然“书越厚,时间越长”确实与 O(n) 的遍历部分相关,但查字典之所以是 O(log n),是因为你只在缩小范围内查找,不需要遍历所有书。
二、什么是数组?📦
在编程中,数组就像一个可以装东西的盒子,你可以把各种不同类型的元素放进这个盒子里。让我们先来看一下在 JavaScript 和 Go 语言中,数组是如何使用的。
1、JavaScript 数组讲解 💻
在 JavaScript 中,数组可以装下不同类型的东西,比如字母、数字、布尔值(true
或 false
),甚至是对象或其他数组。每个元素都有一个编号,称为索引,从 0
开始计数。
示例:
-
stringArr
是一个装字母的盒子,里面有 “a”, “b”, “c”, “d”。const stringArr = ["a", "b", "c", "d"];
-
numArr
是一个装数字的盒子,里面有 1, 2, 3, 4, 5。const numArr = [1, 2, 3, 4, 5];
-
boolArr
是一个装布尔值的盒子,里面有true
和false
。const boolArr = [true, false];
-
mixed
是一个装各种东西的盒子,里面有字母、数字、布尔值、未定义、空值、对象和另一个数组。const mixed = ["a", 2, true, undefined, null, { a: "a" }, ["b"]];
2、Go 语言数组讲解 💻
在 Go 语言中,数组的盒子和 JavaScript 有点不同。每个盒子只能装同一种类型的元素,并且大小是固定的。也就是说,创建时就要指定数组的类型和大小。
示例:
-
如果你想创建一个装字母的盒子:
stringArr := [4]string{"a", "b", "c", "d"}
-
如果你想创建一个装数字的盒子:
numArr := [5]int{1, 2, 3, 4, 5}
-
如果你想创建一个装布尔值的盒子:
boolArr := [2]bool{true, false}
💡 注意:在 Go 语言中,数组的大小一旦确定就不能改变。如果你需要一个可以动态改变大小的盒子,可以使用切片(slice
)。
3、自己定义一个数组 🛠️
为了更好地理解数组的操作,我们来自己写一个模仿 JavaScript 数组的类 MyArray
,支持添加、删除、获取元素等操作。
push(item)
- 放书上架 📚
这个操作类似于在书架上增加一本书。每次调用 push
,会把新的书放到最后一个空位,并增加书架的总数量(length
)。
push(item) {
this.data[this.length] = item;
this.length++;
return this.length;
}
Go 版本:
// 放书上架
func (a *MyArray) Push(item string) int {
a.data[a.length] = item
a.length++
return a.length
}
get(index)
- 拿出某本书 📖
这个方法用来从书架的某个位置取出书籍。根据传入的索引,返回对应的书。
get(index) {
return this.data[index];
}
Go 版本:
// 拿出某本书
func (a *MyArray) Get(index int) string {
return a.data[index]
}
pop()
- 取走最后一本书 📕
pop
会从书架上取下最后一本书,并且减少书架的长度。
pop() {
const lastItem = this.data[this.length - 1];
delete this.data[this.length - 1];
this.length--;
return lastItem;
}
Go 版本:
// 取走最后一本书
func (a *MyArray) Pop() string {
lastItem := a.data[a.length-1]
delete(a.data, a.length-1)
a.length--
return lastItem
}
shift()
- 取走第一本书 📚➡️
shift
就像把书架上最前面的书拿走,后面的书会依次往前移。
shift() {
const firstItem = this.data[0];
for (let i = 0; i < this.length; i++) {
this.data[i] = this.data[i + 1];
}
delete this.data[this.length - 1];
this.length--;
return firstItem;
}
Go 版本:
// 取走第一本书
func (a *MyArray) Shift() string {
firstItem := a.data[0]
for i := 0; i < a.length-1; i++ {
a.data[i] = a.data[i+1]
}
delete(a.data, a.length-1)
a.length--
return firstItem
}
delete(index)
- 删除某个位置的书 ❌📕
delete
会删除书架中指定位置的书,后面的书会往前移动以填补空位。
delete(index) {
const item = this.data[index];
for (let i = index; i < this.length - 1; i++) {
this.data[i] = this.data[i + 1];
}
delete this.data[this.length - 1];
this.length--;
return item;
}
Go 版本:
// 删除某个位置的书
func (a *MyArray) Delete(index int) string {
item := a.data[index]
for i := index; i < a.length-1; i++ {
a.data[i] = a.data[i+1]
}
delete(a.data, a.length-1)
a.length--
return item
}
调用示例 🚀
JavaScript 版本:
const myNewArray = new MyArray();
myNewArray.push("one");
myNewArray.push("two");
myNewArray.push("three");
// myNewArray.pop();
// myNewArray.shift();
myNewArray.delete(1);
console.log(myNewArray.get(0));
console.log(myNewArray);
Go 版本:
func main() {
myArray := NewMyArray()
myArray.Push("one")
myArray.Push("two")
myArray.Push("three")
// myArray.Pop()
// myArray.Shift()
myArray.Delete(1)
fmt.Println(myArray.Get(0))
fmt.Println(myArray)
}
// 1. 将字符串变成数组 (split 方法)
// 2. 反转数组 (reverse 方法)
// 3. 将数组重新变成字符串 (join 方法)
const reverseString = (str) => str.split("").reverse().join("");
console.log(reverseString("hello")); // 输出: olleh
Go 版本:
func reverseString(str string) string {
// 将字符串转换为切片
runes := []rune(str) // 使用 rune 处理 Unicode 字符
// 反转切片
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
// 将切片重新组合为字符串
return string(runes)
}
func main() {
fmt.Println(reverseString("hello")) // 输出 "olleh"
}
4、回文检测 🧠
// 1. 将字符串变成数组 (split 方法)
// 2. 反转数组 (reverse 方法)
// 3. 将数组重新变成字符串 (join 方法)
// 4. 比较字符串是否相等
const palindrome = (str) => str.split("").reverse().join("") === str;
console.log(palindrome("cddc")); // 输出: true
console.log(palindrome("Hello")); // 输出: false
Go 版本:
func reverseString(str string) bool {
// 将字符串转换为切片
runes := []rune(str)
// 反转切片
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
// 将切片重新组合为字符串
restring := string(runes)
return restring == str
}
func main() {
fmt.Println(reverseString("abba")) // 输出: true
fmt.Println(reverseString("hello")) // 输出: false
}
5、反转整数 🔄
// 1. 将整数转换为字符串 (toString 方法)
// 2. 将字符串转换为数组 (split 方法)
// 3. 反转数组 (reverse 方法)
// 4. 将数组转换为字符串 (join 方法)
// 5. 将字符串转换为整数 (parseInt 方法)
// 6. 返回最终数字
const reverseInt = (n) => {
const reversed = n.toString().split("").reverse().join("");
return parseInt(reversed) * Math.sign(n);
};
console.log(reverseInt(-123)); // 输出: -321
Go 版本:
func reverseInt(n int) (int, error) {
// 处理负数
negative := n < 0
if negative {
n = -n // 将负数转换为正数处理
}
// 数字转换为字符串
str := fmt.Sprintf("%d", n)
runes := []rune(str)
// 反转切片
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
restring := string(runes)
reint, err := strconv.Atoi(restring)
if err != nil {
return 0, err
}
if negative {
return -reint, nil
}
return reint, nil
}
func main() {
if result, err := reverseInt(1234); err == nil {
fmt.Println(result) // 输出: 4321
}
}
6、句子首字母大写 🔠
// 1. 将字符串转换为小写 (toLowerCase 方法)
// 2. 使用 split 将字符串分割为单词数组
// 3. 使用 map 使每个单词的首字母大写
// 4. 使用 join 将单词数组重新组合为字符串
const capitalize = (str) => {
return str
.toLowerCase()
.split(" ")
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(" ");
};
console.log(capitalize("hello world")); // 输出: Hello World
console.log(capitalize("jordan peterson")); // 输出: Jordan Peterson
Go 版本:
func capitalize(str string) string {
// 将字符串转换为小写
str = strings.ToLower(str)
// 将字符串分割为单词
words := strings.Split(str, " ")
// 处理每个单词,首字母大写
for i, word := range words {
if len(word) > 0 {
words[i] = string(unicode.ToUpper(rune(word[0]))) + word[1:]
}
}
// 将单词数组重新组合为字符串
return strings.Join(words, " ")
}
func main() {
fmt.Println(capitalize("hello world")) // 输出: Hello World
fmt.Println(capitalize("jordan peterson")) // 输出: Jordan Peterson
}
7、FizzBuzz 🥳
// 1. 循环 1 到 n
// 2. 如果数字可以整除 3,打印 "Fizz"
// 3. 如果数字可以整除 5,打印 "Buzz"
// 4. 如果数字可以整除 3 和 5,打印 "FizzBuzz"
// 5. 否则,打印数字本身
const fizzBuzz = (n) => {
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
console.log("FizzBuzz");
} else if (i % 3 === 0) {
console.log("Fizz");
} else if (i % 5 === 0) {
console.log("Buzz");
} else {
console.log(i);
}
}
};
fizzBuzz(15); // 输出 1 到 15 的 FizzBuzz 结果
Go 版本:
func fizzBuzz(n int) []string {
var result []string
for i := 1; i <= n; i++ {
if i%3 == 0 && i%5 == 0 {
result = append(result, "FizzBuzz")
} else if i%3 == 0 {
result = append(result, "Fizz")
} else if i%5 == 0 {
result = append(result, "Buzz")
} else {
result = append(result, fmt.Sprintf("%d", i))
}
}
return result
}
func main() {
fmt.Println(fizzBuzz(15)) // 输出: FizzBuzz 结果
}
8、最大利润 💰
// 1. 假设第一天的价格是最低价格
// 2. 从第二天开始遍历每个价格,更新最低价格
// 3. 计算当前价格的潜在利润
// 4. 如果当前利润大于之前的最大利润,更新最大利润
const maxProfit = (prices) => {
let minPrice = prices[0];
let maxProfit = 0;
for (let i = 1; i < prices.length; i++) {
const currentPrice = prices[i];
minPrice = Math.min(minPrice, currentPrice);
const potentialProfit = currentPrice - minPrice;
maxProfit = Math.max(maxProfit, potentialProfit);
}
return maxProfit;
};
const prices = [7, 1, 5, 3, 6, 4];
console.log("Maximum profit:", maxProfit(prices)); // 输出最大利润
Go 版本:
func maxProfit(prices []int) int {
minPrice := prices[0]
maxProfit := 0
for i := 1; i < len(prices); i++ {
currentPrice := prices[i]
if currentPrice < minPrice {
minPrice = currentPrice
}
potentialProfit := currentPrice - minPrice
if potentialProfit > maxProfit {
maxProfit = potentialProfit
}
}
return maxProfit
}
func main() {
prices := []int{7, 1, 5, 3, 6, 4}
fmt.Println("Maximum profit:", maxProfit(prices)) // 输出最大利润
}
9、两个数之和(非最佳解) 🔢
function twoSum(nums, target) {
// 循环遍历每个数字
for (let i = 0; i < nums.length; i++) {
// 检查其余部分
for (let j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] === target) {
return [i, j];
}
}
}
return [];
}
console.log(twoSum([2, 7, 11, 15], 9)); // 输出: [0, 1]
// Go 版本需要用哈希表或双循环实现,但可以优化性能
三、链表是什么 ?
链表是一种数据存储方式,和数组类似,但它的数据位置并不是紧挨着的。每个节点存储着数据,同时还会告诉你下一个节点在哪里。这种”告诉下一个节点位置”的机制就是指针。可以把它想象成一串珍珠项链,每颗珍珠(节点)都通过线(指针)串联在一起。
生活中的例子: 你在逛一条街,每个路标上写着两个信息:一个是当前的地名(数据),另一个是”接下来你该往哪走”的箭头(指针)。你就这样依次往下走,直到没有路标了。
1、单链表是什么?
单链表是最基础的链表,每个节点只有一个指针,只能指向下一个节点。这样你就只能顺着方向走,无法回头。
生活中的例子: 想象你在一条单行道上开车,只能沿着路一直往前走,不能掉头或返回。这就像单链表,只能朝一个方向前进。
关键点:
- 每个节点只知道下一个节点。
- 无法往回走。
2、双链表是什么?
双链表比单链表更灵活,它的每个节点有两个指针:一个指向下一个节点,另一个指向上一个节点。这样,你既可以往前走,也可以倒退。
生活中的例子: 这就像坐电梯,你可以从1楼到2楼(往前走),也可以从2楼回到1楼(倒退)。双链表就像这部电梯,可以在节点之间自由来回。
关键点:
- 每个节点知道前后两个节点。
- 你可以往前也可以往回走。
3、反向链表(反转链表)是什么?
反向链表,顾名思义就是把链表中的顺序倒过来。假如你有一个链表是 A -> B -> C -> D,反转链表之后它会变成 D -> C -> B -> A。
生活中的例子: 假如你从家里出发,依次经过超市(B)和公园(C)到达朋友家(D)。反转链表就像你从朋友家原路返回到家,途经公园和超市,但顺序变成 D -> C -> B -> A。
关键点:
- 原本的第一个节点会变成最后一个,原本的最后一个节点会变成第一个。
补充:链表 vs 数组
数组和链表都是存储数据的方式,但数组里的数据是紧挨着存放的,而链表里的数据是分散的,只是通过指针串起来。链表的优势是当你想要插入或删除数据时,操作比数组简单,但链表的缺点是查找某个数据需要从头开始一个个查找,而数组可以通过索引直接找到目标。
4、链表可以存储什么数据?
链表可以存储任何类型的数据,像数字、字符串、对象、甚至其他链表!链表的一个强大之处就是它可以灵活地存储不同类型的数据。
例如:
- 数字链表:
1 -> 2 -> 3
- 字符串链表:
"apple" -> "banana" -> "cherry"
- 混合链表:
1 -> "apple" -> { name: "John" }
链表不限制你存储的数据类型,只要你将它们放入节点,链表就能把它们串联起来。
5、单向链表:
- 头节点(head):
- 值为
1
,其next
指向下一个节点(值为2
)。
- 值为
- 中间节点:
- 值为
2
,其next
指向下一个节点(值为3
)。
- 值为
- 尾节点(tail):
- 值为
3
,其next
为null
,表示这是链表的最后一个节点。
- 值为
- 链表的长度(length):
- 链表的长度为
3
,表示链表中有三个节点。
- 链表的长度为
LinkedList {
head: Node {
value: 1,
next: Node {
value: 2,
next: Node {
value: 3,
next: null
}
}
},
tail: Node {
value: 3,
next: null
},
length: 3
}
1. Node
类
链表中的每一个节点可以想象成一节车厢,每个车厢保存数据,并且与下一个车厢有连接(指针)。
class Node {
constructor(value) {
this.value = value; // 节点存储的数据,比如车厢里装的货物
this.next = null; // 指向下一个节点的指针,初始为空(null),表示没有下一个节点
}
}
value
是这个节点存储的值,可能是数字、字符串等。next
是一个指针,指向链表中的下一个节点。
2. LinkedList
类
这个类代表了整个链表,它包含对链表的第一个节点(head
)和最后一个节点(tail
)的引用。
初始化链表
class LinkedList {
constructor(value) {
this.head = new Node(value); // 创建链表的第一个节点,并将其设为头节点
this.tail = this.head; // 由于链表只有一个节点,尾节点等于头节点
this.length = 1; // 链表初始长度为 1
}
head
指向链表的第一个节点(就像列车的第一节车厢)。tail
指向链表的最后一个节点(只有一个节点时,head
和tail
是同一个)。length
表示链表中的节点数量。
实例化:
const myLinkedList = new LinkedList(1); // 创建一个包含值为 1 的节点的链表
现在我们有了一个只有一个节点的链表,节点的值为 1
。
3. push
方法
在链表末尾添加一个新节点,相当于给列车增加一个新车厢。
push(value) {
let newNode = new Node(value); // 创建一个新节点
// 如果链表为空,重新设置头和尾节点
if (!this.head) {
this.head = newNode;
this.tail = newNode;
}
// 将当前尾节点的 next 指向新节点
this.tail.next = newNode;
// 更新尾节点为新添加的节点
this.tail = newNode;
// 链表长度加 1
this.length++;
}
- 新节点通过
tail.next
与当前的尾节点连接。 - 新节点成为新的尾节点。
- 链表长度增加。
实践:
myLinkedList.push(2); // 在末尾添加值为 2 的节点
myLinkedList.push(3); // 在末尾添加值为 3 的节点
4. pop
方法
从链表末尾删除节点,相当于移除列车的最后一节车厢。
pop() {
// 如果链表为空,返回 undefined
if (!this.head) {
return undefined;
}
let temp = this.head; // 临时变量,用于遍历链表
let prev = this.head; // 保存当前节点的前一个节点
// 遍历链表,直到找到最后一个节点
while (temp.next) {
prev = temp;
temp = prev.next;
}
// 将尾节点设为前一个节点,并断开最后一个节点的链接
this.tail = prev;
this.tail.next = null;
this.length--; // 长度减 1
// 如果链表长度为 0,将头和尾都设为 null
if (this.length === 0) {
this.head = null;
this.tail = null;
}
return temp; // 返回移除的节点
}
- 通过遍历链表找到最后一个节点,然后移除它。
- 更新尾节点为倒数第二个节点,链表长度减少。
实践:
myLinkedList.pop(); // 移除末尾节点
5. unshift
方法
在链表头部插入一个新节点,相当于在列车的前面添加一个车厢。
unshift(value) {
const newNode = new Node(value); // 创建新节点
// 如果链表为空,将新节点作为头和尾
if (!this.head) {
this.head = newNode;
this.tail = newNode;
}
// 将新节点的 next 指向当前头节点,并更新头节点为新节点
newNode.next = this.head;
this.head = newNode;
this.length++; // 长度加 1
return this;
}
- 新节点成为链表的新头节点。
- 旧头节点通过
newNode.next
与新节点相连。
实践:
myLinkedList.unshift(0); // 在头部插入值为 0 的节点
6. shift
方法
从链表头部删除节点,就像移除列车的第一节车厢。
shift() {
// 如果链表为空,返回 undefined
if (!this.head) {
return undefined;
}
let temp = this.head; // 保存当前头节点
this.head = this.head.next; // 将头节点更新为下一个节点
temp.next = null; // 断开旧头节点与链表的连接
this.length--; // 长度减 1
// 如果链表为空,重置尾节点为 null
if (this.length === 0) {
this.tail = null;
}
return temp; // 返回移除的节点
}
- 删除头节点,并更新链表的头指针。
- 如果链表为空,尾节点也会被重置。
实践:
myLinkedList.shift(); // 移除头节点
7. getFirst
和 getLast
方法
getFirst() {
return this.head; // 返回头节点
}
getLast() {
return this.tail; // 返回尾节点
}
getFirst()
返回链表的头节点,getLast()
返回链表的尾节点。
8. get
方法
获取指定索引的节点,就像查看列车中第几节车厢装了什么货物。
get(index) {
let counter = 0; // 计数器,用于记录当前位置
let node = this.head;
// 遍历链表,直到计数器等于目标索引
while (node) {
if (counter === index) {
return node; // 返回指定位置的节点
}
counter++;
node = node.next;
}
return null; // 如果索引超出范围,返回 null
}
- 通过遍历链表查找对应位置的节点。
实践:
myLinkedList.get(1); // 获取索引为 1 的节点
9. set
方法
更新指定索引的节点的值,比如更新某节车厢里的货物。
set(index, value) {
let temp = this.get(index); // 获取指定位置的节点
if (temp) {
temp.value = value; // 更新节点的值
return true; // 返回 true 表示更新成功
}
return false; // 如果节点不存在,返回 false
}
- 查找对应节点并更新其值。
实践:
myLinkedList.set(1, 5); // 更新索引为 1 的节点的值为 5
10. insert
方法
在指定索引位置插入节点,就像在某节车厢前面插入一个新车厢。
insert(index, value) {
if (index === 0) {
return this.unshift(value); // 如果索引为 0,调用 unshift 方法插入头节点
}
if (index === this.length) {
return this.push(value); // 如果索引等于链表长度,调用 push 方法插入尾节点
}
const newNode = new Node(value);
const temp = this.get(index - 1); // 获取插入位置的前一个节点
newNode.next = temp.next; // 将新节点的 next 指向原来的下一个节点
temp.next = newNode; // 将前
一个节点的 next 指向新节点
this.length++; // 链表长度加 1
return true;
}
- 插入新节点后,前一个节点通过
next
与新节点相连,新节点通过next
指向原来的下一个节点。
实践:
myLinkedList.insert(1, 4); // 在索引 1 的位置插入值为 4 的节点
6、双向链表
双向链表(Doubly Linked List),在这个链表中,每个节点不仅有指向下一个节点的指针(next
),还包含一个指向前一个节点的指针(prev
)。双向链表的好处是我们可以从头遍历到尾部,也可以从尾部遍历到头部。
DoublyLinkedList {
head: Node {
value: 1,
next: Node {
value: 2,
next: Node {
value: 3,
next: null,
prev: [Node with value 2]
},
prev: [Node with value 1]
},
prev: null
},
tail: Node {
value: 3,
next: null,
prev: Node {
value: 2,
next: [Node with value 3],
prev: [Node with value 1]
}
},
length: 3
}
1. Node
类
每个节点可以看作是链表中的一个“车厢”,其中包含了数据,并且车厢与前后车厢通过“连接器”连接。
class Node {
constructor(value) {
this.value = value; // 节点保存的值(比如一节车厢里存放的东西)
this.next = null; // 指向下一个节点(车厢)的指针
this.prev = null; // 指向前一个节点(车厢)的指针
}
}
value
是存储在节点中的数据,可能是数字、字符串、对象等。next
表示下一个节点是谁(就像每节车厢通过连接器连接下一个车厢)。prev
表示前一个节点是谁(可以回到前一节车厢)。
2. DoublyLinkedList
类
这个类代表整个双向链表,就像整个列车由多个车厢组成。
class DoublyLinkedList {
constructor(value) {
const newNode = new Node(value); // 创建第一个节点
this.head = newNode; // 链表的头部(第一节车厢)
this.tail = this.head; // 链表的尾部(只有一个节点时,头尾相同)
this.length = 1; // 初始链表长度为1
}
head
是链表的第一个节点。tail
是链表的最后一个节点(当链表只有一个节点时,head
和tail
相同)。length
记录链表中的节点数量。
实例化:
let myDoublyLinkedList = new DoublyLinkedList(0); // 创建一个双向链表,并且其中存放数字0
3. push
方法
向链表的尾部添加新节点,就像列车增加新车厢。
push(value) {
const newNode = new Node(value); // 创建新节点(新车厢)
if (!this.head) { // 如果链表为空(列车没有车厢)
this.head = newNode; // 让新节点成为头节点
this.tail = newNode; // 也成为尾节点
} else { // 如果链表不为空
this.tail.next = newNode; // 旧尾节点的 next 指向新节点(将新车厢连接到列车尾部)
newNode.prev = this.tail; // 新节点的 prev 指向旧尾节点(双向连接)
this.tail = newNode; // 将尾节点更新为新节点(新车厢成为列车的最后一节)
}
this.length++; // 链表长度加1
return this; // 返回链表,方便链式调用
}
实践:
就像在购物车中不断添加商品,链表会不断增加新的节点,每个新商品都放在购物车的末尾。
4. pop
方法
从链表的尾部删除节点,相当于移除列车的最后一节车厢。
pop() {
if (this.length === 0) { // 如果链表为空
return undefined; // 返回 undefined,表示没有可以删除的节点
}
let temp = this.tail; // 暂存当前的尾节点(要被删除的车厢)
if (this.length === 1) { // 如果链表中只有一个节点
this.head = null; // 清空链表头
this.tail = null; // 清空链表尾
} else {
this.tail = this.tail.prev; // 将尾节点更新为前一个节点(倒数第二节车厢成为新尾部)
this.tail.next = null; // 新尾节点的 next 设置为 null,断开与旧尾节点的连接
temp.prev = null; // 清除旧尾节点的 prev
}
this.length--; // 链表长度减1
return temp; // 返回被删除的节点
}
实践:
就像从购物车中移除最后一个加入的商品,链表会从末尾删除一个节点。
5. unshift
方法
向链表的头部添加新节点,就像在列车的前面增加新车厢。
unshift(value) {
const newNode = new Node(value); // 创建新节点(新车厢)
if (this.length === 0) { // 如果链表为空
this.head = newNode; // 新节点成为头节点
this.tail = newNode; // 新节点也成为尾节点
} else {
newNode.next = this.head; // 新节点的 next 指向旧头节点
this.head.prev = newNode; // 旧头节点的 prev 指向新节点
this.head = newNode; // 更新头节点为新节点
}
this.length++; // 链表长度加1
return this; // 返回链表,方便链式调用
}
实践:
在购物车的前面放一个特别重要的商品,链表会在头部插入一个新节点。
6. shift
方法
从链表的头部删除节点,相当于移除列车的第一节车厢。
shift() {
if (this.length === 0) { // 如果链表为空
return undefined; // 返回 undefined,表示没有可以删除的节点
}
let temp = this.head; // 暂存当前的头节点(要被删除的车厢)
if (this.length === 1) { // 如果链表中只有一个节点
this.head = null; // 清空链表头
this.tail = null; // 清空链表尾
} else {
this.head = this.head.next; // 更新头节点为下一个节点(第二节车厢成为新头部)
this.head.prev = null; // 新头节点的 prev 设置为 null,断开与旧头节点的连接
temp.next = null; // 清除旧头节点的 next
}
this.length--; // 链表长度减1
return temp; // 返回被删除的节点
}
}
实践:
从购物车中移除第一个加入的商品,链表会从头部删除一个节点。
7. 实例化链表并添加元素
let myDoublyLinkedList = new DoublyLinkedList(0); // 创建一个初始值为 0 的链表
myDoublyLinkedList.push(1); // 在链表末尾添加 1
console.log(myDoublyLinkedList); // 打印链表
- 我们首先创建了一个双向链表,其中第一个节点保存数字
0
。 - 然后使用
push(1)
在链表末尾添加一个节点,保存数字1
。
7、反转链表
反转链表 vs 单向链表
单向链表(Singly Linked List):
- 一本书从头翻到尾。
反转链表(Reversing a Linked List):
- 一本书从尾翻到头。
1、Node
类
// 定义一个节点类,每个节点代表火车中的一节车厢
class Node {
constructor(value) {
// 当前节点存储的值,类似于车厢编号
this.value = value;
// 指向下一节车厢(节点)的指针,初始为null表示没有下一节车厢
this.next = null;
}
}
2、LinkedList
类
// 定义一个单向链表类,火车链条就是链表的整体结构
class LinkedList {
constructor(value) {
// 创建一节新的车厢(节点),设置它为火车的第一节车厢(head)
this.head = new Node(value);
// 只有一节车厢时,头和尾都是同一节
this.tail = this.head;
// 初始链表长度为1(因为有一个车厢)
this.length = 1;
}
3、push
方法
// 在火车末尾添加新的车厢
push(value) {
// 创建一个新的车厢(节点)
let newNode = new Node(value);
// 如果火车链表没有头部(即链表为空),我们直接把新车厢设为头和尾
if (!this.head) {
this.head = newNode;
this.tail = newNode;
}
// 将当前尾车厢连接到新车厢
this.tail.next = newNode;
// 更新链表的尾部为新车厢
this.tail = newNode;
// 链表长度加1
this.length++;
}
4、reverse
方法
// 反转火车方向(反转链表)
reverse() {
// 先用临时变量保存当前的头车厢(起始车厢)
let temp = this.head;
// 将头部变成尾部
this.head = this.tail;
// 将尾部变成头部
this.tail = temp;
// 为了反转车厢的顺序,定义两个变量:next保存下一个车厢,prev保存前一个车厢
let next = temp; // 下一个车厢
let prev = null; // 前一个车厢,最开始没有前一个车厢
// 循环遍历整个火车链条
for (let i = 0; i < this.length; i++) {
// 保存当前车厢的下一个车厢的引用
next = temp.next;
// 将当前车厢的连接指向前一个车厢(反转方向)
temp.next = prev;
// 前一个车厢更新为当前车厢
prev = temp;
// 当前车厢更新为下一个车厢,继续向后遍历
temp = next;
}
// 返回链表,以便进行调试和验证
return this;
}
}
// 创建一个新的火车链表,并加入多个车厢
const myLinkedList = new LinkedList(1); // 初始车厢编号为1
myLinkedList.push(2); // 添加车厢2
myLinkedList.push(3); // 添加车厢3
myLinkedList.push(4); // 添加车厢4
// 调用反转函数,将车厢顺序反转
console.log(myLinkedList.reverse());
四、栈与队列
1、什么是栈(Stack)?
- 特点:后进先出(LIFO,Last In First Out)。就像一堆盘子,你总是先拿掉最上面的盘子,然后再拿下面的。
- 操作:主要有两个操作,
push
(添加)和pop
(移除)。push
操作总是在栈顶添加元素,而pop
操作则是从栈顶移除元素。 - 日常例子:想象你在吃汉堡。你一层一层地叠加食材,最后一层放上去的(比如生菜),往往是你第一口吃到的。这就是后进先出的概念。
2、什么是队列(Queue)?
- 特点:先进先出(FIFO,First In First Out)。就像排队买票,排在队伍前面的总是最先被服务的。
- 操作:主要有两个操作,
enqueue
(添加)和dequeue
(移除)。enqueue
操作总是在队尾添加元素,而dequeue
操作则是从队头移除元素。 - 日常例子:在银行排队办理业务,排在你前面的人会先被服务,然后轮到你,这就是先进先出的概念。
3、栈(Stack)
栈可以理解为“先进后出”(First In Last Out,FILO)的结构。举个简单的例子,就像我们在餐厅里堆盘子,最新放上去的盘子会最先被拿走。
Node
类
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
这个类用来创建栈中的节点,每个节点有两个属性:
- value:节点的值,比如盘子上的数字。
- next:指向下一个节点的位置(就像一个盘子堆在另一个上面)。
Stack
类的构造函数
class Stack {
constructor(value) {
const newNode = new Node(value);
this.first = newNode;
this.length = 1;
}
}
这个构造函数会创建一个新栈,初始时栈里有一个节点,也就是放入第一个盘子。
push
方法
push(value) {
const newNode = new Node(value);
if (this.length === 0) {
this.first = newNode;
} else {
newNode.next = this.first;
this.first = newNode;
this.length++;
return this;
}
}
Push 方法就像在盘子堆上加一个新的盘子:
- 创建一个新的盘子(节点
newNode
)。 - 把新盘子放到最上面(
newNode.next = this.first
),然后把栈顶指向新盘子(this.first = newNode
)。
pop
方法
pop() {
if (this.length === 0) {
return undefined;
}
let temp = this.first;
this.first = this.first.next;
temp.next = null;
this.length--;
return temp;
}
Pop 方法就是从盘子堆顶拿走最上面的盘子:
- 把栈顶的盘子拿出来(
temp = this.first
)。 - 然后让栈顶指向下一个盘子(
this.first = this.first.next
)。 - 最后返回拿走的盘子。
top
方法
top() {
if (this.length === 0) {
return undefined;
}
return this.first;
}
Top 方法就是看看最上面那个盘子是谁,但不拿走。
总结
- Push:往盘子堆上加一个新盘子。
- Pop:从盘子堆上拿走最上面的盘子。
- Top:看看最上面的盘子是谁,但不动它。
let theStack = new Stack(0);
theStack.push(1);
theStack.push(2);
console.log(theStack);
new Stack(0)
:创建了一个栈,初始盘子是0
。push(1)
:在盘子0
上面放了一个盘子1
。push(2)
:再在盘子1
上放了一个盘子2
。
最终结果:栈的顺序是 2 -> 1 -> 0
,最上面的盘子是 2
。
4、队列(Queue)
队列是“先进先出”(First In First Out,FIFO)的数据结构。举个日常的例子,想象你在排队买票,最先排队的人会最先买到票,后面排队的人得等前面的人离开后才能继续。
代码分块讲解
Node
类
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
这个类用来创建队列中的每个节点。每个节点(就像一张票)包含:
- value:节点的值,比如票上的编号。
- next:指向下一个节点,像排队时下一个人。
Queue
类的构造函数
class Queue {
constructor(value) {
const newNode = new Node(value);
this.first = newNode;
this.last = newNode;
this.length = 1;
}
}
构造函数初始化了一个队列,最开始队列里有一个节点,类似于排队时第一个人在队伍里。
enqueue
方法
enqueue(value) {
const newNode = new Node(value);
if (this.length === 0) {
this.first = newNode;
this.last = newNode;
}
this.last.next = newNode;
this.last = newNode;
this.length++;
return this;
}
Enqueue 方法可以理解为加入排队:
- 创建一个新的节点(相当于新来的人,
newNode
)。 - 将新节点放在队列最后(
this.last.next = newNode
),并将last
更新为新的节点。 - 队伍长度增加。
dequeue
方法
dequeue() {
if (this.length === 0) {
return undefined;
}
let temp = this.first;
if (this.length === 1) {
this.first = null;
this.last = null;
}
this.first = this.first.next;
temp.next = null;
this.length--;
return temp;
}
Dequeue 方法可以理解为离开排队:
- 拿出队伍最前面的人(
temp = this.first
)。 - 如果队列只有一个人,
first
和last
都变为null
。 - 队列的第一个人变为下一个人(
this.first = this.first.next
)。 - 返回最先离开的人(
temp
)。
总结
- Enqueue:加入队伍,放到最后面。
- Dequeue:离开队伍,最前面的人先离开。
let myQueue = new Queue(0);
myQueue.enqueue(1);
myQueue.enqueue(2);
myQueue.dequeue();
console.log(myQueue);
new Queue(0)
:创建一个队列,最开始队伍里有一个人,编号0
。enqueue(1)
:加入一个新的人,编号1
,排在0
的后面。enqueue(2)
:再加入一个新的人,编号2
,排在队伍的最后。dequeue()
:最前面的0
离开队伍,剩下的队伍是1 -> 2
。
最终结果:队列里的人从前到后是 1 -> 2
,最前面的变成了 1
。
5、找出最小值的盘子
栈(Stack)概念回顾
栈是“先进后出”(First In Last Out,FILO)的数据结构。就像堆盘子,最先放进去的盘子被压在最下面,最后放上去的盘子在最上面,因此会最先被拿走。
Node
类
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
每个盘子是一个节点 Node
,它有两个属性:
- value:盘子上的值。
- next:指向下一个盘子。
Stack
类的构造函数
class Stack {
constructor(value) {
const newNode = new Node(value);
this.first = newNode;
this.length = 1;
}
}
创建栈时,如果传入初始值,就会将第一个盘子放进去,并把它作为栈顶(first
),并记录栈的长度为 1。
push
方法
push(value) {
const newNode = new Node(value);
if (this.length === 0) {
this.first = newNode;
} else {
newNode.next = this.first;
this.first = newNode;
this.length++;
}
return this;
}
Push 就是往盘子堆上加一个新盘子:
- 创建一个新的盘子(
newNode
)。 - 把新盘子放在栈顶,并让原来的盘子成为新盘子的下一个(
newNode.next = this.first
)。 - 栈的长度增加。
pop
方法
pop() {
if (this.length === 0) {
return undefined;
}
let temp = this.first;
this.first = this.first.next;
temp.next = null;
this.length--;
return temp;
}
Pop 就是拿走栈顶的盘子:
- 把最上面的盘子拿走,并将第二个盘子(
this.first.next
)放到栈顶。 - 栈的长度减少。
top
方法
top() {
if (this.length === 0) {
return undefined;
}
return this.first;
}
Top 方法是查看栈顶的盘子,不移除它。
min
方法
min() {
if (this.length === 0) {
return undefined;
}
let current = this.first;
let minValue = current.value;
while (current.next) {
current = current.next;
if (current.value < minValue) {
minValue = current.value;
}
}
return minValue;
}
Min 方法就像找出最小值的盘子:
- 遍历所有盘子,比较每个盘子的值。
- 找出值最小的那个盘子,并返回它的值。
总结
- Push:往盘子堆上加盘子。
- Pop:拿走最上面的盘子。
- Top:查看最上面的盘子。
- Min:查看所有盘子里值最小的盘子。
let theStack = new Stack();
theStack.push(1);
theStack.push(2);
theStack.push(3);
console.log(theStack.min());
new Stack()
:创建一个空栈。push(1)
:将盘子1
放入栈。push(2)
:将盘子2
放在盘子1
上面。push(3)
:将盘子3
放在盘子2
上面。min()
:遍历所有盘子,找到最小的盘子,返回1
。
最终输出:1
,因为 1
是最小的盘子值。
6、isValidParenthesis
括号匹配问题概念
括号匹配问题是指检查一个字符串中的括号是否成对出现且顺序正确。可以用栈(先进后出的结构)来解决,因为括号的嵌套关系和栈的“后进先出”特性很契合。
举个例子:假设我们正在堆盘子,每个盘子有一个对应的盖子。当我们放一个盘子时,也会放上盖子,而最上面的盘子和盖子要先被拿走。如果盖子和盘子的顺序不对,我们就会发现错误。
代码分块讲解
定义栈与括号映射
const stack = [];
const brackets = {
"{": "}",
"[": "]",
"(": ")",
};
- Stack:像我们放盘子的地方,存储每个“左括号”(也就是盘子)。
- Brackets:定义左括号与右括号的对应关系,就像定义每个盘子和它对应的盖子。
循环遍历字符串
for (let char of str) {
if (brackets[char]) {
stack.push(char);
} else {
const top = stack.pop();
if (!top || brackets[top] !== char) {
return false;
}
}
}
- 遍历字符串中的每个字符:
- 如果是左括号(如
{
,(
,[
),就把它推入栈中,像是放上盘子。 - 如果是右括号(如
}
,)
,]
),就检查它是否和栈顶的左括号匹配:- 从栈中拿出最上面的盘子,检查是否能和当前盖子匹配。
- 如果匹配不上,就返回
false
(表示顺序错误,比如盘子放错了盖子)。
- 如果是左括号(如
最终检查栈
return stack.length === 0;
- 最后,检查栈是否为空。如果栈为空,说明所有的括号都成对匹配了(就像所有的盘子都有了对应的盖子),否则返回
false
。
console.log(isValidParenthesis("(){}[]")); // true
console.log(isValidParenthesis("([)]")); // false
console.log(isValidParenthesis("()")); // true
console.log(isValidParenthesis("(")); // false
isValidParenthesis("(){}[]")
:- 每对括号都正确配对,返回
true
。
- 每对括号都正确配对,返回
isValidParenthesis("([)]")
:- 括号
[
和)
不匹配,返回false
。
- 括号
isValidParenthesis("()")
:- 括号正确配对,返回
true
。
- 括号正确配对,返回
isValidParenthesis("(")
:- 没有对应的右括号,返回
false
。
- 没有对应的右括号,返回
总结
- Stack:存储左括号,像堆盘子。
- Push:遇到左括号时,往栈里加盘子。
- Pop:遇到右括号时,检查是否匹配最上面的盘子,如果匹配不上,就返回
false
。 - 最后检查栈是否为空,确保所有盘子都盖好盖子。
7、使用栈反转字符串
字符串反转问题
反转字符串可以用栈(先进后出的结构)轻松实现。想象你把盘子从桌子上一个一个堆起来,然后再一个一个按照相反的顺序拿下来,最后拿的盘子最先放上去,类似于反转字符串中的字符顺序。
代码分块讲解
1. 创建一个空的栈
const stack = [];
- 栈:就像堆盘子的地方,用来存放字符串的每个字符。
2. 将字符串的每个字符放入栈中
for (let char of str) {
stack.push(char);
}
- 遍历字符串的每个字符,用
stack.push(char)
把字符放进栈里。 - 这里相当于一个个将盘子堆到栈里,顺序是从左到右。
3. 初始化一个空字符串来存储反转后的字符串
let reversedStr = "";
- reversedStr:用来拼接反转后的字符,最终得到反转的字符串。
4. 从栈中弹出字符并构建反转字符串
while (stack.length > 0) {
reversedStr += stack.pop();
}
- Pop:每次从栈顶取出字符,并将它加到
reversedStr
。 - 因为栈是先进后出的数据结构,最早进入的字符会在最后被弹出,所以我们可以按反序构建字符串。
- 就像你最早堆上去的盘子最后被拿下来,反转了顺序。
5. 返回反转后的字符串
return reversedStr;
- 反转后的字符串拼接完成后,返回结果。
const originalString = "hello world";
const reversedString = reverseString(originalString);
console.log(reversedString); // Output: dlrow olleh
reverseString("hello world")
:- 把每个字符
h
,e
,l
,l
,o
,w
,o
,r
,l
,d
依次放入栈。 - 然后从栈顶开始,一个一个弹出字符,拼接成
dlrow olleh
。 - 输出反转后的字符串
dlrow olleh
。
- 把每个字符
总结
- Push:将每个字符堆到栈里,像把盘子一个一个叠起来。
- Pop:从栈顶开始弹出字符,像一个一个从盘子堆里拿出来,顺序和堆的时候相反。
- 最终,我们得到反转后的字符串。
五、哈希表
Java Script —> 对象 Object
Python —> 字典 Dictionaries
Go —> 映射 Maps
1、哈希表概念
哈希表(Hash Table)是一种用于快速查找和存储数据的结构。它的核心原理是通过一个 哈希函数,将数据映射到一个数组中,通过索引快速找到数据。
想象我们有一个放盘子的柜子,每个柜子有固定编号,哈希函数就像一个分配规则,根据盘子(数据)的名字来决定它应该放在哪个编号的柜子里。
1. 构造函数
constructor(size = 6) {
this.keyMap = new Array(size);
}
- 我们初始化一个固定大小的数组
keyMap
,用来存储数据。每个数据(盘子)通过哈希函数分配到数组中的某个位置(柜子)。 - 默认柜子大小是
6
。
2. 哈希函数
_hashFunction(key) {
let sum = 0;
const PRIME_NUMBER = 31;
for (let i = 0; i < Math.min(key.length, 100); i++) {
const charCode = key.charCodeAt(0) - 96;
sum = (sum * PRIME_NUMBER + charCode) % this.keyMap.length;
}
return sum;
}
- 哈希函数:根据输入的
key
计算出一个数字,这个数字对应数组的索引位置。这个过程就像根据盘子的特定特征,分配它在某个柜子里存放。 - 这里我们使用一个素数(31)进行乘法运算,可以减少碰撞(不同的数据被放到同一个柜子)。
3. 设置数据(set
方法)
set(key, value) {
const index = this._hashFunction(key);
if (!this.keyMap[index]) this.keyMap[index] = [];
this.keyMap[index].push([key, value]);
return this;
}
- 哈希函数根据
key
计算出柜子的编号(索引index
)。 - 如果这个柜子是空的,就新建一个空数组。
- 然后把键值对
[key, value]
放入这个柜子里,就像把盘子放进特定的柜子里。
4. 获取数据(get
方法)
get(key) {
const index = this._hashFunction(key);
if (this.keyMap[index]) {
for (let i = 0; i < this.keyMap[index].length; i++) {
if (this.keyMap[index][i][0] === key) {
return this.keyMap[index][i][1];
}
}
}
return undefined;
}
- 根据
key
计算哈希值,找到对应的柜子。 - 在柜子里找到对应的盘子(
key
),并返回它的值(value
)。
5. 获取所有键值对
获取所有键:
getAllKeys() {
const keys = [];
for (let i = 0; i < this.keyMap.length; i++) {
if (this.keyMap[i]) {
for (let j = 0; j < this.keyMap[i].length; j++) {
keys.push(this.keyMap[i][j][0]);
}
}
}
return keys;
}
获取所有值:
getAllValues() {
const values = [];
for (let i = 0; i < this.keyMap.length; i++) {
if (this.keyMap[i]) {
for (let j = 0; j < this.keyMap[i].length; j++) {
values.push(this.keyMap[i][j][1]);
}
}
}
return values;
}
- 遍历整个
keyMap
,收集所有柜子里的所有key
或value
。 - 这相当于我们在柜子里找所有的盘子,或者所有盘子的标签。
const phoneBook = new HashTable();
phoneBook.set("john", "555-333-444");
phoneBook.set("jordan", "222-444-666");
phoneBook.set("michel", "111-666-222");
console.log(phoneBook.get("jordan")); // 输出: 222-444-666
console.log(phoneBook.getAllValues()); // 输出: ['555-333-444', '222-444-666', '111-666-222']
console.log(phoneBook.getAllKeys()); // 输出: ['john', 'jordan', 'michel']
phoneBook.set("john", "555-333-444")
:将"john"
和"555-333-444"
存入哈希表。phoneBook.get("jordan")
:通过jordan
找到他的电话号码"222-444-666"
。phoneBook.getAllValues()
:返回所有电话号码。phoneBook.getAllKeys()
:返回所有姓名。
总结
- 哈希表:像一个有很多柜子的架子,用来存储盘子(数据)。每个盘子根据它的特征被分配到不同的柜子里。
- 哈希函数:根据盘子的名字(key),决定它应该放在哪个柜子里(计算索引)。
- 设置数据:将盘子和它的标签一起存放到正确的柜子中。
- 获取数据:根据标签(key)找到盘子的位置,并拿出它的值(value)。
2、wordCounter
这个函数用于计算文本中每个单词出现的次数。它将文本转为小写,并使用哈希表来记录每个单词的计数。
function wordCounter(text) {
const lowerText = text.toLowerCase();
const wordMap = {};
const words = lowerText.split(/\s+/);
for (const word of words) {
if (word in wordMap) {
wordMap[word]++;
} else {
wordMap[word] = 1;
}
}
return wordMap;
}
日常例子:盘子柜子
- 想象每个单词是一个盘子,哈希表就像柜子,我们为每个盘子建立一个柜子来记录它出现的次数。
- key 是盘子的标签(单词),value 是盘子的数量(出现的次数)。
步骤讲解
- 将文本转换为小写:避免“Hello”和“hello”被视为不同的单词。
- 分割单词:用空格分割成一个个单词,就像把盘子一个个放入柜子。
- 记录每个单词的出现次数:每个单词对应的柜子(
wordMap
)里累加它的数量。
const text = "Hello my name name name is huxn";
// { hello: 1, my: 1, name: 3, is: 1, huxn: 1 }
const wordCounts = wordCounter(text);
console.log(wordCounts);
3、twoSum
这个函数在一个数组中寻找两个数字,使它们的和等于目标值,并返回它们的索引。
function twoSum(nums, target) {
const numMap = {};
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (complement in numMap && numMap[complement] !== i) {
return [numMap[complement], i];
}
numMap[nums[i]] = i;
}
return [];
}
日常例子:找盘子
- 想象你有一些盘子(数组中的数字),你需要找到两个盘子的编号,它们加起来的重量等于目标重量(
target
)。 - 使用哈希表(
numMap
)存储盘子及其编号,找到合适的盘子组合。
步骤讲解
- 计算差值:在找盘子的时候,我们先计算出目标重量与当前盘子重量的差值。
- 检查差值是否已经存在:如果差值对应的盘子已经在哈希表中,说明我们找到了两个盘子可以组成目标重量。
- 返回两个盘子的索引:找到两个盘子后返回它们的编号。
const nums = [2, 7, 11, 15];
const target = 9;
const result = twoSum(nums, target);
console.log(result); // [0, 1]
3、groupAnagrams
这个函数将一组单词按字母异位词(Anagram)分组。字母异位词是指由相同字母组成、顺序不同的单词。
function groupAnagrams(strs) {
const anagramMap = {};
for (const str of strs) {
const sortedStr = str.split("").sort().join("");
if (sortedStr in anagramMap) {
anagramMap[sortedStr].push(str);
} else {
anagramMap[sortedStr] = [str];
}
}
return Object.values(anagramMap);
}
日常例子:整理盘子
- 想象我们有一些盘子,每个盘子上刻着不同的字母。我们要把字母相同、但顺序不同的盘子放在一起。
- 使用哈希表(
anagramMap
)把这些盘子归类,把字母一样的放到同一个柜子里。
步骤讲解
- 排序单词:每个单词都可以打乱字母顺序,但只要我们把字母按顺序排列,异位词就会一样。
- 将异位词分组:通过排序后的单词,把相同的单词放进同一个组(哈希表中的同一个
key
)。 - 返回结果:最后,我们从哈希表中取出所有的分组。
const strs = ["eat", "tea", "tan", "ate", "nat", "bat"];
const groups = groupAnagrams(strs);
console.log(groups);
// Output: [["eat", "tea", "ate"], ["bat"], ["nat", "tan"]]
六、树
1、树(Tree)
树是一种层次结构的数据结构,由节点(Node)组成,每个节点可能有多个子节点。通常,树从一个根节点(Root)开始,根节点再通过子节点(左、右)连接其他节点。树在很多场景中非常有用,比如组织数据、创建文件系统等。
日常例子:家庭树
- 节点(Node):想象每个节点是一个家庭成员,每个成员都可能有孩子(子节点)。
- 根节点(Root Node):家族的祖先就是这个树的根节点,从他开始延续家庭。
2、递归(Recursion)
递归是一种编程技巧,函数在执行时调用自己,直到满足某个结束条件。递归非常适合解决树形结构问题,因为它可以自然地遍历子节点。
日常例子:自我复制
- 递归就像让某人站在镜子前,如果他们自己也能看到自己在镜子里的影像,就会不断反射下去,直到镜子足够小停止。
- 递归条件就像限制镜子反射次数的条件,确保不会无限反射。
3、二叉搜索树(Binary Search Tree, BST)
这段代码实现了一个二叉搜索树,每个节点最多有两个子节点,左边的子节点比当前节点小,右边的子节点比当前节点大。
代码解读
class Node {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
class BST {
constructor() {
this.root = null;
}
insert(value) {
const newNode = new Node(value);
if (this.root === null) {
this.root = newNode;
return this;
}
let temp = this.root;
while (true) {
if (newNode.value === temp.value) return undefined;
if (newNode.value < temp.value) {
if (temp.left === null) {
temp.left = newNode;
return this;
} else {
temp = temp.left;
}
} else {
if (temp.right === null) {
temp.right = newNode;
return this;
}
temp = temp.right;
}
}
}
includes(value) {
if (!this.root) return false;
let temp = this.root;
while (temp) {
if (value < temp.value) {
temp = temp.left;
} else if (value > temp.value) {
temp = temp.right;
} else if (value === temp.value) {
return true;
}
}
return false;
}
}
日常例子:查找盘子
- 想象你有一个储物柜,它按顺序存放盘子,较小的盘子放在左边,较大的盘子放在右边。
- 插入新盘子时,根据它的大小决定将它放在左边还是右边。
功能说明
- 插入盘子:插入时从根节点开始,比较新盘子和当前盘子的大小,依次找到正确的插入位置。
- 查找盘子:查找时从根节点开始,依次向左或向右寻找对应的盘子。
const tree = new BST();
tree.insert(5);
tree.insert(8);
tree.insert(3);
tree.insert(1);
tree.insert(7);
tree.insert(9);
console.log(tree.includes(9)); // true
4、递归倒计时与阶乘
这段代码展示了两个递归的例子:一个是倒计时,一个是计算阶乘。
代码解读
function countDown(number) {
if (number === 0) {
console.log("And finally the stopping point! 🥂");
return;
}
console.log(number);
countDown(number - 1);
}
function factorial(number) {
if (number === 0) {
return 1;
}
return number * factorial(number - 1);
}
日常例子:递归的倒数与计算
- 倒计时:就像我们从5开始倒数,每次都减少1,直到数到0为止。
- 阶乘:计算阶乘就像每次乘以一个比自己小的数,直到乘以1为止。
功能说明
- 递归倒计时:每次打印一个数字,然后减少1再调用自己,直到数字变为0。
- 递归阶乘:每次将数字乘以比它小1的数,直到乘以0时返回1。
countDown(5); // 5, 4, 3, 2, 1, And finally the stopping point! 🥂
console.log(factorial(4)); // 24
5、树的遍历
这段代码展示了如何使用深度优先搜索(DFS)遍历二叉树的不同方式:前序遍历、中序遍历、和后序遍历。
代码解读
class BST {
constructor() {
this.root = null;
}
// 深度优先搜索:广度优先遍历
dfs() {
let current = this.root;
let queue = [];
let data = [];
queue.push(current);
while (queue.length) {
current = queue.shift();
data.push(current.value);
if (current.left) queue.push(current.left);
if (current.right) queue.push(current.right);
}
return data;
}
// 深度优先搜索:前序遍历
dfsPreOrder(node = this.root, data = []) {
if (node === null) return data;
data.push(node.value);
if (node.left) this.dfsPreOrder(node.left, data);
if (node.right) this.dfsPreOrder(node.right, data);
return data;
}
// 深度优先搜索:后序遍历
dfsPostOrder(node = this.root, data = []) {
if (node === null) return data;
if (node.left) this.dfsPostOrder(node.left, data);
if (node.right) this.dfsPostOrder(node.right, data);
data.push(node.value);
return data;
}
// 深度优先搜索:中序遍历
dfsInOrder(node = this.root, data = []) {
if (node === null) return data;
this.dfsInOrder(node.left, data);
data.push(node.value);
this.dfsInOrder(node.right, data);
return data;
}
}
日常例子:寻找盘子
- 想象我们要按不同的顺序找盘子,前序遍历是先看柜子顶上的盘子(根),然后再查看左边的盘子,最后是右边的盘子。
功能说明
- 前序遍历:先访问根节点,然后访问左子节点,最后访问右子节点。
- 后序遍历:先访问左子节点,然后访问右子节点,最后访问根节点。
- 中序遍历:先访问左子节点,然后访问根节点,最后访问右子节点。
const tree = new BST();
tree.insert(5);
tree.insert(8);
tree.insert(3);
tree.insert(1);
tree.insert(7);
tree.insert(9);
console.log(tree.dfsPreOrder()); // [5, 3, 1, 8, 7, 9]
console.log(tree.dfsPostOrder()); // [1, 3, 7, 9, 8, 5]
console.log(tree.dfsInOrder()); // [1, 3, 5, 7, 8, 9]
总结
- 树:层次结构的数据结构,每个节点都有左右子节点。
- 递归:函数调用自己,适合解决树形问题。
- 二叉搜索树(BST):左小右大的结构,便于插入和查找。
- DFS遍历:三种方式(前序、中序、后序)分别按不同顺序访问节点。
七、图
什么是图(Graph)?
**图(Graph)是一种数据结构,由顶点(Vertices)和边(Edges)**组成。顶点是图中的元素,边则是连接这些顶点的线。
想象一下,你有一堆盘子和一些线,你可以用线将这些盘子连接起来,这样的结构就类似于图。
1. 顶点和边
- 顶点(Vertex):图中的每个点,代表一个对象。例如,我们可以把每个盘子看作一个顶点。
- 边(Edge):连接顶点的线,表示顶点之间的关系。例如,我们用线连接两个盘子,表示它们有某种联系。
图的构成:
- 无向图:边没有方向,意味着关系是双向的。例如,盘子 A 和盘子 B 用线连接,表示 A 和 B 互相联系。
- 有向图:边有方向,意味着关系是单向的。例如,盘子 A 指向盘子 B,表示关系是单向的。
代码示例:用 JavaScript 实现一个简单的无向图
1. 图的表示方式:邻接表(Adjacency List)
在代码中,图通过邻接表来表示。邻接表是一个对象,每个顶点都对应一个数组,数组中存储了它连接的其他顶点。
class Graph {
constructor() {
this.adjacencyList = {}; // 邻接表存储顶点和它们的连接关系
}
// 添加顶点
addVertex(vtx) {
if (!this.adjacencyList[vtx]) {
this.adjacencyList[vtx] = []; // 新顶点的邻接表初始化为空数组
return true;
}
return false;
}
// 添加边
addEdge(vtx1, vtx2) {
if (this.adjacencyList[vtx1] && this.adjacencyList[vtx2]) {
this.adjacencyList[vtx1].push(vtx2); // 将顶点vtx2加入顶点vtx1的邻接表中
this.adjacencyList[vtx2].push(vtx1); // 同时将顶点vtx1加入顶点vtx2的邻接表中(无向图)
return true;
}
return false;
}
// 移除边
removeEdge(vtx1, vtx2) {
if (this.adjacencyList[vtx1] && this.adjacencyList[vtx2]) {
this.adjacencyList[vtx1] = this.adjacencyList[vtx1].filter(
(v) => v !== vtx2,
);
this.adjacencyList[vtx2] = this.adjacencyList[vtx2].filter(
(v) => v !== vtx1,
);
return true;
}
return false;
}
// 移除顶点
removeVertex(vtx) {
if (!this.adjacencyList[vtx]) return undefined;
// 先移除与该顶点连接的所有边
for (let neighbor of this.adjacencyList[vtx]) {
this.adjacencyList[neighbor] = this.adjacencyList[neighbor].filter(
(v) => v !== vtx,
);
}
// 然后删除这个顶点
delete this.adjacencyList[vtx];
return this;
}
}
2. 使用图:添加顶点和边
添加顶点:像是往桌子上放盘子。
添加边:像是用线把两个盘子连接起来。
let g = new Graph();
// 添加顶点
g.addVertex("A");
g.addVertex("B");
g.addVertex("C");
g.addVertex("D");
// 添加边,连接这些顶点
g.addEdge("A", "B");
g.addEdge("A", "C");
g.addEdge("A", "D");
g.addEdge("B", "D");
g.addEdge("C", "D");
console.log(g);
输出:
{
adjacencyList: {
A: [ 'B', 'C', 'D' ],
B: [ 'A', 'D' ],
C: [ 'A', 'D' ],
D: [ 'A', 'B', 'C' ]
}
}
上面的图表示顶点 A、B、C、D 之间的连接关系。例如,A 通过边连接了 B、C、D。
3. 删除顶点和边
- 删除边:像是剪断连接两个盘子的线。
- 删除顶点:像是从桌子上拿走一个盘子,并同时把所有连着它的线都剪断。
g.removeVertex("D");
console.log(g);
输出:
{
adjacencyList: {
A: [ 'B', 'C' ],
B: [ 'A' ],
C: [ 'A' ]
}
}
删除了顶点 D 后,所有与它相连的边也都被移除了。
小结:图的关键概念
- 顶点(Vertex):图中的节点,可以看作每一个盘子。
- 边(Edge):顶点之间的连接关系,像用线把盘子连起来。
- 邻接表(Adjacency List):用一个对象存储图的结构,每个顶点对应一个数组,表示与该顶点相连的其他顶点。
八、排序
以下是关于四种排序算法(冒泡排序、选择排序、插入排序和归并排序)的解释。我们将使用日常生活中的例子,以“盘子”为例来帮助理解这些概念。
1. 冒泡排序 (Bubble Sort)
想象你有一组盘子,按从大到小的顺序摆放。你每次比较相邻的两个盘子,确保较大的盘子在左边,较小的在右边。每次循环后,最大的盘子会“冒泡”到最后的位置。
代码解释
function bubbleSort(arr) {
for (let i = arr.length - 1; i > 0; i--) {
for (let j = 0; j < i; j++) {
if (arr[j] > arr[j + 1]) {
let temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
}
- 外层循环:从最后一个盘子开始到第一个盘子(
i
)。 - 内层循环:比较相邻的盘子(
j
),如果左边的盘子大于右边的盘子,则交换它们的位置。 - 结果:每次循环后,最大的盘子会移到数组的最后。
示例
const myArr = [4, 2, 6, 5, 1, 3];
const res = bubbleSort(myArr);
console.log(res); // 输出:[1, 2, 3, 4, 5, 6]
2. 选择排序 (Selection Sort)
想象你在一个盘子堆中寻找最小的盘子。每次选择一个盘子,把它放到最前面。然后重复这个过程,直到所有的盘子都按顺序摆好。
代码解释
function selectionSort(arr) {
for (let i = 0; i < arr.length; i++) {
let minIndex = i;
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
if (i !== minIndex) {
let temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
return arr;
}
- 外层循环:遍历每个盘子(
i
)。 - 内层循环:在剩下的盘子中找到最小的盘子(
j
)。 - 交换:如果当前盘子不是最小的,就将其与最小的盘子交换。
示例
const myArr = [4, 2, 6, 5, 1, 3];
const res = selectionSort(myArr);
console.log(res); // 输出:[1, 2, 3, 4, 5, 6]
3. 插入排序 (Insertion Sort)
想象你正在整理一堆盘子,你每次拿一个新的盘子,并把它插入到已经排好序的盘子中。你要确保新盘子放在正确的位置。
代码解释
function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
let key = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
}
- 外层循环:从第二个盘子开始(
i
),因为第一个盘子默认已经排序。 - 内层循环:将当前盘子(
key
)与已排序的盘子进行比较,找到合适的位置。 - 插入:将盘子插入到正确的位置。
示例
const unsortedArray = [5, 2, 4, 6, 1, 3];
const sortedArray = insertionSort(unsortedArray);
console.log(sortedArray); // 输出:[1, 2, 3, 4, 5, 6]
4. 归并排序 (Merge Sort)
想象你有一堆盘子,你把它们分成两个小堆,每个小堆都按顺序排好。然后你把这两个小堆合并成一个大堆,确保合并后依然是有序的。
代码解释
function mergeSort(arr) {
if (arr.length <= 1) return arr;
const middle = Math.floor(arr.length / 2);
const left = arr.slice(0, middle);
const right = arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
}
function merge(left, right) {
const result = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] < right[j]) {
result.push(left[i]);
i++;
} else {
result.push(right[j]);
j++;
}
}
result.push(...left.slice(i));
result.push(...right.slice(j));
return result;
}
- 分割:将数组分成两个部分,递归地对每个部分进行排序。
- 合并:将两个已排序的数组合并成一个新的排序数组。
示例
const unsortedArray = [38, 27, 43, 3, 9, 82, 10];
const sortedArray = mergeSort(unsortedArray);
console.log(sortedArray); // 输出:[3, 9, 10, 27, 38, 43, 82]