算法原理与分析


1 算法是什么

1.1 算法定义

算法(algorithm)是在有限时间内解决特定问题的一组指令或操作步骤,具有以下特性。

  • 问题是明确的,包含清晰的输入和输出定义。
  • 具有可行性,能够在有限步骤、时间和内存空间下完成。
  • 各步骤都有确定的含义,在相同的输入和运行条件下,输出始终相同。

1.2 数据结构定义

数据结构(data structure)是组织和存储数据的方式,涵盖数据内容、数据之间关系和数据操作方法,具有以下设计目标。

  • 空间占用尽量少,以节省计算机内存。
  • 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等。
  • 提供简洁的数据表示和逻辑信息,以便算法高效运行。

数据结构设计是一个充满权衡的过程。如果想在某方面取得提升,往往需要在另一方面作出妥协。

  • 链表相较于数组,在数据添加和删除操作上更加便捷,但牺牲了数据访问速度。
  • 图相较于链表,提供了更丰富的逻辑信息,但需要占用更大的内存空间。

1.3 数据结构与算法的关系

如图所示,数据结构与算法高度相关、紧密结合,具体表现在以下三个方面。

  数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及操作数据的方法。

  算法为数据结构注入生命力。数据结构本身仅存储数据信息,结合算法才能解决特定问题。

  算法通常可以基于不同的数据结构实现,但执行效率可能相差很大,选择合适的数据结构是关键。

  数据结构与算法犹如拼装积木。一套积木,除了包含许多零件之外,还附有详细的组装说明书。按照说明书一步步操作,就能组装出精美的积木模型。

  两者的详细对应关系如下表所示:

数据结构与算法 拼装积木
输入数据 未拼装的积木
数据结构 积木组织形式,包括形状、大小、连接方式等
算法 把积木拼成目标形态的一系列操作步骤
输出数据 积木模型

  数据结构与算法是独立于编程语言的。通常将“数据结构与算法”简称为“算法”。比如众所周知的 LeetCode 算法题目,实际上同时考查数据结构和算法两方面的知识。

2 复杂度分析

复杂度分析犹如浩瀚的算法宇宙中的时空向导。

带领我们在时间与空间这两个维度上深入探索,寻找更优雅的解决方案。

2.1 算法效率评估

在算法设计中,先后追求以下两个层面的目标。

  1. 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
  2. 寻求最优解法:同一个问题可能存在多种解法,希望找到尽可能高效的算法。

在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。

  • 时间效率:算法运行时间的长短。
  • 空间效率:算法占用内存空间的大小。

目标是设计“既快又省”的数据结构与算法。而有效地评估算法效率至关重要,因为只有这样,才能将各种算法进行对比,进而指导算法设计与优化过程。

效率评估方法主要分为两种:实际测试、理论估算。

1.实际测试

  假设有算法 A 和算法 B ,它们都能解决同一问题,现在需要对比这两个算法的效率。最直接的方法是找一台计算机,运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大的局限性。

  难以排除测试环境的干扰因素。— 硬件配置会影响算法的性能表现。

   比如一个算法的并行度较高,那么就更适合在多核 CPU 上运行,一个算法的内存操作密集,那么在高性能内存上的表现就会更好。也就是说,算法在不同的机器上的测试结果可能是不一致的。这意味着需要在各种机器上进行测试,统计平均效率,而这是不现实的。

  展开完整测试非常耗费资源。— 随着输入数据量的变化,算法会表现出不同的效率。

   在输入数据量较小时,算法 A 的运行时间比算法 B 短;而在输入数据量较大时,测试结果可能恰恰相反。因此,为了得到有说服力的结论,需要测试各种规模的输入数据,而这需要耗费大量的计算资源。

2.理论估算

  由于实际测试具有较大的局限性,可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为渐近复杂度分析(asymptotic complexity analysis),简称复杂度分析。

  复杂度分析能够体现算法运行所需的时间和空间资源与输入数据规模之间的关系。描述了随着输入数据规模的增加,算法执行所需时间和空间的增长趋势。这个定义有些拗口,可以将其分为三个重点来理解。

  • 时间和空间资源分别对应时间复杂度(time complexity)和空间复杂度(space complexity)。
  • 随着输入数据规模的增加意味着复杂度反映了算法运行效率与输入数据规模之间的关系。
  • 时间和空间的增长趋势表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的快慢

  复杂度分析克服了实际测试方法的弊端,体现在以下几个方面。

  • 无需实际运行代码,更加绿色节能。
  • 独立于测试环境,分析结果适用于所有运行平台。
  • 可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。

