排序算法中体现出的奇妙思想

排序算法中的奇妙思想

注意:本文的重点是分析排序算法中体现的一些重要是算法思维,旨在掌握排序算法的核心思想,并举一反三,并不是排序算法的入门科普,故不会详细介绍各种算法,但也会给出各种算法的Python实现,本文的结构如下:

  • 十大排序算法总结
  • 选择、冒泡、插入、希尔排序的算法实现
  • 归并、快排、堆排、桶排的详细剖析(奇妙思想的发源地)

一、十大排序算法总结

排序方法平均时间复杂度最优时间复杂度最差时间复杂度空间复杂度稳定性适用情况
冒泡排序O(n2)O(n)O(n2)O(1)稳定序列基本有序或较短
快速排序O(nlogn)O(nlogn)O(n2)O(nlogn)不稳定不在乎内存使用
插入排序O(n2)O(n)O(n2)O(1)稳定序列基本有序或较短
希尔排序O(n2)O(n1.3)O(n2)O(1)不稳定不推荐
选择排序O(n2)O(n2)O(n2)O(1)稳定要求交换次数较少
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定对内存有需求
归并排序O(nlogn)O(nlogn)O(nogn)O(n )稳定追求稳定,不在乎内存
计数排序O(n+k)O(n+k)O(n+k)O(n+k)稳定便于获取数组最值
桶排序O(n+k)O(n2)O(n)O(n+k)稳定便于获取数组最值
基数排序O(n*k)O(n)*lO(n*k)O(n+k)稳定便于获取数组最值

归纳

  1. 选择、冒泡、插入是三个基础的算法,都是稳定排序,其中冒泡和插入的最有时间复杂度是最快的,因此基本有序或较短的时间就没必要追求高级算法了。
  2. 堆排、快排、希尔分别是上述三个算法的进阶版,都是不稳定排序,各有各的特点,需重点掌握。
  3. 归并排序作为一种高级算法,体现的分治思想,需重点掌握。
  4. 计数、桶排、基数排序都是非比较排序算法,都用了哈希思想(利用索引信息),都是稳定排序,但使用场景受限,但它们的算法思想常能巧妙的解决问题,需重点掌握思想。
  5. 黄色标记出的四种算法便是本文要重点分析的四种奇妙思想。

二、选择、冒泡、插入、希尔排序的算法实现

  1. 冒泡排序
def bubble_sort(nums):
# 基本思想:每轮倒序遍历,将最小值移到左侧。
	flag = True # 当上一轮未进行交换,说明已经有序,flag用来实现最优复杂度。
	for i in range(len(nums)):
		if not flag: break
		flag = False
		for j in range(len(nums) - 1, i):
			if nums[j] < nums[j - 1]:
				nums[j], nums[j - 1] = nums[j - 1], nums[j]
				flag = True
  1. 选择排序
def selection_sort(nums):
# 基本思想:每轮将第一个数和后面所有数比较,交换它和后面数中的最小值。
	for i in range(len(nums)):
		mini = i
		for j in range(i + 1, len(nums)):
			if nums[j] < nums[mini]:
				mini = j
		nums[i], nums[mini] = nums[mini], nums[i]
  1. 插入排序
def insert_sort(nums):
# 基本思想:类似扑克牌的抓牌,将每个元素插入到前面已经有序的序列中。
	for i in range(len(nums) - 1):
		if nums[i] > nums[i + 1]:
			# 将nums[i + 1] 插入到 nums[: i+1]中
			temp, j = nums[i + 1], i
			while nums[j] > temp and j >= 0:
				nums[j + 1] = nums[j]
				j -= 1
			nums[j + 1] = temp
  1. 希尔排序
def shell_sort(nums):
# 变间隔(increment)的插入排序
	increment = len(nums)
	while increment > 1:
		increment = increment // 3 + 1
		for i in range(increment, len(nums)):
			if nums[i] < nums[i - increment]:
				temp, j = nums[i], i - increment
				while nums[j] > temp and j >= 0:
					nums[j + increment] = nums[j]
					j -= increment
				nums[j + increment] = temp

三、归并、快排、堆排、桶排的详细剖析

