golang数据结构与算法学习笔记——进阶篇

本文介绍了数据结构中的栈、队列及链表的基本概念与实现方法,包括括号匹配问题的解决思路,普通队列与双端队列的区别,以及单向链表与双向链表的构造过程。

目录

排序算法

排序算法(sorting algorithm)用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更高效地查找、分析和处理。

排序算法中的数据类型可以是整数、浮点数、字符或字符串等。排序的判断规则可根据需求设定,如数字大小、字符ASCII码顺序或自定义规则。

在这里插入图片描述
评价维度:

  • 运行效率:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(时间复杂度中的常数项变小)。对于大数据量的情况,运行效率显得尤为重要。
  • 就地性:顾名思义,原地排序通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
  • 稳定性:稳定排序在完成排序后,相等元素在数组中的相对顺序不发生改变。

稳定性在某些场景非常重要,尤其是当排序依赖多重关键字时。稳定排序可以保留前一个关键字排序的结果,不需要重新排列。例如,如果需要对学生成绩按科目分数排序,然后按学号排序,使用稳定排序会使流程更加简洁。

# (name, age)
 ('A', 19)
 ('B', 18)
 ('C', 21)
 ('D', 19)
 ('E', 23)

# 使用非稳定排序算法按年龄排序列表
('B', 18)
('D', 19)
('A', 19)
('C', 21)
('E', 23)

例如,对于以下数组,按关键字排序:

初始数组

[(A,2), (B,1), (C,2), (D,1)]

按关键字(第二个值)升序排序:

  • 稳定排序结果

    [(B,1), (D,1), (A,2), (C,2)]
    

    相等关键字的元素 (A,2)(C,2) 的相对顺序保持了原先的 (A,2) 在前。

  • 非稳定排序可能的结果

    [(D,1), (B,1), (C,2), (A,2)]
    

    相等关键字的元素顺序可能被打乱。

  • 常见的稳定排序算法:冒泡排序、归并排序、插入排序。

  • 常见的非稳定排序算法:快速排序、堆排序、选择排序(默认实现中)。

选择排序

选择排序(Selection Sort)基本思想:将数组分为两个区间:左侧为已排序区间,右侧为未排序区间。每趟从未排序区间中选择一个值最小的元素,放到已排序区间的末尾,从而将该元素划分到已排序区间。

算法步骤:

  1. 初始状态下,所有元素未排序,即未排序(索引)区间为[0,𝑛−1]。
  2. 选取区间[0,𝑛−1]中的最小元素,将其与索引0处的元素交换。完成后,数组前1个元素已排序。
  3. 选取区间[1,𝑛−1]中的最小元素,将其与索引1处的元素交换。完成后,数组前2个元素已排序。
  4. 以此类推。经过𝑛−1轮选择与交换后,数组前𝑛−1个元素已排序。
  5. 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
根据上述图例和算法过程可以写出如下选择排序的实现:

func selectionSort(nums []int) {
	n := len(nums)
	// 外循环:未排序区间为 [i, n-1]
	for i := 0; i < n-1; i++ {
		// 内循环:找到未排序区间内的最小元素
		k := i
		for j := i + 1; j < n; j++ {
			if nums[j] < nums[k] {
				// 记录最小元素的索引
				k = j
			}
		}
		// 将该最小元素与未排序区间的首个元素交换
		nums[i], nums[k] = nums[k], nums[i]
	}
}

算法分析:

  1. 时间复杂度为𝑂(𝑛**2):排序法所进行的元素之间的比较次数与序列的原始状态无关,哪怕初始数组是排序好的,每次也需要进行遍历比较。外循环共𝑛−1轮,第一轮的未排序区间长度为𝑛,最后一轮的未排序区间长度为2,即各轮外循环分别包含𝑛、𝑛−1、…、3、2轮内循环,求和为((𝑛−1)(𝑛+2))/2

  2. 空间复杂度为𝑂(1)、原地排序:指针𝑖和𝑗使用常数大小的额外空间。

  3. 排序稳定性:由于值最小元素与未排序区间第 1 个元素的交换动作是在不相邻的元素之间进行的,因此很有可能会改变相等元素的相对顺序,因此,选择排序法是一种 不稳定排序算法

    在这里插入图片描述

  4. 选择排序适用情况:选择排序方法在排序过程中需要移动较多次数的元素,并且排序时间效率比较低。因此,选择排序方法比较适合于参加排序序列的数据量较小的情况。选择排序的主要优点是仅需要原地操作无需占用其他空间就可以完成排序,因此在空间复杂度要求较高时,可以考虑选择排序。

冒泡排序

冒泡排序(Bubble Sort)基本思想:经过多次迭代,通过相邻元素之间的比较与交换,使值较小的元素逐步从后面移到前面,值较大的元素从前面移到后面。(这个过程就像水底的气泡一样从底部向上「冒泡」到水面,这也是冒泡排序法名字的由来。)

冒泡排序算法步骤:

  1. 第 1 趟「冒泡」:对前 (n) 个元素执行「冒泡」,从而使第 1 个值最大的元素放置在正确位置上。
    先将序列中第 1 个元素与第 2 个元素进行比较,如果前者大于后者,则两者交换位置,否则不交换。
    然后将第 2 个元素与第 3 个元素比较,如果前者大于后者,则两者交换位置,否则不交换。
    依次类推,直到第 (n - 1) 个元素与第 (n) 个元素比较(或交换)为止。
    经过第 1 趟排序,使得 (n) 个元素中第 (i) 个值最大元素被安置在第 (n) 个位置上。

  2. 第 2 趟「冒泡」:对前 (n - 1) 个元素执行「冒泡」,从而使第 2 个值最大的元素放置在正确位置上。
    先将序列中第 1 个元素与第 2 个元素进行比较,若前者大于后者,则两者交换位置,否则不交换。
    然后将第 2 个元素与第 3 个元素比较,若前者大于后者,则两者交换位置,否则不交换。
    依次类推,直到对 (n - 2) 个元素与第 (n - 1) 个元素比较(或交换)为止。
    经过第 2 趟排序,使得数组中第 2 个值最大元素被安置在第 (n) 个位置上。

  3. 依次类推,重复上述「冒泡」过程,直到某一趟排序过程中不出现元素交换位置的动作,则排序结束。

以 [5, 2, 3, 6, 1, 4] 为例,

在这里插入图片描述

集合算法的步骤,可以得到如下实现:

func bubbleSort(nums []int) {
	// 外循环:未排序区间为 [0, i]
	for i := len(nums) - 1; i > 0; i-- {
		// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
		for j := 0; j < i; j++ {
			if nums[j] > nums[j+1] {
				// 交换 nums[j] 与 nums[j + 1]
				nums[j], nums[j+1] = nums[j+1], nums[j]
			}
		}
	}
}

鉴于冒泡排序的特性,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位flag 来监测这种情况,一旦出现就立即返回。

经过优化,冒泡排序的最差时间复杂度和平均时间复杂度仍为𝑂(𝑛**2);但当输入数组完全有序时,可达到最佳时间复杂度𝑂(𝑛)。

func bubbleSortWithFlag(nums []int) {
	// 外循环:未排序区间为 [0, i]
	for i := len(nums) - 1; i > 0; i-- {
		flag := false // 初始化标志位
		// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
		for j := 0; j < i; j++ {
			if nums[j] > nums[j+1] {
				// 交换 nums[j] 与 nums[j + 1]
				nums[j], nums[j+1] = nums[j+1], nums[j]
				flag = true // 记录交换元素
			}
		}
		if flag == false { // 此轮“冒泡”未交换任何元素,直接跳出
			break
		}
	}
}

算法分析:

  1. 最佳时间复杂度: O(n)。最好的情况下(初始时序列已经是升序排列),只需经过 1 趟排序,总共经过 n 次元素之间的比较,并且不移动元素,算法就可以结束排序。因此,冒泡排序算法的最佳时间复杂度为 O(n)。

  2. 最坏时间复杂度: O(n²)。最差的情况下(初始时序列已经是降序排列,或者最小值元素处在序列的最后),则需要进行 n 趟排序,总共进行:(𝑛−1)𝑛/2次元素之间的比较,因此,冒泡排序算法的最坏时间复杂度为 O(n²)。

  3. 空间复杂度: O(1)。冒泡排序为原地排序算法,只用到指针变量 i、j 以及标志位 flag 等常数项的变量。

  4. 冒泡排序适用情况: 冒泡排序方法在排序过程中需要移动较多次数的元素,并且排序时间效率比较低。因此,冒泡排序方法比较适合于排序序列的数据量较小的情况,尤其是当序列的初始状态为基本有序的情况。

  5. 排序稳定性: 由于元素交换是在相邻元素之间进行的,不会改变相等元素的相对顺序,因此,冒泡排序法是一种稳定排序算法。

插入排序

插入排序(Insertion Sort)基本思想:将数组分为两个区间:左侧为有序区间,右侧为无序区间。每趟从无序区间取出一个元素,然后将其插入到有序区间的适当位置。插入排序在每次插入一个元素时,该元素会在有序区间找到合适的位置,因此每次插入后,有序区间都会保持有序。

插入排序算法步骤:

  1. 初始状态下,数组的第1个元素已完成排序。
  2. 选取数组的第2个元素作为tmp,将其插入到正确位置后,数组的前2个元素已排序。
  3. 选取第3个元素作为tmp ,将其插入到正确位置后,数组的前3个元素已排序。
  4. 以此类推,在最后一轮中,选取最后一个元素作为tmp,将其插入到正确位置后,所有元素均已排序。

在这里插入图片描述
给出如下实现:

func insertionSort(nums []int) {
	// 外循环:已排序区间为 [0, i-1]
	for i := 1; i < len(nums); i++ {
		// 用于交换的临时数据
		tmp := nums[i]
		j := i - 1
		// 内循环:将 tmp 插入到已排序区间 [0, i-1] 中的正确位置
		for j >= 0 && nums[j] > tmp {
			nums[j+1] = nums[j] // 将 nums[j] 向右移动一位
			j--
		}
		nums[j+1] = tmp // 将 tmp 赋值到正确位置
	}
}

插入排序算法分析:

  1. 最佳时间复杂度: O(n)。最好的情况下(初始时区间已经是升序排列),每个元素只进行一次元素之间的比较,因而总的比较次数最少,为:𝑂(𝑛),并不需要移动元素(记录),这是最好的情况。

  2. 最差时间复杂度: O(n²)。最差的情况下(初始时区间已经是降序排列),每个元素 nums[i] 都要进行 i−1 次元素之间的比较,元素之间总的比较次数达到最大值,为:(𝑛−1)𝑛/2

  3. 平均时间复杂度: O(n²)。如果区间的初始情况是随机的,即参加排序的区间中元素可能出现的各种排列的概率相同,则可取上述最小值和最大值的平均值作为插入排序时所进行的元素之间的比较次数,约为:n²/4,由此得知,插入排序算法的平均时间复杂度为 O(n²)。

  4. 空间复杂度: O(1)。插入排序算法为原地排序算法,只用到指针变量 i、j 以及表示无序区间中第 1 个元素的变量(tmp)等常数项的变量。

  5. 排序稳定性: 在插入操作过程中,每次都将元素插入到相等元素的右侧,并不会改变相等元素的相对顺序。因此,插入排序方法是一种稳定排序算法。

我们注意到,许多编程语言的内置排序函数采用了插入排序,大致思路为:对于长数组,采用基于分治策略的排序算法,例如快速排序等;对于短数组,直接使用插入排序。

我们在go的pdqsortOrdered算法中也可以看到插入排序的影子:

// pdqsortOrdered sorts data[a:b].
// The algorithm based on pattern-defeating quicksort(pdqsort), but without the optimizations from BlockQuicksort.
// pdqsort paper: https://arxiv.org/pdf/2106.05123.pdf
// C++ implementation: https://github.com/orlp/pdqsort
// Rust implementation: https://docs.rs/pdqsort/latest/pdqsort/
// limit is the number of allowed bad (very unbalanced) pivots before falling back to heapsort.
func pdqsortOrdered[E cmp.Ordered](data []E, a, b, limit int) {
	const maxInsertion = 12
	
	var (
		wasBalanced    = true // whether the last partitioning was reasonably balanced
		wasPartitioned = true // whether the slice was already partitioned
	)
	
	for {
		length := b - a
	
		if length <= maxInsertion {
			insertionSortOrdered(data, a, b)
			return
		}
		// 其他复杂的算法实现
		...
	}
}	

通常情况下,log(n) 在算法分析中是以 2 为底数的。因为在大多数情况下,log(n) 是指二进制对数,即表示二进制位数的对数。特别是在分析分治算法(如快速排序、归并排序等)时,我们通常会遇到 log₂(n)。

快速排序这类𝑂(𝑛log𝑛)的算法属于基于分治策略的排序算法,往往包含更多单元计算操作。而在数据量较小时,𝑛2和𝑛*log𝑛的数值比较接近,复杂度不占主导地位,每轮中的单元操作数量起到决定性作用。

在这里插入图片描述
虽然冒泡排序、选择排序和插入排序的时间复杂度都为𝑂(n²),但在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因。

  1. 冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及3个单元操作;插入排序基于元素赋值实现,仅需1个单元操作。 因此,冒泡排序的计算开销通常比插入排序更高。
  2. 选择排序在任何情况下的时间复杂度都为𝑂(n²)。如果给定一组部分有序的数据,插入排序通常比选择排序效率更高。而且选择排序不稳定,无法应用于多级排序。

“多级排序”指的是对数据按照多个关键字依次进行排序的过程,也称为多关键字排序或联合排序。这是一个常见的数据排序需求,例如在数据库、多字段记录或表格数据处理中。具体来说,多级排序的核心是:先按照一个关键字排序,然后在第一关键字相同的情况下,依据第二关键字进行排序,以此类推。

快速排序

快速排序(Quick Sort)基本思想:采用经典的分治策略,选择数组中某个元素作为基准数,通过一趟排序将数组分为独立的两个子数组,一个子数组中所有元素值都比基准数小,另一个子数组中所有元素值都比基准数大。 然后再按照同样的方式递归的对两个子数组分别进行快速排序,以达到整个数组有序。

