bigsai

bigsai 查看完整档案

无锡编辑南京理工大学  |  软件工程 编辑  |  填写所在公司/组织 github.com/javasmall 编辑
编辑

公众号:bigsai

专注于Java、数据结构与算法。
欢迎各位叨扰。目前组织力扣打卡群,欢迎加入一起学习!

个人动态

bigsai 发布了文章 · 1月27日

超全的位运算介绍与总结

前言

位运算隐藏在编程语言的角落中,其神秘而又强大,暗藏内力,有些人光听位运算的大名的心中忐忑,还有些人更是一看到位运算就远远离去,我之前也是。但狡猾的面试官往往喜欢搞偷袭,抓住我们的弱点搞我们,为了防患于未然,特记此篇!

本篇的内容为位运算的介绍和一些比较经典的位运算问题进行介绍分析,当然,位运算这么牛,后面肯定还是要归纳总结的。

认识位运算

什么是位运算?

程序中的所有数在计算机内存中都是以二进制的形式储存的。位运算就是直接对整数在内存中的二进制位进行操作。

位运算就是直接操作二进制数,那么有哪些种类的位运算呢?

常见的运算符有与(&)、或(|)、异或(^)、取反(~)、左移(<<)、右移(>>是带符号右移 >>>无符号右移动)。下面来细看看每一种位运算的规则。

位运算 & (与)

规则:二进制对应位两两进行逻辑AND运算(只有对应位的值都是 1 时结果才为 1, 否则即为 0)即 0&0=0, 0&1=0, 1&1=1

例如:2 & -2

image-20210103203435246

位运算 | (或)

规则:二进制对应位两两进行逻辑或运算(对应位中有一 个为1则为1) 即0|0=0,0|1=1,1|1=1

例如:2 | -2

image-20210103203852165

位运算 ^ (异或)

规则:二进制对应位两两进行逻辑XOR (异或) 的运算(当对应位的值不同时为 1, 否则为 0)即0^0=0, 0^1=1, 1^1=0

例如:2 ^ -2

image-20210103204258194

按位取反~

规则:二进制的0变成1,1变成0。

image-20210103204832085

移位运算符

左移运算<<:左移后右边位补 0

右移运算>>:右移后左边位补原最左位值(可能是0,可能是1)

右移运算>>>:右移后左边位补 0

  • 对于左移运算符<<没有悬念右侧填个零无论正负数相当于整个数乘以2。
  • 而右移运算符就有分歧了,分别是左侧补0>>>和左侧补原始位>>,如果是正数没争议左侧都是补0,达到除以2的效果;如果是负数的话左侧补0>>>那么数值的正负会发生改变,会从一个负数变成一个相对较大的正数。而如果是左侧补原始位(负数补1)>>那么整个数还是负数,也就是相当于除以2的效果。

下面这张图可以很好的帮助你理解负数的移位运算符:

image-20210111203911045

到这里,我想你应该对位运算有了初步的认识,在这里把上面提到的部分案例执行对比一下让你看一下可能会理解的更清晰:

image-20210112233233639

位运算小技巧

在这里有些常用的位运算小技巧。

判断奇偶数

正常判断奇数偶数的时候我们会这样写:

if( n % 2 == 1)
    // n 是个奇数
}

使用位运算可以这么写:

if(n & 1 == 1){
    // n 是个奇数。
}

其核心就是判断二进制的最后一位是否为1,如果为1那么结果加上2^0=1一定是个奇数,否则就是个偶数。

交换两个数

对于传统的交换两个数,我们需要使用一个变量来辅助完成操作,可能会是这样:

int team = a;
a = b;
b = team;

但是使用位运算可以不需要借助额外空间完成数值交换:

a=a^b;//a=a^b
b=a^b;//b=(a^b)^b=a^0=a
a=a^b;//a=(a^b)^(a^b^b)=0^b=0

原理已经写在注释里面了,是不是感觉非常diao呢?

二进制枚举

在遇到子集问题的处理时候,我们有时候会借助二进制枚举来遍历各种状态(效率大于dfs回溯)。这种就属于排列组合的问题了,对于每个物品(位置)来说,就是使用和不使用的两个状态,而在二进制中刚好可以用1和0来表示。而在实现上,通过枚举数字范围分析每个二进制数字各符号位上的特征进行计算求解操作即可。

image-20210122160614157

二进制枚举的代码实现为:

for(int i = 0; i < (1<<n); i++) //从0~2^n-1个状态
{
  for(int j = 0; j < n; j++) //遍历二进制的每一位 共n位
  {
    if(i & (1 << j))//判断二进制数字i的第j位是否存在
    {
      //操作或者输出
    }
  }
}

位运算经典问题

有了上面的位运算基础,我们怎么用位运算处理实际问题呢?或者有哪些经典的问题可以用位运算来解决呢。

不用加减乘除做加法

题目描述

写一个函数,求两个整数之和,要求在函数体内不得使用+、-、*、/四则运算符号。

分析
这道题咋一听可能没啥思路,简单研究一下位运算还是能独立推出来和理解的。

当然,解决这题前,需要了解上面的四种位运算。还要知道二进制的运算:0+0=0,0+1=1,1+1=0(进位)

对于加法的一个二进制运算。如果不进位那么就是非常容易的。这时候相同位都为0则为0,0和1则为1.满足这种运算的异或(不相同取1,相同取0)和或(有一个1则为1)都能满足.
在这里插入图片描述
但事实肯定有进位的运算啊!看到上面操作的不足之后,我们肯定还需要解决进位的问题对于进位的两数相加,这种核心思想为

  • 用两个数,一个正常m相加(不考虑进位的)。用异或a^b就是满足这种要求,先不考虑进位(如果没进位那么就是最终结果)。另一个专门考虑进位的n。两个1需要进位。所以我们用a&b与记录需要进位的。但是还有个问题,进位的要往上面进位,所以就变成这个需要进位的数左移一位。
  • 然后就变成m+n重新迭代开始上面直到不需要进位的(即n=0时候)。

在这里插入图片描述

实现代码为:

public class Solution {
        public int Add(int num1,int num2) {
        /*
         *  5+3   5^3(0110)   5&3(0001) 
         *  0101    
         *  0011 
         */
        int a=num1^num2;
        int b=num1&num2;
        b=b<<1;
        if(b==0)return a;
        else {
            return Add(a, b);
        }        
     }
}

当然,这里也可以科普一下二进制求加法:average = (a&b) + ((a^b)>>1) ;

二进制中1的个数

这是一道经典题,在剑指offer上也有对应题目,其具体题目要求输入一个整数,输出该数二进制表示中1的个数(其中负数用补码表示)

对于这个问题,不用位运算将它转成二进制字符串直接枚举字符'1'的个数也可以直接求出来,但是这样做是没有灵魂的并且效率比较差。这里提供两种解决思路

法一: 大家知道每个类型的数据它的背后实际都是二进制操作。大家知道int的数据类型的范围是(-2^31,2^31 -1)。并且int有32位。但是并非32位全部用来表示数据。真正用来表示数据大小的也是31位。最高位用来表示正负

首先要知道:

1<<0=1 =00000000000000000000000000000001
1<<1=2 =00000000000000000000000000000010
1<<2=4 =00000000000000000000000000000100
1<<3=8 =00000000000000000000000000001000
. . . . . .
1<<30=2^30 =01000000000000000000000000000000
1<<31=-2^31 =10000000000000000000000000000000

其次还要知道位运算&与。两个十进制与运算.每一位同1为1。所以我们用2的正数次幂与知道的数分别进行与运算操作。如果结果不为0,那么就说明这位为1.(前面31个都是大于0的最后一个与结果是负数但是如果该位为1那么结果肯定不为0)

image-20210122170223380

具体代码实现为:

public int NumberOf1(int n) {
  int va=0;
  for(int i=0;i<32;i++)
  {
    if((n&(1<<i))!=0)
    {                    
      va++;
    }
  }
  return va;          
}

法二是运用n&(n-1)。n如果不为0,那么n-1就是二进制第一个为1的变为0,后面全为1.这样的n&(n-1)一次运算就相当于把最后一个1变成0.这样一直到运算的数为0停止计算次数就好了,如下图共进行三次运算那么n的二进制中就有三个1。
image-20210122163518288
实现代码为:

public class Solution {
    public int NumberOf1(int n) {
       int count=0;
       while (n!=0) {
           n=n&(n-1);
           count++;
       }
       return count;
    }
}

只出现一次的(一个)数字①

问题描述:

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

说明:你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?

分析:

这是一道简单的面试题,面试官常问怎么样用不太复杂的方法找出数组中仅出现一次的数字(其他均出现两次),暴力枚举或者使用其他的存储结构都不够优化,而这个问题最高效的答案就是使用位运算。首先你要注意两点:

  • 0和任意数字进行异或操作结果为数字本身.
  • 两个相同的数字进行异或的结果为0.

具体的操作就是用0开始和数组中每个数进行异或,得到的值和下个数进行异或,最终获得的值就是出现一次(奇数次)的值。

image-20210122002813297

class Solution {
    public int singleNumber(int[] nums) {
        int value=0;
        for(int i=0;i<nums.length;i++)
        {
            value^=nums[i];
        }
        return value;
    }
}

只出现一次的(一个)数字②

问题描述:

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现了三次。找出那个只出现了一次的元素。

说明:你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?

分析:

这题和上一题的思路略有不同,这题其他数字出现了3次,那么我们如果直接使用位运算异或操作的话无法直接找到结果,就需要巧妙的运用二进制的其他特性:判断整除求余操作。即判断所有数字二进制1的总个数和0的总个数一定有一个不是三的整数倍,如果0不是三的整数倍那么就说明结果的该位二进制数字为0,同理否则为1.

image-20210122122756549

在具体的操作实现上,问题中给出数组中的数据在int范围之内,那么我们就可以在实现上可以对int的32个位每个位进行依次判断该位1的个数求余3后是否为1,如果为1说明结果该位二进制为1可以将结果加上去。最终得到的值即为答案。

具体代码为:

class Solution {
    public int singleNumber(int[] nums) {
        int value=0;
        for(int i=0;i<32;i++)
        {
            int sum=0;
            for(int num:nums)
            {
                if(((num>>i)&1)==1)
                {
                    sum++;
                }
            }
            if(sum%3==1)
                value+=(1<<i);
        }
        return value;
    }
}

只出现一次的(两个)数字③

题目描述

一个整型数组里除了两个数字之外,其他的数字都出现了两次。请写程序找出这两个只出现一次的数字。

思路

上面的问题处理和理解起来可能比较容易,但是这个问题可能稍微复杂一点,但是这题可以通过特殊的手段转化为上面只出现一次的一个数字问题来解决,当然核心的位运算也是异或^

具体思路就是想办法将数组逻辑上一分为二!先异或一遍到最后得到一个数,得到的肯定是a^b(假设两个数值分别为a和b)的值。在看异或^的属性:不同为1,相同为0. 也就是说最终这个结果的二进制为1的位置上a和b是不相同的。而我们可以找到这个第一个不同的位,然后将数组中的数分成两份,该位为0的进行异或求解得到其中一个结果a,该位为1的进行异或求解得到另一个结果b。

具体可以参考下图流程:

image-20210121225712670

实现代码为:

public int[] singleNumbers(int[] nums) {
    int value[]=new int[2];
    if(nums.length==2)
        return  nums;
    int val=0;//异或求的值
    for(int i=0;i<nums.length;i++)
    {
        val^=nums[i];
    }
    int index=getFirst1(val);
    int num1=0,num2=0;
    for(int i=0;i<nums.length;i++)
    {
        if(((nums[i]>>index)&1)==0)//如果这个数第index为0 和num1异或
            num1^=nums[i];
        else//否则和 num2 异或
            num2^=nums[i];
    }
    value[0]=num1;
    value[1]=num2;
    return  value;
}

private int getFirst1(int val) {
    int index=0;
    while (((val&1)==0&&index<32))
    {
        val>>=1;// val=val/2
        index++;
    }
    return index;
}

结语

当然,上面的问题可能有更好的解法,也有更多经典位运算问题将在后面归纳总结,希望本篇的位运算介绍能够让你有所收获,对位运算能有更深一点的认识。对于很多问题例如博弈问题等二进制位运算能够很巧妙的解决问题,日后也会分享相关内容,敬请期待!

原创公众号:bigsai
原创不易,如果有收获请不要吝啬你的赞赞
文章已收录在 全网都在关注的数据结构与算法学习仓库

咱们下次再见!

查看原文

赞 7 收藏 4 评论 0

bigsai 发布了文章 · 1月12日

我和蓝桥杯的那两年

前言

有很多事情在最初的时候是令人最难忘的,无论是学习还是生活的点点滴滴,追忆起那些最初的场景,既美好又有点失落,美好是因为那种懵懂而摸索的进步和得知确实很难得,而些许失落是因为一晃都过去那么久啦,那时候的地点、人和事都已很难重温。

前几天翻空间说说发现母校的师弟师妹们都在报名第十二届蓝桥杯大赛,走在寒风飕飕的路上,勾起本科生涯那段寒天与蓝桥杯的故事。记得刚上大一时候不久,老师问班上同学们有什么目标,有几个同学回答了我记得很清楚,一个说想考研,还有说想进BAT,还有一个同学说想参加竞赛拿奖。那是我第一次知道算法竞赛的存在。而我自己本科开始学算法时候不是为了进大厂、为了考研,那时候啥也不懂就是因为要参加蓝桥杯比赛。

我们学校是双非,大部分人要么考研要么搞开发,专注算法的不是很多,更多的还是带着学。我本科学校对蓝桥杯还是挺看重的,并不是个人直接报名,而是参加校赛之后得奖后然后学校统一安排报名,所以第一道坎就是过校赛。

第一次止步校赛

第一次准备比赛的时候,那时候刚上大二,因为在大一基本都是玩过来的,到了大二距离校赛前一段时间。我的舍友W找我问我是要参加蓝桥杯的校赛嘛,我跟他说是的然后他说可以一起准备。因为咱两没有参加协会、也不认识啥这方面有啥天赋的人,所以只能黑灯瞎摸索。开始了第一次蓝桥杯的探寻之旅。

然后那个时候完全是小白从0开始,我们俩从协会群里找到几年历年试题以及一些资料,然后开始研究。我记得很清楚的那时候练习一些啥求素数、进制转换 等等之类的题。那个时候这种题对我们X小白来说已经很有挑战啦。然后后面的编程题更是读不懂不知道怎么做啊,也没测试样例只有题目的一两个样例。但是哪个时候,学会了一个新的东西:回溯算法 。回溯也称为暴力,我和w花了好几天研究回溯算法,刚开始也搞不懂递归,更何况带着逻辑的回溯算法,把回溯算法硬啃之后我两发现:咦,这题好像可以暴力破解哎!当然,虽然用暴力能够求解出一部分问题,但是实质上暴力只能过一部分样例。

当然感觉良好,到参加校赛那天和想象的不太一样,第一次和那么多人一起参加这样比赛,大部分是cpp我用的是Java学校的机器非常老旧,跑个Java程序就会非常卡,遇到那些题突然就慌了,记得很清楚的一道需要用long类型表示的数字我硬是在那边纠结为啥用int表示不出来,那时候编程素养其实还真的很欠火候,天气凉凉,结果校赛也是很遗憾的凉了。当然W舍友也凉了,我们决定寒假和来年要好好准备。

第二次终去北京

在第一次落败第二年的春天,我和W舍友就在杭电上刷题准备下一次的蓝桥杯,从基础到字符串,再到贪心、bfs、dfs以及其他。快到暑假的时候Y同学加入到我们,那时候我们三暑假就会一起刷题讨论题,共同进步。入秋之后我们专业几个报名的还开了一个蓝桥杯校选拔赛互助小队一起准备,那时候快校赛时候发现《将夜Ⅰ》超级好看哈哈在暖暖被窝里熬夜追了一晚,第二天上午还不是很清醒的就去参加比赛了。经过不少时间的准备当然也是容易通过校赛(毕竟我们双非强者有限)。而我们专业也有好多人通过校赛,可以一起省赛一日游,终于能满一个小心愿了,不管怎么样也去体验一波。

在寒假期间我们也做了一些准备,搜集了一些算法资料和视频以及蓝桥杯试题,有个小伙伴还买了历年试题讲解,假期有时我正在被窝里打王者Y同学就偶尔给我来一题强行拖我一下,想想那段无忧无虑的日子还是很美好的。在三月份很幸运的我们专业又是很多人晋级国赛,我们几个晋级的就很期待去北京。

在五月份天气变暖起来,我们一行在J老师的带领下出行去北京,这是我第一次坐高铁去那么远的地方,也是第一次去北京。途径南京、徐州、济南、天津这些大站都拿起手机拍一拍。到了北京在J老师的带领下我们就在北方工业大学考点附近一个酒店。老师允许我们小范围活动我们专业几个人便在附近商场一起吃了顿自助餐,可能是咱们乡下人居多很多人(我)没来过北京走两步拍两下、发个朋友圈,跟家里说我来北京啦!

而第二天比赛时候,也算是被国赛血虐了一把。我参与的那场国赛的难度和竞争力比省赛高了一大截。如果能拿个国一,我觉得还是很厉害的。当初还打算北京转转但由于时间紧,服从安排就老实呆着,不过踏过北京的土地也很满足了又多去过一个大城市!在这里插入图片描述

谈谈蓝桥杯

有些人可能很少参加比赛,所以对蓝桥杯不太了解。

我打蓝桥杯的时候,还有一些打ACM的同学没有参与蓝桥杯,但现在就不同了。这些年随着蓝桥杯大赛的水准和规模慢慢提高,有很多双一流学校的学生参加,也吸引了很多ACMer参与,看到前面拿奖的基本都是好学校,专业顶尖选手越来越多。大赛选手与ACM参赛选手重叠度逐年增加,多届蓝桥杯国赛一等奖、二等奖选手同时是ACM的金牌获得者,可以说蓝桥杯大赛俨然是一块大佬试金石。

讲了这么多,我应该帮你捋一捋介绍一下,搞清自身定位,当然可能有些偏颇仅供参考哈!

蓝桥杯 VS ACM:

属性蓝桥杯ACM
队伍形式个人赛三人团体
赛制OIACM
分组研究生组、A组、B组、C组各学校统一竞争
时长4小时5小时
题目类型填空+编程题编程题
官网dasai.lanqiao.cn

蓝桥杯:

蓝桥杯全国软件和信息技术专业人才大赛是由工业和信息化部人才交流中心举办的全国性IT学科赛事。全国1200余所高校参赛,累计参赛人数超过40万人。2020年,蓝桥杯大赛被列入中国高等教育学会发布的“全国普通高校学科竞赛排行榜”,是高校教育教学改革和创新人才培养的重要竞赛项目。
大赛共包括三个竞赛组别,个人赛-软件类,个人赛-电子类,以及视觉艺术大赛。其中个人赛-软件类的比赛科目包括C/C++程序设计、Java软件开发、Python程序设计。今年第十二届蓝桥杯报名时间是2020年12月-2021年3月,4月省赛,5月国赛。

ACM:

国际大学生程序设计竞赛(英文全称:International Collegiate Programming Contest(简称ICPC))是由国际计算机协会(ACM)主办的,一项旨在展示大学生创新能力、团队精神和在压力下编写程序、分析和解决问题能力的年度竞赛。经过近40年的发展,ACM国际大学生程序设计竞赛已经发展成为全球最具影响力的大学生程序设计竞赛。赛事目前由方正集团赞助。ACM一般区域赛在秋季,各个区域赛时间不同,每个队只能参加同一年两场区域赛。

蓝桥杯是个人赛,个人赛软件类分为:C/C++大学研究生组,C/C++大学A组,C/C++大学B组,C/C++大学C组,Java大学研究生组,Java大学A组,Java大学B组,Java大学C组,Python大学组共9个组别。研究生只能报研究生组。一本院校(985、211)本科生只能报大学A组以上组别。其它本科院校本科生可报大学B组及以上组别。其它高职、高专院校可自行选择报任意组别。每位选手只能申请参加其中一个组别的竞赛。各个组别单独评奖。蓝桥杯的分组竞赛方式,让平时被“学霸”打压的普通学生,也能有获得感,有进步感,给更多学生指引了努力的方向。

在比赛的时候蓝桥杯是OI赛制,也就是提交答案之后赛后评判,根据通过的样例数量给分。这样的赛制,放宽了对于编程速度的要求,对于大部分选手来说更友好一点,可以更从容地解决问题,但也可能有些错误被疏忽不知道已经错了。

而ACM是团体赛,需要三个人协力解答问题,想要拿到好的成绩队友当然也相当关键,各个学校强弱校都统一竞争,头部榜基本被名校和ACM强校霸榜。竞赛是ACM制,也就是当场评测,只能知道通过(通过会升起一个气球看周围气球数就知道其他队A了多少题),或者错误(WA、RE、TLE等),出错需要及时修改答案。只有完全通过才会给分,对算法要求是比较高的。

蓝桥杯适合各个层次的人,特别是给了很多普通本科和高职高专选手接触更多算法编程的机会,有一定的普及性,为广大双非和专科院校的学生提供了更广阔的舞台。现在很多程序比赛,都属于拔高性质。很多初级阶段的计算机相关专业的学生,无法参加这类拔高性质的比赛,但是从数量上看,他们才是未来程序界的主力军,他们应该接触更多的算法知识,提升自身水平。蓝桥杯的试题以算法和数据结构为主,和各种国际国内知名的程序设计比赛相比,其专业水平绝对不输。

ACM(ICPC)个人觉得是更适合一些算法高端玩家,老玩家(高中就打OI)、传统ACM强校(有氛围、能凑齐队友)、高付出的一个比赛,当然也适合对它热爱的同学,当然,这种比赛偏一小部分人,是算法精英级别的一个比赛。当然也有很多努力几年最后也打了个铁(甚至爆零)也没办法,ACM就是个无底洞,它的乐趣在于不停的探索和AC。

当然,我的建议就是有能力、有准备、有氛围、有热爱去冲ACM的,趁着年轻当然冲一冲,拿个牌牌很好(和参加蓝桥杯刚好也不冲突),当然这个期间也要付出非常多的努力。如果准备的比较晚了(大二无算法基础就很难了),就不一定非要去冲ACM,因为在这个高手集群和后浪层出的时代你真的有可能会打个铁,所以要慎重选择。而蓝桥杯感觉是全民皆宜的一个比赛,认可度在算法竞赛类也很高,通过比赛大部分人也能够进步、去证明自己。总的来说ACM是圈内难度较大,普及分布在强校,认可度最高的一个比赛,题型上来看范围也更广、更深。而蓝桥杯则是一个算法普及度很高的比赛,题型上更侧重于经典算法和常用算法(例如贪心、bfs、dfs、dp等,而数论、计算几何等知识考查相比ACM少很多)。蓝桥杯将算法普及和推广、让更多人参与进来,这点目前在国内做的是最好的。

蓝桥杯对我(你)的意义

其实生活和学习需要一定的竞争和认可,通过这样的竞争促进自己的进步,通过得奖或者其他成就增强自己的信心,为下一轮的学习循环做准备。当然这个过程可能并不一定一帆风顺,很可能你会遇到一些挫败和灰心,而蓝桥杯相比ACM就是给了更多人这样的机会(至少我和我身边同学这样)。在同一个舞台,不同人追向不同的目标,根据自己条件和身边氛围去向前迈进。至少我觉得在这方面蓝桥杯是其他赛事无法比拟的。

如果你有ACM的机会,那么和队友刷题的经历一定很难忘,如果没有ACM机会也没关系,可以一起备战蓝桥杯等算法比赛,找几个队友一起准备,讨论互助,让枯燥的东西因为竞争和帮助而变得更加有趣,也希望看到此篇的大佬都能有成,进步的路上一帆风顺!也愿看到此篇的后来人能有所收获。希望你们都能去北京,也希望你们都能拿证书!

最后,附上和本校小学弟部分聊天图,因为从我们这届过后本科学校搬到又大又豪华的新校区,每次遇到母校小学弟都会很温馨的给老学长拍几张新校区图片,实名羡慕啊!

看到这张图,突然就是想起自己那个时候,我曾向一个学长问的问题我跟他说我好好冲蓝桥杯,但事后我凉了就没消息了,第二年才过了校赛和那个学长一起参赛。虽然我不能和小学弟一起参赛了,在这里也希望他以及看到这篇的你们都能有个好的结果!

从室友到队友到专业伙伴,圈子越来越大,从校选拔赛到省赛到国赛,走的越来越远,虽然我花了很久才体验到这段旅程,但依然很满足那段天真的岁月。第十二届蓝桥杯大赛正在报名(报名官网:https://dasai.lanqiao.cn/),也希望你们都能有属于自己的这段岁月,望加油共勉!

原创公众号:bigsai
文章已收录在 全网都在关注的数据结构与算法学习仓库 欢迎star

原创不易,bigsai请思否的朋友们帮两件事帮忙一下:

  1. 一键三连支持一下, 您的肯定是我创作的源源动力。
  2. 微信搜索「bigsai」,关注我,2021一起加油!

咱们下次再见!

查看原文

赞 1 收藏 0 评论 0

bigsai 发布了文章 · 1月6日

【五大常用算法】一文搞懂分治算法

前言

分治算法(divide and conquer)是五大常用算法(分治算法、动态规划算法、贪心算法、回溯法、分治界限法)之一,很多人在平时学习中可能只是知道分治算法,但是可能并没有系统的学习分治算法,本篇就带你较为全面的去认识和了解分治算法。

在学习分治算法之前,问你一个问题,相信大家小时候都有存钱罐的经历,父母亲人如果给钱都会往自己的宝藏中存钱,我们每隔一段时间都会清点清点钱。但是一堆钱让你处理起来你可能觉得很复杂,因为数据相对于大脑有点庞大了,并且很容易算错,你可能会将它先分成几个小份算,然后再叠加起来计算总和就获得这堆钱的总数了

image-20201130124009617

当然如果你觉得各个部分钱数量还是太大,你依然可以进行划分然后合并,我们之所以这么多是因为:

  • 计算每个小堆钱的方式和计算最大堆钱的方式是相同的(区别在于体量上)
  • 然后大堆钱总和其实就是小堆钱结果之和。这样其实就有一种分治的思想。

当然这些钱都是想出来的……

BACDB95DF648E67CF0576A009697EBD2

分治算法介绍

分治算法是用了分治思想的一种算法,什么是分治

分治,字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。在计算机科学中,分治法就是运用分治思想的一种很重要的算法。分治法是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)等等。

将父问题分解为子问题同等方式求解,这和递归的概念很吻合,所以在分治算法通常以递归的方式实现(当然也有非递归的实现方式)。分治算法的描述从字面上也很容易理解,分、治其实还有个合并的过程:

  • 分(Divide):递归解决较小的问题(到终止层或者可以解决的时候停下)
  • 治(Conquer):递归求解,如果问题够小直接求解。
  • 合并(Combine):将子问题的解构建父类问题

一般分治算法在正文中分解为两个即以上的递归调用,并且子类问题一般是不想交的(互不影响)。当求解一个问题规模很大很难直接求解,但是规模较小的时候问题很容易求解并且这个问题并且问题满足分治算法的适用条件,那么就可以使用分治算法。

image-20201130165303362

那么采用分治算法解决的问题需要 满足那些条件(特征) 呢?

1 . 原问题规模通常比较大,不易直接解决,但问题缩小到一定程度就能较容易的解决。

2 . 问题可以分解为若干规模较小、求解方式相同(似)的子问题。且子问题之间求解是独立的互不影响。

3 . 合并问题分解的子问题可以得到问题的解。

你可能会疑惑分治算法和递归有什么关系?其实分治重要的是一种思想,注重的是问题分、治、合并的过程。而递归是一种方式(工具),这种方式通过方法自己调用自己形成一个来回的过程,而分治可能就是利用了多次这样的来回过程。

分治算法经典问题

对于分治算法的经典问题,重要的是其思想,因为我们大部分借助递归去实现,所以在代码实现上大部分都是很简单,而本篇也重在讲述思想。

分治算法的经典问题,个人将它分成两大类:子问题完全独立和子问题不完全独立。

1 . 子问题完全独立就是原问题的答案可完全由子问题的结果推出。

2 . 子问题不完全独立,有些区间类的问题或者跨区间问题使用分治可能结果跨区间,在考虑问题的时候需要仔细借鉴下。

二分搜索

二分搜索是分治的一个实例,只不过二分搜索有着自己的特殊性

  • 序列有序
  • 结果为一个值

正常二分将一个完整的区间分成两个区间,两个区间本应单独找值然后确认结果,但是通过有序的区间可以直接确定结果在那个区间,所以分的两个区间只需要计算其中一个区间,然后继续进行一直到结束。实现方式有递归和非递归,但是非递归用的更多一些:

public int searchInsert(int[] nums, int target) {
  if(nums[0]>=target)return 0;//剪枝
  if(nums[nums.length-1]==target)return nums.length-1;//剪枝
  if(nums[nums.length-1]<target)return nums.length;
  int left=0,right=nums.length-1;
  while (left<right) {
    int mid=(left+right)/2;
    if(nums[mid]==target)
      return mid;
    else if (nums[mid]>target) {
      right=mid;
    }
    else {
      left=mid+1;
    }
  }
  return left;
}

快速排序

快排也是分治的一个实例,快排每一趟会选定一个数,将比这个数小的放左面,比这个数大的放右面,然后递归分治求解两个子区间,当然快排因为在分的时候就做了很多工作,当全部分到最底层的时候这个序列的值就是排序完的值。这是一种分而治之的体现。

image-20201120133851275

public void quicksort(int [] a,int left,int right)
{
  int low=left;
  int high=right;
  //下面两句的顺序一定不能混,否则会产生数组越界!!!very important!!!
  if(low>high)//作为判断是否截止条件
    return;
  int k=a[low];//额外空间k,取最左侧的一个作为衡量,最后要求左侧都比它小,右侧都比它大。
  while(low<high)//这一轮要求把左侧小于a[low],右侧大于a[low]。
  {
    while(low<high&&a[high]>=k)//右侧找到第一个小于k的停止
    {
      high--;
    }
    //这样就找到第一个比它小的了
    a[low]=a[high];//放到low位置
    while(low<high&&a[low]<=k)//在low往右找到第一个大于k的,放到右侧a[high]位置
    {
      low++;
    }
    a[high]=a[low];            
  }
  a[low]=k;//赋值然后左右递归分治求之
  quicksort(a, left, low-1);
  quicksort(a, low+1, right);        
}

归并排序(逆序数)

快排在分的时候做了很多工作,而归并就是相反,归并在分的时候按照数量均匀分,而合并时候已经是两两有序的进行合并的,因为两个有序序列O(n)级别的复杂度即可得到需要的结果。而逆序数在归并排序基础上变形同样也是分治思想求解。

image-20201120173153449

private static void mergesort(int[] array, int left, int right) {
  int mid=(left+right)/2;
  if(left<right)
  {
    mergesort(array, left, mid);
    mergesort(array, mid+1, right);
    merge(array, left,mid, right);
  }
}

private static void merge(int[] array, int l, int mid, int r) {
  int lindex=l;int rindex=mid+1;
  int team[]=new int[r-l+1];
  int teamindex=0;
  while (lindex<=mid&&rindex<=r) {//先左右比较合并
    if(array[lindex]<=array[rindex])
    {
      team[teamindex++]=array[lindex++];
    }
    else {                
      team[teamindex++]=array[rindex++];
    }
  }
  while(lindex<=mid)//当一个越界后剩余按序列添加即可
  {
    team[teamindex++]=array[lindex++];

  }
  while(rindex<=r)
  {
    team[teamindex++]=array[rindex++];
  }    
  for(int i=0;i<teamindex;i++)
  {
    array[l+i]=team[i];
  }
}

最大子序列和

最大子序列和的问题我们可以使用动态规划的解法,但是也可以使用分治算法来解决问题,但是最大子序列和在合并的时候并不是简单的合并,因为子序列和涉及到一个长度的问题,所以正确结果不一定全在最左侧或者最右侧,而可能出现结果的区域为:

  • 完全在中间的左侧
  • 完全在中间的右侧
  • 包含中间左右两个节点的一个序列

用一张图可以表示为:

在这里插入图片描述

所以在具体考虑的时候需要将无法递归得到结果的中间那个最大值串的结果也算出来参与左侧、右侧值得比较。

力扣53. 最大子序和在实现的代码为:

public int maxSubArray(int[] nums) {
    int max=maxsub(nums,0,nums.length-1);
    return max;
}
int maxsub(int nums[],int left,int right)
{
    if(left==right)
        return  nums[left];
    int mid=(left+right)/2;
    int leftmax=maxsub(nums,left,mid);//左侧最大
    int rightmax=maxsub(nums,mid+1,right);//右侧最大

    int midleft=nums[mid];//中间往左
    int midright=nums[mid+1];//中间往右
    int team=0;
    for(int i=mid;i>=left;i--)
    {
        team+=nums[i];
        if(team>midleft)
            midleft=team;
    }
    team=0;
    for(int i=mid+1;i<=right;i++)
    {
        team+=nums[i];
        if(team>midright)
            midright=team;
    }
    int max=midleft+midright;//中间的最大值
    if(max<leftmax)
        max=leftmax;
    if(max<rightmax)
        max=rightmax;
    return  max;
}

最近点对

最近点对是一个分治非常成功的运用之一。在二维坐标轴上有若干个点坐标,让你求出最近的两个点的距离,如果让你直接求那么枚举暴力是个非常非常大的计算量,我们通常采用分治的方法来优化这种问题。

image-20201130204401673

如果直接分成两部分分治计算你肯定会发现如果最短的如果一个在左一个在右会出现问题。我们可以优化一下。

在具体的优化方案上,按照x或者y的维度进行考虑,将数据分成两个区域,先分别计算(按照同方法)左右区域内最短的点对。然后根据这个两个中较短的距离向左和向右覆盖,计算被覆盖的左右点之间的距离,找到最小那个距离与当前最短距离比较即可。

image-20201130205950625

这样你就可以发现就这个一次的操作(不考虑子情况),左侧红点就避免和右侧大部分红点进行距离计算(O(n2)的时间复杂度)。事实上,在进行左右区间内部计算的时候,它其实也这样递归的进行很多次分治计算。如图所示:

image-20201130210925059

这样下去就可以节省很多次的计算量。

但是这种分治会存在一种问题就是二维坐标可能点都聚集某个方法某条轴那么可能效果并不明显(点都在x=2附近对x分割作用就不大),需要注意一下。

杭电1007推荐给大家,ac的代码为:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
public class Main {
    static int n;
    public static void main(String[] args) throws IOException {
        StreamTokenizer in=new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));
        PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
        //List<node>list=new ArrayList();
         while(in.nextToken()!=StreamTokenizer.TT_EOF)
         {
             n=(int)in.nval;if(n==0) {break;}
            node no[]=new node[n];
            
             for(int i=0;i<n;i++)
             {
                 in.nextToken();double x=in.nval;
                 in.nextToken();double y=in.nval;
                // list.add(new node(x,y));
                 no[i]=new node(x,y);
             }
             Arrays.sort(no, com);
            double min= search(no,0,n-1);
            out.println(String.format("%.2f", Math.sqrt(min)/2));out.flush();
         }         
    }
    private static double search(node[] no, int left,int right) {
        int mid=(right+left)/2;
        double minleng=0;
        if(left==right) {return Double.MAX_VALUE;}
        else if(left+1==right) {minleng= (no[left].x-no[right].x)*(no[left].x-no[right].x)+(no[left].y-no[right].y)*(no[left].y-no[right].y);}
        else minleng= min(search(no,left,mid),search(no,mid,right));
        int ll=mid;int rr=mid+1;
        while(no[mid].y-no[ll].y<=Math.sqrt(minleng)/2&&ll-1>=left) {ll--;}
        while(no[rr].y-no[mid].y<=Math.sqrt(minleng)/2&&rr+1<=right) {rr++;}
        for(int i=ll;i<rr;i++)
        {
            for(int j=i+1;j<rr+1;j++)
            {
                double team=0;
                if(Math.abs((no[i].x-no[j].x)*(no[i].x-no[j].x))>minleng) {continue;}
                else
                { 
                    team=(no[i].x-no[j].x)*(no[i].x-no[j].x)+(no[i].y-no[j].y)*(no[i].y-no[j].y);
                    if(team<minleng)minleng=team;
                }
            }
        }
        return minleng;
    
    }
    private static double min(double a, double b) {
        // TODO 自动生成的方法存根
        return a<b?a:b;
    }
    static Comparator<node>com=new Comparator<node>() {

        @Override
        public int compare(node a1, node a2) {
            // TODO 自动生成的方法存根
            return a1.y-a2.y>0?1:-1;
        }};
    static class node
    {
        double x;
        double y;
        public node(double x,double y)
        {
            this.x=x;
            this.y=y;
        }
    }
}

结语

到这里,分治算法就讲这么多了,因为分治算法重要在于理解其思想,还有一些典型的分治算法解决的问题,例如大整数乘法、Strassen矩阵乘法、棋盘覆盖、线性时间选择、循环赛日程表、汉诺塔等问题你可以自己研究其分治的思想和原理。

原创公众号:bigsai
文章收录在 bigsai-algorithm

原创不易,bigsai请你帮两件事帮忙一下:

  1. 点赞在看, 您的肯定是我在思否创作的源源动力。
  2. 微信搜索「bigsai」,新人求关注~
查看原文

赞 7 收藏 5 评论 0

bigsai 发布了文章 · 2020-12-29

IVX开发—0代码实现一个九宫格抽奖

原创公众号:bigsai

前言

上次说过在看一些关于0代码开发平台ivx,前一段时间忙完考试最近跟着教程0代码实现一个九宫格抽奖,哈哈哈感觉还是蛮强大的,懂点的人都知道可视化这个东西我们正常都是用一些包或者库来实现数据可视化。而可视化编程我们可能还停留在Dreamweaver和安卓xml编程上。如果写过GUI或者之类就知道任何一个可视化操作的任务量是非常巨大的,所以内心还是很钦佩出这么一个东西。并且这个可视化不错的(上手需要一点时间)。

对于九宫格抽奖问题,要清楚并不是真正的前端界面去抽奖,而是后端生成一个数据前端九宫格根据这个数据去跑成一个这么结果的效果。下面就把个人实现的一个抽奖小程序实现过程记录一下,大家也可以尝试一下,因为不涉及代码可能截图更多。当然,由于这部分如果完整实现设计的内容太多可能使读者失去兴趣,我将一些简单的步骤先截图描述大家可以跟着做,后面更完善的功能可以看这个教学视频

试了一下可能刚开始了解稍微复杂一点各个按钮不熟悉,跟着教程一步步来慢慢会熟悉一点。后续也可能会使用ivx平台实现一些后台管理或者一些简单的小程序。当然,这只是一次破冰试验,到底怎么样还要等以后在看!

九宫格背景制作

首先登录ivx平台,进入控制台,新建一个WebApp、小程序。
image-20201227193413150

创建完毕之后在前台创建一个页面(点击一下页面图标即可),然后在右侧可以双击改名为抽奖页。
image-20201227202126328

由于九宫格抽奖效果在画布上的效果更好,可以点击抽奖页,然后在左侧拓展组件中(下滑)找到画布,点击然后在中间画一个差不多大小的矩形。
image-20201227202608750

然后点击画布,设置一个背景颜色更醒目一点。当然,为了美观你也可以将画布的宽高x,y设置一下。
image-20201227202758787

接着可以在画布中添加一个九宫格的背景图(需要提前制作)。点击画布然后在组件列表选择图片,画一个框加入之前准备好的图片,调整大小坐标使其大概覆盖画布。
image-20201227210257955

这样背景就搞好啦,下面需要添加一些动作能让他跑起来!

九宫格跑马灯效果制作

如何实现一个 的效果呢?

跑的效果其实是一个九宫格其中之一大小格子旋转移动的效果,所以事先思路也是先添加对应矩形,然后对矩形添加移动事件即可。

我们首先在画布下添加一个矩形,后将矩形坐标大小可以调(这里为了演示就不搞那么精准啦)。
image-20201227234354811

然后点击矩形,将背景颜色清空,然后适当修改矩形边框的大小。这样,初始位置的矩形就设置好了,下面就需要添加一些轨迹动作。

接下来在画布下添加一个时间轴,然后将我们刚刚跳动的矩形放到时间轴内(右侧对象树可直接拖动)。
image-20201228181625658
然后点击右侧对象树的矩形,在左侧的事件中添加轨迹 。然后点击右侧对象树的时间轴将事件设置成8的整数倍数(因为这里要跳动8下),方便设置每个跳动时间。点击右侧对象树的轨迹,将轨迹类别设为逐帧 (因为九宫格抽奖都是跳的而不是连续的),然后在时间轴上添加帧点。
image-20201228192800380
关键帧设置完毕之后,我们需要在每个关键帧确定方块移动到达的位置。按照顺时针的顺序在每个关键帧将矩形移动到应该展示的位置。可设置对应时刻具体的x和y。
image-20201228193658014
这样设置完毕之后,点击启动,是可以启动的,但是跑起来的速度太慢了,我们需要加大倍速,点击时间轴设置循环播放然后将播放倍数扩大到20倍,点击开始这个动画就能跑起来了!
image-20201228201511355

确定停止时间

在上面我们详细讲解了如何让马灯跑起来,现在就需要再优化一下界面,并且使它能够停下来。我们首先优化一下抽奖页面,在画布上添加一些文本到各个方格中,点击画布,然后在左侧拓展组件选择文本,赋值谢谢惠顾、各种奖项可以自己设置。当然字体颜色也可自己随意改动啊。
image-20201228205917047
页面做好之后可以准备考虑启动事件,我们可以通过按钮这个启动项让页面动起来,触发一系列抽奖逻辑,点击右侧对象树的抽奖页,在左侧拓展组件选择按钮,大小差不多覆盖网格最中间的部分,然后在对象树点击这个按钮,再点击右侧最上的事件,将按钮触发一个点击事件,点击与事件轴关联播放、暂停。
image-20201228211231312
这样预览的时候点击按钮就可以跑起来了,但是我们怎么让它在某个时刻停下来呢?这里就需要时间轴的好帮手—时间标记。我们可以在时间轴下添加一个时间标记,可以在任意一个时刻停下来。在这里我就让它停在三等奖的时间范围内,并且将这个时间标记改名为三等奖。同时将左侧默认的暂停点取消。
image-20201228213249027
然后我们需要在按钮上继续添加关联,点击按钮的关联事件,然后新添时间轴关联,事件选择播放某段时间段,结束时间就选择对象树种咱们刚刚选择的记录点(三等奖),播放方向为正向。
image-20201228212824437
这样完成之后编译点击抽奖会发现跑马灯能跑起来了!但是这个跑马灯只能跑一圈到三等奖就停下来了,我们怎样才能让它多跑几圈,实现一个真正跑马灯抽奖的效果呢?答案也很简单,我们依旧借助时间标记,我们在时间轴下再添加一个时间标记,并且将其暂停点也关掉、出发方向也改为正向,同时将它命名为记录点 (将它时间挪到1-2s之间)。后面的事情就让这个记录点来帮我们完成。
image-20201228214131461
然后我们准备给这个记录点添加一个事件之前,在画布下添加一个数值变量圈数。然后点击记录点再点击事件,可以选择事件播放到标记 。关联的对象就是圈数让每经过这个点圈数+1。
image-20201228220446396
同时还要将播放按钮的事件播放到某时间段先注释掉,让它可以跑下去。我们将注释的这个部分复制下来,添加到记录点的条件中,这个条件就是停止的条件,我们让圈数为6的时候执行前面停下来的动作
image-20201228221334415
这样编译运行就能在我们想要的三等奖下停下来啦! 今天先分享到这里,大家也可以一起研究一下!

查看原文

赞 1 收藏 1 评论 0

bigsai 发布了文章 · 2020-12-28

跳表 | 会跳的链表真的非常diao

原创公众号「bigsai
文章已收录在 我的Github bigsai-algorithm

前言

跳表是面试常问的一种数据结构,它在很多中间件和语言中得到应用,我们熟知的就有Redis跳表。并且在面试的很多场景可能会问到,偶尔还会让你手写试一试(跳表可能会让手写,红黑树是不可能的),这不,给大伙复原一个场景:

image-20201225113330615

但你别慌,遇到蘑菇头这种面试官也别怕,因为你看到这篇文章了(得意😏),不用像熊猫那样窘迫。

对于一个数据结构或算法,人群数量从听过名称、了解基本原理、清楚执行流程、能够手写 呈抖降的趋势。因为很多数据结构与算法其核心原理可能简单,但清楚其执行流程就需要动脑子去思考想明白,但是如果能够把它写出来,那就要自己一步步去设计和实现。可能要花很久才能真正写出来,并且还可能要查阅大量的资料。

而本文在前面进行介绍跳表,后面部分详细介绍跳表的设计和实现,搞懂跳表,这一篇真的就够了。

快速了解跳表

跳跃表(简称跳表)由美国计算机科学家William Pugh发明于1989年。他在论文《Skip lists: a probabilistic alternative to balanced trees》中详细介绍了跳表的数据结构和插入删除等操作。

跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树,AVL树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。

在这里你可以看到一些关键词:链表(有序链表)、索引、二分查找。想必你的脑海中已经有了一个初略的印象,不过你可能还是不清楚这个"会跳的链表"有多diao,甚至还可能会产生一点疑虑:跟随机化有什么关系?你在下文中很快就能得到答案!

回顾链表,我们知道链表和顺序表(数组)通常都是相爱相杀,成对出现,各有优劣。而链表的优势就是更高效的插入、删除。痛点就是查询很慢很慢!每次查询都是一种O(n)复杂度的操作,链表估计自己都气的想哭了😢。

image-20201224155243423

这是一个带头结点的链表(头结点相当于一个固定的入口,不存储有意义的值),每次查找都需要一个个枚举,相当的慢,我们能不能稍微优化一下,让它稍微跳一跳呢?答案是可以的,我们知道很多算法和数据结构以空间换时间,我们在上面加一层索引,让部分节点在上层能够直接定位到,这样链表的查询时间近乎减少一半,链表自己虽然没有开心起来,但收起了它想哭的脸。

image-20201224160740034

这样,在查询某个节点的时候,首先会从上一层快速定位节点所在的一个范围,如果找到具体范围向下然后查找代价很小,当然在表的结构设计上会增加一个向下的索引(指针)用来查找确定底层节点。平均查找速度平均为O(n/2)。但是当节点数量很大的时候,它依旧很慢很慢。我们都知道二分查找是每次都能折半的去压缩查找范围,要是有序链表也能这么跳起来那就太完美了。没错跳表就能让链表拥有近乎的接近二分查找的效率的一种数据结构,其原理依然是给上面加若干层索引,优化查找速度。

image-20201224175922421

通过上图你可以看到,通过这样的一个数据结构对有序链表进行查找都能近乎二分的性能。就是在上面维护那么多层的索引,首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候以及十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以跳查找的查找速度也就变快了。

对于理想的跳表,每向上一层索引节点数量都是下一层的1/2.那么如果n个节点增加的节点数量(1/2+1/4+…)<n。并且层数较低,对查找效果影响不大。但是对于这么一个结构,你可能会疑惑,这样完美的结构真的存在吗?大概率不存在的,因为作为一个链表,少不了增删该查的一些操作。而删除和插入可能会改变整个结构,所以上面的这些都是理想的结构,在插入的时候是否添加上层索引是个概率问题(1/2的概率),在后面会具体讲解。

跳表的增删改查

上面稍微了解了跳表是个啥,那么在这里就给大家谈谈跳表的增删改查过程。在实现本跳表的过程为了便于操作,我们将跳表的头结点(head)的key设为int的最小值(一定满足左小右大方便比较)。

对于每个节点的设置,设置成SkipNode类,为了防止初学者将next向下还是向右搞混,直接设置right,down两个指针。

class SkipNode<T>
{
    int key;
    T value;
    SkipNode right,down;//右下个方向的指针
    public SkipNode (int key,T value) {
        this.key=key;
        this.value=value;
    }
}

跳表的结构和初始化也很重要,其主要参数和初始化方法为:

public class SkipList <T> {
    
    SkipNode headNode;//头节点,入口
    int highLevel;//当前跳表索引层数
    Random random;// 用于投掷硬币
    final int MAX_LEVEL = 32;//最大的层

    SkipList(){
        random=new Random();
        headNode=new SkipNode(Integer.MIN_VALUE,null);
        highLevel=0;
    }
    //其他方法
}

查询操作

很多时候链表也可能这样相连仅仅是某个元素或者key作为有序的标准。所以有可能链表内部存在一些value。不过修改和查询其实都是一个操作,找到关键数字(key)。并且查找的流程也很简单,设置一个临时节点team=head。当team不为null其流程大致如下:

(1) 从team节点出发,如果当前节点的key与查询的key相等,那么返回当前节点(如果是修改操作那么一直向下进行修改值即可)。

(2) 如果key不相等,且右侧为null,那么证明只能向下(结果可能出现在下右方向),此时team=team.down

(3) 如果key不相等,且右侧不为null,且右侧节点key小于待查询的key。那么说明同级还可向右,此时team=team.right

(4)(否则的情况)如果key不相等,且右侧不为null,且右侧节点key大于待查询的key 。那么说明如果有结果的话就在这个索引和下个索引之间,此时team=team.down。

最终将按照这个步骤返回正确的节点或者null(说明没查到)。

image-20201224210130178

例如上图查询12节点,首先第一步从head出发发现右侧不为空,且7<12,向右;第二步右侧为null向下;第三步节点7的右侧10<12继续向右;第四步10右侧为null向下;第五步右侧12小于等于向右。第六步起始发现相等返回节点结束。

而这块的代码也非常容易:

public SkipNode search(int key) {
    SkipNode team=headNode;
    while (team!=null) {
        if(team.key==key)
        {
            return  team;
        }
        else if(team.right==null)//右侧没有了,只能下降
        {
            team=team.down;
        }
        else if(team.right.key>key)//需要下降去寻找
        {
            team=team.down;
        }
        else //右侧比较小向右
        {
            team=team.right;
        }
    }
    return null;
}

删除操作

删除操作比起查询稍微复杂一丢丢,但是比插入简单。删除需要改变链表结构所以需要处理好节点之间的联系。对于删除操作你需要谨记以下几点:

(1)删除当前节点和这个节点的前后节点都有关系

(2)删除当前层节点之后,下一层该key的节点也要删除,一直删除到最底层

根据这两点分析一下:如果找到当前节点了,它的前面一个节点怎么查找呢?这个总不能在遍历一遍吧!有的使用四个方向的指针(上下左右)用来找到左侧节点。是可以的,但是这里可以特殊处理一下 ,不直接判断和操作节点,先找到待删除节点的左侧节点。通过这个节点即可完成删除,然后这个节点直接向下去找下一层待删除的左侧节点。设置一个临时节点team=head,当team不为null具体循环流程为:

(1)如果team右侧为null,那么team=team.down(之所以敢直接这么判断是因为左侧有头结点在左侧,不用担心特殊情况)

(2)如果team右侧不 为null,并且右侧的key等于待删除的key,那么先删除节点,再team向下team=team.down为了删除下层节点。

(3)如果team右侧不 为null,并且右侧key小于待删除的key,那么team向右team=team.right。

(4)如果team右侧不 为null,并且右侧key大于待删除的key,那么team向下team=team.down,在下层继续查找删除节点。

image-20201225002518856

例如上图删除10节点,首先team=head从team出发,7<10向右(team=team.right后面省略);第二步右侧为null只能向下;第三部右侧为10在当前层删除10节点然后向下继续查找下一层10节点;第四步8<10向右;第五步右侧为10删除该节点并且team向下。team为null说明删除完毕退出循环。

删除操作实现的代码如下:

public void delete(int key)//删除不需要考虑层数
{
    SkipNode team=headNode;
    while (team!=null) {
        if (team.right == null) {//右侧没有了,说明这一层找到,没有只能下降
            team=team.down;
        }
        else if(team.right.key==key)//找到节点,右侧即为待删除节点
        {
            team.right=team.right.right;//删除右侧节点
            team=team.down;//向下继续查找删除
        }
        else if(team.right.key>key)//右侧已经不可能了,向下
        {
            team=team.down;
        }
        else { //节点还在右侧
            team=team.right;
        }
    }
}

插入操作

插入操作在实现起来是最麻烦的,需要的考虑的东西最多。回顾查询,不需要动索引;回顾删除,每层索引如果有删除就是了。但是插入不一样了,插入需要考虑是否插入索引,插入几层等问题。由于需要插入删除所以我们肯定无法维护一个完全理想的索引结构,因为它耗费的代价太高。但我们使用随机化的方法去判断是否向上层插入索引。即产生一个[0-1]的随机数如果小于0.5就向上插入索引,插入完毕后再次使用随机数判断是否向上插入索引。运气好这个值可能是多层索引,运气不好只插入最底层(这是100%插入的)。但是索引也不能不限制高度,我们一般会设置索引最高值如果大于这个值就不往上继续添加索引了。

我们一步步剖析该怎么做,其流程为

(1)首先通过上面查找的方式,找到待插入的左节点。插入的话最底层肯定是需要插入的,所以通过链表插入节点(需要考虑是否为末尾节点)

(2)插入完这一层,需要考虑上一层是否插入,首先判断当前索引层级,如果大于最大值那么就停止(比如已经到最高索引层了)。否则设置一个随机数1/2的概率向上插入一层索引(因为理想状态下的就是每2个向上建一个索引节点)。

(3)继续(2)的操作,直到概率退出或者索引层数大于最大索引层。

具体向上插入的时候,实质上还有非常重要的细节需要考虑。首先如何找到上层的待插入节点

这个各个实现方法可能不同,如果有左、上指向的指针那么可以向左向上找到上层需要插入的节点,但是如果只有右指向和下指向的我们也可以巧妙的借助查询过程中记录下降的节点。因为曾经下降的节点倒序就是需要插入的节点,最底层也不例外(因为没有匹配值会下降为null结束循环)。在这里我使用这个数据结构进行存储,当然使用List也可以。下图就是给了一个插入示意图。

image-20201225100031207

其次如果该层是目前的最高层索引,需要继续向上建立索引应该怎么办?

首先跳表最初肯定是没索引的,然后慢慢添加节点才有一层、二层索引,但是如果这个节点添加的索引突破当前最高层,该怎么办呢?

这时候需要注意了,跳表的head需要改变了,新建一个ListNode节点作为新的head,将它的down指向老head,将这个head节点加入栈中(也就是这个节点作为下次后面要插入的节点),就比如上面的9节点如果运气够好在往上建立一层节点,会是这样的。

image-20201225100432978

插入上层的时候注意所有节点要新建(拷贝),除了right的指向down的指向也不能忘记,down指向上一个节点可以用一个临时节点作为前驱节点。如果层数突破当前最高层,头head节点(入口)需要改变。

这部分更多的细节在代码中注释解释了,详细代码为:

public void add(SkipNode node)
{

    int key=node.key;
    SkipNode findNode=search(key);
    if(findNode!=null)//如果存在这个key的节点
    {
        findNode.value=node.value;
        return;
    }
    Stack<SkipNode>stack=new Stack<SkipNode>();//存储向下的节点,这些节点可能在右侧插入节点
    SkipNode team=headNode;//查找待插入的节点   找到最底层的哪个节点。
    while (team!=null) {//进行查找操作 
        if(team.right==null)//右侧没有了,只能下降
        {
            stack.add(team);//将曾经向下的节点记录一下
            team=team.down;
        }
        else if(team.right.key>key)//需要下降去寻找
        {
            stack.add(team);//将曾经向下的节点记录一下
            team=team.down;
        }
        else //向右
        {
            team=team.right;
        }
    }
    int level=1;//当前层数,从第一层添加(第一层必须添加,先添加再判断)
    SkipNode downNode=null;//保持前驱节点(即down的指向,初始为null)
    while (!stack.isEmpty()) {
        //在该层插入node
        team=stack.pop();//抛出待插入的左侧节点
        SkipNode nodeTeam=new SkipNode(node.key, node.value);//节点需要重新创建
        nodeTeam.down=downNode;//处理竖方向
        downNode=nodeTeam;//标记新的节点下次使用
        if(team.right==null) {//右侧为null 说明插入在末尾
            team.right=nodeTeam;
        }
        //水平方向处理
        else {//右侧还有节点,插入在两者之间
            nodeTeam.right=team.right;
            team.right=nodeTeam;
        }
        //考虑是否需要向上
        if(level>MAX_LEVEL)//已经到达最高级的节点啦
            break;
        double num=random.nextDouble();//[0-1]随机数
        if(num>0.5)//运气不好结束
            break;
        level++;
        if(level>highLevel)//比当前最大高度要高但是依然在允许范围内 需要改变head节点
        {
            highLevel=level;
            //需要创建一个新的节点
            SkipNode highHeadNode=new SkipNode(Integer.MIN_VALUE, null);
            highHeadNode.down=headNode;
            headNode=highHeadNode;//改变head
            stack.add(headNode);//下次抛出head
        }
    }
}

总结

对于上面,跳表完整分析就结束啦,当然,你可能看到不同品种跳表的实现,还有的用数组方式表示上下层的关系这样也可以,但本文只定义right和down两个方向的链表更纯正化的讲解跳表。

对于跳表以及跳表的同类竞争产品:红黑树,为啥Redis的有序集合(zset) 使用跳表呢?因为跳表除了查找插入维护和红黑树有着差不多的效率,它是个链表,能确定范围区间,而区间问题在树上可能就没那么方便查询啦。而JDK中跳跃表ConcurrentSkipListSet和ConcurrentSkipListMap。 有兴趣的也可以查阅一下源码。

对于学习,完整的代码是非常重要的,这里我把完整代码贴出来,需要的自取。

import java.util.Random;
import java.util.Stack;
class SkipNode<T>
{
    int key;
    T value;
    SkipNode right,down;//左右上下四个方向的指针
    public SkipNode (int key,T value) {
        this.key=key;
        this.value=value;
    }

}
public class SkipList <T> {

    SkipNode headNode;//头节点,入口
    int highLevel;//层数
    Random random;// 用于投掷硬币
    final int MAX_LEVEL = 32;//最大的层
    SkipList(){
        random=new Random();
        headNode=new SkipNode(Integer.MIN_VALUE,null);
        highLevel=0;
    }
    public SkipNode search(int key) {
        SkipNode team=headNode;
        while (team!=null) {
            if(team.key==key)
            {
                return  team;
            }
            else if(team.right==null)//右侧没有了,只能下降
            {
                team=team.down;
            }
            else if(team.right.key>key)//需要下降去寻找
            {
                team=team.down;
            }
            else //右侧比较小向右
            {
                team=team.right;
            }
        }
        return null;
    }

    public void delete(int key)//删除不需要考虑层数
    {
        SkipNode team=headNode;
        while (team!=null) {
            if (team.right == null) {//右侧没有了,说明这一层找到,没有只能下降
                team=team.down;
            }
            else if(team.right.key==key)//找到节点,右侧即为待删除节点
            {
                team.right=team.right.right;//删除右侧节点
                team=team.down;//向下继续查找删除
            }
            else if(team.right.key>key)//右侧已经不可能了,向下
            {
                team=team.down;
            }
            else { //节点还在右侧
                team=team.right;
            }
        }
    }
    public void add(SkipNode node)
    {
    
        int key=node.key;
        SkipNode findNode=search(key);
        if(findNode!=null)//如果存在这个key的节点
        {
            findNode.value=node.value;
            return;
        }

        Stack<SkipNode>stack=new Stack<SkipNode>();//存储向下的节点,这些节点可能在右侧插入节点
        SkipNode team=headNode;//查找待插入的节点   找到最底层的哪个节点。
        while (team!=null) {//进行查找操作
            if(team.right==null)//右侧没有了,只能下降
            {
                stack.add(team);//将曾经向下的节点记录一下
                team=team.down;
            }
            else if(team.right.key>key)//需要下降去寻找
            {
                stack.add(team);//将曾经向下的节点记录一下
                team=team.down;
            }
            else //向右
            {
                team=team.right;
            }
        }

        int level=1;//当前层数,从第一层添加(第一层必须添加,先添加再判断)
        SkipNode downNode=null;//保持前驱节点(即down的指向,初始为null)
        while (!stack.isEmpty()) {
            //在该层插入node
            team=stack.pop();//抛出待插入的左侧节点
            SkipNode nodeTeam=new SkipNode(node.key, node.value);//节点需要重新创建
            nodeTeam.down=downNode;//处理竖方向
            downNode=nodeTeam;//标记新的节点下次使用
            if(team.right==null) {//右侧为null 说明插入在末尾
                team.right=nodeTeam;
            }
            //水平方向处理
            else {//右侧还有节点,插入在两者之间
                nodeTeam.right=team.right;
                team.right=nodeTeam;
            }
            //考虑是否需要向上
            if(level>MAX_LEVEL)//已经到达最高级的节点啦
                break;
            double num=random.nextDouble();//[0-1]随机数
            if(num>0.5)//运气不好结束
                break;
            level++;
            if(level>highLevel)//比当前最大高度要高但是依然在允许范围内 需要改变head节点
            {
                highLevel=level;
                //需要创建一个新的节点
                SkipNode highHeadNode=new SkipNode(Integer.MIN_VALUE, null);
                highHeadNode.down=headNode;
                headNode=highHeadNode;//改变head
                stack.add(headNode);//下次抛出head
            }
        }

    }
    public void printList() {
        SkipNode teamNode=headNode;
        int index=1;
        SkipNode last=teamNode;
        while (last.down!=null){
            last=last.down;
        }
        while (teamNode!=null) {
            SkipNode enumNode=teamNode.right;
            SkipNode enumLast=last.right;
            System.out.printf("%-8s","head->");
            while (enumLast!=null&&enumNode!=null) {
                if(enumLast.key==enumNode.key)
                {
                    System.out.printf("%-5s",enumLast.key+"->");
                    enumLast=enumLast.right;
                    enumNode=enumNode.right;
                }
                else{
                    enumLast=enumLast.right;
                    System.out.printf("%-5s","");
                }

            }
            teamNode=teamNode.down;
            index++;
            System.out.println();
        }
    }
    public static void main(String[] args) {
        SkipList<Integer>list=new SkipList<Integer>();
        for(int i=1;i<20;i++)
        {
            list.add(new SkipNode(i,666));
        }
        list.printList();
        list.delete(4);
        list.delete(8);
        list.printList();
    }
}

进行测试一下可以发现跳表还是挺完美的(自夸一下)。
image-20201225105810595

原创不易,bigsai请思否的朋友们帮两件事帮忙一下:

  1. 点赞、收藏、关注支持一下, 您的肯定是我创作的源源动力。
  2. 微信搜索「bigsai」,关注我的原创技术公众号,前进道路上不迷路!

咱们下次再见!

查看原文

赞 17 收藏 10 评论 0

bigsai 发布了文章 · 2020-12-18

5张图搞懂Java引用拷贝、深拷贝、浅拷贝

原创公众号 「bigsai」 专注于Java和数据结构与算法的分享
文章收录在github/bigsai-algorithm 可收藏

在开发、刷题、面试中,我们可能会遇到将一个对象的属性赋值到另一个对象的情况,这种情况就叫做拷贝。拷贝与Java内存结构息息相关,搞懂Java深浅拷贝是很必要的!

在对象的拷贝中,很多初学者可能搞不清到底是拷贝了引用还是拷贝了对象。在拷贝中这里就分为引用拷贝、浅拷贝、深拷贝进行讲述。

引用拷贝

引用拷贝会生成一个新的对象引用地址,但是两个最终指向依然是同一个对象。如何更好的理解引用拷贝呢?很简单,就拿我们人来说,通常有个姓名,但是不同场合、人物对我们的叫法可能不同,但我们很清楚哪些名称都是属于"我"的!

image-20201216222353944

当然,通过一个代码示例让大家领略一下(为了简便就不写get、set等方法):

class Son {
    String name;
    int age;

