Golang数据结构与算法学习笔记

现在在学的课程:尚硅谷-数据结构与算法(韩顺平)(共14小时49分钟)

数组

稀疏数组

介绍

稀疏数组(Sparse Array)是一种数据结构,用于表示大部分元素值为默认值(通常为零或空)的数组。稀疏数组通常用于节省内存空间,因为它不存储默认值,而只存储非默认值元素的位置和值。

稀疏数组通常包括以下元素:

  1. 原始数组:原始数组是稀疏数组所代表的完整数组。它包含了所有元素的值,包括默认值和非默认值。

  2. 非默认值元素列表:非默认值元素列表是一个记录了非默认值元素的位置和值的数据结构。通常使用元素的坐标(行号、列号等)和元素的值来表示。

  3. 默认值:默认值是原始数组中的默认元素值,通常为零或空,而在稀疏数组中不存储这些默认值。

稀疏数组的优势在于它可以显著减少内存消耗,特别是在处理大规模数据集时。它适用于那些具有大量默认值元素的数组,而只有少数元素具有非默认值的情况。

以下是一个示例,演示了稀疏数组的表示方式:

假设我们有一个5x5的矩阵,其中大多数元素为零,只有少数几个元素具有非零值:

1
2
3
4
5
1 0 0 0 0
0 0 0 0 0
0 0 2 0 0
0 0 0 0 0
0 0 0 0 3

一个稀疏数组表示方式可能如下:

  • 原始数组:5x5 的矩阵,包含了所有元素的值。
  • 非默认值元素列表:包括 (1,1,1)(3,3,2)(5,5,3),表示坐标 (1,1)(3,3)(5,5) 处的非默认值元素和它们的值。

稀疏数组的表示方式可以根据具体需求进行优化,通常包括记录非默认值元素的坐标和值,以及原始数组的维度信息。这种数据结构在图像处理、稀疏矩阵运算等领域有广泛应用。

应用实例

使用稀疏数组,来保留类似前面的二维数组(棋盘、地图等等)。

把稀疏数组存盘,并且可以从新恢复原来的二维数组数。

代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
var (
    originalArray [10][10]int // 定义一个二维数组
    sparseArr     []ValueNode // 定义一个切片来存放所有的值
    newArray      [10][10]int // 解压缩之后的数组
)

// ValueNode 定义一个结构体,表示存放值的结构
type ValueNode struct {
    row int
    col int
    val int
}

func main() {
    // 先给二维数组赋值
    originalArray[3][3] = 3
    originalArray[4][4] = 4
    originalArray[5][5] = 5

    // 输出查看原始数组的值
    for _, v1 := range originalArray {
       for _, v2 := range v1 {
          fmt.Printf("%d  ", v2)
       }
       fmt.Println()
    }

    compressArray(originalArray) // 转为稀疏数组
    // 遍历压缩后的值
    for _, vNode := range sparseArr {
       fmt.Println(vNode.row, vNode.col, vNode.val)
    }

    decompressArray(sparseArr) // 将压缩矩阵解压
    // 遍历解压后的数组
    for _, v1 := range newArray {
       for _, v2 := range v1 {
          fmt.Printf("%d  ", v2)
       }
       fmt.Println()
    }

}

// 将数组压缩
func compressArray(originalArray [10][10]int) {
    // 转为稀疏数组
    // 思路:
    // 遍历originalArray,如果我们发现有一个元素的值不为0,创建一个node结构体。如何设计结构体呢?
    // 将其放入到对应的切片中即可。

    valNode := ValueNode{ // 创建一个valNode 值结点,记录元素的二维数组的规模(行、列、默认值)
       row: 11,
       col: 11,
       val: 0,
    }
    sparseArr = append(sparseArr, valNode) // 将数组的结构信息也存入sparseArr中

    for i, v1 := range originalArray {
       for j, v2 := range v1 {
          if v2 != 0 {
             valNode = ValueNode{
                row: i,
                col: j,
                val: v2,
             }
             sparseArr = append(sparseArr, valNode)
          }
       }
    }
}

// 将数组还原
func decompressArray(sparseArr []ValueNode) {
    for i, v := range sparseArr {
       if i != 0 { // 第一行的值舍弃
          newArray[v.row][v.col] = v.val // 给新数组赋值
       }
    }
}

数组模拟队列

2023-10-10

队列的介绍

队列(Queue)是一种常见的数据结构,它遵循先进先出(FIFO)的原则,即先被放入队列的元素会先被取出。队列通常用于在计算机科学和编程中处理各种问题,例如任务调度、广度优先搜索、缓冲等。

以下是队列的主要特点和操作:

特点:

  1. 先进先出(FIFO):队列中的元素按照它们被添加的顺序被处理,最早进入队列的元素最早被移出。

队列操作:

  1. Enqueue(入队):将元素添加到队列的末尾。
  2. Dequeue(出队):从队列的前端移除并返回元素。
  3. Front(查看队首元素):查看队列的前端元素,但不移除它。
  4. IsEmpty(检查是否为空):检查队列是否为空。
  5. Size(获取队列大小):获取队列中元素的数量。

队列的实现方式:

  1. 数组实现队列:使用数组来存储队列元素。数组实现的队列有固定的大小,当队列已满时,需要进行扩展。
  2. 链表实现队列:使用链表来实现队列,可以动态地添加和移除元素,无需担心固定大小的限制。
  3. 双端队列(Deque):支持在队列的前端和后端进行插入和删除操作,提供更灵活的功能。

应用场景: 队列在计算机科学中有广泛的应用,包括但不限于以下领域:

  • 任务调度:用于处理异步任务,确保任务按顺序执行。
  • 广度优先搜索:在图和树等数据结构中用于遍历节点。
  • 缓冲:用于处理异步事件或数据流,以平衡生产者和消费者之间的速度差异。
  • 操作系统:用于管理进程和线程的调度顺序。
  • 计算机网络:用于管理数据包的传输和路由。

总之,队列是一种基本的数据结构,用于管理元素按照特定顺序排列和处理的需求。了解队列的特性和操作可以帮助程序员在解决各种问题时更有效地使用它。

单向非循环队列

设置一个结构体Queue,来保存队列的信息。需要的字段如下:

  • maxSize int:队列的最大长度。
  • array [5]int:一个定长的数组,来存放队列的数据。
  • front int:队首,指向下一个要弹出元素的前一个位置,弹出元素前,先将front加1,然后从array中取值返回。
  • rear int:队尾,指向队列中最后一个元素的位置,添加元素前,先将rear加1,然后给array赋值。

注意:

front的初始值为 -1,rear的初始值也为 -1。 队满条件:rear = maxSize-1。 队空条件:front=rear。

单向非循环队列,当加入5个元素,然后再取出5个元素后,不能再添加数据。不能复用数组的队列空间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
type Queue struct {
    maxSize int
    array   [5]int // 数组 ==> 模拟队列
    front   int    // 队首(但不包含)
    rear    int    // 队尾(包含)
}

func (queue *Queue) AddQueue(val int) (err error) {

    if queue.rear == queue.maxSize-1 { // 先判断队列是否已满
       return errors.New("queue full")
    }
    queue.rear++ // rear后移
    queue.array[queue.rear] = val
    return nil
}

func (queue *Queue) GetQueue() (val int, err error) {

    if queue.rear == queue.front { // 先判断队列是否为空
       return -1, errors.New("queue empty")
    }
    queue.front++
    val = queue.array[queue.front]
    return val, err
}

func (queue *Queue) ShowQueue() {
    for i := queue.front + 1; i <= queue.rear; i++ {
       fmt.Printf("array[%d]=%d\t", i, queue.array[i])
    }
    fmt.Println()
}

func main() {

    // 先创建一个队列
    queue := Queue{
       maxSize: 5,
       front:   -1,
       rear:    -1,
    }

    // 添加数据
    var key string
    var val int

    fmt.Println("1. 输入add 表示添加数据到队列")
    fmt.Println("2. 输入get 表示从队列中获取数据")
    fmt.Println("3. 输入show 表示显示队列")
    fmt.Println("4. 输入exit 表示退出")

    for {
       fmt.Printf("请输入指令:")

       if _, err := fmt.Scanln(&key); err != nil {
          panic(err)
       }

       keyMap := map[string]struct{}{"add": struct{}{}, "get": struct{}{}, "show": struct{}{}, "exit": struct{}{}}
       if _, exist := keyMap[key]; !exist { // 输入的指令不正确
          fmt.Println("请输入正确的指令(add/get/show/exit)!")
          continue
       }

       switch key {
       case "add":
          fmt.Printf("输入需要添加到队列的数据:")
          _, err := fmt.Scanln(&val)
          if err != nil {
             panic(err)
          }

          if err := queue.AddQueue(val); err != nil {
             fmt.Println(err.Error())
          } else {
             fmt.Println("加入队列成功!")
          }
       case "get":
          data, err := queue.GetQueue()
          if err != nil {
             fmt.Println(err.Error())
          } else {
             fmt.Printf("取出元素:%d\n", data)
          }
       case "show":
          fmt.Printf("当前队列为:")
          queue.ShowQueue()
       case "exit":
          os.Exit(0)
       }
    }
}

单向环形队列

2023.10.11

对前面的数组模拟队列的优化,充分利用数组,可以将数组看做是一个环形的。(通过取模的方式来实现即可)

还是有两个指针head和tail:

  • head表示队首,head指向下一个要弹出元素的位置,当弹出一个元素时,head加1。
  • tail表示队尾,tail指向下一个要添加元素的位置,当添加一个元素时,tail加1。

为了达到循环的效果,head和tail加1后,需要取模maxSize。

需要注意的是由于这是一个环形队列,所以队满的条件不能再是rear = maxSize-1,所以可以考虑将数组留出最后一个元素作为约定,当 (tail+1) % maxSize == head时,表示队列已满。

当head=rail时,表示队列已空。

注意:

head和tail的初始值都为0。

代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
type CircleQueue struct {
    maxSize int
    array   [5]int
    head    int // head指向下一个弹出元素的位置
    tail    int // tail指向下一个添加元素的位置
}

// Push 向队列中添加元素
func (queue *CircleQueue) Push(data int) (err error) {
    if (queue.tail+1)%queue.maxSize == queue.head { // 队列已满
       return errors.New("queue full")
    }
    queue.array[queue.tail] = data
    queue.tail = (queue.tail + 1) % queue.maxSize
    return
}

// Pop 从队列中弹出元素
func (queue *CircleQueue) Pop() (data int, err error) {
    if queue.head == queue.tail { // 队列为空
       return -1, errors.New("queue empty")
    }
    data = queue.array[queue.head]
    queue.head = (queue.head + 1) % queue.maxSize
    return data, nil
}

func (queue *CircleQueue) ShowCircleQueue() {
    queueLen := (queue.tail - queue.head + queue.maxSize) % queue.maxSize // 队列中元素个数
    if queueLen == 0 {
       fmt.Println("queue is empty")
       return
    }
    originalHead := queue.head
    for i := 1; i <= queueLen; i++ { // 遍历队列长度个元素
       fmt.Printf("array[%d]=%d\t", i, queue.array[originalHead])
       originalHead = (originalHead + 1) % queue.maxSize
    }
    fmt.Println()
}