快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。这里以当前数组第 1 个元素作为基准数

  1. 使指针 i 指向数组开始位置,指针 j 指向数组末尾位置。
  2. 从右向左移动指针 j,找到第 1 个小于基准值的元素。
  3. 从左向右移动指针 i,找到第 1 个大于基准数的元素。
  4. 交换指针 i 和指针 j 指向的两个元素位置。
  5. 重复第 2∼4步,直到指针 i 和指针 j 相遇时停止,最后将基准数放到两个子数组交界的位置上。 (交换基准数和i的下标

在这里插入图片描述
在这里插入图片描述

哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素≤基准数≤右子数组任意元素”。对划分好的左右子数组分别进行递归排序。

  1. 按照基准数的位置将数组拆分为左右两个子数组。
  2. 对每个子数组分别重复「哨兵划分」和「递归分解」,直到各个子数组只有 1 个元素,排序结束。

在经过一次「哨兵划分」过程之后,数组就被划分为左子数组、基准数、右子树组三个独立部分。接下来只要对划分好的左右子数组分别进行递归排序即可完成排序。快速排序算法的整个步骤如下:

在这里插入图片描述
下面给出划分的代码:

/* 哨兵划分 */
func partition(nums []int, left, right int) int {
	// 以 nums[left] 为基准数
	i, j := left, right
	for i < j {
		for i < j && nums[j] >= nums[left] {
			j-- // 从右向左找首个小于基准数的元素
		}
		for i < j && nums[i] <= nums[left] {
			i++ // 从左向右找首个大于基准数的元素
		}
		// 元素交换
		nums[i], nums[j] = nums[j], nums[i]
	}
	// 将基准数交换至两子数组的分界线
	nums[i], nums[left] = nums[left], nums[i]
	return i // 返回基准数的索引
}

然后给出整体快排的代码实现:

/* 快速排序 */
func quickSort(nums []int, left, right int) {
	// 子数组长度为 1 时终止递归
	if left >= right {
		return
	}
	// 哨兵划分
	pivot := partition(nums, left, right)
	// 递归左子数组、右子数组
	quickSort(nums, left, pivot-1)
	quickSort(nums, pivot+1, right)
}

快速排序算法分析:

  1. 快速排序算法的时间复杂度主要跟基准数的选择有关。本文中是将当前数组中第 1 个元素作为基准值。在这种选择下,如果参加排序的元素初始时已经有序的情况下,快速排序方法花费的时间最长。也就是会得到最坏时间复杂度。在这种情况下,第 1 趟排序经过 (n-1) 次比较以后,将第 1 个元素仍然确定在原来的位置上,并得到 1 个长度为 n-1 的子数组。 第 2 趟排序经过 n-2 次比较以后,将第 2 个元素确定在它原来的位置上,又得到 1 个长度为 n-2 的子数组。 最终,总的比较次数为: (n-1) + (n-2) + … + 1 = n * (n-1) / 2 因此,这种情况下的时间复杂度为 O(n^2),也是最坏时间复杂度。
  2. 我们可以改进一下基准数的选择。如果每次选中的基准数恰好能将当前数组平分为两份,也就是选取数组的中位数。在这种选择下,每次操作都会将数组从 (n) 个元素变为 (n/2) 个元素。此时的时间复杂度公式为:T(n) = 2 × T(n/2) + Θ(n) (2 表示划分后有两个子数组,T(n/2) 是对每个子数组递归进行快速排序的时间,Θ(n) 是当前这一层划分的开销,即将数组划分为两部分所需的时间)可以得出:T(n) = O(n × log n)这就是快速排序的最佳时间复杂度。

时间复杂度的三种表示法

  1. O(大 O 表示法):

    • 表示算法的上界,即算法在最坏情况下的运行时间。
    • 意义:算法在最坏情况下不会比这个复杂度更糟糕。
    • 示例:快速排序的最坏时间复杂度是 O(n²)。
  2. Ω(大 Omega 表示法):

    • 表示算法的下界,即算法在最优情况下至少需要这么多的时间。
    • 意义:算法的运行时间不可能比这个更快。
    • 示例:快速排序的最好时间复杂度是 Ω(n * log n)。
  3. Θ(大 Theta 表示法):

    • 表示算法的紧确界,即算法的运行时间在所有情况下的复杂度是这个值的一个常数倍。
    • 意义:Θ(f(n)) 意味着算法的运行时间既是 O(f(n)) 又是 Ω(f(n)),可以精确地描述复杂度。
    • 示例:快速排序的平均时间复杂度是 Θ(n * log n)。

为什么快速排序的时间复杂度用 Θ(n * log n) 而不是 O(n * log n)?

  1. 上下界的意义

    • 快速排序的最佳情况和平均情况:
      • 每次递归都把数组分为两半,每一层的操作需要 n 次比较,总共有 log n 层。
      • 在这种情况下,无论怎么操作,时间复杂度的范围都非常精确。
      • 因此,我们可以说它是 Θ(n * log n),因为时间复杂度的上下界一致。
    • 快速排序的最坏情况:
      • 如果每次基准数都取得不好(例如总是选取数组的最大或最小值),数组会退化成不平衡划分,时间复杂度是 O(n²)。在这种情况下,使用 O(n²) 更合适。
  2. 用 O(n * log n) 是不是错的?

    • 严格来说,O(n * log n) 也是正确的,因为它确实是一个上界。
    • 但 O(n * log n) 的含义是不超过 n * log n,而它无法描述出快速排序在平均和最佳情况下的真实复杂度。
    • Θ(n * log n) 更准确,因为它说明了快速排序在这些情况下的时间复杂度既是 O(n * log n) 的上界,又是 Ω(n * log n) 的下界。
  3. 总结:什么时候用 Θ,什么时候用 O?

    • 如果我们只想描述算法的上界(比如最坏情况分析),用 O。
    • 如果我们已经精确知道了算法的上下界一致(比如平均和最好情况),用 Θ 会更准确。
  1. 在平均情况下,我们可以从当前数组中随机选择一个元素作为基准数。这样,每一次选择的基准数可以看作是等概率随机的。在这种策略下,快速排序的时间复杂度可以通过概率分析计算,其期望时间复杂度为 O(n * log n),也就是平均时间复杂度。
  2. 空间复杂度为𝑂(𝑛)、原地排序:在输入数组完全倒序的情况下,达到最差递归深度𝑛,使用𝑂(𝑛)栈空间。排序操作是在原数组上进行的,未借助额外数组。
  3. 非稳定排序:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。

基准数优化

上文提到,我们可以随机选取一个元素作为基准数。

package main

import (
	"fmt"
	"math/rand/v2"
)

/* 随机选取元素作为基准数 */
func random(left, right int) int {
	if left == right {
		return left
	}
	return rand.IntN(right-left+1) + left
}

/* 哨兵划分 */
func partition(nums []int, left, right int) int {
	med := random(left, right)
	// 将中位数交换至数组最左端
	nums[left], nums[med] = nums[med], nums[left]
	// 以nums[left]为基准数
	i, j := left, right
	for i < j {
		for i < j && nums[j] >= nums[left] {
			j-- // 从右向左找首个小于基准数的元素
		}
		for i < j && nums[i] <= nums[left] {
			i++ // 从左向右找首个大于基准数的元素
		}
		// 元素交换
		nums[i], nums[j] = nums[j], nums[i]
	}
	// 将基准数交换至两子数组的分界线
	nums[i], nums[left] = nums[left], nums[i]
	return i // 返回基准数的索引
}

/* 快速排序 */
func quickSort(nums []int, left, right int) {
	// 子数组长度为 1 时终止递归
	if left >= right {
		return
	}
	// 哨兵划分
	pivot := partition(nums, left, right)
	// 递归左子数组、右子数组
	quickSort(nums, left, pivot-1)
	quickSort(nums, pivot+1, right)
}

然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。

为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选元素的中位数作为基准数。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至𝑂(𝑛2)的概率大大降低。

func media(nums []int, left, mid, right int) int {
	l, m, r := nums[left], nums[mid], nums[right]
	if (l <= m && m <= r) || (r <= m && m <= l) {
		return mid //m在l和r之间
	}
	if (m <= l && l <= r) || (r <= l && l <= m) {
		return left //l在m和r之间
	}
	return right
}

/* 哨兵划分 */
func partition(nums []int, left, right int) int {
	med := media(nums,left, (left+right)/2, right)
	// 将中位数交换至数组最左端
	nums[left], nums[med] = nums[med], nums[left]
	// 以nums[left]为基准数
	i, j := left, right
	for i < j {
		for i < j && nums[j] >= nums[left] {
			j-- // 从右向左找首个小于基准数的元素
		}
		for i < j && nums[i] <= nums[left] {
			i++ // 从左向右找首个大于基准数的元素
		}
		// 元素交换
		nums[i], nums[j] = nums[j], nums[i]
	}
	// 将基准数交换至两子数组的分界线
	nums[i], nums[left] = nums[left], nums[i]
	return i // 返回基准数的索引
}

为什么要交换基准值呢?nums[left], nums[med] = nums[med], nums[left],我们的优化是为了均匀的取基准数这个值。如果不交换到第一个位置也可以,就需要修改partition后面的处理步骤了。

举例,nums=[1,2,3,4,5,6],不优化则每次右边都会以最小值作为基准值,重心倾斜,直到不满足递归条件退出:

[1 2 3 4 5 6] 0 5
[1 2 3 4 5 6] 1 5
[1 2 3 4 5 6] 2 5
[1 2 3 4 5 6] 3 5
[1 2 3 4 5 6] 4 5
[1 2 3 4 5 6]

优化后,左右两边平均,递归可以更快结束:

[1 2 3 4 5 6] 0 5
[1 2 3 4 5 6] 0 1
[1 2 3 4 5 6] 3 5
[1 2 3 4 5 6]

尾递归优化

在某些输入下,快速排序可能占用空间较多。以完全有序的输入数组为例,设递归中的子数组长度为𝑚,每轮哨兵划分操作都将产生长度为0的左子数组和长度为𝑚−1的右子数组,这意味着每一层递归调用减少的问题规模非常小(只减少一个元素),递归树的高度会达到𝑛−1,此时需要占用𝑂(𝑛)大小的栈空间。

每次只减少一个问题规模:

[1 2 3 4 5 6] 0 5
[1 2 3 4 5 6] 1 5
[1 2 3 4 5 6] 2 5
[1 2 3 4 5 6] 3 5
[1 2 3 4 5 6] 4 5
[1 2 3 4 5 6]

为了防止栈空间的累积,可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。由于较短子数组的长度不会超过𝑛/2,因此这种方法能确保递归深度不超过log𝑛,从而将最差空间复杂度优化至𝑂(log𝑛)。

/*快速排序(尾递归优化)*/
func quickSort2(nums []int, left, right int) {
	// 子数组长度为 1 时终止
	for left < right {
		// 哨兵划分操作
		pivot := partition(nums, left, right)
		// 对两个子数组中较短的那个执行快速排序
		if pivot-left < right-pivot {
			quickSort(nums, left, pivot-1) // 递归排序左子数组
			left = pivot + 1               // 剩余未排序区间为 [pivot + 1, right]
		} else {
			quickSort(nums, pivot+1, right) // 递归排序右子数组
			right = pivot - 1               // 剩余未排序区间为 [left, pivot- 1]}
		}
	}
}

原理:尾递归优化通常是通过消除一个递归调用来实现的。具体来说,我们可以在递归中选择较小的子数组进行递归调用,然后通过循环处理较大的子数组,从而避免栈溢出或深度过大的问题。

我们来做一个对照:

/* 哨兵划分 */
func partition(nums []int, left, right int) int {
	fmt.Println(nums, left, right)
	// 以nums[left]为基准数
	i, j := left, right
	for i < j {
		for i < j && nums[j] >= nums[left] {
			j-- // 从右向左找首个小于基准数的元素
		}
		for i < j && nums[i] <= nums[left] {
			i++ // 从左向右找首个大于基准数的元素
		}
		// 元素交换
		nums[i], nums[j] = nums[j], nums[i]
	}
	// 将基准数交换至两子数组的分界线
	nums[i], nums[left] = nums[left], nums[i]
	return i // 返回基准数的索引
}

func main() {
	nums1 := []int{4, 2, 1, 5, 6, 3, 2}
	quickSort2(nums1, 0, len(nums1)-1)
	fmt.Println("-------------------------------")
	nums2 := []int{4, 2, 1, 5, 6, 3, 2}
	quickSort(nums2, 0, len(nums2)-1)
}

// 尾递归优化
[4 2 1 5 6 3 2] 0 6
[3 2 1 2 4 6 5] 5 6
[3 2 1 2 4 5 6] 0 3
[2 2 1 3 4 5 6] 0 2
[1 2 2 3 4 5 6] 0 1
-------------------------------
// 普通
[4 2 1 5 6 3 2] 0 6
[3 2 1 2 4 6 5] 0 3
[2 2 1 3 4 6 5] 0 2
[1 2 2 3 4 6 5] 0 1
[1 2 2 3 4 6 5] 5 6

可以看到,两种方法都将数组分组为:[3 2 1 2 [4] 6 5],接下来的处理方式就发生了变化,优化后的快排选择先处理小数组,最终消灭一个递归。而普通快排则是分别处理左右两个子数组,在遍历左数组的时候,右数组的递归堆栈一直未释放(右子数组 [6 5] 仍然保持递归调用,直到最后才被处理)。

归并排序

归并排序(Merge Sort)基本思想:采用经典的分治策略,先递归地将当前数组平均分成两半,然后将有序数组两两合并,最终合并成一个有序数组。

假设数组的元素个数为 n,则归并排序的算法步骤如下:

  1. 分解过程: 先递归地将当前数组平均分成两半,直到子数组长度为 1

    1. 找到数组中心位置 mid,从中心位置将数组分成左右两个子数组 left numsright nums
    2. 对左右两个子数组 left numsright nums 分别进行递归分解。
    3. 最终将数组分解为 n 个长度均为 1 的有序子数组。
  2. 归并过程: 从长度为 1 的有序子数组开始,依次将有序数组两两合并,直到合并成一个长度为 n 的有序数组。

    1. 使用数组变量 nums 存放合并后的有序数组。
    2. 使用两个指针 left iright i 分别指向两个有序子数组 left numsright nums 的开始位置。
    3. 比较两个指针指向的元素,将两个有序子数组中较小元素依次存入到结果数组 nums 中,并将指针移动到下一位置。
    4. 重复步骤 3,直到某一指针到达子数组末尾。
    5. 将另一个子数组中的剩余元素存入到结果数组 nums 中。
    6. 返回合并后的有序数组 nums

我们以 [0, 5, 7, 3, 1, 6, 8, 4] 为例,演示一下归并排序算法的整个步骤。

在这里插入图片描述

划分阶段”从顶至底递归地将数组从中点切分为两个子数组。

  1. 计算数组中点mid, 递归划分左子数组(区间[left, mid] )和右子数组(区间[mid + 1, right])
  2. 递归执行步骤1,直至子数组区间长度为1时终止。
  3. “合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为1的子数组开始合并,合并阶段中的每个子数组都是有序的。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
归并排序的实现如以下代码所示:

/* 合并左子数组和右子数组 */
func merge(nums []int, left, mid, right int) {
	// 左子数组区间为 [left, mid], 右子数组区间为 [mid+1, right]
	// 创建一个临时数组 tmp ,用于存放合并后的结果
	tmp := make([]int, right-left+1)
	// 初始化左子数组和右子数组的起始索引
	i, j, k := left, mid+1, 0
	// 当左右子数组都还有元素时,进行比较并将较小的元素复制到临时数组中
	for i <= mid && j <= right {
		if nums[i] <= nums[j] {
			tmp[k] = nums[i]
			i++
		} else {
			tmp[k] = nums[j]
			j++
		}
		k++
	}
	// 将左子数组和右子数组的剩余元素复制到临时数组中
	for i <= mid {
		tmp[k] = nums[i]
		i++
		k++
	}
	for j <= right {
		tmp[k] = nums[j]
		j++
		k++
	}
	// 将临时数组 tmp 中的元素复制回原数组 nums 的对应区间
	for k = 0; k < len(tmp); k++ {
		nums[left+k] = tmp[k]
	}
}

由于golang提供的便捷方法,可以用copy(nums[left:right+1], tmp)代替:

for k = 0; k < len(tmp); k++ {
	nums[left+k] = tmp[k]
}

归并过程:

func mergeSort(nums []int, left, right int) {
	// 终止条件
	if left >= right {
		return
	}
	// 划分阶段
	mid := (left + right) / 2
	mergeSort(nums, left, mid)
	mergeSort(nums, mid+1, right)
	// 合并阶段
	merge(nums, left, mid, right)
}

归并排序算法分析:

  1. 时间复杂度O(n × log n)。归并排序算法的时间复杂度等于归并趟数与每一趟归并的时间复杂度乘积。 子算法 merge(left_nums, right_nums) 的时间复杂度是 O(n),因此,归并排序算法总的时间复杂度为 O(n × log n)

  2. 空间复杂度O(n)。归并排序方法需要用到与参与排序的数组同样大小的辅助空间。因此,算法的空间复杂度为 O(n)

  3. 排序稳定性: 因为在两个有序子数组的归并过程中,如果两个有序数组中出现相等元素,merge(left_nums, right_nums) 算法能够使前一个数组中那个相等元素先被复制,从而确保这两个元素的相对顺序不发生改变。因此,归并排序算法是一种 稳定排序算法

堆排序

堆排序(heapsort)是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序。

堆排序(Heap sort)基本思想:借用「堆结构」所设计的排序算法。将数组转化为大顶堆,重复从大顶堆中取出数值最大的节点,并让剩余的结构继续维持大顶堆性质。

堆排序算法步骤:

  1. 构建初始大顶堆

    1. 定义一个数组实现的堆结构,将原始数组的元素依次存入堆结构的数组中(初始顺序不变)。
    2. 从数组的中间位置开始,从右至左,依次通过「下移调整」将数组转换为一个大顶堆。
  2. 交换元素,调整堆

    1. 交换堆顶元素(第 1 个元素)与末尾(最后 1 个元素)的位置,交换完成后,堆的长度减 1
    2. 交换元素之后,由于堆顶元素发生了改变,需要从根节点开始,对当前堆进行「下移调整」,使其保持堆的特性。
  3. 重复交换和调整堆

    1. 重复第 2 步,直到堆的大小为 1 时,此时大顶堆的数组已经完全有序。

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
func shiftDown(nums []int, n, i int) {
	for {
		// 判断节点 i, l, r 中值最大的节点,记为 ma
		l := 2*i + 1
		r := 2*i + 2
		ma := i
		if l < n && nums[l] > nums[ma] {
			ma = l
		}
		if r < n && nums[r] > nums[ma] {
			ma = r
		}
		//若节点i最大或索引l,r越界,则无须继续堆化,跳出
		if ma == i {
			break
		}
		//交换两节点
		nums[i], nums[ma] = nums[ma], nums[i]
		//循环向下堆化
		i = ma
	}
}

/*堆排序*/
func heapSort(nums []int) {
	//建堆操作:堆化除叶节点以外的其他所有节点
	for i := len(nums)/2 - 1; i >= 0; i-- {
		shiftDown(nums, len(nums), i)
	}
	//从堆中提取最大元素,循环n-1轮
	for i := len(nums) - 1; i > 0; i-- {
		//交换根节点与最右叶节点(交换首元素与尾元素)
		nums[0], nums[i] = nums[i], nums[0]
		//以根节点为起点,从顶至底进行堆化
		shiftDown(nums, i, 0)
	}
}

堆排序算法分析:

  1. 时间复杂度O(n × log n)。 堆积排序的时间主要花费在两个方面:「建立初始堆」和「下移调整」。

    1. 建立初始堆:从数组的中间位置开始,从右至左进行「下移调整」,每次下移调整的时间复杂度是 O(log n),由于有 n/2 个元素需要调整,因此总体时间复杂度为 O(n)
    2. 下移调整:每次交换堆顶元素与末尾元素后,都需要进行一次「下移调整」,每次调整的时间复杂度是 O(log n),总共进行 n 次交换,因此总体时间复杂度为 O(n log n)
  2. 空间复杂度为𝑂(1)、原地排序:元素交换和堆化操作都是在原数组上进行的。

  3. 非稳定排序:在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。

桶排序

前述几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越𝑂(𝑛log𝑛)。 而“非比较排序算法”,它们的时间复杂度可以达到线性阶。

桶排序(Bucket Sort)基本思想:桶排序(bucketsort)是分治策略的一个典型应用,将待排序数组中的元素分散到若干个「桶」中,然后对每个桶中的元素再进行单独排序。最终按照桶的顺序将所有数据合并。

桶排序算法步骤:

  1. 确定桶的数量: 根据待排序数组的值域范围,将数组划分为 k 个桶,每个桶可以看做是一个范围区间。

  2. 分配元素: 遍历待排序数组元素,将每个元素根据大小分配到对应的桶中。

  3. 对每个桶进行排序: 对每个非空桶内的元素单独排序(使用插入排序、归并排序、快排排序等算法)。

  4. 合并桶内元素: 将排好序的各个桶中的元素按照区间顺序依次合并起来,形成一个完整的有序数组。

以* [39, 49, 8, 13, 22, 15, 10, 30, 5, 44] *为例,

在这里插入图片描述
给出如下代码:

func bucketSort(nums []int, bucketSize int) {
	if len(nums) <= 1 {
		return
	}

	// 1. 找到数组中的最大值和最小值
	minVal, maxVal := nums[0], nums[0]
	for _, num := range nums {
		if num < minVal {
			minVal = num
		}
		if num > maxVal {
			maxVal = num
		}
	}

	// 2. 计算桶的数量
	bucketCount := (maxVal-minVal)/bucketSize + 1
	buckets := make([][]int, bucketCount)

	// 3. 将元素分配到桶中
	for _, num := range nums {
		// 映射到桶的索引
		index := (num - minVal) / bucketSize
		buckets[index] = append(buckets[index], num)
	}

	// 4. 对每个桶进行排序
	for i := 0; i < bucketCount; i++ {
		sort.Ints(buckets[i])
	}

	// 5. 将桶中的数据合并回原数组
	index := 0
	for _, bucket := range buckets {
		for _, num := range bucket {
			nums[index] = num
			index++
		}
	}
}

为什么采用index := (num - minVal) / bucketSize方式映射索引:

  • minVal:找到数组中的最小值。通过减去最小值,确保所有的数据都从 0 或者 1 开始,避免负数的问题。这样可以使桶的分配变得更为标准化。
  • bucketSize:每个桶的大小,决定了每个桶能容纳多少数值。
  • / bucketSize:通过将数据减去最小值后再除以 bucketSize,可以把数据映射到适当的桶索引中。每个桶的索引对应于某一范围的数值。

假设最小值 minVal = 10,桶的大小为 bucketSize = 10。我们有一些数据 nums = [12, 25, 34, 47, 50, 55]

  • 对于 12(12 - 10) / 10 = 0,所以 12 放到桶 0
  • 对于 25(25 - 10) / 10 = 1,所以 25 放到桶 1
  • 对于 34(34 - 10) / 10 = 2,所以 34 放到桶 2
  • 对于 47(47 - 10) / 10 = 3,所以 47 放到桶 3
  • 对于 50(50 - 10) / 10 = 4,所以 50 放到桶 4
  • 对于 55(55 - 10) / 10 = 4,所以 55 放到桶 4

这种映射方式确保了每个元素都被合理地分配到一个桶中,并且桶的大小由 bucketSize 控制。

虽然使用 % bucketSize 也会将数值映射到范围内的某个桶中,但是这种方式有两个问题:

  1. 问题 1:没有考虑数据范围如果你的数据范围非常大,或者桶的数量和大小没有合理设置,那么通过 % bucketSize 会导致一些桶的内容过多,而其他桶则为空。对于桶排序来说,桶的数量应该与数据的范围密切相关,而不是简单的取模。

  2. 问题 2:分布不均匀假设你的数据从 minValmaxVal 范围内分布,但如果使用 % bucketSize,你只能根据 bucketSize 决定桶的数量,这不一定与数据的分布一致。特别是在数据范围非常大时,桶的分布可能会不均匀,导致一些桶中的数据过多,影响性能。

桶排序算法分析:

  1. 时间复杂度O(n)。当输入元素个数为 n,桶的个数是 m 时,每个桶里的数据数量是 k = n / m
    每个桶内排序的时间复杂度为 O(k × log k)。 因此,总体时间复杂度为:m × O(k × log k) = m × O((n / m) × log(n / m)) = O(n × log(n / m)) 。当桶的个数 m 接近于数据个数 n 时,log(n / m) 就是一个较小的常数,因此桶排序的时间复杂度接近于 O(n)。因此,桶排序适用于处理体量很大的数据。例如,输入数据包含100万个元素,由于间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成1000个桶,然后分别对每个桶进行排序,最后将结果合并。
  2. 空间复杂度O(n + m)。 由于桶排序使用了辅助空间来存放桶,并且每个桶内需要存储部分数据,因此桶排序的空间复杂度是 O(n + m)
  3. 排序稳定性:桶排序的稳定性取决于桶内使用的排序算法。如果桶内使用稳定的排序算法(比如插入排序算法),并且在合并桶的过程中保持相等元素的相对顺序不变,则桶排序是一种 稳定排序算法。反之,则桶排序是一种 不稳定排序算法。

如何实现平均分配

桶排序的时间复杂度理论上可以达到𝑂(𝑛),关键在于将元素均匀分配到各个桶中,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到10个桶中,但商品价格分布不均,低于100元的非常多,高于1000元的非常少。若将价格区间平均划分为10个,各个桶中的商品数量差距会非常大。

为实现平均分配,我们可以先设定一条大致的分界线,将数据粗略地分到3个桶中。分配完毕后,再将商品较多的桶继续划分为3个桶,直至所有桶中的元素数量大致相等。

这种方法本质上是创建一棵递归树,目标是让叶节点的值尽可能平均。当然,不一定要每轮将数据划分为3个桶,具体划分方式可根据数据特点灵活选择。

在这里插入图片描述

如果我们提前知道商品价格的概率分布,则可以根据数据概率分布设置每个桶的价格分界线。值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。

我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。

在这里插入图片描述

计数排序

计数排序(countingsort)通过统计元素数量来实现排序,通常应用于整数数组。

计数排序(Counting Sort)基本思想:通过统计数组中每个元素在数组中出现的次数,根据这些统计信息将数组元素有序的放置到正确位置,从而达到排序的目的。

给定一个长度为𝑛的数组nums ,其中的元素都是“非负整数”,计数排序的整体流程如下:

  1. 遍历数组,找出最大值

    • 遍历数组 nums,找出其中的最大值,记为 m
    • 创建一个长度为 m + 1 的辅助数组 counter,用于统计数字出现的次数。
  2. 统计数字出现次数

    • 遍历数组 nums(假设当前数字为 num),对每个数字,执行以下操作:
      • counter[num] 的值加 1,表示数字 num 出现了一次。
  3. 排序填入原数组

    • 遍历 counter 数组,由于 counter 的索引天然有序,相当于所有数字已经排序。
    • 根据 counter 中每个数字的出现次数,将对应数字按顺序填入原数组 nums 中。

在这里插入图片描述

/* 计数排序 */
func countingSort(nums []int) {
	// 1. 统计数组最大元素 m
	m := 0
	for _, num := range nums {
		if num > m {
			m = num
		}
	}
	// 2. 统计各数字的出现次数
	// counter[num] 代表 num 的出现次数
	counter := make([]int, m+1)
	for _, num := range nums {
		counter[num]++
	}
	// 3. 遍历 counter ,将各元素填入原数组 nums
	for i, num := 0, 0; num < m+1; num++ {
		for j := 0; j < counter[num]; j++ {
			nums[i] = num
			i++
		}
	}
}

计数排序算法分析:

  1. 时间复杂度O(n + k)

    • 其中,n 是数组的元素个数,k 是待排序数组的值域大小(最大值与最小值的差再加 1)。
    • 遍历数组统计出现次数的操作是 O(n),而填充结果数组的操作是 O(k)
  2. 空间复杂度O(k)

    • 用于计数的辅助数组 counts 的长度等于值域大小 k,因此计数排序对于数据范围较大的数组需要大量内存。
  3. 适用情况

    • 计数排序适合用于排序整数数组。
    • 不适用于需要按字母顺序或复杂比较规则(如人名排序)的场景。
  4. 排序稳定性

    • 计数排序是一种稳定排序算法
    • 因为在填充结果数组时,如果存在相等的元素,会按照它们在原数组中的相对顺序填充。

基数排序

基数排序(radixsort)的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。

基数排序(Radix Sort)基本思想:将整数按位数切割成不同的数字,然后从低位开始,依次到高位,逐位进行排序,从而达到排序的目的。

基数排序算法步骤:基数排序算法可以采用「最低位优先法(Least Significant Digit First)」或者「最高位优先法(Most Significant Digit first)」。最常用的是「最低位优先法」。

  1. 确定排序的最大位数: 遍历数组元素,获取数组最大值元素,并取得对应位数。

  2. 逐位排序:
    从最低位(个位)开始,到最高位为止,逐位对每一位进行排序:

    • 定义桶数组:
      定义一个长度为 10 的桶数组 buckets,每个桶分别代表数字 0 ~ 9。

    • 分配到桶中:
      按照每个元素当前位上的数字,将元素放入对应数字的桶中。

    • 重组数组:
      清空原始数组,然后按照桶的顺序依次取出对应元素,重新加入到原始数组中。

  3. 重复上述步骤,直到完成最高位的排序,最终数组即为有序数组。

我们以 [692,924,969,503,871,704,542,436] 为例,演示一下基数排序算法的整个步骤。

在这里插入图片描述

func radixSort(nums []int) {
	// 获取最大的位数
	size := slices.Max(nums)
	// 从低到高遍历
	for i := 0; i < size; i++ {
		// 定义长度为 10 的桶数组 buckets,每个桶分别代表 0 ~ 9 中的 1 个数字。
		buckets := make([][]int, 10)
		// 遍历数组元素,按照每个元素当前位上的数字,将元素放入对应数字的桶中。
		for _, num := range nums {
			buckets[num/int(math.Pow(10, float64(i)))%10] = append(buckets[num/int(math.Pow(10, float64(i)))%10], num)
		}
		
		// 按照桶的顺序复原数组
		j := 0
		for _, bucket := range buckets {
			for _, num := range bucket {
				nums[j] = num
				j++
			}
		}
	}
}

基数排序算法分析:

  1. 相较于计数排序,基数排序适用于数值范围较大的情况,但前提是数据必须可以表示为固定位数的格式,且位数不能过大。
  2. 时间复杂度: O(n * k)。其中 n 是待排序元素的个数,k 是数字位数。 k 的大小取决于数字位的选择(十进制位、二进制位)和待排序元素所属数据类型全集的大小。
  3. 空间复杂度: O(n + k)
  4. 排序稳定性: 基数排序采用的桶排序是稳定的。基数排序是一种稳定排序算法。

总结

下图对比了主流排序算法的效率、稳定性、就地性和自适应性等:

在这里插入图片描述
自适应性:自适应排序的时间复杂度会受输入数据的影响,即最佳时间复杂度、最差时间复杂度、平均时间复杂度并不完全相等。

  • 冒泡排序:通过交换相邻元素来实现排序。通过添加一个标志位来实现提前返回,我们可以将冒泡排序的最佳时间复杂度优化到 ( O(n) )。

  • 插入排序:每轮将未排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 ( O(n^2) ),但由于单元操作相对较少,因此在小数据量的排序任务中非常受欢迎。

  • 快速排序:基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,导致时间复杂度劣化至 ( O(n^2) )。引入中位数基准数或随机基准数可以降低这种劣化的概率。尾递归方法可以有效地减少递归深度,将空间复杂度优化到 ( O(\log n) )。

  • 归并排序:包括划分和合并两个阶段,典型地体现了分治策略。在归并排序中,排序数组需要创建辅助数组,空间复杂度为 ( O(n) );然而排序链表的空间复杂度可以优化至 ( O(1) )。

  • 桶排序:包含三个步骤:数据分桶、桶内排序和合并结果。它同样体现了分治策略,适用于数据体量很大的情况。桶排序的关键在于对数据进行平均分配。

  • 计数排序:是桶排序的一个特例,它通过统计数据出现的次数来实现排序。计数排序适用于数据量大但数据范围有限的情况,并且要求数据能够转换为正整数。

  • 基数排序:通过逐位排序来实现数据排序,要求数据能够表示为固定位数的数字。

总的来说,我们希望找到一种排序算法,具有高效率、稳定、原地以及正向自适应性等优点。然而,正如其他数据结构和算法一样,没有一种排序算法能够同时满足所有这些条件。在实际应用中,我们需要根据数据的特性来选择合适的排序算法。

再探——栈与队列

单调栈

单调栈(Monotone Stack):一种特殊的栈。在栈的「先进后出」规则基础上,要求「从栈顶到栈底的元素是单调递增(或者单调递减)」。其中,满足从栈顶到栈底的元素是单调递增的栈,叫做单调递增栈;满足从栈顶到栈底的元素是单调递减的栈,叫做单调递减栈

单调递增栈

单调递增栈:只有比栈顶元素小的元素才能直接进栈,否则需要先将栈中比当前元素小的元素出栈,再将当前元素入栈。

这样就保证了:栈中保留的都是比当前入栈元素大的值,并且从栈顶到栈底的元素值是单调递增的。

单调递增栈的入栈、出栈过程如下:

假设当前进栈元素为 x,如果 x 比栈顶元素小,则直接入栈。
否则从栈顶开始遍历栈中元素,把小于 x 或者等于 x 的元素弹出栈,直到遇到一个大于 x 的元素为止,然后再把 x 压入栈中。

下面我们以数组 [2,7,5,4,6,3,4,2] 为例(遍历顺序为从左到右),模拟一下「单调递增栈」的进栈、出栈过程。具体过程如下:

第 i 步待插入元素操 作结 果(左侧为栈底)作 用
122 入栈[2]元素 2 的左侧无比 2 大的元素
272 出栈,7 入栈[7]元素 7 的左侧无比 7 大的元素
355 入栈[7, 5]元素 5 的左侧第一个比 5 大的元素为:7
444 入栈[7, 5, 4]元素 4 的左侧第一个比 4 大的元素为:5
564 出栈,5 出栈,6 入栈[7, 6]元素 6 的左侧第一个比 6 大的元素为:7
633 入栈[7, 6, 3]元素 3 的左侧第一个比 3 大的元素为:6
743 出栈,4 入栈[7, 6, 4]元素 4 的左侧第一个比 4 大的元素为:6
822 入栈[7, 6, 4, 2]元素 2 的左侧第一个比 2 大的元素为:4

最终栈中元素为 [7, 6, 4, 2]
因为从栈顶(右端)到栈底(左侧)元素的顺序为 2, 4, 6, 7,满足递增关系,所以这是一个单调递增栈。

我们以上述过程第 5 步为例,所对应的图示过程为:

在这里插入图片描述
结合单调栈的特性可以给出如下代码:

// monotoneIncreasingStack 单调递增栈
func monotoneIncreasingStack(nums []int) []int {
	stack := make([]int, 0) // 初始化时预估栈的最大长度
	for _, num := range nums {
		// 弹出栈顶元素,直到栈顶元素小于当前元素
		for len(stack) > 0 && num >= stack[len(stack)-1] {
			stack = stack[:len(stack)-1] // 弹出栈顶元素
		}
		stack = append(stack, num) // 将当前元素压入栈
	}
	return stack
}

单调递减栈

单调递减栈:只有比栈顶元素大的元素才能直接进栈,否则需要先将栈中比当前元素大的元素出栈,再将当前元素入栈。这样就保证了:栈中保留的都是比当前入栈元素小的值,并且从栈顶到栈底的元素值是单调递减的。

由于单调递减栈与单调递增栈相反,所以改变一下判断条件就可以给出如下代码:

// monotoneDecreasingStack 单调递减栈
func monotoneDecreasingStack(nums []int) []int {
	stack := make([]int, 0) // 初始化时预估栈的最大长度
	for _, num := range nums {
		// 弹出栈顶元素,直到栈顶元素小于当前元素
		for len(stack) > 0 && num <= stack[len(stack)-1] {
			stack = stack[:len(stack)-1] // 弹出栈顶元素
		}
		stack = append(stack, num) // 将当前元素压入栈
	}
	return stack
}

单调栈适用场景

单调栈可以在时间复杂度为 ( O(n) ) 的情况下,求解出某个元素左边或者右边第一个比它大或者小的元素。

所以单调栈一般用于解决以下几种问题:

  • 寻找左侧第一个比当前元素大的元素。
  • 寻找左侧第一个比当前元素小的元素。
  • 寻找右侧第一个比当前元素大的元素。
  • 寻找右侧第一个比当前元素小的元素。

下面分别说一下这几种问题的求解方法。

寻找左侧第一个比当前元素大的元素

从左到右遍历元素,构造单调递增栈(从栈顶到栈底递增):

  • 一个元素左侧第一个比它大的元素就是将其「插入单调递增栈」时的栈顶元素。
  • 如果插入时的栈为空,则说明左侧不存在比当前元素大的元素。

寻找左侧第一个比当前元素小的元素

从左到右遍历元素,构造单调递减栈(从栈顶到栈底递减):

  • 一个元素左侧第一个比它小的元素就是将其「插入单调递减栈」时的栈顶元素。
  • 如果插入时的栈为空,则说明左侧不存在比当前元素小的元素。

2.3 寻找右侧第一个比当前元素大的元素

从左到右遍历元素,构造单调递增栈(从栈顶到栈底递增):

  • 一个元素右侧第一个比它大的元素就是将其「弹出单调递增栈」时即将插入的元素。
  • 如果该元素没有被弹出栈,则说明右侧不存在比当前元素大的元素。

从右到左遍历元素,构造单调递增栈(从栈顶到栈底递增):

  • 一个元素右侧第一个比它大的元素就是将其「插入单调递增栈」时的栈顶元素。
  • 如果插入时的栈为空,则说明右侧不存在比当前元素大的元素。

2.4 寻找右侧第一个比当前元素小的元素

从左到右遍历元素,构造单调递减栈(从栈顶到栈底递减):

  • 一个元素右侧第一个比它小的元素就是将其「弹出单调递减栈」时即将插入的元素。
  • 如果该元素没有被弹出栈,则说明右侧不存在比当前元素小的元素。

从右到左遍历元素,构造单调递减栈(从栈顶到栈底递减):

  • 一个元素右侧第一个比它小的元素就是将其「插入单调递减栈」时的栈顶元素。
  • 如果插入时的栈为空,则说明右侧不存在比当前元素小的元素。

上边的分类解法有点绕口,可以简单记为以下规则:

  • 无论哪种题型,都建议从左到右遍历元素。
  • 查找 比当前元素大的元素 就用 单调递增栈,查找 比当前元素小的元素 就用 单调递减栈
  • 左侧 查找就看 插入栈 时的栈顶元素,从 右侧 查找就看 弹出栈 时即将插入的元素。

优先队列

优先队列(Priority Queue):一种特殊的队列。在优先队列中,元素被赋予优先级,当访问队列元素时,具有最高优先级的元素最先删除。

优先队列与普通队列最大的不同点在于 出队顺序。

  1. 普通队列的出队顺序跟入队顺序相关,符合「先进先出(First in, First out)」的规则。
  2. 优先队列的出队顺序跟入队顺序无关,优先队列是按照元素的优先级来决定出队顺序的。优先级高的元素优先出队,优先级低的元素后出队。优先队列符合 「最高级先出(First in, Largest out)」 的规则。

在这里插入图片描述

优先队列的实现方式

优先队列所涉及的基本操作跟普通队列差不多,主要是「入队操作」和「出队操作」。

而优先队列的实现方式也有很多种,除了使用「数组(顺序存储)实现」与「链表(链式存储)实现」之外,我们最常用的是使用 「二叉堆结构实现」 优先队列。以下是三种方案的介绍和总结。

  1. 数组(顺序存储)实现优先队列

    • 入队操作:直接插入到数组队尾,时间复杂度为 O ( 1 ) O(1) O(1)
    • 出队操作:需要遍历整个数组,找到优先级最高的元素,返回并删除该元素,时间复杂度为 O ( n ) O(n) O(n)
  2. 链表(链式存储)实现优先队列

    • 入队操作:链表中的元素按照优先级排序,入队操作需要为待插入元素创建节点,并在链表中找到合适的插入位置,时间复杂度为 O ( n ) O(n) O(n)
    • 出队操作:直接返回链表队头元素,并删除队头元素,时间复杂度为 O ( 1 ) O(1) O(1)
  3. 二叉堆结构实现优先队列

    • 入队操作:将元素插入到二叉堆中合适位置,时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn)
    • 出队操作:返回二叉堆中优先级最大节点并删除,时间复杂度也是 O ( log ⁡ n ) O(\log n) O(logn)