    public Son(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
public class test {
    public static void main(String[] args) {
        Son s1 = new Son("son1", 12);
        Son s2 = s1;
        s1.age = 22;
        System.out.println(s1);
        System.out.println(s2);
        System.out.println("s1的age:" + s1.age);
        System.out.println("s2的age:" + s2.age);
        System.out.println("s1==s2" + (s1 == s2));//相等
    }
}

输出的结果为:

Son@135fbaa4
Son@135fbaa4
s1的age:22
s2的age:22
true

浅拷贝

如何创建一个对象,将目标对象的内容复制过来而不是直接拷贝引用呢?

这里先讲一下浅拷贝,浅拷贝会创建一个新对象,新对象和原对象本身没有任何关系,新对象和原对象不等,但是新对象的属性和老对象相同。具体可以看如下区别:

  • 如果属性是基本类型(int,double,long,boolean等),拷贝的就是基本类型的值;
  • 如果属性是引用类型,拷贝的就是内存地址(即复制引用但不复制引用的对象) ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。

如果用一张图来描述一下浅拷贝,它应该是这样的:

image-20201217002917565

如何实现浅拷贝呢?也很简单,就是在需要拷贝的类上实现Cloneable接口并重写其clone()方法

@Override
protected Object clone() throws CloneNotSupportedException {
  return super.clone();
}

在使用的时候直接调用类的clone()方法即可。具体案例如下:

class Father{
    String name;
    public Father(String name) {
        this.name=name;
    }
    @Override
    public String toString() {
        return "Father{" +
                "name='" + name + '\'' +
                '}';
    }
}
class Son implements Cloneable {
    int age;
    String name;
    Father father;
    public Son(String name,int age) {
        this.age=age;
        this.name = name;
    }
    public Son(String name,int age, Father father) {
        this.age=age;
        this.name = name;
        this.father = father;
    }
    @Override
    public String toString() {
        return "Son{" +
                "age=" + age +
                ", name='" + name + '\'' +
                ", father=" + father +
                '}';
    }
    @Override
    protected Son clone() throws CloneNotSupportedException {
        return (Son) super.clone();
    }
}
public class test {
    public static void main(String[] args) throws CloneNotSupportedException {
        Father f=new Father("bigFather");
        Son s1 = new Son("son1",13);
        s1.father=f;
        Son s2 = s1.clone();
        
        System.out.println(s1);
        System.out.println(s2);
        System.out.println("s1==s2:"+(s1 == s2));//不相等
        System.out.println("s1.name==s2.name:"+(s1.name == s2.name));//相等
        System.out.println();

        //但是他们的Father father 和String name的引用一样
        s1.age=12;
        s1.father.name="smallFather";//s1.father引用未变
        s1.name="son222";//类似 s1.name=new String("son222") 引用发生变化
        System.out.println("s1.Father==s2.Father:"+(s1.father == s2.father));//相等
        System.out.println("s1.name==s2.name:"+(s1.name == s2.name));//不相等
        System.out.println(s1);
        System.out.println(s2);
    }
}

运行结果为:

Son{age=13, name='son1', father=Father{name='bigFather'}}
Son{age=13, name='son1', father=Father{name='bigFather'}}
s1==s2:false
s1.name==s2.name:true//此时相等

s1.Father==s2.Father:true
s1.name==s2.name:false//修改引用后不等
Son{age=12, name='son222', father=Father{name='smallFather'}}
Son{age=13, name='son1', father=Father{name='smallFather'}}

不出意外,这种浅拷贝除了对象本身不同以外,各个零部件和关系和拷贝对象都是相同的,就好像双胞胎一样,是两个人,但是其开始的样貌、各种关系(父母亲人)都是相同的。需要注意的是其中name初始==是相等的,是因为初始浅拷贝它们指向一个相同的String,而后 s1.name="son222" 则改变引用指向。

image-20201217103648400

深拷贝

对于上述的问题虽然拷贝的两个对象不同,但其内部的一些引用还是相同的,怎么样绝对的拷贝这个对象,使这个对象完全独立于原对象呢?就使用我们的深拷贝了。深拷贝:在对引用数据类型进行拷贝的时候,创建了一个新的对象,并且复制其内的成员变量。

image-20201217111300466

在具体实现深拷贝上,这里提供两个方式,重写clone()方法和序列法。

重写clone()方法

如果使用重写clone()方法实现深拷贝,那么要将类中所有自定义引用变量的类也去实现Cloneable接口实现clone()方法。对于字符类可以创建一个新的字符串实现拷贝。

对于上述代码,Father类实现Cloneable接口并重写clone()方法。son的clone()方法需要对各个引用都拷贝一遍

//Father clone()方法
@Override
protected Father clone() throws CloneNotSupportedException {
    return (Father) super.clone();
}
//Son clone()方法
@Override
protected Son clone() throws CloneNotSupportedException {
    Son son= (Son) super.clone();//待返回克隆的对象
    son.name=new String(name);
    son.father=father.clone();
    return son;
}

其他代码不变,执行结果如下:

Son{age=13, name='son1', father=Father{name='bigFather'}}
Son{age=13, name='son1', father=Father{name='bigFather'}}
s1==s2:false
s1.name==s2.name:false

s1.Father==s2.Father:false
s1.name==s2.name:false
Son{age=12, name='son222', father=Father{name='smallFather'}}
Son{age=13, name='son1', father=Father{name='bigFather'}}

序列化

可以发现这种方式实现了深拷贝。但是这种情况有个问题,如果引用数量或者层数太多了怎么办呢?

image-20201217105458651

不可能去每个对象挨个写clone()吧?那怎么办呢?借助序列化啊。

因为序列化后:将二进制字节流内容写到一个媒介(文本或字节数组),然后是从这个媒介读取数据,原对象写入这个媒介后拷贝给clone对象,原对象的修改不会影响clone对象,因为clone对象是从这个媒介读取。

熟悉对象缓存的知道我们经常将Java对象缓存到Redis中,然后还可能从Redis中读取生成Java对象,这就用到序列化和反序列化。一般可以将Java对象存储为字节流或者json串然后反序列化成Java对象。因为序列化会储存对象的属性但是不会也无法存储对象在内存中地址相关信息。所以在反序列化成Java对象时候会重新创建所有的引用对象。

在具体实现上,自定义的类需要实现Serializable接口。在需要深拷贝的类(Son)中定义一个函数返回该类对象:

protected Son deepClone() throws IOException, ClassNotFoundException {
      Son son=null;
      //在内存中创建一个字节数组缓冲区,所有发送到输出流的数据保存在该字节数组中
      //默认创建一个大小为32的缓冲区
      ByteArrayOutputStream byOut=new ByteArrayOutputStream();
      //对象的序列化输出
      ObjectOutputStream outputStream=new ObjectOutputStream(byOut);//通过字节数组的方式进行传输
      outputStream.writeObject(this);  //将当前student对象写入字节数组中

      //在内存中创建一个字节数组缓冲区,从输入流读取的数据保存在该字节数组缓冲区
      ByteArrayInputStream byIn=new ByteArrayInputStream(byOut.toByteArray()); //接收字节数组作为参数进行创建
      ObjectInputStream inputStream=new ObjectInputStream(byIn);
      son=(Son) inputStream.readObject(); //从字节数组中读取
      return  son;
}

使用时候调用我们写的方法即可,其他不变,实现的效果为:

Son{age=13, name='son1', father=Father{name='bigFather'}}
Son{age=13, name='son1', father=Father{name='bigFather'}}
s1==s2:false
s1.name==s2.name:false

s1.Father==s2.Father:false
s1.name==s2.name:false
Son{age=12, name='son222', father=Father{name='smallFather'}}
Son{age=13, name='son1', father=Father{name='bigFather'}}

写在最后

原创不易,bigsai我请思否两件事帮忙一下:

  1. 点赞支持一下, 您的肯定是我在思否创作的源源动力。
  2. 微信搜索「bigsai」,关注我的公众号(新人求支持),不仅免费送你电子书,我还会第一时间在公众号分享知识技术。加我还可拉你进力扣打卡群一起打卡LeetCode。

如果本篇对你有帮助,还请点个赞,如果您有更好的见解,可以在评论区交流!

查看原文

赞 10 收藏 6 评论 0

bigsai 发布了文章 · 2020-12-11

面试官本拿求素数搞我,但被我优雅的“回击“了(素数筛)

原创公众号(希望能支持一下):bigsai 转载请联系bigsai
文章收录在github

前言

现在的面试官,是无数开发者的梦魇,能够吊打面试官的属实不多,因为大部分面试官真的有那么那几下子。但在面试中,我们这些小生存者不能全盘否定只能单点突破—从某个问题上让面试官眼前一亮。这不,今天就来分享来了。

这年头,算法岗内卷不说,开发岗也有点内卷,对开发者要求越来越高了,而面试官也是处心积虑的 "刁难" 面试者,凡是都喜欢由浅入深,凡是都喜欢问个:你知道为什么?你知道原理吗?之类。并且,以前只是大厂面试官喜欢问算法,大厂员工底子好,很多甚至有ACM经验或者系统刷题经验,这很容易理解,但现在一些小公司面试官也是张口闭口 xx算法、xx数据结构你说说看,这不,真的被问到了。

求一个质数

在这么一次的过程,面试官问我算法题我不吃惊,我实现早把十大排序原理、复杂度分析、代码手写实现出来了,也把链表、树的各种操作温习的滚瓜烂熟,不过突然就是很诧异的面试官来了一道求素数问题,我把场景还原一下:

面试官:你知道怎么求素数吗?

我:求素数?

面试官:是的,就是求素数。

我:这很简单啊,判断一个数为素数,那么肯定就没有两个数(除了自身和1)相乘等于它,只需要枚举看看有没有能够被它整除的数就可以了,如果有那么就不是素数,如果没有,那么就是素数。

面试官露出一种失望的表情,说我说的对,但没答到点子上,让我具体说一下。

下面开始开始我的表演:

首先,最笨的方法,判断n是否为素数,就是枚举[2,n-1]之间有没有直接能够被n整除的,如果有,那么返回false这个就不是素数,否则就是素数,代码如下:

boolean isprime(int value){
  for(int i=2;i<value;i++)
  {
       if(value%i==0)
       {return false;}
  }
    return true;
}

这种判断一个素数的时间复杂度为O(n).

但是其实这种太浪费时间了,完全没必要这样,可以优化一下 。如果一个数不是质数,那么必定是两个数的乘积,而这两个数通常一个大一个小,并且小的小于等于根号n,大的大于等于根号n,我们只需要枚举小的可能范围,看看是否能够被整除,就可以判断这个数是否为素数啦。例如100=2*50=4*25=5*20=10*10 只需要找2—10这个区间即可。右侧的一定有个对应的不需要管它。

boolean isprime(int value)
{
  for(int i=2;i*i<value+1;i++)
    {
       if(value%i==0)
       {return false;}
    }
    return true;
}

这里之所以要小于value+1,就是要包含根号的情况,例如 3*3=9.要包含3.这种时间复杂度求单个数是O(sqrt(n))。面试官我给你画张图让你看看其中区别:

image-20201208105627132

说到这里面试官露出欣慰的笑容。

面试官:不错不错,基本点掌握了
我:老哥,其实求素数精髓不在这,这个太低效在很多时候,比如求小于n的所有素数,你看看怎么搞?

面试官:用个数组用第二种方法求O(n*sqrt(n))还行啊。

求多个素数

求多个素数的时候(小于n的素数),上面的方法就很繁琐了,因为有大量重复计算,因为 计算某个数的倍数 是否为素数的时候出现大量的重复计算,如果这个数比较大那么对空间浪费比较多。

这样,素数筛的概念就被发明和使用。筛的原理是从前往后进行一种递推、过滤排序以来统计素数。

埃拉托斯特尼(Eratosthenes)筛法

我们看一个数如果不是为素数,那么这个数没有数的乘积能为它,那么这样我们可以根据这个思想进行操作啊:

直接从前往后枚举,这个数位置没被标记的肯定就是素数,如果这个数是素数那么将这个数的倍数标记一下(下次遍历到就不需要在计算)。如果不是素数那么就进行下一步。这样数值越大后面计算次数越少,在进行具体操作时候可借助数组进行判断。所以埃氏筛的核心思想就是将素数的倍数确定为合数

假设刚开始全是素数,2为素数,那么2的倍数均不是素数;然后遍历到3,3的倍数标记一下;下个是5(因为4已经被标记过);一直到n-1为止。具体流程可以看图:

image-20201208112829007

具体代码为:

boolean isprime[];
long prime[];
void getprime()
{
        prime=new long[100001];//记录第几个prime
      int index=0;//标记prime当前下标
        isprime=new boolean [1000001];//判断是否被标记过
        for(int i=2;i<1000001;i++)
        {
            if(!isprime[i])
            {
                prime[index++]=i;
            }
            for(int j=i+i;j<1000000;j=j+i)//他的所有倍数都over
            {
                isprime[j]=true;                    
            }
        }
}

这种筛的算法复杂度为O(nloglogn);别小瞧多的这个logn,数据量大一个log可能少不少个0,那时间也是十倍百倍甚至更多的差距。

欧拉筛

面试官已经开始点头赞同了,哦哦的叫了起来,可其实还没完。还有个线性筛—欧拉筛。观察上述的埃氏筛,有很多重复的计算,尤其是前面的素数,比如2和3的最小公倍数为6,每3次2的计算就也会遇到是3的倍数,而欧拉筛在埃氏筛的基础上改进,有效的避免了这个重复计算。

具体是何种思路呢?就是埃氏筛是遇到一个质数将它的倍数计算到底,而欧拉筛则是只用它乘以已知晓的素数的乘积进行标记,如果素数能够被整除那就停止往后标记。

在实现上同样也是用两个数组,一个存储真实有效的素数,一个用来作为标记使用。

  • 在遍历到一个数的时候,如果这个数没被标记,那么这个数存在素数的数组中,对应下标加1.
  • 不管这个数是不是素数,遍历已知素数将它和该素数的乘积值标记,如果这个素数能够被当前值i整除,那么停止操作进行下一轮。

具体实现的代码为:

boolean isprime[];
int prime[];
void getprimeoula()// 欧拉筛
{
        prime = new int[100001];// 记录第几个prime
        int index = 0;
        isprime = new boolean[1000001];
        for (int i = 2; i < 1000001; i++) {
            if (!isprime[i]) {
                prime[index++] = i;
            }
            for (int j = 0; j < index && i * prime[j] <= 100000; j++){//已知素数范围内枚举
                isprime[i * prime[j]] = true;// 标记乘积
                if (i % prime[j] == 0)
                    break;
            }
        }
}

你可能会问为啥if (i % prime[j] == 0)就要break。

如果i%prime[j]==0,那么就说明i=prime[j]*k. k为一个整数。
那么如果进行下一轮的话
i*prime[j+1]=(prime[j]*k)*prime[j+1]=prime[j]*(k*prime[j+1]) i=k*prime[j+1]两个位置就产生冲突重复计算啦,所以一旦遇到能够被整除的就停止。

image-20201208121324157

你可以看到这个过程,6只标记12而不标记18,18被9*2标记。详细理解还需要多看看代码想想。过程图就不画啦!欧拉的思路就是离我较近的我给它标记。欧拉筛的时间复杂度为O(n),因为每个数只标记一次。

面试官露出一脸欣赏的表情,说了句不错,下面就是聊聊家常,让我等待下一次面试!

image-20201208121913781
原创不易,bigsai我请你帮两件事帮忙一下:

  1. 点赞支持一下, 您的肯定是我在思否创作的源源动力。
  2. 微信搜索「bigsai」,关注我的公众号(新人求支持),不仅免费送你电子书,我还会第一时间在公众号分享知识技术。加我还可拉你进力扣打卡群一起打卡LeetCode。

记得关注、咱们下次再见!

查看原文

赞 15 收藏 7 评论 4

bigsai 发布了文章 · 2020-12-08

花五分钟看这篇之前,你才发现你不懂RESTful

原创公众号:bigsai 转载请联系bigsai
文章收藏在回车课堂github

前言

在学习RESTful 风格接口之前,即使你不知道它是什么,但你肯定会好奇它能解决什么问题?有什么应用场景?听完下面描述我想你就会明白:

在互联网并没有完全流行的初期,移动端也没有那么盛行,页面请求和并发量也不高,那时候人们对接口的要求没那么高,一些动态页面(jsp)就能满足绝大多数的使用需求。

image-20201204001441126

但是随着互联网和移动设备的发展,人们对Web应用的使用需求也增加,传统的动态页面由于低效率而渐渐被HTML+JavaScript(Ajax)的前后端分离所取代,并且安卓、IOS、小程序等形式客户端层出不穷,客户端的种类出现多元化,而客户端和服务端就需要接口进行通信,但接口的规范性就又成了一个问题:

image-20201204001702612

所以一套结构清晰、符合标准、易于理解、扩展方便让大部分人都能够理解接受的接口风格就显得越来越重要,而RESTful风格的接口(RESTful API)刚好有以上特点,就逐渐被实践应用而变得流行起来。

image-20201204001618944

现在,RESTful是目前最流行的接口设计规范,在很多公司有着广泛的应用,其中Github 的API设计就是很标准的RESTful API,你可以参考学习。

在开发实践中我们很多人可能还是使用传统API进行请求交互,很多人其实并不特别了解RESTful API,对RESTful API的认知可能会停留在:

  • 面向资源类型的
  • 是一种风格
  • (误区)接口传递参数使用斜杠(/)分割而不用问号(?)传参。

而其实一个很大的误区不要认为没有查询字符串就是RESTful API,也不要认为用了查询字符串就不是RESTful API,更不要认为用了JSON传输的API就是RESTful API。

本教程将带你了解RESTful并用SpringBoot实战RESTful API,在实现本案例前,你需要保证你的电脑上

  • 拥有IDEA用来编写项目代码
  • 拥有Postman模拟请求进行测试
  • 拥有关系数据库MySQL 5.7
  • 拥有navicat对MySQL进行管理

一、REST介绍

REST涉及一些概念性的东西可能比较多,在实战RESTful API之前,要对REST相关的知识有个系统的认知。

REST的诞生

REST(英文:Representational State Transfer,简称REST,直译过来表现层状态转换)是一种软件架构风格、设计风格,而不是标准,只是提供了一组设计原则和约束条件。它主要用于客户端和服务器交互类的软件。基于这个风格设计的软件可以更简洁,更有层次,更易于实现缓存等机制。

它首次出现在 2000 年 Roy Thomas Fielding 的博士论文中,这篇论文定义并详细介绍了表述性状态转移(Representational State Transfer,REST)的架构风格,并且描述了 如何使用 REST 来指导现代 Web 架构的设计和开发。用他自己的原话说:

我写这篇文章的目的是:在符合架构原理前提下,理解和评估基于网络的应用软件的架构设计,得到一个功能强、性能好、适宜通信的架构。

需要注意的是REST并没有一个明确的标准,而更像是一种设计的风格,满足这种设计风格的程序或接口我们称之为RESTful(从单词字面来看就是一个形容词)。所以RESTful API 就是满足REST架构风格的接口。

在这里插入图片描述

Fielding博士当时提出的是REST架构在很久的时间内并没有被关注太多,而近些年REST在国内才变得越来越流行。下面开始详细学习REST架构特征。

REST架构特征

既然知道REST和RESTful的联系和区别,现在就要开始好好了解RESTful的一些约束条件和规则,RESTful是一种风格而不是标准,而这个风格大致有以下几个主要特征

以资源为基础 :资源可以是一个图片、音乐、一个XML格式、HTML格式或者JSON格式等网络上的一个实体,除了一些二进制的资源外普通的文本资源更多以JSON为载体、面向用户的一组数据(通常从数据库中查询而得到)。
统一接口: 对资源的操作包括获取、创建、修改和删除,这些操作正好对应HTTP协议提供的GET、POST、PUT和DELETE方法。换言而知,使用RESTful风格的接口但从接口上你可能只能定位其资源,但是无法知晓它具体进行了什么操作,需要具体了解其发生了什么操作动作要从其HTTP请求方法类型上进行判断。具体的HTTP方法和方法含义如下:

  • GET(SELECT):从服务器取出资源(一项或多项)。
  • POST(CREATE):在服务器新建一个资源。
  • PUT(UPDATE):在服务器更新资源(客户端提供完整资源数据)。
  • PATCH(UPDATE):在服务器更新资源(客户端提供需要修改的资源数据)。
  • DELETE(DELETE):从服务器删除资源。

当然也有很多在具体使用的时候使用PUT表示更新。从请求的流程来看,RESTful API和传统API大致架构如下:
image-20201204001311359

URI指向资源:URI = Universal Resource Identifier 统一资源标志符,用来标识抽象或物理资源的一个紧凑字符串。URI包括URL和URN,在这里更多时候可能代指URL(统一资源定位符)。RESTful是面向资源的,每种资源可能由一个或多个URI对应,但一个URI只指向一种资源。

无状态:服务器不能保存客户端的信息, 每一次从客户端发送的请求中,要包含所有必须的状态信息,会话信息由客户端保存, 服务器端根据这些状态信息来处理请求。 当客户端可以切换到一个新状态的时候发送请求信息, 当一个或者多个请求被发送之后, 客户端就处于一个状态变迁过程中。 每一个应用的状态描述可以被客户端用来初始化下一次的状态变迁。

REST架构限制条件

Fielding在论文中提出REST架构的6个限制条件,也可称为RESTful 6大原则, 标准的REST约束应满足以下6个原则:

客户端-服务端(Client-Server): 这个更专注客户端和服务端的分离,服务端独立可更好服务于前端、安卓、IOS等客户端设备。

无状态(Stateless):服务端不保存客户端状态,客户端保存状态信息每次请求携带状态信息。

可缓存性(Cacheability) :服务端需回复是否可以缓存以让客户端甄别是否缓存提高效率。

统一接口(Uniform Interface):通过一定原则设计接口降低耦合,简化系统架构,这是RESTful设计的基本出发点。当然这个内容除了上述特点提到部分具体内容比较多详细了解可以参考这篇REST论文内容

分层系统(Layered System):客户端无法直接知道连接的到终端还是中间设备,分层允许你灵活的部署服务端项目。

按需代码(Code-On-Demand,可选):按需代码允许我们灵活的发送一些看似特殊的代码给客户端例如JavaScript代码。

REST架构的一些风格和限制条件就先介绍到这里,后面就对RESTful风格API具体介绍。

二、RESTful API设计规范

既然了解了RESTful的一些规则和特性,那么具体该怎么去设计一个RESTful API呢?要从URL路径、HTTP请求动词、状态码和返回结果等方面详细考虑。至于其他的方面例如错误处理、过滤信息等规范这里就不详细介绍了。

URL设计规范

URL为统一资源定位器 ,接口属于服务端资源,首先要通过URL这个定位到资源才能去访问,而通常一个完整的URL组成由以下几个部分构成:

URI = scheme "://" host  ":"  port "/" path [ "?" query ][ "#" fragment ]

scheme: 指底层用的协议,如http、https、ftp
host: 服务器的IP地址或者域名
port: 端口,http默认为80端口
path: 访问资源的路径,就是各种web 框架中定义的route路由
query: 查询字符串,为发送给服务器的参数,在这里更多发送数据分页、排序等参数。
fragment: 锚点,定位到页面的资源

我们在设计API时URL的path是需要认真考虑的,而RESTful对path的设计做了一些规范,通常一个RESTful API的path组成如下:

/{version}/{resources}/{resource_id}

version:API版本号,有些版本号放置在头信息中也可以,通过控制版本号有利于应用迭代。
resources:资源,RESTful API推荐用小写英文单词的复数形式。
resource_id:资源的id,访问或操作该资源。

当然,有时候可能资源级别较大,其下还可细分很多子资源也可以灵活设计URL的path,例如:

/{version}/{resources}/{resource_id}/{subresources}/{subresource_id}

此外,有时可能增删改查无法满足业务要求,可以在URL末尾加上action,例如

/{version}/{resources}/{resource_id}/action

其中action就是对资源的操作。

从大体样式了解URL路径组成之后,对于RESTful API的URL具体设计的规范如下:

  1. 不用大写字母,所有单词使用英文且小写。
  2. 连字符用中杠"-"而不用下杠"_"
  3. 正确使用 "/" 表示层级关系,URL的层级不要过深,并且越靠前的层级应该相对越稳定
  4. 结尾不要包含正斜杠分隔符"/"
  5. URL中不出现动词,用请求方式表示动作
  6. 资源表示用复数不要用单数
  7. 不要使用文件扩展名

HTTP动词

在RESTful API中,不同的HTTP请求方法有各自的含义,这里就展示GET,POST,PUT,DELETE几种请求API的设计与含义分析。针对不同操作,具体的含义如下:

GET /collection:从服务器查询资源的列表(数组)
GET /collection/resource:从服务器查询单个资源
POST /collection:在服务器创建新的资源
PUT /collection/resource:更新服务器资源
DELETE /collection/resource:从服务器删除资源

在非RESTful风格的API中,我们通常使用GET请求和POST请求完成增删改查以及其他操作,查询和删除一般使用GET方式请求,更新和插入一般使用POST请求。从请求方式上无法知道API具体是干嘛的,所有在URL上都会有操作的动词来表示API进行的动作,例如:query,add,update,delete等等。

而RESTful风格的API则要求在URL上都以名词的方式出现,从几种请求方式上就可以看出想要进行的操作,这点与非RESTful风格的API形成鲜明对比。

在谈及GET,POST,PUT,DELETE的时候,就必须提一下接口的安全性和幂等性,其中安全性是指方法不会修改资源状态,即读的为安全的,写的操作为非安全的。而幂等性的意思是操作一次和操作多次的最终效果相同,客户端重复调用也只返回同一个结果。

上述四个HTTP请求方法的安全性和幂等性如下:

HTTP Method安全性幂等性解释
GET安全幂等读操作安全,查询一次多次结果一致
POST非安全非幂等写操作非安全,每多插入一次都会出现新结果
PUT非安全幂等写操作非安全,一次和多次更新结果一致
DELETE非安全幂等写操作非安全,一次和多次删除结果一致

状态码和返回数据

服务端处理完成后客户端也可能不知道具体成功了还是失败了,服务器响应时,包含状态码返回数据两个部分。

状态码

我们首先要正确使用各类状态码来表示该请求的处理执行结果。状态码主要分为五大类:

1xx:相关信息
2xx:操作成功
3xx:重定向
4xx:客户端错误
5xx:服务器错误

每一大类有若干小类,状态码的种类比较多,而主要常用状态码罗列在下面:

200 OK - [GET]:服务器成功返回用户请求的数据,该操作是幂等的(Idempotent)。
201 CREATED - [POST/PUT/PATCH]:用户新建或修改数据成功。
202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务)
204 NO CONTENT - [DELETE]:用户删除数据成功。
400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的。
401 Unauthorized - [*]:表示用户没有权限(令牌、用户名、密码错误)。
403 Forbidden - [*] 表示用户得到授权(与401错误相对),但是访问是被禁止的。
404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的。
422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。

返回结果

针对不同操作,服务器向用户返回数据,而各个团队或公司封装的返回实体类也不同,但都返回JSON格式数据给客户端。

第三关 一个RESTful API案例

上面讲了RESTful理论知识,下面动手实现一个小案例吧!

预备

在本案例的实战中,我们访问的RESTful接口都是对数据库真实的操作,新建数据库,创建一个数据库和表(根据自己喜好)。

选择Maven依赖的时候,只需要勾选其中Spring的Web模块、MySQL驱动以及MyBatis框架。

本案例的POJO创建Dog.java实体对象,其具体构造为:

package com.restfuldemo.pojo;

public class Dog {
    private int id;//唯一id标识
    private String name;//名称
    private  int age;//年龄
    //省略get set
}

上面创建好了项目,我们就开始构建RESTful风格的API。在具体构建RESTful API的时候,需要对各种请求有更细致的认知,当然,本案例在实现各种请求的时候为了演示的便捷并没有完全遵循RESTful API规范,例如版本号等信息这里就不添加了,案例更侧重于使用SpringBoot实现这个接口。

本案例实现对dog资源的增删改查,如下是非RESTful 和RESTful接口对比:

API name非 RESTfulRESTful
获取dog/dogs/query/{dogid}GET: /dogs/{dogid}
插入dog/dogs/addPOST: /dogs
更新dog/dogs/update/{dogid}PUT:/dogs/{dogid}
删除dog/dods/delete/{dogid} DELETE:/dogs/{dogid}

另外在使用postman进行发送请求的时候,有三种常用的文件类型传递到后端:

在这里插入图片描述
form-data : 就是form表单中的multipart/form-data,会将表单数据处理为一条信息,用特定标签符将一条条信息分割开,而这个文件类型通常用来上传二进制文件。

x-www-form-urlencoded:就是application/x-www-form-urlencoded,是form表单默认的encType,form表单会将表单内的数据转换为键值对,这种格式不能上传文件。

raw:可以上传任意格式的文本,可以上传Text,JSON,XML等,但目前大部分还是上传JSON格式数据。当后端需要接收JSON格式数据处理的时候,可以采用这种格式来测试。

因为GET请求查询参数在URL上,其他类型请求使用x-www-form-urlencoded方式向后端传值。

GET POST PUT DELETE请求

GET请求用来获取资源:GET请求会向数据库发索取数据的请求,从而来获取资源,该请求就像数据库的select操作一样,只是用来查询数据,不会影响资源的内容。无论进行多少次操作,结果都是一样的。

并且GET请求会把请求的参数附加在URL后面,但是不同的浏览器对其有不同的大小长度限制。

在本案例中,我们设计两个GET请求的API。
GET /dogs :用来返回dog资源的列表。
GET /dogs/{dogid} :用来查询此id的单个dog资源。

POST请求用来新增一个资源 : POST请求向服务器发送数据,但是该请求会改变数据的内容(新添),就像数据库的insert操作一样,会创建新的内容。且POST请求的请求参数都是请求体中,其大小是没有限制的。

在本案例中,我们设计以下POST请求的API。
POST /dogs :服务端新增一个dog资源。

PUT请求用来更新资源,PUT请求是向服务器端发送数据的, 与POST请求不同的是,PUT请求侧重于数据的修改 ,就像数据库中update一样,而POST请求侧重于数据的增加。

在本案例中,我们设计以下POST请求的API。
PUT /dogs/{dogid} :用来更新此id的单个dog资源。

DELETE 请求用来删除资源,DELETE请求用途和它字面意思一致,用来删除资源。和数据库中delete相对应。

在本案例中,我们设计以下DELETE请求的API。
DELETE /dogs/{dogid} :用来删除此id的单个dog资源。

对应的Mapper文件为:

package com.restfuldemo.mapper;

import com.restfuldemo.pojo.Dog;
import org.apache.ibatis.annotations.*;
import java.util.List;

@Mapper
public interface DogMapper {

    @Select("select * from dog")
    List<Dog> getAllDog();

    @Select("select * from dog where id=#{id}")
    Dog getDogById(@Param("id") int id);

    @Insert("insert into dog (name,age) values (#{name},#{age})")
    boolean addDog(Dog dog);

    @Update("update dog set name=#{name},age=#{age} where id=#{id}")
    boolean updateDog(Dog dog);

    @Delete("delete  from dog where id=#{id}")
    boolean deleteDogById(int id);
}

对应controller文件为:

package com.restfuldemo.controller;

import com.restfuldemo.mapper.DogMapper;
import com.restfuldemo.pojo.Dog;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Arrays;
import java.util.List;

@RestController
public class TestController {

    @Autowired(required = false)
    DogMapper dogMapper;

    @GetMapping("dogs")
    public List<Dog> getDogs()
    {
        return  dogMapper.getAllDog();
    }

    @GetMapping("dogs/{id}")
    public Dog getDogById(@PathVariable("id") int id)
    {
        Dog dog=dogMapper.getDogById(id);
        return  dog;
    }
    @PostMapping("dogs")
    public boolean addDog(Dog dog)
    {
        return dogMapper.addDog(dog);
    }
    @PutMapping("dogs/{id}")
    public boolean updateDog(@PathVariable("id")int id,@RequestParam("name")String name,@RequestParam("age")int age)
    {

        Dog dog=dogMapper.getDogById(id);
        dog.setName(name);
        dog.setAge(age);
        return  dogMapper.updateDog(dog);
    }

    @DeleteMapping("dogs/{id}")
    public boolean deleteDog(@PathVariable("id") int id)
    {
        return  dogMapper.deleteDogById(id);
    }
}

经过笔者测试一切都是ok的,如果要项目源文件请联系笔者发你哈!

总结

RESTful风格的API 固然很好很规范,但大多数互联网公司并没有按照或者完全按照其规则来设计,因为REST是一种风格,而不是一种约束或规则,过于理想的RESTful API 会付出太多的成本。

比如RESTful API也有一些缺点

