程序员不得不会的计算机科班知识——数据结构与算法篇(上)

5,519 阅读19分钟

计算机科班知识整理专栏系列文章:

【1】程序员不得不会的计算机科班知识——操作系统篇(上)
【2】程序员不得不会的计算机科班知识——操作系统篇(下)
【3】程序员不得不会的计算机科班知识——数据库原理篇(上)
【4】程序员不得不会的计算机科班知识——数据库原理篇(下)
【5】程序员不得不会的计算机科班知识——数据结构与算法篇(上)
【6】程序员不得不会的计算机科班知识——数据结构与算法篇(下)
【7】程序员不得不会的计算机科班知识——软件工程篇(上)
【8】程序员不得不会的计算机科班知识——软件工程篇(中)
【9】程序员不得不会的计算机科班知识——软件工程篇(下)
【10】程序员不得不会的计算机科班知识——编译原理篇(上)
【11】程序员不得不会的计算机科班知识——编译原理篇(中)
【12】程序员不得不会的计算机科班知识——编译原理篇(下)
【13】程序员不得不会的计算机科班知识——计算机网络篇(上)
【14】程序员不得不会的计算机科班知识——计算机网络篇(中)
【15】程序员不得不会的计算机科班知识——计算机网络篇(下)

第一章 绪论

  • 数据元素: 数据元素是数据的基本单位,也被称为记录。
  • 数据项: 有的数据元素由若干数据项组成,数据项是不可分割的最小单位。
  • 数据对象: 具有相同性质的数据元素的集合,即数据的一个子集。
  • 数据结构: 数据结构数据结构指相互之间存在看一定关系的数据元素的集合人逻辑结构(包括逻辑结构、物理结构与运算)。数据结构可形式定义为为( D , S ),其中 S 是 D 上关系的有限集。
  • 数据类型: 数据类型是指一组性质相同的值的集合以及定义在此集合上的一些操作总称。数据结构在计算机中的表示称为计算机的存储结构。
  • 算法的特性: 正确性、具体性、确定性、有限性、可读性、健壮性。
  • 时间复杂度: 时间复杂度是一个函数,它定性描述算法的运行时间。它用大O符号表示,不包括这个函数的低阶项和首项系数。时间复杂度可以用来估算程序的运行时间和资源消耗。不同的问题规模,对应不同的时间复杂度f(n)。常见的时间复杂度有O(1)、O(logn)、O(n)、O(nlogn)等。

常见的时间复杂度排序如下(从小到大):

O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(n³) < ... < O(2ⁿ) < O(n!)

其中,n代表问题规模,O表示渐进符号,表示算法执行时间随问题规模增大而增长的趋势。时间复杂度越小,算法的效率越高。

第二章 线性表

2.1 线性表概述

  • 含有大量记录的线性表称为文件
  • 线性表的4个特点:
    1. 同一性
    2. 有穷性
    3. 有序性
    4. 数据的运算是定义在逻辑结构上, 而运算的具体实现则是在存储结构上进行的。
  • 头结点的数据域可以为空,也可存放线性表长度等附加信息,但此结点不计入链表长度。

2.2 链表的种类

2.2.1 单链表

  • 特点:不能访问到它的前驱节点。

2.2.1.1 动态建立单链表的方法

头插法: 将新结点插入到当前链表表头上,导致生成的链表中结点的次序和生成的顺序相反。

S:新申请的节点,L:头节点

S->next = L->next

L->next = S;

尾插法: 将新结点插入到当前链表表尾上,为此要增加一个尾指针r。

r->next = S

2.2.1.2 在单链表第i个结点前抽入个结点的过程

pre:原本的第i-1个结点,S:新申请的结点

S->next = pre->next;

pre->next = S;

2.2.1.3 在单链表删除第i个结点的过程

pre:原本的第i-1个结点,S:要删除的节点

S = pre->next;

