5.28LeetCode

247 阅读5分钟

199. 二叉树的右视图

给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。

示例:

输入: [1,2,3,null,5,null,4]

输出: [1, 3, 4]

解释:

   1            <---
 /   \
2     3         <---
 \     \
  5     4       <---

方法一:深度优先搜索

我们对树进行深度优先搜索,在搜索过程中,我们总是先访问右子树。那么对于每一层来说,我们在这层见到的第一个结点一定是最右边的结点。 复杂度分析

class Solution {
    public List<Integer> rightSideView(TreeNode root) {
        Map<Integer, Integer> rightmostValueAtDepth = new HashMap<Integer, Integer>();
        int max_depth = -1;

        Stack<TreeNode> nodeStack = new Stack<TreeNode>();
        Stack<Integer> depthStack = new Stack<Integer>();
        nodeStack.push(root);
        depthStack.push(0);

        while (!nodeStack.isEmpty()) {
            TreeNode node = nodeStack.pop();
            int depth = depthStack.pop();

            if (node != null) {
            	// 维护二叉树的最大深度
                max_depth = Math.max(max_depth, depth);

                // 如果不存在对应深度的节点我们才插入
                if (!rightmostValueAtDepth.containsKey(depth)) {
                    rightmostValueAtDepth.put(depth, node.val);
                }

                nodeStack.push(node.left);
                nodeStack.push(node.right);
                depthStack.push(depth + 1);
                depthStack.push(depth + 1);
            }
        }

        List<Integer> rightView = new ArrayList<Integer>();
        for (int depth = 0; depth <= max_depth; depth++) {
            rightView.add(rightmostValueAtDepth.get(depth));
        }

        return rightView;
    }
}

时间复杂度 : O(n)。深度优先搜索最多访问每个结点一次,因此是线性复杂度。

空间复杂度 : O(n)。最坏情况下,栈内会包含接近树高度的结点数量,占用 O(n) 的空间。

方法二:广度优先搜索

思路

我们可以对二叉树进行层次遍历,那么对于每层来说,最右边的结点一定是最后被遍历到的。二叉树的层次遍历可以用广度优先搜索实现。

算法

执行广度优先搜索,左结点排在右结点之前,这样,我们对每一层都从左到右访问。因此,只保留每个深度最后访问的结点,我们就可以在遍历完整棵树后得到每个深度最右的结点。除了将栈改成队列,并去除了rightmost_value_at_depth之前的检查外,算法没有别的改动。

class Solution {
    public List<Integer> rightSideView(TreeNode root) {
        Map<Integer, Integer> rightmostValueAtDepth = new HashMap<Integer, Integer>();
        int max_depth = -1;

        Queue<TreeNode> nodeQueue = new LinkedList<TreeNode>();
        Queue<Integer> depthQueue = new LinkedList<Integer>();
        nodeQueue.add(root);
        depthQueue.add(0);

        while (!nodeQueue.isEmpty()) {
            TreeNode node = nodeQueue.remove();
            int depth = depthQueue.remove();

            if (node != null) {
            	// 维护二叉树的最大深度
                max_depth = Math.max(max_depth, depth);

                // 由于每一层最后一个访问到的节点才是我们要的答案,因此不断更新对应深度的信息即可
                rightmostValueAtDepth.put(depth, node.val);

                nodeQueue.add(node.left);
                nodeQueue.add(node.right);
                depthQueue.add(depth + 1);
                depthQueue.add(depth + 1);
            }
        }

        List<Integer> rightView = new ArrayList<Integer>();
        for (int depth = 0; depth <= max_depth; depth++) {
            rightView.add(rightmostValueAtDepth.get(depth));
        }

        return rightView;
    }
}

时间复杂度 : O(n)。 每个节点最多进队列一次,出队列一次,因此广度优先搜索的复杂度为线性。

空间复杂度 : O(n)。每个节点最多进队列一次,所以队列长度最大不不超过 nn,所以这里的空间代价为 O(n)。

300. 最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:

输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:

输入:nums = [7,7,7,7,7,7,7]
输出:1

方法一:动态规划

思路与算法

定义 dp[i]\textit{dp}[i] 为考虑前 i 个元素,以第 i 个数字结尾的最长上升子序列的长度,注意 nums[i]\textit{nums}[i] 必须被选取。

我们从小到大计算 dp\textit{dp} 数组的值,在计算 dp[i]\textit{dp}[i] 之前,我们已经计算出 dp[0i1]\textit{dp}[0 \ldots i-1]的值,则状态转移方程为:

