ArtyStudio

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

线性结构(上):数组和链表

1.数据结构基本分类

线性结构:表、栈、队列

非线性结构:树、图、集合

本节,我们主要围绕线性表展开讨论

线性表主要有两类存储方式:即顺序存储方式——顺序表(数组);链表存储方式——链表

在探讨线性表时,我们主要把目光聚焦在“增、删、查”这三种操作之上,同时我们会简要概述其时间维度的性能(时间复杂度)

2.顺序表(数组)

定义:将线性表中的元素相继存放在一个连续的空间中,顺序表一般形式为数组

特点:各表项(元素)逻辑顺序与物理顺序一致;各表项(元素)可以顺序查找,也可以随机访问

具体实现:我们将自己动手实现一个Array类,来模拟数组,主要分为以下几个步骤:

(一)基本类成员及函数

#include<iostream>
const int defaultSize = 100;
template<class E>
class Array
{
protected:
    E* data;//存放数组
    int maxSize;//最大容量
    int last;//最后元素的下标
public:
    Array(int sz = defaultSize);    
    ~Array();
    int Search(const E& x);
    bool Insert(int i,const E& x);
    bool Remove(const E& x);
    int Length(){return last+1;}
    //......
}

(二)构造及析构函数

构造函数需要给成员赋予恰当初始值,并且用new动态开辟内存

template<class E>
Array<E>::Array(int sz)
{
    if(sz<=0)return;
    maxSize = sz;
    last = -1;
    data = new E[maxSize];//动态分配内存空间以便存储数组元素
    if(!data){
       std::cerr << "storage allocation failed!" << std::endl;
       exit(1);
    }//考虑失败的情况
}

析构就很简单了,手动释放掉内存即可

template<class E>
Array<E>::~Array()
{
    delete[] data;
}

(三)访问查找

1.根据下标访问:

往往可以通过重载[ ]操作符来实现,

template<class E>
class Array
{
    //......
    E operator[](int i)
    {
        if(i>=maxSize||i<0){
            std::cerr<<"访问越界!"<<std::endl;
            exit(1);
        }
        return data[i];
    }
}

这种搜索直接通过给定下标从某一位置拿到元素值,时间复杂度为O(1)

2.根据值查找:

给定某个值,通过顺序比较元素是否等于该值逐一查找,找到值相等的元素即成功,并且返回该元素的下标

template<class E>
int Array<E>::Search(const E& x)
{
    for(int i = 0;i<maxSize;i++){
        if(data[i]==x)return i;
        return -1;
    }
}

最坏情况下需要遍历整个数组,故时间复杂度为O(n)

(四)插入操作

我们指定一个下标位置和元素值,往已有数组中插入元素

需要注意的是,该位置之后的元素需要依次向后移动一个单位,腾出空位便于该元素插入

简单起见我们暂且不考虑数组的动态扩容(感兴趣可以去了解C++ vector)

template<class E>
bool Array<E>::Insert(int i,const E& x)
{
    if(i<0||i>=maxSize)return false;//考虑i不合法情况
    if(last>=maxSize-1)return false;//考虑数组容量已满
    for(int j = last;j>=i;j--)
    {
        data[j+1] = data[j];
    }
    data[i] = x;
    last++;
    return true;
}

最坏情况下依旧需要遍历整个数组,所以时间复杂度为O(n)

(五)删除操作

我们想要删除某个元素,势必要先找到它,故需要先进行一个查找操作

我们以值查找为例

与插入相反,该元素之后的元素需要依次向前移动一个单位

template<class E>
bool Array<E>::Remove(const E& x)
{
    int index = Search(x);
    if(index==-1)return false;
    for(int j = index;j<last;j++)
    {
        data[j] = data[j+1];
    }
    last--;
    return true;
}

无论怎样查找待删除的元素,由于顺序表结构改变的弊端,时间复杂度依旧是O(n)

适用情形:

优点:存储利用率高,存取速度快

缺点:插入、删除等操作时需要移动大量数据

故适合需要频繁访问值,但改变结构操作不频繁的情形

例如学号成绩单。

由于人数基本固定所以成绩单结构几乎不需要改变,而只需要输入学号序号就能直接查找到分数,故适合用数组存储

3.链表

与数组不同,链表采用链式存储方式,

其优点是:适应表的动态增长和删除;

当然也有不足:需要额外的指针存储空间,且访问起来较为麻烦

特点:每个元素由一个节点类组成,结点可以不连续存储(物理地址不连续),扩充长度更加方便

分类:单向链表、双向链表,循环链表

3.1 单向链表:

(一)基本结构

每个元素存储到一个节点中,并且该节点中需要保存下一个节点的地址信息,该地址指向下一个节点,

节点依次相连,呈线性结构

故节点需要存储至少两个信息,一个是元素值,另一个是下一个节点的地址,我们用一个结构体来实现节点类

struct LinkNode //链表结点结构 
{	        	
      int data;		     //结点数据
      LinkNode *link;        //结点指针		
};
 
class List //链表类 
{	    
public:
      bool Insert(int i, int x); //插入元素
      bool Remove(int i);//删除元素
      //...... 
private:
      LinkNode *first;        //表头指针
};