复杂度分析提供了一把评估算法效率的“标尺”,可以衡量执行某个算法所需的时间和空间资源,对比不同算法之间的效率。

2.1 迭代

在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。

在程序中实现重复执行任务,即两种基本的程序控制结构:迭代、递归。

  迭代(iteration)是重复执行某个任务的控制结构。在迭代中,程序在满足一定的条件下重复执行某段代码,直到这个条件不再满足。

1.for 循环

  for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用

  以下函数基于 for 循环实现了求和1+2+…+n ,求和结果使用变量 res 记录。

// for循环
// 以下函数基于 for 循环实现了求和 1 + 2 + … + n,求和结果使用变量 res 记录。
// Go语言三段式for循环条件 i <= n 代表闭区间[1, n],遍历1到n所有整数。

func forLoop(n int) int {
	res := 0

	///  循环求和 1,2,3,...,n-1,n
	for i := 1; i <= n; i++ {
		res += i
	}
	return res
}

func main() {
	iterateFor := forLoop(3)
	fmt.Println("for循环的求和结果:", iterateFor)
}

  此求和函数的操作数量与输入数据大小n成正比,即成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”

2.while 循环

  与 for 循环类似,while 循环也是实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。

  • Go语言中没有 while 关键字:该函数演示了如何在 Go 中模拟 while 循环的效果
  • 循环结构for 条件 { ... } 的形式等价于其他语言的 while(条件) { ... }
  • 执行流程:先判断条件 i <= n,若为真则执行循环体,否则退出循环
/* while 循环 */
func whileLoop(n int) int {
	res := 0 // 初始化累加结果为0
	// 初始化条件变量
	i := 1 // 初始化循环变量为1
	// 循环求和 1,2,3,...,n-1,n
	for i <= n { // 当i <= n 时继续循环(类似while循环的条件)
		res += i
		// 更新条件变量
		i++ // 更新循环变量(i自增1)
	}
	return res // 返回最终求和结果
}

func main() {
	iterateWhile := whileLoop(3)
	fmt.Println("while循环的求和结果:", iterateWhile)
}

  while 循环比 for 循环的自由度更高。在 while 循环中,可以自由地设计条件变量的初始化和更新步骤。

  在以下代码中,条件变量每轮进行两次更新,这种情况就不太方便用 for 循环实现:

/* while 循环 (两次更新) */
func whileLoop2(n int) int {
	res := 0
	i := 1   // 初始化条件变量
    // 循环求和 1, 4, 10, ...
	for i <= n {   // 循环统计满足条件的序列元素个数
		res += 1
		i++    // 更新条件变量
		i *= 2
	}
	return res
}

func main() {
	iterateWhile2 := whileLoop2(3)
	fmt.Println("while循环(两次更新)的求和结果:", iterateWhile2)
}

  总结:for 循环的代码更加紧凑,while 循环更加灵活,两者都可以实现迭代结构。

3.嵌套循环

  可以在一个循环结构内嵌套另一个循环结构,以 for 循环为例:

核心逻辑

  双层循环结构:外层控制 i,内层控制 j,形成 n × n 的二维遍历

  执行顺序:对于每个固定的 i,内层循环会遍历所有 j 的值

  字符串拼接:每次内层循环将当前 (i,j) 对格式化为 "i, j" 的形式追加到结果中

/* 双层for循环 */
func nestedForLoop(n int) string {
	res := ""                 // 初始化结果字符串为空
	for i := 1; i <= n; i++ { // 外层循环:i 从 1 到 n  控制行数
		for j := 1; j <= n; j++ { // 内层循环:j 从 1 到 n   控制每一层的列数
			res += fmt.Sprintf("(%d, %d)\n", i, j) // 将 (i,j) 格式化为字符串追加到res
		}
	}
	return res // 返回拼接后的完整字符串
}

