浅聊分治算法

155 阅读3分钟

前提概要

谈到分治算法其实思想很简单就是分而治之,而且我们也很容易就联想到这种算法应用的最佳实践案例就是Google的MapReduce,但是当我们遇到真实的场景需要你运用这种算法思想去解决问题时,你可能根本想不到还可以用分治算法。

闲话不多说,下面我们就来看个案例,看看你是否会想到用分治算法的思想?

案例

给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。

链表的结构如下:

public class ListNode {
    int val;
    ListNode next;
    ListNode() {}
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}

解题思路一

要解决把所有升序链表合并,其实先解决两个升序链表的合并就可以。
遍历所有链表,每次用遍历的链表和合并过的链表再合并就可以,所以最终其实就是解决两个链表合并的问题。

解决两个升序链表的合并问题

/**
 * 两个链表合并
 * @param a a
 * @param b b
 * @return ListNode
 */
public ListNode mergeTwoLists(ListNode a, ListNode b) {
    if (a == null || b == null) {
        return a != null ? a : b;
    }
    ListNode head = new ListNode(0);
    ListNode tail = head, aPtr = a, bPtr = b;
    while (aPtr != null && bPtr != null) {
        if (aPtr.val < bPtr.val) {
            tail.next = aPtr;
            aPtr = aPtr.next;
        } else {
            tail.next = bPtr;
            bPtr = bPtr.next;
        }
        tail = tail.next;
    }
    tail.next = aPtr != null ? aPtr : bPtr;
    return head.next;
}

解决所有升序链表的合并问题

public ListNode mergeKLists(ListNode[] lists) {
    ListNode ans = null;
    for (ListNode listNode : lists) {
        ans = mergeTwoLists(ans, listNode);
    }
    return ans;
}

复杂度分析

假设每个链表的最大长度是n, 链表的总个数为k。
在第一次合并后, ans的长度为n;第二次合并后,ans的长度为2*n;第i次合并后,ans的长度是i * n。
那么第 i 次合并的时间复杂度是:O(in)O(i*n)
那么总的时间复杂度为:O(i=1k(in))=O((1+k)k2n)=O(k2n)O(\sum_{i=1}^k(i*n))=O(\frac{(1+k)k}{2}*n)=O(k^2n)

从复杂度分析可知总的时间复杂度是非常高的,链表的个数和链表的长度在实际应用中都是一个可变量很大的因素,所以会导致整个算法的性能较差。

那这个案例跟分治算法有什么关系,怎么用分治算法来解决时间复杂度高的问题?

解题思路二

上面的解题思路有没有发现一个明显的漏洞?

通过遍历所有的升序链表来进行两两合并,会导致其中一个链表在不停的变长,而另一个链表长度始终为n。而我们了解影响算法时间复杂度的因素是由最差的那个因素决定的,既然这样,那我们就可以把原来只对一个链表增加长度,改变为同时增加两个链表的长度,并尽量保持两个链表增长的速度一致,这样就可以解决原来由一个链表决定的时间复杂度,变为两个链表一起决定。

分治算法就是分而治之,把k个链表分成k/2个链表合并,再分成k/4个链表合并,一直往下分直到只剩下一个链表无法再分,总体思路如下:

  • 将k个链表配对,并将同一对中的链表合并。
  • 第一轮合并以后,k个链表被合并成了k/2个链表,平均长度为2n;然后是k/4个链表,k/8个链表等等。
  • 重复这一过程,直到我们得到了最终个有序链表。

image.png

public ListNode mergeKLists2(ListNode[] lists) {
    return merge(lists, 0, lists.length - 1);
}

public ListNode merge(ListNode[] lists, int left, int right) {
    if (left == right) {
        return lists[left];
    }
    if (left > right) {
        return null;
    }
    int mid = (left + right) >> 1;
    return mergeTwoLists(merge(lists, left, mid), merge(lists, mid + 1, right));
}

复杂度分析

基于递归向上回升的过程:
第一轮合并k/2组链表,每一组的时间复杂度为O(2n)
第二轮合并k/4组链表,每一组的时间复杂度为O(4n)
所以总的时间复杂度为O(i=1k2i2in)=O(knlogk)O(\sum_{i=1}^\infty\frac{k}{2^i}*2^in)=O(kn*logk)

总结

对比上面两个解题思路的复杂度

解题思路一的时间复杂度为:O(k2n)O(k^2n)
解题思路二的时间复杂度为:O(klogkn)O(k*logk*n)

链表长度n对两种解题思路的影响是一样的,主要的不同点在合并的思路上,一个使用贪心算法的思路合并,另一个使用分治算法的思路合并,从而导致了两种算法性能上的差异。这个案例映射到排序算法的思路上,其实就是我们在大学里面经常讲到的冒泡排序和归并排序两种算法的思路。

分治算法的核心思想其实就是四个字,分而治之,也就是将原问题划分成n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。

这个算法的定义和递归的定义类似,分治算法是一种处理问题的思想,递归一种编程的技巧。实际上,分治算法一般都比较合适用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作:

  • 分解:将原问题分解成一系列子问题
  • 解决:递归地求解各个子问题,若子问题足够小,则直接求解
  • 合并:将子问题的结果合并成原问题

分治算法能解决的问题,一般需要满足如下条件:

  • 原问题与分解成的小问题具有相同的模式
  • 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法和动态规则算法的明显区别
  • 具有分解终止条件,也就是说,当问题足够小时,可以直接求解
  • 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法复杂度的效果了