1. 归并排序

  • 算法原理:先将序列两两分割至n个只含一个序列的数列,再两两按序合并。
  • 奇妙思想:分而治之,应用广泛,据此能够总结出分治法递归解法和非递归解法的两套模板,能够应付绝大多数分治问题。
# 递归版本(自顶而下)
def merging_sort_recursion(nums):
	# input: 未排序数组 # output: 排序后数组
	if len(nums) <= 1: return nums
	mid = (left + right) // 2
	l = merging_sort_recursion(nums[left: mid + 1])
	r = merging_sort_recursion(nums[mid + 1: right + 1])
	return merge(l, r)
	
def merge(l,r):
	# input: 两个有序数组 # output: 合并后的有序数组
	if not l: return r
	if not r: return l
	i, j, res = 0, 0, []
	while i < len(l) and j < len(r):
		if l[i] < r[j]:
			res.append(l[i])
			i += 1
		else:
			res.append(r[j])
			j += 1
	for k in range(i, len(l)):
		res.append(l[k])
	for k in range(j, len(r)):
		res.append(r[k])
	return res

据此总结出分治递归版的模板:

def function(x: List[]): # 以输入x是List为例
	if 终止条件(一般是区间长度 <= 1: return res
	# 下面4个是固定写法,拆分x
	left, right = 0, len(x) - 1
	mid = (left + right) // 2
	left_result = function(x[0:mid + 1])
	right_result = function(x[mid + 1: right + 1])
	# 合并左右结果,left_result和right_result就是实现了想要功能的左右子数组
	merge_result = merge(left_result, right_result)
	return merge_result
def merge(left, right):
	# 假设你已经将左右两个子数组实现了想实现的功能
	# 在merge函数里想办法将两个数组合并并实现功能
	return merge_result

这里有几个小TIPS:

  1. 终止条件怎么确定? 一般要么是为空的时候终止,要么是还剩一个的时候终止,可以看这两种情况你想要返回什么,且出现这种情况时候左右端点的相对关系。
  2. 拆分后mid到底属于左边还是右边? 找一个偶数的情况,如长度为4,看划分一次后你想要mid在哪边,那么所有情况下mid就在哪边,如[0,1,2,3], mid = 1, 我想要1分在左边,所以左数组即为nums[ : mid + 1]
  3. 递归调用函数调用的头晕? 写函数前先写上它的输入和输出,再次调用时,就默认实现了这个函数,直接用它的输出即可,不用去想它的内部实现
# 迭代版本(自底向上)
def merging_sort_iteration(nums):
	if not nums: return
	interval = 1
	while interval < len(nums): # 模板
		i = 0 # 遍历整个数组所需的指针
		while i < len(nums): # 遍历整个数组
			# 找出左子数组
			j1, c1 = i, interval # j1代表左子数组的开头,c1记录左子数组长度
			while j1 < len(nums) and c1 > 0:
				j1, c1 = j1 + 1, c1 - 1
			r1 = nums[i: j1]
			if j1 == len(nums): break # 模板,无右子数组直接跳出
			j2, c2 = j1, interval # j2代表右子数组的开头,c2记录右子树组长度
			while j2 < len(nums) and c2 > 0:
				j2, c2 = j2 + 1, c2 - 1
			r2 = nums[j1: j2] 
			nums[i: j2] = merge(r1, r2) # 合并左右子数组,这里要在原数组上操作
			i = j2 # 进入下一对的合并
		interval *= 2

据此总结出分治迭代版的模板:

def function(x: List[]): # 以输入x是List为例
	if not x: return
	interval = 1
	while interval < len(x):
		设置遍历所需变量,如 i
		while 未遍历完(如 i < len(x)):
			# 找r1
			start1, count1 = i, interval
			while start1 < len(x) and count1 > 0:
				start1, count1 = start1 + 1, count1 -1
			r1 = x[i, start1]
			# 无r2直接跳出循环
			if start1 == len(x) [或者写成count1 > 0]: break
			# 找r2
			start2, count1 = start1, interval
			while start2 < len(x) and count2 > 0:
				start2, count2 = start2 + 1, count2 -1
			r2 = x[start1, start2]
			# 合并
			合并r1和r2的结果
			i = start2
		interval *= 2
	return res

可以看出,上面模板中,除了中文字标出的需要根据题意修改,其他都是固定的套路,貌似很复杂的代码实际上都是有规律可循的!
这里要解释一下的就是,有人可能会发现,找r1和找r2的过程,根本不需要用count计数,因为数组长度即为interval, 直接用start + interval就行了!在数组中确实如此,但如果要归并的不是数组,是链表呢?为了使模板通用,这里采用计数的写法。就算在数组中,也能避免区间端点加加减减绕晕人的啦!
如果掌握了上述方法,不妨拿Leetcode练练手:

23. 合并K个排序链表

示例:
输入:
[
  1->4->5,
  1->3->4,
  2->6
]
输出: 1->1->2->3->4->4->5->6

给出答案:

def mergeKLists_recursion(lists):
	if not lists: return None
	if len(lists) == 1: return lists[0]
	left, right = 0, len(lists) - 1
	mid = (left + right) // 2
	l = mergeKLists_recursion(lists[left: mid + 1])
	r = mergeKLists_recursion(lists[mid + 1: right + 1])
	return merge2Lists(l, r)

def mergeKLists_interation(lists):
	if not lists: return
	interval = 1
	while interval < len(lists):
		i = 0
		while i < len(lists):
			h1, j = i, i + interval
			if j >= len(lists): break
			h2, i = j, j + interval
			lists[h1] = merge2Lists(lists[h1], lists[h2])
		interval *= 2
	return lists[0]

def merge2Lists(l1, l2):
	if not l1: return l2
	if not l2: return l1
	if l1.val < l2.val:
		l1.next = merge2Lists(l1.next, l2)
		return l1
	else:
		l2.next = merge2Lists(l2.next, l1)
		return l2

148. 排序链表

在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序。
示例:
输入: 4->2->1->3
输出: 1->2->3->4

给出答案:
对于O(n log n)的时间复杂度,可以存入数组,再快排,但是不满足O(1)的空间复杂度。
由于时间和空间复杂度的双重限制,本题需要采用归并排序的递归版本
此题就可以看出用count计数的必要性。

def sortList(head):
	if not head: return
	n, cur = 0, head
	while cur: cur, n = cur.next, n + 1
	interval, res = 1, ListNode(0)
	res.next = head
	while interval < n:
		pre, cur = res, res.next
		while cur:
			h1, c1 = cur, interval
			while cur and c1 > 0: 
				cur, c1 = cur.next, c1 - 1
			if not cur: break
			h2, c2 = cur, interval
			while cur and c2 > 0:
				cur, c2 = cur.next, c2 - 1
			c1, c2 = interval, interval - c2
			while c1 > 0 and c2 > 0:
				if h1.val < h2.val:
					pre.next, h1, c1 = h1, h1.next, c1 - 1
				else:
					pre.next, h2, c2 = h2, h2.next, c2 - 1
				pre = pre.next
			if c1 > 0: pre.next = h1
			if c2 > 0: pre.next = h2
			while c1 > 0 or c2 > 0: pre, c1, c2 = pre.next, c1 - 1, c2 - 1
			pre.next = cur
		interval *= 2
	return res.next

2. 快速排序

  • 算法原理:通过一趟排序将记录分割成独立的两部分,分割点称作pivot,左边一部分都比pivot小,右边一部分都比pivot大,对左右两部分进行相同分割操作,直至有序,每一趟分割,称作partition。
  • 奇妙思想:partition,作用是按照某个pivot,把数组切分成三部分:左边数字均小于pivot,中间均等于pivot,右边均大于pivot。该思想能够用于解决一切需要按照特定规则切割数组的问题。
partition的步骤:
step1: 
初始化三个区间,三个区间需要满足两个条件:(1)不重不漏; (2)初始时均为空区间
常用:
# [0, left), [left, i), [right, len(x) -1]
# 初始化为left = 0, i = 0, right = len(x)
step2:
循环遍历数组, 停止条件即为i == right, 此时三个区间连成了完整区间。
# while i < right: do sth.
step3:
每个位置元素分三类判断:
(1) x[i] > pivot: 
		swap(x[i], x[right - 1])
		right -= 1
(2) x[i] < pivot:
		swap(x[left], x[i])
		left += 1
		i += 1
(3) x[i] == pivot
		i += 1

以上即为partition操作的模板,如果没有等于区间就缩成两个;且不一定是大于等于操作,例如把奇数放左边,偶数放右边,也可以用partition解决。
据此,可以写出快排的完整代码:

def quick_sort(nums, left, right):
# 输入:待排序数组 # 输出:None,输入数组已排序
	if left < right:
		pivot_index = partition(nums, left, right)
		quick_sort(nums, left, pivot_index - 1)
		quick_sort(nums, pivot_index + 1, right)

def partition(nums, left, right):
# 输入:一个数组 # 输出:按pivot划分后返回pivot的下标
    pivot = nums[left]
    l, i, r = left, left, right + 1
    while i < r:
        if nums[i] > pivot:
            nums[i], nums[r - 1] = nums[r - 1], nums[i]
            r -= 1
        elif nums[i] < pivot:
            nums[i], nums[l] = nums[l], nums[i]
            l += 1
            i += 1
        else:
            i += 1
     # 由于这种partition方法只能保证数组符合要求,但是不能确保i的位置上正好是partition
    for i in range(left, right + 1):
        if nums[i] == pivot:
            return i
    return i

这样的写法显然在要获取pivot的索引时比较麻烦,并且需要多次交换。
下面给出标准的快排中的partition的写法:

def partition(nums, left, right):
# 输入:一个数组 # 输出:按pivot划分后返回pivot的下标
	pivot = nums[left]
	while left < right:
		while left < right and nums[right] >= pivot: right -= 1 # 从右边找第一个小于pivot的值
		nums[left] = nums[right]
		while left < right and nums[left] <= pivot: left += 1 # 从左边找第一个小于pivot的值
		nums[right] = nums[left]
	nums[left] = pivot
	return left

这样的写法通过赋值省去了交换的步骤,但也存在一个问题:必须要保证pivot的值在首位。
事实上,实践证明,用nums[left]作为pivot时,有时候会将简单问题复杂化,达到最坏时间复杂度O(n2),因此,可以通过选取nums[left]、nums[right]、nums[mid]的中位数做pivot的方法,降低复杂度,这样说的时候切记要先把pviot交换到首位。

同样,拿Leetcode练练手:

75. 颜色分类

给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
此题中,我们使用整数 012 分别表示红色、白色和蓝色。
实例:
输入: [2,0,2,1,1,0]
输出: [0,0,1,1,2,2]

给出答案:
很明显是pivot = 1 的partition问题

def sortColors(nums):
	if not nums or len(nums) <= 1: return
	left, i, right = 0, 0, len(nums)
	while i < right:
		if nums[i] == 2:
			nums[i], nums[right - 1] = nums[right - 1], nums[i]
			right -= 1
		elif nums[i] == 0:
			nums[left], nums[i] = nums[i], nums[left]
			left, i = left + 1, i + 1
		else:
			i += 1

3. 堆排序

  • 算法原理:将待排序的序列构造成大顶堆,此时序列的最大值就是对顶的根结点。将它和堆的末尾元素交换,此时末尾元素就是最大值。然后对剩余的n - 1个元素的序列重新构造成堆,重复上述操作,最终可以得到一个有序序列。
  • 奇妙思想:数据结构“堆”,在众多问题上可以作为最优解, 最典型的就是TopK问题。

数据结构“堆”(别名:优先队列)
堆是一棵完全二叉树,具有如下性质:

  1. 每个节点值都大于其左右孩节点的值,称作大顶堆;反之,都小于称为小顶堆。
  2. (完全二叉树的性质)约定最上层根节点的编号从0开始,按照完全二叉树的方式给每个节点编号,则第i个节点的左孩节点为2i+1,右孩节点为2i+2。
  3. 最后一个非叶子节点的编号为len(heap) // 2 - 1。
  4. 由于完全二叉树的带编号性质,堆是可以用数组表示的,最值为nums[0]。
  5. 插入、删除操作是时间复杂度均为O(logk),k为堆的大小。

由于python只支持最小堆,实现最大堆可以通过负最小堆的方法实现,在此,我将介绍一下最小堆的相关函数,并手写一个最大堆。

调用函数实现最小堆:

import heapq
h = [] # 创建一个空堆
heapq.heappush(h, num) # 向h里添加元素
min = heapq.heappop(h) # 弹出并返回堆顶元素
h = [1, 3, 4, 5, 3]
heapq.heapify(h) # 将h调整为最小堆
heapq.heapreplace(h, num) # 弹出堆顶元素并将num加入
heapq.nlargest(k, h) # 返回h中前k大的元素
heapq.nsmallest(k, h) # 返回h中前k小的元素

手写一个最大堆来实现堆排序:

def heapify(nums, root, length):
# 调整以root为根节点的堆,使之符合最大堆要求
	while root < length:
		l_child = root * 2 + 1 # 左孩节点
		r_child = root * 2 + 2 # 右孩节点
		# 比较根、左孩、右孩,找最大值位置
		maxi = root
		if l_child < length:
			maxi = l_child if nums[l_child] > nums[maxi] else maxi
		if r_child < length:
			maxi = r_child if nums[r_child] > nums[maxi] else maxi
		# 如果根节点不是最大值,则需要上移最大值,继续调整子树
		if root != maxi:
			nums[root], nums[maxi] = nums[maxi], nums[root]
			root = maxi
		else: break
		
def generate(nums):
# 将数组构造成一个最大堆
	length = len(nums)
	last_not_leaf = length // 2 - 1 # 最后一个非叶子结点
	# 从最后一个非叶子结点起,从后向前构造最大堆
	for i in range(last_not_leaf, -1, -1):
		heapify(nums, i, length)

def heap_sort(nums):
	# step1: 先把nums改造成最大堆,这样,nums[0]即为最大值
	generate(nums)
	for i in range(len(nums) - 1, -1, -1):
	# step2: 把首尾元素交换,这样最大值便移到了数组尾部
		nums[0], nums[i] = nums[i], nums[0]
	# step3: 调整nums[:-1]符合堆的要求,再进行step1,则第二大的值会移到数组倒数第二位,如此循环
		heapify(nums, 0, i)  

找几道Leetcode上用到“堆”的典型题目:

215. 数组中的第K个最大元素

在未排序的数组中找到第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5

给出答案:
思路一:排序,返回第length - k个数
时间复杂度:O(nlogn), 主要是排序时间

def findKthLargest(nums, k):
	return sorted(nums)[-k]

思路二:partition思想!
结合我们上文讲的partition,仔细想一下,这不就找pivot_index = length - k的位置吗,这样pivot后面的k个数就是都大于它,它就正好是第k大的元素了。
时间复杂度O(n) ~ O(n2)。

def findKthLargest(nums, k):
	left, right = 0, len(nums) - 1
	pivot_index = partition(nums, left, right)
	while pivot_index != len(nums) - k:
		if pivot_index > len(nums) - k:
			right = pivot_index - 1
			pivot_index = partition(nums, left, right)
		else:
			left = pivot_index + 1
			pivot_index = partition(nums, left, right)
	return nums[pivot_index]
	
def partition(nums, left, right):
	pivot = nums[0]
	while left < right:
		while left < right and nums[right] >= pivot: right -= 1
		nums[left] = nums[right]
		while left < right and nums[left] <= pivot: left += 1
		nums[right] = nums[left]
	nums[left] = pivot
	return left

思路三:堆
维护一个大小为k的最小堆,当新来的数小于n的时不作处理,大于n时则删除堆顶元素,把它加入进去。
时间复杂度O(nlogk)。

import heapq

def findKthLargest(nums, k):
	h = []
	for i in range(len(nums)):
		if i < k:
			heapq.heappush(h, nums[i])
		else:
			if nums[i] > h[0]:
				heapq.heapreplace(h, nums[i])
	return h[0]

# 甚至可以一行解决
def findKthLargest(nums, k):
	return heapq.nsmallest(len(nums) - k + 1, nums)[-1]

面试题41. 数据流中的中位数

如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。
例如,
[2,3,4] 的中位数是 3
[2,3] 的中位数是 (2 + 3) / 2 = 2.5
设计一个支持以下两种操作的数据结构:
void addNum(int num) - 从数据流中添加一个整数到数据结构中。
double findMedian() - 返回目前所有元素的中位数。

给出答案:
方案一:用数组存储,每次放入时用二分查找插入。(代码略)
插入时间复杂度O(logn + n), 查找O(1)

奇妙解答:结合使用了最大堆和最小堆, 详细解答可以去看官方题解,有动图很直观~
插入时间复杂度o(logn), 查找O(1)

import heapq

class MedianFinder:
	def __init__(self):
		self.big = []
		self.small = []
	
	def addNum(self, num):
		if len(self.big) == len(self.small):
			heapq.heappush(self.big, -num)
			heapq.heappush(self.small, -heapq.heappop(self.big))
		else:
			heapq.heappush(self.small, num)
			heapq.heappush(self.big, -heapq.heappop(self.small))
		
	def findMedian(self):
		if not self.big or not self.small: return
		if (len(self.big) + len(self.small)) % 2 == 0:
			return (-self.big[0] + self.small[0]) / 2
		return self.small[0]

4. 桶排序

  • 算法原理:

    以nums = [2, 1, 5, 4, 2, 4, 8]为例,如何获得排序后的数组呢?
    step1: 找到nums的最大值 ( = 8 )
    step2: 创建一个长度为8 + 1的空数组temp,初始化为0。
    step3: 遍历nums,设当前遍历到的元素为k, 则将temp中索引为k的位置+1,例如, 当前元素是2,则temp[2] += 1。
    step4:遍历temp, 如果当前value不为0,则循环输出当前index,令value -= 1,知道value为0,继续向前遍历。

			step2:	temp [  index 0 1 2 3 4 5 6 7 8   
							value 0 0 0 0 0 0 0 0 0 ]
							
			step3:  temp [  index 0 1 2 3 4 5 6 7 8   
							value 0 1 2 0 2 1 0 0 1 ]
  • 奇妙思想:桶思想,类似于哈希思想,即赋予数组的索引以意义,在某些问题中强调数组中的数均为0 ~ n之类有固定区间的值时,往往可以用“桶思想”去奇妙地解答。

以LeetCode上的两题为例:

347. 前 K 个高频元素

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
你可以假设给定的 k 总是合理的,且 1 ≤ k ≤ 数组中不相同的元素的个数。
你的算法的时间复杂度必须优于 O(n log n) , n 是数组的大小。
示例: 
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]

