算法与数据结构解析学习笔记

算法与数据结构解析学习笔记

1、算法简介

1.1、算法的基本概念

1.1.1、什么是算法

算法(Algorithm),就是“计算方法”,指解决一个问题具体的步骤和方法。

对于计算机而言,就是一系列解决问题的清晰指令。也就是说,对于一个问题,我们可以通过算法来描述解决的策略和步骤;对于规范的输入,按照算法描述的步骤,就可以在有限时间内得到对应的输出。

1.1.2、为什么要学习算法

  • 算法是计算机程序的核心。在一个计算机程序中,有两个非常重要的部分:数据结构和算法。数据结构决定了程序中数据组织和存储的形式,而算法决定了程序的功能和效率。算法在具体应用时,与数据结构密不可分,是一个程序的灵魂。
  • 算法是程序员的核心竞争力。算法是解决问题的方法和步骤,所以掌握了算法,就可以拥有更强的解决问题的能力。对于一个程序员而言,这往往比学会用一个框架、一个工具更加重要。
  • 算法是IT大厂面试的必考环节。大家可能知道,在IT特别是互联网公司的面试过程中,数据结构和算法是非常重要的一部分,为什么大厂都青睐于考察算法呢?总结起来,考察的原因以下几点:
    • 看一个程序员的技术功底是否扎实,考算法是最好的方式;
    • 看一个程序员的学习能力,和成长潜力,最好就是看他的算法能力;
    • 算法能力强,可以在面对新问题时,有更强的分析并解决问题的能力;
    • 算法能力,是设计一个高性能系统、性能优化的必备基础。

所以,算法是程序员绕不过去的必修课,也是走向架构师的必经之路。

1.1.3、怎样学习算法

  • 在学习算法之前,应该至少熟练掌握一门编程语言。本课程中的代码实现,都会以java为例来讲解。
  • 算法和数据结构密不可分,在系统学习算法之前,最好能够对基本的数据结构,数组、链表、哈希表、栈、队列、树都有充分的了解。在后续的课程中,我们会以算法讲解为主、穿插复习一些数据结构的基本知识。当然,算法本身是解题方法,有些也是不依赖于数据结构的,所以即使没有系统学过数据结构,同样可以开始算法的学习。
  • 算法学习的捷径,就是用算法去解决大量的具体问题,也就是通常所说的“刷题”。如果目的在于通过大厂面试,那这一步更是必不可少的准备。最经典的刷题网站,毫无疑问就是leetcode(力扣)。所以我们接下来的学习过程中,就会以leetcode的原题为例,分门别类进行算法的讲解,在具体解题的过程中巩固深化算法的学习。

1.2、算法的特征

一个算法应该具有以下五个重要的特征:

  • 有穷性(Finiteness)
    • 算法的有穷性,是指算法必须能在执行有限个步骤之后终止。
  • 确切性(Definiteness)
    • 算法的每一步骤必须有确切的定义。
  • 输入项(Input)
    • 一个算法有0个或多个输入,以刻画运算对象的初始情况。所谓0个输入,是指算法本身定出了初始条件。
  • 输出项(Output)
    • 一个算法有一个或多个输出,以反映对输入数据加工后的结果。
  • 可行性(Effectiveness)
    • 算法中执行的任何计算步骤都是可以被分解为基本的可执行的操作步骤,即每个计算步骤都可以在有限时间内完成(也称之为有效性)。

1.3、算法复杂度

基于算法的有穷性,我们可以知道算法运行消耗的时间不能是无限的。而对于一个问题的处理,可能有多个不同的算法,它们消耗的时间一般是不同的;运行过程中占用的空间资源也是不同的。
这就涉及到对算法的性能考察。主要有两方面:时间和空间。在计算机算法理论中,用时间复杂度和空间复杂度来分别从这两方面衡量算法的性能。

1.3.1、时间复杂度(Time Complexity)

算法的时间复杂度,是指执行算法所需要的计算工作量。
一般来说,计算机算法是问题规模n 的函数f(n),算法的时间复杂度也因此记做:T(n)=Ο(f(n))。
问题的规模n 越大,算法执行的时间的增长率与f(n) 的增长率正相关,称作渐进时间复杂度(Asymptotic Time Complexity)。

1.3.2、空间复杂度

算法的空间复杂度,是指算法需要消耗的内存空间。有时候做递归调用,还需要考虑调用栈所占用的空间。
其计算和表示方法与时间复杂度类似,一般都用复杂度的渐近性来表示。同时间复杂度相比,空间复杂度的分析要简单得多。
所以,我们一般对程序复杂度的分析,重点都会放在时间复杂度上。

1.3.3、时间复杂度的计算

要想衡量代码的“工作量”,我们需要将每一行代码,拆解成计算机能执行一条条“基本指令”。这样代码的执行时间,就可以用“基本指令”的数量来表示了。
真实的计算机系统里,基本指令包括:
算术指令(加减乘除、取余、向上向下取整)、数据移动指令(装载、存储、赋值)、控制指令(条件或无条件跳转,子程序调用和返回)。

1.3.3.1、考虑的问题
  • 不同的机器,计算速度不同
  • 如果计算机无限快,那么只要确定算法能够终止就可以了,所有算法性能也都没有比较的必要
  • 如果输入数据量很小,那么一般的机器也可以在非常短的时间内完成,算法性能也没有必要进行比较
  • 计算资源有限,输入数据量很大的情况下,不同算法消耗的时间相差极大
1.3.3.2、基本指令——程序执行消耗的时间单位
  • 算术指令、数据移动指令、控制指令
  • int a = 1;
    简单赋值操作,运行时间 1(1个单位)
  • if (a>1) {} 运行时间1
    简单判断操作、条件跳转,运行时间 1
  • for (int i = 0; i < N; i++) {System.out.println(i);} 运行时间3n+2
    有循环,运行时间 1(i赋初值)+ N+1(判断)+N(打印)+N(i自增)= 3N + 2

