数据结构--》从线性表说起,掌握常用基础算法

目录

初识线性表

线性表的基本操作

顺序表的定义

顺序表的基本操作

单链表的定义

单链表的基本操作 

双链表的介绍

循环链表的介绍

静态链表的介绍


初识线性表

线性表是具有相同数据类型的 n (n\geqslant0) 个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为:

{a_i{}}^{} 是线性表中的第 “i” 个元素线性表中的位序{a_1{}}^{}表头元素{a_n{}}^{}表尾元素

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

线性表的基本操作

实际开发中,可根据实际需求定义其他的基本操作,对数据的操作(记忆思路)为:创销增删改查,函数名和参数的形式都可以改变。那么我们为什么要实现对数据结构的基本操作呢

1)团队合作编程,你定义的数据结构要让别人能够很方便的使用(封装)

2)将常用的操作/运算封装成函数,避免重复工作,降低出错风险。

InitList($L):初始化表。构造一个空的线性表L,分配内存空间。

DestroyList(&L):销毁线性表。销毁操作并释放线性表L所占用的内存空间。

ListInsert(&L,i,e):插入操作。在表L中的第i个位置插入指定元素e。

ListDelete(&L,i,e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。

LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。

GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。

Length(L):求表长。返回线性表L的长度,即L中数据元素的个数。

PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。

Empty(L):判空操作。若L为空表,则返回true,否则返回false。

当我们对参数的修改结果需要带过来时,需要传入引用 “&” 。给出如下代码进行解释:

#include <stdio.h>
void test(int x) {
	x = 1024;
	printf("test函数内部 x=%d\n", x);
}

int main() {
	int x = 1;
	printf("调用test前 x=%d\n", x);
	test(x);
	printf("调用test后 x=%d\n", x);
}

控制台打印的结果如下, 其实test函数里的x是main函数里面x的复制品,两者是两种不同内存地址的数据,test仅仅是改变了其内容x的数据,并没有改变main函数中x的数据,说白了就是main函数的x调用test函数并没有对参数的修改结果带回来,所有结果依然是1。

如果我们在test函数中采用引用类型,test函数对x的修改就会带回到main函数当中,如下:

回顾重点,其主要内容整理成如下内容:

顺序表的定义

顺序表是用顺序存储的方式实现线性表顺序存储。把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。

顺序表的静态分配实现(顺序表的表长刚开始确定就无法更改)

#define MaxSize 10			// 定义最大长度
typedef struct {			
	ElemType data[MaxSize]; // 用静态的“数组”存储数据元素
	int length;				// 顺序表的当前长度
}SqList;					// 顺序表的类型定义

顺序表的动态分配实现

#define InitSize 10			// 顺序表的初识长度
typedef struct {			
	ElemType *data;		    // 指示动态分配数组的指针
	int MaxSize;			// 顺序表的最大容量
	int length;				// 顺序表的当前长度
}SeqList;					// 顺序表的类型定义(动态分配方式)

顺序表的特点

1)随机访问,即可以在O(1)时间内找到第i个元素。

2)存储密度不高,每个节点只存储数据元素

3)拓展容量不方便(即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高)

4)插入、删除操作不方便,需要移动大量元素

回顾重点,其主要内容整理成如下内容:

顺序表的基本操作

插入操作:ListInsert(&L,i,e):在表L中的第i个位置上插入指定元素e。其基础代码如下:

#include <stdio.h>

#define MaxSize 10			// 定义最大长度
typedef struct {			
	int data[MaxSize]; // 用静态的“数组”存储数据元素
	int length;				// 顺序表的当前长度
}SqList;					// 顺序表的类型定义

bool ListInsert(SqList& L, int i, int e) {
	if (i<1 || i>L.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;
}

int main() {
	SqList L; // 声明一个顺序表
	InitList(L); // 初始化顺序表
	// ...此处省略代码,插入几个元素
	ListInsert(L, 3, 3);
	return 0;
}

插入操作的时间复杂度分析

最好情况:新元素插入到表尾,不需要移动元素

                  i = n + 1,循环0次,最好的时间复杂度 = O(1)

最坏情况:新元素插入到表头,需要将原有的n个元素全都向后移动

                  i = 1,循环n次,最坏时间复杂度 = O(n)

平均情况:假设新元素插入到任何一个位置的概率相同,即i=1,2,3,..,length+1的概率都是p=\frac{1}{n+1}。i=1,循环n次;i=2,循环n-1次;i=3,循环n-2次 ... i=n+1时,循环0次。

                 平均循环次数 = np + (n-1)p+...+1·p = \frac{n(n+1))}{2}\frac{1}{n+1} = \frac{n}{2},平均时间复杂度 = O(n)