  • 比如操作方式繁琐,RESTful API通常根据GET、POST、PUT、DELETE 来区分操作资源的动作,而HTTP Method 本身不可直接见,是隐藏的,而如果将动作放到URL的path上反而清晰可见,更利于团队的理解和交流。
  • 并且有些浏览器对GET,POST之外的请求支持不太友好,还需要特殊额外的处理。
  • 过分强调资源,而实际业务API可能有各种需求比较复杂,单单使用资源的增删改查可能并不能有效满足使用需求,强行使用RESTful风格API只会增加开发难度和成本。

所以,当你或你们的技术团队在设计API的时候,如果使用场景和REST风格很匹配,那么你们可以采用RESTful 风格API。但是如果业务需求和RESTful风格API不太匹配或者很麻烦,那也可以不用RESTful风格API或者可以借鉴一下,毕竟无论那种风格的API都是为了方便团队开发、协商以及管理,不能墨守成规。
在这里插入图片描述
到这里RESTful API的介绍和实战就结束啦,本篇首先从RESTful的一些特点进行介绍,再到SpringBoot实战RESTful API,最后也说了一些RESTful API并不完美的地方,相信睿智的你对RESTful 一定有了很深刻的理解。在以后项目的API设计上定能有所优化。

不同的人对RESTful API可能有着不同的理解,但存在即合理,RESTful API有着其鲜明的优势和特点,目前也是一种API设计的主要选型之一,所以掌握和理解RESTful API还是相当重要的!
在这里插入图片描述

原创不易,bigsai我请你帮两件事帮忙一下:

  1. 点赞支持一下, 您的肯定是我在思否创作的源源动力。
  2. 微信搜索「bigsai」,关注我的公众号(新人求支持),会第一时间在公众号分享知识技术。还可一起打卡LeetCode。
查看原文

赞 37 收藏 20 评论 0

bigsai 赞了文章 · 2020-12-05

学生物的女朋友都能看懂的哈希表总结!

散列(哈希)表总结

之前给大家介绍了链表栈和队列今天我们来说一种新的数据结构散列(哈希)表,散列是应用非常广泛的数据结构,在我们的刷题过程中,散列表的出场率特别高。所以我们快来一起把散列表的内些事给整明白吧。文章框架如下

脑图

脑图

说散列表之前,我们先设想以下场景。

袁厨穿越回了古代,凭借从现代学习的做饭手艺,开了一个袁记菜馆,正值开业初期,店里生意十分火爆,但是顾客结账时就犯难了,每当结账的时候,老板娘总是按照菜单一个一个找价格(遍历查找),每次都要找半天,所以结账的地方总是排起长队,顾客们表示用户体验不咋滴。袁厨一想这不是办法啊,让顾客老是等着,太影响客户体验啦。所以袁厨就先把菜单按照首字母排序(二分查找),然后查找的时候根据首字母查找,这样结账的时候就能大大提高检索效率啦!但是呢?工作日顾客不多,老板娘完全应付的过来,但是每逢节假日,还是会排起长队。那么有没有什么更好的办法呢?对呀!我们把所有的价格都背下来不就可以了吗?每个菜的价格我们都了如指掌,结账的时候我们只需简单相加即可。所以袁厨和老板娘加班加点的进行背诵。下次再结账的时候一说吃了什么菜,我们立马就知道价格啦。自此以后收银台再也没有出现过长队啦,袁记菜馆开着开着一不小心就成了天下第一饭店了。

下面我们来看一下袁记菜馆老板娘进化史。

image-20201117132633797

image-20201117132633797

上面的后期结账的过程则模拟了我们的散列表查找,那么在计算机中是如何使用进行查找的呢?

散列表查找步骤

散列表-------最有用的基本数据结构之一。是根据关键码的值儿直接进行访问的数据结构,散列表的实现常常叫做散列(hasing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术,下面我们来看一下散列过程。

我们的整个散列过程主要分为两步

(1)通过散列函数计算记录的散列地址,并按此散列地址存储该记录。就好比麻辣鱼我们就让它在川菜区,糖醋鱼,我们就让它在鲁菜区。但是我们需要注意的是,无论什么记录我们都需要用同一个散列函数计算地址,再存储。

(2)当我们查找时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。因为我们存和取得时候用的都是一个散列函数,因此结果肯定相同。

刚才我们在散列过程中提到了散列函数,那么散列函数是什么呢?

我们假设某个函数为 f,使得

存储位置 = f (关键字)

输入:关键字输出:存储位置(散列地址)

那样我们就能通过查找关键字不需要比较就可获得需要的记录的存储位置。这种存储技术被称为散列技术。散列技术是在通过记录的存储位置和它的关键字之间建立一个确定的对应关系 f ,使得每个关键字 key 都对应一个存储位置 f(key)。见下图

这里的 f 就是我们所说的散列函数(哈希)函数。我们利用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间就是我们本文的主人公------散列(哈希)表

上图为我们描述了用散列函数将关键字映射到散列表,但是大家有没有考虑到这种情况,那就是将关键字映射到同一个槽中的情况,即 f(k4) = f(k3) 时。这种情况我们将其称之为冲突k3k4则被称之为散列函数 f同义词,如果产生这种情况,则会让我们查找错误。幸运的是我们能找到有效的方法解决冲突。

首先我们可以对哈希函数下手,我们可以精心设计哈希函数,让其尽可能少的产生冲突,所以我们创建哈希函数时应遵循以下规则

(1)必须是一致的,假设你输入辣子鸡丁时得到的是在看,那么每次输入辣子鸡丁时,得到的也必须为在看。如果不是这样,散列表将毫无用处。

(2)计算简单,假设我们设计了一个算法,可以保证所有关键字都不会冲突,但是这个算法计算复杂,会耗费很多时间,这样的话就大大降低了查找效率,反而得不偿失。所以咱们散列函数的计算时间不应该超过其他查找技术与关键字的比较时间,不然的话我们干嘛不使用其他查找技术呢?

(3)散列地址分布均匀我们刚才说了冲突的带来的问题,所以我们最好的办法就是让散列地址尽量均匀分布在存储空间中,这样即保证空间的有效利用,又减少了处理冲突而消耗的时间。

现在我们已经对散列表,散列函数等知识有所了解啦,那么我们来看几种常用的散列函数构造规则。这些方法的共同点为都是将原来的数字按某种规律变成了另一个数字。

散列函数构造方法

直接定址法

如果我们对盈利为0-9的菜品设计哈希表,我们则直接可以根据作为地址,则 f(key) = key;

即下面这种情况。

有没有感觉上面的图很熟悉,没错我们经常用的数组其实就是一张哈希表,关键码就是数组的索引下标,然后我们通过下标直接访问数组中的元素。

另外我们假设每道菜的成本为50块,那我们还可以根据盈利+成本来作为地址,那么则 f(key) = key + 50。也就是说我们可以根据线性函数值作为散列地址。

f(key) = a * key + ba,b均为常数

优点:简单、均匀、无冲突。

应用场景:需要事先知道关键字的分布情况,适合查找表较小且连续的情况

数字分析法

该方法也是十分简单的方法,就是分析我们的关键字,取其中一段,或对其位移,叠加,用作地址。比如我们的学号,前 6 位都是一样的,但是后面 3 位都不相同,我们则可以用学号作为键,后面的 3 位做为我们的散列地址。如果我们这样还是容易产生冲突,则可以对抽取数字再进行处理。我们的目的只有一个,提供一个散列函数将关键字合理的分配到散列表的各位置。这里我们提到了一种新的方式,抽取,这也是在散列函数中经常用到的手段。

image-20201117161754010

image-20201117161754010

优点:简单、均匀、适用于关键字位数较大的情况

应用场景:关键字位数较大,知道关键字分布情况且关键字的若干位较均匀

折叠法

其实这个方法也很简单,也是处理我们的关键字然后用作我们的散列地址,主要思路是将关键字从左到右分割成位数相等的几部分,然后叠加求和,并按散列表表长,取后几位作为散列地址。

比如我们的关键字是123456789,则我们分为三部分 123 ,456 ,789 然后将其相加得 1368 然后我们再取其后三位 368 作为我们的散列地址。

优点:事先不需要知道关键字情况

应用场景:适合关键字位数较多的情况

除法散列法

在用来设计散列函数的除法散列法中,通过取 key 除以 p 的余数,将关键字映射到 p 个槽中的某一个上,对于散列表长度为 m 的散列函数公式为

f(k) = k mod p (p <= m)

例如,如果散列表长度为 12,即 m = 12 ,我们的参数 p 也设为12,那 k = 100时 f(k) = 100 % 12 = 4

由于只需要做一次除法操作,所以除法散列法是非常快的。

由上面的公式可以看出,该方法的重点在于 p 的取值,如果 p 值选的不好,就可能会容易产生同义词。见下面这种情况。我们哈希表长度为6,我们选择6为p值,则有可能产生这种情况,所有关键字都得到了0这个地址数。 image-20201117191635083

那我们在选用除法散列法时选取 p 值时应该遵循怎样的规则呢?

  • m 不应为 2 的幂,因为如果 m = 2^p ,则 f(k) 就是 k 的 p 个最低位数字。例 12 % 8 = 4 ,12的二进制表示位1100,后三位为100。
  • 若散列表长为 m ,通常 p 为 小于或等于表长(最好接近m)的最小质数或不包含小于 20 质因子的合数。
合数:合数是指在大于1的整数中除了能被1和本身整除外,还能被其他数(0除外)整除的数。

质因子:质因子(或质因数)在数论里是指能整除给定正整数的质数。

这里的2,3,5为质因子

还是上面的例子,我们根据规则选择 5 为 p 值,我们再来看。这时我们发现只有 6 和 36 冲突,相对来说就好了很多。

优点:计算效率高,灵活

应用场景:不知道关键字分布情况

乘法散列法

构造散列函数的乘法散列法主要包含两个步骤

  • 用关键字 k 乘上常数 A(0 < A < 1),并提取 k A 的小数部分
  • 用 m 乘以这个值,再向下取整

散列函数为

f (k) = ⌊ m(kA mod 1) ⌋

这里的 kA mod 1 的含义是取 keyA 的小数部分,即 kA - ⌊kA⌋

优点:对 m 的选择不是特别关键,一般选择它为 2 的某个幂次(m = 2 ^ p ,p为某个整数)

应用场景:不知道关键字情况

平方取中法

这个方法就比较简单了,假设关键字是 321,那么他的平方就是 103041,再抽取中间的 3 位就是 030 或 304 用作散列地址。再比如关键字是 1234 那么它的平方就是 1522756 ,抽取中间 3 位就是 227 用作散列地址.

优点:灵活,适用范围广泛

适用场景:不知道关键字分布,而位数又不是很大的情况。

随机数法

故名思意,取关键字的随机函数值为它的散列地址。也就是 f(key) = random(key)。这里的random是 随机函数。

优点:易实现

适用场景:关键字的长度不等时

上面我们的例子都是通过数字进行举例,那么如果是字符串可不可以作为键呢?当然也是可以的,各种各样的符号我们都可以转换成某种数字来对待,比如我们经常接触的ASCII 码,所以是同样适用的。

以上就是常用的散列函数构造方法,其实他们的中心思想是一致的,将关键字经过加工处理之后变成另外一个数字,而这个数字就是我们的存储位置,是不是有一种间谍传递情报的感觉。

一个好的哈希函数可以帮助我们尽可能少的产生冲突,但是也不能完全避免产生冲突,那么遇到冲突时应该怎么做呢?下面给大家带来几种常用的处理散列冲突的方法。

处理散列冲突的方法

我们在使用 hash 函数之后发现关键字 key1 不等于 key2 ,但是 f(key1) = f(key2),即有冲突,那么该怎么办呢?不急我们慢慢往下看。

开放地址法

了解开放地址法之前我们先设想以下场景。

袁记菜馆内,铃铃铃,铃铃铃 电话铃响了

大鹏:老袁,给我订个包间,我今天要去带几个客户去你那谈生意。

袁厨:大鹏啊,你常用的那个包间被人订走啦。

大鹏:老袁你这不仗义呀,咋没给我留住呀,那你给我找个空房间吧。

袁厨:好滴老哥

哦,穿越回古代就没有电话啦,那看来穿越的时候得带着几个手机了。

上面的场景其实就是一种处理冲突的方法-----开放地址法

开放地址法就是一旦发生冲突,就去寻找下一个空的散列地址,只要列表足够大,空的散列地址总能找到,并将记录存入,为了使用开放寻址法插入一个元素,需要连续地检查散列表,或称为探查,我们常用的有线性探测,二次探测,随机探测

线性探测法

下面我们先来看一下线性探测,公式:

f,(key) = ( f(key) + di ) MOD m(di = 1,2,3,4,5,6....m-1)

我们来看一个例子,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,21},表长为12,我们再用散列函数 f(key) = key mod 12。

我们求出每个 key 的 f(key)见下表

我们查看上表发现,前五位的 f(key) 都不相同,即没有冲突,可以直接存入,但是到了第六位 f(37) = f(25) = 1,那我们就需要利用上面的公式 f(37) = f (f(37) + 1 ) mod 12 = 2,这其实就是我们的订包间的做法。下面我们看一下将上面的所有数存入哈希表是什么情况吧。

我们把这种解决冲突的开放地址法称为线性探测法。下面我们通过视频来模拟一下线性探测法的存储过程。

另外我们在解决冲突的时候,会遇到 48 和 37 虽然不是同义词,却争夺一个地址的情况,我们称其为堆积。因为堆积使得我们需要不断的处理冲突,插入和查找效率都会大大降低。

通过上面的视频我们应该了解了线性探测的执行过程了,那么我们考虑一下这种情况,若是我们的最后一位不为21,为 34 时会有什么事情发生呢?

此时他第一次会落在下标为 10 的位置,那么如果继续使用线性探测的话,则需要通过不断取余后得到结果,数据量小还好,要是很大的话那也太慢了吧,但是明明他的前面就有一个空房间呀,如果向前移动只需移动一次即可。不要着急,前辈们已经帮我们想好了解决方法

二次探测法

其实理解了我们的上个例子之后,这个一下就能整明白了,根本不用费脑子,这个方法就是更改了一下di的取值

线性探测: f,(key) = ( f(key) + di ) MOD m(di = 1,2,3,4,5,6....m-1)

二次探测:f,(key) = ( f(key) + di ) MOD m(di =1^2 , -1^2 , 2^2 , -2^2 .... q^2, -q^2, q<=m/2)

注:这里的是 -1^2 为负值 而不是 (-1)^2

所以对于我们的34来说,当di = -1时,就可以找到空位置了。

二次探测法的目的就是为了不让关键字聚集在某一块区域。另外还有一种有趣的方法,位移量采用随机函数计算得到,接着往下看吧.

随机探测法

大家看到这是不又有新问题了,刚才我们在散列函数构造规则的第一条中说

(1)必须是一致的,假设你输入辣子鸡丁时得到的是在看,那么每次输入辣子鸡丁时,得到的也必须为在看。如果不是这样,散列表将毫无用处。

咦?怎么又是在看哈哈,那么问题来了,我们使用随机数作为他的偏移量,那么我们查找的时候岂不是查不到了?因为我们 di 是随机生成的呀,这里的随机其实是伪随机数,伪随机数含义为,我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子它每次得到的数列是相同的,那么相同的 di 就能得到相同的散列地址

随机种子(Random Seed)是计算机专业术语,一种以随机数作为对象的以真随机数(种子)为初始条件的随机数。一般计算机的随机数都是伪随机数,以一个真随机数(种子)作为初始条件,然后用一定的算法不停迭代产生随机数

通过上面的测试是不是一下就秒懂啦,为什么我们可以使用随机数作为它的偏移量,理解那句,相同的随机种子,他每次得到的数列是相同的。

下面我们再来看一下其他的函数处理散列冲突的方法

再哈希法

这个方法其实也特别简单,利用不同的哈希函数再求得一个哈希地址,直到不出现冲突为止。

f,(key) = RH,( key ) (i = 1,2,3,4.....k)

这里的RH,就是不同的散列函数,你可以把我们之前说过的那些散列函数都用上,每当发生冲突时就换一个散列函数,相信总有一个能够解决冲突的。这种方法能使关键字不产生聚集,但是代价就是增加了计算时间。是不是很简单啊。

链地址法

下面我们再设想以下情景。

袁记菜馆内,铃铃铃,铃铃铃电话铃又响了,那个大鹏又来订房间了。

大鹏:老袁啊,我一会去你那吃个饭,还是上回那个包间

袁厨:大鹏你下回能不能早点说啊,又没人订走了,这回是老王订的

大鹏:老王这个老东西啊,反正也是熟人,你再给我整个桌子,我拼在他后面吧

不好意思啊各位同学,信鸽最近太贵了还没来得及买。上面的情景就是模拟我们的新的处理冲突的方法链地址法。

上面我们都是遇到冲突之后,就换地方。那么我们有没有不换地方的办法呢?那就是我们现在说的链地址法。

还记得我们说过得同义词吗?就是 key 不同 f(key) 相同的情况,我们将这些同义词存储在一个单链表中,这种表叫做同义词子表,散列表中只存储同义词子表的头指针。我们还是用刚才的例子,关键字集合为{12,67,56,16,25,37,22,29,15,47,48,21},表长为12,我们再用散列函数 f(key) = key mod 12。我们用了链地址法之后就再也不存在冲突了,无论有多少冲突,我们只需在同义词子表中添加结点即可。下面我们看下链地址法的存储情况。

链地址法虽然能够不产生冲突,但是也带来了查找时需要遍历单链表的性能消耗,有得必有失嘛。

公共溢出区法

下面我们再来看一种新的方法,这回大鹏又要来吃饭了。

袁记菜馆内.....

袁厨:呦,这是什么风把你给刮来了,咋没开你的大奔啊。

大鹏:哎呀妈呀,别那么多废话了,我快饿死了,你快给我找个位置,我要吃点饭。

袁厨:你来的,太不巧了,咱们的店已经满了,你先去旁边的小屋看会电视,等有空了我再叫你。小屋里面还有几个和你一样来晚的,你们一起看吧。

大鹏:电视?看电视?

上面得情景就是模拟我们的公共溢出区法,这也是很好理解的,你不是冲突吗?那冲突的各位我先给你安排个地方呆着,这样你就有地方住了。我们为所有冲突的关键字建立了一个公共的溢出区来存放。 溢出区法

那么我们怎么进行查找呢?我们首先通过散列函数计算出散列地址后,先于基本表对比,如果不相等再到溢出表去顺序查找。这种解决冲突的方法,对于冲突很少的情况性能还是非常高的。

散列表查找算法(线性探测法)

下面我们来看一下散列表查找算法的实现

首先需要定义散列列表的结构以及一些相关常数,其中elem代表散列表数据存储数组,count代表的是当前插入元素个数,size代表哈希表容量,NULLKEY散列表初始值,然后我们如果查找成功就返回索引,如果不存在该元素就返回元素不存在。

我们将哈希表初始化,为数组元素赋初值。

插入操作的具体步骤:

(1)通过哈希函数(除法散列法),将 key 转化为数组下标

(2)如果该下标中没有元素,则插入,否则说明有冲突,则利用线性探测法处理冲突。详细步骤见注释

查找操作的具体步骤:

(1)通过哈希函数(同插入时一样),将 key 转成数组下标

(2)通过数组下标找到 key值,如果 key 一致,则查找成功,否则利用线性探测法继续查找。

下面我们来看一下完整代码

散列表性能分析

如果没有冲突的话,散列查找是我们查找中效率最高的,时间复杂度为O(1),但是没有冲突的情况是一种理想情况,那么散列查找的平均查找长度取决于哪些方面呢?

1.散列函数是否均匀

我们在上文说到,可以通过设计散列函数减少冲突,但是由于不同的散列函数对一组关键字产生冲突可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。

2.处理冲突的方法

相同关键字,相同散列函数,不同处理冲突方式,会使平均查找长度不同,比如我们线性探测有时会堆积,则不如二次探测法好,因为链地址法处理冲突时不会产生任何堆积,因而具有最佳的平均查找性能

3.散列表的装填因子

本来想在上文中提到装填因子的,但是后来发现即使没有说明也不影响我们对哈希表的理解,下面我们来看一下装填因子的总结

装填因子 α = 填入表中的记录数 / 散列表长度

散列因子则代表着散列表的装满程度,表中记录越多,α就越大,产生冲突的概率就越大。我们上面提到的例子中 表的长度为12,填入记录数为6,那么此时的 α = 6 / 12 = 0.5 所以说当我们的 α 比较大时再填入元素那么产生冲突的可能性就非常大了。所以说散列表的平均查找长度取决于装填因子,而不是取决于记录数。所以说我们需要做的就是选择一个合适的装填因子以便将平均查找长度限定在一个范围之内。


各位如果能感觉到这个文章写的很用心的话,能给您带来一丢丢帮助的话,能麻烦您给这个文章点个赞吗?这样我就巨有动力写下去啦。

另外大家如果需要其他精选算法题的动图解析,大家可以微信关注下 【袁厨的算法小屋】,我是袁厨一个酷爱做饭所以自己考取了厨师证的菜鸡程序员,会一直用心写下去的,感谢支持!
image

查看原文

赞 2 收藏 0 评论 7

bigsai 赞了文章 · 2020-12-04

坚持并活下去!cxuan 在思否的 2020 年终总结。

前段时间被 why 神开车带飞的时候,我才想起来,一年前的我和他有一段对话

image.png

没想到今年,却开启了 爆肝模式

写了 100 + 篇文章

在公众号的历程中,我喜欢使用大图 + 公众号原创篇数来记录一下自己究竟写了多少篇原创文章。详情可以翻阅一下这篇文章

cxuan 都能写 100 篇文章,你还有啥不能的

从刚开始写文章的磕磕绊绊,到现在能完整的撸出来一篇万字长文,也算是有了十足的进步。现在回过头来看一下当年的文章,有点想把他们都删了的冲动 ...

image.png

这就是文章刚开始的样子了,是的你没看错,我一篇文章到现在已经有一年半的时间了。刚开始的文章,没有排版,没有条理逻辑,仿佛不是给人看的,完全是在记笔记。

到现在,每篇文章都会认认真真画图。

但是这种方式并没有什么错,如果你选择了这种方式,就不要想着自己的文章为什么没人看,这就和上学时你的笔记是一样的,同学是不会看的,而且为什么要看你的笔记?有这个时间看一些官方的文章不是更值吗?

但是当时不懂,我第一开始的想法是通过记笔记的方式能让我的技术有一些进步,不甘于日常循环反复的 CRUD。事实上我也确实是这样做的,所以才有了后来的 100+ 篇原创技术文章。

这个目标一定要找好,如果你做公众号纯碎是想挣钱的话,那么建议不要走原创博主这条路线,否则你挣的都是辛苦钱

为什么我说的是辛苦钱?可能大家看到了自媒体接广告多么多么挣钱,但是这背后熬了多少次夜,起了多少次早,只有自己知道。

比如现在是早上 4.27 ,我又坐在电脑前淦文章了。

image.png

一些圈内的小伙伴给我起了很多昵称,比如刘肝?肝帝?我都笑纳了,不过我从来没这么认为,我只是想着能通过技术分享让大家学到点东西,同时实现自己的价值,否则,人生过得太无趣了。

你问我想实现的价值是什么?

我之前看到过有一个大佬写了一本从 Java 基础到主流框架的思想、源码、面试题分享,涵盖面比较全,我就想着有朝一日,我也要写出来这种 PDF,从计算机四大课程作为入手点,延伸到 Java 和其主流框架,中间夹杂着一些 C 语言知识、汇编知识等,这些都搞完了可能就是 bestJavaer 了。

这就是我的 Github,https://github.com/crisxuan/b... 目前还没有什么名气。

100 篇很少很少,但是每年 100 篇很多很多,我希望这份信心和坚持不是三分钟热度和一腔热血。

一些我觉得自己画的比较好的图片

image.png

image.png

image.png

要坚持下去。

技术的提升

写技术分享带来最大的一个好处就是技术的提升,有的时候确实是 倒逼 自己输出。如果一周没有写出来一篇文章,整个人会非常难受,所以有的时候为了这个目标,不得不熬夜或者早起。倒逼自己输出的好处就是,你能够系统的学习一些知识,让自己更快的成长。在做技术博主之前,我从来没有想过还能学习到操作系统这个层次,更不要说系统性的学习,但是今年这半年以来,真的是快要把 《现代操作系统》学完了。

但是学完了和学会了不一样,学完了只能你的视野更加开阔,和其他人有的聊,能够定位问题所出现的原因、涉及的相关知识点,从而有效的查资料从而解决排查和解决问题。

学会了就是另一个纬度 的事情了,学完了和学会了之前差着时间呢!我能在半年内学完一本硬核书,但是肯定做不到在半年时间内学会一本硬核书。

一个技术点,你能否站在现在的角度把它写深或者写全或者写的通俗易懂?一篇文章,你能否把它的相关知识写满写全?这些每个原创博主需要考虑的事情。你想给读者带来什么,这就是你的 IP

认识了非常多的小伙伴

自从做起来技术博主之后,真的太多小伙伴加我微信联系了,真的非常欣慰和感谢,感谢你们的认可和支持,这将会是我继续淦文的十足动力。

加我微信联系的有很多小伙伴,我可能无法一一回复(因为真的很忙),这里给大家说一声抱歉。再者,如果你可以像下面一样自我介绍一下就更好啦。

image.png

这可能是我看过最好的自我介绍了。

所以我非常希望能和每位小伙伴成为朋友,哎又要说但是了,真的是时间不允许。但是像是上面这样 用心 的自我介绍,我肯定会记得你的,因为上面这个聊天记录,也是我翻了好久的。

我一直认为我这个人挺 low 的,但是自从认识了这么多优秀的小伙伴们,我发现我简直 low 到爆了。有太多优秀的人才了。

有高一就开始学 C 的、有北大研究生、有 985 大学教授、有北美博士生,还有虽然学历不高,但是对技术拥有无限热枕的大专生,很幸运能够认识你们。

除了读者之外,还有很多优秀的同行。

获得一些小成就

思否 Top Writer ,年度最佳写作者,这个含金量很重。思否还给我寄了很多卫衣奖励,我至今还在穿。

image.png

博客园 2 年我收获了 450 + 个粉丝,我知道博客园的粉丝都比较优质,一般都是工作几年的大佬,所以这个数字,让我充满了成就感。希望有朝一日能够获得博客园的 推荐博客 荣誉,也希望博客园可以多给我的文章进行推荐啦。

image.png

掘金一年我就升到了 LV5 的等级,倔力值为

image.png

感觉明年升到 LV6 ,成为 掘金贡献者 指日可待呀。

这个是头条青云计划的奖励,赏金 1000 元,也是我见过奖励最大的平台。果然宇宙条舍得让创作者们恰饭。

image.png

我的一篇文章 《Java核心基础知识总结》 获得了 Infoq 的官方推荐还有池老板的推荐,让我倍感荣幸,同时 infoq 也给我寄了很多非常棒的奖励

image.png

image.png

CSDN 无疑是对我帮助最大的社区了,自从认真写博客以来,红月和刘成还有各位 CSDN 小编们都对我帮助很大,给予我写作的信心,也让我收获了 博客专家原力计划王者 的荣誉称号。

image.png

也有幸收到了 CSDN 定制的月饼,这个月饼非常赞

image.png

同时也感谢异步社区的认可,我荣获了 2020 年度最佳合作伙伴的称号

继续加油。

写了六本 PDF

这是我最值得炫耀的事情了,是的,我一年的时间把公众号的文章汇总成了六本 PDF,它们分别是

  • 《Java 核心技术总结》
  • 《HTTP 核心总结》
  • 《程序员必知的基础知识》
  • 《操作系统核心总结》
  • 《Java V2.0》
  • 《面试题总结》

这些 PDF 你可以在我的公众号 Java建设者 回复 cxuan 获取。

image.png

这些 PDF 并不是完整版,而且也会产生笔误,希望小伙伴们可以提出来,下一版进行更新

image.png

我会慢慢更新,欢迎读者朋友们多多关注我的公众号获取最新的更新信息。

当然,我的 Github 也收录了这些文章

我的 github

其中我的操作系统 PDF 也被国内某大学作为学习的参考资料了。

image.png

每日一题群

这同样是 2020 年我觉得做的非常有意义的一件事情,每天都会抛出来一个问题让大家探讨,目前还在更新,每日一题会包括 Java 方向、计算机基础方向、MySQL 方向、框架方向的一些面试题,目前已坚持四个月了(中间断隔了一段时间)。刚开始大家的热枕非常高,但是渐渐的回答的人慢慢少了下去。这也难怪,毕竟不是每个人都有时间每天回答问题,不过我希望这些面试题能够给你方向,因为很多小伙伴们都反映面试官出的题就是我群里发出来的题了

image.png

当然我欢迎你的加入,你可以在 Java建设者 公众号回复 交流 备注 每日一题 我拉你进群。

囤书

我个人有很强烈的囤书癖好,目前已经囤了各大出版社的经典书籍了。

原谅我还在租房,所以没弄书架,我估计搬家的时候能让我痛不欲生

你问我看没看完,当然没看完了,如果有生之年能够刷完这些书,那么这一辈子是不是就满足了呢?

好了,以上就是我这一年来主要做的事情了。其实每天都过的很有意义,也比较喜欢现在自己的这种状态,其实身居二线离家近做点自己的事情,也很香的不是吗?

展望 2021

《钢铁是怎样练成的》中有一句经典的话语

一个人的生命是应该这样度过的,当他回首往事的时候,不因虚度年华而悔恨,也不因碌碌无为而羞耻,这样他才能够说过好了这一生

2020 虽然经历了全球疫情、自然灾害、经济危机、战火纷争,内卷。。。。。。但却依然没有打到我们,在各种外部因素的摧残中,活下去,你就成功了。

所以 2021 年没有特别宏大的蓝图和愿景,只有六个字送给自己

坚持并活下去

勤勉、真诚、见贤思齐、不断学习才是自媒体或者说原创博主带给读者最大的财富。

查看原文

赞 3 收藏 0 评论 2

bigsai 发布了文章 · 2020-12-03

数据结构与算法—一文搞懂线性表(顺序表、链表)

原创公众号:bigsai 文章收藏在GitHub

前言

通过前面数据结构与算法前导我么知道了数据结构的一些概念和重要性,那么我们今天总结下线性表相关的内容。当然,我用自己的理解解分享给大家。

其实说实话,可能很多人依然分不清线性表,顺序表,和链表之间的区别和联系!

  • 线性表:逻辑结构, 就是对外暴露数据之间的关系,不关心底层如何实现。
  • 顺序表、链表:物理结构,他是实现一个结构实际物理地址上的结构。比如顺序表就是用数组实现。而链表用指针完成主要工作。不同的结构在不同的场景有不同的区别。

对于java来说,大家都知道List接口类型,这就是逻辑结构,因为他就是封装了一个线性关系的一系列方法和数据。而具体的实现其实就是跟物理结构相关的内容。比如顺序表的内容存储使用数组的,然后一个get,set,add方法都要基于数组来完成,而链表是基于指针的。当我们考虑对象中的数据关系就要考虑指针的属性。指针的指向和value。

下面用一个图来浅析线性表的关系。可能有些不太确切,但是其中可以参考,并且后面也会根据这个图举例。
在这里插入图片描述

线性表基本架构

对于一个线性表来说。不管它的具体实现方法如何,我们应该有的函数名称实现效果应该一致。你也可以感觉的到在一些结构的设计。比如List的ArraylistLinkedList。Map的HashMap和currentHashMap他们的接口api都是相同的,但是底层设计实现肯定是有区别的。

所以,基于面向对象的编程思维,我们可以将线性表写成一个接口,而具体实现的顺序表和链表可以继承这个接口的方法,提高程序的可读性。

还有一点比较重要的,记得初学数据结构与算法时候实现的线性表都是固定类型(int),随着知识的进步,我们应当采用泛型来实现更合理。至于接口的具体设计如下:

package LinerList;
public interface ListInterface<T> {    
    void Init(int initsize);//初始化表
    int length();
    boolean isEmpty();//是否为空
    int ElemIndex(T t);//找到编号
    T getElem(int index) throws Exception;//根据index获取数据
    void add(int index,T t) throws Exception;//根据index插入数据
    void delete(int index) throws Exception;
    void add(T t) throws Exception;//尾部插入
    void set(int index,T t) throws Exception;
    String toString();//转成String输出    
}

顺序表

顺序表是基于数组实现的,所以一些方法要基于数组的特性。对于顺序表应该有的基础属性为一个数组data和一个length.

还有需要注意的是初始化数组的大小,你可以固定大小,但是笔者为了可用性如果内存不够将扩大二倍。当然这样很可能因为空间使用问题造成很大的浪费

一些基本的额方法就不说了,下面着重讲解一些初学者容易混淆的概念和方法实现。这里把顺序表比作一队坐在板凳上的人。

插入

add(int index,T t)
其中index为插入的编号位置,t为插入的数据
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
-根据图片你就很好理解插入操作。当插入一个index时候,他的后面所有元素都要后移一位。你可以看的出插入时候整个操作的臃肿性。所以这也是顺序表性能表现最差的地方,频繁的插入,删除。

删除

同理,删除也是非常占用资源的。原理和插入类似,不过人走了,空一个小板凳后面的人需要往前挪
在这里插入图片描述

其他操作

其他操作就很简单了。比如如果按照编号获取数据getElem(int index),你可以直接根据数据坐标返回。a[index],而其他操作,可以通过遍历直接操作数组即可。

链表

我想,表应该是很多人感觉很绕的东西,这个很大原因可能因为指针。很多人说java没指针,其实java他也有隐形指针。只不过不能直接用罢了。

指针建立的数据关系往往比数组这些要抽象的多。对于指针域,你把他当成一个对象就好了,不过这个对象指向的是另一个同等级对象。对于这个关系,你可以比作每个person类。每个person类都有老公(老婆),而这个老公老婆也是一个实际对象,可以理解这更像一种逻辑约定关系,而不是硬生生的关系吧。在这里插入图片描述
指针你可以考虑成脑子记忆。上面的顺序表我们说它有序因为每个小板凳(数组)编号,我们可以根据这个来确定位置。而对于链表来说,你可以看作成一个站在操场上的一队人。而他的操作也略有不同,下面针对一些比较特殊和重要的进行归纳。

基本结构

对于线性表,我们只需要一个data数组和length就能表示基本信息。而对于链表,我们需要一个node(head头节点),和length,当然,这个node也是一个结构体。

class node<T>{
    T data;//节点的结果
    node next;//下一个连接的节点
    public node(){}
    public node(T data)
    {
        this.data=data;
    }
    public node(T data, node next) {
        this.data = data;
        this.next = next;
    } 
}

当然,这个节点有数据域指针域。数据域就是存放真实的数据,而指针域就是存放下一个node的指针。所以相比顺序表,如果用满数组情况下,链表占用更多的资源,因为它要存放指针占用资源。
在这里插入图片描述

插入

add(int index,T t)
其中index为插入的编号位置,t为插入的数据
加入插入一个节点node,根据index找到插入的前一个节点叫pre。那么操作流程为

