链表(LinkedList):
003(06)-从尾到头打印链表
==要求:==输入一个链表,按链表从尾到头的顺序返回一个ArrayList。
方法:栈
栈的特点是后进先出,即最后压入栈的元素最先弹出。考虑到栈的这一特点,使用栈将链表元素顺序倒置。从链表的头节点开始,依次将每个节点压入栈内,然后依次弹出栈内的元素并存储到数组中。
创建一个栈,用于存储链表的节点
创建一个指针,初始时指向链表的头节点
当指针指向的元素非空时,重复下列操作:
将指针指向的节点压入栈内
将指针移到当前节点的下一个节点
获得栈的大小 size,创建一个数组 print,其大小为 size
创建下标并初始化 index = 0
重复 size 次下列操作:
从栈内弹出一个节点,将该节点的值存到 print[index]
将 index 的值加 1
返回 print
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/
class Solution {
public int[] reversePrint(ListNode head) {
Stack<ListNode> stack = new Stack<ListNode>();
ListNode temp = head;
while (temp != null) {
stack.push(temp);
temp = temp.next;
}
int size = stack.size();
int[] print = new int[size];
for (int i = 0; i < size; i++) {
print[i] = stack.pop().val;
}
return print;
}
}
014(22)-链表中倒数第k个结点
==要求:==输入一个链表,输出该链表中倒数第k个节点。为了符合大多数人的习惯,本题从1开始计数,即链表的尾节点是倒数第1个节点。例如,一个链表有6个节点,从头节点开始,它们的值依次是1、2、3、4、5、6。这个链表的倒数第3个节点是值为4的节点。
示例:
给定一个链表: 1->2->3->4->5, 和 k = 2.
返回链表 4->5.==
解题思路:
使用双指针则可以不用统计链表长度。
算法流程:
1.初始化: 前指针 former 、后指针 latter ,双指针都指向头节点 head 。
2.构建双指针距离: 前指针 former 先向前走 k 步(结束后,双指针 former 和 latter 间相距 k 步)。
3.双指针共同移动: 循环中,双指针 former 和 latter 每轮都向前走一步,直至 former 走过链表尾节点时 跳出(跳出后, latter 与尾节点距离为 k-1,即 latter 指向倒数第 k 个节点)。
4.返回值: 返回 latter 即可。
复杂度分析:
时间复杂度 O(N) : N 为链表长度;总体看, former 走了 N 步, latter 走了 (N−k) 步。
空间复杂度 O(1) : 双指针 former , latter 使用常数大小的额外空间。
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/
class Solution {
public ListNode getKthFromEnd(ListNode head, int k) {
ListNode former = head,latter = head;
for(int i=0;i<k;i++){
if(former == null) return null;
former = former.next;
}
while(former != null){
former = former.next;
latter = latter.next;
}
return latter;
}
}
015(24)-反转链表
==要求:==定义一个函数,输入一个链表的头节点,反转该链表并输出反转后链表的头节点。
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL==
方法一:双指针
定义两个指针: pre 和 cur ;pre 在前 cur 在后。
每次让 pre 的 next 指向 cur ,实现一次局部反转
局部反转完成之后, pre 和 cur 同时往前移动一个位置
循环上述过程,直至 pre 到达链表尾部
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/
class Solution {
public ListNode reverseList(ListNode head) {
ListNode cur = null,pre = head;
while(pre != null){
ListNode t = pre.next;
pre.next = cur;
cur = pre;
pre = t;
}
return cur;
}
}
方法二:递归
使用递归函数,一直递归到链表的最后一个结点,该结点就是反转后的头结点,记作 ret .
此后,每次函数在返回的过程中,让当前结点的下一个结点的 next 指针指向当前节点。
同时让当前结点的 next 指针指向 NULL ,从而实现从链表尾部开始的局部反转
当递归函数全部出栈后,链表反转完成。
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/
class Solution {
public ListNode reverseList(ListNode head) {
if(head == null || head.next == null){
return head;
}
ListNode ret = reverseList(head.next);
head.next.next = head;
head.next = null;
return ret;
}
}
016(25)-合并两个或k个有序链表
==要求:==输入两个递增排序的链表,合并这两个链表并使新链表中的节点仍然是递增排序的。
示例1:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4
解题思路:
整体思路: 根据题目描述, 链表 l1, l2 是递增的,因此容易想到使用 l1 和 l2 遍历两链表,根据 l1.val 和 l2.val 的大小关系确定节点添加顺序,两节点指针交替前进,直至遍历完毕。
循环启动问题: 由于初始状态合并链表中并无节点,因此循环第一轮时无法将节点添加到合并链表中。通常 有以下两种解决方案:
先确认头节点: 比较 l1.val, l2.val 的大小关系,先确定合并链表的头节点,循环开始将各节点添加至此节点 后。
引入伪头节点: 初始化一个辅助节点作为合并链表的头节点,循环开始将各节点添加至此辅助接点之后。
本文采用引入 伪头节点 ,以提升代码的简洁性和可读性。
算法流程:
1.初始化: 伪头节点 dum (作为整个合并链表的头部),节点 cur 指向伪头节点 dum 。
2.循环合并: 当 l1 或 l2 为空时跳出;
(1)当 l1.val < l2.val 时: 将节点 l1 添加至节点 cur 之后;并节点 l1 向前走一步;
(2)当 l1.val >= l2.val 时: 将节点 l2 添加至节点 cur 之后;并节点 l2 向前走一步 ;
(3)节点 cur 向前走一步(即 cur = cur.next )。
3.合并剩余尾部: 跳出时有两种情况,即 l1 为空 或 l2 为空。
(1)当节点 l1 不为空时: 将 l1 添加至节点 cur 之后;
(2)否则: 将 l2 添加至节点 cur 之后。
4.返回值: 合并链表在伪头节点 dum 之后,因此返回 dum.next 即可。
复杂度分析:
时间复杂度 O(M+N): M, N 分别为链表 l1, l2 的长度,合并操作需遍历两链表。
空间复杂度 O(1) : 节点引用 dum , cur 使用常数大小的额外空间。
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dum = new ListNode(0),cur = dum;
while(l1 != null && l2 != null){
if(l1.val < l2.val){
cur.next = l1;
l1 = l1.next;
}
else{
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}
cur.next = l1 !=null ? l1 : l2;
return dum.next;
}
}
025(35)-复杂链表的复制
==问题:==请实现 copyRandomList 函数,复制一个复杂链表。在复杂链表中,每个节点除了有一个 next 指针指向下一个节点,还有一个 random 指针指向链表中的任意节点或者 null。
==方法一:==哈希表,空间和时间复杂度都为O(n)
/*
// Definition for a Node.
class Node {
int val;
Node next;
Node random;
public Node(int val) {
this.val = val;
this.next = null;
this.random = null;
}
}
*/
class Solution {
public Node copyRandomList(Node head) {
if(head == null){
return head;
}
//map中存的是(原节点,拷贝节点)的一个映射
Map<Node,Node> map = new HashMap<>();
for(Node cur = head; cur != null; cur = cur.next){
map.put(cur,new Node(cur.val));
}
//将拷贝的新的节点组织成一个链表
for(Node cur = head; cur != null; cur = cur.next){
map.get(cur).next = map.get(cur.next);
map.get(cur).random = map.get(cur.random);
}
return map.get(head);
}
}
==方法二:==原地修改,空间复杂度为O(1)
1.复制一个新的节点在原有节点之后,如 1 -> 2 -> 3 -> null 复制完就是 1 -> 1 -> 2 -> 2 -> 3 - > 3 -> null
2.从头开始遍历链表,通过 cur.next.random = cur.random.next 可以将复制节点的随机指针串起来,当然 需要判断 cur.random 是否存在
3.将复制完的链表一分为二 根据以上信息,我们不难写出以下代码
/*
// Definition for a Node.
class Node {
int val;
Node next;
Node random;
public Node(int val) {
this.val = val;
this.next = null;
this.random = null;
}
}
*/
class Solution {
public Node copyRandomList(Node head) {
if(head == null){
return head;
}
//完成链表节点的复制
Node cur = head;
while(cur != null){
Node copyNode = new Node(cur.val);
copyNode.next = cur.next;
cur.next = copyNode;
cur = cur.next.next;
}
//完成链表复制节点的随机指针复制
cur = head;
while(cur != null){
if(cur.random != null){ //判断原来的节点有没有random指针
cur.next.random = cur.random.next;
}
cur = cur.next.next;
}
//将链表一分为二
Node copyHead = head.next;
cur = head;
Node curCopy = head.next;
while(cur != null){
cur.next = cur.next.next;
cur = cur.next;
if(curCopy.next != null){
curCopy.next = curCopy.next.next;
curCopy = curCopy.next;
}
}
return copyHead;
}
}
036(52)-两个链表的第一个公共结点
==问题:==输入两个链表,找出它们的第一个公共节点。
解题思路:
我们使用两个指针 node1,node2 分别指向两个链表 headA,headB 的头结点,然后同时分别逐结点遍 历,当 node1 到达链表 headA 的末尾时,重新定位到链表 headB 的头结点;当 node2 到达链表 headB 的 末尾时,重新定位到链表 headA 的头结点。
这样,当它们相遇时,所指向的结点就是第一个公共结点。
复杂度分析
- 时间复杂度:O(M+N)
- 空间复杂度:O(1)
设交集链表长c,链表1除交集的长度为a,链表2除交集的长度为b,有
a + c + b = b + c + a
若无交集,则a + b = b + a
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode h1 = headA, h2 = headB;
while(h1 != h2){
h1 = h1 == null ? headB : h1.next;
h2 = h2 == null ? headA : h2.next;
}
return h1;
}
}
055-链表中环的入口结点
题目描述:
给一个链表,若其中包含环,请找出该链表的环的入口结点,否则,输出null。
思路:
设置快慢指针,都从链表头出发,快指针每次走两步,慢指针一次走一步,假如有环,一定相遇于环中某点(结论1)。接着让两个指针分别从相遇点和链表头出发,两者都改为每次走一步,最终相遇于环入口(结论2)。以下是两个结论证明:
两个结论:
1、设置快慢指针,假如有环,他们最后一定相遇。
2、两个指针分别从链表头和相遇点继续出发,每次走一步,最后一定相遇与环入口。
证明结论1:设置快慢指针fast和low,fast每次走两步,low每次走一步。假如有环,两者一定会相遇(因为low一旦进环,可看作fast在后面追赶low的过程,每次两者都接近一步,最后一定能追上)。
证明结论2:
设:
链表头到环入口长度为–a
环入口到相遇点长度为–b
相遇点到环入口长度为–c
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cuExh8rF-1593577508885)(E:\学习java\剑指offer+leetcode\images\055.png)]
则:相遇时
快指针路程=a+(b+c)k+b ,k>=1 其中b+c为环的长度,k为绕环的圈数(k>=1,即最少一圈,不能是0圈,不然和慢指针走的一样长,矛盾)。
慢指针路程=a+b
快指针走的路程是慢指针的两倍,所以:
(a+b)*2=a+(b+c)k+b
化简可得:
a=(k-1)(b+c)+c 这个式子的意思是: 链表头到环入口的距离=相遇点到环入口的距离+(k-1)圈环长度。其中k>=1,所以k-1>=0圈。所以两个指针分别从链表头和相遇点出发,最后一定相遇于环入口。
/*
public class ListNode {
int val;
ListNode next = null;
ListNode(int val) {
this.val = val;
}
}
*/
public class Solution {
public ListNode EntryNodeOfLoop(ListNode pHead){
ListNode low = pHead;
ListNode fast = pHead;
while(fast != null && fast.next != null){
fast = fast.next.next;
low = low.next;
if(fast == low)
break;
}
if(fast == null || fast.next == null){
return null;
}
low = pHead;
while(low != fast){
low = low.next;
fast = fast.next;
}
return low;
}
}
056-删除链表中重复的结点
题目描述:
在一个排序的链表中,存在重复的结点,请删除该链表中重复的结点,重复的结点不保留,返回链表头指 针。 例如,链表1->2->3->3->4->4->5 处理后为 1->2->5
解题思路:
1.首先添加一个头节点,以方便碰到第一个,第二个节点就相同的情况
2.设置 pre ,last 指针, pre指针指向当前确定不重复的那个节点,而last指针相当于工作指针,一直往后面搜索。
/*
public class ListNode {
int val;
ListNode next = null;
ListNode(int val) {
this.val = val;
}
}
*/
public class Solution {
public ListNode deleteDuplication(ListNode pHead){
if(pHead == null || pHead.next == null){
return pHead;
}
ListNode Head = new ListNode(0);
Head.next = pHead;
ListNode pre = Head;
ListNode last = Head.next;
while(last != null){
if(last.next != null && last.val == last.next.val){
//找到最后一个相同节点
while(last.next != null && last.val == last.next.val){
last = last.next;
}
pre.next = last.next;
last = last.next;
}else{
pre = pre.next;
last = last.next;
}
}
return Head.next;
}
}
树(Tree):
004(07)-重建二叉树
题目描述:
输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
例如,给出下列数据来返回正确的二叉树
前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]
解题思路:递归法
题目分析:
前序遍历特点: 节点按照 [ 根节点 | 左子树 | 右子树 ] 排序,以题目示例为例:[ 3 | 9 | 20 15 7 ]
中序遍历特点: 节点按照 [ 左子树 | 根节点 | 右子树 ] 排序,以题目示例为例:[ 9 | 3 | 15 20 7 ]
根据题目描述输入的前序遍历和中序遍历的结果中都不含重复的数字,其表明树中每个节点值都是唯一的。
根据以上特点,可以按顺序完成以下工作:
1.前序遍历的首个元素即为根节点 root 的值;
2.在中序遍历中搜索根节点 root 的索引 ,可将中序遍历划分为 [ 左子树 | 根节点 | 右子树 ] 。
3.根据中序遍历中的左(右)子树的节点数量,可将前序遍历划分为 [ 根节点 | 左子树 | 右子树 ] 。
自此可确定 三个节点的关系 :1.树的根节点、2.左子树根节点、3.右子树根节点(即前序遍历中左(右)子 树的首个元素)。
子树特点: 子树的前序和中序遍历仍符合以上特点,以题目示例的右子树为例:前序遍历:[20 | 15 | 7],中序遍历 [ 15 | 20 | 7 ] 。
根据子树特点,我们可以通过同样的方法对左(右)子树进行划分,每轮可确认三个节点的关系 。此递推性 质让我们联想到用 递归方法 处理。
递归解析:
递推参数: 前序遍历中根节点的索引pre_root、中序遍历左边界in_left、中序遍历右边界in_right。
终止条件: 当 in_left > in_right ,子树中序遍历为空,说明已经越过叶子节点,此时返回 null 。
递推工作:
1.建立根节点root: 值为前序遍历中索引为pre_root的节点值。
2.搜索根节点root在中序遍历的索引i: 为了提升搜索效率,本题解使用哈希表 dic 预存储中序遍历的值 与索引的映射关系,每次搜索的时间复杂度为 O(1)。
3.构建根节点root的左子树和右子树: 通过调用 recur() 方法开启下一层递归。
左子树: 根节点索引为 pre_root + 1 ,中序遍历的左右边界分别为 in_left 和 i - 1。
右子树: 根节点索引为 i - in_left + pre_root + 1(即:根节点索引 + 左子树长度 + 1),中序遍历 的左右边界分别为 i + 1 和 in_right。
返回值: 返回 root,含义是当前递归层级建立的根节点 root 为上一递归层级的根节点的左或右子节点。
复杂度分析
时间复杂度 O(N) : N为树的节点数量。初始化 HashMap 需遍历 inorder ,占用 O(N) ;递归共建立 N 个节点,每层递归中的节点建立、搜索操作占用 O(1) ,因此递归占用 O(N) 。(最差情况为所有子树只有左节点,树退化为链表,此时递归深度 O(N) ;平均情况下递归深度 O(log_2 N))。
空间复杂度 O(N) : HashMap 使用 O(N) 额外空间;递归操作中系统需使用 O(N) 额外空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
HashMap<Integer,Integer> dic = new HashMap<>();
int[] po;
public TreeNode buildTree(int[] preorder, int[] inorder) {
po = preorder;
for(int i = 0; i < inorder.length; i++){
dic.put(inorder[i], i);
}
return recur(0, 0, inorder.length -1);
}
TreeNode recur(int pre_root, int in_left, int in_right){
if(in_left > in_right){
return null;
}
TreeNode root = new TreeNode(po[pre_root]);
int i = dic.get(po[pre_root]);
root.left = recur(pre_root + 1, in_left, i-1);
root.right = recur(pre_root + i - in_left + 1, i + 1, in_right);
return root;
}
}
017(26)-树的子结构
题目描述:
输入两棵二叉树A和B,判断B是不是A的子结构。(约定空树不是任意一个树的子结构)
B是A的子结构, 即 A中有出现和B相同的结构和节点值。
示例 1:
输入:A = [1,2,3], B = [3,1]
输出:false
示例 2:
输入:A = [3,4,5,1,2], B = [4,1]
输出:true
若树 B 是树 A 的子结构,则子结构的根节点可能为树 A 的任意一个节点。因此,判断树 B 是否是树 A 的子结构,需完成以下两步工作:
1.先序遍历树 A 中的每个节点 n_A;(对应函数 isSubStructure(A, B))
2.判断树 A 中 以 n_A 为根节点的子树是否包含树 B 。(对应函数 recur(A, B))
算法流程:
名词规定:树 A 的根节点记作 节点 A ,树 B 的根节点称为 节点 B 。
recur(A, B) 函数:
1.终止条件:
1.当节点 B 为空:说明树 B 已匹配完成(越过叶子节点),因此返回 true ;
2.当节点 A 为空:说明已经越过树 A 叶子节点,即匹配失败,返回 false ;
3.当节点 A 和 B 的值不同:说明匹配失败,返回 false ;
2.返回值:
1.判断 A 和 B 的左子节点是否相等,即 recur(A.left, B.left) ;
2.判断 A 和 B 的右子节点是否相等,即 recur(A.right, B.right) ;
isSubStructure(A, B) 函数:
1.特例处理: 当 树 A 为空或树 B 为空时,直接返回 false ;
2.返回值: 若树 B 是树 A 的子结构,则必满足以下三种情况之一,因此用或 || 连接;
1.以 节点 A 为根节点的子树包含树 B ,对应 recur(A, B);
2.树 B 是 树 A 左子树 的子结构,对应 isSubStructure(A.left, B);
3.树 B 是 树 A 右子树 的子结构,对应 isSubStructure(A.right, B);
以上 2. 3. 实质上是在对树 A 做 先序遍历 。
复杂度分析:
1.时间复杂度 O(MN) : 其中 M,N 分别为树 A 和 树 B 的节点数量;先序遍历树 A 占用 O(M) ,每次调用 recur(A, B) 判断占用 O(N) 。
2.空间复杂度 O(M) : 当树 A 和树 B 都退化为链表时,递归调用深度最大。当 M ≤ N 时,遍历树 A 与递归判 断的总递归深度为 M ;当 M>N 时,最差情况为遍历至树 A 叶子节点,此时总递归深度为 M。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public boolean isSubStructure(TreeNode A, TreeNode B) {
return (A != null && B != null) && (recur(A, B) || isSubStructure(A.left, B) || isSubStructure(A.right, B));
}
boolean recur(TreeNode A, TreeNode B){
if(B == null) return true;
if(A == null || A.val != B.val) return false;
return recur(A.left, B.left) && recur(A.right, B.right);
}
}
018(27)-二叉树的镜像
问题描述:
请完成一个函数,输入一个二叉树,该函数输出它的镜像。
输入:root = [4,2,7,1,3,6,9]
输出:[4,7,2,9,6,3,1]
二叉树镜像定义: 对于二叉树中任意节点 root ,设其左 / 右子节点分别为 left, right;则在二叉树的镜像中的对应 root 节点,其左 / 右子节点分别为 right, left 。
方法一:递归法
根据二叉树镜像的定义,考虑递归先序遍历(dfs)二叉树,交换每个节点的左 / 右子节点,即可生成二叉树的镜像。
递归解析:
1.终止条件: 当节点 rootroot 为空时(即越过叶节点),则返回 nullnull ;
2.递推工作:
1.初始化节点 tmp ,用于暂存 root 的左子节点;
2.开启递归 右子节点 mirrorTree(root.right),并将返回值作为 root 的 左子节点 。
3.开启递归 左子节点 mirrorTree(tmp) ,并将返回值作为 root 的 右子节点 。
3.返回值: 返回当前节点 root ;
Q: 为何需要暂存 root 的左子节点?
A: 在递归右子节点 “root.left = mirrorTree(root.right);” 执行完毕后, root.left 的值已经发生改变,此时递归左子节点 mirrorTree(root.left) 则会出问题。
复杂度分析:
时间复杂度 O(N : 其中 N 为二叉树的节点数量,建立二叉树镜像需要遍历树的所有节点,占用 O(N) 时间。
空间复杂度 O(N): 最差情况下(当二叉树退化为链表),递归时系统需使用 O(N) 大小的栈空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public TreeNode mirrorTree(TreeNode root) {
if(root == null) return null;
TreeNode tmp = root.left;
root.left = mirrorTree(root.right);
root.right = mirrorTree(tmp);
return root;
}
}
方法二:辅助栈(或队列)
利用栈(或队列)遍历树的所有节点 node ,并交换每个 node 的左 / 右子节点。
算法流程:
1.特例处理: 当 root 为空时,直接返回 null;
2.初始化: 栈(或队列),本文用栈,并加入根节点 root 。
3.循环交换: 当栈 stack 为空时跳出;
1.出栈: 记为 node ;
2.添加子节点: 将 node 左和右子节点入栈;
3.交换: 交换 node 的左 / 右子节点。
4.返回值: 返回根节点 root 。
复杂度分析:
时间复杂度 O(N) : 其中 N 为二叉树的节点数量,建立二叉树镜像需要遍历树的所有节点,占用 O(N) 时间。
空间复杂度 O(N) : 最差情况下(当为满二叉树时),栈 stack 最多同时存储 N/2 个节点,占用 O(N) 额外空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public TreeNode mirrorTree(TreeNode root) {
if(root == null) return null;
Stack<TreeNode> stack = new Stack<>(){
{add(root);}
};
while(!stack.isEmpty()){
TreeNode node = stack.pop();
if(node.left != null) stack.add(node.left);
if(node.right != null) stack.add(node.right);
TreeNode tmp = node.left;
node.left = node.right;
node.right = tmp;
}
return root;
}
}
022(32.1)-从上往下打印二叉树
问题描述:
从上到下打印出二叉树的每个节点,同一层的节点按照从左到右的顺序打印。
例如:
给定二叉树: [3,9,20,null,null,15,7],
返回:[3,9,20,15,7]
解题思路:
题目要求的二叉树的 从上至下 打印(即按层打印),又称为二叉树的 广度优先搜索(BFS)。
BFS 通常借助 队列 的先入先出特性来实现。
算法流程:
1.特例处理: 当树的根节点为空,则直接返回空列表 [] ;
2.初始化: 打印结果列表 res = [] ,包含根节点的队列 queue = [root] ;
3.BFS 循环: 当队列 queue 为空时跳出;
1.出队: 队首元素出队,记为 node;
2.打印: 将 node.val 添加至列表 tmp 尾部;
3.添加子节点: 若 node 的左(右)子节点不为空,则将左(右)子节点加入队列 queue ;
4.返回值: 返回打印结果列表 res 即可。
复杂度分析:
时间复杂度 O(N) : N 为二叉树的节点数量,即 BFS 需循环 N 次。
空间复杂度 O(N) : 最差情况下,即当树为平衡二叉树时,最多有 N/2 个树节点同时在 queue 中,使用 O(N) 大小的额外空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public int[] levelOrder(TreeNode root) {
if(root == null) return new int[0];
Queue<TreeNode> queue = new LinkedList<>(){
{add(root); }
};
ArrayList<Integer> ans = new ArrayList<>();
while(!queue.isEmpty()){
TreeNode node = queue.poll();
ans.add(node.val);
if(node.left != null) queue.add(node.left);
if(node.right != null) queue.add(node.right);
}
int[] res = new int[ans.size()];
for(int i = 0; i < ans.size(); i++)
res[i] = ans.get(i);
return res;
}
}
023(33)-二叉搜索树的后序遍历序列
问题描述:
输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历结果。如果是则返回 true
,否则返回 false
。假设输入的数组的任意两个数字都互不相同。
示例1:
输入:[1,6,3,2,5] 输出:false
输入:[1,3,2,6,5] 输出:true
解题思路:
以下两种方法都是基于以下两性质推出。
后序遍历性质: [ 左子树 | 右子树 | 根节点 ] ,即遍历顺序为 “左、右、根” 。
二叉搜索树性质: 左子树任意节点的值 < 根节点的值;右子树任意节点的值 > 根节点的值。
方法一:递归分治
子树递推性质: 对于 [ 左子树 | 右子树 | 根节点 ] 中的左(右)子树,仍满足后序遍历和二叉搜索树性质。
因此,可以通过递归判断每个子树的 正确性 (即其后序遍历是否满足二叉搜索树性质) ,若所有子树都正 确,则此序列为二叉搜索树的后序遍历。
递归解析:
终止条件: 当 i ≥ j,说明子树节点少于或等于 1 个,无需判别正确性,因此直接返回 true ;
递推工作:
1.划分左右子树: 遍历后序遍历的 [i,j] 区间元素,寻找 第一个大于根节点(即 postorder[j])的节点,索引记为 m 。此时,可划分出左子树区间 [i,m−1] 、右子树区间 [m,j−1] 、根节点索引 j 。
2.判断是否满足二叉搜索树性质:
1.右子树区间 [m,j−1] 内的所有元素都应 >postorder[j] 。实现方式为遍历,当遇到≤postorder[j] 的元素 则跳出;后续可通过 l = j 判断是否满足二叉搜索树性质。
2.左子树区间 [i,m−1] 内的所有元素都应 <postorder[j] 。而第 “1.划分左右子树” 步骤已经保证左子树区 间的正确性,因此只需要判断右子树区间即可。
返回值: 所有子树都需正确才可判定正确,因此使用 与逻辑符 && 连接。
1. l==j : 判断此树是否正确。
2. recur(i, m - 1): 判断此树的左子树是否正确。
3. recur(m, j - 1) : 判断此树的右子树是否正确。
复杂度分析:
时间复杂度 O(N^2): 每次调用 recur(i,j) 减去一个根节点,因此递归占用 O(N) ;最差情况下(即当树退化 为链表),每轮递归都需遍历树所有节点,占用 O(N) 。
空间复杂度 O(N) : 最差情况下(即当树退化为链表,递归深度将达到 N 。
class Solution {
public boolean verifyPostorder(int[] postorder) {
return recur(postorder, 0, postorder.length - 1);
}
boolean recur(int[] po, int i, int j){
if(i >= j) return true;
int l = i;
while(po[l] < po[j]) l++;
int m = l;
while(po[l] > po[j]) l++;
return l == j && recur(po, i, m - 1) && recur(po, m, j - 1);
}
}
方法二:单调栈
完了再研究
024(34)-二叉树中和为某一值的路径
问题描述:
输入一棵二叉树和一个整数,打印出二叉树中节点值的和为输入整数的所有路径。从树的根节点开始往下一直到叶节点所经过的节点形成一条路径。
解题思路:
本问题是典型的二叉树方案搜索问题,使用回溯法解决,其包含 先序遍历 + 路径记录 两部分。
先序遍历: 按照 “根、左、右” 的顺序,遍历树的所有节点。
路径记录: 在先序遍历中,记录从根节点到当前节点的路径。当路径为 ① 根节点到叶节点形成的路径 且 ② 各节点值的和等于目标值 sum 时,将此路径加入结果列表。
算法流程:
pathSum(root, sum) 函数:
初始化: 结果列表 res ,路径列表 path 。
返回值: 返回 res 即可。
recur(root, tar) 函数:
递推参数: 当前节点 root ,当前目标值 tar 。
终止条件: 若节点 root 为空,则直接返回。
递推工作:
1.路径更新: 将当前节点值 root.val 加入路径 path ;
2.目标值更新: tar = tar - root.val(即目标值 tar 从 sum 减至 00 );
3.路径记录: 当 ① root 为叶节点 且 ② 路径和等于目标值 ,则将此路径 path 加入 res 。
4.先序遍历: 递归左 / 右子节点。
5.路径恢复: 向上回溯前,需要将当前节点从路径 path 中删除,即执行 path.pop() 。
复杂度分析:
时间复杂度 O(N) : N 为二叉树的节点数,先序遍历需要遍历所有节点。
空间复杂度 O(N) : 最差情况下,即树退化为链表时,path 存储所有树节点,使用 O(N) 额外空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
LinkedList<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> pathSum(TreeNode root, int sum) {
recur(root, sum);
return res;
}
void recur(TreeNode root, int tar){
if(root == null) return;
path.add(root.val);
tar -= root.val;
if(tar == 0 && root.left == null && root.right == null){
res.add(new LinkedList(path));
}
recur(root.left, tar);
recur(root.right, tar);
path.removeLast();
}
}
026(36)-二叉搜索树与双向链表
问题描述:
输入一棵二叉搜索树,将该二叉搜索树转换成一个排序的循环双向链表。要求不能创建任何新的节点,只能调整树中节点指针的指向。
方法:中序遍历访问节点,全局遍历保存头尾指针
递归遍历
/*
// Definition for a Node.
class Node {
public int val;
public Node left;
public Node right;
public Node() {}
public Node(int _val) {
val = _val;
}
public Node(int _val,Node _left,Node _right) {
val = _val;
left = _left;
right = _right;
}
};
*/
class Solution {
Node head = null, pre = null, tail = null;
public Node treeToDoublyList(Node root) {
if(root == null){
return root;
}
//中序遍历访问节点并连接
inorder(root);
//连接头尾节点
head.left = tail;
tail.right = head;
return head;
}
public void inorder(Node root){
//递归出口
if(root == null){
return;
}
//访问左子树
inorder(root.left);
//将当前节点和上一个节点连接
if(pre == null){
head = root;
}else{
pre.right = root;
}
root.left = pre;
pre = root;
tail = root;
//访问右子树
inorder(root.right);
return;
}
}
038(55.1)-二叉树的深度
问题描述:
输入一棵二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。
例如:
给定二叉树 [3,9,20,null,null,15,7],返回它的最大深度 3 。
思想:
树的遍历方式总体分为两类:深度优先搜索(DFS)、广度优先搜索(BFS);
常见的 DFS : 先序遍历、中序遍历、后序遍历;
常见的 BFS : 层序遍历(即按层遍历)。
求树的深度需要遍历树的所有节点,本文将介绍基于 先序遍历(DFS) 和 层序遍历(BFS) 的两种解法。
方法一:先序遍历(DFS)
树的先序遍历 / 深度优先搜索往往利用递归或栈实现,本文使用递归实现。
关键点: 此树的深度和其左(右)子树的深度之间的关系。显然,此树的深度等于左子树的深度与右子树 的深度中的最大值 +1 。
算法解析:
1.终止条件: 当 root 为空,说明已越过叶节点,因此返回 深度 0 。
2.递推工作: 本质上是对树做先序遍历。
1.计算节点 root 的 左子树的深度 ,即调用 maxDepth(root.left) ;
2.计算节点 root 的 右子树的深度 ,即调用 maxDepth(root.right) ;
3.返回值: 返回 此树的深度 ,即 max(maxDepth(root.left), maxDepth(root.right)) + 1 。
复杂度分析:
时间复杂度 O(N) : N 为树的节点数量,计算树的深度需要遍历所有节点。
空间复杂度 O(N) : 最差情况下(当树退化为链表时),递归深度可达到 N 。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public int maxDepth(TreeNode root) {
if(root == null){
return 0;
}
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
}
}
方法二:层序遍历(BFS)
树的层序遍历 / 广度优先搜索往往利用 队列 实现。
关键点: 每遍历一层,则计数器 +1 ,直到遍历完成,则可得到树的深度。
算法解析:
1.特例处理: 当 root 为空,直接返回 深度 0 。
2.初始化: 队列 queue (加入根节点 root ),计数器 res = 0 。
3.循环遍历: 当 queue 为空时跳出。
1.初始化一个空列表 tmp ,用于临时存储下一层节点;
2.遍历队列:遍历 queue 中的各节点 node ,并将其左子节点和右子节点加入 tmp ;
3.更新队列: 执行 queue = tmp ,将下一层节点赋值给 queue ;
4.统计层数: 执行 res += 1 ,代表层数加 1 ;
4.返回值: 返回 res 即可。
复杂度分析:
时间复杂度 O(N) : N 为树的节点数量,计算树的深度需要遍历所有节点。
空间复杂度 O(N) : 最差情况下(当树平衡时),队列 queue 同时存储 N/2 个节点。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public int maxDepth(TreeNode root) {
if(root == null){
return 0;
}
List<TreeNode> queue = new LinkedList<>(){
{add(root);}
},tmp;
int res = 0;
while(!queue.isEmpty()){
tmp = new LinkedList<>();
for(TreeNode node :queue){
if(node.left != null) tmp.add(node.left);
if(node.right != null) tmp.add(node.right);
}
queue = tmp;
res++;
}
return res;
}
}
039(55.2)-平衡二叉树
问题描述:
输入一棵二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过1,那么它就是一棵平衡二叉树。
以下两种方法均基于以下性质推出: 此树的深度等于左子树的深度与右子树的深度中的最大值 +1 。
方法一:先序遍历 + 剪枝 (从底至顶)
此方法为本题的最优解法,但剪枝的方法不易第一时间想到。
思路是对二叉树做先序遍历,从底至顶返回子树深度,若判定某子树不是平衡树则 “剪枝” ,直接向上返回。
算法流程:
recur(root) 函数:
返回值:
1.当节点root 左 / 右子树的深度差 ≤1 :则返回当前子树的深度,即节点 root 的左 / 右子树的深度最大值 +1 ( max(left, right) + 1 );
2.当节点root 左 / 右子树的深度差 > 2 :则返回 −1 ,代表此子树不是平衡树 。
终止条件:
1.当 root 为空:说明越过叶节点,因此返回高度 0 ;
2.当左(右)子树深度为 −1 :代表此树的 左(右)子树 不是平衡树,因此剪枝,直接返回 −1 ;
isBalanced(root) 函数:
返回值: 若 recur(root) != -1 ,则说明此树平衡,返回 true ; 否则返回 false 。
复杂度分析:
时间复杂度 O(N): N 为树的节点数;最差情况下,需要递归遍历树的所有节点。
空间复杂度 O(N): 最差情况下(树退化为链表时),系统递归需要使用 O(N) 的栈空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public boolean isBalanced(TreeNode root) {
return recur(root) != -1;
}
private int recur(TreeNode root){
if(root == null){
return 0;
}
int left = recur(root.left);
if(left == -1){
return -1;
}
int right = recur(root.right);
if(right == -1){
return -1;
}
return Math.abs(left - right) < 2 ? Math.max(left, right) + 1 : -1;
}
}
方法二:先序遍历 + 判断深度 (从顶至底)
此方法容易想到,但会产生大量重复计算,时间复杂度较高。
思路是构造一个获取当前子树的深度的函数 depth(root) (即 面试题55 - I. 二叉树的深度 ),通过比较某子树的左右子树的深度差 abs(depth(root.left) - depth(root.right)) <= 1 是否成立,来判断某子树是否是二叉平衡树。若所有子树都平衡,则此树平衡。
算法流程:
isBalanced(root) 函数: 判断树 root 是否平衡
特例处理: 若树根节点 root 为空,则直接返回 true ;
返回值: 所有子树都需要满足平衡树性质,因此以下三者使用与逻辑 && 连接;
1.abs(self.depth(root.left) - self.depth(root.right)) <= 1 :判断 当前子树 是否是平衡树;
2.self.isBalanced(root.left) : 先序遍历递归,判断 当前子树的左子树 是否是平衡树;
3.self.isBalanced(root.right) : 先序遍历递归,判断 当前子树的右子树 是否是平衡树;
depth(root) 函数: 计算树 root 的深度
终止条件: 当 root 为空,即越过叶子节点,则返回高度 0 ;
返回值: 返回左 / 右子树的深度的最大值 +1 。
复杂度分析:
时间复杂度 O(N log N)O(NlogN): 最差情况下(为 “满二叉树” 时), isBalanced(root) 遍历树所有节点,判断每个节点的深度 depth(root) 需要遍历 各子树的所有节点 。
因此,总体时间复杂度 == 每层执行复杂度 \times× 层数复杂度 = O(N \times log N)O(N×logN) 。
空间复杂度 O(N)O(N): 最差情况下(树退化为链表时),系统递归需要使用 O(N)O(N) 的栈空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public boolean isBalanced(TreeNode root) {
if(root == null){
return true;
}
return Math.abs(depth(root.left) - depth(root.right)) <= 1 && isBalanced(root.left) && isBalanced(root.right);
}
private int depth(TreeNode root){
if(root == null){
return 0;
}
return Math.max(depth(root.left), depth(root.right)) + 1;
}
}
057-二叉树的下一个结点
题目描述:
给定一个二叉树和其中的一个结点,请找出中序遍历顺序的下一个结点并且返回。注意,树中的结点不仅包含左右子结点,同时包含指向父结点的指针。
解题思路:
① 如果一个节点的右子树不为空,那么该节点的下一个节点是右子树的最左节点;
② 否则,向上找第一个左链接指向的树包含该节点的祖先节点。
/*
public class TreeLinkNode {
int val;
TreeLinkNode left = null;
TreeLinkNode right = null;
TreeLinkNode next = null; //指向父节点的指针
TreeLinkNode(int val) {
this.val = val;
}
}
*/
public class Solution {
public TreeLinkNode GetNext(TreeLinkNode pNode)
{
if(pNode.right != null){
TreeLinkNode node = pNode.right;
while(node.left != null)
node = node.left;
return node;
}else{
while(pNode.next != null){
TreeLinkNode parent = pNode.next;
if(parent.left == pNode)
return parent;
pNode = pNode.next;
}
}
return null;
}
}
058(28)-对称的二叉树
问题描述:
请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。
解题思路:
对称二叉树定义: 对于树中 任意两个对称节点 L 和 R ,一定有:
L.val = R.val :即此两对称节点值相等。
L.left.val = R.right.val :即 L 的 左子节点 和 R 的 右子节点 对称;
L.right.val = R.left.val :即 L 的 右子节点 和 R 的 左子节点 对称。
根据以上规律,考虑从顶至底递归,判断每对节点是否对称,从而判断树是否为对称二叉树。
算法流程:
isSymmetric(root) :
特例处理: 若根节点 root 为空,则直接返回 true 。
返回值: 即 recur(root.left, root.right) ;
recur(L, R) :
终止条件:
当 L 和 R 同时越过叶节点: 此树从顶至底的节点都对称,因此返回 true ;
当 L 或 R 中只有一个越过叶节点: 此树不对称,因此返回 false ;
当节点 L 值 ≠ 节点 R 值: 此树不对称,因此返回 false ;
递推工作:
判断两节点L.left 和 R.right 是否对称,即 recur(L.left, R.right) ;
判断两节点 L.right 和 R.left 是否对称,即 recur(L.right, R.left) ;
返回值: 两对节点都对称时,才是对称树,因此用与逻辑符 && 连接。
复杂度分析:
时间复杂度 O(N) : 其中 N 为二叉树的节点数量,每次执行 recur() 可以判断一对节点是否对称,因此最多调 用 N/2 次 recur() 方法。
空间复杂度 O(N) : 最差情况下,二叉树退化为链表,系统使用 O(N) 大小的栈空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public boolean isSymmetric(TreeNode root) {
return root == null ? true : recur(root.left, root.right);
}
boolean recur(TreeNode L, TreeNode R){
if(L == null && R == null)
return true;
if(L ==null || R == null || L.val != R.val)
return false;
return recur(L.left, R.right) && recur(L.right, R.left);
}
}
059(32.3)-按之字形顺序打印二叉树
问题描述:
请实现一个函数按照之字形顺序打印二叉树,即第一行按照从左到右的顺序打印,第二层按照从右到左的顺序打印,第三行再按照从左到右的顺序打印,其他行以此类推。
方法一:层序遍历 + 倒序
I. 树的按层打印: 题目要求的二叉树的 从上至下 打印(即按层打印),又称为二叉树的 广度优先搜索 (BFS)。BFS 通常借助 队列 的先入先出特性来实现。
II. 每层打印到一行: 若将二叉树从上至下分为多层,则通过访问 某层所有节点的 左右子节点 ,可统计出 下一层的所有节点 。根据此特性,可在打印本层节点时将下一层节点加入队列,以此类推,即可分为多行打印。
III. 打印顺序交替变化: 根据题意,即实现 奇数层从左到右 , 偶数层从右到左 打印。只需将所有偶数层的打印结果执行 倒序 即可。
算法流程:
1.特例处理: 当树的根节点为空,则直接返回空列表 [] ;
2.初始化: 打印结果列表 res = [] ,包含根节点的队列 queue = [root] ;
3.BFS 循环: 当队列 queue 为空时跳出;
1.新建列表 tmp ,用于临时存储当前层打印结果;
2.当前层打印循环: 循环次数为队列 queue 长度(队列中元素为所有当前层节点);
1.出队: 队首元素出队,记为 node;
2.打印: 将 node.val 添加至列表 tmp 尾部;
3.添加子节点: 若 node 的左(右)子节点不为空,则加入队列 queue ;
3.偶数层倒序: 若 res 的长度为 奇数 ,说明当前是偶数层,则对 tmp 执行 倒序 操作;
4.将当前层结果 tmp 添加入 res ;
4.返回值: 返回打印结果列表 res 即可;
复杂度分析:
时间复杂度 O(N) : N 为二叉树的节点数量,即 BFS 需循环 N 次,占用 O(N) 。总共完成 少于 N 个节点的倒 序操作,占用 O(N) 。
空间复杂度 O(N) : 最差情况下,即当树为满二叉树时,最多有 N/2 个树节点同时在 queue 中,使用 O(N) 大小的额外空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
Queue<TreeNode> queue = new LinkedList<>();
List<List<Integer>> res = new ArrayList<>();
if(root != null)
queue.add(root);
while(!queue.isEmpty()){
List<Integer> tmp = new ArrayList<>();
for(int i = queue.size(); i > 0; i--){
TreeNode node = queue.poll();
tmp.add(node.val);
if(node.left != null)
queue.add(node.left);
if(node.right != null)
queue.add(node.right);
}
if(res.size() % 2 == 1)
Collections.reverse(tmp);
res.add(tmp);
}
return res;
}
}
方法二:利用双栈
方法一好一点
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
int layer = 1;
//s1存奇数层节点
Stack<TreeNode> s1 = new Stack<TreeNode>();
s1.push(root);
//s2存偶数层节点
Stack<TreeNode> s2 = new Stack<TreeNode>();
List<List<Integer>> list = new ArrayList<>();
while(!s1.empty() || !s2.empty()){
if(layer % 2 != 0){
ArrayList<Integer> temp = new ArrayList<Integer>();
while(!s1.empty()){
TreeNode node = s1.pop();
if(node != null){
temp.add(node.val);
System.out.print(node.val + " ");
s2.push(node.left);
s2.push(node.right);
}
}
if(!temp.isEmpty()){
list.add(temp);
layer++;
System.out.println();
}
}else{
ArrayList<Integer> temp = new ArrayList<Integer>();
while(!s2.empty()){
TreeNode node = s2.pop();
if(node != null){
temp.add(node.val);
System.out.print(node.val + " ");
s1.push(node.right);
s1.push(node.left);
}
}
if(!temp.isEmpty()){
list.add(temp);
layer++;
System.out.println();
}
}
}
return list;
}
}
060(32.2)-把二叉树打印成多行
问题描述:
从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。
解题思路:
I. 按层打印: 题目要求的二叉树的 从上至下 打印(即按层打印),又称为二叉树的 广度优先搜索(BFS)。BFS 通常借助 队列 的先入先出特性来实现。
II. 每层打印到一行: 若将二叉树从上至下分为多层,则通过访问 某层所有节点的 左右子节点 ,可统计出 下一层的所有节点 。根据此特性,可在打印本层全部节点时,将下一层全部节点加入队列,以此类推,即可分为多行打印。
算法流程:
1.特例处理: 当树的根节点为空,则直接返回空列表 [] ;
2.初始化: 打印结果列表 res = [] ,包含根节点的队列 queue = [root] ;
3.BFS 循环: 当队列 queue 为空时跳出;
1.新建一个临时列表 tmp ,用于存储当前层打印结果;
2.当前层打印循环: 循环次数为队列 queue 长度(队列中元素为所有当前层节点);
1.出队: 队首元素出队,记为 node;
2.打印: 将 node.val 添加至列表 tmp 尾部;
3.添加子节点: 若 node 的左(右)子节点不为空,则将左(右)子节点加入队列 queue ;
3.将当前层结果 tmp 添加入 res 。
4.返回值: 返回打印结果列表 res 即可。
复杂度分析:
时间复杂度 O(N) : N 为二叉树的节点数量,即 BFS 需循环 N 次。
空间复杂度 O(N) : 最差情况下,即当树为平衡二叉树时,最多有 N/2 个树节点同时在 queue 中,使用 O(N) 大小的额外空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
Queue<TreeNode> queue = new LinkedList<>();
List<List<Integer>> res = new ArrayList<>();
if(root != null)
queue.add(root);
while(!queue.isEmpty()){
List<Integer> tmp = new ArrayList<>();
for(int i = queue.size(); i > 0; i--){
TreeNode node = queue.poll();
tmp.add(node.val);
if(node.left != null)
queue.add(node.left);
if(node.right != null)
queue.add(node.right);
}
res.add(tmp);
}
return res;
}
}
061(37)-序列化二叉树
问题描述:
请实现两个函数,分别用来序列化和反序列化二叉树。
分析:
题目要求的 “序列化” 和 “反序列化” 是 可逆 操作。因此,序列化的字符串应携带 “完整的” 二叉树信息,即拥有单独表示二叉树的能力。
为使反序列化可行,考虑将越过叶节点后的 null 也看作是节点。在此基础上,通过层序遍历生成序列化列表,若任意选择列表中某节点 node ,则其左子节点 node.left 和右子节点 node.right 在序列中的位置都是 唯一确定 的。
node在列表中的索引 | node.left在列表中的索引 | 节点node.right在列表中的索引 |
---|---|---|
0 | 1 | 2 |
1 | 3 | 4 |
2 | 5 | 6 |
… | … | … |
n | 2n+1 | 2n+2 |
序列化 serialize :
借助队列,对二叉树做层序遍历,并将越过叶节点的 null 也打印出来。
算法流程:
1.特例处理: 若 root 为空,则直接返回空列表 “[]” ;
2.初始化: 队列 queue (包含根节点 root );序列化列表 res ;
3.层序遍历: 当 queue 为空时跳出;
1.节点出队,记为 node ;
2.若 node 不为空:① 打印字符串 node.val ,② 将左、右子节点加入 queue ;
3.否则(若 node 为空):打印字符串 “null” ;
4.返回值: 拼接列表(用 ‘,’ 隔开,首尾添加中括号)。
复杂度分析:
时间复杂度 O(N) : N 为二叉树的节点数,层序遍历需要访问所有节点,最差情况下需要访问 N+1 个 null , 总体复杂度为 O(2N+1) ,因此还是线性的 O(N) 。
空间复杂度 O(N) : 最差情况下,队列 queue 同时存储 (N+1)/2 个节点(或 N+1 个 null ),因此使用 O(N) 额 外空间。
反序列化 deserialize :
基于本文一开始分析的 “ node , node.left , node.right ” 在序列化列表中的位置关系,可实现反序列化。
利用队列按层构建二叉树,借助一个指针 i 指向节点 node 的左、右子节点,每构建一个 node 的左、右子节点,指针 i 就向右移动 1 位。
算法流程:
1.特例处理: 若 datadata 为空,直接返回 nullnull ;
2.初始化: 序列化列表 vals(先去掉首尾中括号,再用逗号隔开,指针 i = 1,根节点 root(值为vals[0]), 队列 queue(包含 root );
3.按层构建: 当 queue 为空时跳出;
1.节点出队,记为 node ;
2.构建 node 的左子节点:node.left 的值为 vals[i] ,并将 node.left 入队;
3.执行 i+=1 ;
4.构建 node 的右子节点:node.left 的值为 vals[i] ,并将 node.left 入队;
5.执行 i+=1 ;
4.返回值: 返回根节点 root 即可。
复杂度分析:
时间复杂度 O(N) : N 为二叉树的节点数,按层构建二叉树需要遍历整个 vals ,其长度最大为 2N+1 。
空间复杂度 O(N) : 最差情况下,队列 queue 同时存储 (N+1)/2 个节点,因此使用 O(N) 额外空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
public class Codec {
public String serialize(TreeNode root) {
if(root == null) return "[]";
StringBuilder res = new StringBuilder("[");
Queue<TreeNode> queue = new LinkedList<>() {{ add(root); }};
while(!queue.isEmpty()) {
TreeNode node = queue.poll();
if(node != null) {
res.append(node.val + ",");
queue.add(node.left);
queue.add(node.right);
}
else res.append("null,");
}
res.deleteCharAt(res.length() - 1);
res.append("]");
return res.toString();
}
public TreeNode deserialize(String data) {
if(data.equals("[]")) return null;
String[] vals = data.substring(1, data.length() - 1).split(",");
TreeNode root = new TreeNode(Integer.parseInt(vals[0]));
Queue<TreeNode> queue = new LinkedList<>() {{ add(root); }};
int i = 1;
while(!queue.isEmpty()) {
TreeNode node = queue.poll();
if(!vals[i].equals("null")) {
node.left = new TreeNode(Integer.parseInt(vals[i]));
queue.add(node.left);
}
i++;
if(!vals[i].equals("null")) {
node.right = new TreeNode(Integer.parseInt(vals[i]));
queue.add(node.right);
}
i++;
}
return root;
}
}
// Your Codec object will be instantiated and called as such:
// Codec codec = new Codec();
// codec.deserialize(codec.serialize(root));
062(54)-二叉搜索树的第k大结点
问题描述:
给定一棵二叉搜索树,请找出其中第k大的节点。
解题思路:
本文解法基于此性质:二叉搜索树的中序遍历为 递增序列 。
根据以上性质,易得二叉搜索树的 中序遍历倒序 为 递减序列 。
因此,求 “二叉搜索树第 k 大的节点” 可转化为求 “此树的中序遍历倒序的第 k 个节点” 。
中序遍历 为 “左、根、右” 顺序,递归法代码如下:
// 打印中序遍历
void dfs(TreeNode root) {
if(root == null) return;
dfs(root.left); // 左
System.out.println(root.val); // 根
dfs(root.right); // 右
}
中序遍历的倒序 为 “右、根、左” 顺序,递归法代码如下:
// 打印中序遍历倒序
void dfs(TreeNode root) {
if(root == null) return;
dfs(root.right); // 右
System.out.println(root.val); // 根
dfs(root.left); // 左
}
为求第 k 个节点,需要实现以下 三项工作 :
1.递归遍历时计数,统计当前节点的序号;
2.递归到第 k 个节点时,应记录结果 res ;
3.记录结果后,后续的遍历即失去意义,应提前终止(即返回)。
递归解析:
1.终止条件: 当节点 root 为空(越过叶节点),则直接返回;
2.递归右子树: 即 dfs(root.right) ;
3.三项工作:
1.提前返回: 若 k = 0,代表已找到目标节点,无需继续遍历,因此直接返回;
2.统计序号: 执行 k = k - 1(即从 k 减至 0 );
3.记录结果: 若 k = 0,代表当前节点为第 k 大的节点,因此记录 res = root.val;
4.递归左子树: 即 dfs(root.left);
复杂度分析:
时间复杂度O(N):当树退化为链表时(全部为右子节点),无论 k 的值大小,递归深度都为 N,占用 O(N) 时间。
空间复杂度 O(N) : 当树退化为链表时(全部为右子节点),系统使用 O(N) 大小的栈空间。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
int res, k;
public int kthLargest(TreeNode root, int k) {
this.k = k;
dfs(root);
return res;
}
void dfs(TreeNode root){
if(root == null || k ==0)
return;
dfs(root.right);
if(--k ==0)
res = root.val;
dfs(root.left);
}
}
栈和队列(Stack & Queue):
005(09)-用两个栈实现队列
问题描述:
用两个栈实现一个队列。队列的声明如下,请实现它的两个函数 appendTail 和 deleteHead ,分别完成在队列尾部插入整数和在队列头部删除整数的功能。(若队列中没有元素,deleteHead 操作返回 -1 )
方法一:一个栈存储元素,一个栈辅助
维护两个栈,第一个栈存储元素,第二个栈用于辅助操作。
根据栈的特性,第一个栈的底部元素是最后插入的元素,第一个栈的顶部元素是下一个被删除的元素。为了维护队列的特性,每次插入的元素应该在第一个栈的底部。因此每次插入元素时,若第一个栈内已经有元素,应将已有的全部元素依次弹出并压入第二个栈,然后将新元素压入第一个栈,最后将第二个栈内的全部元素依次弹出并压入第一个栈。经过上述操作,新插入的元素在第一个栈的底部,第一个栈内的其余元素的顺序和插入元素之前保持一致。
删除元素时,若第一个栈非空,则直接从第一个栈内弹出一个元素并返回,若第一个栈为空,则返回 -1。
另外维护队列的元素个数,用于判断队列是否为空。初始元素个数为 0。每次插入元素,元素个数加 1。每次删除元素,元素个数减 1。
成员变量
• 维护两个栈 stack1 和 stack2,其中 stack1 用于存储元素,stack2 用于辅助操作
• 维护 size 表示队列元素个数
构造方法
• 初始化 stack1 和 stack2 为空
• 初始化 size = 0
插入元素
插入元素对应方法 appendTail
• 如果 stack1 非空,则将 stack1 内的元素依次弹出并依次压入 stack2,直至 stack1 内的全部元素都被弹出
• 将新元素 value 压入 stack1 内
• 如果 stack2 非空,则将 stack2 内的元素依次弹出并依次压入 stack1,直至 stack2 内的全部元素都被弹出
• 将 size 的值加 1
删除元素
删除元素对应方法 deleteHead
• 如果 size 为 0,则队列为空,返回 -1
• 如果 size 大于 0,则队列非空,将 size 的值减 1,从 stack1 弹出一个元素并返回
复杂度分析
插入元素
• 时间复杂度:O(n)。插入元素时,对于已有元素,每个元素都要弹出栈两次,压入栈两次,因此是线性时间复杂度。
• 空间复杂度:O(n)。需要使用额外的空间存储已有元素。
删除元素
• 时间复杂度:O(1)。判断元素个数和删除队列头部元素都使用常数时间。
• 空间复杂度:O(1)。从第一个栈弹出一个元素,使用常数空间。
class CQueue {
Stack<Integer> stack1;
Stack<Integer> stack2;
int size;
public CQueue() {
stack1 = new Stack<Integer>();
stack2 = new Stack<Integer>();
size = 0;
}
public void appendTail(int value) {
while(!stack1.isEmpty()){
stack2.push(stack1.pop());
}
stack1.push(value);
while(!stack2.isEmpty()){
stack1.push(stack2.pop());
}
size++;
}
public int deleteHead() {
if(size == 0){
return -1;
}
size--;
return stack1.pop();
}
}
/**
* Your CQueue object will be instantiated and called as such:
* CQueue obj = new CQueue();
* obj.appendTail(value);
* int param_2 = obj.deleteHead();
*/
方法二:
解题思路:
• 栈无法实现队列功能: 栈底元素(对应队首元素)无法直接删除,需要将上方所有元素出栈。
• 双栈可实现列表倒序: 设有含三个元素的栈 A = [1,2,3]A=[1,2,3] 和空栈 B = []。若循环执行 A 元素出栈并添加入栈 B ,直到栈 A 为空,则 A = [], B = [3,2,1] ,即 栈 B 元素实现栈 A 元素倒序 。
• 利用栈 B 删除队首元素: 倒序后,B 执行出栈则相当于删除了 A 的栈底元素,即对应队首元素。
函数设计:
题目只要求实现 加入队尾appendTail() 和 删除队首deleteHead() 两个函数的正常工作,因此我们可以设计栈 A 用于加入队尾操作,栈 B 用于将元素倒序,从而实现删除队首元素。
• 加入队尾 appendTail()函数: 将数字 val 加入栈 A 即可。
• 删除队首deleteHead()函数: 有以下三种情况。
1.当栈 B 不为空: B中仍有已完成倒序的元素,因此直接返回 B 的栈顶元素。
2.否则,当 A 为空: 即两个栈都为空,无元素,因此返回 -1。
3.否则: 将栈 A 元素全部转移至栈 B 中,实现元素倒序,并返回栈 B 的栈顶元素。
复杂度分析:
由于问题特殊,以下分析仅满足添加 N 个元素并删除 N 个元素,即栈初始和结束状态下都为空的情况。
• 时间复杂度: appendTail()函数为 O(1) ;deleteHead() 函数在 N 次队首元素删除操作中总共需完成 N 个元素的倒序。
• 空间复杂度 O(N): 最差情况下,栈 A 和 B 共保存 N 个元素。
class CQueue {
LinkedList<Integer> A,B;
public CQueue() {
A = new LinkedList<Integer>();
B = new LinkedList<Integer>();
}
public void appendTail(int value) {
A.addLast(value);
}
public int deleteHead() {
if(!B.isEmpty()){
return B.removeLast();
}
if(A.isEmpty()){
return -1;
}
while(!A.isEmpty()){
B.addLast(A.removeLast());
}
return B.removeLast();
}
}
/**
* Your CQueue object will be instantiated and called as such:
* CQueue obj = new CQueue();
* obj.appendTail(value);
* int param_2 = obj.deleteHead();
*/
020(30)-包含min函数的栈
问题描述:
定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的 min 函数在该栈中,调用 min、push 及 pop 的时间复杂度都是 O(1)。
示例:
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.min(); --> 返回 -3.
minStack.pop();
minStack.top(); --> 返回 0.
minStack.min(); --> 返回 -2.
解题思路:
普通栈的 push() 和 pop() 函数的复杂度为 O(1) ;而获取栈最小值 min() 函数需要遍历整个栈,复杂度为 O(N) 。
• 本题难点: 将 min() 函数复杂度降为 O(1) ,可通过建立辅助栈实现;
○ 数据栈 A : 栈 A 用于存储所有元素,保证入栈 push() 函数、出栈 pop() 函数、获取栈顶 top() 函数的正常逻辑。
○ 辅助栈 B : 栈 B 中存储栈 A 中所有 非严格降序 的元素,则栈 A 中的最小元素始终对应栈 B 的栈顶元素,即 min() 函数只需返回栈 B 的栈顶元素即可。
• 因此,只需设法维护好 栈 B 的元素,使其保持非严格降序,即可实现 min() 函数的 O(1) 复杂度。
函数设计:
• push(x) 函数: 重点为保持栈 B 的元素是 非严格降序 的。
1.将 x 压入栈 A (即 A.add(x) );
2.若 ① 栈 BB 为空 或 ② xx 小于等于 栈 BB 的栈顶元素,则将 xx 压入栈 BB (即 B.add(x) )。
• pop() 函数: 重点为保持栈 A, B的 元素一致性 。
1.执行栈 A 出栈(即 A.pop() ),将出栈元素记为 y ;
2.若 y 等于栈 B 的栈顶元素,则执行栈 B 出栈(即 B.pop() )。
• top() 函数: 直接返回栈 A 的栈顶元素即可,即返回 A.peek() 。
• min() 函数: 直接返回栈 B 的栈顶元素即可,即返回 B.peek() 。
复杂度分析:
时间复杂度 O(1) : push(), pop(), top(), min() 四个函数的时间复杂度均为常数级别。
空间复杂度 O(N): 当共有 N 个待入栈元素时,辅助栈 B 最差情况下存储 N 个元素,使用 O(N) 额外空间。
class MinStack {
Stack<Integer> A,B;
/** initialize your data structure here. */
public MinStack() {
A = new Stack<>();
B = new Stack<>();
}
public void push(int x) {
A.add(x);
if(B.empty() || B.peek() >= x){
B.add(x);
}
}
public void pop() {
if(A.pop().equals(B.peek())){
B.pop();
}
}
public int top() {
return A.peek();
}
public int min() {
return B.peek();
}
}
/**
* Your MinStack object will be instantiated and called as such:
* MinStack obj = new MinStack();
* obj.push(x);
* obj.pop();
* int param_3 = obj.top();
* int param_4 = obj.min();
*/
021(31)-栈的压入、弹出序列
问题描述:
输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如,序列 {1,2,3,4,5} 是某栈的压栈序列,序列 {4,5,3,2,1} 是该压栈序列对应的一个弹出序列,但 {4,3,5,1,2} 就不可能是该压栈序列的弹出序列。
解题思路:
如下图所示,给定一个压入序列 pushed 和弹出序列 popped ,则压入 / 弹出操作的顺序(即排列)是 唯一确定 的。
如下图所示,栈的数据操作具有 先入后出 的特性,因此某些弹出序列是无法实现的。
考虑借用一个辅助栈 stack,模拟 压入 / 弹出操作的排列。根据是否模拟成功,即可得到结果。
• 入栈操作: 按照压栈序列的顺序执行。
• 出栈操作: 每次入栈后,循环判断 “栈顶元素 == 弹出序列的当前元素” 是否成立,将符合弹出序列顺序的栈顶元素全部弹出。
算法流程:
1.初始化: 辅助栈 stack,弹出序列的索引 ii ;
2.遍历压栈序列: 各元素记为 num ;
1.元素 num入栈;
2.循环出栈:若 stack 的栈顶元素 == 弹出序列元素 popped[i] ,则执行出栈与 i++ ;
3.返回值: 若 stack为空,则此弹出序列合法。
复杂度分析:
• 时间复杂度 O(N) : 其中 N 为列表 pushed 的长度;每个元素最多入栈与出栈一次,即最多共 2N 次出入栈操作。
• 空间复杂度 O(N) : 辅助栈 stack 最多同时存储 N 个元素。
class Solution {
public boolean validateStackSequences(int[] pushed, int[] popped) {
Stack<Integer> stack = new Stack<>();
int i = 0;
for(int num : pushed){
stack.push(num); //num入栈
while(!stack.isEmpty() && stack.peek() == popped[i]){ //循环判断与出栈
stack.pop();
i++;
}
}
return stack.isEmpty();
}
}
044(58)-翻转单词顺序(栈)
问题描述:
输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变。为简单起见,标点符号和普通字母一样处理。例如输入字符串"I am a student. “,则输出"student. a am I”。
方法一:双指针
算法解析:
• 倒序遍历字符串 s ,记录单词左右索引边界 i , j ;
• 每确定一个单词的边界,则将其添加至单词列表 res;
• 最终,将单词列表拼接为字符串,并返回即可。
复杂度分析:
• 时间复杂度 O(N): 其中 N 为字符串 s 的长度,线性遍历字符串。
• 空间复杂度 O(N) : 新建的 list(Python) 或 StringBuilder(Java) 中的字符串总长度 ≤N ,占用 O(N) 大小的额外空间。
class Solution {
public String reverseWords(String s) {
s = s.trim();
int j = s.length() - 1, i = j;
StringBuilder res = new StringBuilder();
while(i >= 0) {
while(i >= 0 && s.charAt(i) != ' ') i--; // 搜索首个空格
res.append(s.substring(i + 1, j + 1) + " "); // 添加单词
while(i >= 0 && s.charAt(i) == ' ') i--; // 跳过单词间空格
j = i; // j 指向下个单词的尾字符
}
return res.toString().trim(); // 转化为字符串并返回
}
}
064(59.1)-滑动窗口的最大值(双端队列)
问题描述:
给定一个数组 nums
和滑动窗口的大小 k
,请找出所有滑动窗口里的最大值。
示例:
输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
解题思路:
我们先看什么是单调的双向队列,双向队列大家都知道,既能在头部进行插入、删除操作,也能在尾部进行插入删除操作,而所谓的单调,就是我们人为规定从队列的头部到尾部,所存储的元素是依次递减(或依次递增)的。如下所示:
头部 尾部
--------------------------
| 5 3 2 1 0 -1 |
--------------------------
由大 → 到小
也就是说,我们维护一个单调的双向队列,窗口在每次滑动的时候,我就从队列头部取当前窗口中的最大值,每次窗口新进来一个元素的时候,我就将它与队列中的元素进行大小比较:
• 如果刚刚进来的元素比队列的尾部元素大,那么先将队列尾部的元素弹出,然后把刚刚进来的元素添到队列的尾部;
• 如果刚刚进来的元素比队列的尾部元素小,那么把刚刚进来的元素直接添到队列的尾部即可。
下面通过图示进行说明。
分析
添加元素
在不规定窗口大小的前提下,我们先看看如何将新元素添加到单调的双向队列中。假如有5、4、1、2、6要进入单调的双向队列,首先让索引为0的元素5进入,由于之前队列是空的,所以5直接进去即可,如下所示:
头部 尾部
--------------------------
| 5 |
| ↑ |
| 0 |
--------------------------
大 小
此时,索引1位置上的4要进队列,则需要比较队列尾部与4的大小关系。由于5是大于4的,并且4从尾部进去以后能够满足从头到尾、从大到小的规定,所以我们让4进去即可,如下所示:
头部 尾部
--------------------------
| 5 4 |
| ↑ ↑ |
| 0 1 |
--------------------------
大 小
然后,索引2
位置上的元素1
也想要进去,根据我们的规定,让它直接进入就好了,如下所示:
头部 尾部
--------------------------
| 5 4 1 |
| ↑ ↑ ↑ |
| 0 1 2 |
--------------------------
大 小
然后,索引3位置上的元素2想要进去,此时,由于尾部的元素1是小于元素2的,2进去以后不满足从大到小的规定,所以让1从尾部出来,直接丢掉它,然后再让元素2从尾部进入,如下所示:
头部 尾部
--------------------------
| 5 4 2 |
| ↑ ↑ ↑ |
| 0 1 3 |
--------------------------
大 小
你可以看到,对于上面的这些过程,每次在元素进来之前,我们都可以通过LinkedList.peekFirst()操作来获取队列的头部,也就是整个队列的最大值,同时也是当前窗口的最大值。
每进入一个元素我就可以取最大值,每进入一个元素我就可以取最大值,每进入一个元素我就可以取最大值。(这句话说了三遍~)
因此,我就是通过这种既能从头部进出,又能从尾部进出的结构,来维持窗口的最大值的。
好了,现在还剩下索引为4的元素6想要进入队列,我们发现6比队列中任何一个元素都要大,所以我们将队列中的所有元素都弹出,然后只让6进入,如下所示:
头部 尾部
--------------------------
| 6 |
| ↑ |
| 4 |
--------------------------
大 小
此时,窗口中的最大值就是6了。要注意一点的是:如果此时又来了一个索引为5的元素6想要进入队列中,则我们需要将之前的索引为4的元素6进行弹出,让新来的6进入,此时就变成了如下所示:
头部 尾部
--------------------------
| 6 |
| ↑ |
| 5 |
--------------------------
大 小
为什么在元素相等的情况下,也要更新元素呢?
这是因为窗口是每次向右进行滑动的,每次进入到窗口中的值都有可能是当前窗口中最大的值,我们将相同的值进行更换,其实是为了更新它的索引。这样在窗口进行滑动的时候,每次的最大值都是新的,就能保持最大。
删除元素
不妨假设以下场景,窗口大小是 2,之前窗口中包含5和4,但是此时已经来到了4、1元素,队列中的情况也如下所示:
元素: 5 [4 1] 2
索引: 0 1 2 3
头部 尾部
--------------------------
| 5 4 1 |
| ↑ ↑ ↑ |
| 0 1 2 |
--------------------------
大 小
由于元素5已经被滑动窗口略过了,所以我们应将队列中的最大值,也就是5弹出,让4成为当前窗口新的最大值,如下所示:
头部 尾部
--------------------------
| 4 1 |
| ↑ ↑ |
| 1 2 |
--------------------------
大 小
总结
总之,整个流程看起来比较繁琐,不过你可以用一个具体的例子,按照下面的代码走一遍,就能知道整个流程是怎么样运行的。
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if(nums == null || k<1 || nums.length < k){
return new int[0];
}
int index = 0;
int[] res = new int[nums.length-k+1];
LinkedList<Integer> qMax = new LinkedList<>();
for(int i = 0; i < nums.length; i++){
// 在队列不为空的情况下,如果队列尾部的元素要比当前的元素小,或等于当前的元素
// 那么为了维持从大到小的原则,我必须让尾部元素弹出
while (!qMax.isEmpty() && nums[qMax.peekLast()] <= nums[i]) {
qMax.pollLast();
}
// 不走 while 的话,说明我们正常在队列尾部添加元素
qMax.addLast(i);
// 如果滑动窗口已经略过了队列中头部的元素,则将头部元素弹出
if (qMax.peekFirst() == (i - k)) {
qMax.pollFirst();
}
// 看看窗口有没有形成,只有形成了大小为 k 的窗口,我才能收集窗口内的最大值
if (i >= (k - 1)) {
res[index++] = nums[qMax.peekFirst()];
}
}
return res;
}
}
堆(Heap):
029(40)-最小的K个数
问题描述:
输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。
示例:
输入:arr = [3,2,1], k = 2
输出:[1,2] 或者 [2,1]
==方法一:==用快排最最最高效解决 TopK 问题:O(N)
注意找前 K 大/前 K 小问题不需要对整个数组进行 O(NlogN)O(NlogN) 的排序!
例如本题,直接通过快排切分排好第 K 小的数(下标为 K-1),那么它左边的数就是比它小的另外 K-1 个数啦~
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if(k == 0 || arr.length == 0){
return new int[0];
}
//最后一个参数表示我们要找的是下标为k-1的数
return quickSearch(arr, 0, arr.length - 1, k - 1);
}
private int[] quickSearch(int[] nums, int lo, int hi, int k){
//每块排切分1次,找到排序后下标为j的元素,如果j恰好等于k就返回j以及j左边所有的数;
int j = partition(nums, lo, hi);
if(j == k){
return Arrays.copyOf(nums, j + 1);
}
// 否则根据下标j与k的大小关系来决定继续切分左段还是右段。
return j > k? quickSearch(nums, lo, j - 1, k) : quickSearch(nums, j + 1, hi, k);
}
// 快排切分,返回下标j,使得比nums[j]小的数都在j的左边,比nums[j]大的数都在j的右边。
private int partition(int[] nums, int lo, int hi){
int v = nums[lo];
int i = lo, j = hi + 1;
while(true){
while(++i <= hi && nums[i] < v);
while(--j <= hi && nums[j] > v);
if(i >= j){
break;
}
int t = nums[j];
nums[j] = nums[i];
nums[i] = t;
}
nums[lo] = nums[j];
nums[j] = v;
return j;
}
}
==方法二:==大根堆(前 K 小) / 小根堆(前 K 大),Java中有现成的 PriorityQueue,实现起来最简单:O(NlogK)
本题是求前 K 小,因此用一个容量为 K 的大根堆,每次 poll 出最大的数,那堆中保留的就是前 K 小啦(注意不是小根堆!小根堆的话需要把全部的元素都入堆,那是 O(NlogN),就不是 O(NlogK)。这个方法比快排慢,但是因为 Java 中提供了现成的 PriorityQueue(默认小根堆)
// 保持堆的大小为K,然后遍历数组中的数字,遍历的时候做如下判断:
// 1. 若目前堆的大小小于K,将当前数字放入堆中。
// 2. 否则判断当前数字与大根堆堆顶元素的大小关系,如果当前数字比大根堆堆顶还大,这个数就直接跳过;
// 反之如果当前数字比大根堆堆顶小,先poll掉堆顶,再将该数字放入堆中。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) {
return new int[0];
}
// 默认是小根堆,实现大根堆需要重写一下比较器。
Queue<Integer> pq = new PriorityQueue<>((v1, v2) -> v2 - v1);
for (int num: arr) {
if (pq.size() < k) {
pq.offer(num);
} else if (num < pq.peek()) {
pq.poll();
pq.offer(num);
}
}
// 返回堆中的元素
int[] res = new int[pq.size()];
int idx = 0;
for(int num: pq) {
res[idx++] = num;
}
return res;
}
}
哈希表(Hash Table):
034(50)-第一个只出现一次的字符
问题描述:
在字符串 s 中找出第一个只出现一次的字符。如果没有,返回一个单空格。
==方法一:==哈希表
1. 遍历字符串 s ,使用哈希表统计 “各字符数量是否 > 1 ”。
2. 再遍历字符串 s ,在哈希表中找到首个 “数量为 1 的字符”,并返回。
算法流程:
1. 初始化: 字典(Python)、HashMap(Java),记为 dic ;
2. 字符统计: 遍历字符串 s 中的每个字符 c ;
1. 若 dic 中 不包含 键(key) c :则向 dic 中添加键值对 (c, True) ,代表字符 c 的数量为 1 ;
2. 若 dic 中 包含 键(key) c :则修改键 c 的键值对为 (c, False) ,代表字符 c 的数量 > 1 。
3. 查找数量为 1 的字符: 遍历字符串 s 中的每个字符 c ;
1. 若 dic中键 c 对应的值为 True :,则返回 c 。
4. 返回 ' ' ,代表字符串无数量为 1 的字符。
复杂度分析:
时间复杂度 O(N): N 为字符串 s 的长度;需遍历 s 两轮,使用 O(N);HashMap 查找的操作复杂度为O(1);
空间复杂度 O(N): HashMap 需存储 N 个字符的键值对,使用 O(N) 大小的额外空间。
class Solution {
public char firstUniqChar(String s) {
HashMap<Character, Boolean> dic = new HashMap<>();
char[] sc = s.toCharArray();
for(char c : sc){
dic.put(c, !dic.containsKey(c));
}
for(char c : sc){
if(dic.get(c)){
return c;
}
}
return ' ';
}
}
==方法二:==有序哈希表
在哈希表的基础上,有序哈希表中的键值对是 按照插入顺序排序 的。基于此,可通过遍历有序哈希表,实现搜索首个 “数量为 1 的字符”。
哈希表是 去重 的,即哈希表中键值对数量 ≤ 字符串 s 的长度。因此,相比于方法一,方法二减少了第二轮遍历的循环次数。当字符串很长(重复字符很多)时,方法二则效率更高。
复杂度分析:
时间和空间复杂度均与 “方法一” 相同,而具体分析时间复杂度:
方法一 O(2N): N为字符串 s 的长度;需遍历 s 两轮;
方法二 O(N) :遍历 s 一轮,遍历 dic 一轮。
class Solution {
public char firstUniqChar(String s) {
Map<Character, Boolean> dic = new LinkedHashMap<>();
char[] sc = s.toCharArray();
for(char c : sc){
dic.put(c, !dic.containsKey(c));
}
for(Map.Entry<Character, Boolean> d : dic.entrySet()){
if(d.getValue()){
return d.getKey();
}
}
return ' ';
}
}
图、回溯:
065(12)-矩阵中的路径
问题描述:
请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格。如果一条路径经过了矩阵的某一格,那么该路径不能再次进入该格子。
示例:
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true
输入:board = [["a","b"],["c","d"]], word = "abcd"
输出:false
解题思路:
本问题是典型的矩阵搜索问题,可使用 深度优先搜索(DFS)+ 剪枝 解决。
算法原理:
深度优先搜索: 可以理解为暴力法遍历矩阵中所有字符串可能性。DFS 通过递归,先朝一个方向搜到底,再回溯至上个节点,沿另一个方向搜索,以此类推。
剪枝: 在搜索中,遇到 这条路不可能和目标字符串匹配成功 的情况(例如:此矩阵元素和目标字符不同、此元素已被访问),则应立即返回,称之为 可行性剪枝 。
算法剖析:
• 递归参数: 当前元素在矩阵 board 中的行列索引 i 和 j ,当前目标字符在 word 中的索引 k 。
• 终止条件:
1.返回 false : ① 行或列索引越界 或 ② 当前矩阵元素与目标字符不同 或 ③ 当前矩阵元素已访问过 (③ 可合并至 ② ) 。
2.返回 true: 字符串 word 已全部匹配,即 k = len(word) - 1 。
• 递推工作:
1.标记当前矩阵元素: 将 board[i][j] 值暂存于变量 tmp ,并修改为字符 ‘/’ ,代表此元素已访问过,防止之后搜索时重复访问。
2.搜索下一单元格: 朝当前元素的 上、下、左、右 四个方向开启下层递归,使用或连接 (代表只需一条可行路径) ,并记录结果至 res 。
3.还原当前矩阵元素: 将 tmp 暂存值还原至 board[i][j] 元素。
• 回溯返回值: 返回 res ,代表是否搜索到目标字符串。
图解中,从每个节点 DFS 的顺序为:下、上、右、左。
复杂度分析:
回头leetcode上再看看
class Solution {
public boolean exist(char[][] board, String word) {
char[] words = word.toCharArray();
for(int i = 0; i < board.length; i++){
for(int j = 0; j < board[0].length; j++){
if(dfs(board, words, i, j, 0)){
return true;
}
}
}
return false;
}
boolean dfs(char[][] board, char[] word, int i, int j, int k){
if(i >= board.length || i < 0 || j >= board[0].length || j < 0 || board[i][j] != word[k]){
return false;
}
if(k == word.length - 1){
return true;
}
char tmp = board[i][j];
board[i][j] = '/';
boolean res = dfs(board, word, i + 1, j, k + 1) || dfs(board, word, i -1, j, k + 1) ||
dfs(board, word, i, j + 1, k + 1) || dfs(board, word, i , j - 1, k + 1);
board[i][j] = tmp;
return res;
}
}
066(13)-机器人的运动范围(DFS)
问题描述:
地上有一个m行n列的方格,从坐标 [0,0] 到坐标 [m-1,n-1] 。一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向左、右、上、下移动一格(不能移动到方格外),也不能进入行坐标和列坐标的数位之和大于k的格子。例如,当k为18时,机器人能够进入方格 [35, 37] ,因为3+5+3+7=18。但它不能进入方格 [35, 38],因为3+5+3+8=19。请问该机器人能够到达多少个格子?
方法一:深度优先遍历 DFS
深度优先搜索: 可以理解为暴力法模拟机器人在矩阵中的所有路径。DFS 通过递归,先朝一个方向搜到底,再回溯至上个节点,沿另一个方向搜索,以此类推。
剪枝: 在搜索中,遇到数位和超出目标值、此元素已访问,则应立即返回,称之为 可行性剪枝 。
算法解析:
• 递归参数: 当前元素在矩阵中的行列索引 i 和 j ,两者的数位和 si, sj 。
• 终止条件: 当 ① 行列索引越界 或 ② 数位和超出目标值 k 或 ③ 当前元素已访问过 时,返回 00 ,代表不计入可达解。
• 递推工作:
1.标记当前单元格 :将索引 (i, j) 存入 Set visited 中,代表此单元格已被访问过。
2.搜索下一单元格: 计算当前元素的 下、右 两个方向元素的数位和,并开启下层递归 。
• 回溯返回值: 返回 1 + 右方搜索的可达解总数 + 下方搜索的可达解总数,代表从本单元格递归搜索的可达解总数。
复杂度分析:
M, N 分别为矩阵行列大小。
时间复杂度 O(MN): 最差情况下,机器人遍历矩阵所有单元格,此时时间复杂度为 O(MN) 。
空间复杂度 O(MN): 最差情况下,Set visited 内存储矩阵所有单元格的索引,使用 O(MN) 的额外空间。
class Solution {
int m,n,k;
boolean visited[][];
public int movingCount(int m, int n, int k) {
this.m = m;
this.n = n;
this.k = k;
this.visited = new boolean[m][n];
return dfs(0,0,0,0);
}
public int dfs(int i, int j, int si, int sj){
if(i >= m || j >= n || k < si + sj || visited[i][j]){
return 0;
}
visited[i][j] = true;
return 1 + dfs(i + 1, j, (i + 1) % 10 != 0 ? si + 1 : si - 8, sj) +
dfs(i, j + 1, si, (j + 1) % 10 != 0 ? sj + 1 : sj - 8);
}
}
斐波那契数列:
007(10.1)-斐波拉契数列
问题描述:
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项。斐波那契数列的定义如下:
F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
解题思路:
斐波那契数列的定义是 f(n + 1) = f(n) + f(n - 1)f(n+1)=f(n)+f(n−1) ,生成第 nn 项的做法有以下几种:
递归法:
原理: 把 f(n)f(n) 问题的计算拆分成 f(n-1)f(n−1) 和 f(n-2)f(n−2) 两个子问题的计算,并递归,以 f(0)f(0) 和 f(1)f(1) 为终止条件。
缺点: 大量重复的递归计算,例如 f(n)f(n) 和 f(n - 1)f(n−1) 两者向下递归需要 各自计算 f(n - 2)f(n−2) 的值。
记忆化递归法:
原理: 在递归法的基础上,新建一个长度为 nn 的数组,用于在递归时存储 f(0)f(0) 至 f(n)f(n) 的数字值,重复遇到某数字则直接从数组取用,避免了重复的递归计算。
缺点: 记忆化存储需要使用 O(N)O(N) 的额外空间。
动态规划:
原理: 以斐波那契数列性质 f(n + 1) = f(n) + f(n - 1)f(n+1)=f(n)+f(n−1) 为转移方程。
从计算效率、空间复杂度上看,动态规划是本题的最佳解法。
复杂度分析:
时间复杂度 O(N) : 计算 f(n) 需循环 n 次,每轮循环内计算操作使用 O(1) 。
空间复杂度 O(1) : 几个标志变量使用常数大小的额外空间。
class Solution {
public int fib(int n) {
int a = 0, b = 1, sum;
for(int i = 0; i < n; i++){
sum = (a + b) % 1000000007;
a = b;
b = sum;
}
return a;
}
}
008(10.2)-青蛙跳台阶问题
问题描述:
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
解题思路:
此类求 多少种可能性 的题目一般都有 递推性质 ,即 f(n)和 f(n-1)…f(1) 之间是有联系的。
设跳上 n 级台阶有 f(n) 种跳法。在所有跳法中,青蛙的最后一步只有两种情况: 跳上 1 级或 2 级台阶。
当为 1 级台阶: 剩 n-1 个台阶,此情况共有 f(n-1) 种跳法;
当为 2 级台阶: 剩 n-2个台阶,此情况共有 f(n-2) 种跳法。
f(n) 为以上两种情况之和,即 f(n)=f(n-1)+f(n-2),以上递推性质为斐波那契数列。本题可转化为 求斐波那契数列第 n 项的值 ,与 面试题10- I. 斐波那契数列 等价,唯一的不同在于起始数字不同。
青蛙跳台阶问题: f(0)=1 , f(1)=1 , f(2)=2 ;
斐波那契数列问题: f(0)=0 , f(1)=1 , f(2)=1 。
复杂度分析:
时间复杂度 O(N): 计算 f(n)需循环 n 次,每轮循环内计算操作使用 O(1) 。
空间复杂度 O(1) : 几个标志变量使用常数大小的额外空间。
class Solution {
public int numWays(int n) {
int a = 1, b = 1, sum;
for(int i = 0; i < n; i++){
sum = (a + b) % 1000000007;
a = b;
b = sum;
}
return a;
}
}
009-变态跳台阶
问题描述:
一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
数学推导
跳上 n-1 级台阶,可以从 n-2 级跳 1 级上去,也可以从 n-3 级跳 2 级上去…,那么
f(n-1) = f(n-2) + f(n-3) + … + f(0)
同样,跳上 n 级台阶,可以从 n-1 级跳 1 级上去,也可以从 n-2 级跳 2 级上去… ,那么
f(n) = f(n-1) + f(n-2) + … + f(0)
综上可得
f(n) - f(n-1) = f(n-1)
即
f(n) = 2*f(n-1)
所以 f(n) 是一个等比数列
public class Solution {
public int JumpFloorII(int target) {
if(target <= 0){
return -1;
}else if(target == 1){
return 1;
}else{
return 2 * JumpFloorII(target - 1);
}
}
}
010-矩形覆盖
问题描述:
我们可以用21的小矩形横着或者竖着去覆盖更大的矩形。请问用n个21的小矩形无重叠地覆盖一个2*n的大矩形,总共有多少种方法?
解题思路:
当 n 为 1 时,只有一种覆盖方法:
当 n 为 2 时,有两种覆盖方法:
要覆盖 2n 的大矩形,可以先覆盖 21 的矩形,再覆盖 2*(n-1) 的矩形;或者先覆盖 22 的矩形,再覆盖 2(n-2) 的矩形。而覆盖 2*(n-1) 和 2*(n-2) 的矩形可以看成子问题。
public class Solution {
public int RectCover(int target) {
if (target <= 2){
return target;
}
int pre2 = 1, pre1 = 2;
int result = 0;
for (int i = 3; i <= target; i++) {
result = pre2 + pre1;
pre2 = pre1;
pre1 = result;
}
return result;
}
}
搜索算法:
001(04)-二维数组查找
问题描述:
在一个 n * m 的二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
解题思路:
若使用暴力法遍历矩阵 matrix ,则时间复杂度为O(N∗M) 。暴力法未利用矩阵 “从上到下递增、从左到右递增” 的特点,显然不是最优解法。
本题解利用矩阵特点引入标志数,并通过标志数性质降低算法时间复杂度。
• 标志数引入: 此类矩阵中左下角和右上角元素有特殊性,称为标志数。
○ 左下角元素: 为所在列最大元素,所在行最小元素。
○ 右上角元素: 为所在行最大元素,所在列最小元素。
• 标志数性质: 将 matrix 中的左下角元素(标志数)记作 flag ,则有:
1.若 flag > target ,则 target 一定在 flag 所在行的上方,即 flag 所在行可被消去。
2.若 flag < target ,则 target 一定在 flag 所在列的右方,即 flag 所在列可被消去。
○ 本题解以左下角元素为例,同理,右上角元素 也具有行(列)消去的性质。
• 算法流程: 根据以上性质,设计算法在每轮对比时消去一行(列)元素,以降低时间复杂度。
1.从矩阵 matrix 左下角元素(索引设为 (i, j) )开始遍历,并与目标值对比:
• 当 matrix[i][j] > target 时: 行索引向上移动一格(即 i--),即消去矩阵第 i 行元素;
• 当 matrix[i][j] < target 时: 列索引向右移动一格(即 j++),即消去矩阵第 j 列元素;
• 当 matrix[i][j] == target 时: 返回 true 。
2.若行索引或列索引越界,则代表矩阵中无目标值,返回 false 。
算法本质: 每轮 i 或 j 移动后,相当于生成了“消去一行(列)的新矩阵”, 索引(i,j) 指向新矩阵的左下角元素(标志数),因此可重复使用以上性质消去行(列)。
复杂度分析:
• 时间复杂度 O(M+N) :其中,N 和 M分别为矩阵行数和列数,此算法最多循环 M+N 次。
• 空间复杂度 O(1) : i, j 指针使用常数大小额外空间。
class Solution {
public boolean findNumberIn2DArray(int[][] matrix, int target) {
int i = matrix.length - 1, j = 0;
while(i >= 0 && j < matrix[0].length){
if(matrix[i][j] > target){
i--;
}else if(matrix[i][j] < target){
j++;
}else{
return true;
}
}
return false;
}
}
006(11)-旋转数组的最小数字
问题描述:
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如,数组 [3,4,5,1,2] 为 [1,2,3,4,5] 的一个旋转,该数组的最小值为1。
解题思路:
如下图所示,寻找旋转数组的最小元素即为寻找 右排序数组 的首个元素 numbers[x] ,称 x 为 旋转点 。
排序数组的查找问题首先考虑使用 二分法 解决,其可将遍历法的 线性级别 时间复杂度降低至 对数级别 。
算法流程:
1.循环二分: 设置 i, j 指针分别指向 numbers 数组左右两端,m = (i + j) // 2m=(i+j)//2 为每次二分的中点( "//" 代表向下取整除法,因此恒有 i≤m<j ),可分为以下三种情况:
1.当 numbers[m] > numbers[j]时: m 一定在 左排序数组 中,即旋转点 x 一定在 [m + 1, j] 闭区间内,因此执行 i = m + 1;
2.当 numbers[m] < numbers[j] 时: m 一定在 右排序数组 中,即旋转点 x 一定在[i, m] 闭区间内,因此执行 j = m;
3.当 numbers[m] == numbers[j] 时: 无法判断 m 在哪个排序数组中,即无法判断旋转点 x 在 [i, m] 还是 [m + 1, j] 区间中。解决方案: 执行 j = j - 1 缩小判断范围 (分析见以下内容) 。
2.返回值: 当 i = j 时跳出二分循环,并返回 numbers[i] 即可。
复 杂 度 分 析 : 时 间 复 杂 度 O ( l o g 2 N ) : 在 特 例 情 况 下 ( 例 如 [ 1 , 1 , 1 , 1 ] ) , 会 退 化 到 O ( N ) 。 空 间 复 杂 度 O ( 1 ) : i , j , m 指 针 使 用 常 数 大 小 的 额 外 空 间 。 复杂度分析: 时间复杂度 O(log_2N): 在特例情况下(例如 [1, 1, 1, 1]),会退化到 O(N)。 空间复杂度 O(1): i, j, m 指针使用常数大小的额外空间。 复杂度分析:时间复杂度O(log2N):在特例情况下(例如[1,1,1,1]),会退化到O(N)。空间复杂度O(1):i,j,m指针使用常数大小的额外空间。
class Solution {
public int minArray(int[] numbers) {
int i = 0, j = numbers.length - 1;
while(i < j){
int m = (i + j) / 2;
if(numbers[m] > numbers[j]){
i = m + 1;
}else if(numbers[m] < numbers[j]){
j = m;
}else{
j--;
}
}
return numbers[i];
}
}
037(53.1)-数字在排序数组中出现的次数
问题描述:
统计一个数字在排序数组中出现的次数。
解题思路:
排序数组中的搜索问题,首先想到 二分法 解决。
• 排序数组 nums 中的所有数字 target 形成一个窗口,记窗口的 左 / 右边界 索引分别为 left和 right ,分别对应窗口左边 / 右边的首个元素。
• 本题要求统计数字 target 的出现次数,可转化为:使用二分法分别找到 左边界 left 和 右边界 right ,易得数字 target 的数量为 right - left - 1 。
算法解析:
1.初始化: 左边界 i = 0 ,右边界 j = len(nums) - 1 ;代表闭区间 [i, j] 。
2.循环二分: 当 i≤j 时循环 (即当闭区间 [i, j] 为空时跳出) ;
1.计算中点 m = (i + j) // 2,其中 “//” 为向下取整除法;
2.若 nums[m] < target ,则数字 target 一定在闭区间 [m + 1, j] 中,因此执行 i = m + 1;
3.若 nums[m] > target ,则数字 target 一定在闭区间 [i, m - 1] 中,因此执行 j = m - 1;
4.若 nums[m] = target ,则右边界 right 在闭区间 [m+1, j] 中;左边界 left 在闭区间 [i, m-1] 中。因此分为以下两种情况:
1.若查找 右边界 right,则执行 i = m + 1;(跳出时 i 指向右边界)
2.若查找 左边界 left ,则执行 j = m - 1 ;(跳出时 j 指向左边界)
3.返回值: 应用两次二分,分别查找 right 和 left ,最终返回 right - left - 1 即可。
复杂度分析:
时间复杂度 O(log N) : 二分法为对数级别复杂度。
空间复杂度 O(1) : 几个变量使用常数大小的额外空间。
class Solution {
public int search(int[] nums, int target) {
int i = 0, j = nums.length - 1;
while(i <= j){
int m = (i + j) / 2;
if(nums[m] <= target){
i = m + 1;
}else{
j = m - 1;
}
}
int right = i;
i = 0; j = nums.length - 1;
while(i <= j){
int m = (i + j) / 2;
if(nums[m] < target){
i = m + 1;
}else{
j = m - 1;
}
}
int left = j;
return right - left - 1;
}
}
全排列:
027(38)-字符串的排列
问题描述:(这道题要重新看看)
输入一个字符串,打印出该字符串中字符的所有排列。
你可以以任意顺序返回这个字符串数组,但里面不能有重复元素。
示例:
输入:s = “abc”
输出:[“abc”,“acb”,“bac”,“bca”,“cab”,“cba”]
解题思路:
排列方案数量: 对于一个长度为 n 的字符串(假设字符互不重复),其排列共有 n×(n−1)×(n−2)…×2×1 种方案。
排列方案的生成方法: 根据字符串排列的特点,考虑深度优先搜索所有排列方案。即通过字符交换,先固定第 1位字符( n 种情况)、再固定第 2 位字符( n-1 种情况)、… 、最后固定第 n 位字符( 1 种情况)。
重复方案与剪枝: 当字符串存在重复字符时,排列方案中也存在重复方案。为排除重复方案,需在固定某位字符时,保证 “每种字符只在此位固定一次” ,即遇到重复字符时不交换,直接跳过。从 DFS 角度看,此操作称为 “剪枝” 。
递归解析:
1.终止条件: 当 x = len(c) - 1x=len(c)−1 时,代表所有位已固定(最后一位只有 11 种情况),则将当前组合 c 转化为字符串并加入 res,并返回;
2.递推参数: 当前固定位 xx ;
3.递推工作: 初始化一个 Set ,用于排除重复的字符;将第 xx 位字符与 i \in [x, len(c)]i∈[x,len(c)] 字符分别交换,并进入下层递归;
1.剪枝: 若 c[i]在 Set中,代表其是重复字符,因此“剪枝”;
2.将 c[i] 加入 Set,以便之后遇到重复字符时剪枝;
3.固定字符: 将字符 c[i] 和 c[x] 交换,即固定 c[i] 为当前位字符;
4.开启下层递归: 调用 dfs(x + 1),即开始固定第 x + 1个字符;
5.还原交换: 将字符 c[i] 和 c[x] 交换(还原之前的交换);
复杂度分析:
• 时间复杂度 O(N!) : N 为字符串 s 的长度;时间复杂度和字符串排列的方案数成线性关系,方案数为 N×(N−1)×(N−2)…×2×1 ,因此复杂度为 O(N!) 。
• 空间复杂度 O(N^2): 全排列的递归深度为 N,系统累计使用栈空间大小为 O(N);递归中辅助 Set 累计存储的字符数量最多为 N + (N-1) + … + 2 + 1 = (N+1)N/2 ,即占用 O(N^2)的额外空间。
class Solution {
List<String> res = new LinkedList<>();
char[] c;
public String[] permutation(String s) {
c = s.toCharArray();
dfs(0);
return res.toArray(new String[res.size()]);
}
void dfs(int x) {
if(x == c.length - 1) {
res.add(String.valueOf(c)); // 添加排列方案
return;
}
HashSet<Character> set = new HashSet<>();
for(int i = x; i < c.length; i++) {
if(set.contains(c[i])) continue; // 重复,因此剪枝
set.add(c[i]);
swap(i, x); // 交换,将 c[i] 固定在第 x 位
dfs(x + 1); // 开启固定第 x + 1 位字符
swap(i, x); // 恢复交换
}
}
void swap(int a, int b) {
char tmp = c[a];
c[a] = c[b];
c[b] = tmp;
}
}
动态规划:
030(42)-连续子数组的最大和
问题描述:
输入一个整型数组,数组里有正数也有负数。数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。
要求时间复杂度为O(n)。
解题思路:
动态规划解析:
状态定义: 设动态规划列表dp ,dp[i] 代表以元素 nums[i] 为结尾的连续子数组最大和。
为何定义最大和 dp[i] 中必须包含元素 nums[i]:保证 dp[i] 递推到 dp[i+1] 的正确性;如果不包含 nums[i] ,递推时则不满足题目的连续子数组要求。
转移方程: 若dp[i−1]≤0,说明dp[i−1]对dp[i]产生负贡献,即dp[i−1]+nums[i]还不如nums[i] 本身大。
当 dp[i−1]>0 时:执行 dp[i]=dp[i−1]+nums[i] ;
当 dp[i−1]≤0 时:执行 dp[i]=nums[i] ;
初始状态:dp[0]=nums[0],即以 nums[0] 结尾的连续子数组最大和为 nums[0] 。
返回值: 返回 dp 列表中的最大值,代表全局最大值。
空间复杂度降低:
由于 dp[i] 只与 dp[i−1] 和 nums[i] 有关系,因此可以将原数组 nums 用作 dp 列表,即直接在 nums 上修改即可。
由于省去 dp 列表使用的额外空间,因此空间复杂度从 O(N) 降至 O(1) 。
复杂度分析:
时间复杂度 O(N) : 线性遍历数组 nums 即可获得结果,使用 O(N) 时间。
空间复杂度 O(1) : 使用常数大小的额外空间。
class Solution {
public int maxSubArray(int[] nums) {
int res = nums[0];
for(int i = 1; i < nums.length; i++){
nums[i] += Math.max(nums[i - 1], 0);
res = Math.max(res, nums[i]);
}
return res;
}
}
052(19)-正则表达式匹配
问题描述:
请实现一个函数用来匹配包含'. '和'∗'的正则表达式。模式中的字符'.'表示任意一个字符,而'∗'表示它前面的字符可以出现任意次(含0次)。在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a"和"ab∗ac∗a"匹配,但与"aa.a"和"ab∗a"均不匹配。
解题思路:
假设主串为 A,模式串为 B 从最后一步出发,需要关注最后进来的字符。假设 A 的长度为 n ,B 的长度为 m ,关注正则表达式 B 的最后一个字符是谁,它有三种可能,正常字符、∗ 和 .(点),那针对这三种情况讨论即可,如下:
1.如果 B 的最后一个字符是正常字符,那就是看 A[n-1] 是否等于 B[m-1],相等则看 A0…n-2与 B0…m-2,不等则是不能匹配,这就是子问题。
2.如果 B 的最后一个字符是 . ,它能匹配任意字符,直接看 A0…n-2与 B0…m-2
3.如果 B 的最后一个字符是 ∗ 它代表 B[m-2]=c可以重复 0次或多次,它们是一个整体 c∗
○ 情况一:A[n-1] 是 0 个 c,B 最后两个字符废了,能否匹配取决于 A0…n-1与 B0…m-3 是否匹配
○ 情况二:A[n-1]是多个 c中的最后一个(这种情况必须 A[n-1]=c 或者 c=’.’),所以 A 匹配完往前挪一个,B 继续匹配,因为可以匹配多个,继续看 A0…n-2与 B0…m-1是否匹配。
转移方程
f[i][j]代表 A 的前 i个和 B 的前 j个能否匹配
• 对于前面两个情况,可以合并成一种情况 f[i][j] = f[i-1][j-1]
• 对于第三种情况,对于 c*c∗ 分为看和不看两种情况
○ 不看:直接砍掉正则串的后面两个, f[i][j] = f[i][j-2]
○ 看:正则串不动,主串前移一个,f[i][j] = f[i-1][j]
初始条件
特判:需要考虑空串空正则
• 空串和空正则是匹配的,f[0][0] = true
• 空串和非空正则,不能直接定义 true 和 false,必须要计算出来。(比如A="",B=a∗b∗c∗)
• 非空串和空正则必不匹配,f[1][0]=...=f[n][0]=false
• 非空串和非空正则,那肯定是需要计算的了。
大体上可以分为空正则和非空正则两种,空正则也是比较好处理的,对非空正则我们肯定需要计算,非空正则的三种情况,前面两种可以合并到一起讨论,第三种情况是单独一种,那么也就是分为当前位置是 ∗ 和不是 ∗ 两种情况了。
结果
我们开数组要开 n+1 ,这样对于空串的处理十分方便。结果就是 f[n][m]
代码如下
class Solution {
public boolean isMatch(String s, String p) {
int n = s.length();
int m = p.length();
boolean[][] f = new boolean[n+1][m+1];
for(int i = 0; i <= n; i++){
for(int j = 0; j <= m; j++){
//分成空正则和非空正则两种
if(j == 0){
f[i][j] = i == 0;
}else{
//非空正则分为两种情况*和非*
if(p.charAt(j - 1) != '*'){
if(i > 0 && (s.charAt(i - 1) == p.charAt(j - 1) || p.charAt(j - 1) == '.')){
f[i][j] = f[i - 1][j - 1];
}
}else{
//碰到 * 了,分为看和不看两种情况
//不看
if(j >= 2){
f[i][j] |= f[i][j - 2];
}
//看
if(i >= 1 && j >= 2 && (s.charAt(i - 1) == p.charAt(j - 2) || p.charAt(j - 2) == '.')){
f[i][j] |= f[i - 1][j];
}
}
}
}
}
return f[n][m];
}
}
排序:
035(51)-数组中的逆序对(归并排序)
问题描述:
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
示例:
输入: [7,5,6,4]
输出: 5
解题思路:
建议结合链接的视频理解
https://leetcode-cn.com/problems/shu-zu-zhong-de-ni-xu-dui-lcof/solution/shu-zu-zhong-de-ni-xu-dui-by-leetcode-solution/
class Solution {
public int reversePairs(int[] nums) {
int len = nums.length;
if (len < 2) {
return 0;
}
int[] copy = new int[len];
for (int i = 0; i < len; i++) {
copy[i] = nums[i];
}
int[] temp = new int[len];
return reversePairs(copy, 0, len - 1, temp);
}
/**
* nums[left..right] 计算逆序对个数并且排序
*
* @param nums
* @param left
* @param right
* @param temp
* @return
*/
private int reversePairs(int[] nums, int left, int right, int[] temp) {
if (left == right) {
return 0;
}
int mid = left + (right - left) / 2;
int leftPairs = reversePairs(nums, left, mid, temp);
int rightPairs = reversePairs(nums, mid + 1, right, temp);
if (nums[mid] <= nums[mid + 1]) {
return leftPairs + rightPairs;
}
int crossPairs = mergeAndCount(nums, left, mid, right, temp);
return leftPairs + rightPairs + crossPairs;
}
/**
* nums[left..mid] 有序,nums[mid + 1..right] 有序
*
* @param nums
* @param left
* @param mid
* @param right
* @param temp
* @return
*/
private int mergeAndCount(int[] nums, int left, int mid, int right, int[] temp) {
for (int i = left; i <= right; i++) {
temp[i] = nums[i];
}
int i = left;
int j = mid + 1;
int count = 0;
for (int k = left; k <= right; k++) {
if (i == mid + 1) {
nums[k] = temp[j];
j++;
} else if (j == right + 1) {
nums[k] = temp[i];
i++;
} else if (temp[i] <= temp[j]) {
nums[k] = temp[i];
i++;
} else {
nums[k] = temp[j];
j++;
count += (mid - i + 1);
}
}
return count;
}
}
029-最小的K个数(堆排序)
029-最小的K个数(快速排序)
以上两个问题都在堆(Heap)中有解答
位运算:
011(15)-二进制中1的个数
问题描述:
请实现一个函数,输入一个整数,输出该数二进制表示中 1 的个数。例如,把 9 表示成二进制是 1001,有 2 位是 1。因此,如果输入 9,则该函数输出 2。
方法一:逐位判断
• 根据 与运算 定义,设二进制数字 n,则有:
○ 若 n&1=0 ,则 n 二进制 最右一位 为 0;
○ 若 n&1=1 ,则 n 二进制 最右一位 为 1 。
• 根据以上特点,考虑以下 循环判断 :
1.判断 n 最右一位是否为 1 ,根据结果计数。
2.将 n 右移一位(本题要求把数字 n 看作无符号数,因此使用 无符号右移 操作)。
算法流程:
1.初始化数量统计变量 res=0 。
2.循环逐位判断: 当 n=0 时跳出。
1. res += n & 1 : 若 n&1=1 ,则统计数 res 加一。
2. n >>= 1 : 将二进制数字 n 无符号右移一位( Java 中无符号右移为 ">>>" ) 。
3. 返回统计数量 res 。
复杂度分析:
时间复杂度 O(log2n): 此算法循环内部仅有 移位、与、加 等基本运算,占用 O(1);逐位判断需循环O(log2n)次,其中 O(log2n) 代表数字 n 最高位 1 的所在位数(例如 O(log24)=2, O(log216) = 4)。
空间复杂度 O(1) : 变量 res 使用常数大小额外空间。
public class Solution {
public int hammingWeight(int n) {
int res = 0;
while(n != 0) {
res += n & 1;
n >>>= 1;
}
return res;
}
}
方法二:巧用 n&(n−1)
(n - 1) 解析: 二进制数字 n 最右边的 1 变成 0 ,此 1 右边的 0 都变成 1 。
n&(n−1) 解析: 二进制数字 n 最右边的 1 变成 0 ,其余不变。
算法流程:
初始化数量统计变量 res 。
循环消去最右边的 1 :当 n=0 时跳出。
res += 1 : 统计变量加 1 ;
n &= n - 1 : 消去数字 n 最右边的 1 。
返回统计数量 res 。
复杂度分析:
时间复杂度 O(M) : n&(n−1) 操作仅有减法和与运算,占用 O(1) ;设 M 为二进制数字 n 中 1 的个数,则需循环 M 次(每轮消去一个 1 ),占用 O(M) 。
空间复杂度 O(1) : 变量 res 使用常数大小额外空间。
public class Solution {
public int hammingWeight(int n) {
int res = 0;
while(n != 0) {
res++;
n &= n - 1;
}
return res;
}
}
012(16)-数值的整数次方
问题描述:
实现函数double Power(double base, int exponent),求base的exponent次方。不得使用库函数,同时不需要考虑大数问题。
解题思路:
求 xn 最简单的方法是通过循环将 n 个 x 乘起来,依次求 x1, x2, …, xn-1, xn,时间复杂度为 O(n) 。
快速幂法 可将时间复杂度降低至 O(log2 n),以下从 “二分法” 和 “二进制” 两个角度解析快速幂法。
快速幂解析(二分法角度):
快速幂实际上是二分思想的一种应用。
• 二分推导: xn = xn/2 * xn/2 = (xn/2)n/2 ,令 n/2 为整数,则需要分为奇偶两种情况(设向下取整除法符号为 “//” ):
○ 当 n 为偶数: xn = (x2)n//2;
○ 当 n 为奇数: xn = x(x2)n//2,即会多出一项 x ;
• 幂结果获取:
○ 根据二分推导,可通过循环 x = x2操作,每次把幂从 n 降至 n//2 ,直至将幂降为 0 ;
○ 设 res=1,则初始状态 xn = xn * res 。在循环二分时,每当 n 为奇数时,将多出的一项 x 乘入 res ,则最终可化至 xn = x0 * res = res ,返回 res 即可。
• 转化为位运算:
○ 向下整除 n//2 等价于 右移一位 n>>1 ;
○ 取余数 n%2 等价于 判断二进制最右一位值 n&1 ;
算法流程:
1.当 x=0 时:直接返回 0 (避免后续 x=1/x 操作报错)。
2.初始化 res=1 ;
3.当 n<0 时:把问题转化至 n≥0 的范围内,即执行 x=1/x ,n=−n ;
4.循环计算:当 n=0 时跳出;
1.当 n&1=1 时:将当前 x 乘入 res (即 res*=x );
2.执行 x = x2(即 x *= x);
3.执行 n 右移一位(即 n>>=1)。
5.返回 res 。
复杂度分析:
• 时间复杂度 O(log2n): 二分的时间复杂度为对数级别。
• 空间复杂度 )O(1) : res, b 等变量占用常数大小额外空间。
class Solution {
public double myPow(double x, int n) {
if(x == 0){
return 0;
}
long b = n;
double res = 1.0;
if(b < 0){
x = 1 / x;
b = -b;
}
while(b > 0){
if((b & 1) == 1){
res *= x;
}
x *= x;
b >>= 1;
}
return res;
}
}
040(56.1)-数组中只出现一次的数字
问题描述:
一个整型数组 nums 里除两个数字之外,其他数字都出现了两次。请写程序找出这两个只出现一次的数字。要求时间复杂度是O(n),空间复杂度是O(1)。
解题思路:
问题转化:
怎么在一堆情侣里面找出两只单身狗?
官方思路简单化:
已知像数组[5,2,2,3,3,4,4]全部数字一起异或结果为5,情侣组队出来一只单身狗。
核心思路:
核心思路简单一句话为:不希望两只单身狗组团,并且给他们塞一对一对的情侣恶心他们。
一只单身狗(单次数字)配一群情侣(两次出现),因为有两个单身狗所以两个旅游团。
重点来了,怎么找个简单方法把两只单身狗扔到不同旅游团,又要让每一对情侣在一起呢?
假设单身狗A为(1101 0001)
假设单身狗B为(1011 1101)
两人异或结果为(0110 1100)其他情侣异或纷纷抵消,不知道为啥的建议找个女朋友体会下。
那么可以用100、1000、10 0000、100 0000去和单身狗做&操作。
哎单身狗A说凭啥把我跟暗恋对象B分来呢,因为A(0001)和B(1101)从后往前数第三、四个数字异或为1,A的前面两个爱好和B前面两个爱好不一样,所以sorry,B对A说你是个好人,我们兴趣爱好不一样。
运算过程就是,用0000 0100这个爱好过滤规则(上面说的规则都可以,随便找一个最低位的)对AB做&运算:
A 1101 0001
& 0000 0100
0000 0000 = 0
B 1011 1101
& 0000 0100
0000 0100 = 4
至于其他一对一对情侣,他们爱好都一样,什么样的过滤规则都分不开,我管你去到那个旅游团
class Solution {
public int[] singleNumbers(int[] nums) {
//用于将所有的数异或起来
int k = 0;
for(int num : nums){
k ^= num;
}
//获得k中最低位的1
int mask = 1;
//mask = k & (-k) 这种方法也可以得到mask
//-k 是 k 二进制数取反加1的结果
// k&(-k) ,对于是用补码的环境,k和-k相与可知以获得最低的非0位
while((k & mask) == 0){
mask <<= 1;
}
int a = 0;
int b = 0;
for(int num : nums){
if((num & mask) == 0){
a ^= num;
}else{
b ^= num;
}
}
return new int[]{a, b};
}
}
其他算法:
002(05)-替换空格
问题描述:
请实现一个函数,把字符串 s
中的每个空格替换成"%20"。
示例 1:
输入:s = "We are happy."
输出:"We%20are%20happy."
解法流程:
1.初始化一个 StringBuilder ,记为 res ;
2.遍历字符串 s 中的每个字符 c :
• 当 c 为空格时:向 res 后添加字符串 “%20”;
• 当 c 不为空格时:向 res 后添加字符 c ;
3.将 res 转化为 String 类型并返回。
复杂度分析:
时间复杂度 O(N) : 遍历使用 O(N) ,每轮添加(修改)字符操作使用 O(1) ;
空间复杂度 O(N): Java 新建的 StringBuilder 使用了线性大小的额外空间。
class Solution {
public String replaceSpace(String s) {
StringBuilder res = new StringBuilder();
for(char c : s.toCharArray()){
if(c == ' '){
res.append("%20");
}else{
res.append(c);
}
}
return res.toString();
}
}
013(21)-调整数组顺序使奇数位于偶数前面
问题描述:
输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分。
解题思路:
考虑定义双指针 i , j 分列数组左右两端,循环执行:
1.指针 i 从左向右寻找偶数;
2.指针 j 从右向左寻找奇数;
3.将 偶数 nums[i] 和 奇数 nums[j] 交换。
可始终保证: 指针 i 左边都是奇数,指针 j 右边都是偶数
算法流程:
1.初始化: i , j 双指针,分别指向数组 nums 左右两端;
2.循环交换: 当 i=j 时跳出;
1.指针 i 遇到奇数则执行 i=i+1 跳过,直到找到偶数;
2.指针 j 遇到偶数则执行 j=j−1 跳过,直到找到奇数;
3.交换 nums[i] 和 nums[j] 值;
3.返回值: 返回已修改的 nums 数组。
复杂度分析:
时间复杂度 O(N) : N 为数组 nums 长度,双指针 i, j 共同遍历整个数组。
空间复杂度 O(1) : 双指针 i, j 使用常数大小的额外空间。
class Solution {
public int[] exchange(int[] nums) {
int i = 0, j = nums.length - 1, tmp;
while(i < j){
while(i < j && (nums[i] & 1) == 1){
i++;
}
while(i < j && (nums[j] & 1) == 0){
j--;
}
tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
return nums;
}
}
028(39)-数组中出现次数超过一半的数字
问题描述:
数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
示例 1:
输入: [1, 2, 3, 2, 2, 2, 5, 4, 2]
输出: 2
解题思路:
本题常见解法如下:
1.哈希表统计法: 遍历数组 nums ,用 HashMap 统计各数字的数量,最终超过数组长度一半的数字则为众数。此方法时间和空间复杂度均为 O(N) 。
2.数组排序法: 将数组 nums 排序,由于众数的数量超过数组长度一半,因此 数组中点的元素 一定为众数。此方法时间复杂度 O(N log2N)
3.摩尔投票法: 核心理念为 “正负抵消” ;时间和空间复杂度分别为 O(N) 和 O(1) ;是本题的最佳解法。
摩尔投票法:
• 票数和: 由于众数出现的次数超过数组长度的一半;若记众数的票数为 +1 ,非众数的票数为 −1 ,则一定有所有数字的票数和 >0 。
• 票数正负抵消: 设数组nums 中的众数为 x ,数组长度为 n 。若 nums 的前 a 个数字的 票数和 =0 ,则数组后 (n−a) 个数字的 票数和一定仍 >0 (即后 (n−a) 个数字的 众数仍为 x )。
算法原理:
• 为构建正负抵消,假设数组首个元素 n1为众数,遍历统计票数,当发生正负抵消时,剩余数组的众数一定不变 ,这是因为(设真正的众数为 x ):
○ 当 n1=x: 抵消的所有数字中,有一半是众数 x 。
○ 当 n1 ≠ x : 抵消的所有数字中,少于或等于一半是众数 x 。
• 利用此特性,每轮假设都可以 缩小剩余数组区间 。当遍历完成时,最后一轮假设的数字即为众数(由于众数超过一半,最后一轮的票数和必为正数)。
算法流程:
1.初始化: 票数统计 votes = 0 , 众数 x;
2.循环抵消: 遍历数组 nums 中的每个数字 num ;
1.当 票数 votes 等于 0 ,则假设 当前数字 num 为 众数 x ;
2.当 num=x 时,票数 votes 自增 1 ;否则,票数 votes 自减 1 。
3.返回值: 返回 众数 x 即可。
复杂度分析:
• 时间复杂度 O(N) : N 为数组 nums 长度。
• 空间复杂度 O(1) : votes 变量使用常数大小的额外空间。
class Solution {
public int majorityElement(int[] nums) {
int x = 0, votes = 0;
for(int num : nums){
if(votes == 0){
x = num;
}
votes += num == x ? 1 : -1;
}
return x;
}
}
题目拓展:
• 由于题目明确给定 给定的数组总是存在多数元素 ,因此本题不用考虑 数组中不存在众数 的情况。
• 若考虑,则需要加入一个 “验证环节” ,遍历数组 nums 统计 x 的数量。
○ 若 x 的数量超过数组长度一半,则返回 x ;
○ 否则,返回 0 (这里根据不同题目的要求而定)。
• 时间复杂度仍为 O(N) ,空间复杂度仍为 O(1) 。
class Solution {
public int majorityElement(int[] nums) {
int x = 0, votes = 0, count = 0;
for(int num : nums){
if(votes == 0){
x = num;
}
votes += num == x ? 1 : -1;
}
//验证 x 是否为众数
for(int num : nums){
if(num == x){
count++;
}
}
return count > nums.length / 2 ? x : 0; //当无众数时返回0
}
}
031(43)-从1到n整数中1出现的次数
问题描述:
输入一个整数 n ,求1~n这n个整数的十进制表示中1出现的次数。
例如,输入12,1~12这些整数中包含1 的数字有1、10、11和12,1一共出现了5次。
解题思路:
https://leetcode-cn.com/problems/1nzheng-shu-zhong-1chu-xian-de-ci-shu-lcof/solution/mian-shi-ti-43-1n-zheng-shu-zhong-1-chu-xian-de-2/
class Solution {
public int countDigitOne(int n) {
int digit = 1, res = 0;
int high = n / 10, cur = n % 10, low = 0;
while(high != 0 || cur != 0){
if(cur == 0){
res += high * digit;
}else if(cur == 1){
res += high * digit + low + 1;
}else{
res += (high + 1) * digit;
}
low += cur * digit;
cur = high % 10;
high /= 10;
digit *= 10;
}
return res;
}
}
032(45)-把数组排成最小的数
问题描述:
输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。
解题思路
通过在排序时传入一个自定义的 Comparator 实现,重新定义 String 列表内的排序方法,若拼接 s1 + s2 > s2 + s1,那么显然应该把 s2 在拼接时放在前面,以此类推,将整个 String 列表排序后再拼接起来。
class Solution {
public String minNumber(int[] nums) {
String[] strs = new String[nums.length];
for(int i = 0; i < nums.length; i++){
strs[i] = String.valueOf(nums[i]);
}
Arrays.sort(strs, (x, y) -> (x + y).compareTo(y + x));
StringBuilder res = new StringBuilder();
for(String s : strs){
res.append(s);
}
return res.toString();
}
}
033(49)-丑数
问题描述:
把只包含因子 2、3 和 5 的数称作丑数(Ugly Number)。例如 6、8 都是丑数,但 14 不是,因为它包含因子 7。习惯上我们把 1 当做是第一个丑数。求按从小到大的顺序的第 N 个丑数。
解题思路:
一个十分巧妙的动态规划问题
1.我们将前面求得的丑数记录下来,后面的丑数就是前面的丑数*2,*3,*5
2.但是问题来了,我怎么确定已知前面k-1个丑数,我怎么确定第k个丑数呢
3.采取用三个指针的方法,n2,n3,n5
4.n2指向的数字下一次永远*2,n3指向的数字下一次永远*3,n5指向的数字永远*5
5.我们从2*n2 3*n3 5*n5选取最小的一个数字,作为第k个丑数
6.如果第K个丑数==2*n2,也就是说前面0-n2个丑数*2不可能产生比第K个丑数更大的丑数了,所以n2++
7.n3,n5同理
8.返回第n个丑数
class Solution {
public int nthUglyNumber(int n) {
int a = 0, b = 0, c = 0;
int[] dp = new int[n];
dp[0] = 1;
for(int i = 1; i < n; i++){
int n2 = dp[a] * 2, n3 = dp[b] * 3, n5 = dp[c] * 5;
dp[i] = Math.min(Math.min(n2, n3), n5);
if(dp[i] == n2) a++;
if(dp[i] == n3) b++;
if(dp[i] == n5) c++;
}
return dp[n - 1];
}
}
041(57.2)-和为S的连续正数序列(滑动窗口思想)
问题描述:
输入一个正整数 target ,输出所有和为 target 的连续正整数序列(至少含有两个数)。
序列内的数字由小到大排列,不同序列按照首个数字从小到大排列。
解题思路:
什么是滑动窗口
滑动窗口可以看成数组中框起来的一个部分。在一些数组类题目中,我们可以用滑动窗口来观察可能的候选结果。当滑动窗口从数组的左边滑到了右边,我们就可以从所有的候选结果中找到最优的结果。
对于这道题来说,数组就是正整数序列 [1,2,3,…,n]。我们设滑动窗口的左边界为 i,右边界为 j,则滑动窗口框起来的是一个左闭右开区间 [i,j)。注意,为了编程的方便,滑动窗口一般表示成一个左闭右开区间。在一开始,i=1,j=1,滑动窗口位于序列的最左侧,窗口大小为零。
滑动窗口的重要性质是:窗口的左边界和右边界永远只能向右移动,而不能向左移动。这是为了保证滑动窗口的时间复杂度是 O(n)。如果左右边界向左移动的话,这叫做“回溯”,算法的时间复杂度就可能不止 O(n)。
在这道题中,我们关注的是滑动窗口中所有数的和。当滑动窗口的右边界向右移动时,也就是 j = j + 1,窗口中多了一个数字 j,窗口的和也就要加上 j。当滑动窗口的左边界向右移动时,也就是 i = i + 1,窗口中少了一个数字 i,窗口的和也就要减去 i。滑动窗口只有 右边界向右移动(扩大窗口) 和 左边界向右移动(缩小窗口) 两个操作,所以实际上非常简单。
如何用滑动窗口解这道题
要用滑动窗口解这道题,我们要回答两个问题:
• 第一个问题,窗口何时扩大,何时缩小?
• 第二个问题,滑动窗口能找到全部的解吗?
对于第一个问题,回答非常简单:
• 当窗口的和小于 target 的时候,窗口的和需要增加,所以要扩大窗口,窗口的右边界向右移动
• 当窗口的和大于 target 的时候,窗口的和需要减少,所以要缩小窗口,窗口的左边界向右移动
• 当窗口的和恰好等于 target 的时候,我们需要记录此时的结果。设此时的窗口为 [i,j),那么我们已经找到了一个 i 开头的序列,也是唯一一个i 开头的序列,接下来需要找 i+1 开头的序列,所以窗口的左边界要向右移动
对于第二个问题,我们可以稍微简单地证明一下:
我们一开始要找的是 1 开头的序列,只要窗口的和小于 target,窗口的右边界会一直向右移动。假设 1+2+⋯+8 小于 target,再加上一个 9 之后, 发现 1+2+⋯+8+9 又大于 target 了。这说明 1 开头的序列找不到解。此时滑动窗口的最右元素是 9。
接下来,我们需要找 2 开头的序列,我们发现,2+⋯+8<1+2+⋯+8<target。这说明 2 开头的序列至少要加到 9。那么,我们只需要把原先 1~9 的滑动窗口的左边界向右移动,变成 2~9 的滑动窗口,然后继续寻找。而右边界完全不需要向左移动。
以此类推,滑动窗口的左右边界都不需要向左移动,所以这道题用滑动窗口一定可以得到所有的解。时间复杂度是 O(n)。
class Solution {
public int[][] findContinuousSequence(int target) {
int i = 1; //滑动窗口的左边界
int j = 1; //滑动窗口的有边界
int sum = 0; //滑动窗口中数字的和
List<int[]> res = new ArrayList<>();
while(i <= target / 2){
if(sum < target){
//右边界向右移动
sum += j;
j++;
}else if(sum > target){
//左边界向右移动
sum -= i;
i++;
}else{
//记录结果
int[] arr = new int[j - i];
for(int k = i; k < j; k++){
arr[k - i] = k;
}
res.add(arr);
//左边界向右移动
sum -= i;
i++;
}
}
return res.toArray(new int[res.size()][]);
}
}
042(57.1)-和为S的两个数字(双指针思想)
问题描述:
输入一个递增排序的数组和一个数字s,在数组中查找两个数,使得它们的和正好是s。如果有多对数字的和等于s,则输出任意一对即可。
解题思路:
利用 HashMap 可以通过遍历数组找到数字组合,时间和空间复杂度均为 O(N) ;
注意本题的 nums 是 排序数组 ,因此可使用 双指针法 将空间复杂度降低至 O(1) 。
算法流程:
1.初始化: 双指针 i , j 分别指向数组 nums 的左右两端 (俗称对撞双指针)。
2.循环搜索: 当双指针相遇时跳出;
计算和 s=nums[i]+nums[j] ;
若 s > target ,则指针 j 向左移动,即执行 j = j - 1 ;
若 s < target ,则指针 i 向右移动,即执行 i = i + 1 ;
若 s = target ,立即返回数组 [nums[i],nums[j]] ;
3.返回空数组,代表无和为 target 的数字组合。
复杂度分析:
时间复杂度 O(N) : N 为数组 nums 的长度;双指针共同线性遍历整个数组。
空间复杂度 O(1) : 变量 i, j 使用常数大小的额外空间。
class Solution {
public int[] twoSum(int[] nums, int target) {
int i = 0, j = nums.length - 1;
while(i < j){
int s = nums[i] + nums[j];
if(s < target){
i++;
}else if(s > target){
j--;
}else{
return new int[]{nums[i], nums[j]};
}
}
return new int[0];
}
}
043(58.2)-左旋转字符串(矩阵翻转)
问题描述:
字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现字符串左旋转操作的功能。比如,输入字符串"abcdefg"和数字2,该函数将返回左旋转两位得到的结果"cdefgab"。
==方法一:==字符串切片
应用字符串切片函数,可方便实现左旋转字符串。
获取字符串 s[n:]切片和 s[:n] 切片,使用 “+” 运算符拼接并返回即可。
复杂度分析:
时间复杂度 O(N) : 其中 N 为字符串 s 的长度,字符串切片函数为线性时间复杂度;
空间复杂度 O(N) : 两个字符串切片的总长度为 N 。
class Solution {
public String reverseLeftWords(String s, int n) {
return s.substring(n, s.length()) + s.substring(0, n);
}
}
==方法二:==列表遍历拼接
若面试规定不允许使用 切片函数 ,则使用此方法。
算法流程:
1.新建一个 list(Python)、StringBuilder(Java) ,记为 res ;
2.先向 res 添加 “第 n+1 位至末位的字符” ;
3.再向 res 添加 “首位至第 n 位的字符” ;
4.将 res 转化为字符串并返回。
复杂度分析:
• 时间复杂度 O(N) : 线性遍历 s 并添加,使用线性时间;
• 空间复杂度 O(N) : 新建的辅助 res 使用 O(N) 大小的额外空间。
class Solution {
public String reverseLeftWords(String s, int n) {
StringBuilder res = new StringBuilder();
for(int i = n; i < s.length(); i++){
res.append(s.charAt(i));
}
for(int i = 0; i < n; i++){
res.append(s.charAt(i));
}
return res.toString();
}
}
046(62)-圆圈中最后剩下的数(约瑟夫环)
问题描述:
0,1,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。
例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。
解题思路:
约瑟夫环
第一轮是 [0, 1, 2, 3, 4] ,所以是 [0, 1, 2, 3, 4] 这个数组的多个复制。这一轮 2 删除了。
第二轮开始时,从 3 开始,所以是 [3, 4, 0, 1] 这个数组的多个复制。这一轮 0 删除了。
第三轮开始时,从 1 开始,所以是 [1, 3, 4] 这个数组的多个复制。这一轮 4 删除了。
第四轮开始时,还是从 1 开始,所以是 [1, 3] 这个数组的多个复制。这一轮 1 删除了。
最后剩下的数字是 3。
然后我们从最后剩下的 3 倒着看,我们可以反向推出这个数字在之前每个轮次的位置。
最后剩下的 3 的下标是 0。
第四轮反推,补上 m 个位置,然后模上当时的数组大小 2,位置是(0 + 3) % 2 = 1。
第三轮反推,补上 m 个位置,然后模上当时的数组大小 3,位置是(1 + 3) % 3 = 1。
第二轮反推,补上 m 个位置,然后模上当时的数组大小 4,位置是(1 + 3) % 4 = 0。
第一轮反推,补上 m 个位置,然后模上当时的数组大小 5,位置是(0 + 3) % 5 = 3。
所以最终剩下的数字的下标就是3。因为数组是从0开始的,所以最终的答案就是3。
总结一下反推的过程,就是 (当前index + m) % 上一轮剩余数字的个数。
class Solution {
public int lastRemaining(int n, int m) {
int ans = 0;
// 最后一轮剩下2个人,所以从2开始反推
for (int i = 2; i <= n; i++) {
ans = (ans + m) % i;
}
return ans;
}
}
051(66)-构建乘积数组
问题描述:
给定一个数组 A[0,1,…,n-1],请构建一个数组 B[0,1,…,n-1],其中 B 中的元素 B[i]=A[0]×A[1]×…×A[i-1]×A[i+1]×…×A[n-1]。不能使用除法。
class Solution {
public int[] constructArr(int[] a) {
if(a.length == 0){
return new int[0];
}
int [] res =new int[a.length];
//先乘左边的,不包括自己
for(int i = 0, cur = 1 ; i < a.length; i++){
res[i] = cur;
cur *= a[i];
}
//再乘以右边的数
for(int i = a.length - 1, cur = 1 ; i >= 0; i--){
res[i] *= cur;
cur *= a[i];
}
return res;
}
}
i++;
}
}
return res.toArray(new int[res.size()][]);
}
}
## 042(57.1)-和为S的两个数字(双指针思想)
==问题描述:==
输入一个递增排序的数组和一个数字s,在数组中查找两个数,使得它们的和正好是s。如果有多对数字的和等于s,则输出任意一对即可。
==解题思路:==
利用 HashMap 可以通过遍历数组找到数字组合,时间和空间复杂度均为 O(N) ;
注意本题的 nums 是 排序数组 ,因此可使用 双指针法 将空间复杂度降低至 O(1) 。
**算法流程:**
1.初始化: 双指针 i , j 分别指向数组 nums 的左右两端 (俗称对撞双指针)。
2.循环搜索: 当双指针相遇时跳出;
计算和 s=nums[i]+nums[j] ;
若 s > target ,则指针 j 向左移动,即执行 j = j - 1 ;
若 s < target ,则指针 i 向右移动,即执行 i = i + 1 ;
若 s = target ,立即返回数组 [nums[i],nums[j]] ;
3.返回空数组,代表无和为 target 的数字组合。
==复杂度分析:==
时间复杂度 O(N) : N 为数组 nums 的长度;双指针共同线性遍历整个数组。
空间复杂度 O(1) : 变量 i, j 使用常数大小的额外空间。
```java
class Solution {
public int[] twoSum(int[] nums, int target) {
int i = 0, j = nums.length - 1;
while(i < j){
int s = nums[i] + nums[j];
if(s < target){
i++;
}else if(s > target){
j--;
}else{
return new int[]{nums[i], nums[j]};
}
}
return new int[0];
}
}
043(58.2)-左旋转字符串(矩阵翻转)
问题描述:
字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现字符串左旋转操作的功能。比如,输入字符串"abcdefg"和数字2,该函数将返回左旋转两位得到的结果"cdefgab"。
==方法一:==字符串切片
应用字符串切片函数,可方便实现左旋转字符串。
获取字符串 s[n:]切片和 s[:n] 切片,使用 “+” 运算符拼接并返回即可。
复杂度分析:
时间复杂度 O(N) : 其中 N 为字符串 s 的长度,字符串切片函数为线性时间复杂度;
空间复杂度 O(N) : 两个字符串切片的总长度为 N 。
class Solution {
public String reverseLeftWords(String s, int n) {
return s.substring(n, s.length()) + s.substring(0, n);
}
}
==方法二:==列表遍历拼接
若面试规定不允许使用 切片函数 ,则使用此方法。
算法流程:
1.新建一个 list(Python)、StringBuilder(Java) ,记为 res ;
2.先向 res 添加 “第 n+1 位至末位的字符” ;
3.再向 res 添加 “首位至第 n 位的字符” ;
4.将 res 转化为字符串并返回。
复杂度分析:
• 时间复杂度 O(N) : 线性遍历 s 并添加,使用线性时间;
• 空间复杂度 O(N) : 新建的辅助 res 使用 O(N) 大小的额外空间。
class Solution {
public String reverseLeftWords(String s, int n) {
StringBuilder res = new StringBuilder();
for(int i = n; i < s.length(); i++){
res.append(s.charAt(i));
}
for(int i = 0; i < n; i++){
res.append(s.charAt(i));
}
return res.toString();
}
}
046(62)-圆圈中最后剩下的数(约瑟夫环)
问题描述:
0,1,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。
例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。
解题思路:
约瑟夫环
第一轮是 [0, 1, 2, 3, 4] ,所以是 [0, 1, 2, 3, 4] 这个数组的多个复制。这一轮 2 删除了。
第二轮开始时,从 3 开始,所以是 [3, 4, 0, 1] 这个数组的多个复制。这一轮 0 删除了。
第三轮开始时,从 1 开始,所以是 [1, 3, 4] 这个数组的多个复制。这一轮 4 删除了。
第四轮开始时,还是从 1 开始,所以是 [1, 3] 这个数组的多个复制。这一轮 1 删除了。
最后剩下的数字是 3。
然后我们从最后剩下的 3 倒着看,我们可以反向推出这个数字在之前每个轮次的位置。
最后剩下的 3 的下标是 0。
第四轮反推,补上 m 个位置,然后模上当时的数组大小 2,位置是(0 + 3) % 2 = 1。
第三轮反推,补上 m 个位置,然后模上当时的数组大小 3,位置是(1 + 3) % 3 = 1。
第二轮反推,补上 m 个位置,然后模上当时的数组大小 4,位置是(1 + 3) % 4 = 0。
第一轮反推,补上 m 个位置,然后模上当时的数组大小 5,位置是(0 + 3) % 5 = 3。
所以最终剩下的数字的下标就是3。因为数组是从0开始的,所以最终的答案就是3。
总结一下反推的过程,就是 (当前index + m) % 上一轮剩余数字的个数。
class Solution {
public int lastRemaining(int n, int m) {
int ans = 0;
// 最后一轮剩下2个人,所以从2开始反推
for (int i = 2; i <= n; i++) {
ans = (ans + m) % i;
}
return ans;
}
}
051(66)-构建乘积数组
问题描述:
给定一个数组 A[0,1,…,n-1],请构建一个数组 B[0,1,…,n-1],其中 B 中的元素 B[i]=A[0]×A[1]×…×A[i-1]×A[i+1]×…×A[n-1]。不能使用除法。
class Solution {
public int[] constructArr(int[] a) {
if(a.length == 0){
return new int[0];
}
int [] res =new int[a.length];
//先乘左边的,不包括自己
for(int i = 0, cur = 1 ; i < a.length; i++){
res[i] = cur;
cur *= a[i];
}
//再乘以右边的数
for(int i = a.length - 1, cur = 1 ; i >= 0; i--){
res[i] *= cur;
cur *= a[i];
}
return res;
}
}