【交换排序】手撕八大排序之快速排序和冒泡排序(超级详细)

news2024/9/23 3:32:52

目录

🍁一.快速排序

🍀Ⅰ.Hoare法

🍇Ⅱ.挖坑法 

🍋1.递归版本

🍊2.关于时间复杂度

🍎3.快速排序的优化之三数取中法

🍌4.非递归版本(使用栈实现)

🍐5.非递归的挖坑大法的全部代码

🍑二.冒泡排序(设置flag值) 

🍁1.从前往后冒

🏵️2.从后往前冒


🍁一.快速排序

快速排序:英国计算机科学家Tony Hoare于1959年提出。它是基于分治法的思想,具有高效的排序速度和较低的内存消耗。
快速排序的基本思想是通过一个基准元素,将数组分成两个子数组较小的元素放在左边,较大的元素放在右边,然后对两个子数组递归地进行排序。这样一次划分后,基准元素的位置就已经确定了,它处于最终排序结果的正确位置上。

快速排序的特点是它的平均时间复杂度为O(nlogn),其中n为数组的长度。它具有较低的内存消耗,能够处理大规模的数据集,并且在实践中表现出良好的性能。

由于快速排序的高效性和广泛应用,它成为了经典的排序算法之一,并且在各种编程语言和算法库中被广泛实现和使用。

🍀Ⅰ.Hoare法

Hoare法也就是发明快速排序的大佬以自己名字命名的方法,我也这么牛逼的话(白日做梦,哈哈哈)。

方法的介绍:
1.需要设置一个key值(这个key是下标),一般是数组的第一个位置或者最后一个位置。后面的方法我们都是设置的数组的第一个位置为key值。

2.
数组首元素的下标定义为begin,尾元素的下标定义为end,然后我们开始使用a[key]和a[end]的数进行比较,如果a[end]的值大于或者等于a[key]的值,那么这个end位置的值就不动,然后end--,继续找小于a[key]的值,如果找到了,那么就退出循环。
然后就开始比较a[begin]和a[key]的值,和上述的方法是一样的,如果没找到比a[key]大的数,那么begin就一直++,直到找到了比a[key]小的数,退出循环。
然后就是把begin和end位置的交换。

3.然后就是重复上面的操作,直到begin的位置和end的位置重合,退出循环,此时把end的位置的值和key位置的值交换一下,再更新一下key的位置为end(因为现在end和begin位置重合,随便哪个都行)。

4.现在的情况是,key位置左边的值全部比a[key]小,而key位置右边的值全部比a[key]大,这时候我们就要缩小区间,继续上述的操作,直到把数组全部变成有序的,这就需要用到递归了。

动图理解:

代码:

//Hoare法
void QuickSort(int* a, int left, int right)
{             //这里的left和right都是下标
	if (left >= right)//递归退出的条件
		return;
	int begin = left;
	int end = right;
	int key = begin;//key值为数组首元素的下标
	while (begin < end)
	{
		while(begin < end && a[end] >= a[key])
			//必须要写begin<end
		{
			end--;
		}
		while (begin < end && a[begin] <=a[key])
		{
			begin++;
		}
		Swap(&a[begin], &a[end]);//交换begin和end位置的值
	}
	Swap(&a[begin], &a[key]);
	key = begin;//更新key的位置
	QuickSort(a, left, key - 1);
	QuickSort(a, key + 1, right);
}

关于while循环里面的while循环为什么也要写begin<end,这里我们可以举一个例子,你就可以明白了。

🍇Ⅱ.挖坑法 

🍋1.递归版本

挖坑法思路讲解:
1.
还是一样,选择数组的首元素为pit,也就是为坑。

2.然后从最右边的数开始比较,如果大于坑位置的值,就end--,否则就让这个位置的值填到坑的位置,再更新这个位置为坑的位置。

3.然后再继续比较坑和左边的数,和上述操作一样。

4.直到begin和end的位置重合,最后再将end位置的值更新为坑位置的值即可,然后又是递归完成全部的操作。

动图理解:

代码实现: 

