5.30LeetCode

191 阅读7分钟

33. 搜索旋转排序数组

整数数组 nums 按升序排列,数组中的值 互不相同 。

在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。

给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。

示例 1:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4

示例 2:
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1

示例 3:
输入:nums = [1], target = 0
输出:-1
 

方法一:二分查找

思路和算法

对于有序数组,可以使用二分查找的方法查找元素。

但是这道题中,数组本身不是有序的,进行旋转后只保证了数组的局部是有序的,这还能进行二分查找吗?答案是可以的。

可以发现的是,我们将数组从中间分开成左右两部分的时候,一定有一部分的数组是有序的。拿示例来看,我们从 6 这个位置分开以后数组变成了 [4, 5, 6] 和 [7, 0, 1, 2] 两个部分,其中左边 [4, 5, 6] 这个部分的数组是有序的,其他也是如此。

这启示我们可以在常规二分查找的时候查看当前 mid 为分割位置分割出来的两个部分 [l, mid] 和 [mid + 1, r] 哪个部分是有序的,并根据有序的那个部分确定我们该如何改变二分查找的上下界,因为我们能够根据有序的那部分判断出 target 在不在这个部分:

如果 [l, mid - 1] 是有序数组,且 target 的大小满足 [nums[l],nums[mid])[\textit{nums}[l],\textit{nums}[mid]),则我们应该将搜索范围缩小至 [l, mid - 1],否则在 [mid + 1, r] 中寻找。 如果 [mid, r] 是有序数组,且 target 的大小满足 (nums[mid+1],nums[r]]\textit{nums}[mid+1],\textit{nums}[r]]),则我们应该将搜索范围缩小至 [mid + 1, r],否则在 [l, mid - 1] 中寻找。

class Solution{
	public int search(int[] nums,int target){
		int n = nums.length;
		if(n == 0){
			return -1;
		}
		if(n == 1){
			return nums[0] == target ? 0:-1;
		}
		int l = 0,r = n-1;
		while(l <= r){
			int mid = (l+r)/2;
			if(nums[mid] == target){
				return mid;
			}
			if(nums[0] <= nums[mid]){
				if(nums[0]<=target&&target<nums[mid]){
					r = mid - 1;
				}else{
					l = mid + 1;
				}
			}else{
				if(nums[mid] < target && target <= nums[n-1]){
					l = mid + 1;
				}else{
					r = mid - 1;
				}
			}
		}
		return -1;
	}
}

复杂度分析

时间复杂度:O(logn)O(\log n),其中 nn 为nums \textit{nums} 数组的大小。整个算法时间复杂度即为二分查找的时间复杂度O(logn) O(\log n)

空间复杂度: O(1) 。我们只需要常数级别的空间存放变量。

144. 二叉树的前序遍历

给你二叉树的根节点 root ,返回它节点值的 前序 遍历。

方法一:递归

思路与算法

首先我们需要了解什么是二叉树的前序遍历:按照访问根节点——左子树——右子树的方式遍历这棵树,而在访问左子树或者右子树的时候,我们按照同样的方式遍历,直到遍历完整棵树。因此整个遍历过程天然具有递归的性质,我们可以直接用递归函数来模拟这一过程。

定义 preorder(root) 表示当前遍历到 root 节点的答案。按照定义,我们只要首先将 root 节点的值加入答案,然后递归调用 preorder(root.left) 来遍历 root 节点的左子树,最后递归调用 preorder(root.right) 来遍历 root 节点的右子树即可,递归终止的条件为碰到空节点。

class Solution{
	public List<Integer> preorderTraversal(TreeNode root){
		List<Integer> res = new ArrayList<Integer>();
		preorder(root, res);
		return res;
	}
	public void preorder(TreeNode root,List<Integer>res){
		if(root == null){
			return;
		}
		res.add(root.val);
		preorder(root.left,res);
		preorder(root.right,res);
	}
}

时间复杂度:O(n),其中 n 是二叉树的节点数。每一个节点恰好被遍历一次。

空间复杂度:O(n),为递归过程中栈的开销,平均情况下为 O(logn)O(\log n),最坏情况下树呈现链状,为 O(n)。

方法二:迭代

思路与算法 我们也可以用迭代的方式实现方法一的递归函数,两种方式是等价的,区别在于递归的时候隐式地维护了一个栈,而我们在迭代的时候需要显式地将这个栈模拟出来,其余的实现与细节都相同,具体可以参考下面的代码。

class Solution{
	public List<Integer>preorderTraversal(TreeNode root){
		List<Integer> res = new ArrayList<Integer>();
		if(root == null){
			return res;
		}
		Deque<TreeNode> stack = new LinkedList<TreeNode>();
		TreeNode node = root;
		while(!stack.isEmpty()||node != null){
			while(node != null){
				res.add(node.val);
				stack.push(node);
				node = node.left;
			}
			node = stack.pop();
			node = node.right;
		}
		return res;
	}
}