func main() {

    // 先创建一个队列
    queue := CircleQueue{
       maxSize: 5,
       head:    0,
       tail:    0,
    }

    // 添加数据
    var key string
    var val int

    fmt.Println("1. 输入push 表示添加数据到队列")
    fmt.Println("2. 输入pop 表示从队列中获取数据")
    fmt.Println("3. 输入show 表示显示队列")
    fmt.Println("4. 输入exit 表示退出")

    for {
       fmt.Printf("请输入指令:")

       if _, err := fmt.Scanln(&key); err != nil {
          panic(err)
       }

       keyMap := map[string]struct{}{"pop": struct{}{}, "push": struct{}{}, "show": struct{}{}, "exit": struct{}{}}
       if _, exist := keyMap[key]; !exist { // 输入的指令不正确
          fmt.Println("请输入正确的指令(add/get/show/exit)!")
          continue
       }

       switch key {
       case "push":
          fmt.Printf("输入需要添加到队列的数据:")
          _, err := fmt.Scanln(&val)
          if err != nil {
             panic(err)
          }

          if err := queue.Push(val); err != nil {
             fmt.Println(err.Error())
          } else {
             fmt.Println("加入队列成功!")
          }
       case "pop":
          data, err := queue.Pop()
          if err != nil {
             fmt.Println(err.Error())
          } else {
             fmt.Printf("取出元素:%d\n", data)
          }
       case "show":
          fmt.Printf("当前队列为:")
          queue.ShowCircleQueue()
       case "exit":
          os.Exit(0)
       }
    }
}

这个环形队列的实现是需要掌握的。

前缀和

数组前缀和(Prefix Sum)是一种用于处理数组的技术,它通过将数组中的元素累积起来,生成一个新的数组,其中每个元素表示原数组中某个位置之前的所有元素的总和。这个新数组通常被用来在常数时间内快速计算数组中任意子数组的和。数组前缀和的应用非常广泛,特别是在需要频繁查询子数组和的问题中,它可以显著提高算法的效率。

以下是一个示例来说明如何计算数组的前缀和:

假设我们有一个原始数组 arr,其元素如下:

1
arr = [1, 2, 3, 4, 5]

我们可以计算它的前缀和数组 prefix_sum 如下:

1
prefix_sum = [1, 3, 6, 10, 15]

在前缀和数组中,prefix_sum[i] 表示原数组 arr 中前 i+1 个元素的总和。例如,prefix_sum[3] 等于 10,因为原数组中前 4 个元素(1, 2, 3, 4)的总和为 10。

使用前缀和数组,你可以在常数时间内计算任何子数组的和。例如,如果你想计算原数组中索引 1 到索引 3 之间的子数组的和,你可以通过以下方式计算:

1
subarray_sum = prefix_sum[3] - prefix_sum[0] = 10 - 1 = 9

这种方法避免了重复遍历数组来计算子数组和,从而提高了计算效率。前缀和数组也可以用于解决许多与子数组和相关的问题,如找到具有最大和或最小和的子数组等。

小和问题

2023.10.17

在一个数组中,一个数左边比它小的数的总和,叫数的小和所有数的小和累加起来,叫数组小和,返回数组小和。 例子:[1, 3, 4, 2, 5] 1左边比1小的数:没有 3左边比3小的数:1 4左边比4小的数:1、3 2左边比2小的数:1 5左边比5小的数:1、3、4、2 所以数组小和 =1+1+3+1+1+3+4+2 =16

这个问题可以转化为:统计i位置的右侧有多少个数字比arr[i]大。 如:i=0是,arr[0]=1,右侧有4个数字比1大,也就是最后在计算小和时,1会被计算4次,然后i依次往后推。 可以使用归并排序的思想来解决这个问题。 在归并排序的过程中,当合并两个已排序的子数组时,可以统计右侧子数组中的每个元素比左侧子数组中的所有元素大的情况,从而计算小和。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
func main() {
    arr := []int{2, 3, 5, 1, 4} // 定义一个整数切片 arr
    help := make([]int, len(arr)) // 创建一个与 arr 长度相同的辅助切片 help
    smallSum := calcSmallSum(arr, help, 0, len(arr)-1) // 计算小和并存储在 smallSum 中
    fmt.Println(smallSum) // 打印小和
}

func calcSmallSum(arr, help []int, left, right int) int {
    if left >= right {
        return 0
    }
    middle := left + (right-left)>>1 // 计算中间位置的索引
    leftSmallSum := calcSmallSum(arr, help, left, middle) // 递归计算左半部分的小和
    rightSmallSum := calcSmallSum(arr, help, middle+1, right) // 递归计算右半部分的小和
    mergeSmallSum := merge(arr, help, left, middle, right) // 合并左右两部分并计算小和
    return leftSmallSum + rightSmallSum + mergeSmallSum // 返回总的小和
}

func merge(arr, help []int, left, middle, right int) int {
    ans := 0 // 初始化小和为0
    p1 := left // 左半部分起始索引
    p2 := middle + 1 // 右半部分起始索引
    i := left // 用于遍历 help 切片的索引

    for ; p1 <= middle && p2 <= right; i++ {
        if arr[p1] < arr[p2] { // 只有左组元素严格小于右组元素时,才执行以下操作
            ans += (right - p2 + 1) * arr[p1] // 计算小和并加到 ans 中
            help[i] = arr[p1] // 将较小的左组元素存储到 help 中
            p1++
        } else { // 当左组元素大于等于右组元素时,将右组元素存储到 help 中
            help[i] = arr[p2]
            p2++
        }
    }

    // 处理剩余的元素
    for ; p1 <= middle; i++ {
        help[i] = arr[p1]
        p1++
    }
    for ; p2 <= right; i++ {
        help[i] = arr[p2]
        p2++
    }

    // 将 help 中的元素复制回原数组 arr
    for i := left; i <= right; i++ {
        arr[i] = help[i]
    }

    return ans // 返回当前合并部分的小和
}

荷兰国旗问题

2023.10.17

给定一个数组arr,和一个整数num,请把小于num的数放在数组的左边,等于num的数放在数组的中间,大于num的数放在数的右边。 要求额外空间复杂度O(1),时间复杂度O(N)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
    arr := []int{3, 1, 2, 8, 5, 2, 7, 1, 3, 6, 3}
    num := 3
    partition(arr, num)
    fmt.Println(arr)
}

func partition(arr []int, num int) {
    low, mid, high := 0, 0, len(arr)-1

    for mid <= high {
       if arr[mid] < num {
          arr[low], arr[mid] = arr[mid], arr[low]
          low++
          mid++
       } else if arr[mid] == num {
          mid++
       } else {
          arr[mid], arr[high] = arr[high], arr[mid]
          high--
       }
    }
}

在这个示例中,我们使用三个指针:low、mid和high,它们分别表示数组中小于num的区域、等于num的区域和大于num的区域。 我们从中间开始遍历数组中的每个元素,并根据其值将元素放入相应的区域。具体来说: 如果当前元素小于num,将其与low位置的元素交换,然后将low和mid分别向前移动一位。 如果当前元素等于num,只将mid向前移动一位。 如果当前元素大于num,将其与high位置的元素交换,然后将high向后移动一位。 这个过程将数组分成三个区域,分别包含小于、等于和大于num的元素,满足题目要求的条件。

找到第K大的数

2023.10.18

给你一个整数数组nums和一个正数k,请你返回第k大的数。要求时间复杂度O(n)。 可以把这个问题转化为:确定排完序(升序)后,第 len(num)-k 位置上的数,这个数就是我们的最后结果。 但我们不需要真的去将数组nums排好序,可以借鉴快速排序中的partition函数(左边的数比pivot小,右边的数比pivot大)的思想。 如果pivot刚好等于了我们的目标位置 len(num)-k,则说明这个位置上的数就是我们要找的数。 如果pivot小于目标位置 len(num)-k,说明我们要找的数在pivot的右侧,反之则在左侧。(只需要找一侧就可以了,这样就提高了查找的效率)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func main() {
	nums := []int{3, 2, 1, 5, 6, 4}
	k := 6
	result := findKthLargest(nums, k)
	fmt.Printf("第 %d 大的数是:%d\n", k, result)
}

func findKthLargest(nums []int, k int) int {
	return quickSelect(nums, 0, len(nums)-1, len(nums)-k)
}

func quickSelect(nums []int, left, right, index int) int {
	for {
		if left > right { // 遍历完了都没找到的情况
			return 0
		}
		pivotIndex := partition2(nums, left, right)
		if pivotIndex == index {
			return nums[pivotIndex]
		}
		if pivotIndex < index {
			left = pivotIndex + 1
		} else {
			right = pivotIndex - 1
		}
	}
}

func partition2(nums []int, left, right int) int {
	l := left
	r := right
	pivot := l + (r-l)>>1
	for {
		for nums[l] < nums[pivot] {
			l++
		}
		for nums[r] > nums[pivot] {
			r--
		}
		if l >= r {
			return r
		}
		nums[l], nums[r] = nums[r], nums[l]
		l++
		r--
	}
}

链表

单向链表

2023.10.12

这里指的是单向非循环链表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
type SingleLinkedList struct {
    id   int
    name string
    age  int
    next *SingleLinkedList // 指针,指向下一个元素
}

func (head *SingleLinkedList) InsertNode(newNode *SingleLinkedList) {
    tempNode := head
    for {
       if tempNode.next == nil { // 找到最后一个Node
          tempNode.next = newNode
          return
       }
       tempNode = tempNode.next
    }
}
func (head *SingleLinkedList) DeleteNode(id int) {
    exist := false
    tempNode := head
    if tempNode.next == nil { // 先判断链表是否为空,防止空指针异常
       fmt.Println("LinkedList is empty")
       return
    }

    for {
       if tempNode.next.id == id {
          exist = true
          break
       }
       tempNode = tempNode.next
       if tempNode.next == nil {
          break
       }
    }
    if exist {
       tempNode.next = tempNode.next.next
    } else {
       fmt.Printf("\nid=%d的元素不存在!\n", id)
    }
}

func (head *SingleLinkedList) GetAllNodes() {
    tempNode := head
    if tempNode.next == nil { // 先判断链表是否为空,防止空指针异常
       fmt.Println("\nLinkedList is empty")
       return
    }
    fmt.Println()
    for {
       tempNode = tempNode.next
       fmt.Printf("[%d %s %d] ---> ", tempNode.id, tempNode.name, tempNode.age)
       if tempNode.next == nil {
          return
       }
    }
}

func main() {

    head := &SingleLinkedList{} // 定义一个头结点

    elem01 := &SingleLinkedList{ // 创建一个结点
       id:   1,
       name: "Tom",
       age:  23,
    }

    elem02 := &SingleLinkedList{
       id:   2,
       name: "Jerry",
       age:  25,
    }
    elem03 := &SingleLinkedList{
       id:   3,
       name: "Tomas",
       age:  28,
    }

    head.InsertNode(elem01) // 插入结点
    head.InsertNode(elem02)
    head.InsertNode(elem03)

    head.GetAllNodes() // 获取所有结点信息
    head.DeleteNode(2) // 删除结点
    head.GetAllNodes()

}