1.3.4、时间复杂度

  • 不同的算法,运行时间T(n)随着输入规模n的增长速度是不同的

在这里插入图片描述
第二个的时间复杂度计算如下:
int i = 0的运行时间是1
i < n的判断共进行了n+1次,所以运行时间是n+1
i++共运行了n次,所以运行时间是n
输出语句也运行了n次,所以运行时间是n
所以他们加起来就是1+(n+1)+n+n=3n+2
第三个的时间复杂度计算过程和第二个有点像,只不过是输出语句变成了一个遍历循环
因此计算公式变成了1+(n+1)+n+n(3n+2)=2n+2+3n2+2n=3n2+4n+2

1.3.5、复杂度的大O表示法

  • 对于给定函数g(n),用O(g(n))来表示以下函数的集合
    • O(g(n)) = { f(n):存在正常量c和n0,使对所有n≥ n0,有0≤f(n)≤cg(n) }
  • 算法分析中,一般用O符号表示函数的渐进上界
  • 这表示,当数据量达到一定程度时,g(n)的增长速度不会超过O(g(n))限定的范围

1.3.6、常见的算法复杂度

  • O(1):最低的时间复杂度,时间与输入的数据量大小无关,计算一次即可找到目标
  • O(log(n)):该算法随着输入规模翻倍,操作次数只增加一
  • O(√n):该算法随着输入规模翻倍,运行时间
  • O(n):随着输入规模的增加,运行时间线性增加
  • O(nlog(n))
  • O(n2):随着输入规模的增加,运行时间次方增加
  • O(en):随着输入规模的增加,指数增长逐渐增加
    在这里插入图片描述
  • 其实还有个O(n!),这个比O(en)还慢:随着规模的增加,运行时间阶段式增加

1.3.7、空间复杂度

  • 算法执行所占用的空间
    • Array[100]: O(100)
    • Array[n]: O(n)
    • int a = 1 O(1)
  • 有时候递归调用,还需要计算栈所占用的空间

1.4、算法的分类

1.4.1、按照应用目的

  • 搜索算法
  • 排序算法
  • 字符串算法
  • 图算法
  • 最优化算法
  • 数学算法

1.4.2、按照实现策略

  • 暴力法
  • 增量法
  • 分治法
  • 贪心算法
  • 动态规划法
  • 回溯法
  • 分支限界法

1.4.3、分治算法

  • 分而治之
    • 问题的规模越小,越容易解决
    • 把复杂问题不断分成多个相同或相似的子问题,直到每个子问题都可以简单的进行求解
    • 将所有子问题的解合并起来,就是原问题的解
  • 分治和递归
    • 产生的子问题形式往往和原问题相同,只是原问题的较小规模表达
    • 使用递归手段求解子问题,可以很容易的将子问题的解合并,得到原问题的解
  • 基本步骤
    • step1:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题
    • step2:若子问题规模较小而容易被解决则直接求解,否则递归地解每个子问题
    • step3:将各个子问题的解合并成原问题的解
  • 应用场景
    • 二分搜索、大整数乘法、归并排序、快速排序
    • 棋盘覆盖问题、循环赛日程表问题、汉诺塔问题等
  • 贪心(Greedy)——总是做出在当前看来最好的选择
    • 不从整体考虑,只考虑眼前,得到局部最优解
  • 局部最优解和全局最优解
    • 要保证最终得到的是全局最优解,贪心策略必须具备无后效性
  • 适用场景
    • 用贪心算法直接求解全局最优解,条件比较苛刻
    • 哈夫曼(Huffman)编码
  • 具体实现框架
    从问题的某一初始解出发;
    while (能朝着给定目标前进一步)
    {
    利用可行的策略,求出可行解的一个解元素;
    }
    由所有解元素组合成问题的一个可行解;

1.4.4、动态规划

  • 动态决策的过程
    • 将原问题划分成多个阶段,依次来做决策,得到当前的局部解
    • 每次决策依赖于当前的状态,随即引起状态的转移
    • 一个决策序列就是在变化的状态中产生出来的
    • 这种多阶段决策最优化,解决问题的过程就称为动态规划
  • 最优化问题
    • 动态规划通常用来求解最优化问题
    • 可以有很多可行解,每个解都对应一个值,我们希望找到具有最优值的解
      在这里插入图片描述
  • 应用场景
    • 最优二叉搜索树、最长公共子序列、背包问题等

1.4.5、回溯法

  • 选优搜索法
    • 按照一定的选优条件,不停向前搜索,直到达到目标
    • 如果搜索到某一步,发现之前的选择并不优,就退回一步重新选择
    • 通用解题方法
  • 深度优先搜索(DFS)策略
    • 在包含问题所有解的解空间树中,按照深度优先搜索的策略,从根结点出发,深度搜索解空间树
    • 回溯法就是对隐式图的深度优先搜索算法

1.4.6、分支限界法

  • 广度优先搜索(BFS)策略
    • 所谓分支,就是采用广度优先的策略,依次搜索当前节点的所有分支
    • 抛弃不满足约束条件的相邻节点,其余节点加入活结点表
    • 然后从表中选择一个节点作为下一扩展节点,继续搜索
  • 限界策略
    • 为了加速搜索的进程,一般会在每一个活结点处,计算一个函数值
    • 根据这些已计算出的函数值,从当前活结点表中选择一个最有利的节点作为扩展节点,使搜索朝着解空间树上的最优解的分支推进,以便最快的找出一个最优解

1.4.7、回溯法和分支限界法的区别