给出答案:
思路一: 建立一个哈希表,统计每个词对应的出现频率,再按照频率使用上文的TopK方法求解。

import heapq

def topKFrequent(nums, k):
	dic = {} # key: num, value: frequent of num
	for num in nums:
		if num not in dic.keys(): dic[num] = 1
		else: dic[num] += 1
	h = []
	for key, value in dic.items():
		h.append([value, key])
	return [x[1] for x in heapq.nlargest(k, h)]

思路二:桶思想,桶数组的index代表频率,数组中每位存储出现了相应频率次的数字,注意value也是一个列表,因为可能3和4都出现2次,那么桶数组下标为2的地方应该存放[3, 4]。leetcode上的精选解答有详细的图解哦~

def topKFrequent(nums, k):
	dic = {} # key: num, value: frequent of num
	for num in nums:
		if num not in dic.keys(): dic[num] = 1
		else: dic[num] += 1
	temp = [[] for _ in range(len(nums) + 1)] # 桶数组, 注意python的深拷贝问题,切记不能用[[]] * len的形式!!
	for key in dic.keys():
		temp[dic[key]].append(key)
	res = []
	for i in range(len(temp)):
		if temp[i]: res += temp[i] # 因为temp[i]是个数组,所以用“+”不用append
		if len(res) == k:break
	return res