dp[i]=max(dp[j])+1,其中0j<inum[j]<num[i]\textit{dp}[i] = \max(\textit{dp}[j]) + 1, \text{其中} \, 0 \leq j < i \, \text{且} \, \textit{num}[j]<\textit{num}[i]

即考虑往 dp[0i1]\textit{dp}[0 \ldots i-1]中最长的上升子序列后面再加一个 nums[i]\textit{nums}[i]。由于 dp[j]\textit{dp}[j] 代表 nums[0j]\textit{nums}[0 \ldots j] 中以 nums[j]\textit{nums}[j] 结尾的最长上升子序列,所以如果能从 dp[j]\textit{dp}[j] 这个状态转移过来,那么 nums[i]\textit{nums}[i]必然要大于 nums[j]\textit{nums}[j],才能将 nums[i]\textit{nums}[i] 放在 nums[j]\textit{nums}[j]后面以形成更长的上升子序列。

最后,整个数组的最长上升子序列即所有 dp[i]\textit{dp}[i] 中的最大值。

LISlength=max(dp[i]),其中0i<n\text{LIS}_{\textit{length}}= \max(\textit{dp}[i]), \text{其中} \, 0\leq i < n

class Solution{
	public int lengthOfLIS(int[] nums){
		if(nums.length == 0){
			return 0;
		}
		int[] dp = new int[nums.length];
		dp[0] = 1;
		int maxans = 1;
		for(int i = 1;i < nums.length; i++){
			dp[i] = 1;
                            for(int j = 0;j <i;j++){
					if(nums[i] > nums[j]){
						dp[i] = Math.max(dp[i],dp[j]+1);
					}
				}
				maxans = Math.max(maxans,dp[i]);
			}
			return maxans;
		}
}

时间复杂度:O(n2)O(n^2),其中 n 为数组 nums\textit{nums}的长度。动态规划的状态数为 n,计算状态 dp[i]dp[i] 时,需要 O(n) 的时间遍历 dp[0i1]dp[0 \ldots i-1]的所有状态,所以总时间复杂度为 O(n2)O(n^2)

空间复杂度:O(n),需要额外使用长度为 n 的 dp 数组。

方法二:贪心 + 二分查找

思路与算法

考虑一个简单的贪心,如果我们要使上升子序列尽可能的长,则我们需要让序列上升得尽可能慢,因此我们希望每次在上升子序列最后加上的那个数尽可能的小。

基于上面的贪心思路,我们维护一个数组 d[i] ,表示长度为 i 的最长上升子序列的末尾元素的最小值,用 len 记录目前最长上升子序列的长度,起始时 len 为 1,d[1]=nums[0]。

同时我们可以注意到 d[i] 是关于 i 单调递增的。因为如果 d[j]≥d[i] 且 j < i,我们考虑从长度为 i 的最长上升子序列的末尾删除 i−j 个元素,那么这个序列长度变为 j ,且第 j 个元素 x(末尾元素)必然小于 d[i],也就小于 d[j]。那么我们就找到了一个长度为 j 的最长上升子序列,并且末尾元素比 d[j] 小,从而产生了矛盾。因此数组 d 的单调性得证。

我们依次遍历数组 nums 中的每个元素,并更新数组 d 和 len 的值。如果 nums[i]>d[len] 则更新 len = len + 1,否则在 d[1len]d[1 \ldots len]中找满足 d[i1]<nums[j]<d[i]d[i - 1] < \textit{nums}[j] < d[i]的下标 i,并更新d[i]=nums[j] d[i] = \textit{nums}[j]

根据 d 数组的单调性,我们可以使用二分查找寻找下标 i,优化时间复杂度。

最后整个算法流程为:

设当前已求出的最长上升子序列的长度为 len(初始时为 11),从前往后遍历数组 nums,在遍历到 nums[i] 时:

如果 nums[i]>d[len] ,则直接加入到 dd 数组末尾,并更新 len=len+1;

否则,在 d 数组中二分查找,找到第一个比 nums[i] 小的数 d[k] ,并更新 d[k+1]=nums[i]。

以输入序列 [0,8,4,12,2] 为例:

第一步插入 0,d = [0];

第二步插入 8,d = [0, 8];

第三步插入 4,d = [0, 4];

第四步插入 12,d = [0, 4, 12];

第五步插入 2,d = [0, 2, 12]。

最终得到最大递增子序列长度为 3。