  1. node.next=pre.next如下1的操作,将插入节点后面联系起来。此时node.next和pre.next一致。
  2. pre.next=node因为我们要插入node,而node链可以替代pre自身的next。那么直接将pre指向node。那么就相当于原始链表插入了一个node。

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

带头节点与不带头节点

在这里插入图片描述
很多人搞不清什么是带头节点不带头节点。带头节点就是head节点不放数据,第0项从head后面那个开始数。而不带头节点的链表head放数据,head节点就是第0位
主要区别

  • 带头节点和不带头节点的主要区别就在插入删除首位,尤其是首位插入。带头节点找元素需要多遍历一次因为它的第一个head节点是头节点,不存数据(可看作一列火车的火车头)。而方便的就是带头节点在首位插入更简单。因为插入第0位也是在head的后面
  • 而不带头节点的链表就需要特殊考虑首位。因为插入第0位其实是插入head的前面。假设有head,插入node。具体操作为:

在这里插入图片描述

  1. node.next=head;(node指向head,node这条链成我们想要的链)
  2. head=node;(很多人想不明白,其实这个时候node才是插入后最长链的首位节点,head在他的后面,而在链表中head通常表示首位节点,所以head不表示第二个节点,直接"="node节点。这样head和node都表示操作完成的链表。但是对外暴露的只有head。所以head只能指向第一个节点!)

插入尾

  • 而在插入尾部的时候,需要注意尾部的nextnull。不能和插入普通位置相比!

删除

在这里插入图片描述
按照index移除:delete(int index)

  • 找到该index的节点node。node.next=node.next.nex

按照尾部移除(拓展):deleteEnd()
这个方法我没有写,但是我给大家讲一下,按照尾部删除的思想就是:

  1. 声明一个node为head。
  2. node.next!=nullnode=node.next 指向下一个
  3. node.next==null时候。说明这个节点时最后一个。你可以node=null。这个这个node的前驱pre的next就是null。这个节点就被删除了。

头部删除(带头节点)

  • 带头节点的删除和普通删除一直。直接head.next(第1个元素)=head.next.next(第二个元素)
  • 这样head.next就直接指向第二个元素了。第一个就被删除了

头部删除(不带头节点)

  • 我们知道不带头节点的第一个就是存货真价实的元素的。不带头节点删除也很简单。直接将head移到第二位就行了。即:head=head.next

在这里插入图片描述

其他

  • 对于其他操作,主要时结合查找。而单链表的查找时从head开始。然后另一个节点team=headhead.next。然后用这个节点不停的等于它指向的next去查找我们需要的内容即while(循环条件){team=team.next}类似。
  • 不同教程和人写的线性表也不一致,这里只给出一个样例学习使用而并不是标准,希望大家审视。
  • 在实现上用了带头节点的链表实现,因为比较方便管理,不需要很多if else.

代码实现

顺序表

package LinerList;

public class seqlist<T> implements ListInterface<T> {
    private Object[] date;//数组存放数据
    private int lenth;
    public seqlist() {//初始大小默认为10
        Init(10);
    }

    public void Init(int initsize) {//初始化
        this.date=new Object[initsize];
        lenth=0;        
    }
    public int length() {        
        return this.lenth;
    }

    public boolean isEmpty() {//是否为空
        if(this.lenth==0)
            return true;
        return false;
    }

    /*
     * * @param t    
     * 返回相等结果,为-1为false
     */
    public int ElemIndex(T t) {
        // TODO Auto-generated method stub
        for(int i=0;i<date.length;i++)
        {
            if(date[i].equals(t))
            {
                return i;
            }
        }
        return -1;
    }

    /*
     *获得第几个元素
     */
    public T getElem(int index) throws Exception {
        // TODO Auto-generated method stub
        if(index<0||index>lenth-1)
            throw new Exception("数值越界");
        return (T) date[index];
    }
    
    public void add(T t) throws Exception {//尾部插入
         add(lenth,t);
    }

    /*
     *根据编号插入
     */
    public void add(int index, T t) throws Exception {
        if(index<0||index>lenth)
            throw new Exception("数值越界");
        if (lenth==date.length)//扩容
        {
            Object newdate[]= new Object[lenth*2];
            for(int i=0;i<lenth;i++)
            {
                newdate[i]=date[i];
            }
            date=newdate;
        }
        for(int i=lenth-1;i>=index;i--)//后面元素后移动
        {
            date[i+1]=date[i];
        }
        date[index]=t;//插入元素
        lenth++;//顺序表长度+1
        
    }

    public void delete(int index) throws Exception {
        if(index<0||index>lenth-1)
            throw new Exception("数值越界");
        for(int i=index;i<lenth;i++)//index之后元素前移动
        {
            date[i]=date[i+1];
        }
        lenth--;//长度-1    
    }

    @Override
    public void set(int index, T t) throws Exception {
        if(index<0||index>lenth-1)
            throw new Exception("数值越界");
        date[index]=t;
    }
    public String  toString() {
        String vaString="";
        for(int i=0;i<lenth;i++)
        {
            vaString+=date[i].toString()+" ";
        }
        return vaString;
        
    }
}

链表

package LinerList;

class node<T>{
    T data;//节点的结果
    node next;//下一个连接的节点
    public node(){}
    public node(T data)
    {
        this.data=data;
    }
    public node(T data, node next) {
        this.data = data;
        this.next = next;
    }
   
}
public class Linkedlist<T> implements ListInterface<T>{

    node head;
    private int length;
    public Linkedlist() {
        head=new node();
        length=0;
    }
    public void Init(int initsize) {
        head.next=null;
        
    }

    public int length() {
        return this.length;
    }

    
    public boolean isEmpty() {
        if(length==0)return true;
        else return false;
    }

    /*
     * 获取元素编号
     */
    public int ElemIndex(T t) {
        node team=head.next;
        int index=0;
        while(team.next!=null)
        {
            if(team.data.equals(t))
            {
                return index;
            }
            index++;
            team=team.next;
        }
        return -1;//如果找不到
    }

    @Override
    public T getElem(int index) throws Exception {
        node team=head.next;
        if(index<0||index>length-1)
        {
            throw new Exception("数值越界");
        }
        for(int i=0;i<index;i++)
        {
            team=team.next;
        }
        return (T) team.data;
    }


    public void add(T t) throws Exception {
        add(length,t);
        
    }
    //带头节点的插入,第一个和最后一个一样操作
    public void add(int index, T value) throws Exception {
        if(index<0||index>length)
        {
            throw new Exception("数值越界");
        }
        node<T> team=head;//team 找到当前位置node
        for(int i=0;i<index;i++)
        {
             team=team.next;
        }
        node<T>node =new node(value);//新建一个node
        node.next=team.next;//指向index前位置的下一个指针
        team.next=node;//自己变成index位置    
        length++;
    }
    

    @Override
    public void delete(int index) throws Exception {
        if(index<0||index>length-1)
        {
            throw new Exception("数值越界");
        }
        node<T> team=head;//team 找到当前位置node
        for(int i=0;i<index;i++)//标记team 前一个节点
        {
             team=team.next;
        }
        //team.next节点就是我们要删除的节点
        team.next=team.next.next;
        length--;
    }

    @Override
    public void set(int index, T t) throws Exception {
        // TODO Auto-generated method stub
        if(index<0||index>length-1)
        {
            throw new Exception("数值越界");
        }
        node<T> team=head;//team 找到当前位置node
        for(int i=0;i<index;i++)
        {
             team=team.next;
        }
        team.data=t;//将数值赋值,其他不变
        
    }

    public String toString() {
        String va="";
        node team=head.next;
        while(team!=null)
        {
            va+=team.data+" ";
            team=team.next;
        }
        return va;
    }

}

测试与结果

package LinerList;
public class test {
    public static void main(String[] args) throws Exception {
        // TODO Auto-generated method stub
        System.out.println("线性表测试:");
        ListInterface<Integer>list=new seqlist<Integer>();
        list.add(5);
        list.add(6);
        list.add(1,8);
        list.add(3,996);
        list.add(7);
        System.out.println(list.ElemIndex(8));
        System.out.println(list.toString());
        list.set(2, 222);
        System.out.println(list.toString());
        list.delete(4);
        System.out.println(list.toString());
        System.out.println(list.length());    
        
        System.out.println("链表测试:");
        list=new Linkedlist<Integer>();
        list.add(5);
        list.add(6);
        list.add(1,8);
        list.add(3,996);
        list.add(7);
        System.out.println(list.ElemIndex(8));
        System.out.println(list.toString());
        list.set(2, 222);
        System.out.println(list.toString());
        list.delete(4);
        System.out.println(list.toString());
        System.out.println(list.length());    
    }
}

输出:

线性表测试:
1
5 8 6 996 7
5 8 222 996 7
5 8 222 996
4
链表测试:
1
5 8 6 996 7
5 222 6 996 7
5 222 6 996
4

总结

这里的只是简单实现,实现基本方法。链表也只是单链表。完善程度还可以优化。如果有错误还请大佬指正

单链表查询速度较慢,因为他需要从头遍历。如果频繁操作尾部,可以考虑链表中不仅有head在加尾tail节点。而顺序表查询速度虽然快但是插入很费时费力。实际应用根据需求选择

java中的Arraylist和LinkedList就是两种方式的代表,不过LinkedList使用双向链表优化,并且jdk的api做了大量优化。所以大家不用造轮子,可以直接用,但是还是很有学习价值的。

如果有不理解或者不懂的可以联系交流讨论。

原创不易,bigsai请朋友们帮两件事帮忙一下:

  1. 点赞、关注支持一下, 您的肯定是我在思否创作的源源动力。
  2. 微信搜索「bigsai」,关注我的公众号,分享更多内容!

咱们下次再见!

查看原文

赞 3 收藏 1 评论 0

bigsai 发布了文章 · 2020-12-01

科普分享|原来这些图灵奖巨匠就藏在身边

前言

文章收录在github 欢迎回顾精彩

这是一个真实的故事,在笔者今年参加考研复试的时候,由于疫情原因是线上复试,但是一些流程还是没变的,机试+笔试完之后就是面试了。

然后就开始紧张的面试了,大家都知道面试在最开始就是英语部分,当老师说咱们开始英语口语,我把早已背的滚瓜烂熟的个人介绍藏在脑海中正准备一泄而出等待老师说"Please introduce yourself"的时候,事情突然发生反转,老师来了一句:"Do you know who won the Turing prize?". 我使劲皱着眉头假装自己很努力思考的样子(实际本来就不会嘛),说完还不忘用中文偷偷告诉我:图灵奖。我深沉的注视在场的面试官说了句:"Sorry, I don't know" (我只知道这个奖但我也不知道谁得过奖啊)。

img

老师说不要紧,再来一个:"Do you know who put forward the relational model of relational database?" ,我停顿半天皱着眉头假装没听清,老师直接说中文 "你知道关系数据库的关系模型谁提出的嘛?" 我眉头皱的更紧了,又说了句:"Sorry, I don't know" (这……)。

image

此时的我心情是觉得太倒霉了,咋问这个问题,不过还好后面老师问我加密算法有哪些,有一些爬虫的经验知道一些些加密算法我用英文踉踉跄跄的说了出来,并介绍了一些区别,还好后面回答的还行前面笔试机试也还行才很险的苟上岸。

最后老师说了一句关系模型的提出者也是图灵奖的获得者,我就很纳闷:"难道老师以为我知道谁提出关系模型但是不知道他拿过啥奖嘛!谁拿过图灵奖我不知道,谁提出关系模型我更不知道"!但我还是笑嘻嘻的和老师说道:"哈哈,这个触及到盲区了,回去了解一波" !不过具体了解没了解,你们都知道的。

什么是图灵奖

图灵奖(Turing Award),全称A.M. 图灵奖(A.M Turing Award),是由美国计算机协会(ACM)于1966年设立的计算机奖项,名称取自艾伦·麦席森·图灵(Alan M. Turing),旨在奖励对计算机事业作出重要贡献的个人 。图灵奖对获奖条件要求极高,评奖程序极严,一般每年仅授予一名计算机科学家。图灵奖是计算机领域的国际最高奖项,被誉为 "计算机界的诺贝尔奖"

图灵奖一般在每年3月下旬颁发。从1966年至2019年,图灵奖共授予72名获奖者,以美国、欧洲科学家为主。据统计,截至2020年3月,世界各高校的图灵奖获奖人数依次为美国斯坦福大学(28位)、美国麻省理工学院(26位)、美国加州大学伯克利分校(25位)、美国哈佛大学(14位)和美国普林斯顿大学(14位)。

2000年,华人科学家姚期智(生于上海)获图灵奖,是华人第一次也是唯一一次获得图灵奖。

Codd博士与关系模型

当然短期内没了解谁拿过图灵奖(复试完该玩的玩、搞毕设的搞毕设、开黑的开黑),但是这毕竟是一道曾经的坎,过了比较久的时间还是不甘心,打开了百度搜索 关系数据库 关系模型 关键字找到了答案:

image-20201123232045317

也从中找到了答案,顺便大家也科普一下:

1970年,IBM的研究员E.F.Codd博士发表《大型共享数据银行的关系模型》一文提出了关系模型的概念,论述了范式理论和衡量关系系统的12条标准,如定义了某些关系代数运算,研究了数据的函数相关,定义了关系的第三范式,从而开创了数据库的关系方法和数据规范化理论的研究,他为此获得了1981年的图灵奖。

后来Codd又陆续发表多篇文章,奠定了关系数据库的基础。关系模型有严格的数学基础,抽象级别比较高,而且简单清晰,便于理解和使用。但是当时也有人认为关系模型是理想化的数据模型,用来实现DBMS是不现实的,尤其担心关系数据库的性能难以接受,更有人视其为当时正在进行中的网状数据库规范化工作的严重威胁。为了促进对问题的理解,1974年ACM牵头组织了一次研讨会,会上开展了一场分别以Codd和Bachman为首的支持和反对关系数据库两派之间的辩论。这次著名的辩论推动了关系数据库的发展,使其最终成为现代数据库产品的主流。

教你们一招:以后面试官问你熟悉关系数据库(MySQL)吗,你就往Codd博士 扯上一波,然后歌颂一波它的简要事迹再说他在1981年因为在关系数据库理论的研究获得图灵奖,并带上一脸赞叹和仰慕的表情。面试官肯定感觉不错:这小伙子底子可以啊,态度也挺好的,加分加分!不出意外稳妥拿到offer概率大大增加!(如果这招有用记得回来三连一波)。

算法大家与图灵奖

Dijkstra(迪科斯彻)

虽然心中图灵奖的获得者盲区已经打破,但是肯定止不住好奇去翻翻哪些人得了图灵奖,看了一下大部分都是人工智能数学领域还有一部分就是偏底层或者数据库相关都是陌生而难记的面孔,我有些失望。但突然找到一个熟悉的面孔:Dijkstra

image-20201124205039288

哇,这个算法不是我们上数据结构与算法图论中必学的嘛,图论算法掐指可数,Dijkstra、prim、floyed再加上经典的dfs和bfs嘛!我兴致勃勃的点开Dijkstra大佬的介绍,Dijkstra大佬被称为结构程序设计之父 ,他有以下的成就:

知晓的:goto有害论(耳熟);第一个Algol 60编译器的设计者和实现者(厉害啊);THE操作系统的设计者和开发者(真大佬啊!);

熟悉的:Dijkstra最短路径算法(以它而闻名);银行家算法的创造者; 解决了“哲学家聚餐”问题;提出信号量和PV原语;

此时的我已经很震惊了,我知道pv信号量和原语,也知道银行家算法,哲学家进餐问题都是操作系统很经典的问题,没想到都是Dijkstra大佬提出和发现的,真的强强强!并且Dijkstra和与D. E. Knuth并称为我们这个时代最伟大的计算机科学家的人。

Floyd(弗洛伊德)

Dijkstra是经典的单源求最短路径,而与之对应很流行的多源最短路径算法—Floyd(弗洛伊德)算法,该算法名称以创始人之一、1978年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德命名。此外在算法方面,弗洛伊德(Floyd)和威廉姆斯(J.Williams)在1964年共同发明了著名的堆排序算法heapSort(笔者前几天刚写的竟然没发现)!

Hoare(霍尔)

谈起排序,那快排肯定不可缺少啊,霍尔爵士(英国计算机科学家)就是快速排序的发明者,巧的是霍尔爵士在1980年获得图灵奖。

Niklaus Wirth(沃斯)

凭借一句话获得图灵奖的Pascal之父——Niklaus Wirth(沃斯) ,让他获得图灵奖的这句话就是他提出的著名公式:"算法+数据结构=程序" ,作为程序员,上大学第一节c语言或者数据结构与算法课堂的时候我们就听老师讲过这句话。这个公式对计算机科学的影响程度足以类似物理学中爱因斯坦的“E=MC^2”—一个公式展示出了程序的本质。

结语

通过一件小事发现了有趣的联系。都有着共同的联系—图灵奖,在以前,我的认知是这样的:

image-20201126195319305

通过本篇的整理和学习,现在对一些知识有着更条理化的认识:

image-20201130170148223

当然,图灵奖的得主非常多,每位得奖主都是了不起的人才,都是我辈楷模,这里仅列举所联系到、熟悉和数据结构与算法相关的得奖主,其他的就不一一列举啦!

虽然这并不是一件非常大的事情,源于复试的一个提问,但通过后来的查找总让我对熟悉的算法和人物有着焕然一新的感觉:原来还是这样啊! 而生活中、工作中、再或学习中有很多类似的地方,我们可能只差一步就能发现更多、建立更多有效的联系以及知识体系结构。而我们常常都是浮于表面,希望在日后的学习生活中能与大家同作一个有心人

最后给你一个问题,你知道图灵奖杯🏆为什么是个银碗嘛?

原创不易,bigsai请你帮两件事帮忙一下:

  1. 一键三联、分享支持一下, 您的肯定是我在思否创作的源源动力。
  2. 微信搜索「bigsai」,更多精彩等你!
查看原文

赞 9 收藏 0 评论 4

bigsai 发布了文章 · 2020-11-26

「干货总结」程序员必知必会的十大排序算法

首发公众号:bigsai 转载请联系
文章已收录在 Github:bigsai-algorithm

绪论

身为程序员,十大排序是是所有合格程序员所必备和掌握的,并且热门的算法比如快排、归并排序还可能问的比较细致,对算法性能和复杂度的掌握有要求。bigsai作为一个负责任的Java和数据结构与算法方向的小博主,在这方面肯定不能让读者们有所漏洞。跟着本篇走,带你捋一捋常见的十大排序算法,轻轻松松掌握!

首先对于排序来说大多数人对排序的概念停留在冒泡排序或者JDK中的Arrays.sort(),手写各种排序对很多人来说都是一种奢望,更别说十大排序算法了,不过还好你遇到了本篇文章!

对于排序的分类,主要不同的维度比如复杂度来分、内外部、比较非比较等维度来分类。我们正常讲的十大排序算法是内部排序,我们更多将他们分为两大类:基于比较和非比较这个维度去分排序种类。

  • 非比较类的有桶排序、基数排序、计数排序。也有很多人将排序归纳为8大排序,那就是因为基数排序、计数排序是建立在桶排序之上或者是一种特殊的桶排序,但是基数排序和计数排序有它特有的特征,所以在这里就将他们归纳为10种经典排序算法。而比较类排序也可分为
  • 比较类排序也有更细致的分法,有基于交换的、基于插入的、基于选择的、基于归并的,更细致的可以看下面的脑图。

image-20201120124138560

交换类

冒泡排序

冒泡排序,又称起泡排序,它是一种基于交换的排序典型,也是快排思想的基础,冒泡排序是一种稳定排序算法,时间复杂度为O(n^2).基本思想是:循环遍历多次每次从前往后把大元素往后调,每次确定一个最大(最小)元素,多次后达到排序序列。(或者从后向前把小元素往前调)。

具体思想为(把大元素往后调):

  • 从第一个元素开始往后遍历,每到一个位置判断是否比后面的元素大,如果比后面元素大,那么就交换两者大小,然后继续向后,这样的话进行一轮之后就可以保证最大的那个数被交换交换到最末的位置可以确定
  • 第二次同样从开始起向后判断着前进,如果当前位置比后面一个位置更大的那么就和他后面的那个数交换。但是有点注意的是,这次并不需要判断到最后,只需要判断到倒数第二个位置就行(因为第一次我们已经确定最大的在倒数第一,这次的目的是确定倒数第二)
  • 同理,后面的遍历长度每次减一,直到第一个元素使得整个元素有序。

例如2 5 3 1 4排序过程如下:

image-20201120155114930

实现代码为:

public void  maopaosort(int[] a) {
  // TODO Auto-generated method stub
  for(int i=a.length-1;i>=0;i--)
  {
    for(int j=0;j<i;j++)
    {
      if(a[j]>a[j+1])
      {
        int team=a[j];
        a[j]=a[j+1];
        a[j+1]=team;
      }
    }
  }
}

快速排序

快速排序是对冒泡排序的一种改进,采用递归分治的方法进行求解。而快排相比冒泡是一种不稳定排序,时间复杂度最坏是O(n^2),平均时间复杂度为O(nlogn),最好情况的时间复杂度为O(nlogn)。

对于快排来说,基本思想是这样的

  • 快排需要将序列变成两个部分,就是序列左边全部小于一个数序列右面全部大于一个数,然后利用递归的思想再将左序列当成一个完整的序列再进行排序,同样把序列的右侧也当成一个完整的序列进行排序。
  • 其中这个数在这个序列中是可以随机取的,可以取最左边,可以取最右边,当然也可以取随机数。但是通常不优化情况我们取最左边的那个数。

image-20201120133851275

实现代码为:

public void quicksort(int [] a,int left,int right)
{
  int low=left;
  int high=right;
  //下面两句的顺序一定不能混,否则会产生数组越界!!!very important!!!
  if(low>high)//作为判断是否截止条件
    return;
  int k=a[low];//额外空间k,取最左侧的一个作为衡量,最后要求左侧都比它小,右侧都比它大。
  while(low<high)//这一轮要求把左侧小于a[low],右侧大于a[low]。
  {
    while(low<high&&a[high]>=k)//右侧找到第一个小于k的停止
    {
      high--;
    }
    //这样就找到第一个比它小的了
    a[low]=a[high];//放到low位置
    while(low<high&&a[low]<=k)//在low往右找到第一个大于k的,放到右侧a[high]位置
    {
      low++;
    }
    a[high]=a[low];            
  }
  a[low]=k;//赋值然后左右递归分治求之
  quicksort(a, left, low-1);
  quicksort(a, low+1, right);        
}

插入类排序

直接插入排序

直接插入排序在所有排序算法中的是最简单排序方式之一。和我们上学时候 从前往后、按高矮顺序排序,那么一堆高低无序的人群中,从第一个开始,如果前面有比自己高的,就直接插入到合适的位置。一直到队伍的最后一个完成插入整个队列才能满足有序。

直接插入排序遍历比较时间复杂度是每次O(n),交换的时间复杂度每次也是O(n),那么n次总共的时间复杂度就是O(n^2)。有人会问折半(二分)插入能否优化成O(nlogn),答案是不能的。因为二分只能减少查找复杂度每次为O(logn),而插入的时间复杂度每次为O(n)级别,这样总的时间复杂度级别还是O(n^2).

插入排序的具体步骤:

  • 选取当前位置(当前位置前面已经有序) 目标就是将当前位置数据插入到前面合适位置。
  • 向前枚举或者二分查找,找到待插入的位置。
  • 移动数组,赋值交换,达到插入效果。

image-20201120160709469

实现代码为:

public void insertsort (int a[])
{
  int team=0;
  for(int i=1;i<a.length;i++)
  {
    System.out.println(Arrays.toString(a));
    team=a[i];
    for(int j=i-1;j>=0;j--)
    {

      if(a[j]>team)
      {
        a[j+1]=a[j];
        a[j]=team;    
      }    
      else {
        break;
      }
    }
  }    
}

希尔排序

直接插入排序因为是O(n^2),在数据量很大或者数据移动位次太多会导致效率太低。很多排序都会想办法拆分序列,然后组合,希尔排序就是以一种特殊的方式进行预处理,考虑到了数据量和有序性两个方面纬度来设计算法。使得序列前后之间小的尽量在前面,大的尽量在后面,进行若干次的分组别计算,最后一组即是一趟完整的直接插入排序。

对于一个长串,希尔首先将序列分割(非线性分割)而是按照某个数模(取余这个类似报数1、2、3、4。1、2、3、4)这样形式上在一组的分割先各组分别进行直接插入排序,这样很小的数在后面可以通过较少的次数移动到相对靠前的位置。然后慢慢合并变长,再稍稍移动。

因为每次这样插入都会使得序列变得更加有序,稍微有序序列执行直接插入排序成本并不高。所以这样能够在合并到最终的时候基本小的在前,大的在后,代价越来越小。这样希尔排序相比插入排序还是能节省不少时间的。

image-20201120164448973

实现代码为:

public void shellsort (int a[])
{
  int d=a.length;
  int team=0;//临时变量
  for(;d>=1;d/=2)//共分成d组
    for(int i=d;i<a.length;i++)//到那个元素就看这个元素在的那个组即可
    {
      team=a[i];
      for(int j=i-d;j>=0;j-=d)
      {                
        if(a[j]>team)
        {
          a[j+d]=a[j];
          a[j]=team;    
        }
        else {
          break;
        }
      }
    }    
}

选择类排序

简单选择排序

简单选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

image-20201120201910761

实现代码为:

public void selectSort(int[] arr) {
  for (int i = 0; i < arr.length - 1; i++) {
    int min = i; // 最小位置
    for (int j = i + 1; j < arr.length; j++) {
      if (arr[j] < arr[min]) {
        min = j; // 更换最小位置
      }
    }
    if (min != i) {
      swap(arr, i, min); // 与第i个位置进行交换
    }
  }
}
private void swap(int[] arr, int i, int j) {
  int temp = arr[i];
  arr[i] = arr[j];
  arr[j] = temp;
}

堆排序

对于堆排序,首先是建立在堆的基础上,堆是一棵完全二叉树,还要先认识下大根堆和小根堆,完全二叉树中所有节点均大于(或小于)它的孩子节点,所以这里就分为两种情况

  • 如果所有节点大于孩子节点值,那么这个堆叫做大根堆,堆的最大值在根节点。
  • 如果所有节点小于孩子节点值,那么这个堆叫做小根堆,堆的最小值在根节点。

在这里插入图片描述

堆排序首先就是建堆,然后再是调整。对于二叉树(数组表示),我们从下往上进行调整,从第一个非叶子节点开始向前调整,对于调整的规则如下:

建堆是一个O(n)的时间复杂度过程,建堆完成后就需要进行删除头排序。给定数组建堆(creatHeap)

①从第一个非叶子节点开始判断交换下移(shiftDown),使得当前节点和子孩子能够保持堆的性质

②但是普通节点替换可能没问题,对如果交换打破子孩子堆结构性质,那么就要重新下移(shiftDown)被交换的节点一直到停止。

在这里插入图片描述

堆构造完成,取第一个堆顶元素为最小(最大),剩下左右孩子依然满足堆的性值,但是缺个堆顶元素,如果给孩子调上来,可能会调动太多并且可能破坏堆结构。

①所以索性把最后一个元素放到第一位。这样只需要判断交换下移(shiftDown),不过需要注意此时整个堆的大小已经发生了变化,我们在逻辑上不会使用被抛弃的位置,所以在设计函数的时候需要附带一个堆大小的参数。

②重复以上操作,一直堆中所有元素都被取得停止。

在这里插入图片描述

而堆算法复杂度的分析上,之前建堆时间复杂度是O(n)。而每次删除堆顶然后需要向下交换,每个个数最坏为logn个。这样复杂度就为O(nlogn).总的时间复杂度为O(n)+O(nlogn)=O(nlogn).

实现代码为:

static void swap(int arr[],int m,int n)
{
  int team=arr[m];
  arr[m]=arr[n];
  arr[n]=team;
}
//下移交换 把当前节点有效变换成一个堆(小根)
static void shiftDown(int arr[],int index,int len)//0 号位置不用
{
  int leftchild=index*2+1;//左孩子
  int rightchild=index*2+2;//右孩子
  if(leftchild>=len)
    return;
  else if(rightchild<len&&arr[rightchild]<arr[index]&&arr[rightchild]<arr[leftchild])//右孩子在范围内并且应该交换
  {
    swap(arr, index, rightchild);//交换节点值
    shiftDown(arr, rightchild, len);//可能会对孩子节点的堆有影响,向下重构
  }
  else if(arr[leftchild]<arr[index])//交换左孩子
  {
    swap(arr, index, leftchild);
    shiftDown(arr, leftchild, len);
  }
}
//将数组创建成堆
static void creatHeap(int arr[])
{
  for(int i=arr.length/2;i>=0;i--)
  {
    shiftDown(arr, i,arr.length);
  }
}
static void heapSort(int arr[])
{
  System.out.println("原始数组为         :"+Arrays.toString(arr));
  int val[]=new int[arr.length]; //临时储存结果
  //step1建堆
  creatHeap(arr);
  System.out.println("建堆后的序列为  :"+Arrays.toString(arr));
  //step2 进行n次取值建堆,每次取堆顶元素放到val数组中,最终结果即为一个递增排序的序列
  for(int i=0;i<arr.length;i++)
  {
    val[i]=arr[0];//将堆顶放入结果中
    arr[0]=arr[arr.length-1-i];//删除堆顶元素,将末尾元素放到堆顶
    shiftDown(arr, 0, arr.length-i);//将这个堆调整为合法的小根堆,注意(逻辑上的)长度有变化
  }
  //数值克隆复制
  for(int i=0;i<arr.length;i++)
  {
    arr[i]=val[i];
  }
  System.out.println("堆排序后的序列为:"+Arrays.toString(arr));

}

归并类排序

在归并类排序一般只讲归并排序,但是归并排序也分二路归并、多路归并,这里就讲较多的二路归并排序,且用递归方式实现。

归并排序

归并和快排都是基于分治算法的,分治算法其实应用挺多的,很多分治会用到递归,但事实上分治和递归是两把事。分治就是分而治之,可以采用递归实现,也可以自己遍历实现非递归方式。而归并排序就是先将问题分解成代价较小的子问题,子问题再采取代价较小的合并方式完成一个排序。

至于归并的思想是这样的:

  • 第一次:整串先进行划分成一个一个单独,第一次是将序列中(1 2 3 4 5 6---)两两归并成有序,归并完(xx xx xx xx----)这样局部有序的序列。
  • 第二次就是两两归并成若干四个(1 2 3 4 5 6 7 8 ----)每个小局部是有序的
  • 就这样一直到最后这个串串只剩一个,然而这个耗费的总次数logn。每次操作的时间复杂的又是O(n)。所以总共的时间复杂度为O(nlogn).

image-20201120173153449

合并为一个O(n)的过程:

image-20201120174526108

实现代码为:

private static void mergesort(int[] array, int left, int right) {
  int mid=(left+right)/2;
  if(left<right)
  {
    mergesort(array, left, mid);
    mergesort(array, mid+1, right);
    merge(array, left,mid, right);
  }
}

private static void merge(int[] array, int l, int mid, int r) {
  int lindex=l;int rindex=mid+1;
  int team[]=new int[r-l+1];
  int teamindex=0;
  while (lindex<=mid&&rindex<=r) {//先左右比较合并
    if(array[lindex]<=array[rindex])
    {
      team[teamindex++]=array[lindex++];
    }
    else {                
      team[teamindex++]=array[rindex++];
    }
  }
  while(lindex<=mid)//当一个越界后剩余按序列添加即可
  {
    team[teamindex++]=array[lindex++];

  }
  while(rindex<=r)
  {
    team[teamindex++]=array[rindex++];
  }    
  for(int i=0;i<teamindex;i++)
  {
    array[l+i]=team[i];
  }

}

桶类排序

桶排序

桶排序是一种用空间换取时间的排序,桶排序重要的是它的思想,而不是具体实现,时间复杂度最好可能是线性O(n),桶排序不是基于比较的排序而是一种分配式的。桶排序从字面的意思上看:

  • 桶:若干个桶,说明此类排序将数据放入若干个桶中。
  • 桶:每个桶有容量,桶是有一定容积的容器,所以每个桶中可能有多个元素。
  • 桶:从整体来看,整个排序更希望桶能够更匀称,即既不溢出(太多)又不太少。

桶排序的思想为:将待排序的序列分到若干个桶中,每个桶内的元素再进行个别排序。 当然桶排序选择的方案跟具体的数据有关系,桶排序是一个比较广泛的概念,并且计数排序是一种特殊的桶排序,基数排序也是建立在桶排序的基础上。在数据分布均匀且每个桶元素趋近一个时间复杂度能达到O(n),但是如果数据范围较大且相对集中就不太适合使用桶排序。

image-20201120180500488

实现一个简单桶排序:

import java.util.ArrayList;
import java.util.List;
//微信公众号:bigsai
public class bucketSort {
    public static void main(String[] args) {
        int a[]= {1,8,7,44,42,46,38,34,33,17,15,16,27,28,24};
        List[] buckets=new ArrayList[5];
        for(int i=0;i<buckets.length;i++)//初始化
        {
            buckets[i]=new ArrayList<Integer>();
        }
        for(int i=0;i<a.length;i++)//将待排序序列放入对应桶中
        {
            int index=a[i]/10;//对应的桶号
            buckets[index].add(a[i]);
        }
        for(int i=0;i<buckets.length;i++)//每个桶内进行排序(使用系统自带快排)
        {
            buckets[i].sort(null);
            for(int j=0;j<buckets[i].size();j++)//顺便打印输出
            {
                System.out.print(buckets[i].get(j)+" ");
            }
        }    
    }
}

计数排序

计数排序是一种特殊的桶排序,每个桶的大小为1,每个桶不在用List表示,而通常用一个值用来计数。

设计具体算法的时候,先找到最小值min,再找最大值max。然后创建这个区间大小的数组,从min的位置开始计数,这样就可以最大程度的压缩空间,提高空间的使用效率。

在这里插入图片描述

public static void countSort(int a[])
{
  int min=Integer.MAX_VALUE;int max=Integer.MIN_VALUE;
  for(int i=0;i<a.length;i++)//找到max和min
  {
    if(a[i]<min) 
      min=a[i];
    if(a[i]>max)
      max=a[i];
  }
  int count[]=new int[max-min+1];//对元素进行计数
  for(int i=0;i<a.length;i++)
  {
    count[a[i]-min]++;
  }
  //排序取值
  int index=0;
  for(int i=0;i<count.length;i++)
  {
    while (count[i]-->0) {
      a[index++]=i+min;//有min才是真正值
    }
  }
}

基数排序

基数排序是一种很容易理解但是比较难实现(优化)的算法。基数排序也称为卡片排序,基数排序的原理就是多次利用计数排序(计数排序是一种特殊的桶排序),但是和前面的普通桶排序和计数排序有所区别的是,基数排序并不是将一个整体分配到一个桶中,而是将自身拆分成一个个组成的元素,每个元素分别顺序分配放入桶中、顺序收集,当从前往后或者从后往前每个位置都进行过这样顺序的分配、收集后,就获得了一个有序的数列。

image-20201113154119682

如果是数字类型排序,那么这个桶只需要装0-9大小的数字,但是如果是字符类型,那么就需要注意ASCII的范围。

所以遇到这种情况我们基数排序思想很简单,就拿 934,241,3366,4399这几个数字进行基数排序的一趟过程来看,第一次会根据各位进行分配、收集:

image-20201113161050871

分配和收集都是有序的,第二次会根据十位进行分配、收集,此次是在第一次个位分配、收集基础上进行的,所以所有数字单看个位十位是有序的。

image-20201113161752292

而第三次就是对百位进行分配收集,此次完成之后百位及其以下是有序的。

image-20201113162803486

而最后一次的时候进行处理的时候,千位有的数字需要补零,这次完毕后后千位及以后都有序,即整个序列排序完成。

image-20201113170715860

简单实现代码为:

static void radixSort(int[] arr)//int 类型 从右往左
{
  List<Integer>bucket[]=new ArrayList[10];
  for(int i=0;i<10;i++)
  {
    bucket[i]=new ArrayList<Integer>();
  }
  //找到最大值
  int max=0;//假设都是正数
  for(int i=0;i<arr.length;i++)
  {
    if(arr[i]>max)
      max=arr[i];
  }
  int divideNum=1;//1 10 100 100……用来求对应位的数字
  while (max>0) {//max 和num 控制
    for(int num:arr)
    {
      bucket[(num/divideNum)%10].add(num);//分配 将对应位置的数字放到对应bucket中
    }
    divideNum*=10;
    max/=10;
    int idx=0;
    //收集 重新捡起数据
    for(List<Integer>list:bucket)
    {
      for(int num:list)
      {
        arr[idx++]=num;
      }
      list.clear();//收集完需要清空留下次继续使用
    }
  }
}

当然,基数排序还有字符串等长、不等长、一维数组优化等各种实现需要需学习,具体可以参考公众号内其他文章。

结语

本次十大排序就这么潇洒的过了一遍,我想大家都应该有所领悟了吧!对于算法总结,避免不必要的劳动力,我分享这个表格给大家:

排序算法平均时间复杂度最好最坏空间复杂度稳定性
冒泡排序O(n^2)O(n)O(n^2)O(1)稳定
快速排序O(nlogn)O(nlogn)O(n^2)O(logn)不稳定
插入排序O(n^2)O(n)O(n^2)O(1)稳定
希尔排序O(n^1.3)O(n)O(nlog2n)O(1)不稳定
选择排序O(n^2)O(n^2)O(n^2)O(1)不稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
桶排序O(n+k)O(n+k)O(n+k)O(n+k)稳定
计数排序O(n+k)O(n+k)O(n+k)O(k)稳定
基数排序O(n*k)O(n*k)O(n*k)O(n+k)稳定

原创不易,点赞、分享支持一下, 您的肯定是我在思否创作的源源动力。

文章已收录在 Github:bigsai-algorithm 和个人公众号「bigsai」

咱们下次再见!

查看原文

赞 0 收藏 0 评论 0

bigsai 发布了文章 · 2020-11-19

「万字图文」史上最姨母级Java继承详解

原创公众号:「bigsai」关注这个有情怀的程序员 除公众号以外拒绝任意擅自转载
文章收录在bigsai公众号和回车课堂

课程导学

在Java课堂中,所有老师不得不提到面向对象(Object Oriented),而在谈到面向对象的时候,又不得不提到面向对象的三大特征:封装、继承、多态。三大特征紧密联系而又有区别,本课程就带你学习Java的继承

你可能不知道继承到底有什么用,但你大概率曾有过这样的经历:写Java项目/作业时候创建很多相似的类,类中也有很多相同的方法,做了很多重复的工作量,感觉很臃肿。而合理使用继承就能大大减少重复代码,提高代码复用性。

在这里插入图片描述

继承的初相识

学习继承,肯定是先从广的概念了解继承是什么以及其作用,然后才从细的方面学习继承的具体实现细节,本关就是带你先快速了解和理解继承的重要概念。

### 什么是继承

继承(英语:inheritance)是面向对象软件技术中的一个概念。它使得复用以前的代码非常容易,能够大大缩短开发周期,降低开发费用。

Java语言是非常典型的面向对象的语言,在Java语言中继承就是子类继承父类的属性和方法,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的方法。父类有时也叫基类、超类;子类有时也被称为派生类。

我们来举个例子:我们知道动物有很多种,是一个比较大的概念。在动物的种类中,我们熟悉的有猫(Cat)、狗(Dog)等动物,它们都有动物的一般特征(比如能够吃东西,能够发出声音),不过又在细节上有区别(不同动物的吃的不同,叫声不一样)。在Java语言中实现Cat和Dog等类的时候,就需要继承Animal这个类。继承之后Cat、Dog等具体动物类就是子类,Animal类就是父类。

image-20201104084434252

为什么需要继承

你可能会疑问为什么需要继承?在具体实现的时候,我们创建Dog,Cat等类的时候实现其具体的方法不就可以了嘛,实现这个继承似乎使得这个类的结构不那么清晰。

如果仅仅只有两三个类,每个类的属性和方法很有限的情况下确实没必要实现继承,但事情并非如此,事实上一个系统中往往有很多个类并且有着很多相似之处,比如猫和狗同属动物,或者学生和老师同属人。各个类可能又有很多个相同的属性和方法,这样的话如果每个类都重新写不仅代码显得很乱,代码工作量也很大。

这时继承的优势就出来了:可以直接使用父类的属性和方法,自己也可以有自己新的属性和方法满足拓展,父类的方法如果自己有需求更改也可以重写。这样使用继承不仅大大的减少了代码量,也使得代码结构更加清晰可见

image-20201025115427580

所以这样从代码的层面上来看我们设计这个完整的Animal类是这样的:

class Animal
{
    public int id;
    public String name;
    public int age;
    public int weight;

    public Animal(int id, String name, int age, int weight) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.weight = weight;
    }
    //这里省略get set方法
    public void sayHello()
    {
        System.out.println("hello");
    }
    public void eat()
    {
        System.out.println("I'm eating");
    }
    public void sing()
    {
        System.out.println("sing");
    }
}

而Dog,Cat,Chicken类可以这样设计:

class Dog extends Animal//继承animal
{
    public Dog(int id, String name, int age, int weight) {
        super(id, name, age, weight);//调用父类构造方法
    }
}
class Cat extends Animal{

    public Cat(int id, String name, int age, int weight) {
        super(id, name, age, weight);//调用父类构造方法
    }
}
class Chicken extends Animal{

    public Chicken(int id, String name, int age, int weight) {
        super(id, name, age, weight);//调用父类构造方法
    }
    //鸡下蛋
    public void layEggs()
    {
        System.out.println("我是老母鸡下蛋啦,咯哒咯!咯哒咯!");
    }
}

各自的类继承Animal后可以直接使用Animal类的属性和方法而不需要重复编写,各个类如果有自己的方法也可很容易地拓展。上述代码中你需要注意extends就是用来实现继承的。

继承的分类

继承分为单继承和多继承,Java语言只支持类的单继承,但可以通过实现接口的方式达到多继承的目的。我们先用一张表概述一下两者的区别,然后再展开讲解。

定义优缺点
单继承
图片
一个子类只拥有一个父类优点:在类层次结构上比较清晰
缺点:结构的丰富度有时不能满足使用需求
多继承(Java不支持,但可以用其它方式满足多继承使用需求)
一个子类拥有多个直接的父类优点:子类的丰富度很高
缺点:容易造成混乱

单继承

单继承,是一个子类只拥有一个父类,如我们上面讲过的Animal类和它的子类。单继承在类层次结构上比较清晰,但缺点是结构的丰富度有时不能满足使用需求

多继承(Java不支持,但可以实现)

多继承,是一个子类拥有多个直接的父类。这样做的好处是子类拥有所有父类的特征,子类的丰富度很高,但是缺点就是容易造成混乱。下图为一个混乱的例子。

image-20201026092613166

Java虽然不支持多继承,但是Java有三种实现多继承效果的方式,分别是内部类、多层继承和实现接口。

内部类可以继承一个与外部类无关的类,保证了内部类的独立性,正是基于这一点,可以达到多继承的效果。

多层继承:子类继承父类,父类如果还继承其他的类,那么这就叫多层继承。这样子类就会拥有所有被继承类的属性和方法。

image-20201104092855833

实现接口无疑是满足多继承使用需求的最好方式,一个类可以实现多个接口满足自己在丰富性和复杂环境的使用需求。类和接口相比,类就是一个实体,有属性和方法,而接口更倾向于一组方法。举个例子,就拿斗罗大陆的唐三来看,他存在的继承关系可能是这样的:

image-20201026111105372

如何实现继承

实现继承除了上面用到的extends外,还可以用implements这个关键字实现。下面,让我给你逐一讲解一下。

extends关键字

在Java中,类的继承是单一继承,也就是说一个子类只能拥有一个父类,所以extends只能继承一个类。其使用语法为:

class 子类名 extends 父类名{}

例如Dog类继承Animal类,它是这样的:

class Animal{} //定义Animal类
class Dog extends Animal{} //Dog类继承Animal类

子类继承父类后,就拥有父类的非私有的属性和方法。如果不明白,请看这个案例,在IDEA下创建一个项目,创建一个test类做测试,分别创建Animal类和Dog类,Animal作为父类写一个sayHello()方法,Dog类继承Animal类之后就可以调用sayHello()方法。具体代码为:

class Animal {
    public void  sayHello()//父类的方法
    {
        System.out.println("hello,everybody");
    }
}
class Dog extends Animal//继承animal
{ }
public class test {
    public static void main(String[] args) {
       Dog dog=new Dog();
       dog.sayHello();
    }
}

点击运行的时候Dog子类可以直接使用Animal父类的方法。

image-20201025171057573

implements 关键字

使用implements 关键字可以变相使Java拥有多继承的特性,使用范围为类实现接口的情况,一个类可以实现多个接口(接口与接口之间用逗号分开)。Java接口是一系列方法的声明,一个接口中没有方法的具体实现 。子类实现接口的时候必须重写接口中的方法。

我们来看一个案例,创建一个test2类做测试,分别创建doA接口和doB接口,doA接口声明sayHello()方法,doB接口声明eat()方法,创建Cat2类实现doA和doB接口,并且在类中需要重写sayHello()方法和eat()方法。具体代码为:

interface doA{
     void sayHello();
}
interface doB{
     void eat();
    //以下会报错 接口中的方法不能具体定义只能声明
    //public void eat(){System.out.println("eating");}
}
class Cat2 implements  doA,doB{
    @Override//必须重写接口内的方法
    public void sayHello() {
        System.out.println("hello!");
    }
    @Override
    public void eat() {
        System.out.println("I'm eating");
    }
}
public class test2 {
    public static void main(String[] args) {
        Cat2 cat=new Cat2();
        cat.sayHello();
        cat.eat();
    }
}

Cat类实现doA和doB接口的时候,需要实现其声明的方法,点击运行结果如下,这就是一个类实现接口的简单案例:

image-20201025212020810

继承的特点

继承的主要内容就是子类继承父类,并重写父类的方法。使用子类的属性或方法时候,首先要创建一个对象,而对象通过构造方法去创建,在构造方法中我们可能会调用子父类的一些属性和方法,所以就需要提前掌握this和super关键字。创建完这个对象之后,在调用重写父类的方法,并区别重写和重载的区别。所以本节根据this、super关键字—>构造函数—>方法重写—>方法重载的顺序进行讲解。

this和super关键字

this和super关键字是继承中非常重要的知识点,分别表示当前对象的引用和父类对象的引用,两者有很大相似又有一些区别。

this表示当前对象,是指向自己的引用。

this.属性 // 调用成员变量,要区别成员变量和局部变量
this.() // 调用本类的某个方法
this() // 表示调用本类构造方法

super表示父类对象,是指向父类的引用。

super.属性 // 表示父类对象中的成员变量
super.方法() // 表示父类对象中定义的方法
super() // 表示调用父类构造方法

此外,this和super关键字只能出现在非static修饰的代码中。

this()和super()都只能在构造方法的第一行出现,如果使用this()表示调用当前类的其他构造方法,使用super()表示调用父类的某个构造方法,所以两者只能根据自己使用需求选择其一。

写一个小案例,创建D1类和子类D2如下:

class D1{
    public D1() {}//无参构造
    public void sayHello() {
        System.out.println("hello");
    }
}
class D2 extends D1{
    public String name;
    public D2(){
        super();//调用父类构造方法
        this.name="BigSai";//给当前类成员变量赋值
    }
    @Override
    public void sayHello() {
        System.out.println("hello,我是"+this.name);
    }
    public void test()
    {
        super.sayHello();//调用父类方法
        this.sayHello();//调用当前类其他方法
    }
}
public class test8 {
    public static void main(String[] args) {
        D2 d2=new D2();
        d2.test();
    }
}

执行的结果为:

image-20201027221658119

构造方法

构造方法是一种特殊的方法,它是一个与类同名的方法。对象的创建就通过构造方法来完成,其主要的功能是完成对象的初始化。但在继承中构造方法是一种比较特殊的方法(比如不能继承),所以要了解和学习在继承中构造方法的规则和要求。

构造方法可分为有参构造和无参构造,这个可以根据自己的使用需求合理设置构造方法。但继承中的构造方法有以下几点需要注意:

父类的构造方法不能被继承:

因为构造方法语法是与类同名,而继承则不更改方法名,如果子类继承父类的构造方法,那明显与构造方法的语法冲突了。比如Father类的构造方法名为Father(),Son类如果继承Father类的构造方法Father(),那就和构造方法定义:构造方法与类同名冲突了,所以在子类中不能继承父类的构造方法,但子类会调用父类的构造方法。

子类的构造过程必须调用其父类的构造方法:

Java虚拟机构造子类对象前会先构造父类对象,父类对象构造完成之后再来构造子类特有的属性,这被称为内存叠加。而Java虚拟机构造父类对象会执行父类的构造方法,所以子类构造方法必须调用super()即父类的构造方法。就比如一个简单的继承案例应该这么写:

class A{
    public String name;
    public A() {//无参构造
    }
    public A (String name){//有参构造
    }
}
class B extends A{
    public B() {//无参构造
       super();
    }
    public B(String name) {//有参构造
      //super();
       super(name);
    }
}

如果子类的构造方法中没有显示地调用父类构造方法,则系统默认调用父类无参数的构造方法。

你可能有时候在写继承的时候子类并没有使用super()调用,程序依然没问题,其实这样是为了节省代码,系统执行时会自动添加父类的无参构造方式,如果不信的话我们对上面的类稍作修改执行:

image-20201026201029796

方法重写(Override)

方法重写也就是子类中出现和父类中一模一样的方法(包括返回值类型,方法名,参数列表),它建立在继承的基础上。你可以理解为方法的外壳不变,但是核心内容重写

在这里提供一个简单易懂的方法重写案例:

class E1{
    public void doA(int a){
        System.out.println("这是父类的方法");
    }
}
class E2 extends E1{
    @Override
    public void doA(int a) {
        System.out.println("我重写父类方法,这是子类的方法");
    }
}

其中@Override注解显示声明该方法为注解方法,可以帮你检查重写方法的语法正确性,当然如果不加也是可以的,但建议加上。

对于重写,你需要注意以下几点:

从重写的要求上看:

  • 重写的方法和父类的要一致(包括返回值类型、方法名、参数列表)
  • 方法重写只存在于子类和父类之间,同一个类中只能重载

从访问权限上看:

  • 子类方法不能缩小父类方法的访问权限
  • 子类方法不能抛出比父类方法更多的异常
  • 父类的私有方法不能被子类重写

从静态和非静态上看:

  • 父类的静态方法不能被子类重写为非静态方法
  • 子类可以定义于父类的静态方法同名的静态方法,以便在子类中隐藏父类的静态方法(满足重写约束)
  • 父类的非静态方法不能被子类重写为静态方法

从抽象和非抽象来看:

  • 父类的抽象方法可以被子类通过两种途径重写(即实现和重写)
  • 父类的非抽象方法可以被重写为抽象方法

当然,这些规则可能涉及一些修饰符,在第三关中会详细介绍。

方法重载(Overload)

如果有两个方法的方法名相同,但参数不一致,那么可以说一个方法是另一个方法的重载。方法重载规则如下:

  • 被重载的方法必须改变参数列表(参数个数或类型或顺序不一样)
  • 被重载的方法可以改变返回类型
  • 被重载的方法可以改变访问修饰符
  • 被重载的方法可以声明新的或更广的检查异常
  • 方法能够在同一个类中或者在一个子类中被重载
  • 无法以返回值类型作为重载函数的区分标准

重载可以通常理解为完成同一个事情的方法名相同,但是参数列表不同其他条件也可能不同。一个简单的方法重载的例子,类E3中的add()方法就是一个重载方法。

class E3{
    public int add(int a,int b){
        return a+b;
    }
    public double add(double a,double b) {
        return a+b;
    }
    public int add(int a,int b,int c) {
        return a+b+c;
    }
}

方法重写和方法重载的区别

方法重写和方法重载名称上容易混淆,但内容上有很大区别,下面用一个表格列出其中区别:

区别点方法重写方法重载
结构上垂直结构,是一种父子类之间的关系水平结构,是一种同类之间关系
参数列表不可以修改可以修改
访问修饰符子类的访问修饰符范围必须大于等于父类访问修饰符范围可以修改
抛出异常子类方法异常必须是父类方法异常或父类方法异常子异常可以修改

继承与修饰符

Java修饰符的作用就是对类或类成员进行修饰或限制,每个修饰符都有自己的作用,而在继承中可能有些特殊修饰符使得被修饰的属性或方法不能被继承,或者继承需要一些其他的条件,下面就详细介绍在继承中一些修饰符的作用和特性。

Java语言提供了很多修饰符,修饰符用来定义类、方法或者变量,通常放在语句的最前端。主要分为以下两类:

  • 访问修饰符
  • 非访问修饰符

这里访问修饰符主要讲解public,protected,default,private四种访问控制修饰符。非访问修饰符这里就介绍static修饰符,final修饰符和abstract修饰符。

访问修饰符

public,protected,default(无修饰词),private修饰符是面向对象中非常重要的知识点,而在继承中也需要懂得各种修饰符使用规则。

首先我们都知道不同的关键字作用域不同,四种关键字的作用域如下:

同一个类同一个包不同包子类不同包非子类
private
default
protect
public
  1. private:Java语言中对访问权限限制的最窄的修饰符,一般称之为“私有的”。被其修饰的属性以及方法只能被该类的对象访问,其子类不能访问,更不能允许跨包访问。
  2. default:(也有称friendly)即不加任何访问修饰符,通常称为“默认访问权限“或者“包访问权限”。该模式下,只允许在同一个包中进行访问。
  3. protected:介于public 和 private 之间的一种访问修饰符,一般称之为“保护访问权限”。被其修饰的属性以及方法只能被类本身的方法及子类访问,即使子类在不同的包中也可以访问。
  4. public: Java语言中访问限制最宽的修饰符,一般称之为“公共的”。被其修饰的类、属性以及方法不仅可以跨类访问,而且允许跨包访问。

Java 子类重写继承的方法时,不可以降低方法的访问权限子类继承父类的访问修饰符作用域不能比父类小,也就是更加开放,假如父类是protected修饰的,其子类只能是protected或者public,绝对不能是default(默认的访问范围)或者private。所以在继承中需要重写的方法不能使用private修饰词修饰。

如果还是不太清楚可以看几个小案例就很容易搞懂,写一个A1类中用四种修饰词实现四个方法,用子类A2继承A1,重写A1方法时候你就会发现父类私有方法不能重写,非私有方法重写使用的修饰符作用域不能变小(大于等于)。

image-20201027085359779

正确的案例应该为:

class A1 {
    private void doA(){ }
    void doB(){}//default
    protected void doC(){}
    public void doD(){}
}
class A2 extends A1{

    @Override
    public void doB() { }//继承子类重写的方法访问修饰符权限可扩大

    @Override
    protected void doC() { }//继承子类重写的方法访问修饰符权限可和父类一致

    @Override
    public void doD() { }//不可用protected或者default修饰
}

还要注意的是,继承当中子类抛出的异常必须是父类抛出的异常或父类抛出异常的子异常。下面的一个案例四种方法测试可以发现子类方法的异常不可大于父类对应方法抛出异常的范围。

image-20201027091537901

正确的案例应该为:

class B1{
    public void doA() throws Exception{}
    public void doB() throws Exception{}
    public void doC() throws IOException{}
    public void doD() throws IOException{}
}
class B2 extends B1{
    //异常范围和父类可以一致
    @Override
    public void doA() throws Exception { }
    //异常范围可以比父类更小
    @Override
    public void doB() throws IOException { }
    //异常范围 不可以比父类范围更大
    @Override
    public void doC() throws IOException { }//不可抛出Exception等比IOException更大的异常
    @Override
    public void doD() throws IOException { }
}

非访问修饰符

访问修饰符用来控制访问权限,而非访问修饰符每个都有各自的作用,下面针对static、final、abstract修饰符进行介绍。

static 修饰符

static 翻译为“静态的”,能够与变量,方法和类一起使用,称为静态变量,静态方法(也称为类变量、类方法)。如果在一个类中使用static修饰变量或者方法的话,它们可以直接通过类访问,不需要创建一个类的对象来访问成员。

我们在设计类的时候可能会使用静态方法,有很多工具类比如MathArrays等类里面就写了很多静态方法。static修饰符的规则很多,这里仅仅介绍和Java继承相关用法的规则:

  • 构造方法不允许声明为 static 的。
  • 静态方法中不存在当前对象,因而不能使用 this,当然也不能使用 super。
  • 静态方法不能被非静态方法重写(覆盖)
  • 静态方法能被静态方法重写(覆盖)

可以看以下的案例证明上述规则:

image-20201027193540259

源代码为:

class C1{
    public  int a;
    public C1(){}
   // public static C1(){}// 构造方法不允许被声明为static
    public static void doA() {}
    public static void doB() {}
}
class C2 extends C1{
    public static  void doC()//静态方法中不存在当前对象,因而不能使用this和super。
    {
        //System.out.println(super.a);
    }
    public static void doA(){}//静态方法能被静态方法重写
   // public void doB(){}//静态方法不能被非静态方法重写
}

final修饰符

final变量:

  • final 表示"最后的、最终的"含义,变量一旦赋值后,不能被重新赋值。被 final 修饰的实例变量必须显式指定初始值(即不能只声明)。final 修饰符通常和 static 修饰符一起使用来创建类常量。

final 方法:

  • 父类中的 final 方法可以被子类继承,但是不能被子类重写。声明 final 方法的主要目的是防止该方法的内容被修改。

final类:

  • final 类不能被继承,没有类能够继承 final 类的任何特性。

所以无论是变量、方法还是类被final修饰之后,都有代表最终、最后的意思。内容无法被修改。

abstract 修饰符

abstract 英文名为“抽象的”,主要用来修饰类和方法,称为抽象类和抽象方法。

抽象方法:有很多不同类的方法是相似的,但是具体内容又不太一样,所以我们只能抽取他的声明,没有具体的方法体,即抽象方法可以表达概念但无法具体实现。

抽象类有抽象方法的类必须是抽象类,抽象类可以表达概念但是无法构造实体的类。

抽象类和抽象方法内容和规则比较多。这里只提及一些和继承有关的用法和规则:

  • 抽象类也是类,如果一个类继承于抽象类,就不能继承于其他的(类或抽象类)
  • 子类可以继承于抽象类,但是一定要实现父类们所有abstract的方法。如果不能完全实现,那么子类也必须被定义为抽象类
  • 只有实现父类的所有抽象方法,才能是完整类。

image-20201027205344230

比如我们可以这样设计一个People抽象类以及一个抽象方法,在子类中具体完成:

abstract class People{
    public abstract void sayHello();//抽象方法
}
class Chinese extends People{
    @Override
    public void sayHello() {//实现抽象方法
        System.out.println("你好");
    }
}
class Japanese extends People{
    @Override
    public void sayHello() {//实现抽象方法
        System.out.println("口你七哇");
    }
}
class American extends People{
    @Override
    public void sayHello() {//实现抽象方法
        System.out.println("hello");
    }
}

Object类和转型

提到Java继承,不得不提及所有类的根类:Object(java.lang.Object)类,如果一个类没有显式声明它的父类(即没有写extends xx),那么默认这个类的父类就是Object类,任何类都可以使用Object类的方法,创建的类也可和Object进行向上、向下转型,所以Object类是掌握和理解继承所必须的知识点。而Java向上和向下转型在Java中运用很多,也是建立在继承的基础上,所以Java转型也是掌握和理解继承所必须的知识点。

Object类概述

  1. Object是类层次结构的根类,所有的类都隐式的继承自Object类。
  2. Java所有的对象都拥有Object默认方法
  3. Object类的构造方法有一个,并且是无参构造

Object是java所有类的父类,是整个类继承结构的顶端,也是最抽象的一个类。像toString()、equals()、hashCode()、wait()、notify()、getClass()等都是Object的方法。你以后可能会经常碰到,但其中遇到更多的就是toString()方法和equals()方法,我们经常需要重写这两种方法满足我们的使用需求。

toString()方法表示返回该对象的字符串,由于各个对象构造不同所以需要重写,如果不重写的话默认返回类名@hashCode格式。

如果重写toString()方法后直接调用toString()方法就可以返回我们自定义的该类转成字符串类型的内容输出,而不需要每次都手动的拼凑成字符串内容输出,大大简化输出操作。

equals()方法主要比较两个对象是否相等,因为对象的相等不一定非要严格要求两个对象地址上的相同,有时内容上的相同我们就会认为它相等,比如String 类就重写了euqals()方法,通过字符串的内容比较是否相等。

image-20201029174821212

向上转型

向上转型 : 通过子类对象(小范围)实例化父类对象(大范围),这种属于自动转换。用一张图就能很好地表示向上转型的逻辑:

image-20201029150415474

父类引用变量指向子类对象后,只能使用父类已声明的方法,但方法如果被重写会执行子类的方法,如果方法未被重写那么将执行父类的方法。

向下转型

向下转型 : 通过父类对象(大范围)实例化子类对象(小范围),在书写上父类对象需要加括号()强制转换为子类类型。但父类引用变量实际引用必须是子类对象才能成功转型,这里也用一张图就能很好表示向上转型的逻辑:

image-20201029150242786

子类引用变量指向父类引用变量指向的对象后(一个Son()对象),就完成向下转型,就可以调用一些子类特有而父类没有的方法 。

在这里写一个向上转型和向下转型的案例:

Object object=new Integer(666);//向上转型

Integer i=(Integer)object;//向下转型Object->Integer,object的实质还是指向Integer

String str=(String)object;//错误的向下转型,虽然编译器不会报错但是运行会报错

子父类初始化顺序

在Java继承中,父子类初始化先后顺序为:

  1. 父类中静态成员变量和静态代码块
  2. 子类中静态成员变量和静态代码块
  3. 父类中普通成员变量和代码块,父类的构造函数
  4. 子类中普通成员变量和代码块,子类的构造函数

总的来说,就是静态>非静态,父类>子类,非构造函数>构造函数。同一类别(例如普通变量和普通代码块)成员变量和代码块执行从前到后,需要注意逻辑。

这个也不难理解,静态变量也称类变量,可以看成一个全局变量,静态成员变量和静态代码块在类加载的时候就初始化,而非静态变量和代码块在对象创建的时候初始化。所以静态快于非静态初始化。

而在创建子类对象的时候需要先创建父类对象,所以父类优先于子类。

