目录
一、LeetCode 21. 合并两个有序链表
方法一:迭代
代码与性能
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
ListNode p1 = l1;
ListNode p2 = l2;
while(p1 != null && p2 != null){
if(p1.val < p2.val){
p.next = p1;
p1 = p1.next;
}else{
p.next = p2;
p2 = p2.next;
}
p = p.next;
}
p.next = p1 == null ? p2 : p1;
return dummy.next;
}
}
时间复杂度: O(m+n)
空间复杂度: O(1)
拓展:如果要求去重
在l1.val == l2.val
的情况下,只输出一个,然后两个链表同时往下走就好了。
输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,2,3,4]
代码:
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
ListNode p1 = l1;
ListNode p2 = l2;
while(p1 != null && p2 != null){
if(p1.val < p2.val){
p.next = p1;
p1 = p1.next;
}else if(p1.val == p2.val){
p.next = p1;
p1 = p1.next;
p2 = p2.next;
}else{
p.next = p2;
p2 = p2.next;
}
p = p.next;
}
p.next = p1 == null ? p2 : p1;
return dummy.next;
}
}
方法二:递归
代码与性能
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if(list1 == null) return list2;
if(list2 == null) return list1;
if(list1.val < list2.val){
list1.next = mergeTwoLists(list1.next, list2);
return list1;
}else{
list2.next = mergeTwoLists(list1, list2.next);
return list2;
}
}
}
**时间复杂度:**每次递归的操作:去掉 l1 或者 l2 的头节点(直到至少有一个链表为空),函数 mergeTwoList
至多只会递归调用每个节点一次。因此,时间复杂度取决于合并后的链表长度,即 O(n+m)。
**空间复杂度:**结束递归调用时mergeTwoLists
函数最多调用 n+m次,递归需要消耗栈空间,因此空间复杂度为 O(n+m)。
去重版代码
还是同样的思路,改变l1.val == l2.val
时的操作。
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if(list1 == null) return list2;
if(list2 == null) return list1;
if(list1.val < list2.val){
list1.next = mergeTwoLists(list1.next, list2);
return list1;
}else if(list1.val == list2.val){
list1.next = mergeTwoLists(list1.next, list2.next);
return list1;
}else{
list2.next = mergeTwoLists(list1, list2.next);
return list2;
}
}
}
二、LeetCode 23. 合并K个升序链表
方法一:归并
1.递归版
以“合并两个有序链表”为原子操作,和归并排序一样。
注意归并的base case:if(left == right) return lists[left];
,可以不写成left<=right
。
因为和二分不同,归并的分区间操作是left = mid + 1
和right = mid
,没有right = mid - 1
;又因为mid = left + (right - left) / 2
,是向下取整的,所以不会出现left>right
的情况。
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
int n = lists.length;
if(n == 0) return null;
if(n == 1) return lists[0];
return merge(lists, 0, n-1);
}
ListNode merge(ListNode[] lists, int left, int right){
if(left == right) return lists[left];
int mid = left + (right - left) / 2;
ListNode l1 = merge(lists, left, mid);
ListNode l2 = merge(lists, mid+1, right);
return mergeTwoLists(l1, l2);
}
//mergeTwoLists方法代码直接复制上一题
ListNode mergeTwoLists(ListNode l1, ListNode l2);
}
时间复杂度:
和归并排序一个分析思路。设链表的最大长度为n,第一次合并K/2组,每组耗时2n,第二次合并K/4组,每组耗时4n……总共有logK次,每次耗时都是Kn,所以时间复杂度就是O(nK*logK)。其中n为链表最大长度,K为链表条数。
空间复杂度: 主要是递归的栈空间,O(logK)。(注:最好用迭代的mergeTwoLists方法,不会额外占据栈空间)
如果要求去重,只需要把mergeTwoLists
方法改成去重版就行了。
2.迭代版
迭代的实现思想和归并排序类似,但是具体实现很不一样。
第一次遍历结束后,在链表数组的前半段,按顺序存储了K/2组链表合并后的结果。
之后每次循环都是这样,实现详见下面代码:
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
int k = lists.length;
if(k == 0) return null;
if(k == 1) return lists[0];
while(k > 1){
//跟在i后面的指针,i走两下newRight走一下
//为什么叫这个名,因为它在for循环结束后会成为新的右边界(是开的,不是闭的)
int newRight = 0;
for(int i=0; i<k; i+=2){
//如果k是奇数,则最后会单独留下lists[k-1],不合并直接存下来
if(i == k-1){
lists[newRight++] = lists[i];
}else{
lists[newRight++] = mergeTwoLists(lists[i],lists[i+1]);
}
}
//把newRight赋给k,使k成为新的右边界(开)
k = newRight;
}
//最后链表数组lists的第一个元素就是合并完的数组
return lists[0];
}
//mergeTwoLists方法代码直接复制上一题
ListNode mergeTwoLists(ListNode l1, ListNode l2);
}
时间复杂度:
和递归版完全一样,O(nKlogK)。
空间复杂度: 主要是递归的栈空间,O(1)。(注:最好用迭代的mergeTwoLists方法,不会额外占据栈空间)
如果要求去重,只需要把mergeTwoLists
方法改成去重版就行了。
方法二:优先级队列
1.自己实现优先级队列
合并操作与上一题较为类似。
题解中的MinPQ类实现了一个小顶堆。
如果只是用在本题,那么MinPQ类可以没有Comparator<K>
属性。直接通过比较ListNode的val属性来实现more()方法就行。但是为了更加让MinPQ更加通用,我还是选择了模仿JDK的PriorityQueue。MinPQ的通用性就体现在,它的构造器参数中有一个Comparator<K> comparator
,而Comparator<T>
是一个典型的函数式接口(见这篇文章)。所以,在构造一个MinPQ时,你可以传入一个Lambda表达式来代替参数列表中的Comparator<K> comparator
。你可以随意定制Lambda表达式,来改变more()函数的比较机制。比如,在本题的代码中,Lambda表达式是这样的:(node1, node2)->(node1.val - node2.val)
。
MinPQ没有写grow()方法,不能扩容,然而在本题中没有这个必要。
swim、sink、insert、delMin这四个方法是核心,代码实现来自公众号labuladong的文章《图文详解二叉堆,实现优先级队列》。
另外,如果堆从queue[0]就开始存储,那么left、right、parent方法需要改成:
private int parent(int k){
return (k-1) / 2;
}
private int left(int k){
return 2*k + 1;
}
private int right(int k){
return 2*k + 2;
}
以下为完整题解:
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if(lists.length == 0) return null;
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
MinPQ<ListNode> mp = new MinPQ<>(
lists.length, (node1, node2)->(node1.val - node2.val));
//将k个链表的头结点放入最小堆
for(ListNode head : lists){
if(head != null) mp.insert(head);
}
while(!mp.isEmpty()){
//找到并连接下一个节点
ListNode node = mp.delMin();
p.next = node;
//移动指针
if(node.next != null) mp.insert(node.next);
//p指针不断前进
p = p.next;
}
return dummy.next;
}
}
//优先级队列,插入或删除元素时,自动排序,队首变成最大的元素
//底层为小顶堆
//小顶堆:完全二叉树,且每个节点的值都小于等于它左右子节点的值
class MinPQ<K> {
//存储元素的数组
private K[] queue;
//当前堆中的元素个数
private int n;
//堆的最大容量
private int capacity;
//Comparator:典型函数式接口
private Comparator<K> comparator;
//构造器
public MinPQ(int cap, Comparator<K> comparator){
//索引0不用,所以是cap+1
this.queue = (K[]) new Object[cap + 1];
this.n = 0;
this.capacity = cap;
this.comparator = comparator;
}
//返回堆中最小的元素
public K min(){
return queue[1];
}
public boolean isEmpty(){
return n == 0;
}
public void insert(K e){
//如果堆满了,则添加不进去
if(n >= capacity) return;
n++;
queue[n] = e;
swim(n);
}
public K delMin(){
//如果堆空了,则删除不了,返回null
if(n == 0) return null;
//把最小元素换到最后
exch(1,n);
//存下原最小元素的值,然后删除
K min = queue[n];
queue[n] = null;
n--;
sink(1);
return min;
}
private void swim(int k){
while(k > 1 && more(parent(k),k)){
exch(k, parent(k));
k = parent(k);
}
}
private void sink(int k){
while(left(k) <= n){
int smaller = left(k);
if(right(k) <= n && more(left(k),right(k)))
smaller = right(k);
if(more(smaller,k))
break;
exch(k, smaller);
k = smaller;
}
}
//交换pq数组中的两个元素
private void exch(int i, int j){
K temp = queue[i];
queue[i] = queue[j];
queue[j] = temp;
}
//判断pq[i]是否大于pq[j]
private boolean more(int i, int j){
//不能直接用">"
return comparator.compare(queue[i],queue[j]) > 0;
}
//计算父节点和左右子节点的索引值
private int parent(int k){
return k / 2;
}
private int left(int k){
return 2*k;
}
private int right(int k){
return 2*k + 1;
}
}
2.用JDK的PriorityQueue
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if(lists.length == 0) return null;
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
PriorityQueue<ListNode> pq = new PriorityQueue<>(
lists.length, (node1, node2)->(node1.val - node2.val));
//将k个链表的头结点放入最小堆
for(ListNode head : lists){
if(head != null) pq.add(head);
}
while(!pq.isEmpty()){
//找到并连接下一个节点
ListNode node = pq.poll();
p.next = node;
//移动指针
if(node.next != null) pq.add(node.next);
//p指针不断前进
p = p.next;
}
return dummy.next;
}
}
3.性能
时间复杂度:优先级队列中元素最多为K个(链表条数),所以一次delMin或insert的时间复杂度为O(logK)。所有链表节点都会被加入和弹出优先级队列,这部分总共耗时O(NlogK),K为链表条数,N为链表节点总数。其他操作如指针的移动,耗时的数量级都不如加入和弹出操作,所以整体时间复杂度就是O(NlogK)。
空间:多存储一个优先级队列,O(K)。
当然这个不太方便去重,去重的话需要改变堆的机制。