class Solution{
	public int lengthOfLIS(int[] nums){
		int len = 1, n = nums.length;
		if(n == 0){
			return 0;
		}
		int[] d = new int[n+1];
		for(int i = 1; i < n; ++i){
			if(nums[i] > d[len]){
				 d[++len] = nums[i];
			}else{
				int l = 1, r = len, pos = 0;
				while(l<=r){
					int mid = (l+r)>>1;
					if(d[mid]<nums[i]){
						pos = mid;
						l = mid + 1;
					}else{
						r = mid - 1;
					}
				}
				d[pos+1] = nums[i];
			}
		}
		return len;
	}
}

时间复杂度:O(nlogn)。数组 nums 的长度为 n,我们依次用数组中的元素去更新 d 数组,而更新 d 数组时需要进行 O(logn) 的二分搜索,所以总时间复杂度为 O(nlogn)。

空间复杂度:O(n),需要额外使用长度为 n 的 d 数组。

两数相加

给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照 逆序 的方式存储的,并且每个节点只能存储 一位 数字。

请你将两个数相加,并以相同形式返回一个表示和的链表。

你可以假设除了数字 0 之外,这两个数都不会以 0 开头。

var addTwoNumbers = function(l1,l2){
	let head = null,tail = null;
	let carry = 0;
	while(l1||l2){
		const n1 = l1?l1.val:0;
		const n2 = l2?l2.val:0;
		const sum = n1+n2+carry;
		if(!head){
			head = tail = new ListNode(sum%10);
		}else{
			tail.next = new ListNode(sum%10);
			tail = tail.next;
		}
		carry = Math.floor(sum/10);
		if(l1){
			l1 = l1.next;
		}
		if(l2){
			l2 = l2.next;
		}
	}
	if(carry > 0){
		tail.next = new ListNode(carry);
	}
	return head;
};

复杂度分析

时间复杂度:O(max(m,n)),其中 m 和 n 分别为两个链表的长度。我们要遍历两个链表的全部位置,而处理每个位置只需要 O(1) 的时间。

空间复杂度:O(1)。注意返回值不计入空间复杂度。

151. 翻转字符串里的单词

给你一个字符串 s ,逐个翻转字符串中的所有 单词 。

单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。

请你返回一个翻转 s 中单词顺序并用单个空格相连的字符串。

说明:

输入字符串 s 可以在前面、后面或者单词间包含多余的空格。 翻转后单词间应当仅用一个空格分隔。 翻转后的字符串中不应包含额外的空格。

示例 1:
输入:s = "the sky is blue"
输出:"blue is sky the"

示例 2:
输入:s = "  hello world  "
输出:"world hello"
解释:输入字符串可以在前面或者后面包含多余的空格,但是翻转后的字符不能包括。

示例 3:
输入:s = "a good   example"
输出:"example good a"
解释:如果两个单词间有多余的空格,将翻转后单词间的空格减少到只含一个。

示例 4:
输入:s = "  Bob    Loves  Alice   "
输出:"Alice Loves Bob"

示例 5:
输入:s = "Alice does not even like bob"
输出:"bob like even not does Alice"

方法一:使用语言特性

思路和算法

很多语言对字符串提供了 split(拆分),reverse(翻转)和 join(连接)等方法,因此我们可以简单的调用内置的 API 完成操作:

使用 split 将字符串按空格分割成字符串数组; 使用 reverse 将字符串数组进行反转; 使用 join 方法将字符串数组拼成一个字符串。

var reverseWords = function(s) {
    return s.trim().split(/\s+/).reverse().join(' ');
};

复杂度分析

时间复杂度:O(N),其中 N 为输入字符串的长度。

空间复杂度:O(N),用来存储字符串分割之后的结果。

方法二:自行编写对应的函数

思路和算法

我们也可以不使用语言中的 API,而是自己编写对应的函数。在不同语言中,这些函数实现是不一样的,主要的差别是有些语言的字符串不可变(如 Java 和 Python),有些语言的字符串可变(如 C++)。

对于字符串不可变的语言,首先得把字符串转化成其他可变的数据结构,同时还需要在转化的过程中去除空格。

	public StringBuilder trimSpaces(String s){
		int left = 0,right = s.length()-1;
		//去掉字符串开头空白字符
		while(left <= right &&s.charAt(left)== ' '){
			++left;
		}
		//去掉字符串末尾的空白字符
		while(left <= right && s.charAt(right)==' '){
			--right;
		}
		//去多余字符
		StringBuilder sb = new StringBuilder();
		while(left <= right){
			char c = s.charAt(left);

			if(c !=' '){
				sb.append(c);
			}else if (sb.charAt(sb.length()-1)!=' '){
				sb.append(c);
			}
			++left;
		}
		return sb;
	}
	public void reverse(StringBuilder sb,int left,int right){
		while(left < right){
			char tmp = sb.charAt(left);
			sb.setCharAt(left++;sb.charAt(end)!=' ');
			sb.setCharAt(right--,tmp);
		}
	}
	public void reverseEachWord(StringBuilder sb){
		int n = sb.length();
		int start = 0,end = 0;
		while(start<n){
			//至单词结尾
			while(end <n&&sb.charAt(end)!=' '){
				++end;
			}
			//翻转单词
			reverse(sb,start,end -1);
			start = end+1;
			++end;
		}
	}
}

