LeetCode——哲学家进餐问题

Posted Shaw_喆宇

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了LeetCode——哲学家进餐问题相关的知识,希望对你有一定的参考价值。

Q:5 个沉默寡言的哲学家围坐在圆桌前,每人面前一盘意面。叉子放在哲学家之间的桌面上。(5 个哲学家,5 根叉子)
所有的哲学家都只会在思考和进餐两种行为间交替。哲学家只有同时拿到左边和右边的叉子才能吃到面,而同一根叉子在同一时间只能被一个哲学家使用。每个哲学家吃完面后都需要把叉子放回桌面以供其他哲学家吃面。只要条件允许,哲学家可以拿起左边或者右边的叉子,但在没有同时拿到左右叉子时不能进食。
假设面的数量没有限制,哲学家也能随便吃,不需要考虑吃不吃得下。
设计一个进餐规则(并行算法)使得每个哲学家都不会挨饿;也就是说,在没有人知道别人什么时候想吃东西或思考的情况下,每个哲学家都可以在吃饭和思考之间一直交替下去。

哲学家从 0 到 4 按 顺时针 编号。请实现函数 void wantsToEat(philosopher, pickLeftFork, pickRightFork, eat, putLeftFork, putRightFork):
philosopher 哲学家的编号。
pickLeftFork 和 pickRightFork 表示拿起左边或右边的叉子。
eat 表示吃面。
putLeftFork 和 putRightFork 表示放下左边或右边的叉子。
由于哲学家不是在吃面就是在想着啥时候吃面,所以思考这个方法没有对应的回调。
给你 5 个线程,每个都代表一个哲学家,请你使用类的同一个对象来模拟这个过程。在最后一次调用结束之前,可能会为同一个哲学家多次调用该函数。
示例:
输入:n = 1
输出:[[4,2,1],[4,1,1],[0,1,1],[2,2,1],[2,1,1],[2,0,3],[2,1,2],[2,2,2],[4,0,3],[4,1,2],[0,2,1],[4,2,2],[3,2,1],[3,1,1],[0,0,3],[0,1,2],[0,2,2],[1,2,1],[1,1,1],[3,0,3],[3,1,2],[3,2,2],[1,0,3],[1,1,2],[1,2,2]]
解释:
n 表示每个哲学家需要进餐的次数。
输出数组描述了叉子的控制和进餐的调用,它的格式如下:
output[i] = [a, b, c] (3个整数)

  • a 哲学家编号。
  • b 指定叉子:{1 : 左边, 2 : 右边}.
  • c 指定行为:{1 : 拿起, 2 : 放下, 3 : 吃面}。
    如 [4,2,1] 表示 4 号哲学家拿起了右边的叉子。

A:
引用:@̶.̶G̶F̶u̶'̶ 、̶ ̶|
这个题目是防止死锁,每个哲学家都拿起左手或右手,导致死锁
1.第一种方法是设置一个信号量,当前哲学家会同时拿起左手和右手的叉子直至吃完。即有3 个人中,2 个人各自持有 2 个叉子,1 个人持有 1 个叉子,共计 5 个叉子。
用Semaphore去实现上述的限制:Semaphore eatLimit = new Semaphore(4);
一共有5个叉子,视为5个ReentrantLock,并将它们全放入1个数组中。
设置编码:

代码:

class DiningPhilosophers {
    //1个Fork视为1个ReentrantLock,5个叉子即5个ReentrantLock,将其都放入数组中
    private ReentrantLock[] locks = {new ReentrantLock(), new ReentrantLock(), new ReentrantLock(), new ReentrantLock(), new ReentrantLock()};
    //限制 最多只有4个哲学家去持有叉子
    private Semaphore eatLimit = new Semaphore(4);

    public DiningPhilosophers() {

    }

    // call the run() method of any runnable to execute its code
    public void wantsToEat(int philosopher,
                           Runnable pickLeftFork,
                           Runnable pickRightFork,
                           Runnable eat,
                           Runnable putLeftFork,
                           Runnable putRightFork) throws InterruptedException {
        int leftFork = (philosopher + 1) % 5;//左边的叉子 的编号
        int rightFork = philosopher;//右边的叉子 的编号

        eatLimit.acquire();//限制人数减一

        locks[leftFork].lock();
        locks[rightFork].lock();

        pickLeftFork.run();
        pickRightFork.run();

        eat.run();

        putLeftFork.run();
        putRightFork.run();

        locks[leftFork].unlock();
        locks[rightFork].unlock();

        eatLimit.release();
    }
}

