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 的大小满足 ,则我们应该将搜索范围缩小至 [l, mid - 1],否则在 [mid + 1, r] 中寻找。 如果 [mid, r] 是有序数组,且 target 的大小满足 (),则我们应该将搜索范围缩小至 [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;
}
}
复杂度分析
时间复杂度:,其中 nn 为 数组的大小。整个算法时间复杂度即为二分查找的时间复杂度。
空间复杂度: 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(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(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;
}
}
复杂度分析
时间复杂度: 。
空间复杂度: 。
方法二:滑动窗口
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(1)。
方法三:二分查找 + 哈希
思路及算法
如果数组 A 和 B 有一个长度为 k 的公共子数组,那么它们一定有长度为 j <= k 的公共子数组。这样我们可以通过二分查找的方法找到最大的 k。
而为了优化时间复杂度,在二分查找的每一步中,我们可以考虑使用哈希的方法来判断数组 A 和 B 中是否存在相同特定长度的子数组。
注意到序列内元素值小于 100 ,我们使用 Rabin-Karp 算法来对序列进行哈希。具体地,我们制定一个素数 base,那么序列 S 的哈希值为:
形象地说,就是把 S 看成一个类似 base 进制的数(左侧为高位,右侧为低位),它的十进制值就是这个它的哈希值。由于这个值一般会非常大,因此会将它对另一个素数 mod 取模。
当我们要在一个序列 S 中算出所有长度为 len 的子序列的哈希值时,我们可以用类似滑动窗口的方法,在线性时间内得到这些子序列的哈希值。例如,如果我们当前得到了 S[0:len] 的哈希值,希望算出 S[1:len+1] 的哈希值时,有下面这个公式:
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(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 并且可以合并,但和 不能合并。这说明它们满足下面的不等式:
我们联立这些不等式(注意还有一个显然的不等式 ),可以得到:
产生了矛盾!这说明假设是不成立的。因此,所有能够合并的区间都必然是连续的。
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()][]);
}
}
复杂度分析
时间复杂度:,其中 n 为区间的数量。除去排序的开销,我们只需要一次线性扫描,所以主要的时间开销是排序的。
空间复杂度:,其中 n 为区间的数量。这里计算的是存储答案之外,使用的额外空间。 即为排序所需要的空间复杂度。
113. 路径总和 II
给你二叉树的根节点 root 和一个整数目标和 targetSum ,找出所有 从根节点到叶子节点 路径总和等于给定目标和的路径。
叶子节点 是指没有子节点的节点。
方法一:深度优先搜索
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();
}
}
复杂度分析
时间复杂度:,其中 N 是树的节点数。在最坏情况下,树的上半部分为链状,下半部分为完全二叉树,并且从根节点到每一个叶子节点的路径都符合题目要求。此时,路径的数目为 O(N),并且每一条路径的节点个数也为 O(N),因此要将这些路径全部添加进答案中,时间复杂度为 。
空间复杂度: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));
}
}
复杂度分析
时间复杂度:,其中 N 是树的节点数。分析思路与方法一相同。
空间复杂度:O(N),其中 NN 是树的节点数。空间复杂度主要取决于哈希表和队列空间的开销,哈希表需要存储除根节点外的每个节点的父节点,队列中的元素个数不会超过树的节点数。