双向链表

这里也是非循环的链表。多了一个逆序遍历链表的方法。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
type DoubleLinkedList struct {
    id       int
    name     string
    age      int
    previous *DoubleLinkedList // 指向前一个元素
    next     *DoubleLinkedList // 指针,指向下一个元素
}

func (head *DoubleLinkedList) InsertNode(newNode *DoubleLinkedList) {
    tempNode := head
    for {
       if tempNode.next == nil { // 找到最后一个Node
          tempNode.next = newNode
          newNode.previous = tempNode
          return
       }
       tempNode = tempNode.next
    }
}
func (head *DoubleLinkedList) DeleteNode(id int) {
    exist := false
    tempNode := head
    if tempNode.next == nil { // 先判断链表是否为空,防止空指针异常
       fmt.Println("DoubleLinkedList is empty")
       return
    }

    for {
       tempNode = tempNode.next
       if tempNode.id == id {
          exist = true
          break
       }
       if tempNode.next == nil {
          break
       }
    }
    if exist {
       tempNode.previous.next = tempNode.next
       tempNode.next.previous = tempNode.previous
       //tempNode.next = nil  // 2023.10.11 这里到底需不需要写呢?
       //tempNode.previous = nil
    } else {
       fmt.Printf("\nid=%d的元素不存在!\n", id)
    }
}

func (head *DoubleLinkedList) GetAllNodes() {
    tempNode := head
    if tempNode.next == nil { // 先判断链表是否为空,防止空指针异常
       fmt.Println("\nDoubleLinkedList is empty")
       return
    }
    fmt.Println()
    for {
       tempNode = tempNode.next
       fmt.Printf("[%d %s %d] <---> ", tempNode.id, tempNode.name, tempNode.age)
       if tempNode.next == nil {
          return
       }
    }
}

func (head *DoubleLinkedList) GetReverseNodes() {
    tempNode := head
    if tempNode.next == nil { // 先判断链表是否为空,防止空指针异常
       fmt.Println("\nDoubleLinkedList is empty")
       return
    }
    fmt.Println()
    for { // 找到最后一个Node
       if tempNode.next == nil {
          break
       }
       tempNode = tempNode.next
    }
    for { // 向前遍历
       if tempNode.previous == nil {
          break
       }
       fmt.Printf("[%d %s %d] <---> ", tempNode.id, tempNode.name, tempNode.age)
       tempNode = tempNode.previous
    }
}
func main() {

    head := &DoubleLinkedList{} // 定义一个头结点

    elem01 := &DoubleLinkedList{ // 创建一个结点
       id:   1,
       name: "Tom",
       age:  23,
    }

    elem02 := &DoubleLinkedList{
       id:   2,
       name: "Jerry",
       age:  25,
    }
    elem03 := &DoubleLinkedList{
       id:   3,
       name: "Tomas",
       age:  28,
    }

    head.InsertNode(elem01) // 插入结点
    head.InsertNode(elem02)
    head.InsertNode(elem03)

    head.GetAllNodes()     // 获取所有结点信息
    head.GetReverseNodes() // 向前遍历结点

    head.DeleteNode(2) // 删除结点
    head.GetAllNodes()
    head.GetReverseNodes() // 向前遍历结点
}

单向循环链表

约瑟夫问题

约瑟夫问题(Josephus problem)是一个经典的计算和数学谜题,源自古罗马历史学家弗拉维奥·约瑟夫斯(Flavius Josephus)的著作《犹太战记》。问题的描述如下:

有一群人(通常用 n 表示),站成一个圆圈,从某个人开始,每次数到第 m 个人,就将该人从圆圈中淘汰,然后从下一个人开始重新计数,继续数到第 m 个人,再将该人淘汰,如此循环下去,直到最后只剩下一个人。问题的目标是找出最后剩下的那个人的位置。

解决约瑟夫问题的方法有很多,最著名的是使用数学递推关系。通常,最后剩下的人的位置(通常用 f(n, m) 表示)可以用以下递归关系表示:

  • f(1, m) = 0 // 当只剩下一个人时,该人就是最后剩下的人
  • f(n, m) = (f(n-1, m) + m) % n // 当有 n 个人时,淘汰第 m 个人后,问题转化为有 n-1 个人的子问题

这个关系可以用递归或迭代的方式来计算,得出最后一个人的位置。这个问题在编程竞赛、算法和数学领域经常被讨论和使用。

这里我们考虑采用单向环形链表来解决这一问题。

1.约瑟夫环的初始化

我们可以指定元素的数量,对约瑟夫环进行初始化。

在初始化时,我们需要用到两个指针 first 和 last 。在插入第一个元素之后,fisrt就不再移动了,让fisrt始终指向第一个元素。我们通过last指针的移动来扩充链表。

初始化完成后,first 指向第一个加入的元素,last 指向之后一个加入的元素(first 的前一个)。

2.遍历链表

引入辅助指针last,初始化 last=first,然后通过last 往后依次遍历链表。当 last.next==first 时,链表变量结束。

3.开始游戏

我们需要指定开始元素的下标和每次数的数字。

我们也需要两个指针 first和 last,first 指向将要被删除的元素,last 指向fisrt 的前一个元素(便于删除元素)。

  1. 移动 first 和 last 指针到开始元素的位置;
  2. 移动 first 和 last 指针到数指定数字之后的位置;
  3. 删除 first 位置的元素。
  4. 重复2、3步,直到链表中只剩最后一个元素(first==last)。

总结:从上面的算法中可以看出,引入的指向fisrt前一个结点的last指针是至关重要的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
type JosephusRing struct {
	id   int
	next *JosephusRing
}

func InitRing(num int) *JosephusRing { // 指定成员的个数进行初始化环
	first := &JosephusRing{}
	last := &JosephusRing{}
	if num < 1 {
		fmt.Println("成员数量至少为1!")
		return nil
	}
	for i := 1; i <= num; i++ {
		rData := &JosephusRing{
			id: i,
		}
		if i == 1 { // 第一个成员
			first = rData     // first指向链表的第一个元素,此后就不再改变
			last = rData      // 通过移动last来实现链表元素的增长,初始值为第一个元素
			last.next = first // 将新加入的元素指向first
		} else {
			last.next = rData // 将新的元素加入链表,并将last指向新的元素
			rData.next = first
			last = rData
		}
	}
	return first
}

func GetRingElems(first *JosephusRing) {
	if first.next == nil {
		fmt.Println("链表为空,没有任何成员!")
		return
	}
	last := first
	fmt.Println()
	for {
		fmt.Printf("[id=%d]--->", last.id)
		if last.next == first { // 遍历到最后一个元素了
			break
		}
		last = last.next
	}
}

func PlayGame(first *JosephusRing, startNo int, countNo int) {
	if first.next == nil {
		fmt.Println("链表为空!")
		return
	}
	last := first // 需要一个last指针指向最后(也就是first之前的那个元素),因为要删除first结点,必须要找打它的前一个结点
	for {         // 将last指向最后的元素
		if last.next == first {
			break
		}
		last = last.next
	}
	// 让first和last都移动startNo-1次(移动到起始位置)(因为startNo表示的是开始的编号,但first已经指向第1个元素了,所以只需要移动startNo-1次)
	for i := 1; i <= startNo-1; i++ {
		first = first.next
		last = last.next
	}
	// 游戏开始,开始数数(countNo)
	// first始终指向的是要删除的成员
	for {
		// 移动countNo-1次(因为成员自身也算一次)
		for i := 1; i <= countNo-1; i++ {
			first = first.next
			last = last.next
		}

		// 删除first指向的结点
		fmt.Printf("\nid=%d的元素被删除!", first.id)
		first = first.next
		last.next = first

		// 循环的终止条件:链表中只有一个元素(first=last)
		if last == first {
			break
		}
	}
	fmt.Printf("\nid=%d的元素被删除!", first.id)
}

func main() {
	first := InitRing(5)
	GetRingElems(first)
	PlayGame(first, 2, 3)
}

排序

冒泡排序

冒泡排序(Bubble Sort)是一种简单的排序算法,它的排序思想是通过多次遍历待排序的元素,每次遍历都将相邻的元素进行比较,如果它们的顺序不符合排序规则(升序或降序),就交换它们的位置。重复这个过程,直到整个数组变得有序。冒泡排序的核心思想是比较并交换相邻元素,将最大(或最小)的元素逐渐“冒泡”到数组的末尾(或开头)。

下面是使用Go语言实现冒泡排序的示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import "fmt"

func bubbleSort(arr []int) {
    n := len(arr)
    for i := 0; i < n-1; i++ {
        // 对未排序部分的元素进行多次遍历
        for j := 0; j < n-i-1; j++ {
            // 如果当前元素大于下一个元素,交换它们的位置
            if arr[j] > arr[j+1] {
                arr[j], arr[j+1] = arr[j+1], arr[j]
            }
        }
    }
}

func main() {
    // 待排序的切片
    arr := []int{64, 34, 25, 12, 22, 11, 90}

    fmt.Println("未排序的数组:", arr)
    bubbleSort(arr)
    fmt.Println("排序后的数组:", arr)
}

这段代码演示了冒泡排序的过程。在外层循环中,通过多次遍历数组,每次将未排序部分的最大元素冒泡到末尾。内层循环用于比较相邻元素,如果它们的顺序不正确,就交换它们的位置。

冒泡排序的时间复杂度为O(n^2),其中n是要排序的元素个数。它是一种基本的排序算法,对于小型数据集是有效的,但在大型数据集上性能较差。在实际应用中,通常会选择更高效的排序算法,如快速排序或归并排序,以获得更好的性能。

选择排序

选择排序(Selection Sort)是一种简单的排序算法,其排序思想是通过多次遍历待排序的元素,在每次遍历中选择最小(或最大)的元素,并将其放置在已排序部分的末尾(或开头)。选择排序的核心思想是找到未排序部分的最小(或最大)元素,并与未排序部分的第一个元素交换位置。

下面是使用Go语言实现选择排序的示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import "fmt"

func selectionSort(arr []int) {
    n := len(arr)
    for i := 0; i < n-1; i++ {
        // 假设当前元素是最小的
        minIndex := i

        // 在未排序部分中查找最小元素的索引
        for j := i + 1; j < n; j++ {
            if arr[j] < arr[minIndex] {
                minIndex = j
            }
        }

        // 将找到的最小元素与当前元素交换位置
        arr[i], arr[minIndex] = arr[minIndex], arr[i]
    }
}

func main() {
    // 待排序的切片
    arr := []int{64, 25, 12, 22, 11}

    fmt.Println("未排序的数组:", arr)
    selectionSort(arr)
    fmt.Println("排序后的数组:", arr)
}

这段代码演示了选择排序的过程。在外层循环中,通过多次遍历数组,每次在未排序部分中找到最小的元素,并将其交换到已排序部分的末尾。内层循环用于查找最小元素的索引。

