4种方法解决约瑟夫环问题

        约瑟夫环问题是大多数编程初学者必须要跨越的一道坎。在第一次见到它的时候,我还是个刚刚学会循环语句的小蒟蒻,而现在的我已经是深陷图论以及各种其他算法的大蒟蒻了(bushi)。可以说,约瑟夫环问题是我从编程基础向编程思维踏出的重要一步。

        现在再看这个问题,心中不由得感慨,于是随手抛出几种做法与诸君分享之。

首先看题干:洛谷P1996 约瑟夫问题

        n 个人围成一圈,从第一个人开始报数,数到 m 的人出列,再由下一个人重新从 11 开始报数,数到 m 的人再出圈,依次类推,直到所有的人都出圈,请输出依次出圈人的编号。

约瑟夫环问题可以有多种变化,所以我取了比较有代表性的一种,读者可以根据需要自行修改。

方法一:普通数组

        最基础的做法,也是初学者们可以首先想到的做法。我们首先了解到问题的原理:依次计数,直到m时删除当前数字,然后重新计数。如果到了列表末尾就从表头重新开始。想到这里,要实现起来就非常简单了。

        我们使用数组p作为主要计算对象,也就是说,p的迭代与我们代码中主循环息息相关。由此我们想到有几个需求:1.怎么让线性结构的普通数组形成环?2.怎么让我们在较短时间内区分开未出圈和已经出圈的人?3.怎么让计算机知道什么时候需要出圈?

        对于需求1,我们有两种方法。较为简单的一种是,我们在循环变量 i 到达数组末尾的时候重置下标,这样它就可以重新遍历一遍数组了,这不就相当于是形成了环吗?另一种方法是,我们直接通过下标取余的方法实现成环,可以使代码更加优雅。第一种方法要实现起来很简单,因此接下来的代码使用第二种,读者可以自行思考原理。

        对于需求2,要区分当前的人是否已经在圈内,我们可以使用标记数组。有的读者可能会问:不能把已经出圈的人删除吗?线性表的删除操作效率并不高并且实现起来有一定难度,并且由于我们需要知道每个人对应的编号,这和下标是直接相关的,删除一个结点会破坏这种相关性。因此我们不删除,而是选择将它标记成“已经出圈”的状态。

        需求3要解决起来就很简单了,我们通过一个变量实时记录当前的点数到第几个,达到目标数的时候就把这个点标记为出圈,并且重新开始计数。问题解决,以下是代码实现:

int n, m;
int p[1100];
int cnt;
int main()
{
	cin >> n >> m;
	cnt = n;
	int i = 0;
	int flag = 0;
	while (1)
	{
		++i;
		if (!p[i%n])++flag;
		if (flag == m)
		{
			cnt--;
			p[i % n] = 1; 
			if (i % n)
				cout << i % n << " ";
			else cout << n << " ";
			flag = 0;
		}
		if (!cnt)break;
	}
	return 0;
}

方法一是最简单的方法,但它同时也是实现效率比较低的方法。接下来,我们尝试用其他几种方法解决。

方法二:动态数组

        在刚才的方法中我们讨论过,是否可以靠删除节点来保证表中的所有数据都是未出圈的有效数据呢?答案当然是肯定的,这里就要提到我们的动态数组(vector)了,它可以实现删除任意节点,并且代码简单易实现,这不就可以让我们为所欲为了吗?

        不过请注意,正如我们前文提到过的线性表的删除效率不高,vector虽然能够像链表一样删除节点,但是两者进行这一举动是有很大区别的。严格说来,vector作为包装起来的容器,删除节点的效率远远低于链表。不过要解决这个问题,实现既简单又好用,vector绰绰有余了。

以下是实现代码:

#include<iostream>
#include<vector>
using namespace std;
int n, m;
int main()
{
	cin >> n >> m;
	vector<int>p(n);
	for (int i = 0; i < n; i++)
	{
		p[i] = i + 1;
	}
	int cur = 0;
	while (p.size())
	{
		cur = (cur + m - 1) % p.size();
		cout << p[cur]<<" ";
		p.erase(p.begin() + cur);
	}
	return 0;
}