我们直接实现golang的sort.Interface和heap.Interface!

package main

import (
	"container/heap"
	"fmt"
)

type Item struct {
	value    string // 存储值
	priority int    // 优先级
}

type PriorityQueue []Item

func (pq *PriorityQueue) Len() int           { return len(*pq) }
func (pq *PriorityQueue) Less(i, j int) bool { return (*pq)[i].priority > (*pq)[j].priority } // 大顶堆
func (pq *PriorityQueue) Swap(i, j int)      { (*pq)[i], (*pq)[j] = (*pq)[j], (*pq)[i] }

func (pq *PriorityQueue) Push(x interface{}) {
	*pq = append(*pq, x.(Item))
}

func (pq *PriorityQueue) Pop() interface{} {
	old := *pq
	n := len(old)
	item := old[n-1]
	*pq = old[:n-1]
	return item
}

func main() {
	p := new(PriorityQueue)
	heap.Init(p)
	heap.Push(p, Item{value: "hello", priority: 4})
	heap.Push(p, Item{value: "world", priority: 1})
	heap.Push(p, Item{value: "你好", priority: 2})
	heap.Push(p, Item{value: "世界", priority: 3})
	fmt.Println(p)
	fmt.Println(heap.Pop(p))
}

在这里插入图片描述

再探——树