//挖坑大法
void QuickSort(int* a, int left,int right)
{
	if (left >= right)
		return;//递归的结束条件
	int begin = left;
	int end = right;
	int pit = begin;
	int key = a[pit];
	while (begin <end)
	{
		while (begin < end && key <= a[end])//先找右边的
		{
			end--;
		}
		a[pit] = a[end];
		pit = end;//更新坑的位置
		while (begin < end && key >= a[begin])//再找左边的
		{
			begin++;
		}
		a[pit] = a[begin];
		pit = begin;
	}
	pit = begin;
	a[pit] = key;//最后把坑的位置给填上
	QuickSort(a, 0, pit - 1);
	QuickSort(a, pit + 1, right);
}

🍊2.关于时间复杂度

关于时间复杂度不是看循环的个数,可能有些老铁理解为,这里的挖坑法while循环里面再嵌套了一个while循环,所以时间复杂度是O(n^2)。这样理解肯定就大错特错了,时间复杂度是看执行的次数,而不是循环的多少个来决定的。

在还没有递归前,循环就是走到begin和end的位置重合即结束,所以就是遍历完了数组,次数就是数组的长度N。
在平均情况下,快速排序的时间复杂度也为O(nlogn)。这是因为快速排序的思想是通过不断地划分和排序子数组来实现整个数组的排序,每次划分可以将一个规模为n的问题划分成两个规模约为n/2的子问题。根据主定理,递归树中每层的总时间复杂度为O(n),递归树的高度为O(logn),因此整个排序的时间复杂度为O(nlogn)。

但是当数组已经是有序了之后,不管是升序还是降序, 时间复杂度会变成O(n^2),这是为什么呢?我们可以画图来理解一下。

因为有序了之后,就一直不用排左边,一直比较右边,所以次数加起来就是等差数列相加最坏的时间复杂度就是O(n^2)。那有没有什么办法避免这种情况呢? 接下来就是我们要将的快排的优化。

🍎3.快速排序的优化之三数取中法

之前我们取坑和key的位置都是取的数组的第一个位置,在数组无序的时候确实可以这样取,但是当数组有序了之后,如果你还这样取,坑的位置就是最大或者最小的了,那么就会导致坑这个位置的左端或者右端,永远不会和坑进行比较,这就会导致上述快速排序时间复杂度最坏的情况出现。而三数取中法就可以完美的避开这种麻烦。
三数取中法:
三个数分别是数组的两端和中间的数。也就是在这三个数的中取一个中间大小的数作为坑的位置。这样数组的左右都会和坑进行比较。这样就可以避免数组有序的时候,时间复杂度是O(n^2)。

代码的实现:

int MidSize(int* a, int left, int right)
{
	int mid = (left + right) >> 1;
	//二进制向左移动一位就是除2的意思,找中间数
	while (left < right)
	{
		if (a[left] > a[mid])
		{
			if (a[mid] > a[right])
			{
				return mid;
			}
			else  if (a[right] > a[left])
			{
				return left;
			}
			else
			{
				return right;
			}  
		}
		else
		{
			if (a[right] < a[left])
			{
				return left;
			}
			else if (a[right] > a[mid])
			{
				return mid;
			}
			else
			{
				return right;
			}
		}

	}
}

这样就可以找到三个数中中间大小的数了。我们还是一样使用第一个位置为坑的位置,只是使用之前先把坑的这个位置和中间大小数的这个位置交换一下,这样就不会存在什么问题了。

int mid = MidSize(a, left, right);
Swap(&a[pit], &a[mid]);

🍌4.非递归版本(使用栈实现)

前面我们都是玩的递归法,现在我们来整一个非递归的,递归这么香?为什么还要学习非递归法呢?
这是因为:
递归函数具有很好的可读性和可维护性,但是大部分情况下程序效率不如非递归函数,所以在程序设计中一般喜欢先用递归解决问题,在保证方法正确的前提下再转换为非递归函数以提高效率。

函数调用时,需要在栈中分配新的栈帧,将返回地址,调用参数和局部变量入栈。所以递归调用越深,占用的栈空间越多。如果层数过深,肯定会导致栈溢出,这也是消除递归的必要性之一。

非递归思路:

1.利用栈的先进后出的性质,我们可以先把数组的right入栈,再将left入栈。

