【C++算法】dfs深度优先搜索(上) ——【全面深度剖析+经典例题展示】

news2024/11/18 9:46:48

💃🏼 本人简介:男
👶🏼 年龄:18
📕 ps:七八天没更新了欸,这几天刚搞完元宇宙,上午一直练🚗,下午背四级单词和刷题来着,还在忙一些学弟学妹录制视频和准备开学一些事,一直没空出时间来,等 20号练完车,也马上开学了QAQ。不过今天倒是空出来一些时间,恰好这几天学到了dfs,原理和例题都很棒,谨以此篇作为学后的回顾总结!

文章目录

  • 1. dfs算法原理
    • 1.1 dfs思想
    • 1.2 与递归区别
    • 1.3 举例说明
  • 2. 经典例题——迷宫游戏
    • 2.1 题干信息
    • 2.2 整体思路
    • 2.3 细分拆解
      • ①判断迷宫终点,记录所走路径
      • ②完善搜索与回溯,处理数组边界
      • ③找寻迷宫起点,打印结束路径
    • 2.4 总体代码展示
    • 2.5 测试样例
    • 2.6 代码优化
  • 最后,感谢大家支持u (^ _ ^)

1. dfs算法原理

1.1 dfs思想

  • 深度优先搜索,简称dfs,简单讲就是一个搜索算法。
  • 深搜是按照深度优先的方式进行搜索,通俗来讲就是一条路走到黑不撞南墙不回头
  • 注意:这里的搜索并不是我们平时在文件上或网络上查找信息,而是通过一种穷举的方式,把所有可行的方案都列举出来,不断去尝试,直到找到问题的解。
  • 具体来讲,dfs可以将“问题状态空间”看做一棵搜索树,深度优先就是从树根一直往下搜,遇到不可解就回溯,往其它方向继续向下扩展,像子集和和全排列问题,还有N皇后问题都可以深度优先搜索算法解决,它是一种暴力解决NP问题的非常直观的方法。
  • 总的来说:DFS 用于找所有解的问题,它的空间效率高,而且找到的不一定是最优解,必须记录并完成整个搜索,故一般情况下,深搜需要非常高效的剪枝(剪枝的概念请百度)。

1.2 与递归区别

  • 深搜是一种算法,注重的是思想;而递归是一种基于编程语言的实现方式。
  • 深搜可以用递归实现,也就是说递归是我们用计算机编程语言实现深搜算法的手段。

1.3 举例说明

如下图,灰色代表墙壁,绿色代表起点,红色代表终点,规定每次只能走一步,且只能往下或右走。求一条绿色到红色的最短路径。例子来源于这里

在这里插入图片描述

用dfs来讲就是,先从绿点开始找准一个方向,并沿这个方向一直遍历,如果遇到障碍物不能继续遍历就回溯,返回上一个节点,直到找到终点为止。请添加图片描述

2. 经典例题——迷宫游戏

都学了这么多了。我们不妨来玩一个迷宫游戏巩固一下所学的算法!【迷宫如图下所示】
在这里插入图片描述

最短的解法如下图所示【大家答对了嘛】 在这里插入图片描述

2.1 题干信息

  • 我们用一个二维字符数组来表示图画的迷宫。
S**.
....
***T
  • 其中S表示起点,T表示终点,*表示墙壁,.表示平地。你需要从S出发走到T,每次只能向上下左右相邻的位置移动一位,不能走出地图,也不能穿过墙壁,每个点只能通过一次。用x表示你所要走的路线。

