算法设计与分析实验:堆排序与分治

目录

一、合并K个升序链表

1.1 采用堆排序的思路

1.2 采用优先队列的思路

1.3 采用分治的思路及具体测试

二、数据流中的中位数

​编辑2.1 具体思路

2.2 代码实现

2.3 测试结果

三、数组中的第k个最大元素

3.1 采用分治思路

3.2 采用最小堆

四、 最小K个数

4.1 采用快速排序思路

4.2 采用堆的思想


一、合并K个升序链表

力扣第23题

1.1 采用堆排序的思路

  1. 具体思路

首先,我们遍历链表数组,将每个链表的头节点添加到一个列表中。

创建一个哑节点dummy和一个指针cur,哑节点用于标记合并后的链表的头节点,cur指针用于遍历合并后的链表。

对列表中的节点进行堆排序,使得列表中最小的节点位于堆顶。

当堆不为空时,取出堆顶节点(即最小节点),将其连接到cur指针的后面,并更新cur指针为新加入的节点。

如果该节点还有下一个节点,则将下一个节点加入到堆中。

重复步骤4和步骤5,直到堆为空。

返回哑节点的下一个节点作为合并后的链表的头节点。

(2)流程展示:以[1,4,5],[1,3,4],[2,6]为例

链表数组:[1 -> 4 -> 5, 1 -> 3 -> 4, 2 -> 6]

步骤1 - 遍历链表数组,将每个链表的头节点添加到列表中:

节点列表:[1, 1, 2]

步骤2 - 创建哑节点和指针cur:

dummy -> None

cur -> None

步骤3 - 对节点列表进行堆排序,使得最小节点位于堆顶:

堆:[1, 1, 2]

步骤4 - 取出堆顶节点,连接到cur指针的后面,并更新cur指针:

dummy -> 1 -> None

cur -> 1

步骤5 - 将下一个节点加入到堆中:

堆:[1, 2]

步骤4 - 取出堆顶节点,连接到cur指针的后面,并更新cur指针:

dummy -> 1 -> 1 -> None

cur -> 1

步骤5 - 将下一个节点加入到堆中:

堆:[1]

步骤4 - 取出堆顶节点,连接到cur指针的后面,并更新cur指针:

dummy -> 1 -> 1 -> 2 -> None

cur -> 2

步骤5 - 堆为空,结束。

合并后的链表:dummy -> 1 -> 1 -> 2 -> None

(3)代码实现

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next


