425.LeetCode

165 阅读5分钟

排序数组

方法一:快速排序

class Solution{
	public int[] sortArray(int[] nums){
		randomizedQuicksort(nums,0,nums.length - 1);
		return nums;
	}

	public void randomizedQuicksort(int[] nums,int l,int r){
		if(l < r){
			int pos = randomizedPartition(nums,l,r);
			randomizedQuicksort(nums,l,pos - 1);
			randomizedQuicksort(nums,pos - 1,r);
		}
	}

	public int randomizedPartition(int[] nums,int l,int r){
		int i = new Random().nextInt(r - l + 1) + l;
		swap(nums,r,i);
		return partition(nums,l,r);
	}

	public int partition(int[] nums,int l, int r){
		int pivot = nums[r];
		int i = l - 1;
		for(int j = l;j<=r - 1;++j){
			if(nums[j] <= pivot){
				i = i+1;
				swap(nums,i,j);
			}
		}
		swap(nums,i+1,r);
		return i+1;
	}

	private void swap(int[] nums, int i,int j){
		int temp = nums[i];
		nums[i] = nums[j];
		nums[j] = temp;
	}
}

时间复杂度:基于随机选取主元的快速排序时间复杂度为期望O(nlogn) O(n\log n),其中 nn为数组的长度。详细证明过程可以见《算法导论》第七章,这里不再大篇幅赘述。

空间复杂度:O(h)O(h),其中 hh 为快速排序递归调用的层数。我们需要额外的 O(h)O(h) 的递归调用的栈空间,由于划分的结果不同导致了快速排序递归调用的层数也会不同,最坏情况下需 O(n)O(n) 的空间,最优情况下每次都平衡,此时整个递归树高度为 logn\log n,空间复杂度为 O(logn)O(\log n)

堆排序

class Solution{
	public int[] sortArray(int[] nums){
		heapSort(nums);
		return nums;
	}

	public void heapSort(int[] nums){
		int len = nums.length - 1;
		buildMaxHeap(nums,len);
		for(int i = len; i>=1; --i){
			swap(nums,i,0);
			len -= 1;
			maxHeapify(nums,0,len);
		}
	}

	public void buildMaxHeap(int[] nums, int len){
		for(int i = len/2;i>=0;--i){
			maxHeapify(nums,i,len);
		}
	}

	public void maxHeapify(int[] nums,int i,int len){
		for(,(i*2)+1<=len;){
			int lson = (i*2)+1;
			int rson = (i*2)+2;
			int large;
			if(lson<=len&&nums[lson]>nums[i]){
				large = lson;
			}else{
				large = i;
			}
			if(rson <= len&&nums[rson]>nums[large]){
				large = rson;
			}
			if(large != i){
				swap(nums,i,large);
				i = large;
			}else{
				break;
			}
		}
	}

	private void swap(int[] nums, int i,int j){
		int temp = nums[i];
		nums[i] = nums[j];
		nums[j] = temp;
	}
}

时间复杂度:O(nlogn)O(n\log n)。初始化建堆的时间复杂度为 O(n)O(n),建完堆以后需要进行 n1n-1 次调整,一次调整(即 maxHeapify) 的时间复杂度为 O(logn)O(\log n),那么 n1n-1次调整即需要 O(nlogn)O(n\log n) 的时间复杂度。因此,总时间复杂度为 O(n+nlogn)=O(nlogn)O(n+n\log n)=O(n\log n)

空间复杂度:O(1)O(1)。只需要常数的空间存放若干变量。

归并排序

归并排序利用了分治的思想来对序列进行排序。对一个长为 nn 的待排序的序列,我们将其分解成两个长度为 n2\frac{n}{2} 的子序列。每次先递归调用函数使两个子序列有序,然后我们再线性合并两个有序的子序列使整个序列有序。

class Solution{
	int[] tmp;
	public int[] sortArray(int[] nums){
		tmp = new int[nums.length];
		mergeSort(nums,0,nums.length -1);
		return nums;
	}