选择排序的时间复杂度为O(n^2),其中n是要排序的元素个数。虽然选择排序不如快速排序或归并排序等高级排序算法快,但它是一种稳定的排序算法,适用于小型数据集或当内存有限时。与冒泡排序不同,选择排序的交换次数相对较少,因此在某些情况下性能可能更好。

上面是ChatGPT生成的标准答案,下面是自己写的分析和技巧:

从小到大排序 选择排序:每轮选择一个无需中的最小值,n-1轮后,数组有序。 技巧:先写内层循环,然后再嵌套一个外层循环。 先假设我们只需要找一次最小值,具体操作如下:

  1. 假设arr[0]就是最小元素(min=arr[0]);(注意:需要使用一个变量index记录下标0,后面交换的时候要用到)
  2. 遍历数组的每个元素,依次与min进行比较,如果有比min值更小的,则更新min的值和下标index;(这里的min变量可以优化掉)
  3. 交换arr[0]和arr[index]的值。

这样就完成了一次最小值的排序,我们只需要在外面嵌套一层 for 循环,将 0 改成循环变量 j 即可。这样就大大简化了我们写代码的过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func main() {
    arr := []int{3, 8, 1, 5, 4, 7, 6, 9, 2}
    result := SelectionSort(arr)
    fmt.Println(result)
}

func SelectionSort(arr []int) []int {
    newArr := arr
    for j := 0; j < len(arr)-1; j++ {
       //minArr := newArr[j] // 之前用了一个变量来存最小值,后面发现可以优化一下,只需要记录下标minIndex就可以了,所以将这里注释掉
       minIndex := j
       for i := j + 1; i < len(arr); i++ { // 找出最小值的下标
          if newArr[i] < newArr[minIndex] {
             minIndex = i // 更新minIndex
          }
       }
       newArr[minIndex], newArr[j] = newArr[j], newArr[minIndex]
    }
    return newArr
}

插入排序

插入排序(Insertion Sort)是一种简单而有效的排序算法。其排序思想是将待排序的元素逐个插入到已排序部分的合适位置,从未排序部分的第一个元素开始,逐步构建有序序列。插入排序的核心思想是将未排序部分的元素逐一与已排序部分的元素进行比较,找到合适的位置插入,直到整个数组排序完成。

下面是使用Go语言实现插入排序的示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import "fmt"

func insertionSort(arr []int) {
    n := len(arr)
    for i := 1; i < n; i++ {
        // 从第二个元素开始,将当前元素插入到已排序部分的合适位置
        current := arr[i]
        j := i - 1
        for j >= 0 && arr[j] > current {
            arr[j+1] = arr[j]
            j--
        }
        arr[j+1] = current
    }
}

func main() {
    // 待排序的切片
    arr := []int{64, 34, 25, 12, 22, 11, 90}

    fmt.Println("未排序的数组:", arr)
    insertionSort(arr)
    fmt.Println("排序后的数组:", arr)
}

这段代码演示了插入排序的过程。在外层循环中,从第二个元素开始(索引1),将当前元素逐一插入到已排序部分的合适位置。内层循环用于找到合适的插入位置,并将元素后移,直到找到合适的位置或到达已排序部分的起始位置。

插入排序的时间复杂度为O(n^2),其中n是要排序的元素个数。虽然插入排序不如一些高级排序算法(如快速排序或归并排序)快,但它对于小型数据集是一种有效的排序算法。它是稳定的,且不需要额外的内存空间,因此在某些情况下是一个很有用的选择。

上面是ChatGPT生成的标准答案,下面是自己写的分析和技巧:

这里和选择排序一样,先写内层,然后在外面嵌套一个循环。

首先,第一个元素组成的数组是有序的,我们需要确定第二个元素插入到有序数组中的位置。

  • 定义两个变量insertVal和insertIndex:insertVal表示将要插入的值,也就是arr[1]。insertIndex表示插入值的insertVal下标的前一个下标,表示要从这个下标开始往前比较。

  • 现在我们需要将insertVal去和有序数组的值进行依次比较交换了(需要循环)。这里就是insertVal和arr[insertIndex]去比较,由于是向前比较,所以循环体中insertIndex应该递减。

  • 由于我们这里是从小到大排序,则如果insertVal比arr[insertIndex]小,则需要将arr[insertIndex]的值往后移,直到不满足循环条件。

    所以这里的循环条件为:insertVal<arr[insertIndex] 以及 insertIndex>0。

  • 最后,将arr[insertIndex]的值赋值为insertVal。

快速排序

快速排序(Quick Sort)是一种高效的、分而治之的排序算法,它的排序思想是选择一个基准元素,将数组中的元素分成两个子数组,一个小于基准元素,一个大于基准元素,然后对这两个子数组分别进行快速排序。这个过程递归进行,直到整个数组排序完成。快速排序的核心思想是分治和递归。

下面是使用Go语言实现快速排序的示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import "fmt"

func quickSort(arr []int) []int {
    if len(arr) <= 1 {
        return arr
    }

    pivot := arr[0] // 选择第一个元素作为基准
    var left, right []int

    for i := 1; i < len(arr); i++ {
        if arr[i] < pivot {
            left = append(left, arr[i])
        } else {
            right = append(right, arr[i])
        }
    }

    left = quickSort(left)
    right = quickSort(right)

    return append(append(left, pivot), right...)
}

func main() {
    // 待排序的切片
    arr := []int{64, 34, 25, 12, 22, 11, 90}

    fmt.Println("未排序的数组:", arr)
    arr = quickSort(arr)
    fmt.Println("排序后的数组:", arr)
}

这段代码演示了快速排序的过程。它选择数组中的第一个元素作为基准元素,然后将数组中的其他元素分为两个子数组,一个包含小于基准的元素,一个包含大于基准的元素。然后,递归地对这两个子数组进行快速排序,最后将它们合并成一个有序的数组。

快速排序的时间复杂度通常为O(n log n),其中n是要排序的元素个数。它是一种高效的排序算法,通常比冒泡排序和选择排序快得多。快速排序的性能取决于选择的基准元素和分割策略。它是一种不稳定的排序算法,因为在分割过程中相同元素的相对位置可能会发生变化。

自己总结:

先选定一个元素作为pivot(枢轴),然后将数组中的元素依次和pivot进行比较,小于pivot的数字放在pivot的左边,大于pivot的值放在pivot的右边,这样就分成了两个数组。然后再对这两个数据使用快速排序的思想,继续分治,直到子数组的元素个数小于等于1。

改进:(2023.10.14)

前面的这种方法虽然在逻辑上比较简单,但是开辟了额外的空间来pivot存储左右两边的元素,造成了一定时间和空间的浪费,下面是原地(in-place)完成排序的改进算法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func QuickSort2(low, high int, arr []int) {
    if low < high {
        pivotIndex := partition(low, high, arr) // 选择分区点并获取分区点的索引
		QuickSort2(low, pivotIndex, arr)        // 递归排序分区点左侧的子数组
		QuickSort2(pivotIndex+1, high, arr)     // 递归排序分区点右侧的子数组
    }
}

// partition 将数组分成两部分,左侧的元素都小于或等于基准元素,右侧的元素都大于基准元素。
// 这是快速排序算法中的关键步骤,它在原地(in-place)完成,不需要额外的内存空间。
func partition(low, high int, arr []int) int {
    pivot := arr[(low+high)/2] // 选择最中间的元素作为基准
    i, j := low, high

    for {
       for arr[i] < pivot {
          i++
       }
       for arr[j] > pivot {
          j--
       }
       if i >= j {
          return j  // 返回 j 的索引,它表示分区点的位置。分区点是基准元素在排序后应该处于的位置。
       }
       arr[i], arr[j] = arr[j], arr[i]
       i++
       j--
    }
}

快速排序中需要注意的地方:在partition()函数中,pivot变量必须存储最中间元素的值而不是下标,因为后面交换的过程中,可能中间位置的元素值被交换,从而导致基准值的变换,最后无法得到我们想要的结果。

排序算法的速度比较

随机生成80000个数,添加到切片中,然后使用4中算法进行排序,比较它们的排序耗时。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var numCount = 80000

func main() {
    var arr []int
    for i := 0; i < numCount; i++ {
       arr = append(arr, rand.Intn(numCount)) // 随机生成numCount个数字
    }

    start := time.Now().UnixMilli()
    BubbleSort(arr)
    end := time.Now().UnixMilli()
    fmt.Printf("冒泡排序耗时(ms):%d\n", end-start)

    start = time.Now().UnixMilli()
    SelectionSort(arr)
    end = time.Now().UnixMilli()
    fmt.Printf("选择排序耗时(ms):%d\n", end-start)

    start = time.Now().UnixMilli()
    InsertSort(arr)
    end = time.Now().UnixMilli()
    fmt.Printf("插入排序耗时(ms):%d\n", end-start)

    start = time.Now().UnixMilli()
    QuickSort(arr)
    end = time.Now().UnixMilli()
    fmt.Printf("快速排序耗时(ms):%d\n", end-start)
    
    start = time.Now().UnixMilli()
	QuickSort2(0, len(arr)-1, arr)
	end = time.Now().UnixMilli()
	fmt.Printf("改进快速排序耗时(ms):%d\n", end-start)
}

结果:

1
2
3
4
5
冒泡排序耗时(ms):9575
选择排序耗时(ms):3458
插入排序耗时(ms):0
快速排序耗时(ms):56649
改进快速排序耗时(ms):4

理论上来说,快速排序应该是最快的,但改进之前的快排却是最慢的,而且慢得离谱,说明前面写的代码是存在问题的,应该采用改进后的代码来完成快速排序。

归并排序

2023.10.16

递归实现

归并排序,递归实现。 归并排序-递归实现的基本思想:先选取数组中最中间的值作为middle,将数组分为左右两个数组。 然后将左右两个数组分别进行归并排序,然后定义一个和原来数组容量相同的数组help,用两个指针p1和p2, 分别指向左右两个数组的最开始元素(需要借助middle),取出二者中更小的元素存入数组help中,移动p1和p2,直到p1和p2越界,最后,使用help数组覆盖原来的arr数组,得到最后有序的数组。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func main() {
    arr := []int{3, 8, 1, 5, 4, 7, 6, 9, 2}
    help := make([]int, len(arr))
    MergeSort(arr, help, 0, len(arr)-1)
    fmt.Println(arr)
}

func MergeSort(arr, help []int, left, right int) {
    if left >= right {
       return
    }

    middle := left + (right-left)>>1      // 求中点
    MergeSort(arr, help, left, middle)    // 左部分有序
    MergeSort(arr, help, middle+1, right) // 右部分有序

    Merge(arr, help, left, middle, right) // 将左右数组合并到help数组中(middle参数用来分割左右数组)
}

func Merge(arr, help []int, left, middle, right int) { // 左:left...middle,右:middle+1...right。help为辅助辅助,存放合并后的值。
    p1 := left
    p2 := middle + 1
    i := left // 两个数组中比较出来的数字,应该放在help数组中的位置
    for ; p1 <= middle && p2 <= right; i++ {
       if arr[p1] <= arr[p2] {
          help[i] = arr[p1]
          p1++
       } else {
          help[i] = arr[p2]
          p2++
       }
    }
    for ; p1 <= middle; i++ {  // 将p1或p2中剩下的元素添加到help数组中
       help[i] = arr[p1]
       p1++
    }
    for ; p2 <= right; i++ {
       help[i] = arr[p2]
       p2++
    }
    for i := left; i <= right; i++ {
       arr[i] = help[i]
    }
}

