算法修炼之路(一) —— 数组

      在火影忍者里,有一位思想深度非常深却又非常复杂的人物,那就是大蛇丸。蛇叔的哲思深度无人能及,他的追求远远超出了常人的境界。在火影忍者中,晓组织追求统治,六道追求世间和平,火影追求传承,鸣人追求伙伴….而这一切目标,说到底,无非就是为了自身物种的延续。而只有大蛇丸的思考领域,早已超越了生与死的范畴:他追求的是探索这世间的真相。大蛇丸终其一生,都在寻找生命的真谛,从研究禁术脱离死亡的束缚开始,到不断渴望得到世界最强的力量,再到最后创造他的孩子巳月,回归生命本身,去探索生命的本质…..  蛇叔所做的这一切,全都是为了研究一个人类的终极命题:我是谁?从哪里来?到哪里去?大蛇丸曾经说过,只要获得这世间一切数据,就能掌握这世界所有的规律,看透世界的本质。

      这是神创造的世界,由神制定的规则,我们可能永远也得不到这世间的答案。要说这婆娑世界中,什么样的工作最接近神,那莫过于程序员了。程序员通过使用各种数据结构,创造了一个个程序世界,又通过对程序使用各种算法,制定了这程序世界的一切运行规则。从简单的加减计算,到复杂的AI智能计算,这些都是算法。算法是一个程序员的必经之路,一个出色的程序员不一定精通算法,但一个精通算法的程序员一定很出色。如果说硬件是计算机的躯体,那么算法就是计算机的灵魂,把算法工程师比喻成计算机灵魂工程师也不过分。

      今天我们要讨论的,是计算机一个重要的线性数据结构——数组。数组,是相同数据类型的元素的集合,它们的内存是连续的,长度是固定的,因此不能溢出。数组中的各元素的存储有先后顺序之分,它们在内存中按照这个先后顺序连续存放在一起。一个数组名只是一个地址,只记录它第一个元素的地址,即数组指向数组第一个元素arr[0]。我们可以根据数组的类型,取得数组元素的长度,从而获得数组里其它元素的地址,并根据地址读取/写入元素内容。

      接下来我将围绕数组,展开与数组有关的各种经典算法题。对于算法来说,编程语言并不是重点,重点是思路。讨论用什么机械键盘或什么语言来编写算法没有什么意义。由于本人是一名前端爱好者,比较习惯使用JavaStript,所以接下来的代码都将用JavaStript编写。

1.等概率洗牌算法:给你一个排好序的数组,要求你将这个数组随机打乱,并且数字在每一位出现的概率是相同的。
      随机洗牌其实很容易,我们可以在每次循环中指定两个随机数,从数组中交换这两个索引对应位置的数。但这道题的难点在于等概率,按我说的这种做法,两个产生的随机数可能是相同的,这意味着一个数字在它自身位置出现的概率会是在其它位置出现概率的两倍(因为两个随机数相同意味着数组那一位的数字在原地不动)。比如一个从小到大排好序数组包含0-9十个数字,那么0出现在第0位的概率是出现在其它任何一位概率的2倍,1出现在第1位的概率也是出现在其它任何一位概率的2倍,2出现在第2位的概率也是出现在其它任何一位概率的2倍….因此我们就不能使用这种方式。

      我们换一种方式,给每个固定位置随机插入任意数字:

      首先,我们在数组0-9的位置中随机抽取某一位,并将其中的数字放入第0位,同时第0位的数字与之交换位置;接着,我们在1-9的位置中随机抽取某一位,将其中的数字与第1位交换位置;再接着,我们在2-9的位置中随机抽取某一位,将其中的数字与第2位交换位置…依次类推。

       这种洗牌方式是等概率的,第一次我们随机抽取某一位并放入第0位,每一个数字被抽中并放入第0位的概率都是1/10;第二次我们在剩下的9位中随机抽取某一位并放入第2位,剩下的每一位被放入第二位的概率是9/10 × 1/9 =1/10,这里的9/10表示第一次没有抽中的概率,1/9表示第二次在剩下的9张牌中被抽中的概率,相乘也等于1/10;以此类推,剩下8张牌中每张牌被抽中并放入第三位为9/10 × 9/8 × 1/8 = 1/10…于是即使有n张牌,每张牌根据之前没有被抽中的概率×之后被抽中的概率,在每一位出现的概率都是1/n,这样便实现了等概率随机洗牌算法。

 

 

