vlambda博客
学习文章列表

数据结构与算法-byfvsu

数据结构与算法

不管是数据结构还是算法思维,它们的目标都是降低时间复杂度。数据结构是从数据组织形式的角度达成这个目标,而算法思维则是从数据处理的思路上去达成这个目标。

递归

在数学与计算机科学中,递归 (Recursion))是指在函数的定义中使用函数自身的方法,直观上来看,就是某个函数自己调用自己。

递归有两层含义:

  1. 递归问题必须可以分解为若干个规模较小、与原问题形式相同的子问题。并且这些子问题可以用完全相同的解题思路来解决;
  2. 递归问题的演化过程是一个对原问题从大到小进行拆解的过程,并且会有一个 明确的终点(临界点)。一旦原问题到达了这个临界点,就不用再往更小的问题上拆解了。最后, 从这个临界点开始,把小问题的答案按照原路返回,原问题便得以解决。

递归的思想与数学归纳法类似。

主要考虑:

  1. 可以拆解为除了数据规模以外,求解思路完全相同的子问题;
  2. 存在终止条件。
  • 汉诺塔问题

递归是解决问题的一种手段。

而分治算法是一种策略。

分治策略

分治法经常会用在海量数据处理中。这也是它显著区别于遍历查找方法的优势。在面对陌生问题时,需要注意原问题的数据是否有序,预期的时间复杂度是否带有 logn 项,是否可以通过小问题的答案合并出原问题的答案。如果这些先决条件都满足,你就应该第一时间想到分治法。

从定性的角度来看,分治法的核心思想就是**“分而治之”**。利用分而治之的思想,就可以把一个大规模、高难度的问题,分解为若干个小规模、低难度的小问题。通常而言,这些子问题具备互相独立、形式相同的特点。随后,开发者将面对多个简单的问题,并很快地找到答案各个击破。在把这些简单问题解决好之后,我们通过把这些小问题的答案合并,就得到了原问题的答案。

一般原问题都需要具备以下几个特征:

  1. 难度在降低,即原问题的解决难度,随着数据的规模的缩小而降低。这个特征绝大多数问题都是满足的。
  2. 问题可分,原问题可以分解为若干个规模较小的同类型问题。这是应用分治法的前提。
  3. 解可合并,利用所有子问题的解,可合并出原问题的解。这个特征很关键,能否利用分治法完全取决于这个特征。
  4. 相互独立,各个子问题之间相互独立,某个子问题的求解不会影响到另一个子问题。如果子问题之间不独立,则分治法需要重复地解决公共的子问题,造成效率低下的结果。

分治法需要递归地分解问题,再去解决问题。因此,分治法在每轮递归上,都包含了分解问题、解决问题和合并结果这 3 个步骤。

最佳应用:快速排序和归并排序。

二分查找的思路比较简单,步骤如下:

  1. 选择一个标志 i 将集合 L 分为二个子集合,一般可以使用中位数;
  2. 判断标志 L(i) 是否能与要查找的值 des 相等,相等则直接返回结果;
  3. 如果不相等,需要判断 L(i) 与 des 的大小;
  4. 基于判断的结果决定下步是向左查找还是向右查找。如果向某个方向查找的空间为 0,则返回结果未查到;
  5. 回到步骤 1。

得到的经验:

  1. 二分查找的时间复杂度是 O(logn),这也是分治法普遍具备的特性。当你面对某个代码题,而且约束了时间复杂度是 O(logn) 或者是 O(nlogn) 时,可以想一下分治法是否可行。
  2. 二分查找的循环次数并不确定。一般是达到某个条件就跳出循环。因此,编码的时候,多数会采用 while 循环加 break 跳出的代码结构。
  3. 二分查找处理的原问题必须是有序的。因此,当你在一个有序数据环境中处理问题时,可以考虑分治法。相反,如果原问题中的数据并不是有序的,则使用分治法的可能性就会很低了。

排序算法

衡量一个排序算法的优劣,我们主要会从以下 3 个角度进行分析:

1.时间复杂度,具体包括,最好时间复杂度、最坏时间复杂度以及平均时间复杂度。

2.空间复杂度,如果空间复杂度为 1,也叫作原地排序。

3.稳定性,排序的稳定性是指相等的数据对象,在排序之后,顺序是否能保证不变。

冒泡排序