删除操作:ListDelete(&L,i,&e):删除表L中的第i个位置的元素,并用e返回删除元素的值。

bool ListDelete(SqList &L, int i, int &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;
}

int main() {
	SqList L; // 声明一个顺序表
	InitList(L); // 初始化顺序表
	// ...此处省略代码,插入几个元素
	int e = -1; // 用变量e把删除的元素“带回来”
	if (ListDelete(L, 3, e)) {
		printf("已删除第3个元素,删除元素值为=%d\n", e);
	}
	else {
		printf("位序i不合法,删除失败\n");
	}
	return 0;
}

删除操作的时间复杂度分析

最好情况:删除表尾元素,不需要移动其他元素。

                  i=n,循环0次;最好时间复杂度 = O(1)

最坏情况:删除表头元素,需要将后续的n-1个元素全都向前移动

                  i=1,循环n-1次;最坏时间复杂度 = O(n)

平均情况:假设删除任何一个元素的概率相同,即i=1,2,3,..,length的概率都是p=\frac{1}{n},i=1,循环n-1次;i=2时,循环n-2次;i=3时,循环n-3次...i=n时,循环0次

                  平均循环次数=(n-1)p+(n-2)p+...+1·p=\frac{n(n-1))}{2}\frac{1}{n}=\frac{n-1}{2},平均时间复杂度=O(n)

回顾重点,其主要内容整理成如下内容:

按位查找: GetElem(L,i):获取表L中第i个位置的元素的值。

ElemType GetElem(SeqList L, int i) {
	return L.data[i - 1];
}

由于顺序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第i个元素——“随机存储”特性,因此其时间复杂度:O(1)。

按值查找:在表L中查找具有给定关键字值的元素。

int LocateElem(SeqList L, ElemType e) {
	for (int i = 0; i < L.length; i++) {
		if (L.data[i] == e) {
			return i + 1;// 数组下标为i的元素值等于e,返回其位序i+1
		}
	}
	return 0; // 退出循环,说明查找失败
}

按值查找操作的时间复杂度分析

最好情况:目标元素在表头

                  循环1次:最好时间复杂度=O(1)

最坏情况:目标元素在表尾

                  循环n次:最坏时间复杂度=O(n)

平均情况:假设目标元素出现在任何一个位置的概率相同,都是\frac{1}{n},目标元素在第1位,循环1次;在第2位,循环2次,...;在第n位,循环n次。

                  平均循环次数=1·\frac{1}{n}+2·\frac{1}{n}+3·\frac{1}{n}+....+n·\frac{1}{n} = \frac{n(n+1))}{2}\frac{1}{n} = \frac{n+1}{2},平均时间复杂度=O(n)

回顾重点,其主要内容整理成如下内容:

单链表的定义

单链表每个节点除了存放数据元素之外,还要存储指向下一个节点的指针。其不要求大片连续空间,改变容量方便,但是不可随机存取,要耗费一定空间存放指针。

要声明一个单链表时,只需声明一个头指针L,指向单链表的第一个结点。

typedef struct LNode { // 定义单链表结点类型
	ElemType data; // 每个节点存放一个数据元素
	struct LNode* next; // 指针指向下一个节点
}LNode, *LinkList;

不带头节点的单链表

// 初始化一个空的单链表
bool InitList(LinkList& L) {
	L = NULL; // 空表,暂时没有任何节点
	return true;
}

带头节点的单链表

// 初始化一个单链表(带头节点)
bool InitList(LinkList& L) {
	L = (Lode * )malloc(sizeof(LNode)); // 分配一个头节点
	if (L == NULL) // 内存不足,分配失败
		return false;
	L->next = NULL; // 头节点之后暂时还没有节点
	return true;
}

