2.29
时间复杂度
-
时间、空间复杂度优先关注
-
只要时间复杂度达到 O(n3)及以上,工程上就没有意义了
-
n是‘数据量级
-
-
这里面的 2的x方为n,所以x取log(n)
-
数组
C++中可以看到数组的每个元素地址连续,例如int类型的两元素地址相差4;地址16进制
Java的地址,Java是没有指针的,同时也不对程序员暴露其元素的地址,寻址操作完全交给虚拟机。;地址16进制
二分查找
题目的前提是数组为有序数组,同时题目还强调数组中无重复元素,因为一旦有重复元素,使用二分查找法返回的元素下标可能不是唯一的,这些都是使用二分法的前提条件。区间的定义就是不变量
-
升序数组,不重复 -- 所以查middle和target的关系
-
锁定了方向后,重新比较新的区间里middle和target的关系
-
两种区间方法:左闭右闭;左闭右开
class Solution { public int search(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left <= right) { int middle = left + (right - left) / 2; if (nums[middle] > target) { right = middle - 1; } else if (nums[middle] < target) { left = middle + 1; } else { return middle; } } return -1; } }
时间复杂度 O(log n)
以每次将搜索范围缩小一半的速度来看,当搜索范围缩小到 1 时就可以确定是否存在目标值,或者搜索范围为空。假设初始搜索范围的长度为 n,那么经过 k 次迭代后,搜索范围的长度将变为 n / 2^k。当搜索范围的长度为 1 时,即 n / 2^k = 1,解得 k = log₂(n)。
移除元素
暴力法
双指针法
双指针法(快慢指针法): 通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。
定义快慢指针
-
快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
-
慢指针:指向更新 新数组下标的位置
class Solution { public int removeElement(int[] nums, int val) { int slowIndex = 0; for (int fastIndex = 0; fastIndex < nums.length; fastIndex++) { if (nums[fastIndex] != val) { nums[slowIndex] = nums[fastIndex]; slowIndex++; } } return slowIndex; } }
-
时间复杂度:O(n)
-
空间复杂度:O(1)
3.1
有序数组的平方
双指针法
数组其实是有序的, 只不过负数平方之后可能成为最大数了。
那么数组平方的最大值就在数组的两端,不是最左边就是最右边,不可能是中间。
此时可以考虑双指针法了,i指向起始位置,j指向终止位置。
定义一个新数组result,和A数组一样的大小,让k指向result数组终止位置。
如果A[i] * A[i] < A[j] * A[j]
那么result[k--] = A[j] * A[j];
。
如果A[i] * A[i] >= A[j] * A[j]
那么result[k--] = A[i] * A[i];
。
新建固定长度的数组:
// 创建一个固定长度为10的整型数组 int[] array = new int[10];
class Solution { public int[] sortedSquares(int[] nums) { int left = 0; int right = nums.length - 1; int[] result = new int[nums.length]; int index = result.length - 1; while (left <= right) { int leftSquare = nums[left] * nums[left]; int rightSquare = nums[right] * nums[right]; if (leftSquare >= rightSquare) { result[index--] = leftSquare; left++; } else { result[index--] = rightSquare; right--; } } return result; } }
-
旧数组左右双指针,判断哪个大
-
新数组的新指针,递减从右开始排列放数值大的数
-
时间复杂度为 O(n),其中 n 是数组 nums 的长度。这是因为我们只需一次遍历数组就可以完成平方、比较和填充结果数组的操作。
空间复杂度也是 O(n),因为我们创建了一个与输入数组相同长度的结果数组,用于存储平方后的值。
长度最小的子数组
滑动窗口
接下来就开始介绍数组操作中另一个重要的方法:滑动窗口。
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
在暴力解法中,是一个for循环滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。
那么滑动窗口如何用一个for循环来完成这个操作呢。
首先要思考 如果用一个for循环,那么应该表示 滑动窗口的起始位置,还是终止位置。
如果只用一个for循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?
此时难免再次陷入 暴力解法的怪圈。
所以 只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
那么问题来了, 滑动窗口的起始位置如何移动呢?
这里还是以题目中的示例来举例,s=7, 数组是 2,3,1,2,4,3,来看一下查找的过程:
int result = Integer.MAX_VALUE;
Integer.MAX_VALUE
是 Java 中int
类型的最大值,它的值为2147483647
。在计算机中,int
类型通常占用 32 位,其中一位用于表示符号位,所以int
类型的最大值是2^31 - 1
。这个值在进行整数运算时通常用于初始化一个变量,表示这个变量的初始值为整型的最大值。
return result == Integer.MAX_VALUE ? 0 : result;
这行代码的意思是,如果
result
的值等于Integer.MAX_VALUE
,则返回0
,否则返回result
的值。这样的逻辑通常用于处理特殊情况,例如在某些算法中,当无法找到符合条件的结果时,返回一个特殊的值(这里是0
),以表示没有找到结果。
class Solution { public int minSubArrayLen(int target, int[] nums) { int left = 0; int sum = 0; int result = Integer.MAX_VALUE; for (int right = 0; right < nums.length;right++){ sum += nums[right]; while(sum >= target){ result = Math.min(result, right-left+1); sum -= nums[left++]; } } return result == Integer.MAX_VALUE?0:result; } }
-
遍历窗口右端,累加求和得sum
-
如果sum符合条件,则保留最小的 窗口长度,并从窗口左端减少sum继续观察while
-
时间复杂度
-
循环:代码中有一个外层的for循环,遍历数组
nums
一次。对于每个右边界right
,内部while循环可能会执行多次,但每次执行都会导致左边界left
向右移动。注意,左边界left
和右边界right
在整个过程中各自只会从左向右遍历数组nums
一次。 -
总体:尽管存在嵌套循环,但每个元素最多被访问两次(一次被添加到
sum
中,一次从sum
中减去)。因此,总的时间复杂度为O(n),其中n
是数组nums
的长度。
空间复杂度
-
额外空间:代码中使用的额外空间包括几个基本类型的变量:
left
、right
、sum
和result
。它们的空间占用与输入数组的长度无关。 -
总体:因此,空间复杂度为O(1),即常数空间复杂度。
总结
-
时间复杂度:O(n)
-
空间复杂度:O(1)
-
螺旋矩阵II
class Solution { public int[][] generateMatrix(int n) { int matrix[][] = new int[n][n]; int loop = 0; // 控制循环圈数 int i,j; int start = 0; int count = 1; while(loop++ < n/2){ // 后面再考虑奇数的情况,第一次这里loop已经等于1了 for(j=start;j<n-loop;j++){ i = start; matrix[i][j] = count++; // 注意这里初始i要是start不然没有赋值 } //此时j = n-loop-1 for(i=start;i<n-loop;i++){ matrix[i][j] = count++; } //此时i = n-loop-1 for(;j>=loop;j--){ matrix[i][j] = count++; } //此时j = 0 for(;i>=loop;i--){ // 注意这里i>loop matrix[i][j] = count++; } start++; } if(n % 2 == 1){ matrix[start][start] = count; } return matrix; } }
-
要设置循环(圈数);循环还要考虑奇数的情况
-
一圈顺时针保持 【左开右闭】
-
开始的位置要设置start;这个start每个循环还要累加
-
for循环(i;i<n;i++)这个格式不能变:for (初始化语句; 循环条件检查; 步进语句);也可以int i = 0;
for (; i < n; i++)
-
-
loop++ < n/2
:这个条件确保了算法只处理到矩阵的一半深度,因为螺旋填充是对称的。当n
是奇数时,中心点会单独处理。 -
start++
:每完成一层的填充后,start
递增,意味着下一轮填充的起始点向内移动一格。
-
-
算法复杂度
-
时间复杂度:O(n^2),因为需要填充整个n×n的矩阵,每个元素只访问一次。
-
空间复杂度:O(1),不考虑输出矩阵所占用的空间。算法本身只使用了固定数量的变量(
loop
、i
、j
、start
、count
)进行计算。
-
链表
什么是链表,链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
链表的入口节点称为链表的头结点也就是head。
-
单链表
-
双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。既可以向前查询也可以向后查询。
-
循环链表:首尾相连。循环链表可以用来解决约瑟夫环问题。
链表的存储方式
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。
链表是通过指针域的指针链接在内存中各个节点。
所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
这个链表起始节点为2, 终止节点为7, 各个节点分布在内存的不同地址空间上,通过指针串联在一起。
链表的定义
public class ListNode { int value; ListNode next; ListNode(int value) { this.value = value; this.next = null; } public static void main(String[] args) { // 创建链表节点 ListNode head = new ListNode(1); head.next = new ListNode(2); head.next.next = new ListNode(3); // 打印链表 ListNode temp = head; while (temp != null) { System.out.println(temp.value); temp = temp.next; } } }
class ListNode: def __init__(self, value=0, next=None): self.value = value self.next = next # 创建链表 head = ListNode(1) head.next = ListNode(2) head.next.next = ListNode(3) # 打印链表 temp = head while temp: print(temp.value) temp = temp.next
-
数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组。
-
链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。
移除链表元素
ListNode dummy = new ListNode(-1,head)
通过创建一个值为 -1 的虚拟节点,并将其下一个节点指向
head
,我们可以在链表的头部插入新的节点时,统一操作,无论是否有头部节点存在。这样可以避免在插入操作时需要处理头部节点为空的特殊情况。
让我们通过一个简化的图解来说明这个过程。假设我们有一个链表,我们要移除所有值为
val
的节点。这里我们用val = 2
作为例子。初始链表(要移除所有值为2的节点):
rustCopy code 1 -> 2 -> 3 -> 2 -> 4我们引入一个虚拟头节点(dummy head)以简化边界条件,这样我们就不需要单独处理头节点可能被移除的情况。
步骤 0: 引入虚拟头节点
rustCopy code dummy -> 1 -> 2 -> 3 -> 2 -> 4现在,
pre
指向dummy
节点,cur
指向第一个实际节点(值为1)。步骤 1:
cur
指向1, 它不等于2,所以我们不移除它。pre
现在需要指向cur
,因为cur
是新的“前一个节点”。rustCopy codepre v dummy -> 1 -> 2 -> 3 -> 2 -> 4 ^ cur步骤 2: 移动
cur
到下一个节点(值为2),这个节点需要被移除。我们将pre.next
设置为cur.next
,跳过了值为2的节点。rustCopy codepre v dummy -> 1 2 -> 3 -> 2 -> 4 \ ^ -------> cur注意,在这一步,我们没有移动
pre
,因为我们移除了cur
指向的节点,pre
应该保持指向上一个有效节点。步骤 3: 继续移动
cur
到下一个节点(值为3),它不等于2,所以我们保留它。此时,我们需要更新pre
指向新的“前一个节点”cur
。rustCopy code pre v dummy -> 1 -> 3 -> 2 -> 4 ^ cur步骤 4:
cur
移动到下一个节点(值为2),这个节点需要被移除。我们再次将pre.next
设置为cur.next
,跳过了值为2的节点。rustCopy code pre v dummy -> 1 -> 3 2 -> 4 \ ^ -----> cur步骤 5: 最后,
cur
移动到值为4的节点,它不等于2,我们保留它。此时链表中所有值为2的节点都已被移除。最终链表:
rustCopy code dummy -> 1 -> 3 -> 4通过这个图解,你可以看到
pre
仅当cur
指向的节点被保留(即不等于val
)时才向前移动。这样,pre
始终指向最后一个未被移除的节点,确保我们可以正确地跳过(即移除)所有值等于val
的节点。希望这个图解能帮助你更好地理解算法的逻辑。
/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode removeElements(ListNode head, int val) { if (head == null){ return head; } ListNode dummy = new ListNode(-1,head); ListNode pre = dummy; ListNode cur = head; while(cur != null){ if(cur.val == val){ pre.next = cur.next; // 如果当前cur等于val,那么跳过该节点,所以pre.next应该跳过等于cur.next。构建连接 }else{ pre = cur; // 逻辑上的移动:当我们说 pre = cur 时,我们的意图是将 pre 指针逻辑上移动到 cur 的位置,而不是改变任何节点之间的物理链接。这样做是为了在下一次迭代中,如果需要从链表中移除 cur 指向的节点,我们能正确地做到这一点。 } cur = cur.next; } return dummy.next; } }
-
添加虚拟头节点:
ListNode dummy = new ListNode(-1,head);
注意返回第一个节点时要是虚拟节点的下一个dummy.next;
。链表重在顺序 -
如果链表是空的,没有任何节点可以被处理或移除,则返回空
-
双指针
pre
和cur
,需要跳过的pre.next = cur.next;
不需要跳过的pre = cur;
-
pre.next = cur.next;
这行代码直接修改了链表的结构。它将
pre
节点的next
指针指向了cur
节点的next
节点,从而在链表中跳过了cur
节点。这是删除操作的核心,因为它确保了cur
节点被逻辑上从链表中移除:pre
节点现在直接连接到cur
节点的下一个节点,而cur
节点没有任何节点指向它(假设没有其他引用指向cur
),因此在链表的上下文中被移除了。pre = cur.next;
这行代码仅仅是移动了
pre
指针,让它指向cur
节点的下一个节点。这个操作并不修改链表的结构,也不会从链表中移除任何节点。实际上,它只是改变了pre
指针的位置,但cur
节点仍然保留在链表中,并且仍然被pre
的前一个位置(即操作前的pre
节点)指向。因此,这并不满足删除节点的目标。