单链表的存储映像:

Alt Text

(二)插入方法

我们分情况讨论:

头插: 在链表最前端插入

newNode->link = first;

first = newNode;

Alt Text

(插入前后)

尾插:在链表末尾插入

newNode->link = current->link(NULL);

current->link = newNode;

Alt Text

中间插:在链表中间插入

newNode->link = current->link;

current->link = newNode;

Alt Text

完整插入算法实现如下:

bool List::Insert(int i, int x) {
//将新元素 x 插入到第 i 个结点之后。i 从 1 开始,
//i <= 0 表示插入到首元结点之前。
    if (first == NULL || i <= 0) {	  //空表或首元结点前
	    LinkNode *newNode = new LinkNode(x);		  //建立一个新结点
	    newNode->link = first;  first = newNode;       //新结点成为首元结点
    }			 		
  	else {                     //否则,寻找插入位置
        LinkNode *current = first;	 //用current记录当前遍历到的节点地址
        int k = 1; 
        while (k < i && current != NULL)    //遍历找到第i结点
 	    { 
            current = current->link;  k++; 
        }
	    if (current == NULL)    //插入位置不恰当(超出链表长度)
	    { 
            cerr << “无效的插入位置!\n”;  return false;
        }
        else   //插入在链表的中间
        {		 
		   LinkNode *newNode = new LinkNode(x);
	 	   newNode->link = current->link;
            current->link = newNode;
        }
	}
	return true; 
};

(三)删除方法

情况一:删除表中第一个元素

del = first; first = first->link; delete del;

情况二:删除表中或表尾元素

delNode = current->link; current->link = delNode->link; delete del;

Alt Text

我们以通过下标删除元素为例:

bool List::Remove (int i) {
//将链表中的第 i 个元素删去, i从1开始。
    if(first == NULL)
    {
        cerr << “链表为空!\n”;  return false;
    }
    LinkNode *delNode;		//暂存删除结点指针
	if (i <= 1)  
    { 
        delNode = first;  first = first->link; //删头节点
    }
	else 
    { 
        LinkNode *current = first;  k = 1;   //找i-1号结点
	    while (k < i-1 && current != NULL)	
  	    { 
            current = current->link;   k++; 
        }
	    if (current == NULL || current->link == NULL) 
        { 
            cerr << “无效的删除位置!\n”;  return false;
        }		
          delNode = current->link;  current->link = delNode->link;	 //删中间或尾结点	
	}
	delete delNode; 	
	return true;			
};


注意,无论插入还是删除算法,都需确保指针赋值的顺序正确

综上我们可以看出,链表的插入和删除,不需要移动元素,只需修改结点指针,比顺序表方便

然而也有弊端——寻找插入或删除位置只能沿着链顺序检测,且要专门讨论空表和在表头插入的特殊情形

其访问、插入、删除操作均需沿着链顺序遍历,故时间复杂度均为O(n)

(四)附加头节点的单链表

为了简化链表操作,统一空表与非空表、最前端与非最前端的操作,我们引入一种新的概念——附加头节点的单链表

其与普通链表的区别是:额外添加一个表头结点位于表的最前端,但其本身不带数据,仅起标志表头的作用,不作为链表元素

Alt Text

  • 在带表头结点的单链表最前端插入新结点:

Alt Text

newNode->link = current->link;

current->link = newNode;

不难发现,表空与否均可用同样代码逻辑表示,且和其他位置插入的代码逻辑同样一致

  • 从带表头结点的单链表中删除最前端的结点:

Alt Text

delNode = current->link;

current->link = delNode->link;

delete delNode;

我们同样统一了代码逻辑

例题1:实现一个算法,能够翻转已有链表序列。

3.2 双向链表与循环链表:

(一)双向链表:

单链表结点中只有一个指向其后继的指针,使得单链表只能从头结点依次顺序地向后遍历。要访问某个结点的前驱结点,只能从头开始遍历,访问后继结点的时间复杂度为O(1),访问前驱结点的时间复杂度为O(n)。

为了克服单链表的上述缺点,我们引入双向链表的结构。

双向链表每个节点记录了两个方向的节点地址:

Alt Text

向左为前驱方向,向右为后继反向

节点类定义如下:

template <class E> 
struct DblNode {		//双向链表结点类定义
    E data;			//链表结点数据
    DblNode<E> *lLink, *rLink;//前驱、后继指针
    /*构造函数*/   
    DblNode (const E& value, DblNode<E> *l = NULL, DblNode<E> *r = NULL):data(value),lLink(l),rLink(r){}
};

(二)循环链表:

与非循环链表相比,循环链表最后一个结点的 link 指针不为NULL,而是指向了表的前端

这样只要知道表中任一结点的地址,就可搜寻到所有其他结点的地址

Alt Text

以下为附加头节点的结构:

Alt Text

类定义大致如下:

template <class E>      
class CircList  //循环链表类 
{ 
private: 
	LinkNode<E> *head, *tail;  //头指针, 尾指针
public: 
	 CircularLinkedList() : head(nullptr), tail(nullptr) {}
	~CircList();			       
 
