- 算法简介
- 简单查找
- 二分查找法
- 选择排序
- 内存的工作原理
- 数组和链表
- 数组
- 选择排序
- 小结
- 递归
- 小梗 要想学会递归,首先要学会递归。
- 递归的基线条件和递归条件
- 递归和栈
- 小结
- 快速排序
- 分而治之
- 快速排序
- 合并排序
- 时间复杂度的平均情况和最糟情况
- 小结
算法简介
算法是一组完成任务的指令。算法与编程语言无关算法是一种思考。
简单查找
简单查找(Simple Search),也称为线性查找(Linear Search),是一种基本的查找算法,适用于未排序或部分排序的数组。其基本思想是逐个地对数组元素进行比较,直到找到目标元素或遍历完整个数组为止。
简单查找的实现非常直观,通常用于简单的问题或者对性能要求不高的场景。然而,它的时间复杂度为 O(n),其中 n 是数组的长度,即在最坏情况下需要遍历整个数组才能确定目标元素的位置,效率较低。
二分查找法
二分查找(Binary Search)是一种在有序数组中查找特定元素的搜索算法。它的基本思想是通过不断将待查找区间分成两半,并利用目标值与中间元素的比较结果来确定下一步查找的区间,直到找到目标值或者区间缩小到空为止。
二分查找适用于满足以下条件的场景:
-
有序数组: 数组必须是有序的(升序或降序),否则无法利用二分查找的特性。
-
静态数据结构: 二分查找适用于静态的数据结构,即查找操作频率远远大于插入、删除操作的场景。因为每次插入、删除操作都需要对数组进行调整,破坏了数组的有序性。
-
连续存储空间: 二分查找通常使用数组这种连续存储空间实现,不适用于链式存储结构。
-
单调性: 如果数组中存在重复元素,只能找到其中一个元素的位置。另外,二分查找通常是找到第一个满足条件的元素,如果要找最后一个元素,则需要稍作修改。
- 时间复杂度为 O(log n),效率较高。
你的目标是以最少的次数猜到这个数字。你每次猜测后,我会说小了、大了或对了。
假设你从1开始依次往上猜,猜测过程会是这样。
但这种方式是连续的询问,方法比较笨。
可以一次排除一半,增加效率
大了,那余下的数字又排除了一半!使用二分查找时,你猜测的是中间的数字,从而每次都
将余下的数字排除一半。接下来,你猜63(50和75中间的数字)
仅当列表是有序的时候,二分查找才管用。例如,电话簿中的名字是按字母顺序排列的,
因此可以使用二分查找来查找名字。如果名字不是按顺序排列的,结果将如何呢?
线性时间 是指算法的运行时间与输入规模成正比,即随着输入规模的增加,算法的执行时间也按比例增加。具体来说,如果算法的运行时间是输入规模的线性函数,我们就说该算法是线性时间的。
大O表示法是一种特殊的表示法,指出了算法的速度有多快。
随着元素数量的增加,二分查找需要的额外时间并不多,而简单查找需要的额外时间却很多。因此,随着列表的增长,二分查找的速度比简单查找快得多。
大O表示法指出了算法有多快。大O表示法指的并非以秒为单位的速度。大O表示法让你能够比较操作数,它指出了算法运行时间的增速。
除最糟情况下的运行时间外,还应考虑平均情况的运行时间,这很重要。
下面按从快到慢的顺序列出了你经常会遇到的5种大O运行时间。
O(log n),也叫对数时间,这样的算法包括二分查找。
O(n),也叫线性时间,这样的算法包括简单查找。
O(n * log n),这样的算法包括第4章将介绍的快速排序——一种速度较快的排序算法。
O(n2),这样的算法包括第2章将介绍的选择排序——一种速度较慢的排序算法。
O(n!),这样的算法包括接下来将介绍的旅行商问题的解决方案——一种非常慢的算法。
启示
算法的速度指的并非时间,而是操作数的增速。
谈论算法的速度时,我们说的是随着输入的增加,其运行时间将以什么样的速度增加。
算法的运行时间用大O表示法表示。
O(log n)比O(n)快,当需要搜索的元素越多时,前者比后者快得越多
上图的O(n!)的例子
时间复杂度为 O(n!) 的算法通常用于解决排列组合等问题,其运行时间随着输入规模的增加呈阶乘增长。这种算法在实际应用中一般不可接受,因为它的运行时间增长速度太快,对于稍微大一点的输入规模就会耗费非常大的时间。
一个简单的例子是求解 n 个元素的全排列。全排列是指将 n 个元素按照不同顺序排列的所有可能结果。一个简单的递归算法可以求解全排列,其时间复杂度为 O(n!),因为对于 n 个元素,第一个位置有 n 种选择,第二个位置有 n-1 种选择,以此类推,总共有 n! 种排列。
小结
二分查找的速度比简单查找快得多。
O(log n)比O(n)快。需要搜索的元素越多,前者比后者就快得越多。
算法运行时间并不以秒为单位。
算法运行时间是从其增速的角度度量的。
算法运行时间用大O表示法表示
选择排序
内存的工作原理
每个数据需要每个空间存放。
数组和链表
链表
链表的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在一起。使用链表时,根本就不需要移动元素。
链表(Linked List) 是一种常见的数据结构,用于存储一系列元素。它由一系列节点(Node)组成,每个节点包含数据和指向下一个节点的指针(或引用)。链表中的每个节点都有一个指针指向下一个节点,最后一个节点的指针指向空值(NULL),表示链表的末尾。
链表与数组不同,链表中的元素在内存中不必是连续存储的,每个节点都可以独立存在于内存的任何位置。这使得链表具有动态分配内存的能力,可以根据需要灵活地添加或删除节点,而不需要像数组一样预先分配固定大小的内存空间。
链表通常分为单向链表、双向链表和循环链表等不同类型。其中,单向链表中每个节点只有一个指针指向下一个节点;双向链表中每个节点有两个指针,分别指向前一个节点和后一个节点;循环链表是一种特殊的链表,其中最后一个节点的指针指向第一个节点,形成一个循环。
链表在某些情况下比数组更加适用,特别是在需要频繁插入和删除元素的情况下。但是,链表的随机访问效率较低,因为需要从头开始遍历链表才能找到指定位置的元素。
数组
数组(Array)是一种线性数据结构,用于存储相同类型的元素序列。数组中的元素在内存中是连续存储的,通过索引(index)可以访问数组中的元素。数组的长度是固定的,一旦创建就无法改变。
数组通常由以下几个要素组成:
- 元素类型:数组中所有元素的数据类型必须相同,例如整数数组、字符数组等。
- 数组名:数组的名称用于标识数组,可以通过数组名来访问数组中的元素。
- 元素个数:数组中包含的元素数量,即数组的长度。
- 索引:用于访问数组中特定位置元素的整数值。数组的索引从0开始,因此第一个元素的索引为0,第二个元素的索引为1,依此类推。
数组的优点是能够快速访问任意位置的元素,因为元素在内存中是连续存储的;同时,由于数组的长度固定,所以可以在编译时静态地分配内存,不需要动态分配和释放内存,从而节省了内存管理的开销。
然而,数组的缺点是长度固定,无法动态调整大小;插入和删除元素比较麻烦,需要移动其他元素。因此,在需要频繁插入和删除操作的情况下,使用链表等数据结构可能更合适。
数组的元素带编号,编号从0而不是1开始。例如,在下面的数组中,元素20的位置为1。元素的位置称为索引。
链表擅长插入和删除,而数组擅长随机访问。
选择排序
选择排序(Selection Sort)是一种简单直观的排序算法。它的基本思想是每次从未排序的部分选取最小(或最大)的元素,然后将其与未排序部分的第一个元素交换位置,直到所有元素都排序完毕。
具体步骤如下:
- 从待排序序列中找到最小(或最大)的元素,将其与第一个元素交换位置。
- 在剩余未排序的序列中找到最小(或最大)的元素,将其与第二个元素交换位置。
- 重复上述步骤,直到所有元素都排序完毕。
选择排序的时间复杂度为 O(n^2),其中 n 是待排序序列的长度。虽然选择排序在时间复杂度上并不是最优的,但由于其简单直观的实现方式,在某些情况下仍然是一种有效的排序算法。
选择排序是一种灵巧的算法,但其速度不是很快。
小结
计算机内存犹如一大堆抽屉。
需要存储多个元素时,可使用数组或链表。
数组的元素都在一起。
链表的元素是分开的,其中每个元素都存储了下一个元素的地址。
数组的读取速度很快。
链表的插入和删除速度很快。
在同一个数组中,所有元素的类型都必须相同(都为int、double等)
递归
小梗 要想学会递归,首先要学会递归。
递归是一种在函数定义中使用函数自身的编程技巧。简单来说,递归是将一个问题分解成更小、更简单的子问题来解决,直到问题被简化到最小规模的情况,然后再逐步将结果合并起来。递归通常涉及到两个重要的概念:基本情况和递归情况。
递归的基线条件和递归条件
由于递归函数调用自己,因此编写这样的函数时很容易出错,进而导致无限循环。
递归的条件包括两个重要部分:基本情况和递归情况。
-
基本情况(Base Case):基本情况是递归算法中的终止条件,它定义了递归应该在何时结束。当问题被简化到足够小或特定情况时,递归将不再继续,而是返回一个明确的值或执行某些特定操作。没有正确定义基本情况会导致无限递归,最终导致栈溢出等问题。
-
递归情况(Recursive Case):递归情况定义了如何将原始问题分解为更小、更简单的子问题。在递归情况下,递归函数会调用自身来解决子问题,直到达到基本情况。
递归算法的关键是确保在每次递归调用时,问题都能朝着基本情况靠近,最终达到终止条件。否则,递归会无限循环或无法终止。因此,设计递归算法时,需要仔细考虑如何将问题分解,并定义明确的基本情况。
递归和栈
调用栈(Call Stack)是一种用于管理函数调用和返回的数据结构,它在计算机内存中占据一块区域。当一个函数被调用时,该函数的信息(如参数、局部变量、返回地址等)会被压入调用栈中,然后函数开始执行。当函数执行完毕后,它的信息会从调用栈中弹出,控制权返回到调用该函数的地方。
递归与调用栈的关系密切,因为递归函数在执行过程中会多次调用自身。每次递归调用都会将函数的信息压入调用栈中,包括参数、局部变量和返回地址等。当递归达到基本情况时,开始返回,逐步弹出调用栈中的信息,直到回到最初的调用位置。
递归的实现依赖于调用栈的支持,它使得递归函数能够正确地返回到上一层调用,同时保持每个递归调用之间的独立性。然而,如果递归深度过大,调用栈可能会耗尽内存,导致栈溢出的错误。因此,在设计递归算法时,需要注意控制递归深度,避免出现过深的递归调用。
使用栈虽然很方便,但是也要付出代价:存储详尽的信息可能占用大量的内存。每个函数调
用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。在这种情况
下,你有两种选择。
重新编写代码,转而使用循环。
使用尾递归。这是一个高级递归主题。另外,并非所有的语言都支持尾递归
尾递归是指在递归函数的最后一步调用中,递归调用是整个函数体的最后一条语句。在尾递归中,递归调用的返回值直接被当前函数返回,而不需要进行其他计算或操作。这种特殊的递归形式可以被优化为迭代,从而减少调用栈的深度,提高性能和节省内存。
尾递归的优化原理是重用当前栈帧而不是创建新的栈帧。在每次递归调用中,函数参数和局部变量的值会被更新,然后直接跳转到函数开头重新执行,而不是在调用栈中创建新的栈帧。这样可以避免调用栈的不断增长,节省了空间和时间。
小结
递归指的是调用自己的函数。
每个递归函数都有两个条件:基线条件和递归条件。
栈有两种操作:压入和弹出。
所有函数调用都进入调用栈。
调用栈可能很长,这将占用大量的内存。
快速排序
分而治之
分治法(Divide and Conquer,D&C)是一种解决问题的思想和算法范式,它将一个大问题分解成多个相似的小问题,然后递归地解决这些小问题,并将它们的解合并起来,得到原问题的解。分治法通常包括三个步骤:
-
分解(Divide):将原问题分解成若干个规模较小的子问题,这些子问题是原问题的规模的一个子集。
-
解决(Conquer):递归地解决这些子问题。如果子问题的规模足够小,并且可以直接求解,则不再递归,直接求解。
-
合并(Combine):将子问题的解合并成原问题的解。
分治法常用于解决具有以下特点的问题:
- 原问题可以分解成规模较小的相似子问题。
- 子问题可以独立求解,且子问题的解可以合并成原问题的解。
- 使用分治法求解的问题,递归求解的复杂度通常可以表示为递归深度乘以每层的复杂度。
分治法的经典应用包括归并排序、快速排序和二分查找等。
快速排序
快速排序是一种常用的排序算法,比选择排序快得多。例如,C语言标准库中的函数qsort实现的就是快速排序。快速排序也使用了D&C
**快速排序(Quick Sort)**是一种高效的排序算法,它采用了分治法的思想。快速排序的基本思想是选择一个基准元素,将数组分成两部分,使得左边的元素都小于基准元素,右边的元素都大于基准元素,然后对左右两部分递归地进行排序,最终得到一个有序数组。
具体步骤如下:
-
选择基准元素:从数组中选择一个基准元素(通常选择第一个元素、最后一个元素或者中间元素)。
-
分区操作:将数组中小于基准元素的元素放到基准元素的左边,大于基准元素的元素放到基准元素的右边,基准元素放到合适的位置,这个操作称为分区(Partition)操作。
-
递归排序:对基准元素左边的子数组和右边的子数组分别递归地进行快速排序。
-
合并结果:不需要合并,因为在分区操作中,数组已经被分成了两部分,左边的部分都小于基准元素,右边的部分都大于基准元素。
快速排序的时间复杂度为 O(nlogn),其中 n 为数组的大小。在平均情况下,快速排序是一种性能较好的排序算法,但在最坏情况下(例如已排序的数组作为输入),时间复杂度为 O(n^2),因此在实际应用中需要注意选择合适的基准元素来避免最坏情况的发生。
快速排序是一种高效的排序算法,它的基本思想是选择一个基准元素,通过一趟排序将待排序的记录分割成独立的两部分,其中一部分记录的关键字均比基准元素小,另一部分记录的关键字均比基准元素大,然后分别对这两部分记录继续进行排序,从而达到整个序列有序的目的。
快速排序的步骤如下:
- 选择一个基准元素,通常选择第一个元素、最后一个元素或者中间元素。
- 使用两个指针,一个指向数组的起始位置,一个指向数组的末尾。
- 从末尾开始,找到第一个小于基准元素的元素,从起始位置开始,找到第一个大于基准元素的元素,然后交换这两个元素。
- 继续进行步骤 3,直到两个指针相遇。
- 将基准元素与相遇位置的元素交换,使得基准元素左边的元素都小于它,右边的元素都大于它。
- 递归地对基准元素左边和右边的子数组进行排序。
快速排序的时间复杂度为 O(nlogn),在最坏情况下为 O(n^2)(例如当序列已经有序时)。快速排序是一种原地排序算法,不需要额外的空间来存储临时数据,但是它不是稳定的排序算法,相同元素的相对位置可能会发生变化。
合并排序
合并排序(Merge Sort)是一种经典的分治算法,它将一个待排序的数组分成两个子数组,分别对这两个子数组进行排序,然后将排好序的子数组合并成一个有序数组。具体步骤如下:
-
分解(Divide):将待排序的数组分成两个长度大致相等的子数组。
-
解决(Conquer):递归地对两个子数组进行排序。
-
合并(Merge):将排好序的两个子数组合并成一个有序数组。
-
合并排序的时间复杂度:合并排序的时间复杂度是 O(nlogn),其中 n 是待排序数组的长度。合并排序的空间复杂度是 O(n),因为在排序过程中需要一个与原数组长度相同的辅助数组来存储排序结果。
-
稳定性:合并排序是一种稳定的排序算法,即相同元素的相对位置在排序前后不发生改变。
-
优点:合并排序的主要优点是稳定且时间复杂度稳定在 O(nlogn),在处理大数据量的排序时表现较好。
-
缺点:合并排序的缺点是需要额外的内存空间来存储辅助数组,因此对于内存空间较小的情况可能不太适用。
时间复杂度的平均情况和最糟情况
快速排序的时间复杂度取决于选取的基准元素,不同的基准元素选择策略会导致不同的性能表现。一般情况下,快速排序的时间复杂度为 O(nlogn),其中 n 是待排序数组的长度。但在最坏情况下,快速排序的时间复杂度会退化到 O(n^2),这种情况通常发生在选取的基准元素不合适的情况下,比如待排序数组已经有序或基本有序的情况下。在最好情况下,即每次都能选取中间位置的元素作为基准元素时,快速排序的时间复杂度为 O(nlogn)。
具体来说,快速排序的平均时间复杂度为 O(nlogn),这是因为在平均情况下,快速排序会将待排序数组均匀地分成两部分,每次递归都会减少一半的元素数量,因此总的比较次数是 O(nlogn)。但在最坏情况下,比如每次选择的基准元素都是最大或最小的元素,导致每次只能将待排序数组减少一个元素,总的比较次数将是 O(n^2)。
为了避免快速排序的最坏情况,通常可以采用随机化的方法来选择基准元素,或者使用三数取中法等策略来选择基准元素,这样可以尽可能地降低出现最坏情况的概率,从而提高快速排序的性能。
小结
D&C将问题逐步分解。使用D&C处理列表时,基线条件很可能是空数组或只包含一个元素的数组。
实现快速排序时,请随机地选择用作基准值的元素。快速排序的平均运行时间为O(n log n)。
大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在。
比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时,O(logn)的速度比O(n)快得多。