	public void mergeSort(int[] nums,int l,int r){
		if(l>=r){
			return;
		}
		int mid = (l+r)/2;
		mergeSort(nums,l,mid);
		mergeSort(nums,mid+1,r);
		int i = l,j = mid + 1;
		int cnt = 0;
		while(i <= mid && j<=r){
			if(nums[i]<=nums[j]){
				tmp[cnt++]=nums[i++];
			}else{
				tmp[cnt++]=nums[j++];
			}
		}
		while(i <= mid){
			tmp[cnt++] = nums[i++];
		}
		while(j<=r){
			tmp[cnt++] = nums[j++];
		}
		for(int k = 0;k < r-l + 1;++k){
			nums[k +l] = tmp[k];
		}
	}
}

时间复杂度:O(nlogn)O(n\log n)。由于归并排序每次都将当前待排序的序列折半成两个子序列递归调用,然后再合并两个有序的子序列,而每次合并两个有序的子序列需要 O(n)O(n)的时间复杂度,所以我们可以列出归并排序运行时间 T(n)T(n) 的递归表达式:

T(n)=2T(n2)+O(n)T(n)=2T(\frac{n}{2})+O(n)

根据主定理我们可以得出归并排序的时间复杂度为 O(nlogn)O(n\log n)

空间复杂度:O(n)O(n)。我们需要额外 O(n)O(n) 空间的 tmp\textit{tmp} 数组,且归并排序递归调用的层数最深为 log2\log_2 ,所以我们还需要额外的 O(logn)O(\log n ) 的栈空间,所需的空间复杂度即为 O(n+logn)=O(n)O(n+\log n) = O(n)

三数之和

方法一:排序 + 双指针

然而它是很容易继续优化的,可以发现,如果我们固定了前两重循环枚举到的元素 aabb,那么只有唯一的 cc 满足 a+b+c=0a+b+c=0。当第二重循环往后枚举一个元素b b' 时,由于b>b b' > b,那么满足a+b+c=0 a+b'+c'=0c c'一定有 c<cc' < ccc'在数组中一定出现在 cc 的左侧。也就是说,我们可以从小到大枚举 bb,同时从大到小枚举 cc,即第二重循环和第三重循环实际上是并列的关系。有了这样的发现,我们就可以保持第二重循环不变,而将第三重循环变成一个从数组最右端开始向左移动的指针

这个方法就是我们常说的「双指针」,当我们需要枚举数组中的两个元素时,如果我们发现随着第一个元素的递增,第二个元素是递减的,那么就可以使用双指针的方法,将枚举的时间复杂度从 O(N2)O(N^2)减少至 O(N)O(N)

public List<List<Integer>> threeSum(int[] nums) {
        int n = nums.length;
        Arrays.sort(nums);
        List<List<Integer>> ans = new ArrayList<List<Integer>>();
        // 枚举 a
        for (int first = 0; first < n; ++first) {
            // 需要和上一次枚举的数不相同
            if (first > 0 && nums[first] == nums[first - 1]) {
                continue;
            }
            // c 对应的指针初始指向数组的最右端
            int third = n - 1;
            int target = -nums[first];
            // 枚举 b
            for (int second = first + 1; second < n; ++second) {
                // 需要和上一次枚举的数不相同
                if (second > first + 1 && nums[second] == nums[second - 1]) {
                    continue;
                }
                // 需要保证 b 的指针在 c 的指针的左侧
                while (second < third && nums[second] + nums[third] > target) {
                    --third;
                }
                // 如果指针重合,随着 b 后续的增加
                // 就不会有满足 a+b+c=0 并且 b<c 的 c 了,可以退出循环
                if (second == third) {
                    break;
                }
                if (nums[second] + nums[third] == target) {
                    List<Integer> list = new ArrayList<Integer>();
                    list.add(nums[first]);
                    list.add(nums[second]);
                    list.add(nums[third]);
                    ans.add(list);
                }
            }
        }
        return ans;
    }