可以看到,我们使用vector模拟出圈,循环的次数远远小于方法一,因此效率也相应提高了。在代码实现中我们可以看到,每次选择出圈的人时,我们不再像前两种方法一样依次迭代并判断,而是一步到位,直接到达应该删除节点的所在位置,并且完成操作。

在转移向下一个出圈对象时,为了达到模拟成环的效果,也就是防止下标越界,我们使用了和方法一下标取余相似的方法,相信读者经过自己的思考可以充分理解。

方法三:环形链表

        这个方法适用于已经学习过链表知识点的读者。如果没有学过也没关系,理解其中的意义即可。我们使用链表解决这个问题,好处是链表在删除节点很方便,可以高效率把出圈的人删除,保证链表内的节点都是未出圈的;并且,链表可以真正意义上实现成环,不必使用其他方式代替。

        这个方法实现难度算是比较高一点的,但是也确实明显提高了效率。我们可以观察到,链表的方法相比于普通数组,有什么优化?我们避免重复访问无效的节点,节省了很多时间,对于大部分问题来说,时间就是金钱,能够优化代码的时间复杂度自然是极好的。

        以下是实现代码,读者可以自行思考:

#include<iostream>
using namespace std;
struct Node {
	int id;
	Node* next;
	Node(int x) :id(x), next(NULL) {};
};
Node* Head = new Node(1);
Node* End = Head;
int n, m;
int cnt = 1;
void add(int k)
{
	Node* p = new Node(k);
	End->next = p;
	End = End->next;
}
int main()
{
	cin >> n >> m;
	for (int i = 2; i <= n; ++i)
	{
		add(i);
	}
	End->next = Head;
	Node* t = Head;
	while (t)
	{
		if (++cnt == m)
		{
			cout << t->next->id << " ";
			t->next = t->next->next;
			if (t == t->next)break;
			cnt = 1;
		}
		t = t->next;
	}
	cout << t->id;
	return 0;
}

方法四:队列模拟

        刚才我们尝试了用链表模拟环,并且取得了不菲的成效。但遗憾的是,这样的方法要实现起来并没有想象的那么容易,对于部分初学者来说,很有可能因为空指针的问题导致错误。那么有没有一种方法既可以达到模拟成环的效果,又简单易实现呢?我们可以看到,对于任意一个点来说,本次访问结束后,只有在下一圈模拟时才会用到它,也就是说它从我们访问的顺序首位转移到了末尾,这和队列的数据结构一进一出,先进先出的思想是一致的。因此,我们使用队列模拟的方法解决此题也是一个极优的选择。

        本题我直接使用STL中的queue队列,代码如下:

#include<iostream>
#include<queue>
using namespace std;
queue<int>q;
int cnt;

int main()
{
	int n, k;
	cin >> n >> k;
	for (int i = 1; i <= n; ++i)
	{
		q.push(i);
	}
	while (!q.empty())
	{
		cnt++;
		int x = q.front();
		q.pop();
		if (cnt == k)
		{
			cnt = 0;
			cout << x << " ";
		}
		else q.push(x);
	}
	return 0;
}

使用队列模拟的效率是所有方法中比较高的一种,并且实现起来并不复杂,姑且算作本题的优解。

方法+:数学规律

        根据本题动态数组的解决方法,我们可以观察到,在不考虑其他因素的情况下,我们的出圈人满足式子 f(n)=(f(n-1)+m)%n。由此可以直接通过此公式递推得到最后出队的人的编号。但是请注意,这个方法并不能直接轻松得到每个人的编号,因为编号随着人数变动具有不确定性。但在一些衍生问题中,数学公式递推求解无疑是一个好的选择。

#include<iostream>
using namespace std;

int n, m;
int main()
{
	cin >> n >> m;
	int p = 0;
	for (int i = 2; i <= n; i++) {
		p = (p + m) % i;
	}
	cout << p + 1;
	return 0;
}

总结

        本篇文章我们总结了关于约瑟夫环问题的几种解法,它们各有优势,读者可以根据实际需要自行选择。好的问题可以引发学习者的思考,寻找更优解可以让攀登者更上一层楼。希望本篇文章能对你有所帮助,我们共勉。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

没啥基础的小白

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值