回溯法分支限界法
对解空间树的搜索方式DFSBFS
存储节点常用数据结构堆栈队列
应用场景找出满足约束条件的所有解;找出全局最优解找出满足约束条件的一个解;找出局部最优解

1.4.8、一些经典算法

  • 二分查找
  • 快速排序、归并排序
  • KMP算法
  • 快慢指针(双指针法)
  • 普利姆(Prim)和克鲁斯卡尔(Kruskal)算法
  • 迪克斯特拉(Dijkstra)算法
  • 其他优化算法:模拟退火,蚁群、遗传算法

2、数组问题

在程序设计中,为了处理方便,常常需要把具有相同类型的若干元素按有序的形式组织起来,这种形式就是数组(Array)。
数组是程序中做常见、也是最基本的数据结构。在很多算法问题中,都少不了数组的处理和转换。
对数组进行处理需要注意以下特点:

  • 首先,数组会利用索引来记录每个元素在数组中的位置,且在大多数编程语言中,索引是从0算起的。我们可以根据数组中的索引,快速访问数组中的元素。事实上,这里的索引其实就是内存地址
    在这里插入图片描述
  • 其次,作为线性表的实现方式之一,数组的元素在内存中是连续存储的,且每个元素占用相同大小的内存

接下来,我们就以LeetCode上一些数组相同的题目要为例,来学习解决数组问题的算法

2.1、两数之和

给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

2.1.1、方法一:暴力法

看到一道算法题,首先考虑暴力解法,再进行优化。
暴力法其实非常简单:把所有数、两两组合在一起,计算它们的和,如果是target,就输出。
我们可以在代码中实现一下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(n2),对于每个元素,我们试图通过遍历数组的其余部分来寻找它所对应的目标元素,这将耗费 O(n)。
  • 空间复杂度:O(1)。

2.1.2、方法二:哈希表

为了对运行时间复杂度进行优化,我们需要一种更有效的方法来检查数组中是否存在目标元素。如果存在,我们需要找出它的索引。这可以使用哈希表来实现。
具体实现方法,最简单就是使用两次迭代。
在第一次迭代中,我们将每个元素的值和它的索引添加到表中;然后,在第二次迭代中,我们将检查每个元素所对应的目标元素 (target-nums[i]) 是否存在于表中。
代码如下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(N),我们把包含有 N 个元素的列表遍历两次。由于哈希表将查找时间缩短到 O(1),所以时间复杂度为 O(N)。
  • 空间复杂度:O(N),所需的额外空间取决于哈希表中存储的元素数量,该表中存储了 N 个元素。

2.1.3、方法三、改进哈希表,改成只遍历一次哈希表

在上述算法中,我们对哈希表进行了两次扫描,这其实是不必要的。在进行迭代并将元素插入到表中的同时,我们可以直接检查表中是否已经存在当前元素所对应的目标元素。如果它存在,那我们已经找到了对应解,并立即将其返回。这样,只需要扫描一次哈希表,就可以完成算法了。
代码如下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(N),我们只遍历了包含有 N 个元素的列表一次。在表中进行的每次查找只花费 O(1) 的时间。其实这个过程中,我们也借鉴了动态规划的思想、把子问题解保存起来,后面用到就直接查询。
  • 空间复杂度:O(N),所需的额外空间取决于哈希表中存储的元素数量,该表最多需要存储 N 个元素。

2.1.4、三种方法消耗时间对比

2.1.4.1、数据量少且顺序排序

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.1.4.2、数据量少且倒序排序

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

2.1.4.3、数据量大且倒序排序

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.1.5、代码

import java.util.HashMap;

public class TwoSum {
    //方法一:暴力法,穷举所有两数集合
    public int[] TwoSum1(int[] nums, int target) {
        int n = nums.length;
        //双重for循环
        for (int i = 0; i < n-1; i++) {
            for (int j = i+1; j < n; j++) {
                if (nums[i] + nums[j] == target) {
                    return new int[]{i,j};
                }
            }
        }
        //如果找不到,抛出异常
        throw new IllegalArgumentException("no solution");
    }

    //方法二:哈希表保存所有数的信息
    public int[] TwoSum2(int[] nums, int target) {
        int n = nums.length;
        //定义一个哈希表
        HashMap<Integer, Integer> map = new HashMap<Integer,Integer>();
        //1、遍历数组,将数据全部保存入哈希表
        for (int i = 0; i < n; i++) {
            map.put(nums[i],i);
        }
        //2、再次遍历数组,寻找每个数对应的那个数是否存在
        for (int i = 0; i < n; i++) {
            int thatNum = target - nums[i];
            //如果那个数存在,且不是当前数自己,就直接返回结果
            if (map.containsKey(thatNum) && map.get(thatNum) != i) {
                return new int[]{i,map.get(thatNum)};
            }
        }
        //如果找不到,抛出异常
        throw new IllegalArgumentException("no solution");
    }

    //方法三:改进,遍历一次哈希表
    public int[] TwoSum3(int[] nums, int target) {
        int n = nums.length;
        //定义一个哈希表
        HashMap<Integer, Integer> map = new HashMap<Integer,Integer>();
        //遍历数组,寻找每个数对应的那个数是否存在(只向前寻找)
        for (int i = 0; i < n; i++) {
            int thatNum = target - nums[i];
            //如果那个数存在,且不是当前数自己,就直接返回结果
            if (map.containsKey(thatNum) && map.get(thatNum) != i) {
                return new int[]{map.get(thatNum),i};
            }
            map.put(nums[i],i);
        }
        //如果找不到,抛出异常
        throw new IllegalArgumentException("no solution");
    }

