【剑指offer】——链表相关习题总结

一、链表中倒数第k个结点

1、题目大意
输入一个链表,输出该链表中倒数第k个结点。为了符合大多数人的习惯,本题从1开始计数,即链表的尾结点是倒数第1个结点。例如:一个链表有6个结点,从头结点开始,他们的值依次是1、2、3、4、5、6。这个链表的倒数第3个节点是值为4的节点。链表节点定义如下:

struct ListNode
{
	int m_nKey;
	ListNode* m_pNext;
};

2、题目分析
首先,一拿到这道题我们会自然的想到先走到链表的尾端,再从尾端回溯k步。但是仔细想一想这个做法似乎是不正确的,因为我们这个是单向链表,没有从后往前的指针,所以这种方法是行不通的。

后来,我们又发现了这样一个规律:倒数第k个结点就是从头开始的第n-k+1个结点。我们可以先顺序遍历一次计算出n的值,再从头结点开始往后走n-k+1个结点即可。也就是说要遍历两次链表,但是这样效率不高。

接着,我们就想出来了只需要遍历一次的方法。我们可以定义两个指针,第一个指针p1从头结点开始走k-1(3-1)步,第二个指针保持不动。还是以题目中的例子来进行实例化的分析如下:
在这里插入图片描述
从第k(3)步开始,p2也开始从链表的头指针开始遍历。由于两个指针的距离保持在k-1,当p1走到链表的尾结点时,p2指针刚好指向倒数第k(3)个结点
在这里插入图片描述

有了上述的解法,但是这个方法还存在许多小细节是值的我们另外思考的

  1. 如果pListHead为空指针,代码试图访问空指针指向的内存会造成内存崩溃。这种情况下倒数第k个结点自然返回nullptr
  2. 如果输入的以pListNode为头结点的链表的节点总数少于k。由于for循环中会在链表上向前走k-1步,仍然会因为空指针造成崩溃。所以在for循环里面加一个条件判断
  3. 如果输入的参数是0,由于k是一个无符号整数,那么在for循环中k-1得到的将不是-1,而是0xffffffff,造成程序崩溃。这种输入是没有意义的,可以返回nullptr。

所以代码实现如下:

ListNode* FindKthToTail(ListNode* pListHead, unsigned int k)
{
	if (pListHead == nullptr || k == 0)//细节1、3
		return nullptr;

	ListNode* p1 = pListHead;
	ListNode* p2 = nullptr;

	for (unsigned int i = 0; i < k-1; i++)
	{
		if (p1->m_pNext != nullptr)//细节2
			p1 = p1->m_pNext;
		else
			return nullptr;
	}
	p2 = pListHead;
	while (p1->m_pNext != nullptr)
	{
		p1 = p1->m_pNext;
		p2 = p2->m_pNext;
	}
	return p2;
}

二、复杂链表的复制

1、题目大意
输入一个复杂链表(每个节点中有节点值,以及两个指针,一个指向下一个节点,另一个特殊指针指向任意一个节点),返回结果为复制后复杂链表的head。(注意,输出结果中请不要返回参数中的节点引用,否则判题程序会直接返回空)。他的结点定义如下:

struct ComplexListNode
{
	int m_nValue;
	ComplexListNode* m_pNext;
	ComplexListNode* m_pSibling;
};

下图为一个复杂链表的具体图示:
在这里插入图片描述
2、题目分析
一看到这道题,我们会发现这和我们熟悉的一般链表结构有些许的不同,可能会觉得无从下手。但是我们应该都知道“各个击破”的军事思想吧,也就是当我们遇到复杂的大问题的时候,如果能够先把大问题分解成若干个简单的小问题,然后逐个解决这些小问题就很容易了。

方法一:
我们大多数人经过一番思考过后可能都会想到把复制过程分成两步。第一步就是复制原始链表上的结点,并用m_pNext链接起来,第二步就是设置每个结点的m_pSibling指针。在设置每个结点的m_pSibling指针的时候,就需要每个结点的m_pSibling都需要从头结点开始经过O(n)步才能找到。因此这种方法的时间复杂度需要O(n^2)。效率不高考虑下一种方法。

方法二:
由于方法一的时间主要花费到定位结点m_pSibling上面。我们第二种方法的第一步还是和方法一样复制原始链表上的结点。第二步对定位结点m_pSibling进行优化为把<N,N1>配对信息放入到一个哈希表中。由于在原始链表结点中节点N的m_pSibling指向结点S,那么在复制链表中,对应的N1也应该指向S1。有了哈希表,我们可以用O(1)的时间根据S找到S1.但是这种方法是典型的以空间换时间的策略。