	bool IsEmpty() { return first->link == first; }//判表空否
	LinkNode<E> * Search(const E& x);//查找某元素
    //......
}

我们额外添加一个tail指针用来记录链表尾部

通过 tail 成员,可以直接访问链表的尾部节点,而不需要从头节点开始遍历整个链表。

这使得在尾部插入(其实相当于在头部之前插入)或删除节点的操作更加高效,时间复杂度为 O(1)。

(在其他地方插入的逻辑较非循环链表并无区别)

搜寻算法为例(假如附加头节点):

template <class E> 
LinkNode<E> * CircList<E>::Search(const E& x) 
{
//在链表中从头搜索其数据值为 x 的结点
     current = first->link;
     while (current != first && current->data != x )  {
         current = current->link;
     }
     if(current == first){ cerr << “查找失败!\n”; return NULL;}
     return current;
}

循环链表的经典应用:约瑟夫问题

例题2:一个圈共有N个人,每个人的编号顺时针依次为1,2,...,N。从编号为1的人顺时针开始依次从1报数,报到M这个数字的人出局,紧接着从出局的这个人的下一个人重新开始从1报数,和上面过程类似,报到M的人出局,直到N个人全部出局。

请输出出局编号序列。

3.3 * 双向循环链表:

现实应用中往往双向链表和循环链表结合使用

下图为附加头节点的双向循环链表结构图:

Alt Text

template <class E>
class DClList {		//双向循环链表类定义
public:
    DClList(const E& uniqueVal) {	        //构造函数
        first = new DblNode<E>(uniqueVal);
        first->rLink = first->lLink = first;
    };

    bool IsEmpty() { return first->rlink == first; }//判断链表空否

    DClNode<E>* Search(const E& x, int d);
    //在链表中按d指示方向寻找等于给定值x的结点,d=0按前驱方向,d≠0按后继方向

    bool Insert(int i,const E& x, int d);
    //在第i个结点后插入一个包含有值x的新结点

    bool Remove(int i,int d);   //删除第i个结点
private:
    DClNode<E>* first;                 //表头指针
};

(一)搜索方法

template <class E>
DblNode<E> * DClList<E>::Search (const E& x, int d) {
//在双向循环链表中寻找其值等于x的结点。
    DblNode<E> *current = (d == 0)?first->lLink : first->rLink;   //按d确定搜索方向
	while ( current != first && current->data != x ){	
        current = (d == 0) ? current->lLink : current->rLink;
    }
     if(current == first){ cerr << “查找失败!\n”; return NULL;}
     return current;
};

(二)插入方法

插入到current后:

newNode->rLink = current->rLink;

current->rLink = newNode;

newNode->rLink->lLink = newNode;

newNode->lLink = current;

template <class E>
bool DClList<E>::Insert(int i,const E& x, int d) {
	//建立一个包含有值x的新结点, 并将其按 d 指定的方向插入到第i个结点之后。
	DblNode<E>* current = Search(i, d);
	
	if (!current) return false;    //插入失败
	DblNode<E>* newNode = new DblNode<E>(x);
	if (d == 0) {	  //前驱方向:插在第i个结点左侧
		newNd->lLink = current->lLink; //链入lLink链
		current->lLink = newNode;
		newNd->lLink->rLink = newNode;  //链入rLink链
		newNd->rLink = current;
	}
	else {		     //后继方向:插在第i个结点后面
		newNd->rLink = current->rLink;  //链入rLink链
		current->rLink = newNode;
		newNd->rLink->lLink = newNode;   //链入lLink链
		newNd->lLink = current;
	}
	return true; 	    //插入成功
};

(三)删除方法

删除current:

current->rLink->lLink = current->lLink;

current->lLink->rLink = current->rLink;

template < class E>
bool DClList< E>::Remove(int i, int d) {
//在双向循环链表中按d所指方向删除第i个结点。
    DblNode<E> *current = Search(i, d);
    if (!current) return false; 	      //删除失败
	current->rLink->lLink = current->lLink;
	current->lLink->rLink = current->rLink;	//从lLink链和rLink链中摘下
    delete current; 	  
    return true; 			             //删除成功
};


练习:

1.给你一个 mn 列的矩阵 matrix ,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= m, n <= 10
  • -100 <= matrix[i][j] <= 100

示例 1:

img

输入:matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]
输出:[1,2,3,4,8,12,11,10,9,5,6,7]

2.将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

  • 两个链表的节点数目范围是 [0, 50]
  • -100 <= Node.val <= 100
  • l1l2 均按 非递减顺序 排列

示例 1:

img

输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]

示例 2:

输入:l1 = [], l2 = [0]
输出:[0]

3.给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。

如果链表中存在环 ,则返回 true 。 否则,返回 false

  • 链表中节点的数目范围是 [0, 10^4]
  • -10^5 <= Node.val <= 10^5

示例 1:

img

head = [3,2,0,-4], pos = 1 (pos 来表示链表尾连接到链表中的位置(索引从 0 开始),-1表示无环)
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

示例 2:

img

head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。
posted on 2024-07-27 20:56  *Arty*  阅读(3)  评论(0编辑  收藏  举报