func main() {
	iteratenestedForLoop := nestedForLoop(2)
	fmt.Println("双层for循环的遍历结果:", iteratenestedForLoop)
}

  这种情况下,函数的操作数量与n^2成正比,或者说算法运行时间和输入数据大小 成平方关系。可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至立方关系四次方关系,以此类推。

2.2 递归

在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。

在程序中实现重复执行任务,即两种基本的程序控制结构:迭代、递归。

递归(recursion)是一种算法策略,通过函数调用自身来解决问题。主要包含两个阶段。

  1. :程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
  2. :触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。

从实现的角度看,递归代码主要包含三个要素。

  终止条件:用于决定什么时候由“递”转“归”。

  递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。

  返回结果:对应“归”,将当前递归层级的结果返回至上一层。

以下代码,只需调用函数 recur(n) ,就可以完成1+2+…+n 的计算:

/* 递归 */

func recur(n int) int {
	// 终止条件
	if n == 1 {
		return 1
	}

	// 递: 递归调用
	res := recur(n - 1)

	// 归: 返回结果
	return n + res
}

func main() {

	reCur := recur(8)
	fmt.Println("递归函数的求和结果:", reCur)
}

迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式

  迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。

  递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。

以上述求和函数为例,设问题f(n) = 1 + 2 + … + n

  迭代:在循环中模拟求和过程,从1遍历到n,每轮执行求和操作,即可求得f(n) 。

  递归:将问题分解为子问题f(n) = n + f(n-1) ,不断(递归地)分解下去,直至基本情况f(1) = 1时终止。

1.调用栈

递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。

  • 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间
  • 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低

如下图所示,在触发终止条件前,同时存在 n个未返回的递归函数,递归深度为n。

  实际编程时,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。

2.尾递归

如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况称为尾递归(tail recursion)。

  • 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
  • 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。

以计算1+2+…+n为例,可以将结果变量 res 设为函数参数,从而实现尾递归:

func tailRecur(n int, res int) int {
	// 终止条件
	if n == 0 {
		return res
	}

	// 尾递归调用
	return tailRecur(n-1, res+n)
}

func main() {
	tailreCur := tailRecur(3, 0)
	fmt.Println("尾递归函数的求和结果:", tailreCur)
}

尾递归执行过程如下图所示。普通递归和尾递归的求和操作执行点是不同的:

  普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。

  尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。

3.递归树

当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。

以“斐波那契数列”为例:给定一个斐波那契数列0,1,1,2,3,5,8,13,……,求该数列的第n个数字。

设斐波那契数列的第n个数字为f(n),易得两个结论:

  • 数列的前两个数字为f(1) = 0 和 f(2) = 1。
  • 数列中的每个数字是前两个数字的和,即f(n) = f(n-1) + f(n-2)。

按递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那契数列的第n个数字:

/* 斐波那契数列:递归 */
func fib(n int) int {
	// 终止条件 f(1) = 0, f(2) = 1
	if n == 1 || n == 2 {
		return n - 1
	}

	// 递归调用 f(n) = f(n-1) + f(n-2)
	res := fib(n-1) + fib(n-2)

	// 返回结果 f(n)
	return res

}

func main() {

	fibRecur := fib(6)
	fmt.Println("斐波那契数列的第6项:", fibRecur)
}

// 在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。这样不断递归调用下去,最终将产生一棵层数为n的递归树。

从本质上看,递归体现了“将问题分解为更小子问题”的思维范式。

  • 从算法角度看:搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
  • 从数据结构角度看:递归天然适合处理链表、树和图的相关问题,因为非常适合用分治思想进行分析。

2.3 迭代与递归的对比

  迭代和递归在实现、性能和适用性上有所不同:

迭代 递归
实现方式 循环结构 函数调用自身
时间效率 效率通常较高,无函数调用开销 每次函数调用都会产生开销
内存使用 通常使用固定大小的内存空间 累积函数调用可能使用大量的栈帧空间
适用问题 适用于简单循环任务,代码直观、可读性好 适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰

  以上述递归函数为例,求和操作在递归的“归”阶段进行。意味着最初被调用的函数实际上是最后完成其求和操作的,这种工作机制与栈的“先入后出”原则异曲同工