621. 任务调度器

给定一个用字符数组表示的 CPU 需要执行的任务列表。其中包含使用大写的 A - Z 字母表示的26 种不同种类的任务。
任务可以以任意顺序执行,并且每个任务都可以在 1 个单位时间内执行完。CPU 在任何一个单位时间内都可以执行一个任务,或者在待命状态。
然而,两个相同种类的任务之间必须有长度为 n 的冷却时间,因此至少有连续 n 个单位时间内 CPU 在执行不同的任务,或者在待命状态。
你需要计算完成所有任务所需要的最短时间。
示例:
输入: tasks = ["A","A","A","B","B","B"], n = 2
输出: 8
执行顺序: A -> B -> (待命) -> A -> B -> (待命) -> A -> B.

给出答案
思路: 直观得, 想要(最短时间)完成任务,那么一定是先安排最多的任务,然后在该任务的冷却期内,安排第二多的任务…因此, 可以以n+1(n为冷却期)为一个周期, 每个周期按当前任务数从多到少安排任务,直至最多的任务为0。

方案一:找前n + 1大的元素,把它们的次数-1(想当于均做一次);第二轮再找更新后的次数中前n + 1大的…直到次数最多的任务次数为0,说明全部做完了。

方案一优化:每次找频率前n + 1大的元素,熟不熟悉?不就是上一题吗!OK,用优先队列(堆)可以把复杂度降低。