    public static void main(String[] args) {
        int[] input = {2,7,11,15};
        int target = 9;

        //定义一个大数组,进行性能测试
        int[] input3 = new int[100000];
        for (int i = 0; i<input3.length; i++) {
            input3[i] = input3.length - i;
        }

        int target3 = 56789;

        //为了计算系统程序运行时间,开始计算和计算完成分别计时
        long startTime = System.currentTimeMillis();

        TwoSum twoSum = new TwoSum();
        int[] output = twoSum.TwoSum3(input3, target3);

        long endTime = System.currentTimeMillis();

        for (int i : output) {
            System.out.println(i);
        }

        System.out.println("程序运行时间:" + (endTime-startTime) + "ms");

    }
}

2.2、三数之和

给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例:
给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为:
[
[-1, 0, 1],
[-1, -1, 2]
]

2.2.1、方法一:暴力法

最简单的办法,当然还是暴力法。基本思路是,每个人都先去找到另一个人,然后再一起逐个去找第三个人。
很容易想到,实现起来就是三重循环:这个时间复杂度是 O(n3)。

代码如下:
在这里插入图片描述
运行一下,我们会发现,这个结果其实是不正确的没有去重,同样的三元组在结果中无法排除。比如-1,0,1会出现两次。而且时间复杂度非常高,是N3
所以接下来,我们就要做一些改进,试图降低时间复杂度,而且解决去重问题。

2.2.2、方法二:使用哈希表保存结果

要做去重,自然首先想到的,就是把结果保存到一张hash表里。仿照两数之和,直接存到HashMap里查找,代码如下:
在这里插入图片描述
时间复杂度降为N2,空间复杂度O(N)。
但是,我们加一个输入[0,0,0,0],会发现 结果不正确。
因为尽管通过HashMap存储可以去掉相同二元组的计算结果的值,但没有去掉重复的输出(三元组)。这就导致,0对应在HashMap中有一个值(0,List(0,0)),第三个0来了会输出一次,第四个0来了又会输出一次。
如果希望解决这个问题,那就需要继续加入其它的判断来做去重,整个代码复杂度会变得更高。

2.2.3、方法三:双指针法

暴力法搜索时间复杂度为O(N3),要进行优化,可通过双指针动态消去无效解来提高效率。
双指针的思路,又分为左右指针和快慢指针两种。
我们这里用的是左右指针。左右指针,其实借鉴的就是分治的思想,简单来说,就是在数组头尾各放置一个指针,先让头部的指针(左指针)右移,移不动的时候,再让尾部的指针(右指针)左移:最终两个指针相遇,那么搜索就结束了。

(1)双指针法铺垫: 先将给定 nums 排序,复杂度为 O(NlogN)。
首先,我们可以想到,数字求和,其实跟每个数的大小是有关系的,如果能先将数组排序,那后面肯定会容易很多。
之前我们搜索数组,时间复杂度至少都为O(N2),而如果用快排或者归并,排序的复杂度,是 O(NlogN),最多也是O(N2)。所以增加一步排序,不会导致整体时间复杂度上升。
在这里插入图片描述
下面我们通过图解,来看一下具体的操作过程。

(2)初始状态,定义左右指针L和R,并以指针i遍历数组元素。
在这里插入图片描述
固定 3 个指针中最左(最小)数字的指针 i,双指针 L,R 分设在数组索引 (i, len(nums)) 两端,所以初始值,i=0;L=i+1;R=nums.length-1
通过L、R双指针交替向中间移动,记录对于每个固定指针 i 的所有满足 nums[i] + nums[L] + nums[R] == 0 的 L,R 组合。
两个基本原则:

  • 当 nums[i] > 0 时直接break跳出:因为 nums[R] >= nums[L] >= nums[i] > 0,即 3 个数字都大于 0 ,在此固定指针 i 之后不可能再找到结果了。
  • 当 i > 0且nums[i] == nums[i - 1]时,即遇到重复元素时,跳过此元素nums[i]:因为已经将 nums[i - 1] 的所有组合加入到结果中,本次双指针搜索只会得到重复组合。

(3)固定i,判断sum,然后移动左右指针L和R。
L,R 分设在数组索引 (i, len(nums)) 两端,当L < R时循环计算当前三数之和:
sum = nums[i] + nums[L] + nums[R]
并按照以下规则执行双指针移动:

  • 当sum < 0时,L ++并跳过所有重复的nums[L];
    在这里插入图片描述
    在这里插入图片描述
  • 由于sum<0,L一直右移,直到跟R重合。如果依然没有结果,那么i++,换下一个数考虑。

换下一个数,i++,继续移动双指针:
在这里插入图片描述
初始同样还是L=i+1,R=nums.length-1。同样,继续判断sum。

  • 找到一组解之后,继续移动L和R,判断sum,如果小于0就右移L,如果大于0就左移R:
    在这里插入图片描述

找到一组解[-1,-1,2],保存,并继续右移L。判断sum,如果这时sum=-1+0+2>0,(R还没变,还是5),那么就让L停下,开始左移R。

  • 一直移动,又找到一组解
    在这里插入图片描述

如果又找到sum=0的一组解,把当前的[-1,0,1]也保存到结果数组。继续右移L。

  • 如果L和R相遇或者L>R,代表当前i已经排查完毕,i++;如果i指向的数跟i-1一样,那么直接继续i++,考察下一个数;
    在这里插入图片描述
  • 这时i=3,类似地,当sum > 0时,R左移R -= 1,并跳过所有重复的nums[R];
    在这里插入图片描述
  • L和R相遇,结束当前查找,i++。
    在这里插入图片描述
    当 nums[i] > 0 时直接break跳出:过程结束。
    所以,最终的结果,就是[-1,-1,2],[-1,0,1]。

代码如下:
在这里插入图片描述

复杂度分析

  • 时间复杂度 O(n2):其中固定指针k循环复杂度 O(n),双指针 i,j 复杂度 O(n)。比暴力法的O(n3),显然有了很大的改善。
  • 空间复杂度 O(1):指针使用常数大小的额外空间。

