# 递归算法 ## 什么是递归 在计算机中,程序调用自身的编程技巧我们称之为递归算法。那么再通俗一点来讲就是:在某个python文件中,有一个函数,这个函数可以在自己的函数体内根据条件,自己调用自己的函数,那么这样自身调用自身的过程或者说行为,我们称之为递归。 再简单的讲,就是在一个函数里再次调用该函数本身 为了防止递归无限进行,通常我们还会指定一个退出条件 ## 案例切入 我们可以用 **求和** 这样一个更简单的例子来展示从迭代到递归的过渡。假设我们要求从 1 到 `n` 的整数和。这个问题既可以用迭代方式解决,也可以用递归方式解决。 - 用迭代的方式解决这个问题 ```python def sum(n): sum_total = 0 for i in range(1,n+1): sum_total += i return sum_total print(sum(100)) # Output: 5050 ``` - 转换为迭代的思想解决 递归的思想是将问题分解为更小的子问题。对求和的递归定义如下: - 当 `n = 1` 时,返回 1(递归的基础情况) - 否则,`sum(n)` 可以表示为 `n + sum(n - 1)`,逐步缩小问题规模。 ```python def sum(n): if n == 1: return 1 else: return n + sum(n-1) print(sum(100)) # Output: 5050 ``` **迭代方式**:通过循环从 1 到 `n` 累加。 **递归方式**:通过将问题分解为 `n + sum(n - 1)`,直到到达递归的基础情况 `n == 1`。 ## 递归的核心思想 递归可以理解为如果我们做一件很多步骤的事情,但是每个步骤都有固定的规律。那我们就可以将这个规律写成一个代码,反复调用这个代码,就可以实现完成整件事情 比如,小明的妈妈让他去把客厅的书搬到书房,但是由于书太多了,一次性搬不完。于是小明想到了一个聪明的办法。 “我可以一次搬走一本,然后身下的书由该怎么搬呢?” 转换一下思维,把他变成”我们每次都搬走最上面的书,然后第二次的时候,下面的书就变成的最上面的书,我们还是按照同样的办法搬走最上面的书“ **递归的核心思想:**把一个复杂的问题分解为一个小问题,并且这个小问题的解决方法和原问题完全一样。直到问题变得足够简单,可以直接解决。 **用python代码实现小明搬书的过程:** ```python def move_books(n): if n == 1: print("搬走第 1 本书") else: print(f"搬走第 {n} 本书") move_books(n - 1) # 假设有 5 本书 move_books(5) # Output: 搬走第 5 本书 搬走第 4 本书 搬走第 3 本书 搬走第 2 本书 搬走第 1 本书 ``` ## 递归的最大深度 在Python中,为了防止程序占用过多的内存和资源,会有一个递归最大深度的限制,一般情况下是997 ```python def foo(n): print(n) n += 1 foo(n) foo(1) ``` 当然我们也可以手动调整这个递归的最大深度限制,这里我们调整为2000 ```python import sys print(sys.setrecursionlimit(2000)) def foo(n): print(n) n += 1 foo(n) foo(1) ``` 然而我们并不推荐修改这个默认的递归深度,如果用997层递归都没有解决这个问题,就要考虑一下这个问题是不是适合使用递归来解决了。 ## 汉诺塔问题 汉诺塔是一个源于印度古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。 - [点击开始汉诺塔游戏](http://file.eagleslab.com:8889/%E8%AF%BE%E7%A8%8B%E7%9B%B8%E5%85%B3%E8%BD%AF%E4%BB%B6/%E4%BA%91%E8%AE%A1%E7%AE%97%E8%AF%BE%E7%A8%8B/Python%E7%9B%B8%E5%85%B3/hanoi/) image-20241009104523929 如果层数较多的话,只靠人力是很难完成的。据统计,如果按照神话故事里的64片来计算,假设每移动一步需要1秒,那么如果64片全部移动完成,大概需要18446744073709551615秒,转换为年,大概是5845.42亿年。然而地球存在也不过45亿年.... 如果用代码实现呢?我们可以尝试以下 ```python def move(n,A,B,C): ''' n代表层数 A代表原来的柱子 B代表空闲的柱子 C代表目的柱子 ''' if n == 1: # 如果只有一层的话,那么直接从A移动到C就结束了 print(A,'-->',C) else: # 将n-1个盘子从A-->B move(n-1,A,C,B) # 再将最后一个盘子从A-->C print(A,'-->',C) move(n-1,B,A,C) n = int(input('请输入汉诺塔的层数:')) move(n,'A','B','C') ``` ## 二分查找算法 image-20241009161706993 接下来我们使用不同的查找方式,来查找某个元素,看一下需要查找多少次才能找到 ### 使用循环的方式去查找 ```python l = [2,3,5,10,15,16,18,22,26,30,32,35,41,42,43,55,56,66,67,69,72,76,82,83,88] num = 0 for i in l: num += 1 if i ==66: print('66在索引为:',l.index(i),'的位置') break print('查找次数:',num) # Output: 66在索引为: 17 的位置 查找次数: 18 ``` 使用循环的方式查找66这个元素,一共需要查找18次 ### 递归方法 ```python l = [2,3,5,10,15,16,18,22,26,30,32,35,41,42,43,55,56,66,67,69,72,76,82,83,88] num = 0 def func(l,aim): global num num += 1 mid = (len(l)-1)//2 if l: if aim > l[mid]: func(l[mid+1:],aim) elif aim < l[mid]: func(l[:mid],aim) elif aim == l[mid]: print(f'查找次数:{num}次') else: print('没有找到') func(l,66) ``` 这里使用二分法查找,` mid = (len(l)-1)//2`是用来找到整个列表长度的中间位置,然后如果要找的数字大于中间位置(索引)的数字,那么就把整个列表截断,拿着后面的列表再次调用该函数进行查找,以此往复。最后找到我们要查找的哪个数字。 # 排序算法 ## 选择排序 ### 思路: 选择排序的基本思想是:每一次从未排序的部分中找到最小(或最大)的元素,并将其放到已排序部分的末尾。 ### 步骤: 1. 从未排序的部分中找到最小的元素。 2. 将这个最小元素与未排序部分的第一个元素交换位置。 3. 重复这个过程,直到数组完全排序。 ```python def selection_sort(arr): n = len(arr) for i in range(n): # 假设当前第 i 个元素是最小的 min_index = i # 找到从 i 到 n-1 之间最小的元素 for j in range(i + 1, n): if arr[j] < arr[min_index]: min_index = j # 交换当前元素和找到的最小元素 arr[i], arr[min_index] = arr[min_index], arr[i] # 测试 arr = [64, 25, 12, 22, 11] selection_sort(arr) print("选择排序结果:", arr) ``` ### 解释: - 每一轮循环找到剩余元素中最小的元素,并将其放在已排序部分的末尾。 - 例如,对于 `[64, 25, 12, 22, 11]`,第一轮找到 `11`,和 `64` 交换。第二轮找到 `12`,和 `25` 交换,依此类推。 ## 冒泡排序 ### 思路: 冒泡排序的基本思想是:通过相邻元素的比较,不断将较大的元素“冒泡”到数组的末尾。 ### 步骤: 1. 从数组的开头开始,比较每一对相邻的元素。如果顺序不对,就交换它们。 2. 一轮比较结束后,最大的元素会被放在最后。 3. 重复这个过程,直到数组完全排序。 ```python def bubble_sort(arr): n = len(arr) for i in range(n): # 每次冒泡会把最大的数放到最后,所以每次可以少比较一个元素 for j in range(0, n - i - 1): if arr[j] > arr[j + 1]: # 交换相邻元素 arr[j], arr[j + 1] = arr[j + 1], arr[j] # 测试 arr = [64, 34, 25, 12, 22, 11, 90] bubble_sort(arr) print("冒泡排序结果:", arr) ``` ### 解释: - 每一轮循环会将最大的元素移动到数组的末尾。通过交换相邻的元素,较大的元素会逐渐“冒泡”到数组的右侧。 - 例如,对于 `[64, 34, 25, 12, 22, 11, 90]`,第一轮会将 `90` 放到最后,第二轮会将 `64` 放到倒数第二位,依此类推。 ## 快速排序 ### 思路: 快速排序是一种“分而治之”的排序算法。它通过选择一个**基准**(pivot),将数组分成两部分:一部分比基准小,另一部分比基准大。然后递归地对这两部分进行排序。 ### 步骤: 1. 选择一个基准元素(pivot)。 2. 将数组分成两部分,一部分所有元素比基准小,另一部分所有元素比基准大。 3. 递归地对这两部分进行快速排序。 4. 合并结果。 ```python def quick_sort(arr): # 基础情况:当数组长度为 0 或 1 时,直接返回 if len(arr) <= 1: return arr # 选择基准元素 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) # 测试 arr = [10, 7, 8, 9, 1, 5] sorted_arr = quick_sort(arr) print("快速排序结果:", sorted_arr) ``` ### 解释: - 快速排序通过选择一个基准元素(这里我们选择数组中间的元素),然后将数组分成三部分:小于基准的部分、等于基准的部分和大于基准的部分。 - 递归地对小于和大于基准的部分进行排序,最终将所有部分合并起来,形成有序数组。 - 例如,对于 `[10, 7, 8, 9, 1, 5]`,选择 `8` 作为基准,分成 `[7, 1, 5]`、`[8]` 和 `[10, 9]`,然后分别排序并合并。 ## 插入排序 ### 思路: 插入排序的基本思想是:将数组分为已排序和未排序两部分。每次从未排序部分取出一个元素,将其插入到已排序部分的正确位置。 ### 步骤: 1. 从第二个元素开始,将其与前面的元素比较,插入到正确的位置。 2. 重复这个过程,直到整个数组有序。 ```python def insertion_sort(arr): # 从第二个元素开始,因为第一个元素默认是已排序的 for i in range(1, len(arr)): key = arr[i] # 将 key 插入到前面已排序部分的正确位置 j = i - 1 while j >= 0 and arr[j] > key: arr[j + 1] = arr[j] # 把比 key 大的元素向后移动 j -= 1 arr[j + 1] = key # 插入 key 到正确的位置 # 测试 arr = [12, 11, 13, 5, 6] insertion_sort(arr) print("插入排序结果:", arr) ``` ### 解释: - 插入排序模拟了我们整理手中扑克牌的过程。我们从前往后遍历数组,将每个元素插入到前面已排序部分的正确位置。 - 例如,对于 `[12, 11, 13, 5, 6]`,我们从 `11` 开始,将其插入到 `12` 前面。然后处理 `13`,再处理 `5`,最后处理 `6`,直到整个数组有序。 ## 各个算法的比较: | 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | | -------- | --------------------------- | ---------------- | ----------------------- | ------ | | 选择排序 | O(n2)O(n^2)O(n2) | O(n2)O(n^2)O(n2) | O(1)O(1)O(1) | 不稳定 | | 冒泡排序 | O(n2)O(n^2)O(n2) | O(n2)O(n^2)O(n2) | O(1)O(1)O(1) | 稳定 | | 快速排序 | O(nlog⁡n)O(n \log n)O(nlogn) | O(n2)O(n^2)O(n2) | O(log⁡n)O(\log n)O(logn) | 不稳定 | | 插入排序 | O(n2)O(n^2)O(n2) | O(n2)O(n^2)O(n2) | O(1)O(1)O(1) | 稳定 | - **选择排序**:每次选择未排序部分中最小的元素放到已排序部分末尾。适合数据量较小的情况。 - **冒泡排序**:通过相邻元素的比较交换,逐渐将较大的元素“冒泡”到数组末尾。 - **快速排序**:效率较高的一种排序算法,通过“分而治之”的方法将问题递归分解为更小的部分。 - **插入排序**:将每个元素插入到前面已排序部分的正确位置,适合数据量较小且基本有序的数组。 # 扩展阅读 从现在开始坚持刷算法题,日积月累,你终将成为算法大佬。如果你现在觉得还早,那么...... [力扣](https://leetcode.cn/)