冒泡排序最好时间复杂度是 O(n),也就是当输入数组刚好是顺序的时候,只需要挨个比较一遍就行了,不需要做交换操作,所以时间复杂度为 O(n)。

冒泡排序最坏时间复杂度会比较惨,是 O(n*n)。也就是说当数组刚好是完全逆序的时候,每轮排序都需要挨个比较 n 次,并且重复 n 次,所以时间复杂度为 O(n*n)。

很显然,当输入数组杂乱无章时,它的平均时间复杂度也是 O(n*n)。

冒泡排序不需要额外的空间,所以空间复杂度是 O(1)。

插入排序

选取未排序的元素,插入到已排序区间的合适位置,直到未排序区间为空。插入排序顾名思义,就是从左到右维护一个已经排好序的序列。直到所有的待排数据全都完成插入的动作。

插入排序最好时间复杂度是 O(n),即当数组刚好是完全顺序时,每次只用比较一次就能找到正确的位置。这个过程重复 n 次,就可以清空未排序区间。

插入排序最坏时间复杂度则需要 O(n*n)。即当数组刚好是完全逆序时,每次都要比较 n 次才能找到正确位置。这个过程重复 n 次,就可以清空未排序区间,所以最坏时间复杂度为 O(n*n)。

插入排序的平均时间复杂度是 O(n*n)。这是因为往数组中插入一个元素的平均时间复杂度为 O(n),而插入排序可以理解为重复 n 次的数组插入操作,所以平均时间复杂度为 O(n*n)。

插入排序不需要开辟额外的空间,所以空间复杂度是 O(1)。

插入排序是稳定的排序算法

小结:插入排序和冒泡排序算法的异同点

接下来我们来比较一下上面这两种排序算法的异同点:

相同点

  • 插入排序和冒泡排序的平均时间复杂度都是 O(n*n),且都是稳定的排序算法,都属于原地排序。

差异点

  • 冒泡排序每轮的交换操作是动态的,所以需要三个赋值操作才能完成;
  • 而插入排序每轮的交换动作会固定待插入的数据,因此只需要一步赋值操作。

归并排序

分治法的应用。

对于归并排序,它采用了二分的迭代方式,复杂度是 logn。

每次的迭代,需要对两个有序数组进行合并,这样的动作在 O(n) 的时间复杂度下就可以完成。因此,**归并排序的复杂度就是二者的乘积 O(nlogn)。**同时,它的执行频次与输入序列无关,因此,归并排序最好、最坏、平均时间复杂度都是 O(nlogn)。

空间复杂度方面,由于每次合并的操作都需要开辟基于数组的临时内存空间,所以空间复杂度为 O(n)。归并排序合并的时候,相同元素的前后顺序不变,所以归并是稳定的排序算法。

快速排序

分治法应用。

快速排序法的原理也是分治法。它的每轮迭代,会选取数组中任意一个数据作为分区点,将小于它的元素放在它的左侧,大于它的放在它的右侧。再利用分治思想,继续分别对左右两侧进行同样的操作,直至每个区间缩小为 1,则完成排序。

在快排的最好时间的复杂度下,如果每次选取分区点时,都能选中中位数,把数组等分成两个,那么此时的时间复杂度和归并一样,都是 O(n*logn)。

而在最坏的时间复杂度下,也就是如果每次分区都选中了最小值或最大值,得到不均等的两组。那么就需要 n 次的分区操作,每次分区平均扫描 n / 2 个元素,此时时间复杂度就退化为 O(n*n) 了。

快速排序法在大部分情况下,统计上是很难选到极端情况的。因此它平均的时间复杂度是 O(n*logn)。

快速排序法的空间方面,使用了交换法,因此空间复杂度为 O(1)。

很显然,快速排序的分区过程涉及交换操作,所以快排是不稳定的排序算法。

排序算法的性能分析

排序算法性能的下限,也就是最差的情况。求数组的最大值,它的时间复杂度是 O(n)。对于 n 个元素的数组,只要重复执行 n 次最大值的查找就能完成排序。因此排序最暴力的方法,时间复杂度是 O(n*n)。这恰如冒泡排序和插入排序。

当我们利用算法思维去解决问题时,就会想到尝试分治法。此时,利用归并排序就能让时间复杂度降低到 O(nlogn)。然而,归并排序需要额外开辟临时空间。一方面是为了保证稳定性,另一方面则是在归并时,由于在数组中插入元素导致了数据挪移的问题。