复杂度分析

时间复杂度:O(N),其中 N 为输入字符串的长度。

空间复杂度:Java 和 Python 的方法需要 O(N) 的空间来存储字符串,而 C++ 方法只需要 O(1) 的额外空间来存放若干变量。

方法三:双端队列

思路和算法

由于双端队列支持从队列头部插入的方法,因此我们可以沿着字符串一个一个单词处理,然后将单词压入队列的头部,再将队列转成字符串即可。

class Solution{
	public String reverseWords(String s){
		int left = 0,right = s.length()-1;
		while(left<=right&&s.charAt(left)==' '){
			++left;
		}
		while(left <= right&&s.charAt(right==' ')){
			--right;
		}
		Deque<String> d = new ArrayDeque<String>();
		StringBuilder word = new StringBuilder();
		while(left <= right){
			char c = s.charAt(left);
			if((word.length()!=0)&&(c==' ')){
				d.offerFirst(word.toString());
				word.setLength(0);
			}else if (c!=' '){
				word.append(c);
			}
			++left;
		}
		d.offerFirst(word.toString());
		return String.join(" ",d);
	}
}

复杂度分析

时间复杂度:O(N),其中 N 为输入字符串的长度。

空间复杂度:O(N),双端队列存储单词需要 O(N) 的空间

8. 字符串转换整数 (atoi)

捕获.PNG

根据问题的描述我们来判断并且描述对应的解题方法。对于这道题目,很明显是字符串的转化问题。需要明确转化规则,尽量根据转化规则编写对应的子函数。

这里我们要进行模式识别,一旦涉及整数的运算,我们需要注意溢出。本题可能产生溢出的步骤在于推入,乘 10 操作和累加操作都可能造成溢出。对于溢出的处理方式通常可以转换为 INT_MAX 的逆操作。比如判断某数乘 10 是否会溢出,那么就把该数和 INT_MAX 除 10 进行比较。

方法一:自动机

思路

字符串处理的题目往往涉及复杂的流程以及条件情况,如果直接上手写程序,一不小心就会写出极其臃肿的代码。

因此,为了有条理地分析每个输入字符的处理方法,我们可以使用自动机这个概念:

我们的程序在每个时刻有一个状态 s,每次从序列中输入一个字符 c,并根据字符 c 转移到下一个状态 s'。这样,我们只需要建立一个覆盖所有情况的从 s 与 c 映射到 s' 的表格即可解决题目中的问题。

捕获.PNG

class Solution{
	public int myAuto(String str){
		Automaton automaton = new Automaton();
		int length = str.length();
		for(int i = 0;i<length;++i){
			automaton.get(str.charAt(i));
		}
		return (int)(automaton.sign*automaton.ans);
	}
}
class Automaton{
	public int sign = 1;
	public long ans = 0;
	private String state = "start":
	private Map<String,String[]>table = new HashMap<String,String[]>(){{
		put("start", new String[]{"start", "signed", "in_number", "end"});
        put("signed", new String[]{"end", "end", "in_number", "end"});
        put("in_number", new String[]{"end", "end", "in_number", "end"});
        put("end", new String[]{"end", "end", "end", "end"});
	}};

	public void get(char c){
		state = table.get(state)[get_col(c)];
		if("in_number".equals(State)){
			ans = ans*10+c-'0';
			ans = sign == 1?Math.min(ans, (long)Integer.MAX_VALUE):Math.min(ans,-(long)Integer.MIN_VALUE);
		}else if ("signed".equals(state)){
			sign = c =='+'?1:-1;
		}
	}
	private int get_col(char c){
		if(c == ' '){
			return 0;
		}
		if(c=='+'||c=='-'){
			return 1;
		}
		if(Character.isDigit(c)){
			return 2;
		}
		return 3;
	}
}

复杂度分析

时间复杂度:O(n),其中 nn 为字符串的长度。我们只需要依次处理所有的字符,处理每个字符需要的时间为 O(1)。

空间复杂度:O(1),自动机的状态只需要常数空间存储。