方法三:
这种方法是不用辅助空间的情况下实现O(n)的时间效率。第一步根据原始链表每个节点N创建对应的N1,这一次,我们把N1链接到N的后面,如下图所示
在这里插入图片描述
完成这一步的代码如下:

void CloneNodes(ComplexListNode* pHead)
{
	ComplexListNode* pNode = pHead;
	while (pNode != nullptr)
	{
		ComplexListNode* pCloned = new ComplexListNode();
		pCloned->m_nValue = pNode->m_nValue;
		pCloned->m_pNext = pNode->m_pNext;
		pCloned->m_pSibling = nullptr;

		pNode->m_pNext = pCloned;
		pNode = pCloned->m_pNext;
	}
}

接着就是设置复制出来的节点的m_pSibling。让其复制出来的节点指向保持一致。如下如所示:
在这里插入图片描述
完成代码如下:

void ConnectSibilingNodes(ComplexListNode* pHead)
{
	ComplexListNode* pNode = pHead;
	while (pNode != nullptr)
	{
		ComplexListNode* pCloned = pNode->m_pNext;
		if (pNode->m_pSibling != nullptr)
		{
			pCloned->m_pSibling = pNode->m_pSibling->m_pNext;
		}

		pNode = pCloned->m_pNext;
	}
}

最后就是把长链表拆分成两个链表。把奇数位置节点为原始链表,偶数位置的节点连接起来是复制出来的链表。代码实现如下:

ComplexListNode* ReconnectNodes(ComplexListNode* pHead)
{
	ComplexListNode* pNode = pHead;
	ComplexListNode* pCloneHead = nullptr;
	ComplexListNode* pCloneNode = nullptr;

	if (pNode != nullptr)
	{
		pCloneHead = pCloneNode = pNode->m_pNext;
		pNode->m_pNext = pCloneNode->m_pNext;
		pNode = pNode->m_pNext;
	}

	while (pNode != nullptr)
	{
		pCloneNode->m_pNext = pNode->m_pNext;
		pCloneNode = pCloneNode->m_pNext;
		pNode->m_pNext = pCloneNode->m_pNext;
		pNode = pNode->m_pNext;
	}
	return pCloneHead;
}

最后把上面三步合起来就是复制的完整过程。

ComplexListNode* Clone(ComplexListNode* pHead)
{
	CloneNodes(pHead);
	ConnectSibilingNodes(pHead);
	return ReconnectNodes(pHead);
}

三、两个链表的第一个公共节点

1、题目要求
输入两个链表,找出他们的第一个公共节点,链表的定义如下:

struct ListNode
{
	int m_key;
	ListNode* m_pnext;
};

2、题目分析
方法一:
一拿到这道题,如果你不多加思考的话可能最先想到的解法就是使用蛮力法来解决。首先遍历第一个链表的结点,每遍历一个结点就在第二个链表上顺序遍历一个结点。如果在第二个链表中出现了和第一个链表相同的结点说明找到了公共结点。但是这种方法的效率不高,时间复杂度达到了O(nm).

方法二:
因为这道题所设计的链表是一个单链表的结构。也就是说这两个链表一旦相交了过后就一定是一条链表了,是一种Y字型的而不是X字型的。效果如下:
在这里插入图片描述
仔细观察上述结构,我们会发现如果我们从两个链表的尾部开始往前比较,那么最后一个相同的节点就是我们要找的节点。但是由于单链表的结构只能从前往后遍历,但是我们要求从后往前比较,这时我们就引入了我们常用的栈。分别把两个链表的结点放入栈中,这样两个结点的尾结点就是栈的栈顶元素,接下来的工作就是挨个比较栈顶元素是否相同。直到找到最后一个相同的结点。但是这种思路的空间复杂度是O(n+m),时间复杂度是O(n+m)。虽然效率有所提高,但是这是一种以空间换时间的策略。

方法三:
我们上一种方法之所以要用到栈就是因为我们想同时到达链表的尾结点。当两个链表的长度不相同时,如果我们从头开始遍历,那么到达尾结点的时间就不一致。要使到达尾结点的时间一致就只能先顺序遍历两个链表计算各自链表长度,他们的长度差即为第二次遍历时较长链表先走的节点数。然后同时出发遍历两个结点进行比较,找到第一个相同的结点就是我们想要的结果。

//得到链表长度
unsigned int GetListLength(ListNode* pHead)
{
	ListNode* pNode = pHead;
	unsigned int count = 0;
	while (pNode != nullptr)
	{
		count++;
		pNode = pNode->m_pnext;
	}
	return count;
}

