数据结构与算法 — 排序问题(LeetCode 912)
一、插入排序(稳定排序)
基本思想:将一个元素插入到已经排好序的有序表中,从而得到一个新的、元素数增1的有序表。
实现思路:使用双层循环。外层循环是对除了第1个元素之外的所有元素,内层循环是对当前元素前面的有序表进行待插入位置查找,并进行移动。
时间复杂度:O(n^2),这里 n 是元素的总个数。当是对「有序表」做排序时,为最优时间复杂度O(n);当是对「逆序表」做排序时,为最坏时间复杂度O(n^2)。
空间复杂度:O(1)。
class Solution(object): def sortArray(self, nums): len_nums = len(nums); if len_nums==0: retrun []; for i in range(1,len_nums): temp = nums[i]; j = i; while j>0 and nums[j-1]>temp: nums[j] = nums[j-1]; j = j-1; nums[j] = temp; # j记录的是:当前元素nums[i](也即temp)的待插入位置 return nums;
插入排序的优点:在「几乎有序」的数组上表现良好;
在「短数组」上的表现也很好。因为「短数组」的特点是:每个元素离它最终排定的位置都不会太远。因此,在「小区间」内执行排序任务的时候,可以使用
「插入排序」。
二、归并排序(稳定排序)
基本思想:借助额外的空间,将已有序的子序列合并,从而得到完全有序的序列。即,先使每个子序列有序,再使子序列段间有序。
实现思路:递归实现,
1. 把长度为 n 的输入序列,分成长度 n/2 的子序列;
2. 对两个子序列分别使用「归并排序」;
3. 合并所有子序列。
时间复杂度:O(n*log(n)),这里 n 是元素的总个数。该时间复杂度是由数学归纳法推导得到的「T(n) = 2*T(n/2) + 合并时间 = 2*T(n/2) + n = … = n*T(1) + log(n)*n,T(1) = 0」。
空间复杂度:?。
class Solution(object): # 归并排序: 写法1 def sortArray(self, nums): len_nums = len(nums); if len_nums<=1: return nums; mid = len_nums/2; left = self.sortArray(nums[:mid]); right = self.sortArray(nums[mid:]); return self.Merge_sortedArray(left, right); def Merge_sortedArray(self, left, right): res = []; i = 0; j = 0; while i<len(left) and j<len(right): if left[i]<=right[j]: res.append(left[i]); i = i+1; else: res.append(right[j]); j = j+1; res.extend(left[i:]); # 如果用的是.append()就报错;因为当left[i:]为[]时,append方法也会将[]添加到res中。本语句还可写为: res += left[i:] res.extend(right[j:]); # 如果用的是.append()就报错;因为当right[j:]为[]时,append方法也会将[]添加到res中。本语句还可写为: res += right[j:] return res;
归并排序的优化实现:
优化 ①:在分割得到的「小区间」子序列上转而使用「插入排序」,Java 源码里面也有类似这种操作。「小区间」的长度是个超参数,需要测试决定,比如 JDK 的timsort源码中设为7。
优化 ②:全程使用一个临时数组tmp进行「合并两个有序数组」的操作,避免多次创建临时数组和销毁的消耗。
class Solution(object): # 归并排序: 写法2 def sortArray(self, nums): n = len(nums) if n<=1: return nums tmp = [0] * n return self.mergeSort(nums, tmp, 0, n - 1) def mergeSort(self, nums, tmp, l, r): if l >= r: return ; mid = (l + r) // 2 self.mergeSort(nums, tmp, l, mid) self.mergeSort(nums, tmp, mid + 1, r) i, j, pos = l, mid + 1, l while i <= mid and j <= r: if nums[i] <= nums[j]: tmp[pos] = nums[i] i += 1 else: tmp[pos] = nums[j] j += 1 pos += 1 for k in range(i, mid + 1): tmp[pos] = nums[k] pos += 1 for k in range(j, r + 1): tmp[pos] = nums[k] pos += 1 nums[l:r+1] = tmp[l:r+1]; return nums;
时间复杂度:O(n*log(n)),这里 n 是元素的总个数。该时间复杂度是由数学归纳法推导得到的「T(n) = 2*T(n/2) + 合并时间 = 2*T(n/2) + n = … = n*T(1) + log(n)*n,T(1) = 0」。
空间复杂度:O(n)。
三、快速排序(不稳定排序)
基本思想:每一次都只排定一个元素的位置(这个元素会呆在它最终应该呆的位置);然后递归地去排它左边的部分和右边的部分,直到数组有序。
实现思路:递归 + 双指针实现,
1. 选定一个基准元素pivot(通常选数组的第1个元素);
2. 利用元素pivot将数组分为2部分,pivot左边的元素均 <= pivot,pivot右边的元素均 > pivot;
3. 对被元素pivot分出的左、右2部分元素,分别使用「快速排序」。
时间复杂度:O(n*log(n)),这里 n 是元素的总个数。最坏情况下,是O(n^2),即当数组是已排好序的数组时。
空间复杂度:O(log(n)),这里的空间占用主要来自递归函数占用的栈空间。最坏情况下,是O(n),即当数组是已排好序的数组时。
递归函数占用的栈空间是指:当前这轮的递归函数还没有进行完,就先去执行下一个递归函数,那么这个还没进行完的递归函数就需要在栈中保存,方便下一个递归函数调用结
束时,能继续执行这个递归函数。
class Solution(object): def sortArray(self,nums): len_nums = len(nums); return self.quick(nums, 0, len_nums-1); def quick(self, nums, left, right): if left >= right: return nums; pivot = left; i = left; j = right; while i < j: while i < j and nums[j] > nums[pivot]: # 这里的2个while循环的顺序不能颠倒;如果颠倒,那么当nums[left]-nums[right]在未排序前就已经有序时, j = j-1; # i往右多走了一步,j往左少走了一步,会把这段有序序列打乱。 while i < j and nums[i] <= nums[pivot]: i = i+1; nums[i], nums[j] = nums[j], nums[i]; nums[pivot], nums[j] = nums[j], nums[pivot]; # 这里写nums[j]或nums[i]都可 self.quick(nums, left, j - 1); # 这里写j-1或i-1都可 self.quick(nums, j + 1, right); # 这里写j+1或i+1都可 return nums;
四、堆排序(不稳定排序)
基本思想:把未排定的部分构建成一个「堆」,这样就能以 O(log(N)) 的方式选出最大元素。
实现思路:利用循环 + 「堆」的节点性质实现,
1. 对「堆」中第1个非叶子节点开始,从右向左、从下向上地对每个非叶子节点做下沉操作,构建出「最大堆」;
2. 循环实现以下步骤:
1) 把堆顶元素(当前最大)交换到数组末尾;
2) 对新换上来的堆顶元素做下沉操作;
直到堆中无元素可与堆顶元素交换、下沉。
时间复杂度:O(n*log(n)),这里 n 是元素的总个数。具体来说,初始化堆的时间复杂度为O(n),调整堆的时间复杂度为O( log(n!) ) ≈ n*log(n)。
空间复杂度:O(1)。
class Solution(object): def sortArray(self,nums): self.Build_Heap(nums); len_nums = len(nums); i = len_nums-1; while i>=1: nums[0],nums[i] = nums[i], nums[0]; i = i-1; self.siftdown(nums, 0, i); return nums; # 构建堆 def Build_Heap(self, nums): len_nums = len(nums); for i in reversed(range(len_nums//2)): self.siftdown(nums, i, len_nums-1); # 对数组下标为k的元素,做下沉操作(迭代写法) def siftdown(self, nums, k, end): while (2*k+1)<=end: left = 2*k+1; right = left+1; large = left; if right<=end and nums[left]<nums[right]: large = right; if nums[k]<nums[large]: nums[k], nums[large] = nums[large], nums[k]; else: break; k = large; # 对数组下标为k的元素,做下沉操作(递归写法) def siftdown(self, nums, k, end): left = 2*k+1; right = left + 1; large = left; if left <= end: if right <= end and nums[left]<nums[right]: large = right; if nums[large] > nums[k]: nums[k], nums[large] = nums[large], nums[k]; self.siftdown(nums, large, end);
堆的节点性质:
1)从下往上、从右往左数,堆中第一个非叶子节点的数组下标为:n//2,这里n是元素的总个数。
2)假设堆是一棵满二叉树,则这棵树共有H = log2(n+1) 层,共有 H-1 层非叶子节点,其中第 i 层共有2^(i-1)个节点。第 i 层的每个节点做下沉操作时,最多需要的操作次数为 H - i 次。【分析初始化(即创建)堆的时间复杂度时,可用】
3)如何计算调整堆的时间复杂度:假设根节点和排在最后的序号为m的叶子结点交换、并进行调整,那么调整的操作次数 = 原来m结点所在的层数 = log(m+1),这里 m 的取值范围从1 ~ n-1。
浙公网安备 33010602011771号