2.求质数算法:写一个函数,求出一个数里面的所有质数。

      质数又称素数。指在一个大于1的自然数中,除了1和此整数自身外,没法被其他自然数整除的数。换句话说,只有两个正因数(1和自己)的自然数即为素数。比1大但不是素数的数称为合数。1和0既非素数也非合数。

      如果现在给你一个数为101,判断它是否为质数,需不需要从2-100里一个个检查是否可以被101整除呢?显然是不需要的。判断一个数X是否为质数,我们可以从2开始到根号X寻找,如果找不到一个可以被X整除的数,那么X就是质数。对于101,如果我们在2到根号101之间找不到可以被101整除的数,那就没必要再找根号101到101之间的数了(这里可以用反证法:假设X = a * b,如果1~根号X之间找不到可以被X整除的数,可以在根号X到X之间找到的话,那么a和b肯定都大于根号X,a * b的结果就大于X了,结果前后矛盾,显然不成立)。

      现在,我们即然知道了怎么判断一个数,自然就可以求出在一个范围内有哪些数是质数了,首先我们会想到依据刚才的思路对每个数进行试除。

 

     

      然而,试除法其实不够优化,仔细想想,质数是除了1和此整数自身外没法被其他自然数整除的数。我们知道2是质数,但除2以外到200的所有2的倍数,它们本身除了可以被1和自身整除,还可以被2整除,那它们自然就肯定不是质数了。包括3、5、7、11这些质数也一样,比它们大的倍数肯定不是质数,我们在循环过程中却依然对每个数反复确认,这就显得很浪费效率了。因此,除了试除法,我们还可以结合筛选法,更高效的求出一个范围内的所有质数。

      筛选法的思路是这样,先在X的范围里取2到根号X之间的质数,然后把不是质数的合数连同它们的倍数全部去除,再把2到根号X里的质数的倍数从两倍开始到X全部去除,全部筛选完剩下的数就是质数。我们可以将这种思路,简化为以下代码:

 

      这个方法比使用试除法要快上很多,应该算是目前最好也是最快的求一个范围里质数的算法。


3.双指针证明哥德巴赫猜想算法:哥德巴赫猜想是数学界三大猜想之一,哥德巴赫曾提出,任何一个大于5的偶数都可写成两个质数之和,如8=3+5。哥德巴赫猜想目前已被证明在10的14次方范围内的偶数都是正确的,不过它依然是数学界一大未解难题。现在给定一个偶数,让你找出两个相加等于它的质数,请写出该算法。

      这道题其实是我们上一道题的扩展,我们现在已经可以求出一个数里面所有的质数了,只需要在这些质数中找出两个相加等于给定偶数的质数。我们首先最容易想到的可能就是暴力破解法了,循环数组里的每一个数,让每一个数依次与其它所有数进行相加尝试。但是这种算法每一个数首先都要与其它剩下的数挨个计算一遍,接着每一个数都要走一步这样的流程,直到找到两个目标质数,这样的算法时间复杂度是O(n²)。

      其实,我们根据第二道题的算法,求出来的质数数组是排好序的。我们可以利用数组排好序的特点,从数组最前面和最后面开始设定两个指针,并计算左右两位相加之和。

      

      每次我们都根据左右两个质数相加的结果与给定偶数进行判断,若相加大于偶数,则最右边的指针往左移动一位;若相加小于偶数,则最左边的指针往右移动一位;当左右指针移动后所对应数字相加等于偶数时,我们便可以将左右两位的数字输出。

 

 

      由于哪怕是最糟糕的情况,左右两个指针合起来也只会走一遍数组,所以双指针解法的时间复杂的只为O(n)。

4.矩阵置零算法:编写一个算法,若矩阵m×n中某个元素为0,则将其所在的行与列都设为0。

      乍一看这个问题似乎很简单,只要直接遍历整个矩阵,如果发现0元素,则将其所在的整行和整列都设为0。不过这种方法并不可行,因为你会发现,当某一行和列被你设为0后,数组接下来读到的尽是0,于是所在行和列又得变成0,很快整个矩阵所有元素将全部变成0。

      避开这个陷阱,我们可以新建一个矩阵(二维数组),标记0元素的位置,然后在第二次遍历矩阵时将矩阵0元素所在行和列都置为0,这种做法的空间复杂度为O(m×n)。

      其实,若打算将整行和整列都置0,我们并不需要准确知道矩阵中某个0元素所在的准确位置,只需要创建两个一维数组,分别记录它所处的行和它所处的列。

 

      这样我们就把空间复杂度从O(m×n)变为O(m+n)了。