尽管时间复杂度依然为O(n2),但是过程中避免了复杂的数据结构,空间复杂度仅为常数级O(1),可以说,双指针法是一种很巧妙、很优雅的算法设计。

2.2.4、代码

import java.util.*;

public class ThreeSum {
    //方法一:暴力法
    public List<List<Integer>> threeSum1(int[] nums) {
        int n = nums.length;
        List<List<Integer>> list = new ArrayList<>();
        //三重for循环,枚举所有的三数组合
        for (int i = 0; i < n-2; i++) {
            for (int j = i + 1; j < n-1; j++) {
                for (int k = j + 1; k < n; k++) {
                    if (nums[i] + nums[j] + nums[k] == 0) {
                        list.add(Arrays.asList(nums[i],nums[j],nums[k]));
                    }
                }
            }
        }
        return list;
    }

    //方法二:使用哈希表保存结果
    public List<List<Integer>> threeSum2(int[] nums) {
        int n = nums.length;
        List<List<Integer>> list = new ArrayList<>();
        //定义一个Hashmap
        Map<Integer, List<Integer>> map = new HashMap<>();
        //遍历数组,寻找每个数对应的那个数
        for (int i = 0; i < n; i++) {
            int thatNum = 0 - nums[i];
            if (map.containsKey(thatNum)) {
                //如果已经存在thatNum,就找到了一组解
                List<Integer> arrayList = new ArrayList<>(map.get(thatNum));
                arrayList.add(nums[i]);
                list.add(arrayList);
            }

            //将当前数对应的两数组合都保存到map里
            for (int j = 0; j < i; j++) {
                //以两数之和作为key
                int newKey = nums[i] + nums[j];
                //如果key不存在,就直接添加进去
                if (!map.containsKey(newKey)) {
                    List<Integer> arrayList = new ArrayList<>();
                    arrayList.add(nums[i]);
                    arrayList.add(nums[j]);
                    map.put(newKey,arrayList);
                }
            }
        }
        return list;
    }
    //方法三:双指针法
    public List<List<Integer>> threeSum3(int[] nums) {
        int n = nums.length;
        List<List<Integer>> list = new ArrayList<>();
        // 先对数组进行排序
        Arrays.sort(nums);
        for(int i = 0; i < n; i++){
            if(nums[i] > 0) {
                break;
            }
            if(i > 0 && nums[i] == nums[i-1]) {
                continue;
            }
            // 定义左右指针(索引位置)
            int lp = i + 1;
            int rp = n - 1;
            // 只要左右不重叠,就继续移动指针
            while(lp < rp){
                int sum = nums[i] + nums[lp] + nums[rp];
                if(sum == 0){
                    list.add(Arrays.asList(nums[i], nums[lp], nums[rp]));
                    lp ++;
                    rp --;
                    while (lp < rp && nums[lp] == nums[lp - 1]) {
                        lp ++;
                    }
                    while (lp < rp && nums[rp] == nums[rp + 1]) {
                        rp --;
                    }
                } else if(sum < 0) {
                    lp ++;
                } else {
                    rp --;
                }
            }
        }
        return list;
    }

    public static void main(String[] args) {
        int[] input = {-1,0,1,2,-1,-4};

        ThreeSum threeSum = new ThreeSum();
        List<List<Integer>> lists = threeSum.threeSum3(input);
        System.out.println(lists);
    }
}

2.3、下一个排列

实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。
如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。
必须原地修改,只允许使用额外常数空间。

以下是一些例子,输入位于左侧列,其相应输出位于右侧列。
1,2,3 → 1,3,2
3,2,1 → 1,2,3
1,1,5 → 1,5,1

2.3.1、方法一:暴力法

最简单的想法就是暴力枚举,我们找出由给定数组的元素形成的列表的每个可能的排列,并找出比给定的排列更大的排列。
但是这个方法要求我们找出所有可能的排列,这需要很长时间,实施起来也很复杂。因此,这种算法不能满足要求。 我们跳过它的实现,直接采用正确的方法。

复杂度分析

  • 时间复杂度:O(n!),可能的排列总计有 n! 个。
  • 空间复杂度:O(n),因为数组将用于存储排列。

2.3.2、方法二:一遍扫描

首先,我们观察到对于任何给定序列的降序排列,就不会有下一个更大的排列。
例如,以下数组不可能有下一个排列:
[9, 5, 4, 3, 1]
这时应该直接返回升序排列。

所以对于一般的情况,如果有一个“升序子序列”,那么就一定可以找到它的下一个排列。具体来说,需要从右边找到第一对两个连续的数字 a[i] 和 a[i-1],它们满足 a[i]>a[i-1]。
所以一个思路是,找到最后一个的“正序”排列的子序列,把它改成下一个排列就行了。
在这里插入图片描述
不过具体操作会发现,如果正序子序列后没数了,那么子序列的“下一个”一定就是整个序列的“下一个”,这样做没问题;但如果后面还有逆序排列的数,这样就不对了。比如
[1,3,8,7,6,2]
最后的正序子序列是[1,3,8],但显然不能直接换成[1,8,3]就完事了;而是应该考虑把3换成后面比3大、但比8小的数,而且要选最小的那个(6)。接下来,还要让6之后的所有数,做一个升序排列,得到结果:
[1,6,2,3,7,8]
代码实现如下:

public class NextPermutation {
    public void nextPermutation1(int[] nums) {
        int k = nums.length - 2;
        while (k >= 0 && nums[k] >= nums[k+1]) {
            k--;
        }
        // 如果全部降序,以最小序列输出
        if(k < 0){
            reverse(nums,0,k-1);
            return;
        }
        int i = k + 2;
        while(i < nums.length && nums[i] > nums[k]) {
            i++;
        }
        // 交换nums[k]和找到的nums[i-1]
        swap(nums,k,i-1);
        // k以后剩余的部分反转成升序
        int start = k + 1;
        int end = nums.length - 1;
        reverse(nums,start,end);
    }