前面我们学习到了树和二叉树,知道了二叉树(binary tree) 是一种非线性数据结构,表示“祖先”与“后代”之间的派生关系,体现了“一分为二”的分治逻辑。

进阶篇将会讲述基于二叉树衍生出来的进阶数据结构——二叉搜索树和AVL树!

二叉搜索树

二叉搜索树(Binary Search Tree, BST):也叫做二叉查找树、有序二叉树或者排序二叉树。是指一棵空树或者具有下列性质的二叉树:

  • 如果任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值。
  • 如果任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值。
  • 任意节点的左子树、右子树均为二叉搜索树。

如图所示,这 3 棵树都是二叉搜索树。

在这里插入图片描述
二叉树具有一个特性,即:左子树的节点值 < 根节点值 < 右子树的节点值。

根据这个特性,如果我们以中序遍历的方式遍历整个二叉搜索树时,会得到一个递增序列。

二叉搜索树的操作

将二叉搜索树封装为一个类 BinarySearchTree,并声明一个成员变量 root,指向树的根节点。

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

type binarySearchTree struct {
	root *TreeNode
}

/* 查找节点 */
func (bst *binarySearchTree) search(num int) *TreeNode {
	node := bst.root
	// 循环查找,越过叶节点后跳出
	for node != nil {
		if node.Val < num {
			// 目标节点在 cur 的右子树中
			node = node.Right
		} else if node.Val > num {
			// 目标节点在 cur 的左子树中
			node = node.Left
		} else {
			// 找到目标节点,跳出循环
			break
		}
	}
	// 返回目标节点
	return node
}
查找节点

给定目标节点值 num,可以根据二叉搜索树的性质来查找。 声明一个节点 cur,从二叉树的根节点 root 出发,循环比较 cur.valnum 之间的大小关系:

  • cur.val < num,说明目标节点在 cur 的右子树中,因此执行 cur = cur.right
  • cur.val > num,说明目标节点在 cur 的左子树中,因此执行 cur = cur.left
  • cur.val == num,说明找到目标节点,跳出循环并返回该节点。

在这里插入图片描述
在这里插入图片描述
二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用𝑂(log𝑛)时间。

/* 查找节点 */
func (bst *binarySearchTree) search(num int) *TreeNode {
	node := bst.root
	// 循环查找,越过叶节点后跳出
	for node != nil {
		if node.Val < num {
			// 目标节点在 cur 的右子树中
			node = node.Right
		} else if node.Val > num {
			// 目标节点在 cur 的左子树中
			node = node.Left
		} else {
			// 找到目标节点,跳出循环
			break
		}
	}
	// 返回目标节点
	return node
}
插入节点

给定一个待插入元素 num,为了保持二叉搜索树 “左子树 < 根节点 < 右子树” 的性质,插入操作流程如下:

  1. 查找插入位置
    与查找操作类似,从根节点出发,根据当前节点值和 num 的大小关系循环向下搜索,直到越过叶节点(遍历至 None)时跳出循环。

  2. 在该位置插入节点
    初始化节点 num,将该节点置于 None 的位置。

注意:二叉搜索树不允许存在重复节点,否则将违反其定义。因此,如果带插入节点在树中已存在,则不执行插入操作,直接返回。

在这里插入图片描述

/* 插入节点 */
func (bst *binarySearchTree) insert(num int) {
	cur := bst.root

	// 若树为空,则初始化根节点
	if cur == nil {
		bst.root = &TreeNode{num, nil, nil}
		return
	}

	// 待插入节点之前的节点位置
	var pre *TreeNode
	// 循环查找,越过叶节点后跳出
	for cur != nil {
		if cur.Val == num {
			return
		}
		pre = cur
		if cur.Val < num {
			cur = cur.Right
		} else {
			cur = cur.Left
		}
	}
	// 插入节点
	node := &TreeNode{num, nil, nil}
	if pre.Val < num {
		pre.Right = node
	} else {
		pre.Left = node
	}
}

与查找节点相同,插入节点使用𝑂(log𝑛)时间。

删除节点

删除操作需要先在二叉搜索树中查找到目标节点,然后将其删除。与插入节点类似,我们需要保证删除操作完成后,二叉搜索树的 “左子树 < 根节点 < 右子树” 的性质仍然满足。

因此,根据目标节点的子节点数量,可分为以下三种情况:

  1. 目标节点没有子节点(叶子节点) :直接删除该节点。
    在这里插入图片描述

  2. 目标节点只有一个子节点 :让目标节点的父节点直接指向其唯一的子节点,删除目标节点。
    在这里插入图片描述

  3. 目标节点有两个子节点 :找到目标节点的 中序后继节点(即右子树中最小的节点),用该节点的值替换目标节点的值,然后递归删除该后继节点。(这个节点可以是右子树的最小节点或左子树的最大节点。)假设我们选择右子树的最小节点(中序遍历的下一个节点),则删除操作流程如下:

    1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为tmp 。
    2. 用tmp的值覆盖待删除节点的值,并在树中递归删除节点tmp 。

在这里插入图片描述
删除节点操作同样使用𝑂(log𝑛)时间,其中查找待删除节点需要𝑂(log𝑛)时间,获取中序遍历后继节点需要𝑂(log𝑛)时间。

/* 删除节点 */
func (bst *binarySearchTree) remove(num int) {
	cur := bst.root
	// 若树为空,直接提前返回
	if cur == nil {
		return
	}
	// 待删除节点之前的节点位置
	var pre *TreeNode = nil
	// 循环查找,越过叶节点后跳出
	for cur != nil {
		if cur.Val == num {
			break
		}
		pre = cur
		if cur.Val < num {
			// 待删除节点在右子树中
			cur = cur.Right
		} else {
			// 待删除节点在左子树中
			cur = cur.Left
		}
	}
	// 若无待删除节点,则直接返回
	if cur == nil {
		return
	}
	// 子节点数为 0 或 1
	if cur.Left == nil || cur.Right == nil {
		var child *TreeNode = nil
		// 取出待删除节点的子节点
		if cur.Left != nil {
			child = cur.Left
		} else {
			child = cur.Right
		}
		// 删除节点 cur
		if cur != bst.root {
			if pre.Left == cur {
				pre.Left = child
			} else {
				pre.Right = child
			}
		} else {
			// 若删除节点为根节点,则重新指定根节点
			bst.root = child
		}
		// 子节点数为 2
	} else {
		// 获取中序遍历中待删除节点 cur 的下一个节点
		tmp := cur.Right
		for tmp.Left != nil {
			tmp = tmp.Left
		}
		// 递归删除节点 tmp
		bst.remove(tmp.Val)
		// 用 tmp 覆盖 cur
		cur.Val = tmp.Val
	}
}

中序遍历有序

二叉树的中序遍历遵循“左 → 根 → 右”的遍历顺序,而二叉搜索树满足“左子节点 < 根节点 < 右子节点”的大小关系。

这意味着,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:二叉搜索树的中序遍历序列是升序的。

利用中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 O(n) 时间,无须进行额外的排序操作,非常高效。

在这里插入图片描述

二叉搜索树的效率

给定一组数据,我们考虑使用数组或二叉搜索树存储。二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能。只有在高频添加、低频查找和删除数据的场景下,数组比二叉搜索树的效率更高。

在这里插入图片描述
在理想情况下,二叉搜索树是“平衡”的,这样就可以在 log(n) 轮循环内查找任意节点。

然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为下图所示的链表,这时各种操作的时间复杂度也会退化为 O(n)。

在这里插入图片描述
二叉搜索树常见应用:

  • 用作系统中的多级索引,实现高效的查找、插入、删除操作。
  • 作为某些搜索算法的底层数据结构。
  • 用于存储数据流,以保持其有序状态。

AVL树

在“二叉搜索树”中提到,在多次插入和删除操作后,二叉搜索树可能退化为链表。在这种情况下,所有操作的时间复杂度将从 O(log n) 劣化为 O(n)

如图所示,经过两次删除节点操作,这棵二叉搜索树便会退化为链表。

在这里插入图片描述
1962年,G. M. Adelson‑Velsky 和 E. M. Landis 在论文《An algorithm for the organization of information》中提出了 AVL树。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL树不会退化,从而使得各种操作的时间复杂度保持在 O(log n) 级别。

换句话说,在需要频繁进行增删查改操作的场景中,AVL树能始终保持高效的数据操作性能,具有很好的应用价值。

AVL树常见术语

AVL树既是二叉搜索树,也是平衡二叉树,同时满足这两类二叉树的所有性质,因此是一种平衡二叉搜索树(balancedbinarysearchtree)。

节点高度

“节点高度”是指从该节点到它的最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为0,而空节点的高度为−1。

由于AVL树的相关操作需要获取节点高度,因此我们需要为节点类添加height变量:

/*AVL树节点结构体*/
type TreeNode struct {
	Val    int       //节点值
	Height int       //节点高度
	Left   *TreeNode //左子节点引用
	Right  *TreeNode //右子节点引用
}

type AVLTree struct {
	root *TreeNode
}

创建两个工具函数,分别用于获取和更新节点的高度:

/*获取节点高度*/
func (t *AVLTree) height(node *TreeNode) int {
	//空节点高度为-1,叶节点高度为0
	if node != nil {
		return node.Height
	}
	return -1
}

/*更新节点高度*/
func (t *AVLTree) updateHeight(node *TreeNode) {
	lh := t.height(node.Left)
	rh := t.height(node.Right)
	//节点高度等于最高子树高度+1
	if lh > rh {
		node.Height = lh + 1
	} else {
		node.Height = rh + 1
	}
}
节点平衡因子

节点的平衡因子(balancefactor)定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子
为0。同样将获取节点平衡因子的功能封装成函数,方便后续使用:

设平衡因子为𝑓,则一棵AVL树的任意节点的平衡因子皆满足−1≤𝑓 ≤1。

/* 获取平衡因子 */
func (t *AVLTree) balanceFactor(node *TreeNode) int {
	// 空节点平衡因子为 0
	if node == nil {
		return 0
	}
	// 节点平衡因子 = 左子树高度- 右子树高度
	return t.height(node.Left)- t.height(node.Right)
}

AVL树旋转

AVL树的特点在于“旋转”操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,旋转操作既能保持“二叉搜索树”的性质,也能使树重新变为“平衡二叉树”。

我们将平衡因子绝对值 > 1 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:

  • 右旋
  • 左旋
  • 先右旋后左旋
  • 先左旋后右旋
右旋

如图所示,二叉树中首个失衡节点是“节点3”。我们关注以该失衡节点为根节点的子树,将该节点记为 node,其左子节点记为 child,执行“右旋”操作。完成右旋后,子树恢复平衡,并且仍然保持二叉搜索树的性质。

在这里插入图片描述
在这里插入图片描述
当节点 child 有右子节点(记为 grand_child)时,需要在右旋中添加一步:将 grand_child 作为 node 的左子节点。

在这里插入图片描述

/* 右旋操作 */
func (t *AVLTree) rightRotate(node *TreeNode) *TreeNode {
	child := node.Left
	grandChild := child.Right
	// 以 child 为原点,将 node 向右旋转
	child.Right = node
	node.Left = grandChild
	// 更新节点高度
	t.updateHeight(node)
	t.updateHeight(child)
	// 返回旋转后子树的根节点
	return child
}
左旋

相应地,如果考虑上述失衡二叉树的“镜像”,则需要执行“左旋”操作。

在这里插入图片描述
同理,如图7‑29所示,当节点 child 有左子节点(记为 grand_child)时,需要在左旋中添加一步:将 grand_child 作为 node 的右子节点。

在这里插入图片描述

可以观察到,右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的。基于对称性,我们只需将右旋的实现代码中的所有的 left 替换为 right,将所有的 right 替换为 left,即可得到左旋的实现代码:

/* 左旋操作 */
func (t *AVLTree) leftRotate(node *TreeNode) *TreeNode {
	child := node.Right
	grandChild := child.Left
	// 以 child 为原点,将 node 向左旋转
	child.Left = node
	node.Right = grandChild
	// 更新节点高度
	t.updateHeight(node)
	t.updateHeight(child)
	// 返回旋转后子树的根节点
	return child
}
先左旋后右旋

对于图中的失衡节点3,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先对 child 执行“左旋”,再对 node 执行“右旋”。

在这里插入图片描述

先右旋后左旋

如图所示,对于上述失衡二叉树的镜像情况,需要先对 child 执行“右旋”,再对 node 执行“左旋”。

在这里插入图片描述

旋转的选择

下图展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、先左旋后右旋、先右旋后左旋、左旋
的操作。

在这里插入图片描述

如下表所示,通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于哪种情况。

在这里插入图片描述
为了便于使用,我们将旋转操作封装成一个函数。有了这个函数,我们就能对各种失衡情况进行旋转,使失
衡节点重新恢复平衡。

/*执行旋转操作,使该子树重新恢复平衡*/
func (t *AVLTree) rotate(node *TreeNode) *TreeNode {
	//获取节点node的平衡因子
	//Go推荐短变量,这里bf指代t.balanceFactor
	bf := t.balanceFactor(node)
	//左偏树
	if bf > 1 {
		if t.balanceFactor(node.Left) >= 0 {
			//右旋
			return t.rightRotate(node)
		} else {
			//先左旋后右旋
			node.Left = t.leftRotate(node.Left)
			return t.rightRotate(node)
		}
	}
	//右偏树
	if bf < -1 {
		if t.balanceFactor(node.Right) <= 0 {
			//左旋
			return t.leftRotate(node)
		} else {
			//先右旋后左旋
			node.Right = t.rightRotate(node.Right)
			return t.leftRotate(node)
		}
	}
	//平衡树,无须旋转,直接返回
	return node
}

AVL树常用操作

插入节点

AVL树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在AVL树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡。

/*插入节点*/
func (t *AVLTree) insert(val int) {
	t.root = t.insertHelper(t.root, val)
}

/*递归插入节点(辅助函数)*/
func (t *AVLTree) insertHelper(node *TreeNode, val int) *TreeNode {
	if node == nil {
		return &TreeNode{Val: val}
	}
	// 查找插入位置并插入节点
	if val < node.Val {
		node.Left = t.insertHelper(node.Left, val)
	} else if val > node.Val {
		node.Right = t.insertHelper(node.Right, val)
	} else {
		//重复节点不插入,直接返回
		return node
	}
	//更新节点高度
	t.updateHeight(node)
	// 执行旋转操作,使该子树重新恢复平衡
	node = t.rotate(node)
	//返回子树的根节点
	return node
}
删除节点

类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶执行旋转操作,使所有失衡节点恢复平衡。代码如下所示:

/*删除节点*/
func (t *AVLTree) remove(val int) {
	t.root = t.removeHelper(t.root, val)
}

/* 递归删除节点(辅助函数) */
func (t *AVLTree) removeHelper(node *TreeNode, val int) *TreeNode {
	if node == nil {
		return nil
	}
	/* 1. 查找节点并删除 */
	if val < node.Val {
		node.Left = t.removeHelper(node.Left, val)
	} else if val > node.Val {
		node.Right = t.removeHelper(node.Right, val)
	} else {
		if node.Left == nil || node.Right == nil {
			child := node.Left
			if node.Right != nil {
				child = node.Right
			}
			if child == nil {
				// 子节点数量 = 0 ,直接删除 node 并返回
				return nil
			} else {
				// 子节点数量 = 1 ,直接删除 node
				node = child
			}
		} else {
			// 子节点数量 = 2 ,则将中序遍历的下个节点删除,并用该节点替换当前节点
			temp := node.Right
			for temp.Left != nil {
				temp = temp.Left
			}
			node.Right = t.removeHelper(node.Right, temp.Val)
			node.Val = temp.Val
		}
	}
	// 更新节点高度
	t.updateHeight(node)
	/* 2. 执行旋转操作,使该子树重新恢复平衡 */
	node = t.rotate(node)
	// 返回子树的根节点
	return node
}
查找节点

AVL树的节点查找操作与二叉搜索树一致!

AVL树典型应用

  • 组织和存储大型数据,适用于高频查找、低频增删的场景。
  • 用于构建数据库中的索引系统。
  • 红黑树也是一种常见的平衡二叉搜索树。相较于AVL树,红黑树的平衡条件更宽松,插入与删除节点所需的旋转操作更少,节点增删操作的平均效率更高。

再探索——数组和链表

二分查找

二分查找算法(Binary Search Algorithm):也叫做折半查找算法、对数查找算法,是一种用于在有序数组中查找特定元素的高效搜索算法。它是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮缩小一半搜索范
围,直至找到目标元素或搜索区间为空为止。

在这里插入图片描述
二分查找算法步骤:

  1. 初始化 :确定要查找的有序数据集合,确保其中的元素按 升序降序 排列。

  2. 确定查找范围 :设定整个数组的查找范围,初始化 左边界 left右边界 right

  3. 计算中间元素 计算中间索引 mid := (left + right) / 2,如果 left + right 可能溢出,可以使用:mid := left + (right - left) / 2

  4. 比较中间元素

    • target == nums[mid],找到目标值,返回 mid
    • target < nums[mid],目标在左半部分 [left, mid - 1],更新右边界:right = mid - 1
    • target > nums[mid],目标在右半部分 [mid + 1, right],更新左边界:left = mid + 1
  5. 重复步骤 3-4 :直到找到 target 并返回索引,或当 left > right 时,说明目标不存在,返回 -1