2.2 整体思路

  • 我们从起点S开始,每走一步需要对上下左右一个方向一个方向地尝试,如果沿着某个方向不能走到终点,我们就要原路返回,继续尝试其他方向,直到走出迷宫。这是一种最朴素的走迷宫方式,虽然效率也许比较低,但如果迷宫有解,就一定能走出终点。
  • 上面说的这种走法,就对应着今天学习的dfs算法。首先找到起点s,走到每个点时,按照左、下、右、上的顺序尝试。每走到下一个点以后,我们把这个点当做起点S,继续按顺序尝试。如果某个点上下左右四个方向都尝试过,便回到走到这个点之前的点,这一步我们称之为回溯。继续尝试其他方向。直到所有点都尝试过上下左右四个方向。
  • 这就好比你自己去走这个迷宫,你也要一个方向一个方向的尝试着走,如果这条路不行就回头,尝试下一条路,dfs 的思想和我们直观的想法很类似。只不过,接下来我们需要用程序来完成这个过程。

2.3 细分拆解

第一步前的输入地图和变量设置,就不详细讲了,直接看代码即可

#include<iostream>
#include<stdio.h>
using namespace std;
int n, m;
char pos[150][150];	   //判断走没走过
bool trace[150][150];  //显示路径

int main() {
	//输入地图
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			cin >> pos[i][j];
		}
	}
	return 0;
}

①判断迷宫终点,记录所走路径

  • 首先确定边界条件,当走到字符T时,我们找到了终点,从而结束搜索。所以边界条件判断为pos[x][y] == 'T'
  • 其次,为了防止走回头路,我们需要标记当前这个路径已走过,即当前这个点已走过,所以我们需要用trace[x][y]数组来做标记,为了显示出路径,走过的点我们用字符x表示。
bool dfs(int x, int y) {
	if (pos[x][y] == 'T') { //找到终点,返回true
		return true;
	}
	trace[x][y] = 1;		//若找不到,则trace数组标记为1表示已走过
	pos[x][y] = 'x';		//用pos显示最后的路径
}

②完善搜索与回溯,处理数组边界

  • 结束操作处理好后,就要开始真正的搜索了。假设现在我们坐标为(x, y),分别遍历该坐标的上下左右位置,选择好依次进行方向的顺序后,一个方向一个方向进行访问,如果某一方向能走到终点,则返回true
  • 在上下左右遍历时,我们要考虑数组元素是否越界,此时我们就需要一个bool类型的check_in()函数进行判断。
  • 注意:判断移动后位置能走的3个条件【缺一不可】
    • ①没越界,在地图内;
    • ②这个位置不是障碍物*,可以走到;
    • ③该位置之前没走过
bool check_in(int x, int y) {	//	判断数组是否越界
	return (x > 0 && x <= n && y > 0 && y <= m); //这里表示的是如果()里为真,则返回true,否则返回false
}

bool dfs(int x, int y) {
	if (pos[x][y] == 'T') { //找到终点,返回true
		return true;
	}
	trace[x][y] = 1;		//若上下左右都找不到,则trace数组标记为1表示已走过
	pos[x][y] = 'x';		//用pos显示最后的路径

	int tx = x - 1, ty = y; //假设先往上走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) { //能移动到该位置的条件有三个:①没越界,在地图内; ②这个位置不是障碍物*,可以走到; ③该位置之前没走过
		if (dfs(tx, ty)) {	//对移动后位置进行判断是不是终点,如果是,返回true,如果不是,在对其上下左右判断。
			return true;
		}
	}

	tx = x, ty = y - 1; //如果往上走行不通,则选择向左走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
		if (dfs(tx, ty)) {
			return true;
		}
	}

	tx = x + 1, ty = y; //如果往左走行不通,则选择向下走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
		if (dfs(tx, ty)) {
			return true;
		}
	}

	tx = x , ty = y + 1; //如果往下走行不通,则选择向右走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
		if (dfs(tx, ty)) {
			return true;
		}
	}

	pos[x][y] = '.';	//	如果一个位置的上下左右都走不了,则取消该位置的路径进行回溯,回溯过程把之前已标记的'x'改回'.'
	trace[x][y] = 0;//只找可行路线的话,trace可改可不改。但如果找全部解,则需要恢复
	return false;
}