原理分析:

  1. 归并排序是一种经典的分而治之排序算法,它的基本思想是将一个未排序的数组分成两个子数组,然后递归地对这些子数组进行排序,最后将它们合并成一个有序数组。

  2. MergeSort 函数中,首先检查是否需要排序(left >= right),如果不需要排序则直接返回。否则,找到数组的中点 middle,将数组分为两部分:左部分 [left, middle] 和右部分 [middle+1, right]

  3. 递归地对左部分和右部分进行排序,这是通过再次调用 MergeSort 来完成的。递归排序保证了左右两部分最终变得有序。

  4. 接下来,在 Merge 函数中,将左部分和右部分合并到辅助数组 help 中。使用指针 p1p2 分别指向左部分和右部分的起始位置,以及一个指针 i 用来指示 help 中存放合并后的值的位置。

  5. 在合并的过程中,比较 arr[p1]arr[p2],将较小的值存入 help[i] 中,然后将 p1p2 向前移动,直到其中一个指针越界。这确保了合并后的数组仍然是有序的。

  6. 最后,将 help 中的合并结果复制回原始数组 arr 中,从 leftright 的位置。

  7. 通过递归和合并步骤,整个数组在不断地分割、排序和合并,直到最终形成一个完全有序的数组。

这就是归并排序的基本原理,它是一种高效的排序算法,具有稳定性,并且适用于各种数据集大小。

非递归实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
func main() {
    arr := []int{3, 8, 1, 5, 4, 7, 6, 9, 2}
    sortedArr := mergeSort(arr)
    fmt.Println(sortedArr)
}

// mergeSort 使用非递归方式实现归并排序
func mergeSort(arr []int) []int {
    if len(arr) < 2 {
       return arr
    }

    n := len(arr)
    help := make([]int, n) // 辅助数组用于合并
    mergeSize := 1         // 初始化合并步长

    for mergeSize < n { // 当合并步长小于数组长度时,继续迭代
       for left := 0; left < n; left += 2 * mergeSize {
          middle := left + mergeSize - 1
          right := min(left+2*mergeSize-1, n-1) // 控制右边界不越界

          if middle < right { // 只有左侧和右侧都有元素时才进行合并
             merge(arr, help, left, middle, right)
          }
       }

       mergeSize *= 2        // 增加合并步长
       arr, help = help, arr // 交换arr和help以备下一轮合并
    }

    return arr
}

// merge 用于合并两个已排序的子数组
func merge(arr, help []int, left, middle, right int) {
    i, j, k := left, middle+1, left

    for i <= middle && j <= right {
       if arr[i] <= arr[j] {
          help[k] = arr[i]
          i++
       } else {
          help[k] = arr[j]
          j++
       }
       k++
    }

    for i <= middle {
       help[k] = arr[i]
       i++
       k++
    }

    for j <= right {
       help[k] = arr[j]
       j++
       k++
    }
}

上面的代码实现了非递归的归并排序,以下是该排序算法的思想和实现过程:

思想

  • 分割:首先将待排序的数组不断分割成越来越小的子数组,直到每个子数组只包含一个元素或为空,即数组长度变为1。

  • 合并:然后将这些子数组两两合并,确保每次合并后的子数组仍然有序,重复这个过程,直到最终合并成一个完全有序的数组。

实现过程

  1. 定义一个mergeSort函数,接受一个整数切片arr作为参数。

  2. mergeSort函数中,首先检查arr的长度是否小于2,如果是,直接返回arr,因为一个包含0或1个元素的数组已经是有序的。

  3. 创建一个辅助数组help,长度与arr相同,用于在合并过程中存储临时结果。

  4. 初始化一个mergeSize为1,表示初始合并步长。

  5. 使用一个外层循环,不断增加mergeSize的值,以迭代地合并子数组。

  6. 在内层循环中,从左到右遍历数组arr,以mergeSize为步长,分割数组并进行合并。

  7. 在每一次合并操作中,控制合并的左子数组的起始索引left,中间索引middle,以及右子数组的结束索引right。注意,在合并过程中要确保不越界,所以需要使用min函数来控制右子数组的边界。

  8. 调用merge函数来合并两个子数组,将合并后的结果存储在辅助数组help中。

  9. 完成一轮合并后,交换arrhelp,以便下一轮合并使用。

  10. 继续外层循环,直到mergeSize不小于数组的长度,此时排序完成。

  11. 返回已排序的数组arr

  12. merge函数用于合并两个已排序的子数组。在合并过程中,它会比较左子数组和右子数组的元素,然后按顺序合并它们,并将结果存储在辅助数组help中。

这样,通过不断迭代地分割和合并,最终得到了一个完全有序的数组,实现了非递归的归并排序。

数组实现栈

2023.10.13

定义一个结构体,来保存栈的信息。其包含3个字段:

  • maxSize:栈的最大容量;
  • arr:使用数组,来存放数据;
  • top:一个指向栈顶的指针,每次入栈,top加1,每次出栈,top减1。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
type Stack struct {
    maxSize int    // 栈的容量
    arr     [5]int // 存放数据
    top     int    // 栈顶,初始值位-1
}

func (stack *Stack) Push(inData int) {
    if stack.top == stack.maxSize-1 {
       fmt.Println("stack is full")
       return
    }
    stack.top++
    stack.arr[stack.top] = inData
}

func (stack *Stack) List() {
    if stack.top == -1 {
       fmt.Println("stack is empty")
       return
    }
    for i := stack.top; i > -1; i-- {
       fmt.Printf("arr[%d]=%d\n", i, stack.arr[i])
    }
}

func (stack *Stack) Pop() int {
    if stack.top == -1 {
       fmt.Println("stack is empty")
       return -1
    }
    outData := stack.arr[stack.top]
    stack.top--
    fmt.Printf("%d出栈\n", outData)
    return outData
}

func main() {
    stack := &Stack{
       maxSize: 5,
       top:     -1,
    }
    stack.Push(1)
    stack.Push(2)
    stack.Push(3)
    stack.Push(4)

    stack.List()

    stack.Pop()
    stack.Pop()
    stack.List()
}

表达式求值

使用栈来进行表达式求值,如:3+5*2-6。

算法思路:

  1. 创建两个栈,numStack(存放数),operStack(存放操作符);
  2. 定义一个字符串exp,保存要计算的表达式;
  3. 遍历exp:(现在只考虑计算的数字是1位的情况)
  • 如果扫描发现是一个数字,则直接入numstack;
  • 如果发现是一个运算符:
    • 如果operStack是一个空栈,直接入栈;
    • 如果operStack不是一个空栈:
      • 如果发现opertStack栈顶的运算符的优先级大于等于当前准备入栈的运算符的优先级,就从符号栈pop出,并从数栈也pop两个数,进行运算,运算后的结果再重新入栈到数栈,符号再入符号栈;
      • 否则,运算符就直接入栈。
  1. 如果扫描表达式完毕,依次从符号栈取出符号,然后从数栈取出两个数,运算后的结果,入数栈,直到符号栈为空。

上面的思路很重要,一定要理清楚!

示例代码:(考虑了数字是多位的情况,只支持简单的加减乘除,不能加括号)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
type CalcStack struct {
    maxSize int
    arr     [20]int
    top     int
}

// Push 入栈
func (stack *CalcStack) Push(inData int) {
    if stack.top+1 == stack.maxSize {
       fmt.Println("stack is full")
       return
    }
    stack.top++
    stack.arr[stack.top] = inData
}

// Pop 出栈
func (stack *CalcStack) Pop() (outData int, err error) {
    if stack.top == -1 {
       return -1, errors.New("stack is empty")
    }
    outData = stack.arr[stack.top]
    stack.top--
    return
}

// IsNum 判断传入的byte是否为数字
func IsNum(s byte) bool {
    return 0 <= (int(s)-int(byte('0'))) &&
       (int(s)-int(byte('0'))) <= 9
}

// GetPriority 获取运算符的优先级,乘除优先级值为1,加减优先级值为0
func GetPriority(s byte) int {
    if string(s) == "*" || string(s) == "/" {
       return 1
    } else {
       return 0
    }
}

// Calc 传入要计算的两个数和操作符,进行运算
func Calc(num1 int, num2 int, oper int) int {

    switch string(byte(oper)) {
    case "+":
       return num2 + num1
    case "-":
       return num2 - num1
    case "*":
       return num2 * num1
    case "/":
       return num2 / num1
    default:
       panic("操作符有误!")
    }
}
func main() {
    var tempStr string
    var num1, num2, oper, result int

    str := "30+4*7-83*5" // -357
    realResult := 30 + 4*7 - 83*5

    NumStack := &CalcStack{ // 定义两个栈,一个存放数字,一个存放运算符
       maxSize: 20,
       top:     -1,
    }

    OperStack := &CalcStack{
       maxSize: 20,
       top:     -1,
    }

    for i := 0; i < len(str); i++ {
       if IsNum(str[i]) {
          if tempStr == "" {
             tempStr += string(str[i]) // 初始化tempStr
          }
          if i == len(str)-1 { // 当找到表达式的最后一位时,不再往后探寻,直接将tempStr转为整数并加入数栈
             intData, _ := strconv.Atoi(tempStr)
             NumStack.Push(intData)
             tempStr = ""
          } else {
             if IsNum(str[i+1]) { // 探寻后面一位是否是数字
                tempStr += string(str[i+1])
             } else { // 下一位是字符,则将tempStr转换为整数,然后加入数栈
                intData, _ := strconv.Atoi(tempStr)
                NumStack.Push(intData)
                tempStr = ""
             }
          }
       } else {
          if OperStack.top == -1 { // 符号栈为空,直接入栈
             OperStack.Push(int(str[i]))
          } else { // 符号栈不为空,判断新入栈符号的优先级
             if GetPriority(str[i]) > GetPriority(byte(OperStack.arr[OperStack.top])) {
                OperStack.Push(int(str[i])) // 新运算符的优先级高,直接入栈
             } else { // 新运算符的优先级低,数栈中弹出两个元素进行运算
                num1, _ = NumStack.Pop()
                num2, _ = NumStack.Pop()
                oper, _ = OperStack.Pop()
                result = Calc(num1, num2, oper)
                NumStack.Push(result) // 将结果保存到数栈中
                OperStack.Push(int(str[i]))
             }
          }
       }
    }
    // 最后计算数栈中剩余的数
    for {
       num1, _ = NumStack.Pop()
       num2, _ = NumStack.Pop()
       oper, _ = OperStack.Pop()
       result = Calc(num1, num2, oper)
       NumStack.Push(result)
       if OperStack.top == -1 { // 运算符栈为空,结束所有运算
          break
       }
    }

    fmt.Println("result:", result)
    if result == realResult {
       fmt.Println("结果正确!")
    }
}

递归

