重学数据结构六:算法思维基础-递归、分治

简介: 重学数据结构六:算法思维基础-递归、分治

## 递归

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

**什么是递归**

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


递归有两层含义:


1)递归问题必须可以分解为若干个规模较小、与原问题形式相同的子问题。并且这些子问题可以用完全相同的解题思路来解决;


2)递归问题的演化过程是一个对原问题从大到小进行拆解的过程,并且会有一个明确的终点(临界点)。一旦原问题到达了这个临界点,就不用再往更小的问题上拆解了。最后,从这个临界点开始,把小问题的答案按照原路返回,原问题便得以解决。


简而言之,递归的基本思想就是把规模大的问题转化为规模小的相同的子问题来解决。 在函数实现时,因为大问题和小问题是一样的问题,因此大问题的解决方法和小问题的解决方法也是同一个方法。这就产生了函数调用它自身的情况,这也正是递归的定义所在。


格外重要的是,这个解决问题的函数必须有明确的结束条件,否则就会导致无限递归的情况。总结起来,递归的实现包含了两个部分,一个是递归主体,另一个是终止条件。


**递归的算法思想**

递归的数学模型其实就是数学归纳法,这个证明方法是我们高中时期解决数列问题最常用的方法。接下来,我们通过一道题目简单回顾一下数学归纳法。


一个常见的题目是:证明当 n 等于任意一个自然数时某命题成立。


当采用数学归纳法时,证明分为以下 2 个步骤:

1)证明当 n = 1 时命题成立;

2)假设 n = m 时命题成立,那么尝试推导出在 n = m + 1 时命题也成立。

与数学归纳法类似,当采用递归算法解决问题时,我们也需要围绕这 2 个步骤去做文章:

1)当你面对一个大规模问题时,如何把它分解为几个小规模的同样的问题;

2)当你把问题通过多轮分解后,最终的结果,也就是终止条件如何定义。

所以当一个问题同时满足以下 2 个条件时,就可以使用递归的方法求解:

1)可以拆解为除了数据规模以外,求解思路完全相同的子问题;

2)存在终止条件。


**递归的案例**

下面我们通过一个古老而又经典的汉诺塔问题,帮助你理解复杂的递归问题。


汉诺塔问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着 64 片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上,并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。


我们可以把这个问题抽象为一个数学问题。如下图所示,从左到右有 x、y、z 三根柱子,其中 x 柱子上面有从小叠到大的 n 个圆盘。现要求将 x 柱子上的圆盘移到 z 柱子上去。要求是,每次只能移动一个盘子,且大盘子不能被放在小盘子上面。求移动的步骤。


我们来分析一下这个问题。这是一个大规模的复杂问题,如果要采用递归方法去解决的话,就要先把问题化简。

我们的原问题是,把从小到大的 n 个盘子,从 x 移动到 z。

我们可以将这个大问题拆解为以下 3 个小问题:

把从小到大的 n-1 个盘子,从 x 移动到 y;

接着把最大的一个盘子,从 x 移动到 z;

再把从小到大的 n-1 个盘子,从 y 移动到 z。

首先,我们来判断它是否满足递归的第一个条件。 其中,第 1 和第 3 个问题就是汉诺塔问题。这样我们就完成了一次把大问题缩小为完全一样的小规模问题。我们已经定义好了递归体,也就是满足来递归的第一个条件。如下图所示:

![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200713231226251.gif)

接下来我们来看判断它是否满足终止条件。随着递归体不断缩小范围,汉诺塔问题由原来“移动从小到大的 n 个盘子”,缩小为“移动从小到大的 n-1 个盘子”,直到缩小为“移动从小到大的 1 个盘子”。移动从小到大的 1 个盘子,就是移动最小的那个盘子。根据规则可以发现,最小的盘子是可以自由移动的。因此,递归的第二个条件,终止条件,也是满足的。


经过仔细分析可见,汉诺塔问题是完全可以用递归实现的。我们定义汉诺塔的递归函数为 hanio()。这个函数的输入参数包括了:


3 根柱子的标记 x、y、z;


待移动的盘子数量 n。


具体代码如下所示,在代码中,hanio(n, x, y, z),代表了把 n 个盘子由 x 移动到 z。根据分析,我们知道递归体包含 3 个步骤:

1)把从小到大的 n-1 个盘子从 x 移动到 y,那么代码就是 hanio(n-1, x, z, y);

2)再把最大的一个盘子从 x 移动到 z,那么直接完成一次移动的动作就可以了;

3)再把从小到大的 n-1 个盘子从 y 移动到 z,那么代码就是 hanio(n-1, y, x, z)。对于终止条件则需要判断 n 的大小。如果 n 等于 1,那么同样直接移动就可以了。


```java

public static void main(String[] args) {

   String x = "x";

   String y = "y";

   String z = "z";

   hanio(3, x, y, z);

}

public void hanio(int n, String x, String y, String z) {

   if (n < 1) {

       System.out.println("汉诺塔的层数不能小于1");

   } else if (n == 1) {

       System.out.println("移动: " + x + " -> " + z);

       return;

   } else {

       hanio(n - 1, x, z, y);

       System.out.println("移动: " + x + " -> " + z);

       hanio(n - 1, y, x, z);

   }

}


```