大家有没有发现判断上下左右是否可行时,主题代码是不是都是一样的欸?所以大家可以想一下,能怎么去优化一下嘛,在这里我卖个关子,咱们后面马上讲!

③找寻迷宫起点,打印结束路径

  • 我们费心费力地把迷宫主体的dfs的函数写完了,但我们该从哪开始呢?结束条件如何设置呢?
int main() {
	//输入地图
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			cin >> pos[i][j];
		}
	}
	cout << "----------------------------------------" << endl;

	//找寻起点
	int x , y ; //定义x,y用于保存迷宫起点时的位置
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			if (pos[i][j] == 'S') {
				x = i, y = j;
			}
		}
	}
	if (dfs(x, y)) { //如果能找到终点,则打印迷宫显示其路径
		cout << "鼠鼠我欸,已到达终点啦,路线如下: " << endl;
		for (int i = 1; i <= n; i++) {
			for (int j = 1; j <= m; j++) {
				cout << pos[i][j];
			} cout << endl;
		}
	}
	else {
		cout << "鼠鼠我欸,走不出去啦awa" << endl;
	}
	return 0;
}

2.4 总体代码展示

#include<iostream>
#include<stdio.h>
using namespace std;

int n, m;
char pos[150][150];	   //判断走没走过
bool trace[150][150];  //显示路径

bool check_in(int x, int y) {	//	判断数组是否越界
	return (x > 0 && x <= n && y > 0 && y <= m); //这里表示的是如果()里为真,则返回true,否则返回false
}

bool dfs(int x, int y) {
	if (pos[x][y] == 'T') { //找到终点,返回true
		return true;
	}
	trace[x][y] = 1;		//若上下左右都找不到,则trace数组标记为1表示已走过
	pos[x][y] = 'x';		//用pos显示最后的路径

	int tx = x - 1, ty = y; //假设先往上走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) { //能移动到该位置的条件有三个:①没越界,在地图内; ②这个位置不是障碍物*,可以走到; ③该位置之前没走过
		if (dfs(tx, ty)) {	//对移动后位置进行判断是不是终点,如果是,返回true,如果不是,在对其上下左右判断。
			return true;
		}
	}

	tx = x, ty = y - 1; //如果往上走行不通,则选择向左走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
		if (dfs(tx, ty)) {
			return true;
		}
	}

	tx = x + 1, ty = y; //如果往左走行不通,则选择向下走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
		if (dfs(tx, ty)) {
			return true;
		}
	}

	tx = x , ty = y + 1; //如果往下走行不通,则选择向右走
	if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
		if (dfs(tx, ty)) {
			return true;
		}
	}

	pos[x][y] = '.';	//	如果一个位置的上下左右都走不了,则取消该位置的路径进行回溯,回溯过程把之前已标记的'x'改回'.'
	trace[x][y] = 0;//只找可行路线的话,trace可改可不改。但如果找全部解,则需要恢复
	return false;
}
int main() {
	//输入地图
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			cin >> pos[i][j];
		}
	}
	cout << "----------------------------------------" << endl;
	//找寻起点
	int x , y ; //定义x,y用于保存迷宫起点时的位置
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			if (pos[i][j] == 'S') {
				x = i, y = j;
			}
		}
	}
	if (dfs(x, y)) { //如果能找到终点,则打印迷宫显示其路径
		cout << "鼠鼠我欸,已到达终点啦,路线如下: " << endl;
		for (int i = 1; i <= n; i++) {
			for (int j = 1; j <= m; j++) {
				cout << pos[i][j];
			} cout << endl;
		}
	}
	else {
		cout << "鼠鼠我欸,走不出去啦awa" << endl;
	}
	return 0;
}

2.5 测试样例

在这里插入图片描述

2.6 代码优化

  • 前面提到,我们在对下一位置进行上下左右判断时,需要写四个主体相同的代码。为了减少代码量,我们不妨写一个循环,用一个二维数组依次表示四个方向,进而进行判断。