2.设置 1 个临界区以实现 1 个哲学家 “同时”拿起左右 2 把叉子的效果。即进入临界区之后,保证成功获取到左右 2 把叉子 并 执行相关代码后,才退出临界区。
与上一种的差别是“允许1个哲学家用餐”。方法2是在成功拿起左右叉子之后就退出临界区,而“只让1个哲学家就餐”是在拿起左右叉子 + 吃意面 + 放下左右叉子 一套流程走完之后才退出临界区。
前者的情况可大概分为2种,举具体例子说明(可参照上面给出的图片):

  • 1号哲学家拿起左右叉子(1号叉子 + 2号叉子)后就退出临界区,此时4号哲学家成功挤进临界区,他也成功拿起了左右叉子(0号叉子和4号叉子),然后就退出临界区。
  • 1号哲学家拿起左右叉子(1号叉子 + 2号叉子)后就退出临界区,此时2号哲学家成功挤进临界区,他需要拿起2号叉子和3号叉子,但2号叉子有一定的概率还被1号哲学家持有(1号哲学家意面还没吃完),因此2号哲学家进入临界区后还需要等待2号叉子。至于3号叉子,根本没其他人跟2号哲学家争夺,因此可以将该种情况视为“2号哲学家只拿起了1只叉子,在等待另1只叉子”的情况。

总之,第1种情况即先后进入临界区的2位哲学家的左右叉子不存在竞争情况,因此先后进入临界区的2位哲学家进入临界区后都不用等待叉子,直接就餐。此时可视为2个哲学家在同时就餐(当然前1个哲学家有可能已经吃完了,但姑且当作是2个人同时就餐)。
第2种情况即先后进入临界区的2位哲学家的左右叉子存在竞争情况(说明这2位哲学家的编号相邻),因此后进入临界区的哲学家还需要等待1只叉子,才能就餐。此时可视为只有1个哲学家在就餐。
至于“只允许1个哲学家就餐”的代码,很好理解,每次严格地只让1个哲学家就餐,由于过于严格,以至于都不需要将叉子视为ReentrantLock。
方法2有一定的概率是“并行”,“只允许1个哲学家就餐”是严格的“串行”。

代码:

class DiningPhilosophers {
    //1个Fork视为1个ReentrantLock,5个叉子即5个ReentrantLock,将其都放入数组中
	private ReentrantLock[] lockList = {new ReentrantLock(),
		new ReentrantLock(),
		new ReentrantLock(),
		new ReentrantLock(),
		new ReentrantLock()};
    
    //让 1个哲学家可以 “同时”拿起2个叉子(搞个临界区)
	private ReentrantLock pickBothForks = new ReentrantLock();

	public DiningPhilosophers() {

	}

	// call the run() method of any runnable to execute its code
	public void wantsToEat(int philosopher,
		Runnable pickLeftFork,
		Runnable pickRightFork,
		Runnable eat,
		Runnable putLeftFork,
		Runnable putRightFork) throws InterruptedException {
        
		int leftFork = (philosopher + 1) % 5;	//左边的叉子 的编号
		int rightFork = philosopher;	//右边的叉子 的编号

		pickBothForks.lock();	//进入临界区

		lockList[leftFork].lock();	//拿起左边的叉子
		lockList[rightFork].lock();	//拿起右边的叉子

		pickLeftFork.run();	//拿起左边的叉子 的具体执行
		pickRightFork.run();	//拿起右边的叉子 的具体执行
        
		pickBothForks.unlock();	//退出临界区

		eat.run();	//吃意大利面 的具体执行

		putLeftFork.run();	//放下左边的叉子 的具体执行
		putRightFork.run();	//放下右边的叉子 的具体执行

		lockList[leftFork].unlock();	//放下左边的叉子
		lockList[rightFork].unlock();	//放下右边的叉子
	}
}

3.前面说过,该题的本质是考察 如何避免死锁。
而当5个哲学家都左手持有其左边的叉子 或 当5个哲学家都右手持有其右边的叉子时,会发生死锁。
故只需设计1个避免发生上述情况发生的策略即可。
即可以让一部分哲学家优先去获取其左边的叉子,再去获取其右边的叉子;再让剩余哲学家优先去获取其右边的叉子,再去获取其左边的叉子。
代码:

class DiningPhilosophers {
	//1个Fork视为1个ReentrantLock,5个叉子即5个ReentrantLock,将其都放入数组中
	private ReentrantLock[] lockList = {new ReentrantLock(),
		new ReentrantLock(),
		new ReentrantLock(),
		new ReentrantLock(),
		new ReentrantLock()};

	public DiningPhilosophers() {

	}

	// call the run() method of any runnable to execute its code
	public void wantsToEat(int philosopher,
		Runnable pickLeftFork,
		Runnable pickRightFork,
		Runnable eat,
		Runnable putLeftFork,
		Runnable putRightFork) throws InterruptedException {

		int leftFork = (philosopher + 1) % 5;    //左边的叉子 的编号
		int rightFork = philosopher;    //右边的叉子 的编号

        //编号为偶数的哲学家,优先拿起左边的叉子,再拿起右边的叉子
		if (philosopher % 2 == 0) {
			lockList[leftFork].lock();    //拿起左边的叉子
			lockList[rightFork].lock();    //拿起右边的叉子
		}
        //编号为奇数的哲学家,优先拿起右边的叉子,再拿起左边的叉子
		else {
			lockList[rightFork].lock();    //拿起右边的叉子
			lockList[leftFork].lock();    //拿起左边的叉子
		}

		pickLeftFork.run();    //拿起左边的叉子 的具体执行
		pickRightFork.run();    //拿起右边的叉子 的具体执行

		eat.run();    //吃意大利面 的具体执行

		putLeftFork.run();    //放下左边的叉子 的具体执行
		putRightFork.run();    //放下右边的叉子 的具体执行

		lockList[leftFork].unlock();    //放下左边的叉子
		lockList[rightFork].unlock();    //放下右边的叉子
	}
}

改进:改进代码看3种解法(互斥锁或volatile)
1.ReentrantLock和synchronize关键字都是使用互斥量的重量级锁,而volatile关键字相较于它们就比较“轻量”。
因此把ReentrantLock数组改为使用volatile修饰的boolean数组。
PS: volatile要和原子操作搭配使用才能保证同步。
而对volatile变量赋 常量值 可看为是原子操作。

看着后面这种解法更清晰:
每个人都可以尝试去吃东西,吃东西前尝试去拿左边的叉子和右边的叉子,这样就可以想到使用信号量Semaphore的tryAcquire方法。
这里竞争的资源是叉子,所以定义代表5个叉子的信号量即可。
代码:

class DiningPhilosophers {
    int num = 5;
    //五个叉子的信号量
    private Semaphore[] semaphores = new Semaphore[5];

    public DiningPhilosophers() {
        for (int i = 0; i < num; i++) {
            //每只叉子只有1个
            semaphores[i] = new Semaphore(1);
        }

    }

    // call the run() method of any runnable to execute its code
    public void wantsToEat(int philosopher,
                           Runnable pickLeftFork,
                           Runnable pickRightFork,
                           Runnable eat,
                           Runnable putLeftFork,
                           Runnable putRightFork) throws InterruptedException {
        //左边叉子的位置
        int left = philosopher;
        //右边叉子的位置
        int right = (philosopher + 1) % num;
        while (true) {
            if (semaphores[left].tryAcquire()) {
                //先尝试获取左边叉子,如果成功再尝试获取右边叉子
                if (semaphores[right].tryAcquire()) {
                    //两个叉子都得到了,进餐
                    pickLeftFork.run();
                    pickRightFork.run();
                    eat.run();
                    putLeftFork.run();
                    //释放左边叉子
                    semaphores[left].release();
                    putRightFork.run();
                    //释放右边边叉子
                    semaphores[right].release();

                    //吃完了,就跳出循环
                    break;
                } else {
                    //如果拿到了左边的叉子,但没拿到右边的叉子: 就释放左边叉子
                    semaphores[left].release();
                    //让出cpu等一会
                    Thread.yield();
                }
            } else {
                //连左边叉子都没拿到,就让出cpu等会吧
                Thread.yield();
            }
        }

    }

}

以上是关于LeetCode——哲学家进餐问题的主要内容,如果未能解决你的问题,请参考以下文章

1226-哲学家进餐

(考研)哲学家进餐问题(附代码)

哲学家进餐问题的死锁问题

进程调度模拟——哲学家进餐问题

哲学家进餐问题 - 只有 2 个线程有效

Java实现PV操作 | 哲学家进餐问题