这两种方法思路貌似很简单,但实际写起来非常麻烦,容易出错,可能是我的coding能力不足…大家可以试试。
排序版本:

def leastInterval(tasks, n):
	toDo = [0] * 26
	for task in tasks:
		toDo[ord(task) - ord('A')] += 1
	toDo.sort()
	time = 0 # 输出结果
	T = n + 1 # 一个周期长度
	while toDO[-1] > 0:
		if toDo[-1] == 0: break # 易错句
		# 做一轮任务
		i = 0
		while i < T:
			if i <= 25 and toDo[25 - i] > 0:
				toDO[25 - i] -= 1
			time, i = time + 1, i + 1
		toDo.sort()
	return time

优先队列版本:

import heapq
def leastInterval(tasks, n):
	todo = [0] * 26
	for task in tasks:
		todo[ord(task) - ord('A')] -= 1
	toDo = [] # 大顶堆, 存放非0任务, 用负的小顶堆模拟
	for t in todo:
		if t < 0: heapq.heappush(toDo, t)
	time = 0 # 输出结果
	T = n + 1 # 一个周期长度
	while toDo:
		i,topK = 0, [] # topK存放当前轮要做的任务
		while i < T:
			if toDo:
				temp = toDo.pop(0)
				if temp < -1: topK.append(temp + 1)
				heapq.heapify(toDo)
			time,i = time + 1, i + 1
			if not toDo and len(topK) == 0: break # 易错句
		for task in topK: heapq.heappush(toDo, task)
	return time