int dir[4][2] = { { -1 , 0 } , { 0 , -1 } , { 1 ,  0 } , { 0 , 1 } };
//	按逆时针依次表示     向上	      向左		 向下	         向右	
//第一个数表示x(行)变化,第二个表示y(列)变化
bool dfs(int x, int y) {
	if (dfs(x, y) == 'T') {
		return true;
	}
	trace[x][y] = 1;
	pos[x][y] = 'x';
	for (int i = 1; i <= 4; i++) {  //1、2、3、4依次表示上、左、下、右
		int tx = x + dir[i][0]; //表示x加上第几个方向的第1个数,即行变化,
		int ty = y + dir[i][1];  //表示x加上第几个方向的第2个数,即列变化,
		if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
			if (dfs(tx, ty)) {
				return true;
			}
		}
	}
	pos[x][y] = '.';	//	如果一个位置的上下左右都走不了,则取消该位置的路径进行回溯,回溯过程把之前已标记的'x'改回'.'
	trace[x][y] = 0;//只找可行路线的话,trace可改可不改。但如果找全部解,则需要恢复
}

优化后代码

#include<iostream>
#include<stdio.h>
using namespace std;

int n, m;
char pos[150][150];	   //判断走没走过
bool trace[150][150];  //显示路径
		int dir[4][2] = { { -1 , 0 } , { 0 , -1 } , { 1 ,  0 } , { 0 , 1 } };
//	按逆时针依次表示     向上	      向左		 向下	         向右	
//第一个数表示x(行)变化,第二个表示y(列)变化

bool check_in(int x, int y) {	//	判断数组是否越界
	return x > 0 && x <= n && y > 0 && y <= m; //这里表示的是如果()里为真,则返回true,否则返回false
}

bool dfs(int x, int y) {
	if (dfs(x, y) == 'T') {
		return true;
	}
	trace[x][y] = 1;
	pos[x][y] = 'x';
	for (int i = 1; i <= 4; i++) {  //1、2、3、4依次表示上、左、下、右
		int tx = x + dir[i][0]; //表示x加上第几个方向的第1个数,即行变化,
		int ty = y + dir[i][1];  //表示x加上第几个方向的第2个数,即列变化,
		if (check_in(tx, ty) && pos[tx][ty] != '*' && !trace[tx][ty]) {
			if (dfs(tx, ty)) {
				return true;
			}
		}
	}
	pos[x][y] = '.';	//	如果一个位置的上下左右都走不了,则取消该位置的路径进行回溯,回溯过程把之前已标记的'x'改回'.'
	trace[x][y] = 0;//只找可行路线的话,trace可改可不改。但如果找全部解,则需要恢复

}


int main() {
	//输入地图
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			cin >> pos[i][j];
		}
	}
	cout << "----------------------------------------" << endl;
	//找寻起点
	int x , y ; //定义x,y用于保存迷宫起点时的位置
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			if (pos[i][j] == 'S') {
				x = i, y = j;
			}
		}
	}
	if (dfs(x, y)) { //如果能找到终点,则打印迷宫显示其路径
		cout << "鼠鼠我欸,已到达终点啦,路线如下: " << endl;
		for (int i = 1; i <= n; i++) {
			for (int j = 1; j <= m; j++) {
				cout << pos[i][j];
			} cout << endl;
		}
	}
	else {
		cout << "鼠鼠我欸,走不出去啦awa" << endl;
	}
	return 0;
}

最后,感谢大家支持u (^ _ ^)

如果感觉这篇文章对你有帮助的话,不妨三连支持下,十分感谢(✪ω✪)。

printf("点个赞吧*^*");
cout << "收藏一下叭o_o";
System.out.println("评论一下吧^_^");
print("关注一下叭0-0")

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/349864.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

leaflet: 禁止拖拽、禁止zoom(双击、滚轮、键盘)、禁止tap(076)