ListNode* FindFirstCommonNode(ListNode* pHead1,ListNode* pHead2)
{
	unsigned int length1 = GetListLength(pHead1);
	unsigned int length2 = GetListLength(pHead2);
	int lengthDif = length1 - length2;

	ListNode* pListHeadlong = pHead1;
	ListNode* pListHeadshort = pHead2;
	if (length1 < length2)
	{
		ListNode* pListHeadlong = pHead2;
		ListNode* pListHeadshort = pHead1;
		lengthDif = length2 - length1;
	}

	for (int i = 0; i < lengthDif; i++)
	{
		pListHeadlong = pListHeadlong->m_pnext;
	}

	while ((pListHeadlong != nullptr) && (pListHeadshort != nullptr) && (pListHeadlong != pListHeadshort))
	{
		pListHeadlong = pListHeadlong->m_pnext;
		pListHeadshort = pListHeadshort->m_pnext;
	}

	ListNode* pFirstCommonNode = pListHeadlong;
	return pFirstCommonNode;
}

方法四
采用双指针的方式。走对方走过的路,次遍历链表A和链表B,谁先遍历完就到另外一个链表的头部开始继续遍历。最后如果两个链表存在相交,他们必然就会有结点相同的情况,记录对应的元素即可。如果没有相等的元素说明两个链表不相交。

struct ListNode
{
	int val;
	struct ListNode* next;
	ListNode(int x) :val(x), next(NULL)
	{}
};

//两个指针,走别人走过的路
ListNode* FindFirstCommentNode(ListNode* headA,ListNode* headB)
{
	//特殊情况判断
	if (headA == NULL || headB == NULL)
	{
		return NULL;
	}

	//初始化两个指针
	ListNode* la = headA;
	ListNode* lb = headB;

	//循环查找
	while (la != lb)
	{
		//让la走
		if (la != NULL)
		{
			la = la->next;
		}
		else
		{
			la = headB;
		}

		//让lb走
		if (lb != NULL)
		{
			lb = lb->next;
		}
		else
		{
			lb = headA;
		}
	}
	return la;
}

四、圆圈中最后剩下的数字

1、题目要求
0,1,…,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。
【举个栗子】0,1,2,3,4这5个数字组成一个圆圈如下图所示,从0开始每次删除第3个数字,则删除的前4个数字依次是2,0,4,1。最后剩下的数字是3.
在这里插入图片描述

示例
输入: n = 5, m = 3
输出: 3

2、题目分析
方式一:利用STL容器中的list构建循环链表
其实这就是出名的约瑟夫环问题。由此,解决问题的关键在于使用STL中的list单向链表容器构建出一个循环链表。

  1. 首先,初始化一个list单向链表,其中每一个结点的值为从0-n-1的值。并初始化一个迭代器指针指向容器的首部。

  2. 接着,依次删除序列为m的结点。
    【关键】在寻找序列为m的结点的时候可能会出现遍历到链表末尾的情况,因此对这种特殊情况要对迭代器首部更新的操作。

  3. 关键的一步进行删除操作。删除的时候要用一个迭代器指针保存当前迭代器所指向位置的下一个位置(此处也要对迭代器指向位置进行判定)

  4. 最后将剩余的一个数字返回即可

【注意】这样实现的时间复杂度太高,达到了O(m+n),空间复杂度为O(N)。在力扣上面的提交结果会超出时间限制。
代码实现如下:

int LastNumber(int n, int m)
{
	//特殊情况的考虑
	if (n < 1 || m < 1)
	{
		return -1;
	}

	//list容器的初始化
	list<int> l1;
	for (int i = 0; i < n; i++)
	{
		l1.push_back(i);
	}

	//初始化一个迭代器指针指向容器的首部
	list<int>::iterator current = l1.begin();

	//删除结点的操作
	while (l1.size()>1)
	{
		for (int i = 1; i < m; i++)
		{
			current++;
			//如果到达了链表末尾,就要构建出一个循环链表
			if (current == l1.end())
			{
				current = l1.begin();
			}
		}

		//初始化一个迭代器指针next
		list<int>::iterator next = ++current;

		//避免next为链表末尾的特殊情况
		if (next == l1.end())
		{
			next = l1.begin();
		}

		//删除结点操作
		--current;
		l1.erase(current);

		//更新current结点的值
		current = next;
	}
	return *(current);
}

int main()
{
	int n, m;
	cout << "n=";
	cin >> n ;
	cout << "m=";
	cin >> m;
	cout << LastNumber(n, m);
}

方法二:采用数学公式分析
经过复杂的数学分析,得出一个递归的关系表达式如下这种算法的时间复杂度是O(n),空间复杂度是O(1).
在这里插入图片描述
根据公式写出代码如下:

int LastRemaining(unsigned int n, unsigned int m)
{
	if (n < 1 || m < 1)
		return -1;

	int last = 0;
	for (int i = 2; i <= n; i++)
	{
		last = (last + m) % i;
	}
	return last;
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值