• 数据结构基础(待续)


    基础知识

    数据结构基本概念

    1)数据

      数据元素:数据基本单位,可由若干数据项组成;

      数据对象:性质相同的数据元素的集合;

      数据类型:包括原子类型,结构类型,抽象数据类型;

      抽象数据类型(ADT):通常用(数据对象,数据关系,基本操作集)这样的三元组表示;

      数据结构:包括逻辑结构、存储结构、数据的运算;

    2)数据的逻辑结构(独立于计算机的,与存储结构无关)

      包括:线性结构(线性表,栈,队列),非线性结构(树,图,集合);

    3)数据的存储结构(物理结构,不独立于计算机)

      顺序存储:逻辑上相邻的元素存储在物理位置上也相邻的存储单元里;

      链式存储:用指示元素存储地址的指针表示元素之间的逻辑关系;

      索引存储:建立附加索引表,索引项一般为(关键字,地址);

      散列存储:即hash存储,由关键字直接计算出地址;

    4)数据的运算:包括运算的定义与实现;

    算法和算法评价

    1)算法:对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作;

    2)五个特性:有穷性,确定性,可行性,输入,输出;

    3)时间复杂度:设算法中所有语句的频度之和为T(n),算法中最深层循环内语句的频度为f(n),则T(n)=O(f(n));

      频度:语句在算法中被重复执行的次数;T(n)是问题规模n的函数;

      最好的时间复杂度,平均时间复杂度,最坏时间复杂度; => 一般考虑的是最坏时间复杂度;

      O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)

    4)空间复杂度:算法所耗费的存储空间,一般为除输入和程序之外的辅助空间;若算法原地工作,则辅助空间为常量O(1);

    5)有序表:关键字有序的线性表,属于逻辑结构;

    6)附加说明:

      将两个长度分别为m和n的升序链表,合并为一个长度为m+n的降序链表,最坏情况下的时间复杂度为O(max{m,n}),因为比较次数为2*max{m,n}-1(插空时);

      相同规模n下,复杂度为O(n)的算法在时间上总是优于复杂度为O(2^n)的算法;

      所谓时间复杂度,是指最坏情况下,估算算法执行时间的一个上界;

      同一个算法,实现语言的级别越高,执行效率就越低;

    线性表

    线性表是一种逻辑结构,顺序表和链表是存储结构

    线性表的定义和基本操作

    1)线性表:具有相同数据类型的n个数据元素的有限序列;L=(a1,a2,...,ai,ai+1,...,an);

      除第一个元素外,每个元素有且仅有一个直接前驱,除最后一个元素外,每个元素有且仅有一个直接后继;

      特征:有限个数据元素;逻辑结构;元素有先后次序(前驱后继关系);数据类型相同;

    2)基本操作:

      InitList(&L):初始化

      Length(&L):求表长

      LocateElem(L,e):按值查找

      GetElem(L,e):按位查找

      ListInsert(&L,i,e):插入

      ListDelete(&L,i,&e):删除,用e返回删除元素的值

      PrintList(L):输出

      Empty(L):判空

      DestroyList(&L):销毁

    线性表的顺序表示

    顺序表的定义

    1)顺序表:线性表的顺序存储,特点是表中元素的逻辑顺序与其物理顺序相同

    2)线性表的顺序存储类型描述

    1.静态分配(数据大小、空间固定)

    #define MaxSize 50 //定义线性表的最大长度
    typedef struct {
        ElemType data[MaxSize]; //顺序表的元素
        int length; //顺序表的当前长度
    }SqList;  //顺序表的类型定义

    2.动态分配(动态分配依然是顺序存储结构,随机存取方式,只是分配的空间大小可以在运行时决定)

    #define InitSize 100   //表长度的初始定义
    typedef struct {
        ElemType *data; //动态分配数组的指针
        int MaxSize,length;  //定义数组的最大容量和当前个数
    }SqList;   //动态分配数组顺序表的类型定义C语言

    C语言动态分配初始语句 :L.data=(ElemType*)malloc(sizeof(ElemType)*InitSize);

    C++动态分配初始语句 :L.data=New ElemType(InitSize);

    3)顺序表的特点:

      随机存取,随机访问,即首地址+元素序号可以在O(1)时间内找到指定元素;

      存储密度高,每个结点只存储数据元素;

      逻辑上相邻,物理上也相邻,插入和删除需移动大量元素;

    顺序表上基本操作的实现(插入,删除,查找)

    1)插入操作 

    //将元素e插入到顺序表的第i个位置
    bool ListInsert(SqList &L,int i, ElemType e) {
        if(i<1 || i>length+1){ //判断i的范围是否有效
           return false;
        } 
        if(L.length > MaxSize){
            return false; //当前存储空间已满,不能插入
        }
        for(int j=L.length;j>=i;j--){ //将第i个元素及之后的元素后移,从后往前后移
            L.data[j]=L.data[j-1];
        }
        L.data[i-1]=e;  //位置i处放入e
        L.length++;  //线性表长度加1
        return true;
    }  

    最好的情况:在表尾插入(i=n+1),则后移语句不执行,时间复杂度为O(1);

    最坏的情况:在表头插入(i=1),则元素后移执行n次,时间复杂度为O(n);

    平均情况:在第i个位置插入,元素后移执行n/2次,平均时间复杂度为O(n);

    2)删除操作

    //删除顺序表中第i个位置的元素
    bool ListDelete(SqList &L,int i,ElemType e){
        if(i<1 || i>L.length){ //判断i的范围是否有效
            return false;
        }
        e = L.data[i-1]; //将被删除的元素赋给e
        for(int j=i;j<L.length;j++){ //将第i个元素位置之后的元素前移
            L.data[j-1]=L.data[j];
        }
        L.length--;  //线性表长度减1
        return true;
    }

    最好情况:删除表尾元素(i=n),无需前移元素,时间复杂度为O(1);

    最坏情况:删除表头元素(i=1),前移元素n-1次,时间复杂度为O(n);

    平均情况:删除第i个位置的元素,前移(n-1)/2次,平均时间复杂度为O(n);

    3)按值查找(顺序查找)

    //查找顺序表中值为e的元素,若查找成功,则返回位序,否则返回0;
    int LocateElem(SqList L, ElemType e){
        for(int i=0;i<L.length;i++){
            if(L.data[i]==e){
               return i+1; 
        }
        return 0;
    }

    最好情况:查找元素在表头,仅需比较一次,时间复杂度为O(1);

    最坏情况:查找元素在表尾(或不存在)时,需比较n次,时间复杂度为O(n);

    平均情况:查找元素在第i个位置,需比较(n+1)/2次,平均时间复杂度为O(n);

     线性表的链式表示

    不需使用地址连续的存储单元,即不要求逻辑上相邻的两个元素在物理位置上也相邻,通过“链”建立起数据元素之间的逻辑关系;

    单链表

    1)定义:线性表的链式存储;每个链表节点,除了存放元素自身的信息外,还需要存放一个指向其后继的指针;

       单链表中结点类型描述:

    typedef struct LNode{  //定义单链表结点类型
        ElemType data; //数据域
        struct LNode *next;  //指针域
    }LNode,*LinkList;

      优点:解决顺序表需要大量连续存储空间的缺点;

      缺点:由于附加指针域,所以也浪费空间;

      特点:非随机存储结构,查找特定结点时,需要从表头开始遍历,依次查找;

    2)通常用"头指针"标识一个单链表L,头指针为"NULL"时表示一个空表;

      头结点:为操作方便,单链表第一个结点之前附加一个结点,称为头结点,头结点的指针域指向第一个结点,数据域可以不设信息,也可记录表长等相关信息;

      头结点&头指针:

      1.不论是否有头结点,头指针始终指向链表的第一个结点;

      2.头结点是带头结点链表的第一个结点,结点内通常不存储任何信息;

      头结点的优点:

      1.链表的第一个位置操作与其他位置一致;

      2.头指针是指向头结点(或链表第一个结点)的非空指针(空表中头结点的指针域为空),所以空表非空表处理一致;

    单链表基本操作的实现

    1)采用头插法建立单链表

    //从空表开始生成新结点
    //将新结点插入到当前链表的表头,即头结点之后
    LinkList CreateList1(LinkList &L){
        LNode *s;  //新结点
        int x;  //结点值
        L=(LinkList)malloc(sizeof(LNode));  //创建头结点
        L->next = NULL;  //初始为空链表
        scanf("%d",&x);  //输入结点值
        while(x != 9999)   //输入9999表示结束
       {
            s=(LNode*)malloc(sizeof(LNode));  //创建新结点
            s->data = x;
            s->next = L->next;  //将新结点插入表中,L为头指针
            L->next = s;
            scanf("%d",&x);
        }
        return L;
    } 

    特点: 读入数据的顺序与生成链表中元素顺序相反;设单链表长为n,则时间复杂度为O(n).

    2)采用尾插法建立单链表

      将新结点插入到当前链表的表尾上,需要增加一个尾指针r,使其始终指向当前链表的尾结点.(即最后一个结点为r,会更新)

    LinkList CreateList2(LinkList &L){
        int x; //元素类型为整型
        L=(LinkList)malloc(sizeof(LNode)); //创建头结点
        LNode *s,*r=L;
        scanf("%d",&x);  //输入结点值
        while(x != 9999)  {  //输入9999表示结束
            s=(LNode*)malloc(sizeof(LNode));  //创建新结点
            s->data = x;
            r->next = s;  //将新结点插入表中,r为表尾指针
            r = s->next;  //r指向新的表尾结点
            scanf("%d",&x);
        }
        r->next = NULL;  //尾结点指针置空
        return L;
    }

    时间复杂度为O(n),单链表长为n的情况下 

    3)按序号查找结点值

    //取出单链表L(带头结点)中第i个位置的结点指针(元素从第一个位置开始)
    LNode *GetElem(LinkList L,int i){
        int j=1; //计数,初始为1
        LNode *p=L->next;  //将头结点指针赋给p
        if(i==0){
            return L;   //返回头结点
        }
        if(i<0){
            return NULL;  //无效,返回NULL
        }
        while(p && j<i){  //从第1个结点开始查找第i个结点
            p = p->next;
            j++;
        }
        return p;   //返回第i个结点的指针
    }
    

    4)按值查找表结点

    //查找单链表L(带头结点)中数据域值等于e的结点指针,否则返回NULL
    LNode *LocateElem(LinkList L, ElemType e){
        LNode *p=L->next;  //将头结点的指针赋给p
        while(p!=NULL && p->data != e){  //从第1个结点开始查找data域为e的结点
            p = p->next;
        }
        return p;  //指到后返回该结点指针,否则返回NULL
    }
    

    5)插入结点操作

    //将值为x的新结点插入到单链表的第i个位置上
    p = GetElem(L,i-1);  //查找前驱结点
    s->next = p->next;
    p->next = s;

    时间复杂度为O(n)(主要是GetElem(L,i-1)所花费的),若在固定结点后插入,时间复杂度为O(1).

    //将*s插入到*p之前
    s->next = p->next; //s插入到p之后
    p->next = s;
    temp=p->data;  //*s,*p交换数据域
    p->data = s->data;
    s->data = temp;    //时间复杂度为O(1)
    

    6)删除结点操作

    //将单链表的第i个结点删除
    p=GetElem(L,i-1); //查找删除位置的前驱结点
    q=p->next;  //令q指向被删除结点
    p->next = q->next; //将*q结点从链中断开
    free(q);  //释放结点的存储空间

    时间复杂度为O(n)(主要用于GetElem(L,i-1));

    //仅知p的后继结点,删除*p
    q=p->next;  //删除后继
    p->next = q->next; //将后继数据域赋给p
    free(q);

    7)求表长操作

    计算单链表中数据结点(不含头结点)的个数,时间复杂度为O(n)(设置计数器变量),单链表的长度是不包括头结点的;

    双链表

    1)单链表:只能从前往后顺序遍历,访问前驱的时间复杂度为O(n),访问后继为O(1);

    2)双链表:有两个指针prior和next,分别指向其前驱结点和后继结点;

    3)双链表中结点类型描述

    typedef struct DNode{   //定义双链表结点类型
        ElemType data;  //数据域
        struct DNode *prior,*next;  //前驱和后继指针
    }DNode,*DLinkList;

    4)双链表的插入操作

    //在*p所指结点之后插入结点*s,时间复杂度为O(1)
    s->next = p->next;
    p->next->prior = s;
    p->next = s; //前两行代码必须在该代码之前
    s->prior = p;
    

    5)双链表的删除操作

    //删除双链表中结点*p的后继结点*q,时间复杂度为O(1)
    p->next = q->next;
    q->next->prior = p;
    free(q);  //释放结点空间
    循环链表 

    1)循环单链表:与单链表的区别在于,最后一个结点的指针不是NULL,而是指向头结点;

      表尾结点*r的next域指向L,表中没有指针域为NULL的结点;

      判空条件:头结点的指针是否等于头指针;

      因为循环单链表是一个环,所以在任何位置上的插入和删除操作都等价,无需判断表尾;

      单链表:只能从表头结点开始往后顺序遍历整个链表;循环单链表:可以从表中任一结点开始遍历整个链表;

      仅设立尾指针的循环单链表,设r是尾指针,r->next即为头指针,对表头表尾操作都只需O(1)的时间复杂度;

    3)循环双链表

      表尾结点的next指向头结点,头结点的prior指向表尾结点;

      循环双链表L中,当结点*p为尾结点时,p->next=L;当为空表时,L->prior=L, L->next=L;

    静态链表

    1)借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next。此处指针为结点的相对地址(数组下标),静态链表也要预先分配一块连续的存储空间;

     2)静态链表结构类型描述

    #define MaxSize 50  //静态链表的最大长度
    typedef struct {  //静态链表结构类型的定义
        ElemType data;  //存储数据元素
        int next;      //下一个元素的数组下标
    }SLinkList[MaxSize]; 

      静态链表以next=-1作为其结束的标志(设单链表使用方便)

    顺序表和链表的比较

    1)如何选取存储结构

      存储考虑:当线性表长度或存储规模难以估计 =>链表;

      运算考虑:按序号访问 =>顺序表;插入、删除=>链表;

      环境考虑:顺序表基于数组,链表基于指针;

    栈和队列

     栈

    基本概念

    1)栈:只允许在一端进行插入或删除操作的线性表;

    2)栈顶:线性表允许进行插入和删除的那一端;

    3)栈底:固定的,不允许进行插入和删除的那一端;

    4)空栈:不含任何元素的空表;

    5)栈特点:后进先出;

    6)基本操作:

      InitStack(&S):初始化一个空栈S;

      StackEmpty(S):判断栈是否为空,空返回true,不空返回false;

      Push(&S,x):进栈;

      Pop(&S,x):出栈

      GetTop(S,&x):读栈顶元素

      ClearStack(&S):销毁栈,并释放栈S占用的存储空间;

    栈的顺序存储结构(顺序栈)

    1)顺序栈:栈的顺序存储,利用一组地址连续的存储单元存放自栈底至栈顶的数据元素,同时附设一个指针top指示当前栈顶的位置;

    2)栈的顺序存储类型描述:

    #define MaxSize 50  //定义栈中元素的最大个数
    typedef struct{
        ElemType data[MaxSize]; //存放栈中元素
        int top;   //栈顶指针
    }SqStack;

    初始栈顶指针S.top=-1; 栈顶元素S.data[S.top];栈空:S.top=-1;栈满:S.top=MaxSize-1;栈长S.top+1;进栈+1,出栈-1;

    3)顺序栈的基本运算

    1.初始化

    void InitStack(&S){
        S.top=-1;  //初始化栈顶指针
    }

    2.判栈空

    bool StackEmpty(S){
        if(S.top==-1){  //栈空
            return true;
        }
        else{    //不空
            return false;
    }    

    3.进栈

    bool Push(SqStack &S,ElemType x){
        if(S.top=MaxSize-1){    //栈满,报错
            return false;
        }
        S.data[++S.top]=x;    //进栈,指针加1再进栈
        return true;
    }

    4.出栈

    bool Pop(SqStack &S,ElemType &x){
        if(S.top==-1){    //栈空,报错
            return false;
        }
        x=S.data[S.top--];   //先出栈,指针再减1
        return true;
    }

    5.读栈顶元素

    bool GetTop(SqStack S,ElemType &x){
        if(S.top==-1){   //栈空,报错
            return false;
        }
        x=S.data[S.top];  //x记录栈顶元素
        return true;
    }

    注意初始条件,S.top=-1表示栈空,S.top=0表示指向第一个元素;

    4)共享栈(能有效利用存储空间)

      将栈底设置在共享空间两端,固定不变;栈顶向共享空间的中间延伸;

    栈的链式存储结构(链栈)

    1)通常用单链表实现,规定所有操作在单链表表头进行,规定链栈没有头结点,Lhead指向栈顶元素;

    2)栈的链式存储类型描述

    typedef struct Linknode {
        ElemType data;  //数据域
        struct Linknode *next; //指针域
    }*LiStack;   //栈类型定义

    队列 

    队列基本概念

    1)队列:简称队,只允许在表的一端进行插入,而在表的另一端进行删除的线性表,先进先出(FIFO);

    2)队头:允许删除的一端,删除元素称为出队或离队;

    3)队尾:允许插入的一段,插入元素称为入队或进队;

    4)空队列:不含任何元素的空表;

    5)基本操作:

      InitQueue(&Q):初始化队列;

      QueueEmpty(&Q):判队空;

      EnQueue(&Q,x):入队;

      DeQueue(&Q,&x):出队;

      GetHead(Q,&x):读队头元素,将值赋给x;

    队列的顺序存储结构

    ==>队列的顺序存储

    1)队列的顺序存储:分配一块连续的存储单元存放队列中的元素,并设两个指针,front指向当前队头元素的位置,rear指向当前队尾元素的位置。

    2)队列顺序存储类型描述:

    #define MaxSize 50 //定义队列中元素的最大个数
    typedef struct{
        ElemType data[MaxSize];  //存放队列元素
        int front,rear;   //队头指针和队尾指针
    }SqQueue;

    3)

    初始状态 (队空):Q.front = Q.rear = 0;

    进队:先送值到队尾,再队尾指针+1;

    出队:先取队头值,再队头指针+1;

    ==>循环队列

    1)把存储队列元素的表从逻辑上看作一个环;

      初始:Q.front = Q.rear = 0;

      入队:队尾指针进1取模,Q.rear=(Q.rear+1)%MaxSize;

      出队:队首指针进1取模,Q.front=(Q.front+1)%MaxSize;

      队列长度:(Q.rear+MaxSize-Q.front)%MaxSize;

    2)队空队满判定方法

      1.牺牲一个单元,入队时少用一个单元,front在rear下一个位置则队满;

        队空:Q.front == Q.rear;

        队满:Q.front == (Q.rear+1)%MaxSize;

        队列长度:(Q.rear+MaxSize-Q.front)%MaxSize;

      2.增设表示元素个数的数据成员

        队空:Q.size=0;

        队满:Q.size=MaxSize;

      3.增设数据成员tag

        队空:tag=0,删除导致Q.front=Q.rear;

        队满:tag=1,插入导致Q.front=Q.rear;

    ==>循环队列的操作(采用“牺牲一个单元”判定法则判队空队满)

    1)初始化

    void InitQueue(&Q){
        Q.front = Q.rear =0;   //初始化队首、队尾指针
    }
    

    2)判队空

    bool IsEmpty(Q){
        if(Q.rear==Q.front){  //队空条件
            return true;
        }
        else{
            return false;
        }
    }
    

    3)入队

    bool EnQueue(SqQueue &Q,ElemType x){
        if((Q.rear+1)%MaxSize == Q.front){  //队满
            return false;
        }
        Q.data[Q.rear]=x; //送值至队尾
        Q.rear = (Q.rear+1)%MaxSize; //队尾指针加1取模
        return true;
    }
    

    4)出队

    bool DeQueue(SqQueue &Q,ElemType &x){
        if(Q.rear == Q.front){  //队空
            return false;
        }
        x=Q.data[Q.front];  //取队头元素值
        Q.front=(Q.front+1)%MaxSize;  //队头指针加1取模
        return true;
    }
    队列的链式存储结构

    ==>队列的链式存储

    1)队列的链式表示称为链式队列,同时带有队头指针和队尾指针的单链表,头指针指向队头结点,尾指针指向队尾结点;

    2)队列链式存储类型描述

    typedef struct {  //链式队列结点
        ElemType data;
        struct LinkNode *next;
    }LinkNode;
    
    typedef struct{  //链式队列
        LinkNode *front,*rear; //链式队列队头和队尾指针
    }LinkQueue;

      队空:Q.front=NULL and Q.rear=NULL(若是带头结点的链式队列,只需Q.rear=Q.front即可)

      入队:新结点插入链表尾部,即Q.rear指向新结点

      出队:取队头元素,Q.front指向下一结点(若该结点为最后一个结点,则令Q.front,Q.rear均为NULL)

    3)链式队列的优点:不存在存储分配不合理和溢出问题,适合数据元素变动较大的问题;

    ==>链式队列基本操作

    1)初始化

    void InitQueue(LinkQueue &Q){
        Q.front = Q.rear =(LinkNode *)malloc(sizeof(LinkNode)); // 建立头结点
        Q.front->next = NULL;  //初始为空
    }
    

    2)判队空

    bool IsEmpty(LinkQueue Q){
        if(Q.rear==Q.front){
            return true;
        }
        else{
            return false;
        }
    }

    3)入队

    void EnQueue(LinkQueue &Q,ElemType x){
        s=(LinkNode *)malloc(sizeof(LinkNode));  //创建新结点
        s->data=x;
        s->next = NULL;
        Q.rear->next = s;   //插入链表尾部
        Q.rear=s;
    }
    

    4)出队

    bool DeQueue(LinkQueue &Q,ElemType &x){
        if(Q.front==Q.rear){  //队空
            return false;
        }
        p=Q.front->next;
        x=p->data;
        Q.front->next = p->next;
        if(Q.rear ==p){  //若原队列只有一个结点,则删除后变空
            Q.rear = Q.front;
        }
        free(p);
        return true;
    }
    双端队列

    1)允许两端都可以进行入队和出队操作的队列(元素逻辑结构仍是线性结构)

      进队:前端进的在后端进的前面

      出队:先出的元素排在后出元素的前面

    2)输出受限的双端队列:允许在一端进行插入和删除,另一端只允许插入的双端队列;

    3)输入受限的双端队列:允许在一端进行插入和删除,另一端只允许删除的双端队列;

     不能通过输入受限的双端队列得到的是4,2,3,1和4,2,1,3;

     不能通过输出受限的双端队列得到的是4,2,3,1和4,1,3,2;

    栈和队列的应用

    栈的应用

    1)栈在括号匹配的应用:

      设空栈=>左括号进栈=>右括号,则消除栈中与之最近的左括号(匹配则继续,不匹配则退出);

    2)栈在表达式求值中的应用:后缀表达式求值

      操作数进栈=>操作符,从栈中取最上的操作数计算,并将结果压入栈中=>重复上述操作;

    3)栈在递归中的应用

      递归=>非递归,需要借助栈;

    队列的应用

    1)队列在层次遍历中的应用

      根结点入队;队空则结束遍历重复第三步;队列第一个结点出队并访问,若有左孩子,则将左孩子入队,若有右孩子,则将右孩子入队,返回第二步;

    2)队列在计算机系统中的应用

      打印机的数据缓冲区(主机和外设速度不匹配的问题);

      CPU资源竞争(队首用户先使用)(多用户引起的资源竞争问题);

    特殊矩阵的压缩存储

    数组

    数组是线性表的推广,一维数组是一个线性表,二维数组可看作线性表的线性表;数组一旦定义,维数和维界不可改变,除初始化和销毁外,只能存取和修改元素;

    数组的存储结构

    一维数组: A[0,1,...,n-1]

    二维数组:按行优先和按列优先

    矩阵的压缩存储

    压缩矩阵:多个值相同的元素只分配一个存储空间;

    1)对称矩阵

     2)三角矩阵

     3)三对角矩阵

     4)稀疏矩阵

    树与二叉树

     树的基本概念

    待续-----------------------------------

    参考资料:

    《王道数据结构》

  • 相关阅读:
    assign()与create()的区别
    ES6对象扩展——部分新的方法和属性
    ES6对象扩展——扩展运算符
    rest operater剩余操作符
    深拷贝和浅拷贝
    for in和for of的简单区别
    查询ES6兼容的网站
    ES6扩展——对象的扩展(简洁表示法与属性名表达式)
    滚动条设置样式
    marquee横向无缝滚动无js
  • 原文地址:https://www.cnblogs.com/meiqin970126/p/15919876.html
Copyright © 2020-2023  润新知