数组本身属于最基础的简单数据结构,经常会用vector来替代或者实现数组。玩好数组就要在最大限度发挥数组储存量大的优势下,尽可能地补足数组的劣势,如查找复杂度高等。这时候就需要采用相应的基础算法。
二分查找(binary search)
查找数组中某元素的操作,暴力解法是直接遍历数组,这样的时间复杂度是o(n)。如果再用上其他复杂度为n的算法,很容易超时,于是二分法应运而生。
使用二分法的前提是,要查找的区间具有单调性,可以升序也可以降序,可以在前面加上用上快排。平时使用二分法,经常无缘无故出现死循环,写起来感觉像碰运气,大于小于改来改去,最后自己都看不懂码,下面给出类似公式形式的二分模板。在此之前,先来看一下二分的易错点。
1、易错点:
(1)while()括号内出错
while里面到底应该是left<right 还是left<=right.
(2)端点更新到底需不需要包括
当我们已经搜完mid时候,假设需要更新右边界,那么到底是right=mid-1还是right=mid.
2、循环不变量原则:
上述易错点归根结底还是缺乏具体而专业的算法思想,只理解了算法的形,却没有吸收算法本质的巧妙。
按照《算法导论》的介绍,循环不变量原则的定义:在循环过程中保持不变的性质。
这句话很模糊,用人话说就是循环过程中,每一次进入循环时的状态以及条件都必须是一样的,这样才能保证算法的正确性。
直白地说,假如我们查找的是一个左闭右闭区间,那么我们每一次进入while都应是按照左闭右闭的条件进行查找操作。
3、具体实现:
所以while()括号里面到底应该填什么?
我们给出一个区间[1,1],这个区间里面只含有元素1,但是它是合法的;相应地,给出区间[1,1),它里面没有任何元素,是不合法的。对于一个合法区间[1,1],需不需要判断它里面的元素1是我们需要查找的值?
当然需要。
那么这个时候,令left=1,right=1,理所应当就应该有left==right时,仍进入while来进行判断,
所以while(left<=right),这是对于左闭右闭区间而言的。
那么左闭右开区间呢?零left=1,right=1,区间 [1,1)里面并没有元素,也就没有必要去查找了,自然而然写作while(left<right).。对于左开右开、左开右闭,也是自然。
第二个易错点,关于端点更新也是如此。
左闭右闭区间,我们确定了mid不能满足条件p且mid偏大的时候,需要更新right来缩小mid。因为已经判断过了mid是错的,就没有必要在更新后还把mid囊括其中,因而我们只需要让right包含mid前面一个元素即可,所以right=mid-1.。同理,对于左闭右开区间,我们也不需要包含mid,但是因为右区间是开的,所以写成right=mid也不会把mid包含在内。
左区间则正好相反,左闭为了不包含mid,更新时需要先取到mid+1。
在这个二分查找的算法中,使用了while()循环,而他的循环不变量,就是区间的定义,即区间开闭性。
4、总结:
二分法是一种在单调区间查找的常用算法,本质理解起来不难,难在应用到千变万化的题目上,同时,二分法并不局限在数组上,在排序等算法中也能有所应用,因为其优秀的o(logn)复杂度而得到了广泛的使用。
双指针
数组单方向遍历起来的复杂度很高,一次遍历最好能进行多个操作,可以使用双指针。
我们以删除数组中某元素为例。
1、简述删除
数组是一段连续分配ide内存空间,当我们把其中的某一个元素”删除“的时候,数组连续性被破坏,就找不到后面的元素了,所以与其说是删除某个元素,不如说是让后面的元素依次前进一个单位,覆盖掉这个元素。
我们暴力实现这个操作,两个for循环的嵌套就可以完成,但是时间复杂度高达o(),很明显是不合适的。
2、快慢指针
为了提速,我们采用两个指针来在一次遍历中同时完成寻找和覆盖两个操作。
声明一个fast指针int fast=0,用for(fast=0,fast<array.size();fast++)来让他遍历整个数组;同时,声明一个slow指针int slow=0,在循环体里面每当fast读到一个非删除值的元素时,就把它覆盖到slow指向的位置,然后slow++移动慢指针指向下一位,而当fast读到删除值的时候,slow不动,等待fast++之后指向的下一个元素把array[slow]处的元素覆盖掉。
除了删除操作和快慢指针还有很多地方也能用上双指针的思路。
3、头尾指针
下面我们以leetcode977为例:
给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。
- 输入:nums = [-4,-1,0,3,10]
- 输出:[0,1,9,16,100]
- 解释:平方后,数组变为 [16,1,0,9,100],排序后,数组变为 [0,1,9,16,100].
数组其实是有序的, 只不过负数平方之后可能成为最大数了。那么数组平方的最大值就在数组的两端,不是最左边就是最右边,不可能是中间。此时可以考虑双指针法了,i指向起始位置,j指向终止位置。从两边往中间遍历,指针指向的数按规则比大小。然后再用一个指针k来遍历答案数组ans,从ans的末尾往前填数。这样的i和j称为头尾指针(我一开始还以为i,j,k可以称为三指针法。。。)
4、滑动窗口
滑动窗口是一个很巧妙的算法,也是应用起来略为困难的算法。
我们以leetcode209为例:
给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。
示例:
- 输入:s = 7, nums = [2,3,1,2,4,3]
- 输出:2
- 解释:子数组 [4,3] 是该条件下的长度最小的子数组。
提示:
- 1 <= target <= 10^9
- 1 <= nums.length <= 10^5
- 1 <= nums[i] <= 10^5
这道题的暴力解法就是两个for嵌套遍历,直接tle不解释。
于是我们引入滑动窗口。
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
在暴力解法中,是一个for循环滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。
那么滑动窗口如何用一个for循环来完成这个操作呢。
首先要思考 如果用一个for循环,那么应该表示 滑动窗口的起始位置,还是终止位置。
如果只用一个for循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?
此时难免再次陷入 暴力解法的怪圈。
所以 只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
那么起始位置怎么移动呢?
当终止位置到达某一处时,窗口框定的范围呢所有值已经满足甚至超出了条件,那么我们此时就可以开始移动起始位置来让尽可能让框定的范围小一点了。
每一次我们移动之后,都要判断这个满足条件的窗口是不是最短,如果是,我们就直接把他记录下来,下一次再用新的窗口和它比较,一直到退出循环就行了。
但是,滑动窗口的难点不在双指针上,而在对他的模拟上。使用双指针的时候,我们经常要考虑以下问题:
- 窗口内是什么?
- 如何移动窗口的起始位置?
- 如何移动窗口的结束位置?
窗口内是什么的问题,一般题意都会给出,主要不是很难以模拟,都没什么太大问题。
窗口的结束位置,有的题目会很奇怪,但是大部分时候只需要一个个向后遍历就可以了。
关键就在于如何移动起始位置。
起始位置的移动一般需要两个步骤分析:
1.什么时候移动
2.移动到哪里
这个题的起始位置窗口移动比较容易,比较复杂的起始位置移动,可以参考leetcode的907 .这道题的起始位置移动是跳跃的,并且不是开始移动的时候循环不变量原则很容易被破坏。
(以后等我学会了滑动窗口再来系统记录一下吧。。。现在还不是很会。。。)
数组在模拟上的应用
1、关注循环不变量原则
因为数组存储某一状态的特性,使用二维、三维数组可以实现对矩阵,坐标系的模拟。这其中较少涉及到算法的相关知识,但是对模拟过程的精确描述却尤其重要,如果连模拟过程都不能通过,就不用谈用算法来优化了。
数组是一种顺序存储结构,读取时只能用循环来一个个遍历,所以写对循环是重点。
因此,在模拟过程中,我们也要时刻注意循环不变量原则。比如回形矩阵,如果我们遍历的时候,一会儿左开右闭一会儿左闭右开,那么首先我们一个循环就不能解决问题,其次多个循环并列的时候并不一定能满足所有情况,并且当一个循环嵌套了多个循环的时候,不必说时空复杂度,光是理解上的复杂度就已经够高了。
2、考虑特例、边界与细节
相信大家有遇到过这种情况: 感觉题目的边界调节超多,一波接着一波的判断,找边界,拆了东墙补西墙,好不容易运行通过了,代码写的十分冗余,毫无章法,其实真正解决题目的代码都是简洁的,或者有原则性的,大家可以在这道题目中体会到这一点。
所以模拟还是要多刷题的吧。。。