2023.10.14

入门案例

Go语言可以使用递归来解决问题,递归是一种在函数内部调用自身的编程技巧。递归的原理是将一个大问题分解成一个或多个相似的子问题,直到这些子问题变得足够简单,可以直接解决。每次递归调用都会解决一个子问题,最终累积的结果将解决原始问题。递归必须包含一个终止条件,以防止无限递归。

以下是一个Go语言中递归的示例,计算阶乘(factorial)的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func factorial(n int) int {
    // 终止条件:如果n等于0,返回1
    if n == 0 {
        return 1
    }
    
    // 递归调用:计算n * (n-1) 的阶乘
    return n * factorial(n-1)
}

func main() {
    // 计算5的阶乘
    result := factorial(5)
    fmt.Println("5的阶乘是", result)
}

在这个示例中,factorial 函数通过递归计算阶乘。递归的终止条件是 n == 0,当 n 为0时,函数返回1。否则,它通过递归调用 factorial(n-1) 来计算 n 的阶乘,直到 n 达到0为止。

运行上述代码,你将得到输出结果:5的阶乘是 120。

这个示例展示了Go语言中递归的基本原理和用法。当使用递归时,要确保设置终止条件,以防止无限递归。递归通常用于解决树形结构的问题、分治算法和其他需要分解问题的情况。

递归是一种大事化小的思想,但是要求问题和小问题拥有同样的结构。

新手需要常常画递归决策图,这有利于分析递归的调用过程。

递归的底层是利用系统栈来完成的。

迷宫回溯问题

迷宫问题是一个经典的回溯算法示例,其目标是找到从迷宫的入口到出口的路径。回溯算法是一种穷举搜索算法,它通过探索所有可能的路径并在遇到障碍或无法继续前进时回退,直到找到解决方案。下面是一个Go语言实现的迷宫回溯问题的示例代码,以及其原理说明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package main

import "fmt"

func solveMaze(maze [][]int, startX, startY, endX, endY int) bool {
    if startX == endX && startY == endY {
        // 已经到达终点
        return true
    }
    
    if isValidMove(maze, startX, startY) {
        // 标记当前位置为已访问
        maze[startX][startY] = 2

        // 尝试向上、向下、向左、向右四个方向移动
        if solveMaze(maze, startX+1, startY, endX, endY) ||  // 向下
           solveMaze(maze, startX-1, startY, endX, endY) ||  // 向上
           solveMaze(maze, startX, startY+1, endX, endY) ||  // 向右
           solveMaze(maze, startX, startY-1, endX, endY) {   // 向左
            return true
        }

        // 如果上述四个方向都没有通路,则回退
        maze[startX][startY] = 0
    }
    
    return false
}

func isValidMove(maze [][]int, x, y int) bool {
    // 检查是否越界或者是否是墙(1 表示墙,0 表示通路,2 表示已访问)
    if x >= 0 && x < len(maze) && y >= 0 && y < len(maze[0]) && maze[x][y] == 0 {
        return true
    }
    return false
}

func main() {
    maze := [][]int{
        {0, 1, 0, 0, 0},
        {0, 1, 0, 1, 0},
        {0, 0, 0, 0, 0},
        {0, 1, 1, 1, 0},
        {0, 0, 0, 0, 0},
    }

    startX, startY := 0, 0
    endX, endY := len(maze)-1, len(maze[0])-1

    if solveMaze(maze, startX, startY, endX, endY) {
        fmt.Println("找到一条路径:")
        printMaze(maze)
    } else {
        fmt.Println("未找到路径。")
    }
}

func printMaze(maze [][]int) {
    for _, row := range maze {
        for _, cell := range row {
            if cell == 0 {
                fmt.Print("  ") // 通路
            } else if cell == 1 {
                fmt.Print("█ ") // 墙
            } else if cell == 2 {
                fmt.Print("x ") // 已访问
            }
        }
        fmt.Println()
    }
}

这段代码实现了解决迷宫问题的原理如下:

  1. solveMaze 函数使用递归来尝试从起点 (startX, startY) 到终点 (endX, endY) 寻找路径。如果当前位置等于终点位置,返回 true,表示找到了路径。

  2. 如果当前位置不是终点位置,它会检查当前位置是否可以移动(即不是墙,且不越界)。如果可以移动,将当前位置标记为已访问(2),然后尝试向四个方向移动(上、下、左、右)。

  3. 对于每个可能的移动方向,递归调用 solveMaze 函数,以尝试找到通往终点的路径。如果在某个方向上找到了路径(递归返回 true),则返回 true,表示找到了路径。否则,如果没有在任何方向上找到路径,将当前位置标记为未访问(0),并返回 false

  4. 主函数 main 定义了迷宫的布局,起点和终点,并调用 solveMaze 函数来查找路径。如果找到了路径,它会打印迷宫,显示找到的路径。否则,它会输出 “未找到路径”。

这段代码演示了如何使用回溯算法来解决迷宫问题,找到从起点到终点的路径。

Master公式

引入案例

求数组arr[L..R]中的最大值,递归方法实现。

1)将[L..R]范围分成左右两半。左:[L…Mid] 右[Mid+1..R] 2)左部分求最大值,右部分求最大值 3) [L…R]范围上的最大值,是max{左部分最大值,右部分最大值}

注意:2)是个递归过程,当范围上只有一个数,就可以不用再递归了。

代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
    arr := []int{24, 31, 60, 55, 72, 18}
    max := maxValue(arr, 0, len(arr)-1)
    fmt.Println(max)
}

func maxValue(arr []int, left int, right int) int {
    if left >= right {
       return arr[right]
    }
    middle := left + (right-left)>>1
    leftMax := maxValue(arr, left, middle)
    rightMax := maxValue(arr, middle+1, right)

    if leftMax >= rightMax {
       return leftMax
    } else {
       return rightMax
    }

}

如何分析上面递归函数的时间复杂度?

可以使用Master公式:

形如T(N) = a * T(N/b) + O(N^d)(其中的a、b、d都是常数)的递归函数,可以直接通过Master公式来确定时间复杂度:

  • 如果 log(b,a) < d,复杂度为O(N^d)
  • 如果 log(b,a) > d,复杂度为O(N^log(b,a))
  • 如果 log(b,a) == d,复杂度为O(N^d * logN)

log(b,a) 表示以b为底数,a为真数,进行log运算。

Master公式只能用在子过程都是等规模的递归问题,并且都按比例减小子过程不是等规模的递归问题,分析起来比较复杂,这里不做阐述。

上面的每次递归子过程中,时间复杂度为: 2 * T(N/2) + O(1)。

leftMax := maxValue(arr, left, middle) rightMax := maxValue(arr, middle+1, right)

上面利用middle将原数组对半分,时间复杂度为: 2 * T(N/2),然后还是两次比较,时间复杂度为O(1)。

显然,上面例子中的递归过程是满足Master公式条件的,可以利用该公式求时间复杂度。

a=2,b=2,d=0

log(b,a) =1大于d的值,所以时间复杂度为O(N^log(b,a))=O(N)。

也就是说,这种递归查找最大值的方法和直接遍历数组查找最大值的方法,时间复杂度是相等的。

总结:如果满足Master公式的条件,则只需要看一层递归的过程,就可以估计整个递归过程的时间复杂度。

哈希

2023.10.15

介绍

哈希表(HashTable)是一种常用的数据结构,它允许你以常量时间复杂度(O(1))进行插入、查找和删除操作,前提是哈希函数设计得当。Go语言提供了内置的map数据类型来实现哈希表,它将键映射到值。

哈希表的原理:

  1. 哈希函数:哈希表的关键部分是哈希函数,它将输入数据(通常是键)映射到固定大小的哈希表中的索引。这个函数应该是快速执行的,且尽量避免碰撞(多个不同的输入映射到相同的索引)。碰撞是不可避免的,因此好的哈希函数会尽量减少碰撞的发生。

  2. 哈希表数组:哈希表通常是一个固定大小的数组,每个元素称为槽位(bucket)。槽位的数量通常是一个质数,以帮助减少碰撞。

  3. 碰撞处理:如果多个键被哈希到同一个槽位,需要一种方法来处理碰撞。常见的方法包括链式哈希和开放寻址哈希。

示例代码,使用Go语言的map来实现一个简单的哈希表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

func main() {
    // 创建一个空的哈希表
    hashMap := make(map[string]int)

    // 插入键值对
    hashMap["apple"] = 1
    hashMap["banana"] = 2
    hashMap["cherry"] = 3

    // 查找键的值
    fmt.Println("banana:", hashMap["banana"]) // 输出: banana: 2

    // 删除键值对
    delete(hashMap, "banana")

    // 检查键是否存在
    _, exists := hashMap["banana"]
    fmt.Println("Is banana in the hash map?", exists) // 输出: Is banana in the hash map? false
}

这个示例演示了如何使用Go语言的map来创建、插入、查找、删除键值对。map的内部实现会自动处理哈希和碰撞,你只需关心如何使用它来管理键值对。请注意,Go语言的map是无序的,所以遍历时无法保证键值对的顺序。

不使用map实现

以手动创建一个哈希表数据结构,以下是一个简单的示例,展示了哈希表的基本原理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package main

import (
    "fmt"
    "hash/fnv"
)

const tableSize = 100

type HashTable struct {
    data [tableSize][]KeyValue
}

type KeyValue struct {
    key   string
    value int
}

func main() {
    ht := NewHashTable()

    ht.Insert("apple", 1)
    ht.Insert("banana", 2)
    ht.Insert("cherry", 3)

    value, exists := ht.Search("banana")
    if exists {
        fmt.Println("banana:", value)
    } else {
        fmt.Println("Key not found")
    }

    ht.Delete("banana")
    _, exists = ht.Search("banana")
    if exists {
        fmt.Println("banana:", value)
    } else {
        fmt.Println("Key not found")
    }
}

func NewHashTable() *HashTable {
    return &HashTable{}
}

func (ht *HashTable) Insert(key string, value int) {
    index := hash(key)
    kv := KeyValue{key, value}
    ht.data[index] = append(ht.data[index], kv)
}

func (ht *HashTable) Search(key string) (int, bool) {
    index := hash(key)
    for _, kv := range ht.data[index] {
        if kv.key == key {
            return kv.value, true
        }
    }
    return 0, false
}

func (ht *HashTable) Delete(key string) {
    index := hash(key)
    for i, kv := range ht.data[index] {
        if kv.key == key {
            ht.data[index] = append(ht.data[index][:i], ht.data[index][i+1:]...)
            return
        }
    }
}

func hash(s string) uint32 {
    h := fnv.New32a()
    h.Write([]byte(s))
    return h.Sum32() % tableSize
}

这个示例创建了一个基本的哈希表,使用了FNV哈希函数(hash/fnv包)来将键映射到哈希表中的索引。哈希表使用一个固定大小的数组,每个槽位存储一个包含键值对的切片。你可以根据需要扩展这个示例来支持更多操作或解决碰撞问题。注意,这个示例仅用于说明基本原理,实际应用中需要更多的错误处理和性能优化。