而在调用构造函数的时候,是对成员变量进行一些初始化操作,所以普通成员变量和代码块优于构造函数执行。

至于更深层次为什么这个顺序,就要更深入了解JVM执行流程啦。下面一个测试代码为:

class Father{
    public Father() {
        System.out.println(++b1+"父类构造方法");
    }//父类构造方法 第四
    static int a1=0;//父类static 第一 注意顺序
    static {
        System.out.println(++a1+"父类static");
    }
    int b1=a1;//父类成员变量和代码块 第三
    {
        System.out.println(++b1+"父类代码块");
    }
}
class Son extends Father{
    public Son() {
        System.out.println(++b2+"子类构造方法");
    }//子类构造方法 第六
    static {//子类static第二步
        System.out.println(++a1+"子类static");
    }
    int b2=b1;//子类成员变量和代码块 第五
    {
        System.out.println(++b2 + "子类代码块");
    }
}
public class test9 {
    public static void main(String[] args) {
        Son son=new Son();
    }
}

执行结果:

image-20201029230721006

结语

好啦,本次继承就介绍到这里啦,Java面向对象三大特征之一继承——优秀的你已经掌握。再看看Java面向对象三大特性:封装、继承、多态。最后问你能大致了解它们的特征嘛?

封装:是对类的封装,封装是对类的属性和方法进行封装,只对外暴露方法而不暴露具体使用细节,所以我们一般设计类成员变量时候大多设为私有而通过一些get、set方法去读写。

继承:子类继承父类,即“子承父业”,子类拥有父类除私有的所有属性和方法,自己还能在此基础上拓展自己新的属性和方法。主要目的是复用代码

多态:多态是同一个行为具有多个不同表现形式或形态的能力。即一个父类可能有若干子类,各子类实现父类方法有多种多样,调用父类方法时,父类引用变量指向不同子类实例而执行不同方法,这就是所谓父类方法是多态的。

最后送你一张图捋一捋其中的关系吧。

image-20201104163238242

原创不易,bigsai请你帮两件事帮忙一下:

  1. 一键三连支持一下, 您的肯定是我在平台创作的源源动力。
  2. 微信搜索「bigsai」,关注我的公众号(原创干货博主),不仅免费送你电子书,我还会第一时间在公众号分享知识技术。加我还可拉你进力扣打卡群一起打卡LeetCode。
  3. 最近在将历史文章和图解整理成电子书,即将第一时间给大家免费放送。

image.png

查看原文

赞 0 收藏 0 评论 0

bigsai 发布了文章 · 2020-11-14

【八大排序算法】16张图带你彻底搞懂基数排序

原创公众号:bigsai 转载需联系作者

前言

在排序算法中,大家可能对桶排序、计数排序、基数排序不太了解,不太清楚其算法的思想和流程,也可能看过会过但是很快就忘记了,但是不要紧,幸运的是你看到了本篇文章。本文将通俗易懂的给你讲解基数排序。

基数排序,是一种原理简单,但实现复杂的排序。很多人在学习基数排序的时候可能会遇到以下两种情况而浅尝辄止:

  • 一看原理,这么简单,懂了懂了(顺便溜了)
  • 再一看代码,这啥啥啥啊?这些的肯定有问题(不看溜了)

    image-20201113205712629

要想深入理解基数排序,必须搞懂基数排序各种形式(数字类型、等长字符类型、不等长字符)各自实现方法,了解其中的联系和区别,并且也要掌握空间优化的方法(非二维数组而仅用一维数组)。下面跟着我详细学习基数排序吧!

基数排序原理

首先百度百科看看基数排序的定义:

基数排序(radix sort)属于“分配式排序”(distribution sort),又称“桶子法”(bucket sort)或bin sort,顾名思义,它是透过键值的部份资讯,将要排序的元素分配至某些“桶”中,藉以达到排序的作用,基数排序法是属于稳定性的排序,基数排序法的效率高于其它的稳定性排序法。

基数排序也称为卡片排序,简而言之,基数排序的原理就是多次利用计数排序(计数排序是一种特殊的桶排序),但是和前面的普通桶排序和计数排序有所区别的是,基数排序并不是将一个整体分配到一个桶中,而是将自身拆分成一个个组成的元素,每个元素分别顺序分配放入桶中、顺序收集,当从前往后或者从后往前每个位置都进行过这样顺序的分配、收集后,就获得了一个有序的数列。

在具体实现上如果从左往右那就是最高位优先(Most Significant Digit first)法,简称MSD法;如果从右往左那就是最低位优先(Least Significant Digit first)法,简称LSD法。但是不管从最高位开始还是从最低位开始要保证和相同位进行比较,你需要注意的是如果是int等数字类型需要保证从右往左(从低位到高位)保证对齐,如果是字符类型的话需要从左往右(从高位到低位)保证对齐。

image-20201113154119682

你可能会问为啥不直接将这个数或者这个数按照区间范围放到对应的桶中,一方面基数排序可能很多时候处理的是字符型的数据,不方便放入某个桶中,另一方面如果数字很大,不方便直接放入桶中。并且基数排序并不需要交换,也不需要比较,就是多次分配、收集得到结果。

image-20201113150949762

所以遇到这种情况我们基数排序思想很简单,就拿 934,241,3366,4399这几个数字进行基数排序的一趟过程来看,第一次会根据各位进行分配、收集:

image-20201113161050871

分配和收集都是有序的,第二次会根据十位进行分配、收集,此次是在第一次个位分配、收集基础上进行的,所以所有数字单看个位十位是有序的。

image-20201113161752292

而第三次就是对百位进行分配收集,此次完成之后百位及其以下是有序的。

image-20201113162803486

而最后一次的时候进行处理的时候,千位有的数字需要补零,这次完毕后后千位及以后都有序,即整个序列排序完成。

image-20201113170715860

想必看到这里基数排序的思想你也已经懂了吧,但是虽然懂你不一定能够写出代码来,继续看看下面的分析和实现。

数字类型基数排序

有很多时候也有很多时候对基数排序的讲解也是基于数字类型的,而数字类型这里就用int来实现,对于数字类型的基数排序你需要注意的有以下几点:

  • 无论是最高位优先法还是最低位优先法进行遍历需要保证数字各位、十位、百位等对齐,这里我使用最低位优先法从个位开始向上。
  • 数字类型的基数排序需要十个桶(0-9),你可以使用二维数组,第一维度长度为10表示十个数字,第二个维度为数组长度,用来存储数字(因为最坏情况可能当前位数字一样)。但这样无疑太浪费内存空间了,你可以使用List或者Queue替代,这里就用List了。
  • 具体实现要先找到最大值确定最高多少位,用来进行遍历时候确认。
  • 收集的时候借助一个自增参数遍历收集。
  • 每次收集完毕十个桶(bucket)需要清空待下次收集。

实现的代码为:

static void radixSort(int[] arr)//int 类型 从右往左
{
  List<Integer>bucket[]=new ArrayList[10];
  for(int i=0;i<10;i++)
  {
    bucket[i]=new ArrayList<Integer>();
  }
  //找到最大值
  int max=0;//假设都是正数
  for(int i=0;i<arr.length;i++)
  {
    if(arr[i]>max)
      max=arr[i];
  }
  int divideNum=1;//1 10 100 100……用来求对应位的数字
  while (max>0) {//max 和num 控制
    for(int num:arr)
    {
      bucket[(num/divideNum)%10].add(num);//分配 将对应位置的数字放到对应bucket中
    }
    divideNum*=10;
    max/=10;
    int idx=0;
    //收集 重新捡起数据
    for(List<Integer>list:bucket)
    {
      for(int num:list)
      {
        arr[idx++]=num;
      }
      list.clear();//收集完需要清空留下次继续使用
    }
  }
}

等长字符串基数排序

除了数字之外,等长字符串也是常常遇到的方式,其主要方法和数字类型差不多,这里也看不出策略上的不同。低位优先法或者高位优先法都可使用(这里依旧低位从右向左)。

image-20201113182852797

在实现细节方面,和前面的数字类型区别不是很大,但是因为字符串是等长的遍历更加方便容易。但需要额外注意的是:

  • 字符类型的桶bucket不是10个而是ASCII字符的个数(根据实际需要查看ASCII表)。其实就是利用char和int之间关系可以直接按照每个字符进行顺序存储。

具体实现代码为:

static void radixSort(String arr[],int len)//等长字符排序情况 长度为len
{
  List<String>buckets[]=new ArrayList[128];
  for(int i=0;i<128;i++)
  {
    buckets[i]=new ArrayList<String>();
  }
  for(int i=len-1;i>=0;i--)//每一位上进行操作
  {
    for(String str:arr)
    {
      buckets[str.charAt(i)].add(str);//分配
    }
    int idx=0;
    for(List<String>list:buckets)
    {
      for(String str:list)
      {
        arr[idx++]=str;//收集
      }
      list.clear();//收集完该bucket清空
    }
  }
}

非等长字符串基数排序

等长的字符串进行基数排序时候很好遍历,那么非等长的时候该如何考虑呢?这种非等长不能像处理数字那样粗暴的计算当成0即可。字符串的大小是从前往后进行排列的(和长度没关系)。例如看下面字符串,“d”这个字符串即使很短但是在排序依然放在最后面。你知道该怎么处理吗?

"abigsai"
"bigsai"
"bigsaisix"
"d"

如果高位优先,前面一旦比较过各个字符的桶(bucket)就要固定下来,也就是在进行右面下一个字符分配、收集的时候要标记空间,即下次进行分配收集的前面是‘a’字符的一组,‘b’字符一组,并且不能越界,实现起来很麻烦这里就不详细讲解了有兴趣的可以自行研究一下。

而本篇实现的是低位优先。低位优先采用什么思路呢?很简单,跟我看图解。

第一步,先将字符按照长度进行分配到一个桶(bucket)中,声明一个List<String>wordLen[maxlen+1];在遍历字符时候,以字符长度为下表index,将字符串顺序加入进去。其中maxlen为最长字符串长度,之所以要maxlen+1是因为需要使用maxlen下标(0-maxlen)。

image-20201113190245500

第二步,分配完成遍历收集到原数组中,这样原数组在长度上相对有序

image-20201113190606255

这样就可以进行基数排序啦,当然,在开始的时候并不是全部都进行分配收集,而是根据长度慢慢递减,长度可以到达6位分配、收集,长度到达5的分配、收集……长度为1的进行分配、收集。这样进行一遭就很完美的进行完基数排序,因为我们借助根据长度收集的桶可以很容易知道当前长度开始的index在哪里。

image-20201113192740924

具体实现的代码为:

static void radixSort(String arr[])//字符不等长的情况进行排序
{
    //找到最长的那个
    int maxlen=0;
    for(String team:arr)
    {
        if(team.length()>maxlen)
            maxlen=team.length();
    }
    //一个对长度分  一个对具体字符分,先用长度来找到
    List<String>wordLen[]=new ArrayList[maxlen+1];//用长度先统计各个长度的单词
    List<String>bucket[]=new ArrayList[128];//根据字符来划分
    for(int i=0;i<wordLen.length;i++)
        wordLen[i]=new ArrayList<String>();
    for(int i=0;i<bucket.length;i++)
        bucket[i]=new ArrayList<String>();
    //先根据长度来一下
    for(String team:arr)
    {
        wordLen[team.length()].add(team);
    }
    int index=0;//先进行一次(按照长度分)的桶排序使得数组长度初步有序
    for(List<String>list:wordLen)
    {
        for(String team:list)
        {
            arr[index++]=team;
        }
    }
    //然后 先进行长的 从后往前进行
    int startIndex=arr.length;
    for(int len=maxlen;len>0;len--)//每次长度相同的要进行基数一次
    {
        int preIndex=startIndex;
        startIndex-=wordLen[len].size();
        for(int i=startIndex;i<arr.length;i++)
        {
            bucket[arr[i].charAt(len-1)].add(arr[i]);//利用字符桶重新装
        }
        //重新收集
        index=startIndex;
        for(List<String>list:bucket)
        {
            for(String str:list)
            {
                arr[index++]=str;
            }
            list.clear();
        }
    }
}

空间优化(等长字符)基数排序

上面无论是等长还是不等长,使用的空间其实都是跟二维相关的,我们能不能使用一维的空间去解决这个问题呢?当然能啊。

在使用空间的整个思路是差不多的,但是这里为了让你能够理解我们在讲解的时候讲解等长字符串的情况

先回忆刚刚讲的等长字符串,就是从个位进行遍历,在遍历的时候将数据放到对应的桶里面,然后在进行收集的时候放回原数组。

image-20201113195501579

你能否发现什么规律

  • 一个字符串收集的时候放的位置其实它只需要知道它前面有多少个就可以确定
  • 并且当前位置字符如果相同那么就是根据arr中相对顺序来进行当前轮。

所以我们可以尝试来动态维护这个int bucket[]。第一次进行只记录次数,第二次进行叠加表示比当前位置+1编号小的元素的个数。

image-20201113200950104

但是这样处理不太好知道比当前位置小的有多少,所以我们在分配的时候向下挪一位,这样bucket[i]就可以表示比当前位置小的元素的个数。

image-20201113201809349

我们在进行收集的时候需要再次遍历arr,但我们需要一个临时数组String value[]储存结果(因为arr没遍历完后面不能使用),而进行遍历的规则就是:遍历arr时候对应字符串str,该位字符对应bucket[str.charAt(i)]桶中数字就是要放到arr中的编号(多少个比它小的就放到第多少位),放置之后要对bucket当前位自增(因为下一个这个位置字符串要把这个str考虑进去)。这样到最后即可完成排序。

第一趟遍历arr前两个字符串部分过程如下:

image-20201113203419472

第一趟中间两个字符串处理情况:

image-20201113203931193

第一趟最后两个字符串处理情况:

image-20201113204444889

就这样即可完成一趟操作,一趟完成记得将value的值赋值到arr中,当然有方法使用指针引用可以避免交换数据带来的时间影响,但这里为了使大家更加简单理解就直接复制过去。这样完成若干次,整个基数排序即可完成。

具体实现的代码为:

static void radixSortByArr(String arr[],int len)//固定长度的使用数组进行优化
{
    int charLen=129;//多用一个

    String value[]=new String[arr.length];
    for(int index=len-1;index>=0;index--)//不同的位置
    {
        int bucket[]=new int[charLen];//储存character的桶
        for(int i=0;i<arr.length;i++)//分配
        {
            bucket[(int)(arr[i].charAt(index)+1)]++;
        }
        for(int i=1;i<bucket.length;i++)//叠加 当前i位置表示比自己小的个数
        {
            bucket[i]+=bucket[i-1];
        }

        for(int i=0;i<arr.length;i++)
        {
            value[bucket[arr[i].charAt(index)]++]=arr[i];//中间的++因为当前位置填充了一个,下次再来同元素就要后移
        }
        System.arraycopy(value,0,arr,0,arr.length);//copy数组
    }
}

至于不定长的,思路也差不多,这里就留给你优秀的你自己去思考啦。

结语

至于基数排序的算法分析,以定长的情况分析,假设有n数字(字符串),每个有k位,那么根据基数就要每一位都遍历就是K次,每次都是O(n)级别。所以差不多是O(n*k)级别,当然k远远小于n,可能有成千上万个数,但是每个数或者字符正常可没成千上万那么长。

本次基数排序就全讲完啦,那么多张图我想你也应该懂了。

最后我请你们两连事帮忙一下:

  1. 点赞、关注一下支持, 您的肯定是我在平台创作的源源动力。
  2. 微信搜索「bigsai」,关注我的公众号,不仅免费送你电子书,我还会第一时间在公众号分享知识技术。加我还可拉你进力扣打卡群一起打卡LeetCode。

记得关注、咱们下次再见!

image-20201114211553660

查看原文

赞 0 收藏 0 评论 0

bigsai 发布了文章 · 2020-11-05

「算法分析」图解双轴快排

原创公众号:bigsai

前言

在排序算法中,快排是占比非常多的一环,但是快排其思想一直被考察研究,也有很多的优化方案。这里主要讲解双轴快排的思想和实现。

首选,双轴快排也是一种快排的优化方案,在JDK的Arrays.sort()中被主要使用。所以,掌握快排已经不能够满足我们的需求,我们还要学会双轴快排的原理和实现才行。

回顾单轴快排

单轴快排也就是我们常说的普通快速排序,对于快速排序我想大家应该都很熟悉:基于递归和分治的,时间复杂度最坏而O(n2),最好和平均情况为O(nlogn).

而快排的具体思路也很简单,每次在待排序序列中找一个数(通常最左侧多一点),然后在这个序列中将比他小的放它左侧,比它大的放它右侧。

image-20201104195402101

如果运气肯不好遇到O(n)平方的,那确实就很被啦:

image-20201104200411750

实现起来也很容易,这里直接贴代码啦:

private static void quicksort(int [] a,int left,int right)
{
  int low=left;
  int high=right;
  //下面两句的顺序一定不能混,否则会产生数组越界!!!very important!!!
  if(low>high)//作为判断是否截止条件
    return;
  int k=a[low];//额外空间k,取最左侧的一个作为衡量,最后要求左侧都比它小,右侧都比它大。
  while(low<high)//这一轮要求把左侧小于a[low],右侧大于a[low]。
  {
    while(low<high&&a[high]>=k)//右侧找到第一个小于k的停止
    {
      high--;
    }
    //这样就找到第一个比它小的了
    a[low]=a[high];//放到low位置
    while(low<high&&a[low]<=k)//在low往右找到第一个大于k的,放到右侧a[high]位置
    {
      low++;
    }
    a[high]=a[low];            
  }
  a[low]=k;//赋值然后左右递归分治求之
  quicksort(a, left, low-1);
  quicksort(a, low+1, right);        
}

双轴快排分析

咱们今天的主题是双轴快排,双轴和单轴的区别你也可以知道,多一个轴,前面讲了快排很多时候选最左侧元素以这个元素为轴将数据划分为两个区域,递归分治的去进行排序。但单轴很多时候可能会遇到较差的情况就是当前元素可能是最大的或者最小的,这样子元素就没有被划分区间,快排的递推T(n)=T(n-1)+O(n)从而为O(n2).

双轴就是选取两个主元素理想将区间划为3部分,这样不仅每次能够确定元素个数增多为2个,划分的区间由原来的两个变成三个,最坏最坏的情况就是左右同大小并且都是最大或者最小,但这样的概率相比一个最大或者最小还是低很多很多,所以双轴快排的优化力度还是挺大的。

总体情况分析

至于双轴快排具体是如何工作的呢?其实也不难理解,这里通过一系列图讲解双轴快排的执行流程。

首先在初始的情况我们是选取待排序区间内最左侧、最右侧的两个数值作为pivot1pivot2 .作为两个轴的存在。同时我们会提前处理数组最左侧和最右侧的数据会比较将最小的放在左侧。所以pivot1<pivot2.

而当前这一轮的最终目标是,比privot1小的在privot1左侧,比privot2大的在privot2右侧,在privot1和privot2之间的在中间。

image-20201104203647728

这样进行一次后递归的进行下一次双轴快排,一直到结束,但是在这个执行过程应该去如何处理分析呢?需要几个参数呢?

  • 假设知道排序区间[start,end]。数组为arr, pivot1=arr[start],pivot2=arr[end]
  • 还需要三个参数left,right和k。 l
  • left初始为start,[start,left]区域即为小于等于pivot1小的区域(第一个等于)。
  • right与left对应,初始为end,[right,end]为大于等于pivot2的区域(最后一个等于)。
  • k初始为start+1,是一个从左往右遍历的指针,遍历的数值与pivot1,pivot2比较进行适当交换,当k>=right即可停止。

image-20201104210629951

k交换过程

然后你可能会问k遍历时候究竟怎么去交换?left和right该如何处理呢?不急我带你慢慢分析,首先K是在left和right中间的,遍历k的位置和pivot1,pivot2进行比较:

如果arr[k]<pivot1,那么先++left,然后swap(arr,k,left),因为初始在start在这个过程不结束start先不动。然后k++;继续进行

image-20201104211550402

而如果arr[k]>pivot2.(区间自行安排即可)有点区别的就是right可能连续的大于arr[k],比如9 3 3 9 7如果我们需要跳过7前面9到3才能正常交换,这和快排的交换思想一致,当然再具体的实现上就是right--到一个合适比arr[k]小的位置。然后swap(arr,k,right)切记此时k不能自加。因为带交换的那个有可能比pivot1还小要和left交换。

image-20201104212644648

如果是介于两者之间,k++即可

收尾工作

在执行完这一趟即k=right之后,即开始需要将pivot1和pivot2的数值进行交换

swap(arr, start, left);
swap(arr, end, right);

image-20201104213550250

然后三个区间根据编号递归执行排序函数即可。

双轴快排代码

在这里,分享下个人实现双轴快排的代码:

import java.util.Arrays;

public class 双轴快排 {
    public static void main(String[] args) {
        int a[]= {7,3,5,4,8,5,6,55,4,333,44,7,885,23,6,44};
        dualPivotQuickSort(a,0,a.length-1);
        System.out.println(Arrays.toString(a));
    }

    private static void dualPivotQuickSort(int[] arr, int start, int end) {
        if(start>end)return;//参数不对直接返回
        if(arr[start]>arr[end])
            swap(arr, start, end);
        int pivot1=arr[start],pivot2=arr[end];//储存最左侧和最右侧的值
        //(start,left]:左侧小于等于pivot1 [right,end)大于pivot2
        int left=start,right=end,k=left+1;
        while (k<right) {
            //和左侧交换
            if(arr[k]<=pivot1)
            {
                //需要交换
                swap(arr, ++left, k++);
            }
            else if (arr[k]<=pivot2) {//在中间的情况
                k++;
            }
            else {
                while (arr[right]>=pivot2) {//如果全部小于直接跳出外层循环

                    if(right--==k)
                        break ;
                }
                if(k>=right)break ;
                swap(arr, k, right);
            }
        }
        swap(arr, start, left);
        swap(arr, end, right);
        dualPivotQuickSort(arr, start, left-1);
        dualPivotQuickSort(arr, left+1, right-1);
        dualPivotQuickSort(arr, right+1, end);
    }
    static void swap(int arr[],int i,int j)
    {
        int team=arr[i];
        arr[i]=arr[j];
        arr[j]=team;
    }
}

执行结果为:

image-20201104213745893

好啦,到这里双轴快排就实现完成啦。至于算法分析,希望在评论区和你们讨论哦!

原创不易,欢迎关注「bigsai」回复bigsai领取Java进阶资料:

image-20201104213959836

查看原文

赞 0 收藏 0 评论 0

bigsai 发布了文章 · 2020-11-01

看了这篇终于搞透快速幂算法

前言

快速幂是什么?

  • 顾名思义,快速幂就是快速算底数的n次幂。

有多快?

  • 其时间复杂度为 O(log₂n), 与朴素的O(n)相比效率有了极大的提高。

用的多么?

  • 快速幂属于数论的范畴,本是ACM经典算法,但现在各厂对算法的要求越来越高,并且快速幂适用场景也比较低多并且相比朴素方法有了非常大的提高。所以掌握快速幂算法已经是一名更合格的工程师必备要求!

下面来详细看看快速幂算法吧!

快速幂介绍

先看个问题再说:

初探

首先问你一个问题,如果让你求 (2^10)%1000你可能会这样写:

int va=1;
for(int i=0;i<10;i++)
{
  va*=2;
}
System.out.println(va%10000);

熟悉的1024没问题,总共计算了10次。但是如果让你算 (2^50)%10000呢?

你可能会窃喜,小样,这就想难住我?我知道int只有32位,50位超出范围会带来数值越界的异常,我这次可以用long,long有64位呢!

long va=1;
for(int i=0;i<50;i++)
{
  va*=2;
}
System.out.println(va);
System.out.println(va%10000);

计算50次出了结果正当你暗暗私喜的时候又来了一个要命的问题:让你算 (2^1e10)%10000 且不许你用Java大数类,你为此苦恼不知所措。这时bigsai小哥哥让你百度下取模运算,然后你恍然大悟,在纸上写了几个公式:

(a + b) % p = (a % p + b % p) % p  (1)
(a - b) % p = (a % p - b % p ) % p (2)
(a * b) % p = (a % p * b % p) % p  (3)
a ^ b % p = ((a % p)^b) % p        (4)

你还算聪明一眼发现其中的规律:

(a * b) % p = (a % p * b % p) % p   (3)
(2*2*2···*2) %1e10=[2*(2*2···*2)]%1e5=(2%1e5)*(2*2···*2%le5)%1e5

凭借这个递推你明白:每次相乘都取模。机智的你pia pia写下以下代码,却发现另一个问题:怎么跑不出来?

image-20201028160221192

咱们打工人需要对计算机运行速度和数值有一个大致的概念。循环体中不同操作占用时间不同,所以当你的程序循环次数到达1e6或1e7的时候就需要非常非常小心了。如果循环体逻辑或者运算较多可能非常非常慢。

image-20201028163737620

快速幂探索

机智的你不甘失败,开始研究其数的规律,将这个公式写在手上、膀子上、小纸条上。吃饭睡觉都在看:

image-20201028171029641

然后你突然发现其中的奥秘,n次幂可以拆分成一个平方计算后就剩余n/2的次幂了:

image-20201028174250098

现在你已经明白了快速幂是怎么回事,但你可能有点上头,还是给我讲了很多内容:

image-20201028180224832

快速幂实现

至于快速幂已经懂了,我们该怎么实现这个算法呢?

image-20201028185101226

说的不错,确实有递归和非递归的实现方式,但是递归使用的更多一些。在实现的时候,注意一下奇偶性、停止条件就可以了,奇数问题可以转换为偶数问题:

2*2*2*2*2=2 * (2*2*2*2) 奇数问题可以转化为偶数问题。

这里,递归的解法如下

long c=10000007;
public  long divide(long a, long b) {
        if (b == 0)
            return 1;
        else if (b % 2 == 0) //偶数情况
            return divide((a % c) * (a % c), b / 2) % c;
    else//奇数情况
            return a % c * divide((a % c) * (a % c), (b - 1) / 2) % c;
    }

非递归实现也不难,控制好循环条件即可:

//求 a^b%1000000007
long c = 1000000007;
public  long divide(long a, long b) {
  a %= c;
  long res = 1;
  for (; b != 0; b /= 2) {
    if (b % 2 == 1)
      res = (res * a) % c;
    a = (a * a) % c;
  }
  return res;
}

对于非递归你可能有点模糊为啥偶数情况不给res赋值。这里有两点:

  • 为奇数的情况res仅仅是收集相乘那个时候落单的a
  • 最终b均会降到1,a最终都会和res相乘,不用担心会漏掉
  • 理想状态一直是偶数情况,那最后直接获得a取模的值即可。

如果还是不懂,可以用这个图来解释一下:

image-20201028192842778

矩阵快速幂

你以为这就结束了?虽然快速幂主要内容就是以上内容,但是总有很多牛人能够发现很有趣的规律—矩阵快速幂。如果你没听过的话建议仔细看看了解一下。

大家都知道斐波那契数列: 的规则:

image-20201028193231170

前几个斐波那契的数列为:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, …

斐波那契从递推式就可以看出是指数级别的增长,所以稍微多几个数字就是爆炸式增长,所以很多时候也会要求最后几位的结果。有了前面模运算公式溢出就不成问题,但n如果非常非常大怎么快速计算就成了一个新的问题。

我们看下面一组公式:

f(n+1) = f(n)   + f(n-1)
f(n)   = f(n)

如果那f(n)和f(n-1)放到一个矩阵中(一行两列):[f(n+1),f(n)] 能否找到和[f(n),f(n-1)]之间的什么规律呢?

答案是存在规律的,看上面的公式知道

[f(n+1),f(n)]
=[f(n)+f(n-1),f(n)]

                 [1  1]
=[f(n),f(n-1)]  *      
                 [1  0]
                 
                 [1  1] [1   1]
=[f(n-1),f(n-2)]*      *
                 [1  0] [1   1]  

=·······           

所以现在你可以知道它的规律了吧,这样一直迭代到f(2),f(1)刚好都为1,所以这个斐波那契的计算为:

image-20201028195631635

而这个矩阵有很多次幂,就可以使用快速幂啦,原理一致,你只需要写一个矩阵乘法就可以啦,下面提供一个矩阵快速幂求斐波那契第n项的后三位数的模板,可以拿这个去试一试poj3070的题目啦。

public int Fibonacci(int n)
    {
        n--;//矩阵为两项
        int a[][]= {{1,1},{1,0}};//进行快速幂的矩阵
        int b[][]={{1,0},{0,1}};//存储漏单奇数、结果的矩阵,初始为单位矩阵
        int time=0;
        while(n>0)
        {
            if(n%2==1)
            {
                b=matrixMultiplication(a, b);
            }
            a=matrixMultiplication(a, a);
            n/=2;
        }
        return b[0][0];
    }
 public  int [][]matrixMultiplication(int a[][],int b[][]){//
        int x=a.length;//a[0].length=b.length 为满足条件
        int y=b[0].length;//确定每一排有几个
        int c[][]=new int [x][y];
        for(int i=0;i<x;i++)
            for(int j=0;j<y;j++)
            {
                //需要确定每一个元素
                //c[i][j];
                for(int t=0;t<b.length;t++)
                {
                    c[i][j]+=(a[i][t]%10000)*(b[t][j]%10000);
                    c[i][j]%=10000;
                }
            }
        return c;
    }

结语

这篇到这里就肝完啦,其实快速幂的内容还不止这么多,尤其是矩阵快速幂,会有着各种巧妙的变形,不过跟数学有一些关系,这年头,不会点算法、不会点数学真的是举步维艰。所以大家要对本篇内容好好吸收,让我那么久的努力发挥出作用。

如果有疑问不懂得欢迎私聊我讨论。也希望大家点个在看,您的支持是我努力的不断动力。

关注bigsai,回复bigsai领取干货资源,回复进群加入力扣打卡群。下次再见,打工人!

image-20201028210802464

image-20201028210842709

查看原文

赞 0 收藏 0 评论 0

bigsai 发布了文章 · 2020-10-24

用python实现豆瓣短评通用爬虫(登录、爬取、可视化分析)

原创技术公众号:bigsai

前言

在本人上的一门课中,老师对每个小组有个任务要求,介绍和完成一个小模块、工具知识的使用。然而我所在的组刚好遇到的是python爬虫的小课题。

心想这不是很简单嘛,搞啥呢?想着去搞新的时间精力可能不太够,索性自己就把豆瓣电影的评论(短评)搞一搞吧。

之前有写过哪吒那篇类似的,但今天这篇要写的像姨母般详细。本篇主要实现的是对任意一部电影短评(热门)的抓取以及可视化分析。 也就是你只要提供链接和一些基本信息,他就可以
在这里插入图片描述

分析

对于豆瓣爬虫,what shold we 考虑?怎么分析呢?豆瓣电影首页

这个首先的话尝试就可以啦,打开任意一部电影,这里以姜子牙为例。打开姜子牙你就会发现它是非动态渲染的页面,也就是传统的渲染方式,直接请求这个url即可获取数据。但是翻着翻着页面你就会发现:未登录用户只能访问优先的界面,登录的用户才能有权限去访问后面的页面。

image-20201022195020410

所以这个流程应该是 登录——> 爬虫——>存储——>可视化分析

这里提一下环境和所需要的安装装,环境为python3,代码在win和linux可成功跑,如果mac和linux不能跑友字体乱码问题还请私我。其中pip用到包如下,直接用清华 镜像下载不然很慢很慢(够贴心不)。

pip install requests -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install numpy -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install xlrd -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install xlwt -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install bs4 -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install lxml -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install wordcloud -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install jieba -i https://pypi.tuna.tsinghua.edu.cn/simple

登录

豆瓣的登录地址

进去后有个密码登录栏,我们要分析在登录的途中发生了啥,打开F12控制台是不够的,我们还要使用Fidder抓包。

在这里插入图片描述

打开F12控制台然后点击登录,多次试探之后发现登录接口也很简单:

在这里插入图片描述

查看请求的参数发现就是普通请求,无加密,当然这里可以用fidder进行抓包,这里我简单测试了一下用错误密码进行测试。如果失败的小伙伴可以尝试手动登陆再退出这样再跑程序。

image-20201022195625220

这样编写登录模块的代码:

url='https://accounts.douban.com/j/mobile/login/basic'
header={'user-agent':'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36',
'Referer': 'https://accounts.douban.com/passport/login_popup?login_source=anony',
        'Origin': 'https://accounts.douban.com',
 'content-Type':'application/x-www-form-urlencoded',
 'x-requested-with':'XMLHttpRequest',
 'accept':'application/json',
 'accept-encoding':'gzip, deflate, br',
 'accept-language':'zh-CN,zh;q=0.9',
 'connection': 'keep-alive'
 ,'Host': 'accounts.douban.com'
 }
data={
    'ck':'',
    'name':'',
    'password':'',
    'remember':'false',
    'ticket':''
}
def login(username,password):
    global  data
    data['name']=username
    data['password']=password
    data=urllib.parse.urlencode(data)
    print(data)
    req=requests.post(url,headers=header,data=data,verify=False)
    cookies = requests.utils.dict_from_cookiejar(req.cookies)
    print(cookies)
    return cookies

这块高清之后,整个执行流程大概为:
在这里插入图片描述

爬取

成功登录之后,我们就可以携带登录的信息访问网站为所欲为的爬取信息了。虽然它是传统交互方式,但是每当你切换页面时候会发现有个ajax请求。
在这里插入图片描述
这部分接口我们可以直接拿到评论部分的数据,就不需要请求整个页面然后提取这部分的内容了。而这部分的url规律和之前分析的也是一样,只有一个start表示当前的条数在变化,所以直接拼凑url就行。

也就是用逻辑拼凑url一直到不能正确操作为止。

https://movie.douban.com/subject/25907124/comments?percent_type=&start=0&其他参数省略
https://movie.douban.com/subject/25907124/comments?percent_type=&start=20&其他参数省略
https://movie.douban.com/subject/25907124/comments?percent_type=&start=40&其他参数省略

对于每个url访问之后如何提取信息呢?
我们根据css选择器进行筛选数据,因为每个评论他们的样式相同,在html中就很像一个列表中的元素一样。

再观察我们刚刚那个ajax接口返回的数据刚好是下面红色区域块,所以我们直接根据class搜素分成若干小组进行曹祖就可以。

在这里插入图片描述

在具体的实现上,我们使用requests发送请求获取结果,使用BeautifulSoup去解析html格式文件。
而我们所需要的数据也很容易分析对应部分。

image-20201022210917778

实现的代码为:

import requests
from  bs4 import BeautifulSoup
url='https://movie.douban.com/subject/25907124/comments?percent_type=&start=0&limit=20&status=P&sort=new_score&comments_only=1&ck=C7di'

header = {
    'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36',
}
req = requests.get(url,headers=header,verify=False)
res = req.json() # 返回的结果是一个json
res = res['html']
soup = BeautifulSoup(res, 'lxml')
node = soup.select('.comment-item')
for va in node:
    name = va.a.get('title')
    star = va.select_one('.comment-info').select('span')[1].get('class')[0][-2]
    comment = va.select_one('.short').text
    votes=va.select_one('.votes').text
    print(name, star,votes, comment)

这个测试的执行结果为:

image-20201022220333519

储存

数据爬取完就要考虑存储,我们将数据储存到cvs中。

使用xlwt将数据写入excel文件中,xlwt基本应用实例:

import xlwt

#创建可写的workbook对象
workbook = xlwt.Workbook(encoding='utf-8')
#创建工作表sheet
worksheet = workbook.add_sheet('sheet1')
#往表中写内容,第一个参数 行,第二个参数列,第三个参数内容
worksheet.write(0, 0, 'bigsai')
#保存表为test.xlsx
workbook.save('test.xlsx')

使用xlrd读取excel文件中,本案例xlrd基本应用实例:

import xlrd
#读取名称为test.xls文件
workbook = xlrd.open_workbook('test.xls')
# 获取第一张表
table =  workbook.sheets()[0]  # 打开第1张表
# 每一行是个元组
nrows = table.nrows
for i in range(nrows):
    print(table.row_values(i))#输出每一行

到这里,我们对登录模块+爬取模块+存储模块就可把数据存到本地了,具体整合的代码为:

import requests
from bs4 import BeautifulSoup
import urllib.parse

import xlwt
import xlrd

# 账号密码
def login(username, password):
    url = 'https://accounts.douban.com/j/mobile/login/basic'
    header = {
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36',
        'Referer': 'https://accounts.douban.com/passport/login_popup?login_source=anony',
        'Origin': 'https://accounts.douban.com',
        'content-Type': 'application/x-www-form-urlencoded',
        'x-requested-with': 'XMLHttpRequest',
        'accept': 'application/json',
        'accept-encoding': 'gzip, deflate, br',
        'accept-language': 'zh-CN,zh;q=0.9',
        'connection': 'keep-alive'
        , 'Host': 'accounts.douban.com'
    }
    # 登陆需要携带的参数
    data = {
        'ck' : '',
        'name': '',
        'password': '',
        'remember': 'false',
        'ticket': ''
    }
    data['name'] = username
    data['password'] = password
    data = urllib.parse.urlencode(data)
    print(data)
    req = requests.post(url, headers=header, data=data, verify=False)
    cookies = requests.utils.dict_from_cookiejar(req.cookies)
    print(cookies)
    return cookies

def getcomment(cookies, mvid):  # 参数为登录成功的cookies(后台可通过cookies识别用户,电影的id)
    start = 0
    w = xlwt.Workbook(encoding='ascii')  # #创建可写的workbook对象
    ws = w.add_sheet('sheet1')  # 创建工作表sheet
    index = 1  # 表示行的意思,在xls文件中写入对应的行数
    while True:
        # 模拟浏览器头发送请求
        header = {
            'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36',
        }
        # try catch 尝试,一旦有错误说明执行完成,没错误继续进行
        try:
            # 拼凑url 每次star加20
            url = 'https://movie.douban.com/subject/' + str(mvid) + '/comments?start=' + str(
                start) + '&limit=20&sort=new_score&status=P&comments_only=1'
            start += 20
            # 发送请求
            req = requests.get(url, cookies=cookies, headers=header)
            # 返回的结果是个json字符串 通过req.json()方法获取数据
            res = req.json()
            res = res['html']  # 需要的数据在`html`键下
            soup = BeautifulSoup(res, 'lxml')  # 把这个结构化html创建一个BeautifulSoup对象用来提取信息
            node = soup.select('.comment-item')  # 每组class 均为comment-item  这样分成20条记录(每个url有20个评论)
            for va in node:  # 遍历评论
                name = va.a.get('title')  # 获取评论者名称
                star = va.select_one('.comment-info').select('span')[1].get('class')[0][-2]  # 星数好评
                votes = va.select_one('.votes').text  # 投票数
                comment = va.select_one('.short').text  # 评论文本
                print(name, star, votes, comment)
                ws.write(index, 0, index)  # 第index行,第0列写入 index
                ws.write(index, 1, name)  # 第index行,第1列写入 评论者
                ws.write(index, 2, star)  # 第index行,第2列写入 评星
                ws.write(index, 3, votes)  # 第index行,第3列写入 投票数
                ws.write(index, 4, comment)  # 第index行,第4列写入 评论内容
                index += 1
        except Exception as e:  # 有异常退出
            print(e)
            break
    w.save('test.xls')  # 保存为test.xls文件


if __name__ == '__main__':
    username = input('输入账号:')
    password = input('输入密码:')
    cookies = login(username, password)
    mvid = input('电影的id为:')
    getcomment(cookies, mvid)

执行之后成功存储数据:

image-20201022221256503

可视化分析

我们要对评分进行统计、词频统计。还有就是生成词云展示。而对应的就是matplotlibWordCloud库。

实现的逻辑思路:读取xls的文件,将评论使用分词处理统计词频,统计出现最多的词语制作成直方图和词语。将评星🌟数量做成饼图展示一下,主要代码均有注释,具体的代码为:

其中代码为:

import matplotlib.pyplot as plt
import matplotlib
import jieba
import jieba.analyse
import xlwt
import xlrd
from wordcloud import WordCloud
import numpy as np
from collections import Counter
# 设置字体 有的linux字体有问题
matplotlib.rcParams['font.sans-serif'] = ['SimHei']
matplotlib.rcParams['axes.unicode_minus'] = False


# 类似comment 为评论的一些数据 [  ['1','名称','star星','赞同数','评论内容']  ,['2','名称','star星','赞同数','评论内容'] ]元组
def anylasescore(comment):
    score = [0, 0, 0, 0, 0, 0]  # 分别对应0 1 2 3 4 5分出现的次数
    count = 0  # 评分总次数
    for va in comment:  # 遍历每条评论的数据  ['1','名称','star星','赞同数','评论内容']
        try:
            score[int(va[2])] += 1  # 第3列 为star星 要强制转换成int格式
            count += 1
        except Exception as e:
            continue
    print(score)
    label = '1分', '2分', '3分', '4分', '5分'
    color = 'blue', 'orange', 'yellow', 'green', 'red'  # 各类别颜色
    size = [0, 0, 0, 0, 0]  # 一个百分比数字 合起来为100
    explode = [0, 0, 0, 0, 0]  # explode :(每一块)离开中心距离;
    for i in range(1, 5):  # 计算
        size[i] = score[i] * 100 / count
        explode[i] = score[i] / count / 10
    pie = plt.pie(size, colors=color, explode=explode, labels=label, shadow=True, autopct='%1.1f%%')
    for font in pie[1]:
        font.set_size(8)
    for digit in pie[2]:
        digit.set_size(8)
    plt.axis('equal')  # 该行代码使饼图长宽相等
    plt.title(u'各个评分占比', fontsize=12)  # 标题
    plt.legend(loc=0, bbox_to_anchor=(0.82, 1))  # 图例
    # 设置legend的字体大小
    leg = plt.gca().get_legend()
    ltext = leg.get_texts()
    plt.setp(ltext, fontsize=6)
    plt.savefig("score.png")
    # 显示图
    plt.show()


def getzhifang(map):  # 直方图二维,需要x和y两个坐标
    x = []
    y = []
    for k, v in map.most_common(15):  # 获取前15个最大数值
        x.append(k)
        y.append(v)
    Xi = np.array(x)  # 转成numpy的坐标
    Yi = np.array(y)

    width = 0.6
    plt.rcParams['font.sans-serif'] = ['SimHei']  # 用来正常显示中文标签
    plt.figure(figsize=(8, 6))  # 指定图像比例: 8:6
    plt.bar(Xi, Yi, width, color='blue', label='热门词频统计', alpha=0.8, )

    plt.xlabel("词频")
    plt.ylabel("次数")
    plt.savefig('zhifang.png')
    plt.show()
    return


def getciyun_most(map):  # 获取词云
    # 一个存对应中文单词,一个存对应次数
    x = []
    y = []
    for k, v in map.most_common(300):  # 在前300个常用词语中
        x.append(k)
        y.append(v)
    xi = x[0:150]  # 截取前150个
    xi = ' '.join(xi)  # 以空格 ` `将其分割为固定格式(词云需要)
    print(xi)
    # backgroud_Image = plt.imread('')  # 如果需要个性化词云
    # 词云大小,字体等基本设置
    wc = WordCloud(background_color="white",
                   width=1500, height=1200,
                   # min_font_size=40,
                   # mask=backgroud_Image,
                   font_path="simhei.ttf",
                   max_font_size=150,  # 设置字体最大值
                   random_state=50,  # 设置有多少种随机生成状态,即有多少种配色方案
                   )  # 字体这里有个坑,一定要设这个参数。否则会显示一堆小方框wc.font_path="simhei.ttf"   # 黑体
    # wc.font_path="simhei.ttf"
    my_wordcloud = wc.generate(xi)  #需要放入词云的单词 ,这里前150个单词
    plt.imshow(my_wordcloud)  # 展示
    my_wordcloud.to_file("img.jpg")  # 保存
    xi = ' '.join(x[150:300])  # 再次获取后150个单词再保存一张词云
    my_wordcloud = wc.generate(xi)
    my_wordcloud.to_file("img2.jpg")

    plt.axis("off")


def anylaseword(comment):
    # 这个过滤词,有些词语没意义需要过滤掉
    list = ['这个', '一个', '不少', '起来', '没有', '就是', '不是', '那个', '还是', '剧情', '这样', '那样', '这种', '那种', '故事', '人物', '什么']
    print(list)
    commnetstr = ''  # 评论的字符串
    c = Counter()  # python一种数据集合,用来存储字典
    index = 0
    for va in comment:
        seg_list = jieba.cut(va[4], cut_all=False)  ## jieba分词
        index += 1
        for x in seg_list:
            if len(x) > 1 and x != '\r\n':  # 不是单个字 并且不是特殊符号
                try:
                    c[x] += 1  # 这个单词的次数加一
                except:
                    continue
        commnetstr += va[4]
    for (k, v) in c.most_common():  # 过滤掉次数小于5的单词
        if v < 5 or k in list:
            c.pop(k)
            continue
        # print(k,v)
    print(len(c), c)
    getzhifang(c)  # 用这个数据进行画直方图
    getciyun_most(c)  # 词云
    # print(commnetstr)


def anylase():
    data = xlrd.open_workbook('test.xls')  # 打开xls文件
    table = data.sheets()[0]  # 打开第i张表
    nrows = table.nrows  # 若干列的一个集合
    comment = []

    for i in range(nrows):
        comment.append(table.row_values(i))  # 将该列数据添加到元组中
    # print(comment)
    anylasescore(comment)
    anylaseword(comment)


if __name__ == '__main__':
    anylase()

我们再来查看一下执行的效果:

这里我选了姜子牙千与千寻 电影的一些数据,两个电影评分比例对比为:

image-20201023081251237

从评分可以看出明显千与千寻好评度更高,大部分人愿意给他五分。基本算是最好看的动漫之一了,再来看看直方图的词谱:

image-20201023081534644

很明显千与千寻的作者更出名,并且有很大的影响力,以至于大家纷纷提起他。再看看两者词云图:

宫崎骏、白龙、婆婆,真的是满满的回忆,好了不说了,有啥想说的欢迎讨论!

如果感觉不错, 原创公众号:bigsai,分享知识和干货!
image

查看原文

赞 0 收藏 0 评论 0

bigsai 发布了文章 · 2020-10-22

教你手写一个优先队列

文章收录在首发公众号:bigsai 期待你的到访!

前言

事情还要从一个故事讲起:
在这里插入图片描述
在这里插入图片描述

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

对于上面那只可爱的小狗狗不会,本篇即为该教程,首先,我要告诉这只可爱的小狗狗,这种问题你要使用的数据结构为优先队列,每次操作的时间复杂度为O(logn),而整个过程的时间复杂度为O(nlogn).

对于本片的设计与实现和堆排序可能有些相似,因为他们都借助堆来实现算法和数据结构,下面详细介绍优先队列的设计与实现

而堆就是一类特殊的数据结构的统称。堆通常是一个可以被看做一棵树(完全)的数组对象。且总是满足以下规则:

  • 堆总是一棵完全二叉树
  • 每个节点总是大于(或小于)它的孩子节点。

对于完全二叉树,我想大家都能明白,就是最底层叶子节点要严格按照从左向右来。
在这里插入图片描述
堆有大根堆和小根堆,如果是所有父节点都大于子节点的时候,那么这就是个大根堆,反之则为小根堆,以下就是一个大根堆:
在这里插入图片描述
最后需要注意的是我们并不是用链式去储存这个二叉树而是用数组去存储这个树,虽然链式的使用场景可能更多一些,但是在完全二叉树的情况下空间使用率较好没有斜树的出现。并且在操作的时候可以直接通过编号找到位置进行交换。

优先队列

如何理解优先队列,我们先从一段对话说起:
在这里插入图片描述

优先队列,它是一个队列。而普通的队列遵从先进先出的规则。而优先队列遵循一个排序的规则:每次抛出自定义排序最大(小)的,默认的情况是抛出最小的,本篇也就从最基本的原理进行分析。

并且它的用法队列还是一样的,,所以我们在设计这个类的时候api方面要与队列的api一致。
在这里插入图片描述

我们主要实现add、poll、和peek方法,并且会着重于算法的实现而不太着重一些细节的实现。

虽然优先队列和堆排序利用堆结构特性的流程有一些相似,但是两者其实还是有些操作上的区别的:

堆排序

  • 刚开始是一个杂乱无章的序列,所以需要将杂乱的序列(树)通过一个方法变成一个合法的堆。
  • 转成一个堆之后需要删除n次每次删除完都要重新调整这个堆。没有插入操作

优先队列

  • 队列(堆)刚开始的内容为空,每次增加一个元素时需要即使调整堆。每次删除也要及时调整堆,增加和删除每次都只是一个元素。

但是优先队列的具体操作流程是如何的呢?我们具体分析其插入和删除的流程

插入add流程(小根堆为例):

  • 正常处理完的优先队列内的数据满足一个堆的结构,所以就是插入在堆中。
  • 堆是一棵完全二叉树,所以在插入初始,插入到最后一个位置不影响其他结构。
  • 节点和父节点比较大小(父节点索引为其二分之一)。如果该节点比父节点更小,则交换数据,一直到不能交换为止,这个过程不用担心不合法,因为父节点更小的话更满足比孩子节点更小。

在这里插入图片描述

删除pop流程(小根堆为例):

  • pop删除操作取优先队列内最小的元素,而这个元素肯定就是堆顶元素了,取完之后,这个堆的其他部分还是满足堆的结构但是缺少堆顶。
  • 为了不影响整个结构,我们将末尾的那个元素移到堆顶,此时堆需要调整使其满足堆的性质条件。
  • 交换的这个节点和左右孩子进行比较,如果需要交换则交换,交换后再次考虑交换子节点是否需要交换,一直到不交换为止。最坏情况是交换到根节点,这个复杂度每次为O(logn).

在这里插入图片描述

代码实现

我想到这里,优先队列的内部流程思想你已经掌握了,但是懂优先队列原理和手写优先队列是两个概念,为了更深入的学习优先队列,在这里就带你手写一个简易型的优先队列。

在代码的具体实现方面,最主要的就是pop()和add()两个函数了。在pop()函数具体实现的时候,将最后一个元素移到堆头考虑和其他孩子节点交换的时候,用while进行操作的时候计算孩子下标的时候要确保不越界。我们用的是数组存储数据,优先队列的长度不一定等于这个数组的长度

而在实现add()函数的时候,这里简单的考虑了一下扩容。

具体实现的代码为:

import java.util.Arrays;

public class priQueue {

    private  int size;//优先队列的大小
    private  int capacity;//数组的容量
    private  int value[];//储存的值

    public priQueue() {
        this.capacity = 10;
        this.value = new int[capacity];
        this.size=0;
    }
    public priQueue(int capacity) {
        this.capacity = capacity;
        this.value = new int[capacity];
        this.size=0;
    }

    /**
     * 插入元素
     * @param number
     */
    public void add(int number) {
        if(size==capacity)//扩容
        {
            capacity*=1.5;
            value= Arrays.copyOf(value,capacity);
        }
        value[size++]=number;//先加到末尾
        int index=size-1;
        while (index>=1) {//进行交换
            if(value[index]<value[index/2]) {
                swap(index,index/2,value);
                index=index/2;
            }
            else//不需要交换即停止
                break;
        }
    }
    public int peek() {
        return  value[0];
    }

    /**
     * 抛出队头
     * @return
     */
    public int pop() {
        int val=value[0];//呆返回数据额
        value[0]=value[--size];//将最后一个元素赋值在堆头
        int index=0,leftChild=0,rightChild=0;
        while (true)
        {
            leftChild=index*2+1;
            rightChild=index*2+2;
            if(leftChild>=size)//左孩子必须满足在条件内
                break;
            else if(rightChild<size&&value[rightChild]<value[index]&&value[rightChild]<value[leftChild])
            {//右孩子更小
                swap(index,rightChild,value);
                index=rightChild;
            }
            else if(value[leftChild]<value[index])
            {//左孩子更小
                swap(index,leftChild,value);
                index=leftChild;
            }
            else //不需要 它自己最小
                break;
        }
        return  val;
    }
    //交换两个元素
    public  void swap(int i,int j,int arr[]) {
        int team=arr[i];
        arr[i]=arr[j];
        arr[j]=team;
    }

    public int size() {
        return  size;
    }
}

写个类测试一下看看:

在这里插入图片描述

结语

在这里插入图片描述

本次优先队列介绍就到这里啦,感觉不错记得点赞或一键三连哦,建议和堆排序一起看和学习效果更佳,要能够手写代码。个人公众号:bigsai 回复 bigsai 更多精彩和资源与你分享。
在这里插入图片描述

查看原文

赞 0 收藏 0 评论 0

bigsai 发布了文章 · 2020-10-18

【回溯算法】追忆那些年曾难倒我们的八皇后问题

文章收录在公众号:bigsai 更多精彩干货敬请关注!

前言

说起八皇后问题,它是一道回溯算法类的经典问题,也可能是我们大部分人在上数据结构或者算法课上遇到过的最难的一道题……
在这里插入图片描述

第一次遇到它的时候应该是大一下或者大二这个期间,这个时间对啥都懵懵懂懂,啥都想学却发现好像啥都挺难的,八皇后同样把那个时候的我阻拦在外,我记得很清楚当时大二初我们学业导师给我们开班会时候讲到的一句话很清晰:"如果没有认真的学习算法他怎么可能解出八皇后的代码呢"。

确实,那个时候的我搞不懂递归,回溯也没听过,连Java的集合都没用明白,毫无逻辑可言,八皇后对我来说确实就是无从下手。

但今天,我可以吊打八皇后了,和你们一起白银万两,佳丽三十。在这里插入图片描述

浅谈递归

对于递归算法,我觉得掌握递归是入门数据结构与算法的关键,因为后面学习很多操作涉及到递归,例如链表的一些操作、树的遍历和一些操作、图的dfs、快排、归并排序等等。
在这里插入图片描述

递归的实质还是借助栈实现一些操作,利用递归能够完成的操作使用栈都能够完成,并且利用栈的话可以很好的控制停止,效率更高(递归是一个来回的过程回来的时候需要特判)。

递归实现和栈实现操作的区别,递归对我们来说更简洁巧妙,并且用多了会发现很多问题的处理上递归的思考方式更偏向人的思考方式,而栈的话就是老老实实用计算机(数据结构特性)的思维去思考问题。这个你可以参考二叉树的遍历方式递归和非递归版本,复杂性一目了然。

从递归算法的特征上来看,递归算法的问题都是父问题可以用过一定关系转化为子问题。即从后往前推导的过程,一般通过一个参数来表示当前的层级。

而递归的主要特点如下:

  • 自己调用自己
  • 递归通常不在意具体操作,只关心初始条件和上下层的变化关系。
  • 递归函数需要有临界停止点,即递归不能无限制的执行下去。通常这个点为必须经过的一个数。
  • 递归可以被栈替代。有些递归可以优化。比如遇到重复性的可以借助空间内存记录而减少递归的次数。

在这里插入图片描述

而通常递归算法的一个流程大致为:

定义递归算法及参数
- 停止递归算法条件
- (可存在)其他逻辑
- 递归调用(参数需要改变)
- (可存在)其他逻辑

如果还是不理解的话就要看我的另一篇文章了:数据结构与算法—递归算法(从阶乘、斐波那契到汉诺塔的递归图解),写的是真的好!

回溯算法

谈完递归,你可能明白有这么一种方法可以使用,但你可能感觉单单的递归和八皇后还是很难扯上关系,是的没错,所以我来讲回溯算法了。

这里插个小插曲。前天(真的前天)有个舍友我们宿舍一起聊天的时候谈到回溯算法,他说回shuo(朔)算法,我们差异的纠正了一下是回su(素)算法,他竟然读错了四年……不知道路过的你们有没有读错的。
在这里插入图片描述
咱们言归正传,算法界中,有五大常用算法:贪心算法、分治算法、动态规划算法、回溯算法、分支界限算法。咱们回溯算法就是五大之一,因为回溯算法能够解决很多实际的问题,尽管很多时候复杂度可能不太小,但大部分情况都能得到一个不错的结果。

对于回溯法的定义,百度百科是这么定义的:

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称

对于回溯法,它的核心就是试探和复原。这个自动化的过程就是利用递归去执行,在递归函数执行前去修改尝试,满足条件后向下递归试探,试探完毕后需要将数值复原。在这个试探的过程中找到我们所需要的一个或者所有解。这个我们也俗称暴力。

在这里插入图片描述
啥?没听懂?好,那我就再讲讲,你应该知道深度优先搜索(dfs)吧?其实回溯算法就是一种特殊的dfs。之所以叫回溯,就是因为这类算法在运用递归都有个复原的过程,所以前面的操作就相当于试探一样。而这类算法一般常常配对一个或多个boolean类型的数组用来标记试探途中用过的点。

举个例子,我们知道回溯算法用来求所有数字的排列顺序。我们分析其中一个顺序。比如数列6 8 9这个序列的话,我们用来求它的排列顺序。

对于代码块来说,这可能很容易实现:

import java.util.Arrays;

public class test {
    public static void main(String[] args) {
        int arr[]={6,8,9};//需要排列组合的数组
        int val[]={0,0,0};//临时储存的数组
        boolean jud[] = new boolean[arr.length];// 判断是否被用
        dfs(arr,val, jud,  0,"");//用一个字符串长度更直观看结果
    }

    private static void dfs(int[] arr, int val[],boolean[] jud, int index,String s) {
        System.out.println(s+Arrays.toString(val));
        if (index == arr.length){ }//停止递归条件
        else{
            for (int i = 0; i < arr.length; i++) {
                if (!jud[i]) {//当前不能用的
                    int team=val[index];
                    val[index] = arr[i];
                    jud[i] = true;// 下层不能在用
                    dfs(arr, val, jud, index + 1,s+"  _  ");
                    jud[i] = false;// 还原
                    val[index]=team;
                }
            }
        }
    }
}

而执行的结果为:
在这里插入图片描述
这里再配张图理解:
在这里插入图片描述
而通常回溯算法的一个流程大致为:

定义回溯算法及参数
- (符合条件)跳出
- (不符合)不跳出:
- - 遍历需要操作的列表&&该元素可操作&&可以继续试探
- - - 标记该元素已使用以及其他操作
- - - 递归调用(参数改变)
- - - 清除该元素标记以及其他操作

也就是在使用数组进行回溯的时候,使用过的时候需要标记子递归不能再使用防止死循环,而当回来的时候需要解封该位置,以便该编号位置被其他兄弟使用之后这个数值在后面能够再次使用!而如果使用List或者StringBuilder等动态空间用来进行回溯的时候记得同样的复原,删了要记得增,减了要记得加。搞明白这些,我想回溯算法也应该难不倒你了吧。

八皇后问题

掌握了回溯算法的关键,八皇后问题多思考就可以想的出来了。前面的讲解都是为了解决八皇后问题做铺垫。首先,我们认真的看下八皇后问题描述。

八皇后问题(英文:Eight queens),是由国际西洋棋棋手马克斯·贝瑟尔于1848年提出的问题,是回溯算法的典型案例。

问题表述为:在8×8格的国际象棋上摆放8个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。高斯认为有76种方案。1854年在柏林的象棋杂志上不同的作者发表了40种不同的解,后来有人用图论的方法解出92种结果。如果经过±90度、±180度旋转,和对角线对称变换的摆法看成一类,共有42类。计算机发明后,有多种计算机语言可以编程解决此问题。
在这里插入图片描述

我们该怎么思考这种问题呢?也就是从何入手呢?

  • 从限制条件入手

八皇后问题有以下限制条件:

  • 8 x 8的方格
  • 每行一个,共八行(0-7)
  • 每列一个,共八列(0-7)
  • 每左斜杠一个,共十五左斜杠(0-14)
  • 每右斜杠一个,共十五右斜杠(0-14)

当看到这些限制条件,肯定想到这么多限制条件需要判断。判断的话当然就是借助boolean数组啦。还是一维的8个大小,所以我们首先用4个boolean数组用来判断各自的条件是否被满足。

表示这个图的话我们可以使用一个int类型数组表示,0表示没有,1表示有皇后。

那么如何去设计这个算法呢?这个并不是每个格子都有数字,所以在进行回溯的时候不应该每个格子每个格子进行向下递归(同行互斥),也就是递归到当前层的时候,循环遍历该层的八种情况进行试探(每个都试探),如果不满足条件的就不操作而被终止掉,但该行每个满足条件的需要递归的时候需要进入到下一行

当然你需要提前知道当前位置横纵坐标怎们知道对应的boolean位置(位置从0号开始计算)。例如位置p(x,y)中对应的位置为:

  • hang[] : x 每一行就是对应的i。
  • lie[] : y 每一列就是对应的j。
  • zuoxie[] : x+y 规定顺序为左上到右下
  • youxie[] : x+(7-y) 规定顺序为右上到左下(个人习惯)

在这里插入图片描述

好啦,该算法的实现代码为:

import java.util.Arrays;

public class EightQueens {
    static  int allnum=0;
    public static void main(String[] args) {
        boolean hang[]=new boolean[8];//行
        boolean lie[]=new boolean[8];//列
        boolean zuoxie[]=new boolean[15];//左斜杠
        boolean youxie[]=new boolean[15];//右斜杠
        int map[][]=new int[8][8];//地图
        dfs(0,hang,lie,zuoxie,youxie,map);//进行下去
    }

    private static void dfs(int hindex, boolean[] hang, boolean[] lie, boolean[] zuoxie, boolean[] youxie, int[][] map) {
        if(hindex==8){
            allnum++;
            printmap(map);//输出map
        }
        else{
            //hindex为行  i为具体的某一列
            for(int i=0;i<8;i++)
            {
                if(!hang[hindex]&&!lie[i]&&!zuoxie[hindex+i]&&!youxie[hindex+(7-i)])
                {
                    hang[hindex]=true;//试探
                    lie[i]=true;
                    zuoxie[hindex+i]=true;
                    youxie[hindex+(7-i)]=true;
                    map[hindex][i]=1;
                    dfs(hindex+1,hang,lie,zuoxie,youxie,map);//dfs
                    hang[hindex]=false;//还原
                    lie[i]=false;
                    zuoxie[hindex+i]=false;
                    youxie[hindex+(7-i)]=false;
                    map[hindex][i]=0;
                }
            }
        }
    }
    //输出地图
    private static void printmap(int[][] map) {
        System.out.println("第"+allnum+"个排列为");
        for(int a[]:map)
        {
            System.out.println(Arrays.toString(a));
        }
    }
}

跑一边就知道到底有多少种皇后,最终是92种皇后排列方式,不得不说能用数学方法接出来的是真的牛叉。
在这里插入图片描述

八皇后变种

此时我想八皇后问题已经搞得明明白白了,但是智慧的人们总是想出各种方法变化题目想难到我们,这种八皇后问题有很多变种,例如n皇后,数独等问题

这里就简单讲讲两数独问题的变种。

力扣36 有效的数独
在这里插入图片描述
像这种题需要考虑和八皇后还是很像,改成9*9,只不过在具体处理需要考虑3x3小方格

当然这题比较简单,还有一题就比较麻烦了 力扣37解数独

在这里插入图片描述
在这里插入图片描述
这一题有难度的就是需要我们每个位置都有数据都要去试探。

这种二维的回溯需要考虑一些问题,我们对于每一行每一行考虑。 每一行已经预有一些数据事先标记,在从开始试探放值,满足条件后向下递归试探。一直到结束如果都满足那么就可以结束返回数组值。

这里的话有两点需要注意的在这里提一下:

  • 用二维两个参数进行递归回溯判断起来谁加谁减比较麻烦,所以我们用一个参数index用它来计算横纵坐标进行转换,这样就减少二维递归的一些麻烦。
  • 回溯是一个来回的过程,在回来的过程正常情况需要将数据改回去,但是如果已经知道结果就没必要再该回去可以直接停止放置回溯造成值的修改(这里我用了一个isfinish的boolean类型进行判断)。

代码可以参考为:
在这里插入图片描述

结语

好啦,不知道这个专题结束之后能否能够掌握这个八皇后的回溯算法以及思想,能否理清递归,回溯,深搜以及八皇后为关系?

总的来说

  • 递归更注重一种方式,自己调用自己。
  • 回溯更注重试探和复原,这个过程一般借助递归。
  • dfs深度优先搜素,一般用栈或者递归去实现,如果用递归可能会复原也可能不复原数据,所以回溯是深搜的一种。
  • 八皇后是经典回溯算法解决的问题,你说深度优先搜素其实也没问题,但回溯更能精准的描述算法特征。

好啦,不说啦,我bigsai去领取佳丽30和白银万两啦!(不错的话记得一键三联,微信搜索bigsai,回复bigsai 下次迎娶美杜莎女王!)

在这里插入图片描述

查看原文

赞 1 收藏 0 评论 0