算法和数据结构解析:3 - 二分查找相关问题

1. 二分查找

1.1 解析

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

二分查找事实上采用的就是一种分治策略,它充分利用了元素间的次序关系,可在最坏的情况下用O(log n)完成搜索任务。

它的基本思想是:假设数组元素呈升序排列,将n个元素分成个数大致相同的两半,取a[n/2]与欲查找的x作比较,如果x=a[n/2]则找到x,算法终止;如 果xa[n/2],则我们只要在数组a的右 半部继续搜索x。

算法和数据结构解析:3 - 二分查找相关问题_第1张图片

二分查找问题也是面试中经常考到的问题。

1.2 案例代码

用代码实现一个对int数组的二分查找

    public static int binarySearch(int[] binary, int key){
        // 定义初始查找范围,双指针
        int low = 0;
        int high = binary.length-1;

        if (key < binary[low] &&  key > binary[high]){
            return -1;
        }
        while (low < high ){
            int mid = (low + high)/2;

            // key 大于中间值,取后半部分
            if(key > binary[mid]){
                low = mid+1;
            //key小于中间值,取前半部分
            }else if (key < binary[mid]){
                high = mid-1;
            }else{
                // key等于当前值
                return mid;
            }
        }
        return -1;
    }

方法二:递归实现

    /**
     * 方法二:递归调用
     * @param binary
     * @param key
     * @param fromIndex
     * @param toIndex
     * @return
     */
    public static int binarySearch(int[] binary, int key, int fromIndex, int toIndex){
        // 基本判断,当起始位置大于结束位置时,直接返回-1:特殊情况超出最大最小值,直接返回-1
        if(key < binary[fromIndex] || key > binary[toIndex] || fromIndex > toIndex){
            return -1;
        }

        // 计算中间位置
        int mid = (fromIndex + toIndex)/2;

        // 判断中间位置元素和key的大小关系,更改搜索范围,递归调用
        if (binary[mid] < key){
            return binarySearch(binary, key, fromIndex, mid -1);
        }else if (binary[mid] > key){
            return binarySearch(binary, key, mid+1, toIndex);
        }else {
            return mid;
        }
    }

递归调用,使用空间换取时间。递归调用过程中,栈会一层层的开辟使用,直到最底层返回return。

总结一下二分查找:

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

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

2.二维矩阵

2.1 题目说明

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

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

示例 1:

算法和数据结构解析:3 - 二分查找相关问题_第2张图片

输入: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

提示:

  1. m == matrix.length
  2. n == matrix[i].length
  3. 0 <= m, n <= 100
  4. -104 <= matrix[i][j], target <= 104

2.2分析

既然这是一个查找元素的问题,并且数组已经排好序,我们自然可以想到用二分查找是一个高效的查找方式。

输入的 m x n 矩阵可以视为长度为 m x n的有序数组:

算法和数据结构解析:3 - 二分查找相关问题_第3张图片

行列坐标为(row, col)的元素,展开之后索引下标为idx = row * n + col;反过来,对于一维下标为idx的元素,对应二维数组中的坐标就应该是:

row = idx / n;  col = idx % n;

2.3 实现:二分查找

    /**
     * 方法一:二分查找
     * @param matrix
     * @param target
     * @return
     */
    public static boolean searchMatrix(int[][] matrix, int target) {

        // 如果长度为0,则必定不存在
        int rowLength = matrix.length;
        // 获取列长度
        int colLength = matrix[0].length;

        if (rowLength==0 )
            return false;

        int left = 0;
        int right = rowLength * colLength -1 ;

        // 二分查找,定义左右指针,左指针小于等于右指针
        while ( left <= right) {
            // 中间指针
            int midIndex = (left + right)/2;
            // 获取当前值
            int midElement = matrix[midIndex / colLength ][ midIndex % colLength ];

            if (midElement < target){
                left = midIndex + 1;
            }else if (midElement > target){
                right = midIndex - 1;
            }else{
                return true;
            }
        };
        return false;
    }

复杂度分析

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

3. 寻找重复数

3.1 题目说明

给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。

示例 1:

输入: [1,3,4,2,2]

输出: 2

示例 2:

输入: [3,1,3,4,2]

输出: 3

说明

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

3.2 分析

怎样证明 nums 中存在至少一个重复值?其实很简单,这是“抽屉原理”(或者叫“鸽子洞原理”)的简单应用。

这里,nums 中的每个数字(n+1个)都是一个物品,nums 中可以出现的每个不同的数字(n个)都是一个 “抽屉”。把n+1 个物品放入n个抽屉中,必然至少会有一个抽屉放了2个或者2个以上的物品。所以这意味着nums中至少有一个数是重复的。

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