    //定义一个方法,交换数组中的两个元素
    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }

    //定义一个翻转数组的方法
    private void reverse(int[] nums, int start, int end) {
        while (start < end) {
            swap(nums,start,end);
            start++;
            end--;
        }
    }
    public static void main(String[] args) {
        int[] nums = {1,3,8,7,6,2};
        NextPermutation nextPermutation = new NextPermutation();
        nextPermutation.nextPermutation1(nums);
        for (int num : nums) {
            System.out.println(num);
        }
    }
}

复杂度分析

  • 时间复杂度:O(N),其中 N 为给定序列的长度。我们至多只需要扫描两次序列,以及进行一次反转操作。
  • 空间复杂度:O(1),只需要常数的空间存放若干变量。

2.4、旋转图像

给定一个 n × n 的二维矩阵表示一个图像。
将图像顺时针旋转 90 度。
说明:
你必须在原地旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要使用另一个矩阵来旋转图像。

示例 1:
给定 matrix = 
[
  [1,2,3],
  [4,5,6],
  [7,8,9]
],
原地旋转输入矩阵,使其变为:
[
  [7,4,1],
  [8,5,2],
  [9,6,3]
]
示例 2:
给定 matrix =
[
  [ 5, 1, 9,11],
  [ 2, 4, 8,10],
  [13, 3, 6, 7],
  [15,14,12,16]
], 
原地旋转输入矩阵,使其变为:
[
  [15,13, 2, 5],
  [14, 3, 4, 1],
  [12, 6, 8, 9],
  [16, 7,10,11]
]

旋转图像,这个应用在图片处理的过程中,非常常见。我们知道对于计算机而言,图像,其实就是一组像素点的集合(所谓点阵),所以图像旋转的问题,本质上就是一个二维数组的旋转问题。

2.4.1、方法一:数学方法(转置再翻转)

我们可以利用矩阵的特性。所谓顺时针旋转,其实就是先转置矩阵,然后翻转每一行。

代码如下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(N2)
    这个简单的方法已经能达到最优的时间复杂度O(N2) ,因为既然是旋转,那么每个点都应该遍历到,N2的复杂度不可避免。
  • 空间复杂度:O(1)。旋转操作是原地完成的,只耗费常数空间。

2.4.2、方法二:分治(分为四部分旋转)

方法 1 使用了两次矩阵操作,能不能只使用一次操作的方法完成旋转呢?
为了实现这一点,我们来研究每个元素在旋转的过程中如何移动。
在这里插入图片描述

这提供给我们了一个思路,可以将给定的矩阵分成四个矩形并且将原问题划归为旋转这些矩形的问题。这其实就是分治的思想。
在这里插入图片描述

具体解法也很直接,可以在每一个矩形中遍历元素,并且在长度为 4 的临时列表中移动它们。
在这里插入图片描述
代码如下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(N2) 是两重循环的复杂度。
  • 空间复杂度:O(1) 由于我们在一次循环中的操作是“就地”完成的,并且我们只用了长度为 4 的临时列表做辅助。

2.4.3、方法三:分治法改进(单次循环内完成旋转)

大家可能也发现了,我们其实没有必要分成4个矩阵来旋转。这四个矩阵的对应关系,其实是一目了然的,我们完全可以在一次循环内,把所有元素都旋转到位。
因为旋转的时候,是上下、左右分别对称的,所以我们遍历元素的时候,只要遍历一半行、一半列就可以了(1/4元素)。

代码如下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(N2),是两重循环的复杂度。
  • 空间复杂度:O(1)。我们在一次循环中的操作是“就地”完成的。

2.4.4、代码

public class Rotate {
    public void rotate1(int[][] matrix) {
        int n = matrix.length;

        // 转置矩阵
        for (int i = 0; i < n; i++) {
            for (int j = i; j < n; j++) {
                int tmp = matrix[i][j];
                matrix[i][j] = matrix[j][i];
                matrix[j][i] = tmp;
            }
        }
        
        // 翻转行
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < n/2; j++) {
                int tmp = matrix[i][j];
                matrix[i][j] = matrix[i][n-j-1];
                matrix[i][n-j-1] = tmp;
            }
        }
    }

    public void rotate2(int[][] matrix) {
        int n = matrix.length;

        for (int i = 0; i < n / 2 + n % 2; i++) {
            for (int j = 0; j < n / 2; j++) {
                int[] tmp = new int[4];
                int row = i;
                int col = j;
                for (int k = 0; k < 4; k++) {
                    tmp[k] = matrix[row][col];
                    // 定位下一个数
                    int x = row;
                    row = col;
                    col = n - 1 - x;
                }

                for (int k = 0; k < 4; k++) {
                    matrix[row][col] = tmp[(k + 3) % 4];
                    int x = row;
                    row = col;
                    col = n - 1 - x;
                }
            }
        }
    }

    public void rotate3(int[][] matrix) {
        int n = matrix.length;

        // 不区分子矩阵,直接遍历每一个元素
        for( int i = 0; i < (n + 1)/2; i++ ){
            for( int j = 0; j < n/2; j++ ){
                int temp = matrix[i][j];
                matrix[i][j] = matrix[n-j-1][i];
                matrix[n-j-1][i] = matrix[n-i-1][n-j-1];
                matrix[n-i-1][n-j-1] = matrix[j][n-i-1];
                matrix[j][n-i-1] = temp;
            }
        }
    }

    public static void main(String[] args) {
        int[][] matrix1 = {
                {1,2,3},
                {4,5,6},
                {7,8,9}
        };
        int[][] matrix2 = {
                {1,2,3,4},
                {5,6,7,8},
                {9,10,11,12},
                {13,14,15,16}
        };
        Rotate rotate = new Rotate();
        rotate.rotate3(matrix1);
        for (int[] ints : matrix1) {
            for (int i : ints) {
                System.out.print(i + " ");
            }
            System.out.println(" ");
        }
    }
}