如图所示,我们先 初始化指针 i = 0j = n - 1,分别指向 数组首元素和尾元素,代表搜索区间 [0, n - 1]
请注意,中括号 [] 表示 闭区间,即包含边界值本身。

接下来,循环执行以下 两步

  1. 计算 中点索引 m = ⌊(i + j) / 2⌋,其中 ⌊⌋ 表示向下取整操作。
  2. 判断 nums[m]target 的大小关系,分为以下 三种情况
    • nums[m] < target:说明 target 在区间 [m + 1, j] 中,因此执行 i = m + 1
    • nums[m] > target:说明 target 在区间 [i, m - 1] 中,因此执行 j = m - 1
    • nums[m] = target:说明找到 target,返回索引 m

如果数组 不包含目标元素,最终搜索区间会 缩小为空,此时返回 -1

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

/* 二分查找(双闭区间) */
func binarySearch(nums []int, target int) int {
	// 初始化双闭区间 [0, n-1] ,即 i, j 分别指向数组首元素、尾元素
	i, j := 0, len(nums)-1
	// 循环,当搜索区间为空时跳出(当 i > j 时为空)
	for i <= j {
		// 计算中点索引 m
		m := i + (j-i)/2
		if nums[m] < target { // 此情况说明 target 在区间 [m+1, j] 中
			i = m + 1
		} else if nums[m] > target { // 此情况说明 target 在区间 [i, m-1] 中
			j = m-1
		} else{ //找到目标元素,返回其索引
			return m
		}
	}
	//未找到目标元素,返回-1
	return-1
}

时间复杂度:𝑂(log 𝑛) , 在 二分查找 过程中,每轮循环都会使 搜索区间缩小一半, 因此 循环次数log₂ 𝑛,故时间复杂度为 𝑂(log 𝑛)

空间复杂度:𝑂(1) 仅使用了 两个指针 ij,占用 常数级别的额外空间, 故空间复杂度为 𝑂(1)

区间表示方法(区间的开闭问题)

除了上述双闭区间外,常见的区间表示还有“左闭右开”区间,定义为[0,𝑛),即左边界包含自身,右边界不包含自身。在该表示下,区间[𝑖,𝑗)在𝑖=𝑗时为空。

我们可以基于该表示实现具有相同功能的二分查找算法:

/*二分查找(左闭右开区间)*/
func binarySearchLCRO(nums []int, target int) int {
	//初始化左闭右开区间[0,n),即i,j分别指向数组首元素、尾元素+1
	i, j := 0, len(nums)
	//循环,当搜索区间为空时跳出(当i=j时为空)
	for i < j {
		m := i + (j-i)/2      //计算中点索引m
		if nums[m] < target { //此情况说明target在区间[m+1,j)中
			i = m + 1
		} else if nums[m] > target { //此情况说明target在区间[i,m)中
			j = m
		} else { //找到目标元素,返回其索引
			return m
		}
	}
	//未找到目标元素,返回-1
	return -1
}

在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。

由于“双闭区间”表示中的左右边界都被定义为闭区间,因此通过指针𝑖和指针𝑗缩小区间的操作也是对称的。这样更不容易出错,因此一般建议采用“双闭区间”的写法。

在这里插入图片描述

优点与局限性

优点
  • 时间效率高

    • 对数阶 𝑂(log 𝑛) 时间复杂度,在大数据量下优势明显。
    • 例如:当数据规模 𝑛 = 2^20 = 1,048,576 时,
      • 线性查找需要 1,048,576 轮循环。
      • 二分查找仅需 log₂(2^20) = 20 轮循环。
  • 空间占用低

    • 不需要额外空间,相比哈希查找等算法更加节省空间。
缺点
  • 仅适用于有序数据

    • 若数据无序,需要先排序,通常需要 𝑂(𝑛 log 𝑛) 时间,
      • 可能比 直接线性查找 还要昂贵。
    • 频繁插入的场景下,维持有序数组的插入操作复杂度为 𝑂(𝑛)
      • 使得二分查找的优势大大降低。
  • 仅适用于数组

    • 二分查找需要跳跃访问元素,而链表的随机访问性能较差
      • 在链表或基于链表的数据结构上效率低
  • 小数据量下,线性查找更优

    • 线性查找 每轮仅需 1 次判断
    • 二分查找 每轮至少需要:
      • 1 次加法
      • 1 次除法
      • 1~3 次判断
      • 1 次加法/减法
    • 小规模数据 (𝑛 较小时),线性查找比二分查找 更快

二分查找变体

二分查找插入点

二分查找不仅可用于搜索目标元素,还可用于解决许多变种问题,比如搜索目标元素的插入位置。

  • 给定一个长度为 𝑛 的有序数组 nums无重复元素)。
  • 给定一个目标元素 target
  • 需要在 保持数组有序性 的前提下,将 target 插入 nums
  • nums已存在 target,则将其插入到左方
  • 返回 target 插入后的索引

在这里插入图片描述
如果想复用上一节的二分查找代码,则需要回答以下两个问题。

  • 问题一:当数组中包含target时,插入点的索引是否是该元素的索引?

    • 题目要求将target插入到相等元素的左边,这意味着新插入的target替换了原来target的索引。
  • 问题二:当数组中不存在target时,插入点是哪个元素的索引?

    • 进一步思考二分查找过程:
      • 当nums[m] < target时,i移动,这意味着指针i在向大于等于target的元素靠近。
      • 同理,指针j始终在向小于等于target的元素靠近。
    • 因此二分结束时一定有:i指向首个大于target的元素,j指向首个小于target时,插入索引为i。

代码如下所示:

/* 二分查找插入点(无重复元素) */
func binarySearchInsertionSimple(nums []int, target int) int {
	// 初始化双闭区间 [0, n-1]
	i, j := 0, len(nums)-1
	for i <= j {
		// 计算中点索引 m
		m := i + (j-i)/2
		if nums[m] < target {
			// target 在区间 [m+1, j] 中
			i = m + 1
		} else if nums[m] > target {
			// target 在区间 [i, m-1] 中
			j = m - 1
		} else {
			// 找到 target ,返回插入点 m
			return m
		}
	}
	// 未找到 target ,返回插入点 i
	return i
}

假设数组中存在多个 target,则普通二分查找只能返回其中一个 target 的索引,而无法确定该元素的左边和右边还有多少 target。

题目要求将目标元素插入到最左边,所以我们需要查找数组中最左一个target 的索引。

现考虑拓展二分查找代码。如图10-6所示,整体流程保持不变,每轮先计算中点索引𝑚,再判断 targetnums[m] 的大小关系,分为以下几种情况。

  • nums[m] < targetnums[m] > target 时,说明还没有找到目标元素,此时需要进行小区间操作,使得指针 ij 向目标逼近。

  • nums[m] == target 时,说明小于 target 的元素靠近目标,因此采用普通二分查找的方式缩小区间。此时,target 靠近区间 [i, m-1] 中的元素,因此采用 j = m - 1 来缩小区间,使得指针 j 向小于 target 的元素靠近。

循环完成后,i 指向最左边的目标元素,而 j 指向首个小于 target 的元素,因此索引 i 就是插入点。

在这里插入图片描述
在这里插入图片描述

/*二分查找插入点(存在重复元素)*/
func binarySearchInsertion(nums []int, target int) int {
	//初始化双闭区间[0,n-1]
	i, j := 0, len(nums)-1
	for i <= j {
		//计算中点索引m
		m := i + (j-i)/2
		if nums[m] < target {
			//target在区间[m+1,j]中
			i = m + 1
		} else if nums[m] > target {
			//target在区间[i,m-1]中
			j = m - 1
		} else {
			//首个小于target的元素在区间[i,m-1]中
			j = m - 1
		}
	}
	//返回插入点i
	return i
}

总的来看,二分查找无非就是给指针 ij 分别设定搜索目标,目标可能是一个具体的元素(例如 target),也可能是一个元素范围(例如小于 target 的元素)。

在不断的循环二分中,指针 ij 都逐渐逼近预先设定的目标。最终,它们或是成功找到答案,或是越过边界后停止。

二分查找边界
查找左边界

给定一个长度为𝑛的有序数组nums,其中可能包含重复元素。请返回数组中最左一个元素target的索引。若数组中不包含该元素,则返回−1。

回忆二分查找插入点的方法,搜索完成后 i 指向最左一个 target 的索引。因此,查找插入点本质上是在查找最左一个 target。

考虑通过查找插入点的函数实现查找左边界。请注意,数组中可能不包含 target,这种情况可能导致以下两种结果:

  • 插入点的索引 i 越界。
  • 元素 nums[i] 与 target 不相等。

当遇到以上两种情况时,直接返回 -1 即可。代码如下所示:

/*二分查找插入点(存在重复元素)*/
func binarySearchInsertion(nums []int, target int) int {
	//初始化双闭区间[0,n-1]
	i, j := 0, len(nums)-1
	for i <= j {
		//计算中点索引m
		m := i + (j-i)/2
		if nums[m] < target {
			//target在区间[m+1,j]中
			i = m + 1
		} else if nums[m] > target {
			//target在区间[i,m-1]中
			j = m - 1
		} else {
			//首个小于target的元素在区间[i,m-1]中
			j = m - 1
		}
	}
	//返回插入点i
	return i
}

/* 二分查找最左一个 target */
func binarySearchLeftEdge(nums []int, target int) int {
	// 等价于查找 target 的插入点
	i := binarySearchInsertion(nums, target)
	// 未找到 target ,返回-1
	if i == len(nums) || nums[i] != target {
		return -1
	}
	// 找到 target ,返回索引 i
	return i
}
查找右边界

我们可以利用查找最左元素的函数来查找最右元素,具体方法为:将查找最右一个 target 转化为查找最左一个 target + 1。

如图所示,查找完成后,指针 i 指向最左一个 target + 1(如果存在),而 j 指向最右一个 target,因此返回 j 即可。

在这里插入图片描述

/* 二分查找最右一个 target */
func binarySearchRightEdge(nums []int, target int) int {
	// 转化为查找最左一个 target + 1
	i := binarySearchInsertion(nums, target+1)
	// j 指向最右一个 target ,i 指向首个大于 target 的元素
	j := i- 1
	// 未找到 target ,返回-1
	if j ==-1 || nums[j] != target {
		return-1
	}
	// 找到 target ,返回索引 j
	return j
}

哈希优化策略

在算法题中,我们常通过将线性查找替换为哈希查找来降低算法的时间复杂度。

给定一个整数数组 nums 和一个目标元素 target,请在数组中搜索“和”为 target 的两个元素,并返回它们的数组索引。返回任意一个解即可。

线性查找,暴力枚举,以时间换空间:开启一个两层循环,在每轮中判断两个整数的和是否为target ,若是,则返回它们的索引。

func twoSumBruteForce(nums []int, target int) []int {
	size := len(nums)
	// 两层循环,时间复杂度为 O(n^2)
	for i := 0; i < size-1; i++ {
		for j := i + 1; i < size; j++ {
			if nums[i]+nums[j] == target {
				return []int{i, j}
			}
		}
	}
	return nil
}

此方法的时间复杂度为 O(n²),空间复杂度为 O(1),在大数据量下非常耗时。

哈希查找,以空间换时间,借助一个哈希表,键值对分别为数组元素和元素索引。

  1. 判断数字 target - nums[i] 是否在哈希表中,若是,则直接返回这两个元素的索引。
  2. 将键值对 nums[i] 和索引 i 添加进哈希表。

在这里插入图片描述

func twoSumHashTable(nums []int, target int) []int {
	// 辅助哈希表,空间复杂度为 O(n)
	hashTable := map[int]int{}
	// 单层循环,时间复杂度为 O(n)
	for idx, val := range nums {
		if preIdx, ok := hashTable[target-val]; ok {
			return []int{preIdx, idx}
		}
		hashTable[val] = idx
	}
	return nil
}

此方法通过哈希查找将时间复杂度从 𝑂(𝑛²) 降至 𝑂(𝑛),大幅提升运行效率。

由于需要维护一个额外的哈希表,因此空间复杂度为 𝑂(𝑛)。尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法。

搜索算法

搜索算法(Searching Algorithm)用于在数据结构(例如数组、链表、树或图)中搜索一个或一组满足特定条件的元素。

搜索算法可根据实现思路分为以下两类:

  • 通过遍历数据结构来定位目标元素,例如数组、链表、树和图的遍历等。
  • 利用数据组织结构或数据包含的先验信息,实现高效元素查找,例如二分查找、哈希查找和二叉搜索树查找等。

暴力搜索

暴力搜索通过遍历数据结构的每个元素来定位目标元素。

  • 线性搜索 适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。
  • 广度优先搜索(BFS)深度优先搜索(DFS) 是图和树的两种遍历策略:
    • 广度优先搜索(BFS) 从初始节点开始逐层搜索,由近及远地访问各个节点。
    • 深度优先搜索(DFS) 从初始节点开始,沿着一条路径走到底,再回溯并尝试其他路径,直到遍历完整个数据结构。

优点:
暴力搜索简单且通用性强,无须对数据做预处理,也不需要借助额外的数据结构。

缺点:
此类算法的时间复杂度为 𝑂(𝑛),其中 𝑛 为元素数量,因此在数据量较大的情况下性能较差。

自适应搜索

自适应搜索利用数据的特有属性(例如有序性)来优化搜索过程,从而更高效地定位目标元素。

  • 二分查找:利用数据的有序性实现高效查找,仅适用于数组。
  • 哈希查找:利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现快速查询。
  • 树查找:在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除无关节点,从而定位目标元素。

优点:
自适应搜索算法的效率较高,时间复杂度可达到 𝑂(log 𝑛) 甚至 𝑂(1)

缺点:

  • 这些算法通常需要对数据进行预处理
    • 二分查找需要预先对数组进行排序。
    • 哈希查找和树查找需要额外的数据结构来存储和维护数据。
  • 维护这些数据结构可能会带来额外的时间和空间开销

📌 Tip
自适应搜索算法通常被称为 查找算法,主要用于在特定数据结构中快速检索目标元素。

搜索方法选取

给定大小为𝑛的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法从中搜索目标元素。

在这里插入图片描述
上述几种方法的操作效率与特性如表所示。

在这里插入图片描述
搜索算法的选择取决于 数据体量、搜索性能要求、数据的查询与更新频率 等因素。

线性搜索

优点

  • 通用性强,无须任何数据预处理。
  • 适用于 小规模数据,此时时间复杂度影响较小。
  • 适用于 数据更新频率高 的场景,因为无需维护额外的数据结构。

缺点

  • 时间复杂度较高,为 𝑂(𝑛),大数据量时性能较差。

二分查找

优点

  • 适用于大数据量,时间复杂度为 𝑂(log 𝑛),查询高效。
  • 查找效率稳定,即使在最差情况下也维持 𝑂(log 𝑛)

缺点

  • 数组必须有序,高频增删数据时,维护有序数组的开销较大。
  • 需要连续内存,数据量过大时,存储成本较高。

哈希查找

优点

  • 查询速度最快,平均时间复杂度 𝑂(1)
  • 适用于 查询性能要求极高 的场景。

缺点

  • 不适用于有序数据或范围查询,因为哈希表不维护数据顺序。
  • 性能依赖于哈希函数和冲突处理策略,哈希冲突可能导致 𝑂(𝑛) 的最坏情况。
  • 占用额外空间,特别是在数据量过大时,可能带来高额存储开销。

树查找

优点

  • 适用于海量数据,树节点在内存中是分散存储的。
  • 支持有序数据和范围查询,例如 二叉搜索树 (BST) 可用于区间搜索。

缺点

  • 普通二叉搜索树可能失衡,最坏情况下时间复杂度退化至 𝑂(𝑛)
  • 需要维护树的平衡
    • 使用 AVL 树或红黑树 可确保查询、插入、删除均为 𝑂(log 𝑛),但会增加维护开销。

搜索算法选型建议

场景推荐算法
数据量较小,查询频率低线性搜索
数据有序,查询频率高二分查找
查询性能要求极高哈希查找
数据有序且需要范围查询树查找
数据量大且增删频繁平衡二叉树(如红黑树)

📌 Tip
在实际应用中,可以结合 多种搜索算法,例如在数据库索引结构中,通常会使用 B+树+哈希索引 来兼顾高效查询和有序检索。

双指针

双指针(Two Pointers)指的是在遍历元素的过程中,不是使用单个指针进行访问,而是使用两个指针进行访问,从而达到相应的目的。根据指针的不同运动方式,双指针有不同的分类:

  1. 对撞指针:两个指针方向相反,通常用于处理有序数组或链表,像是从两端向中间移动。
  2. 快慢指针:两个指针方向相同,其中一个指针以较快的速度移动,另一个以较慢的速度移动,常用于查找环形链表或中点。
  3. 分离双指针:两个指针分别属于不同的数组或链表,通常用于比较或合并不同数据结构中的元素。

数组双指针

在数组的区间问题上,暴力算法的时间复杂度往往是 O(n²)。而双指针利用了区间「单调性」的性质,可以将时间复杂度降到 O(n)。

对撞指针

对撞指针:指的是两个指针 leftright 分别指向序列第一个元素和最后一个元素,然后 left 指针不断递增,right 指针不断递减,直到两个指针的值相撞(即 left == right),或者满足其他要求的特殊条件为止。

在这里插入图片描述
对撞指针一般用来解决有序数组或者字符串问题:

  1. 查找有序数组中满足某些约束条件的一组元素问题:比如二分查找、数字之和等问题。
  2. 字符串反转问题:反转字符串、回文数、颠倒二进制等问题。

二分查找就是一个典型的对撞指针,这里就不再赘述。

快慢指针

快慢指针:指的是两个指针从同一侧开始遍历序列,且移动的步长一个快一个慢。移动快的指针被称为「快指针(fast)」,移动慢的指针被称为「慢指针(slow)」。两个指针以不同速度、不同策略移动,直到快指针移动到数组尾端,或者两指针相交,或者满足其他特殊条件时为止。