hash函数的详细说明:

  1. a. fnv.New32a()hash/fnv包中的fnv.New32a()函数创建一个FNV-1a哈希算法的新实例,该算法是一种非常简单但有效的哈希算法。 b. h.Write([]byte(s)):将输入的字符串s转换为字节数组,然后将该字节数组传递给FNV哈希实例h进行哈希计算。FNV算法会迭代处理每个字节,生成一个哈希值。 c. h.Sum32():通过h.Sum32()方法获取FNV哈希算法计算出的32位哈希值。这个哈希值是一个无符号整数。 d. % tableSize:为了确保哈希值在合理的范围内,通常对其取模,将其映射到哈希表中的槽位。tableSize 是哈希表的大小,这样可以确保哈希值在合法的槽位索引范围内。
  2. 返回值:hash函数返回计算出的哈希值,该值表示了键在哈希表中的存储位置。不同的键会产生不同的哈希值,这有助于分散键的分布,减少碰撞的发生。

总之,hash函数的主要功能是将输入的字符串键映射为一个整数哈希值,使得相同的键总是映射到相同的槽位,从而允许快速的查找和插入操作,同时尽量减少碰撞。

Hash碰撞

在哈希表中,**碰撞(Collision)**指的是不同的键经过哈希函数运算后,得到了相同的哈希值,因此它们被映射到哈希表中的相同槽位(或索引位置)。这种情况被称为哈希碰撞。

哈希碰撞是不可避免的,因为哈希函数将无限的输入域(不同的键)映射到有限的输出域(有限数量的槽位)。当两个不同的键具有相同的哈希值时,就会发生碰撞。

哈希碰撞可能会引起问题,因为哈希表的目标是将键唯一地映射到值,以便能够快速查找、插入和删除数据。当碰撞发生时,通常有以下两种主要处理方法:

  1. 开放寻址法(Open Addressing):在这种方法中,如果发生碰撞,就会尝试在哈希表中的其他槽位中寻找可用的位置,直到找到一个空的槽位来存储数据。这个过程可以采用不同的探测方式,如线性探测、二次探测、双重哈希等。

  2. 链接法(Chaining):在这种方法中,每个槽位存储一个链表、数组或其他数据结构,当碰撞发生时,新数据项将添加到该数据结构中。这意味着一个槽位可以容纳多个键-值对,它们都具有相同的哈希值。

选择哪种碰撞解决方法通常取决于应用程序的需求和性能特性。开放寻址法可能会导致哈希表变得更加稠密,因为碰撞后数据项在同一哈希表中连续存储。链接法则保持了哈希表的稳定性,因为碰撞后的数据项分散在不同的数据结构中。通常,性能取决于哈希函数的质量、哈希表的负载因子以及具体应用场景。

解决碰撞问题是设计和使用哈希表时需要考虑的重要方面,因为它直接影响了哈希表的性能和数据访问效率。

Hash表编程题

给定一个数组nums和一个目标值k,找到累加和等于k的最长连续子数组长度。如果不存在任意一个符合要求的子数组,则返回 0。

力扣:和等于 k 的最长子数组长度

解析:

这个问题可以使用前缀和和哈希表来解决。

我们先构建一个累加和的哈希表(key=累加和,value=该累加和第一次出现的位置)。

注意,这里一定要是第一次出现的位置,因为要保证后面得到的数组长度最大。

要找到累加和等于k的最长连续子数组长度,可以转化为下面的问题:

将当前位置的累加和减去目标值k(假设为sub),然后查找哈希表中是否存在key的值等于sub,如果存在则说明找到了最后的结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main

import "fmt"

// maxSubArrayLen 函数接受一个整数数组 nums 和一个目标值 k,并返回累加和等于 k 的最长连续子数组的长度
func maxSubArrayLen(nums []int, k int) int {
    // prefixSum 是一个映射,用于存储累加和和对应的索引
    prefixSum := make(map[int]int)
    
    // maxLen 用于跟踪最长子数组的长度
    maxLen := 0
    
    // sum 用于跟踪当前的累加和
    sum := 0

    for i := 0; i < len(nums); i++ {
        sum += nums[i]

        // 如果当前累加和等于目标值 k,整个数组就是一个候选子数组
        if sum == k {
            maxLen = i + 1
        } else if index, ok := prefixSum[sum-k]; ok {
            // 如果存在之前的累加和等于当前累加和减去 k,说明这两个位置之间的元素和为 k
            maxLen = max(maxLen, i - index)
        }

        // 如果当前累加和不在前缀和映射中,将其添加进去
        if _, ok := prefixSum[sum]; !ok {
            prefixSum[sum] = i
        }
    }

    return maxLen
}

// max 函数用于比较两个整数并返回较大的那个
func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

func main() {
    nums := []int{1, -1, 5, -2, 3}
    k := 3
    result := maxSubArrayLen(nums, k)
    fmt.Println("最长连续子数组的长度为:", result)
}

这个代码中,我们维护一个前缀和映射(prefixSum),其中键是累加和,值是该累加和首次出现的索引。我们遍历数组,并在每个位置更新累加和,然后检查前缀和映射中是否存在对应的累加和,以确定是否存在一个子数组的累加和等于k。如果存在,我们计算子数组的长度并更新最大长度(maxLen)。

这个算法的时间复杂度是O(n),其中n是输入数组的长度。

二叉树

2023.10.15

几种遍历方式

二叉树的遍历是指按照一定的顺序访问二叉树中的节点,常见的遍历方式有三种:前序遍历、中序遍历和后序遍历。这些遍历方式可以用递归或迭代的方法来实现。

下面是这三种遍历方式的简要描述:

  1. 前序遍历(Preorder Traversal)

    • 遍历顺序:根节点 -> 左子树 -> 右子树
    • 递归实现:
      1. 访问根节点。
      2. 递归地对左子树进行前序遍历。
      3. 递归地对右子树进行前序遍历。
  2. 中序遍历(Inorder Traversal)

    • 遍历顺序:左子树 -> 根节点 -> 右子树
    • 递归实现:
      1. 递归地对左子树进行中序遍历。
      2. 访问根节点。
      3. 递归地对右子树进行中序遍历。
  3. 后序遍历(Postorder Traversal)

    • 遍历顺序:左子树 -> 右子树 -> 根节点
    • 递归实现:
      1. 递归地对左子树进行后序遍历。
      2. 递归地对右子树进行后序遍历。
      3. 访根节点。

总结:

前序遍历:根—左—右。 中序遍历:左—根—右。 后序遍历:左—右—根。

代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
type BinaryTree struct {
    id    int
    left  *BinaryTree
    right *BinaryTree
}

func InitTree() *BinaryTree {  // 构建二叉树,返回根结点
    node1 := &BinaryTree{
       id: 1,
    }
    node2 := &BinaryTree{
       id: 2,
    }
    node3 := &BinaryTree{
       id: 3,
    }
    node4 := &BinaryTree{
       id: 4,
    }
    node5 := &BinaryTree{
       id: 5,
    }
    node6 := &BinaryTree{
       id: 6,
    }
    node7 := &BinaryTree{
       id: 7,
    }

    node4.left = node2 // node4为根节点
    node4.right = node6
    node2.left = node1
    node2.right = node3
    node6.left = node5
    node6.right = node7

    return node4
}

func PreOrder(node *BinaryTree) { // 前序遍历
    if node == nil {
       return
    }
    fmt.Printf("%d ", node.id)  // 先遍历根节点,然后遍历左右节点
    PreOrder(node.left)
    PreOrder(node.right)
}

func InOrder(node *BinaryTree) { // 中序遍历
    if node == nil {
       return
    }
    InOrder(node.left)
    fmt.Printf("%d ", node.id)
    InOrder(node.right)
}

func PostOrder(node *BinaryTree) { // 后序遍历
    if node == nil {
       return
    }
    PostOrder(node.left)
    PostOrder(node.right)
    fmt.Printf("%d ", node.id)
}

func main() {
    root := InitTree()
    fmt.Printf("\nPreorder traversal: ")
    PreOrder(root)
    fmt.Printf("\nInorder traversal: ")
    InOrder(root)
    fmt.Printf("\nPostorder traversal: ")
    PostOrder(root)
}

已学完:尚硅谷-数据结构与算法(韩顺平)(共14小时49分钟)

现在开始学:马士兵教育-算法和数据结构(Golang语言实现)(共37小时42分钟)

时间复杂度

时间复杂度是算法分析中的一个概念,用来描述算法的运行时间与输入规模之间的关系。它用大O符号(O)来表示,表示算法运行时间的增长趋势。时间复杂度可以帮助我们估计算法在不同输入规模下的运行效率。

常数时间操作是指不论输入规模的大小如何变化,操作的运行时间都是固定的,即常数时间。在时间复杂度中,常数时间操作通常用O(1)来表示,表示操作的运行时间是固定的,与输入规模无关。

举例来说,让我们考虑一个简单的数组操作,如通过索引访问数组中的元素。无论数组的大小是10还是1,000,000,通过索引访问一个特定位置的元素都需要相同的时间,因此这是一个常数时间操作。

常见的常数时间的操作: 常见的位运算(»、»>、«、|、&、^等) 常见的算术运算(+、-、*、/、% 等) 赋值、比较、自增、自减操作等 数组寻址操作

下面是一些示例,说明了不同时间复杂度和常数时间操作的概念:

  1. 常数时间操作(O(1)):访问数组元素、插入或删除链表中的头节点、进行基本的算术运算等。这些操作的运行时间是不随输入规模而变化的。

  2. 线性时间操作(O(n)):遍历数组或链表中的所有元素,运行时间与输入规模成线性关系。例如,遍历一个包含n个元素的数组需要O(n)时间。

  3. 对数时间操作(O(log n)):通常出现在二分查找等算法中,运行时间与输入规模的对数成正比。例如,对一个有序数组进行二分查找需要O(log n)时间。

  4. 平方时间操作(O(n^2 )):通常出现在嵌套循环中,运行时间与输入规模的平方成正比。例如,嵌套循环遍历一个n×n的二维数组需要O(n^2)时间。

总之,时间复杂度是一种衡量算法性能的方式,而常数时间操作表示算法的运行时间不受输入规模的影响,始终保持稳定。在算法设计和分析中,追求常数时间操作是一个重要的目标,因为它通常代表了高效的算法。

前缀和

利用前缀和数组,快速求解指定范围的元素之和。 数组前缀和(Prefix Sum)是一种用于处理数组的技术,它通过将数组中的元素累积起来,生成一个新的数组,其中每个元素表示原数组中某个位置之前的所有元素的总和。这个新数组通常被用来在常数时间内快速计算数组中任意子数组的和。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
    arr := []int{3, -1, 5, -3, 8, 7}
    sumArr := preSumArray(arr)

    fmt.Println(getSum(sumArr, 0, 5))
    fmt.Println(getSum(sumArr, 2, 4))
    fmt.Println(getSum(sumArr, 4, 5))
}

func preSumArray(arr []int) []int { //生成 前缀和 数组
    n := len(arr)
    sum := make([]int, n)
    sum[0] = arr[0]
    for i := 1; i < n; i++ {
       sum[i] = sum[i-1] + arr[i]
    }
    return sum
}

