算法设计高效指南-全-
算法设计高效指南(全)
原文:
annas-archive.org/md5/5f0ba566b86db0052b379e58b4789df4
译者:飞龙
第一章:前言
本书适合谁阅读
本书内容
为了从本书中获取最大收益
下载示例代码文件
使用的约定
<st c="11761">文中的代码</st>
<st c="12029">for i in range(1, n + 1):</st>
def dp_fib(n, memo={}):
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = dp_fib(n-1, memo) + dp_fib(n-2, memo)
return memo[n]
pip install networkx matplotlib
联系我们
分享你的想法
下载本书的免费 PDF 副本
扫描二维码或访问下面的 链接
-
提交你的购买 凭证 -
就这样! 我们将把免费的 PDF 和其他福利直接发送到你的 电子邮箱
第二章:第一部分:算法分析基础
-
第一章 , 算法分析导论 -
第二章 , 算法正确性中的数学归纳法和循环不变式 -
第三章 , 复杂度分析中的增长速率 -
第四章 , 递归与递推函数 -
第五章 , 递推函数求解
第三章:1
算法分析简介
-
算法 和问题解决 -
算法分析的理论依据 -
算法分析的双重维度——效率 和正确性
理解算法和问题解决
-
程序员独立性 :理想情况下,算法的最终产品应当在很大程度上独立于谁来实现它。 这意味着,不管程序员是谁,算法都应始终产生相同的正确结果,且计算成本或资源使用在 不同的实现中应该大致相似。 -
硬件独立性 :一个有效的算法应尽可能独立于运行它的硬件。 它应能够在各种硬件平台上产生一致的结果,而无需重大修改或依赖于特定的 硬件特性。 -
抽象性与清晰性 :算法应当是抽象且明确的,不留任何解释的余地。 这种清晰性确保了算法能够被理解 并且始终如一地实施,无论程序员的主观理解 或方法如何。 -
可量化的正确性与成本 :算法的正确性——其产生预期结果的能力——和其成本,包括时间和内存等计算资源,必须是可量化的。 这使得可以根据算法的效率 和有效性对不同算法进行客观评估和比较。
<st c="8840">[3, 1, 4, 1, 5, 9, 2]</st>
<st c="8951">[1, 1, 2, 3, 4, 5, 9]</st>
算法分析的理由
-
算法正确性 :无论计算资源多么丰富,必须在所有场景下数学证明算法的正确性。 通过实例展示有效性并不足够;需要数学框架来进行严格的证明。 这确保了算法在各种条件 和输入下的可靠性。 -
算法效率 :算法分析是理解不同算法效率的关键。 通过检查时间和空间复杂度,我们可以选择或设计不仅更快而且更具资源效率的算法。 这在资源有限的环境中,或处理大数据集时尤为重要。 此外,当多种算法可用于同一任务时,分析有助于做出明智的决策,选择哪种算法 来使用。 -
更好的解决问题能力 :深入了解算法的功能及如何分析它们,能够提高解决问题的能力。 这包括有条理地将问题分解为更小的部分,并设计最优解决方案,这项技能在计算以外的许多领域同样具有价值 。 -
理解局限性 :理解算法的局限性与找到最有效的解决方案同样重要。 算法分析帮助识别算法能够或不能有效解决的问题,这在处理时间、内存或特定数据 结构限制时至关重要。 -
为未来挑战做准备 :技术和数据的格局持续演变,规模和复杂性不断增长。 在算法分析方面的坚实基础,使我们能够有效地应对并解决这些新兴的 计算挑战。
让我们进行一个思想实验。假设在计算资源无限的情况下,拥有极其快速的处理器和几乎免费的内存。考虑一个包含大量元素的数组,A,其中包含非常多的元素,n。我们的目标是将这些元素按升序排列。为了简化算法设计和分析的复杂性,我们可能会选择生成所有可能的* A *排列,并检查每一个排列是否已经排序。这种暴力破解方法需要生成 种排列。 然而,即使是最基础的排序算法,例如冒泡排序,其复杂度也为
,而更先进的排序算法可以提供更高的效率。这个例子说明了深入理解算法分析如何显著改变我们解决问题的方式,强调即便在计算资源丰富的世界里,设计高效算法的重要性。
在这本书中,我们探讨了四种主要的算法问题解决方法,每种方法都有其独特的优点、局限性以及在不同类型问题中的适用性:
-
顺序或直接方法:这种方法是最基本的解决问题的形式。它涉及一系列线性指令,通常包括循环和决策点。虽然顺序算法相对容易测试和调试,但它们可能会计算开销较大,从而在处理复杂任务时效率较低。
-
分治法 :这种方法通过将问题分解为更小、更易处理的子问题来解决问题。 每个子问题都独立求解,然后它们的解决方案被组合起来解决原始的更大问题。 然而,将顺序算法转化为分治策略并不总是有利的。 一个典型的例子是阶乘问题,其中顺序方法更为简单(参见 第四章 )。 -
动态规划 :动态规划通过将问题分解为较小的子问题来解决它,递归地解决每一个子问题。 动态规划的一个关键要求是子问题必须有重叠,这样方法才能有效地重用先前计算的解决方案。 这种方法的局限性在于它需要具有重叠子问题的必要性,这种情况并非总是存在(参见 第十章 )。 -
贪婪算法 :贪婪算法专注于从一组可能解决方案中找到最优解。 它们在每一步都做出最佳选择,旨在达到全局最优。 贪婪算法的挑战在于,它们不一定始终导致最佳的整体解决方案,因为在每一步做出局部最优选择并不一定会导致全局最优解(参见 第十章 )。
算法分析的双重维度——效率与正确性
-
终止性 :一个算法必须在有限的步骤后得以结束。 它不应陷入无限循环或无休止地运行,无论提供什么样的输入。 -
有效性 :算法必须对每个可能的输入产生预期的结果或有效的解决方案。 它需要精确地遵循问题的规范和 要求。
-
时间效率或计算复杂度 :这与算法解决问题所需的时间有关,特别是当输入数据的大小增长时。 了解一个算法的时间复杂度至关重要,以便确定它如何高效地处理日益增长的 大数据集。 -
空间效率 :这指的是一个算法在执行过程中所需要的内存量。 估算内存使用量至关重要,尤其是在数据密集型任务或内存资源有限的环境中。 内存资源。
-
电池和能耗 :在移动应用中,算法的效率可以显著影响电池寿命。 要求较少处理能力的算法有助于节省电池,这是移动计算中一个至关重要的因素。 。 -
数据传输和网络访问 :对于需要数据传输和网络连接的算法 ,传输的数据量和网络访问的频率成为关键的效率因素。 这在网络带宽有限或成本高昂的应用中尤为重要 。 -
基于云的服务 :在算法严重依赖云服务的场景中,必须考虑与这些服务相关的成本。 这不仅包括计算成本,还包括云环境中的数据存储和传输成本。 。 -
人工标注在人工智能和机器学习中的作用 :某些算法,特别是在人工智能和机器学习中,可能需要人工标注或干预。 这一过程所涉及的时间和精力也可能成为整体效率和实用性的关键因素。 这些算法的效率。
总结
第四章:2
数学归纳法与算法正确性中的循环不变式
-
数学归纳法 -
算法正确性的循环不变式 的数学归纳法
数学归纳法
在计算机算法的背景下,数学归纳可以被看作是验证算法正确性的强大工具。
算法可以被视为一个复杂的序列,其中输入,,表示案例编号或循环索引,序列体现了算法的功能期望。
在数学归纳的过程中,证明从一个边界开始,通常是当时。从那里开始,任务是证明如果假设对某个任意自然数
有效,则对
也是真实的。一旦对
(这可以是任何自然数)建立了这一点,它有效地证明了所有自然数
的假设。这种一步一步的方法,从一个自然数到下一个自然数的进展,强调了数学归纳在纯数学和算法设计中的可靠性和适用性。
-
基础案例(初始步骤) :这 包括评估并证明假设对于最小的 值成立,通常是 或者 ,具体取决于问题的要求。 在算法设计的上下文中,基础案例通常是 ,因为测试算法通常从第一个可能的、非空的实例开始。 此步骤确立了假设在 序列起始点的有效性。 -
归纳步骤 :这一 步骤要求证明,如果假设对于某个任意的案例编号有效 ,那么它对于 依然有效。这个过程,通常称为假设或归纳假设,涉及通过证明某个案例的假设成立,从而逻辑地推断出下一个案例的假设也成立。 通过成功地展示这一点,我们可以确定假设对于所有后续案例成立,直到 无穷大。
-
基础情况 : 对于 ,序列只包含一个元素,即 1\。 根据 公式: 基础情况成立,因为方程的两边 相等。 -
归纳步骤 : 假设命题对于 ;即假设 满足 。
现在,证明它适用于
根据归纳假设,前面几个数的和是
加上
-
简单命令 :这些是 不依赖于输入大小的指令。 例如,基本的赋值操作,如 x = 0 。尽管可能涉及复杂的算术或逻辑操作,这些命令依然不依赖于输入大小。 我们将其称为 规模无关命令 。通常,这些命令的正确性是直观的,且常被认为是理所当然的,因为它们的简单性。 -
复合命令 :该 类别包括其他命令的块,这些命令可以是简单命令或其他复合命令。 复合命令可能被封装在一个函数调用内,或者由一系列更简单的命令组成。 然而,最显著的复合命令形式是 选择块 和 迭代 : -
选择 :这些 算法中的命令修改了控制流或程序中的逻辑方向。 最常用的选择命令是 if-then 指令块。 这些块的正确性至关重要,取决于算法的逻辑结构,因此需要仔细分析以确保它们按预期运行。 -
迭代 :如果 我们将算法类比为一辆车辆,那么简单命令代表了车辆的车身和所有固定部件,而选择组件则类似于转向机制。 然而,车辆的运动依赖于 发动机,类似地,算法的运行也依赖于其迭代组件。 虽然所有其他组件可能是规模无关的,但循环定义了算法的规模——换句话说,它们的成本和复杂性。 此外,算法的正确性在很大程度上依赖于这些 迭代组件。
-
def binary_search(ar,x):
low = 0
high = len(ar) - 1
mid = 0
while low <= high:
mid = (high + low) // 2
if ar[mid] < x:
low = mid + 1
elif ar[mid] > x:
high = mid - 1
else:
return mid
-
简单命令 : -
low = 0 :初始化 搜索区域的下边界 边界 -
high = len(ar) - 1 :根据 数组的长度 设置上边界 -
mid = 0 :初始化 中点变量
这些命令设置了搜索的初始条件,且不直接依赖于输入的大小。 它们会在 循环开始之前执行一次。 -
-
迭代块 :当 while 循环( while low <= hig h: )将持续进行,只要数组中还有部分 剩余元素需要考虑,定义了算法的复杂度,并且通过确保搜索边界内的每个元素 都被考虑,影响算法的正确性。 -
选择块 :在 循环内部,根据 ar[mid] 与 x 的比较, low 和 high 边界被调整,从而缩小搜索空间。 其中, if 和 elif 条件调整搜索边界(low 和 high),而 else 条件则处理目标被找到的情况,直接影响算法中的流向和决策过程。 。
算法正确性的循环不变量
-
初始化 :在初始站点(起始站),公交车是空的,车上没有学生。 这为我们的循环不变式设定了初始条件,即公交车上的乘客(学生)最初为零。 乘客数量最初为零。 -
维护 :当公交车从一个车站到下一个车站时(从站点 i -1 到站点 i),学生只会上车,没有人下车。 因此,车上的学生人数要么增加,要么保持不变,但绝不会减少。 这维护了循环不变式,即车上乘客人数只能增加。 -
终止 :当到达学校时,接送学生的过程停止。 循环不变式成立,因为在整个旅程中,学生人数只会增加或保持不变。 在最后这一点,循环不变式确认没有学生被送下车,且每个停靠站只能增加学生。 站点。
while low <= high:
mid = (high + low)//2
if ar[mid] < x:
low = mid + 1
elif ar[mid] > x:
high = mid - 1
else:
return mid
<st c="17767">low <= high</st>
<st c="18863">low <= high</st>
<st c="19078">low</st>
<st c="19086">high</st>
<st c="19164">low</st>
<st c="19176">high</st>
-
不同阶段的有效性 :循环不变量必须在循环开始前、每次迭代过程中以及循环退出时始终保持为真。 相比之下,循环条件仅在循环过程中需要为真,以允许循环继续进行。 一旦循环退出(当 low 超过 high 时), low <= high 条件为假,这对于停止循环是必要的,但与 循环不变量的要求不符。 -
条件的作用 :循环条件的主要作用是根据当前的状态检查是否可以继续循环,基于 low 和 high 。它是一种控制机制,而不是关于算法有效性或过程完整性的正确性声明或保证。 另一方面,循环不变量涉及保持某种属性或条件,这些属性或条件在整个执行过程中验证算法逻辑的正确性。 它的执行。
<st c="20969">low</st>
<st c="20976">high</st>
<st c="21078">ar[mid]</st>
总结
参考文献和进一步阅读
-
算法导论 . 作者:托马斯·H. 科尔曼、查尔斯·E. 莱瑟森、罗纳德·L. 里维斯特和克利福德·斯坦恩。 第四版。 麻省理工学院出版社 。 2022 年。 -
算法设计 . 作者:乔恩·克莱因伯格和埃娃·塔尔多斯。 第一版。 皮尔森 。 2005 年。 -
计算机程序设计的艺术 ,第 1 卷:基础算法。 唐纳德·E. 克努斯。 第三版。 阿迪森-韦斯利专业出版 。 1997 年。 -
算法解锁 . 作者:托马斯·H. 科尔曼。 麻省理工学院出版社 。 2013 年。 -
离散数学及其应用 . 作者:肯尼斯·H. 罗森。 麦格劳-希尔科学/工程/数学出版 第十二版。 麦格劳-希尔 。 2012 年。 -
《具体数学:计算机科学基础》 . 作者:罗纳德·L. 格雷厄姆,唐纳德·E. 克努斯,欧仁·帕塔什尼克。 第二版。 艾迪生-韦斯利出版社。 1994 年。 -
《如何证明:结构化方法》 . 作者:丹尼尔·J. 韦尔曼。 第三版。 剑桥大学出版社。 2019 年。
第五章:3
复杂度分析的增长速率
-
算法增长速率解析 中的算法 -
渐进符号 -
应对无法解决的问题 – 非确定性多项式时间( NP)-困难问题
算法增长速率解析
-
做出明智的 设计决策 -
理解如何设计高效且 低成本的算法
常数增长
-
报告输入的第一个数字 :这与输入的数字 数量无关 -
访问数组元素 :通过索引检索元素是一个常数 时间操作 -
在链表的开头插入一个元素 :这个操作涉及更新几个指针,所需的时间是 恒定的
在这些例子中,
亚线性增长
-
二分查找 ( ) : 二分查找 是一种高效的算法,用于在 排序数组中通过反复将搜索区间对半分割来查找元素。 该算法特别适用于大型的排序数据集。 以下是二分查找算法的一个简单 Python 实现。 迭代部分使用 while 循环 实现: def binary_search(a, x): low = 0 high = len(a) - 1 while low <= high: mid = (high + low) // 2 if a[mid] < x: low = mid + 1 elif a[mid] > x: high = mid - 1 else: return mid return -1 print(binary_search([0,1,3,5,8,10],10))
二分查找的最坏情况时间复杂度为 当被查找的项位于二叉树的底部时。 在最优情况下,它的时间复杂度为 。对数增长率, ,是多对数增长的特例,表示为 或 ,其中 且常数。 在对数增长中,我们 有 。 -
跳跃搜索( ) :跳跃搜索(或称为块搜索)是一种算法,旨在通过按固定步长跳跃前进,再在识别的块内执行线性搜索,从而高效地在已排序的数组中查找元素。 这种方法特别适用于大型已排序数组,在这些数组中,线性搜索会过于缓慢。 这里展示的是跳跃搜索算法的 Python 代码实现: import math def jump_search(a, x): n = len(a) step = int(math.sqrt(n)) prev = 0 while a[min(step, n)-1] < x: prev = step step += int(math.sqrt(n)) if prev >= n: return -1 for i in range(prev, min(step, n)): if a[i] == x: return i return -1
-
插值搜索( ) :一个具有复杂度的算法示例 是插值搜索 算法。 插值搜索是对二分搜索的改进,适用于有序数组中值分布均匀的情况。 当数据均匀分布时,搜索可以在 的时间复杂度下完成。 这适用于数据均匀分布的情况。 插值搜索的工作原理是估计目标值在有序数组中的位置。 它根据数组的范围和目标值,使用公式计算目标的可能位置。 如果数据的分布均匀,位置估算非常准确,从而大大减少了比较的次数。 以下是插值搜索的 Python 实现代码,供你参考: def interpolation_search(a, x): low = 0 high = len(a) - 1 while low <= high and x >= a[low] and x <= a[high]: if low == high: if a[low] == x: return low return -1 pos = low + ((high - low) // (a[high] - a[low]) * (x - a[low])) if a[pos] == x: return pos if a[pos] < x: low = pos + 1 else: high = pos - 1 return -1 ```</st></st>
线性增长
算法分析中最直接的增长速率函数是线性时间,表示为
def factorial(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
def factorial_recursive(n):
if n == 0 or n == 1:
return 1
else:
return n * factorial_recursive(n - 1)
非线性增长
非线性时间算法族涵盖了各个领域中一系列著名的复杂问题,包括数据处理、优化、机器学习和人工智能。这些算法的增长速度比线性时间复杂度更为复杂,复杂度范围从相对高效的算法,如O(n log n)
,到极为昂贵的算法,具有非多项式增长的复杂度,例如指数级的O(2^n)
和阶乘级的O(n!)
时间。
以下是一些非线性算法的例子:
-
算法 :如 归并排序 、 快速排序 ,以及 堆排序 都 属于这一类。 这些是经典的排序算法,它们的时间复杂度为 。它们对于大数据集非常高效,并且由于其相对可控的增长率,在实际应用中经常使用。 在接下来的章节中,我们将详细讨论这一类算法。 由于其高效性和广泛适用于各种 排序任务,这些算法在计算机科学中是基础性的。 -
对数线性时间 :对数线性时间算法,也 称为 准线性时间算法 ,其时间复杂度为 ,其中 是一个正的常数。 许多著名的算法属于这一类,并且在高效处理大数据集时非常重要。 对数线性时间算法的示例如下: -
归并排序 :一种 经典的 分治排序算法,它将输入数组分割成更小的子数组,对它们进行排序,然后将它们合并回一起。 它的时间复杂度 是 。 -
堆排序 :该 排序算法从输入数据构建堆数据结构,然后反复提取最大元素来构建排序后的数组。 它的时间复杂度 是 。 -
快速排序 :另一种 分治排序算法,它选择一个 枢轴元素并围绕枢轴将数组分区。 在平均情况下,它的时间复杂度 是 。
-
-
多项式时间算法 :多项式时间算法的复杂度为 ,其中 a 是一个小常数。 这些算法通常被认为在中等大小的输入下高效,但随着输入规模的增加,其性能可能会显著下降。 以下是 一些例子: -
矩阵乘法 :标准的矩阵乘法算法时间复杂度为 。更先进的算法,如斯特拉森算法,可以将此复杂度降低到大约 ,但它仍然是多项式复杂度。 -
弗洛伊德-沃舍尔最短路径 :该 算法找到从源节点到图中所有其他节点的最短路径,前提是图中的边权为非负数。 其时间复杂度为 ,其中 𝑉 是图中的顶点数。
多项式时间算法 由于其相对可预测的性能,对于许多实际应用至关重要。 然而,随着输入规模的增长,它们的效率可能会成为问题,使得它们不太适用于非常大的数据集。 尽管如此,它们仍然是算法设计的基石,为计算机科学 和工程中的广泛问题提供了可行的解决方案。 -
-
指数时间算法 :这些 算法的特点是极其缓慢且计算开销巨大。 它们的复杂度一般形式为 ,其中 𝑎 是一个正的常数。 这些算法的一个常见特殊情况是 。一个著名的指数时间算法示例是递归解决方案 用于解决 汉诺塔问题 。 汉诺塔问题涉及将 𝑛 个圆盘从一个柱子移动到另一个柱子,使用第三个柱子作为辅助,遵循 以下规则: -
每次只能移动一个圆盘 。 -
一个圆盘只能放在一个 更大的圆盘上 。
汉诺塔问题的递归解法的时间复杂度为 ,因为每一步都涉及递归地解决两个大小为 𝑛−1 的子问题。 汉诺塔问题的时间复杂度为 ,将在后续章节中详细探讨,当我们讨论递归函数时。 此问题的递归函数为 ,将通过替代法和主方法等技术进行分析,以推导出复杂度。 通过这些方法,可以证明 。要全面了解,请参阅 第四章 和 第五章 。 这是一个关于汉诺塔问题的递归 Python 实现: def tower_of_hanoi(n, source, target, auxiliary): if n == 1: print(f"Move disk 1 from {source} to {target}") return tower_of_hanoi(n - 1, source, auxiliary, target) print(f"Move disk {n} from {source} to {target}") tower_of_hanoi(n - 1, auxiliary, target, source)
指数时间算法 通常在大规模输入时不切实际,因为随着输入规模的增长,其运行时间会急剧增加。 这些算法通常用于没有已知有效解的问题,它们作为一种基准,用于比较更 复杂算法的性能。 -
-
阶乘时间算法 :这些 算法会生成一个集合的所有排列,因此它们在计算上非常昂贵。 一个典型的例子是暴力破解方法解决 旅行商问题( TSP ),它的时间复杂度是阶乘级别 为 。 旅行商问题(TSP)是一个经典的优化问题,在这个问题中,销售员必须找到一条最短的路线,访问一组城市并且每个城市只能访问一次,最后回到起始城市。 给定一组城市以及每对城市之间的距离,目标是确定最有效的旅行路线,最小化总的 旅行距离。
from itertools import permutations
def calculate_distance(permutation, distance_matrix):
distance = 0
for i in range(len(permutation) - 1):
distance += distance_matrix[permutation[i]][permutation[i + 1]]
distance += distance_matrix[permutation[-1]][permutation[0]] # Return to the start
return distance
def tsp_brute_force(distance_matrix):
n = len(distance_matrix)
cities = list(range(n))
min_distance = float('inf')
best_permutation = None
for permutation in permutations(cities):
current_distance = calculate_distance(permutation, distance_matrix)
if current_distance < min_distance:
min_distance = current_distance
best_permutation = permutation
return best_permutation, min_distance
![]() |
|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
渐近符号
在渐近符号中的简化规则
-
去除常数 :忽略常数系数,因为它们不影响 增长率。 示例 : 简化为 . -
保留最重要的项 :保留增长率最高的项,因为它在 n
变大时主导了该函数。示例 : 简化为 为 . -
合并相似项 :加法时,保留增长率最高的项。 示例 : 简化为 ![+ 项的乘积 :当乘以函数时,乘上它们的 增长率。 示例 : 简化为 ![+ 嵌套函数 :对于嵌套函数,外层函数的增长 率占主导。 示例 : 简化为 ! 。
-
示例 3.1 : 简化 。 解答 :去掉常数项和低阶项: 。 -
示例 3.2 : 简化 . 解答 :舍去低阶 项: . -
例子 3.3 : 简化 . 解答 :保留增长最快的 项: . -
例子 3.4 : 简化 。 解答 :乘以增长 率: 。 -
示例 3.5 : 简化 。 解答 :首先简化内层 函数: .
渐近界限
-
最坏情况 :最坏情况考虑了在给定最具挑战性的输入数据的情况下,算法完成所需的最大时间。 。这种分析提供了一个保证,确保算法不会超过此时间,这对于那些对性能要求严格的应用尤为重要。 描述最坏情况的渐近符号是 符号(大 O 符号)。 这被称为 渐近 上界 。 -
平均情况 :平均情况评估算法在所有可能输入(大小为 )下的期望运行时间,假设输入的概率分布为某种特定形式。 这种分析提供了算法在典型使用场景下性能的更现实的衡量标准,揭示了其在正常操作条件下的效率。 用于描述平均情况的渐近表示法通常是 表示法(大 Theta 表示法)。 这被称为 渐近 紧界限 界限 。 -
最优情况 :最优情况检查算法在最有利的输入(大小为 )下完成所需的最短时间。虽然在性能分析中不常用,但它可以突出算法在最佳条件下的效率。 用于描述最优情况的渐近表示法,称为 渐近下界 ,通常是 -表示法(大 Omega 表示法)。
渐近上界(O 表示法)
为了简化,我们将两边同时除以
展示属于的成员。
如前所述,表示一组函数。 表 3.2 提供了属于
的函数及其对应的常数
和
。
![]() |
![]() |
![]() |
---|---|---|
![]() |
||
![]() |
||
![]() |
||
![]() |
||
![]() |
||
![]() |
使用这个近似公式来表示 ,我们需要证明以下内容:
对两边取对数,我们得到以下结果:
这简化为以下形式:
渐近下界( ![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><mml:mi mathvariant="bold">Ω</mml:mi></mml:math>]()
-符号)
一个算法被认为是
对于较大的
![]() |
![]() |
![]() |
---|---|---|
![]() |
||
![]() |
||
![]() |
||
![]() |
||
![]() |
||
![]() |
渐近紧界( ![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><mml:mi mathvariant="bold-italic">θ</mml:mi></mml:math>]()
-符号)
对于较大的!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:min</mml:mi></mml:math>
如前所述, 符号表示一组函数。表 3.4 提供了属于
的函数示例,以及它们对应的常数
,和
。
![]() |
![]() |
![]() |
![]() |
---|---|---|---|
![]() |
|||
![]() |
|||
![]() |
|||
![]() |
![]() |
![]() |
|
---|---|---|
![]() |
![]() |
|
![]() |
![]() |
面对无法解决的 NP-hard 问题
-
固有复杂性 :某些问题本质上具有复杂性,随着输入大小的增加,解决它们所需的步骤数呈指数级增长。 这种复杂性通常使得在合理的时间内找到解决方案变得不切实际,甚至是不可能的。 例如,涉及检查所有可能配置或组合的问题,如旅行商问题(TSP),可能有阶乘或指数级数量的 可能解决方案。 -
资源约束 :即使在现代计算能力下,解决一些问题所需的资源(时间、内存或两者)也可能超出可行的限制。 那些需要更多计算资源的问题,超过可用资源时,变得 实际上无法解决。 -
非确定性特性 :一些问题在给出解决方案的情况下可以快速验证,但找到解决方案本身需要探索一个指数级大的搜索空间。 这些问题属于 NP 类,其中解决方案可以在多项式时间内检查,但用 当前的算法找到它们是不可行的。 -
不可判定性 :某些问题是不可判定的,这意味着没有算法可以为所有可能的输入确定解决方案。 最著名的例子是 停机问题 ,它询问给定的计算机程序是否最终会停止或永远运行。 艾伦·图灵证明了 没有算法能够为所有可能的程序 和输入解决这个问题。
NP,NP 完全,和 NP 困难的复杂度
-
NP :NP 是 一类决策问题,对于这些问题,给定的解决方案可以在多项式时间内通过确定性图灵机验证其正确性或错误性。 如果一个问题属于 NP,那么就存在一个算法,可以在 时间内验证某个 常数 的解决方案。 -
NP-complete : NP-complete 问题 是 NP 问题的一个子集,既在 NP 中又与 NP 中的任何问题一样困难。 如果一个问题是 NP-complete,那么每个 NP 中的问题都可以通过多项式时间归约到它。 有效地(在多项式时间内)解决一个 NP-complete 问题意味着每个 NP 中的问题都可以在多项式时间内解决,实质上是将复杂性类 P 和 NP 折叠在一起的可能性。 解决任何 NP-complete 问题的多项式时间算法的存在仍然是计算机科学中最重要的未解问题之一。 。 -
NP-hard : NP-hard 问题 代表了计算问题中至少与 NP 复杂度类中最困难的问题一样具有挑战性的类别。 与 NP-complete 问题相比,NP-hard 问题不局限于决策问题(是/否问题),也不一定能在 NP 内部解决。 NP-hard 问题通常以指数时间复杂度为特征。 如果一个问题是 NP-hard,目前没有已知的多项式时间算法来解决它,其渐近界限通常用超多项式函数表示,如 或 。
总结
参考文献与进一步阅读
-
算法导论 . 作者:Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest 和 Clifford Stein。 第四版。 MIT 出版社。 2022 年。 -
算法设计 . 作者:Jon Kleinberg 和 Éva Tardos。 第一版。 Pearson 出版社。 2005 年。 -
《 计算机程序的艺术,第 1 卷:基础算法 . 作者:Donald E. Knuth。 第三版。 Addison-Wesley Professional。 1997 年。 -
算法概要:实践指南 . 作者:George Heineman, Gary Pollice, Stanley Selkow。 第二版。 O’Reilly Media。 2016 年。 -
算法 . 作者:Robert Sedgewick 和 Kevin Wayne。 第四版。 Addison-Wesley Professional。 2011 年。 -
C++中的数据结构与算法分析 . 作者:Mark Allen Weiss。 第四版。 Pearson。 2013 年。 -
离散数学及其应用 . 作者:Kenneth H. Rosen。 McGraw-Hill Science/Engineering/Math。 第十二版。 McGraw-Hill。 2012 年。 -
具体数学:计算机科学的基础 . 作者:Ronald L. Graham, Donald E. Knuth 和 Oren Patashnik。 第二版。 Addison-Wesley。 1994 年。
第六章:4
递归与递推函数
-
递归算法 -
递推函数 -
展开 递推函数
递归算法
递归的基础知识
def factorial_iterative(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
<st c="4703">for i in range(1, n + 1):</st>
def factorial_recursive(n):
# Base case
if n == 0:
return 1
# Recursive case
else:
return n * factorial_recursive(n - 1)
-
递归调用 :一种在其定义中调用自身的函数,例如 factorial_recursive(n - 1) 。每次调用都会处理原始问题的一个更小或更简单的版本。 -
基准情况 :递归调用停止的条件。 它防止了无限递归,并为问题的最简单版本提供了直接的解决方案。 在递归阶乘中,这个部分是 如下所示: if n == 0: return 1
-
递归情况 :函数中发生递归的部分。 它将问题分解为更小的子问题,并通过调用函数自身来解决这些子问题。 在递归阶乘中,这部分是 return n * factorial_recursive(n - 1) 。
<st c="6909">factorial_recursive(n)</st>
def power_recursive(base, exponent):
# Base case
if exponent == 0:
return 1
# Recursive case
elif exponent % 2 == 0:
half_power = power_recursive(base, exponent // 2)
return half_power * half_power
else:
return base * power_recursive(base, exponent - 1)
-
基本情况 :如果 指数为 0 ,结果是 1 ,因为任何数的零次方 都是 1。 -
<st c="7947">base</st>
在递归调用之后。 -
递归调用 :该函数在 recursive(base, exponent // 2) 和 power_recursive(base, exponent - 1) 中递归调用自身。
递归的类型
直接递归
直接递归发生在一个函数直接调用自身时。这是最常见的递归形式,函数是自引用的。本书迄今为止所有的递归算法示例都是直接递归类型。一些直接递归的主要应用场景如下:
-
简化了那些可以自然分解为相同子问题的问题
-
相较于间接递归,直接递归更易于理解和调试
-
常用于诸如阶乘计算、斐波那契数列和树遍历等问题中
直接递归有几种类型,每种类型适用于不同的计算问题和算法策略。这里是直接递归的主要类型:
-
尾递归:这是递归的一种特殊情况,其中递归调用是函数返回前的最后一步操作。这意味着不会对递归调用的结果执行进一步的操作。尾递归的优势在于它可以通过编译器进行优化,从而避免栈溢出。编译器可以复用现有的栈帧,而不是为每个递归调用创建新的栈帧,有效地将递归转化为循环。一个说明性示例是factorial_tail函数,它通过直接调用自身作为最后一步来计算一个数的阶乘:
def factorial_tail(n, accumulator=1): if n == 0: return accumulator else: return factorial_tail(n - 1, n * accumulator)
尾递归的一种应用是管理链表。尽管读者可能已经熟悉链表数据结构,我们将在第十二章中详细探讨它们。简而言之,链表是一种基本数据结构,由节点组成,每个节点包含一个值和一个指向下一个节点的引用(或链接)。链表的固有递归结构——每个节点可以看作是整个链表的一个小版本——使得递归成为执行诸如遍历、插入和删除等操作的理想方法。
这是一个使用尾递归进行链表递归遍历的示例。 链表的遍历通常通过递归方式进行,处理当前节点后,通过递归调用移动到下一个节点。 在尾递归方法中,处理下一个节点的调用是函数中的最后一个操作。 以下是一个简单的 Python 代码: def traverse_linked_list(node): if node is None: return print(node.value) traverse_linked_list(node.next)
-
头递归 :这是指一个函数的初始操作是对自身的 递归调用。 这意味着函数中的所有其他操作都要等到递归调用完成后才会执行。 虽然头递归不如尾递归常见,但它确实有其用途。 然而,由于需要维护一个待处理操作的栈,直到递归调用展开,因此它通常被认为效率较低。 以下是一个 头递归 的示例: def head_recursive(n): if n == 0: return else: head_recursive(n - 1) print(n)
-
线性递归 :在这种类型的 递归中,一个函数在每次调用时最多进行一次递归调用。 这导致了一个简单的递归调用链,类似一条直线。 以下是一个简单的 线性递归示例: def linear_recursive(n): if n == 0: return 0 else: return n + linear_recursive(n - 1)
-
树形递归 :与线性递归不同,在树形递归中,一个函数在一次调用中会多次调用自身。 这导致了递归调用的分支结构,类似一棵树。 示例包括斐波那契数列计算、树的遍历和快速排序算法: def fun(n): if (n > 0): print(n, end=" ") fun(n - 1) fun(n - 1)
-
二叉递归 :这是指 一个函数在一次调用中对自身进行两次递归调用的模式。 这种方法常用于分治算法,它将一个问题分解成两个较小的子问题,并递归地解决它们。 这些子问题的解决方案随后被组合起来,以得到 原始问题的 解决方案: def fibonacci_binary_recursive(n): if n <= 1: return n else: return fibonacci_binary_recursive(n - 1) + fibonacci_binary_recursive(n - 2)
-
多重递归 :这是一种递归形式,其中一个函数在一次调用中对自身进行 超过两个递归调用。 这种模式比线性递归或二叉递归更为少见,但对于那些固有地分解成多个子问题的问题来说,它可能非常有用。 这些子问题每一个都会递归求解,然后将它们的解决方案结合起来以获得最终结果。 下一个 示例中实现了一个简单的多重递归( multiple_recursive ): def multiple_recursive(n): if n <= 1: return 1 else: return multiple_recursive(n - 1) + multiple_recursive(n - 2) + multiple_recursive(n - 3)
一个更复杂的例子是 如下: def ternary_search(arr, target, start, end): if start > end: return -1 # Target not found else: mid1 = start + (end - start) // 3 mid2 = start + 2 * (end - start) // 3 if arr[mid1] == target: return mid1 elif arr[mid2] == target: return mid2 elif arr[mid1] > target: return ternary_search(arr, target, start, mid1 - 1) # First recursive call elif arr[mid2] < target: return ternary_search(arr, target, mid2 + 1, end) # Second recursive call else: return ternary_search(arr, target, mid1 + 1, mid2 - 1) # Third recursive call
在这个例子中, <st c="14153">ternary_search</st>
函数在一个已排序的数组上执行三分查找。 它将数组分成三个大致相等的部分,并进行三次递归调用 来搜索每个部分,演示了多重递归的概念。 -
嵌套递归 :这是递归的一种更复杂的形式,其中一个函数的 递归调用不仅仅传递一个修改过的参数,而是将另一个递归调用包含在参数中。 这意味着递归的深度可能会迅速增加,使得这种递归类型比线性递归或二叉递归更难分析 和理解: def nested_recursive(n): if n > 100: return n - 10 else: return nested_recursive_function(nested_recursive(n + 11))
间接递归
<st c="15199">functionA</st>
<st c="15215">functionB</st>
<st c="15230">functionB</st>
<st c="15246">functionA</st>
def functionA(n):
if n <= 0:
return "End"
else:
return functionB(n - 1)
def functionB(n):
if n <= 0:
return "End"
else:
return functionA(n - 2)
<st c="15470">functionA</st>
<st c="15486">functionB</st>
<st c="15514">functionA</st>
<st c="15604">n <= 0</st>
-
对于 需要多个阶段转化或处理的问题非常有用 。 -
由于涉及多个函数,因此调试和追踪可能会更加困难。 -
通常 出现在互相递归的算法中 以及某些 状态机中
递归问题解决
-
分解 :在 分解步骤中,将问题拆解为较小的子问题,这些子问题更容易解决。 此步骤涉及确定如何将原始问题划分为更小的部分。 关键是要确保子问题与原始问题具有相同的性质,但在大小上更简单或更小。 尺寸上。 -
征服 :在 征服步骤中,子问题通过递归方式解决。 如果子问题仍然太大,则使用相同的分解、征服和合并方法进一步划分。 这个过程将继续,直到子问题达到基本情况,可以直接解决,而不需要 进一步递归。 -
合并 :在 合并步骤中,将子问题的解决方案合并,以形成原始问题的解决方案。 此步骤涉及将递归调用的结果整合,以获得 最终答案。
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left_half = arr[:mid]
right_half = arr[mid:]
sorted_left = merge_sort(left_half)
sorted_right = merge_sort(right_half)
return merge(sorted_left, sorted_right)
def merge(left, right):
sorted_array = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
sorted_array.append(left[i])
i += 1
else:
sorted_array.append(right[j])
j += 1
# Append any remaining elements
sorted_array.extend(left[i:])
sorted_array.extend(right[j:])
return sorted_array
-
分治 : 归并排序 函数将数组分成两部分, 左半部分 和 右半部分 。这是通过使用数组的中点来完成的。 -
征服 : 归并排序 函数在两个子数组上递归调用。 每次递归调用都会进一步划分数组,直到达到基准情况,即数组长度为 0 或 1 (已排序)。 -
合并 : 归并 函数用于合并已排序的两部分。 它遍历两部分,比较元素并将较小的元素添加到排序后的数组中。 任何剩余的元素都会被 添加到数组中。
递归的优点与挑战
-
简洁性和清晰度 : 递归通常能为具有重复或自相似结构的问题提供更直接且直观的解决方案,如树的遍历、阶乘计算和斐波那契数列。 与迭代解法相比,递归解法通常更加简洁、易读。 这使得代码更易维护和理解。 -
复杂问题的简化 : 递归简化了将复杂问题分解为更简单子问题的过程。 这在分治算法中尤其有用,如归并排序和快速排序。 递归函数还可以产生优雅且简洁的代码,特别是当问题本身具有递归特性时,如动态规划和 组合问题。 -
隐式状态管理 :递归调用通过调用栈本身来管理状态,在许多情况下不需要显式的状态管理。 这可以简化逻辑,并减少与 状态 管理相关的错误几率。
-
性能问题 :每次 递归调用都会向调用栈添加一个新帧,相较于迭代解决方案,这可能会导致显著的开销,尤其是在递归深度较大时。 深度递归或无限递归可能会导致栈溢出错误,特别是当递归深度超过最大栈大小时。 这是在栈内存有限的语言中常见的问题。 -
调试复杂性 :调试递归函数可能会很具挑战性,因为它涉及跟踪多个层次的函数调用。 理解执行流程和每层递归中的状态可能会很困难。 不正确地定义基准情况也可能导致无限递归或错误结果。 确保所有基准情况都被正确处理是保证算法正确性的关键。 -
空间复杂度 :递归算法可能会因为调用栈所需的额外内存而具有较高的空间复杂度。 对于输入规模较大或递归深度较深的问题,这可能会成为一个问题。 递归函数通常需要为每次递归调用额外分配内存,这可能会导致与其 迭代 对应方法相比,增加辅助空间的使用。
-
尾递归 :某些 语言和编译器通过将尾递归函数优化为迭代形式,从而减少了调用栈的开销。 在可能的情况下,设计递归函数时应考虑使其成为尾递归。 -
备忘录法 :使用备忘录法 存储昂贵递归调用的结果,避免重复计算。 这种技术在动态规划中尤为有用。 欲了解更多信息,请参见 第十章 。 -
迭代替代方案 :当递归导致性能或内存问题时,考虑使用迭代解决方案。 迭代算法通常能更高效地实现相同的结果。 迭代算法通常能以更高效的方式实现相同的结果。 更高效。
递归函数
<st c="25397">def factorial_incremental(n):</st> |
|
<st c="25429">result = 1</st> |
![]() |
<st c="25441">for i in range(1, n +</st> <st c="25463">1):</st> |
![]() |
<st c="25471">result *= i</st> |
![]() |
<st c="25532">返回结果</st> |
![]() |
<st c="25547">运行时间</st> <st c="25560">函数 T(n)</st> |
![]() |
<st c="25590">复杂度</st> |
![]() |
减法递归函数
-
减法方法通过从原始问题大小中减去一个常数值(例如 )来分解问题。 它通过减小问题大小来解决问题。 -
较小问题的解决方案随后被用来解决原始问题,通常不需要完全解决原始问题的其余部分。 -
这种方法不像分治法那样常见,但对于某些特定问题,能够从稍微 更小的子问题 获得解答,它是有效的。
-
是每次递归中子问题的数量 ,它的值为 1。 -
表示在每次递归中问题的规模减少 1。 递归步骤。 -
是递归部分。 它表明阶乘函数调用自身 ,并且 。 -
是非递归部分。 它表示在每一步执行的常量时间操作,如乘法和函数 调用开销。
def factorial_recursive(n):
# Base case: if n is 0, the factorial is 1
if n == 0:
return 1
# Recursive case: multiply n by the factorial of (n - 1)
else:
return n * factorial_recursive(n - 1)
def fibonacci_recursive(n):
if n <= 0:
return 0
elif n == 1:
return 1
else:
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
-
表示计算第 个斐波那契数的时间复杂度。 该函数以 作为参数递归调用自身。 -
表示计算第 个斐波那契数的时间复杂度。 该函数以 作为参数递归调用自身。 -
表示每次递归调用中执行的常数时间操作,如加法操作以及其他 常数时间操作。
分治法递归函数
-
意味着子问题的数量不能小于 1 -
意味着每个子问题应该比原始问题小,从而确保算法 最终终止
-
分治法将问题分解为两个或更多大小大致相等的子问题 -
这些子问题的解决方案然后被组合起来,以得到 原始问题的解决方案 -
这种方法通常用于那些可以自然地分解成 独立子问题
-
是每次递归中子问题的数量。 -
表示问题被划分为两个子问题( ),每个子问题的大小为 。解决每个子问题的时间复杂度为 是 。 -
表示合并已排序子数组所需的时间,这与数组的大小成线性关系。
def binary_search(arr, target, low, high):
if low > high:
return -1 # Target is not in the array
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search(arr, target, mid + 1, high)
else:
return binary_search(arr, target, low, mid - 1)
-
表示在数组的一半内进行递归调用查找。 从递归函数中我们知道 并且 。 -
表示常数时间操作,例如将目标与中间元素进行比较并确定下一步搜索的半部分。 。
展开递归函数
总结
参考文献及进一步阅读
-
《算法导论》 . 由 Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest 和 Clifford Stein 编著。 第四版。 MIT 出版社。 2022 年: -
第四章 分治法 , Divide-and-Conquer -
第三十四章 , 高级话题
-
-
算法设计 . 作者:乔恩·克莱因伯格和埃娃·塔尔多斯。 第一版。 皮尔逊 2005 年: -
第五章 , 分治法 与征服 -
第六章 , 递归关系与 主定理
-
-
算法 . 作者:罗伯特·塞奇威克和凯文·韦恩。 第四版。 亚迪生-韦斯利 专业出版。 2011 年: -
第二章 , 算法分析原理 算法分析原理 -
第四章 , 分治算法
-
-
计算机程序设计艺术,第 1 卷:基本算法 . 作者:唐纳德·E·克努斯。 第三版。 亚迪生-韦斯利 专业出版。 1997 年: -
第一章 , 基本概念 -
第二章 , 信息结构
-
-
算法设计与分析导论 . 作者:阿纳尼·列维廷。 第三版。 皮尔逊 2011 年: 第五章 分治法
第七章:5
解决递归函数
-
代入法 方法 -
递归树作为一种 可视化技术 -
主定理 方法 -
超越主定理—— Akra-Bazzi 方法
代入法
迭代法或展开递归
所以,以下是
因此,递推函数的闭式解为
这为我们提供了
在前面的例子中,我们演示了如何使用
例 5.2
解以下递减递推函数:!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:miT</mml:mi><mml:mfenced separators="|">mml:mrowmml:min</mml:mi></mml:mrow></mml:mfenced>mml:mo=</mml:mo>mml:miT</mml:mi><mml:mfenced separators="|">mml:mrowmml:min</mml:mi>mml:mo-</mml:mo>mml:mn2</mml:mn></mml:mrow></mml:mfenced>mml:mo+</mml:mo>mml:msupmml:mrowmml:min</mml:mi></mml:mrow>mml:mrowmml:mn2</mml:mn></mml:mrow></mml:msup></mml:math>
-
问题的规模 被缩减为一个子问题,规模为 大小为 -
递归调用外的工作 是
猜测和归纳法
我们通过猜测
-
猜测 形式 : 我们假设 。具体地,假设 对于某个 常数 。 -
基础情况 : 我们需要建立一个基例。 对于较小的 (例如, ),递推函数会简化。 假设 是一个常数 。 由于我们关心的是渐进行为,我们专注于较大的值 。 -
归纳假设 : 假设 对于所有 我们需要证明成立。 也成立。 -
归纳步骤 : 使用递推函数,我们 得出 。 根据归纳假设,
。将其代入递归函数中,我们得到
。
对于我们的假设
,我们需要
。
-
简化不等式:
为了满足这个不等式,我们需要
。
随着
增大,
接近
。因此,存在一个充分大的
,使得:
-
选择 常数:
我们可以选择
使得前述条件在充分大的
下成立。例如,对于
,有:
-
理解递归函数 :我们首先仔细检查递归函数。 我们查看其中的各个组成部分,例如在每次递归调用中问题规模的变化以及非递归的成本函数 。然后,我们将递归函数与熟悉的模式进行比较,比如常见算法中的模式(例如,归并排序或 二分查找) -
分析增长率 :我们考虑递归函数中的项,以推测它们如何贡献于整体复杂度。 例如,如果递归涉及如下项 或 ,我们可能会猜测解中包含一个对数项,因为每一步都将问题规模减少一个倍数。 如果递归包括一个加法线性项,例如 ,这表明解可能涉及线性或非线性的 增长 。 -
利用经验和模式 :我们使用对常见递推函数及其解的了解。 例如,如果递推式看起来像 ,我们可能猜测 ,因为这个形式对于分治算法来说是典型的。 对于递推式 ,我们可能猜测 ,因为每一步都会将问题规模减少 1,且具有 常数成本。 -
做出有根据的猜测 :基于我们的分析,我们假设一个可能的形式为 。这可能是 ![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:miT</mml:mi><mml:mfenced separators="|">mml:mrowmml:min</mml:mi></mml:r></mml:mfenced>mml:mo=</mml:mo>mml:mi </mml:mi>mml:miO</mml:mi><mml:mfenced separators="|">mml:mrowmml:msupmml:mrowmml:min</mml:mi></mml:mrow>mml:mrowmml:mn2</mml:mn></mml:mrow></mml:msup></mml:mrow></mml:mfenced>+细化猜测 :有时,在通过归纳法验证初步猜测后,我们可能需要对猜测进行细化。 例如,如果我们的猜测 未满足归纳步骤,我们可能需要考虑一个更高阶的项,如 。
变量变换方法
现在,我们解这个新的递推函数!<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:miS</mml:mi>mml:mo(</mml:mo>mml:mim</mml:mi>mml:mo)</mml:mo></mml:math>。让我们展开递推几步,找出其中的模式:
替换为进入第一个方程:
再展开一步:
通过展开这个模式,我们可以看到在每个层级中,S的系数
我们可以在经过
当
递归树作为一种可视化技术
-
构建 递归树 : -
首先写下原始的 递归函数 -
树中的每个节点代表一次对 递归的调用 -
树的根节点对应于原始的 问题 -
一个节点的子节点表示由 递归调用 生成的子问题
-
-
识别 成本 : -
确定每个节点的成本。 此成本通常对应于该步骤中的非递归工作,通常表示为 f(n) 。 -
写下根节点的成本并将其传播到 树中。
-
-
展开 树 : -
通过根据 递归函数 将每个子问题拆解成其组成部分,继续展开树。 -
此过程会持续进行,直到子问题达到 递归的基本情况
-
-
计算每一层的总成本 每一层 : -
求出 每一层中所有节点的 成本 -
确定每一层的节点数量和每个节点的成本 每个节点
-
-
求和各层的成本 所有层级的成本 : -
将树中所有层的成本加起来,得到 总成本 -
分析求和以确定整体
渐进复杂度
-
递归树方法通过将递归问题分解为较小的子问题并以树形结构进行可视化,来分析其复杂度。
示例 5.6
求解 使用递归树方法。
这是
-
构造
递归树 : -
树的根是
-
这将分解为 4 个子问题,每个子问题的
大小
...
第 0 层:
/ | | </st> 第 1 级: / / / / | | | | \ \ \ </st> 第 2 级: (16 个子问题) ... -
-
识别 成本 : -
根节点(第 0 级)处的成本 是 -
在第 1 级,每个 4 个子问题 的成本是 ! -
在第 2 级,每个 16 个子问题 的成本是 !
... -
-
展开 树 : -
继续展开,直到子问题达到基本情况( 例如, ) -
树的层数是 因为每一层 问题大小会减少一半
-
-
计算每一层的总成本 每一层 : -
第 0 层 : 成本 = -
第 1 层 : 成本 = -
第 2 层 : 成本 = -
一般层级 : 成本 =
-
-
总计各层级的成本 所有层级 : -
总成本 是所有层级的成本之和: 所有层级: -
这是一个等比数列,其首项为 和公比为 比例 : -
在我们的例子中, , , 并且 : -
简化后,我们得到以下结果: -
渐近地,主导项是 ,因此我们得到以下结果:
-
主定理
-
临界指数( ) :这个值, ,代表了一个阈值,用于比较驱动函数 的增长速率 与递归部分的 递归函数的增长速率。 -
分水岭函数( ) :这个函数, ,作为 分界线 区分了 主定理的不同情况。 它告诉我们递归的增长速率,假如驱动函数 被忽略时。
案例 1 – 递归调用的主导作用或叶子重的递归树
情况 2 – 平衡增长或平衡递归树
情况 3 – 非递归工作或根重递归树的主导作用
除了增长率条件外,
修改后的主定理用于解决递减递归函数
-
表示减法递归函数中的子问题数量 -
是每个子问题的规模缩小程度,其中 表示 每个子问题的大小
-
是每次递归时子问题数量减少的因子 每次递归 -
是每次递归时问题规模减少的量 每次递归 -
是多项式 项的指数
主定理的限制
-
驱动函数的限制 函数 : -
非多项式函数 :主定理假设递归调用之外的工作( )是一个多项式函数(例如, , ) 。如果 是指数函数( ),对数函数( ),阶乘函数( ),或其他非多项式形式,主定理不能 直接应用。 -
非正函数 :主定理要求 对于所有相关的输入大小,必须严格为正。 如果 ) 变为负数或零,定理的假设 将被违反。 -
非光滑函数 :主定理假设 具有一定的光滑性。具有突变、间断或分段定义的函数可能不符合该定理的框架。 -
不规则函数 :该定理的有效性取决于 与函数的递归部分相比,具有规则的增长模式。 具有振荡行为或可变指数的函数可能会挑战该定理的适用性。
-
-
递归参数的约束( 和 ****
) : -
非恒定参数 :主定理假设子问题的数量( )以及输入规模减少的因子( )是常数。 如果这些中的任何一个依赖于输入规模 ,则主定理不再适用。 。 -
非整数除数 :该定理假设输入被均匀地划分为子问题( )。 如果 是分数或无理数, 则可能会导致定理无法处理的复杂情况。
-
-
递推函数的限制 函数 : 非单调函数 :主定理假设时间复杂度函数 是单调递增的,这意味着随着输入大小的增加,算法的运行时间不会减少。 像 等函数 违反了这一假设。
替代方法
-
Akra-Bazzi 方法 :该 方法推广了主定理,并能够处理更广泛的递推函数,包括那些具有非多项式差异的函数。 它提供了一种系统化的方式来计算 这些递推的渐近复杂度。 -
代入法 :该 方法涉及猜测递推的解,并通过 数学归纳法 来证明其正确性。 -
迭代法 :这涉及到反复展开递推函数,揭示出一种模式,从而得到一个 闭式解。 -
递归树方法 :这种 可视化方法帮助理解递归调用的结构,并估计每一层的整体工作量。
例子 5.13
例子 5.14
检查是否
示例 5.15
例 5.16
例 5.17
-
大小为 的问题被分解为一个大小为 的子问题 -
递归调用外的工作 是
-
下界 -
上界 :
-
(子问题的数量) -
(输入大小的减少因子) -
(用于下界) 和 (用于 上界)
在这两种情况下,我们都有。
应用主定理的案例 2,我们对 和
,得到如下结果:
由于和
被夹在两个其他函数之间,并且这两个
和
是
,因此我们可以得出以下结论:
你应该注意到,前一个例子中余弦函数的振荡特性并不会显著影响递归函数的整体增长率。长期来看,线性项主导了行为,而算法的时间复杂度主要由递归拆分和每步中执行的线性工作决定。
例 5.18
求解。
-
(子问题的数量) -
(输入大小减少的因子) -
(递归调用外的工作量)
-
案例 1 : 但是 增长速度比 分水岭函数 -
案例 2 : 但是 不符合 该形式 -
案例 3 ****并且 增长速度慢于 分水岭函数
尽管
超越主定理 – Akra-Bazzi 方法
-
不等的子问题规模 :递归拆分成大小差异显著的子问题 不同的规模 -
更复杂的拆分 :递归中有超过两个子问题 的情况 -
非多项式工作量 :函数 表示递归调用外部工作量的函数,并不能轻易归类为多项式 或对数形式
-
是子问题的数量(可以大于 2) -
并且 是 一个常数 -
,一个对所有的常数 ,表示第 个子问题出现的次数 -
,对于所有的 和 ,是 的子问题大小(不同的子问题可以有 不同的大小) -
其中 是 一个常数 -
,对于 所有 -
是递归的非递归部分或驱动函数,表示递归调用外的工作,并且具有更广泛的 可允许形式
-
求 :解以下方程: 这个 是至关重要的;它代表了递归的“平衡点”。 -
计算积分 :求以下积分的值: -
渐近界 :T(n)的渐近复杂度如下: 如下:
-
Akra-Bazzi 方法是 主定理 的一种推广。 -
它处理具有不等子问题大小、更复杂分割以及更广泛工作函数类型的递归关系。 -
该方法涉及寻找一个平衡指数 并对工作函数进行积分,从而确定 渐近复杂度
为什么它有效? Akra-Bazzi 背后的直觉
这个积分有点复杂,但它的近似值是 。
渐近界限:
总结
参考文献与进一步阅读资料
-
算法导论 。作者:Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, 和 Clifford Stein。 第四版。 MIT Press。 2022 年: 第四章, 递归
-
算法 。作者:R. Sedgewick, K. Wayne。 第四版。 Addison-Wesley。 2011 年: -
第二章,算法分析原理 算法分析 -
第五章,排序
-
-
算法设计手册 。作者:S. S. Skiena。 第二版。 Springer。 2008 年: -
第三章,数据结构 与递归 -
第五章, 图算法
-
-
算法设计 。作者:Jon Kleinberg 和 Éva Tardos。 第一版。 Pearson。 2005 年: -
第五章,分治法 与征服 -
第七章,递归
-
-
线性递归方程的解法 。Mohamad Akra, Louay Bazzi。 计算优化与应用 。第 10 卷,第 2 期, 第 195–210 页。 1998 年。
第八章:第二部分:算法深度剖析
-
第六章 **, 排序算法 -
第七章 **, 搜索算法 -
第八章 **, 排序与搜索之间的共生关系 -
第九章 **, 随机化算法 -
第十章 **, 动态规划
第九章:6
排序算法
-
排序算法的 分类 -
迭代 排序算法 -
递归 排序算法 -
非比较性 排序算法
排序算法的分类
比较
基于比较的排序
<st c="4660"><</st>
<st c="4663">></st>
<st c="4669">==</st>
-
每个内部(非终端)节点表示两个元素之间的比较( 例如,A<B?) -
每个分支表示该比较的结果(是 或否) -
每个叶(终端)节点表示输入数组的一个可能的最终排序顺序。
-
对于一个大小为 的数组,存在 种可能的排列(不同的顺序)。 这些排列中的每一个都有可能是正确的 排序顺序。 -
在决策树中,每个叶节点代表这些可能排列中的一个。 因此,树必须至少有 个叶子节点来覆盖所有可能性。 在我们的例子中,我们将有 种可能的排列或 叶节点。 -
一个具有 叶子节点的二叉树,其最小高度为 。由于我们的决策树至少需要 个叶子节点,其最小高度为 。使用斯特林近似,我们知道 l o g ( n ! ) 大约等于 (见 示例 3.8 来自 第三章 )。
-
冒泡排序 :数组中的相邻元素反复比较,如果它们的顺序错误,则交换它们的位置。 每一次遍历,较大的元素逐渐被移动到数组的末尾。 -
插入排序 :该算法通过反复比较并将元素插入到已排序部分的适当位置,逐步构造一个已排序的数组。 排序部分逐步增大。 -
快速排序 :在 快速排序算法中,使用比较将数组围绕一个主元素进行分区,然后递归地排序 结果分区。 -
归并排序 :在 这种排序算法中,数组被分成两部分,几乎相等。 每一部分会递归排序,然后通过比较将已排序的部分合并 。 -
堆排序 :在这种 排序算法中,从原始数组构建一个最大堆(或最小堆)树,然后反复提取最大(或最小)元素。 在整个过程中,使用比较来维持堆的性质(见 第十三章 )。
非比较排序
-
数据特定技术 :这些算法经常利用数据的特定属性,如范围(特别是针对数值型或整数数据)或位数,来执行 排序过程 。 -
线性时间复杂度 :非比较排序算法可以实现优于 。时间复杂度,通常为 ,通过避免比较并使用更直接的方法来 对元素进行排序 -
内存使用 :尽管这些算法可以实现线性时间复杂度,但它们通常需要临时内存,这些内存通常与输入大小或数据值范围成正比。 数据值
递归
递归排序算法
-
分治策略 :顾名思义,这种策略包含三个步骤:首先,将问题(数组)拆分成更小的子问题(子数组)。 其次,递归地解决(排序)每个子问题(子数组)。 第三,将各个子问题的解合并,解决 原始问题。 -
基本情况与递归情况 :每个递归算法都有一个基本情况,当子问题足够小(例如,只有一个元素或一个空数组)时终止递归。 递归情况继续分解问题并解决 子问题。 -
栈的使用 :递归调用消耗栈空间,这可能导致较高的内存使用,特别是在深度递归时。 然而,尾递归优化和迭代方法在 某些情况下可以缓解这一问题。
-
分割 :如果数组有多个元素,将其分割成两半,尽量 均匀。 -
分治 :递归地对每一半应用归并排序。 直到每个子数组只有一个元素(一个 trivially 已排序的数组)。 -
合并 :通过比较每一半的元素,将两个已排序的半部分合并成一个排序后的数组,先取较小的元素,放入 新数组中。 -
重复此过程,直到两个半部分的所有元素都已 合并。
![]() ![]() |
---|
-
选择枢轴 :从数组中选择一个元素作为枢轴。 常见的选择有第一个元素、最后一个元素或随机 选择的元素。 -
分区 :将数组划分为两个子数组。 第一个子数组(左边)的元素小于枢轴,第二个子数组(右边)的元素大于枢轴。 枢轴现在处于最终 排序位置。 -
递归排序 :递归地对左子数组应用快速排序。 递归地对右子数组应用快速排序。 -
继续这个过程,直到每个子数组要么为空,要么只包含一个元素(一个 trivially 已排序的数组)。
![]() ![]() ![]() |
---|
|
-
交换根节点(最大元素)与堆的最后一个元素。 -
将堆大小 减少 1。 -
对根节点应用递归堆化,恢复 最大堆属性。
非递归排序算法
-
迭代方法 :非递归排序算法使用循环(例如, for 循环或 while 循环)对数组进行排序,从而避免了 递归调用 -
内存效率 :这些算法通常更加节省内存,因为它们避免了递归调用所需的额外栈空间 -
更简化的栈管理 :通过使用迭代方法,非递归算法避免了管理递归栈深度的复杂性,这在处理 大型数据集 时可能会导致栈溢出
|
-
将其与已排序部分的元素进行比较(与其左边的元素)。 -
将已排序部分的元素向右移动,直到当前元素处于 正确位置。 -
将当前元素插入 该位置。
![]() ![]() |
---|
|
-
比较每两个元素。 如果它们的顺序不正确, 交换它们。 -
继续这个过程,直到数组的最后一个元素 被访问。
-
经过一轮遍历,最大的元素将“冒泡”到 数组的末尾。 -
重复步骤 1,但这次,停止在距离数组末尾一位的位置(因为最后一个元素已经在其 正确位置)。 -
继续重复步骤 1,每次减少比较范围一个元素,直到整个数组 排序完成。
![]() ![]() ![]() |
---|
适应性
另一方面,
逆序
-
零反转 :对于所有 ,我们有 ;换句话说,数组 完全排序 。 -
最大逆序数 :对于所有 ,我们有 ;换句话说,数组 是按 逆序排列的
内存使用
原地排序
非原地排序
-
在内存受限的环境中,原地排序算法由于其最小的 内存占用,通常是首选。 -
非原地算法可能更容易实现和理解,特别是对于复杂的 排序任务。 -
在某些情况下,非原地算法由于能够高效处理大规模或复杂的数据集,尽管它们的 内存使用量较高,仍然能够提供更好的性能。 -
非原地算法通常更容易保持相等元素的相对顺序,因此它们是稳定的。 确保原地算法的稳定性可能会 更具挑战性。
稳定性
-
多关键字排序 :当 执行多级排序(例如,首先按一个属性排序,然后按另一个属性排序)时,稳定性确保先前排序的顺序得到保持。 例如,如果你首先根据员工的雇佣状态排序,然后再根据员工编号排序,稳定排序会保持雇佣状态的顺序,同时按 id 排序。 在此过程中,雇佣状态的顺序得以保留。 -
保持输入顺序 :在某些应用中,输入元素的顺序除了其排序后的位置外,还具有其他意义。 稳定性确保这种意义 得以保留。
迭代排序算法
冒泡排序
<st c="29655">5, 3, 8, 4, 2, 7,</st>
<st c="29674">1, 6</st>
def bubble_sort_iterative(a):
n = len(a)
for i in range(n):
elements_swapped = False
for j in range(0, n - i - 1):
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
elements_swapped = True
if not elements_swapped:
break
return a
正确性证明
<st c="32130">for i in range(n):</st>
<st c="32162">i</st>
-
初始化 :在第一次迭代之前( i = 0),没有任何元素被处理。 由于空子数组 已经是排序的,所以不变式显然成立。 -
维护 :假设不变式在 i 次 迭代之前成立。 在第 i 次 迭代过程中,若相邻元素的顺序不对,则会交换它们。 在第 i 次 遍历结束时,最大的未排序元素会被“冒泡”到数组中的正确位置,从而确保最后的 i 个元素已排序。 因此,不变式 得以维持。 -
终止条件:该算法在外部循环执行完
n
次迭代后终止,其中n
是输入数据的大小。在此时,恒等式保证整个数组已排序,因为最后n
个元素(即整个数组)已处于正确的位置。
复杂度分析
要理解时间复杂度,我们可以在以下几种场景中进行分析:
-
最优情况:![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:miO</mml:mi>mml:mo(</mml:mo>mml:min</mml:mi>mml:mo)</mml:mo></mml:math>]:当数组已排序时,会发生这种情况。算法只需通过数组执行一次遍历,而不需要进行任何交换,提前终止,因为不会找到任何无序对(“冒泡”)。
-
平均情况:![<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:miO</mml:mi><mml:mfenced separators="|">mml:mrowmml:msupmml:mrowmml:min</mml:mi></mml:mrow>mml:mrowmml:mn2</mml:mn></mml:mrow></mml:msup></mml:mrow></mml:mfenced></mml:math>]:当输入数据是随机时,会发生这种情况。算法需要多次遍历,每次需要进行
n-i-1
次比较。 -
最坏情况 : - 当数组按逆序排列时,会发生这种情况。 冒泡排序在每次遍历数组时都需要执行最多次数的比较和交换操作。
选择排序
<st c="35244">29, 10, 14, 37,</st>
<st c="35261">13, 5</st>
def selection_sort_iterative(a):
n = len(a)
for i in range(n):
min_id = i
for j in range(i + 1, n):
if a[j] < a[min_id]:
min_id = j
a[i], a[min_id] = a[min_id], a[i]
return a
<st c="36399">for i in range(n):</st>
<st c="36440">a</st>
<st c="36463">for j in range(i + 1, n):</st>
<st c="36607">a[i], a[min_id] =</st>
<st c="36625">a[min_id], a[i]</st>
正确性证明
<st c="36849">for i in range(n):</st>
<st c="36887">a[0:i]</st>
<st c="36920">i</st>
-
初始化 :在第一次迭代之前( i = 0),子数组 a[0:0] 是空的。 由于空子数组 已经是有序的 ,因此不变量显然成立。 -
维护 :假设在第 次迭代之前,不变量成立。 在这一迭代过程中,算法在子数组 a[i:n] 中找出最小的元素,并将其与 a[i] 交换。 这样,a[i] 就成了 a[i:n] 中的最小元素,子数组 a[0:i+1] 也变得有序。 这样可以确保不变量 得以维持。 -
终止 :算法在外层循环执行完 n 次迭代后终止。 此时,不变量保证整个数组 a[0:n] 已排序,因为所有元素都已 处理完毕。
复杂度分析
插入排序
<st c="39515">8, 3, 1, 7,</st>
<st c="39528">0, 10</st>
def insertion_sort_iterative(a):
n = len(a)
for i in range(1, n):
pointer = a[i]
j = i - 1
while j >= 0 and pointer < a[j]:
a[j + 1] = a[j]
j -= 1
a[j + 1] = pointer
return a
<st c="40659">while</st>
<st c="40671">while j >= 0 and key < a[j]:</st>
<st c="40725">while</st>
正确性证明
<st c="41053">for i in range(1, n):</st>
<st c="41095">a[0:i]</st>
<st c="41152">a[0:i]</st>
-
初始化 :在第一次迭代之前(i = 1),子数组 a[0:1] 仅包含数组中的第一个元素,该元素自然处于正确的位置(已排序)。 -
维护 :让我们假设循环不变量在第 i 次 迭代之前成立。 该算法将 a[i] 插入到已排序的子数组 a[0:i] 中,方法是将大于 a[i] 的元素向右移动一个位置。 这个插入操作确保了 a[0:i+1] 是已排序的。 因此,循环不变量 得以保持。 -
终止条件 :该算法在 次外循环迭代后终止,其中 是数组的长度。 此时,不变式保证整个数组 a[0:n] 已经排序,因为所有元素都已 处理完毕。
复杂度分析
-
最佳情况 : – 如前所示, while 循环的条件确保当输入已经排序时, while 循环不会执行,从而保证了线性 时间复杂度。 -
平均情况 : – 算法平均执行 次比较和 次交换操作。 这是算法的平均表现。 -
最坏情况 : - 当输入数组是逆序排列时,发生此情况。 在这种情况下, while 循环的条件使得它在每次迭代中执行 次。 考虑到外部循环执行 次,最大比较次数和交换次数将 是 。
递归排序算法
归并排序
<st c="45113">38, 27, 43, 3,</st>
<st c="45129">9, 82</st>
import numpy as np
def merge(A,p,q,r):
n1=q-p+1
n2=r-q
n11=n1+1
n22=n2+1
left = [0 for i in range(n11)]
right = [0 for i in range(n22)]
for i in range(n1):
left[i]=A[p+i-1]
for j in range(n2):
right[j]=A[q+j]
left[n11-1]=1000 #very large number
right[n22-1]=1000 #very large number
i=0
j=0
for k in range(p-1,r):
if left[i]<=right[j]:
A[k]=left[i]
i=i+1
else:
A[k]=right[j]
j=j+1
return(A)
def mergeSort(A,p,r):
if p<r:
q=int(np.floor((p+r)/2))
mergeSort(A,p,q)
mergeSort(A,q+1,r)
merge(A,p,q,r)
return(A)
<st c="46561">merge</st>
正确性证明
<st c="46771">左分区</st>
<st c="46790">右分区</st>
-
初始化 :在第一次合并操作之前,子数组包含单个元素,这些元素 本身就是排序好的。 -
维护 :假设在合并两个子数组之前,不变量是成立的。 在合并过程中,从任一子数组中选择最小的剩余元素并将其添加到合并数组中。 这确保了合并数组的排序顺序。 不变量成立的原因是每一步选择都确保合并后的数组 保持有序。 -
终止条件 :当所有子数组都合并成一个排序数组时,算法终止。 此时,不变量保证整个数组 是有序的。
复杂度分析
-
如果 其中 , 则 -
如果 , 那么 -
如果 其中 ,并且如果 对于某些 且当 时, 则
-
最佳情况 : – 合并排序始终以对数步长分割数组并合并子数组。 -
平均情况 : – 由于其 分治法 ,算法在所有情况下都能保持一致的表现。 -
最坏情况 : – 归并排序不是自适应的,这意味着无论初始元素的顺序如何,它的表现始终保持一致。 元素
<st c="49363">left_partition</st>
<st c="49382">right_partition</st>
快速排序
-
枢轴选择 :这一步骤涉及从数组中选择一个元素作为枢轴。 常见的选择有第一个、最后一个、中间元素,或随机选择一个元素。 具体的枢轴选择策略会影响算法的整体性能。 在概率快速排序中,枢轴是随机选择的(见 第九章 )。 -
划分 :在这一步中,数组被分成两个较小的子数组。 所有小于枢轴的元素放入一个子数组,而大于枢轴的元素放入另一个子数组。 经过这一步,枢轴元素已经处于其最终排序位置。 在数组中。 -
递归 :划分步骤会递归地重复,直到所有子数组为空或只包含一个元素,最终得到一个完全 排序好的数组。
<st c="51665">35, 12, 99, 42,</st>
<st c="51682">5, 8</st>
-
选择 一个枢轴(例如, 8 )并重新排列数组,使得小于枢轴的元素位于其前面,大于枢轴的元素位于其后。 -
递归排序左 子数组 <st c="51864">[5]</st>
已经 排序完毕。 -
递归排序右子数组 <st c="51960">35</st>
)并重新排列: <st c="51980">[5, 8, 12, 35,</st>
<st c="51995">42, 99]</st>
。 -
递归排序子数组 [12] 和 [ ****42, 99] : -
[12] 已经排序。 -
选择一个枢轴(例如, 42 )并重新排列 [42, 99] : [5, 8, 12, 35, 42, 99] 。
-
-
现在数组已经排序: [5, 8, 12, 35, 42, 99] 。
def quick_sort(arr):
if len(arr) <= 1:
return arr
else:
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + middle + quick_sort(right)
正确性证明
<st c="52827">left_partition</st>
<st c="52867">pivot</st>
<st c="52894">right_partition</st>
<st c="52932">pivot</st>
-
初始化 :在第一次分区操作之前,子数组是整个数组,由于尚未处理任何元素,循环不变式显然成立。 -
维护 :在分区过程中,元素会与枢轴进行比较,并根据需要交换位置。 这确保了两个子数组, left_partition 和 right_partition ,按照定义被维护; left_partition 包含小于 pivot 的元素, right_partition 包含大于 pivot **的元素。 -
终止 :当子数组的长度为 0 或 1 时,算法终止,因为这些子数组本身已经排序。 循环不变式保证在每一步中元素都被正确分区,从而确保整个数组在完成时已经排序。
复杂度分析
-
最佳情况 : – 这种情况发生在基准值始终将数组划分为两个几乎相等的部分时 。 -
平均情况 : 下表现良好– 该算法在随机 枢轴选择 -
最坏情况 : – 当枢轴选择始终导致最不平衡的划分时发生(例如,最小或 最大元素)
非比较排序
计数排序
<st c="59948">4, 2, 2, 8,</st>
<st c="59961">3, 3</st>
-
找到 最大元素( 8 )和最小 元素( 2 ) -
创建一个大小为 的计数数组,初始化为 0
-
统计每个元素的出现次数并将其存储在 计数数组 -
计数数组: [2, 2, 1, 0, 0, 0, 1] -
这对应于元素 2, 3, 4, 5, 6, 7, 8
-
通过将前一个元素的计数加到每个元素中来修改计数数组。 这有助于确定元素的位置 。 -
累计计数数组: [2, 4, 5, 5, 5, 5, 6] 。
根据累计计数将每个元素放入输出数组的正确位置。 我们从最后一个元素开始,直到 第一个元素。
<st c="60725">4, 2, 2, 8,</st>
<st c="60738">3, 3</st>
def counting_sort(arr):
if not arr:
return arr
max_val = max(arr)
min_val = min(arr)
range_of_elements = max_val - min_val + 1
# Create a count array to store count of individual elements and initialize it to 0
count = [0] * range_of_elements
output = [0] * len(arr)
# Store the count of each element
for num in arr:
count[num - min_val] += 1
# Change count[i] so that count[i] contains the actual position of this element in the output array
for i in range(1, len(count)):
count[i] += count[i - 1]
# Build the output array
for num in reversed(arr):
output[count[num - min_val] - 1] = num
count[num - min_val] -= 1
# Copy the output array to arr, so that arr contains sorted numbers
for i in range(len(arr)):
arr[i] = output[i]
return arr
<st c="61916">计数排序</st>
<st c="61978">for i in range(1, len(count)):</st>
<st c="62014">for i in range(1, len(count)):</st>
<st c="62122">for k in reversed(a):</st>
正确性证明
<st c="62293">计数</st>
-
初始化 :在处理任何元素之前, 计数 数组初始化为 0,输出数组为空。 此时,不变量 显然成立。 -
维护 :在处理每个元素时,累积 计数 数组会更新,以反映元素的正确计数。 在构建输出数组的过程中,元素根据累积计数放入正确的位置,从而确保排序顺序 得以保持。 -
终止 :算法在所有元素处理并放入输出数组后终止。 此时,不变量保证输出数组已排序,且累积 计数 数组准确反映了元素的位置。
复杂度分析
-
最优情况 :在最优情况下,计数排序遍历输入数组以计算每个元素的频率( 计数 ),然后遍历 计数 数组以计算累计计数,最后再次遍历输入数组,将元素放置到输出数组的正确位置。 这个过程需要 时间来计算元素, 时间来计算累计计数,最后 时间来构建输出数组,最终总时间复杂度为 ! 。 -
平均情况 :计数排序的平均时间复杂度为 因为涉及的步骤(计数元素、计算累计计数、构建输出数组)无论初始顺序或元素分布如何都保持一致。 输入数组中的每个元素都会被处理固定次数 。 -
最坏情况 :在最坏情况下,计数排序的时间复杂度仍为 。 最坏情况发生在输入值的范围相较于输入数组的大小较大时。 尽管如此,计数、计算累计计数以及将元素放入输出数组的操作相对于元素数量和范围 的值是在线性时间内完成的。
<st c="64871">count</st>
<st c="64905">temp</st>
<st c="65293">temp</st>
基数排序
-
适用于大数据 :基数排序对于排序大数据集特别有效,尤其是当输入值的范围并不比元素的数量大很多时。 它能够很好地处理大量数据,特别是当键是整数或长度固定的字符串时。 -
可预测的性能 :基数排序始终表现出色,没有最坏情况的退化,正如快速排序中所见。 它的时间复杂度是可预测的,不依赖于输入数据的 初始顺序。 -
可扩展性 :基数排序可以很容易地适应排序除整数以外的数据类型,例如字符串 或其他序列,方法是使用不同的基数或将每个字符视为 “数字”。
<st c="68084">170, 45, 75, 90,</st>
<st c="68102">802, 24</st>
-
按最不重要的数字排序原始数组( 个位数): 对最不重要的数字应用计数排序:[
170, 90, 802, 24, 45, 75
] -
按照第二个最不重要的数字(10 位)排序:
对第二个最不重要的数字应用计数排序:[
802, 24, 45, 75, 170, 90
] -
按照最重要数字(100 位)排序:
对最重要数字应用计数排序:[
24, 45, 75, 90, 170, 802
]
现在数组已排序:[24, 45, 75, 90, 170, 802
]
这是基数排序算法的 Python 实现:
def count_sort(a, e):
size = len(a)
result = [0] * size
count = [0] * 10 # For digits 0-9
for i in range(size):
digit = (a[i] // e) % 10
count[digit] += 1
for i in range(1, 10):
count[i] += count[i - 1]
for i in range(size - 1, -1, -1):
digit = (a[i] // e) % 10
result[count[digit] - 1] = a[i]
count[digit] -= 1
for i in range(size):
a[i] = result[i]
def radix_sort(a):
maxv = max(a)
e = 1
while maxv // e > 0:
count_sort(a, e)
e *= 10
在上述基数排序算法实现中,radix_sort(a)
函数接受输入数组(a
),并从最不重要的数字开始应用计数排序(count_sort(a, e)
)。它根据每个数字在适当的位数上排序数据(e
)。
正确性证明
在基数排序中,循环不变式定义为:每次按每个数字(从最不重要到最重要的顺序)排序后,数组会相对于该数字部分排序,同时保持具有相同数字的元素的相对顺序。
-
初始化:在处理任何数字之前,数组是未排序的。由于没有部分排序,循环不变式显然成立。
-
维护:在每次迭代中,应用计数排序基于当前数字对元素进行排序。由于计数排序是稳定的,它保持具有相同数字的元素的相对顺序。这保证了每次经过排序后,数组相对于该数字是部分排序的。
-
终止:算法在按最重要数字排序后终止。此时,循环不变式保证数组已完全排序,因为所有数字都按重要性顺序处理过。
复杂度分析
-
最佳情况 :在最佳情况下,基数排序处理数组中每个数字的每一位。 对于每一位,它使用计数排序,计数排序的时间复杂度为 时间。 由于有 位数,总的时间复杂度为 为 。 -
平均情况 :基数排序的平均时间复杂度保持为 因为涉及的步骤(每位使用计数排序排序)对于每一位都一致执行,无论输入值的分布如何。 -
最坏情况 :在最坏的情况下,基数排序仍然表现为 的时间复杂度。 这是因为每一位数字都通过计数排序在线性时间内处理,并且该过程会重复 次,处理 个数字。
桶排序
<st c="72904">0.78, 0.17, 0.39, 0.26,</st>
<st c="72929">0.72, 0.94</st>
-
将元素 分配到桶中: 创建一个空的桶列表。 根据每个元素的值,将其分配到适当的桶中。 桶: <st c="73091">[[0.17, 0.26], [0.39], [0.72,</st>
<st c="73121">0.78], [0.94]]</st>
。 -
排序 每个桶: 将每个桶内的元素排序。 这可以通过使用另一个排序算法,如 插入排序 来实现。已排序的桶: <st c="73282">[[0.17, 0.26], [0.39], [0.72,</st>
<st c="73312">0.78], [0.94]]</st>
。 -
连接 已排序的桶: 将所有已排序的桶合并,形成最终的 已排序数组: <st c="73422">[0.17, 0.26, 0.39, 0.72,</st>
<st c="73448">0.78, 0.94]</st>
def bucket_sort(a):
number_of_buckts = len(a)
buckts = [[] for _ in range(number_of_buckts)]
for i in a:
idx = int(i * number_of_buckts)
buckts[idx].append(i)
sorted_a = []
for b in buckts:
sorted_a.extend(insertion_sort(b))
return sorted_a
<st c="73904">a</st>
<st c="73943">for I in a:</st>
<st c="74025">for b in buckts:</st>
正确性证明
-
初始化 :在处理任何元素之前,桶是空的。 由于没有进行部分排序,不变量显然成立。 -
维护 :在每次迭代过程中,元素会根据它们的值被分配到不同的桶中。 逐个对每个桶进行排序确保桶内的元素是有序的。 这保持了 不变量。 -
终止 :当所有的桶都排序完成并连接后,算法终止。 此时, 不变量保证整个数组是有序的,因为每个桶内的元素已排序,且桶被按顺序连接。 按顺序。
复杂度分析
-
最优情况 :在最优情况下,元素均匀分布在各个桶中,并且每个桶包含大致相等数量的元素。 如果在桶内使用有效的排序算法,如插入排序,单独排序每个桶的时间是常数时间。 因此,总体时间复杂度 是 。 -
平均情况 :平均来说,桶排序的时间复杂度保持在 只要元素分布均匀,且桶的数量 与元素的数量成正比, 。每个元素都会被放入一个桶中,时间复杂度为 ,每个桶根据其包含的元素数量在线性时间内进行排序。 -
最坏情况 :在最坏的情况下,如果所有元素都被放入一个桶中,算法的时间复杂度会退化为 。这是因为所有元素都需要在一个桶内进行排序,当使用像插入排序这样的排序算法时,时间复杂度会达到平方级别, 从而导致最坏情况。
总结
参考文献及进一步阅读
-
算法导论 . 作者:Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest 和 Clifford Stein 。第四版。 MIT 出版社。 2022 年 -
第二章, 入门 -
第四章,分治法(包括 归并排序) -
第七章,快速排序 -
第八章,线性时间排序 线性时间排序 -
第九章,中位数与 顺序统计量
-
-
算法 . 由 R. Sedgewick 和 K. Wayne 。第四版 。Addison-Wesley 。2011 年 。第二章: 基本排序
-
计算机程序设计艺术,第 3 卷:排序与查找 ,作者:唐纳德· E. 克努斯 。第五章 排序
-
C++中的数据结构与算法分析 ,作者:马克· 艾伦·魏斯 。 第七章 排序
-
算法设计手册 ,作者:史蒂文· S. 斯基纳 。第四章 排序与查找
第十章:7
搜索算法
-
搜索算法的特性 搜索算法 -
线性时间和对数时间 搜索算法 -
哈希
搜索算法的特性
-
线性查找 :逐个遍历每个元素,直到找到目标或到达结构的末尾 。 -
二分查找 :通过反复将查找区间 对半分割,高效地定位排序数组中的元素 。 -
哈希 :利用哈希函数将元素映射到特定位置,以实现 快速访问 。 -
树遍历 :在树结构中查找,例如二叉搜索树、AVL 树和 红黑树 。
-
数据结构要求 :不同的搜索算法可能需要特定的数据结构才能高效地运行。 例如,二分查找要求数据是有序的,才能正确执行,而顺序查找可以在任何线性数据结构中工作,如数组或 链表。 -
适应性 :一些搜索算法具有根据输入数据特征进行自我调整的能力,从而提高其性能。 例如,插值搜索可以根据数据分布进行调整,在均匀分布的数据集上,比二分查找表现得要好得多。 分布的数据集。 -
实现复杂性 :算法实现的复杂性是一个实际考虑因素,尤其在时间紧迫的情况下。 例如,顺序搜索等简单算法容易实现和理解,而更复杂的算法,如平衡搜索树(参见 第十三章 )或哈希算法,则需要对数据结构和 算法设计有更深入的理解。 -
预处理要求 :某些搜索算法要求在应用之前对数据进行预处理。 例如,二分查找要求数据已排序,这增加了整体时间复杂度。 预处理步骤有时可能会抵消更快搜索时间的好处,特别是当数据频繁变化并需要 不断重新排序时。 -
最优性 :一些算法基于其时间复杂度和性能特征,在特定场景下被认为是最优的。 例如,二分查找在排序数组中进行查找时是 最优的,因为它具有对数时间复杂度。 然而,最优性可能根据上下文和应用的具体要求而变化。 在一个场景中最优的算法,在另一个场景中可能不是最佳选择,特别是当假设条件或 环境变化时。
-
线性时间搜索算法 :这些 算法,如顺序查找,的运行时间是 ,这意味着找到一个元素所需的时间随着数据集大小的增加而线性增长。 我们将探索线性时间算法适用的场景以及它们的 实现细节。 -
子线性时间搜索算法 :此类算法包括二分查找, 其操作时间为 时间。 二分查找对于排序后的数据集特别高效,利用分治策略迅速缩小搜索空间。 在 第十三章 中,我们将讨论一种基于特定数据结构的搜索算法 ,该数据结构被称为 二叉搜索树 ( BSTs )。 二叉搜索树保持元素的有序排列,从而允许高效的搜索、插入和 删除操作。 -
常数时间搜索算法 :这些 算法旨在实现 时间复杂度,其中查找元素所需的时间无论数据集大小如何都保持不变。 哈希是实现常数时间搜索操作的主要技术。 我们将研究哈希函数是如何工作的,它们的实现方式,以及在什么条件下它们能提供 最佳性能。
线性时间和对数时间搜索算法
线性或顺序搜索
def iterative_linear_search(a, target):
for index in range(len(a)):
if a[index] == target:
return index
return -1
<st c="12693">估算</st>
<st c="12704">迭代线性搜索的时间复杂度非常简单。</st>
<st c="12772">该算法包含一个循环,循环内部的所有命令都会执行</st>
<st c="12842"><st c="12891">次,其中</st>
<st c="12904"><st c="12953">是数组中的元素个数。</st>
<st c="12993">此外,循环结束后的最后一条指令只执行一次。</st>
<st c="13055">这导致了运行时间的上界为</st>
<st c="13107">O</st>
<st c="13110"><st c="13111">。</st></st></st></st>
<st c="13112">线性搜索的递归实现涉及检查当前元素,如果未找到目标元素,则递归调用以检查下一个元素。</st>
<st c="13279">以下是一个用 Python 实现递归</st>
<st c="13333">顺序搜索的代码:</st>
def recursive_linear_search(a, target, index=0):
if index >= len(a):
return -1
if a[index] == target:
return index
return recursive_linear_search(a, target, index + 1)
<st c="13519">The</st>
<st c="13524">递归线性搜索</st>
<st c="13547">函数接受三个参数:</st>
<st c="13581">a</st>
<st c="13582">(待搜索的数组),</st>
<st c="13606">target</st>
<st c="13612">(要查找的元素),以及</st>
<st c="13646">index</st>
<st c="13651">(数组中的当前位置,默认为</st>
<st c="13699">0</st>
<st c="13700">)。</st>
<st c="13703">如果</st>
<st c="13706">index</st>
<st c="13711">大于或等于数组的长度,则意味着我们已经到达数组的末尾,目标元素未找到。</st>
<st c="13840">函数返回</st>
<st c="13861">-1</st>
<st c="13863">。如果当前元素在</st>
<st c="13891">a[index]</st>
<st c="13899">与</st>
<st c="13908">target</st>
<st c="13914">匹配,函数返回当前的</st>
<st c="13949">index</st>
<st c="13954">。如果当前元素与目标不匹配,函数会递归调用自己,移动到下一个索引(</st>
<st c="14071">index + 1</st>
<st c="14081">)。</st>
<st c="14085">该算法可以使用减法递归函数进行描述,具体如下:</st>
-
情况 1 :如果 , 那么 -
案例 2 :如果 , 那么 -
案例 3 :如果 , 那么
-
无序或非结构化数据 :当数据没有排序或没有存储在一种特定结构中,无法促进更快的搜索方法时,线性搜索是一种直接且 可行的选项 -
小型数据集 :对于小型数据集,更复杂的搜索算法可能不值得投入额外开销,从而使线性搜索成为一个 高效的选择 -
首次出现搜索 :当你需要在数组或列表中找到某个元素的第一次出现时,线性搜索是 适用的 -
单次或少量搜索 :如果你只需要执行一次或几次搜索,线性搜索的简单性可以超过那些需要预处理(例如排序)的复杂算法的优点 。
-
扫描器和解析器 :线性搜索常用于词法扫描器和解析器中,寻找序列中字符的标记或特定模式 或数据 -
无序列表中的查找操作 :在处理无序列表或数组时,线性搜索被用来找到特定的元素 或值 -
验证和核实 :线性搜索用于验证输入或核实列表中是否存在某个元素,例如检查用户输入的值是否存在于数据库 或列表中 -
实时系统 :在数据不断变化且无法进行排序的实时系统中,线性搜索提供了一种快速查找元素的方法,无需 预处理 -
嵌入式系统 :在资源有限的嵌入式系统中,线性搜索的常数空间复杂度使其成为搜索操作的合适选择 。
子线性搜索
二分查找
def recursive_binary_search(a, target, left, right):
if right >= left:
mid = left + (right - left) // 2
if a[mid] == target:
return mid
elif a[mid] > target:
return recursive_binary_search(a, target, left, mid - 1)
else:
return recursive_binary_search(a, target, mid + 1, right)
return -1
<st c="19618">a</st>
<st c="19641">target</st>
-
初始化 :设置两个指针, left 和 right ,分别指向数组的开始和结束位置。 -
中间元素 :计算中间索引, mid = left + (right - left) // 2 。 -
比较 : -
如果 a[mid] == target ,目标已找到,返回 mid 索引 。 -
如果 a[mid] < target ,更新 left 为 mid + 1 并重复 该过程 -
如果 a[mid] > target ,更新 right 为 mid - 1 并重复 该过程
-
-
终止 :该过程继续直到 left > right 。如果没有找到目标, 返回 -1 。
-
第一步之后,搜索空间 是 -
第二步之后,搜索空间 是 -
在 步后,搜索空间 变为
插值查找
def recursive_interpolation_search(a, target, low, high):
if low <= high and target >= a[low] and target <= a[high]:
pos = low + ((high - low) // (a[high] - a[low]) * (target - a[low]))
if a[pos] == target:
return pos
if a[pos] < target:
return recursive_interpolation_search(a, target, pos + 1, high)
return recursive_interpolation_search(a, target, low, pos - 1)
return -1 # Target not found
<st c="24192">mid = left + (right - left) // 2</st>
<st c="24427">pos</st>
<st c="24546">pos = low + ((high - low) // (arr[high] - a[low]) * (target -</st>
<st c="24609">a[low]))</st>
<st c="27836">pos</st>
指数搜索
def iterative_exponential_search(a, target):
if a[0] == target:
return 0
n = len(a)
i = 1
while i < n and a[i] <= target:
i = i * 2
return binary_search(a, i // 2, min(i, n - 1), target)
<st c="29784">while i < n and a[i] <= target:</st>
<st c="29854">i = i * 2</st>
<st c="29898">1</st>
<st c="29902">2</st>
<st c="29905">4</st>
<st c="29908">8</st>
<st c="30012">binary_search</st>
def recursive_exponential_search(a, target, i=1):
n = len(a)
if a[0] == target:
return 0
if i < n and a[i] <= target:
return recursive_exponential_search(a, target, i * 2)
return binary_search(a, i // 2, min(i, n - 1), target)
跳跃搜索
跳跃搜索 是一种用于在排序数组中查找元素的算法。它的工作原理是将数组分成固定大小的块,通过块大小跳跃到前面,然后在可能包含目标元素的块内执行线性搜索。跳跃的最优步长通常是 ,其中
是数组中的元素数量。该方法的目的是通过最初跳过数组中的大部分部分来减少比较的次数。我们将证明跳跃搜索的时间复杂度是
。跳跃搜索的迭代实现如下:
import math
def jump_search(a, target):
n = len(a)
step = int(math.sqrt(n))
prev = 0
while a[min(step, n) - 1] < target:
prev = step
step += int(math.sqrt(n))
if prev >= n:
return -1
while a[prev] < target:
prev += 1
if prev == min(step, n):
return -1
if a[prev] == target:
return prev
return -1
由于算法的特性,跳跃搜索的递归实现不太常见。尽管如此,它仍可以按照以下方式实现:
import math
def recursive_jump_search(a, target, prev=0, step=None):
n = len(a)
if step is None:
step = int(math.sqrt(n)) # Block size to jump
if prev >= n:
return -1
if a[min(step, n) - 1] < target:
return recursive_jump_search(a, target, step, step + int(math.sqrt(n)))
while prev < min(step, n) and a[prev] < target:
prev += 1
if prev < n and a[prev] == target:
return prev
return -1
我们来分析一下算法,然后估算跳跃搜索的时间复杂度。该算法分为三个步骤:
-
初始化:
-
设置块大小为
。
-
初始化 prev 为 0,step 为
。
-
-
跳跃阶段:按块大小跳跃,直到当前值大于或等于目标值或达到数组的末尾。
-
线性搜索阶段 :在 识别的块内执行线性搜索。
-
跳跃阶段 比较: -
线性搜索阶段 比较:
总结
哈希
-
关键字 :在搜索算法和数据结构的背景下,关键字是用于搜索、访问或管理集合中元素的唯一标识符,如数组、列表或数据库。 关键字对于高效的数据检索和操作至关重要。 例如,在字典中,关键字可以是一个单词,而相关联的值可以是该单词的定义。 关键字被用于各种数据结构,例如哈希表,其中它们被输入到哈希函数中以生成 一个索引。 -
索引 :在数据结构(如数组或列表)中,索引是位置的数值表示。 它指示了特定元素在结构中存储的位置。 例如,在数组 [10, 20, 30, 40] 中,元素 30 的索引是 2 。索引对于在支持随机访问的数据结构(如数组 和列表)中直接访问元素至关重要。 -
地址 :地址 是指存储数据元素的内存中特定位置。 在搜索和数据结构的上下文中,地址通常是指对应某个索引或键的实际内存位置。 在低级编程中,例如 C 或 C++,地址可能是像 0x7ffee44b8b60 这样的值,表示变量的确切内存位置。 地址用于直接访问和操作存储在内存中的数据。 在高级编程中,地址通常会被抽象化,但理解地址对于优化性能和理解 内存管理至关重要。
哈希函数
-
确定性 :哈希函数必须对相同的输入始终产生相同的输出(哈希值)。 这确保了数据检索 和验证等应用的可预测性和可靠性。 -
固定输出大小 :无论输入数据的大小如何,输出的哈希值应该具有固定长度。 这使得哈希值容易存储和比较,提升了 各种算法中的效率。 -
效率 :哈希函数应该是计算上快速的,即使对于大输入也能够快速生成哈希值。 这对于实时应用和依赖哈希的算法至关重要 以保证性能。 -
均匀性 :一个好的哈希函数会将其输出值均匀地分布在输出空间中。 即使输入发生微小变化,也应该产生显著不同的哈希值,避免出现模式,并使反向工程 输入变得困难。 -
碰撞抗性 :应该在计算上不可行找到两个不同的输入,产生相同的哈希值(即碰撞)。 碰撞抗性对于密码存储和 数字签名等安全应用至关重要。
-
预图像抗性 :给定一个哈希值,应该很难找到产生它的原始输入。 这个属性可以防止试图从 哈希值中恢复原始数据的攻击。 -
第二预图像抗性 :给定一个输入及其哈希值,应该很难找到第二个输入,产生相同的哈希值。 -
无关性 :输入的不同部分与结果哈希值之间不应存在任何关联。
常数时间查找通过哈希技术
-
稀疏哈希表 :直接寻址通常会创建一个非常稀疏的哈希表,这意味着哈希表的大小必须与可能的输入键的范围一样大。 例如,如果输入键的范围是从 1 到 1,000,000,那么哈希表必须有 1,000,000 个槽位,即使实际上只使用了几个键。 这会导致内存的低效使用。 -
高碰撞概率 :在直接寻址中,如果两个不同的键映射到相同的位置(碰撞),可能会导致数据检索和插入问题。 尽管直接寻址假设每个键是唯一的,但在实际应用中,碰撞是 常常不可避免的。 -
仅限于数值数据 :直接寻址仅对数值型整数数据有效。 它不适用于其他数据类型,如字符串或复合对象,因此限制了其在许多 实际场景中的应用。
搜索中使用的哈希函数类型
除法余数(模)方法
-
应用模运算 :通过对键取模哈希表大小来计算哈希值: -
计算余数 :执行除法并找到 余数:
乘法方法
-
将键与常数相乘, , 其中 。 -
提取 乘积的小数部分。 -
将小数部分与哈希表的 大小 相乘。 -
取结果的下限值来获得 哈希值。
-
将键与 相乘: -
提取小数部分: -
通过表大小相乘: -
取 地板:
中平方方法
-
平方密钥 :平方密钥以获得一个较大的 数字: 。 -
提取中间数字 :从平方值中提取适当数量的中间数字。 提取的数字数量可以根据哈希表的大小而有所不同。 为了简单起见,我们提取两个 中间数字: -
平方值: 207936 -
中间数字: 07 (来自平方数的中间部分)
-
-
使用中间数字作为哈希值 :使用这些中间数字来确定哈希表中的索引。 因此,密钥 456 被映射到哈希表中的索引 07 。
-
均匀分布 :通过对密钥进行平方并提取中间的数字,这种方法倾向于产生更均匀的密钥分布,因为平方有助于将 数值分散开来。 -
简洁性 :中平方方法实现起来十分简单。 它涉及对密钥进行平方,然后提取结果的中间部分。 -
独立于密钥大小 :该方法相对独立于密钥的大小,适用于各种 密钥长度。
-
依赖于中间数字 :该方法的效率依赖于平方值的中间数字。 如果中间数字分布不均,可能导致 聚集。 -
选择数字 :决定提取多少中间数字可能是一个挑战,并且可能需要实验来优化 特定应用。 -
有限的密钥范围 :对于非常小的密钥,平方后的值可能无法提供足够的数字来提取,从而降低该方法的有效性。 -
计算成本 :对非常大的密钥进行平方计算可能在计算上开销较大,特别是在处理能力有限的环境中。
折叠方法
-
拆分键 :将键分成相等的部分。 为了简单起见,我们将其拆分为每组三位数: -
将部分相加 :将各部分相加: -
取模 :将总和对表的大小取模得到哈希值 代码:
-
均匀分布 :折叠方法旨在通过确保密钥的所有部分都对哈希值做出贡献,从而产生更均匀的密钥分布。 这有助于减少聚集现象,并提高哈希表的整体性能。 -
简洁性 :该算法实现和理解都非常直接。 它仅涉及拆分、求和和取模运算。 -
灵活性 :它可以通过调整键的拆分方式,处理各种大小的键。
-
依赖于键结构 :折叠哈希函数的效率取决于键的结构。 如果键的各部分具有相似的模式或值,可能无法均匀分布这些 键值。 -
不适合小键值 :对于小键值,拆分和求和的开销可能比简单的哈希函数(如除法余数法)提供的益处要小。 -
处理不同长度 :如果键值长度不同,可能很难决定如何均匀拆分它们,这可能导致 分布不均。 -
求和溢出 :对于非常大的键值,部分的总和可能超过典型的整数范围,导致溢出问题。 不过,这可以通过在每一步使用模运算来缓解。
通用哈希
-
定义一组哈希函数 从中选择一个特定的函数 来使用。 每个哈希函数 应该能够在哈希表中均匀地映射键。 -
随机选择一个哈希函数 从这组函数中 选择一个用于哈希 键。 -
使用选定的哈希函数 计算 键的哈希值。
-
键: 123456 -
表格大小 : 100 -
质数 : 101 -
随机选择的 和 :假设 和 -
我们计算 哈希值: -
首先,我们计算 中间值: -
然后,我们计算最终的哈希 值: 因此,选定参数下,键 123456 的哈希值为 90 。
多项式哈希用于字符串
-
初始化 :选择一个素数 (通常是 11 或 31) 和一个模数 (通常是一个大素数,以最小化哈希冲突,并且与 哈希表的大小相对应). -
哈希计算 : -
初始化哈希值 为 0 。 -
遍历字符串中的每个字符。 -
对于每个字符,执行 以下操作:
-
将当前哈希值 乘以 。 -
加上 字符的 ASCII 值。 -
对结果应用模运算 ,确保哈希值保持在一个 可管理的范围内。
-
-
滚动哈希 :要计算子字符串的哈希值,我们可以减去已经不在子字符串中的字符的哈希值,并加上新包含的字符的哈希值。 这种“滚动”更新非常高效,可以快速比较 不同的子字符串。
def polynomial_hash(string, p=11, m=2**31):
hash_value = 0
for char in string:
hash_value = (hash_value * p + ord(char)) % m
return hash_value
# Example usage
string = "Hello"
hash_value = polynomial_hash(string)
print(f"The polynomial hash value of '{string}' is: {hash_value}")
-
该 polynomial_hash 函数接受一个字符串作为输入,并可以带有可选参数, p (素数) 和 m (模数) -
它将 hash_value 初始化为 0 -
它遍历每个字符( char )在 字符串中 -
对于每个字符,执行哈希 更新计算: -
hash_value * p 有效地将前一个字符在 多项式中向左移动一个位置 -
ord (char) 获取 字符 的 ASCII 值 -
结果通过取模 m 来防止溢出,并确保 哈希值 的范围一致 -
最后,它返回计算得到的 哈希值 -
字符串 "Hello" 的多项式哈希值是 99162322
-
DJB2 哈希函数用于字符串
-
初始化 :哈希值的初始值设为 5381 。这个初始值的选择有些任意,但在实际应用中证明效果良好。 实践中已被证明有效。 -
迭代 :该函数遍历输入字符串中的每个字符。 -
哈希更新 :对于每个字符,当前的哈希值会被乘以 33(左移 5 位后再加到自身)。 该字符的 ASCII 值会被加到 哈希值中。 -
最终化 :在处理完所有字符后,哈希值通常会进行掩码处理,以确保其适合 32 位无符号 整数范围。
def djb2(string):
hash = 5381
for char in string:
hash = ((hash << 5) + hash) + ord(char)
return hash & 0xFFFFFFFF
<st c="64094">djb2</st>
<st c="64169">5381</st>
<st c="64208">char</st>
<st c="64292">(hash << 5) + hash</st>
<st c="64340">hash</st>
<st c="64348">33</st>
<st c="64351">ord(char)</st>
<st c="64499">string = "Hello"</st>
<st c="64550">99162322</st>
碰撞处理
链式法
-
元素数量: -
槽位数: -
负载因子:
-
较低的负载因子通常意味着更高的效率,因为链条较短,搜索时间 更快 -
较高的负载因子表示更好的内存利用率,因为更多的槽位被使用,但也可能由于 更长的链条 而导致性能下降 -
维持一个最优的负载因子(通常低于 0.75)通常需要动态调整哈希表的大小,以平衡性能和 内存利用率
-
随着负载因子的增加,每个索引处的链条变得更长,从而导致搜索、插入和 删除时间增加 -
较高的负载因子增加了碰撞的可能性,这可能会降低哈希表的性能 -
频繁调整大小以维持最优负载因子可能会带来开销,并影响调整大小操作期间的性能
开放寻址
-
线性探测法 :当 发生冲突时,线性探测法检查表中的下一个槽,继续这一过程,直到找到一个空槽。 示例 7.8 为了 使用线性探测法处理冲突,针对一个 大小为 10 的哈希表和键 12 , 13 , 22 , 和 32 , 我们使用哈希函数 。这些键的哈希值分别为 2 , 3 , 2 , 和 2 。
-
计算 哈希值: -
对于键 12 : 2 -
对于键 13 : -
对于键 22 : -
对于键 32 : 2
-
-
使用 线性探测处理冲突: -
键 12 哈希到索引 2 ,因此它被放置在 索引 2 。 -
键 13 哈希到索引 3 ,因此它被放置在 索引 3 。 -
键 22 哈希到索引 2 ,此位置已被 12 占用。使用线性探测,我们检查下一个槽(索引 3 ),该槽已被 13 占用。然后我们检查下一个槽(索引 4 ),该槽为空,因此 22 被放置在 索引 4 。 -
键 32 哈希到索引 2 ,此位置已被 12 占用。使用线性探测,我们检查下一个槽(索引 3 ,该槽已被 13 占用),以及下一个槽(索引 4 ,该槽已被 22 占用)。 下一个空槽位位于索引 5 ,因此 32 被放置在 索引 5 。
-
-
这是结果的 哈希表:
-
二次探测 :这种方法类似于线性探测,但使用二次函数来确定下一个槽位。 探测序列是 ,其中 是探测次数。 例子 7.9 为了处理哈希表大小为 10 和关键字 12 , 13 , 22 和 32 的碰撞,我们使用哈希函数 。这些关键字的哈希值分别为 2 , 3 , 2 和 2 。
-
计算 哈希值: -
对于关键字 12: -
对于键 13: -
对于键 22: -
对于键 32:
-
-
处理冲突 使用 二次探测法: -
键 12 哈希到索引 2,因此它被放置在 索引 2 -
键 13 哈希到索引 3,因此它被放置在 索引 3 -
键 22 哈希到索引 2,该位置已经被 12 占用。 使用二次探测法,我们检查下一个槽位 如下: ,该位置被 13 占用 ,该位置为空,因此 22 被放置在 索引 6 -
键 32 哈希到索引 2,该位置被 12 占用。 使用二次探测法,我们检查下一个槽位 如下: ,该位置被 13 占用 ,该位置被 22 ,该位置为空,因此 32 被放置在 索引 1
-
-
这是得到的 哈希表:
-
双重哈希 :这个方法 使用一个辅助哈希函数来确定探测序列,从而进一步减少聚集。 探测序列如下: 。 例 7.10 要使用双重哈希处理一个大小为 10 的哈希表,并且键为 12、13、22 和 32,我们使用 主哈希函数 。这些键的哈希值分别是 2、3、2 和 2。 我们还使用了一个次级哈希函数, h 2 ( k e y ) = 1 + ( k e y m o d 9 ) ,用于确定 探测序列:
-
计算主 哈希值: -
对于键 12: -
对于键 13: -
对于键 22: -
对于键 32:
-
-
使用 双重哈希处理冲突: -
键 12 哈希到索引 2,因此它被放置在 索引 2。 -
键 13 哈希到索引 3,因此它被放置在 索引 3。 -
键值 22 哈希到索引 2,已经被 12 占用。 使用二次哈希函数,我们计算出 探测序列: 二次哈希 对于 22: 探测序列: 第一次探测: ,该位置为空,因此 22 被放置在 索引 7 -
键值 32 哈希到索引 2,已经被 12 占用。 使用二次哈希函数,我们计算出 探测序列: 二次哈希 对于 32: 探测序列: 第一次探测: ,该位置为空,因此 32 被放置在 索引 8
-
-
这是 结果 哈希表:
布谷鸟哈希 – 吸取鸟类碰撞解决方法的灵感
-
两个哈希函数 :布谷鸟哈希采用两个独立的哈希函数( 和 ),它们将键映射到哈希表中的槽位。 这使得每个键有两个潜在的位置可以 存储。 -
插入 :当插入一个键时,算法首先尝试将其放置在由 :确定的槽位 。-
如果槽位为空,键 将被插入。 -
如果该槽位已被占用,现有的键将被“踢出”,新键将取而代之。
被“踢出的”键然后会尝试插入到其备用位置,该位置由 确定。 该过程会持续进行,可能会继续踢出更多的键,直到找到一个空槽或达到最大踢出次数为止。 -
-
查找 :为了查找一个键,算法会检查两个可能的位置( 和 )。 如果在任一位置找到该键,查找 就成功了。 -
删除 : 删除一个键是直接的;只需从找到它的槽中移除即可。
-
我们使用以下 哈希函数: -
-
(‘/’ 是 整数除法)
-
-
插入步骤: -
键 12: 哈希 值: 将 12 放入表格 1 的 索引 2 处。 -
键 13: 哈希 值: 将 13 放入表格 1 的 索引 3 处。 -
键 22: 哈希 值: 表格 1 中的索引 2 已被 12 占用。 将 12 踢出并将 22 放入 表格 1 -
索引 2。 重新插入 12,使用 : 将 12 放入表 2 的 索引 1 处。 -
键 32: 哈希 值: 表 1 中的索引 2 被 22 占据。 踢出 22 并将 32 放入表 1 中的 -
索引 2。 重新插入 22,使用 : 表 2 中的索引 2 为空,因此将 22 放入表 2 的 索引 2 处。
-
-
以下是 结果 哈希表:
总结
总结
参考文献与进一步阅读
-
《算法导论》 . 作者:Thomas H. 科门,Charles E. 莱瑟森,Ronald L. 里维斯特,和 Clifford Stein。 第四版。 MIT 出版社。 2022 年: 第十一章 , 哈希表
-
《计算机程序设计的艺术》 . 作者:D. E. 克努斯。 第 3 卷:排序与查找(第二版)。 Addison-Wesley。 1998 年: -
第 6.1 节 *, 查找 -
第 6.2 节 , 二分查找 -
第 6.4 节 *, 哈希
-
-
《C++中的数据结构与算法分析》 . 作者:M. A. 威斯。 (第四版)。 Pearson。 2012 年: -
第五章 *, 哈希 -
第七章 , 查找树
-
-
算法 . 作者:R. 塞奇威克,K. 韦恩。 第四版。 Addison-Wesley。 2011 年: 第 3.4 节 , 哈希表
第十一章:8
排序与搜索的共生关系
-
在排序 和搜索 之间找到合适的平衡 -
效率困境——组织 还是不组织?
在排序和搜索之间找到合适的平衡
排序与搜索的共生关系 g
-
共生关系 :两种生物 都从这种关系中受益。 例如,蜜蜂和花朵之间有共生关系;蜜蜂从花朵中获取花蜜,而花朵通过蜜蜂的传粉得到授粉。 -
共栖关系 :一种生物受益,而另一种生物既不受到帮助也不受到伤害。 例如,螺旋藻附着在鲸鱼身上,借助被鲸鱼带到不同的觅食场所而受益,但不影响 鲸鱼。 -
寄生关系 :一种生物 以另一种生物为代价获得好处。 例如,蜱虫吸食哺乳动物的血液,使蜱虫受益,但可能对宿主造成伤害。
-
搜索操作的效率 :二分查找算法要求数据是已排序的,才能正确工作。 当数据已排序时,二分查找可以在 时间内快速定位元素,这比 线性查找在 无序数据中的时间复杂度要快得多。 -
排序作为预处理步骤 :许多与搜索相关的问题从排序作为预处理步骤中受益。 例如,当处理范围查询或搜索多个元素时,首先对数据进行排序可以带来更 高效的算法。 -
复杂算法 :一些复杂算法结合了排序和搜索,以更高效地解决问题。 例如,寻找中位数、众数或其他统计量的算法通常会从 排序数据开始。 -
数据结构 :如平衡二叉查找树(例如,AVL 树和红黑树)等数据结构本身维持有序顺序,从而支持高效的搜索、插入和删除操作。 类似地,数据库中使用的 B 树也维持有序数据,以优化 搜索操作。
-
AI 搜索中的探索与利用 : -
探索 :由像 广度优先搜索 ( BFS )等算法实现,探索涉及 广泛地扫描搜索空间。 我们在探索上投入的越多,就越不需要依赖于利用,而后者可能 是有风险的。 -
利用 :由如 深度优先搜索 ( DFS )等算法实现,利用 专注于深入探索一条路径,然后再尝试其他路径。 平衡探索与利用对于避免搜索失败的风险和确保 高效解决问题至关重要。
-
-
成本与人工智能中的启发式搜索策略 策略 : 在人工智能的搜索策略中,搜索成本与启发式方法之间存在共生关系。 在搜索过程的早期,投入精心选择的启发式方法可以显著降低整体搜索成本,帮助搜索朝着更有成效的方向发展。 这种平衡有助于更高效地解决问题。 一个典型的例子是 A*搜索算法,它将启发式方法和路径成本相结合,最优地找到最 高效的解决方案。
效率困境——组织还是不组织?
像计算机科学家一样思考
-
平均每天收到的新论文数量 :p -
平均每天访问的科研论文数量 :s -
总天数 :k
-
排序能力 :我们假设 Janet 能够使用高效的算法(例如归并排序)对她的文件进行排序。 然而,通常情况下,人类排序的效率不及简单的算法(如冒泡排序),后者的效率要低得多。 -
平等访问概率 :我们假设所有文件被访问的可能性相同。 但实际上,一些文件被频繁引用,而其他文件则很少甚至从未被检索,可能遵循幂律分布。 -
将文件返回顶部 :在无排序的情况下,我们假设 Janet 通常会把用过的文件放回堆顶。 这意味着她不必重新翻找整个堆来找到该文件。 此外,尽管人类在系统排序方面表现不佳,但他们通常具有很强的能力在随机杂乱的物品中定位物品,这与心理学上的 哈希机制类似。
总结
参考资料和进一步阅读
-
《活出算法:人类决策的计算机科学》 。作者:布莱恩·克里斯汀和汤姆·格里菲斯。 出版社:亨利霍尔特和 公司。 2016 年 -
《深度工作:专注成功的规则在分心世界中》 。作者:卡尔·纽波特,大中央 出版社,2016 年
第十二章:9
随机算法
-
概率算法综述 的回顾 -
随机算法分析 的分析 -
案例研究
概率算法综述
-
一个新的在线约会应用程序 Matcher 已经开发出来,旨在帮助用户找到潜在的伴侣。 该应用程序像一个游戏,旨在将用户与他们最合适的 约会对象配对: -
该应用程序中有 潜在的匹配项可供选择(匹配总数对用户是未知的)。 让我们考虑一个名为 Tom 的用户。 -
当 Tom 打开Matcher应用时,系统会一次展示一个潜在匹配对象,随机选择。Tom 可以选择喜欢(向右滑动)或不喜欢(向左滑动)每个个人资料。
-
Tom 的决定是不可逆的;一旦他在个人资料上向右滑动或向左滑动,他就无法改变主意。所有的决定都是最终的。
-
Tom 最多可以喜欢n个个人资料(其中n远小于N)。一旦 Tom 喜欢了n个个人资料,应用程序将不再向他展示更多个人资料。
-
互动结束的条件是:要么 Tom 已经喜欢了n个个人资料,要么没有更多的个人资料可以显示。
-
需要注意的是,喜欢一个个人资料并不保证匹配;还需要对方喜欢 Tom 的个人资料。
-
对 Tom 来说,挑战在于如何最有效地利用他的n个喜欢,选择最合适的匹配对象。他需要决定何时停止浏览,开始喜欢个人资料,以最大化选择最佳可用选项的机会。
-
-
芳被邀请去她朋友家,朋友家位于一条长长的单行道的中间。 街道的一个侧面允许停车。 街道上交通繁忙,找到停车位非常具有挑战性。 根据经验,约有 10%的 停车位通常在任何时刻可用。 芳每次经过时只能看到一个停车位,无法看到前方的停车位。 目标是确定她何时应决定停车并占用一个可用的 停车位。
-
汤姆正在寻找一个最佳匹配的 约会应用程序 -
芳正在尝试找到一个最靠近她 朋友家的停车位
-
数据顺序到达 :数据项逐一到达,必须立即做出决策,而无法预知 未来的数据。 -
不可逆决策 :一旦做出接受或拒绝某个选项的决策,就无法撤回。 这增加了复杂性,因为你不能重新审视 之前的选择。 -
最佳停顿规则 :这些问题的核心在于找到最佳的停顿时刻。 这涉及到决定在何时停止搜索,并接受当前选项作为 最佳可用选项。
-
汤姆可以使用一种策略,他最初会查看一定数量的资料,而不做任何决定(以收集匹配质量的信息),然后选择一个比他已经查看过的资料更好的资料。 到目前为止。 -
方可以开车经过前几个停车位(以评估可用性和接近度),然后停在一个比她已看到的更近的停车位。 已经看到的停车位。
非确定性算法
-
随机化算法的分析 :理解算法在概率和期望结果方面的表现至关重要。 这涉及到分析平均情况的表现,而不仅仅是关注最坏情况。 这一主题将在接下来的名为 随机化算法的分析 的部分中详细讲解。 -
随机化数据结构 :设计包含随机性的 数据结构可以带来更高效的操作。 例如,跳表和哈希表就是其中的典型。 尤其是跳表,我们将在 第十一章 中深入探讨。 -
案例研究 :为了应用所学的概念,我们将分析一些在不确定性下的特定问题,比如本节开始时提到的问题。 详细的解决方案和讨论将在名为 案例研究 的部分中呈现。
随机化算法的分析
-
我们计算关键性能指标的期望值,如运行时间或空间使用。 这涉及对所有可能输入的性能进行平均,并按 它们的概率加权。 -
我们研究算法性能指标的分布和方差,以了解它们偏离期望值的程度。 这有助于评估算法的可靠性和一致性。 算法。 -
重点是分析算法在典型或随机选择的输入下的行为,而不是最坏情况下的输入。 这提供了一个更现实的算法 实际性能衡量标准。 -
我们使用反映算法预期使用场景的现实输入模型。 例如,在排序中,假设输入是随机排列的,而不是总是已排序或 倒序排序的。 -
我们建立了具有高概率的性能界限。 例如,一个算法可能会在 时间内高概率运行,即使它偶尔会 运行较慢。 -
使用随机变量来模拟算法内部的随机性。 我们分析这些变量如何影响算法的行为和性能。 我们还考虑随机变量之间的独立性或相关性,以简化分析或得出更准确的 性能估计。
蒙提·霍尔问题
-
汽车在 A 门后(你最初选择的门)的概率: 。 -
汽车在 B 门或 C 门后面的概率: 。 -
主持人打开 B 门的动作(它总是会露出一只山羊)并不会改变最初的概率。 相反,它提供了额外的信息,影响了这些概率的分布。 这些概率的分布情况。
-
汽车在你最初选择的门后(门 A)的概率: : -
如果你坚持选择 A 门,你有概率赢得汽车,概率为
-
如果你切换到 C 门,你有概率输掉,概率为
-
-
汽车可能在其他门(B 门或 C 门)后的概率为:
-
由于主持人已经在 B 门后揭示了山羊,如果汽车不在 A 门后,那么它一定在 C 门后。
-
如果你切换到 C 门,你有概率赢得汽车,概率为
-
生日悖论
-
第一个人拥有独特生日的概率是 (因为还没有选择其他人) -
第二个人和第一个人生日不同的概率 是 -
第三个人和前两个人生日不同的概率 是 -
…. -
对于 个人,所有生日都独特的概率 如下所示: 如下: 或者,它是 如下所示:
import random
import matplotlib.pyplot as plt
def simulate_birthday_paradox(trials, n):
shared_birthday_count = 0
for _ in range(trials):
birthdays = []
for person in range(n):
birthday = random.randint(1, 365)
if birthday in birthdays:
shared_birthday_count += 1
break
birthdays.append(birthday)
return shared_birthday_count / trials
def main():
trials = 10000
results = []
group_sizes = range(2, 367)
for n in group_sizes:
probability = simulate_birthday_paradox(trials, n)
results.append(probability)
print(f"Group size: {n}, Probability of shared birthday: {probability:.4f}")
plt.figure(figsize=(10, 6))
plt.plot(group_sizes, results, marker='o')
plt.title('Birthday Paradox Simulation')
plt.xlabel('Group Size')
plt.ylabel('Probability of Shared Birthday')
plt.grid(True)
plt.show()
if __name__ == "__main__":
main()
雇佣秘书问题
-
顺序面试 : 候选人 以随机顺序逐一进行面试。 -
立即决策 :在 每次面试后,雇主必须决定是否聘用该候选人。 如果拒绝,该候选人将 无法重新考虑。 -
目标 :目标是最大化选择 最佳候选人的概率。
-
观察阶段 :直接拒绝前 第一个 候选人。 此阶段纯粹是为了观察,以便了解候选人的质量。 第一个 候选人也被称为 训练样本。 -
选择阶段 :从 第 候选人开始,聘用第一个比所有之前 面试过的候选人都更优秀的人。
我们知道 。
以下 Python 代码估算 对于不同的
值的估算,并且还估算了最大化
的比率,这个比率最大化了
的概率。
import numpy as np
import matplotlib.pyplot as plt
def calculate_p_n(n, k):
if k == 1:
return 1 / n
sum_term = sum(1 / (j - 1) for j in range(k, n + 1))
return (k - 1) / n * sum_term
def find_optimal_k(n):
probabilities = [calculate_p_n(n, k) for k in range(1, n + 1)]
optimal_k = np.argmax(probabilities) + 1
return optimal_k, probabilities
n_values = np.arange(10, 501, 1) # Smoother plot with more points
optimal_k_ratios = []
for n in n_values:
optimal_k, probabilities = find_optimal_k(n)
optimal_k_ratios.append(optimal_k / n)
plt.figure(figsize=(10, 6))
plt.plot(n_values, optimal_k_ratios, marker='o', linestyle='-', markersize=4, label='Optimal k/n Ratio')
plt.axhline(1/np.e, color='r', linestyle='--', label='1/e (approximately 0.3679)')
plt.title('Optimal k/n Ratio for Different Values of n')
plt.xlabel('n')
plt.ylabel('Optimal k/n Ratio')
plt.legend()
plt.grid(True)
plt.show()
<st c="25373">calculate_p_n</st>
<st c="25528">find_optimal_k</st>
-
n_values :用于分析的不同 值。 -
optimal_k_ratios :此项存储比率 最大化 每个 的值。 -
第一个图(图 9.4)显示了
与
的关系,针对不同的
值。第二个图(图 9.5)显示了最大化
的比例,针对不同的
值,且包含了一条参考水平线,表示
。
示例 9.1
-
观察阶段 :拒绝前 个候选人。 -
选择阶段 :从第五个候选人开始,聘用第一个比所有 前面候选人都优秀的人。
-
未知候选人数量 :如果候选人数量 未知,可以开发自适应策略 来应对。 -
多重选择 :可以选择多个候选人的变体,相应调整 策略
案例研究
在线约会应用中的最佳选择
-
Tom 必须实时决定是 喜欢 还是 不喜欢 每个呈现的资料 按顺序展示 -
一旦 Tom 选择了 喜欢 个资料后,应用程序将不再向他展示任何 其他资料 -
目标是在他的限制条件下,最大化选择最佳匹配的概率 喜欢
-
Tom 无法重新访问 之前的资料 -
喜欢 一个资料并不保证匹配;还需要对方也 喜欢 Tom 的资料
-
观察阶段 :Tom 应该观察并拒绝前 个资料,以收集关于候选池的信息。 在经典的秘书问题中,最优选择的 大约是 。然而,由于 是未知的,Tom 可以使用一种自适应策略来 估计 。 -
选择阶段 :在观察阶段之后,Tom 应该开始选择下一个比他在观察阶段看到的所有资料更好的资料。 如果 Tom 没有找到更好的资料,他将选择他遇到的最后几个资料,确保他使用所有 选择。
-
观察阶段 : 排除第一个 的资料。 例如对于 ,这意味着排除前 个资料。 -
选择阶段 : 从 开始喜欢资料,仅当它们比观察阶段看到的所有资料更好时,才可以选择。
-
人类记忆的局限性 :对于汤姆来说,记住并比较 每个他看到的新资料是不现实的。 这会带来巨大的认知负担,并且对 大多数人来说并不可行。 -
按顺序排列的资料潜在偏差 :如果资料是按某种方式排序的(例如按他们收到的点赞数排序),则此策略可能会失败。 如果最佳资料出现在开始或结束位置,这种策略将无法有效工作,因为它依赖于 资料排序的随机性。
寻找最靠近的停车位
-
街道起点的目的地 :这是最简单的情况,因为方应该选择她遇到的第一个可用停车位。 在这里无需应用最优停止理论,因为这代表的是 最理想的情况。 -
街道尽头的目的地 :这是最坏的情况。 方需要找到离目的地最近的可用停车位,因此她应该尽可能多地拒绝停车位,以最大化找到接近街道尽头的停车位的机会。 最优停止理论在 这种情况下特别有用。 -
街道中间的目的地 :这代表了一个平均情况,假设目的地正好位于街道的中间。 在这里,最优停止理论同样适用,帮助方决定何时停止并选择一个可用的停车位。 我们为 这个情况解决问题。
-
到达目的地之前 :这一部分与最坏情况类似。 方将尽量使用最优停止理论拒绝尽可能多的停车位。 她应该拒绝前 个停车位,然后选择第 个可用的停车位。 -
经过目的地后 :如果方在到达目的地之前找不到任何可用停车位,策略就会发生变化。 此时,问题转变为最理想的情况。 方应当在经过目的地后,停车于她遇到的第一个可用停车位。 目的地之后。
-
确定 : 对于最优停车策略,我们计算 。然而,考虑到通常只有约 的停车位在任何时候是可用的,我们需要相应地调整我们的计算。 方可以通过考虑街道的长度除以普通轿车的平均长度来估算街道上可以停放的车辆总数。 由于她的目的地位于街道中间,我们将可用性乘以 。如果她的目的地在街道的前三分之一,那么这个系数将是 。因此,我们按以下方式调整 :
-
中点之前 :方应当开车经过第一个 停车位而不停车。 经过 停车位后,方将选择下一个比她之前见过的所有停车位都更好的空位停车。 到目前为止。 -
中点之后 :如果方在到达中点时尚未找到合适的停车位,她将停在遇到的第一个空闲停车位。 她遇到的第一个停车位。
总结
参考文献与进一步阅读
-
算法导论 。作者:Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest 和 Clifford Stein。 第四版。 MIT 出版社。 2022 年: 第五章 , 概率分析与 随机算法
-
算法与人生:人类决策的计算机科学 。作者:Brian Christian 和 Tom Griffiths。 亨利·霍尔特出版社 公司 2016 年。 -
谁解答了秘书问题 。作者:T. S. Ferguson。 统计学 科学 *。4( 3):282–89。
第十三章:10
动态规划
-
动态规划 与分治法 -
探索 动态规划 -
贪心算法 – 简介
动态规划与分治法
最优子结构
然而,如果最后的字符在 和
不匹配(即,
),那么最长公共子序列(LCS)是通过以下两种方法中得到的较长的一个:
-
排除
的最后一个字符,并考虑
和
的最长公共子序列。
-
排除最后一个字符 并考虑 和
-
后续路径的依赖性 :在许多情况下,一个看似是从一个节点到另一个节点的最长路径的一部分的子路径,扩展到其他节点时可能并不会导致整体的最长路径。 这是因为,选择一个看起来很长的子路径,可能会迫使你在后续选择较短的路径,从而减少整体的 路径长度。 -
环的参与 :如果图中包含环,那么最长路径可能涉及以某种方式穿越图的部分,使得它无法简单地分解为独立贡献于整体最长路径的子问题。 是否包含或排除某些边缘的决定,可能会显著改变最终的 路径长度。 -
非加性特性 :在具有最优子结构的问题中,通常可以独立地解决子问题,然后将它们组合起来得到最优解。 然而,在最长路径问题中,优化地解决一个子问题(即,从一个节点到另一个节点找到最长路径)并不能保证这个子路径将成为整个图中最优解的一部分。 例如,如果你已经找到了从顶点 到顶点 ,然后从顶点 到顶点 ,这些路径的组合可能并不会产生从 到 的最长路径。 可能全局最优解会绕过,如果有一条更长的替代路径 可用的话。
假设这些节点
重叠子问题
探索动态规划
没有记忆化时
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
n = 10
print(f"Fibonacci number F({n}) is: {fib(n)}")
使用备忘录技术
def dp_fib(n, memo={}):
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = dp_fib(n-1, memo) + dp_fib(n-2, memo)
return memo[n]
n = 10
print(f"Fibonacci number F({n}) is: {dp_fib(n)}")
自顶向下与自底向上动态规划方法
def bottom_up_fib(n):
if n <= 1:
return n
fib = [0] * (n+1)
fib[1] = 1
for i in range(2, n+1):
fib[i] = fib[i-1] + fib[i-2]
return fib[n]
n = 10
print(f"Fibonacci number F({n}) is: {bottom_up_fib(n)}")
![]() |
![]() |
|
使用动态规划解决 0/1 背包问题
-
如果 ,这个问题被称为 0/1 背包问题。 这是我们将在 本章集中解决的问题。 -
如果 ,其中 是一个常数, 这个问题被称为 有界背包问题 (BKP) 。 -
如果 可以是任何非负整数,那么 该问题称为 无限背包问题 问题 ( UKP ).
最优子结构意味着问题的最优解可以看作一个背包问题,包含
换句话说,较大问题(
-
物品 1: -
物品 2: -
项目 3:
递归解决这个问题时,我们可能会遇到以下子问题:
-
包括项目 1:这会导致解决剩余容量为的子问题
和剩余的项目(项目 2 和 项目 3) -
不包括项目 1:这会导致解决带有完全容量的子问题
和剩余的项目(项目 2 和 项目 3)
然而,当我们考虑项目 2 时,你将再次遇到
-
包括项目 2:解决容量为的问题
(如果包含项目 1) 或 (如果不包含项目 1) 和剩余项目( 项目 3) -
不包括项目 2:解决当前容量的问题,剩余只有项目
3 剩下
正如我们所见,一些子问题(例如,带有的子问题)
现在我们已经清楚理解了 0/1 背包问题中动态规划的两个基本要素,让我们一步步地走过
-
创建 一个二维表格 ,其中有 行和 列,其中 表示物品数量, 表示背包的总容量。 令 表示使用前 个物品,且背包容量为 时所能获得的最大值。例如, 表示使用前 4 个物品,假设背包最大容量为 5 时能达到的最大值,尽管实际容量可能 更大。 -
初始化第一行 和第一列 ,这意味着 对于所有 和 对于所有 . 假设我们有三个物品,其重量和价值如下: 和 并且背包容量是 6。 (见 表 10.2 )。 0 0 1 0 2 0 |
3 |0 | | | | | | |
-
通过以下规则填充表格: 以下规则: 对于每个项 和 重量 : -
如果新项的重量超过当前重量限制,我们将排除新项: ) 如果 。 -
否则,我们有 两种选择: -
包含当前 项: -
排除当前 项:
-
选择这两个值中的最大值: 如果 -
-
生成 解。 该解位于 ,表示通过整个物品集合和完整背包容量可以达到的最大值。 表 10.3 显示了完整的表格, ,最终的解在右下角被突出显示。 最大值为 45,表示仅选择物品 1 和物品 3。 0 0 1 0 2 0 |
3 |0 |15 |15 |20 |30 |30 |45 |
def dp_knapsack(weights, values, W):
n = len(weights)
d = [[0 for _ in range(W + 1)] for _ in range(n + 1)]
for i in range(n + 1):
for w in range(W + 1):
if i == 0 or w == 0:
d[i][w] = 0
elif weights[i - 1] <= w:
dp[i][w] = max(values[i - 1] + d[i - 1][w - weights[i - 1]], d[i - 1][w])
else:
d[i][w] = d[i - 1][w]
return d[n][W]
weights = [2, 3, 4]
values = [3, 4, 5]
W = 5
result = knapsack(weights, values, W)
print("Maximum value:", result)
动态规划的局限性
-
最优解 :动态规划保证在存在重叠子问题和最优子结构的问题中找到最优解。 通过系统地解决并存储子问题的解,动态规划确保最终解是 最佳可能解。 -
效率 :通过避免重计算重叠子问题,动态规划将许多问题的时间复杂度从指数级降低到多项式级,使得解决大规模问题成为可能,这些问题使用 其他方法是不可行的。 -
多功能性 :动态规划可应用于广泛的问题领域,包括但不限于优化问题如背包问题、最短路径问题和序列比对。 它是解决各种组合、概率和 确定性问题的强大工具。 -
空间与时间的权衡 :动态规划通常允许在空间和 时间复杂度之间进行权衡。 例如,通过存储中间结果,可以降低时间复杂度,但以增加空间使用为代价。 在某些情况下,还可以应用空间优化技术以减少 空间需求。
-
高空间复杂度 :动态规划的主要缺点之一是可能具有高空间复杂度。 存储所有子问题的解可能需要大量内存,尤其是对于输入规模或维度较大的问题,在 内存受限的环境中可能是不可行的。 -
复杂的实现 :与贪婪算法或分治法等简单方法相比,动态规划的实现可能更为复杂。 正确定义子问题、识别递归结构以及管理动态规划表格的需要,可能使实现变得具有挑战性,特别是对于 复杂的问题。 -
问题特定 :动态规划并非通用解决方案,只适用于展示重叠子问题和最优子结构的问题。 对于不符合这些条件的问题,动态规划可能不提供任何优势,甚至 可能效率低下。 -
难以识别子问题 :在某些情况下,确定适当的子问题并构建动态规划解的递推关系可能并不容易。 这需要对问题有深刻的理解,这可能是有效应用动态 规划的障碍。 -
表格管理的开销 :特别是在具有多个维度或状态的复杂问题中,管理动态规划表格可能会增加额外的开销和复杂性。 如果不 有效管理,这也可能导致增加的计算开销。
贪心算法——简介
-
局部最优选择 :贪心算法通过选择每一步看似最好的选项来做出决策,而不考虑该选择的全局后果。 -
没有重叠子问题 :贪心算法不需要重叠的子问题。 相反,它们最适用于每个选择互不依赖的问题。 -
简单实现 :由于贪心算法通常涉及直接、顺序的决策,因此相比于 动态规划,它们更容易实现,且在时间复杂度上更高效。
旅行商问题
-
从一个随机城市开始 :选择一个任意城市作为起点。 -
访问最近的未访问城市 :从当前城市出发,访问尚未访问的最近城市。 这一决策是根据当前城市与潜在下一个城市之间的最短距离做出的。 -
重复直到所有城市都被访问 :继续移动到最近的未访问城市,直到所有城市都已被访问。 -
返回起始城市 :当所有城市都被访问过后,返回起始城市以完成旅行。
-
A 到 B = 10 -
A 到 C = 15 -
A 到 D = 20 -
B 到 C = 35 -
B 到 D = 25 -
C 到 D = 30
-
从 城市 A 开始。 -
访问最近的城市:从 A 出发,最近的城市是 B(距离 = 10)。 -
移动到 B 城市:从 B 出发,最近的未访问城市是 D(距离 = 25)。 -
移动到 D 城市:从 D 出发,最近的未访问城市是 C(距离 = 30)。 -
移动到 C 城市:现在所有城市都已访问。 最后,返回起始城市 A(距离 = 15)。 -
使用贪心算法得到的最终旅游路径是 A → B → D → C → A,总距离是 10 + 25 + 30 + 15 = 80 单位。
启发式方法及其在贪心算法中的作用
启发式方法是
-
最优子结构不存在。 如果问题没有明确的最优子结构,动态规划可能不适用。 在这种情况下,贪心算法可以提供更直接的解决方案。 例如。 -
贪心算法适用于特定类型的问题,例如调度、最短路径或资源分配问题,在这些问题中,每一步的局部最优选择能够产生整体 最优解。 -
可以接受快速的近似解,数据量较小,且计算资源 有限。 -
问题的背景允许在解的质量与 计算效率之间进行潜在的权衡。
总结
参考书目与进一步阅读
-
算法导论。 作者:Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest,Clifford Stein。 第四版。 MIT 出版社。 2022 年。 -
第十五章, 动态规划 -
第十六章, 贪心算法 -
第三十四章,NP 完全性(用于算法复杂度的比较) 算法复杂度比较
-
-
算法设计。 作者:J. Kleinberg 和 É. Tardos。 Pearson 出版。 2006 年。 -
第四章, 贪心算法 -
第五章, 分治法 -
第六章, 动态规划
-
-
算法。 作者:S. Dasgupta,C. H. Papadimitriou 和 U. V. Vazirani。 McGraw-Hill 出版。 2008 年 -
第二章, 分治法 -
第五章, 贪心算法 -
第六章, 动态规划
-
第十四章:第三部分:基础数据结构
-
第十一章 **, 数据结构的全景 -
第十二章 **, 线性数据结构 -
第十三章 **, 非线性数据结构
第十五章:11
数据结构的概貌
-
数据结构的分类 数据结构 -
抽象 数据类型 -
字典
数据结构的分类
物理数据结构与逻辑数据结构
原始数据结构与复合数据结构
线性数据结构与非线性数据结构
-
连续内存分配 :线性数据结构,如数组,使用连续的内存块,使得由于引用局部性,内存访问更快、更可预测。 这使得缓存内存的利用更加高效,减少了访问 元素的开销。 -
内存管理简便 :因为线性数据结构通常涉及固定大小(如数组)或顺序指针(如链表),所以内存管理较为简单。 与更复杂的结构相比,内存分配和释放更容易实现。 -
低内存开销 :对于如数组这样的结构,由于不需要额外的指针或链接来连接元素,因此内存开销很小,与非线性结构如树 或图相比,内存使用较低。
-
内存浪费(固定大小限制) :在如数组等结构中,内存在创建时为固定数量的元素分配。 如果元素数量少于分配的大小,就会有未使用的内存空间,从而导致 效率低下。 -
内存重新分配 :扩展如数组这样的线性数据结构需要重新分配内存并将元素复制到新的、更大的内存块中,这在时间和空间上都代价高昂。 这种重新分配可能导致内存碎片化 和低效。 -
链表中的指针开销 :在链表中,每个元素存储一个额外的指针指向下一个元素,这增加了总体的内存使用,特别是在处理大量元素时。 这种开销可能抵消通过 动态大小调整获得的一些内存优势。 -
顺序内存分配用于连续结构 :线性结构如数组需要连续的内存块。 如果没有足够的连续空间可用,内存分配可能会失败或变得低效,从而导致潜在的 性能瓶颈。
静态与动态内存分配
顺序访问与随机访问
抽象数据类型
-
列表 :列表是 一种表示有序元素集合的抽象数据类型(ADT),其中每个元素在序列中都有一个特定的位置。 列表的主要操作包括插入、删除、访问和遍历元素。 常见的列表实现方式有数组和链表。 数组是一种直接的实现方式,其中元素存储在连续的内存位置中,能够通过索引快速访问。 与此不同,链表是一种更灵活的实现方式,每个元素(或节点)指向下一个元素,允许动态调整大小,并且更容易进行元素的插入或删除。 列表常用于管理有序的项集合,例如学生名单、待办事项列表或歌曲播放列表。 数组和链表将会在 第十二章 中讨论。 -
栈 :栈是 一种基于 后进先出 ( LIFO )原则的抽象数据类型(ADT),其中 最近添加的元素是第一个被移除的。 栈的主要操作包括 压栈 (将元素添加到栈顶)和 弹栈 (移除栈顶元素)。 栈的一个常见示例是调用栈,编程语言通过调用栈来跟踪函数的调用与返回。 另一个常见的使用案例是文本编辑器中的撤销机制,其中栈用于回退到先前的状态。 栈在编译器中用于解析表达式、实现回溯算法(例如解决迷宫或谜题),以及管理递归中的嵌套函数调用等方面也至关重要。 栈将在 第十二章 中进行回顾。 -
队列(Queue) :一个 队列是一个抽象数据类型(ADT),它遵循 先进先出( FIFO )原则,意味着最先加入的元素是最先被移除的。 队列的主要操作是 入队 (将元素添加到队列中)和 出队 (从队列中移除元素)。 队列在软件编程和计算机系统中有广泛的应用,比如在打印队列中管理打印任务,文档按照接收的顺序进行处理,以及操作系统中的任务调度,进程按队列顺序执行。 在 第十二章 中,我们将回顾队列的主要特性。 -
双端队列(Deque) :一个 双端队列是一个抽象数据类型(ADT),允许元素从序列的两端插入和删除,实际上是对栈和队列的通用化。 例如, 标准模板库(STL) 中 C++的双端队列实现提供了一个灵活的序列容器,可以在两端动态增长和缩小。 另一个例子是循环缓冲区,它是一个常用于数据周期性添加和删除的双端队列实现。 双端队列在实现诸如文本编辑器中的撤销/重做功能等软件应用中特别有用。 它们在更复杂的应用中也发挥着重要作用,例如在算法中管理滑动窗口问题。 双端队列将在 第十二章 中详细讨论。 -
集合 :集合 是一个抽象数据类型(ADT),表示一组唯一元素,其中元素的顺序不重要。 集合的主要操作包括插入、删除、成员检查以及集合运算,如并集、交集和差集。 集合实现的例子包括 HashSet ,它在 Java 中确保集合中没有重复元素,以及 集合 ,它在 Python 中支持多种数学运算,如并集和交集。 集合广泛应用于诸如管理唯一项目集合(例如,某课程注册学生名单)、实现要求唯一性的操作(例如,从列表中删除重复项)以及进行数学 集合运算等场景。 -
字典 :字典,也 称为映射或关联数组,是一种将数据存储为键值对的抽象数据类型(ADT),其中每个键都是唯一的,并与特定的值相关联。 主要操作包括插入新的键值对、删除键值对、根据键查找值,有时还包括遍历键或值。 字典实现的例子包括 HashMap ,它在 Java 中使用唯一键快速检索值,以及 字典 ,它在 Python 中提供了一种将键映射到值的多功能方式。 字典广泛应用于各种场景,例如实现查找表、管理配置设置(每个设置通过唯一键标识)以及通过键存储和检索数据,例如通过用户 ID 索引的用户个人资料。 关于字典的详细讨论请参见 下一节。 -
图 : 图是 一种抽象数据类型(ADT),表示由节点(称为顶点)组成的集合,节点之间通过边连接。 图可以是有向图,其中边具有特定方向,或者是无向图,其中边没有方向。 图也可以包含环。 图的常见操作包括添加顶点、添加边和遍历结构。 图通常使用邻接表实现,其中每个节点都有一个邻居节点的列表,或者使用邻接矩阵,这是一种二维数组,表示节点之间是否存在边。 图是 广泛应用于各种科学和工程领域,包括建模社交网络、通信网络和交通系统等网络。 图将在 第十三章 中进一步讨论。 -
树 : 树 是一种层次结构的抽象数据类型(ADT),其中元素以节点的形式排列,从一个根节点开始,根节点向外分支成子节点,形成子树。 树可以有多种类型,例如二叉树,其中每个节点最多有两个子节点,或者更复杂的结构,如 B 树。 示例包括 二叉搜索树 ( BST ) 和堆。 在二叉搜索树中,每个节点最多有两个子节点,左子节点小于父节点,右子节点大于父节点。 堆是一种专门的树结构,遵循 堆 属性,通常用于实现优先队列。 我们将在 第十三章 中讨论树。
字典
-
在大多数实现中,字典不会维护键值对的特定顺序。 元素是根据内部使用的 哈希 函数以任意顺序存储的。 -
字典中的每个键都是唯一的。 如果你试图插入一个新键值对,且其键在字典中已存在,现有的值通常会被 新值覆盖。 -
字典的主要操作——如插入、删除和访问元素——通常在平均情况下以 时间完成,这得益于底层哈希表的实现。 这使得字典在需要快速查找的大型数据集上非常高效。
# Creating a dictionary to store information about a student
student_info = {
"name": "John Doe",
"age": 21,
"major": "Computer Science",
"GPA": 3.8
}
# Accessing values using keys
print("Name:", student_info["name"]) # Output: Name: John Doe
print("Age:", student_info["age"]) # Output: Age: 21
# Adding a new key-value pair
student_info["graduation_year"] = 2024
print("Graduation Year:", student_info["graduation_year"]) # Output: Graduation Year: 2024
# Updating an existing value
student_info["GPA"] = 3.9
print("Updated GPA:", student_info["GPA"]) # Output: Updated GPA: 3.9
# Deleting a key-value pair
del student_info["major"]
print("After deletion:", student_info) # Output: {'name': 'John Doe', 'age': 21, 'GPA': 3.9, 'graduation_year': 2024}
# Iterating over the dictionary
for key, value in student_info.items():
print(f"{key}: {value}")
-
创建字典 :我们定义一个字典 student_info ,其中包含表示学生各种属性的键值对。 -
访问值 :我们使用 name 和 age 键,通过方括号访问对应的值。 -
添加键值对 :我们添加一个新的键 graduation_year ,并为其指定相应的值。 -
更新值 :我们更新与 GPA 键相关联的值。 -
删除键值对 :我们使用 del 语句删除 主要 键及其值。 -
遍历字典 :我们使用 for 循环遍历字典,打印每个 键值对。
插入
# Creating an empty dictionary
student_grades = {}
# Inserting key-value pairs into the dictionary
student_grades["Alice"] = 85
student_grades["Bob"] = 90
print(student_grades) # Output: {'Alice': 85, 'Bob': 90}
<st c="30656">student_grades</st>
<st c="30704">Alice: 85</st>
<st c="30718">Bob: 90</st>
搜索
# Searching for a value by its key
alice_grade = student_grades.get("Alice")
print("Alice's grade:", alice_grade) # Output: Alice's grade: 85
# Searching for a non-existent key
charlie_grade = student_grades.get("Charlie", "Not Found")
print("Charlie's grade:", charlie_grade) # Output: Charlie's grade: Not Found
<st c="31382">get</st>
<st c="31445">Alice</st>
<st c="31555">Charlie</st>
<st c="31581">"未找到"</st>
更新
# Updating an existing key-value pair
student_grades["Alice"] = 88
print(student_grades) # Output: {'Alice': 88, 'Bob': 90}
# Adding a new key-value pair through update
student_grades["Charlie"] = 92
print(student_grades) # Output: {'Alice': 88, 'Bob': 90, 'Charlie': 92}
<st c="32175">Alice</st>
<st c="32247">Charlie: 92</st>
删除
# Deleting a key-value pair by key
del student_grades["Bob"]
print(student_grades) # Output: {'Alice': 88, 'Charlie': 92}
# Attempting to delete a non-existent key (optional approach)
removed_grade = student_grades.pop("David", "Key not found")
print(removed_grade) # Output: Key not found
<st c="32783">del</st>
<st c="32819">"Bob"</st>
<st c="32893">pop</st>
<st c="32937">David</st>
<st c="33017">键</st>
<st c="33021">未找到</st>
总结
参考资料与进一步阅读
-
算法导论 作者:Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest,和 Clifford Stein。 第四版。 MIT 出版社。 2022: 第十章 , 基础 数据结构
-
算法 作者:R. Sedgewick,K. Wayne。 第四版。 Addison-Wesley。 2011: 第一章 , 基础知识
-
C++中的数据结构与算法分析 作者:Mark A. Weiss。 第四版。 Pearson。 2012: 第三章 , 列表、栈, 队列
第十六章:12
线性数据结构
-
列表 -
跳表 -
栈 -
队列 -
双端队列
列表
数组
-
固定大小 :一旦创建了数组,它的大小就被设置好,无法更改。 这意味着数组能够容纳的元素数量在创建时就已预定。 例如,在大多数编程语言中,我们必须在声明数组时指定数组的大小,例如 int[] a = new int[10]; 在 Java 中,这会创建一个能够容纳 10 个整数的数组。 以下是一个简单的数组声明 在 Python 中的例子: # Define an array (list) of integers a = [10, 20, 30, 40, 50] # Print the array print(a) # Outputs: [10, 20, 30, 40, 50]
-
连续内存分配 :数组的元素存储在连续的内存位置中。 这使得通过简单的数学公式计算内存地址,从而高效地访问任何元素成为可能。 例如,在一个一维数组 a ,其大小为 ,元素的地址 a[i] 可以通过以下公式计算: ,其中 是基地址, 是数组中元素的索引 a ,而 是数组中每个元素的大小。 例如,对于 1 字节的元素,大小为 1;对于 16 位或字的元素,大小为 2,等等。 表 12.1 展示了一个简单的数组示例。
-
同质元素 :数组中的所有元素必须具有相同的数据类型,确保数组是一个统一的集合。 例如,整数数组 int[] 只能存储整数值,而字符串数组 String[] 只能存储 字符串值。 -
索引访问 :数组 允许通过索引直接访问任何元素,提供常数时间的访问,这是这种数据结构的主要优势之一。 访问数组中的第三个元素 a 就像是 a [2]一样简单。
-
插入 :这是指向数组中添加新元素。 例如,考虑 a = [1, 2, 3, 4] 。如果有空间,将 5 插入数组的末尾是非常直接的。 然而,若要将 5 插入到索引 1 ,则需要将索引 1 到右边的所有元素移动。 数组插入操作的时间复杂度是 在最佳情况下,时间复杂度为 在最坏情况下,时间复杂度为 。最佳情况是在部分填充的数组末尾插入元素。 如果是在数组的开头或中间插入,则需要移动元素,属于最坏情况。 以下是一个 Python 示例: a = [1, 2, 3, 4] a.insert(1, 5) # a becomes [1, 5, 2, 3, 4] print(a)
由于 Python 使用零索引,因此 <st c="6725">a.insert(1, 5)</st>
操作会将值插入数组的第二个位置。 -
删除 : 删除 是指从数组中移除一个元素。 假设有 a = [1, 2, 3, 4] ,删除索引 2 处的元素 1 需要将索引 1 之后的所有元素向左移动,以填补空缺。 在最好的情况下,删除最后一个元素的时间复杂度为 。然而,如果我们删除数组开头或中间的元素,则需要将随后的元素移动,导致最坏情况下的时间复杂度为 : a = [1, 2, 3, 4] a.pop(1) # a becomes [1, 3, 4] ```</st>
-
编辑或更新 : 编辑是 修改数组中现有元素的操作。 假设有 a = [1, 2, 3, 4] ,将索引 2 处的元素 3 改为 5 是一个直接的 操作。 我们可以通过索引直接访问该元素并更新它,因此其时间复杂度为 !<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">mml:miO</mml:mi>mml:mo(</mml:mo>mml:mn1</mml:mn>mml:mo)</mml:mo></mml:math> : a = [1, 2, 3, 4] a[2] = 5 # a becomes [1, 2, 5, 4] ```</st>
-
搜索 : 搜索 是指在数组中查找特定元素。 这一主题在 第七章 中进行了广泛讨论,讨论的多数搜索算法是基于数组作为底层数据结构的。 在 第十三章 中,我们将探讨如何在非线性数据结构上进行搜索,如树形结构。 -
访问 :访问 指的是在数组中特定索引处检索元素的值。 数组的一个关键优势之一是其访问时间是常数时间( ),允许直接通过其索引检索任何元素,无需遍历。
<st c="8292">列表</st>
matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
print(matrix[1][2]) # Outputs: 6 (element at second row, third column)
链表
-
动态大小 :链表的大小可以动态增长或缩小,因为节点可以根据需要添加或删除,而无需重新分配或重新组织整个数据结构。 例如,我们可以继续向链表中添加节点,而不必担心预先定义的大小。 -
非连续内存分配 :与数组不同,链表不要求连续的内存位置。 每个节点都独立存储在内存中,并通过指针连接在一起。 例如,在单向链表中,每个节点包含指向下一个节点的指针,这样元素就可以分散存储在内存中。 通过内存分布。 -
顺序访问 :链表必须从头开始顺序访问,因为没有直接通过索引访问特定元素的方法。 例如,要访问链表中的第三个元素,我们必须先遍历前两个节点。 -
变种 :链表 有不同的形式,包括单向链表(每个节点指向下一个节点)、双向链表(每个节点指向下一个节点和上一个节点)以及循环链表(最后一个节点指向第一个节点)。 例如,在双向链表中,由于每个节点都有指向前后节点的指针,因此可以在两个方向上进行遍历。 下一个节点。
链表插入
<st c="11577">24</st>
<st c="11582">3</st>
<st c="11586">12</st>
<st c="11591">17</st>
<st c="11626">8</st>
<st c="11636">3</st>
<st c="11642">12</st>
<st c="11702">8</st>
-
创建新节点 :首先,我们创建一个包含值 8 的新节点。初始时,这个新节点的指针被设置为 null ,因为它尚未指向任何内容。 -
更新新节点的指针 :接下来,将新节点的指针设置为指向下一个节点, 3 ,即包含 12 的节点。现在,新节点 3 已连接到 节点 4 。 -
更新前一个节点的指针 :最后,更新包含 3 的节点的指针,使其指向新节点 8 。这完成了插入,结果是链表 24 → 3 → 8 → 12 → 17 。
<st c="12710">24</st>
<st c="12776">17</st>
<st c="12792">null</st>
<st c="12856">null</st>
<st c="12870">/</st>
<st c="12932">New</st>
<st c="12965">null</st>
class Node:
def __init__(self, data):
self.data = data # Store data
self.next = None # Initialize next as null (None in Python)
class LinkedList:
def __init__(self):
self.head = None # Initialize the head of the list as None
def insert_after(self, prev_node, new_data):
if prev_node is None:
print("The given previous node must be in the LinkedList.")
return
new_node = Node(new_data) # Create a new node with the provided data
new_node.next = prev_node.next # Point the new node to the next node (e.g., 4)
prev_node.next = new_node # Point the previous node (e.g., 2) to the new node (e.g., 3)
def print_list(self):
temp = self.head
while temp:
print(temp.data, end=" -> ")
temp = temp.next
print("None")
Node
类和 LinkedList
类的功能,我们可以使用以下示例:
if __name__ == "__main__":
llist = LinkedList()
# Creating the initial linked list 1 -> 2 -> 4
llist.head = Node(1)
second = Node(2)
third = Node(4)
llist.head.next = second
second.next = third
# Insert 3 between 2 and 4
llist.insert_after(second, 3)
# Print the updated linked list
llist.print_list()
-
Node :每个 Node 对象存储一个 数据 值和一个 指向下一个节点的 指针,在 链表中。 -
LinkedList : LinkedList
类管理链表,包括 插入操作。 -
insert_after :该方法在给定节点( prev_node )后插入新节点。 新节点的数据为 new_data ,并且更新指针以正确插入到 链表中。 -
print_list :该方法遍历链表并打印每个节点的数据。
链表中的删除操作
<st c="15054">24</st>
<st c="15059">3</st>
<st c="15063">12</st>
<st c="15068">17</st>
<st c="15117">3</st>
<st c="15187">2</st>
<st c="15218">4</st>
<st c="15707">12</st>
def delete_node(self, key):
temp = self.head
if (temp is not None):
if (temp.data == key):
self.head = temp.next
temp = None
return
while(temp is not None):
if temp.data == key:
break
prev = temp
temp = temp.next
if(temp == None):
return
prev.next = temp.next
temp = None
# Example usage:
llist.delete_node(3) # Deletes the node with value 3
llist.print_list()
<st c="16218">delete_node</st>
<st c="16262">LinkedList</st>
在链表中编辑
<st c="16472">2</st>
<st c="16477">5</st>
<st c="16486">1</st>
<st c="16490">2</st>
<st c="16494">3</st>
<st c="16498">4</st>
def update_node(self, old_data, new_data):
temp = self.head
while temp is not None:
if temp.data == old_data:
temp.data = new_data
return
temp = temp.next
# Example usage:
llist.update_node(2, 5) # Updates node with value 2 to 5
<st c="16979">update_node</st>
<st c="17023">LinkedList</st>
在链表中查找
<st c="17090">In</st>
<st c="17717">Here is the Python code example using a singly</st>
<st c="17765">linked list:</st>
def search_node(self, key):
temp = self.head
while temp is not None:
if temp.data == key:
return True
temp = temp.next
return False
# Example usage:
found = llist.search(3) # Returns True if 3 is found
<st c="17979">The</st>
<st c="17984">search_node</st>
<st c="17995">function</st>
<st c="18005">should be added to the</st>
<st c="18028">LinkedList</st>
<st c="18038">class in the</st>
<st c="18052">previous section.</st>
<st c="18069">Access in link lists</st>
<st c="18090">This</st>
<st c="18096">operation involves retrieving the value of a node at a specific position in the linked list.</st>
<st c="18235">17</st>
<st c="18240">12</st>
<st c="18245">3</st>
<st c="18249">24</st>
<st c="18254">6</st>
def get_nth(self, index):
temp = self.head
count = 0
while (temp):
if (count == index):
return temp.data
count += 1
temp = temp.next
return None
# Example usage:
value = llist.get_nth(2) # Returns the value of the third node
<st c="18670">The</st>
<st c="18675">get_nth</st>
<st c="18682">function should be added to the</st>
<st c="18715">LinkedList</st>
<st c="18725">class in the</st>
<st c="18739">previous section.</st>
双向和循环链表
跳表
-
常规公交车会在每个 车站停靠。 -
快速线路仅停靠四个车站,具体为:
,
,
,
和 。
乘坐常规公交是最安全的选择,因为它确保我们能够到达目的地,但它需要在每个站点停靠,这可能会低效,总共需要停靠的次数为
-
多个层级 :跳表由多个层级组成,每个层级包含前一个层级的一个子集。 最底层是一个标准的链表,包含所有元素。 在跳表中,第一层可能包含所有元素,第二层可能包含一半元素,第三层可能包含四分之一元素, 以此类推。 -
概率平衡 :跳表使用随机化来确定每个元素出现的层级。 这使得跳表的平均时间复杂度与平衡二叉搜索树类似,而无需复杂的平衡算法。 例如,在插入一个元素时,会为该元素随机选择一个(最多的)层级。 -
高效的查找 :跳表通过使用更高层级跳过列表中的大部分部分,从而提高查找速度,减少所需比较次数。 例如,在跳表中查找一个元素时,可以通过跳过多个节点来迅速找到该元素。 通过上层级,可以加速查找。 -
动态调整大小 :跳表可以在添加或删除元素时动态调整其结构,保持高效的操作而无需重建整个结构。 例如,随着新元素的插入,可能会根据 随机化过程将它们添加到多个层级。
跳表中的插入
<st c="26463">3</st>
<st c="26466">4</st>
<st c="26469">5</st>
<st c="26472">7</st>
<st c="26475">8</st>
<st c="26478">9</st>
<st c="26485">10</st>
-
步骤 1: 初始化跳表 :从一个空的跳表开始,该跳表有四个层级: 等级 4 (最上层), 等级 3 , 等级 2 ,以及 等级 1 (最底层)。 最初,列表的每个层级只有一个头节点,指向 null 。 -
步骤 2: 插入 3 : -
确定 3 的层级 :随机确定 3 应该出现在的层级。 假设它出现在 所有层级。 -
插入 :在 等级 1 到 4 ,没有其他节点;所以, 3 只是被插入,且 等级 1 到 4 的头节点现在指向 3 。
-
-
等级 4: 3 --> null -
等级 3: 3 --> null -
等级 2: 3 --> null -
等级 1: 3 --> null -
步骤 3: 插入 4 : -
确定 4 的层级 :随机确定 4 应该出现在的层级。 假设它只出现在 等级 1 。较高层级保持不变,因为 3 不出现在那里。 -
插入 :从头到适当位置遍历 等级 1 。应该将 4 插入到 3 之后。 更新指针,使得3 现在指向 4 在 等级 1 。
-
-
等级 4: 3 --> null -
等级 3: 3 --> null -
等级 2: 3 --> null -
等级 1: 3 --> 4 --> null -
<st c="27808">5</st>
被随机分配到 等级 1 , 2 , 和 3 。 插入 :在 级别 2 和 3 ,元素 5 是下一个元素, 3 。然后,这些级别的 3 的指针会更新,指向 5 。在 级别 1 ,将 5 插入到 4 之后,并相应更新指针。
-
级别 4:3 --> null -
级别 3:3 --> 5 --> null -
级别 2:3 --> 5 --> null -
级别 1:3 --> 4 -->5 --> null -
步骤 5: 插入 7 : -
确定 7 的级别 :假设 7 被随机分配给 级别 1 。所有上级 保持不变。 -
插入 :在 级别 1 将 7 插入到 5 之后。
-
-
级别 4:3 --> null -
级别 3:3 --> 5 -->null -
级别 2:3 --> 5 -->null -
级别 1:3 --> 4 --> 5 -->7 --> null -
步骤 6: 插入 8 : -
确定 8 的级别 :假设 8 被随机分配给 级别 1 和 2 。 -
插入 :在 级别 2 ,将 8 插入到 5 之后,通过更新指针。 在 级别 1 ,将 8 插入到 7 之后。
-
-
级别 4:3 --> null -
级别 3:3 --> 5 --> null -
级别 2:3 --> 5 --> 8 --> null -
级别 1:3 --> 4 --> 5 --> 7 --> 8 --> null -
对于 步骤 9 和 步骤 10 ,我们会像处理元素 7 一样进行。插入所有元素后的最终跳表在 图 12 **.5 中进行了展示。
import random
class Node:
def __init__(self, value, level):
self.value = value
self.forward = [None] * (level + 1)
class SkipList:
def __init__(self, max_level):
self.max_level = max_level
self.head = Node(-1, max_level) # Head node with value -1 (acts as a sentinel)
self.level = 0
def random_level(self):
level = 0
while random.random() < 0.5 and level < self.max_level:
level += 1
return level
def insert(self, value):
update = [None] * (self.max_level + 1)
current = self.head
for i in range(self.level, -1, -1):
while current.forward[i] and current.forward[i].value < value:
current = current.forward[i]
update[i] = current
level = self.random_level()
if level > self.level:
for i in range(self.level + 1, level + 1):
update[i] = self.head
self.level = level
new_node = Node(value, level)
for i in range(level + 1):
new_node.forward[i] = update[i].forward[i]
update[i].forward[i] = new_node
def print_skiplist(self):
print("Skip List:")
for i in range(self.level, -1, -1):
print(f"Level {i}: ", end="")
node = self.head.forward[i]
while node:
print(node.value, end=" -> ")
node = node.forward[i]
print("None")
# Example usage
if __name__ == "__main__":
skiplist = SkipList(3)
# Insert elements into the skip list
skiplist.insert(3)
skiplist.insert(4)
skiplist.insert(5)
skiplist.insert(7)
skiplist.insert(8)
skiplist.insert(9)
skiplist.insert(10)
# Print the skip list
skiplist.print_skiplist()
-
节点 :每个 节点 对象存储一个 值 和一个指向 前向 指针的列表,在第 1 层有 4 个指针,第 2 层有 5 个,第 3 层有 5 个,第 4 层为 null -
跳表 :包含以下功能: -
random_level() :此方法根据 概率模型 为每个新节点生成一个随机层级 -
insert() :此方法将在跳表的 适当层级插入一个新值 -
print_skiplist() :此方法打印跳表,显示从最高层到 最低层的每一层节点
-
<st c="31690">3</st>
<st c="31693">4</st>
<st c="31696">5</st>
<st c="31699">7</st>
<st c="31702">8</st>
<st c="31705">9</st>
<st c="31712">10</st>
Skip List:
Level 2: 7 --> 10 --> None
Level 1: 5 --> 7 --> 8 --> 9 --> 10 --> None
Level 0: 3 --> 4 --> 5 --> 7 --> 8 --> 9 --> 10 --> None
在跳表中搜索
-
步骤 1 : 从最高层开始( Level 4 ): -
从 Level 3 的头节点开始。 -
是 3 (第一个值在 Level 3 ) 小于 7 吗?是的;下一个节点 是 null 。
-
-
步骤 2 : 移动到 Level 3 : -
现在,下降到节点 3 在 Level 3 ,下一个节点 是 5 。 -
是 5 小于 9 吗?是的。 移动到节点 5 。下一个节点 为空。
-
-
步骤 3 : 移动到 Level 2 : -
现在,下降到节点 5 在 Level 3 ,下一个节点 是 8 。 -
是 8 小于 9 吗?不。
-
-
步骤 4 : 移动到 Level 1 : 下一个节点是 7 。目标 已找到。
<st c="33213">search</st>
<st c="33274">SkipList</st>
def search(self, value):
current = self.head
for i in range(self.level, -1, -1):
while current.forward[i] and current.forward[i].value < value:
current = current.forward[i]
current = current.forward[0]
if current and current.value == value:
return True
return False
<st c="33593">search</st>
<st c="33605">SkipList</st>
if __name__ == "__main__":
skiplist = SkipList(3)
skiplist.insert(3)
skiplist.insert(4)
skiplist.insert(5)
skiplist.insert(7)
skiplist.insert(8)
skiplist.insert(9)
skiplist.insert(10)
skiplist.print_skiplist()
value_to_search = 7
found = skiplist.search(value_to_search)
print(f"\nSearch for {value_to_search}: {'Found' if found else 'Not Found'}")
value_to_search = 6
found = skiplist.search(value_to_search)
print(f"Search for {value_to_search}: {'Found' if found else 'Not Found'}")
Skip List:
Level 1: 3 --> 8 --> 9 --> 10 --> None
Level 0: 3 --> 4 --> 5 --> 7 --> 8 --> 9 --> 10 --> None
Search for 7: Found
Search for 6: Not Found
栈
-
LIFO 顺序 : 最后插入的元素是最先被移除的。 例如,如果我们将数字 1 , 2 和 3 推入栈中,它们将以相反的顺序被弹出: 3 , 2 , 1 。 -
一端操作 : 所有的插入(push)和删除(pop)操作都在栈的顶部进行。 无法直接访问栈中间或底部的元素。 如果我们将多个元素推入栈中,我们只能直接访问最近添加的元素。 这也意味着栈不支持随机访问。 与数组不同,我们不能直接访问栈中某个特定索引的元素。 访问仅限于栈顶 元素。 -
动态大小 : 栈可以随着元素的推入或弹出动态增大或缩小。 当我们将更多元素推入栈时,栈的大小会增加;而当我们弹出元素时,栈的大小会减小。
-
push : 它将一个新元素添加到栈顶。 它的时间复杂度是 ,因为此操作仅涉及将元素添加到栈顶。 以下是一个简单的 Python 示例: stack = [] stack.append(3) # Stack is now [3] stack.append(5) # Stack is now [3, 5] print(stack) ```</st>
-
pop : 这会移除栈顶元素。 它在常数时间内执行移除操作 ( ). 以下是一个 Python 示例: top_element = stack.pop() print(stack) ```</st>
-
peek : 它用于获取栈顶元素的值,但不将其从栈中移除。 对于栈 [1, 2, 3] ,peek 操作将返回 3 ,并且不会改变栈的状态。 时间复杂度为 ,因为它只涉及访问栈顶元素。 一个简单的 Python 指令如下: top_element = stack[-1] ```</st>
-
search : 通常用于在栈中查找一个元素。 例如,如果我们在栈 [1, 2, 3] 中查找元素 2 ,我们会在栈顶往下数第 1 位找到它。 显然,栈中 search 操作的时间复杂度是 ,因为它可能需要从栈顶到栈底逐个扫描。 让我们来看一个简单的 Python 示例: element_to_find = 2 position = stack.index(element_to_find) # Finds the position of 2 in the stack ```</st>
-
edit : 该操作修改栈顶元素的值。 如果栈顶元素是 [1, 2, 3] ,其值为 3 ,我们希望将其改为 5 ,则栈会变为 [1, 2, 5] 。此操作的时间复杂度为常数时间( ),因为该操作只影响栈顶元素。 一个 Python 代码示例如下: stack[-1] = 5 # Changes the top element to 5; Stack becomes [3, 5] ```</st>
<st c="38511">isFull</st>
<st c="38563">isEmpty</st>
-
栈用于存储函数调用,将每个调用压入栈中,并在 函数完成时弹出 -
它们用于转换和评估表达式,特别是将中缀表达式转换为后缀或 前缀表示法 -
栈在软件中实现撤销功能,每个操作都会压入栈中,可以通过弹出操作撤销 它
<st c="39219">push</st>
<st c="39228">pop</st>
队列
-
FIFO 顺序 :插入到队列中的第一个元素是第一个被 移除的。 -
两端操作 :元素在队列的后端(末端)添加,并从队列的前端(开始)移除。 -
动态大小 :队列 可以在元素入队或出队时动态增大或减小。 当我们入队更多元素时,队列的大小会增加;当我们出队元素时, 队列的大小会减少。 -
没有随机访问 :像栈一样,队列与数组不同,不能直接通过特定的索引访问元素。 访问仅限于队列的前端 元素。
-
入队 :这涉及将一个新元素添加到队列的末尾。 假设队列当前包含元素 1 和 2 。将 3 加入队列时,它会被放置在队列的末尾,结果是队列变为 [1, 2, 3] 。此操作在常数时间内执行,或 ,因为该操作仅涉及将元素添加到队列的末尾。 一个简单的 Python 示例如下: 如下: queue = [] queue.append(1) # Queue is now [1] queue.append(2) # Queue is now [1, 2] queue.append(3) # Queue is now [1, 2, 3] print(queue) ```</st>
-
出队 :此操作用于移除队列的前端元素。 如果我们出队队列 [1, 2, 3] ,前端元素 1 会被移除,剩下队列 [2, 3] 。与 入队 相似,此操作的时间复杂度是 ,因为它仅涉及移除前端元素。 这里是一个简单的 Python 指令: front_element = queue.pop(0) ```</st>
-
查看队首 :此操作用于检索队列前端元素的值,而不将其从队列中移除,且此操作在常数时间内执行。 对于队列 [1, 2, 3] ,查看队首会返回 1 ,并且队列不发生变化。 查看队首的 Python 指令可以如下: 如下: front_element = queue[0]
-
查找 :队列的查找操作是线性时间的,因为它可能需要从队列的前端扫描到队列的末尾。 以下是实现队列查找的 Python 代码: 如下: target = 2 position = queue.index(target)
<st c="42369">isFull</st>
<st c="42421">isNull</st>
双端队列
-
在两个端点进行插入和删除 :元素可以从双端队列的前端或后端进行添加或移除。 例如,我们可以将元素推入双端队列的前端或后端,并从任一端弹出它们。 同样也可以进行操作。 -
无固定方向 :双端队列不强制执行严格的 LIFO 或 FIFO 顺序;相反,它们允许在两端进行操作。 例如,我们可以将双端队列视为栈,只使用一端,或视为队列,使用两端。 -
动态大小 :双端队列可以随着元素的添加或删除动态增长或收缩。 -
无随机访问 :像栈和队列一样,双端队列不允许直接访问指定索引处的元素。
-
从前端添加(在前端插入) :此操作将一个新元素添加到双端队列的前端。 例如,如果当前双端队列为 [2, 3] ,将 1 添加到前端,将变成 [1, 2, 3] 。此操作在常数时间内完成,因为它只涉及将元素添加到前端。 下面是一个简单的 Python 代码: from collections import deque d = deque([2, 3]) d.appendleft(1) # Deque is now [1, 2, 3] print(d)
-
从后端添加(在后端插入) :此操作将一个新元素添加到双端队列的后端。 如果当前双端队列为 [1, 2] ,将 3 添加到后端,将变成 [1, 2, 3] 。与 从前端添加 类似,时间复杂度为 。下面是一个简单的 Python 指令来执行 此操作: d.append(3) # Deque is now [1, 2, 3] ```</st>
-
从前端删除(从前端删除) :此操作以常数时间从双端队列的前端删除元素。 如果我们从双端队列 [1, 2, 3] 删除前端元素,它将变为 [2, 3] 。执行 从前端删除 操作的简单 Python 指令如下: front_element = d.popleft
-
从后端删除(从后端删除) :与前一个操作类似,只是从双端队列的后端删除元素。 执行此操作的 Python 代码如下: rear_element = d.pop()
-
Peek(访问前端或后端元素) :在常数时间内检索前端或后端元素的值,而不将其从双端队列中移除。 对于双端队列 [1, 2, 3] ,查看前端将返回 1 ,查看后端将返回 3 **: front_element = d[0] #Returns the front element w/o removing it; Deque remains [1, 2] rear_element = d[-1] #Returns the rear element w/o removing it; Deque remains [1, 2]
总结
参考文献及进一步阅读
-
算法导论 . 作者:Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest,和 Clifford Stein. 第四版. MIT Press. 2022 年: 第十章 , 基本 数据结构
-
算法 . 作者:R. Sedgewick,K. Wayne. 第四版. Addison-Wesley. 2011 年: 第一章 , 基础知识
-
C++中的数据结构与算法分析 . 作者:Mark A. Weiss. 第四版. Pearson. 2012 年: 第三章 , 列表、栈 和队列
第十七章:13
非线性数据结构
-
非线性 数据结构简介 -
图 -
树 -
堆
非线性数据结构简介
-
层级关系 :元素的结构呈现出反映层次关系的方式,这意味着 某些元素可能作为 父元素 而其他元素则是 子元素 。这种情况在像 树形结构 和 图形 等结构中尤为明显。 -
复杂的遍历模式 :与线性结构不同,线性结构的遍历相对 简单,遍历非线性数据结构需要更复杂的技术,这些技术通常是特定于所使用的结构的。 -
变量访问时间 :搜索、插入或删除元素所需的时间可以根据结构和实现方式的不同而大相径庭。 在许多情况下,非线性数据结构相比 于其 线性结构,能够实现更高效的操作。
-
节点 :节点或顶点是大多数非线性数据结构的基本构建块。 每个节点通常包含数据,并且根据结构的类型,可能还会与其他顶点连接。 例如,在树结构中,顶点表示层级中的独立元素。 例如,在社交网络图中,每个节点代表一个用户,节点中存储的数据可能是该用户的 个人资料信息。 -
边 :边或箭头是连接两个节点的链接。 在非线性结构中,边在定义节点之间的关系中起着至关重要的作用。 在二叉树中,边定义了父节点与子节点之间的关系。 例如,如果父节点代表经理,那么边将连接到表示其员工的子节点。 在图中,边表示两个实体之间的关系。 例如,在交通网络中,一条边可能表示两座城市之间的直飞航班。 在运输网络中,一条边可能代表 两座城市之间的航班。 -
父母和子女 :在树等层级非线性数据结构中,节点按层级组织,父节点直接连接到其下方的子节点。 父子关系是树结构的基本概念: -
父节点 :一个具有一个或多个直接 子节点的节点 -
子节点 :一个直接连接到其上方节点的节点( 即父节点)
例如,在企业层级树中,经理是父节点,子下属是 子节点。 -
-
根 :根是 树结构中最顶部的节点,作为遍历树的起点。 一棵树只能有一个根,所有其他节点都是该根的后代。 如果一个节点没有父节点,它被认为是根。 在文件系统中,根目录是最顶层的文件夹,所有其他目录或文件都从 它那里分支出来。 -
叶子 :叶子是没有子节点的节点。 叶子表示树结构的端点,在这些地方不会再发生分支。 在许多算法中,叶子非常关键,因为它们通常标志着遍历 或搜索的完成点。 。 -
子树 :子树是树的一部分,包括一个节点及其所有子孙节点。 子树使得树可以递归处理,其中每个节点及其子节点可以被视为一个独立的树。 在决策树中,每个节点及其分支构成一个子树,代表一部分 可能的决策。 。
图
无向图 :在 无向图中,边没有方向。 两个节点之间的关系是双向的(参见 图 13 **.1 )。 如果节点 A 和节点 B 之间有一条边,你可以从 A 遍历到 B,也可以从 B 遍历到 A,且没有任何限制。 无向图的一个例子是 Facebook 好友的社交网络,其中的连接是相互的。 这意味着如果 A 是 B 的朋友,那么 B 也是 A 的朋友,体现了关系的双向性质。
有向图(有向图) :在有向图中,边具有方向。 节点之间的关系是单向的,这意味着 如果存在从节点 A 指向节点 B 的有向边,你只能从 A 遍历到 B,而不能反过来。例如,网站上的页面有指向其他页面的链接,形成了一个有向图。 图 13 **.2 展示了有向图的一个例子。 如图所示,图形不必是 完全连接的。
带权图 :在带权图中,每条边都被赋予一个数值或 权重 。这个权重通常表示与节点之间连接相关的成本、距离或时间。 带权图的一个例子是道路网络,其中每条边的权重代表城市之间的 距离或旅行时间。 图 13.3 描绘了一个带权图,其中权重被分配给了 各个边。
-
无权图 :在无权图中,所有边具有相同的重要性,这意味着 在节点之间的旅行没有特定的成本或距离。 在 图 13 **.1 中,图是无权图。 -
有向图与无向图 :有向图包含至少一个循环,即 你可以从一个节点出发,遍历 边,最后回到 同一个节点。 无向图没有这样的循环,因此在任务调度等应用中至关重要。 图中的 *图 13.1 **包含循环。 -
符号图 :在符号图中,边上标有 正号或负号,通常代表有利或不利的关系。 这种图在关系可以有极性时非常有用,例如在社交网络中,边可以表示 友谊(正向)或冲突(负向)。 其中,正边代表友谊,负边代表竞争关系的社交网络就是符号图的一个例子。 -
超图 :超图通过允许边(称为 超边 )一次连接多个节点,从而推广了图的概念。 这种类型的图在表示复杂关系时尤为有用,尤其是当单一连接可能涉及多个实体时。 例如,在研究合作网络中,一个超边可能代表三位或更多研究人员共同撰写的论文,同时连接所有研究人员。
图的表示
-
空间复杂度 :使用所选表示方法存储图所需的内存量 。 -
访问节点所有邻居的时间复杂度 :检索所有直接连接(相邻)到某一给定节点的节点的效率 。 -
检查边是否存在的时间复杂度 :确定两个特定节点之间是否存在边所需的时间 。
邻接矩阵
在图中,访问节点的所有邻居是图中的一个操作。
邻接表
访问一个节点的邻居(即相邻的顶点)需要耗费
边列表
边列表表示法使用
当使用边列表
边列表是
图遍历
DFS 图遍历
-
访问起始节点。 -
对于当前节点的每个未访问的邻居,执行一次对该邻居的 DFS。 -
重复此过程,直到所有从起始节点可达的节点都被访问。
# You will need first: pip install networkx matplotlib
import networkx as nx
import matplotlib.pyplot as plt
# Define the graph as an adjacency list
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['B' , 'F'],
'D': ['E'],
'E': ['F'],
'F': ['A']
}
# Visualize the graph
visualize_graph(G)
# Create a directed graph using NetworkX
G = nx.DiGraph()
# Add edges to the graph
for node, neighbors in graph.items():
for neighbor in neighbors:
G.add_edge(node, neighbor)
# Visualize the graph using NetworkX and Matplotlib
def visualize_graph(G):
pos = nx.spring_layout(G) # Positions for all nodes
nx.draw(G, pos, with_labels=True, node_color='lightblue', node_size=2000, font_size=10, font_weight='bold')
plt.title("Graph Visualization")
plt.show()
# DFS function (optional, same as before)
visited = set()
def dfs(node):
if node not in visited:
print(node)
visited.add(node)
for neighbor in graph[node]:
dfs(neighbor)
# Start DFS at node 'A'
dfs('A')
-
该图表示为一个 邻接表 -
我们使用一个 访问 集合来确保节点不被重复访问 。 -
dfs 函数打印当前节点,标记其为已访问,并递归地对所有 未访问的邻居节点 进行调用。
<st c="20564" class="calibre11">A, B, D, E, F, C</st>
广度优先搜索(BFS)图遍历
from collections import deque
# Graph represented as an adjacency list
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': ['A'],
'E': ['B','D'],
'F': ['E','D']
}
# BFS function
def bfs(start_node):
visited = set() # Set to track visited nodes
queue = deque([start_node]) # Initialize the queue with the starting node
while queue:
node = queue.popleft() # Dequeue a node
if node not in visited:
print(node)
visited.add(node) # Mark it as visited
queue.extend(graph[node]) # Enqueue all unvisited neighbors
# Start BFS at node 'A'
bfs('A')
-
图的表示 :图使用 邻接表 来表示。 -
队列 : deque 用于高效地处理队列操作(入队和 出队节点) -
bfs :节点按照出队顺序被处理,它们的邻居被加入队列以供 进一步探索
A, B, C, D, E, F
def bfs_shortest_path(start_node, target_node):
visited = set()
queue = deque([[start_node]]) # Queue stores paths
while queue:
path = queue.popleft() # Dequeue the first path
node = path[-1] # Get the last node from the path
if node == target_node:
return path # Return the path when target is reached
if node not in visited:
visited.add(node)
for neighbor in graph[node]:
new_path = list(path)
new_path.append(neighbor)
queue.append(new_path) # Enqueue the new path
return None # Return None if there is no path
# Find the shortest path between 'A' and 'F'
print(bfs_shortest_path('A', 'F'))
<st c="29782">A</st>
<st c="29788">F</st>
['A', 'C', 'F']
除了这些核心应用,BFS 还广泛应用于人工智能搜索算法中,与 DFS 一起成为两种主要的搜索技术。此外,BFS 还被用于在各种图相关问题中寻找最小生成树和最短路径:
-
BFS 在人工智能中的应用:BFS 作为许多人工智能搜索策略的基础,特别是那些需要逐层探索所有可能状态的策略,如游戏树或谜题求解。
-
寻找最小生成树:在无权图中,BFS 可以作为构建块,用来通过确保所有节点按最短路径顺序从源节点访问来找到最小生成树。
-
网络广播:在计算机网络中,BFS 用于模拟广播路由,在这种情况下,信息必须在最短时间内发送到所有节点,这使得它在网络发现协议中至关重要,如开放最短路径优先(OSPF)。
这些多样化的应用突出展示了 BFS 的多功能性,使其成为算法设计和实践应用中的基本工具,广泛应用于各个领域。
总结来说,BFS 是一种基本的图遍历技术,特别适用于当目标是逐层探索所有节点或在无权图中寻找最短路径时。虽然它在时间复杂度上效率较高,但由于其较高的内存需求,对于具有较大分支因子的图而言,可能会成为一个缺点。BFS 保证的最短路径特性以及其在各种算法任务中的多功能性使其成为许多现实世界应用中的强大工具。
在继续讨论下一个非线性数据结构之前,让我们通过总结图的意义和在算法设计中的应用来结束对图的讨论。图是极其多功能的结构,它使我们能够在网络、社会分析和人工智能等领域建模和解决各种问题。图可以表示实体之间的复杂关系,通过 BFS 和 DFS 等算法,我们可以高效地遍历、搜索和处理图数据。图在路径寻找、网络路由、环检测甚至层级问题解决等关键应用中发挥着核心作用。
它们在算法设计中的重要性不言而喻,因为它们为解决涉及连通性、优化和搜索策略的问题奠定了基础,无论是在理论领域还是实践领域。
树
不同类型的树及其特性
通用树 :通用树是一种树,其中任何节点可以 拥有任意数量的子节点。 这种树类型可用于表示层次化数据,如文件系统或组织结构图。 图 13 **.7 展示了一个通用树的示例。
-
二叉树 :二叉树是一种每个节点最多有两个子节点的树,子节点分别称为 左子节点 和 右子节点 。它是计算机科学中最常用的树结构之一。 在 图 13 **.7 中,所有以 节点 1 、 节点 5 、 节点 6 为根的子树是二叉树的示例。 -
完全二叉树 :如果每个节点有零个或两个子节点,则称为完全二叉树。 没有节点只有一个子节点。 在 图 13 **.7 中,以 节点 5 为根的子树是一个完全 二叉树。 -
完全二叉树 :一个 完全二叉树是一个 所有层级都已完全填充,除非是最后一层,该层必须从左到右填充。 一个著名的完全二叉树是堆结构,我们将在本章末尾讨论它。 本章。 -
平衡二叉树 :如果 任何节点的左右子树的高度差不超过一,则该树被认为是平衡的。 平衡树更受欢迎,因为它们可以确保搜索、插入和删除操作的最佳性能。 在 图 13 **.8 中,二叉树是 完全平衡的。
-
AVL 树 :一种 AVL 树(以发明者 Adelson-Velsky 和 Landis 命名)是一种自平衡的二叉查找树(BST)。 它 为每个节点维护一个平衡因子(即左右子树高度的差值),确保在插入 和删除操作后,树保持平衡。 -
B 树 :B 树 是一种自平衡的树形数据结构,用于维护 排序的数据,并允许在对数时间内进行搜索、顺序访问、插入和删除操作。 B 树通常用于数据库 和文件系统中。 -
红黑树 :一种 红黑树是另一种类型的自平衡二叉查找树。 树中的每个节点都会被分配一个颜色(红色或黑色),以确保树保持平衡,这样可以保证插入 和删除操作的最坏时间复杂度更好。
树的表示法
链式表示法
# Definition of a TreeNode class in Python
class TreeNode:
def __init__(self, key):
self.key = key # Node value
self.left = None # Pointer to left child
self.right = None # Pointer to right child
# Creating nodes and linking them to form a binary tree
root = TreeNode(10) # Root node
root.left = TreeNode(5) # Left child of root
root.right = TreeNode(20) # Right child of root
# Adding more nodes to the tree
root.left.left = TreeNode(3) # Left child of node with value 5
root.left.right = TreeNode(7) # Right child of node with value 5
root.right.left = TreeNode(15) # Left child of node with value 20
root.right.right = TreeNode(25) # Right child of node with value 20
# Function to perform an in-order traversal of the tree
def inorder_traversal(node):
if node:
inorder_traversal(node.left)
print(node.key, end=' ')
inorder_traversal(node.right)
# In-order traversal of the tree
print("In-order Traversal:")
inorder_traversal(root)
-
TreeNode :每个节点包含一个值( key )和两个指针( left 和 right ),分别引用左子节点和右子节点。 -
创建节点 :我们创建节点并将它们链接在一起,形成一个 二叉树 。 -
中序遍历 : inorder_traversal 函数递归地访问左子树、根节点,然后是右子树,对于 二叉搜索树(BST) 以排序顺序打印节点。
数组表示
-
左子节点位于 索引 -
右子节点位于 索引
# Array representation of a complete binary tree
binary_tree = [10, 5, 20, 3, 7, 15, 25]
# Accessing elements
root = binary_tree[0]
left_child_of_root = binary_tree[2 * 0 + 1] # index 1
right_child_of_root = binary_tree[2 * 0 + 2] # index 2
# Display the values
print(f"Root: {root}")
print(f"Left Child of Root: {left_child_of_root}")
print(f"Right Child of Root: {right_child_of_root}")
父数组表示
<st c="41295">-1</st>
def build_tree(parent_array):
n = len(parent_array)
nodes = [None] * n
root = None
# Create tree nodes for each index
for i in range(n):
nodes[i] = TreeNode(i)
# Assign parents to each node
for i in range(n):
if parent_array[i] == -1:
root = nodes[i] # This is the root node
else:
parent_node = nodes[parent_array[i]]
if parent_node.left is None:
parent_node.left = nodes[i]
else:
parent_node.right = nodes[i]
return root
TreeNode
class TreeNode:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
<st c="42291">-1</st>
parent_array = [-1, 0, 0, 1, 1, 2, 2]
# Build the tree from the parent array
root = build_tree(parent_array)
# Function to perform an in-order traversal of the tree
def inorder_traversal(node):
if node:
inorder_traversal(node.left)
print(node.key, end=' ')
inorder_traversal(node.right)
# In-order traversal of the tree
print("In-order Traversal:")
inorder_traversal(root)
-
父节点数组 :该数组表示每个节点的父节点。 例如,如果 parent_array[3] = 1 ,则意味着 节点 3 的父节点是 节点 1 。根节点的值为 -1 ,表示它没有父节点。 -
构建树 :我们首先创建一个节点数组,然后通过父节点数组将每个节点链接到它的父节点。 这些节点根据可用性,作为左子节点或右子节点相连接。 -
中序遍历 :我们进行树的中序遍历,以按照它们的 排序顺序访问节点。
二叉搜索树
-
左子节点包含的值小于 父节点的值 -
右子节点包含的值大于 父节点的值 -
二叉搜索树的中序遍历会生成一个 有序序列
中序遍历
-
遍历 左子树。 -
访问 根节点。 -
遍历 右子树。
# Definition of TreeNode class
class TreeNode:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
# Function for in-order traversal
def inorder_traversal(node):
if node:
inorder_traversal(node.left)
print(node.key, end=' ')
inorder_traversal(node.right)
# Example: Build the BST
root = TreeNode(22)
root.left = TreeNode(35)
root.right = TreeNode(30)
root.left.left = TreeNode(5)
root.left.right = TreeNode(15)
root.right.left = TreeNode(25)
root.right.right = TreeNode(35)
<st c="46688">inorder_traversal</st>
# Perform in-order traversal
print("In-Order Traversal:")
inorder_traversal(root)
<st c="46866" class="calibre11">5 10 15 20 25 30 35</st>
先序遍历
-
访问 根节点。 -
遍历 左子树。 -
遍历 右子树。
<st c="47307">20, 10, 5, 15, 30,</st>
<st c="47326">25, 35</st>
# Function for pre-order traversal
def preorder_traversal(node):
if node:
print(node.key, end=' ')
preorder_traversal(node.left)
preorder_traversal(node.right)
# Perform pre-order traversal
print("Pre-Order Traversal:")
preorder_traversal(root)
后序遍历
-
遍历 左子树。 -
遍历 右子树。 -
访问 根节点。
<st c="48117">5, 15, 10, 25, 35,</st>
<st c="48136">30, 20</st>
# Function for post-order traversal
def postorder_traversal(node):
if node:
postorder_traversal(node.left)
postorder_traversal(node.right)
print(node.key, end=' ')
# Perform post-order traversal
print("Post-Order Traversal:")
postorder_traversal(root)
二叉搜索树中的搜索操作
-
平均情况(平衡树) :在平衡的二叉搜索树(如 图 13 **.6 所示),搜索 操作需要 时间。 这是因为树的高度相对于节点数量是对数级别的,我们在 每一层都缩小了搜索空间。 -
最坏情况(不平衡树) :如果二叉搜索树(BST)不平衡,搜索的时间复杂度 可能接近 ,此时树形结构类似于链表。 在这种情况下,树的高度会随着节点数量的增加而线性增长,导致 搜索效率低下。
<st c="50283">TreeNode</st>
class TreeNode:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
<st c="50467">insert</st>
Function to insert a node in the BST
def insert(node, key):
# If the tree is empty, return a new node
if node is None:
return TreeNode(key)
# Otherwise, recur down the tree
if key < node.key:
node.left = insert(node.left, key)
else:
node.right = insert(node.right, key)
return node
<st c="50781">search</st>
# Function to search a key in the BST
def search(node, key):
# Base case: the node is None (key not found) or the key matches the current node's key
if node is None or node.key == key:
return node
# If the key is smaller than the node's key, search the left subtree
if key < node.key:
return search(node.left, key)
# Otherwise, search the right subtree
return search(node.right, key)
root = None
keys = [20, 10, 30, 5, 15, 25, 35]
for key in keys:
root = insert(root, key)
search_key = 25
found_node = search(root, search_key)
# Output the result
if found_node:
print(f"Key {search_key} found in the BST.")
else:
print(f"Key {search_key} not found in the BST.")
-
TreeNode :每个 BST 中的节点包含一个键(节点的值),以及指向其左子节点和 右子节点 的引用。 -
insert :insert 函数将值插入到 BST 中。 它递归遍历树并根据 BST 的属性将新节点插入到正确的位置。 -
search :search 函数递归地查找给定的键。 如果当前节点的键与正在搜索的键匹配,它会返回该节点。 否则,根据键是小于还是大于当前节点的键,它会继续在左子树或右子树中进行搜索。
二叉搜索树中的插入操作
<st c="52886">null</st>
-
平均情况(平衡树) :在平衡的二叉搜索树中,插入操作平均需要 的时间,因为我们本质上是在执行搜索,找到合适的位置来插入 新节点 -
最坏情况(不平衡树) :与搜索类似,如果二叉搜索树不平衡,插入时间可能退化为 ,尤其是当插入的值使树变得 倾斜
<st c="53802">insert</st>
<st c="53988">insert</st>
<st c="54059">None</st>
# Function to insert a node in the BST
def insert(node, key):
# If the tree is empty, return a new node
if node is None:
return TreeNode(key)
# Otherwise, recur down the tree
if key < node.key:
node.left = insert(node.left, key)
else:
node.right = insert(node.right, key)
return node
<st c="54915">20</st>
<st c="54919">10</st>
<st c="54923">30</st>
<st c="54927">5</st>
<st c="54930">15</st>
<st c="54934">25</st>
<st c="54942">35</st>
<st c="54956">insert</st>
<st c="55165">35</st>
<st c="55211">20</st>
<st c="55221">35</st>
<st c="55240">20</st>
<st c="55280">35</st>
<st c="55309">30</st>
<st c="55319">35</st>
<st c="55338">30</st>
<st c="55389">30</st>
<st c="55433">35</st>
<st c="55584">insert</st>
二叉搜索树(BST)中的删除操作
-
删除叶子节点 :没有子节点的节点可以 直接删除。 -
删除具有一个子节点的节点 :该节点将被 其子节点替代。 -
删除具有两个子节点的节点 :该节点将被其中序前驱节点(左子树中的最大节点)或中序后继节点(右子树中的最小节点)替换。 替换后,替换源节点也必须 被删除。
<st c="56642">TreeNode</st>
<st c="56733">insert</st>
<st c="56800">min_value_node</st>
# Function to find the minimum value node in the right subtree (in-order successor)
def min_value_node(node):
current = node
while current.left is not None:
current = current.left
return current
<st c="57081">delete_node</st>
# Function to delete a node from the BST
def delete_node(root, key):
# Base case: the tree is empty
if root is None:
return root
# If the key to be deleted is smaller than the root's key, go to the left subtree
if key < root.key:
root.left = delete_node(root.left, key)
# If the key to be deleted is greater than the root's key, go to the right subtree
elif key > root.key:
root.right = delete_node(root.right, key)
# If key is equal to the root's key, this is the node to be deleted
else:
# Case 1: Node with only one child or no child
if root.left is None:
return root.right
elif root.right is None:
return root.left
# Case 2: Node with two children
# Get the in-order successor (smallest in the right subtree)
temp = min_value_node(root.right)
# Replace the current node's key with the in-order successor's key
root.key = temp.key
# Delete the in-order successor
root.right = delete_node(root.right, temp.key)
return root
<st c="58142">insert</st>
<st c="58194">30</st>
# Create a BST and insert values into it
root = None
keys = [20, 10, 30, 5, 15, 25, 35]
for key in keys:
root = insert(root, key)
# Delete a node from the BST
delete_key = 30
root = delete_node(root, delete_key)
# Function to perform in-order traversal
def inorder_traversal(node):
if node:
inorder_traversal(node.left)
print(node.key, end=' ')
inorder_traversal(node.right)
# Perform in-order traversal after deletion
print("In-order Traversal after Deletion:")
inorder_traversal(root)
堆
-
最大堆 :在最大堆中,每个节点的值都大于或等于其子节点的值,最大的元素位于根节点。 最大堆通常用于需要高效访问最大元素的算法中,比如堆排序和优先队列的实现。 在最大堆中,堆的性质如下: 对于每个节点 * (左子节点) (右子节点),其中 是 堆的数组表示
-
最小堆 :在最小堆中, 每个节点的值都小于或等于其子节点的值。 最小元素始终位于根节点。 最小堆通常用于像 Dijkstra 最短路径算法和 Prim 最小生成树这样的算法中。 在这种情况下,堆的性质如下:对于每个节点 ,这是 成立的: -
( 左子节点) -
( 右子节点)
-
-
根节点位于 索引 0 -
对于索引 处的节点,左子节点位于索引 ,右子节点位于索引 处 -
位于索引 的节点的父节点位于 索引
堆操作
堆中的插入
<st c="61991">堆化向上</st>
def heapify_up(heap, index):
parent = (index - 1) // 2
if index > 0 and heap[parent] < heap[index]:
# Swap the parent and current node
heap[parent], heap[index] = heap[index], heap[parent]
# Recursively heapify the parent node
heapify_up(heap, parent)
def insert_max_heap(heap, element):
heap.append(element)
heapify_up(heap, len(heap) - 1)
# Example usage
heap = []
insert_max_heap(heap, 20)
insert_max_heap(heap, 15)
insert_max_heap(heap, 30)
insert_max_heap(heap, 5)
insert_max_heap(heap, 40)
print("Heap after insertions:", heap)
堆中的删除
-
用数组中的最后一个元素替换根元素。 -
从 数组中移除最后一个元素。 -
堆化根元素的过程涉及将其与子节点进行比较。在堆属性被破坏的情况下,会在最大堆中将根与最大的子节点交换,或者在最小堆中将根与最小的子节点交换。此过程会持续直到堆属性恢复。
这里是一个用于在最大堆中删除元素的简单 Python 代码:
def heapify_down(heap, index):
largest = index
left = 2 * index + 1
right = 2 * index + 2
if left < len(heap) and heap[left] > heap[largest]:
largest = left
if right < len(heap) and heap[right] > heap[largest]:
largest = right
if largest != index:
heap[index], heap[largest] = heap[largest], heap[index]
heapify_down(heap, largest)
def delete_max_heap(heap):
if len(heap) == 0:
return None
if len(heap) == 1:
return heap.pop()
root = heap[0]
heap[0] = heap.pop() # Move last element to the root
heapify_down(heap, 0) # Restore heap property
return root
让我们用一个示例最大堆并使用delete_max_heap
删除根元素:
heap = [40, 30, 20, 5, 15]
deleted = delete_max_heap(heap)
print("Heap after deletion of max element:", heap)
删除最大元素后的堆是[30, 15, 20, 5]
。
删除操作的时间复杂度是,因为我们可能需要交换树中的元素以恢复堆的堆属性。
堆化(构建堆)
为了从任意数组构建堆,我们使用堆化过程。从第一个非叶子节点开始,向上遍历至根节点,确保堆属性得以保持。考虑以下heapify
的 Python 实现:
def heapify(heap, n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and heap[left] > heap[largest]:
largest = left
if right < n and heap[right] > heap[largest]:
largest = right
if largest != i:
heap[i], heap[largest] = heap[largest], heap[i]
heapify(heap, n, largest)
如我们所见,heapify
函数递归调用自身,以确保整个树的堆属性得以保持。如果在任何节点发现堆属性被破坏,heapify
将继续向下遍历树,通过比较和交换节点来修复结构,直到堆属性完全恢复。
以下是一个使用heapify
递归算法构建最大堆的 Python 代码:
def build_max_heap(a):
n = len(a)
# Start from the first non-leaf node and heapify each node
for i in range(n // 2 - 1, -1, -1):
heapify(a, n, i)
以下是一个简单的build_max_heap
用法示例:
arr = [5, 15, 20, 30, 40]
build_max_heap(a)
print("Array after building max-heap:", a)
建立最大堆后,表示最大堆的数组
是[40, 30, 20, 5, 15]
。
堆排序
-
从输入数组构建一个最大堆。 -
交换根(最大元素)与 最后一个元素。 -
减小堆的大小并 堆化 根元素。 -
重复这个过程直到堆 为空。
def heapsort(arr):
n = len(arr)
build_max_heap(arr) # Step 1: Build a max-heap
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0] # Step 2: Swap root with last element
heapify(arr, i, 0) # Step 3: Heapify the reduced heap
a = [5, 15, 20, 30, 40]
heapsort(arr)
print("Sorted array:", arr)
<st c="67187" class="calibre11">Sorted array: [5, 15, 20, 30, 40]</st>
总结
参考文献及进一步阅读
-
《算法导论》 . 作者:Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, 和 Clifford Stein. 第四版. MIT 出版社. 2022 年: -
第六章 6 , 堆排序 -
第十二章 , 二叉 查找树 -
第二十二章 , 基础 图算法
-
-
《C++中的数据结构与算法分析》 . 作者:Mark A. Weiss. 第四版. Pearson. 2012 年: -
第四章 4 , 树 -
第五章 , 二叉 查找树 -
第六章 6 , 堆 -
第九章 , 图算法
-
-
算法 . 作者:R. Sedgewick,K. Wayne。 第四版。 Addison-Wesley 出版社。 2011 年。 -
第三章 , 查找(二叉 搜索树) -
第四章 , 排序(堆排序) -
第五章 , 图
-
第十八章:第四部分:下一步
第十四章 , 明日的算法
第十九章:14
明日算法
-
从 过去汲取经验 -
可扩展性 -
上下文感知 -
道德责任 -
总结
从过去汲取经验
-
早期计算机 :这一旅程始于 20 世纪中期早期计算机的开发,例如 ENIAC 和 UNIVAC。 这些机器是大型的、占据整个房间的设备,使用真空管和打孔卡片进行基本的计算。 它们标志着自动化计算的开始,为 未来的创新奠定了基础。 -
第一波人工智能(AI) :人工智能的概念与早期计算机同时出现,研究人员探索了机器能够模仿人类思维的可能性。 早期的 AI 集中于符号推理和问题解决,为这个快速发展的领域奠定了基础。 这一领域迅速演变。 -
大型主机计算机 :随着 技术的进步,大型主机计算机逐渐崭露头角。 这些强大的机器主要被大型组织用于复杂的数据处理任务。 主机计算机引入了集中式计算的概念,允许多个用户访问单一的强大 计算机系统。 -
专家系统与第二波人工智能 :第二波人工智能带来了 专家系统的发展,这些系统旨在模仿人类 专家在特定领域中的决策能力。 这些系统依赖于基于规则的逻辑,并被用于医学诊断和 金融分析等领域。 -
个人计算机(PC) :1970 年代和 1980 年代个人计算机的发明标志着计算机领域的重大转变。 个人计算机使计算变得更加普及, 将其从大型机构的工具转变为家庭必备品。 这个时代见证了用户友好界面、软件应用程序的兴起,以及计算能力的民主化。 -
互联网与网络 :1990 年代互联网 和万维网的出现彻底改变了我们获取和分享信息的方式。 它将全球的计算机连接起来,实现了即时通讯、数据交换,以及在线服务和数字经济的兴起。 这一时期还见证了电子商务、社交媒体和 云计算的崛起。 -
移动计算 :2000 年代初期带来了移动计算时代,智能手机和 平板电脑变得无处不在。 这些设备将计算能力与便携性相结合,允许用户随时随地访问信息、进行沟通,并执行各种任务。 移动应用程序和无线技术进一步扩展了 这些设备的功能。 -
第三波及当前的人工智能 :我们现在正处于第三波人工智能的浪潮中,特点是机器学习、深度学习和自然语言处理的进展。 这一波的重点是构建能够从大量数据中学习、识别模式并做出自主决策的系统。 人工智能正在融入日常生活的各个方面,从虚拟助手和自动驾驶汽车到医学诊断和 金融服务。
可扩展性
-
处理能力的指数级增长 :处理能力的持续提升,已经达到即使是 摩尔定律 (即每两年微芯片上晶体管数量翻倍的预测)可能不再成立的程度,这使得更加复杂和资源密集型的算法得以执行(参见图 14 **.1 )。 专用硬件的出现,如 图形处理单元 (GPU )和张量处理单元 (TPU ),进一步加速了处理大规模计算的能力,尤其是那些机器学习模型所需的计算。GPUs 最初是为了加速计算机图形中的图像和视频渲染而设计的。 它们已经发展成为处理深度学习、科学模拟和数据分析等任务所需的复杂数学计算。 由于其高度并行的结构,GPU 非常适合同时处理大量数据块,使其在训练和运行机器学习模型时尤其高效。 TPU 是一种由 Google 专门为机器学习任务设计的硬件加速器,特别是那些涉及神经网络和深度学习模型的任务。 TPU 经过优化,能够高效地运行大规模计算,提供高性能以支持机器学习应用中的训练和推理。 它们专门设计用于处理 AI 算法的计算需求,尤其是使用 TensorFlow 这一 Google 开源机器学习框架的算法(参见 图 14 **.1 )。
存储成本下降和容量增加 :虽然存储和内存成本大幅下降,但其容量大幅增长(见 图 14 **.2 )。 这 意味着现在可以廉价地存储大量数据,从而实现对庞大数据集的保留和处理。 这些趋势使得数据驱动的算法得以蓬勃发展,因为曾经限制模型大小或可用数据量的存储约束已经不复存在(见 图 14 **.2 )。
互联网推动的数据爆炸 :自 1991 年互联网诞生以来, 可用数据量呈现爆炸式增长。 网络的自由和 开放性释放了一股信息潮流,从用户生成的内容到交易数据,为算法提供了无与伦比的数据集。 社交网络、在线平台和物联网设备进一步推动了这一日益增长的数据海洋。 数据海洋。
-
量子算法 :量子 计算有望彻底改变我们解决大规模 问题的方式。 传统计算机在某些类型的计算中表现不佳,如大数因式分解或量子系统模拟。 量子算法,如 Shor 算法 用于 因式分解,以及 Grover 算法 用于搜索,可以提供指数级的速度提升,使得 它们成为处理当前对经典计算机来说无法解决的大规模问题的理想选择。 经典计算机。 -
近似算法 :在许多 大规模问题中,找到 一个精确的解决方案在计算上是昂贵的,甚至是不可能的。 近似算法提供了一种方法,可以在合理的时间内获得接近最优的解。 这些算法在那些完美解并非必要,但一个足够好的解却很有价值并且必须迅速找到的场景中至关重要,如路由、调度和 优化问题。 -
参数化复杂度 :这是 计算复杂度理论中的一个框架,通过关注输入数据中的特定方面或参数,而不是整体输入大小,从而为分析问题的复杂度提供了更细致的途径。 仅仅依赖于总体输入大小。 在传统的复杂度分析中,问题是根据其运行时间随着输入大小的增长进行分类的。 然而,对于许多现实世界中的问题,输入的某些参数可能比数据的总大小对算法的性能有更大的影响。 通过识别和隔离这些关键参数,参数化复杂度使得即使问题在一般情况下仍然是计算上困难的,也能为实际使用场景设计出更高效的算法。 一般情况下。 参数化复杂性中的主要目标是开发算法,其运行时间不一定对于问题的每个实例都是最优的,但在某些参数保持较小的情况下是可管理的,即使总体输入规模很大。 中心概念 是 固定参数可解性 ( FPT ),其中问题被认为是固定参数可解的,如果它可以在时间内解决 ,其中 是输入规模, 是参数,并且 是一个仅依赖于参数而不是总体输入规模的函数。 如果 很小,即使 很大,算法仍然可以有效运行。 参数化复杂性在生物信息学、调度问题和 数据库系统中的实际应用。 -
亚线性算法 :随着 数据集的规模不断增大,甚至读取整个输入都变得不切实际时,亚线性算法变得至关重要。 这些算法通过仅检查输入的一小部分来产生有用的结果,特别适用于属性测试等应用,在这些应用中,我们希望在不完全处理数据的情况下确定数据集的属性。 -
流数据算法 :在数据以连续流的方式到达的场景中,例如网络流量监控或金融行情数据,算法必须在有限的内存下即时处理信息。 流数据算法旨在在这些约束条件下工作,通过对数据进行单次或有限次数的遍历,提取有意义的洞察或维持摘要,即使数据量不断增长。 -
并行和分布式算法 :为了充分利用现代硬件的强大能力,算法必须设计成能够高效地在多核处理器和 分布式系统上运行。 并行和分布式算法 将大型问题分解为可以并行解决的小子问题,从而减少计算时间,并使得大规模数据能够实时或接近实时地处理。 -
平滑分析 :传统的 算法分析通常侧重于最坏情况或平均情况,这可能无法准确反映实际的性能。 平滑分析通过分析算法在输入数据轻微随机扰动下的表现,提供了一个更为细致的视角。 这种方法为实际的大规模设置中的算法性能提供了更为真实的度量,弥合了理论分析 与 经验性能 之间的差距。 -
大规模数据的算法 :大数据应用需要能够高效处理和分析海量数据集的算法。 这些算法旨在处理 跨不同存储系统分布的高维数据,同时确保可扩展性和性能。 它们包括数据挖掘、聚类和机器学习方法,能够在海量数据上有效运作。 -
图神经网络(GNNs) :在许多现实世界的场景中,数据自然地以图的形式呈现,如社交网络、生物系统和通信网络。 GNNs 是 一类新兴的算法,能够直接在图结构数据上操作,使其能够随着数据中关系的复杂性扩展。 GNNs 在处理涉及大规模互联数据集的任务时尤其强大,使它们成为从网络分析到 分子化学等应用领域中非常有价值的工具。
上下文意识
-
生物启发的算法 :自然为开发能够适应变化环境的算法提供了丰富的灵感来源。 生物启发的算法,如遗传算法、蚁群优化和 神经网络启发的计算,利用自然过程中的机制。 例如,遗传算法模拟自然选择过程,通过连续几代的演化优化复杂问题。 蚁群优化受到蚂蚁寻找资源最优路径行为的启发,提供了有效的路由和调度解决方案。 这些算法在传统方法可能力不从心的情况下表现出色,尤其是在需要适应性和鲁棒性的复杂、大规模环境中。 并且具有很强的适应性。 -
内存高效的算法 :在内存资源有限的环境中,如 嵌入式系统和物联网设备,最小化内存使用的能力至关重要。 内存高效的算法旨在这些约束条件下有效运行,使用数据压缩、就地计算和空间高效的数据结构等技术。 通过减少内存占用,这些算法使得在存储和处理能力有限的设备上能够进行复杂的计算,扩展了可从先进算法解决方案中受益的应用范围。 这在需要设备本地处理数据,且由于延迟、带宽或 隐私问题无法依赖云资源的情境中尤其重要。 -
在线算法 :这些 算法 旨在通过不完整的信息实时做出决策。 在许多现实应用中,数据是连续到达的,决策必须在没有提前知道完整输入的情况下即时做出。 在线算法在这种动态环境中表现出色,使其适用于股票交易、网络 路由以及 负载均衡等需要即时响应的应用场景。 这些算法可以在这些领域中发挥重要作用。 -
自适应与动态算法 :为了在变化的环境中有效运行,一些 算法被设计为自适应,能够根据新的 输入数据或变化的条件进行调整。 自适应算法可以根据观察到的数据修改其行为,随着时间的推移不断学习和改进。 动态算法则能够处理问题结构本身的变化,如图形更新或调度问题的变动。 这些能力对于自动化系统等应用至关重要,这些系统必须持续适应其环境以 有效运作。 -
机器学习算法 :机器学习算法天生具有一定的上下文感知能力 因为它们能够从数据中学习。 这些算法能够适应各种环境,无论是处理静态数据集还是 处理实时数据流。 强化学习等技术使算法能够通过与环境的互动学习最优行为,使它们适合在动态环境中进行复杂决策任务。 此外,机器学习模型可以部署在边缘设备等环境中,在这些设备上本地处理和分析数据,从而减少对持续 云连接的需求。
道德责任
另一个关键问题是
-
伦理设计与实施 :确保算法的设计考虑公平性、透明性和问责制。 这包括使用多样化的数据集,实施偏见检测和缓解策略,并提供清晰的解释,说明算法是如何做出决策的。 -
监管与监督 :政府和监管机构需要建立并执行标准,以防止算法的滥用,保护用户隐私,并确保 AI 系统在伦理边界内运作。 。 -
环境考虑 :开发和采用节能的算法和实践,最大限度地减少对环境的影响。 这包括优化数据中心,投资可再生能源,并考虑技术基础设施的全生命周期。 -
公众意识与教育 :提高公众对算法如何影响日常生活的理解,并赋予个人做出明智选择的能力。 这还包括教育未来的开发者了解他们工作中的伦理影响。 -
算法风险管理 :算法可能引入各种类型的风险,其中最突出的是算法偏差,即基于性别、种族或社会阶层等因素而使某些用户群体受到偏好或劣势化。 这些偏差通常源于用于训练机器学习模型的数据存在偏见,导致不公平的结果。 例如,在招聘、贷款批准或刑事司法系统中使用的算法在未经适当审计或监控时已被证明会持续甚至放大社会偏见。 责任在于确保算法基于多样化、代表性数据集进行训练,并定期测试其公平性 和准确性。 另一类 风险涉及代码的误用或滥用,特别是在嵌入式系统中。 嵌入式系统被集成到无数设备中,从家用电器到医疗设备和工业控制系统,使它们容易受到恶意利用。 人们常说,如果任何设备中的任何代码遭到破坏,这可能成为一种责任。 这在安全性和安全性至关重要的系统中尤其令人担忧,如自动驾驶车辆、医疗设备和国防技术。 如果这些系统中的嵌入式代码被破坏,后果可能是灾难性的,从人员伤亡到大规模的财务和 基础设施损失。
软件实践中的道德意识
-
知识产权和许可 :控制算法使用的一种方式是通过知识产权保护它。 通过申请专利或采用特定的许可协议,开发者可以对其作品的使用方式施加限制,确保其符合伦理标准。 然而,这种方法并非万无一失,因为执行知识产权可能具有挑战性,特别是在全球范围内,不同国家有不同的 法律框架。 -
伦理保障和影响评估 :从业者应在开发过程中进行全面的 伦理影响评估。 这不仅包括分析算法的直接应用,还要考虑其潜在的次要效应和意外后果。 通过考虑算法可能被误用的各种方式,开发者可以实施保障措施,如使用限制或内置监控系统,以防止 不道德的应用。 -
双重用途考虑 :在发布算法之前,必须考虑其双重用途潜力——它如何可能合法且有益地使用,同时也要考虑它如何可能被用于非法或有害的目的。 如果一个算法被误用的风险较高,从业者需要仔细考虑是否应将其公开发布,或者是否需要额外的保障措施来 限制访问。 -
透明度与合作 :与利益相关者(包括伦理学家、法律专家和更广泛的社区)进行交流,可以为算法潜在的影响提供宝贵的见解。 在开发实践中保持透明,并与伦理相关的考虑进行公开对话,有助于做出更明智的决策,并在问题变得严重之前识别潜在风险。 -
持续的伦理教育 :了解不断变化的伦理环境,并对重新审视自己工作的道德影响保持开放态度,对从业人员至关重要。 这包括对新兴伦理问题的持续教育,以及反思社会规范和价值观如何随着时间的推移而变化。
最后的话
参考文献和进一步阅读
-
摩尔定律是什么? 作者 Max Roser, Hannah Ritchie, 和 Edouard Mathieu (2023). 在线出版 于 OurWorldinData.org . -
量子计算与量子信息 . 作者 M. A. Nielsen 和 I. L. Chuang. 剑桥大学 出版社. 2010. -
近似算法 . 作者 V. V. Vazirani. Springer. 2001. -
参数化算法 . 作者 M. Cygan, F. V. Fomin, Ł. Kowalik, D. Lokshtanov, D. Marx, M. Pilipczuk 和 M., A. Saurabh. Springer. 2016. -
亚线性时间算法 . 在数据结构与应用手册中. 作者 A. Czumaj 和 C. Sohler. Chapman & Hall/CRC. 2004. -
数据流: 算法与应用 . 作者 S. Muthukrishnan. 现在的基础和 趋势. 2005. -
并行计算简介 . 作者 A. Grama, A. Gupta, G. Karypis, 和 V. Kumar. Pearson Education. 2003. -
分布式系统: 原理与范例 . 作者 A. A. S. Tanenbaum 和 M. V. Steen. Pearson Education. 2007. -
算法的平滑分析: 为什么单纯形算法通常需要多项式时间 . 作者 D. A. Spielman 和 S.-H. Teng. ACM 期刊, 51(3), 385-463. 2004. -
大数据集挖掘 . 作者:J. Leskovec, A. Rajaraman 和 J. D. Ullman。 剑桥大学 出版社。 2014 年。 -
图神经网络:方法与应用综述。 作者: J. Zhou, G. Cui, S. Hu, Z. Zhang, C, Yang, Z. Liu 和 M. Sun。 AI Open, 1, 57-81。 2020 年。 -
进化计算导论 . 作者:A. E. Eiben 和 J. E. Smith。 Springer 出版社。 2003 年。 -
蚁群算法优化 . 作者:M. Dorigo 和 T. Stützle. MIT 出版社。 2004 年。 -
在线计算与竞争分析 . 作者:A. Borodin 和 R. El-Yaniv。 剑桥大学 出版社。 2005 年。 -
算法伦理:辩论的框架 . 作者:B. D. Mittelstadt, P. Allo, M. Taddeo, S. Wachter 和 L. Floridi。 Big Data & Society, 3(2)。 2016 年。 -
数据中心电力使用增长 2005 至 2010 . 作者:J. G. Koomey. Analytics 出版社。 2011 年。