在这里插入图片描述
快慢指针求解步骤:

  1. 使用两个指针 slowfast

    • slow 一般指向序列第一个元素,即:slow = 0
    • fast 一般指向序列第二个元素,即:fast = 1
  2. 在循环体中将左右指针向右移动。当满足一定条件时,将慢指针右移,即 slow += 1
    当满足另外一定条件时(也可能不需要满足条件),将快指针右移,即 fast += 1

  3. 直到快指针移动到数组尾端(即 fast == len(nums) - 1),或者两指针相交,或者满足其他特殊条件时跳出循环体。

快慢指针一般用于处理数组中的移动、删除元素问题,或者链表中的判断是否有环、长度问题。

分离双指针

分离双指针:两个指针分别属于不同的数组,两个指针分别在两个数组中移动。
在这里插入图片描述
分离双指针求解步骤:

  1. 使用两个指针 left1left2

    • left1 指向第一个数组的第一个元素,即:left1 = 0
      • left2 指向第二个数组的第一个元素,即:left2 = 0
  2. 当满足一定条件时,两个指针同时右移,即 left1 += 1left2 += 1

  3. 当满足另外一定条件时,将 left1 指针右移,即 left1 += 1

  4. 当满足其他一定条件时,将 left2 指针右移,即 left2 += 1

  5. 当其中一个数组遍历完时或者满足其他特殊条件时跳出循环体。

分离双指针一般用于处理有序数组合并,求交集、并集问题。

链表双指针

在单链表中,因为遍历节点只能顺着 next 指针方向进行,所以对于链表而言,一般只会用到「快慢指针」和「分离双指针」。其中链表的「快慢指针」又分为「起点不一致的快慢指针」和「步长不一致的快慢指针」。

起点不一致的快慢指针

起点不一致的快慢指针:指的是两个指针从同一侧开始遍历链表,但是两个指针的起点不一样。
快指针 fast 比慢指针 slow 先走 n 步,直到快指针移动到链表尾端时为止。

起点不一致的快慢指针求解步骤

  1. 使用两个指针 slowfastslowfast 都指向链表的头节点,即:slow = headfast = head
  2. 先将快指针向右移动 n 步。然后再同时向右移动快、慢指针。
  3. 等到快指针移动到链表尾部(即 fast == None)时跳出循环体。

起点不一致的快慢指针主要用于找到链表中倒数第 k 个节点、删除链表倒数第 N 个节点等。

步长不一致的快慢指针

步长不一致的快慢指针:指的是两个指针从同一侧开始遍历链表,两个指针的起点一样,但是步长不一致。例如,慢指针 slow 每次走 1 步,快指针 fast 每次走 2 步。直到快指针移动到链表尾端时为止。

步长不一致的快慢指针求解步骤:

  1. 使用两个指针 slowfastslowfast 都指向链表的头节点。
  2. 在循环体中将快、慢指针同时向右移动,但是快、慢指针的移动步长不一致。比如将慢指针每次移动 1 步,即 slow = slow.next。快指针每次移动 2 步,即 fast = fast.next.next
  3. 等到快指针移动到链表尾部(即 fast == None)时跳出循环体。

步长不一致的快慢指针适合寻找链表的中点、判断和检测链表是否有环、找到两个链表的交点等问题。

分离双指针

分离双指针:两个指针分别属于不同的链表,两个指针分别在两个链表中移动。

分离双指针求解步骤 :

  1. 使用两个指针 left_1left_2left_1 指向第一个链表头节点,即:left_1 = list1left_2 指向第二个链表头节点,即:left_2 = list2
  2. 当满足一定条件时,两个指针同时右移,即 left_1 = left_1.nextleft_2 = left_2.next
  3. 当满足另外一定条件时,将 left_1 指针右移,即 left_1 = left_1.next
  4. 当满足其他一定条件时,将 left_2 指针右移,即 left_2 = left_2.next
  5. 当其中一个链表遍历完时或者满足其他特殊条件时跳出循环体。

分离双指针一般用于有序链表合并等问题。

数组滑动窗口

在计算机网络中,滑动窗口协议(Sliding Window Protocol)是传输层进行流控的一种措施,接收方通过通告发送方自己的窗口大小,从而控制发送方的发送速度,从而达到防止发送方发送速度过快而导致自己被淹没的目的。

滑动窗口算法(Sliding Window):在给定数组 / 字符串上维护一个固定长度或不定长度的窗口。可以对窗口进行滑动操作、缩放操作,以及维护最优解操作。

  • 滑动操作:窗口可按照一定方向进行移动。最常见的是向右侧移动。

  • 缩放操作:对于不定长度的窗口,可以从左侧缩小窗口长度,也可以从右侧增大窗口长度。

滑动窗口利用了双指针中的快慢指针技巧,我们可以将滑动窗口看做是快慢指针两个指针中间的区间,也可以将滑动窗口看做是快慢指针的一种特殊形式。

在这里插入图片描述
滑动窗口算法一般用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。该算法可以将一部分问题中的嵌套循环转变为一个单循环,因此它可以减少时间复杂度。

按照窗口长度的固定情况,我们可以将滑动窗口题目分为以下两种:

  1. 固定长度窗口:窗口大小是固定的。
  2. 不定长度窗口:窗口大小是不固定的。
    • 求解最大的满足条件的窗口。
    • 求解最小的满足条件的窗口。
固定长度滑动窗口

固定长度滑动窗口算法(Fixed Length Sliding Window):在给定数组 / 字符串上维护一个固定长度的窗口。可以对窗口进行滑动操作、缩放操作,以及维护最优解操作。

在这里插入图片描述

固定长度滑动窗口算法步骤 :

假设窗口的固定大小为 window_size

使用两个指针 leftright。初始时,leftright 都指向序列的第一个元素,即:
left = 0right = 0,区间 [left, right] 被称为一个「窗口」。

  1. 当窗口未达到 window_size 大小时,不断移动 right,先将数组前 window_size 个元素填入窗口中,即 window.append(nums[right])
  2. 当窗口达到 window_size 大小时,即满足 right - left + 1 >= window_size 时,判断窗口内的连续元素是否满足题目限定的条件。如果满足,再根据要求更新最优解。
  3. 然后向右移动 left,从而缩小窗口长度,即 left += 1,使得窗口大小始终保持为 window_size
  4. 向右移动 right,将元素填入窗口中,即 window.append(nums[right])
  5. 重复步骤 2 ∼ 4,直到 right 到达数组末尾。
不定长度滑动窗口

不定长度滑动窗口算法(Sliding Window):在给定数组 / 字符串上维护一个不定长度的窗口。可以对窗口进行滑动操作、缩放操作,以及维护最优解操作。

在这里插入图片描述

不定长度滑动窗口算法步骤

使用两个指针 leftright。初始时,leftright 都指向序列的第一个元素,即:
left = 0right = 0,区间 [left, right] 被称为一个「窗口」。

  1. 将区间最右侧元素添加入窗口中,即 window.add(s[right])
  2. 然后向右移动 right,从而增大窗口长度,即 right += 1,直到窗口中的连续元素满足要求。
  3. 此时,停止增加窗口大小。转向不断将左侧元素移出窗口,即 window.popleft(s[left])
  4. 然后向右移动 left,从而缩小窗口长度,即 left += 1,直到窗口中的连续元素不再满足要求。
  5. 重复步骤 2 ~ 4,直到 right 到达序列末尾。

分治算法

分治算法(Divide and Conquer):字面上的解释是「分而治之」,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。

在这里插入图片描述

简单来说,分治算法的基本思想就是: 把规模大的问题不断分解为子问题,使得问题规模减小到可以直接求解为止。分治通常基于递归实现,包括“分”和“治”两个步骤。

  1. 分(划分阶段):递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
  2. 治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。

如图所示,“归并排序”是分治策略的典型应用之一。

  1. :递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
  2. :从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。

在这里插入图片描述
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据:

  1. 问题可以分解:原问题可以分解成规模更小、类似的子问题,并且能够以相同方式递归地进行划分。
  2. 子问题是独立的:子问题之间没有重叠,互不依赖,可以独立解决。
  3. 子问题的解可以合并:原问题的解通过合并子问题的解得来。

显然,归并排序满足以上三个判断依据。

  1. 问题可以分解:递归地将数组(原问题)划分为两个子数组(子问题)。
  2. 子问题是独立的:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
  3. 子问题的解可以合并:两个有序子数组(子问题的解)可以合并为一个有序数组(原问题的解)。

通过分治提升效率

分治不仅可以有效地解决算法问题,往往还可以提升算法效率。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。

那么,我们不禁发问:为什么分治可以提升算法效率,其底层逻辑是什么?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高?这个问题可以从操作数量和并行计算两方面来讨论。

操作数量优化

以“冒泡排序”为例,其处理一个长度为n的数组需要O(n²)时间。假设将数组从中点处分为两个子数组,则划分需要O(n)时间,排序每个子数组需要O((n/2)²)时间,合并两个子数组需要O(n)时间,总体时间复杂度为:

O(n + (n/2)² × 2 + n) = O(n² / 2 + 2n)
在这里插入图片描述
接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数:
在这里插入图片描述
这意味着当n > 4时,划分后的操作数量更少,排序效率应该更高。请注意,划分后的时间复杂度仍然是平方阶O(n²),只是复杂度中的常数项变小了。

进一步想,如果我们把子数组不断地再从中点处划分为两个子数组,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为O(n log n)。

如果我们多设置几个划分点,将原数组平均划分为k个子数组呢?这种情况与“桶排序”非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到O(n + k)。

并行计算优化

分治生成的子问题是相互独立的,因此通常可以并行解决。也就是说,分治不仅可以降低算法的时间复杂度,还有利于操作系统的并行优化。

并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。

比如在图所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再合并结果。

在这里插入图片描述

分治常见应用

一方面,分治可以用来解决许多经典算法问题。

  • 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部分的最近点对。
  • 大整数乘法:例如Karatsuba算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
  • 矩阵乘法:例如Strassen算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
  • 汉诺塔问题:汉诺塔问题可以通过递归解决,这是典型的分治策略应用。
  • 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以利用分治的思想,借助归并排序进行求解。

另一方面,分治在算法和数据结构的设计中应用得非常广泛。

  • 二分查找:二分查找是将有序数组从中点索引处分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,并在剩余区间执行相同的二分操作。
  • 归并排序:本节开头已介绍,不再赘述。
  • 快速排序:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
  • 桶排序:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的元素依次取出,从而得到一个有序数组。
  • :例如二叉搜索树、AVL树、红黑树、B树、B+树等,它们的查找、插入和删除等操作都可以视为分治策略的应用。
  • :堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
  • 哈希表:虽然哈希表并不直接应用分治,但某些哈希冲突解决方案间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。

可以看出,分治是一种“润物细无声”的算法思想,隐含在各种算法与数据结构之中。

分治二分查找

搜索算法分为两大类。

  • 暴力搜索:它通过遍历数据结构实现,时间复杂度为 O(n)。
  • 自适应搜索:它利用特有的数据组织形式或先验信息,时间复杂度可达到 O(logn) 甚至 O(1)。

实际上,时间复杂度为 O(logn) 的搜索算法通常是基于分治策略实现的,例如二分查找和树。

  • 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止。
  • 是分治思想的代表,在二叉搜索树、AVL树、堆等数据结构中,各种操作的时间复杂度皆为 O(logn)。

二分查找的分治策略如下所示:

  • 问题可以分解:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
  • 子问题是独立的:在二分查找中,每轮只处理一个子问题,它不受其他子问题的影响。
  • 子问题的解无须合并:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题得到解决时,原问题也会同时得到解决。

分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项。在之前的章节中,二分查找是基于递推(迭代)实现的。现在我们基于分治(递归)来实现它。

从分治角度,我们将搜索区间 [i, j] 对应的子问题记为 f(i, j)。

以原问题 f(0, n−1) 为起始点,通过以下步骤进行二分查找:

  1. 计算搜索区间 [i, j] 的中点 m,根据它排除一半搜索区间。
  2. 递归求解规模减小一半的子问题,可能为 f(i, m−1) 或 f(m+1, j)。
  3. 循环第 1 步和第 2 步,直至找到 target 或区间为空时返回。

在这里插入图片描述

/* 二分查找:问题 f(i, j) */
func dfs(nums []int, target, i, j int) int {
	// 如果区间为空,代表没有目标元素,则返回-1
	if i > j {
		return -1
	}
	// 计算索引中点
	m := i + ((j - i) >> 1)
	//判断中点与目标元素大小
	if nums[m] < target {
		// 小于则递归右半数组
		// 递归子问题 f(m+1, j)
		return dfs(nums, target, m+1, j)
	} else if nums[m] > target {
		// 小于则递归左半数组
		// 递归子问题 f(i, m-1)
		return dfs(nums, target, i, m-1)
	} else {
		// 找到目标元素,返回其索引
		return m
	}
}

/* 二分查找 */
func binarySearch(nums []int, target int) int {
	n := len(nums)
	return dfs(nums, target, 0, n-1)
}

构建二叉树

给定一棵二叉树的前序遍历 preorder 和中序遍历inorder ,请从中构建二叉树,返回二叉树的根节点。

在这里插入图片描述
原问题定义为从 preorderinorder 构建二叉树,是一个典型的分治问题。

  • 问题可以分解:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每棵子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
  • 子问题是独立的:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
  • 子问题的解可以合并:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,得到原问题的解。

根据以上分析,这道题可以使用分治来求解,但如何通过前序遍历树和右子树呢?根据定义,preorderinorder 都可以划分为三个部分。

  • 前序遍历preorder 和中序遍历 inorder 来划分左子树和右子树。

    • 格式:[ 根节点 | 左子树 | 右子树 ]
    • 例如,图中的树对应:[ 3 | 9 | 2 1 7 ]。
  • 中序遍历

    • 格式:[ 左子树 | 根节点 | 右子树 ]
    • 例如,图中的树对应:[ 9 | 3 | 1 2 7 ]。

以上图数据为例,我们可以通过下图所示的步骤得到划分结果。

  1. 前序遍历的首元素 3 是根节点的值。
  2. 查找根节点 3inorder 中的索引,利用该索引可将 inorder 划分为:
    • [ 9 | 3 | 1 2 7 ]
  3. 根据 inorder 的划分结果,易得左子树和右子树的节点数量分别为 1 和 3,从而可将 preorder 划分为:
    • [ 3 | 9 | 2 1 7 ]

在这里插入图片描述
为什么不能对 [2, 1, 7] 直接认为根是 1

前序遍历的第一个元素总是树的根节点。这是前序遍历的一个非常重要的特点。因此,无论你处于哪个子树,前序遍历的第一个元素都明确地指示了当前子树的根节点

中序遍历将树分成左子树和右子树。对于一个节点,左边是它的左子树,右边是它的右子树。在构建树时,在前序遍历中找到根节点之后,我们利用中序遍历来划分左子树和右子树的范围

如果你直接对 [2, 1, 7] 使用中序遍历的思路去假设 1 为根节点,这会造成问题。原因如下:

  • 你只能从 前序遍历 来确定每一个子树的根节点。
  • 在前序遍历中,当前右子树的根是 2(因为 2 是在 3 后面的第一个元素)。
  • 然后,在右子树的中序遍历中,我们可以找到 2,将它划分为左右子树:[1][7]

如果你直接假设 1 是右子树的根,你会误认为 1 的左右子树是 [2][7],但是实际的顺序和结构不符合。

回到 217 子数组:

  • 前序遍历的元素顺序是 [2, 1, 7],第一个元素是 2,这表明 2 是当前子树的根节点。
  • 在中序遍历 [1, 2, 7] 中,2 的左子树是 [1],右子树是 [7]
  • 所以,我们对右子树的前序遍历应该是 [2, 1, 7],并且可以继续递归构建 17 的左右子树。

所以,

  • 前序遍历确定根节点:在构建树时,我们先通过前序遍历来确定每个子树的根节点。
  • 中序遍历确定左右子树:然后,借助中序遍历来进一步确定根节点的左子树和右子树。

因此,我们不能直接用中序遍历推断根节点,因为 前序遍历的顺序决定了每个子树的根,这一步是必不可少的。

根据以上划分方法,我们已经得到根节点、左子树和右子树的区间。为了有效地表示这些区间,我们需要借助几个指针变量。

  1. preorder 中的索引 𝑖

    • 𝑖 是当前树的根节点在 preorder 遍历中的索引。
    • 在递归的过程中,每次从 preorder 中获取下一个根节点时,𝑖 会递增。每次递归时,𝑖 指向当前子树的根节点。
  2. inorder 中的索引 𝑚

    • 𝑚 是当前树的根节点在 inorder 遍历中的索引。
    • 根据 𝑚,我们可以将 inorder 遍历结果分成左右子树的部分。左子树的元素在 inorder 中位于 [𝑙, 𝑚-1] 区间,而右子树的元素位于 [𝑚+1, 𝑟] 区间。
  3. inorder 中的索引区间 [𝑙,𝑟]

    • [𝑙, 𝑟] 是当前树在 inorder 遍历中的索引区间,表示当前子树的范围。
    • 初始时,[𝑙, 𝑟] 表示整个树的范围。随着递归的深入,[𝑙, 𝑟] 会逐渐缩小,直到到达叶子节点或空子树。

通过这三个指针变量,我们可以有效地表示当前树的根节点以及左右子树在 preorderinorder 中的位置。每次递归时,我们会基于这些变量来划分树的结构,并进一步构建左子树和右子树。

在这里插入图片描述
请注意,右子树根节点索引中的(𝑚−𝑙)的含义是“左子树的节点数量”:

在这里插入图片描述
代码实现:

/* 构建二叉树:分治 */
func dfsBuildTree(preorder []int, inorderMap map[int]int, i, l, r int) *TreeNode {
	// 子树区间为空时终止
	if r-l < 0 {
		return nil
	}
	// 初始化根节点
	root := NewTreeNode(preorder[i])
	// 查询 m ,从而划分左右子树
	m := inorderMap[preorder[i]]
	// 子问题:构建左子树
	root.Left = dfsBuildTree(preorder, inorderMap, i+1, l, m-1)
	// 子问题:构建右子树
	root.Right = dfsBuildTree(preorder, inorderMap, i+1+m-l, m+1, r)
	// 返回根节点
	return root
}

/* 构建二叉树 */
func buildTree(preorder, inorder []int) *TreeNode {
	// 初始化哈希表,存储 inorder 元素到索引的映射
	inorderMap := make(map[int]int, len(inorder))
	for i := 0; i < len(inorder); i++ {
		inorderMap[inorder[i]] = i
	}
	root := dfsBuildTree(preorder, inorderMap, 0, 0, len(inorder)-1)
	return root
}