复杂度分析

时间复杂度:O(n),其中 n 是二叉树的节点数。每一个节点恰好被遍历一次。

空间复杂度:O(n),为迭代过程中显式栈的开销,平均情况下为 O(logn)O(\log n),最坏情况下树呈现链状,为 O(n)。

方法三:Morris 遍历

思路与算法

有一种巧妙的方法可以在线性时间内,只占用常数空间来实现前序遍历。这种方法由 J. H. Morris 在 1979 年的论文「Traversing Binary Trees Simply and Cheaply」中首次提出,因此被称为 Morris 遍历。

Morris 遍历的核心思想是利用树的大量空闲指针,实现空间开销的极限缩减。其前序遍历规则总结如下:

新建临时节点,令该节点为 root;

如果当前节点的左子节点为空,将当前节点加入答案,并遍历当前节点的右子节点;

如果当前节点的左子节点不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点:

如果前驱节点的右子节点为空,将前驱节点的右子节点设置为当前节点。然后将当前节点加入答案,并将前驱节点的右子节点更新为当前节点。当前节点更新为当前节点的左子节点。

如果前驱节点的右子节点为当前节点,将它的右子节点重新设为空。当前节点更新为当前节点的右子节点。

重复步骤 2 和步骤 3,直到遍历结束。

class Solution{
	public <Integer> preorderTraversal(TreeNode root){
		List<Integer> res = new ArrayList<Integer>();
		if(root == null){
			return res;
		}
		TreeNode p1=root,p2=null;
		 while(p1 != null){
			 p2 = p1.left;
			 if(p2!=null){
				 while(p2.right != null &&p2.right != p1){
					 p2 = p2.right;
				 }
				 if(p2.right == null){
					 res.add(p1.val);
					 p2.right = p1;
					 p1 = p1.left;
					 continue;
				 }else{
					 res.add(p1.val);
				 }
				 p1 = p1.right;
			 }
			 return res;
		 }
	}
}

复杂度分析

时间复杂度:O(n),其中 n 是二叉树的节点数。没有左子树的节点只被访问一次,有左子树的节点被访问两次。

空间复杂度:O(1)。只操作已经存在的指针(树的空闲指针),因此只需要常数的额外空间。

718. 最长重复子数组

给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。

输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1]

本题要求我们计算两个数组的最长公共子数组。需要注意到数组长度不超过 1000,且子数组在原数组中连续。

容易想到一个暴力解法,即枚举数组 A 中的起始位置 i 与数组 B 中的起始位置 j,然后计算 A[i:] 与 B[j:] 的最长公共前缀 k。最终答案即为所有的最长公共前缀的最大值。

方法一:动态规划

class Solution{
	public int findLength(int[] A,int[] B){
		int n = A.length, m = B.length;
		int[][] dp = new int[n+1][m+1];
		int ans = 0;
		for(int i=n-1;i>=0;i--){
			for(int j = m-1;j<0;j--){
				dp[i][j]=A[i]==B[j]?dp[i+1]+1:0;
				ans = Math.max(ans,dp[i][j]);
			}
		}
		return ans;
	}
}

复杂度分析

时间复杂度: O(N×M)O(N \times M)

空间复杂度: O(N×M)O(N \times M)

方法二:滑动窗口

class Solution{
	pubilc int findLength(int[] A,int[] B){
		int n = A.length, m =B.length;
		int res = 0;
		for(int i = 0;i<n;i++){
			int len = Math.min(m,n-1);
			int maxlen = maxLength(A,B,i,0,len);
			ret = Math.max(ret,maxlen);
		}
		for(int i = 0;i < n; i++){
			int len = Math.min(n,m-i);
			int maxlen = maxLength(A,B.0,i,len);
			ret = Math.max(ret,maxlen);
		}
		return ret;
	}
	public int maxLength(int[] A,int[] B,int addA,int addB,int len){
		int ret = 0,k = 0;
		for(int i = 0;i<len;i++){){
				k++;
			}else{
				k = 0;
			}
			ret = Math.max(ret,k);
		}
		return ret;
	}
}

复杂度分析

时间复杂度: O((N+M)×min(N,M))O((N + M) \times \min(N, M))

空间复杂度: O(1)。

方法三:二分查找 + 哈希

思路及算法

如果数组 A 和 B 有一个长度为 k 的公共子数组,那么它们一定有长度为 j <= k 的公共子数组。这样我们可以通过二分查找的方法找到最大的 k。

而为了优化时间复杂度,在二分查找的每一步中,我们可以考虑使用哈希的方法来判断数组 A 和 B 中是否存在相同特定长度的子数组。

