作者简介
我是阿宝,一名非科班同学,这是我第一篇文章;为什么要选择数据结构与算法开篇呢?
- 我在研一阶段,在项目空闲阶段,就会到力扣上刷刷题,接触的比较早;
- 理解数据结构有助于理解计算机底层设计和原理,理解算法有助于提升逻辑思维能力;
如何学习?
算法学习还是依托于力扣,一定要多练习;不然,会存在有思路缺写不出来的情况; 然后,就是尽量保持碎片化刷题,没事的时候,奖励自己一道题;
有何收获?
- 第一,就是说对常见的算法有了自己的理解,然后,能够写出来;
- 第二,在学习算法的过程中,提高了逻辑思维能力;
有什么注意点?
- 写之前先想好思路,不要摸棱两可就开始写
- 想好思路了,先取几个示例测试一下,看看能不能通过,防止重新再来;
下面,先从数组开始,学习算法;
数组相关算法
数组相关算法,边界问题是一个主要的问题:为了解决这个问题,我感觉最重要的就是要有一个非常清晰的定义。然后,在之后程序的编写中要维护好这个定义。下面以最简单的二分算法为例,说明这个问题:
/**
* 描述:实现二分查找,找到target所在的索引值
* 例: [1,2,3,4,5,6,7] target = 5 return 4;
* 要点:一定要非常清晰的定义好l 和 r 的意义,
* 在下面的循环中,一定要维护住这个声明
*/
class Solution {
public int binarySearch(int[] nums, int n, int target) {
/**
* 边界的设置与自己的定义相关
* 我定义的:在[l , r]的范围内找target
*/
int l = 0;
int r = n - 1;
// 当l=r时,[l,r]区间还是有效的
// 明确了定义以后,很多边界问题就容易处理了
while (l <= r){
int mid = (l + r)/2;
if (nums[mid] == target){
return mid;
}
// 说明target在[l,mid - 1]范围内
// 这里一定要 - 1 因为我们的定义是闭区间,我们已经知道mid不是目标元素了
if (target < nums[mid]){
r = mid - 1;
}
// 说明target在[mid + 1,r]的范围内
if (target > nums[mid]){
l = mid + 1;
}
}
return -1;
}
public static void main(String[] args) {
int[] nums = {1,2};
Solution solution = new Solution();
System.out.println(solution.binarySearch(nums, nums.length, 5));
}
}
关键点就是:
/**
* 边界的设置与自己的定义相关
* 我定义的:在[l , r]的范围内找target
*/
int l = 0;
int r = n - 1;
所以,之后的所有程序,都需要维护其定义的状态;我个人是喜欢以[l,r]区间来进行定义的;那么如果转为[l,r)区间可以吗?答案当然是可以的:这个时候程序就如下所示:
/**
* 描述:实现二分查找,找到target所在的索引值
* 例: [1,2,3,4,5,6,7] target = 5 return 4;
* 要点:一定要非常清晰的定义好l 和 r 的意义,
* 在下面的循环中,一定要维护住这个声明
*/
class Solution {
public int binarySearch(int[] nums, int n, int target) {
/**
* 边界的设置与自己的定义相关
* 我定义的:在[l , r)的范围内找target
*/
int l = 0;
int r = n;
// 明确了定义以后,很多边界问题就容易处理了
while (l < r){
int mid = (l + r)/2;
if (nums[mid] == target){
return mid;
}
// 说明target在[l,mid)范围内
// 这里一定要 - 1 因为我们的定义是闭区间,我们已经知道mid不是目标元素了
if (target < nums[mid]){
r = mid;
}
// 说明target在[mid + 1,r)的范围内
if (target > nums[mid]){
l = mid + 1;
}
}
return -1;
}
}
很显然,根据约定好的定义来编写,那么也可以解决问题。
总结一下:如何写一个正确的程序,
1.一定要明确我们声明的所有变量的含义
2.循环不变量,在循环过程中,不断的维护这些变量的含义
3.如果发生错误的话,取几个小数据集来测试,调试程序需要有耐心
数组相关问题
1.变量定义实例
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
输入:[0,1,0,3,12]
输出:[1,3,12,0,0]
对于这个问题,可以使用暴力解法来解决;先用一个list把非零元素取出来,然后,在把非零元素按顺序写入到数组中,数组中其他的数置为0;时间复杂度为n 空间复杂度为n;时间复杂度太高。
数组问题,很多可以在原数组中,通过索引来进行解决。
设置一个index来表示当前遍历过的所有非零整数,index:代表[0,index)当前遍历的所有的非零整数;
当遍历到非零整数时,把其存在index上即可;
class Solution {
public void moveZeroes(int[] nums) {
// [0,index)代表当前遍历的所有非零数
int index = 0;
for(int i = 0; i < nums.length; i++){
if (nums[i] != 0 && i != index){
nums[index] = nums[i];
index++;
}else if (nums[i] != 0 && i == index){
index++;
continue;
}
}
for(int i = index; i < nums.length; i++){
nums[i] = 0;
}
}
}
此类型的题目还有力扣26,27,80题,其实都是一样的,需要在原数组中,进行操作。
例26:
class Solution {
public int removeDuplicates(int[] nums) {
if (nums.length == 0){
return 0 ;
}
// [0,index)中存储符合条件的元素
int index = 0;
for (int i = 1; i < nums.length; i++) {
if (nums[i] != nums[i - 1]){
nums[index] = nums[i - 1];
index++;
}
}
nums[index] = nums[nums.length - 1];
return index + 1;
}
}
综上:对于在一个数组中,进行去重的相关问题,特别是题目提示需要使用O(1)的空间复杂度时,就需要引入index, 在[0,index)中保存符合条件的值。
数组相关算法之双指针、滑动窗口
数组相关算法-下
一、双指针
先查看一下本题:这个题目给的条件有升序排列,答案唯一。
这个题目有以下,几种解法
1.暴力解法:时间复杂度O(n2);
2.使用其升序的特点,针对nums[i]时,在[i+1,length]中,使用二分查找,这样的时间复杂度就为O(nlogn);
3.使用双指针
4.如果数组不是递增,或者不是两个数,需要找到多个数时,就转化为背包问题,可以使用动态规划来进行实现。
class Solution {
public int[] twoSum(int[] numbers, int target) {
int headPointer = 0;
int endPointer = numbers.length - 1;
int[] res = new int[2];
while(headPointer < endPointer){
if (numbers[headPointer] + numbers[endPointer] == target){
res[0] = headPointer + 1;
res[1] = endPointer + 1;
return res;
}else if (numbers[headPointer] + numbers[endPointer] > target){
endPointer--;
}else{
headPointer++;
}
}
return res;
}
}
和双指针相关问题在力扣中还有如下题目 125
344 反转字符串(所有的反转相关问题)
345 反转字符串中的元音字母
11 题盛水最大的容器
height[i] > height[j]时,j向右移动;
height[i] <= height[j]时,i向左移动;
二、滑动窗口
使用两个索引来代表一个窗口;通过这个窗口的滑动,在这个数组中游走来找到我们需要的解。
思路如下:
1.暴力解法:先选取nums[0]为起始点,算出和大于target时,它的长度。依次循环,解出最小的那个长度。时间复杂度为O(n2)
2.使用滑动窗口
- 设滑动窗口为[l, r];当[l, r]里面的元素大于等于s时,纪录长度;并且将l右移一位;
- 当[l, r]里面的元素小于s时,r++;
这样和暴力解法相比就是解决了重复计算的问题。
class Solution {
public int minSubArrayLen(int s, int[] nums) {
int length = nums.length;
// 定义滑动窗口为[l,r]
int l = 0;
int r = -1;
int sum = 0;
// 假设一个最大值,永远都不可能取到
int res = length + 1;
while (l < length){
if (sum < s){
r++;
// 如果[l,r]中值小于s,并且r >= length 循环结束
if (r >= length){
break;
}
sum = sum + nums[r];
}else{
res = Math.min((r - l + 1), res);
sum = sum - nums[l];
l++;
}
}
if (res == length + 1){
return 0;
}else{
return res;
}
}
}
这样时间负责度为O(n) 空间复杂度为1;
相同的题目还有力扣第3题:
也是使用滑动窗口的思想;
比如说 s = "abcdcabce";
设置滑动窗口为[l,r];比如说,这里找到abcd都是可以的,当找到c时,发现有重复的元素了,就需要把l移动到c所在的位置上。
class Solution {
public int lengthOfLongestSubstring(String s) {
int length = s.length();
char[] chars = s.toCharArray();
int res = 0;
// 定义滑动窗口为[l,r)
int l = 0;
int r = 0;
Set<Character> set = new HashSet<>();
while (l < length){
// 如果set中没有该数的话,进行加入
if (!set.contains(chars[r])){
set.add(chars[r]);
r++;
res = Math.max((r - l), res);
if (r >= length){
break;
}
}else{
while(chars[l] != chars[r]){
set.remove(chars[l]);
l++;
}
set.remove(chars[l]);
l++;
}
}
return res;
}
}
相同的问题还有438题,76题,42题:
对于,76题来说:其实最难的就是在r移动了一下以后,如何确定是否满足了t中子串的条件。主要困难就是这里。
class Solution {
// 问题:如何从字符串中找到目标子串
public String minWindow(String s, String t) {
/**
* 思路:先新建两个数组int[128],一个count 建立滑动窗口[l,r]
* 一个存t中的字符,另一个存s中的字符
* 在存s字符的过程中,如果发现是t中必要的字符,就进行count++
* 如果count = tLength 就说明找到一个一个目标值; 就让l++ 直到加到不满足条件即可
* 这时候,就把一个最小的[l,r]区间找到了。把其放入到map中
*/
int[] tInts = new int[128];
int[] sInts = new int[128];
// 滑动窗口为[l, r]
int l = 0;
int r = -1;
// key:存储长度,value:存储l
HashMap<Integer, Integer> hashMap = new HashMap<>();
int tLength = t.length();
int sLength = s.length();
char[] tChars = t.toCharArray();
char[] sChars = s.toCharArray();
int count = 0;
for (int i = 0; i < tLength; i++) {
tInts[tChars[i]]++;
}
for (int i = 0; i < sLength; i++) {
char ch = s.charAt(i);
// 说明这个ch是很必要的,count需要+1
if (tInts[ch] > 0 && sInts[ch] < tInts[ch]){
count++;
}
sInts[ch]++;
// 如果他们相等了
while(count == tLength){
char leftChar = s.charAt(l);
// 如果l++ 没有影响的,就l++
if (tInts[leftChar] == 0){
l++;
sInts[leftChar]--;
}else if (tInts[leftChar] > 0 && sInts[leftChar] > tInts[leftChar]){
l++;
sInts[leftChar]--;
}else if (tInts[leftChar] > 0 && sInts[leftChar] == tInts[leftChar]){
hashMap.put((i - l + 1),l);
count--;
sInts[leftChar]--;
l++;
}
}
}
if (hashMap.size() == 0){
return "";
}
// 遍历map中最小的数
Set<Integer> keySet = hashMap.keySet();
int minLength = sLength + 1;
for (Integer integer : keySet) {
minLength = Math.min(integer, minLength);
}
Integer leftIndex = hashMap.get(minLength);
return s.substring(leftIndex, leftIndex + minLength);
}
}
我的解决方案是:
int[] tInts = new int[128];
int[] sInts = new int[128];
先把所有的t都加入到tInts中,在加sInts的过程中,如果发现这个加入的数据是必要的,就需要count++;通过这个count来维持这个是否满足要求;
总结: 1. 当碰到子串问题、子数组、字符串相关的问题时,多考虑滑动窗口。 2. 当碰到反转问题时,多想双指针。
数组相关问题之范围查询
这个题,k的相关问题,可以使用滑动窗口来进行解决;但是,对于t相关的问题,处理就比较有技巧了;这里涉及范围问题了;这个时候,就可以使用TreeMap的floor()函数来进行处理;
假设,TreeMap中有[a, b, c]这三个数,然后,需要加入d ,a, b, c中有一个数在[d - t, d + t]范围内,就可以放回ture; 这时候,我们要先知道TreeSet.floor(x)的作用,它的作用是:返回一个小于等于x的最大数; 很显然,小于d + t的数是都有可能满足条件的;这个时候,我们先找到TreeSet.floor(d + t) 来找到 小于d + t的最大数y 那么,这只是满足了第一个条件,它还需要大于等于TreeSet.floor(d - t);如果,同时满足这两个条件的话,就符合条件可以返回true了。
// 1. 加入一个数 d 需要判断之前的TreeMap中是否有数符合 [d - t, d + t]
// 2. 使用y = TreeSet.floor(d + t) 找到小于等于d + t的最大值,默认满足了 [d - t, d + t]中,d + t这个条件
// 3. y 还需要大于等于d - t;这样,才完全满足所有条件
public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
int length = nums.length;
if (length < 2) {
return false;
}
TreeSet<Double> treeMap = new TreeSet<>();
treeMap.add((double)nums[0]);
// 定义循环不变量[l , r]
int l = 0;
int r;
for (r = 1; r < length; r++) {
if (r - l <= k) {
Double judgment = treeMap.floor((double)nums[r] + t);
if (judgment != null && judgment >= (double)nums[r] - t) {
return true;
}
} else {
treeMap.remove((double)nums[l]);
l++;
Double judgment = treeMap.floor((double)nums[r] + t);
if (judgment != null && judgment >= (double)nums[r] - t) {
return true;
}
}
treeMap.add((double)nums[r]);
}
return false;
}