下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(引用)是在向上“归”的过程中建立的:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
每个递归函数内的前序遍历 preorder 和中序遍历 inorder 的划分结果如图所示。

在这里插入图片描述
时间复杂度分析:

  1. 每个节点的初始化:对于每一个节点,我们只需执行一次递归函数 dfs()。每次递归只需做常数时间的操作(如获取根节点、查找索引等),因此初始化每个节点的时间复杂度是 O(1)
  2. 递归调用次数:树的节点数量为 n,递归会遍历树中的每个节点一次,因此递归调用的总次数为 n
  3. 总体时间复杂度:因为每次递归调用的操作是常数时间 O(1),且递归总共进行 n 次,因此总体时间复杂度为 O(n)

空间复杂度分析:

  1. 哈希表:为了存储 inorder 中每个元素到索引的映射,我们需要一个哈希表。哈希表的大小与树的节点数量成正比,即需要存储 n 个元素,因此哈希表的空间复杂度为 O(n)
  2. 递归栈空间:在最坏情况下,即二叉树退化为链表时,递归的深度将达到树的节点数量 n,因此递归栈的空间复杂度为 O(n)
  3. 总体空间复杂度:因为哈希表和递归栈空间是独立的,因此总体空间复杂度为 O(n)

回溯算法

回溯算法(backtrackingalgorithm)是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出
发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都
无法找到解为止。

回溯算法通常采用“深度优先搜索”来遍历解空间。“二叉树”的前序、中序和后序遍历都属于深度优先搜索。

比如:给定一棵二叉树,搜索并记录所有值为7的节点,返回节点列表。

对于此题,我们前序遍历这棵树,并判断当前节点的值是否为7,若是,则将该节点的值加入结果列表之中。

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

/* 前序遍历:例题一 */
func preOrderI(root *TreeNode, res *[]*TreeNode) {
	if root == nil {
		return
	}
	if root.Val == 7 {
		// 记录解
		*res = append(*res, root)
	}
	preOrderI(root.Left, res)
	preOrderI(root.Right, res)
}

在这里插入图片描述

之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。

对于例题一,访问每个节点都代表一次“尝试”,而越过叶节点或返回父节点的 return 则表示“回退”。值得说明的是,回退并不仅仅包括函数返回。

比如:在二叉树中搜索所有值为7的节点,请返回根节点到这些节点的路径。

在例题一代码的基础上,我们需要借助一个列表复制 path 并添加进结果列表 res

path 记录访问过的节点路径。当访问到值为 7 的节点时,则将 path 添加到 res 中。遍历完成后,res 中保存的就是所有的解。

/* 前序遍历:例题二 */
func preOrderII(root *TreeNode, res *[][]*TreeNode, path *[]*TreeNode) {
	if root == nil {
		return
	}
	// 尝试
	*path = append(*path, root)
	if root.Val == 7 {
		// 记录解
		*res = append(*res, append([]*TreeNode{}, *path...))
	}
	preOrderII(root.Left, res, path)
	preOrderII(root.Right, res, path)
	// 回退
	*path = (*path)[:len(*path)-1]
}

在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从 path 中弹出,以恢复本次尝试之前的状态。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

剪枝

复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”。

比如:在二叉树中搜索所有值为7的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为3的
节点。

为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为3的节点,则提前返回,不再继续搜索。

/* 前序遍历:例题三 */
func preOrderIII(root *TreeNode, res *[][]*TreeNode, path *[]*TreeNode) {
	// 剪枝
	if root == nil || root.Val == 3 {
		return
	}
	// 尝试
	*path = append(*path, root)
	if root.Val == 7 {
		// 记录解
		*res = append(*res, append([]*TreeNode{}, *path...))
	}
	preOrderIII(root.Left, res, path)
	preOrderIII(root.Right, res, path)
	// 回退
	*path = (*path)[:len(*path)-1]
}

“剪枝”是一个非常形象的名词。在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。

在这里插入图片描述

优点与局限

回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。

然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。

  • 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
  • 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。

即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何优化效率,常见的效率优化方法有两种:

  • 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
  • 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。

回溯典型例题

  1. 搜索问题:这类问题的目标是找到满足特定条件的解决方案。

    • 全排列问题:给定一个集合,求出其所有可能的排列组合。
    • 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
    • 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
  2. 约束满足问题:这类问题的目标是找到满足所有约束条件的解。

    • 𝑛皇后:在𝑛×𝑛的棋盘上放置𝑛个皇后,使得它们互不攻击。
    • 数独:在9×9的网格中填入数字1~9,使得每行、每列和每个3×3子网格中的数字不重复。
    • 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。
  3. 组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。

    • 0‑1背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选择物品使得总价值最大。
    • 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
    • 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。

请注意,对于许多组合优化问题,回溯不是最优解决方案。

  • 0‑1背包问题通常使用动态规划解决,以达到更高的时间效率。
  • 旅行商问题是一个著名的NP‑Hard问题,常用解法有遗传算法和蚁群算法等。
  • 最大团问题是图论中的一个经典问题,可用贪心算法等启发式算法来解决。

全排列

全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出其中元素的所有可能的排列。

无相等元素的情况

从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 [1,2,3],如果我们先选择 1,再选择 3,最后选择 2,则获得排列 [1,3,2]。回退表示撤销一个选择,之后继续尝试其他选择。

我们可以将搜索过程展开成一棵递归树,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。

在这里插入图片描述
为了实现每个元素只被选择一次,引入一个布尔型数组 selected,其中 selected[i] 表示元素 choices[i] 是否已被选择,并基于它实现以下剪枝操作。

  • 在做出选择 choice[i] 后,我们就将 selected[i] 赋值为 True,代表它已被选择。
  • 遍历选择列表 choices 时,跳过所有已被选择的节点,即剪枝。

如图,假设我们第一轮选择1,第二轮选择3,第三轮选择2,则需要在第二轮剪掉元素1的分支,在第三轮剪掉元素1和元素3的分支。

在这里插入图片描述
观察上图发现,该剪枝操作将搜索空间大小从 O(n^n) 减小至 O(n!)。

代码实现:

func _backtrackI(tmp *[]int, selected *[]bool, res *[][]int, choice []int) {
	// 全排列
	if len(*tmp) == len(choice) {
		// 防止切片底层被共享导致的结果一样
		newTmp := append([]int{}, *tmp...)
		*res = append(*res, newTmp)
		return
	}
	for index := 0; index < len(choice); index++ {
		// 未被选择
		if !(*selected)[index] {
			(*selected)[index] = true
			*tmp = append(*tmp, choice[index])
			_backtrackI(tmp, selected, res, choice)
			// 回退:撤销选择,恢复到之前的状态
			(*selected)[index] = false
			*tmp = (*tmp)[:len(*tmp)-1]
		}
	}
}

func backtrackI(choices []int) [][]int {
	res := make([][]int, 0, len(choices))
	// 用于存储临时解决方案
	tmp := make([]int, 0, len(choices))
	selected := make([]bool, len(choices))
	//
	_backtrackI(&tmp, &selected, &res, choices)
	return res
}

需要注意的是,newTmp := append([]int{}, *tmp...) 这一行是为了避免直接在原始切片 *tmp 上进行修改时,切片的内容被共享的问题。

切片在 Go 中是引用类型。当你将 *tmp 直接添加到 res 中时,实际上你是将 *tmp 的引用传递给了 res。这意味着 res 中的每个元素都指向同一个底层数组,而不是独立的副本。如果你后续修改了 *tmp(例如在回溯过程中修改了 *tmp),res 中所有的元素都会受到影响,因为它们都指向同一个底层数组。

通过 append([]int{}, *tmp...) 创建了一个新的切片 newTmp,这个切片是 *tmp 的一个副本,因此 newTmp*tmp 不共享底层数组。这样,即使在回溯过程中修改了 *tmpres 中保存的每个结果不会受到影响,因为它们是独立的副本。

考虑相等元素的情况

假设输入数组为 [1,1,2]。为了方便区分两个重复元素 1,我们将第二个 1 记为 1’。

在这里插入图片描述
相等元素剪枝:

  1. 在第一轮中,选择 1 或选择 1’ 是等价的,在这两个选择下生成的所有排列都是重复的。因此,应该将一个 1’ 剪枝。

  2. 同理,在第一轮选择 2 之后,第二轮选择中的 1 和 1’ 也会产生重复分支,因此也应将第二轮的 1’ 剪枝。

  3. 从本质上看,我们的目标是在某一轮选择中,确保多个相等的元素仅被选择一次,避免生成重复的排列。

在这里插入图片描述
代码实现:

在上一题的代码基础上,在每一轮选择中开启一个哈希表 duplicated,用于记录该轮中已经尝试过的元素,并将重复元素剪枝:

func _backtrackI(tmp *[]int, selected *[]bool, res *[][]int, choice []int) {
	// 全排列
	if len(*tmp) == len(choice) {
		// 防止切片底层被共享导致的结果一样
		newTmp := append([]int{}, *tmp...)
		*res = append(*res, newTmp)
		return
	}

	duplicated := make(map[int]struct{})
	for index := 0; index < len(choice); index++ {
		cho := choice[index]
		// 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
		if _, ok := duplicated[cho]; !ok && !(*selected)[index] {
			// 尝试:做出选择,更新状态
			// 记录选择过的元素值
			duplicated[cho] = struct{}{}
			(*selected)[index] = true
			*tmp = append(*tmp, cho)
			// 进行下一轮选择
			_backtrackI(tmp, selected, res, choice)
			// 回退:撤销选择,恢复到之前的状态
			(*selected)[index] = false
			*tmp = (*tmp)[:len(*tmp)-1]
		}
	}
}

func backtrackI(choices []int) [][]int {
	res := make([][]int, 0, len(choices))
	// 用于存储临时解决方案
	tmp := make([]int, 0, len(choices))
	selected := make([]bool, len(choices))
	//
	_backtrackI(&tmp, &selected, &res, choices)
	return res
}

假设元素两两之间互不相同,则𝑛个元素共有𝑛!种排列(阶乘);在记录结果时,需要复制长度为𝑛的列表,使用𝑂(𝑛)时间。因此时间复杂度为𝑂(𝑛!𝑛)。

最大递归深度为𝑛,使用𝑂(𝑛)栈帧空间。selected 使用 𝑂(𝑛)空间。同一时刻最多共有𝑛个元素,使用𝑂(𝑛²)空间。因此空间复杂度为𝑂(𝑛²)。

请注意,虽然 selectedduplicated 都用于剪枝,但两者的目标不同。

  • 重复选择剪枝:整个搜索过程中只有一个 selected。它记录的是当前状态中包含哪些元素,其作用是避免某个元素在 state 中重复出现。

  • 相等元素剪枝:每轮选择(每个调用的 backtrack 函数)都包含一个 duplicated。它记录的是在本轮遍历(for循环)中哪些元素已被选择过,其作用是保证相等元素只被选择一次。

在这里插入图片描述

n皇后

根据国际象棋的规则,皇后可以攻击与同处一行、一列或一条斜线上的棋子。给定𝑛个皇后和一个𝑛×𝑛大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。

在这里插入图片描述

如图所示,当𝑛=4时,共可以找到两个解。从回溯算法的角度看,𝑛×𝑛大小的棋盘共有𝑛²个格子,给出了所有的选择choices。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态state。

在这里插入图片描述
皇后的数量和棋盘的行数都为𝑛且多个皇后不能在同一行、同一列、同一条对角线上,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后

也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。

在这里插入图片描述
从本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。

为了满足列约束,我们可以利用一个长度为𝑛的布尔型数组cols记录每一列是否有皇后。在每次决定放置皇后之前,我们通过cols将已有皇后的列进行剪枝,并在回溯中动态更新cols的状态。

那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为(𝑟𝑜𝑤,𝑐𝑜𝑙),选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的𝑟𝑜𝑤−𝑐𝑜𝑙为恒定值。也就是说,如果两个格子满足𝑟𝑜𝑤₁−𝑐𝑜𝑙₁ = 𝑟𝑜𝑤₂−𝑐𝑜𝑙₂,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图示的数组diags1记录每条主对角线上是否有皇后。

同理,次对角线上的所有格子的𝑟𝑜𝑤+𝑐𝑜𝑙是恒定值。我们同样也可以借助数组diags2来处理次对角线约束。

在这里插入图片描述
𝑛维方阵中𝑟𝑜𝑤−𝑐𝑜𝑙的范围是[−𝑛+1,𝑛−1],𝑟𝑜𝑤+𝑐𝑜𝑙的范围是[0,2𝑛−2],所以主对角线和次对角线的数量都为2𝑛−1,即数组diags1和diags2的长度都为2𝑛−1。

/* 回溯算法:n 皇后 */
func backtrack(row, n int, state *[][]string, res *[][][]string, cols, diags1, diags2 *[]bool) {
	// 当放置完所有行时,记录解
	if row == n {
		newState := make([][]string, len(*state))
		for i, _ := range newState {
			newState[i] = make([]string, len((*state)[0]))
			copy(newState[i], (*state)[i])
		}
		*res = append(*res, newState)
	}
	// 遍历所有列
	for col := 0; col < n; col++ {
		// 计算该格子对应的主对角线和次对角线
		diag1 := row - col + n - 1
		diag2 := row + col
		// 剪枝:不允许该格子所在列、主对角线、次对角线上存在皇后
		if !(*cols)[col] && !(*diags1)[diag1] && !(*diags2)[diag2] {
			// 尝试:将皇后放置在该格子
			(*state)[row][col] = "Q"
			(*cols)[col], (*diags1)[diag1], (*diags2)[diag2] = true, true, true
			// 放置下一行
			backtrack(row+1, n, state, res, cols, diags1, diags2)
			// 回退:将该格子恢复为空位
			(*state)[row][col] = "#"
			(*cols)[col], (*diags1)[diag1], (*diags2)[diag2] = false, false, false
		}
	}
}

/* 求解 n 皇后 */
func nQueens(n int) [][][]string {
	// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
	state := make([][]string, n)
	for i := 0; i < n; i++ {
		row := make([]string, n)
		for j := 0; j < n; j++ {
			row[j] = "#"
		}
		state[i] = row
	}
	// 记录列是否有皇后
	cols := make([]bool, n)
	diags1 := make([]bool, 2*n-1)
	diags2 := make([]bool, 2*n-1)
	res := make([][][]string, 0)
	backtrack(0, n, &state, &res, &cols, &diags1, &diags2)
	return res
}

逐行放置𝑛次,考虑列约束,则从第一行到最后一行分别有𝑛、𝑛−1、…、2、1个选择,使用𝑂(𝑛!)时间。当记录解时,需要复制矩阵state并添加进res,复制操作使用𝑂(𝑛²)时间。因此,总体时间复杂度为𝑂(𝑛! ⋅ 𝑛²)。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复杂度。

数组state使用𝑂(𝑛²)空间,数组cols、diags1和diags2皆使用𝑂(𝑛)空间。最大递归深度为𝑛,使用𝑂(𝑛)栈帧空间。因此,空间复杂度为𝑂(𝑛²)。

小结

回溯算法的本质是穷举法,通过深度优先遍历解空间来寻找符合条件的解。其工作流程通常包括以下几个核心要素:

  1. 尝试与回退

    • 尝试:在回溯过程中,算法会逐步尝试各种可能的选择,进入不同的状态或分支。这种过程类似于深度优先搜索(DFS)。
    • 回退:当算法发现当前路径无法满足条件时,会“撤销”之前的选择,回退到上一状态,继续尝试其他可能的选择。回退是回溯算法与普通深度优先搜索的一个重要区别,它允许算法避免无效的路径。
  2. 解空间的遍历
    回溯算法通过遍历整个解空间来寻找解。它不断地试探每个选择,在搜索过程中记录满足条件的解。直到所有可能的解都被尝试过,或者找到所有解后终止。

  3. 剪枝

    • 回溯算法通常会面临多个约束条件。在遇到某个选择无法满足这些条件时,可以通过“剪枝”来避免不必要的搜索。剪枝操作能够提前终止不符合条件的搜索分支,从而有效提升算法的效率。
    • 约束条件帮助我们决定何时剪枝,从而避免无效的计算。例如,如果某个选择导致不满足某些约束(如列、行、对角线等),就可以提前结束搜索。
  4. 回溯问题的应用领域

    • 回溯算法广泛应用于搜索问题约束满足问题(CSP)。在这些问题中,解空间较大,且问题约束复杂,回溯算法通过逐步探索每个可能的解并回退,以找到符合条件的解。
    • 尽管回溯可以解决组合优化问题,但通常在这种类型的应用中,其他算法如动态规划、贪心算法等效率更高,或者能够产生更好的解。

总之,回溯算法的优势在于其能够有效地探索解空间,并通过回退避免无效路径。它的局限性则在于穷举的特性,可能导致较高的时间复杂度,尤其是在没有合理剪枝的情况下。

怎么理解回溯和递归的关系?回溯和递归的关系可以从以下几个方面来理解:

  1. 回溯是递归的应用
    回溯算法通常是基于递归实现的。递归在这里用于“逐步深入”问题的每个子集或状态,而回溯则是对递归的一种具体应用,通常用于解决搜索问题、组合问题等。

  2. 递归是回溯的“工具”
    递归帮助我们将一个大问题分解为多个更小的子问题,直到到达一个最基本的状态或解。回溯则利用递归来探索所有可能的解,通过递归调用逐步尝试每种可能的选择,并在发现无效解时回退(即“回溯”)。

  3. 回溯的核心是“试探”
    回溯算法会遍历所有可能的解,并通过递归探索每一种选择。在每次递归调用中,我们都会“试探”一个选择(比如放置一个元素、选择一个路径等),并根据问题的约束判断是否满足条件。如果不满足条件,就会回退,尝试其他可能的选择。回溯就是在递归过程中通过不断“回退”来避免无效路径。

  4. 递归结构和回溯结构
    递归的结构体现了“子问题分解”的解题范式,适用于分治、动态规划、回溯等问题。递归是通过将一个大问题分解为更小的问题来逐步解决的,而回溯是在递归框架下,结合对解空间的探索与修剪,寻找满足约束条件的所有解。

简言之,递归是回溯的基础工具,回溯则是一种特定的算法策略,利用递归探索问题的解空间。

动态规划