// getSum 返回原始数组arr[left...right]范围上的累加和(利用 前缀和 数组sumArr加工得到)
func getSum(sumArr []int, left int, right int) int {
    if left <= 0 {
       return sumArr[right]
    }
    return sumArr[right] - sumArr[left-1]
}

位运算

“异或”(XOR)运算和"同或"(XNOR)运算是两种逻辑运算,它们在计算机科学和电子工程中经常使用。

异或运算

介绍

  • 异或运算是一种逻辑运算,用于比较两个值,返回 true 或 false 的结果。
  • 如果两个输入值不相同,异或运算返回 true(或 1),如果两个输入值相同,则返回 false(或 0)。
  • 异或运算的真值表如下:
    1
    2
    3
    4
    5
    
    A   B   A XOR B
    0   0     0
    0   1     1
    1   0     1
    1   1     0

异或运算经常用于位操作、加密、校验和等领域。

可以把异或运算看做二进制数的无进位相加:1+1=0、1+0=1、0+0=0。

异或运算的性质:

  • 0 和任意数 N 异或,结果都为 N;

  • 任意数 N 和自身异或,结果都为 0。

  • 交换律:a ^ b = b ^ a;(同一批数,不管顺序如何,异或结果都是一样的)

  • 结合律:(a ^ b) ^ c = a ^ (b ^ c)

  • 如果 a ^ b = c,则:b = c ^ a, a = c ^ b

交换两个数的值

1
2
3
a = a ^ b
b = a ^ b
c = a ^ b

解析:

假设a=甲,b=乙。

a = a ^ b ———— a=甲 ^ 乙,b=乙。 b = a ^ b ———— a=甲 ^ 乙,b=甲 ^ 乙 ^ 乙 = 甲 ^ 0=甲。 c = a ^ b ———— a=甲 ^ 乙 ^ 甲=乙,b=甲。

这样就完成了两个数的交换。

交换两个数,不额外开辟空间还可以使用下面的方法:

1
2
3
a=a+b
b=a-b
a=a-b

异或运算编程题

题目1:一个数组中只有一个数出现了奇数次,其他数都出现了偶数次,怎么找到并打印这个数。

分析:我们使用异或运算来完成这道题。

遍历数组中的所有元素,定义一个变量 eor 来保存数组中所有元素异或之后,最后的结果。变量eor的值就是我们想要的结果。

原因:同一批数,不管顺序如何,异或结果都是一样的,且N ^ N = 0,M ^ 0 = M。所以,出现偶数次的那些数异或之后得到0,最后只剩下出现了奇数次的那个数。

代码:

1
2
3
4
5
6
7
8
func main() {
    arr := []int{2, 6, 4, 7, 6, 2, 7, 4, 7}
    var eor int
    for i := 0; i < len(arr); i++ {
       eor = eor ^ arr[i]
    }
    fmt.Println("出现奇数次的数:", eor)
}

题目2:一个数组中有两个数出现了奇数次,其他数都出现了偶数次,怎么找到并打印这两个数。

分析:还是和上面一样,所有数进行异或运算,最后得到的结果一定是两个出现奇数次的数(假设为a和b)进行异或运算,即:eor = a ^ b。

由于 a!=b,故eor一定不为0,即一定存在某位值为1,而在这一位,a和b的二进制数一定是不相同的。

这里为了方便计算,我们取出eor的最低位1(如01100100,取出最低位1之后变成了:00000100),用变量onlyOne来存放。

现在可以将所有数分为两类:①上一步中得到的那一位值为1的数;②上一步中得到的那一位值为0的数。而a和b一定分别属于这两类中的一类。

现在让 eor1(初始值为0) 异或上一步中任何一类的所有数字,就可以得到a和b其中的一个数(其他出现偶数次的数都被消掉了)。即:eor1 ^= arr[i]。

然后让 eor ^ eor1,就可以得到另外的一个数。

代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func main() {
    arr := []int{2, 6, 4, 7, 6, 2, 7, 4, 7, 9, 9, 9}
    var eor int
    var result = make([]int, 2)
    for i := 0; i < len(arr); i++ {
       eor = eor ^ arr[i] // eor=a^b
    }
    onlyOne := eor & (-eor)
    eor1 := 0
    for i := 0; i < len(arr); i++ {
       if onlyOne&arr[i] != 0 { // 将所有数分为两类,筛选出指定位值为1的那一类
          eor1 ^= arr[i] // 找出a和b中的一个数
       }
    }
    result[0] = eor1
    result[1] = eor ^ eor1 // 求出另一个数
    fmt.Println("出现奇数次的两个数为:", result)
}

同或运算

  • 同或运算是异或运算的逆运算,也被称为"等价"运算或"不等"运算。
  • 如果两个输入值相同,同或运算返回 true(或 1),如果两个输入值不相同,则返回 false(或 0)。
  • 同或运算的真值表如下:
    1
    2
    3
    4
    5
    
    A   B   A XNOR B
    0   0     1
    0   1     0
    1   0     0
    1   1     1

同或运算通常用于比较两个值是否相等。如果两个值相等,同或运算的结果为真(1),否则为假(0)。

在编程中,异或运算通常用符号 ^ 表示,而同或运算可以通过对异或运算的结果取反来实现。例如,在许多编程语言中,A ^ B 表示 A 异或 B,而 !(A ^ B) 表示 A 同或 B。

Go语言中没有直接提供同或(XNOR)运算符,但你可以使用位运算和条件语句来模拟同或操作。也可以通过按位异或(XOR)运算的结果再取反来实现。

模拟同或运算:

1
2
3
4
5
6
func main() {
    a := true
    b := true
    result := !(a != b)
    fmt.Printf("a XNOR b = %v\n", result)
}

按位与

可以通过A & (-A) 的到A的最低位1的值,如:

1
2
3
4
5
6
A = 18 (10010)
A & (-A):
   10010   (18)
&  01110 (-18)
-----------
   00010

-A 表示 A 的负数,计算方法是取 A 的按位取反(0 变 1,1 变 0),然后加 1。

A-A 有一个特性:它们只有一个位是相同的,即最低位的 1。其他位都是不同的。因此,A & (-A) 的结果将是 A 中最低位的 1,其他位都为 0。

2023.10.18

介绍

堆(Heap)是一种重要的数据结构,通常用于优先队列和排序算法中。堆是一个特殊的树状数据结构,它满足以下两个主要性质:

  1. 堆的结构性质:堆通常是一个二叉树,其中每个节点都有零个或多个子节点。在二叉堆中,每个节点最多有两个子节点,分别称为左子节点和右子节点。

  2. 堆的堆序性质:堆具有堆序性质,它分为两种类型:

    • 最大堆(Max Heap):在最大堆中,每个父节点的值大于或等于其子节点的值。换句话说,堆中的最大元素总是位于根节点。
    • 最小堆(Min Heap):在最小堆中,每个父节点的值小于或等于其子节点的值。堆中的最小元素总是位于根节点。

堆的重要性质和操作包括:

  • 查找最大或最小元素:由于最大堆中根节点是最大元素,而最小堆中根节点是最小元素,因此可以非常高效地查找堆中的最大或最小元素,时间复杂度为O(1)。

  • 插入元素:插入元素时,堆会保持堆的结构和堆序性质,通常需要进行一次堆化操作,时间复杂度为O(log n)。

  • 删除最大或最小元素:删除堆中的最大或最小元素后,堆需要重新调整以保持堆的性质,通常需要进行一次堆化操作,时间复杂度为O(log n)。

堆广泛用于各种应用中,如堆排序、图算法(如Dijkstra算法和Prim算法)、调度算法、优先队列等。最大堆和最小堆的应用各不相同,根据具体问题选择使用哪种堆。

大根堆

以下是一个示例 Go 语言实现最大堆的代码。这个代码包含了最大堆的基本操作,包括插入元素、删除最大元素,以及构建最大堆。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
type MaxHeap struct {
	data []int // 堆的元素存储在切片中
	size int   // 堆的大小
}

// NewMaxHeap 创建一个新的最大堆
func NewMaxHeap() *MaxHeap {
	return &MaxHeap{}
}

// Insert 将元素插入到最大堆
func (h *MaxHeap) Insert(key int) {
	h.data = append(h.data, key) // 将新元素添加到堆的末尾
	h.size++                     // 堆大小加一
	h.heapifyUp(h.size - 1)      // 对新元素执行上浮操作,以保持最大堆性质
}

// ExtractMax 删除并返回最大元素
func (h *MaxHeap) ExtractMax() (int, error) {
	if h.size == 0 {
		return 0, fmt.Errorf("heap is empty")
	}
	max := h.data[0]         // 最大元素是根节点
	last := h.data[h.size-1] // 获取最后一个元素
	h.size--                 // 堆大小减一
	if h.size > 0 {
		h.data[0] = last // 用最后一个元素替代根节点
		h.heapifyDown(0) // 对根节点执行下沉操作,以保持最大堆性质
	}
	return max, nil
}

// heapifyUp 堆化操作,自下而上,用于插入操作
func (h *MaxHeap) heapifyUp(index int) {
	for index > 0 {
		parent := (index - 1) / 2 // 计算父节点索引
		if h.data[index] <= h.data[parent] {
			break // 如果当前节点不大于父节点,堆性质已经满足,退出循环
		}
		// 交换当前节点和父节点的值
		h.data[index], h.data[parent] = h.data[parent], h.data[index]
		index = parent // 更新索引
	}
}

// heapifyDown 堆化操作,自上而下,用于删除操作
func (h *MaxHeap) heapifyDown(index int) {
	for {
		leftChild := 2*index + 1  // 左子节点索引
		rightChild := 2*index + 2 // 右子节点索引
		largest := index          // 假设当前节点是最大的

		// 寻找左右子节点中的最大者
		if leftChild < h.size && h.data[leftChild] > h.data[largest] {
			largest = leftChild
		}
		if rightChild < h.size && h.data[rightChild] > h.data[largest] {
			largest = rightChild
		}

		if largest == index {
			break // 如果当前节点已经是最大的,堆性质已经满足,退出循环
		}

		// 交换当前节点和最大子节点的值
		h.data[index], h.data[largest] = h.data[largest], h.data[index]
		index = largest // 更新索引(largest的位置指向的是和原来index互换值的位置,现在我们需要将index设置为该值,方便进行向下面的子树遍历)
	}
}

func main() {
	maxHeap := NewMaxHeap()

	maxHeap.Insert(10)
	maxHeap.Insert(5)
	maxHeap.Insert(20)
	maxHeap.Insert(3)
	maxHeap.Insert(30)

	fmt.Println("Max Heap:")
	for maxHeap.size > 0 {
		max, _ := maxHeap.ExtractMax()
		fmt.Printf("%d ", max)
	}
}

这段代码创建了一个 MaxHeap 结构体,其中包含最大堆的堆切片和堆大小。它包括了插入元素、删除最大元素、堆化(上浮和下沉)操作。在 main 函数中,你可以看到如何使用最大堆来插入元素并删除最大元素。最终,它会按照最大堆的性质输出元素。

堆排序

0%