方案二(奇妙技巧):桶排序
把一个周期(n + 1)看成一个桶,那么把次数最多的任务做完要多少个桶?显然是(最多次数 - 1)个桶,这时候还剩下1次任务,那么总次数就是(n + 1) * (最多的次数 - 1) + 1。

那如果有m个任务的次数都是最多的次数呢?显然有1个+1,有m个就+m呗,总次数是(n + 1) * (最多的次数 - 1) + 1。
那剩下的次数较少的任务去哪了?一个桶有(n + 1)的空间,这个任务才用了1个,剩下的往桶里填就完事了,反正在冷却期,并不会影响总次数。

这里面临一个问题,如果桶的空间比较少,装满了还没装完剩下的任务怎么办?这就更好办了,都装满了不就相当于没有冷却期了吗?有多少个任务就要花多长时间就完事了~

因此,最后的结果即为 (n + 1) * (最多的次数 - 1) + m 和 总任务数 的较大值。

注意这里要求的是最短时间,但是结果是两者的较大值,可以仔细思考一下,是较大而不是较小,别弄反了!因为如果存在冷却期的话,总任务数一定是会小于(n + 1) * (最多的次数 - 1) + m 的,会多出冷却期;如果不存在冷却期的话,(n + 1) * (最多的次数 - 1) + m 的算法会有任务没能装进桶里,所有两种情况的最短时间均是较大值!!!

def leastInterval(tasks, n):
	toDo = [0] * 26
	for task in tasks:
		toDo[ord(task) - ord('A')] += 1
	toDo.sort()
	N = toDo[-1] # N为最多的次数
	m = 0 # m为最多的次数的任务的个数
	for i in range(25, -1 , -1):
		if toDo[i] == toDo[-1]: m += 1
		else: break
	s = sum(toDO) # 总任务数
	return max(s, (N - 1) * (n + 1) + m

完结撒花~

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值