2.然后再将依次把栈顶的元素拿出来,所以先把left拿出来,再把right拿出来,这样我们利用一次挖坑法就可以先把坑的左右两端给排好,再利用挖坑法的函数把坑这个位置的下标给返回回来,然后继续把左端和坑的位置-1,坑位置+1和右端依次入栈,上述同样的操作,即可完成非递归的排序。
 

这里我们是C语言实现的,所以必须自己写一个栈,不像C++,直接有栈的库供我们使用,这就是C语言的弊端所在了,但是没办法,还没学呢?哈哈。
这个非递归的挖坑大法,刚开始不是很好理解,我也看了好几遍,才学会,老铁们也是一样,
反正不急躁,慢慢来,总会搞懂的。

int MidSize(int* a, int left, int right)
{
	int mid = (left + right) >> 1;
	//二进制向左移动一位就是除2的意思,找中间数
	while (left < right)
	{
		if (a[left] > a[mid])
		{
			if (a[mid] > a[right])
			{
				return mid;
			}
			else  if (a[right] > a[left])
			{
				return left;
			}
			else
			{
				return right;
			}  
		}
		else
		{
			if (a[right] < a[left])
			{
				return left;
			}
			else if (a[right] > a[mid])
			{
				return mid;
			}
			else
			{
				return right;
			}
		}

	}
}
//挖坑大法
int PartSort(int* a, int left,int right)
{
	if (left >= right)
		return;//递归的结束条件
	int begin = left;
	int end = right;
	int pit = begin;
	int mid = MidSize(a, left, right);
	Swap(&a[pit], &a[mid]);
	int key = a[pit];
	while (begin <end)
	{
		while (begin < end && key <= a[end])//先找右边的
		{
			end--;
		}
		a[pit] = a[end];
		pit = end;//更新坑的位置
		while (begin < end && key >= a[begin])//再找左边的
		{
			begin++;
		}
		a[pit] = a[begin];
		pit = begin;
	}
	pit = begin;
	a[pit] = key;//最后把坑的位置给填上
	return pit;
}

void QuickSort(int* a, int left, int right)
{
	ST st;
	StackInit(&st);
	StackPush(&st, right);
	StackPush(&st, left);
	while (!StackEmpty(&st))
	{
		int begin = StackTop(&st);//出栈顶的元素
		StackPop(&st);//
		int end= StackTop(&st);
		StackPop(&st);
		int mid = PartSort(a, begin, end);//返回的mid就坑所在的位置
		if (begin < mid - 1)//当begin>=mid-1,那么左区间即排序完毕
		{
			StackPush(&st, mid-1);//入栈的顺序要区别好,先右再左
			StackPush(&st, begin);
		}
		if (mid + 1 < end)
		{
			StackPush(&st, end);
			StackPush(&st, mid+1);
		}
	}
	StackDestroy(&st);
}

🍐​​​​​​​5.非递归的挖坑大法的全部代码

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
typedef int STDataType;
typedef struct Stack
{
	STDataType* a;//动态数组
	STDataType top;//栈顶
	int Capacity;//容量
}ST;


void StackInit(ST* ps)
{
	assert(ps);
	ps->a = (STDataType*)malloc(sizeof(STDataType) * 4);
	if (ps->a == NULL)
	{
		perror("malloc\n");
		return;
	}
	ps->top = 0;
	ps->Capacity = 4;
}

// 入栈
void StackPush(ST* ps, STDataType x)
{
	assert(ps);
	//判断是否满了
	if (ps->top == ps->Capacity)
	{
		STDataType* temp = (STDataType*)realloc(ps->a, sizeof(STDataType) * ps->Capacity * 2);
		if (temp == NULL)
		{
			perror("realloc\n");
			return;
		}
		ps->Capacity *= 2;//每次增容尾上一次的二倍
		ps->a = temp;
	}
	ps->a[ps->top] = x;
	ps->top++;//栈内入一个数据,top就要往上面走一步
}

//出栈
void StackPop(ST* ps)
{
	assert(ps);
	assert(ps->top > 0);
	ps->top--;
}
//判断栈是否为空
bool StackEmpty(ST* ps)
{
	assert(ps);
	return ps->top == 0;
}