我们以 n = 3 为例,执行一下这段代码:


在主函数中,执行了 hanio(3, "x", "y", "z")。我们发现 3 比 1 要大,则进入递归体。分别先后执行了 hanio(2, "x", "z", "y")、"移动: x->z"、hanio(2, "y", "x", "z")。


其中的 hanio(2, "x", "z", "y"),又先后执行了 hanio(1, "x", "y", "z")、"移动: x->y"、hanio(1, "z", "x", "y")。在这里,hanio(1, "x", "y", "z") 的执行结果是 "移动: x->z",hanio(1, "z", "x", "y")的执行结果是"移动: z->y"。


另一边,hanio(2, "y", "x", "z") 则要先后执行 hanio(1, "y", "z", "x")、"移动: y->z"、hanio(1, "x", "y", "z")。在这里,hanio(1, "y", "z", "x") 的执行结果是"移动: y->x",hanio(1, "x", "y", "z") 的执行结果是 "移动: x->z"。

![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200713231316301.gif)

最终梳理一下,代码执行的结果就是:

移动: x->z

移动: x->y

移动: z->y

移动: x->z

移动: y->x

移动: y->z

移动: x->z

抛开用于处理输入异常的代码部分不谈,它的代码包含了 2 个部分:

终止条件,即如何处理小规模的问题,实现的代码量一定是很少的;

递归体,即大问题向小问题分解的过程,实现的代码量也不会太多。

因此,一个复杂问题的递归实现,通常代码量都不会很多。

## 分治

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


**分治法的使用方法**

当你需要采用分治法时,一般原问题都需要具备以下几个特征:

1)难度在降低,即原问题的解决难度,随着数据的规模的缩小而降低。这个特征绝大多数问题都是满足的。

2)问题可分,原问题可以分解为若干个规模较小的同类型问题。这是应用分治法的前提。

3)解可合并,利用所有子问题的解,可合并出原问题的解。这个特征很关键,能否利用分治法完全取决于这个特征。

4)相互独立,各个子问题之间相互独立,某个子问题的求解不会影响到另一个子问题。如果子问题之间不独立,则分治法需要重复地解决公共的子问题,造成效率低下的结果。

**分治法在每轮递归上,都包含了分解问题、解决问题和合并结果这 3 个步骤。**


**分治法的案例**

下面我们一起来看一个例子。在数组 { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } 中,查找 8 是否出现过。


首先判断 8 和中位数 5 的大小关系。因为 8 更大,所以在更小的范围 6, 7, 8, 9, 10 中继续查找。此时更小的范围的中位数是 8。由于 8 等于中位数 8,所以查找到并打印查找到的 8 对应在数组中的 index 值。如下图所示。

![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20200713231433248.gif)

从代码实现的角度来看,我们可以采用两个索引 low 和 high,确定查找范围。最初 low 为 0,high 为数组长度减 1。在一个循环体内,判断 low 到 high 的中位数与目标变量 targetNumb 的大小关系。根据结果确定向左走(high = middle - 1)或者向右走(low = middle + 1),来调整 low 和 high 的值。直到 low 反而比 high 更大时,说明查找不到并跳出循环。我们给出代码如下:


```java

public static void main(String[] args) {

// 需要查找的数字

int targetNumb = 8;

// 目标有序数组

int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

int middle = 0;

int low = 0;

int high = arr.length - 1;

   int isfind = 0;


while (low <= high) {

 middle = (high + low) / 2;

 if (arr[middle] == targetNumb) {

  System.out.println(targetNumb + " 在数组中,下标值为: " + middle);

           isfind = 1;

  break;

 } else if (arr[middle] > targetNumb) {

  // 说明该数在low~middle之间

  high = middle - 1;

 } else {

  // 说明该数在middle~high之间

  low = middle + 1;

 }

   }

   if (isfind == 0) {

  System.out.println("数组不含 " + targetNumb);

}

}


```

二分查找的时间复杂度是 O(logn),这也是分治法普遍具备的特性。当你面对某个代码题,而且约束了时间复杂度是 O(logn) 或者是 O(nlogn) 时,可以想一下分治法是否可行。


二分查找的循环次数并不确定。一般是达到某个条件就跳出循环。因此,编码的时候,多数会采用 while 循环加 break 跳出的代码结构。


二分查找处理的原问题必须是有序的。因此,当你在一个有序数据环境中处理问题时,可以考虑分治法。相反,如果原问题中的数据并不是有序的,则使用分治法的可能性就会很低了。


**例二**

最后,我们给出一个进阶的问题,供大家练习。题目如下:

在一个有序数组中,查找出第一个大于9的数字,假设一定存在。例如,arr = { -1, 3, 3, 7, 10, 14, 14 }; 则返回 10。