5.九宫图算法:给定一个3×3的二维数组,并放入1到9九个数字,请设计一套算法,使九宫图横竖斜相加都相等。

      九宫图上的数字遵循这样的排列规则,从1开始向斜下方排列,如果遇到边缘就从相反方向的边缘排入,如图中所示,1的斜下方应该是2,但因为已经到边缘,所以2被排到右上角,2的斜下方应该是3,遇到边缘3又被排到左边一列的中间,以此类推。但遇到斜下方已经存在数字时,接下来要排的数字就会跑到它上一个数字的上面,如图中4本应该排在3的斜下方,但斜下方已经有1占据,所以4就排到3的上面,接着4的斜下方又是5,5的斜下方又是6,6的斜下方遇到4(遇到边缘4已经转到左上角了),7就排到了6的上面…

 

 

      了解九宫图的朋友都会发现,九宫图上的数字,不管横竖斜相加,结果都等于15。细心的朋友观察九宫图会发现,不管是什么顺序的九宫图,数字1永远在最边缘一行或一列的中间,这是解决九宫图很关键的一个切入点。所以我们可以从数字1开始入手。

 

      对于换行,我们巧妙的运用了取模操作,如果没有越界,对行数或列数取模结果都不会有影响;如果越界了,取模操作会让它们返回最左边或最上边的位置。

6.旋转数组:给定一个n×n的二维数组,写一个算法,将这个数组按顺时针旋转90°。

      二维数组旋转90°过后有一个特点,原来数字所在的列col,旋转过后永远会变成旋转过后的行row。而原来数字所在的行row,旋转过后列永远会变成n – row。所以我们可以直接创建一个同样n×n的二维空数组,把每次遍历到的数字放到指定空数组的位置。

       这个算法我们重新生成了一个二维数组,所以空间复杂度为O(n×n)。那么,是否可以不创建一个新的二维数组,而是直接在元素组上进行操作呢?
 

 

       我们可以像图中这样,对数组从最外层到内层循环,然后依次将数组上的元素进行交换,对于一个n×n的数组来说,这种按层数的循环只需要进行n/2次,然后再对里面每一层每一组旋转数据再进行循环交换位置(对于5×5的二维数组来说,层数循环只需要循环5/2 = 2.5 = 2次,因为中间的数字不需要再进行操作,它永远停在中间;然后每次从最外层开始,循环4个数字的长度并让上右下左依次交换位置;接着进行第二层循环,循环2个数字的长度并让上右下左依次交换位置…)。这样的算法空间复杂度是O(1) 。
 

7.求数组中最大数:给定一个未排序的数组,判断是否在该数组中存在一个最大数,比其它任何数都大两倍或两倍以上,如果有,返回这个数。

      这道题其实相当简单,最暴力的解法就是进行双重遍历,遍历数组中的每一个数并在第二次循环中将这个数与其它数进行比较判断是否为最大数,但这样的时间复杂度是O(n²)。其实要找出这个最大数,我们完全不需要对每个数进行比较判断,我们只需要找出数组中最大的数与第二大的数进行比较判断就可以了。有这样的思路似乎很简单,只需要对数组调用sort()方法进行排序,然后取倒数两位进行比较。但是排序算法的时间复杂度也很高,为O(n × log n)。这道题我们不用排序,只需要进行一次遍历,取出数组中最大的数和第二大的数就可以了。

 

 

      这道算法只进行一次遍历,所以时间复杂度为O(n)。

8.找出缺失的数:给定一个长度为n的数组,里面包含1~n之间的数,有些数字重复出现,这意味着有些数字就缺失了,请找出这些缺失的数。

      这道题的解法也有很多种,如果不考虑空间复杂度,我们可以新创建一个长度为n的数组并将n中所有的数字存进去。然后遍历原数组,将遍历到的数字从新数组中将该数字设为0,最后新数组中不为0的数就是原数组缺失的数。

      这种解法的空间复杂度是O(n),如果题目要求我们不能用额外的空间,我么可以用排序或查询操作,但排序的时间复杂度是O(n×logn),查询的时间复杂度是O(n²),这些都不是最好的结果。

      我们思考这样一种方法:遍历数组中每一个数字,将遍历到的数字绝对值所对应的索引位置设为负数(这里得到的数字值还要减1,因为数组索引是从0开始的),最后没有被设为负数的两个数字所对应的索引值,就正好是缺失的那些数字。

      举个例子,假设一个长度为6的数组arr=[5,6,1,5,2,1],我们对它进行遍历,第一次遍历到数字5,我们就将数组第五位设为负数:arr=[5,6,1,5,-2,1]。接着遍历第二位,将第六位设为负数:arr=[5,6,1,5,-2,-1]。再接着遍历第三位,将第一位设为负数:arr=[-5,6,1,5,-2,-1]…以此类推,我们遍历到最后会发现,数组arr变成了[-5,-6,1,5,-2,-1],只有第三位和第四位的数字没有变成负数,就说明数字3和数字4是缺失的数。

 

        这种算法的时间复杂度只有O(n),显然要比前面那两种算法要好得多。

      关于数组的算法,今天就暂时讲到这里。总的来说,今天讲的算法题目难度并不是很高,关于数组的算法以后还会陆续进行补充。下次算法专题我将讲解关于递归的算法,期待的朋友敬请关注。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值