pre->next = pre->next->next;(pre-next为要删除的节点,pre->next->next为第i+1个节点)

free(S);

2.2.2 循环链表

特点:最后一个结点的next指针不为null,而是指向了链表的前端。循环的条件是移动的指针是否等于头指针。

(能实现以从任一结点出发着链能找到其前驱结点,时间消耗为O(n)

  • 在用头指针表示的单链表中,找开始结点a1的时间是O(1),然而要找到终端节点an,则需要从头指针开始遍历整个链表,其时间是O(n)。
  • 用尾指针,则时间复杂度都是O(1)。

2.2.3 双向链表

有两个指针域,back和next,设p为某个节点,则有:

p->back->next==p==p->next->back(p->back为前驱节点,p->next为后驱节点)

2.3 关于线性表的选择

  • 当线性表长度变化不大时:顺序表
  • 主要操作为查找,很少插入和删除时:顺序表
  • 频繁插入和删除时:链表
  • 插入和删除主要发生在表的首尾两端时:尾指针表示的单循环链表
  • 主要在最后一个结点后插入和删除最后一个结点时:带头结点的双循环链表最省时间
  • 若用单链表来表示队列时:带尾指针的循环链表

第三章 栈和队列

3.1 栈

3.1.1 概述

  • 特点:先进后出,表尾称为栈顶,表头称为栈底。

  • 多栈共享空间:主要是2个栈共用一个数组,一个用前部分、一个用后部分。

3.1.2 栈的种类

顺序栈

  • 利用了“栈底位置不变,而栈顶位置动态变化”的特性。

链式栈

  • 链式栈无栈满问题,空间可扩充。
  • 链式栈的栈顶在链头
  • 由于只能在链表头部进行操作,故链表没有像单链表那样加头结点。
  • 栈顶指针就是链表的头指针,链表空的条件是top->next== null;

3.2 队列

3.2.1 概述

  • 队列:先进先出,允许插入的一端叫做队尾,允许删除的一端叫队头。(单链表表示时,叫链尾和链头)

3.2.2 队列的种类

双端队列

  • 两头都可以进行插入、删除操作(有输出受限/输入受限情况)
  • 如果双端队列从某个端点插入的元素只能从端点删除,该队列就成为两个栈底相邻的栈。

链队列:

  • 一个链队列需要两个分别表示队头和队尾的指针,称为头指针和尾指针,当队空时,front==rear。

顺序队列的假溢现象:

解决假溢出的方法:

① 将数组首尾相接,形成循环队列

② 采用数据前移

循环队列:

概念:队头front、队尾rear自加1时,从maxsize-1直接进到0,可用语言的取模(余数)运算实现。

队头front自加1:front = (front+1)%maxsize;

队尾rear自加1:rear = (rear+1)%maxsize;

队列初始化:front = rear = 0;

队满条件:(rear+1)%maxsize = front 或者 length() == maxsize-1;

第四章 串

4.1 概述

  • 空格串≠空串
  • 定长顺序存储表示,属于静态存储结构
  • 堆分配存储表示,属于动态存储结构
  • 块链存储表示,属于动态存储结构

设S为一个长度为n的字符串,其中的字符各不相同,则S的互异非平凡子串(非空且不同于S本身)个数为n^2/2+n/2-1。

分析:长度为n-1的不同子串数为2,长度为n-2的不同子串个数为3...,长度为1的不同子串个数为n。

4.2 字符串匹配算法

4.2.1 简单字符串匹配算法

布鲁特-福斯算法,简称BF算法:(没有必要的回溯导致时间消耗大)
从主串的指定位置开始和模式的第一个字符比较,若相等则继续比较下一个字符;否则从主串的下一个开始再重新和模式的字符比较,直到最后结束。时间复杂度最好为O(n+m),最坏为O(n*m)

step1:

S:a b b a b a
P:a b a

step2:

S:a b b a b a
P: a b a

step3:

S:a b b a b a
P: a b a

step4:

S:a b b a b a
P: a b a

4.2.2 首尾字符串匹配算法

  • 在简单字符串匹配算法上改进:从模式串的两头分别进行比较的方法。

4.2.3 KMP字符串匹配算法

  • 时间复杂度为O(m+n)
  • 前缀:指的是字符串的子串中从原串最前面开始的子串,如abcdef的前缀有a, ab, abc, abcd, abcde。(不包括整个串)
  • 后缀:指的是字符串的子串中在原串结尾处结尾的子串,如abcdef的后缀有f, ef, def, cdef, bcdef。(不包括整个串)
  • 最长相等前缀后缀:如“abcabfabcab”的最长相等前后缀为“abcab”

KMP算法精髓:子串移动的结果就是让子串的红色部分最长相等前缀和主串红色部分最长相等后缀对齐。 如:

S:a b c a b e a b c a b c m n
T:a b c a b c m n
==》
S:a b c a b e a b c a b c m n
T: a b c a b c m n

事实上,每一个字符前的字符串都有最长相等前后缀,二最长相等前后缀的长度是我们移位的关键,所以我们单独用一个next数组存储子串的最长相等前后缀长度。而且next数组的数值只与子串本身有关。

next[i] = j,含义是下标为i的字符前字符串最长相等前后缀长度为j。

例如,子串t = “abcabcmn”的next数组为next[0] = -1(前面没有字符串单独处理),next[1] = 0,next[2] = 0,next[3] = 0,next[4] = 1,next[5] = 2,next[6] = 3,next[7] = 0。

如前面的例子,s[5]与t[5]不匹配了,把子串移动,也就是让s[5]与t[5]前面字符串的最长相等前缀的后一个字符比较,而该字符位置就是t[?],显然? = 2,也就是next[5]的值。即下一次比较的是s[5]和t[next[5]]。

next数组的作用:

① next[i]的值表示下标为i前的字符串最长相等前后缀长度

② 表示该处字符不匹配时应该回溯的字符的下标

附:力扣28. 找出字符串中第一个匹配项的下标的kmp解法:

    /**
     * @param {string} haystack
     * @param {string} needle
     * @return {number}
     */
    var strStr = function(haystack, needle) {
        // 考虑空串情况
        if (needle === null || needle.length === 0) {
            return 0;
        }
        var next = kmpNext(needle);
        var i = 0, j = 0;
        while (i < haystack.length && j < needle.length) {
            if (j === -1 || haystack[i] === needle[j]) {
                i++;
                j++;
            } else {
                j = next[j];
            }
        }
        return j === needle.length ? i - j : -1;
    };
    // 找出字符串(子串)的next数组
    function kmpNext(dest) {
        //创建一个 next 数组保存部分匹配值
        var next = new Array(dest.length);
        // next数组的求解方法是:
        // 第一位的next值为-1,第二位为0
        // 后面求解每一位的next值时,根据前一位进行比较。
        // 首先将前一位与其next值对应的内容进行比较,
        // 如果相等,则该位的next值就是前一位的next值加上1;
        // 如果不等,向前继续寻找next值对应的内容来与前一位进行比较,
        // 直到找到某个位上内容的next值对应的内容与前一位相等为止,
        // 则这个位对应的值加上1即为需求的next值;
        // 如果找到第一位都没有找到与前一位相等的内容,那么需求的位上的next值即为1。
        next[0] = -1;
        var i = 0, j = -1;
        while (i < dest.length - 1) {
            if (j === -1 || dest[i] === dest[j]) {
                i++;
                j++;
                next[i] = j;
            } else {
                j = next[j];
            }
        }
        return next;
    }

第五章 数组和广义表

5.1 数组

5.1.1 矩阵的类型

对称矩阵:

满足aij = aji,可以只存储矩阵中上三角或下三角的元素,待节约近一半的空间,即存储n(n+1)/2元素即可。

按行序为主序:(即i>=j,aij在下三角形中)

0f141223e0a4f65324c42969c35b03b.jpg

三角矩阵:

形如:

对称矩阵的存储方式可以应用于三角矩阵。

对角矩阵:

k对角矩阵满足(k为奇数):当|i-j|>(k-1)/2时,aij = 0。

对于三对角矩阵来说,当|i-j|>1时,aij = 0,如,需存储元素个数为3n-2。

按行序为主序:

d49c10895075013e8f004e84e506f47.png

5.1.2 稀疏矩阵

设矩阵有S个非零元素,当e = S/mn <=0.05时,称为稀疏矩阵。

5.1.2.1 三元组表示稀疏矩阵

形如

用三元组实现稀疏矩阵的转置:(假设原三元组为source,转置后为dest)

① 简单转置算法:(时间复杂度为O(cols*num),因为对每一个列号,从头到尾扫描一遍三元组表)

    destPos = 0; //稀疏矩阵dest的第一个三元组存放位置
    for(col = 最小列号; col<=最大列号;col++){
        //在source中从头查找有无列号为col的三元组;
        //若有,将其行、列交换后,依次放入dest中
        //destPos所指位置,同时destPos加1
    }

② 快速转置算法:

需用两个数组:

  • num[col]:统计稀疏矩阵中第col列中非0元素个数
  • cpot[col]:指示稀疏矩阵A中第col列第一个非0元素在转置后的三元组表中的位置

从第二列开始,每一列第一个非零元素序列为上一列非零元素序列加上上一列非零元素个数(即cpot[i] = cpot[i-1]+num[i-1])。

然后某一列下一个非零元素序列是它上一个序列加1。

5.1.2.2 十字链表法表示稀疏矩阵

形如

其中,向下域指示同一列中下一个非零元素的存储节点序号,向右域指示同一行中下一个非零元素的存储节点序号。

稀疏矩阵A的十字链表表示:(用两个二维数组分别存储行链表的头指针和列链表的头指针)

5.2 广义表

  • 广义表:由n(n>=0,为表的长度)个表元素组成的有限序列,记作GL = (a1,a2,a3,...,an),GL为表名,ai为表元素,简称为元素,它可以是表(称为子表元素,简称子表,一般用大写字母表示),也可以是数据元素(称为原子元素,简称为原子,一般用小写字母表示)。

  • n = 0时,称为空表。n>0时,表的第一个表元素称为表头(head),除此之外,其它表元素组成的表称为表尾(tail)。

  • E = (a,E):这是一个递归的表,长度为2,相当于一个无限的列表。

  • 广义表表头和表尾有如下性质:

    • 任何一个非空列表,其表头可能是原子,也可能是广义表。
    • 任何一个非空列表,其表尾必定为广义表。
  • 广义表深度:中括号的最大嵌套层数,与长度概念不同:

第六章 树和二叉树

6.1 概述

  • 结点的度:一个结点的子树个数

  • 树的度:树中所有节点度的最大值

  • 叶子节点(终端结点):度为0,分支节点(非终端结点):度不为0

6.2 二叉树

6.2.1 二叉树的性质

二叉树中,每个节点的孩子节点次序不能任意颠倒。(即要区分左子树和右子树)

具有n个节点不同二叉树的棵树为:

二叉树的性质:

  1. 第i层最多有2^(i-1)个节点
  2. 深度为k=>至多有2^k - 1个节点(2^0+2^1+2^2+·····+2^(k-1) = 2^k-1)
  3. n0个叶子节点,n2个度为2的节点=>n0 = n2+1

证明:设叶子节点总数为n,度为1的节点个数为n1,分支数(即树中短线的数目,即总度数)为b,则有:
n = n0+n1+n2
b=0n0+1n1+2n2 = n1+2n2
b = n-1 = n0+n1+n2-1 = n1+2*n2(总度数 = 节点总数-1)
=>n0 = n2+1

满二叉树:每一层节点都满,即节点总数为2^(k-1)个(节点顺序表示:从上到下,从左到右)
完全二叉树:实质上是在一棵满二叉树基础上,从右至左、从上至下去掉结点。(叶子节点出现在第k层或者k-1层) da21ae7dddca685d7e39fbba290b97a.png

  1. n个节点的完全二叉树深度:

证明:

  1. 对于n个节点的二叉树,按照上面的编序方式,则对序号为i的节点有:

    • i>1时,i的双亲节点序号为i/2(向下取整)
    • 2i>n时,i无左孩子;2i<=n时,i的左孩子为2i
    • 2i+1>n时,i无右孩子;2i+1<=n时,i的右孩子为2i+1

6.2.2 二叉树的遍历

例1:
DLR:ABDFGCEH
LDR:BFDGACEH
LRD:FGDBHECA

例2:(a+b*(c-d)-e/f)
DLR:-+a*b-cd/ef
LDR:a+b*c-d-e/f
LRD:abcd-*+ef/-

  • 已知先序序列=>二叉树不唯一
  • 已知先序、中序=>可确定
  • 已知中序、后序=>可确定

先序遍历:

    //先序遍历的递归,主要思想是先访问根节点,然后访问左子树,最后访问右子树 
    Status PreBiTree(BiTree T)
    {
    	if (T != NULL)
    	{
    		cout << T->data << "\t";
    		PreBiTree(T->lchild);
    		PreBiTree(T->rchild);
    	}
    	return OK;
    }


    //先序遍历的非递归。在二叉树先序遍历非递归算法中,
    //先将根结点压栈,在栈不为空的时候执行循环:让栈顶元素p出栈,访问栈顶元素p,
    //如果p的右孩子不为空,则让其右孩子先进栈,
    //如果p的左孩子不为空,则再让其左孩子进栈, 
    //(注意:进栈顺序一定是先右孩子,再左孩子)。 
    Status SqlpreBiTree(LinkStack &S,BiTree T)
    {
    	BiTNode *p = T;
    	BiTNode *q = new BiTNode;
    	while (p || !Empty(S))//在树不为空的情况下 
    	{
    		if (p) {
    			PushStack(S, p);
    			cout << p->data << "\t";
    			p = p->lchild;
    		}
    		else {
    			PopStack(S, q);
    			p = q->rchild;
    		}
    	}
    	return OK;
    }

中序遍历:

    //中序遍历的递归,主要思想是先访问左子树,然后访问该根节点,最后访问右子树 
    Status InBiTree(BiTree T)
    {
    	if (T != NULL)
    	{
    		InBiTree(T->lchild);
    		cout << T->data << "\t";
    		InBiTree(T->rchild);
    	}
    	return OK;
    }


    //中序遍历的非递归访问结点的具体步骤:
    //结点的所有路径情况:
    //step1: 如果当前结点有左子树,则该结点入栈;
    //-----------如果没有左子树,则访问该结点;
    //step2:如果结点有右子树,则重复step1;
    //--------- 如果没有右子树,则回退,让此时的栈顶元素出栈,访问栈顶元素,并访问栈顶元素的右子树,重复step1,2
    //step3:如果栈为空,则表示遍历结束;
    //注意:入栈结点本身没有被访问过,同时,其右子树也没有被访问过
    Status SqlInBiTree(LinkStack &S,BiTree T)
    {
    	BiTNode *p = T;
    	BiTNode *q = new BiTNode;
    	while (p || !Empty(S))//在树不为空的情况下 
    	{
    		if (p) {
    			PushStack(S, p);//向左走到头 
    			p = p->lchild;
    		}
    		else {
    			PopStack(S, q);//元素出栈 
    			cout << q->data << "\t";
    			p = q->rchild;//右子树入栈 
    		}
    	}
    	return OK;
    }

后序遍历:

    //后序遍历的递归,主要思想是先访问左子树,然后访问右子树,最后访问根节点
    Status LaterBiTree(BiTree T)
    {
    	if (T != NULL)
    	{
    		LaterBiTree(T->lchild);
    		LaterBiTree(T->rchild);
    		cout << T->data << "\t";
    	}
    	return OK;
    }


    //后序遍历的非递归,对于先序遍历是在节点出栈时入栈右左孩子,
    //而对于后序遍历,不应该在父节点出栈时,才把右左孩子入栈,
    //应该在入栈时就把右左孩子一并入栈。
    //在父节点入栈时,应判断右左孩子是否入过栈,设一个标记负责记录。
    Status SqlLaterBiTree(LinkStack &S, BiTree T)
    {
    	BiTNode *p = T;
    	BiTNode *q = new BiTNode;
    	BiTNode *e = new BiTNode;
    	int flag = 0;//标志位,判断是否需要访问当前节点的右子树 
    	while (p || !Empty(S))
    	{
    		while (p) {
    			PushStack(S, p);
    			p = p->lchild;   //将最左的结点全部压栈
    		}
    		flag = 1;   //表示当前结点的左孩子为空或者已经被访问过了
    		p = NULL;
    		while (flag == 1)
    		{
    			e = Gettop(S);//对于最下方的左子孙,判断它的右孩子。如果右孩子为空或者等于p,则出栈并更新p为当前的栈顶元素 
    			if (e->rchild == p) {
    				PopStack(S, q); 
    				p = q;
    				cout << p->data << "\t";
    			}
    			else//否则更新当前的p为右孩子 
    			{
    				p = e->rchild;
    				flag = 0;
    			}
    		}
    	}
    	return OK;
    }

6.2.3 二叉树与树/森林的相互转换

6.2.3.1 树转化为二叉树用孩子兄弟存储来表示(其根节点必无右孩子)

步骤如下:

  1. 树中所有相邻兄弟之间加一条连线
  2. 对树中的每个节点,只保留其与第一个孩子节点之间的连线,删去其与其它孩子节点之间的连线
  3. 以树的根节点为轴心,将整棵树旋转一定角度使之层次分明

例如:

6.2.3.2 森林转化为二叉树用孩子兄弟链表来表示(其根节点有右孩子)

步骤如下:

  1. 将森林中的每棵树按照上述方法转换成相应二叉树
  2. 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根节点作为前一棵二叉树根节点的右孩子

例如:

6.2.3.3 二叉树还原为树或森林

步骤如下:

  1. 若某节点是其双亲的左孩子,则把该节点的右孩子、右孩子的右孩子.....都与该节点的双亲节点用线连起来
  2. 删掉原二叉树中所有双亲节点与其右孩子节点的连线
  3. 整理由第一步、第二步得到的树或森林,使层次分明

例如:

6.2.3.4 哈夫曼树

哈夫曼树:使带权路径长度wpl最小。

在哈夫曼树中,权值越大的节点离根节点越近。

哈夫曼树的构造技巧:每次选两个最小的放在上面一层。

例:

哈夫曼树只有度为0和2的节点,因此叶节点数乘2就是空指针域数目。

构造哈夫曼编码:

构造哈夫曼编码,实质就是以n种字符出现的频率作为n个叶子节点的权来设计一棵哈夫曼树(左子树为0,右子树为1),然后根从每个叶子节点的路径便为n个二进制串,得。

例如:

给出一段报文:CAST CAST SAT AT A TASA
字符集合为{C,A,S,T},各个字符出现的频率w = {2,7,4,5}
若给每个字符以等长编码:A:00,T:10,C:01,S:11
则总编码长度为(2+7+4+5)* 2 = 36
若根据频率来赋长:{2/18,7/18,4/18,5/18},化整为{2,7,4,5}
构造哈夫曼树:
总长为71+52+23+43 = 35
A:0,T:10,C:110,S:111