首先我们想到,最简单的办法就是,遍历整个数组,挨个统计每个数字出现的次数。

用一个HashMap保存每个数字对应的count数量,就可以直观地判断出是否重复了。

代码如下:

    /**
     * 方法一:使用HashMap保存每个数出现的次数
     * @param nums
     * @return
     */
    public int findDuplicate(int[] nums){
        Map map = new HashMap<>();
        // 遍历所有元素,统计count值
        for (int num : nums) {
            // 判断当前 num 是否在 map 中出现
            if(map.containsKey(num)){
                // 如果出现过,num就是重复值
                return num;
            }else {
                // 没出现过,添加到map
                map.put(num,num);
            }
        }
        return -1;
    }

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

也可以直接保存到一个Set里,就知道这个元素到底有没有了。

    /**
     * 方法二:使用Set保存每个数
     * @param nums
     * @return
     */
    public int findDuplicate2(int[] nums){
        Set set = new HashSet<>();
        // 遍历所有元素,统计count值
        for (int num : nums) {
            // 判断当前 num 是否在 set 中出现
            if(set.contains(num)){
                // 如果出现过,num就是重复值
                return num;
            }else {
                // 没出现过,添加到set
                set.add(num);
            }
        }
        return -1;
    }

复杂度分析

时间复杂度:O(n),我们只对数组做了一次遍历,在HashMap和HashSet中查找的复杂度是O(1)。

空间复杂度:O(n),我们需要一个HashMap或者HashSet来做额外存储,最坏情况下,这需要线性的存储空间。

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

3.5 方法三:排序法

本方法是先在原数组上排序。

排序之后,所有重复的数会排在一起;这样,只要我们遍历的时候发现连续两个元素相等,就可以输出结果了。

    /**
     * 方法三:先排序,然后找相邻的相同元素
     * @param nums
     * @return
     */
    public int findDuplicate3(int[] nums){
        Arrays.sort(nums);
        // 遍历数组元素,遇到跟前一个相同的,就返回
        for (int i = 0; i < nums.length; i++) {
            if(nums[i] == nums[i-1]){
                return nums[i];
            }
        }
        return -1;
    }

复杂度分析

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

3.6 方法四:二分查找

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

  1. 如果重复数(比如叫做target)只出现两次,那么其实就是1~N所有数都出现了一次,然后再加一个target;
  2. 如果重复数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次。

我们可以发现一个规律:

  1. 以target为界,对于比target小的数i,数组中所有小于等于它的数,最多出现一次(有可能被多出现的target占用了),所以总个数不会超过i。
  2. 对于比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个自然数进行二分查找,它们可以看作一个排好序的数组,但不占用额外的空间。

    /**
     * 方法四:二分查找
     * @param nums
     * @return
     */
    public int findDuplicate4(int[] nums){
        int left = 1;
        int right = nums.length-1;

        while (left <= right){
            // 计算中间的值
            int mid = ( left + right ) / 2;
            // 对当前的mid计算count值
            int count = 0;
            for (int i = 0; i < nums.length; i++) {
                if (nums[i] < mid) count++;
            }
            // 判断count的mid本身的大小关系
            if ( count <= mid )
                left = mid + 1; // count小于等于mid自身,说明mid比target小,左指针右移
            else
                right = mid;
            // 左右指针重合时,找到target
            if (left == right){
                return mid;
            }
        }
        return -1;
    }

复杂度分析

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

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

这是一种比较特殊的思路。把nums看成是顺序存储的链表,nums中每个元素的值是下一个链表节点的地址

那么如果nums有重复值,说明链表存在环,本问题就转化为了找链表中环的入口节点,因此可以用快慢指针解决。

比如数组

[3,6,1,4,6,6,2]

保存为:

算法和数据结构解析:3 - 二分查找相关问题_第4张图片

整体思路如下:

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

第二次相遇时,应该有:

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

快指针总路程 = 环外0到入口 + 环内入口到相遇点 + 环内n圈

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

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

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

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

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

    /**
     * 方法五:快慢指针
     * @param nums
     */
    public int finDuplicate5(int[] nums){
        // 定义快慢指针
        int fast = 0,low = 0;
        // 第一阶段:寻找链表中的环
        do{
            // 快指针一次走两步,慢指针一次走一步
            low = nums[low];
            fast = nums[nums[fast]];
        }while ( fast != low );

        // 第二阶段:寻找环在链上的入口节点
        int ptr1 = 0,ptr2 = low;
        while ( ptr1 != ptr2){
            ptr1 = nums[ptr1];
            ptr2 = nums[ptr2];
        }
        return ptr1;
    }

复杂度分析

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

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

你可能感兴趣的:(设计模式与算法,算法,散列表,二分查找,二维矩阵,寻找重复数)