3、二分查找相关问题讲解

3.1、二分查找

二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法,前提是数据结构必须先排好序,可以在对数时间复杂度内完成查找。

二分查找事实上采用的就是一种分治策略,它充分利用了元素间的次序关系,可在最坏的情况下用O(log n)完成搜索任务。
它的基本思想是:假设数组元素呈升序排列,将n个元素分成个数大致相同的两半,取a[n/2]与欲查找的x作比较,如果x=a[n/2]则找到x,算法终止;如 果x<a[n/2],则我们只要在数组a的左半部继续搜索x;如果x>a[n/2],则我们只要在数组a的右 半部继续搜索x。
在这里插入图片描述
二分查找问题也是面试中经常考到的问题,虽然它的思想很简单,但写好二分查找算法并不是一件容易的事情。

接下来,我们首先用代码实现一个对int数组的二分查找。
在这里插入图片描述
当然,我们也可以用递归的方式实现:
在这里插入图片描述

public class BinarySearch {
    public static int binarySearch1(int[] a, int key){
        int low = 0;
        int high = a.length - 1;
        if (key < a[low] || key > a[high]) {
            return -1;
        }
        while (low <= high){
            int mid = ( low + high ) / 2;
            if(a[mid] < key) {
                low = mid + 1;
            } else if(a[mid] > key) {
                high = mid - 1;
            } else {
                return mid;    // 查找成功
            }
        }
        // 未能找到
        return -1;
    }

    public static int binarySearch2(int[] a, int key, int fromIndex, int toIndex){
        if (key < a[fromIndex] || key > a[toIndex] || fromIndex > toIndex) {
            return -1;
        }

        int mid = ( fromIndex + toIndex ) / 2;

        if (a[mid] < key) {
            return binarySearch2(a, key, mid + 1, toIndex);
        } else if (a[mid] > key) {
            return binarySearch2(a, key, fromIndex, mid - 1);
        } else {
            return mid;
        }
    }

    public static void main(String[] args) {
        int[] nums = {1,2,3,4,5,6,7,8,9,10};
        BinarySearch binarySearch = new BinarySearch();
        int i = binarySearch.binarySearch2(nums, 5,0,9);
        System.out.println(i);
    }
}

我们总结一下二分查找:

  • 优点是比较次数少,查找速度快,平均性能好;
  • 缺点是要求待查表为有序表,且插入删除困难。

因此,二分查找方法适用于不经常变动而查找频繁的有序列表。使用条件:查找序列是顺序结构,有序。

3.2、搜索二维矩阵

编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:

  • 每行中的整数从左到右按升序排列。
  • 每行的第一个整数大于前一行的最后一个整数。

示例 1:
在这里插入图片描述

输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,50]], target = 3
输出:true
示例 2:
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,50]], target = 13
输出:false
示例 3:
输入:matrix = [], target = 0
输出:false

提示:

  • m == matrix.length
  • n == matrix[i].length
  • 0 <= m, n <= 100
  • -104 <= matrix[i][j], target <= 104

既然这是一个查找元素的问题,并且数组已经排好序,我们自然可以想到用二分查找是一个高效的查找方式。
输入的 m x n 矩阵可以视为长度为 m x n的有序数组:
在这里插入图片描述
行列坐标为(row, col)的元素,展开之后索引下标为idx = row * n + col;反过来,对于一维下标为idx的元素,对应二维数组中的坐标就应该是:
row = idx / n; col = idx % n;
代码实现如下:
在这里插入图片描述

public class SearchMatrix {
    public boolean searchMatrix(int[][] matrix, int target){
        int m = matrix.length;
        if (m == 0) {
            return false;
        }
        int n = matrix[0].length;
        int left = 0;
        int right = m * n - 1;

        // 二分查找,定义左右指针
        while ( left <= right ){
            int midIdx = (left + right) / 2;
            int midElement = matrix[midIdx/n][midIdx%n];
            if ( midElement < target ) {
                left = midIdx + 1;
            } else if ( midElement > target ) {
                right = midIdx - 1;
            } else {
                return true;    // 找到target
            }
        }

        return false;
    }

    public static void main(String[] args) {
        int[][] matrix = {
                {1,2,3,4},
                {5,6,7,8},
                {9,10,11,12},
                {13,14,15,16}
        };
        SearchMatrix searchMatrix = new SearchMatrix();
        boolean b = searchMatrix.searchMatrix(matrix, 8);
        System.out.println(b);
    }
}

复杂度分析

  • 时间复杂度 : 由于是标准的二分查找,时间复杂度为O(log(m n))。
  • 空间复杂度 : 没有用到额外的空间,复杂度为O(1)。

3.3、寻找重复数

给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
示例 1:
输入: [1,3,4,2,2]
输出: 2
示例 2:
输入: [3,1,3,4,2]
输出: 3
说明:

  • 不能更改原数组(假设数组是只读的)。
  • 只能使用额外的 O(1) 的空间。
  • 时间复杂度小于 O(n2) 。
  • 数组中只有一个重复的数字,但它可能不止重复出现一次。