回顾重点,其主要内容整理成如下内容: 

单链表的基本操作 

插入操作:ListInsert(&L,i,e):在表L中的第i个位置上插入指定元素e。

按位序插入(带头节点)

按位序插入(不带头节点)

后插操作

前插操作

删除操作:ListDelete(&L,i,&e):删除表L中第i个位置的元素,并用e返回删除元素的值。

按位序删除(带头节点)

按位查找:获取表L中第i个位置的元素的值。

按值查找:在表L中查找具有给定关键字值的元素。

回顾重点,其主要内容整理成如下内容: 

尾插法建立单链表

头插法建立单链表

头插法尾插法:核心就是初始化操作、指定结点的后插操作。头插法的重要应用:链表的逆置。

双链表的介绍

双向链表(Doubly Linked List),也称双链表,是一种常见的线性数据结构,与单向链表类似,但每个节点有两个指针,一个指向前一个节点,一个指向后一个节点,因此可以双向遍历。

每个节点包含三个部分:数据域、指向前一个节点的指针域 prev 和指向后一个节点的指针域 next。第一个节点的 prev 指针一般为空,最后一个节点的 next 指针一般为空。

相比单向链表,双向链表在插入、删除等操作时更为灵活,同时可以双向遍历,方便查找前一个及后一个节点,因此在某些场景下使用双向链表更为方便快捷。但是,双向链表相对于单向链表需要额外的空间来存储指向前一个节点的指针,因此会占用更多的内存空间。

双链表的初始化(带头结点)

双链表的插入

双链表的删除

双链表的遍历(双链表不可随机存取,按位查找、按值查找操作都只能用遍历方式实现)。

回顾重点,其主要内容整理成如下内容:

循环链表的介绍

循环链表(Circular Linked List)是一种特殊的链表结构,它的最后一个节点指向头节点,从而形成一个环形结构。循环链表可以是单向的或双向的。

循环单链表:循环单链表是一种只有一个指针域的单向链表,其最后一个节点的指针指向头节点,实现了链表的循环。下面是一个用C语言实现循环单链表的基本代码:

// 单向循环链表结点定义
typedef struct ListNode {
    int val;
    struct ListNode *next;
} ListNode;

// 创建循环单链表
ListNode* createCircularLinkedList(int data[], int n) {
    ListNode *head = (ListNode*)malloc(sizeof(ListNode));
    head->val = 0;  // 头结点不存储数据
    head->next = NULL;
    ListNode *tail = head;
    for (int i = 0; i < n; ++i) {
        ListNode *node = (ListNode*)malloc(sizeof(ListNode));
        node->val = data[i];
        node->next = NULL;
        tail->next = node;
        tail = node;
    }
    tail->next = head;  // 最后一个节点指向头节点,形成循环
    return head;
}

// 遍历循环单链表
void traverseCircularLinkedList(ListNode *head) {
    ListNode *p = head->next;
    while (p != head) {
        printf("%d ", p->val);
        p = p->next;
    }
}

// 删除循环单链表
void deleteCircularLinkedList(ListNode *head) {
    ListNode *p = head->next;
    while (p != head) {
        ListNode *temp = p;
        p = p->next;
        free(temp);
    }
    free(head);
}

循环双链表:循环双链表是一种具有两个指针域的链表,除了每个节点都有一个指向下一个节点的指针 next 外,还有一个指向前一个节点的指针 prev。它的最后一个节点的 next 指针指向头节点,头节点的 prev 指针指向最后一个节点,这样就形成了一个环形结构。下面是一个用C++实现循环双链表的基本代码:

// 双向循环链表结点定义
struct ListNode {
    int val;
    ListNode *prev, *next;
    ListNode(int x) : val(x), prev(NULL), next(NULL) {}
};

// 创建循环双向链表
ListNode* createCircularDoublyLinkedList(vector<int>& nums) {
    int n = nums.size();
    ListNode *head = new ListNode(0);  // 头结点不存储数据
    ListNode *tail = head;
    for (int i = 0; i < n; ++i) {
        ListNode *node = new ListNode(nums[i]);
        node->prev = tail;
        node->next = head;
        tail->next = node;
        head->prev = node;
        tail = node;
    }
    return head;
}