动态规划(dynamic programming)是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。

前面提到,分治的思想是将一个大问题拆解成若干个小问题,递归地解决这些小问题,再将这些小问题的结果合并成大问题的解。(回溯是一种通过递归尝试所有可能的解,并在不符合条件时回溯的策略。与分治的不同之处在于,回溯不总是拆解问题并合并结果,而是通过遍历所有解的空间来查找满足条件的解。)

动态规划在分治的基础上优化,避免了重复计算,通常通过状态表来存储和利用子问题的解。关键在于避免了重复计算。

斐波那契数列:数列由  f(0) = 1,  f(1) = 2  开始,后面的每一项数字都是前面两项数字的和。也就是:

f(n) =  {
0, n = 0
1, n = 1
f(n−2) + f(n−1), n > 1
}

通过公式 f(n) = f(n−2) + f(n−1), 我们可以将原问题 f(n) 递归地划分为 f(n−2) 和 f(n−1) 这两个子问题。其对应的递归过程如下图所示:

在这里插入图片描述
从图中可以看出:如果使用传统递归算法计算 f(5),需要先计算 f(3) 和 f(4),而在计算 f(4) 时还需要计算 f(3),这样 f(3) 就进行了多次计算。同理 f(0)、f(1)、f(2) 都进行了多次计算,从而导致了重复计算问题。

为了避免重复计算,我们可以使用动态规划中的「表格处理方法」来处理。

这里我们使用「自底向上的递推方法」求解出子问题 f(n−2) 和 f(n−1) 的解,然后把结果存储在表格中,供随后的计算查询使用。具体过程如下:

  1. 定义一个数组 dp,用于记录斐波那契数列中的值。
  2. 初始化 dp[0] = 0, dp[1] = 1。
  3. 根据斐波那契数列的递推公式 f(n) = f(n−1) + f(n−2),从 dp(2) 开始递推计算斐波那契数列的每个数,直到计算出 dp(n)。
  4. 最后返回 dp(n) 即可得到第 n 项斐波那契数。
func fib(n int) int {
	if n < 2 {
		return n
	}

	dp := make([]int, n+1)
	dp[0], dp[1] = 0, 1
	for i := 2; i <= n; i++ {
		dp[i] = dp[i-1] + dp[i-2]
	}
	return dp[n]
}

再给出一个经典例题:

给定一个共有𝑛阶的楼梯,你每步可以上1阶或者2阶,请问有多少种方案可以爬到楼顶?

在这里插入图片描述
本题的目标是求解方案数量,我们可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上1阶或2阶,每当到达楼梯顶部时就将方案数量加1,当越过楼梯顶部时就将其剪枝。

/* 回溯 */
func backtrack(choices []int, currentLevel, n int, res []int) {
	// 当爬到第 n 阶时,方案数量加 1
	if currentLevel == n {
		res[0] = res[0] + 1
	}
	// 遍历所有选择
	for _, choice := range choices {
		// 剪枝:不允许越过第 n 阶
		if currentLevel+choice > n {
			continue
		}

		// 选择
		// currentLevel = currentLevel + choice
		backtrack(choices, currentLevel + choice, n, res)
		// 回退
		// currentLevel = currentLevel - choice
	}
}

/* 爬楼梯:回溯 */
func climbingStairsBacktrack(n int) int {
	// 可选择向上爬 1 阶或 2 阶
	choices := []int{1, 2}
	// 从第 0 阶开始爬
	currentLevel := 0
	// 使用 res[0] 记录方案数量
	res := make([]int, 1)
	res[0] = 0
	backtrack(choices, currentLevel, n, res)
	return res[0]
}

回溯算法通常并不显式地对问题进行拆解,而是将求解问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。

我们可以尝试从问题分解的角度分析这道题。**设爬到第𝑖阶共有𝑑𝑝[𝑖]种方案,**那么𝑑𝑝[𝑖]就是原问题,其子问题包括: 𝑑𝑝[𝑖 − 1], 𝑑𝑝[𝑖 − 2], …, 𝑑𝑝[2], 𝑑𝑝[1]

由于每轮只能上1阶或2阶,因此当我们站在第𝑖阶楼梯上时,上一轮只可能站在第𝑖−1阶或第𝑖−2阶上。(我们只能从第𝑖−1阶或第𝑖−2阶迈向第𝑖阶。)

由此便可得出一个重要推论:爬到第𝑖−1阶的方案数加上爬到第𝑖−2阶的方案数就等于爬到第𝑖阶的方案数。公式如下: 𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2]

这意味着在爬楼梯问题中,各个子问题之间存在递推关系,原问题的解可以由子问题的解构建得来。

在这里插入图片描述
我们可以根据递推公式得到暴力搜索解法。以𝑑𝑝[𝑛]为起始点,递归地将一个较大问题拆解为两个较小问题的和,直至到达最小子问题𝑑𝑝[1]和𝑑𝑝[2]时返回。其中,最小子问题的解是已知的,即𝑑𝑝[1] = 1、𝑑𝑝[2] = 2,表示爬到第1、2阶分别有1、2种方案。

观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁:

/* 搜索 */
func dfs(i int) int {
	// 已知 dp[1] 和 dp[2] ,返回之
	if i == 1 || i == 2 {
		return i
	}
	// dp[i] = dp[i-1] + dp[i-2]
	count := dfs(i-1) + dfs(i-2)
	return count
}

/* 爬楼梯:搜索 */
func climbingStairsDFS(n int) int {
	return dfs(n)
}

对于问题 dp[n],其递归树的深度为 n,时间复杂度为 O(2^n)。指数阶属于爆炸式增长,如果我们输入一个比较大的 n,则会陷入漫长的等待之中。

在这里插入图片描述
图中,指数阶的时间复杂度是由“重叠子问题”导致的。例如,dp[9] 被分解为 dp[8] 和 dp[7],dp[8] 又被分解为 dp[7] 和 dp[6],这两个子问题都包含 dp[7]。以此类推,子问题中会包含更小的重叠子问题,形成无穷尽的递归调用。绝大部分计算资源都浪费在这些重叠的子问题上,从而导致了指数阶的时间复杂度。

因此可以传入一个数组来存储已经存在的答案,如果已经存在则剪枝:

/* 记忆化搜索 */
func dfsMem(i int, mem []int) int {
	// 已知 dp[1] 和 dp[2] ,返回之
	if i == 1 || i == 2 {
		return i
	}
	// 若存在记录 dp[i] ,则直接返回之
	if mem[i] != -1 {
		return mem[i]
	}
	// dp[i] = dp[i-1] + dp[i-2]
	count := dfsMem(i-1, mem) + dfsMem(i-2, mem)
	// 记录 dp[i]
	mem[i] = count
	return count
}

/* 爬楼梯:记忆化搜索 */
func climbingStairsDFSMem(n int) int {
	// mem[i] 记录爬到第 i 阶的方案总数,-1 代表无记录
	mem := make([]int, n+1)
	for i := range mem {
		mem[i] = -1
	}
	return dfsMem(n, mem)
}

在这里插入图片描述

记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯逐层收集子问题的解,构建出原问题的解。

与之相反,动态规划是一种“从底至顶”的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。

由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无须使用递归。在以下代码中,我们初始化一个数组 dp 来存储子问题的解,它起到了与记忆化搜索中数组 mem 相同的记录作用:

/* 爬楼梯:动态规划 */
func climbingStairsDP(n int) int {
	if n == 1 || n == 2 {
		return n
	}
	// 初始化 dp 表,用于存储子问题的解
	dp := make([]int, n+1)
	// 初始状态:预设最小子问题的解
	dp[1] = 1
	dp[2] = 2
	// 状态转移:从较小子问题逐步求解较大子问题
	for i := 3; i <= n; i++ {
		dp[i] = dp[i-1] + dp[i-2]
	}
	return dp[n]
}

与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数𝑖。

根据以上内容,我们可以总结出动态规划的常用术语:

  • 将数组 dp 称为 dp 表,𝑑𝑝[𝑖] 表示状态𝑖对应子问题的解。
  • 将最小子问题对应的状态(第1阶和第2阶楼梯)称为初始状态。
  • 将递推公式 𝑑𝑝[𝑖]=𝑑𝑝[𝑖−1]+𝑑𝑝[𝑖−2] 称为状态转移方程。

由于 𝑑𝑝[𝑖] 只与 𝑑𝑝[𝑖−1] 和 𝑑𝑝[𝑖−2] 有关,因此我们无须使用一个数组储存所有子问题的解,而只需两个变量滚动前进即可。

/* 爬楼梯:空间优化后的动态规划 */
func climbingStairsDPComp(n int) int {
	if n == 1 || n == 2 {
		return n
	}
	a, b := 1, 2
	// 状态转移:从较小子问题逐步求解较大子问题
	for i := 3; i <= n; i++ {
		a, b = b, a+b
	}
	return b
}

观察以上代码,由于省去了数组 dp 占用的空间,因此空间复杂度从 𝑂(𝑛) 降至 𝑂(1)。

在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过“降维”来节省内存空间。这种空间优化技巧被称为“滚动变量”或“滚动数组”。

动态规划问题特性

实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。

  • 分治算法 递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
  • 动态规划 也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
  • 回溯算法 在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作一个子问题。

动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:

  • 最优子结构:原问题的最优解可以由子问题的最优解构建而来。
  • 无后效性:某个状态的最优解不会受到未来状态的影响,只有当前状态及其之前的状态影响当前决策。

最优子结构

最优子结构:指的是一个问题的最优解包含其子问题的最优解。或者说:原问题的最优解是从子问题的最优解构建得来的。

举个例子,如下图所示,原问题 S = {a1, a2, a3, a4},在 a1 步我们选出一个当前最优解之后,问题就转换为求解子问题 S_子问题 = {a2, a3, a4}。如果原问题 S 的最优解可以由「第 a1 步得到的局部最优解」和「S_子问题 的最优解」构成,则说明该问题满足最优子结构性质。

也就是说,如果原问题的最优解包含子问题的最优解,则说明该问题满足最优子结构性质。

在这里插入图片描述

重叠子问题

重叠子问题性质:指的是在求解子问题的过程中,有大量的子问题是重复的,一个子问题在下一阶段的决策中可能会被多次用到。如果有大量重复的子问题,那么只需要对其求解一次,然后用表格将结果存储下来,以后使用时可以直接查询,不需要再次求解。

在这里插入图片描述

之前我们提到的「斐波那契数列」例子中,f(0)f(1)f(2)f(3)都进行了多次重复计算。动态规划算法利用了子问题重叠的性质,在第一次计算 f(0)f(1)f(2)f(3) 时就将其结果存入表格,当再次使用时可以直接查询,无需再次求解,从而提升效率。

无后效性

无后效性:指的是子问题的解(状态值)只与之前阶段有关,而与后面阶段无关。当前阶段的若干状态值一旦确定,就不再改变,不会再受到后续阶段决策的影响。说人话就是:给定一个确定的状态,它的未来发展只
与当前状态有关,而与过去经历的所有状态无关。

以爬楼梯问题为例,给定状态 i,它会发展出状态 i+1 和状态 i+2,分别对应跳 1 步和跳 2 步。在做出这两种选择时,我们无须考虑状态 i 之前的状态,它们对状态 i 的未来没有影响。

一旦做出当前决策,后续的决策不再受到之前状态的影响。我们只需要关心当前状态以及如何从当前状态达到目标,而不需要回头考虑之前的状态。

如果一个问题具有「后效性」,则可能需要先将其转化或者逆向求解来消除后效性,然后才可以使用动态规划算法。

实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,我们通常会选择使
用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。

动态规划解题思路(先问是不是,再问为什么)

上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题。

  1. 如何判断一个问题是不是动态规划问题?
  2. 求解动态规划问题该从何处入手,完整步骤是什么?

总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常适合用动态规划求解。然而,我们很难从问题描述中直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。

适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。

换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。

在此基础上,动态规划问题还有一些判断的“加分项”:

  • 问题包含最大(小)或最多(少)等最优化描述。
  • 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。

相应地,也存在一些“减分项”:

  • 问题的目标是找出所有可能的解决方案,而不是找出最优解。
  • 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。

如果一个问题满足决策树模型,并具有较为明显的“加分项”,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。

动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:

  1. 划分阶段:将原问题按顺序(时间顺序、空间顺序或其他顺序)分解为若干个相互联系的「阶段」。划分后的阶段一定是有序或可排序的,否则问题无法求解。
    这里的「阶段」指的是子问题的求解过程。每个子问题的求解过程都构成一个「阶段」,在完成前一个阶段的求解后才会进行下一个阶段的求解。

  2. 定义状态:将和子问题相关的某些变量(位置、数量、体积、空间等等)作为一个「状态」表示出来。状态的选择要满足无后效性。
    一个「状态」对应一个或多个子问题,所谓某个「状态」下的值,指的就是这个「状态」所对应的子问题的解。

  3. 状态转移:根据「上一阶段的状态」和「该状态下所能做出的决策」,推导出「下一阶段的状态」。
    或者说,根据相邻两个阶段各个状态之间的关系,确定决策,然后推导出状态间的相互转移方式(即「状态转移方程」)。

  4. 初始条件和边界条件:根据问题描述、状态定义和状态转移方程,确定初始条件和边界条件。

  5. 最终结果:确定问题的求解目标,然后按照一定顺序求解每一个阶段的问题。最后根据状态转移方程的递推结果,确定最终结果。

动态规划相关的问题往往灵活多变,思维难度大,没有特别明显的套路,并且经常会在各类算法竞赛和面试中出现。

贪心算法

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,
都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。贪心算法简洁且高效,
在许多实际问题中有着广泛的应用。

贪心算法动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但
工作原理不同:

  • 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
  • 贪心算法不会考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。
给定𝑛种硬币,第𝑖种硬币的面值为𝑐𝑜𝑖𝑛𝑠[𝑖−1],目标金额为𝑎𝑚𝑡,每种硬币可以重复选取,问
能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回−1

给定目标金额,我们贪心地选择不大于且最接近它的硬币,不断循环该步骤,直至凑出目标金额为止。

在这里插入图片描述

/* 零钱兑换:贪心 */
func coinChangeGreedy(coins []int, amt int) int {
	// 假设 coins 列表有序
	i := len(coins) - 1
	count := 0
	// 循环进行贪心选择,直到无剩余金额
	for amt > 0 {
		// 找到小于且最接近剩余金额的硬币
		for i > 0 && coins[i] > amt {
			i--
		}
		// 选择 coins[i]
		amt -= coins[i]
		count++
	}
	// 若未找到可行方案,则返回-1
	if amt != 0 {
		return -1
	}
	return count
}

贪心算法的优点与局限性

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。贪心算法简洁且高效,在许多实际问题中有着广泛的应用。

贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理不同。

  • 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
  • 贪心算法不会考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。

给定 n 种硬币,第 i 种硬币的面值为 coins[i-1],目标金额为 amt,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 -1

贪心算法不仅操作直接、实现简单,而且通常效率也很高。在以上代码中,记硬币最小面值为 min(coins),则贪心选择最多循环 amt / min(coins) 次,时间复杂度为 O(amt / min(coins))。这比动态规划解法的时间复杂度 O(n * amt) 小了一个数量级。

然而,对于某些硬币面值组合,贪心算法并不能找到最优解。下图给出了两个示例。

  • 正例 coins = [1, 5, 10, 20, 50, 100]:在该硬币组合下,给定任意 amt,贪心算法都可以找到最优解。
  • 反例 coins = [1, 20, 50]:假设 amt = 60,贪心算法只能找到 50 + 1×10 的兑换组合,共计 11 枚硬币,但动态规划可以找到最优解 20 + 20 + 20,仅需 3 枚硬币。
  • 反例 coins = [1, 49, 50]:假设 amt = 98,贪心算法只能找到 50 + 1×48 的兑换组合,共计 49 枚硬币,但动态规划可以找到最优解 49 + 49,仅需 2 枚硬币。

在这里插入图片描述

也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。

一般情况下,贪心算法的适用情况分以下两种:

  1. 可以保证找到最优解:贪心算法在这种情况下往往是最优选择,因为它往往比回溯、动态规划更高效。
  2. 可以找到近似最优解:贪心算法在这种情况下也是可用的。对于很多复杂问题来说,寻找全局最优解非常困难,能以较高效率找到次优解也是非常不错的。

贪心算法特性

那么问题来了,什么样的问题适合用贪心算法求解呢?或者说,贪心算法在什么情况下可以保证找到最优解?

相较于动态规划,贪心算法的使用条件更加苛刻,其主要关注问题的两个性质:

  1. 贪心选择性质:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
  2. 最优子结构:原问题的最优解包含子问题的最优解。

最优子结构已经在“动态规划”中介绍过,这里不再赘述。值得注意的是,一些问题的最优子结构并不明显,但仍然可使用贪心算法解决。

我们主要探究贪心选择性质的判断方法。虽然它的描述看上去比较简单,但实际上对于许多问题,证明贪心选择性质并非易事。

贪心算法解题步骤

贪心问题的解决流程大体可分为以下三步:

  1. 问题分析:梳理与理解问题特性,包括状态定义、优化目标和约束条件等。这一步在回溯和动态规划中都有涉及。

  2. 确定贪心策略:确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终解决整个问题。

  3. 正确性证明:通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要用到数学证明,例如归纳法或反证法等。

确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要有以下原因:

  • 不同问题的贪心策略的差异较大。对于许多问题来说,贪心策略比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。

  • 某些贪心策略具有较强的迷惑性。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是一个典型案例。

为了保证正确性,我们应该对贪心策略进行严谨的数学证明,通常需要用到反证法或数学归纳法。然而,正确性证明也很可能不是一件易事。如若没有头绪,我们通常会选择面向测试用例进行代码调试,一步步修改与验证贪心策略。

贪心算法典型例题

贪心算法常常应用在满足贪心选择性质和最优子结构的优化问题中,以下列举了一些典型的贪心算法问题:

  • 硬币找零问题:在某些硬币组合下,贪心算法总是可以得到最优解。
  • 区间调度问题:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
  • 分数背包问题:给定一组物品和一个载重量,你的目标是选择一组物品,使得总重量不超过载重量,且总价值最大。如果每次都选择性价比最高(价值/重量)的物品,那么贪心算法在一些情况下可以得到最优解。
  • 股票买卖问题:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
  • 霍夫曼编码:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
  • Dijkstra 算法:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Generalzy

文章对您有帮助,倍感荣幸

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值