本篇博客适用于对于数据结构和算法有一定基础,懂得计算机基础概念(例如内存分配)的人阅读和学习,且本文内容的语言仅有java,如果有需求可以再加上python版本。
数组
数组是最基础的数据结构,其特点的支持随即访问,在内存中是连续存储的。在java中,可以使用nums.length得到数组的长度。
java是面向对象的,数据结构也是讲继承的。List
这个接口就衍生出新的数据结构,例如ArrayList
或者LinkedList
,其功能都是很丰富的。那这样看来,什么都没有的数组岂不是很差劲?没有丰富的方法,在内存中连续存储也注定其插入和删除要麻烦一些,那数组还有什么存在意义?
数组的优点大概有以下几点:
- 性能:对于随机访问操作,数组的性能通常优于ArrayList,因为数组访问不涉及额外的函数调用开销。
- 内存占用:相比于ArrayList,数组通常占用更少的内存,因为ArrayList对象有额外的内部开销。
- 基本类型:数组可以直接存储基本类型,如int、char、boolean等,而ArrayList不能直接存储基本类型,必须使用其包装类,如Integer、Character、Boolean等。
- 多维数组:Java支持多维数组,这在处理某些问题(如矩阵运算)时非常有用。而ArrayList的多维使用并不直观。
704 二分查找
二分查找是在大学时期都学过的简单算法,对数组进行查找,能够执行二分查找的前提条件就是需要数组是有序的,且元素无重复元素(否则会找到不同的下标)。
时间复杂度为O(logn),分析时间复杂度的方法类似于画二叉树的方法,树高为logn。空间复杂度为O(1).
class Solution {
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length-1;
while(left<= right){
int mid = (left + right) / 2;
if (nums[mid]==target){
return mid;
}
else if(nums[mid] < target){
left = mid+1;
}
else{
right = mid-1;
}
}
return -1;
}
}
27 移除元素
题目如下:
对于数组的移动、删除、插入,都可以使用双指针来处理。数组不像是链表或者以链表为底层设计的数据结构,可以从中间移除元素(例如python的list或者cpp的vector),因此如果每次都要使用暴力解法(双重for循环,每次把后面的元素进行前移),那就会导致时间复杂度为O(n^2),非常低效。而双指针(或者叫快慢指针)可以有效的利用一快一慢的特点,用快指针去”探路“,查看是否需要更改内容,而慢指针则用来配合快指针进行数组的修改,非常好用。
对于本题,我们的目标是将给定的val进行覆盖(同上,在内存中连续的数据结构无法直接删除,除非是链表或者底层逻辑是链表的数据结构,那就不属于数组的范畴咯),因此可以快慢指针,快指针和慢指针开始都在同一位置,如果没有需要val,则两个指针同时前进。而如果遇到了需要覆盖的元素,就将快指针后移,直到其指向的值不为val,也就可以在下一次循环中覆盖慢指针的值,也就是val。
class Solution {
public int removeElement(int[] nums, int val) {
int fast=0,slow=0;
int len = nums.length;
while(fast < nums.length){
if(nums[fast] == val){
fast++;
}
else{
nums[slow] = nums[fast];
slow++;
fast++;
}
}
return slow;
}
}
977有序数组的平方
题目如下:
很明显,我们可以暴力解决,也就是平方+快排,这样的时间复杂度是O(nlogn),其实也比较优秀了。但前面说到,对于数组的操作,可以多考虑双指针,那么这题有没有可能呢?
当然可以,这个题目算是坑的点就是负数平方后可能大于正数了,也就需要考虑负数的绝对值大小。如果我们新建一个同等大小的数组,指针指向原数组的两端,不断比较大小并且收缩指针夹住的范围,那就可以解决这个问题。
class Solution {
public int[] sortedSquares(int[] nums) {
int left = 0,right = nums.length-1;
int[] res = new int[nums.length];
int ptr = nums.length-1;
for(; ptr>=0; ptr--){
if(nums[left] * nums[left] > nums[right]*nums[right]){
res[ptr] = nums[left]*nums[left];
left++;
}
else{
res[ptr] = nums[right]*nums[right];
right--;
}
}
return res;
}
}
209,长度最小的子数组
在这题中,我们要使用一种和快慢指针相似但不同的方法:滑动窗口。快慢指针在乎的是指针指向的值,而滑动窗口是不断滑动,看重的是指针之间的值。学习过计算机网络中的滑动窗口协议就很好理解,这是一个不断更新的过程。
对于本题,我们可以设置一个滑动窗口,当窗口中的值大于等于(看清楚是大于等于,我自己做的时候就以为是等于,坑惨了)target的时候,就可以存进res里。我们的res变量存的就是最小长度。
首先,左右指针相同(也就是窗口大小为0),这时候每次增加窗口大小直到窗口内的值大于target,这时候就可以存储res并且缩小窗口大小了。
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int left=0,right=0;
int sum;
int res=Integer.MAX_VALUE;
for(sum=0; right<nums.length;right++){
sum += nums[right];
while(sum>=target){
res = Math.min(res, right - left + 1);
sum -= nums[left++];
}
}
return res == Integer.MAX_VALUE ? 0 : res;
}
}
59 螺旋矩阵
不难看出,就这是一道很简单的模拟题,但还是有练习的必要。代码如下:
class Solution {
public int[][] generateMatrix(int n) {
int row=1;// 如果是1就按行遍历,0按列
int right=1; // 如果是1就向右,0向左
int down =1; // 如果是1就向下,0向上
int[][] tmp = new int[n][n];
int i = 0;
int j = 0;
int cnt = 1;
while(cnt<=n*n){
if(row==1&&right==1){
while(j<n&&tmp[i][j]==0){
tmp[i][j++] = cnt;
cnt++;
System.out.println(i+ " " +j);
}
j--;
i++;
row=0;
right=0;
}
if(row==0&&down==1){
while(i<n&&tmp[i][j]==0){
tmp[i++][j] = cnt;
cnt++;
}
i--;
j--;
row=1;
down=0;
}
if(row==1&&right==0){
while(j>=0&&tmp[i][j]==0){
tmp[i][j--] = cnt;
cnt++;
}
j++;
i--;
row=0;
right=1;
}
if(row==0&&down==0){
while(i>0&&tmp[i][j]==0){
tmp[i--][j] = cnt;
cnt++;
System.out.println(i+ " " +j);
}
i++;
j++;
row=1;
down=1;
}
}
return tmp;
}
}
双指针
双指针也一般称为快慢指针,主要用于处理链表和数组等线性数据结构。这种技巧主要涉及到两个指针,一个快指针(通常每次移动两步)和一个慢指针(通常每次移动一步)。快指针可以起到’探路‘的作用,给慢指针修改。
适用范围:
- 一般适用于字符串/数组。
- 对数组/字符串进行更改(反转、删除、增添)。
344 反转字符串
本题就是双指针比较简单的用法,一头一尾进行交换即可。左右指针不断缩紧直到左指针大于等于右指针为主。
class Solution {
public void reverseString(char[] s) {
int left=0,right=s.length-1;
while(left<right){
char tmp = s[left];
s[left++] = s[right];
s[right--] = tmp;
}
}
}
151 反转字符串中的单词
看到这个题我的第一想法就是用split方法进行分割,将原字符串分割为多个字符,然后再双指针逆转(如果是python就更简单了),示例代码如下:
class Solution {
public String reverseWords(String s) {
String[] res = s.trim().split("\\s+");
for(int left=0,right=res.length-1; left<right; left++,right--){
String tmp = res[left];
res[left] = res[right];
res[right] = tmp;
}
return String.join(" ",res);
}
}
或者:
class Solution:
def reverseWords(self, s: str) -> str:
# 删除前后空白
s = s.strip()
# 反转整个字符串
s = s[::-1]
# 将字符串拆分为单词,并反转每个单词
s = ' '.join(word[::-1] for word in s.split())
return s
这里要说明一下,在java里,字符串是不可变的,即无法修改。使用split方法可以将字符串转化为字符串数组,split是将字符串安装给的的规则进行正则匹配分割。
String str = "Hello, world!";
String[] words = str.split(" ");
System.out.println(words[0]); // 输出: Hello,
join:join方法用于将多个字符串连接成一个新的字符串,字符串之间用一个指定的分隔符分隔。例如:
String[] words = {"Hello", "world"};
String sentence = String.join(" ", words);
System.out.println(sentence); // 输出: Hello world
在这个例子中,String.join(" “, words)将字符串数组words中的字符串连接成一个新的字符串,字符串之间用空格” "分隔。
trim:trim方法用于去掉字符串开头和结尾的空格。如果字符串的开头或结尾有一个或多个空格,trim方法会返回一个新的字符串,这个字符串是原字符串去掉开头和结尾的空格后的结果。例如:
String str = " Hello, world! ";
String trimmed = str.trim();
System.out.println(trimmed); // 输出: Hello, world!
双指针在数组里大致就是这样,剩余的内容放到链表中。
滑动窗口
适用范围
1、一般是字符串或者列表
2、一般是要求最值(最大长度,最短长度等等)或者子序列
167 招式拆解
这个滑动窗口策略的目标是找到字符串中最长的不重复字符子串。为了实现这个目标,我们使用两个指针:left 和 right,right 指针在每次循环中都会向右移动一位,表示我们正在考虑的当前字符。left 指针表示窗口的开始位置,它只在我们遇到重复字符时移动。
具体来说,当我们遇到一个新的字符(即这个字符不在 dic 中),我们只需要将这个字符和它的索引添加到 dic 中,然后更新 res 的值为 res 和 right - left + 1 的较大值。
当我们遇到一个已经存在于 dic 的字符时,我们需要更新 left 的值。我们将 left 的值设置为 dic.get(arr.charAt(right)) + 1 和 left 的较大值。这样可以确保 left 总是位于窗口内的重复字符的右侧,也就是说,我们将窗口的左边界移动到了这个字符上一次出现的位置的右侧,以确保窗口内的字符不重复。比如说’abba’,这时候left第一次会更新为2,第二次更新不能再更新回1的位置,而还是2,因此是要取最大值。
在每次循环中,无论我们遇到的是一个新的字符还是一个已经存在于 dic 的字符,我们都将当前字符和它的索引添加到 dic 中。这是因为我们需要记录每个字符最后一次出现的位置,以便在遇到重复字符时正确地更新 left
class Solution {
public int dismantlingAction(String arr) {
Map<Character, Integer> dic = new HashMap<>();
int left=0,right=0;
int res=0;
for(; right<arr.length(); right++){
if(dic.containsKey(arr.charAt(right))){
left = Math.max(left, dic.get(arr.charAt(right)) + 1);
}
dic.put(arr.charAt(right), right);
res = Math.max(res, right - left + 1);
}
return res;
}
}
1438 绝对值不超过限制的最长连续子数组
不难发现,如果用滑动窗口,但是每次都要寻找窗口内的最大/小值,那时间复杂度仍为O(n^2),还是很低效的。
因此可以使用一个双端队列(Deque)来存储窗口内的最大值和最小值。你需要维护两个双端队列,一个用于存储最大值,另一个用于存储最小值。在每次循环中,你需要做以下操作:
- 从最大值队列的尾部移除所有小于当前元素的值,然后将当前元素添加到最大值队列的尾部。
- 从最小值队列的尾部移除所有大于当前元素的值,然后将当前元素添加到最小值队列的尾部。
如果最大值队列的头部元素和最小值队列的头部元素之差大于 limit,则移动窗口的左边界,并从队列中移除对应的元素。
这样,最大值队列的头部元素总是窗口内的最大值,最小值队列的头部元素总是窗口内的最小值,你可以在 O(1) 的时间内获取它们。
import java.util.*;
class Solution {
public int longestSubarray(int[] nums, int limit) {
// 初始化两个双端队列,一个用于存储窗口内的最大值,一个用于存储窗口内的最小值
Deque<Integer> maxDeque = new LinkedList<>();
Deque<Integer> minDeque = new LinkedList<>();
// 初始化窗口的左右边界和结果
int left = 0, right = 0, res = 0;
// 遍历数组
while (right < nums.length) {
// 如果最大值队列不为空,且队列尾部的元素小于当前元素,移除队列尾部的元素
while (!maxDeque.isEmpty() && maxDeque.peekLast() < nums[right]) {
maxDeque.pollLast();
}
// 如果最小值队列不为空,且队列尾部的元素大于当前元素,移除队列尾部的元素
while (!minDeque.isEmpty() && minDeque.peekLast() > nums[right]) {
minDeque.pollLast();
}
// 将当前元素添加到两个队列的尾部
maxDeque.offerLast(nums[right]);
minDeque.offerLast(nums[right]);
// 如果当前的最大值和最小值之差大于 limit,移动窗口的左边界,并从队列中移除对应的元素
while (!maxDeque.isEmpty() && !minDeque.isEmpty() && maxDeque.peekFirst() - minDeque.peekFirst() > limit) {
if (nums[left] == minDeque.peekFirst()) {
minDeque.pollFirst();
}
if (nums[left] == maxDeque.peekFirst()) {
maxDeque.pollFirst();
}
left++;
}
// 更新结果
res = Math.max(res, right - left + 1);
// 移动窗口的右边界
right++;
}
return res;
}
}
用一个例子解释一下:
nums = [8, 2, 4, 7];
limit = 4;
初始时,双端队列 maxDeque
和 minDeque
都是空的。我们的窗口也是空的,左右边界都在索引 0 的位置。
-
首先,我们看到元素 8。我们将其添加到
maxDeque
和minDeque
中。此时,maxDeque
= [8],minDeque
= [8]。窗口中的最大值和最小值的差为 0,小于limit
,所以我们将窗口向右扩展。 -
接下来,我们看到元素 2。我们将其添加到
maxDeque
和minDeque
中。但是,在添加到maxDeque
之前,我们需要先移除队列尾部的所有小于 2 的元素。同理,在添加到minDeque
之前,我们需要先移除队列尾部的所有大于 2 的元素。因此,maxDeque
= [8, 2],minDeque
= [2]。窗口中的最大值和最小值的差为 6,大于limit
,所以我们需要移动窗口的左边界,并从队列中移除对应的元素。此时,maxDeque
= [2],minDeque
= [2]。 -
我们继续看到元素 4。我们将其添加到
maxDeque
和minDeque
中。此时,maxDeque
= [4],minDeque
= [2, 4]。窗口中的最大值和最小值的差为 2,小于limit
,所以我们将窗口向右扩展。 -
最后,我们看到元素 7。我们将其添加到
maxDeque
和minDeque
中。此时,maxDeque
= [7],minDeque
= [2, 4, 7]。窗口中的最大值和最小值的差为 5,大于limit
,所以我们需要移动窗口的左边界,并从队列中移除对应的元素。此时,maxDeque
= [7],minDeque
= [4, 7]。
在遍历完数组后,我们发现满足条件的最长子数组的长度为 2,所以我们返回 2。
这就是这段代码的工作过程。在每次循环中,我们都保证了 maxDeque
的头部元素是窗口内的最大值,minDeque
的头部元素是窗口内的最小值。当窗口内的最大值和最小值的差大于 limit
时,我们移动窗口的左边界,并更新队列。