def mergeKLists(lists):
    # 创建一个列表存储链表头节点
    nodes = []
    for lst in lists:
        if lst:
            nodes.append(lst)

    # 定义堆排序函数
    def heapify(arr, i, n):
        smallest = i
        left = 2 * i + 1
        right = 2 * i + 2

        if left < n and arr[left].val < arr[smallest].val:
            smallest = left

        if right < n and arr[right].val < arr[smallest].val:
            smallest = right

        if smallest != i:
            arr[i], arr[smallest] = arr[smallest], arr[i]
            heapify(arr, smallest, n)

    # 构建堆
    n = len(nodes)
    for i in range(n // 2 - 1, -1, -1):
        heapify(nodes, i, n)

    # 创建哑节点和指针
    dummy = ListNode(0)
    cur = dummy

    # 合并链表
    while nodes:
        node = nodes[0]
        cur.next = node
        cur = cur.next

        if node.next:
            nodes[0] = node.next
        else:
            nodes[0] = nodes[-1]
            nodes.pop()

        heapify(nodes, 0, len(nodes))

    return dummy.next

# 创建链表
def createLinkedList(nums):
    dummy = ListNode(0)
    cur = dummy
    for num in nums:
        cur.next = ListNode(num)
        cur = cur.next
    return dummy.next

# 打印链表的值
def printLinkedList(head):
    res = []
    cur = head
    while cur:
        res.append(cur.val)
        cur = cur.next
    print(res)

# 测试
lists = [[1,4,5],[1,3,4],[2,6]]
linkedLists = [createLinkedList(lst) for lst in lists]
mergedList = mergeKLists(linkedLists)
printLinkedList(mergedList)

(4)时间/空间复杂度分析

对于给定的链表数组,假设其中一共有k个链表,每个链表的平均长度为n,则该算法的时间复杂度可以分为两部分:

构建堆的时间复杂度:O(k)。

取出最小节点、加入下一个节点并进行堆排序的时间复杂度:假设在列表中一共有m个节点,则每个节点会进出堆一次,所以总共需要进行2m次操作。根据大根堆的性质,堆排序的时间复杂度为O(mlogk)。

所以该算法的总时间复杂度为O(k + mlogk)。

空间复杂度方面,除了存储答案链表的空间外,我们只需要维护一个长度为k的数组来存储每个链表的头节点,所以空间复杂度为O(k)。

需要注意的是,在构建堆的过程中,我们只是将链表的头节点添加到了一个列表中,并没有将链表整个拷贝一遍,所以我们并没有使用额外的O(nk)的空间,而只是使用了O(k)的空间。

1.2 采用优先队列的思路

(1)具体思路

首先,我们需要定义一个优先队列(或最小堆)来存储链表节点。优先队列是一种能够按照节点值大小进行自动排序的数据结构。

遍历链表数组,将每个链表的头节点添加到优先队列中。

创建一个哑节点dummy和一个指针cur,哑节点用于标记合并后的链表的头节点,cur指针用于遍历合并后的链表。

从优先队列中取出节点,将其连接到cur指针的后面,并更新cur指针为新加入的节点。然后,将该节点所在链表的下一个节点(如果有)加入到优先队列中。

重复步骤4,直到优先队列为空。

返回哑节点的下一个节点作为合并后的链表的头节点。

(2)思路简图

在这个简图中,最主要的就是优先队列了。我们可以看到,首先将每个链表的头节点加入优先队列中,并且优先队列会自动进行排序(从小到大)。在遍历优先队列的过程中,每次取出堆顶的最小节点,将其连接到cur指针的后面,并更新cur指针为新加入的节点。然后,将该节点所在链表的下一个节点(如果有)加入到优先队列中。不断地重复这个过程直到优先队列为空,最终返回哑节点的下一个节点作为合并后的链表的头节点。

(3)代码实现

import heapq

# 定义链表节点类
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def mergeKLists(lists):
    # 创建一个哑节点,方便操作
    dummy = ListNode(0)
    cur = dummy

    # 创建一个优先队列,每个元素都是包含当前节点值、链表索引、节点本身的三元组
    pq = []
    for i in range(len(lists)):
        if lists[i]:
            heapq.heappush(pq, (lists[i].val, i, lists[i]))

    # 对优先队列进行循环,每次取出最小值
    while pq:
        val, i, node = heapq.heappop(pq)
        cur.next = node
        cur = cur.next

        if node.next:
            heapq.heappush(pq, (node.next.val, i, node.next))

    # 将合并后的链表值转换为列表形式,并按照题目要求进行排序
    result = []
    while dummy.next:
        result.append(dummy.next.val)
        dummy = dummy.next
    result.sort()

    # 输出合并后的链表值
    print(result)

# 测试代码
lists = [ListNode(1, ListNode(4, ListNode(5))), ListNode(1, ListNode(3, ListNode(4))), ListNode(2, ListNode(6))]
mergeKLists(lists)

(4)复杂度分析

创建优先队列:遍历lists列表,时间复杂度为O(m),其中m是所有链表中节点的总数。

循环取出最小值:最坏情况下每个节点都会被取出一次,时间复杂度为O(mlogk),其中k是链表的数量。

将合并后的链表值转换为列表并排序:遍历合并后的链表,时间复杂度为O(m);排序操作时间复杂度为O(mlogm),其中m为结果列表的长度。

输出合并后的链表值:遍历结果列表,时间复杂度为O(m)。

综上所述,整个代码的时间复杂度为O(mlogk + mlogm)。其中,m是所有链表中节点的总数,k是链表的数量。

空间复杂度方面,除了存储输入链表外,额外使用了一个优先队列和结果列表。所以空间复杂度为O(m + k)。

1.3 采用分治的思路及具体测试

(1)具体思路

将k个有序链表合并为一个有序链表。如果我们直接将它们合并,时间复杂度最坏为O(kN),其中N为所有链表节点数的总和。分治法是一种非常适合解决此类问题的算法。具体来说,对于一个k个有序链表的数组,我们可以将其分成两部分,分别递归地进行排序,然后将两个排好序的部分合并。这样,每次排序后链表数量都会减半,因此,递归的次数为logk级别。

在每次递归中,我们可以通过合并两个有序链表的方式来合并两个子链表。这个过程可以参考归并排序中两个有序列表的合并过程。具体来说,我们可以用两个指针p和q分别指向两个有序链表头部,然后比较这两个链表当前元素的大小,将较小的元素加入到合并后的链表中,并将指针后移。最后,如果其中一个链表为空,就将另一个链表直接加入到合并后的链表中。

(2)思路简图

链表数组:[1 -> 4 -> 5, 1 -> 3 -> 4, 2 -> 6]

在这个简图中,我们使用了分治思想将链表数组划分成两个子问题,每次递归只处理两个子问题。通过不断地递归合并,最终得到了合并后的有序链表。在合并的过程中,我们可以利用归并排序的思想,比较两个链表头部元素的大小,并将较小的元素加入到合并后的链表中。不断地重复这个过程,直到合并完所有的子链表,最终得到了一个有序的合并链表。由于每次递归都会将链表数量减半,所以递归的次数为logk级别,因此整体的时间复杂度为O(Nlogk),其中N为所有链表节点数的总和。

(3)代码实现

# 定义链表节点类
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def mergeTwoLists(l1, l2):
    # 如果其中一个链表为空,直接返回另一个链表
    if not l1:
        return l2
    if not l2:
        return l1

    # 定义哑节点和当前节点指针
    dummy = ListNode(0)
    cur = dummy

    # 比较两个链表当前节点值的大小,将较小的那个加入合并后的链表
    while l1 and l2:
        if l1.val < l2.val:
            cur.next = l1
            l1 = l1.next
        else:
            cur.next = l2
            l2 = l2.next
        cur = cur.next

    # 将剩余的链表节点加入合并后的链表中
    if l1:
        cur.next = l1
    if l2:
        cur.next = l2

    return dummy.next


def mergeKLists(lists):
    # 如果链表数组为空,返回空链表
    if not lists:
        return None

    # 如果链表数组中只有一个链表,直接返回该链表
    if len(lists) == 1:
        return lists[0]

    # 将链表数组分为两个子数组,递归地调用mergeKLists函数
    mid = len(lists) // 2
    left = mergeKLists(lists[:mid])
    right = mergeKLists(lists[mid:])

    # 将分别合并后的两个子链表通过合并两个有序链表的方式合并成一个链表
    return mergeTwoLists(left, right)

# 创建链表
def createLinkedList(nums):
    head = ListNode(0)
    cur = head
    for num in nums:
        cur.next = ListNode(num)
        cur = cur.next
    return head.next

# 打印链表
def printLinkedList(head):
    if not head:
        print("链表为空")
    else:
        node = head
        while node:
            print(node.val, end=" ")
            node = node.next
        print()

# 测试示例
lists = [[1,4,5],[1,3,4],[2,6]]
linkedLists = []
for nums in lists:
    linkedLists.append(createLinkedList(nums))

mergedList = mergeKLists(linkedLists)
printLinkedList(mergedList)

(4)复杂度分析

代码中,mergeTwoLists()函数用于合并两个有序链表,mergeKLists()函数则是实现了上述的分治思想。时间复杂度为O(Nlogk),其中N为所有链表节点数的总和,k为链表数量,空间复杂度为O(logk)。

(5)运行结果(三种思路结果一致)

I 如下,测试子列表有[1,4,5],[1,3,4],[2,6]合并结果如下

II 下面是第二种情况,即子链表为空的情况

二、数据流中的中位数

力扣第160题

2.1 具体思路

可以使用两个堆(优先队列)来实现。

一个最大堆(Max Heap),用于存储较小的一半元素

一个最小堆(Min Heap),用于存储较大的一半元素

具体算法如下:

创建一个最大堆(Max Heap)和一个最小堆(Min Heap)。

当需要往数据结构中添加元素时:

先将元素插入到最大堆。

再从最大堆中取出堆顶元素,并将其插入到最小堆。

如果最小堆的元素个数比最大堆多,再从最小堆中取出堆顶元素,并将其插入到最大堆。

当需要计算中位数时:

如果最大堆和最小堆中元素个数相同,则中位数为两个堆顶元素的平均值。

否则,中位数为最大堆的堆顶元素。

2.思路流程展示

这个图示例展示了一个最大堆和一个最小堆的结构。在这个实例中,最大堆存储了较小的一半元素(1, 2, 3, 4, 5),最小堆存储了较大的一半元素(6, 7, 8, 9, 10, 11, 12)。这种方式可以确保最大堆的堆顶元素是整个数据结构的中位数。

当插入新元素时,先将该元素插入最大堆,然后再考虑平衡操作。如果最小堆的元素个数多于最大堆,就将最小堆的堆顶元素移动到最大堆。这样可以保持两个堆的平衡性,同时确保最大堆的堆顶元素是整个数据结构的中位数。

需要注意的是,对于奇数个元素,最大堆和最小堆的大小会相等,此时中位数为两个堆顶元素的平均值。对于偶数个元素,最大堆的大小比最小堆大1,此时中位数为最大堆的堆顶元素。

这种使用两个堆来实现的方法可以在O(logN)的时间复杂度内插入新元素和计算中位数,其中N为数据结构中元素的个数。

2.2 代码实现

import heapq

class MedianFinder:
    def __init__(self):
        self.max_heap = []  # 存储较小的一半元素(最大堆)
        self.min_heap = []  # 存储较大的一半元素(最小堆)

    def addNum(self, num: int) -> None:
        heapq.heappush(self.max_heap, -num)  # 将元素插入最大堆
        heapq.heappush(self.min_heap, -heapq.heappop(self.max_heap))  # 将最大堆的堆顶元素插入最小堆

        if len(self.min_heap) > len(self.max_heap):
            heapq.heappush(self.max_heap, -heapq.heappop(self.min_heap))  # 如果最小堆的元素个数比最大堆多,将最小堆的堆顶元素插入最大堆

    def findMedian(self) -> float:
        if len(self.max_heap) == len(self.min_heap):
            return (-self.max_heap[0] + self.min_heap[0]) / 2.0  # 中位数为堆顶元素的平均值
        else:
            return -self.max_heap[0]  # 中位数为最大堆的堆顶元素

medianFinder = MedianFinder()
medianFinder.addNum(1)
medianFinder.addNum(2)
print(medianFinder.findMedian())  # 输出 1.5
medianFinder.addNum(3)
print(medianFinder.findMedian())  # 输出 2.0

2.3 复杂度分析

这段代码使用了Python的heapq模块来实现堆的操作,并提供了addNum()和findMedian()两个方法。

对于复杂度分析:

addNum()操作:

对于大顶堆self.small,使用heapq.heappushpop()函数将当前元素-num插入到self.small中,返回的是堆顶元素的相反数,即pop出的最大值。

对于小顶堆self.large,使用heapq.heappushpop()函数将当前元素num插入到self.large中,返回的是堆顶元素,即pop出的最小值。

当两个堆的大小不平衡时,根据奇偶情况选择插入到哪个堆中,并将之前pop出的值插入到另一个堆中。

考虑到heappush()和heappop()都是O(logN)的时间复杂度,因此addNum()操作的总时间复杂度为O(logN)。

findMedian()操作:

如果两个堆的大小相等,取出大顶堆的堆顶元素(负数)和小顶堆的堆顶元素,计算平均值,时间复杂度为O(1)。

如果小顶堆self.large的大小大于大顶堆self.small的大小,直接取出小顶堆的堆顶元素,时间复杂度为O(1)。

因此,findMedian()操作的总时间复杂度为O(1)。

总的空间复杂度为O(N),因为堆中最多存储N/2个元素。

2.3 测试结果

输出如下

三、数组中的第k个最大元素

力扣第215题

3.1 采用分治思路

(1)具体思路

可以将该问题转化为寻找排序后第 n - k 小的元素的问题,因为这两个问题是等价的。对于这种问题,我们可以使用快速选择算法,它与快速排序类似。

具体而言,我们可以先选择一个枢轴值 pivot,并将数组中所有小于 pivot 的元素放在其左侧,大于 pivot 的元素放在其右侧。此时,pivot 本身所处的位置就代表了其在整个数组中的排名。如果 pivot 的排名恰好为 n-k,那么它就是第 k 大的数字了;否则,如果 n-k 在 pivot 的左侧,我们只需要在左半部分寻找第 k 大的数字;如果 n-k 在 pivot 的右侧,我们只需要在右半部分寻找第 k 大的数字。

快速选择算法的时间复杂度是 O(n) 的,也就是说平均情况下只需要线性时间复杂度就能找到第 k 大的数字。

(2)代码实现

from typing import List
import random


class Solution:
    def findKthLargest(self, nums: List[int], k: int) -> int:
        n = len(nums)
        pivot_index = self.partition(nums, 0, n - 1)

        while n - k != pivot_index:
            if n - k < pivot_index:
                pivot_index = self.partition(nums, 0, pivot_index - 1)
            else:
                pivot_index = self.partition(nums, pivot_index + 1, n - 1)

        return nums[pivot_index]

    def partition(self, nums: List[int], left: int, right: int) -> int:
        # 随机选择枢轴元素
        pivot_index = random.randint(left, right)
        pivot_value = nums[pivot_index]
        # 将枢轴元素交换到最右侧
        nums[pivot_index], nums[right] = nums[right], nums[pivot_index]

        # 将比枢轴小的元素交换到左边
        store_index = left
        for i in range(left, right):
            if nums[i] < pivot_value:
                nums[i], nums[store_index] = nums[store_index], nums[i]
                store_index += 1

        # 将枢轴元素放到它最终的位置上
        nums[store_index], nums[right] = nums[right], nums[store_index]

        return store_index
def test_findKthLargest():
    s = Solution()

    nums = [3, 2, 1, 5, 6, 4]
    k = 2
    result = s.findKthLargest(nums, k)
    print(result)

test_findKthLargest()

3.2 采用最小堆

(1)具体思路

创建一个最小堆,并初始化为空。

遍历数组中的元素,逐个将元素加入堆中。

如果堆的大小超过了 k,就弹出堆顶元素,保持堆的大小为 k。

最终,堆顶的元素就是数组中第 k 个最大的元素。

在这个算法中,我们通过维护一个最小堆来实现找到第 k 个最大元素。最小堆的特点是堆顶元素始终是堆中的最小值,当堆的大小限制为 k 时,堆顶元素就是第 k 个最大的元素。

(2)算法实现流程如下:

(3)代码实现

import heapq

def findKthLargest(nums, k):
    heap = nums[:k]
    heapq.heapify(heap)  # 将数组转换为最小堆

    for i in range(k, len(nums)):
        if nums[i] > heap[0]:
            heapq.heappop(heap)  # 弹出堆顶元素
            heapq.heappush(heap, nums[i])  # 将当前元素加入堆中

    return heap[0]  # 返回堆顶元素

# 测试样例
nums = [3, 2, 1, 5, 6, 4]
k = 2
print(findKthLargest(nums, k))  # 输出: 5

nums = [3, 2, 3, 1, 2, 4, 5, 5, 6]
k = 4
print(findKthLargest(nums, k))  # 输出: 4

(3)复杂度分析

这段代码使用了最小堆来找到数组中第 k 个最大的元素。下面是其时间和空间复杂度的分析:

时间复杂度:

初始化最小堆 heap 的时间复杂度为 O(k)。

处理剩余 n-k 个元素的时间复杂度为 O((n-k) log k)。

因此,总时间复杂度为 O(k + (n-k) log k) = O(n log k)。

空间复杂度:

需要一个大小为 k 的最小堆来存储前 k 个最大的元素,因此空间复杂度为 O(k)。

综上所述,这段代码的时间复杂度为 O(n log k),空间复杂度为 O(k)。其中,n 表示数组的长度。对于一个输入数组较大,且 k 比较小的情况,这个算法的时间复杂度比直接对整个数组排序的时间复杂度更优秀。

(4)运行结果(两种思路一样)

四、 最小K个数

力扣第1714题

4.1 采用快速排序思路

(1)具体思路

我们可以使用快速排序的思路来解决这个问题。快速排序中每次划分都会把一个数归位,并返回其下标。如果这个下标比 k-1 小,说明前面已经有 k-1 个数比它小了,我们只需要在它后面继续找即可;如果这个下标比 k-1 大,说明前面的 k-1 个数中必有一部分在它的左边,我们只需要在它左边继续找即可。

I 具体步骤:

定义一个函数 partition,用于对数组进行划分,并返回枢轴元素 pivot 的下标。

在 partition 函数中,选择最右边的元素作为枢轴元素,然后使用双指针法对数组进行划分,使得左边的元素都小于等于 pivot,右边的元素都大于 pivot。

如果 pivot 的下标等于 k-1,说明前 k 个最小的元素就是 arr[:k]。如果 pivot 的下标小于 k-1,则在 pivot 右边继续寻找,否则在 pivot 左边继续寻找。

II 流程展示

假设我们要找到数组中第 k 小的元素,初始时整个数组是无序的。我们选择最右边的元素作为枢轴元素(pivot),然后使用双指针法对数组进行划分。

下面是初始状态的数组示例:

[8, 4, 2, 9, 3, 6, 1, 5, 7]

我们选择最右边的元素 7 作为枢轴元素,并使用双指针法进行划分。划分的过程如下:

指针 i 和指针 j 分别从数组的左右两端开始向中间遍历。当指针 i 找到一个比枢轴元素小或者相等的元素时,就将其与指针 j 所指向的元素交换。这样,所有小于或等于枢轴元素的元素都会被放到左边,大于枢轴元素的元素都会被放到右边。

然后,继续移动指针 i 和指针 j 直到相遇。最后,将枢轴元素放到合适的位置,这里是将枢轴元素与指针 i 所指向的元素交换。交换后的结果如下:

此时,枢轴元素 7 已经归位,并且在它的左边都是比它小的元素,在它的右边都是比它大的元素。

接下来,我们根据枢轴元素的位置与 k 的大小进行判断:

如果枢轴元素的下标正好等于 k-1,那么前 k 个最小的元素就是 arr[:k]。

如果枢轴元素的下标小于 k-1,那么前 k 个最小的元素一定在枢轴元素的右边,我们只需要在右边的子数组上继续进行划分查找即可。

如果枢轴元素的下标大于 k-1,那么前 k 个最小的元素一定在枢轴元素的左边,我们只需要在左边的子数组上继续进行划分查找即可。

这样,通过不断地划分和查找,最终我们就能找到第 k 小的元素。

(2)代码实现

def findKSmallest(arr, k):
    def partition(arr, left, right):
        pivot = arr[right]
        i = left
        for j in range(left, right):
            if arr[j] <= pivot:
                arr[i], arr[j] = arr[j], arr[i]
                i += 1
        arr[i], arr[right] = arr[right], arr[i]
        return i

    left, right = 0, len(arr) - 1
    while True:
        pivot_idx = partition(arr, left, right)
        if pivot_idx == k - 1:
            return sorted(arr[:k])
        elif pivot_idx < k - 1:
            left = pivot_idx + 1
        else:
            right = pivot_idx - 1

# 测试样例
arr = [1, 3, 5, 7, 2, 4, 6, 8]
k = 4
print(findKSmallest(arr, k))  # 输出:[1, 2, 3, 4]

(3)复杂度分析

该算法的时间复杂度为 O(n)O(n),其中 nn 是数组的长度。

在主函数中,分区函数 partition 的时间复杂度为 O(n)O(n)。每次分区后需要通过比较中位数所在位置与 k 的大小关系判断下一步操作,而每次分区在期望情况下可以将数组大小减半,所以最好情况下该算法的时间复杂度为 O(nlog⁡n)O(nlogn),最坏情况下退化为 O(n2)O(n2)。

另外,我们还需要考虑递归调用栈的空间复杂度。在最坏情况下,递归深度可能达到 O(n)O(n),因此空间复杂度也为 O(n)O(n)。

综上所述,该算法的时间复杂度为 O(n)O(n),最坏情况下的空间复杂度为 O(n)O(n)。

4.2 采用堆的思想

(1)具体思路

要找出数组中最小的k个数,可以使用堆排序的思想来解决。具体的步骤如下:

构建一个大小为k的最大堆(Max Heap),堆中的元素为arr数组中的前k个数。

从第k+1个元素开始遍历数组arr,对于每个元素num:

若num小于堆顶元素,则将堆顶元素替换为num,并进行堆调整,保持最大堆的性质。

若num大于或等于堆顶元素,则忽略该元素。

遍历完数组之后,最大堆中的元素就是arr中最小的k个数。

思路简图:

初始数组:[1, 3, 5, 7, 2, 4, 6, 8](k=4)

以上简图展示了整个过程,包括初始数组、构建最大堆和遍历数组替换堆顶元素的过程。最后的最大堆中的元素就是数组中最小的k个数。

(2)代码实现

import heapq


def findKSmallest(arr, k):
    max_heap = []
    # 构建大小为k的最大堆
    for i in range(k):
        heapq.heappush(max_heap, -arr[i])

    # 遍历数组
    for i in range(k, len(arr)):
        num = arr[i]
        if num < -max_heap[0]:
            heapq.heappop(max_heap)
            heapq.heappush(max_heap, -num)

    # 最大堆中的元素即为最小的k个数,取其相反数并逆序返回
    return [-x for x in reversed(max_heap)]


# 测试样例
arr = [1, 3, 5, 7, 2, 4, 6, 8]
k = 4
print(findKSmallest(arr, k))  # 输出:[1, 2, 3, 4]

(3)复杂度分析

这段代码的时间复杂度是O(nlogk),其中n是数组的长度。下面是对代码复杂度的分析:

构建大小为k的最大堆的时间复杂度为O(klogk)。

遍历数组的过程中,对于每个元素,最多需要进行一次堆操作(比较和插入),堆操作的时间复杂度是O(logk)。所以遍历数组的时间复杂度是O(nlogk)。

最后从最大堆中取出k个元素并逆序返回,时间复杂度是O(klogk)。

综上,整个算法的时间复杂度是O(klogk + nlogk + klogk) = O(nlogk)。

空间复杂度方面,算法使用了一个大小为k的最大堆作为辅助空间,因此空间复杂度是O(k)。

总结起来,这段代码在时间复杂度和空间复杂度上都是相对较优的,适用于处理较大规模的数据。

(4)运行结果(两种思路一致)

# 测试样例

arr = [1, 3, 5, 7, 2, 4, 6, 8]    k = 4

2024.1.29  天气:小雨

你可能感兴趣的:(算法分析与设计,算法,最小堆,分治,堆排序)