第076个 点击查看专栏目录 本示例的目的是介绍演示如何在vue+leaflet中设定各种禁止状态,这里设置了禁止拖拽、禁止zoom(双击、滚轮、键盘)、禁止tap。 直接复制下面的 vue+leaflet源代码,操作2分钟即可运行实现效果 文章目录 示例效果配置方式示例源代码(共73行)相关A…

linux高级命令之多进程的使用

多进程的使用学习目标能够使用多进程完成多任务1 导入进程包#导入进程包import multiprocessing2. Process进程类的说明Process([group [, target [, name [, args [, kwargs]]]]])group&#xff1a;指定进程组&#xff0c;目前只能使用Nonetarget&#xff1a;执行的目标任务名…

电商导购CPS,京东联盟如何跟单实现用户和订单绑定

前言 大家好&#xff0c;我是小悟 做过自媒体的小伙伴都知道&#xff0c;不管是发图文还是发短视频&#xff0c;直播也好&#xff0c;可以带货。在你的内容里面挂上商品&#xff0c;你自己都不需要囤货&#xff0c;如果用户通过这个商品下单成交了&#xff0c;自媒体平台就会…

【刷题笔记】--搜索二维矩阵 II

题目&#xff1a; 编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性&#xff1a; 每行的元素从左到右升序排列。 每列的元素从上到下升序排列。 示例 1&#xff1a; 输入&#xff1a;matrix [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16…

设计师都在看的全球设计网站,你居然还不知道!

设计师需要拥有无限的创意和熟练的技巧&#xff0c;并且对行业的前景和客户的心理有一定的了解。要能达到“陌生化”之前&#xff0c;肯定是有知识储备&#xff0c;专业能力的前提要求&#xff0c;以及创新能力。 今天为大家整理了多个优秀全球设计网站&#xff0c;这些博客内…

大家都在聊的自动化办公到底是什么?

自动化办公无非是excel、ppt、word、邮件、文件处理、数据分析处理、爬虫这些&#xff0c;下面就详细介绍一下&#xff01;文章最后分享了很不错的python学习教程&#xff0c;适合零基础初学的小伙伴&#xff0c;希望可以对你有所帮助&#xff01;&#xff01; excel自动化 我…

linux基本功系列之grep命令

文章目录前言一. grep命令介绍二. 语法格式及常用选项三. 参考案例3.1 搜索文件中以root开头的文件3.2 搜索文件中出现的root3.3 搜索除了匹配行之外的行3.4 匹配的部分使用颜色显示3.5 只输出文件中匹配到的地方3.6 输出包含匹配字符串的行&#xff0c;并显示所在的行数3.7 统…

Unity CircleLayoutGroup 如何实现一个圆形自动布局组件

文章目录简介实现原理Editor 编辑器简介 Unity中提供了三种类型的自动布局组件&#xff0c;分别是Grid Layou Group、Horizontal Layout Group、Vertical Layout Group&#xff0c;本文自定义了一个圆形的自动布局组件Circle Layout Group&#xff0c;如图所示&#xff1a; Ra…

Maven的安装步骤(保姆级安装教程)

一、安装本地Maven 选择你需要的maven版本下载&#xff1a;官网下载传送门 我使用的是3.6.1版本&#xff1a;maven-3.6.1-bin.zip 二、安装 把下载好的maven压缩包解压到一个没有中文&#xff0c;空格或其他特殊字符的文件夹&#xff0c;如&#xff1a; 三、配置环境变量…

Python 编程必备:盘点nginx和gunicorn的几大用法,建议收藏

程序员是新兴技术工种中比较高薪的一个&#xff0c;在互联网公司&#xff0c;程序员往往与秃头&#xff0c;压力大&#xff0c;找不到女朋友等等挂钩。 最近&#xff0c;最新技能类榜单出炉&#xff0c;这是一个关于程序员自己给自己贴的几个标签。 其中&#xff0c;不难看出…

美国CPC认证是什么?儿童玩具亚马逊CPC认证审核有哪些问题?