// 遍历循环双向链表
void traverseCircularDoublyLinkedList(ListNode *head) {
    ListNode *p = head->next;
    while (p != head) {
        cout << p->val << " ";
        p = p->next;
    }
}

// 删除循环双向链表
void deleteCircularDoublyLinkedList(ListNode *head) {
    ListNode *p = head->next;
    while (p != head) {
        ListNode *temp = p;
        p = p->next;
        delete temp;
    }
    delete head;
}

静态链表的介绍

静态链表(Static Linked List)是一种利用数组来模拟链表的数据结构,其本质上并不是真正的链表,而是通过数组和指针来实现链表的功能。在静态链表中,用数组来存储节点的数据元素,同时用另外一个数组来存储链表节点的指针。下面是一个用C++实现静态链表的基本代码:

const int N = 100000;

struct ListNode {
    int val, next;
} nodes[N];

// 根据数组创建静态链表
int createStaticLinkedList(int data[], int n) {
    for (int i = 0; i < n; ++i) {
        nodes[i].val = data[i];
        nodes[i].next = i + 1;
    }
    nodes[n - 1].next = -1;
    return 0;  // 返回头指针位置
}

// 向静态链表插入元素
// pos为需要插入的位置,val为要插入的元素值
void insertStaticLinkedList(int pos, int val) {
    int p = 0;  // p初始化为头指针
    while (pos--) p = nodes[p].next;  // 在pos-1的位置停留
    int q = 0;  // q初始化为空闲节点的位置
    while (nodes[q].next != -1) q = nodes[q].next;  // 找到最后一个空闲节点
    nodes[q].val = val;
    nodes[q].next = nodes[p].next;
    nodes[p].next = q;  // 将q插入在p之后
}

// 从静态链表删除元素
// pos为需要删除的位置
void deleteStaticLinkedList(int pos) {
    int p = 0;  // p初始化为头指针
    while (pos--) p = nodes[p].next;  // 在pos-1的位置停留
    int q = nodes[p].next;
    nodes[p].next = nodes[q].next;  // 将q从链表中删除
    nodes[q].next = -1;  // 将q加入到空闲节点链表中
}

静态链表优缺点

增删操作不需要大量移动元素。不能随机存取,只能从头结点开始依次往后查找,容量固定不变。

在实际应用中,使用静态链表需要注意以下问题

静态链表中的空闲节点需要预先分配好,不能像动态链表那样动态申请内存,否则会破坏静态链表的结构。

静态链表的节点数量是有限的,因为数组的大小是固定的,因此应该根据实际情况来设置数组的大小。

顺序表和链表的优缺点

顺序表

优点:存储密度高,因为在内存中是一段连续的空间存储数据,访问元素的时间复杂度为O(1),因此查找、修改等操作效率较高。支持下标访问元素,编程简单直观。

缺点:插入、删除元素时需要移动其他元素,时间复杂度为O(n),因此插入、删除操作效率相对较低。内存空间的分配和释放需要考虑到数组大小,因此当需要频繁的插入、删除操作时可能会浪费内存空间。

链表

优点:插入、删除元素时只需要修改指针指向,不需要移动其他元素,时间复杂度为O(1),因此插入、删除操作效率较高。内存空间的分配和释放更加灵活,可以根据实际需要动态分配。

缺点:存储密度较低,因为每个节点需要额外存储指针信息,占用内存空间较大。访问元素需要遍历整个链表,时间复杂度为O(n),因此查找、修改等操作效率相对较低。不支持下标访问元素,需要通过指针进行访问。

实现线性表时,使用顺序表还是链表好

当我们需要高效地进行查找、修改操作时,可以使用顺序表;当我们需要高效地进行插入、删除操作时,可以使用链表。如果需要频繁地进行插入、删除操作,并且元素数量不确定或者需要动态改变,则应该优先考虑使用链表。但是,顺序表的实现和使用比链表更为简单,因此当元素数量相对固定或者需要使用下标操作时,可以优先考虑使用顺序表。

猜你喜欢

转载自blog.csdn.net/qq_53123067/article/details/131282846