为了规避因此而带来的时间损耗,此时我们采用快速排序。通过交换操作,可以解决插入元素导致的数据挪移问题,而且降低了不必要的空间开销。但是由于其动态二分的交换数据,导致了由此得出的排序结果并不稳定。

如果对数据规模比较小的数据进行排序,可以选择时间复杂度为 O(n*n) 的排序算法。因为当数据规模小的时候,时间复杂度 O(nlogn) 和 O(n*n) 的区别很小,它们之间仅仅相差几十毫秒,因此对实际的性能影响并不大。

但对数据规模比较大的数据进行排序,就需要选择时间复杂度为 O(nlogn) 的排序算法了。

  • 归并排序的空间复杂度为 O(n),也就意味着当排序 100M 的数据,就需要 200M 的空间,所以对空间资源消耗会很多。
  • 快速排序在平均时间复杂度为 O(nlogn),但是如果分区点选择不好的话,最坏的时间复杂度也有可能逼近 O(n*n)。而且快速排序不具备稳定性,这也需要看问题是否有稳定性的需求。

动态规划

不满足分治中的子问题互相独立。

从数学的视角来看,动态规划是一种运筹学方法,是在多轮决策过程中的最优方法**。

那么,什么是多轮决策呢?其实多轮决策的每一轮都可以看作是一个子问题。从分治法的视角来看,每个子问题必须相互独立。但在多轮决策中,这个假设显然不成立。这也是动态规划方法产生的原因之一。

动态规划中的状态是影响子问题独立的重要因素,因为动态规划中的下一个状态与当前和之前的状态紧密联系。

  • 动态规划的基本方法

    动态规划问题之所以难,是因为动态规划的解题方法并没有那么标准化,它需要你因题而异,仔细分析问题并寻找解决方案。虽然动态规划问题没有标准化的解题方法,但它有一些宏观层面通用的方法论:

    了解了方法论、状态、多轮决策之后,我们再补充一些动态规划的基本概念。

    • 策略,每轮的动作是决策,多轮决策合在一起常常被称为策略。
    • 策略集合,由于每轮的决策动作都是一个变量,这就导致合在一起的策略也是一个变量。我们通常会称所有可能的策略为策略集合。因此,动态规划的目标,也可以说是从策略集合中,找到最优的那个策略。
    1. 分阶段,将原问题划分成几个子问题。一个子问题就是多轮决策的一个阶段,它们可以是不满足独立性的。

    2. 找状态,选择合适的状态变量 Sk。它需要具备描述多轮决策过程的演变,更像是决策可能的结果。

    3. 做决策,确定决策变量 uk。每一轮的决策就是每一轮可能的决策动作,例如 D2 的可能的决策动作是 D2 -> E2 和 D2 -> E3。

    4. 状态转移方程。这个步骤是动态规划最重要的核心,即 sk+1= uk(sk) 。

    5. 定目标。写出代表多轮决策目标的指标函数 Vk,n。

    6. 寻找终止条件。

一般而言,具有如下几个特征的问题,可以采用动态规划求解:

  1. 最优子结构。它的含义是,原问题的最优解所包括的子问题的解也是最优的。例如,某个策略使得 A 到 G 是最优的。假设它途径了 Fi,那么它从 A 到 Fi 也一定是最优的。
  2. 无后效性。某阶段的决策,无法影响先前的状态。可以理解为今天的动作改变不了历史。
  3. 有重叠子问题。也就是,子问题之间不独立。 这个性质是动态规划区别于分治法的条件。如果原问题不满足这个特征,也是可以用动态规划求解的,无非就是杀鸡用了宰牛刀。
  • 最短距离动态规划

    可以暴力求解,所有路径都遍历一次,去查找最短的路径。

    动态规划求解方法:

解决问题的分析步骤

  1. 确定 复杂度的上下限。上限一般采用暴力解法估计,不计时间、空间的最直接的解法。然后根据具体的问题分析复杂度的下限。比如是否需要对每个元素遍历等。
  2. 首先明确问题的类型。比如:查找?还是删除?还是需要用到其他类型的算法思路。
  3. 根据不同的算法以及 数据结构的特点,采用合理且能达到理论上最优秀的方式。一般是利用空间换取时间。