算法思想及排序算法的实现与对比 循环和递归 递归即大问题分解成小问题,但递归通常效率较低,通常表现为时间
, 空间
均效率低下。
递归时由于重复调用方法自身,每次调用方法时皆需在内存栈中分配空间以保存参数,返回地址及临时变量 因此需大量空间,而压栈弹栈皆需要时间。
写递归代码切忌人脑模拟电脑思考进栈出栈细节。
只需确认三点:
终止条件 + 函数功能 + 函数方程;
此外思考函数方程时,也需注意终止条件中的返回值是什么。
同时, 别太死板的觉得 终止条件一定要 “同时包含互斥条件的两方”, 例如 offer 13. 只需判断当前格子不可选时,return 0即可。 无需包含 当前格子是最后一 个可选的时返回1, 观察函数方程即可理解。
动态规划 特点:
(1)某个问题可以分解成小问题 ,并且所问为求最优解(关于是否要求最优解不绝对 )。
(2)把小问题的最优解组合起来能得到整体最优解,则通常可用动态规划。
(3) 小问题之间有重叠的子问题(即如果用递归则需要算很多重复的值)。
分析动态规划时的思考方式与递归类似,因为都是大问题分解小问题。
动态规划则为自上而下
方式思考解体思路。而用代码实现时则用自下而上
的方式解决问题,同时利用数据存储小问题的解
然后组合小问题的解以得到整个问题的解, 熟练后可抛弃数组,仅用几个变量 存储有用的解
贪心 若某个问题用动态规划的思路思考以后, 发现分解子问题时存在某种特定条件能使得结果一定为最优解
, 则此时应思考贪心算法
回溯 可理解为有剪枝的穷举
,其从解决问题每一步的多个选项里系统的选择一个可行的方案。
适合用于解决有多个步骤组成,且每个步骤有多个选项的问题
。
回溯法解决问题的过程,可以近似理解为“遍历”一颗树的过程。 树中的每一个level 即一个步骤,每个node即一个选项。
当遍历至一个节点时,发现结果已不可能满足条件,则可放弃该节点返回至上一层节点。
同理,若发现一个节点的所有子节点均不满足条件时,则同样返回至上一层节点。
而返回至上一层节点时,若发现上一层中所有节点均已被遍历过,则再次返回至上一层节点。
如果所有节点(即路径)均已遍历且无符合条件的路径,则r
对于在二维数组中搜索路径的问题,如迷宫或棋盘,以及搜索可行解的问题,则通常可用回溯法
。 而回溯法通常可用递归的方式求解。 递归又可用栈来实现。
查找算法 顺序查找 二分查找 对于在一个排序的,或部分排序的数组
中查找一个数字
,或统计一个数字出现的次数
的问题,均可尝试使用二分查找
哈希查找 优: 效率最高, 时间复杂度O(1)
缺: 空间效率较低,需额外空间维护哈希表
二叉排序树查找(即二叉查找树) 排序算法 Code and Commitment: 冒泡排序
空间复杂度O(1)
时间复杂度:
最低O(n): 即input is already sorted
最高O(n^2): input is fully reversed
平均: O(n^2)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 def bubble_sort (nums: List ) -> List : """ # wrong answer writen in 2021-11-29 for i in range(len(nums)-1, 0): for j in range(len(nums)-1, i): if nums[j] < nums[j - 1]: nums[j], nums[j - 1] = nums[j - 1], nums[j] 冒泡排序: 冒泡排序每次交换相邻的两个元素 每次循环结束后, 最大或最小的元素均被挪动至数组结尾,因此, “循环条件为每次上层循环结束后,下层循环的终点应右移” 上述错误产生原因在与对第一层循环i的理解有误, i的作用是使第二曾循环j的终点至右移, 因为i每加1, 则意味着有 1 个元素已经被 放置在正确位置, 也即数组的后i个元素已经排好序无需在比较 """ if len (nums) == 0 : return nums for i in range (0 , len (nums) - 1 ): for j in range (0 , len (nums) - i - 1 ): if nums[j] > nums[j + 1 ]: nums[j], nums[j + 1 ] = nums[j + 1 ], nums[j] return nums def bubble_sort_optimized (nums ): """ 原版本代码对于一个已排序的数组,依旧需循环 n + n-1 + n-2 + ... 次, which is unefficient 可加入标志为实现对于一个已排序的数组,时间复杂度为O(n) 关于标志位: 即记录每次内层循环中是否发生换位,如果没发生,则代表每两个数据间大小顺序均已符合条件,即数组已排好序 :param nums: :return: """ if len (nums) == 0 : return nums for i in range (0 , len (nums) - 1 ): no_swap = True for j in range (0 , len (nums) - i - 1 ): if nums[j] > nums[j + 1 ]: nums[j], nums[j + 1 ] = nums[j + 1 ], nums[j] no_swap = False if no_swap is True : break return nums
插入排序
时间复杂度总是O(n^2)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 def insert_sort (nums ) -> List : """ Overall, insertion sort virtually divide the array into two parts: sorted part and unsorted part It get one of elements in unsorted part every time and insert it into the right position of sorted part During the insertion process, we need to move the elements in sorted part to make space for the newly inserted one E.g. sorted part: index: 0, 1, 2, 3, 4 element: 1, 3, 4, 5, 6 to be inserted: 2 Then: index: 0, 1, 2, 3, 4, 5 element: 1, , 3, 4, 5, 6 """ if len (nums) <= 1 : return nums for i in range (1 , len (nums)): current = nums[i] j = i while current < nums[j-1 ] and j >= 1 : nums[j] = nums[j-1 ] j -= 1 nums[j] = current return nums
快速排序
平均时间复杂度: O(nlogn)
最佳: O(nlogn)
最坏: O(n^2)
快排的复杂度好坏取决于pivot的选取,而因为一般实现快排时通常选取最左或最有的元素为pivot,因此在这种情况下,当输入数组为完全倒序时时间复杂度最坏,为O(n^2)
Specifically, 当pivot总是为数组正中的元素时,那么时间复杂度最佳,为O(n^2)。 原因在于,如果我们将整个排序过程想成一颗树的话,那么此时递归树是平衡的,也即深度最小。 但若对完全反序的数组排序,那么递归树则为完全倾斜的,即深度最高,递归次数最多。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 def quick_sort (nums: List ) -> List : """ 快排的关键即为pivot找位置 pivot: 可以选取任意元素为pivot pivot左侧皆比其小,右侧皆比其大 为pivot选好位置后,即可将pivot左右两侧的数据分为两个数组(pivot无需再包括在其中) 然后对两个sub arrays 进行同样操作即可 注意理解quick_sort_recursive的终止条件 e.g. [1,3,2] ==> pivot = index1 ==> left array = [1], right array = [2] 以[1] 为例, 此时[1] 调用partition后返回的pivot index 为0, 此时再传参至下列代码时 quick_sort_recursive(left, pivot_index - 1, li) quick_sort_recursive(pivot_index + 1, right, li) left = 0, right = -1; left = 1, right = 0 触发终止条件 """ def partition (left, right, li ): pivot = li[left] while left < right: while left < right and li[right] >= pivot: right -= 1 li[left] = li[right] while left < right and li[left] <= pivot: left += 1 li[right] = li[left] li[left] = pivot return left def quick_sort_recursive (left, right, li ): if left >= right: return pivot_index = partition(left, right, li) quick_sort_recursive(left, pivot_index - 1 , li) quick_sort_recursive(pivot_index + 1 , right, li) left = 0 right = len (nums) - 1 quick_sort_recursive(left, right, nums) return nums
归并排序
时间复杂度总是O(nlogn)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 def merge_sort (nums ) -> List : """ 归并即先将数组持续对半分,直到分解成单个元素 再依次进行merge 典型的分治思想 """ def merge (left: List , right: List ): """ 2021-11-29 错误代码: temp_result = [] while len(n1) != 0 and len(n2) != 0: # 错误1.1: en1 = n1.pop(0) en2 = n2.pop(0) if en1 > en2: # 错误1.2: temp_result.append(en2) temp_result.append(en1) else: # 错误1: temp_result.append(en1) temp_result.append(en2) # 错误2 temp_result.append(en1) temp_result.append(en2) return temp_result 错1: 根源在于,在merge时,有可能n1中的元素全部比n2中的小,即n1全部添加进了result以后n2才开始pop 1.1 and 1.2 皆时assume n1, n2 必是轮流进一个, 即n1进一个后n2就进一个 错2: 此处以为添加剩余元素,不可直接append, 这样会使整个列表变成result中的一个元素,即[..., []] 而非 [...,...] """ result = [] while left and right: if left[0 ] < right[0 ]: result.append(left.pop(0 )) else : result.append(right.pop(0 )) result += left result += right return result def merge_sort_entry (nums ): """ merge_sort_entry 本身的函数功能 “仅仅为分解而已!” , 看终止条件的设置即可理解。(写递归代码时需确定: 终止条件,函数功能,递归方程) merge排序的工作是merge function做的 :param nums: :return: """ if len (nums) == 1 : return nums mid = len (nums)//2 left = nums[:mid] right = nums[mid:] return merge(merge_sort_entry(left), merge_sort_entry(right)) if len (nums) <= 1 : return nums return merge_sort_entry(nums)