注意到序列内元素值小于 100 ,我们使用 Rabin-Karp 算法来对序列进行哈希。具体地,我们制定一个素数 base,那么序列 S 的哈希值为:

hash(S)=i=0S1baseS(i+1)×S[i]\mathrm{hash}(S) = \sum_{i=0}^{|S|-1} \textit{base}^{|S|-(i+1)} \times S[i]

形象地说,就是把 S 看成一个类似 base 进制的数(左侧为高位,右侧为低位),它的十进制值就是这个它的哈希值。由于这个值一般会非常大,因此会将它对另一个素数 mod 取模。

当我们要在一个序列 S​ 中算出所有长度为 len 的子序列的哈希值时,我们可以用类似滑动窗口的方法,在线性时间内得到这些子序列的哈希值。例如,如果我们当前得到了 S[0:len] 的哈希值,希望算出 S[1:len+1] 的哈希值时,有下面这个公式:

hash(S[1:len+1])=(hash(S[0:len])baselen1×S[0])×base+S[len]\mathrm{hash}(S[1:len+1]) = (\mathrm{hash}(S[0:len]) - \textit{base}^{len-1} \times S[0]) \times \textit{base} + S[len]

class Solution{
	int mod = 1000000009;
	int base = 113;
	public int findLenght(int[] A,int[] B){
		int left = 1,right = Math.min(A.length,A.length)+1;
		while(left<right){
			int mid = (left + right)>>1;
			if(check(A,B,mid)){
				left = mid +1;
			}else{
				right = mid;
			}
		}
		return left -1;
	}
	public boolean check(int[] A.int[] B,int len){
		long hashA=0;
		for(int i = 0;i < len;i++){
			hashA = ((hashA -A[i - len]*mult %mod+mod)%mod*base+A[i])%mod;
			bucketA.add(hashA);
		}
		long hashB = 0;
		for(int i = 0;i < len; i++){
			hashB = (hashB*base +B[i])%mod;
		}
		if(bucketA.contains(hashB)){
			return true;
		}
		for(int i = len;i<B.length;i++){
			hashB = ((hashB - B[i - len]*mult%mod+mod)%mod*base + B[i])%mod;
			if(bucketA.contains(hashB)){
				return true;
			}
		}
		return false;
	}
	public long qPow(long x, long n){
		long ret = 1;
		while(n != 0){
			if((n&1)!=0){
				ret = ret *x%mod;
			}
			x = x * x % mod;
			n >>= 1;
		}
		return ret;
	}
}

复杂度分析

时间复杂度:O((M+N)log(min(M,N)))O\big((M+N) \log{(\min(M, N))}\big)

空间复杂度:O(N)。

56. 合并区间

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。

示例 1:
输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6]。

示例 2:
输入:intervals = [[1,4],[4,5]]
输出:[[1,5]]
解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。

方法一:排序

思路

如果我们按照区间的左端点排序,那么在排完序的列表中,可以合并的区间一定是连续的。如下图所示,标记为蓝色、黄色和绿色的区间分别可以合并成一个大区间,它们在排完序的列表中是连续的:

算法

我们用数组 merged 存储最终的答案。

首先,我们将列表中的区间按照左端点升序排序。然后我们将第一个区间加入 merged 数组中,并按顺序依次考虑之后的每个区间:

如果当前区间的左端点在数组 merged 中最后一个区间的右端点之后,那么它们不会重合,我们可以直接将这个区间加入数组 merged 的末尾;

否则,它们重合,我们需要用当前区间的右端点更新数组 merged 中最后一个区间的右端点,将其置为二者的较大值。

正确性证明

上述算法的正确性可以用反证法来证明:在排完序后的数组中,两个本应合并的区间没能被合并,那么说明存在这样的三元组 (i,j,k) 以及数组中的三个区间 a[i],a[j],a[k] 满足 i<j<k 并且(a[i],a[k]) (a[i], a[k])可以合并,但(a[i],a[j]) (a[i], a[j])(a[j],a[k])(a[j], a[k]) 不能合并。这说明它们满足下面的不等式:

a[i].end<a[j].start(a[i] 和 a[j] 不能合并)a[j].end<a[k].start(a[j] 和 a[k] 不能合并)a[i].enda[k].start(a[i] 和 a[k] 可以合并)a[i].end < a[j].start \quad (a[i] \text{ 和 } a[j] \text{ 不能合并}) \\ a[j].end < a[k].start \quad (a[j] \text{ 和 } a[k] \text{ 不能合并}) \\ a[i].end \geq a[k].start \quad (a[i] \text{ 和 } a[k] \text{ 可以合并}) \\