在这里提醒一下,带查找的目标数字具备这样的性质:

第一,它比 9 大;

第二,它前面的数字(除非它是第一个数字),比 9 小。

因此,当我们作出向左走或向右走的决策时,必须满足这两个条件。


```java

public static void main(String[] args) {

int targetNumb = 9;

// 目标有序数组

int[] arr = { -1, 3, 3, 7, 10, 14, 14 };

int middle = 0;

int low = 0;

int high = arr.length - 1;

while (low <= high) {

 middle = (high + low) / 2;

 if (arr[middle] > targetNumb && (middle == 0 || arr[middle - 1] <= targetNumb)) {

  System.out.println("第一个比 " + targetNumb + " 大的数字是 " + arr[middle]);

  break;

 } else if (arr[middle] > targetNumb) {

  // 说明该数在low~middle之间

  high = middle - 1;

 } else {

  // 说明该数在middle~high之间

  low = middle + 1;

 }

}

}




```

参考《拉勾教育-重学数据结构》,仅供学习

目录
相关文章
|
1月前
|
算法
【初阶数据结构】复杂度算法题篇
该方法基于如下的事实:当我们将数组的元素向右移动 k 次后,尾部 kmodn 个元素会移动至数组头部,其余元素向后移动 kmodn 个位置。
|
1月前
|
机器学习/深度学习 人工智能 算法
【人工智能】线性回归模型:数据结构、算法详解与人工智能应用,附代码实现
线性回归是一种预测性建模技术,它研究的是因变量(目标)和自变量(特征)之间的关系。这种关系可以表示为一个线性方程,其中因变量是自变量的线性组合。
40 2
|
1月前
|
算法
【初阶数据结构篇】二叉树算法题
二叉树是否对称,即左右子树是否对称.
|
1月前
|
算法 索引
【初阶数据结构篇】单链表算法题进阶
深拷贝应该正好由 n 个全新节点组成,其中每个新节点的值都设为其对应的原节点的值。
|
1月前
|
存储 算法
【初阶数据结构篇】顺序表和链表算法题
此题可以先找到中间节点,然后把后半部分逆置,最近前后两部分一一比对,如果节点的值全部相同,则即为回文。
|
9天前
|
存储 人工智能 C语言
数据结构基础详解(C语言): 栈的括号匹配(实战)与栈的表达式求值&&特殊矩阵的压缩存储
本文首先介绍了栈的应用之一——括号匹配,利用栈的特性实现左右括号的匹配检测。接着详细描述了南京理工大学的一道编程题,要求判断输入字符串中的括号是否正确匹配,并给出了完整的代码示例。此外,还探讨了栈在表达式求值中的应用,包括中缀、后缀和前缀表达式的转换与计算方法。最后,文章介绍了矩阵的压缩存储技术,涵盖对称矩阵、三角矩阵及稀疏矩阵的不同压缩存储策略,提高存储效率。
|
11天前
|
存储 C语言
数据结构基础详解(C语言): 栈与队列的详解附完整代码
栈是一种仅允许在一端进行插入和删除操作的线性表,常用于解决括号匹配、函数调用等问题。栈分为顺序栈和链栈,顺序栈使用数组存储,链栈基于单链表实现。栈的主要操作包括初始化、销毁、入栈、出栈等。栈的应用广泛,如表达式求值、递归等场景。栈的顺序存储结构由数组和栈顶指针构成,链栈则基于单链表的头插法实现。
|
12天前
|
Java
【数据结构】栈和队列的深度探索,从实现到应用详解
本文介绍了栈和队列这两种数据结构。栈是一种后进先出(LIFO)的数据结构,元素只能从栈顶进行插入和删除。栈的基本操作包括压栈、出栈、获取栈顶元素、判断是否为空及获取栈的大小。栈可以通过数组或链表实现,并可用于将递归转化为循环。队列则是一种先进先出(FIFO)的数据结构,元素只能从队尾插入,从队首移除。队列的基本操作包括入队、出队、获取队首元素、判断是否为空及获取队列大小。队列可通过双向链表或数组实现。此外,双端队列(Deque)支持两端插入和删除元素,提供了更丰富的操作。
14 0
【数据结构】栈和队列的深度探索,从实现到应用详解
|
1月前
栈的几个经典应用,真的绝了
文章总结了栈的几个经典应用场景,包括使用两个栈来实现队列的功能以及利用栈进行对称匹配,并通过LeetCode上的题目示例展示了栈在实际问题中的应用。
栈的几个经典应用,真的绝了
|
16天前
|
Linux C++ Windows
栈对象返回的问题 RVO / NRVO
具名返回值优化((Name)Return Value Optimization,(N)RVO)是一种优化机制,在函数返回对象时,通过减少临时对象的构造、复制构造及析构调用次数来降低开销。在C++中,通过直接在返回位置构造对象并利用隐藏参数传递地址,可避免不必要的复制操作。然而,Windows和Linux上的RVO与NRVO实现有所不同,且接收栈对象的方式也会影响优化效果。