//栈顶的元素是多少
STDataType StackTop(ST* ps)
{
	assert(ps);
	assert(ps->top > 0);
	return ps->a[ps->top - 1];
}


//销毁栈
void StackDestroy(ST* ps)
{
	free(ps->a);
	ps->a = NULL;
	ps->Capacity = 0;
	ps->top = 0;
}



void Swap(int* p1, int* p2)
{
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

int MidSize(int* a, int left, int right)
{
	int mid = (left + right) >> 1;
	//二进制向左移动一位就是除2的意思,找中间数
	while (left < right)
	{
		if (a[left] > a[mid])
		{
			if (a[mid] > a[right])
			{
				return mid;
			}
			else  if (a[right] > a[left])
			{
				return left;
			}
			else
			{
				return right;
			}  
		}
		else
		{
			if (a[right] < a[left])
			{
				return left;
			}
			else if (a[right] > a[mid])
			{
				return mid;
			}
			else
			{
				return right;
			}
		}

	}
}
//挖坑大法
int PartSort(int* a, int left,int right)
{
	if (left >= right)
		return;//递归的结束条件
	int begin = left;
	int end = right;
	int pit = begin;
	int mid = MidSize(a, left, right);
	Swap(&a[pit], &a[mid]);
	int key = a[pit];
	while (begin <end)
	{
		while (begin < end && key <= a[end])//先找右边的
		{
			end--;
		}
		a[pit] = a[end];
		pit = end;//更新坑的位置
		while (begin < end && key >= a[begin])//再找左边的
		{
			begin++;
		}
		a[pit] = a[begin];
		pit = begin;
	}
	pit = begin;
	a[pit] = key;//最后把坑的位置给填上
	return pit;
}

void QuickSort(int* a, int left, int right)
{
	ST st;
	StackInit(&st);
	StackPush(&st, right);
	StackPush(&st, left);
	while (!StackEmpty(&st))
	{
		int begin = StackTop(&st);
		StackPop(&st);
		int end= StackTop(&st);
		StackPop(&st);
		int mid = PartSort(a, begin, end);
		if (begin < mid - 1)//当begin>=mid-1,那么左区间即排序完毕
		{
			StackPush(&st, mid-1);//入栈的顺序要区别好,先右再左
			StackPush(&st, begin);
		}
		if (mid + 1 < end)
		{
			StackPush(&st, end);
			StackPush(&st, mid+1);
		}
	}
	StackDestroy(&st);
}


void Print(int* a, int n)
{
	printf("快速排序后为:\n");
	for (int i = 0; i < n; i++)
	{
		printf("%d ", a[i]);
	}
}
int main()
{
	int arr[] = { 3,6,8,2,9,5,4,1 };
	int n = sizeof(arr) / sizeof(arr[0]);
	QuickSort(arr, 0,n-1);
	Print(arr, n);
	return 0;
}

🍑二.冒泡排序(设置flag值) 

🍁1.从前往后冒

冒泡排序是一个非常简单的排序,去年的时候就写过了博客,只是当时还不知道可以设置值来减少冒泡的次数,从而提高效率。

简单的回顾一下思路,冒泡排序中的冒泡就是每次冒泡一个出去,就比如有10个数,我们先把前面的两个数进行比较,如果前面的数大于后面的数,那么它们两个数就进行交换,然后继续后面的数进行比较,10个数只需要交换9次,就可以把最大的一个数给冒泡到数组的末尾。

接着下一次只有9个数交换了,只需要交换8次。最大的也就被冒到最后面了,也就不用管了。
总的就是10个数需要冒9次,冒一次后,交换数的次数也会少一次。

void BubbleSort(int* a, int n)
{      //这里的n是数组的长度,等于8
	for (int i = 0; i < n - 1; i++)//8个数需要冒7次,[0-7)总共就是7次
	{
		int flag = 1;
		for (int j = 0; j < n - 1 - i; j++)//冒一次,少一次交换次数
		{
			if (a[j] > a[j + 1])
			{
				flag = 0;
		//如果没有进入if语句,说明一次都没有交换,即flag恒为1
				Swap(&a[j], &a[j + 1]);
			}
		}
		if (flag == 1)//所以flag为真,直接退出循环,冒泡完成
		{
			break;
		}
	}
}

🏵️2.从后往前冒

void BubbleSort2(int* a, int n)
{
	for (int i = 0; i < n-1; i++)
	{
		int flag = 1;
		for (int j = n - 1; j > i; j--)
		{
			if (a[j] < a[j - 1])
			{
				flag = 0;
				Swap(&a[j], &a[j - 1]);
			}
		}
		if (flag == 1)
		{
			break;
		}
	}
}

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

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

相关文章

什么是文件存储、对象存储、块存储?

什么是文件存储 文件存储带有文件系统&#xff0c;主要是以文件的形式存放数据&#xff0c;能将所有的目录、文件形成一个有层次的树形结构来管理&#xff0c;通过“树”不断伸展的枝丫就能找到你需要的文件。存储协议主要是NFS、CIFS等&#xff0c;以统一命名空间的形式共享一…

docker安装rabbitMQ,JAVA连接进行生产和消费,压测

1.docker安装 docker安装以及部署_docker bu shuminio_春风与麋鹿的博客-CSDN博客 2.doker安装rabbitMQ 使用此命令获取镜像列表 docker search rabbitMq 使用此命令拉取镜像 docker pull docker.io/rabbitmq:3.8-management 这里要注意&#xff0c;默认rabbitmq镜像是…

剑指 Offer 47: 礼物的最大价值

这道题看的出来只能往右往下只能走4步。 应该是每次i或者j加1&#xff01;不是先j后i 用for循&#xff0c;由于横纵坐标是固定的值&#xff08;一条直线&#xff09;&#xff0c;所以i0&#xff0c;j所有情况这种组合可以全部计算出来&#xff0c;当i1时&#xff0c;j0这里也…

图像的IO操作

1.读取图像 cv.imread()2.显示图像 cv.imshow()3.保存图像 cv.imwrite()4.参考代码 import numpy as np import cv2 as cv import matplotlib.pyplot as plt# 1.读取图像&#xff1a;默认方式是彩色图 img cv.imread("yangmi.jpg") # 彩色图 gray_img cv.imre…

C语言进阶---程序的编译(预处理操作)+链接

1、程序的翻译环境和执行环境 在ANSI C的任何一种实现中&#xff0c;存在两个不同的环境。 第1种是翻译环境&#xff0c;在这个环境中源代码被转换为可执行的机器指令。 第2种是执行环境&#xff0c;它用于实际执行代码。 1、每个源文件单独经过编译器处理&#xff0c;或生成一…

ps htop 输出可读文件

需要安装sudo apt-get install aha echo q | ps auxf | aha --black --line-fix > psps.html echo q | htop | aha --black --line-fix > htop.html 使用浏览器打开

Python神经网络学习(七)--强化学习--使用神经网络

前言 前面说到了强化学习&#xff0c;但是仅仅是使用了一个表格&#xff0c;这也是属于强化学习的范畴了&#xff0c;毕竟强化学习属于在试错中学习的。 但是现在有一些问题&#xff0c;如果这个表格非常大呢&#xff1f;悬崖徒步仅仅是一个长12宽4&#xff0c;每个位置4个动…

制造业网络安全最佳实践

网络安全已成为生产部门的一个重要关注点&#xff0c;制造业网络安全现在是高管层的主要考虑因素。 在工业 4.0和物联网 ( IoT )出现的推动下&#xff0c;当今的互连工业系统提供了多种优势&#xff0c;但也使组织面临新的风险和漏洞。 让我们探讨一些制造业网络安全最佳实践…

SOLIDWORKS软件有哪些版本?

SOLIDWORKS软件是基于Windows开发的三维 CAD系统&#xff0c;技术创新符合CAD技术的发展潮流和趋势&#xff0c;SOLIDWORKS每年都有数十乃至数百项的技术创新&#xff0c;公司也获得了很多荣誉。该系统在1995-1999年获得全球微机平台CAD系统评比NO1&#xff1b;从1995年至今&am…

Quiz 15: Object-Oriented Programming | Python for Everybody 配套练习_解题记录

文章目录 Python for Everybody课程简介 Quiz 15: Object-Oriented Programming单选题&#xff08;1-11&#xff09;Multiple instances Python for Everybody 课程简介 Python for Everybody 零基础程序设计&#xff08;Python 入门&#xff09; This course aims to teach e…

qemu 源码编译 qemu-system-aarch64 的方法

前言 最近调试 RT-Thread bsp qemu-virt64-aarch64 时&#xff0c;遇到无法使用网络设备问题&#xff0c;最后确认是 当前 ubuntu 20.04 系统 使用 apt install 安装的 qemu qemu-system-aarch64 版本太低。 RT-Thread qemu-virt64-aarch64 里面的网络设备&#xff0c;需要较新…

回顾分类决策树相关知识并利用python实现

大家好&#xff0c;我是带我去滑雪&#xff01; 决策树&#xff08;Decision Tree&#xff09;是一种基本的分类与回归方法&#xff0c;呈树形结构&#xff0c;在分类问题中&#xff0c;表示预计特征对实例进行分类的过程。它可以认为是if-then规则的集合&#xff0c;也可以认为…

多表-DDL以及DQL

多表DDL 个表之间也可能存在关系 存在在一对多和多对多和一对多的关系 一对多&#xff08;外键&#xff09; 在子表建一哥字段&#xff08;列&#xff09;和对应父表关联 父表是一&#xff0c;对应子表的多&#xff08;一个部门对应多个员工&#xff0c;但一个员工只能归属一…

结构体和数据结构--从基本数据类型到抽象数据类型、结构体的定义

在冯-诺依曼体系结构中&#xff0c;程序代码和数据都是以二进制存储的&#xff0c;因此对计算机系统和硬件本身而言&#xff0c;数据类型的概念其实是不存在的。 在高级语言中&#xff0c;为了有效的组织数据&#xff0c;规范数据的使用&#xff0c;提高程序的可读性&#xff0…

使用Streamlit和Matplotlib创建交互式折线图

大家好&#xff0c;本文将介绍使用Streamlit和Matplotlib创建一个用户友好的数据可视化Web应用程序。该应用程序允许上传CSV文件&#xff0c;并为任何选定列生成折线图。 构建Streamlit应用程序 在本文中&#xff0c;我们将指导完成创建此应用程序的步骤。无论你是专家还是刚刚…

three.js利用点材质打造星空

最终效果如图&#xff1a; 一、THREE.BufferGeometry介绍 这里只是做个简单的介绍&#xff0c;详细的介绍大家可以看看THREE.BufferGeometry及其属性介绍 THREE.BufferGeometry是Three.js中的一个重要的类&#xff0c;用于管理和操作几何图形数据。它是对THREE.Geometry的一…

leetcode 226. 翻转二叉树

2023.7.1 这题依旧可以用层序遍历的思路来做。 在层序遍历的代码上将所有节点的左右节点进行互换即可实现二叉树的反转。 下面上代码&#xff1a; class Solution { public:TreeNode* invertTree(TreeNode* root) {queue<TreeNode*> que;if(root nullptr) return{};que…

gradio库中的Dropdown模块:创建交互式下拉菜单

❤️觉得内容不错的话&#xff0c;欢迎点赞收藏加关注&#x1f60a;&#x1f60a;&#x1f60a;&#xff0c;后续会继续输入更多优质内容❤️ &#x1f449;有问题欢迎大家加关注私戳或者评论&#xff08;包括但不限于NLP算法相关&#xff0c;linux学习相关&#xff0c;读研读博…

2020年全国硕士研究生入学统一考试管理类专业学位联考逻辑试题——纯享题目版

&#x1f3e0;个人主页&#xff1a;fo安方的博客✨ &#x1f482;个人简历&#xff1a;大家好&#xff0c;我是fo安方&#xff0c;考取过HCIE Cloud Computing、CCIE Security、CISP等证书。&#x1f433; &#x1f495;兴趣爱好&#xff1a;b站天天刷&#xff0c;题目常常看&a…

编译原理期末复习简记(更新中~)

注意&#xff1a;该复习简记只是针对我校期末该课程复习纲要进行的&#xff0c;仅供参考 第一章 引论 编译程序是什么&#xff1f; 编译程序是一个涉及分析和综合的复杂系统 编译程序组成 编译程序通常由以下内容组成 词法分析器 输入 组成源程序的字符串输出 记号/单词序列语法…