很多卖家都有遭遇listing下架&#xff0c;被要求提供CPC认证报告。这是因为亚马逊有时会加强对儿童产品的审查。本文带大家对CPC认证进行一个全面了解。什么是CPC认证&#xff1f;CPC认证&#xff0c;全称ChildrensProductCertification.是认可实验室&#xff0c;根据产品不同适…

Hive学习——单机版Hive的安装

目录 一、基本概念 (一)什么是Hive (二)优势和特点 (三)Hive元数据管理 二、Hive环境搭建 1.自动安装脚本 2./opt/soft/hive312/conf目录下创建hive配置文件hive-site.xml 3.拷贝一个jar包到hive下面的lib目录下 4.删除hive的guava&#xff0c;拷贝hadoop下的guava 5…

Java中常见的编码集问题

收录于热门专栏Java基础教程系列&#xff08;进阶篇&#xff09; 一、遇到一个问题 1、读取CSV文件 package com.guor.demo.charset;import java.io.BufferedReader; import java.io.FileReader; import java.util.ArrayList; import java.util.HashMap; import java.util.L…

Syntax-Aware Aspect-Level Sentiment Classification with PWCN 论文阅读笔记

一、作者 Chen Zhang, Qiuchi Li, and Dawei Song. 2019. Syntax-Aware Aspect-Level Sentiment Classification with Proximity-Weighted Convolution Network. In Proceedings of the 42nd International ACM SIGIR Conference on Research and Development in Information …

jsp游泳馆门票管理系统Myeclipse开发mysql数据库web结构java编程计算机网页项目

一、源码特点 jsp游泳馆门票管理系统 是一套完善的web设计系统&#xff0c;对理解JSP java编程开发语言有帮助&#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采用B/S模式开发。开发环境为 TOMCAT7.0,Myeclipse8.5开发&#xff0c;数据库为Mysql&#xff0c;…

MySQL的存储引擎

目录 一.概念 二.分类 操作 修改默认存储引擎 一.概念 数据库存储引擎是数据库底层软件组织&#xff0c;数据库管理系统&#xff08;DBMS&#xff09;使用数据引擎进行创建、查询、更新和删除数据。不同的存储引擎提供不同的存储机制、索引技巧、锁定水平等功能。现在许多不…

数据预处理——数据无量纲化(归一化、标准化)

文章目录1. 数据归一化1.1 数据归一化定义1.2 MinMaxScaler 归一化1.3 MinMaxScaler 使用样例2. 数据标准化2.1 数据标准化定义2.2 StandardScaler 标准化2.3 StandardScaler 使用样例StandardScaler和MinMaxScaler选哪个&#xff1f;在机器学习算法实践中&#xff0c;我们往往…

儿童玩具车扭扭车上架欧盟亚马逊CE认证EN71项目测试

扭扭车又称儿童健身车&#xff0c;摇摆车,主体由工程聚丙烯&#xff0c;经注塑而成&#xff0c;结构稳固&#xff0c;操作简单&#xff0c;无需电瓶和传动装置&#xff0c;只要左右转动方向盘&#xff0c;就可随意前后行驶。是一种环保的绿色玩具&#xff0c;最早出现在中国台湾…

c++:缺省参数,函数重载

今天介绍的是cpp中的缺省参数以及函数重载的知识。 首先我们先看看缺省参数&#xff1a; 缺省参数 缺省参数是声明或定义函数时为函数的参数指定一个缺省值。在调用该函数时&#xff0c;如果没有指定实 参则采用该形参的缺省值&#xff0c;否则使用指定的实参。 例如&#…

项目——博客系统

文章目录项目优点项目创建创建相应的目录&#xff0c;文件&#xff0c;表&#xff0c;导入前端资源实现common工具类实现拦截器验证用户登录实现统一数据返回格式实现加盐加密类实现encrypt方法实现decrypt方法实现SessionUtil类实现注册页面实现前端代码实现后端代码实现登录页…