“调用栈”和“栈帧空间”这类递归术语已经暗示了递归与栈之间的密切关系。

  :当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据。

  :当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境。

使用一个显式的栈来模拟调用栈的行为,从而将递归转化为迭代形式:

/* 使用迭代模拟递归 */
func forLoopRecur(n int) int {
	// 使用一个显式的栈来模拟系统调用栈
	stack := list.New()
	res := 0

	// 递:递归调用
	for i := n; i > 0; i-- {
		// 通过“入栈操作”模拟“递”
		stack.PushBack(i)
	}

	// 归:返回结果
	for stack.Len() != 0 {
		// 通过“出栈操作”模拟“归”
		res += stack.Back().Value.(int)
		stack.Remove(stack.Back())
	}

	// res = 1+2+3+...+n
	return res
}

func main() {
	resResult := forLoopRecur(5)
	fmt.Println("使用迭代模拟递归求和的结果为:", resResult)
}

上述代码分析

1.功能概述:使用迭代(循环)模拟递归过程,计算从 1 到 n 的累加和(即 1 + 2 + 3 + … + n)。该函数通过显示栈手动模拟递归的”递”与”归”两个阶段,深刻展示了递归与迭代的本质等价性。

2.核心设计思想:递归的本质是利用系统调用栈实现”先递后归”,而该函数通过手动管理栈来模拟这一过程:

递归阶段 迭代模拟方式 对应操作
入栈 将数据压入栈中
出栈 从栈中弹出并计算

3.执行流程详解:以forLoopRecur(5)为例

阶段一:"递"---入栈过程

for i := n; i > 0; i-- {
	stack.PushBack(i)   // 依次将5,4,3,2,1入栈
}

栈状态变化:

初始: []
入栈5: [5]
入栈4: [5, 4]
入栈3: [5, 4, 3]
入栈2: [5, 4, 3, 2]
入栈1: [5, 4, 3, 2, 1]

阶段二:"归"---出栈累加

for stack.Len() != 0 {
    res += stack.Back().Value.(int)  // 取栈顶元素累加
    stack.Remove(stack.Back())       // 弹出栈顶元素
}

执行过程:

步骤 栈状态 操作 res 值
1 [5, 4, 3, 2, 1] 弹出 1,res += 1 1
2 [5, 4, 3, 2] 弹出 2,res += 2 3
3 [5, 4, 3] 弹出 3,res += 3 6
4 [5, 4] 弹出 4,res += 4 10
5 [5] 弹出 5,res += 5 15
6 [] 栈空,循环结束 15

4.复杂度分析:

复杂度类型 结果 说明
时间复杂度 O(n) 入栈 n 次,出栈 n 次
空间复杂度 O(n) 栈最多存储 n 个元素

5.与递归版本的对比:

特性 递归版本 recur 迭代版本 forLoopRecur
栈管理 系统自动管理 手动管理显式栈
空间开销 函数调用栈 数据结构栈
适用场景 代码简洁,深度可控 避免栈溢出风险

6.设计价值:

该函数的意义不在于计算累加和本身(直接公式 n*(n+1)/2 更高效),而在于展示递归的底层机制

递归 = 入栈(递) + 出栈(归)

这种思想在处理复杂递归问题(如树的遍历、图的深度优先搜索)时尤为重要,帮助开发者在递归深度过大时手动转换为迭代形式,避免栈溢出。

当递归转化为迭代后,代码变得更加复杂了。尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做。

  • 转化后的代码可能更加难以理解,可读性更差。
  • 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。

选择迭代还是递归取决于特定问题的性质

2.4 时间复杂度

运行时间可以直观且准确地反映算法的效率。如果想准确预估一段代码的运行时间,应该如何操作:

  1.确定运行平台:硬件配置、编程语言、系统环境等。

  2.评估各种计算操作所需的运行时间,例如加法操作 + 需要 1 ns ,乘法操作 * 需要 10 ns ,打印操作 print() 需要 5 ns 等。

  3.统计代码中所有的计算操作:将所有操作的执行时间求和,从而得到运行时间。

例如在以下代码中,输入数据大小为n:


文章作者: 罗宇
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 罗宇 !
  目录