怎样证明 nums 中存在至少一个重复值?其实很简单,这是“抽屉原理”(或者叫“鸽子洞原理”)的简单应用。
这里,nums 中的每个数字(n+1个)都是一个物品,nums 中可以出现的每个不同的数字(n个)都是一个 “抽屉”。把n+1 个物品放入n个抽屉中,必然至少会有一个抽屉放了2个或者2个以上的物品。所以这意味着nums中至少有一个数是重复的。

3.3.1、方法一:保存元素法(存入HashMap)

首先我们想到,最简单的办法就是,遍历整个数组,挨个统计每个数字出现的次数。
用一个HashMap保存每个数字对应的count数量,就可以直观地判断出是否重复了。
代码如下:
在这里插入图片描述

3.3.2、方法二:保存元素法改进(存入Set)

当然我们应该还能想到,其实没必要用HashMap,直接保存到一个Set里,就知道这个元素到底有没有了。
在这里插入图片描述

复杂度分析

  • 时间复杂度:O(n),我们只对数组做了一次遍历,在HashMap和HashSet中查找的复杂度是O(1)。
  • 空间复杂度:O(n),我们需要一个HashMap或者HashSet来做额外存储,最坏情况下,这需要线性的存储空间。

尽管时间复杂度较小,但以上两种保存元素的方法,都用到了额外的存储空间,这个空间复杂度不能让我们满意。

3.3.3、方法三:排序后遍历

可以先将数组进行排序后再遍历,发现相邻两个数字相同的就是重复数了
在这里插入图片描述
复杂度分析

  • 时间复杂度: O(nlgn)。对数组排序,在Java 中要花费 O(nlgn) 时间,后续是一个线性扫描,所以总的时间复杂度是O(nlgn)。
  • 空间复杂度: O(1) (or O(n)),在这里,我们对 nums 进行了排序,因此内存大小是固定的。当然,这里的前提是我们可以用常数的空间,在原数组上直接排序。如果我们不能修改输入数组,那么我们必须把 nums 拷贝出来,并进行排序,这需要分配线性的额外空间。

3.3.4、方法四:二分查找

这道题目中数组其实是很特殊的,我们可以从原始的 [1, N] 的自然数序列开始想。现在增加到了N+1个数,根据抽屉原理,肯定会有重复数。对于增加重复数的方式,整体应该有两种可能:

  • 如果重复数(比如叫做target)只出现两次,那么其实就是1~N所有数都出现了一次,然后再加一个target;
  • 如果重复数target出现多次,那在情况1的基础上,它每多出现一次,就会导致1~N中的其它数少一个。

例如:1~9之间的10个数的数组,重复数是6:
1,2,5,6,6,6,6,6,7,9
本来最简单(重复数出现两次,其它1~9的数都出现一次)的是
1,2,3,4,5,6,6,7,8,9
现在没有3、4和8,所以6会多出现3次。

我们可以发现一个规律:

  • 以target为界,对于比target小的数i,数组中所有小于等于它的数,最多出现一次(有可能被多出现的target占用了),所以总个数不会超过i。
  • 对于比target大的数j,如果每个元素都只出现一次,那么所有小于等于它的元素是j个;而现在target会重复出现,所以总数一定会大于j。

用数学化的语言描述就是:
我们把对于1~N内的某个数i,在数组中小于等于它的所有元素的个数,记为count[i]。
则:当i属于[1, target-1]范围内,count[i] <= i;当i属于[target, N]范围内,count[i] > i。

所以要找target,其实就是要找1~ N中这个分界的数。所以我们可以对1~N的N个自然数进行二分查找,它们可以看作一个排好序的数组,但不占用额外的空间。

代码实现如下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(nlog n),其中 n 为nums[] 数组的长度。二分查找最多需要O(logn) 次,而每次判断count的时候需要O(n) 遍历 nums[] 数组求解小于等于 i 的数的个数,因此总时间复杂度为O(nlogn)。
  • 空间复杂度:O(1)。我们只需要常数空间存放若干变量。

3.3.5、方法五:快慢指针法(循环检测)

这是一种比较特殊的思路。把nums看成是顺序存储的链表,nums中每个元素的值是下一个链表节点的地址。
那么如果nums有重复值,说明链表存在环,本问题就转化为了找链表中环的入口节点,因此可以用快慢指针解决。
比如数组
[3,6,1,4,6,6,2]
保存为:
在这里插入图片描述

整体思路如下:

  • 第一阶段,寻找环中的节点
    • 初始时,都指向链表第一个节点nums[0];
    • 慢指针每次走一步,快指针走两步;
    • 如果有环,那么快指针一定会再次追上慢指针;相遇时,相遇节点必在环中
  • 第二阶段,寻找环的入口节点(重复的地址值)
    • 重新定义两个指针,让before,after分别指向链表开始节点,相遇节点
    • before与after相遇时,相遇点就是环的入口节点

第二次相遇时,应该有:

  • 慢指针总路程 = 环外0到入口 + 环内入口到相遇点 (可能还有 + 环内m圈)
  • 快指针总路程 = 环外0到入口 + 环内入口到相遇点 + 环内n圈

并且,快指针总路程是慢指针的2倍。所以:

  • 环内n-m圈 = 环外0到入口 + 环内入口到相遇点。

把环内项移到同一边,就有:

  • 环内相遇点到入口 + 环内n-m-1圈 = 环外0到入口

这就很清楚了:从环外0开始,和从相遇点开始,走同样多的步数之后,一定可以在入口处相遇。所以第二阶段的相遇点,就是环的入口,也就是重复的元素。

代码如下:
在这里插入图片描述
复杂度分析

  • 时间复杂度:O(n),不管是寻找环上的相遇点,还是环的入口,访问次数都不会超过数组长度。
  • 空间复杂度:O(1),我们只需要定义几个指针就可以了。

通过快慢指针循环检测这样的巧妙方法,实现了在不额外使用内存空间的前提下,满足线性时间复杂度O(n)。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值