时间复杂度:O(N2)O(N^2),其中 NN是数组 nums\textit{nums} 的长度。

空间复杂度:O(logN)O(\log N)。我们忽略存储答案的空间,额外的排序的空间复杂度为 O(logN)O(\log N)。然而我们修改了输入的数组 nums\textit{nums},在实际情况下不一定允许,因此也可以看成使用了一个额外的数组存储了 nums\textit{nums} 的副本并进行排序,空间复杂度为 O(N)O(N)

二叉树的层序遍历

广度优先搜索

我们可以想到最朴素的方法是用一个二元组 (node, level) 来表示状态,它表示某个节点和它所在的层数,每个新进队列的节点的 level 值都是父亲节点的 level 值加一。最后根据每个点的 level 对点进行分类,分类的时候我们可以利用哈希表,维护一个以 level 为键,对应节点值组成的数组为值,广度优先搜索结束以后按键 level 从小到大取出所有值,组成答案返回即可。

考虑如何优化空间开销:如何不用哈希映射,并且只用一个变量 node 表示状态,实现这个功能呢?

我们可以用一种巧妙的方法修改广度优先搜索:

首先根元素入队 当队列不为空的时候 求当前队列的长度 sis_i依次从队列中取si s_i个元素进行拓展,然后进入下一次迭代 它和普通广度优先搜索的区别在于,普通广度优先搜索每次只取一个元素拓展,而这里每次取 sis_i 个元素。在上述过程中的第 ii 次迭代就得到了二叉树的第 ii 层的 sis_i个元素。

为什么这么做是对的呢?我们观察这个算法,可以归纳出这样的循环不变式:第 ii 次迭代前,队列中的所有元素就是第 ii 层的所有元素,并且按照从左向右的顺序排列。证明它的三条性质(你也可以把它理解成数学归纳法):

初始化:i=1i = 1的时候,队列里面只有 root,是唯一的层数为1 1 的元素,因为只有一个元素,所以也显然满足「从左向右排列」;

保持:如果 i=ki = k时性质成立,即第k k轮中出队 sks_k的元素是第 kk层的所有元素,并且顺序从左到右。因为对树进行广度优先搜索的时候由低 kk 层的点拓展出的点一定也只能是k+1 k + 1 层的点,并且 k+1k + 1 层的点只能由第 kk 层的点拓展到,所以由这 sks_k个点能拓展到下一层所有的sk+1s_{k+1}个点。又因为队列的先进先出(FIFO)特性,既然第 kk 层的点的出队顺序是从左向右,那么第 k+1k + 1层也一定是从左向右。至此,我们已经可以通过数学归纳法证明循环不变式的正确性。

终止:因为该循环不变式是正确的,所以按照这个方法迭代之后每次迭代得到的也就是当前层的层次遍历结果。至此,我们证明了算法是正确的。

class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> ret = new ArrayList<List<Integer>>();
        if (root == null) {
            return ret;
        }

        Queue<TreeNode> queue = new LinkedList<TreeNode>();
        queue.offer(root);
        while (!queue.isEmpty()) {
            List<Integer> level = new ArrayList<Integer>();
            int currentLevelSize = queue.size();
            for (int i = 1; i <= currentLevelSize; ++i) {
                TreeNode node = queue.poll();
                level.add(node.val);
                if (node.left != null) {
                    queue.offer(node.left);
                }
                if (node.right != null) {
                    queue.offer(node.right);
                }
            }
            ret.add(level);
        }
        
        return ret;
    }
}

记树上所有节点的个数为 nn。 时间复杂度:每个点进队出队各一次,故渐进时间复杂度为O(n) O(n)。 空间复杂度:队列中元素的个数不超过 nn 个,故渐进空间复杂度为 O(n)O(n)