我们联立这些不等式(注意还有一个显然的不等式 a[j].starta[j].enda[j].start \leq a[j].end),可以得到:

a[i].end<a[j].starta[j].end<a[k].starta[i].end < a[j].start \leq a[j].end < a[k].start

产生了矛盾!这说明假设是不成立的。因此,所有能够合并的区间都必然是连续的。

class Solution {
    public int[][] merge(int[][] intervals) {
        if (intervals.length == 0) {
            return new int[0][2];
        }
        Arrays.sort(intervals, new Comparator<int[]>() {
            public int compare(int[] interval1, int[] interval2) {
                return interval1[0] - interval2[0];
            }
        });
        List<int[]> merged = new ArrayList<int[]>();
        for (int i = 0; i < intervals.length; ++i) {
            int L = intervals[i][0], R = intervals[i][1];
            if (merged.size() == 0 || merged.get(merged.size() - 1)[1] < L) {
                merged.add(new int[]{L, R});
            } else {
                merged.get(merged.size() - 1)[1] = Math.max(merged.get(merged.size() - 1)[1], R);
            }
        }
        return merged.toArray(new int[merged.size()][]);
    }
}

复杂度分析

时间复杂度:O(nlogn)O(n\log n),其中 n 为区间的数量。除去排序的开销,我们只需要一次线性扫描,所以主要的时间开销是排序的O(nlogn) O(n\log n)

空间复杂度:O(logn)O(\log n),其中 n 为区间的数量。这里计算的是存储答案之外,使用的额外空间。O(logn)O(\log n) 即为排序所需要的空间复杂度。

113. 路径总和 II

给你二叉树的根节点 root 和一个整数目标和 targetSum ,找出所有 从根节点到叶子节点 路径总和等于给定目标和的路径。

叶子节点 是指没有子节点的节点。

捕获.PNG

方法一:深度优先搜索

class Solution {
    List<List<Integer>> ret = new LinkedList<List<Integer>>();
    Deque<Integer> path = new LinkedList<Integer>();

    public List<List<Integer>> pathSum(TreeNode root, int sum) {
        dfs(root, sum);
        return ret;
    }

    public void dfs(TreeNode root, int sum) {
        if (root == null) {
            return;
        }
        path.offerLast(root.val);
        sum -= root.val;
        if (root.left == null && root.right == null && sum == 0) {
            ret.add(new LinkedList<Integer>(path));
        }
        dfs(root.left, sum);
        dfs(root.right, sum);
        path.pollLast();
    }
}

复杂度分析

时间复杂度:O(N2)O(N^2),其中 N 是树的节点数。在最坏情况下,树的上半部分为链状,下半部分为完全二叉树,并且从根节点到每一个叶子节点的路径都符合题目要求。此时,路径的数目为 O(N),并且每一条路径的节点个数也为 O(N),因此要将这些路径全部添加进答案中,时间复杂度为 O(N2)O(N^2)

空间复杂度:O(N),其中 N 是树的节点数。空间复杂度主要取决于栈空间的开销,栈中的元素个数不会超过树的节点数。

方法二:广度优先搜索

class Solution {
    List<List<Integer>> ret = new LinkedList<List<Integer>>();
    Map<TreeNode, TreeNode> map = new HashMap<TreeNode, TreeNode>();

    public List<List<Integer>> pathSum(TreeNode root, int sum) {
        if (root == null) {
            return ret;
        }

        Queue<TreeNode> queueNode = new LinkedList<TreeNode>();
        Queue<Integer> queueSum = new LinkedList<Integer>();
        queueNode.offer(root);
        queueSum.offer(0);

        while (!queueNode.isEmpty()) {
            TreeNode node = queueNode.poll();
            int rec = queueSum.poll() + node.val;

            if (node.left == null && node.right == null) {
                if (rec == sum) {
                    getPath(node);
                }
            } else {
                if (node.left != null) {
                    map.put(node.left, node);
                    queueNode.offer(node.left);
                    queueSum.offer(rec);
                }
                if (node.right != null) {
                    map.put(node.right, node);
                    queueNode.offer(node.right);
                    queueSum.offer(rec);
                }
            }
        }

        return ret;
    }

    public void getPath(TreeNode node) {
        List<Integer> temp = new LinkedList<Integer>();
        while (node != null) {
            temp.add(node.val);
            node = map.get(node);
        }
        Collections.reverse(temp);
        ret.add(new LinkedList<Integer>(temp));
    }
}

复杂度分析

时间复杂度:O(N2)O(N^2),其中 N 是树的节点数。分析思路与方法一相同。

空间复杂度:O(N),其中 NN 是树的节点数。空间复杂度主要取决于哈希表和队列空间的开销,哈希表需要存储除根节点外的每个节点的父节点,队列中的元素个数不会超过树的节点数。